From d09e50af55116bacd9cdc205ae29134e21e04bc2 Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Sun, 3 May 2026 23:02:49 +0800 Subject: [PATCH] Phase 3.7: paid pairing flow + returning chat + extension flip - Backend: payment_sessions + pairing_failures tables; payment.service.js and pairing-failure.service.js (new); rewritten pairing.service.js (payment-gated blast + targeted "Curhat lagi" + cancel + fallback); rewritten extension.service.js (data-driven auto-approve with offline safeguard, charge-at-approval); pricing.service.js (extension tiers without free trial); mitra-status.service.js (countAvailableMitras cached path); 60s sweeper for stale payment sessions - Backend routes: client.payment.routes, client.mitra-availability.routes, internal/failed-pairings.routes; client.chat.routes rewritten for payment-gated start + /returning + /cancel + /fallback-to-blast; internal/config.routes adds 4 new keys with Valkey invalidate publish - client_app: mitra-availability poll, payment screen + notifier, pairing notifier rewrite (PairingTargetedWaiting + PairingFailed states), targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi" CTA, failed-pairing terminal, extension via payment-session - mitra_app: PairingRequestType enum, returning-chat 20s countdown auto-dismiss, extension card "otomatis disetujui" copy - control_center: 4 new config rows in Settings, Failed Pairings page (filter + paginate + action menu), sidebar + route registered - Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4 pass), Maestro mobile scaffold (CLI install pending) - Bugs found via Playwright + fixed: LoginPage labels not associated with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE in allow-methods (silent settings breakage in browsers since Stage 4) - Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A), phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md (today's run results) Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/settings.json | 8 + .claude/settings.local.json | 361 ++++- backend/.env.test.example | 26 + backend/.gitignore | 4 + backend/docker-compose.test.yml | 34 + backend/package-lock.json | 1441 ++++++++++++++++- backend/package.json | 9 +- backend/src/app.internal.js | 8 +- backend/src/app.public.js | 4 + backend/src/constants.js | 52 + backend/src/db/migrate.js | 136 ++ backend/src/routes/internal/config.routes.js | 108 +- .../routes/internal/failed-pairings.routes.js | 69 + .../src/routes/public/client.chat.routes.js | 170 +- .../client.mitra-availability.routes.js | 27 + .../routes/public/client.payment.routes.js | 155 ++ backend/src/server.js | 16 + backend/src/services/config.service.js | 66 +- backend/src/services/extension.service.js | 188 ++- backend/src/services/mitra-status.service.js | 123 +- .../src/services/pairing-failure.service.js | 85 + backend/src/services/pairing.service.js | 609 ++++++- backend/src/services/payment.service.js | 298 ++++ backend/src/services/pricing.service.js | 16 + backend/test/README.md | 118 ++ backend/test/helpers/db.js | 81 + backend/test/helpers/fixtures.js | 64 + backend/test/helpers/jwt.js | 42 + backend/test/helpers/server.js | 25 + backend/test/helpers/valkey.js | 27 + .../test/routes/client.payment.routes.test.js | 115 ++ backend/test/services/pairing.service.test.js | 135 ++ backend/test/services/payment.service.test.js | 85 + backend/test/setup.js | 110 ++ backend/vitest.config.js | 24 + client_app/.maestro/README.md | 111 ++ client_app/.maestro/config.yaml | 25 + client_app/.maestro/flows/01_smoke.yaml | 14 + .../flows/02_cta_disabled_when_no_mitra.yaml | 21 + .../flows/03_payment_to_chat_happy.yaml | 42 + .../scripts/force_all_mitras_offline.sh | 34 + .../.maestro/scripts/mitra_accept_latest.sh | 36 + client_app/lib/core/auth/auth_notifier.g.dart | 2 +- .../mitra_availability_notifier.dart | 78 + .../mitra_availability_notifier.g.dart | 39 + client_app/lib/core/chat/chat_notifier.g.dart | 2 +- .../core/chat/session_closure_notifier.dart | 33 +- client_app/lib/core/constants.dart | 50 + .../lib/core/pairing/pairing_notifier.dart | 357 +++- .../lib/core/pairing/pairing_notifier.g.dart | 2 +- .../chat/screens/chat_history_screen.dart | 40 +- .../chat/screens/no_bestie_screen.dart | 80 +- .../chat/screens/searching_screen.dart | 195 ++- .../widgets/bestie_unavailable_dialog.dart | 98 ++ .../chat/widgets/pricing_bottom_sheet.dart | 122 +- .../widgets/targeted_waiting_overlay.dart | 70 + client_app/lib/features/home/home_screen.dart | 159 +- .../features/payment/payment_notifier.dart | 180 ++ .../features/payment/payment_notifier.g.dart | 25 + .../payment/screens/payment_screen.dart | 390 +++++ client_app/lib/router.dart | 18 + control_center/.env.example | 32 +- control_center/.gitignore | 5 + control_center/package-lock.json | 78 + control_center/package.json | 14 +- control_center/playwright.config.js | 72 + control_center/src/App.jsx | 2 + control_center/src/components/Layout.jsx | 1 + control_center/src/core/constants.js | 46 + .../failed-pairings/FailedPairingsPage.jsx | 257 +++ control_center/src/pages/login/LoginPage.jsx | 8 +- .../src/pages/settings/SettingsPage.jsx | 175 +- control_center/tests/e2e/README.md | 160 ++ .../tests/e2e/failed-pairings.spec.js | 96 ++ control_center/tests/e2e/helpers/auth.js | 52 + .../tests/e2e/helpers/backend-api.js | 164 ++ control_center/tests/e2e/settings.spec.js | 138 ++ mitra_app/.maestro/README.md | 85 + mitra_app/.maestro/config.yaml | 22 + mitra_app/.maestro/flows/01_smoke.yaml | 14 + .../flows/02_online_offline_toggle.yaml | 23 + .../flows/03_accept_general_blast.yaml | 33 + .../.maestro/scripts/customer_blast_now.sh | 36 + .../lib/core/chat/chat_request_notifier.dart | 15 + .../chat/widgets/chat_request_overlay.dart | 110 +- mitra_app/lib/core/constants.dart | 13 + .../chat/screens/mitra_chat_screen.dart | 16 + requirement/phase3.7-plan.md | 403 +++++ requirement/phase3.7-questions.md | 124 ++ requirement/phase3.7-test-run-2026-05-03.md | 251 +++ requirement/phase3.7-testing.md | 201 +++ requirement/phase3.7.md | 308 ++++ 92 files changed, 9579 insertions(+), 437 deletions(-) create mode 100644 .claude/settings.json create mode 100644 backend/.env.test.example create mode 100644 backend/docker-compose.test.yml create mode 100644 backend/src/routes/internal/failed-pairings.routes.js create mode 100644 backend/src/routes/public/client.mitra-availability.routes.js create mode 100644 backend/src/routes/public/client.payment.routes.js create mode 100644 backend/src/services/pairing-failure.service.js create mode 100644 backend/src/services/payment.service.js create mode 100644 backend/test/README.md create mode 100644 backend/test/helpers/db.js create mode 100644 backend/test/helpers/fixtures.js create mode 100644 backend/test/helpers/jwt.js create mode 100644 backend/test/helpers/server.js create mode 100644 backend/test/helpers/valkey.js create mode 100644 backend/test/routes/client.payment.routes.test.js create mode 100644 backend/test/services/pairing.service.test.js create mode 100644 backend/test/services/payment.service.test.js create mode 100644 backend/test/setup.js create mode 100644 backend/vitest.config.js create mode 100644 client_app/.maestro/README.md create mode 100644 client_app/.maestro/config.yaml create mode 100644 client_app/.maestro/flows/01_smoke.yaml create mode 100644 client_app/.maestro/flows/02_cta_disabled_when_no_mitra.yaml create mode 100644 client_app/.maestro/flows/03_payment_to_chat_happy.yaml create mode 100755 client_app/.maestro/scripts/force_all_mitras_offline.sh create mode 100755 client_app/.maestro/scripts/mitra_accept_latest.sh create mode 100644 client_app/lib/core/availability/mitra_availability_notifier.dart create mode 100644 client_app/lib/core/availability/mitra_availability_notifier.g.dart create mode 100644 client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart create mode 100644 client_app/lib/features/chat/widgets/targeted_waiting_overlay.dart create mode 100644 client_app/lib/features/payment/payment_notifier.dart create mode 100644 client_app/lib/features/payment/payment_notifier.g.dart create mode 100644 client_app/lib/features/payment/screens/payment_screen.dart create mode 100644 control_center/playwright.config.js create mode 100644 control_center/src/core/constants.js create mode 100644 control_center/src/pages/failed-pairings/FailedPairingsPage.jsx create mode 100644 control_center/tests/e2e/README.md create mode 100644 control_center/tests/e2e/failed-pairings.spec.js create mode 100644 control_center/tests/e2e/helpers/auth.js create mode 100644 control_center/tests/e2e/helpers/backend-api.js create mode 100644 control_center/tests/e2e/settings.spec.js create mode 100644 mitra_app/.maestro/README.md create mode 100644 mitra_app/.maestro/config.yaml create mode 100644 mitra_app/.maestro/flows/01_smoke.yaml create mode 100644 mitra_app/.maestro/flows/02_online_offline_toggle.yaml create mode 100644 mitra_app/.maestro/flows/03_accept_general_blast.yaml create mode 100755 mitra_app/.maestro/scripts/customer_blast_now.sh create mode 100644 requirement/phase3.7-plan.md create mode 100644 requirement/phase3.7-questions.md create mode 100644 requirement/phase3.7-test-run-2026-05-03.md create mode 100644 requirement/phase3.7-testing.md create mode 100644 requirement/phase3.7.md diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..15100fc --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(flutter analyze:*)", + "Bash(adb devices:*)" + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fe97758..a906d69 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,6 +1,12 @@ { "permissions": { "allow": [ + "Bash(npm test:*)", + "Bash(npm run test:*)", + "Bash(npx playwright test:*)", + "Bash(npx playwright install:*)", + "Bash(maestro test:*)", + "Bash(maestro --version)", "Bash(git clone:*)", "Bash(shopt -s dotglob)", "Bash(cp -rn /tmp/halobestie-clone-temp/* /home/rama/workspaces/workspace-claude/halobestie-clone/)", @@ -74,10 +80,361 @@ "Bash(fuser -k 3000/tcp)", "Bash(fuser -k 3001/tcp)", "Bash(fuser 3000/tcp)", - "Bash(kill -9 923894)" + "Bash(kill -9 923894)", + "Bash(curl -s http://localhost:3000/api/client/chat/pricing)", + "Bash(pkill -f \"flutter run -d emulator-5554\")", + "Bash(ip -4 addr show)", + "Bash(pkill -f \"flutter run -d 52002a5db8e0c46b\")", + "Bash(adb -s 52002a5db8e0c46b shell pm clear com.halobestie.mitra.mitra_app)", + "Bash(adb -s 52002a5db8e0c46b shell am force-stop com.halobestie.mitra.mitra_app)", + "Read(//home/rama/sdk/**)", + "Read(//home/rama/**)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s 52002a5db8e0c46b uninstall com.halobestie.mitra.mitra_app)", + "Bash(dart run:*)", + "Bash(flutter analyze:*)", + "Bash(git reset:*)", + "Bash(git checkout:*)", + "Bash(node src/db/migrate.js)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 uninstall com.halobestie.client.client_app)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s 52002a5db8e0c46b logcat -d -s flutter)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s 52002a5db8e0c46b logcat -c)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s 52002a5db8e0c46b shell am force-stop com.halobestie.mitra.mitra_app)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 logcat -d -s flutter)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 logcat -d)", + "Bash(pkill -f \"node --watch\")", + "Bash(curl -s http://localhost:3000/api/mitra/chat-requests/test123/status)", + "Bash(node --watch src/server.js)", + "Bash(psql -h omv.sjamsani.id -U halobestie_clone -d halobestie_clone -c ':*)", + "Bash(git pull:*)", + "Bash(git stash:*)", + "Bash(ls -la /home/rama/workspaces/workspace-claude/halobestie-clone/control_center/.env*)", + "Bash(xargs cat:*)", + "Bash(adb devices:*)", + "Read(//usr/lib/**)", + "Read(//opt/**)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb devices:*)", + "Bash(/home/rama/Android/Sdk/emulator/emulator -list-avds)", + "Bash(/home/rama/Android/Sdk/emulator/emulator -avd Medium_Phone_API_36.1 -no-snapshot-load)", + "Bash(cat /dev/null)", + "Bash(curl -s http://localhost:3000/api/shared/health)", + "Bash(ls -la logs/)", + "Bash(curl -s http://192.168.88.247:3000/api/mitra/auth/login)", + "Bash(curl -s http://192.168.88.247:3000/api/shared/)", + "Bash(PGPASSWORD=halobestie_clone psql -h omv.sjamsani.id -U halobestie_clone -d halobestie_clone -c \"SELECT id, display_name, phone, is_active, firebase_uid FROM mitras LIMIT 10;\")", + "Bash(kill 585365 585412)", + "Bash(curl -s http://192.168.88.247:3000/api/mitra/auth/verify -X POST -H \"Content-Type: application/json\")", + "Bash(curl -s http://192.168.88.247:3000/api/mitra/auth/verify -X POST)", + "Bash(cat .env)", + "Bash(cat .env.local)", + "Bash(ls /home/rama/workspaces/workspace-claude/halobestie-clone/control_center/.env*)", + "Bash(node -e ' *)", + "Bash(npx vite *)", + "Bash(curl -s http://127.0.0.1:3001/internal/auth/verify -X POST -H \"Content-Type: application/json\")", + "Bash(curl -s http://192.168.88.247:3001/internal/auth/verify -X POST)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" -X OPTIONS http://localhost:3001/internal/auth/verify -H \"Origin: http://localhost:5173\" -H \"Access-Control-Request-Method: POST\")", + "Bash(curl -s -i -X OPTIONS http://localhost:3001/internal/auth/verify -H \"Origin: http://localhost:5173\" -H \"Access-Control-Request-Method: POST\")", + "Bash(curl -s http://localhost:3001/internal/mitra-activity/summary)", + "Bash(curl -s http://localhost:3001/internal/mitra-activity/log?page=1\\\\&limit=20)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s 52002a5db8e0c46b install -r /home/rama/workspaces/workspace-claude/halobestie-clone/mitra_app/build/app/outputs/flutter-apk/app-debug.apk)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 install -r /home/rama/workspaces/workspace-claude/halobestie-clone/client_app/build/app/outputs/flutter-apk/app-debug.apk)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell pm list packages -3)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell pm uninstall com.example.wikipedia_reader)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell pm uninstall com.halobestie.mitra.mitra_app)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell pm clear com.halobestie.client.client_app)", + "Bash(git commit -m ' *)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 emu kill)", + "mcp__plugin_figma_figma__get_design_context", + "mcp__plugin_figma_figma__whoami", + "Bash(xargs ls *)", + "Bash(mkdir -p /home/rama/workspaces/workspace-claude/halobestie-clone/client_app/assets/images)", + "Bash(mkdir -p /home/rama/workspaces/workspace-claude/halobestie-clone/mitra_app/assets/images)", + "Bash(adb logcat *)", + "Bash(find /home/rama/Android /home/rama/android* -name \"adb\")", + "Bash(/home/rama/Android/Sdk/platform-tools/adb logcat *)", + "Bash(identify /home/rama/workspaces/workspace-claude/halobestie-clone/mitra_app/assets/images/splash_chat_hebat.png)", + "Bash(python3 -c \"from PIL import Image; img=Image.open\\('/home/rama/workspaces/workspace-claude/halobestie-clone/mitra_app/assets/images/splash_chat_hebat.png'\\); print\\(f'Size: {img.size}'\\)\")", + "Bash(pkill -f \"emulator -avd\")", + "Bash(pkill -f \"node --watch src/server.js\")", + "Bash(psql \"postgresql://halobestie_clone:halobestie_clone@omv.sjamsani.id:5432/halobestie_clone\" -c \"SELECT id, display_name, phone, status FROM mitras ORDER BY id;\")", + "Bash(node --env-file=.env -e ' *)", + "Bash(kill 6374)", + "Bash(kill -9 6374)", + "Bash(nohup /home/rama/Android/Sdk/emulator/emulator -avd Mitra_Phone -no-snapshot-load)", + "Bash(echo \"started pid=$!\")", + "Read(//tmp/**)", + "Bash(/home/rama/Android/Sdk/emulator/emulator -version)", + "Bash(/home/rama/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --list)", + "Bash(/home/rama/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --list_installed)", + "Bash(awk '/Available Updates:/{flag=1} flag')", + "Bash(export JAVA_HOME=/home/rama/IDE/android-studio/jbr)", + "Bash(awk '/Available Updates:/,0')", + "Bash(kill 5147 12946)", + "Bash(yes)", + "Bash(/home/rama/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --update)", + "Bash(echo \"medium pid=$!\")", + "Bash(echo \"mitra pid=$!\")", + "Bash(top -bn1 -o %MEM)", + "Read(//proc/**)", + "Bash(iostat -xm 1 2)", + "Bash(ps -eo stat,pid,comm)", + "Bash(awk '$1 ~ /Z/')", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell getprop sys.boot_completed)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell getprop dev.bootcomplete)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell pidof system_server)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell service list)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5556 shell pidof system_server)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 reboot)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5556 reboot)", + "Bash(awk '/^nvme0n1/ {print}')", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5556 emu kill)", + "Bash(pkill -f \"qemu-system.*Mitra_Phone\")", + "Bash(pkill -f \"qemu-system.*-avd\")", + "Bash(pkill -9 -f \"qemu-system.*-avd\")", + "Bash(lsblk -d -o NAME,SIZE,MODEL,MOUNTPOINT)", + "Bash(mount)", + "Bash(iostat -xm 5)", + "Bash(awk '$1==\"nvme0n1\" && $NF+0 > 50 { printf \"nvme0n1 | w/s=%.0f wMB/s=%.0f aqu-sz=%.1f util=%.0f%%\\\\n\", $8, $9, $\\(NF-1\\), $NF; fflush\\(\\) }')", + "Bash(wait)", + "Bash(iostat -xm 3)", + "Bash(awk '$1==\"nvme0n1\" { u=$NF+0; if \\(u<30\\) c++; else c=0; if \\(c==3\\) { printf \"nvme0n1 calm: util=%.0f%% w/s=%.0f wMB/s=%.0f\\\\n\", u, $8, $9; fflush\\(\\); exit } }')", + "Bash(pidstat -d 2 2)", + "Bash(awk 'NR<=3 || \\($6+0 > 5 && $NF != \"Command\"\\)')", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5556 shell 'top -n 1 -b -s cpu 2>/dev/null | head -15')", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5556 shell 'ps -A 2>/dev/null | grep -iE '\\\\''dex2oat|bg-dexopt|package-manager'\\\\'' | head')", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell 'ps -A 2>/dev/null | grep -iE '\\\\''dex2oat|bg-dexopt|package-manager'\\\\'' | head')", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell 'ps -A 2>/dev/null | grep -iE '\\\\''dex2oat|dexopt'\\\\''')", + "Bash(awk '/^nvme0n1/')", + "Bash(pkill -f \"qemu-system.*Medium_Phone\")", + "Bash(pkill -9 -f \"qemu-system.*Medium_Phone\")", + "Bash(kill -9 950924)", + "Bash(iostat -xm 10)", + "Bash(awk '$1==\"nvme0n1\" && $NF+0 > 50 { printf \"nvme0n1 util=%.0f%% w/s=%.0f wMB/s=%.0f aqu-sz=%.0f\\\\n\", $NF, $8, $9, $\\(NF-1\\); fflush\\(\\) }')", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell \"top -n 1 -b 2>/dev/null | head -15\")", + "Bash(kill -9 979586)", + "Bash(git -C /home/rama/workspaces/workspace-claude/halobestie-clone log --oneline -20)", + "Bash(node --check src/services/sensitivity.service.js)", + "Bash(node --check src/services/pairing.service.js)", + "Bash(node --check src/services/config.service.js)", + "Bash(node --check src/services/extension.service.js)", + "Bash(node --check src/services/session.service.js)", + "Bash(node --check src/services/dashboard.service.js)", + "Bash(node --check src/services/mitra-activity.service.js)", + "Bash(node --check src/routes/public/shared.chat.routes.js)", + "Bash(node --check src/routes/public/client.chat.routes.js)", + "Bash(node --check src/routes/public/shared.config.routes.js)", + "Bash(node --check src/db/migrate.js)", + "Bash(node --check src/constants.js)", + "Bash(node --check src/routes/internal/config.routes.js)", + "Bash(node --check src/routes/internal/session.routes.js)", + "Bash(node --check src/services/password.service.js)", + "Bash(node --check src/services/token.service.js)", + "Bash(node --check src/services/auth.service.js)", + "Bash(node --check src/services/customer.service.js)", + "Bash(node --check src/services/cc-user.service.js)", + "Bash(node --check src/services/mitra.service.js)", + "Bash(curl -sf http://127.0.0.1:3001/internal/auth/me -o /dev/null)", + "Bash(npm --prefix /home/rama/workspaces/workspace-claude/halobestie-clone/backend run start)", + "Bash(psql \"postgresql://halobestie_clone:halobestie_clone@omv.sjamsani.id:5432/halobestie_clone\" -c \"UPDATE mitras SET is_active = true WHERE phone = '+628123456789' RETURNING id, phone, display_name, is_active;\")", + "Bash(flutter --prefix=/home/rama/workspaces/workspace-claude/halobestie-clone/mitra_app build apk --debug)", + "Bash(mv .claude/agents/client-app-flutter.md .claude/agents/customer-app-flutter.md)", + "Bash(mv .claude/agent-memory/client-app-flutter .claude/agent-memory/customer-app-flutter)", + "Bash(flutter doctor *)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb kill-server *)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb start-server *)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell pm list packages)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s RRCR100NN7Z shell pm list packages)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell dumpsys window)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell pidof com.halobestie.client.client_app)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s RRCR100NN7Z shell pidof com.halobestie.mitra.mitra_app)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s RRCR100NN7Z shell dumpsys window)", + "Bash(kill 119427 119427)", + "Bash(awk '{print $2}')", + "Bash(xargs -r kill)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell pm path com.halobestie.client.client_app)", + "Bash(pkill -f \"flutter_tools.snapshot run -d emulator-5554\")", + "Bash(pkill -f \"frontend_server_aot\")", + "Bash(ps aux *)", + "Read(//dev/**)", + "Bash(ps -p 118758 -o args=)", + "Bash(ps -L -o pid,tid,pcpu,comm -p 118758)", + "Bash(sort -k3 -n -r)", + "Bash(awk '{print $2, $11}')", + "Bash(kill -9 119620 120656)", + "Bash(/home/rama/Android/Sdk/emulator/emulator -avd Medium_Phone_API_36.1 -no-snapshot-load -no-boot-anim)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell pm uninstall com.halobestie.client.client_app)", + "Bash(grep -rE \"phone.*\\\\+62|62[0-9]{8,}|insert.*mitra\" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/db/)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 logcat -d -t 500 *:W flutter:V)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 logcat -d -t 200 --pid 4152)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell curl -s -m 5 -o /dev/null -w \"HTTP %{http_code} time=%{time_total}\\\\n\" http://192.168.88.247:3000/health)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell curl -s -m 5 http://192.168.88.247:3000/api/shared/config/anonymity)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell ping -c 2 192.168.88.247)", + "Bash(pkill -9 -f \"qemu-system-x86_64\")", + "Bash(pkill -9 -f \"flutter_tools.snapshot run -d emulator\")", + "Bash(pkill -9 -f \"frontend_server_aot\")", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell ping -c 3 192.168.88.247)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell 'time toybox wget -O- http://192.168.88.247:3000/health')", + "Bash(pkill -9 -f \"flutter_tools.snapshot run\")", + "Bash(/home/rama/Android/Sdk/cmdline-tools/latest/bin/avdmanager delete *)", + "Bash(/home/rama/Android/Sdk/tools/bin/avdmanager delete *)", + "Bash(rm -rf /home/rama/.android/avd/Medium_Phone.avd /home/rama/.android/avd/Medium_Phone_API_36.1.ini)", + "Bash(/home/rama/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --install \"system-images;android-24;google_apis;x86_64\")", + "Bash(/home/rama/Android/Sdk/cmdline-tools/latest/bin/avdmanager create *)", + "Bash(/home/rama/Android/Sdk/emulator/emulator -avd Client_Phone -no-snapshot-load -no-boot-anim)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell getprop)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5554 shell ps -A)", + "Bash(/home/rama/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --install \"system-images;android-34;google_apis_playstore;x86_64\")", + "Bash(glxinfo)", + "Bash(lspci)", + "Bash(/home/rama/Android/Sdk/emulator/emulator -avd Mitra_Phone -no-snapshot-load -no-boot-anim)", + "Bash(awk '{printf \"%-15s %5s%% %-50s\\\\n\", $1, $3, substr\\($11\" \"$12\" \"$13, 1, 50\\)}')", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s emulator-5556 shell getprop sys.boot_completed)", + "Bash(pkill -9 -f \"qemu.*Mitra_Phone\")", + "Bash(pkill -f \"frontend_server_aot.*emulator\")", + "Bash(awk '{print $2, $11, $13, $14}')", + "Bash(timeout 8 node --check src/services/session-timer.service.js)", + "Bash(timeout 8 node --check src/services/closure.service.js)", + "Bash(timeout 10 node -e \"import\\('./src/services/session-timer.service.js'\\).then\\(m => { console.log\\('exports:', Object.keys\\(m\\).join\\(','\\)\\); process.exit\\(0\\); }\\).catch\\(e => { console.error\\('IMPORT FAIL:', e.message\\); process.exit\\(1\\); }\\)\")", + "Bash(timeout 10 node -e \"Promise.all\\([import\\('./src/services/closure.service.js'\\), import\\('./src/services/extension.service.js'\\), import\\('./src/constants.js'\\)]\\).then\\(\\([c,e,k]\\) => { console.log\\('closure:', Object.keys\\(c\\).join\\(','\\)\\); console.log\\('ext:', Object.keys\\(e\\).join\\(','\\)\\); console.log\\('EndedBy:', JSON.stringify\\(k.EndedBy\\)\\); }\\).catch\\(err => { console.error\\('FAIL:', err.message\\); process.exit\\(1\\); }\\)\")", + "Bash(kill 383655 492918)", + "Bash(kill 510357)", + "Bash(pkill -9 -f \"node --watch src/server.js\")", + "Bash(pkill -9 -f \"GradleDaemon\")", + "Bash(pkill -9 -f \"kotlin-compiler\")", + "Bash(kill -9 118680 118681 574735 506972)", + "Bash(tee /tmp/backend.log)", + "Bash(~/Android/Sdk/platform-tools/adb devices *)", + "Bash(netstat -tlnp)", + "Bash(~/Android/Sdk/emulator/emulator -avd Client_Phone -gpu swiftshader_indirect -no-snapshot-save)", + "Bash(~/Android/Sdk/platform-tools/adb shell *)", + "Bash(/home/rama/sdk/flutter/flutter/bin/flutter devices *)", + "Bash(/home/rama/sdk/flutter/flutter/bin/flutter run *)", + "Bash(~/Android/Sdk/platform-tools/adb -s RRCR100NN7Z reverse --remove-all)", + "Bash(~/Android/Sdk/platform-tools/adb -s RRCR100NN7Z reverse tcp:50300 tcp:50300)", + "Bash(~/Android/Sdk/platform-tools/adb -s RRCR100NN7Z shell pm list packages)", + "Bash(~/Android/Sdk/emulator/emulator -list-avds)", + "Bash(xargs basename *)", + "Read(//:sessionId/**)", + "Bash(grep -nB1 -A 12 \"/info\" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/routes/public/shared.chat.routes.js)", + "Bash(grep -nA 15 \"getSessionById\" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/services/session.service.js)", + "Bash(grep -nB1 -A 10 \"active-with-unread\" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/routes/public/client.chat.routes.js)", + "Bash(grep -nB1 -A 30 \"getActiveSessionByCustomerWithUnread\" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/services/session.service.js)", + "Bash(grep -nA 25 \"getCustomerHistory\" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/services/session.service.js)", + "Bash(git -C /home/rama/workspaces/workspace-claude/halobestie-clone log --oneline -10)", + "Bash(git -C /home/rama/workspaces/workspace-claude/halobestie-clone log -1 --stat f838016)", + "Bash(git -C /home/rama/workspaces/workspace-claude/halobestie-clone log --since='2026-04-25' --oneline --all)", + "Bash(git -C /home/rama/workspaces/workspace-claude/halobestie-clone diff f838016 HEAD -- client_app/lib/features/chat/screens/chat_screen.dart client_app/lib/core/chat/chat_notifier.dart client_app/lib/core/chat/session_closure_notifier.dart)", + "Bash(grep -nA 6 \"refreshSessionStatus\" /home/rama/workspaces/workspace-claude/halobestie-clone/mitra_app/lib/core/chat/chat_notifier.dart)", + "Bash(curl -s -o /dev/null -w \"backend public: %{http_code}\\\\n\" http://192.168.88.247:3000/health)", + "Bash(~/Android/Sdk/platform-tools/adb kill-server *)", + "Bash(~/Android/Sdk/platform-tools/adb start-server *)", + "Bash(grep -nB1 -A 10 \"/info\" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/routes/public/shared.chat.routes.js)", + "Bash(grep -nA 8 \"getSessionClosures\\\\|getClosureByUserType\" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/services/closure.service.js)", + "Bash(grep -nA 20 \"submitClosureMessage\\\\|export const submit\" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/services/closure.service.js)", + "Bash(PGPASSWORD=halobestie_clone psql -h omv.sjamsani.id -U halobestie_clone -d halobestie_clone -c \"SELECT id, phone_e164, display_name, is_active, status, created_at FROM mitras ORDER BY created_at DESC LIMIT 10;\")", + "Bash(~/Android/Sdk/platform-tools/adb -s emulator-5554 uninstall com.halobestie.client.client_app)", + "Bash(~/Android/Sdk/platform-tools/adb -s RRCR100NN7Z uninstall com.halobestie.mitra.mitra_app)", + "Bash(/home/rama/sdk/flutter/flutter/bin/flutter clean *)", + "Bash(grep -nA 30 \"verify\\\\|consumed\" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/services/otp.service.js)", + "Bash(grep -nB1 -A 40 \"otp/verify\" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/routes/public/client.auth.routes.js)", + "Bash(grep -nA 20 \"upgradeCustomerIdentity\\\\|normalizeIdentityConflict\\\\|createCustomerWithIdentity\" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/services/auth.service.js)", + "Bash(grep -nA 20 \"upgradeCustomerIdentity\\\\|createCustomerWithIdentity\" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/services/customer.service.js)", + "Bash(~/Android/Sdk/platform-tools/adb -s emulator-5554 shell pm clear com.halobestie.client.client_app)", + "Bash(grep -nB1 -A 12 \"CREATE TABLE.*customers \" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/db/migrate.js)", + "Bash(grep -nA 15 \"getOtpRateLimits\" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/services/otp.service.js)", + "Bash(grep -nA 5 \"resend_cooldown\\\\|verify_max_attempts\" /home/rama/workspaces/workspace-claude/halobestie-clone/backend/src/services/config.service.js)", + "Bash(pkill -f \"qemu-system-x86_64.*Client_Phone\")", + "Bash(/home/rama/Android/Sdk/platform-tools/adb shell *)", + "Bash(/home/rama/Android/Sdk/platform-tools/adb -s RRCR100NN7Z reverse tcp:50300 tcp:50300)", + "Bash(awk -F'time\":' '/otp\\\\/verify/{ split\\($2, a, \",\"\\); if \\(a[1] > 1777257300000\\) print a[1] }' /tmp/claude-1000/-home-rama-workspaces-workspace-claude-halobestie-clone/81b349f2-207b-4016-a161-ea4cb99789a3/tasks/bnztq4jab.output)", + "Bash(psql \"postgresql://halobestie_clone:halobestie_clone@omv.sjamsani.id:5432/halobestie_clone\" -c \"INSERT INTO app_config \\(key, value, updated_at\\) VALUES \\('otp_max_per_ip_per_hour', '{\\\\\"value\\\\\": 1000000}'::jsonb, NOW\\(\\)\\) ON CONFLICT \\(key\\) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW\\(\\) RETURNING key, value, updated_at;\")", + "Bash(curl -s \"http://localhost:3000/api/mitra/chat-requests/recent\" -H \"Authorization: Bearer invalid\")", + "Bash(curl -s -o /dev/null -w \"HTTP %{http_code}\\\\n\" \"http://localhost:3000/api/mitra/chat-requests/recent\")", + "Bash(node --env-file=.env -e \"import\\('./src/app.public.js'\\).then\\(m => m.buildPublicApp\\(\\)\\).then\\(app => { console.log\\('OK:', app.printRoutes\\(\\).split\\('\\\\n'\\).filter\\(l => l.includes\\('recent'\\)\\).join\\('\\\\n'\\)\\); process.exit\\(0\\) }\\)\")", + "Bash(curl -s -o /dev/null -w \"Backend HTTP %{http_code}\\\\n\" http://localhost:3000/health)", + "Bash(curl -s -o /dev/null -w \"Backend HTTP %{http_code}\\\\n\" http://192.168.88.247:3000/health)", + "Bash(curl -sf http://localhost:3000/health)", + "Bash(curl -sf http://localhost:3000/api/shared/config/anonymity)", + "Bash(~/Android/Sdk/platform-tools/adb reverse *)", + "Bash(tee /tmp/mitra-run.log)", + "Bash(pkill -f \"flutter_tools.*run\")", + "Bash(kill 458295 458339)", + "Bash(~/sdk/flutter/flutter/bin/flutter run *)", + "Bash(git check-ignore *)", + "Bash(grep -E \"^ws$\")", + "Bash(node /tmp/phase37_smoke.mjs)", + "Bash(cp /tmp/phase37_smoke.mjs /home/rama/workspaces/workspace-claude/halobestie-clone/backend/_phase37_smoke.mjs)", + "Bash(node _phase37_smoke.mjs)", + "Bash(node _debug_avail.mjs)", + "Bash(curl -s -X GET 'http://127.0.0.1:3000/api/client/mitra-availability/' -H 'authorization: Bearer invalid')", + "Bash(curl -s -X GET 'http://127.0.0.1:3001/internal/failed-pairings/' -H 'authorization: Bearer invalid')", + "Bash(git -C /home/rama/workspaces/workspace-claude/halobestie-clone log --oneline -1 -- client_app/test/widget_test.dart)", + "Bash(git -C /home/rama/workspaces/workspace-claude/halobestie-clone diff HEAD -- client_app/test/widget_test.dart)", + "Bash(node -e \"import\\('./src/services/pairing.service.js'\\).then\\(m => console.log\\('OK'\\)\\).catch\\(e => { console.error\\('FAIL:', e.message\\); process.exit\\(1\\) }\\)\")", + "Bash(curl -s -m 2 -o /dev/null -w \"internal-3001:%{http_code}\\\\n\" http://127.0.0.1:3001/internal/healthz)", + "Bash(curl -s -m 2 -o /dev/null -w \"public-3000:%{http_code}\\\\n\" http://127.0.0.1:3000/healthz)", + "Bash(curl -s -o /dev/null -w \"/:%{http_code}\\\\n\" http://localhost:5173/)", + "Bash(curl -s -o /dev/null -w \"/failed-pairings:%{http_code}\\\\n\" http://localhost:5173/failed-pairings)", + "Bash(curl -s -o /dev/null -w \"/settings:%{http_code}\\\\n\" http://localhost:5173/settings)", + "Bash(curl -s -o /tmp/fp-mod.js -w \"FailedPairings module:%{http_code}\\\\n\" \"http://localhost:5173/src/pages/failed-pairings/FailedPairingsPage.jsx\")", + "Bash(curl -s -o /tmp/sp-mod.js -w \"Settings module:%{http_code}\\\\n\" \"http://localhost:5173/src/pages/settings/SettingsPage.jsx\")", + "Bash(curl -s -X POST http://127.0.0.1:3001/internal/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"admin@sjamsani.id\",\"password\":\"admin123\"}')", + "Bash(curl -s -X POST http://127.0.0.1:3001/internal/auth/login -H \"Content-Type: application/json\" -d '{\"email\":\"admin@sjamsani.id\",\"password\":\"admin123\"}')", + "Bash(curl -s -i -X OPTIONS -H \"Origin: http://localhost:5173\" -H \"Access-Control-Request-Method: GET\" -H \"Access-Control-Request-Headers: authorization,content-type\" http://127.0.0.1:3001/internal/failed-pairings)", + "Bash(pkill -f \"vite\")", + "Bash(pkill -f \"node src/server.js\")", + "Bash(kill 540480)", + "Bash(grep -rn \"_formatPrice\\\\|formatRupiah\\\\|Rp \\\\$\" /home/rama/workspaces/workspace-claude/halobestie-clone/client_app/lib /home/rama/workspaces/workspace-claude/halobestie-clone/mitra_app/lib)", + "Bash(node --check src/services/payment.service.js)", + "Bash(node --check src/routes/public/client.payment.routes.js)", + "Bash(node --check src/services/payment.service.js src/services/pairing.service.js src/services/extension.service.js src/services/config.service.js)", + "Bash(node --check src/services/payment.service.js src/services/pairing.service.js src/services/extension.service.js src/services/config.service.js src/services/mitra-status.service.js src/services/pricing.service.js src/services/pairing-failure.service.js src/routes/internal/config.routes.js src/routes/internal/failed-pairings.routes.js src/routes/public/client.chat.routes.js src/routes/public/client.payment.routes.js src/routes/public/client.mitra-availability.routes.js src/constants.js src/server.js)", + "Bash(~/Android/Sdk/cmdline-tools/latest/bin/avdmanager list *)", + "Bash(~/Android/Sdk/emulator/emulator -avd Client_Phone -gpu swiftshader_indirect -port 5554)", + "Bash(echo \"Client_Phone PID: $!\")", + "Bash(~/Android/Sdk/emulator/emulator -avd Mitra_Phone -gpu swiftshader_indirect -port 5556)", + "Bash(echo \"Mitra_Phone PID: $!\")", + "Bash(~/Android/Sdk/platform-tools/adb -s emulator-5554 emu kill)", + "Bash(~/Android/Sdk/platform-tools/adb -s emulator-5556 emu kill)", + "Bash(grep -h \"Mulai Curhat\\\\|Bestie Ditemukan\\\\|Belum ada bestie\\\\|Mulai$\\\\|Bayar\" /home/rama/workspaces/workspace-claude/halobestie-clone/client_app/lib/features/home/home_screen.dart /home/rama/workspaces/workspace-claude/halobestie-clone/client_app/lib/features/payment/screens/payment_screen.dart)", + "Bash(chmod +x /home/rama/workspaces/workspace-claude/halobestie-clone/client_app/.maestro/scripts/*.sh)", + "Bash(chmod +x /home/rama/workspaces/workspace-claude/halobestie-clone/mitra_app/.maestro/scripts/*.sh)", + "Bash(xargs -I{} cat {})", + "Bash(tee /tmp/vitest-run.log)", + "Bash(echo \"=== exit code: $? ===\")", + "Bash(curl -s -o /dev/null -w \"HTTP %{http_code}\\\\n\" --max-time 3 http://localhost:5173)", + "Bash(curl -s -o /dev/null -w \"HTTP %{http_code}\\\\n\" --max-time 3 http://localhost:3001)", + "Bash(tee /tmp/playwright-run.log)", + "Bash(tee /tmp/maestro-run.log)", + "Bash(curl -s -o /dev/null -w \"HTTP %{http_code} | %{size_download} bytes | content-type: %{content_type}\\\\n\" --max-time 5 http://playwright.sjamsani.id)", + "Bash(curl -s -i --max-time 5 http://playwright.sjamsani.id)", + "Bash(curl -s --max-time 5 -o /dev/null -w \" HTTP %{http_code}\\\\n\" http://playwright.sjamsani.id/index.html)", + "Bash(curl -s --max-time 5 -o /dev/null -w \" HTTP %{http_code}\\\\n\" http://playwright.sjamsani.id/api/)", + "Bash(curl -s --max-time 5 -o /dev/null -w \" HTTP %{http_code}\\\\n\" http://playwright.sjamsani.id/internal/)", + "Bash(curl -s --max-time 5 -o /dev/null -w \" HTTP %{http_code}\\\\n\" http://playwright.sjamsani.id/version)", + "Bash(curl -s --max-time 5 -o /dev/null -w \" HTTP %{http_code}\\\\n\" http://playwright.sjamsani.id/json/version)", + "Bash(tee /tmp/playwright-run-2.log)", + "Bash(PGPASSWORD=halobestie_clone psql -h omv.sjamsani.id -U halobestie_clone -d halobestie_clone -t -A -c \"SELECT id, email, full_name, role, is_active FROM control_center_users ORDER BY created_at LIMIT 10;\")", + "Bash(curl -s -o /dev/null -w \"backend\\(public:3000\\) HTTP %{http_code}\\\\n\" --max-time 2 http://localhost:3000/health)", + "Bash(curl -s -o /dev/null -w \"backend\\(internal:3001\\) HTTP %{http_code}\\\\n\" --max-time 2 http://localhost:3001)", + "Bash(tee /tmp/playwright-run-3.log)", + "Bash(tee /tmp/playwright-run-4.log)", + "Bash(echo \"=== exit: $? ===\")", + "Bash(tee /tmp/playwright-run-5.log)", + "Bash(awk '/Page snapshot/,/^# /' \"/home/rama/workspaces/workspace-claude/halobestie-clone/control_center/test-results/settings-Settings-page-—-P-4073e-→-25-persists-across-reload-chromium/error-context.md\")", + "Bash(tee /tmp/playwright-debug.log)", + "Bash(curl -s -i -X OPTIONS http://localhost:3001/internal/config/payment-session-timeout -H 'Origin: http://localhost:5173' -H 'Access-Control-Request-Method: PATCH' -H 'Access-Control-Request-Headers: authorization,content-type')", + "Bash(tee /tmp/playwright-run-6.log)", + "Bash(kill 882584)" ], "additionalDirectories": [ - "/home/rama/workspaces/workspace-claude/halobestie-clone/backend/src" + "/home/rama/workspaces/workspace-claude/halobestie-clone/backend/src", + "/home/rama/.gradle", + "/home/rama/workspaces/workspace-claude/halobestie-clone/mitra_app/assets/images/splash", + "/home/rama/.android/avd/Mitra_Phone.avd", + "/proc/5649/fd", + "/home/rama/.android/avd/Medium_Phone.avd", + "/tmp", + "/home/rama/.android/avd" ] } } diff --git a/backend/.env.test.example b/backend/.env.test.example new file mode 100644 index 0000000..c18764e --- /dev/null +++ b/backend/.env.test.example @@ -0,0 +1,26 @@ +# Test environment configuration. Copy to .env.test and adjust if needed. +# +# DEFAULT STRATEGY (Option C): same remote Postgres, isolated `halobestie_test` SCHEMA. +# The dev role on the remote DB cannot CREATE DATABASE, so we use schema isolation +# instead of a separate database. Tests set search_path so the migration creates all +# tables inside `halobestie_test`, leaving the dev `public` schema untouched. + +# Test Postgres (same instance + same database as dev — schema isolates). +TEST_DATABASE_URL=postgresql://halobestie_clone:halobestie_clone@omv.sjamsani.id:5432/halobestie_clone + +# Schema used to isolate test tables from dev tables. MUST NOT be `public`. +TEST_DB_SCHEMA=halobestie_test + +# Test Valkey (same instance, separate db number 1 to avoid clashing with dev db 0). +TEST_VALKEY_URL=redis://omv.sjamsani.id:6379/1 + +# JWT secret for test-minted tokens. Any 32+ char string is fine (does not need to +# match the dev secret; tests mint and verify in the same process). +AUTH_JWT_SECRET=test-secret-must-be-at-least-32-characters-long + +# Token TTLs (kept short for tests). +ACCESS_TOKEN_TTL_SECONDS=3600 +REFRESH_TOKEN_TTL_DAYS=30 + +# CC origin needed by app.internal CORS — anything resolvable. +CC_ORIGIN=http://localhost:5173 diff --git a/backend/.gitignore b/backend/.gitignore index 1a7eeb3..c93e198 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,5 +1,9 @@ node_modules/ .env +.env.test *.log firebase-service-account.json *-firebase-adminsdk-*.json +coverage/ +_phase37_smoke.mjs +_check_db.mjs diff --git a/backend/docker-compose.test.yml b/backend/docker-compose.test.yml new file mode 100644 index 0000000..dba60aa --- /dev/null +++ b/backend/docker-compose.test.yml @@ -0,0 +1,34 @@ +# Alternative test infrastructure: ephemeral Postgres + Valkey containers. +# +# CURRENT DEFAULT is Option C (schema-isolated remote DB) because the dev role on +# the remote DB cannot CREATE DATABASE. Use this docker-compose if you'd rather +# run an isolated, throwaway test DB on your local machine. +# +# To switch to docker-compose: +# 1. docker compose -f docker-compose.test.yml up -d +# 2. In .env.test set: +# TEST_DATABASE_URL=postgresql://test:test@localhost:55432/halobestie_test +# TEST_DB_SCHEMA=public +# TEST_VALKEY_URL=redis://localhost:56379/0 +# 3. npm test +# 4. docker compose -f docker-compose.test.yml down -v +# +# The non-default ports (55432, 56379) avoid clashing with any local Postgres/Redis. +services: + postgres-test: + image: postgres:15-alpine + environment: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: halobestie_test + ports: + - "55432:5432" + tmpfs: + - /var/lib/postgresql/data # ephemeral — wiped on container stop + + valkey-test: + image: valkey/valkey:7-alpine + ports: + - "56379:6379" + tmpfs: + - /data diff --git a/backend/package-lock.json b/backend/package-lock.json index 09a107d..d68e314 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,7 +25,80 @@ "zod": "^3.23.8" }, "devDependencies": { - "@types/pg": "^8.11.6" + "@types/pg": "^8.11.6", + "@vitest/coverage-v8": "^4.1.5", + "vitest": "^4.1.5" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@fastify/ajv-compiler": { @@ -462,6 +535,34 @@ "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", "license": "MIT" }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", @@ -527,6 +628,25 @@ "node": ">= 6" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -537,6 +657,16 @@ "node": ">=8.0.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -617,6 +747,277 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -627,6 +1028,17 @@ "node": ">= 10" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/caseless": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", @@ -634,6 +1046,31 @@ "license": "MIT", "optional": true }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", @@ -698,6 +1135,151 @@ "license": "MIT", "optional": true }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -820,6 +1402,28 @@ "node": ">=8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/async-retry": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", @@ -945,6 +1549,16 @@ "node": ">= 0.4" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -1041,6 +1655,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -1206,6 +1827,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1245,6 +1873,16 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -1255,6 +1893,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -1438,6 +2086,24 @@ "node": ">=0.8.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/find-my-way": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", @@ -1548,6 +2214,21 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1789,6 +2470,16 @@ "node": ">=14.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1854,6 +2545,13 @@ "license": "MIT", "optional": true }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1992,6 +2690,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "4.15.9", "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", @@ -2001,6 +2754,13 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -2131,6 +2891,267 @@ ], "license": "MIT" }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/limiter": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", @@ -2232,6 +3253,28 @@ "lru-cache": "6.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -2375,6 +3418,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/node-addon-api": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", @@ -2457,6 +3519,17 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -2516,6 +3589,13 @@ "node": ">=0.10.0" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", @@ -2606,6 +3686,27 @@ "split2": "^4.1.0" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pino": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", @@ -2643,6 +3744,35 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postgres": { "version": "3.4.9", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", @@ -2884,6 +4014,40 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2981,6 +4145,13 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -2996,6 +4167,16 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -3005,6 +4186,13 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -3020,6 +4208,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-events": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", @@ -3091,6 +4286,19 @@ "license": "MIT", "optional": true }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -3176,6 +4384,50 @@ "real-require": "^0.2.0" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toad-cache": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", @@ -3279,6 +4531,176 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -3318,6 +4740,23 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", diff --git a/backend/package.json b/backend/package.json index 5c1ff20..5b9e53f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,10 @@ "dev": "node --watch src/server.js", "start": "node src/server.js", "db:migrate": "node src/db/migrate.js", - "db:seed": "node src/db/seed.js" + "db:seed": "node src/db/seed.js", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@fastify/cookie": "^11.0.2", @@ -28,6 +31,8 @@ "zod": "^3.23.8" }, "devDependencies": { - "@types/pg": "^8.11.6" + "@types/pg": "^8.11.6", + "@vitest/coverage-v8": "^4.1.5", + "vitest": "^4.1.5" } } diff --git a/backend/src/app.internal.js b/backend/src/app.internal.js index b1609bb..dfc001c 100644 --- a/backend/src/app.internal.js +++ b/backend/src/app.internal.js @@ -9,16 +9,21 @@ import { internalAuthRoutes } from './routes/internal/auth.routes.js' import { internalConfigRoutes } from './routes/internal/config.routes.js' import { sessionManagementRoutes } from './routes/internal/session.routes.js' import { mitraActivityRoutes } from './routes/internal/mitra-activity.routes.js' +import { failedPairingsRoutes } from './routes/internal/failed-pairings.routes.js' import { errorHandler } from './plugins/error-handler.js' export const buildInternalApp = async () => { const app = Fastify({ logger: true }) - // CORS: control center origin must be allowed with credentials for httpOnly refresh cookie + // CORS: control center origin must be allowed with credentials for httpOnly refresh cookie. + // Methods must include PATCH/PUT/DELETE — settings mutations and other admin actions + // use those, and @fastify/cors's default allow-methods (GET,HEAD,POST) silently breaks + // them with a CORS preflight rejection in the browser (curl bypasses preflight). const ccOrigin = process.env.CC_ORIGIN await app.register(cors, { origin: ccOrigin ? ccOrigin.split(',').map((s) => s.trim()) : true, credentials: true, + methods: ['GET', 'HEAD', 'POST', 'PATCH', 'PUT', 'DELETE'], }) await app.register(cookie) await app.register(sensible) @@ -31,6 +36,7 @@ export const buildInternalApp = async () => { app.register(internalConfigRoutes, { prefix: '/internal/config' }) app.register(sessionManagementRoutes, { prefix: '/internal/sessions' }) app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' }) + app.register(failedPairingsRoutes, { prefix: '/internal/failed-pairings' }) return app } diff --git a/backend/src/app.public.js b/backend/src/app.public.js index 197435e..0d40551 100644 --- a/backend/src/app.public.js +++ b/backend/src/app.public.js @@ -8,6 +8,8 @@ import { sharedConfigRoutes } from './routes/public/shared.config.routes.js' import { mitraStatusRoutes } from './routes/public/mitra.status.routes.js' import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js' import { clientChatRoutes } from './routes/public/client.chat.routes.js' +import { clientPaymentRoutes } from './routes/public/client.payment.routes.js' +import { clientMitraAvailabilityRoutes } from './routes/public/client.mitra-availability.routes.js' import { sharedChatRoutes } from './routes/public/shared.chat.routes.js' import { errorHandler } from './plugins/error-handler.js' import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js' @@ -28,6 +30,8 @@ export const buildPublicApp = async () => { app.register(mitraStatusRoutes, { prefix: '/api/mitra/status' }) app.register(mitraChatRoutes, { prefix: '/api/mitra/chat-requests' }) app.register(clientChatRoutes, { prefix: '/api/client/chat' }) + app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' }) + app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' }) // WebSocket route (registered at app level, not prefixed) registerWebSocketRoute(app) diff --git a/backend/src/constants.js b/backend/src/constants.js index f00345c..32afabc 100644 --- a/backend/src/constants.js +++ b/backend/src/constants.js @@ -53,6 +53,48 @@ export const TransactionType = Object.freeze({ EXTENSION: 'extension', }) +// Payment session lifecycle +export const PaymentSessionStatus = Object.freeze({ + PENDING: 'pending', + CONFIRMED: 'confirmed', + CONSUMED: 'consumed', + FAILED_PAIRING: 'failed_pairing', + ABANDONED: 'abandoned', + EXPIRED: 'expired', +}) + +// Pairing failure cause tags +export const PairingFailureCause = Object.freeze({ + NO_MITRA_AVAILABLE: 'no_mitra_available', + ALL_MITRAS_REJECTED: 'all_mitras_rejected', + TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline', + TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected', + TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout', + PAYMENT_SESSION_EXPIRED: 'payment_session_expired', + CUSTOMER_CANCELLED: 'customer_cancelled', + EXTENSION_REJECTED: 'extension_rejected', + EXTENSION_SAFEGUARD_TRIPPED: 'extension_safeguard_tripped', +}) + +// Operator actions on failed-pairing rows +export const PairingFailureOperatorAction = Object.freeze({ + REFUNDED: 'refunded', + CREDITED: 'credited', + NO_ACTION: 'no_action', +}) + +// Default action when extension request times out (configurable) +export const ExtensionTimeoutAction = Object.freeze({ + AUTO_APPROVE: 'auto_approve', + AUTO_REJECT: 'auto_reject', +}) + +// Pairing request type — distinguishes general blast from targeted "Curhat lagi" +export const PairingRequestType = Object.freeze({ + GENERAL: 'general', + RETURNING: 'returning', +}) + // Who ended a session export const EndedBy = Object.freeze({ SYSTEM: 'system', @@ -116,6 +158,16 @@ export const WsMessage = Object.freeze({ EXTENSION_REQUEST: 'extension_request', EXTENSION_RESPONSE: 'extension_response', + // Returning-chat + RETURNING_CHAT_TIMEOUT: 'returning_chat_timeout', + RETURNING_CHAT_REJECTED: 'returning_chat_rejected', + + // Sent when the customer is sitting in a searching/waiting state and the server terminates + // the payment session out from under them (general blast exhausts, payment expires mid-search, + // etc). NOT used for intermediate failures like targeted reject/timeout — those use + // RETURNING_CHAT_* events instead. + PAIRING_FAILED: 'pairing_failed', + // Topic sensitivity SESSION_TOPIC_UPDATED: 'session_topic_updated', diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index 1979976..76b4464 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -120,6 +120,13 @@ const migrate = async () => { ON chat_sessions (status) ` + // Composite index for the per-mitra active-session count subquery used by the + // 5s availability poll and the per-blast capacity filter. + await sql` + CREATE INDEX IF NOT EXISTS idx_chat_sessions_mitra_status + ON chat_sessions (mitra_id, status) + ` + await sql` CREATE TABLE IF NOT EXISTS chat_request_notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -413,6 +420,135 @@ const migrate = async () => { ON CONFLICT (key) DO NOTHING ` + // --- Phase 3.7: Paid Pairing Flow + Returning-Chat + Extension Flip --- + + // payment_sessions: customer-initiated payment intents (mocked) that gate pairing + await sql` + CREATE TABLE IF NOT EXISTS payment_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + customer_id UUID NOT NULL REFERENCES customers(id), + amount INTEGER NOT NULL DEFAULT 0, + duration_minutes INTEGER NOT NULL, + is_free_trial BOOLEAN NOT NULL DEFAULT false, + is_extension BOOLEAN NOT NULL DEFAULT false, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','confirmed','consumed','failed_pairing','abandoned','expired')), + targeted_mitra_id UUID REFERENCES mitras(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + confirmed_at TIMESTAMPTZ, + consumed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL + ) + ` + + await sql` + CREATE INDEX IF NOT EXISTS idx_payment_sessions_customer + ON payment_sessions (customer_id) + ` + + await sql` + CREATE INDEX IF NOT EXISTS idx_payment_sessions_status_expires + ON payment_sessions (status, expires_at) + ` + + // pairing_failures: cause-tagged audit rows for confirmed payments that did not yield a chat + await sql` + CREATE TABLE IF NOT EXISTS pairing_failures ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_session_id UUID NOT NULL REFERENCES payment_sessions(id) ON DELETE CASCADE, + customer_id UUID NOT NULL REFERENCES customers(id), + targeted_mitra_id UUID REFERENCES mitras(id), + cause_tag TEXT NOT NULL + CHECK (cause_tag IN ( + 'no_mitra_available', + 'all_mitras_rejected', + 'targeted_mitra_offline', + 'targeted_mitra_rejected', + 'targeted_mitra_timeout', + 'payment_session_expired', + 'customer_cancelled' + )), + amount INTEGER NOT NULL, + operator_action TEXT + CHECK (operator_action IS NULL OR operator_action IN ('refunded','credited','no_action')), + actioned_by UUID REFERENCES control_center_users(id), + actioned_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + ` + + // Phase 3.7 follow-up: extend the pairing_failures.cause_tag CHECK to include the two + // extension-specific tags. Idempotent: drop the existing check (whatever its exact name) and + // re-add the expanded list. Postgres auto-names CHECK constraints `__check` + // unless we name them explicitly; the original DDL above relies on that default. + await sql` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'pairing_failures'::regclass + AND conname = 'pairing_failures_cause_tag_check' + ) THEN + ALTER TABLE pairing_failures DROP CONSTRAINT pairing_failures_cause_tag_check; + END IF; + ALTER TABLE pairing_failures ADD CONSTRAINT pairing_failures_cause_tag_check + CHECK (cause_tag IN ( + 'no_mitra_available', + 'all_mitras_rejected', + 'targeted_mitra_offline', + 'targeted_mitra_rejected', + 'targeted_mitra_timeout', + 'payment_session_expired', + 'customer_cancelled', + 'extension_rejected', + 'extension_safeguard_tripped' + )); + END + $$ + ` + + await sql` + CREATE INDEX IF NOT EXISTS idx_pairing_failures_created_at + ON pairing_failures (created_at DESC) + ` + + await sql` + CREATE INDEX IF NOT EXISTS idx_pairing_failures_cause + ON pairing_failures (cause_tag) + ` + + await sql` + CREATE INDEX IF NOT EXISTS idx_pairing_failures_unactioned + ON pairing_failures (created_at DESC) WHERE operator_action IS NULL + ` + + // chat_sessions FK to payment_sessions (nullable for backward compat with pre-3.7 rows) + await sql` + ALTER TABLE chat_sessions + ADD COLUMN IF NOT EXISTS payment_session_id UUID REFERENCES payment_sessions(id) + ` + + await sql` + CREATE INDEX IF NOT EXISTS idx_chat_sessions_payment + ON chat_sessions (payment_session_id) + ` + + // session_extensions FK to payment_sessions (extensions also have their own payment session) + await sql` + ALTER TABLE session_extensions + ADD COLUMN IF NOT EXISTS payment_session_id UUID REFERENCES payment_sessions(id) + ` + + // Phase 3.7 config keys (idempotent — existing dev DBs need a manual update for extension_timeout_seconds → 10) + await sql` + INSERT INTO app_config (key, value) VALUES + ('payment_session_timeout_minutes', '{"value": 20}'), + ('returning_chat_confirmation_timeout_seconds', '{"value": 20}'), + ('extension_default_action_on_timeout', '{"value": "auto_approve"}'), + ('pairing_blast_timeout_seconds', '{"value": 60}') + ON CONFLICT (key) DO NOTHING + ` + console.log('Migration complete.') await sql.end() } diff --git a/backend/src/routes/internal/config.routes.js b/backend/src/routes/internal/config.routes.js index e512fe2..782b8a2 100644 --- a/backend/src/routes/internal/config.routes.js +++ b/backend/src/routes/internal/config.routes.js @@ -1,6 +1,7 @@ import { authenticate, requirePermission } from '../../plugins/auth.js' import { getCcUserById } from '../../services/cc-user.service.js' -import { UserType } from '../../constants.js' +import { UserType, ExtensionTimeoutAction } from '../../constants.js' +import { publish } from '../../plugins/valkey.js' import { getAnonymityConfig, setAnonymityConfig, getMaxCustomersPerMitra, setMaxCustomersPerMitra, @@ -9,6 +10,10 @@ import { getEarlyEndConfig, setEarlyEndConfig, getMitraPingConfig, setMitraPingConfig, getSensitivityConfig, setSensitivityConfig, + getPaymentSessionTimeoutMinutes, setPaymentSessionTimeoutMinutes, + getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds, + getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout, + getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds, } from '../../services/config.service.js' const attachCcUser = async (request, reply) => { @@ -21,6 +26,17 @@ const attachCcUser = async (request, reply) => { } export const internalConfigRoutes = async (app) => { + // Cross-instance config invalidate. Local mutators (e.g. setMaxCustomersPerMitra) bust + // their own in-process caches directly; this publish fans out to other instances. + const publishConfigInvalidate = async (key) => { + try { + await publish('config:invalidate', { key, ts: Date.now() }) + } catch (err) { + // Valkey may be down in dev. Local invalidate already happened. + app.log.warn({ err, key }, 'config invalidate publish failed') + } + } + app.get('/anonymity', { preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], }, async (request, reply) => { @@ -54,6 +70,7 @@ export const internalConfigRoutes = async (app) => { return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'max_customers_per_mitra must be a positive number' } }) } const config = await setMaxCustomersPerMitra(max_customers_per_mitra) + await publishConfigInvalidate('max_customers_per_mitra') return reply.send({ success: true, data: config }) }) @@ -178,4 +195,93 @@ export const internalConfigRoutes = async (app) => { await sql`UPDATE app_config SET value = ${sql.json({ tiers })}, updated_at = NOW() WHERE key = 'price_tiers'` return reply.send({ success: true, data: tiers }) }) + + // --- Paid pairing flow + extension flip --- + + app.get('/payment-session-timeout', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], + }, async (_req, reply) => { + return reply.send({ success: true, data: await getPaymentSessionTimeoutMinutes() }) + }) + + app.patch('/payment-session-timeout', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], + }, async (request, reply) => { + const { payment_session_timeout_minutes } = request.body ?? {} + if (typeof payment_session_timeout_minutes !== 'number' || payment_session_timeout_minutes < 1) { + return reply.code(422).send({ + success: false, + error: { code: 'VALIDATION_ERROR', message: 'payment_session_timeout_minutes must be a number >= 1' }, + }) + } + const config = await setPaymentSessionTimeoutMinutes(payment_session_timeout_minutes) + await publishConfigInvalidate('payment_session_timeout_minutes') + return reply.send({ success: true, data: config }) + }) + + app.get('/returning-chat-timeout', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], + }, async (_req, reply) => { + return reply.send({ success: true, data: await getReturningChatConfirmationTimeoutSeconds() }) + }) + + app.patch('/returning-chat-timeout', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], + }, async (request, reply) => { + const { returning_chat_confirmation_timeout_seconds } = request.body ?? {} + if (typeof returning_chat_confirmation_timeout_seconds !== 'number' || returning_chat_confirmation_timeout_seconds < 5) { + return reply.code(422).send({ + success: false, + error: { code: 'VALIDATION_ERROR', message: 'returning_chat_confirmation_timeout_seconds must be a number >= 5' }, + }) + } + const config = await setReturningChatConfirmationTimeoutSeconds(returning_chat_confirmation_timeout_seconds) + await publishConfigInvalidate('returning_chat_confirmation_timeout_seconds') + return reply.send({ success: true, data: config }) + }) + + app.get('/extension-default-action', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], + }, async (_req, reply) => { + return reply.send({ success: true, data: await getExtensionDefaultActionOnTimeout() }) + }) + + app.patch('/extension-default-action', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], + }, async (request, reply) => { + const { extension_default_action_on_timeout } = request.body ?? {} + if (!Object.values(ExtensionTimeoutAction).includes(extension_default_action_on_timeout)) { + return reply.code(422).send({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: `extension_default_action_on_timeout must be one of: ${Object.values(ExtensionTimeoutAction).join(', ')}`, + }, + }) + } + const config = await setExtensionDefaultActionOnTimeout(extension_default_action_on_timeout) + await publishConfigInvalidate('extension_default_action_on_timeout') + return reply.send({ success: true, data: config }) + }) + + app.get('/pairing-blast-timeout', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], + }, async (_req, reply) => { + return reply.send({ success: true, data: await getPairingBlastTimeoutSeconds() }) + }) + + app.patch('/pairing-blast-timeout', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], + }, async (request, reply) => { + const { pairing_blast_timeout_seconds } = request.body ?? {} + if (typeof pairing_blast_timeout_seconds !== 'number' || pairing_blast_timeout_seconds < 5) { + return reply.code(422).send({ + success: false, + error: { code: 'VALIDATION_ERROR', message: 'pairing_blast_timeout_seconds must be a number >= 5' }, + }) + } + const config = await setPairingBlastTimeoutSeconds(pairing_blast_timeout_seconds) + await publishConfigInvalidate('pairing_blast_timeout_seconds') + return reply.send({ success: true, data: config }) + }) } diff --git a/backend/src/routes/internal/failed-pairings.routes.js b/backend/src/routes/internal/failed-pairings.routes.js new file mode 100644 index 0000000..6098f21 --- /dev/null +++ b/backend/src/routes/internal/failed-pairings.routes.js @@ -0,0 +1,69 @@ +import { authenticate, requirePermission } from '../../plugins/auth.js' +import { getCcUserById } from '../../services/cc-user.service.js' +import { listFailures, setOperatorAction } from '../../services/pairing-failure.service.js' +import { UserType, PairingFailureCause, PairingFailureOperatorAction } from '../../constants.js' + +const attachCcUser = async (request, reply) => { + if (request.auth?.userType !== UserType.CC_USER) { + return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } }) + } + const user = await getCcUserById(request.auth.userId) + if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } }) + request.ccUser = user +} + +const VALID_CAUSE_TAGS = new Set(Object.values(PairingFailureCause)) +const VALID_ACTIONS = new Set(Object.values(PairingFailureOperatorAction)) + +/** + * Control-center "Failed Pairings" review screen backend. + * + * GET /internal/failed-pairings + * POST /internal/failed-pairings/:id/action + */ +export const failedPairingsRoutes = async (app) => { + app.get('/', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], + }, async (request, reply) => { + const { cause_tags, date_from, date_to, limit = 50, offset = 0 } = request.query ?? {} + + // cause_tags can arrive as a single string (?cause_tags=foo) or an array + // (?cause_tags=foo&cause_tags=bar). Normalize and validate. + let causeTagsArr = null + if (cause_tags !== undefined) { + causeTagsArr = Array.isArray(cause_tags) ? cause_tags : [cause_tags] + for (const tag of causeTagsArr) { + if (!VALID_CAUSE_TAGS.has(tag)) { + return reply.code(422).send({ + success: false, + error: { code: 'VALIDATION_ERROR', message: `Unknown cause_tag: ${tag}` }, + }) + } + } + } + + const result = await listFailures({ + causeTags: causeTagsArr, + dateFrom: date_from || null, + dateTo: date_to || null, + limit: Number(limit) || 50, + offset: Number(offset) || 0, + }) + + return reply.send({ success: true, data: result }) + }) + + app.post('/:id/action', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], + }, async (request, reply) => { + const { action } = request.body ?? {} + if (!VALID_ACTIONS.has(action)) { + return reply.code(422).send({ + success: false, + error: { code: 'VALIDATION_ERROR', message: `action must be one of: ${[...VALID_ACTIONS].join(', ')}` }, + }) + } + const updated = await setOperatorAction(request.params.id, request.ccUser.id, action) + return reply.send({ success: true, data: updated }) + }) +} diff --git a/backend/src/routes/public/client.chat.routes.js b/backend/src/routes/public/client.chat.routes.js index ab41fb3..5d16f13 100644 --- a/backend/src/routes/public/client.chat.routes.js +++ b/backend/src/routes/public/client.chat.routes.js @@ -1,8 +1,19 @@ import { authenticate } from '../../plugins/auth.js' import { getCustomerById } from '../../services/customer.service.js' -import { createPairingRequest, cancelPairingRequest } from '../../services/pairing.service.js' -import { getActiveSessionByCustomer, getActiveSessionByCustomerWithUnread, endSession, getCustomerHistory } from '../../services/session.service.js' -import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js' +import { + createPairingRequest, + createTargetedPairingRequest, + cancelPairingRequest, + cancelPaymentSearch, + fallbackToGeneralBlast, +} from '../../services/pairing.service.js' +import { + getActiveSessionByCustomer, + getActiveSessionByCustomerWithUnread, + endSession, + getCustomerHistory, +} from '../../services/session.service.js' +import { getPricingForCustomer } from '../../services/pricing.service.js' import { requestExtension } from '../../services/extension.service.js' import { EndedBy, TopicSensitivity, UserType } from '../../constants.js' @@ -30,8 +41,21 @@ export const clientChatRoutes = async (app) => { return reply.send({ success: true, data: pricing }) }) + /** + * Start a general-blast pairing search. + * + * Body MUST include `payment_session_id` (a confirmed payment_session owned by the caller). + * Pricing/duration/free-trial values are sourced from the payment session, NOT from the client. + */ app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { - const { duration_minutes, price, is_free_trial, topic_sensitivity } = request.body || {} + const { payment_session_id, topic_sensitivity } = request.body ?? {} + + if (!payment_session_id) { + return reply.code(400).send({ + success: false, + error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' }, + }) + } if (topic_sensitivity !== TopicSensitivity.REGULAR && topic_sensitivity !== TopicSensitivity.SENSITIVE) { return reply.code(400).send({ @@ -40,43 +64,91 @@ export const clientChatRoutes = async (app) => { }) } - // Validate selection - if (is_free_trial) { - const eligible = await isCustomerEligibleForFreeTrial(request.customer.id) - if (!eligible) { - return reply.code(403).send({ - success: false, - error: { code: 'FREE_TRIAL_INELIGIBLE', message: 'Not eligible for free trial' }, - }) - } - const freeTrial = await getFreeTrial() - const session = await createPairingRequest(request.customer.id, { - duration_minutes: freeTrial.duration_minutes, - price: 0, - is_free_trial: true, - topic_sensitivity, - }) - return reply.code(201).send({ success: true, data: session }) - } - - if (!duration_minutes || price === undefined) { - return reply.code(400).send({ - success: false, - error: { code: 'BAD_REQUEST', message: 'duration_minutes and price are required' }, - }) - } - - if (!(await isValidTier(duration_minutes, price))) { - return reply.code(400).send({ - success: false, - error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' }, - }) - } - - const session = await createPairingRequest(request.customer.id, { duration_minutes, price, is_free_trial: false, topic_sensitivity }) + const session = await createPairingRequest(request.customer.id, { + paymentSessionId: payment_session_id, + topic_sensitivity, + }) return reply.code(201).send({ success: true, data: session }) }) + /** + * Start a targeted "Curhat lagi" pairing request. + * + * Body: { payment_session_id, mitra_id, topic_sensitivity? } + * Returns 409 with reason: 'targeted_mitra_offline' if the targeted mitra is unreachable + * or at capacity. The payment session stays `confirmed` in that case so the customer + * can fall back to general blast on the same payment. + */ + app.post('/chat-requests/returning', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + const { payment_session_id, mitra_id, topic_sensitivity } = request.body ?? {} + + if (!payment_session_id) { + return reply.code(400).send({ + success: false, + error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' }, + }) + } + if (!mitra_id) { + return reply.code(400).send({ + success: false, + error: { code: 'BAD_REQUEST', message: 'mitra_id is required' }, + }) + } + + const resolvedTopic = (topic_sensitivity === TopicSensitivity.SENSITIVE) + ? TopicSensitivity.SENSITIVE + : TopicSensitivity.REGULAR + + const session = await createTargetedPairingRequest(request.customer.id, { + paymentSessionId: payment_session_id, + targetedMitraId: mitra_id, + topic_sensitivity: resolvedTopic, + }) + return reply.code(201).send({ success: true, data: session }) + }) + + /** + * Customer-initiated cancel during searching/waiting. + * + * Body: { payment_session_id } + * Terminal — payment session moves to failed_pairing with cause = customer_cancelled. + */ + app.post('/chat-requests/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + const { payment_session_id } = request.body ?? {} + if (!payment_session_id) { + return reply.code(400).send({ + success: false, + error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' }, + }) + } + const result = await cancelPaymentSearch(payment_session_id, request.customer.id) + return reply.send({ success: true, data: result }) + }) + + /** + * After a returning-chat fail, customer taps "Chat dengan bestie lain". + * Reuses the same payment_session_id (no double-charge), runs general blast. + */ + app.post('/chat-requests/:paymentSessionId/fallback-to-blast', { + preHandler: [authenticate, resolveCustomer], + }, async (request, reply) => { + const { topic_sensitivity } = request.body ?? {} + const resolvedTopic = (topic_sensitivity === TopicSensitivity.SENSITIVE) + ? TopicSensitivity.SENSITIVE + : TopicSensitivity.REGULAR + const session = await fallbackToGeneralBlast( + request.params.paymentSessionId, + request.customer.id, + { topic_sensitivity: resolvedTopic }, + ) + return reply.code(201).send({ success: true, data: session }) + }) + + /** + * Cancel-by-session-id retained for in-flight chat_session cancels (e.g. cancel + * during the 20s targeted wait after a chat_session has been created). Customer cancel + * via payment_session_id should prefer POST /chat-requests/cancel above. + */ app.post('/request/:sessionId/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { const session = await cancelPairingRequest(request.params.sessionId, request.customer.id) return reply.send({ success: true, data: session }) @@ -97,16 +169,32 @@ export const clientChatRoutes = async (app) => { return reply.send({ success: true, data: session }) }) - // Request session extension + /** + * Extension request REQUIRES `extension_payment_session_id`. + * The payment session must be is_extension=true and is_free_trial=false. + * Pricing/duration come from the payment session via the extension service. + */ app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { - const { duration_minutes, price } = request.body || {} + const { duration_minutes, price, extension_payment_session_id } = request.body ?? {} + + if (!extension_payment_session_id) { + return reply.code(400).send({ + success: false, + error: { code: 'BAD_REQUEST', message: 'extension_payment_session_id is required' }, + }) + } if (!duration_minutes || price === undefined) { return reply.code(400).send({ success: false, error: { code: 'BAD_REQUEST', message: 'duration_minutes and price are required' }, }) } - const extension = await requestExtension(request.params.sessionId, request.customer.id, { duration_minutes, price }) + + const extension = await requestExtension(request.params.sessionId, request.customer.id, { + duration_minutes, + price, + extension_payment_session_id, + }) return reply.send({ success: true, data: extension }) }) diff --git a/backend/src/routes/public/client.mitra-availability.routes.js b/backend/src/routes/public/client.mitra-availability.routes.js new file mode 100644 index 0000000..5034d9b --- /dev/null +++ b/backend/src/routes/public/client.mitra-availability.routes.js @@ -0,0 +1,27 @@ +import { authenticate } from '../../plugins/auth.js' +import { countAvailableMitrasFromCache } from '../../services/mitra-status.service.js' +import { UserType } from '../../constants.js' + +/** + * Customer-home availability poll. + * + * GET /api/client/mitra-availability → 200 { available: bool, count?: number } + * + * Hot endpoint by design — polled every 5s per active customer while their home is + * foregrounded. Backed by a 10s in-memory cache (see mitra-status.service.js) so DB load + * stays bounded regardless of poller count. No rate limit by intent. + * + * `count` is included for CC/debug; the customer UI must read only `available`. + */ +export const clientMitraAvailabilityRoutes = async (app) => { + app.get('/', { preHandler: [authenticate] }, async (request, reply) => { + if (request.auth?.userType !== UserType.CUSTOMER) { + return reply.code(403).send({ + success: false, + error: { code: 'FORBIDDEN', message: 'Customer account required' }, + }) + } + const result = await countAvailableMitrasFromCache() + return reply.send({ success: true, data: result }) + }) +} diff --git a/backend/src/routes/public/client.payment.routes.js b/backend/src/routes/public/client.payment.routes.js new file mode 100644 index 0000000..5bdac0c --- /dev/null +++ b/backend/src/routes/public/client.payment.routes.js @@ -0,0 +1,155 @@ +import { authenticate } from '../../plugins/auth.js' +import { getCustomerById } from '../../services/customer.service.js' +import { + createPaymentSession, + confirmPaymentSession, + abandonPaymentSession, + getPaymentSession, +} from '../../services/payment.service.js' +import { + isCustomerEligibleForFreeTrial, + isValidTier, + getPriceTiers, +} from '../../services/pricing.service.js' +import { UserType } from '../../constants.js' + +const resolveCustomer = async (request, reply) => { + if (request.auth?.userType !== UserType.CUSTOMER) { + return reply.code(403).send({ + success: false, + error: { code: 'FORBIDDEN', message: 'Customer account required' }, + }) + } + const customer = await getCustomerById(request.auth.userId) + if (!customer) { + return reply.code(404).send({ + success: false, + error: { code: 'ACCOUNT_NOT_FOUND', message: 'Customer account not found' }, + }) + } + request.customer = customer +} + +/** + * Payment session lifecycle (mocked — no Xendit yet). + * + * POST /api/client/payment-sessions + * POST /api/client/payment-sessions/:id/confirm + * POST /api/client/payment-sessions/:id/cancel + * GET /api/client/payment-sessions/:id + */ +export const clientPaymentRoutes = async (app) => { + // Create a payment session (status = pending). Free-trial logic is server-side: if the + // customer is eligible AND this is NOT an extension, amount is forced to 0 and + // is_free_trial = true regardless of what the client passes. + app.post('/', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + const { + duration_minutes, + targeted_mitra_id = null, + is_extension = false, + } = request.body ?? {} + + if (typeof duration_minutes !== 'number' || duration_minutes <= 0) { + return reply.code(422).send({ + success: false, + error: { code: 'VALIDATION_ERROR', message: 'duration_minutes must be a positive number' }, + }) + } + + // Free trial: never for extensions. + let isFreeTrial = false + let amount + + if (!is_extension) { + const eligible = await isCustomerEligibleForFreeTrial(request.customer.id) + if (eligible) { + isFreeTrial = true + amount = 0 + } + } + + if (!isFreeTrial) { + // Resolve amount from the price tiers (duration-keyed). The client passes + // duration_minutes; we look up the matching tier to get the canonical price. + const tiers = await getPriceTiers() + const tier = tiers.find((t) => t.duration_minutes === duration_minutes) + if (!tier) { + return reply.code(400).send({ + success: false, + error: { code: 'INVALID_TIER', message: 'No price tier matches the requested duration' }, + }) + } + amount = tier.price + // Sanity check (defense-in-depth) — duration+price should match a known tier. + if (!(await isValidTier(duration_minutes, amount))) { + return reply.code(400).send({ + success: false, + error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' }, + }) + } + } + + const session = await createPaymentSession({ + customerId: request.customer.id, + durationMinutes: duration_minutes, + amount, + isFreeTrial, + isExtension: Boolean(is_extension), + targetedMitraId: targeted_mitra_id || null, + }) + + return reply.code(201).send({ + success: true, + data: { + id: session.id, + amount: session.amount, + duration_minutes: session.duration_minutes, + is_free_trial: session.is_free_trial, + is_extension: session.is_extension, + targeted_mitra_id: session.targeted_mitra_id, + expires_at: session.expires_at, + status: session.status, + }, + }) + }) + + app.post('/:id/confirm', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + const session = await confirmPaymentSession(request.params.id, request.customer.id) + return reply.send({ + success: true, + data: { + id: session.id, + status: session.status, + confirmed_at: session.confirmed_at, + }, + }) + }) + + app.post('/:id/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + const session = await abandonPaymentSession(request.params.id, request.customer.id) + return reply.send({ + success: true, + data: { + id: session.id, + status: session.status, + }, + }) + }) + + app.get('/:id', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + const session = await getPaymentSession(request.params.id) + if (!session) { + return reply.code(404).send({ + success: false, + error: { code: 'NOT_FOUND', message: 'Payment session not found' }, + }) + } + if (session.customer_id !== request.customer.id) { + return reply.code(403).send({ + success: false, + error: { code: 'FORBIDDEN', message: 'Payment session does not belong to this customer' }, + }) + } + return reply.send({ success: true, data: session }) + }) +} diff --git a/backend/src/server.js b/backend/src/server.js index 62e1880..1b130c6 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -4,6 +4,7 @@ import { buildInternalApp } from './app.internal.js' import { autoOfflineStaleMitras } from './services/mitra-status.service.js' import { initFirebase } from './plugins/firebase.js' import { restoreActiveTimers } from './services/session-timer.service.js' +import { expireStalePaymentSessions } from './services/payment.service.js' const PUBLIC_PORT = process.env.PUBLIC_PORT || 3000 const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001 @@ -32,6 +33,21 @@ const start = async () => { console.error('Auto-offline check failed:', err) } }, 30_000) + + // Expire stale payment_sessions (every 60s). + // Pending past expires_at → expired (no failure row). Confirmed-but-stale → failed_pairing + // with cause = payment_session_expired (writes a pairing_failures row). + // Single-instance for now; Valkey keyspace notifications when we go multi-instance. + setInterval(async () => { + try { + const result = await expireStalePaymentSessions() + if (result.expired > 0 || result.failed > 0) { + console.log(`Payment sweeper: ${result.expired} expired, ${result.failed} failed_pairing`) + } + } catch (err) { + console.error('Payment session sweeper failed:', err) + } + }, 60_000) } start().catch((err) => { diff --git a/backend/src/services/config.service.js b/backend/src/services/config.service.js index 3cbfcc1..2531de3 100644 --- a/backend/src/services/config.service.js +++ b/backend/src/services/config.service.js @@ -1,4 +1,5 @@ import { getDb } from '../db/client.js' +import { ExtensionTimeoutAction } from '../constants.js' const sql = getDb() @@ -27,6 +28,10 @@ export const setMaxCustomersPerMitra = async (value) => { VALUES ('max_customers_per_mitra', ${sql.json({ value })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` + // Capacity changed → drop cached availability snapshot. + // Imported lazily to avoid a circular import (mitra-status.service uses config). + const { invalidateAvailabilityCache } = await import('./mitra-status.service.js') + invalidateAvailabilityCache() return { max_customers_per_mitra: value } } @@ -61,7 +66,8 @@ export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => { export const getExtensionTimeoutConfig = async () => { const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_timeout_seconds'` - return { extension_timeout_seconds: row?.value?.value ?? 60 } + // Default 10s pairs with the auto-approve-on-timeout flow; raise this if you change the policy to auto-reject. + return { extension_timeout_seconds: row?.value?.value ?? 10 } } export const setExtensionTimeoutConfig = async (seconds) => { @@ -222,3 +228,61 @@ export const setCcLoginLockoutConfig = async ({ max_attempts, lockout_minutes }) } return getCcLoginLockoutConfig() } + +// --- Paid Pairing Flow + Returning-Chat + Extension Flip --- + +export const getPaymentSessionTimeoutMinutes = async () => { + const [row] = await sql`SELECT value FROM app_config WHERE key = 'payment_session_timeout_minutes'` + return { payment_session_timeout_minutes: row?.value?.value ?? 20 } +} + +export const setPaymentSessionTimeoutMinutes = async (value) => { + await sql` + INSERT INTO app_config (key, value, updated_at) + VALUES ('payment_session_timeout_minutes', ${sql.json({ value })}, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + ` + return { payment_session_timeout_minutes: value } +} + +export const getReturningChatConfirmationTimeoutSeconds = async () => { + const [row] = await sql`SELECT value FROM app_config WHERE key = 'returning_chat_confirmation_timeout_seconds'` + return { returning_chat_confirmation_timeout_seconds: row?.value?.value ?? 20 } +} + +export const setReturningChatConfirmationTimeoutSeconds = async (value) => { + await sql` + INSERT INTO app_config (key, value, updated_at) + VALUES ('returning_chat_confirmation_timeout_seconds', ${sql.json({ value })}, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + ` + return { returning_chat_confirmation_timeout_seconds: value } +} + +export const getExtensionDefaultActionOnTimeout = async () => { + const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_default_action_on_timeout'` + return { extension_default_action_on_timeout: row?.value?.value ?? ExtensionTimeoutAction.AUTO_APPROVE } +} + +export const setExtensionDefaultActionOnTimeout = async (value) => { + await sql` + INSERT INTO app_config (key, value, updated_at) + VALUES ('extension_default_action_on_timeout', ${sql.json({ value })}, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + ` + return { extension_default_action_on_timeout: value } +} + +export const getPairingBlastTimeoutSeconds = async () => { + const [row] = await sql`SELECT value FROM app_config WHERE key = 'pairing_blast_timeout_seconds'` + return { pairing_blast_timeout_seconds: row?.value?.value ?? 60 } +} + +export const setPairingBlastTimeoutSeconds = async (value) => { + await sql` + INSERT INTO app_config (key, value, updated_at) + VALUES ('pairing_blast_timeout_seconds', ${sql.json({ value })}, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + ` + return { pairing_blast_timeout_seconds: value } +} diff --git a/backend/src/services/extension.service.js b/backend/src/services/extension.service.js index a2c7715..beff0bf 100644 --- a/backend/src/services/extension.service.js +++ b/backend/src/services/extension.service.js @@ -1,20 +1,53 @@ import { getDb } from '../db/client.js' -import { publish } from '../plugins/valkey.js' -import { sendToSessionParticipant } from '../plugins/websocket.js' +import { sendToSessionParticipant, isUserOnlineWs } from '../plugins/websocket.js' import { extendSessionTimer, clearClosureGraceTimer, startClosureGraceTimer } from './session-timer.service.js' -import { UserType, SessionStatus, ExtensionStatus, TransactionType, WsMessage } from '../constants.js' +import { isMitraReachable } from './mitra-status.service.js' +import { consumePaymentSession, failPaymentSession, getPaymentSession } from './payment.service.js' +import { + getExtensionTimeoutConfig, + getExtensionDefaultActionOnTimeout, +} from './config.service.js' +import { + UserType, + SessionStatus, + ExtensionStatus, + TransactionType, + WsMessage, + PaymentSessionStatus, + ExtensionTimeoutAction, + PairingFailureCause, +} from '../constants.js' const sql = getDb() // Extension timeout map: extensionId → timeoutId const extensionTimeouts = new Map() -const getExtensionTimeout = async () => { - const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_timeout_seconds'` - return (row?.value?.value ?? 60) * 1000 // Convert to ms +const getExtensionTimeoutMs = async () => { + const { extension_timeout_seconds } = await getExtensionTimeoutConfig() + return extension_timeout_seconds * 1000 } -export const requestExtension = async (sessionId, customerId, { duration_minutes, price }) => { +const getExtensionTimeoutAction = async () => { + const { extension_default_action_on_timeout } = await getExtensionDefaultActionOnTimeout() + return Object.values(ExtensionTimeoutAction).includes(extension_default_action_on_timeout) + ? extension_default_action_on_timeout + : ExtensionTimeoutAction.AUTO_APPROVE +} + +/** + * Customer requests an extension. + * + * `extension_payment_session_id` is REQUIRED. The payment session must: + * - belong to this customer + * - be in `confirmed` status (not yet consumed) + * - have `is_extension = true` + * - have `is_free_trial = false` (extensions never use free trial) + * + * The payment session is NOT consumed at request time. It is consumed at approval moment + * (mitra explicit accept OR auto-approve fires). + */ +export const requestExtension = async (sessionId, customerId, { duration_minutes, price, extension_payment_session_id }) => { // Verify session belongs to customer and is in an extendable state const [session] = await sql` SELECT id, customer_id, mitra_id, status, topic_sensitivity FROM chat_sessions @@ -25,16 +58,51 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes throw Object.assign(new Error('Session not found or already ended'), { code: 'SESSION_NOT_ACTIVE', statusCode: 409 }) } - // Create extension record + // Validate extension payment session + if (!extension_payment_session_id) { + throw Object.assign(new Error('extension_payment_session_id is required'), { + code: 'VALIDATION_ERROR', statusCode: 422, + }) + } + const paySession = await getPaymentSession(extension_payment_session_id) + if (!paySession) { + throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) + } + if (paySession.customer_id !== customerId) { + throw Object.assign(new Error('Payment session does not belong to this customer'), { + code: 'FORBIDDEN', statusCode: 403, + }) + } + if (paySession.status !== PaymentSessionStatus.CONFIRMED) { + throw Object.assign(new Error(`Payment session is ${paySession.status}, must be confirmed`), { + code: 'INVALID_STATE', statusCode: 409, + }) + } + if (!paySession.is_extension) { + throw Object.assign(new Error('Payment session is not flagged as an extension payment'), { + code: 'INVALID_STATE', statusCode: 409, + }) + } + if (paySession.is_free_trial) { + throw Object.assign(new Error('Free trial is not available for extensions'), { + code: 'FREE_TRIAL_NOT_ALLOWED', statusCode: 400, + }) + } + + // Create extension record (linked to its payment session) const [extension] = await sql` - INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status) - VALUES (${sessionId}, ${duration_minutes}, ${price}, ${ExtensionStatus.PENDING}) - RETURNING id, session_id, requested_duration_minutes, requested_price, status, requested_at + INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status, payment_session_id) + VALUES (${sessionId}, ${duration_minutes}, ${price}, ${ExtensionStatus.PENDING}, ${extension_payment_session_id}) + RETURNING id, session_id, requested_duration_minutes, requested_price, status, requested_at, payment_session_id ` // Pause the session await sql`UPDATE chat_sessions SET status = ${SessionStatus.EXTENDING} WHERE id = ${sessionId}` + // Resolve timeout once so we can both surface it in the WS payload and start the server-side timer. + const timeoutMs = await getExtensionTimeoutMs() + const timeoutSeconds = Math.round(timeoutMs / 1000) + // Notify mitra — include current topic sensitivity so UI can highlight sendToSessionParticipant(sessionId, UserType.MITRA, { type: WsMessage.EXTENSION_REQUEST, @@ -43,6 +111,7 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes duration_minutes, price, topic_sensitivity: session.topic_sensitivity, + timeout_seconds: timeoutSeconds, }) // Notify customer that chat is paused @@ -51,13 +120,12 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes session_id: sessionId, reason: 'extension_pending', }) - - // Start timeout - const timeoutMs = await getExtensionTimeout() const timeoutId = setTimeout(async () => { try { - await timeoutExtension(extension.id, sessionId) - } catch (_) {} + await timeoutExtension(extension.id, sessionId, session.mitra_id) + } catch (err) { + console.error('timeoutExtension failed', { extensionId: extension.id, sessionId, err }) + } }, timeoutMs) extensionTimeouts.set(extension.id, timeoutId) @@ -73,16 +141,25 @@ export const respondToExtension = async (extensionId, sessionId, mitraId, accept throw Object.assign(new Error('Session not found'), { code: 'FORBIDDEN', statusCode: 403 }) } + return finalizeExtension(extensionId, sessionId, accepted, /* viaTimeout */ false) +} + +/** + * Internal: applies the accepted/rejected outcome. Used by both explicit response + * and the data-driven timeout path. + */ +const finalizeExtension = async (extensionId, sessionId, accepted, viaTimeout) => { const status = accepted ? ExtensionStatus.ACCEPTED : ExtensionStatus.REJECTED const [extension] = await sql` UPDATE session_extensions SET status = ${status}, responded_at = NOW() WHERE id = ${extensionId} AND session_id = ${sessionId} AND status = ${ExtensionStatus.PENDING} - RETURNING id, session_id, requested_duration_minutes, requested_price, status + RETURNING id, session_id, requested_duration_minutes, requested_price, status, payment_session_id ` if (!extension) { + if (viaTimeout) return null // race: already resolved before timer fired throw Object.assign(new Error('Extension not found or already resolved'), { code: 'EXTENSION_RESOLVED', statusCode: 409, }) @@ -96,6 +173,11 @@ export const respondToExtension = async (extensionId, sessionId, mitraId, accept } if (accepted) { + // Charge fires AT approval moment (explicit OR auto-approve). + if (extension.payment_session_id) { + await consumePaymentSession(extension.payment_session_id) + } + // Clear any pending grace timer from the previous expiry clearClosureGraceTimer(sessionId) @@ -117,6 +199,7 @@ export const respondToExtension = async (extensionId, sessionId, mitraId, accept type: WsMessage.EXTENSION_RESPONSE, accepted: true, duration_minutes: extension.requested_duration_minutes, + via_timeout: viaTimeout, }) sendToSessionParticipant(sessionId, UserType.CUSTOMER, { type: WsMessage.SESSION_RESUMED, @@ -127,12 +210,19 @@ export const respondToExtension = async (extensionId, sessionId, mitraId, accept session_id: sessionId, }) } else { - // Rejected — proceed to closure + // Rejected — no charge. Fail the extension payment session if present. + // viaTimeout=false here means an explicit mitra reject (the timer path goes through + // timeoutExtension which never enters this branch with viaTimeout=true for reject). + if (extension.payment_session_id) { + await failPaymentSession(extension.payment_session_id, PairingFailureCause.EXTENSION_REJECTED) + } + await sql`UPDATE chat_sessions SET status = ${SessionStatus.CLOSING} WHERE id = ${extension.session_id}` sendToSessionParticipant(sessionId, UserType.CUSTOMER, { type: WsMessage.EXTENSION_RESPONSE, accepted: false, + via_timeout: viaTimeout, }) sendToSessionParticipant(sessionId, UserType.MITRA, { type: WsMessage.SESSION_CLOSING, @@ -148,24 +238,72 @@ export const respondToExtension = async (extensionId, sessionId, mitraId, accept return extension } -const timeoutExtension = async (extensionId, sessionId) => { +/** + * Data-driven timeout handler. + * + * - read `extension_default_action_on_timeout` config: + * - 'auto_approve': check mitra reachability (WS + Valkey online). If both OK → approve. + * If either is offline/disconnected → fall back to reject (no charge). + * - 'auto_reject' (back-compat flag): reject regardless. + */ +const timeoutExtension = async (extensionId, sessionId, mitraId) => { extensionTimeouts.delete(extensionId) - const [extension] = await sql` + // Confirm extension is still pending (race with explicit response) + const [pending] = await sql` + SELECT id FROM session_extensions + WHERE id = ${extensionId} AND status = ${ExtensionStatus.PENDING} + ` + if (!pending) return + + const action = await getExtensionTimeoutAction() + + // Track WHY we ended up rejecting so the failed-pairings audit row gets the right tag. + // Default: configured policy is auto_reject → use EXTENSION_REJECTED. + let causeTag = PairingFailureCause.EXTENSION_REJECTED + let reasonForClient = 'timeout' + + if (action === ExtensionTimeoutAction.AUTO_APPROVE) { + // Safeguard: mitra must be reachable (online in Valkey AND connected via WS). + // Never use "in-session" as a proxy for "online". + const wsConnected = isUserOnlineWs(UserType.MITRA, mitraId) + const onlineFlag = await isMitraReachable(mitraId) + + if (wsConnected && onlineFlag) { + // Approve via the same path as explicit accept. + await finalizeExtension(extensionId, sessionId, /* accepted */ true, /* viaTimeout */ true) + return + } + // Safeguard tripped — treat as auto-reject (no charge), but tag the audit row distinctly + // so CC operators can see this was a system-safety decision, not a mitra reject or a + // configured auto-reject policy decision. + causeTag = PairingFailureCause.EXTENSION_SAFEGUARD_TRIPPED + reasonForClient = 'safeguard' + } + + // auto_reject (configured) OR auto_approve-with-safeguard-tripped — both end with + // the extension marked TIMEOUT, no charge, session moves to CLOSING. The cause_tag + // distinguishes them in the failed-pairings audit log. RETURNING guards against a race + // with explicit accept/decline that landed between the pending check above and here — + // if no row was matched, the extension is no longer ours to time out. + const [timedOut] = await sql` UPDATE session_extensions SET status = ${ExtensionStatus.TIMEOUT}, responded_at = NOW() WHERE id = ${extensionId} AND status = ${ExtensionStatus.PENDING} - RETURNING id, session_id + RETURNING id, payment_session_id ` - if (!extension) return + if (!timedOut) return - // Timeout = proceed to closure - await sql`UPDATE chat_sessions SET status = ${SessionStatus.CLOSING} WHERE id = ${extension.session_id}` + if (timedOut.payment_session_id) { + await failPaymentSession(timedOut.payment_session_id, causeTag) + } + // Move session to closing & notify both parties (matches the explicit-reject UX). + await sql`UPDATE chat_sessions SET status = ${SessionStatus.CLOSING} WHERE id = ${sessionId}` sendToSessionParticipant(sessionId, UserType.CUSTOMER, { type: WsMessage.EXTENSION_RESPONSE, accepted: false, - reason: 'timeout', + reason: reasonForClient, }) sendToSessionParticipant(sessionId, UserType.MITRA, { type: WsMessage.SESSION_CLOSING, diff --git a/backend/src/services/mitra-status.service.js b/backend/src/services/mitra-status.service.js index abd1991..6cd0376 100644 --- a/backend/src/services/mitra-status.service.js +++ b/backend/src/services/mitra-status.service.js @@ -1,9 +1,40 @@ import { getDb } from '../db/client.js' import { SessionStatus } from '../constants.js' -import { getMitraPingConfig } from './config.service.js' +import { getMitraPingConfig, getMaxCustomersPerMitra } from './config.service.js' +import { subscribe } from '../plugins/valkey.js' const sql = getDb() +// --- Short-TTL availability cache for the 5s-poll endpoint --- +// In-memory snapshot { available, count, expiresAt }. The cache: +// - is recomputed at most once per AVAILABILITY_TTL_MS (10s backstop) +// - is invalidated explicitly when CC changes max_customers_per_mitra (call invalidateAvailabilityCache()) +// This keeps customer polls off the DB hot path while staying close to real time. +const AVAILABILITY_TTL_MS = 10_000 +let availabilityCache = null // { available, count, expiresAt } + +export const invalidateAvailabilityCache = () => { + availabilityCache = null +} + +// Subscribe once at module load so other-instance config updates also bust this cache. +// Single-instance: the local mutator already invalidates, so this is a no-op extra. +let _subscribed = false +const ensureSubscribed = () => { + if (_subscribed) return + _subscribed = true + try { + subscribe('config:invalidate', (msg) => { + if (msg?.key === 'max_customers_per_mitra') { + invalidateAvailabilityCache() + } + }) + } catch (_) { + // Valkey may not be reachable in some test contexts; non-fatal. + } +} +ensureSubscribed() + export const ensureStatusRow = async (mitraId) => { await sql` INSERT INTO mitra_online_status (mitra_id) @@ -23,6 +54,7 @@ export const setOnline = async (mitraId) => { await sql` INSERT INTO mitra_online_logs (mitra_id, status) VALUES (${mitraId}, 'online') ` + invalidateAvailabilityCache() } export const setOffline = async (mitraId) => { @@ -41,6 +73,7 @@ export const setOffline = async (mitraId) => { await sql` INSERT INTO mitra_online_logs (mitra_id, status) VALUES (${mitraId}, 'offline') ` + invalidateAvailabilityCache() } export const heartbeat = async (mitraId) => { @@ -116,5 +149,93 @@ export const autoOfflineStaleMitras = async () => { ` } + // Capacity may have changed (mitra went offline) — invalidate the customer-facing + // availability cache so the next poll reflects reality. + if (stale.length > 0) invalidateAvailabilityCache() + return stale.length } + +/** + * Customer-home availability check, cached in-memory for AVAILABILITY_TTL_MS. + * + * Returns { available, count } where: + * - available = true iff at least one mitra is online AND below max_customers_per_mitra + * - count is the number of qualifying mitras (CC/debug only — never expose to customer UI) + * + * The 5s-poll endpoint backed by this function MUST NOT issue per-poll DB queries. + * The 10s TTL caps DB load to ~6 queries/min total regardless of poller count. + * + * Note: today the source of truth for online status + active session counts is Postgres + * (mitra_online_status + chat_sessions). A future refactor can mirror these into Valkey + * sets/hashes (matching the existing memory item "Session Timer Scaling"); the contract + * of this function — Valkey/cache reads only on the hot path — stays the same. + */ +export const countAvailableMitrasFromCache = async () => { + const now = Date.now() + if (availabilityCache && availabilityCache.expiresAt > now) { + return { available: availabilityCache.available, count: availabilityCache.count } + } + + const { max_customers_per_mitra } = await getMaxCustomersPerMitra() + const [{ count }] = await sql` + SELECT COUNT(*)::int AS count + FROM mitras m + INNER JOIN mitra_online_status s ON s.mitra_id = m.id + WHERE m.is_active = true + AND s.is_online = true + AND ( + SELECT COUNT(*) FROM chat_sessions cs + WHERE cs.mitra_id = m.id + AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}) + ) < ${max_customers_per_mitra} + ` + + const available = count > 0 + availabilityCache = { + available, + count, + expiresAt: now + AVAILABILITY_TTL_MS, + } + return { available, count } +} + +/** + * Mitra-online check for use during pairing/extension safeguards. + * Combines the Valkey-mirrored online flag (Postgres mitra_online_status today) with + * the WebSocket-connected check. Never use "in-session" as a proxy for "online". + */ +export const isMitraReachable = async (mitraId) => { + const [row] = await sql` + SELECT is_online FROM mitra_online_status WHERE mitra_id = ${mitraId} + ` + return Boolean(row?.is_online) +} + +/** + * Returns active session count for a mitra (sessions that count toward max_customers_per_mitra). + */ +export const getMitraActiveSessionCount = async (mitraId) => { + const [{ count }] = await sql` + SELECT COUNT(*)::int AS count FROM chat_sessions + WHERE mitra_id = ${mitraId} + AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}) + ` + return count +} + +/** + * True iff this mitra is currently in an ACTIVE chat with this specific customer. + * Used by targeted "Curhat lagi" pre-check: a mitra at-capacity but mid-session + * with the requesting customer is still allowed to receive a returning-chat card. + */ +export const isMitraInActiveSessionWithCustomer = async (mitraId, customerId) => { + const [row] = await sql` + SELECT id FROM chat_sessions + WHERE mitra_id = ${mitraId} + AND customer_id = ${customerId} + AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.EXTENDING}, ${SessionStatus.CLOSING}) + LIMIT 1 + ` + return Boolean(row) +} diff --git a/backend/src/services/pairing-failure.service.js b/backend/src/services/pairing-failure.service.js new file mode 100644 index 0000000..3cca95e --- /dev/null +++ b/backend/src/services/pairing-failure.service.js @@ -0,0 +1,85 @@ +import { getDb } from '../db/client.js' +import { PairingFailureCause, PairingFailureOperatorAction } from '../constants.js' + +const sql = getDb() + +/** + * Insert a pairing_failures row. Called from payment.service.failPaymentSession (and the + * background sweeper for `payment_session_expired`). + */ +export const recordFailure = async ({ paymentSessionId, customerId, targetedMitraId = null, causeTag, amount }) => { + if (!Object.values(PairingFailureCause).includes(causeTag)) { + throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 }) + } + const [row] = await sql` + INSERT INTO pairing_failures ( + payment_session_id, customer_id, targeted_mitra_id, cause_tag, amount + ) + VALUES ( + ${paymentSessionId}, ${customerId}, ${targetedMitraId}, ${causeTag}, ${amount} + ) + RETURNING id, payment_session_id, customer_id, targeted_mitra_id, cause_tag, amount, + operator_action, actioned_by, actioned_at, created_at + ` + return row +} + +/** + * Control-center listing with optional filters. Returns rows + total count. + */ +export const listFailures = async ({ causeTags = null, dateFrom = null, dateTo = null, limit = 50, offset = 0 } = {}) => { + const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200) + const safeOffset = Math.max(Number(offset) || 0, 0) + + const items = await sql` + SELECT + pf.id, pf.payment_session_id, pf.customer_id, pf.targeted_mitra_id, + pf.cause_tag, pf.amount, pf.operator_action, pf.actioned_by, pf.actioned_at, pf.created_at, + c.display_name AS customer_call_name, + m.display_name AS targeted_mitra_call_name, + cc.display_name AS actioned_by_name + FROM pairing_failures pf + JOIN customers c ON c.id = pf.customer_id + LEFT JOIN mitras m ON m.id = pf.targeted_mitra_id + LEFT JOIN control_center_users cc ON cc.id = pf.actioned_by + WHERE + ${causeTags && causeTags.length > 0 ? sql`pf.cause_tag IN ${sql(causeTags)}` : sql`TRUE`} + AND ${dateFrom ? sql`pf.created_at >= ${dateFrom}` : sql`TRUE`} + AND ${dateTo ? sql`pf.created_at <= ${dateTo}` : sql`TRUE`} + ORDER BY pf.created_at DESC + LIMIT ${safeLimit} OFFSET ${safeOffset} + ` + + const [{ count }] = await sql` + SELECT COUNT(*) FROM pairing_failures pf + WHERE + ${causeTags && causeTags.length > 0 ? sql`pf.cause_tag IN ${sql(causeTags)}` : sql`TRUE`} + AND ${dateFrom ? sql`pf.created_at >= ${dateFrom}` : sql`TRUE`} + AND ${dateTo ? sql`pf.created_at <= ${dateTo}` : sql`TRUE`} + ` + + return { rows: items, total: Number(count), limit: safeLimit, offset: safeOffset } +} + +/** + * Operator action menu — record the chosen action against a failure row. + * Each call overwrites the previous decision (operator can change their mind). + */ +export const setOperatorAction = async (failureId, ccUserId, action) => { + if (!Object.values(PairingFailureOperatorAction).includes(action)) { + throw Object.assign(new Error(`Unknown operator action: ${action}`), { code: 'VALIDATION_ERROR', statusCode: 422 }) + } + const [updated] = await sql` + UPDATE pairing_failures + SET operator_action = ${action}, + actioned_by = ${ccUserId}, + actioned_at = NOW() + WHERE id = ${failureId} + RETURNING id, payment_session_id, customer_id, targeted_mitra_id, cause_tag, amount, + operator_action, actioned_by, actioned_at, created_at + ` + if (!updated) { + throw Object.assign(new Error('Failure row not found'), { code: 'NOT_FOUND', statusCode: 404 }) + } + return updated +} diff --git a/backend/src/services/pairing.service.js b/backend/src/services/pairing.service.js index c066578..c282842 100644 --- a/backend/src/services/pairing.service.js +++ b/backend/src/services/pairing.service.js @@ -1,10 +1,22 @@ import { getDb } from '../db/client.js' -import { getMaxCustomersPerMitra } from './config.service.js' +import { getMaxCustomersPerMitra, getPairingBlastTimeoutSeconds, getReturningChatConfirmationTimeoutSeconds } from './config.service.js' import { sendToUser } from '../plugins/websocket.js' import { sendPushNotification } from './notification.service.js' import { startSessionTimer } from './session-timer.service.js' import { startSessionListener } from './chat-handler.service.js' -import { UserType, SessionStatus, NotificationResponse, TransactionType, WsMessage, TopicSensitivity } from '../constants.js' +import { consumePaymentSession, failPaymentSession, getPaymentSession, recordIntermediateFailure } from './payment.service.js' +import { isMitraReachable, isMitraInActiveSessionWithCustomer, getMitraActiveSessionCount } from './mitra-status.service.js' +import { + UserType, + SessionStatus, + NotificationResponse, + TransactionType, + WsMessage, + TopicSensitivity, + PaymentSessionStatus, + PairingFailureCause, + PairingRequestType, +} from '../constants.js' const sql = getDb() @@ -20,7 +32,12 @@ const notifyMitra = async (mitraId, data) => { await sendPushNotification(UserType.MITRA, mitraId, { title: 'Permintaan Chat Baru', body: 'Ada pelanggan yang ingin curhat! Ketuk untuk menerima.', - data: { type: WsMessage.CHAT_REQUEST, session_id: data.session_id, action: 'open_accept' }, + data: { + type: WsMessage.CHAT_REQUEST, + session_id: data.session_id, + request_type: data.request_type || PairingRequestType.GENERAL, + action: 'open_accept', + }, }) } } @@ -43,27 +60,101 @@ const notifyCustomer = async (customerId, data) => { body: 'Maaf, tidak ada bestie yang tersedia saat ini.', data: { type: WsMessage.SESSION_EXPIRED, session_id: data.session_id }, }) + } else if (data.type === WsMessage.PAIRING_FAILED) { + // Terminal pairing failure on a confirmed payment. Push so the customer + // can come back to the app and see the failed-pairing screen / contact support. + await sendPushNotification(UserType.CUSTOMER, customerId, { + title: 'Sesi gagal', + body: 'Maaf, kami tidak bisa menemukan bestie untuk sesimu. Tim kami akan menghubungimu segera.', + data: { + type: WsMessage.PAIRING_FAILED, + payment_session_id: data.payment_session_id || '', + cause_tag: data.cause_tag || '', + }, + }) } } } export const findAvailableMitras = async () => { const { max_customers_per_mitra } = await getMaxCustomersPerMitra() + // Project active_session_count alongside the mitra row so the blast loop doesn't + // need a per-mitra COUNT roundtrip later. const mitras = await sql` - SELECT m.id, m.display_name + SELECT m.id, m.display_name, sub.active_session_count FROM mitras m INNER JOIN mitra_online_status s ON s.mitra_id = m.id + INNER JOIN LATERAL ( + SELECT COUNT(*)::int AS active_session_count + FROM chat_sessions cs + WHERE cs.mitra_id = m.id AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}) + ) sub ON true WHERE m.is_active = true AND s.is_online = true - AND ( - SELECT COUNT(*) FROM chat_sessions cs - WHERE cs.mitra_id = m.id AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}) - ) < ${max_customers_per_mitra} + AND sub.active_session_count < ${max_customers_per_mitra} ` return mitras } -export const createPairingRequest = async (customerId, { duration_minutes, price, is_free_trial, topic_sensitivity } = {}) => { +/** + * Validate that a payment session is owned by the customer, confirmed, and not yet consumed. + * Throws on mismatch. Returns the loaded payment session row. + */ +const requireConfirmedPaymentSession = async (paymentSessionId, customerId, { allowExtension = false } = {}) => { + if (!paymentSessionId) { + throw Object.assign(new Error('payment_session_id is required'), { + code: 'VALIDATION_ERROR', statusCode: 422, + }) + } + const paySession = await getPaymentSession(paymentSessionId) + if (!paySession) { + throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) + } + if (paySession.customer_id !== customerId) { + throw Object.assign(new Error('Payment session does not belong to this customer'), { + code: 'FORBIDDEN', statusCode: 403, + }) + } + if (paySession.status !== PaymentSessionStatus.CONFIRMED) { + throw Object.assign(new Error(`Payment session is ${paySession.status}, must be confirmed`), { + code: 'INVALID_STATE', statusCode: 409, + }) + } + if (paySession.is_extension && !allowExtension) { + throw Object.assign(new Error('Extension payment session cannot be used to start a new chat'), { + code: 'INVALID_STATE', statusCode: 409, + }) + } + if (new Date(paySession.expires_at) <= new Date()) { + // Check expiry inline at every state transition (defense in depth vs. the background sweeper). + await failPaymentSession(paymentSessionId, PairingFailureCause.PAYMENT_SESSION_EXPIRED) + throw Object.assign(new Error('Payment session has expired'), { code: 'EXPIRED', statusCode: 409 }) + } + return paySession +} + +/** + * General-blast pairing request. Requires a confirmed payment_session_id. + * + * The duration_minutes / price / is_free_trial values for the chat_session row are + * sourced from the payment session — the client does not dictate pricing here. + * + * `allowTargetedPayment` is set true by the fallback-to-blast path: the original payment + * was created with a `targeted_mitra_id` for "Curhat lagi" but the customer chose to + * fall back to general blast on the same payment. The flag bypasses the + * "use returning-chat endpoint" guard in that exact case. + */ +export const createPairingRequest = async (customerId, { paymentSessionId, topic_sensitivity, allowTargetedPayment = false } = {}) => { + const paySession = await requireConfirmedPaymentSession(paymentSessionId, customerId) + + // Targeted payment session must use createTargetedPairingRequest unless we're + // explicitly invoked by the fallback-to-blast path. + if (paySession.targeted_mitra_id && !allowTargetedPayment) { + throw Object.assign(new Error('Payment session is targeted to a specific mitra; use returning-chat endpoint'), { + code: 'INVALID_STATE', statusCode: 409, + }) + } + // Check for existing active session or request const [existing] = await sql` SELECT id, status FROM chat_sessions @@ -78,6 +169,8 @@ export const createPairingRequest = async (customerId, { duration_minutes, price const availableMitras = await findAvailableMitras() if (availableMitras.length === 0) { + // No mitras to blast to — fail the payment immediately. + await failPaymentSession(paymentSessionId, PairingFailureCause.NO_MITRA_AVAILABLE) throw Object.assign(new Error('No bestie available'), { code: 'NO_MITRA_AVAILABLE', statusCode: 404, }) @@ -87,53 +180,182 @@ export const createPairingRequest = async (customerId, { duration_minutes, price ? TopicSensitivity.SENSITIVE : TopicSensitivity.REGULAR - // Create session with duration/price/topic + // Create session sourced from the payment session. const [session] = await sql` - INSERT INTO chat_sessions (customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity) - VALUES (${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, ${duration_minutes || null}, ${price || 0}, ${is_free_trial || false}, ${resolvedTopic}) - RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, created_at + INSERT INTO chat_sessions ( + customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id + ) + VALUES ( + ${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, + ${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_free_trial}, + ${resolvedTopic}, ${paymentSessionId} + ) + RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id, created_at ` - // Create notifications for all available mitras - for (const mitra of availableMitras) { - const [{ count: activeCount }] = await sql` - SELECT COUNT(*)::int AS count FROM chat_sessions - WHERE mitra_id = ${mitra.id} - AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}) - ` + // Fan out to all available mitras in parallel — DB inserts and notifications are + // independent per mitra. active_session_count was already projected by findAvailableMitras. + await Promise.all(availableMitras.map(async (mitra) => { await sql` INSERT INTO chat_request_notifications (session_id, mitra_id, active_session_count) - VALUES (${session.id}, ${mitra.id}, ${activeCount}) + VALUES (${session.id}, ${mitra.id}, ${mitra.active_session_count}) ` - // Notify mitra via WebSocket (FCM fallback if offline) await notifyMitra(mitra.id, { type: WsMessage.CHAT_REQUEST, session_id: session.id, + request_type: PairingRequestType.GENERAL, created_at: session.created_at, duration_minutes: session.duration_minutes, is_free_trial: session.is_free_trial, topic_sensitivity: session.topic_sensitivity, }) - } + })) - // Start 60s timeout + // Start blast timeout (configurable via app_config) + const { pairing_blast_timeout_seconds } = await getPairingBlastTimeoutSeconds() const timeoutId = setTimeout(async () => { try { - await expirePairingRequest(session.id) - } catch (_) {} - }, 60_000) + await expirePairingRequest(session.id, PairingFailureCause.NO_MITRA_AVAILABLE) + } catch (err) { + console.error('expirePairingRequest failed', { sessionId: session.id, err }) + } + }, pairing_blast_timeout_seconds * 1000) pairingTimeouts.set(session.id, timeoutId) return session } +/** + * Targeted pairing request for "Curhat lagi" (returning chat). + * + * - Pre-check targeted mitra reachability + capacity. If unreachable or at-capacity-and-not-mid-session + * with this customer → fail payment immediately and return 409 with `reason: 'targeted_mitra_offline'`. + * - Fire ONE notification to the targeted mitra. + * - Start a server-side timer of `returning_chat_confirmation_timeout_seconds`. On expiry, + * mark request auto-rejected, fail payment with `targeted_mitra_timeout`, push WS event. + * - On explicit decline by mitra: fail payment with `targeted_mitra_rejected`, push WS event. + * - On accept: existing accept path runs (consumes payment session as for general blast). + */ +export const createTargetedPairingRequest = async (customerId, { paymentSessionId, targetedMitraId, topic_sensitivity } = {}) => { + const paySession = await requireConfirmedPaymentSession(paymentSessionId, customerId) + + if (!targetedMitraId) { + throw Object.assign(new Error('targetedMitraId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 }) + } + // Cross-check: payment_session.targeted_mitra_id should match (if set). + if (paySession.targeted_mitra_id && paySession.targeted_mitra_id !== targetedMitraId) { + throw Object.assign(new Error('targetedMitraId does not match payment session'), { + code: 'INVALID_STATE', statusCode: 409, + }) + } + + // Check for existing active session or request + const [existing] = await sql` + SELECT id, status FROM chat_sessions + WHERE customer_id = ${customerId} + AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}, ${SessionStatus.PENDING_PAYMENT}, ${SessionStatus.ACTIVE}) + ` + if (existing) { + throw Object.assign(new Error('Customer already has an active session or request'), { + code: 'ALREADY_ACTIVE', statusCode: 409, + }) + } + + // Pre-check: mitra reachable? + const reachable = await isMitraReachable(targetedMitraId) + if (!reachable) { + // Intermediate failure: audit row written, payment stays `confirmed` so the customer + // can choose to fall back to general blast (or cancel, which terminates). + await recordIntermediateFailure({ + paymentSessionId, + customerId, + targetedMitraId, + causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE, + amount: paySession.amount, + }) + throw Object.assign(new Error('Targeted mitra is offline'), { + code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_offline', + }) + } + + // Pre-check: mitra at capacity AND not mid-session with this customer? + const { max_customers_per_mitra } = await getMaxCustomersPerMitra() + const activeCount = await getMitraActiveSessionCount(targetedMitraId) + if (activeCount >= max_customers_per_mitra) { + const midSessionWithCustomer = await isMitraInActiveSessionWithCustomer(targetedMitraId, customerId) + if (!midSessionWithCustomer) { + await recordIntermediateFailure({ + paymentSessionId, + customerId, + targetedMitraId, + causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE, + amount: paySession.amount, + }) + throw Object.assign(new Error('Targeted mitra is at capacity'), { + code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_offline', + }) + } + // Else: at-capacity but mid-session with the requesting customer — request allowed through. + } + + const resolvedTopic = topic_sensitivity === TopicSensitivity.SENSITIVE + ? TopicSensitivity.SENSITIVE + : TopicSensitivity.REGULAR + + // Create session sourced from the payment session, status = pending_acceptance. + const [session] = await sql` + INSERT INTO chat_sessions ( + customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id + ) + VALUES ( + ${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, + ${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_free_trial}, + ${resolvedTopic}, ${paymentSessionId} + ) + RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id, created_at + ` + + // Single notification to the targeted mitra + await sql` + INSERT INTO chat_request_notifications (session_id, mitra_id, active_session_count) + VALUES (${session.id}, ${targetedMitraId}, ${activeCount}) + ` + // Server-side timer (configurable, default 20s) — also surfaced in the WS payload so the mitra + // app countdown UI matches what the server is enforcing. + const { returning_chat_confirmation_timeout_seconds } = await getReturningChatConfirmationTimeoutSeconds() + + await notifyMitra(targetedMitraId, { + type: WsMessage.CHAT_REQUEST, + session_id: session.id, + request_type: PairingRequestType.RETURNING, + created_at: session.created_at, + duration_minutes: session.duration_minutes, + is_free_trial: session.is_free_trial, + topic_sensitivity: session.topic_sensitivity, + confirmation_timeout_seconds: returning_chat_confirmation_timeout_seconds, + }) + + const timeoutId = setTimeout(async () => { + try { + await expireTargetedPairingRequest(session.id) + } catch (err) { + console.error('expireTargetedPairingRequest failed', { sessionId: session.id, err }) + } + }, returning_chat_confirmation_timeout_seconds * 1000) + pairingTimeouts.set(session.id, timeoutId) + + // Surface the timeout to the customer so the targeted-waiting overlay countdown + // matches the server-side timer exactly (CC-configurable; never stale). + return { ...session, confirmation_timeout_seconds: returning_chat_confirmation_timeout_seconds } +} + export const acceptPairingRequest = async (sessionId, mitraId) => { // Use a transaction-like approach: update only if status is still pending_acceptance const [session] = await sql` UPDATE chat_sessions SET mitra_id = ${mitraId}, status = ${SessionStatus.PENDING_PAYMENT}, paired_at = NOW() WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} AND mitra_id IS NULL - RETURNING id, customer_id, mitra_id, status, paired_at + RETURNING id, customer_id, mitra_id, status, paired_at, payment_session_id ` if (!session) { @@ -163,7 +385,12 @@ export const acceptPairingRequest = async (sessionId, mitraId) => { pairingTimeouts.delete(sessionId) } - // Auto-skip payment for now: move to active and set expires_at + // Consume the payment session at the moment of acceptance. + if (session.payment_session_id) { + await consumePaymentSession(session.payment_session_id) + } + + // Activate the session and set expires_at. const [activeSession] = await sql` UPDATE chat_sessions SET status = ${SessionStatus.ACTIVE}, @@ -172,7 +399,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => { ELSE NULL END WHERE id = ${sessionId} - RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_free_trial, expires_at + RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_free_trial, expires_at, payment_session_id ` // Record transaction @@ -205,18 +432,16 @@ export const acceptPairingRequest = async (sessionId, mitraId) => { status: SessionStatus.ACTIVE, }) - // Notify other mitras to dismiss the request + // Notify other mitras to dismiss the request — independent fan-out, run in parallel. const notifications = await sql` SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} AND mitra_id != ${mitraId} ` - for (const n of notifications) { - await notifyMitra(n.mitra_id, { - type: WsMessage.CHAT_REQUEST_CLOSED, - session_id: sessionId, - reason: 'accepted_by_other', - }) - } + await Promise.all(notifications.map((n) => notifyMitra(n.mitra_id, { + type: WsMessage.CHAT_REQUEST_CLOSED, + session_id: sessionId, + reason: 'accepted_by_other', + }))) return activeSession } @@ -227,6 +452,94 @@ export const declinePairingRequest = async (sessionId, mitraId) => { SET response = ${NotificationResponse.DECLINED}, responded_at = NOW() WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} AND response IS NULL ` + + // Targeted-vs-general is determined by the payment_session.targeted_mitra_id, not by + // notification count — a general blast with only one online mitra also has length=1. + const [targetCheck] = await sql` + SELECT ps.targeted_mitra_id + FROM chat_sessions cs + LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id + WHERE cs.id = ${sessionId} + ` + const isTargeted = !!targetCheck?.targeted_mitra_id + + if (isTargeted) { + // Mark the chat_session as expired (the targeted attempt is over) — but keep the + // payment_session in `confirmed` so the customer can fall back to general blast on + // the same payment, or cancel (which then terminates). + const [session] = await sql` + UPDATE chat_sessions + SET status = ${SessionStatus.EXPIRED} + WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} + RETURNING id, customer_id, payment_session_id + ` + if (session) { + // Clear the 20s timer if still pending. + const timeoutId = pairingTimeouts.get(sessionId) + if (timeoutId) { + clearTimeout(timeoutId) + pairingTimeouts.delete(sessionId) + } + + // Audit row only; payment session stays `confirmed`. + if (session.payment_session_id) { + const paySession = await getPaymentSession(session.payment_session_id) + if (paySession) { + await recordIntermediateFailure({ + paymentSessionId: session.payment_session_id, + customerId: session.customer_id, + targetedMitraId: mitraId, + causeTag: PairingFailureCause.TARGETED_MITRA_REJECTED, + amount: paySession.amount, + }) + } + } + + // Push a returning-chat-rejected WS event to the customer (fall-through to fallback flow). + await notifyCustomer(session.customer_id, { + type: WsMessage.RETURNING_CHAT_REJECTED, + session_id: sessionId, + payment_session_id: session.payment_session_id, + }) + } + return + } + + // General-blast: if all notifications now have a non-null DECLINED response → treat as + // every-mitra-rejected (terminal, distinct from blast-window-timeout). Empty-array guard + // prevents a misfire when the SELECT happens to return zero rows. + const notifications = await sql` + SELECT response FROM chat_request_notifications WHERE session_id = ${sessionId} + ` + const allDeclined = notifications.length > 0 + && notifications.every((n) => n.response === NotificationResponse.DECLINED) + if (allDeclined) { + const [session] = await sql` + UPDATE chat_sessions + SET status = ${SessionStatus.EXPIRED} + WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} + RETURNING id, customer_id, payment_session_id + ` + if (session) { + const timeoutId = pairingTimeouts.get(sessionId) + if (timeoutId) { + clearTimeout(timeoutId) + pairingTimeouts.delete(sessionId) + } + + if (session.payment_session_id) { + await failPaymentSession(session.payment_session_id, PairingFailureCause.ALL_MITRAS_REJECTED) + } + + // Terminal: customer is in a searching state and the search just ended with no chat. + await notifyCustomer(session.customer_id, { + type: WsMessage.PAIRING_FAILED, + session_id: sessionId, + payment_session_id: session.payment_session_id, + cause_tag: PairingFailureCause.ALL_MITRAS_REJECTED, + }) + } + } } export const cancelPairingRequest = async (sessionId, customerId) => { @@ -235,7 +548,7 @@ export const cancelPairingRequest = async (sessionId, customerId) => { SET status = ${SessionStatus.CANCELLED} WHERE id = ${sessionId} AND customer_id = ${customerId} AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}) - RETURNING id, status + RETURNING id, customer_id, status, payment_session_id ` if (!session) { @@ -258,27 +571,109 @@ export const cancelPairingRequest = async (sessionId, customerId) => { WHERE session_id = ${sessionId} AND response IS NULL ` - // Notify mitras to dismiss (customer cancelled) + // Notify mitras to dismiss (customer cancelled) — independent fan-out, run in parallel. const notifications = await sql` SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} ` - for (const n of notifications) { - await notifyMitra(n.mitra_id, { - type: WsMessage.CHAT_REQUEST_CLOSED, - session_id: sessionId, - reason: 'cancelled_by_customer', - }) + await Promise.all(notifications.map((n) => notifyMitra(n.mitra_id, { + type: WsMessage.CHAT_REQUEST_CLOSED, + session_id: sessionId, + reason: 'cancelled_by_customer', + }))) + + // Customer initiated this cancel; the calling client already navigates home. Do not + // push PAIRING_FAILED for customer-initiated cancels — surfacing it as a "failure" + // event (especially via FCM if backgrounded) misframes the user's own action. + if (session.payment_session_id) { + await failPaymentSession(session.payment_session_id, PairingFailureCause.CUSTOMER_CANCELLED) } return session } -export const expirePairingRequest = async (sessionId) => { +/** + * Customer-initiated cancel during a payment-search. + * + * Use this when the customer is sitting on the searching/waiting screen with a confirmed + * payment but no chat-session row yet exists, OR when they're in a returning-chat 20s wait. + * If a chat_session was already created (general blast in flight, or targeted request out), + * we cancel that too. + */ +export const cancelPaymentSearch = async (paymentSessionId, customerId) => { + const paySession = await getPaymentSession(paymentSessionId) + if (!paySession) { + throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) + } + if (paySession.customer_id !== customerId) { + throw Object.assign(new Error('Payment session does not belong to this customer'), { + code: 'FORBIDDEN', statusCode: 403, + }) + } + + // If a chat_session exists for this payment in pending_acceptance/searching, cancel it. + const [linkedSession] = await sql` + SELECT id FROM chat_sessions + WHERE payment_session_id = ${paymentSessionId} + AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}) + ` + if (linkedSession) { + // cancelPairingRequest also fails the payment session — short-circuit to avoid double work. + return cancelPairingRequest(linkedSession.id, customerId) + } + + // Otherwise fail the payment directly. Covers the case where the customer cancels after + // the targeted attempt already expired/rejected (chat_session no longer pending_acceptance) + // but the payment is still `confirmed`. No customer-side WS push — see cancelPairingRequest. + if (paySession.status === PaymentSessionStatus.CONFIRMED) { + await failPaymentSession(paymentSessionId, PairingFailureCause.CUSTOMER_CANCELLED) + } + return { id: paymentSessionId, payment_session_id: paymentSessionId } +} + +/** + * After a returning-chat fail, customer taps "Chat dengan bestie lain". + * + * The original payment_session stays in `confirmed` for the entire returning-chat flow — + * targeted reject/timeout writes an audit-only `pairing_failures` row but does NOT terminate. + * So when the customer falls back to general blast, we reuse the same `payment_session_id` + * directly. Multiple `pairing_failures` rows may FK from one payment_session — that's the + * desired CC UX (one row per failed attempt). Termination happens only at the actual end + * of the flow (chat starts → consumed; cancel/blast-exhaust → failed_pairing). + * + * The targeted_mitra_id flag on the original row is left as-is (it records the customer's + * original intent); the general blast happens regardless. + */ +export const fallbackToGeneralBlast = async (paymentSessionId, customerId, { topic_sensitivity } = {}) => { + const paySession = await getPaymentSession(paymentSessionId) + if (!paySession) { + throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) + } + if (paySession.customer_id !== customerId) { + throw Object.assign(new Error('Payment session does not belong to this customer'), { + code: 'FORBIDDEN', statusCode: 403, + }) + } + if (paySession.status !== PaymentSessionStatus.CONFIRMED) { + throw Object.assign(new Error(`Cannot fallback from payment in status ${paySession.status}`), { + code: 'INVALID_STATE', statusCode: 409, + }) + } + + // Run the general blast against the SAME payment session. Pass `allowTargetedPayment` + // so the targeted_mitra_id on the payment session doesn't trip the general-blast guard. + return createPairingRequest(customerId, { + paymentSessionId, + topic_sensitivity, + allowTargetedPayment: true, + }) +} + +export const expirePairingRequest = async (sessionId, causeTag = PairingFailureCause.NO_MITRA_AVAILABLE) => { const [session] = await sql` UPDATE chat_sessions SET status = ${SessionStatus.EXPIRED} WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} - RETURNING id, customer_id, status + RETURNING id, customer_id, status, payment_session_id ` if (!session) return null @@ -291,38 +686,134 @@ export const expirePairingRequest = async (sessionId) => { WHERE session_id = ${sessionId} AND response IS NULL ` - // Notify customer via WebSocket (FCM fallback) + // Fail the payment session (if any) — terminal. + if (session.payment_session_id) { + await failPaymentSession(session.payment_session_id, causeTag) + } + + // Notify customer via WebSocket (FCM fallback). Terminal pairing failure → PAIRING_FAILED + // so the client can route to the failed-pairing screen consistently with the other + // terminal paths (cancel / all-rejected / payment-expired-mid-search). await notifyCustomer(session.customer_id, { - type: WsMessage.SESSION_EXPIRED, + type: WsMessage.PAIRING_FAILED, session_id: sessionId, + payment_session_id: session.payment_session_id, + cause_tag: causeTag, }) - // Notify mitras to dismiss (request expired) + // Notify mitras to dismiss (request expired) — independent fan-out, run in parallel. const notifications = await sql` SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} ` - for (const n of notifications) { - await notifyMitra(n.mitra_id, { - type: WsMessage.CHAT_REQUEST_CLOSED, - session_id: sessionId, - reason: 'expired', - }) + await Promise.all(notifications.map((n) => notifyMitra(n.mitra_id, { + type: WsMessage.CHAT_REQUEST_CLOSED, + session_id: sessionId, + reason: 'expired', + }))) + + return session +} + +/** + * Targeted-request timer fired with no mitra response. + * + * INTERMEDIATE failure: the chat_session is marked expired (the targeted attempt is over) + * but the payment_session stays `confirmed` so the customer can fall back to general blast + * on the same payment, or cancel (which then terminates). + * + * - cause_tag is targeted_mitra_timeout (audit row only) + * - WS event sent to customer is RETURNING_CHAT_TIMEOUT (not PAIRING_FAILED) + */ +const expireTargetedPairingRequest = async (sessionId) => { + const [session] = await sql` + UPDATE chat_sessions + SET status = ${SessionStatus.EXPIRED} + WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} + RETURNING id, customer_id, status, payment_session_id + ` + if (!session) return null + + pairingTimeouts.delete(sessionId) + + // Capture which mitra was targeted (for the audit row). + const [notif] = await sql` + SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} LIMIT 1 + ` + + await sql` + UPDATE chat_request_notifications + SET response = ${NotificationResponse.IGNORED}, responded_at = NOW() + WHERE session_id = ${sessionId} AND response IS NULL + ` + + if (session.payment_session_id) { + const paySession = await getPaymentSession(session.payment_session_id) + if (paySession) { + await recordIntermediateFailure({ + paymentSessionId: session.payment_session_id, + customerId: session.customer_id, + targetedMitraId: notif?.mitra_id ?? null, + causeTag: PairingFailureCause.TARGETED_MITRA_TIMEOUT, + amount: paySession.amount, + }) + } } + await notifyCustomer(session.customer_id, { + type: WsMessage.RETURNING_CHAT_TIMEOUT, + session_id: sessionId, + payment_session_id: session.payment_session_id, + }) + + // Notify the targeted mitra that the card is no longer actionable — fan-out in parallel + // (single recipient today, but cheap to future-proof). + const notifications = await sql` + SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} + ` + await Promise.all(notifications.map((n) => notifyMitra(n.mitra_id, { + type: WsMessage.CHAT_REQUEST_CLOSED, + session_id: sessionId, + reason: 'timeout', + }))) + return session } export const getPendingRequestsForMitra = async (mitraId) => { + // Distinguish general blast from "Curhat lagi" returning requests via payment_session.targeted_mitra_id. + // For returning requests, surface the configured timeout so the cold-start (FCM-tap) path can render + // the countdown overlay — same field the WS payload provides for the live path. const rows = await sql` - SELECT cs.id AS session_id, cs.duration_minutes, cs.is_free_trial, cs.topic_sensitivity, cs.created_at + SELECT + cs.id AS session_id, + cs.duration_minutes, + cs.is_free_trial, + cs.topic_sensitivity, + cs.created_at, + CASE + WHEN ps.targeted_mitra_id IS NOT NULL THEN ${PairingRequestType.RETURNING} + ELSE ${PairingRequestType.GENERAL} + END AS request_type FROM chat_request_notifications crn JOIN chat_sessions cs ON cs.id = crn.session_id + LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id WHERE crn.mitra_id = ${mitraId} AND crn.response IS NULL AND cs.status = ${SessionStatus.PENDING_ACCEPTANCE} ORDER BY cs.created_at ASC ` - return rows + + if (!rows.some((r) => r.request_type === PairingRequestType.RETURNING)) { + return rows + } + + // At least one returning row — fetch the timeout config once and attach. + const { returning_chat_confirmation_timeout_seconds } = await getReturningChatConfirmationTimeoutSeconds() + return rows.map((r) => + r.request_type === PairingRequestType.RETURNING + ? { ...r, confirmation_timeout_seconds: returning_chat_confirmation_timeout_seconds } + : r + ) } export const getSessionStatus = async (sessionId) => { diff --git a/backend/src/services/payment.service.js b/backend/src/services/payment.service.js new file mode 100644 index 0000000..da3f037 --- /dev/null +++ b/backend/src/services/payment.service.js @@ -0,0 +1,298 @@ +import { getDb } from '../db/client.js' +import { PaymentSessionStatus, PairingFailureCause, UserType, WsMessage } from '../constants.js' +import { recordFailure } from './pairing-failure.service.js' +import { sendToUser } from '../plugins/websocket.js' +import { sendPushNotification } from './notification.service.js' +import { getPaymentSessionTimeoutMinutes as readPaymentSessionTimeoutMinutes } from './config.service.js' + +const sql = getDb() + +const getPaymentSessionTimeoutMinutes = async () => { + const { payment_session_timeout_minutes } = await readPaymentSessionTimeoutMinutes() + return payment_session_timeout_minutes +} + +/** + * Create a new payment session in `pending` status. + * Reads `payment_session_timeout_minutes` from config to compute expires_at. + */ +export const createPaymentSession = async ({ + customerId, + durationMinutes, + amount, + isFreeTrial = false, + isExtension = false, + targetedMitraId = null, +}) => { + if (!customerId) { + throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 }) + } + if (typeof durationMinutes !== 'number' || durationMinutes <= 0) { + throw Object.assign(new Error('durationMinutes must be a positive number'), { code: 'VALIDATION_ERROR', statusCode: 422 }) + } + if (typeof amount !== 'number' || amount < 0) { + throw Object.assign(new Error('amount must be a non-negative number'), { code: 'VALIDATION_ERROR', statusCode: 422 }) + } + + const ttlMinutes = await getPaymentSessionTimeoutMinutes() + + const [row] = await sql` + INSERT INTO payment_sessions ( + customer_id, amount, duration_minutes, is_free_trial, is_extension, + status, targeted_mitra_id, expires_at + ) + VALUES ( + ${customerId}, ${amount}, ${durationMinutes}, ${isFreeTrial}, ${isExtension}, + ${PaymentSessionStatus.PENDING}, ${targetedMitraId}, + NOW() + (${ttlMinutes} || ' minutes')::interval + ) + RETURNING id, customer_id, amount, duration_minutes, is_free_trial, is_extension, + status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at + ` + + return row +} + +/** + * Transition pending → confirmed. Throws on ownership/status/expiry mismatch. + */ +export const confirmPaymentSession = async (paymentSessionId, customerId) => { + const [existing] = await sql` + SELECT id, customer_id, status, expires_at + FROM payment_sessions + WHERE id = ${paymentSessionId} + ` + if (!existing) { + throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) + } + if (existing.customer_id !== customerId) { + throw Object.assign(new Error('Payment session does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 }) + } + if (existing.status !== PaymentSessionStatus.PENDING) { + throw Object.assign(new Error(`Payment session is ${existing.status}, cannot confirm`), { + code: 'INVALID_STATE', statusCode: 409, + }) + } + if (new Date(existing.expires_at) <= new Date()) { + // Inline expiry check in addition to the background sweeper, since the customer can + // attempt to confirm a row that's already past expires_at before the sweep runs. + await sql` + UPDATE payment_sessions SET status = ${PaymentSessionStatus.EXPIRED} + WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING} + ` + throw Object.assign(new Error('Payment session has expired'), { code: 'EXPIRED', statusCode: 409 }) + } + + const [updated] = await sql` + UPDATE payment_sessions + SET status = ${PaymentSessionStatus.CONFIRMED}, confirmed_at = NOW() + WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING} + RETURNING id, customer_id, amount, duration_minutes, is_free_trial, is_extension, + status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at + ` + if (!updated) { + throw Object.assign(new Error('Payment session state changed during confirm'), { code: 'CONFLICT', statusCode: 409 }) + } + return updated +} + +/** + * Transition confirmed → consumed. Called from pairing service when a chat starts. + * Idempotent at higher level (caller should check status first if it matters). + */ +export const consumePaymentSession = async (paymentSessionId) => { + const [updated] = await sql` + UPDATE payment_sessions + SET status = ${PaymentSessionStatus.CONSUMED}, consumed_at = NOW() + WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.CONFIRMED} + RETURNING id, status, consumed_at + ` + return updated || null +} + +/** + * TERMINAL: mark a confirmed payment session as failed_pairing AND write a pairing_failures row. + * Idempotent: no-op if already terminal (consumed/failed_pairing/expired/abandoned). + * + * Use only for true terminal failures (no fallback path possible): + * - general blast exhausted, no acceptance + * - all blasted mitras explicitly rejected + * - customer cancels mid-search + * - payment session expires before consumption + * + * For intermediate failures that have a fallback CTA available (targeted-mitra reject/timeout + * during a returning-chat flow), use `recordIntermediateFailure` instead — that writes the + * audit row WITHOUT terminating the payment session. Termination is the caller's decision + * (cancel CTA = terminal, fallback-to-blast CTA = stays confirmed). + */ +export const failPaymentSession = async (paymentSessionId, causeTag) => { + if (!Object.values(PairingFailureCause).includes(causeTag)) { + throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 }) + } + + const [existing] = await sql` + SELECT id, customer_id, targeted_mitra_id, amount, status + FROM payment_sessions + WHERE id = ${paymentSessionId} + ` + if (!existing) { + return null + } + // Idempotent: only confirmed sessions transition to failed_pairing here. + // Pending sessions become expired/abandoned via their own paths. + if (existing.status !== PaymentSessionStatus.CONFIRMED) { + return existing + } + + const [updated] = await sql` + UPDATE payment_sessions + SET status = ${PaymentSessionStatus.FAILED_PAIRING} + WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.CONFIRMED} + RETURNING id, customer_id, targeted_mitra_id, amount, status + ` + if (!updated) { + return existing + } + + await recordFailure({ + paymentSessionId, + customerId: existing.customer_id, + targetedMitraId: existing.targeted_mitra_id, + causeTag, + amount: existing.amount, + }) + + return updated +} + +/** + * INTERMEDIATE: write a pairing_failures audit row WITHOUT terminating the payment session. + * + * Used for failures inside a flow that still has a fallback path: targeted "Curhat lagi" + * reject/timeout (customer can fall back to general blast on the same payment), or any + * other intermediate attempt where the payment_session must remain `confirmed` so it can be + * reused. + * + * One payment_session may FK from multiple pairing_failures rows — that's the desired CC + * UX (each attempt lists as its own row in the Failed Pairings table). + * + * Returns the inserted pairing_failures row, or null if the payment session was missing. + */ +export const recordIntermediateFailure = async ({ paymentSessionId, customerId, targetedMitraId = null, causeTag, amount }) => { + if (!Object.values(PairingFailureCause).includes(causeTag)) { + throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 }) + } + + // Loose sanity: the row should exist. If not, fall through with null — caller likely + // already moved on; we'd rather skip the audit than throw mid-callback. + const [existing] = await sql`SELECT id FROM payment_sessions WHERE id = ${paymentSessionId}` + if (!existing) return null + + return recordFailure({ + paymentSessionId, + customerId, + targetedMitraId, + causeTag, + amount, + }) +} + +/** + * Customer-initiated abandonment of a `pending` payment session (e.g. closed payment screen). + * No pairing_failures row is written — only confirmed-but-no-chat counts as a pairing failure. + */ +export const abandonPaymentSession = async (paymentSessionId, customerId) => { + const [existing] = await sql` + SELECT id, customer_id, status FROM payment_sessions WHERE id = ${paymentSessionId} + ` + if (!existing) { + throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) + } + if (existing.customer_id !== customerId) { + throw Object.assign(new Error('Payment session does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 }) + } + if (existing.status !== PaymentSessionStatus.PENDING) { + // Idempotent — already terminal. + return existing + } + const [updated] = await sql` + UPDATE payment_sessions SET status = ${PaymentSessionStatus.ABANDONED} + WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING} + RETURNING id, customer_id, status + ` + return updated || existing +} + +/** + * Background sweeper: + * - pending rows past expires_at → expired (no failure row; never confirmed) + * - confirmed rows past expires_at AND not consumed → failed_pairing with cause = payment_session_expired + */ +export const expireStalePaymentSessions = async () => { + // 1) pending → expired + const expired = await sql` + UPDATE payment_sessions + SET status = ${PaymentSessionStatus.EXPIRED} + WHERE status = ${PaymentSessionStatus.PENDING} + AND expires_at <= NOW() + RETURNING id + ` + + // 2) confirmed-but-stale → failed_pairing. Single atomic UPDATE returns the rows we + // actually flipped (vs. the old SELECT + per-row UPDATE which leaked a TOCTOU window + // with concurrent confirmPaymentSession/consumePaymentSession). Audit-row writes and + // customer notifications then fan out in parallel. + const flipped = await sql` + UPDATE payment_sessions + SET status = ${PaymentSessionStatus.FAILED_PAIRING} + WHERE status = ${PaymentSessionStatus.CONFIRMED} + AND expires_at <= NOW() + RETURNING id, customer_id, targeted_mitra_id, amount + ` + + await Promise.all(flipped.map(async (row) => { + await recordFailure({ + paymentSessionId: row.id, + customerId: row.customer_id, + targetedMitraId: row.targeted_mitra_id, + causeTag: PairingFailureCause.PAYMENT_SESSION_EXPIRED, + amount: row.amount, + }) + // Customer may be on searching/waiting; push terminal PAIRING_FAILED in real time. + // FCM fallback when not WS-connected so they're notified at the OS level. + try { + const wsSent = sendToUser(UserType.CUSTOMER, row.customer_id, { + type: WsMessage.PAIRING_FAILED, + payment_session_id: row.id, + cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED, + }) + if (!wsSent) { + await sendPushNotification(UserType.CUSTOMER, row.customer_id, { + title: 'Sesi gagal', + body: 'Sesi pembayaranmu telah berakhir. Silakan mulai ulang.', + data: { + type: WsMessage.PAIRING_FAILED, + payment_session_id: row.id, + cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED, + }, + }) + } + } catch (err) { + console.error('expireStalePaymentSessions: failed to notify customer', { + paymentSessionId: row.id, customerId: row.customer_id, err, + }) + } + })) + + return { expired: expired.length, failed: flipped.length } +} + +export const getPaymentSession = async (id) => { + const [row] = await sql` + SELECT id, customer_id, amount, duration_minutes, is_free_trial, is_extension, + status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at + FROM payment_sessions + WHERE id = ${id} + ` + return row || null +} diff --git a/backend/src/services/pricing.service.js b/backend/src/services/pricing.service.js index f0b9216..b8aa4d2 100644 --- a/backend/src/services/pricing.service.js +++ b/backend/src/services/pricing.service.js @@ -57,3 +57,19 @@ export const getPricingForCustomer = async (customerId) => { : { eligible: false }, } } + +/** + * Extension pricing tiers. + * + * Same shape as `getPricingForCustomer`, but free trial is NEVER eligible for extensions. + * The customerId is accepted for API symmetry/future tier personalization. + */ +// eslint-disable-next-line no-unused-vars +export const getExtensionPriceTiers = async (customerId) => { + const tiers = await getPriceTiers() + return { + tiers, + free_trial: { eligible: false }, + is_free_trial: false, + } +} diff --git a/backend/test/README.md b/backend/test/README.md new file mode 100644 index 0000000..40d8691 --- /dev/null +++ b/backend/test/README.md @@ -0,0 +1,118 @@ +# Backend tests (Vitest) + +Vitest scaffolding for the Halo Bestie Fastify backend. Three sample tests exist +to demonstrate the patterns; broader coverage will be filled in incrementally. + +## Strategy: schema-isolated remote DB (default) + +The remote dev role on `omv.sjamsani.id` does **not** have `CREATE DATABASE` +privilege, so the chosen isolation mechanism is a separate **schema** inside the +existing `halobestie_clone` database. The migration runs into a `halobestie_test` +schema (driven by `?options=-c search_path=...` on the test DB URL), leaving the +dev `public` schema untouched. + +Valkey isolation uses a separate logical db number (`/1`) on the same instance. + +### Why not Docker? + +Docker availability could not be verified inside the agent sandbox at scaffold +time. A `docker-compose.test.yml` exists for users who prefer ephemeral local +containers — see "Switching to local Docker" below. + +### Why not a separate Postgres database? + +The dev role is non-superuser and lacks `CREATE DATABASE`. Schema isolation gives +us the same isolation guarantee (test tables live in their own namespace) without +requiring a privilege bump. + +## Setup + +1. Copy `.env.test.example` → `.env.test`: + ``` + cp .env.test.example .env.test + ``` + Adjust `TEST_DATABASE_URL` / `TEST_VALKEY_URL` if your dev DB is elsewhere. + +2. (Optional) Verify connectivity: + ``` + node -e "import('postgres').then(({default:p})=>{const s=p(process.env.TEST_DATABASE_URL);s\`SELECT 1\`.then(console.log).finally(()=>s.end())})" + ``` + +3. The `halobestie_test` schema and all test tables are created automatically the + first time `npm test` runs (idempotent — re-running `npm test` is safe). + +## Running + +``` +npm test # one-shot run +npm run test:watch # re-run on file change +npm run test:coverage # plus coverage report under coverage/ +``` + +## Required environment variables + +| Var | Default | Purpose | +|-----|---------|---------| +| `TEST_DATABASE_URL` | `postgresql://halobestie_clone:halobestie_clone@omv.sjamsani.id:5432/halobestie_clone` | Same as dev — schema isolates | +| `TEST_DB_SCHEMA` | `halobestie_test` | Schema name for test tables. Hard-rejected if set to `public` | +| `TEST_VALKEY_URL` | `redis://omv.sjamsani.id:6379/1` | Note the `/1` — separate logical db from dev | +| `AUTH_JWT_SECRET` | (must be ≥ 32 chars) | Signs JWTs the prod `authenticate` plugin verifies. Test value can differ from dev | +| `ACCESS_TOKEN_TTL_SECONDS` | `3600` | Optional | +| `REFRESH_TOKEN_TTL_DAYS` | `30` | Optional | +| `CC_ORIGIN` | `http://localhost:5173` | Required by the internal app's CORS config | + +## Adding a new test + +Templates by type: + +| Test type | Template | Sample | +|-----------|----------|--------| +| Pure service | uses `db()` + fixtures | `test/services/payment.service.test.js` | +| Service with mocked WS/FCM | `vi.mock('../../src/plugins/websocket.js')` at top | `test/services/pairing.service.test.js` | +| Route (HTTP-free via inject) | `app.inject({ method, url, headers, payload })` | `test/routes/client.payment.routes.test.js` | + +Helpers (under `test/helpers/`): +- `db.js` — `db()` returns the shared sql client; `resetDb()` truncates Phase 3.7 + dependent tables; `resetAppConfig()` restores config defaults. +- `valkey.js` — `getTestValkey()` for direct keyspace assertions; `flushTestDb()` to wipe between tests. +- `server.js` — `buildPublic()` / `buildInternal()` for route tests. +- `jwt.js` — `customerJwt(id)`, `mitraJwt(id)`, `ccJwt(id)` mint tokens the prod `authenticate` plugin accepts. `authHeader(token)` builds the header. +- `fixtures.js` — `createCustomer()`, `createMitra({ isOnline })`. + +Patterns to follow (from the sample tests): +- Always import status / cause values from `../../src/constants.js` — never hard-code `'pending'`, `'all_mitras_rejected'`, etc. (See project memory: "Use Enums for Fixed Values".) +- Mock `../../src/plugins/websocket.js` and `../../src/services/notification.service.js` for any test that touches pairing / extension / closure — they fan out via WS + FCM and you don't want either to fire on a real socket / Firebase project. +- Call `resetDb()` in `beforeEach`, `resetAppConfig()` once in `beforeAll` (or in `afterEach` if your test mutates config). + +## Isolation notes + +Tests run **sequentially** (`fileParallelism: false`, `sequence.concurrent: false`) +because they share one DB schema and one Valkey db. If you ever need +parallelism: switch to per-test transactions (`BEGIN` in `beforeEach`, `ROLLBACK` +in `afterEach`) or per-test schemas (`CREATE SCHEMA test_${random}`) and update +`vitest.config.js`. + +## Switching to local Docker + +If you'd rather run an isolated, throwaway Postgres + Valkey on your machine: + +``` +docker compose -f docker-compose.test.yml up -d +# In .env.test: +TEST_DATABASE_URL=postgresql://test:test@localhost:55432/halobestie_test +TEST_DB_SCHEMA=public +TEST_VALKEY_URL=redis://localhost:56379/0 + +npm test +docker compose -f docker-compose.test.yml down -v +``` + +The non-default ports (55432, 56379) avoid clashing with any local Postgres / +Redis you have running. Note `TEST_DB_SCHEMA=public` is OK in the Docker case +because the whole database is throwaway — schema isolation is only required +when sharing with the dev DB. + +## Safety guards + +- `setup.js` hard-fails if `TEST_DB_SCHEMA === 'public'` AND `TEST_DATABASE_URL` looks like the dev DB. (Schema reuse on the dev DB would clobber dev tables.) +- `setup.js` hard-fails if any required env var is missing — silent fallback to dev URLs would be catastrophic. +- The migration runs as a **child process** (not in-process) so its `sql.end()` at the bottom doesn't tear down the singleton this test process shares with services. diff --git a/backend/test/helpers/db.js b/backend/test/helpers/db.js new file mode 100644 index 0000000..896ec9c --- /dev/null +++ b/backend/test/helpers/db.js @@ -0,0 +1,81 @@ +import { getDb } from '../../src/db/client.js' + +/** + * Single shared sql client used by tests. Same singleton the services use, since + * setup.js has already rewritten DATABASE_URL to point at the test schema. + */ +export const db = () => getDb() + +/** + * Truncate Phase 3.7-relevant tables between tests. + * + * Order matters: pairing_failures FK → payment_sessions; chat_request_notifications + * FK → chat_sessions; customer_transactions FK → chat_sessions; etc. Use CASCADE so + * we don't have to maintain the topological order when tables get added. + * + * We deliberately do NOT truncate roles / control_center_users / mitras / customers + * — those are seeded once per test file by fixtures and re-truncating them would + * force every test to re-create users (slow + noisy). + */ +const TRUNCATE_TABLES = [ + 'pairing_failures', + 'payment_sessions', + 'chat_request_notifications', + 'session_extensions', + 'session_closures', + 'session_sensitivity_log', + 'chat_messages', + 'customer_transactions', + 'chat_sessions', + 'auth_sessions', + 'otp_requests', + 'mitra_online_logs', + 'mitra_online_status', +] + +export const resetDb = async () => { + const sql = db() + // RESTART IDENTITY is a no-op for UUID PKs but cheap; CASCADE handles any future FK additions. + await sql.unsafe(`TRUNCATE TABLE ${TRUNCATE_TABLES.join(', ')} RESTART IDENTITY CASCADE`) +} + +/** + * Wipe the slow-changing tables too — call sparingly (a single test that needs to + * verify "no users" semantics, or in afterAll teardown). + */ +export const resetDbHard = async () => { + const sql = db() + await sql.unsafe( + `TRUNCATE TABLE ${TRUNCATE_TABLES.join(', ')}, mitras, customers, control_center_users, roles RESTART IDENTITY CASCADE` + ) +} + +/** + * Drop and re-seed the configurable app_config rows back to their canonical defaults. + * Tests that mutate config (e.g. flipping free_trial_enabled) call this in afterEach. + */ +export const resetAppConfig = async () => { + const sql = db() + // Restore the same defaults the migration sets. Using ON CONFLICT … DO UPDATE so a + // test-mutated row gets clobbered back, not just left alone. + const defaults = [ + ['anonymity', { enabled: false }], + ['max_customers_per_mitra', { value: 3 }], + ['free_trial_enabled', { value: true }], + ['free_trial_duration_minutes', { value: 5 }], + ['extension_timeout_seconds', { value: 60 }], + ['early_end_mitra_enabled', { value: false }], + ['early_end_customer_enabled', { value: false }], + ['payment_session_timeout_minutes', { value: 20 }], + ['returning_chat_confirmation_timeout_seconds', { value: 20 }], + ['extension_default_action_on_timeout', { value: 'auto_approve' }], + ['pairing_blast_timeout_seconds', { value: 60 }], + ] + for (const [key, value] of defaults) { + await sql` + INSERT INTO app_config (key, value, updated_at) + VALUES (${key}, ${sql.json(value)}, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + ` + } +} diff --git a/backend/test/helpers/fixtures.js b/backend/test/helpers/fixtures.js new file mode 100644 index 0000000..ffc43bf --- /dev/null +++ b/backend/test/helpers/fixtures.js @@ -0,0 +1,64 @@ +import { randomUUID } from 'node:crypto' +import { db, resetAppConfig } from './db.js' + +/** + * Insert a customer row. Defaults to the schema after the Phase 3.4 auth rewrite + * (display_name nullable, is_anonymous defaults true). + */ +export const createCustomer = async ({ + id = randomUUID(), + callName = `TestCust-${id.slice(0, 6)}`, + phone = null, + isAnonymous = false, +} = {}) => { + const sql = db() + const [row] = await sql` + INSERT INTO customers (id, display_name, phone, is_anonymous) + VALUES (${id}, ${callName}, ${phone}, ${isAnonymous}) + RETURNING id, display_name, phone, is_anonymous, created_at + ` + return row +} + +/** + * Insert a mitra row. If `isOnline` is true, also creates the mitra_online_status row + * so pairing.findAvailableMitras includes it. + */ +export const createMitra = async ({ + id = randomUUID(), + callName = `TestMitra-${id.slice(0, 6)}`, + phone = null, + isActive = true, + isOnline = false, +} = {}) => { + const sql = db() + // mitras.phone is NOT NULL UNIQUE — synthesize a unique phone if not given. + const finalPhone = phone || `+62800${Math.floor(Math.random() * 1e10).toString().padStart(10, '0')}` + const [row] = await sql` + INSERT INTO mitras (id, display_name, phone, is_active) + VALUES (${id}, ${callName}, ${finalPhone}, ${isActive}) + RETURNING id, display_name, phone, is_active, created_at + ` + if (isOnline) { + const now = new Date() + await sql` + INSERT INTO mitra_online_status (mitra_id, is_online, last_online_at, last_heartbeat_at, updated_at) + VALUES (${id}, true, ${now}, ${now}, ${now}) + ON CONFLICT (mitra_id) DO UPDATE + SET is_online = true, last_online_at = ${now}, last_heartbeat_at = ${now}, updated_at = ${now} + ` + } + return row +} + +/** + * Reset app_config rows to their canonical defaults. Tests that mutate config call + * this in afterEach (or rely on the global beforeEach in resetAll). + */ +export const seedDefaultConfig = () => resetAppConfig() + +/** + * Convenience: full reset between tests. Truncates Phase 3.7 tables, restores + * default config rows. + */ +export { resetDb, resetDbHard, resetAppConfig } from './db.js' diff --git a/backend/test/helpers/jwt.js b/backend/test/helpers/jwt.js new file mode 100644 index 0000000..97c1d83 --- /dev/null +++ b/backend/test/helpers/jwt.js @@ -0,0 +1,42 @@ +import jwt from 'jsonwebtoken' +import { randomUUID } from 'node:crypto' +import { UserType } from '../../src/constants.js' + +/** + * Mint a JWT that the production `authenticate` plugin will accept. Mirrors the + * payload shape from src/services/token.service.js#signAccessToken. + * + * We deliberately do NOT call issueTokens (which writes an auth_sessions row) so + * tests stay independent of that table. The access-token verification path in + * production never reads the DB — it only validates the JWT signature + claims. + * + * sessionId defaults to a random UUID; pass an explicit one if a test asserts on + * the session_id value. + */ +const sign = ({ userType, userId, sessionId = randomUUID() }) => { + const secret = process.env.AUTH_JWT_SECRET + if (!secret || secret.length < 32) { + throw new Error('AUTH_JWT_SECRET missing or too short for test JWT minting') + } + return jwt.sign( + { user_type: userType, session_id: sessionId }, + secret, + { + algorithm: 'HS256', + expiresIn: 3600, + subject: userId, + }, + ) +} + +export const customerJwt = (userId, opts = {}) => + sign({ userType: UserType.CUSTOMER, userId, ...opts }) + +export const mitraJwt = (userId, opts = {}) => + sign({ userType: UserType.MITRA, userId, ...opts }) + +export const ccJwt = (userId, opts = {}) => + sign({ userType: UserType.CC_USER, userId, ...opts }) + +/** `Authorization: Bearer …` header builder for app.inject calls. */ +export const authHeader = (token) => ({ authorization: `Bearer ${token}` }) diff --git a/backend/test/helpers/server.js b/backend/test/helpers/server.js new file mode 100644 index 0000000..b45d41d --- /dev/null +++ b/backend/test/helpers/server.js @@ -0,0 +1,25 @@ +/** + * Build the public or internal Fastify app for in-process testing. + * + * Tests use `app.inject({ method, url, headers, payload })` to issue requests — + * this skips the HTTP layer entirely (no port binding, no socket overhead) and + * returns a typed response object. + * + * Each test file should call `buildPublic()` / `buildInternal()` in beforeAll and + * `await app.close()` in afterAll. Re-using the same app across tests in a file + * is fine — the DB state is what's reset between tests. + */ + +export const buildPublic = async () => { + const { buildPublicApp } = await import('../../src/app.public.js') + const app = await buildPublicApp() + await app.ready() + return app +} + +export const buildInternal = async () => { + const { buildInternalApp } = await import('../../src/app.internal.js') + const app = await buildInternalApp() + await app.ready() + return app +} diff --git a/backend/test/helpers/valkey.js b/backend/test/helpers/valkey.js new file mode 100644 index 0000000..11943c2 --- /dev/null +++ b/backend/test/helpers/valkey.js @@ -0,0 +1,27 @@ +import Redis from 'ioredis' + +let testClient + +/** + * Test-scoped Valkey client (separate db number from dev — see .env.test). + * Tests can use this directly for keyspace assertions, or just rely on the services + * which read VALKEY_URL via the production plugin (now pointing at the test db). + */ +export const getTestValkey = () => { + if (!testClient) { + testClient = new Redis(process.env.TEST_VALKEY_URL || process.env.VALKEY_URL) + } + return testClient +} + +export const flushTestDb = async () => { + const c = getTestValkey() + await c.flushdb() +} + +export const closeTestValkey = async () => { + if (testClient) { + testClient.disconnect() + testClient = null + } +} diff --git a/backend/test/routes/client.payment.routes.test.js b/backend/test/routes/client.payment.routes.test.js new file mode 100644 index 0000000..45110d9 --- /dev/null +++ b/backend/test/routes/client.payment.routes.test.js @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest' + +// The public app pulls in the websocket plugin which opens real WS upgrades on +// requests — out of scope for HTTP route tests. Mock it to a no-op. +vi.mock('../../src/plugins/websocket.js', () => ({ + sendToUser: vi.fn(() => false), + sendToSessionParticipant: vi.fn(() => false), + registerWebSocketPlugin: vi.fn(async () => {}), + registerWebSocketRoute: vi.fn(), + isUserOnlineWs: vi.fn(() => false), + getSessionConnections: vi.fn(() => ({})), +})) + +vi.mock('../../src/services/notification.service.js', () => ({ + sendPushNotification: vi.fn(async () => true), + registerDeviceToken: vi.fn(async () => {}), +})) + +const { buildPublic } = await import('../helpers/server.js') +const { resetDb, resetAppConfig, db } = await import('../helpers/db.js') +const { createCustomer } = await import('../helpers/fixtures.js') +const { customerJwt, authHeader } = await import('../helpers/jwt.js') +const { PaymentSessionStatus } = await import('../../src/constants.js') + +describe('POST /api/client/payment-sessions', () => { + let app + let customer + let token + + beforeAll(async () => { + await resetAppConfig() + app = await buildPublic() + }) + + beforeEach(async () => { + await resetDb() + customer = await createCustomer({ callName: 'PaymentTester' }) + token = customerJwt(customer.id) + }) + + afterAll(async () => { + await app?.close() + }) + + it('happy path returns 201 + a pending payment-session row', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/client/payment-sessions', + headers: authHeader(token), + payload: { duration_minutes: 15 }, + }) + + expect(res.statusCode).toBe(201) + const body = res.json() + expect(body.success).toBe(true) + expect(body.data.status).toBe(PaymentSessionStatus.PENDING) + expect(body.data.duration_minutes).toBe(15) + // Default tier for 15min from migrate.js is 30000 — but the eligibility logic + // also needs `free_trial_enabled` to be true (default) AND no prior tx. Customer is + // brand-new so they get the trial → amount=0, is_free_trial=true. Verify accordingly. + expect(body.data.is_free_trial).toBe(true) + expect(body.data.amount).toBe(0) + expect(body.data.is_extension).toBe(false) + + // Verify persistence + const sql = db() + const [row] = await sql`SELECT * FROM payment_sessions WHERE id = ${body.data.id}` + expect(row).toBeDefined() + expect(row.customer_id).toBe(customer.id) + }) + + it('POST /:id/confirm transitions the row and returns 200', async () => { + // Create a paid (non-trial) tier so the row has a non-zero amount and we exercise the + // confirm path with a "real" payment. Insert a transaction first so the customer is + // ineligible for the free trial. + const sql = db() + // Bootstrap: create a fake prior chat session + transaction so the customer is no + // longer eligible for the free trial. (The simpler alternative — flipping + // free_trial_enabled in app_config — would impact other tests.) + const [prior] = await sql` + INSERT INTO chat_sessions (customer_id, status, duration_minutes, price) + VALUES (${customer.id}, 'completed', 15, 30000) + RETURNING id + ` + await sql` + INSERT INTO customer_transactions (customer_id, session_id, type, amount) + VALUES (${customer.id}, ${prior.id}, 'paid', 30000) + ` + + const createRes = await app.inject({ + method: 'POST', + url: '/api/client/payment-sessions', + headers: authHeader(token), + payload: { duration_minutes: 15 }, + }) + expect(createRes.statusCode).toBe(201) + const created = createRes.json().data + expect(created.status).toBe(PaymentSessionStatus.PENDING) + expect(created.is_free_trial).toBe(false) + expect(created.amount).toBe(30000) + + const confirmRes = await app.inject({ + method: 'POST', + url: `/api/client/payment-sessions/${created.id}/confirm`, + headers: authHeader(token), + payload: {}, + }) + + expect(confirmRes.statusCode).toBe(200) + const confirmed = confirmRes.json().data + expect(confirmed.id).toBe(created.id) + expect(confirmed.status).toBe(PaymentSessionStatus.CONFIRMED) + expect(confirmed.confirmed_at).toBeTruthy() + }) +}) diff --git a/backend/test/services/pairing.service.test.js b/backend/test/services/pairing.service.test.js new file mode 100644 index 0000000..afaf98c --- /dev/null +++ b/backend/test/services/pairing.service.test.js @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from 'vitest' + +/** + * The pairing service fans out via the websocket plugin (`sendToUser`) and FCM + * (`sendPushNotification`). We mock both so tests assert on intent (which event + * was sent to which user) without needing a real WS client or FCM credentials. + * + * Mocks must be declared at the top level so vi.mock hoists them above the + * service imports. + */ +vi.mock('../../src/plugins/websocket.js', () => ({ + // Default: pretend the user is not connected so the service falls back to FCM — + // matches the "customer is in the app but socket isn't open" path. + sendToUser: vi.fn(() => false), + sendToSessionParticipant: vi.fn(() => false), + registerWebSocketPlugin: vi.fn(), + registerWebSocketRoute: vi.fn(), + isUserOnlineWs: vi.fn(() => false), + getSessionConnections: vi.fn(() => ({})), +})) + +vi.mock('../../src/services/notification.service.js', () => ({ + sendPushNotification: vi.fn(async () => true), + registerDeviceToken: vi.fn(async () => {}), +})) + +// Imports BELOW the mocks (vi.mock is hoisted, but keeping the order explicit aids +// readability and matches Vitest docs). +const { sendToUser } = await import('../../src/plugins/websocket.js') +const { + createPairingRequest, + declinePairingRequest, + cancelPairingRequest, +} = await import('../../src/services/pairing.service.js') +const { createPaymentSession, confirmPaymentSession } = await import('../../src/services/payment.service.js') +const { + WsMessage, + PairingFailureCause, + PaymentSessionStatus, + SessionStatus, +} = await import('../../src/constants.js') +const { db, resetDb, resetAppConfig } = await import('../helpers/db.js') +const { createCustomer, createMitra } = await import('../helpers/fixtures.js') + +describe('pairing.service', () => { + let customer + let mitra + + beforeAll(async () => { + await resetAppConfig() + }) + + beforeEach(async () => { + await resetDb() + sendToUser.mockClear() + customer = await createCustomer({ callName: 'Alice' }) + mitra = await createMitra({ callName: 'MitraOne', isOnline: true }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('single-recipient general blast → mitra declines → terminates with ALL_MITRAS_REJECTED', async () => { + // Arrange: confirmed, non-targeted payment session. + const pay = await createPaymentSession({ + customerId: customer.id, + durationMinutes: 15, + amount: 30000, + }) + await confirmPaymentSession(pay.id, customer.id) + + // Act: customer fires the general blast — only one mitra is online. + const session = await createPairingRequest(customer.id, { + paymentSessionId: pay.id, + }) + expect(session.status).toBe(SessionStatus.PENDING_ACCEPTANCE) + + // The single recipient declines. With the /simplify fix this is correctly + // classified as a general-blast all-rejected, NOT a targeted reject. + await declinePairingRequest(session.id, mitra.id) + + // Assert: pairing_failures row carries ALL_MITRAS_REJECTED, not TARGETED_*. + const sql = db() + const failures = await sql` + SELECT cause_tag FROM pairing_failures WHERE payment_session_id = ${pay.id} + ` + expect(failures).toHaveLength(1) + expect(failures[0].cause_tag).toBe(PairingFailureCause.ALL_MITRAS_REJECTED) + + // Payment session is terminal (failed_pairing) — terminal failures consume the payment. + const [paySession] = await sql`SELECT status FROM payment_sessions WHERE id = ${pay.id}` + expect(paySession.status).toBe(PaymentSessionStatus.FAILED_PAIRING) + + // Customer was notified with PAIRING_FAILED carrying the same cause tag. + const pairingFailedCalls = sendToUser.mock.calls.filter( + ([, , data]) => data?.type === WsMessage.PAIRING_FAILED, + ) + expect(pairingFailedCalls).toHaveLength(1) + expect(pairingFailedCalls[0][2].cause_tag).toBe(PairingFailureCause.ALL_MITRAS_REJECTED) + }) + + it('cancelPairingRequest does NOT push PAIRING_FAILED to the customer', async () => { + // Arrange: a confirmed payment + an in-flight pairing request the customer is about to cancel. + const pay = await createPaymentSession({ + customerId: customer.id, + durationMinutes: 15, + amount: 30000, + }) + await confirmPaymentSession(pay.id, customer.id) + const session = await createPairingRequest(customer.id, { + paymentSessionId: pay.id, + }) + + // Act: customer cancels. + await cancelPairingRequest(session.id, customer.id) + + // Assert: the customer must NOT receive a PAIRING_FAILED event for their own cancel. + // Mitras still get CHAT_REQUEST_CLOSED (that's the dismiss event) — we only assert on + // the customer-targeted events. + const customerEvents = sendToUser.mock.calls.filter( + // sendToUser signature: (userType, userId, data) + ([userType, userId]) => userId === customer.id, + ) + const customerEventTypes = customerEvents.map(([, , data]) => data?.type) + expect(customerEventTypes).not.toContain(WsMessage.PAIRING_FAILED) + + // Payment session is still terminated (CUSTOMER_CANCELLED) — the failure row exists + // for ops accounting, just no real-time push to the customer who initiated the cancel. + const sql = db() + const failures = await sql`SELECT cause_tag FROM pairing_failures WHERE payment_session_id = ${pay.id}` + expect(failures).toHaveLength(1) + expect(failures[0].cause_tag).toBe(PairingFailureCause.CUSTOMER_CANCELLED) + }) +}) diff --git a/backend/test/services/payment.service.test.js b/backend/test/services/payment.service.test.js new file mode 100644 index 0000000..19b8915 --- /dev/null +++ b/backend/test/services/payment.service.test.js @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest' +import { + createPaymentSession, + confirmPaymentSession, + getPaymentSession, +} from '../../src/services/payment.service.js' +import { PaymentSessionStatus } from '../../src/constants.js' +import { resetDb, resetAppConfig } from '../helpers/db.js' +import { createCustomer } from '../helpers/fixtures.js' + +describe('payment.service', () => { + let customer + let otherCustomer + + beforeAll(async () => { + await resetAppConfig() + }) + + beforeEach(async () => { + await resetDb() + customer = await createCustomer({ callName: 'Alice' }) + otherCustomer = await createCustomer({ callName: 'Bob' }) + }) + + afterAll(async () => { + // Leave the seeded users alone for the next test file's speed. + }) + + it('createPaymentSession writes a row with status pending and expires_at in the future', async () => { + const before = Date.now() + const session = await createPaymentSession({ + customerId: customer.id, + durationMinutes: 15, + amount: 30000, + }) + + expect(session.status).toBe(PaymentSessionStatus.PENDING) + expect(session.customer_id).toBe(customer.id) + expect(session.duration_minutes).toBe(15) + expect(session.amount).toBe(30000) + expect(session.is_free_trial).toBe(false) + expect(session.is_extension).toBe(false) + expect(new Date(session.expires_at).getTime()).toBeGreaterThan(before) + + // Verify it's actually persisted (not just returned from the INSERT) + const reloaded = await getPaymentSession(session.id) + expect(reloaded.id).toBe(session.id) + expect(reloaded.status).toBe(PaymentSessionStatus.PENDING) + }) + + it('confirmPaymentSession transitions pending → confirmed', async () => { + const session = await createPaymentSession({ + customerId: customer.id, + durationMinutes: 30, + amount: 60000, + }) + expect(session.status).toBe(PaymentSessionStatus.PENDING) + + const confirmed = await confirmPaymentSession(session.id, customer.id) + + expect(confirmed.status).toBe(PaymentSessionStatus.CONFIRMED) + expect(confirmed.confirmed_at).toBeTruthy() + expect(new Date(confirmed.confirmed_at).getTime()).toBeGreaterThan(0) + }) + + it('confirmPaymentSession throws when the session belongs to a different customer', async () => { + const session = await createPaymentSession({ + customerId: customer.id, + durationMinutes: 15, + amount: 30000, + }) + + await expect( + confirmPaymentSession(session.id, otherCustomer.id), + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + statusCode: 403, + }) + + // Row should still be pending — the failed confirm must not have side effects. + const reloaded = await getPaymentSession(session.id) + expect(reloaded.status).toBe(PaymentSessionStatus.PENDING) + expect(reloaded.confirmed_at).toBeNull() + }) +}) diff --git a/backend/test/setup.js b/backend/test/setup.js new file mode 100644 index 0000000..ce8be01 --- /dev/null +++ b/backend/test/setup.js @@ -0,0 +1,110 @@ +/** + * Vitest global setup. Runs once per test file before any test bodies. + * + * Responsibilities: + * 1. Load .env.test (falls back to env vars already in process.env if missing). + * 2. Override DATABASE_URL / VALKEY_URL with the *test* equivalents BEFORE any + * backend service modules are imported — services capture `getDb()` at module + * load, so this rewrite must happen first. + * 3. Run the migration against the test schema (idempotent — single migrate.js). + * 4. Provide a `beforeEach` truncate hook for any test that imports the helper. + * + * Why this file is small: helpers live under test/helpers/* and are imported lazily + * by individual test files. This file is intentionally just env setup + migrate. + */ + +import { existsSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { config as loadDotenv } from 'dotenv' +import { spawnSync } from 'node:child_process' +import { afterAll, beforeAll } from 'vitest' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const backendRoot = resolve(__dirname, '..') + +// 1. Load .env.test if it exists +const envTestPath = resolve(backendRoot, '.env.test') +if (existsSync(envTestPath)) { + loadDotenv({ path: envTestPath }) +} + +// 2. Validate required env (fail fast — silent fallback to dev DB would be catastrophic) +const required = ['TEST_DATABASE_URL', 'TEST_VALKEY_URL', 'AUTH_JWT_SECRET'] +for (const key of required) { + if (!process.env[key]) { + throw new Error( + `Missing required test env var: ${key}. Copy .env.test.example → .env.test or set it in your shell.` + ) + } +} +if (process.env.AUTH_JWT_SECRET.length < 32) { + throw new Error('AUTH_JWT_SECRET must be at least 32 chars') +} + +const TEST_SCHEMA = process.env.TEST_DB_SCHEMA || 'halobestie_test' +if (TEST_SCHEMA === 'public') { + // Hard guard: schema isolation only works if we don't reuse the dev `public` schema. + throw new Error( + `TEST_DB_SCHEMA must not be "public" (would clobber dev tables). ` + + `Set TEST_DB_SCHEMA to something like "halobestie_test".` + ) +} + +// 3. Build the schema-scoped URL used by getDb() in services. +// The `?options=-c search_path=...` query param tells Postgres to set search_path +// on every new connection. All CREATE TABLE / INSERT / SELECT then default to the +// test schema, leaving the dev `public` schema untouched. +const baseTestUrl = process.env.TEST_DATABASE_URL +const sep = baseTestUrl.includes('?') ? '&' : '?' +const scopedTestUrl = `${baseTestUrl}${sep}options=${encodeURIComponent(`-c search_path=${TEST_SCHEMA},public`)}` + +// CRITICAL: rewrite the env vars services read at module load time. Must happen before +// any `import { ... } from '../src/services/...'` in a test or helper. +process.env.DATABASE_URL = scopedTestUrl +process.env.VALKEY_URL = process.env.TEST_VALKEY_URL + +beforeAll(async () => { + // Ensure the schema exists. Use a one-shot connection that's NOT the singleton. + const { default: postgres } = await import('postgres') + const bootstrap = postgres(process.env.TEST_DATABASE_URL) + try { + await bootstrap`CREATE SCHEMA IF NOT EXISTS ${bootstrap(TEST_SCHEMA)}` + } finally { + await bootstrap.end() + } + + // Run the migration via a child process so we don't conflict with the singleton + // sql client this test process will use. migrate.js calls sql.end() at the bottom, + // which would tear down the shared client if invoked in-process. + const result = spawnSync( + process.execPath, + [resolve(backendRoot, 'src/db/migrate.js')], + { + env: { + ...process.env, + DATABASE_URL: scopedTestUrl, + }, + cwd: backendRoot, + encoding: 'utf8', + } + ) + if (result.status !== 0) { + throw new Error( + `Test migration failed (exit ${result.status}):\n` + + `stdout: ${result.stdout}\nstderr: ${result.stderr}` + ) + } +}, 60_000) + +afterAll(async () => { + // Best-effort cleanup of any singletons opened by the test process. Each helper that + // opens a connection registers its own teardown; this is a safety net. + try { + const { getDb } = await import('../src/db/client.js') + const sql = getDb() + await sql.end({ timeout: 5 }) + } catch { + // Singleton was never created — fine. + } +}) diff --git a/backend/vitest.config.js b/backend/vitest.config.js new file mode 100644 index 0000000..607d82a --- /dev/null +++ b/backend/vitest.config.js @@ -0,0 +1,24 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + setupFiles: ['./test/setup.js'], + // Sequential execution: tests share a single test schema and Valkey db, so we + // serialize to keep state predictable. Switch to per-test transactions or per-test + // schema if you need parallelism later. + fileParallelism: false, + sequence: { concurrent: false }, + // Some tests wait on backend timers (pairing blast, payment expiry sweep, etc). + testTimeout: 30_000, + hookTimeout: 30_000, + include: ['test/**/*.test.js'], + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + include: ['src/**/*.js'], + exclude: ['src/server.js', 'src/db/migrate.js', 'src/db/seed.js'], + }, + }, +}) diff --git a/client_app/.maestro/README.md b/client_app/.maestro/README.md new file mode 100644 index 0000000..1ae1dbc --- /dev/null +++ b/client_app/.maestro/README.md @@ -0,0 +1,111 @@ +# client_app Maestro flows + +End-to-end UI automation for the customer Flutter app using [Maestro](https://maestro.mobile.dev). Single-emulator + curl-as-mitra pattern — the customer app is driven by real Maestro touches; the mitra side is simulated via backend API calls fired from `runScript` steps. + +## One-time install + +Maestro is a global CLI (not a project dependency). Install on your dev machine once: + +```bash +curl -Ls "https://get.maestro.mobile.dev" | bash +``` + +Verify with `maestro --version`. See the [Maestro install docs](https://maestro.mobile.dev/getting-started/installing-maestro) for Homebrew / chocolatey / Docker alternatives. + +You also need: +- `adb` on your PATH (comes with Android Studio's platform-tools) +- `jq` for the helper scripts (`apt install jq` / `brew install jq`) +- One Android emulator OR one connected device — **only one at a time** (per project decision) + +## Folder layout + +``` +.maestro/ +├── README.md # this file +├── config.yaml # shared env: app IDs, backend URL, test credentials +├── flows/ # the YAML test scripts +│ ├── 01_smoke.yaml +│ ├── 02_cta_disabled_when_no_mitra.yaml +│ └── 03_payment_to_chat_happy.yaml +└── scripts/ # bash helpers invoked by `runScript` steps + ├── mitra_accept_latest.sh + └── force_all_mitras_offline.sh +``` + +## Configure for your environment + +Edit `.maestro/config.yaml` and fill in: +- `BACKEND_URL` — must match the `--dart-define=API_BASE_URL=...` value the installed APK was built with +- `TEST_MITRA_ID` and `TEST_MITRA_JWT` — used by the curl harness to "accept" requests from the customer's blast + +The config file is committed because the values are dev-environment defaults. Sensitive credentials (real JWTs, CC operator tokens) should be passed at runtime instead — see "Per-machine overrides" below. + +## Run a flow + +Single emulator (typical case — Maestro auto-picks the only attached device): + +```bash +# from repo root or anywhere +maestro test client_app/.maestro/flows/01_smoke.yaml + +# or run all flows in the directory +maestro test client_app/.maestro/flows/ +``` + +If both an emulator and a real device happen to be connected, list them and pick one explicitly: + +```bash +adb devices # list attached devices +maestro --device emulator-5554 test client_app/.maestro/flows/01_smoke.yaml +``` + +## Per-machine overrides + +Override any config.yaml value at runtime with `--env`: + +```bash +maestro test \ + --env BACKEND_URL=http://192.168.99.10:3000 \ + --env TEST_MITRA_JWT=eyJhbGc... \ + client_app/.maestro/flows/03_payment_to_chat_happy.yaml +``` + +Or export shell variables — `runScript` steps inherit them: + +```bash +export CC_JWT=eyJhbGc... +maestro test client_app/.maestro/flows/02_cta_disabled_when_no_mitra.yaml +``` + +## Single-emulator + curl pattern + +Phase 3.7 flows often need a customer + a mitra acting in concert. Instead of running two emulators (RAM-heavy, flaky), the flows drive the customer side with Maestro and **simulate the mitra via backend curl calls**: + +1. Maestro flow drives customer up to the "Mencari Bestie..." state +2. `runScript: ../scripts/mitra_accept_latest.sh` fires `POST /api/mitra/chat-requests/:id/accept` against the backend, using a pre-minted mitra JWT +3. Maestro flow asserts the customer screen transitions to "Bestie Ditemukan" via the WS round-trip + +This works for ~90% of multi-actor scenarios — including all the Section D ("Curhat lagi") and Section J ("Mitra goes offline mid-session") tests in [phase3.7-testing.md](../../requirement/phase3.7-testing.md). The 10% that needs both UIs running (e.g., asserting the mitra-side overlay countdown displays correctly) is in [`mitra_app/.maestro/`](../../mitra_app/.maestro/) and runs separately. + +## Adding a new flow + +Pick a Phase 3.7 testing checklist scenario (see [phase3.7-testing.md](../../requirement/phase3.7-testing.md)), then: + +1. Copy an existing flow as a template (e.g., `03_payment_to_chat_happy.yaml`) +2. Update the pre-req comment, the steps, and the assertions +3. If you need a "second actor" action, add a bash helper under `scripts/` and call it via `runScript:` +4. If you need new env vars, add them to `config.yaml` with sensible defaults + +## Tips + +- **Find the right text to tap on** — `maestro studio` opens a live UI inspector showing every visible label/widget. Run it while the app is on the screen you care about. +- **Slow it down for debugging** — `maestro test --debug-output ./debug flows/foo.yaml` saves screenshots + logs per step. +- **Add flows incrementally** — Maestro's reload-on-save in `maestro studio` makes iteration fast. +- **Don't commit screenshots / debug output** — add `.maestro/output/` and `.maestro/screenshots/` to `.gitignore` if you generate them locally. + +## Troubleshooting + +- `maestro: device not found` → run `adb devices`; if empty, start an emulator (`emulator -avd `) or plug in a USB device with debugging enabled. +- `Element not visible` errors → use `maestro studio` to inspect actual labels — they may have changed since the flow was written. +- Flow hangs at `assertVisible` waiting for backend → check `BACKEND_URL` matches the APK's build-time value (`grep API_BASE_URL build.gradle`). +- `runScript` exits non-zero → run the script directly to see its error: `bash client_app/.maestro/scripts/mitra_accept_latest.sh`. Most often a missing env var or stale JWT. diff --git a/client_app/.maestro/config.yaml b/client_app/.maestro/config.yaml new file mode 100644 index 0000000..0f3bce6 --- /dev/null +++ b/client_app/.maestro/config.yaml @@ -0,0 +1,25 @@ +# Shared variables for all client_app Maestro flows. +# +# Override at runtime with `maestro test --env KEY=value` or by setting shell env vars. +# See README.md for full setup + per-machine overrides. + +env: + # App identifiers — Android / iOS bundle IDs picked up automatically by `appId:` in flows. + APP_ID_ANDROID: com.halobestie.client.client_app + APP_ID_IOS: com.halobestie.client.clientApp + + # Backend the app talks to — must match what the installed APK was built with + # (the `--dart-define=API_BASE_URL=...` value at build time). + BACKEND_URL: http://192.168.88.247:3000 + BACKEND_INTERNAL_URL: http://192.168.88.247:3001 + + # Test customer credentials — must exist in the customers table on the target backend. + # These are read by helper scripts (see .maestro/scripts/) when seeding state. + CUSTOMER_PHONE: "+628100000001" + CUSTOMER_OTP: "123456" # OTP stub mode emits a known code per phone + + # If you need to drive a "second actor" (e.g., the mitra accepting a blast), the test + # flows curl the backend directly using these credentials. See README §"Single-emulator + # + curl pattern" for details. + TEST_MITRA_ID: "REPLACE-WITH-A-REAL-MITRA-UUID" + TEST_MITRA_JWT: "REPLACE-WITH-A-VALID-MITRA-JWT" diff --git a/client_app/.maestro/flows/01_smoke.yaml b/client_app/.maestro/flows/01_smoke.yaml new file mode 100644 index 0000000..0d5c53f --- /dev/null +++ b/client_app/.maestro/flows/01_smoke.yaml @@ -0,0 +1,14 @@ +# Smoke test: launch the app and assert the home screen renders. +# Use this flow first to verify Maestro can talk to your device/emulator at all. +# +# Run: +# maestro test client_app/.maestro/flows/01_smoke.yaml +# +# Pre-req: client_app debug APK installed on the connected device, signed in as a customer. +appId: ${APP_ID_ANDROID} # Maestro uses APP_ID_IOS automatically when running against an iOS device +--- +- launchApp: + clearState: false # keep existing auth — set to true to test cold-start onboarding +- assertVisible: + text: "Mulai Curhat" + timeout: 10000 # 10s — give Riverpod time to hydrate the home screen diff --git a/client_app/.maestro/flows/02_cta_disabled_when_no_mitra.yaml b/client_app/.maestro/flows/02_cta_disabled_when_no_mitra.yaml new file mode 100644 index 0000000..7917c0d --- /dev/null +++ b/client_app/.maestro/flows/02_cta_disabled_when_no_mitra.yaml @@ -0,0 +1,21 @@ +# Verifies the home CTA is disabled and shows the "no bestie available" subtitle +# when no mitra is online. +# +# Pre-req: NO mitras are online on the target backend. Use the helper script to +# force everyone offline before running: +# bash client_app/.maestro/scripts/force_all_mitras_offline.sh +# +# Run: +# maestro test client_app/.maestro/flows/02_cta_disabled_when_no_mitra.yaml +appId: ${APP_ID_ANDROID} +--- +- launchApp: + clearState: false +- assertVisible: "Mulai Curhat" +- assertVisible: "Belum ada bestie tersedia" +# CTA is disabled — tapping it should be a no-op. +- tapOn: "Mulai Curhat" +# We should still be on the home screen, NOT on the payment screen. +- assertNotVisible: "Bayar" +- assertNotVisible: "Mulai" +- assertVisible: "Mulai Curhat" diff --git a/client_app/.maestro/flows/03_payment_to_chat_happy.yaml b/client_app/.maestro/flows/03_payment_to_chat_happy.yaml new file mode 100644 index 0000000..85d7404 --- /dev/null +++ b/client_app/.maestro/flows/03_payment_to_chat_happy.yaml @@ -0,0 +1,42 @@ +# Happy-path golden flow: customer taps CTA → payment screen → confirm → +# searching → mitra accepts (via curl harness) → chat screen. +# +# This is the canonical demonstration of the "single-emulator + curl-as-mitra" +# pattern. The customer side is real Maestro; the mitra side is a backend API call +# fired from a runScript step. +# +# Pre-req: +# 1. At least one mitra is ONLINE on the target backend +# 2. TEST_MITRA_ID and TEST_MITRA_JWT in .maestro/config.yaml point at that mitra +# 3. The mitra has spare capacity (active_session_count < max_customers_per_mitra) +# +# Run: +# maestro test client_app/.maestro/flows/03_payment_to_chat_happy.yaml +appId: ${APP_ID_ANDROID} +--- +- launchApp: + clearState: false +- assertVisible: "Mulai Curhat" + +# Step 1: customer taps CTA → payment screen +- tapOn: "Mulai Curhat" +- assertVisible: + text: "Bayar|Mulai" # "Bayar" for paid tier, "Mulai" for free trial + timeout: 5000 + +# Step 2: customer confirms payment → searching screen +- tapOn: + text: "Bayar|Mulai" +- assertVisible: + text: "Mencari Bestie" + timeout: 5000 + +# Step 3: simulate mitra accepting via backend API (the "curl-as-mitra" trick). +# This avoids needing a second emulator — the backend treats mitra interactions as +# REST calls regardless of whether they originate from the mitra app or a script. +- runScript: ../scripts/mitra_accept_latest.sh + +# Step 4: customer screen transitions to "found" then chat +- assertVisible: + text: "Bestie Ditemukan" + timeout: 10000 # blast→accept→WS round-trip takes a few seconds diff --git a/client_app/.maestro/scripts/force_all_mitras_offline.sh b/client_app/.maestro/scripts/force_all_mitras_offline.sh new file mode 100755 index 0000000..5ceee41 --- /dev/null +++ b/client_app/.maestro/scripts/force_all_mitras_offline.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Force every mitra offline on the target backend. Used as a pre-step for tests +# that verify the "no bestie available" disabled-CTA state. +# +# Reads BACKEND_INTERNAL_URL and a CC_JWT from the shell env (NOT from +# .maestro/config.yaml — CC credentials should never be committed). +# +# Usage: +# CC_JWT= bash client_app/.maestro/scripts/force_all_mitras_offline.sh + +set -euo pipefail + +: "${BACKEND_INTERNAL_URL:=http://192.168.88.247:3001}" +: "${CC_JWT:?CC_JWT must be set (a valid control_center user JWT)}" + +# Get the list of currently online mitras from the CC dashboard endpoint. +mitras=$(curl -fsSL "$BACKEND_INTERNAL_URL/internal/mitra-online-status" \ + -H "Authorization: Bearer $CC_JWT" \ + | jq -r '.data[] | select(.is_online == true) | .mitra_id') + +if [ -z "$mitras" ]; then + echo "All mitras already offline." + exit 0 +fi + +for mitra_id in $mitras; do + echo "Forcing $mitra_id offline..." + curl -fsSL -X POST "$BACKEND_INTERNAL_URL/internal/mitra-online-status/$mitra_id/offline" \ + -H "Authorization: Bearer $CC_JWT" \ + -H "Content-Type: application/json" \ + -d '{}' || echo " (failed — endpoint may not exist; check route name)" +done + +echo "Done." diff --git a/client_app/.maestro/scripts/mitra_accept_latest.sh b/client_app/.maestro/scripts/mitra_accept_latest.sh new file mode 100755 index 0000000..a4a4502 --- /dev/null +++ b/client_app/.maestro/scripts/mitra_accept_latest.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Find the most recent pending chat_session for the test mitra and accept it via +# the backend API. Used by Maestro flows that drive the customer side and need a +# mitra to "accept" without running a second app. +# +# Reads from .maestro/config.yaml env (BACKEND_URL, TEST_MITRA_ID, TEST_MITRA_JWT). +# Maestro injects these as shell env vars before running this script. + +set -euo pipefail + +: "${BACKEND_URL:?BACKEND_URL must be set in .maestro/config.yaml}" +: "${TEST_MITRA_ID:?TEST_MITRA_ID must be set in .maestro/config.yaml}" +: "${TEST_MITRA_JWT:?TEST_MITRA_JWT must be set in .maestro/config.yaml}" + +# Poll for a pending request (blast may take 1-2 seconds to arrive) +for i in 1 2 3 4 5; do + pending=$(curl -fsSL "$BACKEND_URL/api/mitra/chat-requests/pending" \ + -H "Authorization: Bearer $TEST_MITRA_JWT" 2>/dev/null || echo '{"data":[]}') + session_id=$(echo "$pending" | jq -r '.data[0].session_id // empty') + if [ -n "$session_id" ]; then + break + fi + sleep 1 +done + +if [ -z "${session_id:-}" ]; then + echo "ERROR: no pending chat request found for mitra $TEST_MITRA_ID after 5s" >&2 + exit 1 +fi + +echo "Accepting session $session_id as mitra $TEST_MITRA_ID..." +curl -fsSL -X POST "$BACKEND_URL/api/mitra/chat-requests/$session_id/accept" \ + -H "Authorization: Bearer $TEST_MITRA_JWT" \ + -H "Content-Type: application/json" \ + -d '{}' +echo "OK" diff --git a/client_app/lib/core/auth/auth_notifier.g.dart b/client_app/lib/core/auth/auth_notifier.g.dart index a62ce2c..b4318c8 100644 --- a/client_app/lib/core/auth/auth_notifier.g.dart +++ b/client_app/lib/core/auth/auth_notifier.g.dart @@ -6,7 +6,7 @@ part of 'auth_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$authHash() => r'601e614f3297fb679f5baa893932a43ae981eb9d'; +String _$authHash() => r'f0382b1380749cebe8182ad221023926768ebb92'; /// See also [Auth]. @ProviderFor(Auth) diff --git a/client_app/lib/core/availability/mitra_availability_notifier.dart b/client_app/lib/core/availability/mitra_availability_notifier.dart new file mode 100644 index 0000000..e1059c3 --- /dev/null +++ b/client_app/lib/core/availability/mitra_availability_notifier.dart @@ -0,0 +1,78 @@ +import 'dart:async'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../api/api_client_provider.dart'; + +part 'mitra_availability_notifier.g.dart'; + +/// Customer-home availability poll. +/// +/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home +/// screen is in the foreground. Polling is gated by the home screen calling +/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`: +/// - resumed → setActive(true) +/// - paused/inactive → setActive(false) +/// +/// On any HTTP error we emit `false` (never display stale state). +/// +/// The endpoint also returns a `count`, but the customer UI must only read the +/// binary `available` field — the count is for CC/debug only. +@Riverpod(keepAlive: true) +class MitraAvailability extends _$MitraAvailability { + Timer? _pollTimer; + bool _active = false; + + @override + Future build() async { + ref.onDispose(_stopPolling); + // Default to disabled until the first poll returns. Never optimistically + // show the CTA as enabled. + return false; + } + + /// Called by the home screen via `WidgetsBindingObserver` to gate polling + /// to the foregrounded state. Polling is paused on `paused` / `inactive` + /// and resumed on `resumed` (and an immediate poll fires on resume). + void setActive(bool active) { + if (_active == active) return; + _active = active; + if (_active) { + _startPolling(); + // Fire-and-forget an immediate poll on resume so the CTA reflects + // current availability without waiting up to 5s. + // ignore: unawaited_futures + _pollOnce(); + } else { + _stopPolling(); + } + } + + /// Manual one-shot refresh — used for pull-to-refresh on the home screen. + Future refresh() => _pollOnce(); + + void _startPolling() { + _stopPolling(); + _pollTimer = Timer.periodic(const Duration(seconds: 5), (_) => _pollOnce()); + } + + void _stopPolling() { + _pollTimer?.cancel(); + _pollTimer = null; + } + + Future _pollOnce() async { + bool available; + try { + final api = ref.read(apiClientProvider); + final response = await api.get('/api/client/mitra-availability'); + final data = response['data'] as Map?; + available = data?['available'] as bool? ?? false; + } catch (_) { + // Poll failure → default to disabled. Never keep the last-known state. + available = false; + } + // Skip the assignment when the value didn't change — Riverpod allocates a + // new AsyncData each call, which would re-notify all listeners every 5s. + if (state.valueOrNull == available) return; + state = AsyncData(available); + } +} diff --git a/client_app/lib/core/availability/mitra_availability_notifier.g.dart b/client_app/lib/core/availability/mitra_availability_notifier.g.dart new file mode 100644 index 0000000..7a428d6 --- /dev/null +++ b/client_app/lib/core/availability/mitra_availability_notifier.g.dart @@ -0,0 +1,39 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mitra_availability_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$mitraAvailabilityHash() => r'7429862ccffbae1fc7bbe7368359e1624fe28ec9'; + +/// Phase 3.7 §1: customer-home availability poll. +/// +/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home +/// screen is in the foreground. Polling is gated by the home screen calling +/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`: +/// - resumed → setActive(true) +/// - paused/inactive → setActive(false) +/// +/// On any HTTP error we emit `false` (PRD §1.3: never display stale state). +/// +/// The endpoint also returns a `count`, but per PRD §1.3 the customer UI must +/// only read the binary `available` field — the count is for CC/debug only. +/// +/// Copied from [MitraAvailability]. +@ProviderFor(MitraAvailability) +final mitraAvailabilityProvider = + AsyncNotifierProvider.internal( + MitraAvailability.new, + name: r'mitraAvailabilityProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$mitraAvailabilityHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$MitraAvailability = AsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/client_app/lib/core/chat/chat_notifier.g.dart b/client_app/lib/core/chat/chat_notifier.g.dart index 0dddcd6..c667b2a 100644 --- a/client_app/lib/core/chat/chat_notifier.g.dart +++ b/client_app/lib/core/chat/chat_notifier.g.dart @@ -6,7 +6,7 @@ part of 'chat_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$chatHash() => r'30e0aa41d2022e5bb080877e23fcb0a0c0c4b927'; +String _$chatHash() => r'35388d2977db9cee4307697524620b22691c54f5'; /// See also [Chat]. @ProviderFor(Chat) diff --git a/client_app/lib/core/chat/session_closure_notifier.dart b/client_app/lib/core/chat/session_closure_notifier.dart index 7561bd8..779f9c2 100644 --- a/client_app/lib/core/chat/session_closure_notifier.dart +++ b/client_app/lib/core/chat/session_closure_notifier.dart @@ -1,5 +1,4 @@ import 'package:dio/dio.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../api/api_client_provider.dart'; import 'active_session_notifier.dart'; @@ -41,14 +40,42 @@ class SessionClosure extends _$SessionClosure { @override SessionClosureData build() => const ClosureInitialData(); + /// Extension request is a 3-step flow with the extension cost held in its + /// own `payment_session` (never combined with a free trial). Server-side, + /// the extension service refuses requests without an + /// `extension_payment_session_id` on a confirmed, is_extension payment session. + /// + /// 1. POST `/api/client/payment-sessions` with `is_extension: true` + /// 2. POST `/api/client/payment-sessions/:id/confirm` + /// 3. POST `/api/client/chat/session/:sessionId/extend` with the + /// extension_payment_session_id from step 2. + /// + /// Charge timing is server-side: only on actual approve / auto-approve. + /// If the mitra explicitly rejects within 10s the payment is failed back, no charge. Future requestExtension(String sessionId, {required int durationMinutes, required int price}) async { state = const ExtendingWaitingMitraData(); try { - await ref.read(apiClientProvider).post('/api/client/chat/session/$sessionId/extend', data: { + final api = ref.read(apiClientProvider); + + final createResp = await api.post('/api/client/payment-sessions/', data: { + 'duration_minutes': durationMinutes, + 'is_extension': true, + }); + final paymentSessionId = (createResp['data'] as Map)['id'] as String; + + // Backend rejects truly empty bodies on confirm, so always send `{}`. + await api.post( + '/api/client/payment-sessions/$paymentSessionId/confirm', + data: const {}, + ); + + // Trigger the extension request. The actual approve/reject round-trip is + // owned by the chat WS — ChatNotifier surfaces it. + await api.post('/api/client/chat/session/$sessionId/extend', data: { 'duration_minutes': durationMinutes, 'price': price, + 'extension_payment_session_id': paymentSessionId, }); - // Response will come via WebSocket (ChatBloc/ChatNotifier handles it) } catch (e) { state = const ClosureErrorData('Gagal meminta perpanjangan.'); } diff --git a/client_app/lib/core/constants.dart b/client_app/lib/core/constants.dart index e1a7f9f..3743554 100644 --- a/client_app/lib/core/constants.dart +++ b/client_app/lib/core/constants.dart @@ -9,6 +9,17 @@ String formatCountdown(int totalSeconds) { return '${minutes}m ${seconds}d'; } +/// Format an integer rupiah amount with dot thousand-separators: 1234567 → "Rp 1.234.567". +String formatRupiah(int amount) { + final str = amount.toString(); + final buffer = StringBuffer(); + for (var i = 0; i < str.length; i++) { + if (i > 0 && (str.length - i) % 3 == 0) buffer.write('.'); + buffer.write(str[i]); + } + return 'Rp $buffer'; +} + /// User types class UserType { static const customer = 'customer'; @@ -105,5 +116,44 @@ class WsMessage { // Early end static const earlyEnd = 'early_end'; + // Returning-chat (intermediate failures — payment stays confirmed) + static const returningChatTimeout = 'returning_chat_timeout'; + static const returningChatRejected = 'returning_chat_rejected'; + + // Terminal pairing failure on a confirmed payment session + static const pairingFailed = 'pairing_failed'; + WsMessage._(); } + +/// Pairing-failure cause tags. Mirror of backend +/// `PairingFailureCause` (see backend/src/constants.js). Use for both routing +/// (terminal vs. intermediate) and surfacing copy on the failed-pairing screen. +enum PairingFailureCause { + noMitraAvailable('no_mitra_available'), + allMitrasRejected('all_mitras_rejected'), + targetedMitraOffline('targeted_mitra_offline'), + targetedMitraRejected('targeted_mitra_rejected'), + targetedMitraTimeout('targeted_mitra_timeout'), + paymentSessionExpired('payment_session_expired'), + customerCancelled('customer_cancelled'), + unknown('unknown'); + + final String value; + const PairingFailureCause(this.value); + + static PairingFailureCause fromString(String? v) => + values.firstWhere((e) => e.value == v, orElse: () => PairingFailureCause.unknown); +} + +/// Payment session lifecycle. Mirror of backend +/// `PaymentSessionStatus`. +class PaymentSessionStatus { + static const pending = 'pending'; + static const confirmed = 'confirmed'; + static const consumed = 'consumed'; + static const failedPairing = 'failed_pairing'; + static const abandoned = 'abandoned'; + static const expired = 'expired'; + PaymentSessionStatus._(); +} diff --git a/client_app/lib/core/pairing/pairing_notifier.dart b/client_app/lib/core/pairing/pairing_notifier.dart index 01b4b27..331752a 100644 --- a/client_app/lib/core/pairing/pairing_notifier.dart +++ b/client_app/lib/core/pairing/pairing_notifier.dart @@ -20,9 +20,55 @@ class PairingInitialData extends PairingData { const PairingInitialData(); } +/// General-blast in flight. The chat_session row exists; backend has already +/// notified all available mitras and is waiting for the first to accept. class PairingSearchingData extends PairingData { + /// chat_session id (NOT payment_session id). final String sessionId; - const PairingSearchingData(this.sessionId); + + /// payment_session id — we keep it on the state so cancelSearch can call + /// the payment-session-scoped cancel endpoint without re-prompting. + final String paymentSessionId; + + const PairingSearchingData({ + required this.sessionId, + required this.paymentSessionId, + }); +} + +/// "Curhat lagi" 20s wait. The targeted-mitra request has been created and +/// we're waiting for either accept (→ paired), reject/timeout (→ bestie-unavailable +/// popup), or customer cancel (→ home). +/// +/// `secondsRemaining` is decremented locally for the overlay countdown. The +/// server is the source of truth for the actual auto-reject; the local timer +/// is purely cosmetic. +class PairingTargetedWaitingData extends PairingData { + final String paymentSessionId; + final String mitraId; + final String mitraName; + final int secondsRemaining; + // Carried so the fallback-to-blast path preserves the customer's original choice + // — otherwise sensitive sessions silently get re-routed as regular. + final TopicSensitivity topicSensitivity; + + const PairingTargetedWaitingData({ + required this.paymentSessionId, + required this.mitraId, + required this.mitraName, + required this.secondsRemaining, + required this.topicSensitivity, + }); + + PairingTargetedWaitingData copyWith({int? secondsRemaining}) { + return PairingTargetedWaitingData( + paymentSessionId: paymentSessionId, + mitraId: mitraId, + mitraName: mitraName, + secondsRemaining: secondsRemaining ?? this.secondsRemaining, + topicSensitivity: topicSensitivity, + ); + } } class PairingBestieFoundData extends PairingData { @@ -37,8 +83,35 @@ class PairingActiveData extends PairingData { const PairingActiveData({required this.sessionId, required this.mitraName}); } -class PairingNoBestieData extends PairingData { - const PairingNoBestieData(); +/// Intermediate fail signalled by RETURNING_CHAT_TIMEOUT or RETURNING_CHAT_REJECTED, +/// or by a 409 `targeted_mitra_offline` at request time. Payment session is still +/// `confirmed` server-side — the customer can choose between fallback-to-blast +/// (general blast on the same payment) or going back home (which will leave the +/// payment to expire, no double-charge). +/// +/// The UI surfaces this via the bestie-unavailable dialog. +class PairingTargetedUnavailableData extends PairingData { + final String paymentSessionId; + final String mitraName; + final PairingFailureCause cause; + // Carried so the fallback-to-blast call preserves the customer's original choice. + final TopicSensitivity topicSensitivity; + + const PairingTargetedUnavailableData({ + required this.paymentSessionId, + required this.mitraName, + required this.cause, + required this.topicSensitivity, + }); +} + +/// Terminal pairing failure — payment session is in `failed_pairing`. Routes +/// to the failed-pairing screen (no_bestie_screen). +class PairingFailedData extends PairingData { + final PairingFailureCause cause; + final String? paymentSessionId; + + const PairingFailedData({required this.cause, this.paymentSessionId}); } class PairingCancelledData extends PairingData { @@ -52,7 +125,7 @@ class PairingErrorData extends PairingData { @Riverpod(keepAlive: true) class Pairing extends _$Pairing { - Timer? _timeoutTimer; + Timer? _localCountdownTimer; WebSocketChannel? _channel; StreamSubscription? _wsSubscription; @@ -61,58 +134,176 @@ class Pairing extends _$Pairing { @override PairingData build() => const PairingInitialData(); - Future requestPairing({required TopicSensitivity topicSensitivity}) async { - await _doPairingRequest({'topic_sensitivity': topicSensitivity.value}); - } - - Future requestPairingWithTier({ - int? durationMinutes, - int? price, - bool isFreeTrial = false, + /// General-blast against a confirmed payment session. + /// Returns once the chat_session row is created server-side; subsequent + /// transitions (paired / pairing_failed) arrive via WS. + Future startSearch({ + required String paymentSessionId, required TopicSensitivity topicSensitivity, }) async { - final body = {'topic_sensitivity': topicSensitivity.value}; - if (isFreeTrial) { - body['is_free_trial'] = true; - } else { - body['duration_minutes'] = durationMinutes; - body['price'] = price; - } - await _doPairingRequest(body); - } - - Future _doPairingRequest(Map body) async { - if (state is! PairingInitialData) { - state = const PairingInitialData(); - } + state = const PairingInitialData(); try { await _connectWebSocket(); - - final response = await _apiClient.post('/api/client/chat/request', data: body); + final response = await _apiClient.post( + '/api/client/chat/request', + data: { + 'payment_session_id': paymentSessionId, + 'topic_sensitivity': topicSensitivity.value, + }, + ); final data = response['data'] as Map; final sessionId = data['id'] as String; - - state = PairingSearchingData(sessionId); - - _timeoutTimer = Timer(const Duration(seconds: 60), () { - _cleanup(); - state = const PairingNoBestieData(); - }); + state = PairingSearchingData( + sessionId: sessionId, + paymentSessionId: paymentSessionId, + ); } on DioException catch (e) { _cleanup(); final code = e.response?.data?['error']?['code']; if (code == 'NO_MITRA_AVAILABLE') { - state = const PairingNoBestieData(); + // Backend already failed the payment in this case — terminal. + state = PairingFailedData( + cause: PairingFailureCause.noMitraAvailable, + paymentSessionId: paymentSessionId, + ); } else if (code == 'ALREADY_ACTIVE') { state = const PairingErrorData('Kamu sudah memiliki sesi aktif.'); - } else if (code == 'FREE_TRIAL_INELIGIBLE') { - state = const PairingErrorData('Kamu tidak memenuhi syarat untuk free trial.'); } else { state = const PairingErrorData('Gagal memulai. Coba lagi.'); } } } + /// Targeted "Curhat lagi" against a specific mitra. The backend creates a + /// single-recipient notification + 20s server-side timer. Locally we run a + /// cosmetic countdown for the overlay. + /// + /// On 409 `targeted_mitra_offline`: backend recorded an audit-only failure + /// row (payment stays confirmed) — we transition to TargetedUnavailable so + /// the UI can offer the fallback dialog. + Future startTargetedSearch({ + required String paymentSessionId, + required String mitraId, + required String mitraName, + required TopicSensitivity topicSensitivity, + }) async { + state = const PairingInitialData(); + try { + await _connectWebSocket(); + final response = await _apiClient.post( + '/api/client/chat/chat-requests/returning', + data: { + 'payment_session_id': paymentSessionId, + 'mitra_id': mitraId, + 'topic_sensitivity': topicSensitivity.value, + }, + ); + // Backend returns the configured returning_chat_confirmation_timeout_seconds so + // the overlay countdown matches the server-side timer exactly. + final sessionData = response['data'] as Map?; + final seconds = (sessionData?['confirmation_timeout_seconds'] as num?)?.toInt() ?? 20; + state = PairingTargetedWaitingData( + paymentSessionId: paymentSessionId, + mitraId: mitraId, + mitraName: mitraName, + secondsRemaining: seconds, + topicSensitivity: topicSensitivity, + ); + _startLocalCountdown(); + } on DioException catch (e) { + _cleanup(); + final code = e.response?.data?['error']?['code']; + final reason = e.response?.data?['error']?['reason']; + if (code == 'TARGETED_MITRA_OFFLINE' || reason == 'targeted_mitra_offline') { + // Intermediate — payment session is still confirmed; show the + // bestie-unavailable popup with a "Chat dengan bestie lain" option. + state = PairingTargetedUnavailableData( + paymentSessionId: paymentSessionId, + mitraName: mitraName, + cause: PairingFailureCause.targetedMitraOffline, + topicSensitivity: topicSensitivity, + ); + } else if (code == 'ALREADY_ACTIVE') { + state = const PairingErrorData('Kamu sudah memiliki sesi aktif.'); + } else { + state = const PairingErrorData('Gagal memulai. Coba lagi.'); + } + } + } + + /// Customer-initiated cancel during a search/wait. Terminal — payment + /// session moves to `failed_pairing` server-side. We route the UI to home + /// (NOT to the failed-pairing screen) since the customer chose this. + Future cancelSearch() async { + String? paymentSessionId; + final current = state; + if (current is PairingSearchingData) { + paymentSessionId = current.paymentSessionId; + } else if (current is PairingTargetedWaitingData) { + paymentSessionId = current.paymentSessionId; + } + if (paymentSessionId == null) { + _cleanup(); + state = const PairingCancelledData(); + return; + } + try { + await _apiClient.post( + '/api/client/chat/chat-requests/cancel', + data: {'payment_session_id': paymentSessionId}, + ); + } catch (_) { + // Best-effort. Backend will still fail the payment if/when it + // sweeps stale rows. + } + _cleanup(); + state = const PairingCancelledData(); + } + + /// "Chat dengan bestie lain" tapped from the bestie-unavailable dialog. + /// Reuses the same payment session — backend transitions back into the + /// general-blast path. + Future fallbackToBlast({ + required String paymentSessionId, + required TopicSensitivity topicSensitivity, + }) async { + state = const PairingInitialData(); + try { + await _connectWebSocket(); + final response = await _apiClient.post( + '/api/client/chat/chat-requests/$paymentSessionId/fallback-to-blast', + data: {'topic_sensitivity': topicSensitivity.value}, + ); + final data = response['data'] as Map; + final sessionId = data['id'] as String; + state = PairingSearchingData( + sessionId: sessionId, + paymentSessionId: paymentSessionId, + ); + } on DioException catch (e) { + _cleanup(); + final code = e.response?.data?['error']?['code']; + if (code == 'NO_MITRA_AVAILABLE') { + state = PairingFailedData( + cause: PairingFailureCause.noMitraAvailable, + paymentSessionId: paymentSessionId, + ); + } else { + state = const PairingErrorData('Gagal memulai. Coba lagi.'); + } + } + } + + /// Reset back to initial — used when the failed-pairing screen "Kembali ke + /// beranda" CTA is tapped, or when the bestie-unavailable dialog is + /// dismissed via "Kembali". + void reset() { + _cleanup(); + state = const PairingInitialData(); + } + + // ---- Internal --------------------------------------------------------- + Future _connectWebSocket() async { _closeWebSocket(); final token = ref.read(authBridgeProvider).accessToken; @@ -128,7 +319,7 @@ class Pairing extends _$Pairing { (raw) { final data = jsonDecode(raw as String) as Map; if (data['type'] == WsMessage.authOk) return; - _onStatusUpdate(data); + _onWsEvent(data); }, onError: (_) {}, onDone: () {}, @@ -140,42 +331,89 @@ class Pairing extends _$Pairing { })); } - Future _onStatusUpdate(Map data) async { + Future _onWsEvent(Map data) async { final type = data['type'] as String?; + final current = state; if (type == WsMessage.paired) { _cleanup(); final mitraName = data['mitra_display_name'] as String? ?? 'Bestie'; final sessionId = data['session_id'] as String; state = PairingBestieFoundData(sessionId: sessionId, mitraName: mitraName); - - // A session now exists for this customer — refresh the shared snapshot - // so the home CTA reflects it immediately when the user returns. // ignore: unawaited_futures ref.read(activeSessionProvider.notifier).refresh(); - await Future.delayed(const Duration(seconds: 2)); state = PairingActiveData(sessionId: sessionId, mitraName: mitraName); - } else if (type == SessionStatus.expired) { + return; + } + + if (type == WsMessage.pairingFailed) { + // Terminal — payment_session is in failed_pairing server-side. + final causeTag = data['cause_tag'] as String?; + final paymentSessionId = data['payment_session_id'] as String?; _cleanup(); - state = const PairingNoBestieData(); + state = PairingFailedData( + cause: PairingFailureCause.fromString(causeTag), + paymentSessionId: paymentSessionId, + ); + return; + } + + if (type == WsMessage.returningChatTimeout || type == WsMessage.returningChatRejected) { + // Intermediate — payment still confirmed. Show the bestie-unavailable + // dialog (UI surfaces via state listener). + _stopLocalCountdown(); + final paymentSessionId = data['payment_session_id'] as String?; + // Pull mitra name + topic from the prior targeted-waiting state (we know it from + // the request payload). If we somehow lost it, fall back to safe defaults. + String mitraName = 'Bestie'; + TopicSensitivity carriedTopic = TopicSensitivity.regular; + if (current is PairingTargetedWaitingData) { + mitraName = current.mitraName; + carriedTopic = current.topicSensitivity; + } + state = PairingTargetedUnavailableData( + paymentSessionId: paymentSessionId ?? (current is PairingTargetedWaitingData ? current.paymentSessionId : ''), + mitraName: mitraName, + topicSensitivity: carriedTopic, + cause: type == WsMessage.returningChatTimeout + ? PairingFailureCause.targetedMitraTimeout + : PairingFailureCause.targetedMitraRejected, + ); + return; + } + + if (type == SessionStatus.expired) { + // Legacy event from the older pairing path — treat as terminal "no mitra". + _cleanup(); + state = const PairingFailedData(cause: PairingFailureCause.noMitraAvailable); } } - Future cancelPairing() async { - if (state is PairingSearchingData) { - final sessionId = (state as PairingSearchingData).sessionId; - try { - await _apiClient.post('/api/client/chat/request/$sessionId/cancel'); - } catch (_) {} - _cleanup(); - state = const PairingCancelledData(); - } + void _startLocalCountdown() { + _stopLocalCountdown(); + _localCountdownTimer = Timer.periodic(const Duration(seconds: 1), (_) { + final current = state; + if (current is! PairingTargetedWaitingData) { + _stopLocalCountdown(); + return; + } + final next = current.secondsRemaining - 1; + if (next <= 0) { + _stopLocalCountdown(); + // We don't transition here — the server is the source of truth for + // the actual auto-reject. The WS event will land within ~1s and + // transition us to TargetedUnavailable. + state = current.copyWith(secondsRemaining: 0); + } else { + state = current.copyWith(secondsRemaining: next); + } + }); } - void reset() { - _cleanup(); - state = const PairingInitialData(); + void _stopLocalCountdown() { + _localCountdownTimer?.cancel(); + _localCountdownTimer = null; } void _closeWebSocket() { @@ -186,8 +424,7 @@ class Pairing extends _$Pairing { } void _cleanup() { - _timeoutTimer?.cancel(); - _timeoutTimer = null; + _stopLocalCountdown(); _closeWebSocket(); } } diff --git a/client_app/lib/core/pairing/pairing_notifier.g.dart b/client_app/lib/core/pairing/pairing_notifier.g.dart index 8340ff9..cd31f06 100644 --- a/client_app/lib/core/pairing/pairing_notifier.g.dart +++ b/client_app/lib/core/pairing/pairing_notifier.g.dart @@ -6,7 +6,7 @@ part of 'pairing_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$pairingHash() => r'e1c3074239cc4efda99885331ff91b3e0f903c8d'; +String _$pairingHash() => r'0fe1e5646fe70b90f5489919ec8dd557c998daad'; /// See also [Pairing]. @ProviderFor(Pairing) diff --git a/client_app/lib/features/chat/screens/chat_history_screen.dart b/client_app/lib/features/chat/screens/chat_history_screen.dart index 30b8056..0e48d71 100644 --- a/client_app/lib/features/chat/screens/chat_history_screen.dart +++ b/client_app/lib/features/chat/screens/chat_history_screen.dart @@ -2,7 +2,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/api/api_client_provider.dart'; +import '../../../core/constants.dart'; +/// Chat history with per-row "Curhat lagi" CTA. +/// +/// Tapping "Curhat lagi" routes to the payment screen with the targeted +/// mitra id + display name as extras. The payment screen then: +/// 1. POSTs `/api/client/payment-sessions` with `targeted_mitra_id` +/// 2. On confirm, fires `pairingNotifier.startTargetedSearch(...)` instead +/// of the general `startSearch(...)`. +/// +/// The CTA is per-row (not per-unique-mitra). class ChatHistoryScreen extends ConsumerStatefulWidget { const ChatHistoryScreen({super.key}); @@ -34,6 +44,19 @@ class _ChatHistoryScreenState extends ConsumerState { } } + void _onCurhatLagiPressed(Map session) { + // The mitra id field on the history payload is `mitra_id` per existing + // backend convention. If absent (older rows), don't render the CTA. + final mitraId = session['mitra_id'] as String?; + if (mitraId == null) return; + final mitraName = session['mitra_display_name'] as String? ?? 'Bestie'; + context.push('/payment', extra: { + 'targetedMitraId': mitraId, + 'mitraName': mitraName, + 'topicSensitivity': TopicSensitivity.regular, + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -42,11 +65,13 @@ class _ChatHistoryScreenState extends ConsumerState { ? const Center(child: CircularProgressIndicator()) : _sessions.isEmpty ? const Center(child: Text('Belum ada riwayat chat')) - : ListView.builder( + : ListView.separated( itemCount: _sessions.length, + separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final s = _sessions[index]; final sessionId = s['id'] as String; + final mitraId = s['mitra_id'] as String?; final mitraName = s['mitra_display_name'] as String? ?? 'Bestie'; final status = s['status'] as String?; final isClosing = status == 'closing'; @@ -72,7 +97,18 @@ class _ChatHistoryScreenState extends ConsumerState { if (duration != null) '$duration menit', if (closureMsg != null) '"$closureMsg"', ].join(' - ')), - trailing: const Icon(Icons.chevron_right), + // Curhat-lagi CTA renders inline; transcript view is + // still reachable by tapping the row body (or, for + // closing sessions, the active chat — same as before). + trailing: !isClosing && mitraId != null + ? OutlinedButton( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + ), + onPressed: () => _onCurhatLagiPressed(s), + child: const Text('Curhat lagi'), + ) + : const Icon(Icons.chevron_right), onTap: () => isClosing ? context.push('/chat/session/$sessionId', extra: mitraName) : context.push('/chat/history/$sessionId'), diff --git a/client_app/lib/features/chat/screens/no_bestie_screen.dart b/client_app/lib/features/chat/screens/no_bestie_screen.dart index b85dfa2..62cd978 100644 --- a/client_app/lib/features/chat/screens/no_bestie_screen.dart +++ b/client_app/lib/features/chat/screens/no_bestie_screen.dart @@ -1,36 +1,64 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../core/pairing/pairing_notifier.dart'; -class NoBestieScreen extends StatelessWidget { +/// Terminal failed-pairing screen. +/// +/// Reached when the pairing notifier transitions to [PairingFailedData] +/// (terminal — payment session is `failed_pairing` server-side, audit row +/// recorded). Copy is intentionally identical regardless of `cause_tag` for +/// now (the design pass will revise this later). +/// +/// Single CTA "Kembali ke beranda" resets the pairing notifier and routes +/// home. PopScope falls back to home for deep-link entry per project memory +/// rule "Deep-link pop fallback". +class NoBestieScreen extends ConsumerWidget { const NoBestieScreen({super.key}); @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.sentiment_dissatisfied, size: 80, color: Colors.orange), - const SizedBox(height: 24), - const Text( - 'Bestie belum tersedia', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + Widget build(BuildContext context, WidgetRef ref) { + return PopScope( + canPop: true, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) return; + ref.read(pairingProvider.notifier).reset(); + }, + child: Scaffold( + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.sentiment_dissatisfied, size: 80, color: Colors.orange), + const SizedBox(height: 24), + const Text( + 'Belum berhasil terhubung', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + const Text( + 'Maaf, kami tidak bisa menemukan bestie untuk sesimu. ' + 'Tim kami akan menghubungimu segera.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, color: Colors.grey), + ), + const SizedBox(height: 48), + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), + ), + onPressed: () { + ref.read(pairingProvider.notifier).reset(); + context.go('/home'); + }, + child: const Text('Kembali ke beranda'), + ), + ], ), - const SizedBox(height: 8), - const Text( - 'Maaf, semua Bestie sedang sibuk. Coba lagi nanti ya.', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16, color: Colors.grey), - ), - const SizedBox(height: 48), - ElevatedButton( - onPressed: () => context.go('/home'), - child: const Text('Kembali'), - ), - ], + ), ), ), ), diff --git a/client_app/lib/features/chat/screens/searching_screen.dart b/client_app/lib/features/chat/screens/searching_screen.dart index 99a95e8..1c8d537 100644 --- a/client_app/lib/features/chat/screens/searching_screen.dart +++ b/client_app/lib/features/chat/screens/searching_screen.dart @@ -2,51 +2,170 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/pairing/pairing_notifier.dart'; +import '../widgets/bestie_unavailable_dialog.dart'; +import '../widgets/targeted_waiting_overlay.dart'; -class SearchingScreen extends ConsumerWidget { +/// Searching screen, also responsible for routing all downstream pairing +/// transitions: +/// +/// - PairingTargetedWaitingData → render the targeted waiting overlay above +/// the searching shell (the customer sees the 20s countdown + cancel CTA). +/// - PairingTargetedUnavailableData → show the bestie-unavailable dialog +/// (intermediate; payment stays confirmed; offers fallback-to-blast). +/// - PairingFailedData → terminal; route to no-bestie screen. +/// - PairingBestieFoundData → existing transition to bestie-found screen. +/// - PairingCancelledData → customer cancelled; back home. +/// +/// Per project memory ("Riverpod ref.listen in build is unsafe"), we use +/// ref.listenManual in initState for one-shot side effects rather than +/// build-scoped listeners. +class SearchingScreen extends ConsumerStatefulWidget { const SearchingScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - ref.listen(pairingProvider, (prev, next) { - if (next is PairingBestieFoundData) { - context.go('/chat/found', extra: { - 'sessionId': next.sessionId, - 'mitraName': next.mitraName, - }); - } else if (next is PairingNoBestieData) { - context.go('/chat/no-bestie'); - } else if (next is PairingCancelledData) { - context.go('/home'); - } + ConsumerState createState() => _SearchingScreenState(); +} + +class _SearchingScreenState extends ConsumerState { + /// Guard against re-firing the bestie-unavailable dialog if the notifier + /// briefly emits multiple intermediate states (e.g. WS event arrives just + /// after a 409 already opened the dialog). + bool _unavailableDialogShown = false; + + @override + void initState() { + super.initState(); + ref.listenManual(pairingProvider, _onPairingState); + // The pairing state can already be PairingTargetedUnavailableData by + // the time we mount (the payment screen awaits startTargetedSearch + // before navigating; a 409 lands while we're still on the previous + // screen). Inspect once after first frame to handle that case. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _onPairingState(null, ref.read(pairingProvider)); }); + } + + void _onPairingState(PairingData? prev, PairingData next) { + if (!mounted) return; + + if (next is PairingBestieFoundData) { + context.go('/chat/found', extra: { + 'sessionId': next.sessionId, + 'mitraName': next.mitraName, + }); + return; + } + + if (next is PairingActiveData) { + // Direct route into the active chat — happens after the brief "found" + // animation if the user is already on this screen. + context.go('/chat/session/${next.sessionId}', extra: next.mitraName); + return; + } + + if (next is PairingFailedData) { + // Terminal — payment_session is failed_pairing. + context.go('/chat/no-bestie'); + return; + } + + if (next is PairingCancelledData) { + context.go('/home'); + return; + } + + if (next is PairingTargetedUnavailableData && !_unavailableDialogShown) { + _unavailableDialogShown = true; + // ignore: discarded_futures + BestieUnavailableDialog.show( + context, + paymentSessionId: next.paymentSessionId, + mitraName: next.mitraName, + topicSensitivity: next.topicSensitivity, + ).then((_) { + if (mounted) _unavailableDialogShown = false; + }); + return; + } + + if (next is PairingErrorData) { + // Inline error UX is preferred over SnackBars (project memory: + // "Avoid SnackBars for provider errors"). The build below renders + // a banner when the state is PairingErrorData. + } + } + + @override + Widget build(BuildContext context) { + final pairingState = ref.watch(pairingProvider); return Scaffold( - body: Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 32), - const Text( - 'Mencari Bestie...', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - const Text( - 'Tunggu sebentar ya, kami sedang mencarikan Bestie untukmu', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16, color: Colors.grey), - ), - const SizedBox(height: 48), - OutlinedButton( - onPressed: () => ref.read(pairingProvider.notifier).cancelPairing(), - child: const Text('Batalkan'), - ), - ], - ), + body: SafeArea( + child: Stack( + children: [ + _SearchingBody(state: pairingState), + if (pairingState is PairingTargetedWaitingData) + TargetedWaitingOverlay(waiting: pairingState), + ], + ), + ), + ); + } +} + +class _SearchingBody extends ConsumerWidget { + final PairingData state; + const _SearchingBody({required this.state}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isTargetedWaiting = state is PairingTargetedWaitingData; + final errorMessage = state is PairingErrorData ? (state as PairingErrorData).message : null; + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 32), + Text( + isTargetedWaiting ? 'Menghubungi bestie...' : 'Mencari Bestie...', + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + 'Tunggu sebentar ya, kami sedang mencarikan Bestie untukmu', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, color: Colors.grey), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), + ), + child: Text( + errorMessage, + style: TextStyle(color: Colors.red.shade900), + textAlign: TextAlign.center, + ), + ), + ], + const SizedBox(height: 48), + // The targeted-waiting overlay owns its own cancel button — only + // show the general cancel CTA when we're in a non-overlay state. + if (!isTargetedWaiting) + OutlinedButton( + onPressed: () => ref.read(pairingProvider.notifier).cancelSearch(), + child: const Text('Batalkan'), + ), + ], ), ), ); diff --git a/client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart b/client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart new file mode 100644 index 0000000..ba9005d --- /dev/null +++ b/client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/availability/mitra_availability_notifier.dart'; +import '../../../core/constants.dart'; +import '../../../core/pairing/pairing_notifier.dart'; + +/// Shown when a "Curhat lagi" attempt against a specific bestie can't proceed +/// — either a 409 `targeted_mitra_offline` response on the targeted POST, or +/// one of the intermediate WS events (`returning_chat_timeout`, +/// `returning_chat_rejected`). +/// +/// CTAs: +/// - "Chat dengan bestie lain" — only rendered when +/// [mitraAvailabilityProvider] reports `available == true` at the time of +/// build. Tapping calls [Pairing.fallbackToBlast] (reuses the same payment +/// session — no double-charge) and closes the dialog. The caller is expected +/// to be the searching screen, which will transition into PairingSearchingData +/// and stay put. +/// - "Kembali" — pops dialog and routes home. Backend has already audit-logged +/// the targeted failure; payment session stays `confirmed` until the sweeper +/// expires it. +class BestieUnavailableDialog extends ConsumerWidget { + final String paymentSessionId; + final String mitraName; + final TopicSensitivity topicSensitivity; + + const BestieUnavailableDialog({ + super.key, + required this.paymentSessionId, + required this.mitraName, + required this.topicSensitivity, + }); + + /// Convenience: show this dialog and return when it closes. Per project + /// memory ("Riverpod ref.listen in build is unsafe"), callers should + /// invoke this from `ref.listenManual` callbacks in `initState`, not from + /// `build`. + static Future show( + BuildContext context, { + required String paymentSessionId, + required String mitraName, + required TopicSensitivity topicSensitivity, + }) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (_) => BestieUnavailableDialog( + paymentSessionId: paymentSessionId, + mitraName: mitraName, + topicSensitivity: topicSensitivity, + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Snapshot at dialog-open time — we don't keep listening, we just check + // whether other bestie are around right now. + final availabilityAsync = ref.watch(mitraAvailabilityProvider); + final hasOtherAvailable = availabilityAsync.valueOrNull ?? false; + + return AlertDialog( + title: const Text('Bestie sedang tidak online'), + content: Text( + '$mitraName sedang tidak bisa menerima chat saat ini. ' + 'Kamu bisa coba chat dengan bestie lain atau kembali ke beranda.', + ), + actions: [ + TextButton( + onPressed: () { + // Reset pairing state and route home. Payment session stays + // confirmed until sweeper expires it — no extra API call needed. + ref.read(pairingProvider.notifier).reset(); + Navigator.of(context).pop(); + context.go('/home'); + }, + child: const Text('Kembali'), + ), + if (hasOtherAvailable) + ElevatedButton( + onPressed: () { + // Close the dialog first, then kick off the fallback. The + // searching screen will pick up the new PairingSearchingData + // state and render normally (no targeted overlay). + Navigator.of(context).pop(); + // ignore: discarded_futures + ref.read(pairingProvider.notifier).fallbackToBlast( + paymentSessionId: paymentSessionId, + topicSensitivity: topicSensitivity, + ); + }, + child: const Text('Chat dengan bestie lain'), + ), + ], + ); + } +} diff --git a/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart index 895bf7a..f4bcc24 100644 --- a/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart +++ b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart @@ -3,27 +3,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/chat/chat_opening_provider.dart'; import '../../../core/chat/session_closure_notifier.dart'; import '../../../core/constants.dart'; -import '../../../core/pairing/pairing_notifier.dart'; +/// Extension-only pricing sheet. +/// +/// Used solely for in-session extension requests; the initial pairing flow +/// goes through `/payment` instead. Free-trial is never offered for extensions. +/// +/// Submit triggers [SessionClosure.requestExtension], which internally +/// runs the payment-session create+confirm and then the extend POST. class PricingBottomSheet extends ConsumerWidget { - /// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session. - final String? extensionSessionId; + /// Required — the in-progress chat session id this extension targets. + final String extensionSessionId; - /// Required when starting a new pairing. Null when in extension mode. - final TopicSensitivity? topicSensitivity; + const PricingBottomSheet({super.key, required this.extensionSessionId}); - const PricingBottomSheet({super.key, this.extensionSessionId, this.topicSensitivity}); - - /// Show for new pairing (from home screen) - static Future show(BuildContext context, {required TopicSensitivity topicSensitivity}) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => PricingBottomSheet(topicSensitivity: topicSensitivity), - ); - } - - /// Show for session extension (from chat screen) + /// Show for session extension (from chat screen). static Future showForExtension(BuildContext context, {required String sessionId}) { return showModalBottomSheet( context: context, @@ -32,19 +26,8 @@ class PricingBottomSheet extends ConsumerWidget { ); } - String _formatPrice(int price) { - final str = price.toString(); - final buffer = StringBuffer(); - for (var i = 0; i < str.length; i++) { - if (i > 0 && (str.length - i) % 3 == 0) buffer.write('.'); - buffer.write(str[i]); - } - return 'Rp $buffer'; - } - @override Widget build(BuildContext context, WidgetRef ref) { - final isExtension = extensionSessionId != null; final pricingAsync = ref.watch(chatPricingProvider); return pricingAsync.when( @@ -52,7 +35,7 @@ class PricingBottomSheet extends ConsumerWidget { height: 200, child: Center(child: CircularProgressIndicator()), ), - error: (error, _) => SizedBox( + error: (error, _) => const SizedBox( height: 200, child: Center(child: Text('Gagal memuat harga. Coba lagi.')), ), @@ -67,54 +50,30 @@ class PricingBottomSheet extends ConsumerWidget { child: ListView( controller: scrollController, children: [ - Text( - isExtension ? 'Perpanjang Durasi' : 'Pilih Durasi Curhat', - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + const Text( + 'Perpanjang Durasi', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), const SizedBox(height: 16), - if (!isExtension && pricing.freeTrialEligible) ...[ - Card( - color: Colors.green.shade50, - child: ListTile( - leading: const Icon(Icons.card_giftcard, color: Colors.green), - title: Text('Free Trial (${pricing.freeTrialDurationMinutes} Menit)'), - subtitle: const Text('Gratis untuk pertama kali!'), - trailing: const Icon(Icons.arrow_forward_ios, size: 16), - onTap: () { - Navigator.of(context).pop(); - _startPairing(ref, isFreeTrial: true); - }, - ), - ), - const Divider(height: 24), - ], + // No free-trial path for extensions. ...pricing.tiers.map((tier) => Card( - child: ListTile( - title: Text(tier.label), - trailing: Text( - _formatPrice(tier.price), - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), - ), - onTap: () { - Navigator.of(context).pop(); - if (isExtension) { - _requestExtension( - ref, - sessionId: extensionSessionId!, - durationMinutes: tier.durationMinutes, - price: tier.price, - ); - } else { - _startPairing( - ref, - durationMinutes: tier.durationMinutes, - price: tier.price, - ); - } - }, - ), - )), + child: ListTile( + title: Text(tier.label), + trailing: Text( + formatRupiah(tier.price), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + onTap: () { + Navigator.of(context).pop(); + ref.read(sessionClosureProvider.notifier).requestExtension( + extensionSessionId, + durationMinutes: tier.durationMinutes, + price: tier.price, + ); + }, + ), + )), ], ), ); @@ -122,21 +81,4 @@ class PricingBottomSheet extends ConsumerWidget { ), ); } - - void _startPairing(WidgetRef ref, {bool isFreeTrial = false, int? durationMinutes, int? price}) { - ref.read(pairingProvider.notifier).requestPairingWithTier( - durationMinutes: durationMinutes, - price: price, - isFreeTrial: isFreeTrial, - topicSensitivity: topicSensitivity ?? TopicSensitivity.regular, - ); - } - - void _requestExtension(WidgetRef ref, {required String sessionId, required int durationMinutes, required int price}) { - ref.read(sessionClosureProvider.notifier).requestExtension( - sessionId, - durationMinutes: durationMinutes, - price: price, - ); - } } diff --git a/client_app/lib/features/chat/widgets/targeted_waiting_overlay.dart b/client_app/lib/features/chat/widgets/targeted_waiting_overlay.dart new file mode 100644 index 0000000..6106bc2 --- /dev/null +++ b/client_app/lib/features/chat/widgets/targeted_waiting_overlay.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/pairing/pairing_notifier.dart'; + +/// Full-screen modal overlay shown above the searching screen during the 20s +/// targeted-mitra wait window. The overlay reads its state directly from the +/// [PairingTargetedWaitingData] passed in by the parent — the local countdown +/// ticks are owned by the pairing notifier so the overlay just renders. +/// +/// "Batalkan" calls `pairingNotifier.cancelSearch()` which posts to +/// `/api/client/chat/chat-requests/cancel` and transitions state to +/// `PairingCancelledData`. The parent screen listens for that and pops home. +class TargetedWaitingOverlay extends ConsumerWidget { + final PairingTargetedWaitingData waiting; + + const TargetedWaitingOverlay({super.key, required this.waiting}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Container( + // Slight scrim so the underlying searching UI is still visible but the + // overlay clearly owns the foreground. + color: Colors.black.withValues(alpha: 0.55), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Card( + elevation: 6, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 56, + height: 56, + child: CircularProgressIndicator(strokeWidth: 3), + ), + const SizedBox(height: 20), + Text( + 'Menunggu konfirmasi ${waiting.mitraName}', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + '${waiting.secondsRemaining}d', + style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w300), + ), + const SizedBox(height: 8), + const Text( + 'Bestie punya 20 detik untuk merespon. Kalau tidak ada respon, kami bantu cari bestie lain.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: Colors.grey), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () => ref.read(pairingProvider.notifier).cancelSearch(), + child: const Text('Batalkan'), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart index 192b0c3..2d19279 100644 --- a/client_app/lib/features/home/home_screen.dart +++ b/client_app/lib/features/home/home_screen.dart @@ -2,11 +2,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/auth/auth_notifier.dart'; +import '../../core/availability/mitra_availability_notifier.dart'; import '../../core/chat/active_session_notifier.dart'; -import '../../core/pairing/pairing_notifier.dart'; -import '../chat/widgets/pricing_bottom_sheet.dart'; import '../chat/widgets/topic_selection_bottom_sheet.dart'; +/// Home screen. +/// +/// 1. The "Mulai Curhat" CTA is gated on real-time mitra availability +/// (polling owned by the [mitraAvailabilityProvider]). Polling is paused +/// on background and resumed on foreground via [WidgetsBindingObserver]. +/// 2. Tapping the enabled CTA pushes `/payment` so the customer must confirm +/// a payment session before any blast fires. class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @@ -19,26 +25,38 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + // Kick the availability poll on once the first frame settles. Doing it + // here (rather than in build) avoids re-firing on every rebuild. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref.read(mitraAvailabilityProvider.notifier).setActive(true); + }); } @override void dispose() { + // Stop polling when leaving home. + ref.read(mitraAvailabilityProvider.notifier).setActive(false); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { + final notifier = ref.read(mitraAvailabilityProvider.notifier); if (state == AppLifecycleState.resumed) { // Re-fetch in case a session ended/started while backgrounded. ref.read(activeSessionProvider.notifier).refresh(); + notifier.setActive(true); + } else if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) { + notifier.setActive(false); } } Future _onStartChatPressed(BuildContext context) async { final topic = await TopicSelectionBottomSheet.show(context); if (topic == null || !context.mounted) return; - await PricingBottomSheet.show(context, topicSensitivity: topic); + context.push('/payment', extra: {'topicSensitivity': topic}); } @override @@ -46,6 +64,7 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse final authState = ref.watch(authProvider); final authData = authState.valueOrNull; final activeSessionAsync = ref.watch(activeSessionProvider); + final availabilityAsync = ref.watch(mitraAvailabilityProvider); final displayName = switch (authData) { AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '', @@ -53,76 +72,84 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse _ => '', }; - ref.listen(pairingProvider, (prev, next) { - if (next is PairingSearchingData) { - context.go('/chat/searching'); - } else if (next is PairingNoBestieData) { - context.go('/chat/no-bestie'); - } else if (next is PairingErrorData) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(next.message)), - ); - } - }); + // Poll-failure / loading both default to "no bestie available" (greyed-out). + // Never optimistically enable. + final mitraAvailable = availabilityAsync.valueOrNull ?? false; return Scaffold( - appBar: AppBar( - title: const Text('Halo Bestie'), - actions: [ - IconButton( - icon: const Icon(Icons.history), - onPressed: () => context.push('/chat/history'), - ), - IconButton( - icon: const Icon(Icons.logout), - onPressed: () => ref.read(authProvider.notifier).logout(), + appBar: AppBar( + title: const Text('Halo Bestie'), + actions: [ + IconButton( + icon: const Icon(Icons.history), + onPressed: () => context.push('/chat/history'), + ), + IconButton( + icon: const Icon(Icons.logout), + onPressed: () => ref.read(authProvider.notifier).logout(), + ), + ], + ), + body: RefreshIndicator( + onRefresh: () async { + // Pull-to-refresh kicks both the active-session and availability polls. + await Future.wait([ + ref.read(activeSessionProvider.notifier).refresh(), + ref.read(mitraAvailabilityProvider.notifier).refresh(), + ]); + }, + child: ListView( + // Force-scroll so RefreshIndicator can fire even on a short body. + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(32), + children: [ + const SizedBox(height: 32), + Center(child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24))), + const SizedBox(height: 32), + Center( + child: activeSessionAsync.when( + loading: () => const CircularProgressIndicator(), + error: (_, __) => _StartChatButton( + enabled: mitraAvailable, + onPressed: () => _onStartChatPressed(context), + ), + data: (snapshot) { + // Hide the "Sesi Aktif" CTA when the session is in `closing` + // — the conversation is over, only the goodbye composer + // remains. Backend auto-completes such sessions after a + // grace period; until then the user shouldn't be invited + // back into them from home. + final status = snapshot.session?['status'] as String?; + final isCurhatable = snapshot.hasSession && status != 'closing'; + if (isCurhatable) { + return _ActiveSessionCard( + mitraName: snapshot.mitraName, + unreadCount: snapshot.unreadCount, + onTap: () { + final sessionId = snapshot.sessionId; + if (sessionId == null) return; + context.push('/chat/session/$sessionId', extra: snapshot.mitraName); + }, + ); + } + return _StartChatButton( + enabled: mitraAvailable, + onPressed: () => _onStartChatPressed(context), + ); + }, + ), ), ], ), - body: Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)), - const SizedBox(height: 32), - activeSessionAsync.when( - loading: () => const CircularProgressIndicator(), - error: (_, __) => _StartChatButton(onPressed: () => _onStartChatPressed(context)), - data: (snapshot) { - // Hide the "Sesi Aktif" CTA when the session is in `closing` - // — the conversation is over, only the goodbye composer - // remains. Backend auto-completes such sessions after a - // grace period; until then the user shouldn't be invited - // back into them from home. - final status = snapshot.session?['status'] as String?; - final isCurhatable = snapshot.hasSession && status != 'closing'; - if (isCurhatable) { - return _ActiveSessionCard( - mitraName: snapshot.mitraName, - unreadCount: snapshot.unreadCount, - onTap: () { - final sessionId = snapshot.sessionId; - if (sessionId == null) return; - context.push('/chat/session/$sessionId', extra: snapshot.mitraName); - }, - ); - } - return _StartChatButton(onPressed: () => _onStartChatPressed(context)); - }, - ), - ], - ), - ), - ), + ), ); } } class _StartChatButton extends StatelessWidget { + final bool enabled; final VoidCallback onPressed; - const _StartChatButton({required this.onPressed}); + const _StartChatButton({required this.enabled, required this.onPressed}); @override Widget build(BuildContext context) { @@ -133,9 +160,15 @@ class _StartChatButton extends StatelessWidget { style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16), ), - onPressed: onPressed, + onPressed: enabled ? onPressed : null, child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)), ), + const SizedBox(height: 12), + if (!enabled) + Text( + 'Belum ada bestie tersedia', + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), ], ); } diff --git a/client_app/lib/features/payment/payment_notifier.dart b/client_app/lib/features/payment/payment_notifier.dart new file mode 100644 index 0000000..c42381a --- /dev/null +++ b/client_app/lib/features/payment/payment_notifier.dart @@ -0,0 +1,180 @@ +import 'package:dio/dio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../core/api/api_client.dart'; +import '../../core/api/api_client_provider.dart'; +import '../../core/constants.dart'; + +part 'payment_notifier.g.dart'; + +/// Payment-session lifecycle, customer side. The screen owns one of these per +/// (mitra-target, duration) attempt; the notifier wraps the REST calls to +/// `/api/client/payment-sessions`. +sealed class PaymentSessionData { + const PaymentSessionData(); +} + +class PaymentInitialData extends PaymentSessionData { + const PaymentInitialData(); +} + +class PaymentCreatingData extends PaymentSessionData { + const PaymentCreatingData(); +} + +/// Created server-side, sitting in `pending` until the customer taps "Bayar". +class PaymentPendingData extends PaymentSessionData { + final String paymentSessionId; + final int amount; + final int durationMinutes; + final bool isFreeTrial; + final bool isExtension; + final String? targetedMitraId; + + const PaymentPendingData({ + required this.paymentSessionId, + required this.amount, + required this.durationMinutes, + required this.isFreeTrial, + required this.isExtension, + this.targetedMitraId, + }); +} + +class PaymentConfirmingData extends PaymentSessionData { + final String paymentSessionId; + const PaymentConfirmingData(this.paymentSessionId); +} + +/// Confirmed; the customer can now be routed to the searching screen with +/// this `paymentSessionId` (and optional `targetedMitraId` for "Curhat lagi"). +class PaymentConfirmedData extends PaymentSessionData { + final String paymentSessionId; + final int durationMinutes; + final bool isFreeTrial; + final bool isExtension; + final String? targetedMitraId; + + const PaymentConfirmedData({ + required this.paymentSessionId, + required this.durationMinutes, + required this.isFreeTrial, + required this.isExtension, + this.targetedMitraId, + }); +} + +class PaymentErrorData extends PaymentSessionData { + final String message; + const PaymentErrorData(this.message); +} + +@riverpod +class Payment extends _$Payment { + ApiClient get _api => ref.read(apiClientProvider); + + @override + PaymentSessionData build() => const PaymentInitialData(); + + /// Create a `pending` payment session for the chosen [durationMinutes]. + /// Pass [targetedMitraId] for the "Curhat lagi" path; pass [isExtension] + /// for an extension-cost payment (never combined with free trial). + Future createSession({ + required int durationMinutes, + String? targetedMitraId, + bool isExtension = false, + }) async { + state = const PaymentCreatingData(); + try { + final body = { + 'duration_minutes': durationMinutes, + if (targetedMitraId != null) 'targeted_mitra_id': targetedMitraId, + if (isExtension) 'is_extension': true, + }; + // Trailing slash matters: the backend route is `app.post('/', ...)` mounted + // at prefix `/api/client/payment-sessions`, and Fastify is not configured + // with `ignoreTrailingSlash: true`, so the canonical URL has the slash. + final response = await _api.post('/api/client/payment-sessions/', data: body); + final data = response['data'] as Map; + state = PaymentPendingData( + paymentSessionId: data['id'] as String, + amount: data['amount'] as int? ?? 0, + durationMinutes: data['duration_minutes'] as int? ?? durationMinutes, + isFreeTrial: data['is_free_trial'] as bool? ?? false, + isExtension: data['is_extension'] as bool? ?? isExtension, + targetedMitraId: data['targeted_mitra_id'] as String?, + ); + } on DioException catch (e) { + state = PaymentErrorData(_humanError(e, fallback: 'Gagal membuat sesi pembayaran.')); + } catch (_) { + state = const PaymentErrorData('Gagal membuat sesi pembayaran.'); + } + } + + /// Confirm the pending payment. Backend rejects truly empty bodies on + /// `POST .../confirm`, so we always send `{}`. + Future confirm() async { + final current = state; + if (current is! PaymentPendingData) return; + state = PaymentConfirmingData(current.paymentSessionId); + try { + await _api.post( + '/api/client/payment-sessions/${current.paymentSessionId}/confirm', + data: const {}, + ); + state = PaymentConfirmedData( + paymentSessionId: current.paymentSessionId, + durationMinutes: current.durationMinutes, + isFreeTrial: current.isFreeTrial, + isExtension: current.isExtension, + targetedMitraId: current.targetedMitraId, + ); + } on DioException catch (e) { + state = PaymentErrorData(_humanError(e, fallback: 'Gagal mengkonfirmasi pembayaran.')); + } catch (_) { + state = const PaymentErrorData('Gagal mengkonfirmasi pembayaran.'); + } + } + + /// Best-effort cancel of a still-pending session. Safe to call on dispose + /// even if the state isn't `pending` — we just no-op in that case. + Future cancelIfPending() async { + final current = state; + if (current is! PaymentPendingData) return; + final id = current.paymentSessionId; + try { + await _api.post( + '/api/client/payment-sessions/$id/cancel', + data: const {}, + ); + } catch (_) { + // Best-effort — backend sweeper will expire stale `pending` rows + // after `payment_session_timeout_minutes` regardless. + } + } + + /// Reset to initial — used when the screen is re-entered for a new attempt. + void reset() { + state = const PaymentInitialData(); + } + + String _humanError(DioException e, {required String fallback}) { + final code = e.response?.data?['error']?['code'] as String?; + final status = e.response?.statusCode; + if (status == 422 || code == 'VALIDATION_ERROR' || code == 'INVALID_TIER') { + return 'Pilihan durasi tidak valid.'; + } + if (status == 403) return 'Sesi tidak diizinkan.'; + if (status == 404) return 'Sesi pembayaran tidak ditemukan.'; + if (code == 'EXPIRED') return 'Sesi pembayaran sudah kedaluwarsa.'; + return fallback; + } +} + +/// Mirror of backend `PaymentSessionStatus` for any UI that needs to inspect +/// the raw status field (kept tiny for now — most flows route via state above). +class PaymentStatus { + static const pending = PaymentSessionStatus.pending; + static const confirmed = PaymentSessionStatus.confirmed; + static const consumed = PaymentSessionStatus.consumed; + PaymentStatus._(); +} diff --git a/client_app/lib/features/payment/payment_notifier.g.dart b/client_app/lib/features/payment/payment_notifier.g.dart new file mode 100644 index 0000000..fc7a047 --- /dev/null +++ b/client_app/lib/features/payment/payment_notifier.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'payment_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$paymentHash() => r'63019ba794311cd36761bd6ad6f90b0abde5c747'; + +/// See also [Payment]. +@ProviderFor(Payment) +final paymentProvider = + AutoDisposeNotifierProvider.internal( + Payment.new, + name: r'paymentProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$paymentHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$Payment = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/client_app/lib/features/payment/screens/payment_screen.dart b/client_app/lib/features/payment/screens/payment_screen.dart new file mode 100644 index 0000000..15eca7c --- /dev/null +++ b/client_app/lib/features/payment/screens/payment_screen.dart @@ -0,0 +1,390 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/chat/chat_opening_provider.dart'; +import '../../../core/constants.dart'; +import '../../../core/pairing/pairing_notifier.dart'; +import '../payment_notifier.dart'; + +/// Payment screen. +/// +/// Reuses the mock pricing service (tiers + free trial). The customer picks a +/// duration (or auto-selects the free trial); on tap the screen creates a +/// `pending` payment session, then on "Bayar" / "Mulai" confirms it and routes +/// to the searching screen carrying `paymentSessionId` (and `targetedMitraId` +/// if this is a "Curhat lagi" flow). +/// +/// Reachable from: +/// - Home "Mulai Curhat" CTA → no targeted mitra, normal blast follows. +/// - Chat history "Curhat lagi" CTA → targetedMitraId set, returning-chat +/// flow follows. +class PaymentScreen extends ConsumerStatefulWidget { + /// "Curhat lagi" only — when set, the eventual chat-request goes through + /// the returning-chat endpoint targeting this mitra. + final String? targetedMitraId; + + /// Optional display name for the targeted mitra, surfaced in the screen + /// header so the customer knows who they're paying to chat with again. + final String? mitraName; + + /// The topic-sensitivity choice the customer made in the topic-selection + /// bottom sheet on the home screen. Carried through here to be passed into + /// the chat-request API after confirm. Defaults to regular. + final TopicSensitivity topicSensitivity; + + const PaymentScreen({ + super.key, + this.targetedMitraId, + this.mitraName, + this.topicSensitivity = TopicSensitivity.regular, + }); + + @override + ConsumerState createState() => _PaymentScreenState(); +} + +class _PaymentScreenState extends ConsumerState { + /// Local UI selection (not in the notifier) — the duration the customer is + /// previewing before they tap to lock it in via createSession. + int? _selectedDurationMinutes; + + /// True once we've kicked off `createSession()` for the current selection; + /// used to suppress double-taps while the round-trip is in flight. + bool _creatingSession = false; + + @override + void initState() { + super.initState(); + // Make sure no stale state leaks in from a previous payment attempt. + Future.microtask(() => ref.read(paymentProvider.notifier).reset()); + } + + @override + void dispose() { + // Best-effort cancel on back/dispose if we still have a `pending` row. + // The notifier checks state before calling the API, so this is safe to + // call unconditionally. + // ignore: discarded_futures + ref.read(paymentProvider.notifier).cancelIfPending(); + super.dispose(); + } + + + Future _onTierTapped({ + required int durationMinutes, + required int price, + }) async { + if (_creatingSession) return; + // `price` is informational (already shown in the tier card) — the source + // of truth for the amount comes back from the backend. + setState(() { + _selectedDurationMinutes = durationMinutes; + _creatingSession = true; + }); + await ref.read(paymentProvider.notifier).createSession( + durationMinutes: durationMinutes, + targetedMitraId: widget.targetedMitraId, + ); + if (mounted) setState(() => _creatingSession = false); + } + + Future _onConfirmTapped() async { + final notifier = ref.read(paymentProvider.notifier); + await notifier.confirm(); + } + + Future _routeToSearchOnConfirmed(PaymentConfirmedData payment) async { + // Kick off the right pairing flow against the freshly-confirmed payment. + final pairing = ref.read(pairingProvider.notifier); + if (payment.targetedMitraId != null) { + await pairing.startTargetedSearch( + paymentSessionId: payment.paymentSessionId, + mitraId: payment.targetedMitraId!, + mitraName: widget.mitraName ?? 'Bestie', + topicSensitivity: widget.topicSensitivity, + ); + } else { + await pairing.startSearch( + paymentSessionId: payment.paymentSessionId, + topicSensitivity: widget.topicSensitivity, + ); + } + if (!mounted) return; + // Reset our local notifier so a future payment attempt starts clean. + ref.read(paymentProvider.notifier).reset(); + context.go('/chat/searching'); + } + + @override + Widget build(BuildContext context) { + // One-shot side-effect listener: when the payment lands in `confirmed`, + // route to the searching screen. + ref.listen(paymentProvider, (prev, next) { + if (next is PaymentConfirmedData) { + // ignore: discarded_futures + _routeToSearchOnConfirmed(next); + } + }); + + final paymentState = ref.watch(paymentProvider); + final pricingAsync = ref.watch(chatPricingProvider); + final isReturning = widget.targetedMitraId != null; + + return PopScope( + canPop: true, + child: Scaffold( + appBar: AppBar( + title: Text(isReturning ? 'Chat lagi dengan ${widget.mitraName ?? 'Bestie'}' : 'Pilih Sesi & Bayar'), + leading: IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: () { + // PopScope above lets canPop fire dispose() which cancels the + // pending session. If there's no back-stack, fall back to home. + if (context.canPop()) { + context.pop(); + } else { + context.go('/home'); + } + }, + ), + ), + body: pricingAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text('Gagal memuat harga. Coba lagi.', textAlign: TextAlign.center), + ), + ), + data: (pricing) => _buildBody(pricing, paymentState), + ), + ), + ); + } + + Widget _buildBody(PricingData pricing, PaymentSessionData paymentState) { + // Inline error widget per project memory ("Avoid SnackBars for provider errors"). + final errorBanner = paymentState is PaymentErrorData + ? Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: Colors.red.shade700), + const SizedBox(width: 8), + Expanded( + child: Text( + paymentState.message, + style: TextStyle(color: Colors.red.shade900), + ), + ), + ], + ), + ) + : const SizedBox.shrink(); + + return Column( + children: [ + errorBanner, + Expanded( + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + const Text( + 'Pilih Durasi Curhat', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + if (pricing.freeTrialEligible) ...[ + _FreeTrialCard( + durationMinutes: pricing.freeTrialDurationMinutes, + selected: paymentState is PaymentPendingData && paymentState.isFreeTrial, + onTap: () => _onTierTapped( + // For free trial: backend still wants a duration_minutes — + // pass the trial duration. The backend overrides amount→0 + // when the customer is eligible. + durationMinutes: pricing.freeTrialDurationMinutes, + price: 0, + ), + ), + const Divider(height: 24), + ], + ...pricing.tiers.map((tier) { + final selected = _selectedDurationMinutes == tier.durationMinutes && + paymentState is PaymentPendingData && + !paymentState.isFreeTrial; + return _TierCard( + label: tier.label, + priceLabel: formatRupiah(tier.price), + selected: selected, + onTap: () => _onTierTapped( + durationMinutes: tier.durationMinutes, + price: tier.price, + ), + ); + }), + ], + ), + ), + if (paymentState is PaymentPendingData || + paymentState is PaymentConfirmingData || + paymentState is PaymentCreatingData) + _ConfirmBar( + paymentState: paymentState, + onConfirm: _onConfirmTapped, + formatPrice: formatRupiah, + ), + ], + ); + } +} + +class _FreeTrialCard extends StatelessWidget { + final int durationMinutes; + final bool selected; + final VoidCallback onTap; + + const _FreeTrialCard({ + required this.durationMinutes, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + color: selected ? Colors.green.shade100 : Colors.green.shade50, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: selected + ? BorderSide(color: Colors.green.shade700, width: 1.5) + : BorderSide.none, + ), + child: ListTile( + leading: const Icon(Icons.card_giftcard, color: Colors.green), + title: Text('Free Trial ($durationMinutes Menit)'), + subtitle: const Text('Gratis untuk pertama kali!'), + trailing: Text( + 'Gratis', + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.green.shade800), + ), + onTap: onTap, + ), + ); + } +} + +class _TierCard extends StatelessWidget { + final String label; + final String priceLabel; + final bool selected; + final VoidCallback onTap; + + const _TierCard({ + required this.label, + required this.priceLabel, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: selected + ? const BorderSide(color: Colors.pink, width: 1.5) + : BorderSide.none, + ), + child: ListTile( + title: Text(label), + trailing: Text( + priceLabel, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + onTap: onTap, + ), + ); + } +} + +class _ConfirmBar extends StatelessWidget { + final PaymentSessionData paymentState; + final Future Function() onConfirm; + final String Function(int) formatPrice; + + const _ConfirmBar({ + required this.paymentState, + required this.onConfirm, + required this.formatPrice, + }); + + @override + Widget build(BuildContext context) { + final isCreating = paymentState is PaymentCreatingData; + final isConfirming = paymentState is PaymentConfirmingData; + final pending = paymentState is PaymentPendingData ? paymentState as PaymentPendingData : null; + + final totalLabel = pending == null + ? '...' + : pending.isFreeTrial + ? 'Gratis' + : formatPrice(pending.amount); + final ctaLabel = pending != null && pending.isFreeTrial ? 'Mulai' : 'Bayar'; + final disabled = isCreating || isConfirming || pending == null; + + return SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Total', style: TextStyle(fontSize: 16)), + Text( + totalLabel, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: disabled ? null : onConfirm, + child: isConfirming || isCreating + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : Text(ctaLabel, style: const TextStyle(fontSize: 16)), + ), + ), + ], + ), + ), + ); + } +} diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index e9bc71d..12bdb97 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -11,12 +11,14 @@ import 'features/auth/screens/set_display_name_screen.dart'; import 'features/onboarding/onboarding_screen.dart'; import 'features/splash/splash_screen.dart'; import 'features/home/home_screen.dart'; +import 'core/constants.dart'; import 'features/chat/screens/searching_screen.dart'; import 'features/chat/screens/bestie_found_screen.dart'; import 'features/chat/screens/no_bestie_screen.dart'; import 'features/chat/screens/chat_screen.dart'; import 'features/chat/screens/chat_history_screen.dart'; import 'features/chat/screens/chat_transcript_screen.dart'; +import 'features/payment/screens/payment_screen.dart'; class RouterNotifier extends ChangeNotifier { final Ref _ref; @@ -96,6 +98,22 @@ GoRouter buildRouter(Ref ref) { GoRoute(path: '/auth/set-name', builder: (_, __) => const SetDisplayNameScreen()), GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()), GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), + GoRoute(path: '/payment', builder: (context, state) { + // Payment screen reachable from + // - Home "Mulai Curhat" CTA → no extras (general blast follows confirm) + // - Chat history "Curhat lagi" CTA → extras carry targetedMitraId/mitraName + // for the returning-chat flow, plus optional topicSensitivity. + final extra = state.extra; + if (extra is Map) { + final topic = extra['topicSensitivity']; + return PaymentScreen( + targetedMitraId: extra['targetedMitraId'] as String?, + mitraName: extra['mitraName'] as String?, + topicSensitivity: topic is TopicSensitivity ? topic : TopicSensitivity.regular, + ); + } + return const PaymentScreen(); + }), GoRoute(path: '/chat/searching', builder: (_, __) => const SearchingScreen()), GoRoute(path: '/chat/found', builder: (context, state) { final extra = state.extra as Map; diff --git a/control_center/.env.example b/control_center/.env.example index 1e5e44a..3aee910 100644 --- a/control_center/.env.example +++ b/control_center/.env.example @@ -1,2 +1,32 @@ -# Internal API base URL — accessible via VPN only +# ============================================================================= +# Control Center — environment variables +# ============================================================================= +# Copy this file to `.env` and fill in your local values. `.env` is gitignored. +# +# Two sets of vars live here: +# 1. Vite build-time vars (VITE_*) read by the React app at dev/build time. +# 2. Playwright test runner vars read only by the e2e test process. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Vite (read by the SPA itself) +# ----------------------------------------------------------------------------- +# Internal API base URL — accessible via VPN only. VITE_API_BASE_URL=https://internal.halobestie.com + +# ----------------------------------------------------------------------------- +# Playwright e2e tests (read by `npm run test:e2e`) +# ----------------------------------------------------------------------------- +# Where the CC dev server is reachable (the SPA itself). Override to point at +# a CC dev server running on another machine. +CC_BASE_URL=http://localhost:5173 + +# Where the internal backend listener is reachable. Used by test setup +# helpers that mint test JWTs / seed fixtures via the internal API. +BACKEND_INTERNAL_URL=http://localhost:3001 + +# Test CC user credentials — must already exist in the control_center_users +# table on the target backend. Use the seeded admin (admin@halobestie.com / +# ChangeMe123!) for local dev, or provision a dedicated test operator. +CC_TEST_EMAIL=test-operator@example.com +CC_TEST_PASSWORD=changeme diff --git a/control_center/.gitignore b/control_center/.gitignore index aa0926a..926feaf 100644 --- a/control_center/.gitignore +++ b/control_center/.gitignore @@ -2,3 +2,8 @@ node_modules/ dist/ .env *.log + +# Playwright +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/control_center/package-lock.json b/control_center/package-lock.json index 9f88897..d226ae6 100644 --- a/control_center/package-lock.json +++ b/control_center/package-lock.json @@ -15,9 +15,11 @@ "react-router-dom": "^6.23.1" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "dotenv": "^17.4.2", "vite": "^5.3.1" } }, @@ -745,6 +747,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -1391,6 +1409,19 @@ "node": ">=0.4.0" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1788,6 +1819,53 @@ "dev": true, "license": "ISC" }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.9", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", diff --git a/control_center/package.json b/control_center/package.json index ea62ac4..b3d005a 100644 --- a/control_center/package.json +++ b/control_center/package.json @@ -6,19 +6,25 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test", + "test:e2e:headed": "HEADED=1 playwright test --headed", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "PWDEBUG=1 playwright test" }, "dependencies": { + "@tanstack/react-query": "^5.45.1", + "axios": "^1.7.2", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.23.1", - "axios": "^1.7.2", - "@tanstack/react-query": "^5.45.1" + "react-router-dom": "^6.23.1" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "dotenv": "^17.4.2", "vite": "^5.3.1" } } diff --git a/control_center/playwright.config.js b/control_center/playwright.config.js new file mode 100644 index 0000000..de0e7c3 --- /dev/null +++ b/control_center/playwright.config.js @@ -0,0 +1,72 @@ +import { defineConfig, devices } from '@playwright/test' +import dotenv from 'dotenv' + +// Load `.env` so operators can put CC_TEST_EMAIL / CC_TEST_PASSWORD / +// CC_BASE_URL / BACKEND_INTERNAL_URL there instead of remembering to export +// them in every shell. CLI env vars still win — dotenv does not override +// already-set process.env values. +dotenv.config() + +/** + * Playwright configuration for the Halo Bestie Control Center. + * + * Cross-machine flexibility is the priority: nothing here is hardcoded to a + * specific host. The operator (or CI) starts the CC dev server + backend + * separately and points these env vars at wherever they're reachable. + * + * Required env vars (with sensible local defaults): + * CC_BASE_URL — where the CC SPA is served (default: http://localhost:5173) + * BACKEND_INTERNAL_URL — where the internal Fastify listener is served + * (default: http://localhost:3001) — used by helpers, + * not by Playwright directly. + * CC_TEST_EMAIL — control-center user email (default: placeholder) + * CC_TEST_PASSWORD — control-center user password (default: placeholder) + * + * Optional toggles: + * HEADED=1 — run with a visible browser (also: --headed CLI flag) + * RECORD=1 — record video for every test (default: off) + * PWDEBUG=1 — Playwright's built-in inspector + * + * NOTE: There is intentionally no `webServer` block — we never auto-start + * the CC dev server. The operator controls when/where it runs so the same + * Playwright suite can target a remote dev server on another machine. + */ + +const CC_BASE_URL = process.env.CC_BASE_URL || 'http://localhost:5173' +const RECORD_VIDEO = process.env.RECORD === '1' + +export default defineConfig({ + testDir: './tests/e2e', + outputDir: './test-results', + timeout: 30_000, + expect: { timeout: 5_000 }, + + // CC tests touch shared backend state (config rows, fixtures) — keep + // serial by default to avoid flakes from parallel mutation. + workers: 1, + fullyParallel: false, + + reporter: [ + ['list'], + ['html', { outputFolder: 'playwright-report', open: 'never' }], + ], + + use: { + baseURL: CC_BASE_URL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: RECORD_VIDEO ? 'on' : 'off', + actionTimeout: 10_000, + navigationTimeout: 15_000, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + // To add firefox/webkit later: + // 1. npx playwright install firefox webkit + // 2. Add { name: 'firefox', use: { ...devices['Desktop Firefox'] } } here + ], +}) diff --git a/control_center/src/App.jsx b/control_center/src/App.jsx index 237cfa8..5ffd587 100644 --- a/control_center/src/App.jsx +++ b/control_center/src/App.jsx @@ -7,6 +7,7 @@ import SessionsPage from './pages/sessions/SessionsPage' import UsersPage from './pages/users/UsersPage' import SettingsPage from './pages/settings/SettingsPage' import MitraActivityPage from './pages/mitra-activity/MitraActivityPage' +import FailedPairingsPage from './pages/failed-pairings/FailedPairingsPage' import Layout from './components/Layout' const ProtectedRoute = ({ children }) => { @@ -24,6 +25,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/control_center/src/components/Layout.jsx b/control_center/src/components/Layout.jsx index 1a234e1..6f3c87b 100644 --- a/control_center/src/components/Layout.jsx +++ b/control_center/src/components/Layout.jsx @@ -63,6 +63,7 @@ export default function Layout() {
  • Dashboard
  • Mitra
  • Sesi
  • +
  • Failed Pairings
  • Users
  • Aktivitas Mitra
  • Settings
  • diff --git a/control_center/src/core/constants.js b/control_center/src/core/constants.js new file mode 100644 index 0000000..dcdc948 --- /dev/null +++ b/control_center/src/core/constants.js @@ -0,0 +1,46 @@ +// Frontend mirror of selected backend enums (backend/src/constants.js). +// Keep in sync when new values are added on the server. + +// Pairing failure cause tags — used by the Failed Pairings screen filter. +export const PairingFailureCause = Object.freeze({ + NO_MITRA_AVAILABLE: 'no_mitra_available', + ALL_MITRAS_REJECTED: 'all_mitras_rejected', + TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline', + TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected', + TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout', + PAYMENT_SESSION_EXPIRED: 'payment_session_expired', + CUSTOMER_CANCELLED: 'customer_cancelled', + EXTENSION_REJECTED: 'extension_rejected', + EXTENSION_SAFEGUARD_TRIPPED: 'extension_safeguard_tripped', +}) + +export const PairingFailureCauseLabel = Object.freeze({ + [PairingFailureCause.NO_MITRA_AVAILABLE]: 'No mitra available', + [PairingFailureCause.ALL_MITRAS_REJECTED]: 'All mitras rejected', + [PairingFailureCause.TARGETED_MITRA_OFFLINE]: 'Targeted mitra offline', + [PairingFailureCause.TARGETED_MITRA_REJECTED]: 'Targeted mitra rejected', + [PairingFailureCause.TARGETED_MITRA_TIMEOUT]: 'Targeted mitra timeout', + [PairingFailureCause.PAYMENT_SESSION_EXPIRED]: 'Payment session expired', + [PairingFailureCause.CUSTOMER_CANCELLED]: 'Customer cancelled', + [PairingFailureCause.EXTENSION_REJECTED]: 'Extension rejected', + [PairingFailureCause.EXTENSION_SAFEGUARD_TRIPPED]: 'Extension safeguard tripped', +}) + +// Operator actions on a failed-pairing row. +export const PairingFailureOperatorAction = Object.freeze({ + REFUNDED: 'refunded', + CREDITED: 'credited', + NO_ACTION: 'no_action', +}) + +export const PairingFailureOperatorActionLabel = Object.freeze({ + [PairingFailureOperatorAction.REFUNDED]: 'Refunded', + [PairingFailureOperatorAction.CREDITED]: 'Credited', + [PairingFailureOperatorAction.NO_ACTION]: 'No Action', +}) + +// Default action when the mitra fails to respond to an extension request in time. +export const ExtensionTimeoutAction = Object.freeze({ + AUTO_APPROVE: 'auto_approve', + AUTO_REJECT: 'auto_reject', +}) diff --git a/control_center/src/pages/failed-pairings/FailedPairingsPage.jsx b/control_center/src/pages/failed-pairings/FailedPairingsPage.jsx new file mode 100644 index 0000000..11e7ebf --- /dev/null +++ b/control_center/src/pages/failed-pairings/FailedPairingsPage.jsx @@ -0,0 +1,257 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { apiClient } from '../../core/api/api-client' +import { + PairingFailureCause, + PairingFailureCauseLabel, + PairingFailureOperatorAction, + PairingFailureOperatorActionLabel, +} from '../../core/constants' + +const PAGE_SIZE = 50 + +const CAUSE_OPTIONS = Object.values(PairingFailureCause).map((value) => ({ + value, + label: PairingFailureCauseLabel[value], +})) + +const fetchFailedPairings = async ({ causeTags, dateFrom, dateTo, limit, offset }) => { + const params = new URLSearchParams() + for (const tag of causeTags) params.append('cause_tags', tag) + if (dateFrom) params.set('date_from', dateFrom) + if (dateTo) params.set('date_to', dateTo) + params.set('limit', String(limit)) + params.set('offset', String(offset)) + const res = await apiClient.get(`/internal/failed-pairings?${params}`) + return res.data.data +} + +const submitOperatorAction = async ({ id, action }) => { + const res = await apiClient.post(`/internal/failed-pairings/${id}/action`, { action }) + return res.data.data +} + +const formatRupiah = (amount) => { + if (amount === null || amount === undefined) return '-' + return `Rp ${Number(amount).toLocaleString('id-ID')}` +} + +const formatDateTime = (iso) => { + if (!iso) return '-' + return new Date(iso).toLocaleString('id-ID') +} + +const operatorActionLabel = (row) => { + if (!row.operator_action) return '-' + return PairingFailureOperatorActionLabel[row.operator_action] ?? row.operator_action +} + +export default function FailedPairingsPage() { + const queryClient = useQueryClient() + const [selectedCauses, setSelectedCauses] = useState([]) + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') + const [page, setPage] = useState(1) + const [openMenuId, setOpenMenuId] = useState(null) + + const offset = (page - 1) * PAGE_SIZE + + const { data, isLoading, isError } = useQuery({ + queryKey: ['failed-pairings', selectedCauses, dateFrom, dateTo, page], + queryFn: () => fetchFailedPairings({ + causeTags: selectedCauses, + dateFrom: dateFrom || null, + dateTo: dateTo || null, + limit: PAGE_SIZE, + offset, + }), + keepPreviousData: true, + }) + + const actionMutation = useMutation({ + mutationFn: submitOperatorAction, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['failed-pairings'] }) + setOpenMenuId(null) + }, + }) + + const toggleCause = (value) => { + setPage(1) + setSelectedCauses((prev) => + prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value], + ) + } + + const clearFilters = () => { + setSelectedCauses([]) + setDateFrom('') + setDateTo('') + setPage(1) + } + + const total = data?.total ?? 0 + const rows = data?.rows ?? [] + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + return ( +
    +

    Failed Pairings

    + +
    +
    + Cause: +
    + {CAUSE_OPTIONS.map((opt) => ( + + ))} +
    +
    + +
    +
    + + { setDateFrom(e.target.value); setPage(1) }} + /> +
    +
    + + { setDateTo(e.target.value); setPage(1) }} + /> +
    + +
    +
    + + {isLoading &&
    Loading...
    } + {isError &&

    Gagal memuat data failed pairings.

    } + + {!isLoading && !isError && ( + <> +
    + + + + + + + + + + + + + + + {rows.length === 0 && ( + + + + )} + {rows.map((row) => { + const canAction = !row.operator_action + return ( + + + + + + + + + + + + ) + })} + +
    CreatedCustomerTargeted MitraCauseAmountOperator ActionActioned ByActioned AtAksi
    + Belum ada data failed pairings. +
    {formatDateTime(row.created_at)}{row.customer_call_name ?? '-'}{row.targeted_mitra_call_name ?? '-'} + {PairingFailureCauseLabel[row.cause_tag] ?? row.cause_tag} + {formatRupiah(row.amount)}{operatorActionLabel(row)}{row.actioned_by_name ?? '-'}{formatDateTime(row.actioned_at)} + {canAction ? ( + <> + + {openMenuId === row.id && ( +
    + + + +
    + )} + + ) : ( + + )} +
    + +
    + + Page {page} of {totalPages} ({total} total) + +
    + + )} + + {actionMutation.isError && ( +

    Gagal menyimpan operator action.

    + )} + + ) +} + +const menuItemStyle = { + display: 'block', + width: '100%', + padding: '8px 12px', + background: 'white', + border: 'none', + borderBottom: '1px solid #f0f0f0', + textAlign: 'left', + cursor: 'pointer', + fontSize: 13, +} diff --git a/control_center/src/pages/login/LoginPage.jsx b/control_center/src/pages/login/LoginPage.jsx index 68d4db8..89fbf8d 100644 --- a/control_center/src/pages/login/LoginPage.jsx +++ b/control_center/src/pages/login/LoginPage.jsx @@ -49,12 +49,12 @@ export default function LoginPage() {

    Control Center

    - - setEmail(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} /> + + setEmail(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
    - - setPassword(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} /> + + setPassword(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
    {error &&

    {error}

    } +
    +``` + +The `