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) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 23:02:49 +08:00
parent f3766813f3
commit d09e50af55
92 changed files with 9579 additions and 437 deletions

8
.claude/settings.json Normal file
View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(flutter analyze:*)",
"Bash(adb devices:*)"
]
}
}

View File

@@ -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"
]
}
}

26
backend/.env.test.example Normal file
View File

@@ -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

4
backend/.gitignore vendored
View File

@@ -1,5 +1,9 @@
node_modules/
.env
.env.test
*.log
firebase-service-account.json
*-firebase-adminsdk-*.json
coverage/
_phase37_smoke.mjs
_check_db.mjs

View File

@@ -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

1441
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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',

View File

@@ -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 `<table>_<column>_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()
}

View File

@@ -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 })
})
}

View File

@@ -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 })
})
}

View File

@@ -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,
paymentSessionId: payment_session_id,
topic_sensitivity,
})
return reply.code(201).send({ success: true, data: session })
}
})
if (!duration_minutes || price === undefined) {
/**
* 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: 'duration_minutes and price are required' },
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' },
})
}
if (!(await isValidTier(duration_minutes, price))) {
return reply.code(400).send({
success: false,
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
})
}
const resolvedTopic = (topic_sensitivity === TopicSensitivity.SENSITIVE)
? TopicSensitivity.SENSITIVE
: TopicSensitivity.REGULAR
const session = await createPairingRequest(request.customer.id, { duration_minutes, price, is_free_trial: false, topic_sensitivity })
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 })
})

View File

@@ -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 })
})
}

View File

@@ -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 })
})
}

View File

@@ -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) => {

View File

@@ -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 }
}

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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, {
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, {
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, {
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
`
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) => {

View File

@@ -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
}

View File

@@ -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,
}
}

118
backend/test/README.md Normal file
View File

@@ -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.

View File

@@ -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()
`
}
}

View File

@@ -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'

View File

@@ -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}` })

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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()
})
})

View File

@@ -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)
})
})

View File

@@ -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()
})
})

110
backend/test/setup.js Normal file
View File

@@ -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.
}
})

24
backend/vitest.config.js Normal file
View File

@@ -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'],
},
},
})

View File

@@ -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 <name>`) 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.

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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=<token> 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."

View File

@@ -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"

View File

@@ -6,7 +6,7 @@ part of 'auth_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$authHash() => r'601e614f3297fb679f5baa893932a43ae981eb9d';
String _$authHash() => r'f0382b1380749cebe8182ad221023926768ebb92';
/// See also [Auth].
@ProviderFor(Auth)

View File

@@ -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<bool> 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<void> refresh() => _pollOnce();
void _startPolling() {
_stopPolling();
_pollTimer = Timer.periodic(const Duration(seconds: 5), (_) => _pollOnce());
}
void _stopPolling() {
_pollTimer?.cancel();
_pollTimer = null;
}
Future<void> _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<String, dynamic>?;
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);
}
}

View File

@@ -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<MitraAvailability, bool>.internal(
MitraAvailability.new,
name: r'mitraAvailabilityProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$mitraAvailabilityHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$MitraAvailability = AsyncNotifier<bool>;
// 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

View File

@@ -6,7 +6,7 @@ part of 'chat_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$chatHash() => r'30e0aa41d2022e5bb080877e23fcb0a0c0c4b927';
String _$chatHash() => r'35388d2977db9cee4307697524620b22691c54f5';
/// See also [Chat].
@ProviderFor(Chat)

View File

@@ -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<void> 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<String, dynamic>)['id'] as String;
// Backend rejects truly empty bodies on confirm, so always send `{}`.
await api.post(
'/api/client/payment-sessions/$paymentSessionId/confirm',
data: const <String, dynamic>{},
);
// 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.');
}

View File

@@ -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._();
}

View File

@@ -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<void> requestPairing({required TopicSensitivity topicSensitivity}) async {
await _doPairingRequest({'topic_sensitivity': topicSensitivity.value});
}
Future<void> 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<void> startSearch({
required String paymentSessionId,
required TopicSensitivity topicSensitivity,
}) async {
final body = <String, dynamic>{'topic_sensitivity': topicSensitivity.value};
if (isFreeTrial) {
body['is_free_trial'] = true;
} else {
body['duration_minutes'] = durationMinutes;
body['price'] = price;
}
await _doPairingRequest(body);
}
Future<void> _doPairingRequest(Map<String, dynamic> body) async {
if (state is! 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<String, dynamic>;
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<void> 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<String, dynamic>?;
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<void> 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<void> 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<String, dynamic>;
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<void> _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<String, dynamic>;
if (data['type'] == WsMessage.authOk) return;
_onStatusUpdate(data);
_onWsEvent(data);
},
onError: (_) {},
onDone: () {},
@@ -140,42 +331,89 @@ class Pairing extends _$Pairing {
}));
}
Future<void> _onStatusUpdate(Map<String, dynamic> data) async {
Future<void> _onWsEvent(Map<String, dynamic> 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<void> 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();
}
}

View File

@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$pairingHash() => r'e1c3074239cc4efda99885331ff91b3e0f903c8d';
String _$pairingHash() => r'0fe1e5646fe70b90f5489919ec8dd557c998daad';
/// See also [Pairing].
@ProviderFor(Pairing)

View File

@@ -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<ChatHistoryScreen> {
}
}
void _onCurhatLagiPressed(Map<String, dynamic> 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: <String, dynamic>{
'targetedMitraId': mitraId,
'mitraName': mitraName,
'topicSensitivity': TopicSensitivity.regular,
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -42,11 +65,13 @@ class _ChatHistoryScreenState extends ConsumerState<ChatHistoryScreen> {
? 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<ChatHistoryScreen> {
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'),

View File

@@ -1,13 +1,32 @@
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(
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(
@@ -16,24 +35,33 @@ class NoBestieScreen extends StatelessWidget {
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),
'Belum berhasil terhubung',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const SizedBox(height: 12),
const Text(
'Maaf, semua Bestie sedang sibuk. Coba lagi nanti ya.',
'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(
onPressed: () => context.go('/home'),
child: const Text('Kembali'),
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'),
),
],
),
),
),
),
),
);
}
}

View File

@@ -2,27 +2,128 @@ 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) {
ConsumerState<SearchingScreen> createState() => _SearchingScreenState();
}
class _SearchingScreenState extends ConsumerState<SearchingScreen> {
/// 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<PairingData>(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,
});
} else if (next is PairingNoBestieData) {
context.go('/chat/no-bestie');
} else if (next is PairingCancelledData) {
context.go('/home');
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(
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(
@@ -30,9 +131,9 @@ class SearchingScreen extends ConsumerWidget {
children: [
const CircularProgressIndicator(),
const SizedBox(height: 32),
const Text(
'Mencari Bestie...',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
Text(
isTargetedWaiting ? 'Menghubungi bestie...' : 'Mencari Bestie...',
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
@@ -40,15 +141,33 @@ class SearchingScreen extends ConsumerWidget {
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).cancelPairing(),
onPressed: () => ref.read(pairingProvider.notifier).cancelSearch(),
child: const Text('Batalkan'),
),
],
),
),
),
);
}
}

View File

@@ -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<void> show(
BuildContext context, {
required String paymentSessionId,
required String mitraName,
required TopicSensitivity topicSensitivity,
}) {
return showDialog<void>(
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'),
),
],
);
}
}

View File

@@ -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<void> 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<void> 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,51 +50,27 @@ 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),
formatRupiah(tier.price),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
onTap: () {
Navigator.of(context).pop();
if (isExtension) {
_requestExtension(
ref,
sessionId: extensionSessionId!,
ref.read(sessionClosureProvider.notifier).requestExtension(
extensionSessionId,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
} else {
_startPairing(
ref,
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,
);
}
}

View File

@@ -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'),
),
],
),
),
),
),
),
);
}
}

View File

@@ -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<HomeScreen> 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<void> _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<HomeScreen> 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,17 +72,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> 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(
@@ -79,17 +90,29 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
),
],
),
body: Center(
child: Padding(
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),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
const SizedBox(height: 32),
activeSessionAsync.when(
Center(child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24))),
const SizedBox(height: 32),
Center(
child: activeSessionAsync.when(
loading: () => const CircularProgressIndicator(),
error: (_, __) => _StartChatButton(onPressed: () => _onStartChatPressed(context)),
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
@@ -109,11 +132,14 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
},
);
}
return _StartChatButton(onPressed: () => _onStartChatPressed(context));
return _StartChatButton(
enabled: mitraAvailable,
onPressed: () => _onStartChatPressed(context),
);
},
),
],
),
],
),
),
);
@@ -121,8 +147,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
}
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),
),
],
);
}

View File

@@ -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<void> createSession({
required int durationMinutes,
String? targetedMitraId,
bool isExtension = false,
}) async {
state = const PaymentCreatingData();
try {
final body = <String, dynamic>{
'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<String, dynamic>;
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<void> 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 <String, dynamic>{},
);
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<void> 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 <String, dynamic>{},
);
} 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._();
}

View File

@@ -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<Payment, PaymentSessionData>.internal(
Payment.new,
name: r'paymentProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$paymentHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$Payment = AutoDisposeNotifier<PaymentSessionData>;
// 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

View File

@@ -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<PaymentScreen> createState() => _PaymentScreenState();
}
class _PaymentScreenState extends ConsumerState<PaymentScreen> {
/// 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<void> _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<void> _onConfirmTapped() async {
final notifier = ref.read(paymentProvider.notifier);
await notifier.confirm();
}
Future<void> _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<PaymentSessionData>(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<void> 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)),
),
),
],
),
),
);
}
}

View File

@@ -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<String, dynamic>) {
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<String, dynamic>;

View File

@@ -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

View File

@@ -2,3 +2,8 @@ node_modules/
dist/
.env
*.log
# Playwright
test-results/
playwright-report/
playwright/.cache/

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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
],
})

View File

@@ -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() {
<Route path="dashboard" element={<DashboardPage />} />
<Route path="mitras" element={<MitrasPage />} />
<Route path="sessions" element={<SessionsPage />} />
<Route path="failed-pairings" element={<FailedPairingsPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="mitra-activity" element={<MitraActivityPage />} />

View File

@@ -63,6 +63,7 @@ export default function Layout() {
<li><NavLink to="/dashboard">Dashboard</NavLink></li>
<li><NavLink to="/mitras">Mitra</NavLink></li>
<li><NavLink to="/sessions">Sesi</NavLink></li>
<li><NavLink to="/failed-pairings">Failed Pairings</NavLink></li>
<li><NavLink to="/users">Users</NavLink></li>
<li><NavLink to="/mitra-activity">Aktivitas Mitra</NavLink></li>
<li><NavLink to="/settings">Settings</NavLink></li>

View File

@@ -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',
})

View File

@@ -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 (
<div>
<h1>Failed Pairings</h1>
<div style={{ marginBottom: 16, padding: 12, border: '1px solid #eee', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<strong style={{ marginRight: 8 }}>Cause:</strong>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 4 }}>
{CAUSE_OPTIONS.map((opt) => (
<label key={opt.value} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 13 }}>
<input
type="checkbox"
checked={selectedCauses.includes(opt.value)}
onChange={() => toggleCause(opt.value)}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label style={{ fontSize: 13 }}>From:</label>
<input
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
/>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label style={{ fontSize: 13 }}>To:</label>
<input
type="date"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
/>
</div>
<button onClick={clearFilters} style={{ fontSize: 12 }}>Clear filters</button>
</div>
</div>
{isLoading && <div>Loading...</div>}
{isError && <p style={{ color: 'red' }}>Gagal memuat data failed pairings.</p>}
{!isLoading && !isError && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Created</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Customer</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Targeted Mitra</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Cause</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Amount</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Operator Action</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Actioned By</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Actioned At</th>
<th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={9} style={{ padding: 24, textAlign: 'center', color: '#666' }}>
Belum ada data failed pairings.
</td>
</tr>
)}
{rows.map((row) => {
const canAction = !row.operator_action
return (
<tr key={row.id}>
<td style={{ padding: 8 }}>{formatDateTime(row.created_at)}</td>
<td style={{ padding: 8 }}>{row.customer_call_name ?? '-'}</td>
<td style={{ padding: 8 }}>{row.targeted_mitra_call_name ?? '-'}</td>
<td style={{ padding: 8 }}>
{PairingFailureCauseLabel[row.cause_tag] ?? row.cause_tag}
</td>
<td style={{ padding: 8 }}>{formatRupiah(row.amount)}</td>
<td style={{ padding: 8 }}>{operatorActionLabel(row)}</td>
<td style={{ padding: 8 }}>{row.actioned_by_name ?? '-'}</td>
<td style={{ padding: 8 }}>{formatDateTime(row.actioned_at)}</td>
<td style={{ padding: 8, position: 'relative' }}>
{canAction ? (
<>
<button
onClick={() => setOpenMenuId(openMenuId === row.id ? null : row.id)}
disabled={actionMutation.isPending}
style={{ fontSize: 12 }}
>
Action
</button>
{openMenuId === row.id && (
<div style={{
position: 'absolute',
right: 8,
top: '100%',
background: 'white',
border: '1px solid #ddd',
boxShadow: '0 2px 6px rgba(0,0,0,0.08)',
zIndex: 10,
minWidth: 180,
}}>
<button
style={menuItemStyle}
onClick={() => actionMutation.mutate({ id: row.id, action: PairingFailureOperatorAction.REFUNDED })}
>
Mark as refunded
</button>
<button
style={menuItemStyle}
onClick={() => actionMutation.mutate({ id: row.id, action: PairingFailureOperatorAction.CREDITED })}
>
Mark as credited
</button>
<button
style={menuItemStyle}
onClick={() => actionMutation.mutate({ id: row.id, action: PairingFailureOperatorAction.NO_ACTION })}
>
Mark as no-action
</button>
</div>
)}
</>
) : (
<span style={{ color: '#999', fontSize: 12 }}></span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center', alignItems: 'center' }}>
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Prev</button>
<span>Page {page} of {totalPages} ({total} total)</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
</>
)}
{actionMutation.isError && (
<p style={{ color: 'red', marginTop: 8 }}>Gagal menyimpan operator action.</p>
)}
</div>
)
}
const menuItemStyle = {
display: 'block',
width: '100%',
padding: '8px 12px',
background: 'white',
border: 'none',
borderBottom: '1px solid #f0f0f0',
textAlign: 'left',
cursor: 'pointer',
fontSize: 13,
}

View File

@@ -49,12 +49,12 @@ export default function LoginPage() {
<h2>Control Center</h2>
<form onSubmit={handleSubmit}>
<div>
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
<label htmlFor="cc-login-email">Email</label>
<input id="cc-login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
</div>
<div>
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
<label htmlFor="cc-login-password">Password</label>
<input id="cc-login-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
</div>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit" disabled={loading} style={{ width: '100%' }}>

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
import { ExtensionTimeoutAction } from '../../core/constants'
const fetchAnonymityConfig = async () => {
const res = await apiClient.get('/internal/config/anonymity')
@@ -74,6 +75,47 @@ const updateSensitivityConfig = async (data) => {
return res.data.data
}
// Paid pairing flow + extension flip
const fetchPairingBlastTimeout = async () => {
const res = await apiClient.get('/internal/config/pairing-blast-timeout')
return res.data.data
}
const updatePairingBlastTimeout = async (pairing_blast_timeout_seconds) => {
const res = await apiClient.patch('/internal/config/pairing-blast-timeout', { pairing_blast_timeout_seconds })
return res.data.data
}
const fetchPaymentSessionTimeout = async () => {
const res = await apiClient.get('/internal/config/payment-session-timeout')
return res.data.data
}
const updatePaymentSessionTimeout = async (payment_session_timeout_minutes) => {
const res = await apiClient.patch('/internal/config/payment-session-timeout', { payment_session_timeout_minutes })
return res.data.data
}
const fetchReturningChatTimeout = async () => {
const res = await apiClient.get('/internal/config/returning-chat-timeout')
return res.data.data
}
const updateReturningChatTimeout = async (returning_chat_confirmation_timeout_seconds) => {
const res = await apiClient.patch('/internal/config/returning-chat-timeout', { returning_chat_confirmation_timeout_seconds })
return res.data.data
}
const fetchExtensionDefaultAction = async () => {
const res = await apiClient.get('/internal/config/extension-default-action')
return res.data.data
}
const updateExtensionDefaultAction = async (extension_default_action_on_timeout) => {
const res = await apiClient.patch('/internal/config/extension-default-action', { extension_default_action_on_timeout })
return res.data.data
}
export default function SettingsPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
@@ -143,7 +185,50 @@ export default function SettingsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-sensitivity'] }),
})
if (isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading) return <div>Loading...</div>
// Pairing Blast Timeout
const { data: pbtData, isLoading: pbtLoading } = useQuery({
queryKey: ['config-pairing-blast-timeout'],
queryFn: fetchPairingBlastTimeout,
})
const pbtMutation = useMutation({
mutationFn: updatePairingBlastTimeout,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-pairing-blast-timeout'] }),
})
// Payment Session Timeout
const { data: pstData, isLoading: pstLoading } = useQuery({
queryKey: ['config-payment-session-timeout'],
queryFn: fetchPaymentSessionTimeout,
})
const pstMutation = useMutation({
mutationFn: updatePaymentSessionTimeout,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-payment-session-timeout'] }),
})
// Returning Chat Confirmation Timeout
const { data: rctData, isLoading: rctLoading } = useQuery({
queryKey: ['config-returning-chat-timeout'],
queryFn: fetchReturningChatTimeout,
})
const rctMutation = useMutation({
mutationFn: updateReturningChatTimeout,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-returning-chat-timeout'] }),
})
// Extension Default Action on Timeout
const { data: edaData, isLoading: edaLoading } = useQuery({
queryKey: ['config-extension-default-action'],
queryFn: fetchExtensionDefaultAction,
})
const edaMutation = useMutation({
mutationFn: updateExtensionDefaultAction,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-extension-default-action'] }),
})
if (
isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading ||
pbtLoading || pstLoading || rctLoading || edaLoading
) return <div>Loading...</div>
return (
<div>
@@ -320,6 +405,94 @@ export default function SettingsPage() {
</p>
{senMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Batas Waktu Blast Pairing</h2>
<p>Berapa lama backend menunggu mitra menerima sebelum blast dianggap gagal.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="5"
value={pbtData?.pairing_blast_timeout_seconds ?? 60}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 5) pbtMutation.mutate(val)
}}
disabled={pbtMutation.isPending}
style={{ width: 80 }}
/>
<span>detik</span>
</div>
{pbtMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Batas Waktu Sesi Pembayaran</h2>
<p>Sesi pembayaran customer akan kedaluwarsa otomatis setelah durasi ini jika belum dikonfirmasi atau belum menghasilkan chat.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="1"
value={pstData?.payment_session_timeout_minutes ?? 20}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 1) pstMutation.mutate(val)
}}
disabled={pstMutation.isPending}
style={{ width: 80 }}
/>
<span>menit</span>
</div>
{pstMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Batas Waktu Konfirmasi Chat Lanjutan</h2>
<p>Berapa detik bestie punya waktu untuk menerima/menolak permintaan chat lanjutan dari customer sebelum otomatis ditolak.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="5"
value={rctData?.returning_chat_confirmation_timeout_seconds ?? 20}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 5) rctMutation.mutate(val)
}}
disabled={rctMutation.isPending}
style={{ width: 80 }}
/>
<span>detik</span>
</div>
{rctMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Aksi Default jika Bestie Tidak Menjawab Extension</h2>
<p>Jika bestie tidak merespon permintaan extension dalam batas waktu, sistem akan otomatis menerima atau menolak sesuai pilihan ini.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input
type="radio"
name="extension_default_action_on_timeout"
value={ExtensionTimeoutAction.AUTO_APPROVE}
checked={(edaData?.extension_default_action_on_timeout ?? ExtensionTimeoutAction.AUTO_APPROVE) === ExtensionTimeoutAction.AUTO_APPROVE}
onChange={() => edaMutation.mutate(ExtensionTimeoutAction.AUTO_APPROVE)}
disabled={edaMutation.isPending}
/>
Otomatis disetujui (auto-approve)
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="radio"
name="extension_default_action_on_timeout"
value={ExtensionTimeoutAction.AUTO_REJECT}
checked={edaData?.extension_default_action_on_timeout === ExtensionTimeoutAction.AUTO_REJECT}
onChange={() => edaMutation.mutate(ExtensionTimeoutAction.AUTO_REJECT)}
disabled={edaMutation.isPending}
/>
Otomatis ditolak (auto-reject)
</label>
{edaMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
</div>
)
}

View File

@@ -0,0 +1,160 @@
# Control Center — Playwright E2E Tests
End-to-end tests for the Halo Bestie control center. Tests run a real browser
against a running CC dev server, which talks to a running internal backend.
## Install
From `control_center/`:
```bash
npm install
npx playwright install chromium
```
To add Firefox or WebKit later:
```bash
npx playwright install firefox webkit
```
…then add a project entry in `playwright.config.js`.
## Configure
Copy the example env file and fill in your local values:
```bash
cp .env.example .env
```
Required env vars (all have sensible defaults for `localhost`):
| Var | Default | Purpose |
| ---------------------- | ------------------------ | -------------------------------------------- |
| `CC_BASE_URL` | `http://localhost:5173` | Where the CC SPA is reachable |
| `BACKEND_INTERNAL_URL` | `http://localhost:3001` | Where the internal Fastify listener is |
| `CC_TEST_EMAIL` | placeholder | Operator account used by the suite |
| `CC_TEST_PASSWORD` | placeholder | Operator account password |
The seeded admin (`admin@halobestie.com` / `ChangeMe123!` from
`backend/src/db/seed.js`) works as the test operator for local dev. For
shared/CI environments, provision a dedicated test user.
`playwright.config.js` automatically loads `.env` via `dotenv.config()`. CLI
env vars still take precedence — useful when running one-off:
```bash
CC_BASE_URL=http://192.168.1.10:5173 npm run test:e2e
```
## Run on the same machine
1. Start the backend (public + internal listeners):
```bash
cd backend && npm run dev
```
2. Start the CC dev server (separate shell):
```bash
cd control_center && npm run dev
```
3. Run the suite (separate shell):
```bash
cd control_center && npm run test:e2e
```
## Run on a different machine
The Playwright config does NOT auto-start the CC dev server — that's deliberate
so the same suite can target a remote dev server. Point the env vars at it:
```bash
CC_BASE_URL=http://192.168.88.247:5173 \
BACKEND_INTERNAL_URL=http://192.168.88.247:3001 \
CC_TEST_EMAIL=test-operator@example.com \
CC_TEST_PASSWORD=changeme \
npm run test:e2e
```
The CC dev server must be reachable on the network — by default Vite binds to
`localhost`. To make it listen on all interfaces, start it with:
```bash
npm run dev -- --host
```
…or set `server.host: true` in `vite.config.js`.
## Run a single test
```bash
# One file
npm run test:e2e -- tests/e2e/settings.spec.js
# Tests matching a pattern (across all files)
npm run test:e2e -- --grep "payment session timeout"
```
## Debug
Three options, in order of how heavy they are:
```bash
# 1. UI mode — watches files, lets you re-run individual tests, inspect DOM,
# and see the timeline. Best for iterating on a flaky test.
npm run test:e2e:ui
# 2. Headed mode — same suite, but with a visible browser window.
npm run test:e2e:headed
# 3. Inspector — pauses execution, opens devtools, lets you step through.
npm run test:e2e:debug
```
To record video for every test:
```bash
RECORD=1 npm run test:e2e
```
Failure artifacts (screenshots, traces, videos) land in `test-results/`. Open
the HTML report with:
```bash
npx playwright show-report
```
## Adding a new test
1. Create `tests/e2e/<feature>.spec.js`.
2. Import the helpers:
```js
import { test, expect } from '@playwright/test'
import { loginAsOperator } from './helpers/auth.js'
import { backendRequest } from './helpers/backend-api.js'
```
3. Use `loginAsOperator(page)` in `beforeEach` for any test that hits a
protected route.
4. Use `backendRequest('/internal/...')` for fixture setup/teardown so the
test body stays focused on the UI behavior.
5. Look at `settings.spec.js` and `failed-pairings.spec.js` for the patterns
already in use (snapshot → mutate → reload → restore).
## Files
```
tests/e2e/
├── README.md (you are here)
├── helpers/
│ ├── auth.js loginAsOperator() — UI login flow
│ └── backend-api.js fetch wrapper + fixture helpers
├── settings.spec.js Phase 3.7 config rows (2 cases)
└── failed-pairings.spec.js Failed Pairings page (2 cases)
```

View File

@@ -0,0 +1,96 @@
import { test, expect } from '@playwright/test'
import { loginAsOperator } from './helpers/auth.js'
import { listFailedPairings, ensureAtLeastOneFailedPairing } from './helpers/backend-api.js'
/**
* Failed Pairings page e2e tests.
*
* NOTE: There is currently no public test-seed endpoint for failed_pairings.
* Production rows are inserted by the backend pairing service when a real
* pairing fails. These tests therefore rely on the database already containing
* at least one row (any cause). If the table is empty the tests are skipped
* with a clear TODO so the user can either:
* - run the real pairing flow once to generate real fixtures, or
* - add a backend test-only seed route (see helpers/backend-api.js).
*/
test.describe('Failed Pairings page', () => {
test.beforeEach(async ({ page }) => {
await loginAsOperator(page)
})
test('renders the table when at least one row exists', async ({ page }) => {
let seeded
try {
seeded = await ensureAtLeastOneFailedPairing()
} catch (e) {
test.skip(true, `No failed-pairings rows in DB. ${e.message}`)
return
}
await page.goto('/failed-pairings')
await expect(page.getByRole('heading', { name: 'Failed Pairings', level: 1 })).toBeVisible()
// The table headers should always render once data has loaded.
await expect(page.getByRole('columnheader', { name: 'Created' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Cause' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Operator Action' })).toBeVisible()
// At least one data row (skip the header row).
const dataRows = page.locator('table tbody tr')
const count = await dataRows.count()
expect(count).toBeGreaterThanOrEqual(1)
// The "empty" placeholder row should NOT be visible when we have data.
await expect(page.getByText('Belum ada data failed pairings.')).not.toBeVisible()
// Sanity check — total label matches what the backend reports.
await expect(page.getByText(`(${seeded.total} total)`)).toBeVisible()
})
test('cause-tag filter narrows the visible rows', async ({ page }) => {
// We need rows of at least 2 distinct cause tags to make a meaningful
// assertion about filtering. If the DB has fewer, skip with a clear
// message rather than producing a fake-positive.
const all = await listFailedPairings({ limit: 200 })
const distinctCauses = [...new Set((all?.rows ?? []).map((r) => r.cause_tag))]
if (distinctCauses.length < 2) {
test.skip(
true,
`Need >=2 distinct cause_tags in failed_pairings to test filter. Found: ${distinctCauses.join(', ') || '(none)'}.`,
)
return
}
const targetCause = distinctCauses[0]
const expectedFiltered = await listFailedPairings({ causeTags: [targetCause], limit: 200 })
await page.goto('/failed-pairings')
await expect(page.getByRole('heading', { name: 'Failed Pairings', level: 1 })).toBeVisible()
// Capture pre-filter total — must be >= filtered total or assertion is bogus.
const totalBefore = all.total
expect(totalBefore).toBeGreaterThan(expectedFiltered.total)
// Tick the cause checkbox. The filter section labels the checkbox with the
// human label from PairingFailureCauseLabel — find it by its associated text.
// Map the cause_tag to its label by re-importing the constants.
const { PairingFailureCauseLabel } = await import(
'../../src/core/constants.js'
)
const targetLabel = PairingFailureCauseLabel[targetCause]
await page.getByRole('checkbox', { name: targetLabel }).check()
// Expect the new total to match the API-computed expectation.
await expect(page.getByText(`(${expectedFiltered.total} total)`)).toBeVisible()
// And every visible row should reflect the chosen cause tag.
const causeCells = page.locator('table tbody tr td:nth-child(4)')
const visibleCount = await causeCells.count()
expect(visibleCount).toBeGreaterThan(0)
for (let i = 0; i < visibleCount; i++) {
await expect(causeCells.nth(i)).toHaveText(targetLabel)
}
})
})

View File

@@ -0,0 +1,52 @@
/**
* Auth helper for Playwright e2e tests.
*
* Logs in via the actual UI (rather than minting a JWT directly) for two
* reasons:
* 1. The CC keeps the access token in memory + uses an httpOnly refresh
* cookie. The cleanest way to exercise that flow is the real form.
* 2. It tests the login page implicitly — if the form breaks, every
* downstream test fails fast and obviously.
*
* If/when login becomes the bottleneck, swap this for a fixture that calls
* `POST /internal/auth/login` once per worker and replays the cookie via
* `context.addCookies(...)`.
*/
import { expect } from '@playwright/test'
const TEST_EMAIL = process.env.CC_TEST_EMAIL || 'test-operator@example.com'
const TEST_PASSWORD = process.env.CC_TEST_PASSWORD || 'changeme'
/**
* Navigates to /login, fills the form, submits, and waits for the post-login
* redirect (defaults to /dashboard via App.jsx Navigate).
*
* @param {import('@playwright/test').Page} page
* @param {{ email?: string, password?: string }} [overrides]
*/
export async function loginAsOperator(page, overrides = {}) {
const email = overrides.email ?? TEST_EMAIL
const password = overrides.password ?? TEST_PASSWORD
await page.goto('/login')
await page.getByLabel('Email').fill(email)
await page.getByLabel('Password').fill(password)
await page.getByRole('button', { name: /Masuk/i }).click()
// App.jsx redirects authenticated users from `/` to `/dashboard`.
// Wait for the URL to leave /login as the success signal.
await page.waitForURL((url) => !url.pathname.startsWith('/login'), {
timeout: 10_000,
})
}
/**
* Convenience: assert the current page is a logged-in CC page (i.e. NOT
* /login). Useful as a sanity-check at the top of a test.
*
* @param {import('@playwright/test').Page} page
*/
export async function expectLoggedIn(page) {
await expect(page).not.toHaveURL(/\/login/)
}

View File

@@ -0,0 +1,164 @@
/**
* Thin wrapper around the internal backend for test setup / fixtures.
*
* The CC test suite needs to seed and clean up rows directly via the API
* (rather than via the UI) for tests that depend on pre-existing state —
* e.g. "Failed Pairings page renders rows" needs at least one row to exist.
*
* All requests:
* - go to BACKEND_INTERNAL_URL (default http://localhost:3001)
* - send credentials: 'include' so cookies (refresh token) round-trip
* - automatically attach Authorization: Bearer <access_token> after login
*/
const BACKEND_URL = process.env.BACKEND_INTERNAL_URL || 'http://localhost:3001'
const TEST_EMAIL = process.env.CC_TEST_EMAIL || 'test-operator@example.com'
const TEST_PASSWORD = process.env.CC_TEST_PASSWORD || 'changeme'
let cachedAccessToken = null
let cachedCookieHeader = null
/**
* Logs into the backend via the real CC login route and caches the access
* token + Set-Cookie value for subsequent calls. Token is cached for the
* whole worker lifetime — for the slim CC suite that's plenty.
*/
async function loginToBackend() {
if (cachedAccessToken) return cachedAccessToken
const res = await fetch(`${BACKEND_URL}/internal/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: TEST_EMAIL, password: TEST_PASSWORD }),
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Backend login failed (${res.status}): ${body}`)
}
const json = await res.json()
cachedAccessToken = json?.data?.access_token
if (!cachedAccessToken) {
throw new Error(`Backend login response missing access_token: ${JSON.stringify(json)}`)
}
// node-fetch returns multi-value Set-Cookie via .getSetCookie() in newer
// Node versions. Fall back to .get() for older runtimes.
const setCookie =
typeof res.headers.getSetCookie === 'function'
? res.headers.getSetCookie()
: [res.headers.get('set-cookie')].filter(Boolean)
cachedCookieHeader = setCookie.map((c) => c.split(';')[0]).join('; ') || null
return cachedAccessToken
}
/**
* Generic authenticated request to the internal backend.
*
* @param {string} path — path beginning with `/internal/...`
* @param {RequestInit} [init] — fetch options (method, body, headers)
* @returns {Promise<any>} — parsed JSON response body (whole envelope)
*/
export async function backendRequest(path, init = {}) {
const token = await loginToBackend()
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(init.headers || {}),
}
if (cachedCookieHeader) headers.Cookie = cachedCookieHeader
const res = await fetch(`${BACKEND_URL}${path}`, { ...init, headers })
const text = await res.text()
let json
try {
json = text ? JSON.parse(text) : null
} catch {
json = { raw: text }
}
if (!res.ok) {
const errMsg = json?.error?.message || text || res.statusText
throw new Error(`backend ${init.method || 'GET'} ${path} -> ${res.status}: ${errMsg}`)
}
return json
}
// ---------------------------------------------------------------------------
// Settings helpers — read/write CC config rows used by settings.spec.js
// ---------------------------------------------------------------------------
/** Read current `payment_session_timeout_minutes` value. */
export async function getPaymentSessionTimeout() {
const json = await backendRequest('/internal/config/payment-session-timeout')
return json?.data?.payment_session_timeout_minutes
}
/** Force-set `payment_session_timeout_minutes` (useful for test reset). */
export async function setPaymentSessionTimeout(minutes) {
return backendRequest('/internal/config/payment-session-timeout', {
method: 'PATCH',
body: JSON.stringify({ payment_session_timeout_minutes: minutes }),
})
}
/** Read current `extension_default_action_on_timeout` value. */
export async function getExtensionDefaultAction() {
const json = await backendRequest('/internal/config/extension-default-action')
return json?.data?.extension_default_action_on_timeout
}
/** Force-set `extension_default_action_on_timeout` (useful for test reset). */
export async function setExtensionDefaultAction(action) {
return backendRequest('/internal/config/extension-default-action', {
method: 'PATCH',
body: JSON.stringify({ extension_default_action_on_timeout: action }),
})
}
// ---------------------------------------------------------------------------
// Failed-pairings helpers
// ---------------------------------------------------------------------------
/**
* Fetch failed-pairings rows (optionally filtered). Used to assert the page
* is reading what we just inserted, and to discover a real customer/mitra ID
* if needed.
*/
export async function listFailedPairings({ causeTags = [], limit = 50, offset = 0 } = {}) {
const params = new URLSearchParams()
for (const tag of causeTags) params.append('cause_tags', tag)
params.set('limit', String(limit))
params.set('offset', String(offset))
const json = await backendRequest(`/internal/failed-pairings?${params}`)
return json?.data
}
/**
* Seed a fixture failed-pairing row.
*
* NOTE: There is no public "create failed pairing" endpoint — production
* rows are inserted by the backend pairing service when a real pairing
* fails. For e2e tests we expose a thin test-only seed by reusing the
* /internal/failed-pairings list to verify whatever rows already exist.
*
* If the suite needs guaranteed-fresh rows, the user should add a small
* test-only seed route to the backend (e.g. POST /internal/_test/seed-failed-pairing)
* gated behind NODE_ENV !== 'production'. This helper currently:
* - returns the existing row count, OR
* - throws a clear error so the test can be skipped with a TODO.
*
* @returns {Promise<{ total: number, rows: any[] }>}
*/
export async function ensureAtLeastOneFailedPairing() {
const data = await listFailedPairings({ limit: 1 })
if ((data?.total ?? 0) === 0) {
throw new Error(
'No failed-pairings rows present in the database. ' +
'Either trigger one via the real pairing flow or add a backend test-seed route.',
)
}
return data
}

View File

@@ -0,0 +1,138 @@
import { test, expect } from '@playwright/test'
import { loginAsOperator } from './helpers/auth.js'
import {
getPaymentSessionTimeout,
setPaymentSessionTimeout,
getExtensionDefaultAction,
setExtensionDefaultAction,
} from './helpers/backend-api.js'
/**
* Settings page e2e tests.
*
* These tests exercise two of the four Phase 3.7 config rows end-to-end:
* - payment_session_timeout_minutes (number input)
* - extension_default_action_on_timeout (radio)
*
* Strategy:
* 1. Snapshot the current backend value before each test
* 2. Mutate via the UI
* 3. Reload to confirm persistence (rules out optimistic-only state)
* 4. Restore the original value in afterEach (so the suite is rerunnable)
*/
test.describe('Settings page — Phase 3.7 config rows', () => {
test.beforeEach(async ({ page }) => {
await loginAsOperator(page)
await page.goto('/settings')
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible()
})
// -------------------------------------------------------------------------
// payment_session_timeout_minutes
// -------------------------------------------------------------------------
test.describe('Batas Waktu Sesi Pembayaran', () => {
let originalValue
test.beforeEach(async () => {
originalValue = await getPaymentSessionTimeout()
})
test.afterEach(async () => {
if (typeof originalValue === 'number') {
await setPaymentSessionTimeout(originalValue)
}
})
test('changing 20 → 25 persists across reload', async ({ page }) => {
await setPaymentSessionTimeout(20)
const section = page
.locator('section', { has: page.getByRole('heading', { name: 'Batas Waktu Sesi Pembayaran' }) })
const input = section.getByRole('spinbutton')
await page.reload()
await expect(input).toHaveValue('20')
// Mutate via the UI. The component fires a mutation on every keystroke
// (no Save button), so .fill() with the final value is enough.
// Wait for the PATCH to complete BEFORE reloading — disabled flip alone
// is racy because fill() returns before React processes the onChange.
const patchResponse = page.waitForResponse(r =>
r.url().includes('/internal/config/payment-session-timeout')
&& r.request().method() === 'PATCH'
&& r.status() === 200
)
await input.fill('25')
await patchResponse
// Reload and confirm the new value sticks.
await page.reload()
await expect(input).toHaveValue('25')
// And confirm the backend agrees.
const persisted = await getPaymentSessionTimeout()
expect(persisted).toBe(25)
})
})
// -------------------------------------------------------------------------
// extension_default_action_on_timeout (radio)
// -------------------------------------------------------------------------
test.describe('Aksi Default Extension', () => {
let originalValue
test.beforeEach(async () => {
originalValue = await getExtensionDefaultAction()
})
test.afterEach(async () => {
if (originalValue) {
await setExtensionDefaultAction(originalValue)
}
})
test('flipping auto-approve <-> auto-reject persists across reload', async ({ page }) => {
// Start from a known state.
await setExtensionDefaultAction('auto_approve')
const section = page
.locator('section', { has: page.getByRole('heading', { name: 'Aksi Default jika Bestie Tidak Menjawab Extension' }) })
const approveRadio = section.getByRole('radio', { name: /Otomatis disetujui/ })
const rejectRadio = section.getByRole('radio', { name: /Otomatis ditolak/ })
await page.reload()
await expect(approveRadio).toBeChecked()
await expect(rejectRadio).not.toBeChecked()
// Flip to auto-reject. Use click() instead of check() — check() races against
// the React re-render that flips `checked` after the mutation roundtrip.
// Wait for the PATCH to complete before asserting persistence.
const patchToReject = page.waitForResponse(r =>
r.url().includes('/internal/config/extension-default-action')
&& r.request().method() === 'PATCH'
&& r.status() === 200
)
await rejectRadio.click()
await patchToReject
await page.reload()
await expect(rejectRadio).toBeChecked()
await expect(approveRadio).not.toBeChecked()
expect(await getExtensionDefaultAction()).toBe('auto_reject')
// Flip back to auto-approve.
const patchToApprove = page.waitForResponse(r =>
r.url().includes('/internal/config/extension-default-action')
&& r.request().method() === 'PATCH'
&& r.status() === 200
)
await approveRadio.click()
await patchToApprove
await page.reload()
await expect(approveRadio).toBeChecked()
expect(await getExtensionDefaultAction()).toBe('auto_approve')
})
})
})

View File

@@ -0,0 +1,85 @@
# mitra_app Maestro flows
End-to-end UI automation for the mitra Flutter app using [Maestro](https://maestro.mobile.dev). Single-emulator + curl-as-customer pattern — when a flow needs a customer to "do something", it's simulated via backend API calls fired from `runScript` steps.
## One-time install
See [client_app/.maestro/README.md](../../client_app/.maestro/README.md#one-time-install) — Maestro is a global CLI, install once and it serves both apps.
You also need:
- `adb` on your PATH (Android Studio platform-tools)
- `jq` (`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/
│ ├── 01_smoke.yaml
│ ├── 02_online_offline_toggle.yaml
│ └── 03_accept_general_blast.yaml
└── scripts/
└── customer_blast_now.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_CUSTOMER_ID` and `TEST_CUSTOMER_JWT` — used by the curl harness to fire blasts toward this mitra
## Run a flow
```bash
maestro test mitra_app/.maestro/flows/01_smoke.yaml
# all flows
maestro test mitra_app/.maestro/flows/
```
If multiple devices are attached:
```bash
adb devices
maestro --device emulator-5554 test mitra_app/.maestro/flows/01_smoke.yaml
```
## Per-machine overrides
Override config.yaml values at runtime:
```bash
maestro test \
--env BACKEND_URL=http://192.168.99.10:3000 \
--env TEST_CUSTOMER_JWT=eyJhbGc... \
mitra_app/.maestro/flows/03_accept_general_blast.yaml
```
## Single-emulator + curl pattern
This mirrors the client_app pattern. When a mitra-side flow needs the customer to act, the flow uses `runScript:` to fire the customer's API calls directly:
1. Mitra app is on screen via Maestro on the only connected device
2. `runScript: ../scripts/customer_blast_now.sh` creates + confirms a payment_session and fires a chat request as a "fake" customer
3. The mitra app receives the blast via WS as it would from a real customer; Maestro asserts the overlay appears
For the customer-side equivalent (drive customer with Maestro, simulate mitra via curl), see [`client_app/.maestro/`](../../client_app/.maestro/).
## When to run mitra flows vs. client_app flows
- **Default**: drive the customer side via `client_app/.maestro/`. Most Phase 3.7 assertions live there (CTA gating, payment screen, searching screen, failed-pairing terminal, "Curhat lagi" overlays).
- **Run mitra flows when** you specifically need to assert mitra UI:
- Returning-chat 20s countdown actually visible + ticking
- Extension card copy reads "otomatis disetujui"
- Online/offline toggle behavior (Section J — mitra goes offline mid-session)
- Incoming-request overlay accept/decline buttons
## Adding a new flow
See [client_app/.maestro/README.md](../../client_app/.maestro/README.md#adding-a-new-flow) — same pattern.
## Troubleshooting
See [client_app/.maestro/README.md](../../client_app/.maestro/README.md#troubleshooting) — same checklist applies.

View File

@@ -0,0 +1,22 @@
# Shared variables for all mitra_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
APP_ID_ANDROID: com.halobestie.mitra.mitra_app
APP_ID_IOS: com.halobestie.mitra
# Backend the app talks to — must match what the installed APK was built with.
BACKEND_URL: http://192.168.88.247:3000
BACKEND_INTERNAL_URL: http://192.168.88.247:3001
# Test mitra credentials — must exist in the mitras table on the target backend.
MITRA_PHONE: "+628200000001"
MITRA_OTP: "123456"
# If you need to drive a "second actor" (e.g., a customer creating a blast), the test
# flows curl the backend directly using these credentials.
TEST_CUSTOMER_ID: "REPLACE-WITH-A-REAL-CUSTOMER-UUID"
TEST_CUSTOMER_JWT: "REPLACE-WITH-A-VALID-CUSTOMER-JWT"

View File

@@ -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 mitra_app/.maestro/flows/01_smoke.yaml
#
# Pre-req: mitra_app debug APK installed on the connected device, signed in as a mitra.
appId: ${APP_ID_ANDROID}
---
- launchApp:
clearState: false
- assertVisible:
text: "Sesi Aktif|Riwayat Chat"
timeout: 10000

View File

@@ -0,0 +1,23 @@
# Verifies the online/offline toggle works and reflects in the UI.
# This is independent of the customer side — pure mitra UI test.
#
# Run:
# maestro test mitra_app/.maestro/flows/02_online_offline_toggle.yaml
appId: ${APP_ID_ANDROID}
---
- launchApp:
clearState: false
# Find the toggle and capture initial state.
- assertVisible:
text: "Online|Offline"
# Tap the toggle — it's a Switch widget; Maestro can tap by adjacent text label.
- tapOn:
text: "Online|Offline"
# After flipping, the opposite label should appear within ~2s
# (status is server-confirmed via /api/mitra/status/online or /offline).
- assertVisible:
text: "Online|Offline"
timeout: 5000

View File

@@ -0,0 +1,33 @@
# Mirror of client_app's 03_payment_to_chat_happy.yaml — this drives the MITRA side
# of the same flow. Use this when you specifically need to assert mitra-side UI
# (the incoming overlay, accept tap behavior).
#
# Pre-req:
# 1. Mitra signed in to the mitra_app and ONLINE
# 2. TEST_CUSTOMER_ID and TEST_CUSTOMER_JWT in .maestro/config.yaml point at a real customer
# 3. The customer has an existing confirmed payment_session ready to blast (use the
# seed_customer_pending_blast.sh helper)
#
# Run:
# maestro test mitra_app/.maestro/flows/03_accept_general_blast.yaml
appId: ${APP_ID_ANDROID}
---
- launchApp:
clearState: false
- assertVisible: "Online" # ensure mitra is online before triggering the blast
# Step 1: simulate a customer creating a confirmed payment + firing a general blast.
# This script returns once the blast notification has been sent to this mitra.
- runScript: ../scripts/customer_blast_now.sh
# Step 2: incoming-request overlay appears on this device
- assertVisible:
text: "Terima"
timeout: 10000
- assertVisible: "Tolak"
# Step 3: mitra accepts → overlay closes, chat opens
- tapOn: "Terima"
- assertVisible:
text: "Sesi Aktif"
timeout: 5000

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Seed a confirmed payment_session for the test customer and fire a general blast.
# Used by Maestro flows that drive the mitra side and need a customer's request to
# arrive without running a second app.
#
# Reads from .maestro/config.yaml env (BACKEND_URL, TEST_CUSTOMER_ID, TEST_CUSTOMER_JWT).
set -euo pipefail
: "${BACKEND_URL:?BACKEND_URL must be set in .maestro/config.yaml}"
: "${TEST_CUSTOMER_JWT:?TEST_CUSTOMER_JWT must be set in .maestro/config.yaml}"
# Step 1: create a payment session (paid tier, 30 minutes)
echo "Creating payment session..."
ps_response=$(curl -fsSL -X POST "$BACKEND_URL/api/client/payment-sessions" \
-H "Authorization: Bearer $TEST_CUSTOMER_JWT" \
-H "Content-Type: application/json" \
-d '{"duration_minutes": 30}')
payment_session_id=$(echo "$ps_response" | jq -r '.data.id')
echo " payment_session_id=$payment_session_id"
# Step 2: confirm the payment session
echo "Confirming payment session..."
curl -fsSL -X POST "$BACKEND_URL/api/client/payment-sessions/$payment_session_id/confirm" \
-H "Authorization: Bearer $TEST_CUSTOMER_JWT" \
-H "Content-Type: application/json" \
-d '{}' > /dev/null
# Step 3: fire the chat request (general blast)
echo "Firing general blast..."
curl -fsSL -X POST "$BACKEND_URL/api/client/chat-requests" \
-H "Authorization: Bearer $TEST_CUSTOMER_JWT" \
-H "Content-Type: application/json" \
-d "{\"payment_session_id\":\"$payment_session_id\",\"topic_sensitivity\":\"regular\"}" > /dev/null
echo "OK — blast fired. Mitra should receive the WS event within ~1s."

View File

@@ -37,12 +37,19 @@ class ChatRequestIncomingData extends ChatRequestData {
final bool? isFreeTrial;
final TopicSensitivity topicSensitivity;
final DateTime? createdAt;
// Distinguishes general blast vs targeted "Curhat lagi" requests.
// Returning requests carry a server-driven confirmation window; the overlay shows a
// countdown but the server is the source of truth on auto-reject.
final PairingRequestType requestType;
final int? confirmationTimeoutSeconds;
const ChatRequestIncomingData(
this.sessionId, {
this.durationMinutes,
this.isFreeTrial,
this.topicSensitivity = TopicSensitivity.regular,
this.createdAt,
this.requestType = PairingRequestType.general,
this.confirmationTimeoutSeconds,
});
}
@@ -103,6 +110,8 @@ class ChatRequest extends _$ChatRequest {
'is_free_trial': r['is_free_trial'],
'topic_sensitivity': r['topic_sensitivity'],
'created_at': r['created_at'],
'request_type': r['request_type'],
'confirmation_timeout_seconds': r['confirmation_timeout_seconds'],
};
if (state is ChatRequestIncomingData ||
@@ -118,6 +127,8 @@ class ChatRequest extends _$ChatRequest {
createdAt: r['created_at'] != null
? DateTime.tryParse(r['created_at'] as String)
: null,
requestType: PairingRequestType.fromString(r['request_type'] as String?),
confirmationTimeoutSeconds: r['confirmation_timeout_seconds'] as int?,
);
}
}
@@ -207,6 +218,8 @@ class ChatRequest extends _$ChatRequest {
createdAt: data['created_at'] != null
? DateTime.tryParse(data['created_at'] as String)
: null,
requestType: PairingRequestType.fromString(data['request_type'] as String?),
confirmationTimeoutSeconds: data['confirmation_timeout_seconds'] as int?,
);
}
@@ -287,6 +300,8 @@ class ChatRequest extends _$ChatRequest {
createdAt: next['created_at'] != null
? DateTime.tryParse(next['created_at'] as String)
: null,
requestType: PairingRequestType.fromString(next['request_type'] as String?),
confirmationTimeoutSeconds: next['confirmation_timeout_seconds'] as int?,
);
validateIncomingRequest();
} else {

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../chat_request_notifier.dart';
@@ -20,6 +21,13 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
late final Animation<Offset> _slideAnimation;
bool _visible = false;
// Returning-chat countdown. Server is the source of truth on auto-reject;
// this is purely visual. When it hits 0 we dismiss the overlay and let the server's
// chat_request_closed event (or stale state) take over.
Timer? _countdownTimer;
int? _secondsRemaining;
String? _countdownSessionId;
@override
void initState() {
super.initState();
@@ -35,6 +43,7 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
@override
void dispose() {
_stopCountdown();
_animController.dispose();
super.dispose();
}
@@ -47,11 +56,52 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
}
void _hide() {
_stopCountdown();
_animController.reverse().then((_) {
if (mounted) setState(() => _visible = false);
});
}
void _startCountdown(String sessionId, int seconds) {
_stopCountdown();
_countdownSessionId = sessionId;
_secondsRemaining = seconds;
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
final remaining = (_secondsRemaining ?? 0) - 1;
if (remaining <= 0) {
// Auto-dismiss UI only — server fires the actual auto-reject and will follow up
// with a chat_request_closed event. Do NOT call decline from the client here.
setState(() => _secondsRemaining = 0);
_stopCountdown();
_hide();
} else {
setState(() => _secondsRemaining = remaining);
}
});
}
void _stopCountdown() {
_countdownTimer?.cancel();
_countdownTimer = null;
_countdownSessionId = null;
_secondsRemaining = null;
}
void _maybeStartCountdownFor(ChatRequestIncomingData data) {
final timeout = data.confirmationTimeoutSeconds;
if (data.requestType == PairingRequestType.returning &&
timeout != null &&
timeout > 0) {
// Restart only if this is a different session than the one we're already counting.
if (_countdownSessionId != data.sessionId) {
_startCountdown(data.sessionId, timeout);
}
} else {
_stopCountdown();
}
}
void _onSwipeDown(DragEndDetails details) {
if (details.primaryVelocity != null && details.primaryVelocity! > 200) {
final state = ref.read(chatRequestProvider);
@@ -66,7 +116,12 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
@override
Widget build(BuildContext context) {
ref.listen(chatRequestProvider, (prev, next) {
if (next is ChatRequestIncomingData || next is ChatRequestStaleData) {
if (next is ChatRequestIncomingData) {
_show();
_maybeStartCountdownFor(next);
} else if (next is ChatRequestStaleData) {
// Stale message replaces the active request — kill any returning-chat countdown.
_stopCountdown();
_show();
} else if (next is ChatRequestAcceptedData) {
_hide();
@@ -137,6 +192,15 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
: '';
final isSensitive = data.topicSensitivity == TopicSensitivity.sensitive;
final theme = SensitivityTheme.of(data.topicSensitivity);
final isReturning = data.requestType == PairingRequestType.returning;
final showCountdown = isReturning &&
_countdownSessionId == data.sessionId &&
_secondsRemaining != null;
final headlineText =
isReturning ? 'Customer ingin chat lagi!' : 'Ada permintaan chat baru!';
final subtitleText = isReturning
? 'Seorang customer yang pernah chat denganmu ingin lanjut.'
: 'Seorang customer ingin curhat denganmu.';
return Container(
decoration: BoxDecoration(
@@ -164,11 +228,15 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
borderRadius: BorderRadius.circular(2),
),
),
const Icon(Icons.chat, size: 48, color: Colors.blue),
Icon(
isReturning ? Icons.replay_circle_filled : Icons.chat,
size: 48,
color: isReturning ? Colors.deepPurple : Colors.blue,
),
const SizedBox(height: 12),
const Text(
'Ada permintaan chat baru!',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
Text(
headlineText,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
if (durationText.isNotEmpty)
@@ -181,10 +249,36 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
SensitivityBadge(sensitivity: data.topicSensitivity, fontSize: 12),
],
const SizedBox(height: 8),
const Text(
'Seorang customer ingin curhat denganmu.',
style: TextStyle(fontSize: 14, color: Colors.grey),
Text(
subtitleText,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
if (showCountdown) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.orange.shade200),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.timer_outlined, size: 16, color: Colors.orange.shade800),
const SizedBox(width: 6),
Text(
'Konfirmasi dalam ${_secondsRemaining}s',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.orange.shade800,
),
),
],
),
),
],
const SizedBox(height: 20),
Row(
children: [

View File

@@ -74,6 +74,19 @@ enum TopicSensitivity {
values.firstWhere((e) => e.value == v, orElse: () => TopicSensitivity.regular);
}
/// Pairing request type — distinguishes general blast from targeted
/// "Curhat lagi" returning-chat requests.
enum PairingRequestType {
general('general'),
returning('returning');
final String value;
const PairingRequestType(this.value);
static PairingRequestType fromString(String? v) =>
values.firstWhere((e) => e.value == v, orElse: () => PairingRequestType.general);
}
/// WebSocket message types
class WsMessage {
// Auth

View File

@@ -459,6 +459,10 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
final isResponding = extState is ExtensionRespondingData;
final topic = TopicSensitivity.fromString(request['topic_sensitivity'] as String?);
final isSensitive = topic == TopicSensitivity.sensitive;
// Extensions auto-approve on mitra non-response (server-side, with connectivity
// safeguards). Surface the configured timeout to the mitra so they know what
// "no response" means in this card.
final timeoutSeconds = request['timeout_seconds'] as int?;
return Container(
color: isSensitive ? SensitivityTheme.sensitive.bgTint : null,
@@ -477,6 +481,18 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
],
const SizedBox(height: 8),
Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center),
if (timeoutSeconds != null) ...[
const SizedBox(height: 12),
Text(
'Tidak menjawab dalam $timeoutSeconds detik = otomatis disetujui',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
fontStyle: FontStyle.italic,
),
),
],
const SizedBox(height: 24),
if (isResponding)
const CircularProgressIndicator()

View File

@@ -0,0 +1,403 @@
# Phase 3.7 — Implementation Plan
> See [phase3.7.md](phase3.7.md) for the PRD and [phase3.7-questions.md](phase3.7-questions.md) for the answered question doc.
This document is the build sequence: **what** files change, **in what order**, with **API contracts** for new endpoints. The "why" is in the PRD — don't restate it here.
---
## Build Order (4 stages)
The dependency graph forces this order:
1. **Backend foundation** — schema + config + new services (nothing user-visible yet)
2. **Backend routes** — endpoints, blast/extension behavior changes (smoke-testable via curl)
3. **Apps** — client_app + mitra_app cut over to the new flow
4. **Control center** — Failed Pairings screen + new config UI
Within each stage, items are listed in the order they should land.
---
# Stage 1 — Backend Foundation
## 1.1 Schema additions ([backend/src/db/migrate.js](../backend/src/db/migrate.js))
> The repo uses a single `migrate.js` script (no per-file migrations). Append new DDL to the existing script.
### New table: `payment_sessions`
```sql
CREATE TABLE IF NOT EXISTS payment_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES users(id),
amount INTEGER NOT NULL DEFAULT 0, -- mocked, in IDR (rupiah, no decimals)
duration_minutes INTEGER NOT NULL,
is_free_trial 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), -- NULL for general blast; set for "Curhat lagi"
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
confirmed_at TIMESTAMPTZ,
consumed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL -- created_at + payment_session_timeout_minutes
);
CREATE INDEX IF NOT EXISTS idx_payment_sessions_customer ON payment_sessions(customer_id);
CREATE INDEX IF NOT EXISTS idx_payment_sessions_status_expires ON payment_sessions(status, expires_at);
```
### New table: `pairing_failures`
```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 users(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 cc_users(id),
actioned_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_pairing_failures_created_at ON pairing_failures(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_pairing_failures_cause ON pairing_failures(cause_tag);
CREATE INDEX IF NOT EXISTS idx_pairing_failures_unactioned ON pairing_failures(created_at DESC) WHERE operator_action IS NULL;
```
### `chat_sessions` — add nullable FK
```sql
ALTER TABLE chat_sessions ADD COLUMN IF NOT EXISTS payment_session_id UUID REFERENCES payment_sessions(id);
CREATE INDEX IF NOT EXISTS idx_chat_sessions_payment ON chat_sessions(payment_session_id);
```
> Nullable for backward compat with pre-3.7 sessions; required for newly-created rows (enforced at service layer, not DB).
### Seed new config rows
Append to seed step:
```sql
INSERT INTO app_config (key, value) VALUES
('payment_session_timeout_minutes', '20'),
('returning_chat_confirmation_timeout_seconds', '20'),
('extension_default_action_on_timeout', 'auto_approve'),
('pairing_blast_timeout_seconds', '<existing-Phase-2-default>') -- only if not already present
ON CONFLICT (key) DO NOTHING;
```
Also: in the same seed step, **update** `extension_timeout_seconds` default to `10` ONLY for new installs (use `ON CONFLICT DO NOTHING`; existing dev DB will need a manual one-line update or CC change).
---
## 1.2 New service: `payment.service.js`
**File:** `backend/src/services/payment.service.js` (new)
Exports:
- `createPaymentSession({ customerId, durationMinutes, amount, isFreeTrial, targetedMitraId? })` → returns `{ id, status: 'pending', expiresAt }`. Reads `payment_session_timeout_minutes` from config to compute `expires_at`.
- `confirmPaymentSession(paymentSessionId, customerId)` → transitions `pending → confirmed`. Throws if not owned by customer / wrong status / expired.
- `consumePaymentSession(paymentSessionId)` → transitions `confirmed → consumed`. Called from pairing service when chat starts.
- `failPaymentSession(paymentSessionId, causeTag)` → transitions `confirmed → failed_pairing`, writes a `pairing_failures` row in the same transaction. Idempotent (no-op if already failed/consumed).
- `expireStalePaymentSessions()` → background sweeper: `pending` rows past `expires_at``expired`; `confirmed` rows past `expires_at` AND not consumed → `failed_pairing` with `cause_tag = 'payment_session_expired'`.
- `getPaymentSession(id)` — for routes.
## 1.3 New service: `pairing-failure.service.js`
**File:** `backend/src/services/pairing-failure.service.js` (new)
Exports:
- `recordFailure({ paymentSessionId, customerId, targetedMitraId?, causeTag, amount })` — used by `payment.service.failPaymentSession`. Kept in its own service for clean CC queries.
- `listFailures({ causeTags?, dateFrom?, dateTo?, limit, offset })` — for CC.
- `setOperatorAction(failureId, ccUserId, action)` — for CC operator action menu.
## 1.4 Modify: `mitra-status.service.js`
**File:** [backend/src/services/mitra-status.service.js](../backend/src/services/mitra-status.service.js)
Add a Valkey-only function:
- `countAvailableMitrasFromCache()` → reads online-mitra set + per-mitra active session counters from Valkey, compares against `max_customers_per_mitra` (cached value, refreshed on config change), returns `{ available: boolean, count: number }`. **No Postgres queries on the hot path.**
> The existing `getOnlineMitras()` joins to active session counts via DB — that's too heavy for a 5s poll. Build a parallel cached path. The capacity cap is read once on cold start and kept in memory; bust the cache when CC updates the config (existing config-update path can publish a Valkey invalidate event).
## 1.5 Modify: `extension.service.js`
**File:** [backend/src/services/extension.service.js](../backend/src/services/extension.service.js)
Changes (do not change the timeout numeric — just the action):
- Read `extension_default_action_on_timeout` from `app_config` when timer fires.
- If `auto_approve`: at timer fire, check **mitra connectivity** (WS + Valkey online status). If both OK → call existing approve path (which will trigger the mock charge). If either is offline/disconnected → treat as auto-reject (no charge). Update the `cause_tag` if a payment row is involved.
- If `auto_reject`: existing behavior unchanged (back-compat with the data-driven flag).
- Update extension-request creation: customer must pass `extension_payment_session_id` (separate payment session for the extension cost). If `is_free_trial = true` is not allowed for extensions — enforce server-side per PRD §5.2.
## 1.6 Modify: `pairing.service.js`
**File:** [backend/src/services/pairing.service.js](../backend/src/services/pairing.service.js)
Changes:
- `createPairingRequest()` now requires `paymentSessionId`. On entry: load the payment session, assert `status = confirmed` and ownership. Refuse with 409 if not.
- New variant: `createTargetedPairingRequest({ paymentSessionId, customerId, targetedMitraId })` for "Curhat lagi". Skips the broad blast — sends a single targeted notification, starts a server-side 20s timer (read from `returning_chat_confirmation_timeout_seconds`).
- Pre-check: targeted mitra online? If not → fail immediately with `cause_tag = targeted_mitra_offline` (call `payment.service.failPaymentSession`), return 409 with `{ reason: 'targeted_mitra_offline' }`.
- Pre-check: targeted mitra at capacity AND not mid-session with this customer? → same as offline.
- On timer fire (no response): mark request as `auto_rejected`, fail payment with `cause_tag = targeted_mitra_timeout`, push WS event `RETURNING_CHAT_TIMEOUT` to customer.
- On explicit decline: fail payment with `cause_tag = targeted_mitra_rejected`, push WS event `RETURNING_CHAT_REJECTED`.
- On accept: existing accept path → `consumePaymentSession`, chat starts.
- `respondToPairingRequest()` (general blast) on success: call `consumePaymentSession`. On blast-window expiry without acceptance: call `failPaymentSession` with `cause_tag = no_mitra_available`. On every-mitra-rejected before timeout: `cause_tag = all_mitras_rejected`.
- New: `cancelPaymentSearch(paymentSessionId, customerId)` — customer-initiated cancel during searching/waiting → fail payment with `cause_tag = customer_cancelled`.
- New: `fallbackToGeneralBlast(paymentSessionId, customerId)` — used when "Chat dengan bestie lain" is tapped after a returning-chat fail. Reuses the same payment session (no new charge), runs the standard blast.
## 1.7 Modify: `pricing.service.js`
**File:** [backend/src/services/pricing.service.js](../backend/src/services/pricing.service.js)
- Add `getExtensionPriceTiers(customerId)` — same shape as initial pricing but always returns `is_free_trial = false` for every tier (per PRD §5.2 "no trial for extensions"). Existing `getPriceTiers()` for initial chat is unchanged.
## 1.8 Background sweeper
**File:** new entry in `backend/src/index.js` (or wherever existing intervals live; check for an existing `setInterval` pattern in the chat-session timer code).
- Run `paymentService.expireStalePaymentSessions()` every 60 seconds.
- When we scale to multi-instance, this should move to Valkey keyspace notifications (consistent with the existing memory item "Session Timer Scaling").
---
# Stage 2 — Backend Routes
## 2.1 New: `client.mitra-availability.routes.js`
**File:** `backend/src/routes/public/client.mitra-availability.routes.js` (new)
```
GET /api/client/mitra-availability
→ 200 { "available": true|false, "count": number? }
```
- Auth: customer JWT.
- Backed entirely by `countAvailableMitrasFromCache()`.
- No rate limit needed — this is a hot endpoint by design (every 5s per active customer).
## 2.2 New: `client.payment.routes.js`
**File:** `backend/src/routes/public/client.payment.routes.js` (new)
```
POST /api/client/payment-sessions
body: { duration_minutes, tier_id?, targeted_mitra_id? }
→ 201 { id, amount, is_free_trial, expires_at, status: "pending" }
POST /api/client/payment-sessions/:id/confirm
→ 200 { id, status: "confirmed" }
POST /api/client/payment-sessions/:id/cancel
→ 200 { id, status: "abandoned" } # only valid while pending; failed_pairing for already-confirmed search-cancel uses 2.3
GET /api/client/payment-sessions/:id
→ 200 { ...full row }
```
Free-trial logic: the `POST` checks `pricingService.isCustomerEligibleForFreeTrial(customerId)` and computes `amount = 0, is_free_trial = true` accordingly.
## 2.3 Modify: `client.chat.routes.js` (existing)
**File:** [backend/src/routes/public/client.chat.routes.js](../backend/src/routes/public/client.chat.routes.js)
Changes:
- The "start search" route now requires `payment_session_id` in the body. Old shape rejected with 400.
- Add `POST /api/client/chat-requests/cancel` → forwards to `pairing.service.cancelPaymentSearch`.
- Add `POST /api/client/chat-requests/returning` → body `{ payment_session_id, mitra_id }`, forwards to `createTargetedPairingRequest`.
- Add `POST /api/client/chat-requests/:paymentSessionId/fallback-to-blast` → forwards to `fallbackToGeneralBlast`.
Existing routes that bypass payment (any "start blast directly") — **delete**.
## 2.4 Modify: `client.chat.routes.js` (extension)
Update extension request route to require `extension_payment_session_id`. Free-trial path is forbidden (return 400 if `is_free_trial = true`).
## 2.5 New: `internal.failed-pairings.routes.js`
**File:** `backend/src/routes/internal/failed-pairings.routes.js` (new)
```
GET /internal/failed-pairings
query: cause_tags[]?, date_from?, date_to?, limit=50, offset=0
→ 200 { rows: [...], total }
POST /internal/failed-pairings/:id/action
body: { action: "refunded"|"credited"|"no_action" }
→ 200 { ...updated row }
```
Auth: existing CC JWT. Mount on the internal listener (port 3001).
## 2.6 Modify: `internal/config.routes.js`
**File:** [backend/src/routes/internal/config.routes.js](../backend/src/routes/internal/config.routes.js)
Add the four new keys to the allow-list (PRD §6.1):
- `pairing_blast_timeout_seconds` (int)
- `payment_session_timeout_minutes` (int)
- `returning_chat_confirmation_timeout_seconds` (int)
- `extension_default_action_on_timeout` (enum: `auto_reject` | `auto_approve`)
On any of these keys being updated, publish a Valkey invalidate event so the in-memory caches in `mitra-status.service` and `extension.service` refresh.
## 2.7 Smoke test (curl) — must pass before Stage 3
- Happy path: `POST payment-sessions``POST confirm``POST chat-requests` → mitra accepts → chat starts → `payment_sessions.status = consumed`.
- No-mitra path: same as above but no mitra accepts within blast window → `pairing_failures` row created with `cause_tag = no_mitra_available`.
- Returning-chat happy: `POST chat-requests/returning` → mitra accepts within 20s → chat starts.
- Returning-chat timeout: no mitra response in 20s → `cause_tag = targeted_mitra_timeout`.
- Returning-chat → fallback: `POST .../fallback-to-blast` → general blast runs against same payment session.
- Mitra-availability: `GET /api/client/mitra-availability` returns `{ available: true }` while at least one mitra is online and has spare capacity; flips to `false` when capacity hits cap.
---
# Stage 3 — Apps
## 3.1 client_app — Mitra availability poll
**File:** `client_app/lib/core/availability/mitra_availability_notifier.dart` (new)
Riverpod `AsyncNotifier`:
- `build()` — emits `AsyncData(false)` initially.
- Starts a 5s `Timer.periodic` only when the home screen is in the foreground (`AppLifecycleState.resumed`). Pauses on `paused`/`inactive`. Hook via `WidgetsBindingObserver` in the home screen.
- `refresh()` — manual trigger for pull-to-refresh.
- On HTTP failure → emit `AsyncData(false)` (default to disabled per PRD §1.3).
**File:** [client_app/lib/features/home/home_screen.dart](../client_app/lib/features/home/home_screen.dart)
Changes:
- Watch `mitraAvailabilityProvider`.
- "Mulai Curhat" CTA: enabled when `available == true`, otherwise greyed-out with subtitle "Belum ada bestie tersedia".
- Add lifecycle observer to gate the polling.
## 3.2 client_app — Payment screen
**File:** `client_app/lib/features/payment/screens/payment_screen.dart` (new)
- Reuses existing `pricingProvider` for tier list + free-trial check.
- Layout: tier picker + "Total" line ("Gratis" if free trial, else "Rp X") + primary CTA "Bayar" (or "Mulai" if free trial).
- On confirm tap → `POST /api/client/payment-sessions/:id/confirm` → push to searching screen with `payment_session_id`.
- On back/dispose: call `POST .../cancel` if status still `pending` (best-effort).
**File:** `client_app/lib/features/payment/payment_notifier.dart` (new)
Riverpod notifier managing the payment-session state for the screen.
**Routes:** add `/payment` to GoRouter. Replace the current `_onStartChatPressed` handler in `home_screen.dart` to push `/payment` instead of opening the pricing bottom-sheet directly.
## 3.3 client_app — Pairing notifier rewrite
**File:** [client_app/lib/core/pairing/pairing_notifier.dart](../client_app/lib/core/pairing/pairing_notifier.dart)
Changes:
- `startSearch()` now requires `paymentSessionId`. The HTTP body changes accordingly.
- New state variants:
- `PairingTargetedWaitingData(mitraName, secondsRemaining)` — for the 20s returning-chat overlay.
- `PairingFailedData(causeTag, fallbackOffered: bool)` — for the failed-pairing terminal screen.
- Listen to new WS events: `RETURNING_CHAT_TIMEOUT`, `RETURNING_CHAT_REJECTED`.
## 3.4 client_app — Searching/found/no-bestie screens
**Reuse** [searching_screen.dart](../client_app/lib/features/chat/screens/searching_screen.dart), [bestie_found_screen.dart](../client_app/lib/features/chat/screens/bestie_found_screen.dart) as-is.
**Modify** [no_bestie_screen.dart](../client_app/lib/features/chat/screens/no_bestie_screen.dart): becomes the **failed-pairing terminal screen**. Shows the standard copy from PRD §2.5 ("Maaf, kami tidak bisa menemukan bestie...") + "Kembali ke beranda" CTA.
**New widget:** `client_app/lib/features/chat/widgets/targeted_waiting_overlay.dart` — modal overlay shown above the searching screen during the 20s window. Cancel button → `POST /api/client/chat-requests/cancel`.
**New widget:** `client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart` — popup for §3.4/§3.8: title "Bestie sedang tidak online" + "Chat dengan bestie lain" CTA (only if general availability allows) + "Kembali" CTA. The "Chat dengan bestie lain" CTA calls `POST .../fallback-to-blast` then transitions to the searching screen.
## 3.5 client_app — Chat history "Curhat lagi" CTA
**File:** [client_app/lib/features/chat/screens/chat_history_screen.dart](../client_app/lib/features/chat/screens/chat_history_screen.dart)
Add a **"Curhat lagi"** trailing button on every row. On tap:
1. Push `/payment?targetedMitraId=<id>` (payment screen reads the param; passes `targeted_mitra_id` to `POST /payment-sessions`).
2. After confirm → `POST /chat-requests/returning` → push the searching screen with the targeted-waiting overlay shown.
## 3.6 mitra_app — Returning-chat card
**File:** [mitra_app/lib/core/chat/widgets/chat_request_overlay.dart](../mitra_app/lib/core/chat/widgets/chat_request_overlay.dart)
- Reuse the existing component for general blast requests.
- Add a 20s countdown when the WS payload indicates `request_type = 'returning'`. Overlay closes itself if no action by 0s (UI follows server; server is the source of truth on auto-reject).
**File:** [mitra_app/lib/core/chat/chat_request_notifier.dart](../mitra_app/lib/core/chat/chat_request_notifier.dart)
- Handle the new WS payload field `request_type`. Branch the overlay rendering accordingly.
## 3.7 mitra_app — Extension card copy
**File:** existing extension card widget (find via grep for `ExtensionRequestData` — likely in `mitra_app/lib/features/chat/widgets/extension_request_card.dart` or similar).
- Update copy: "Tidak menjawab dalam 10 detik = otomatis disetujui" (or whatever the team prefers in Bahasa).
- No behavior change — the auto-approve fires server-side.
## 3.8 client_app — Extension flow
- Customer-side: extension request still opens an in-app picker for duration + price (reuse the pricing UX, omit the free-trial path).
- On submit → create an `extension_payment_session` (calls the new payment route with no `targeted_mitra_id`, just an `is_extension: true` flag — small adjustment in `client.payment.routes.js`).
- On mitra approve / auto-approve → existing extended-session UX kicks in.
- On mitra reject within 10s → existing rejected UX, no charge.
---
# Stage 4 — Control Center
## 4.1 Settings page additions
**File:** [control_center/src/pages/settings/SettingsPage.jsx](../control_center/src/pages/settings/SettingsPage.jsx)
Add four new rows (follow the existing `useQuery`+`useMutation` pattern at lines 14-76):
- `payment_session_timeout_minutes` — number input
- `returning_chat_confirmation_timeout_seconds` — number input
- `extension_default_action_on_timeout` — radio/select: "Auto-approve" / "Auto-reject"
- `pairing_blast_timeout_seconds` — number input (only if not already present)
Use the same Indonesian labels and explanations from PRD §6.1.
## 4.2 New: Failed Pairings page
**File:** `control_center/src/pages/failed-pairings/FailedPairingsPage.jsx` (new)
- Table per PRD §6.2.
- Filters: cause-tag multi-select, date range.
- Per-row action menu: "Mark as refunded" / "Mark as credited" / "Mark as no-action" → `POST /internal/failed-pairings/:id/action`.
**File:** [control_center/src/components/Layout.jsx](../control_center/src/components/Layout.jsx)
Add a sidebar entry "Failed Pairings" → `/failed-pairings`.
**File:** wherever the React Router routes live — register the new page.
---
# Verification Checklist
Before declaring done:
- [ ] Stage 1: `node backend/src/db/migrate.js` runs cleanly; new tables and config rows exist.
- [ ] Stage 2: All curl smoke tests in §2.7 pass.
- [ ] Stage 3 client_app: home CTA disables when last mitra goes offline; payment screen renders; happy-path E2E chat starts; "Curhat lagi" path works end-to-end including the targeted-mitra-offline popup; cancel mid-search creates a failed-pairing row.
- [ ] Stage 3 mitra_app: returning-chat card shows the 20s countdown; auto-reject lands in failed-pairing; extension card shows new copy; auto-approve fires when mitra ignores it; auto-reject fires when mitra goes offline mid-extension-window.
- [ ] Stage 4: Failed Pairings page lists all rows from a test run and lets operator action a row; new config rows save and take effect (test by changing `returning_chat_confirmation_timeout_seconds` to 5 and watching auto-reject fire faster).
- [ ] Old instant-blast code paths are gone — grep confirms no remaining call sites.
---
# Risk Notes
- **Cache invalidation for `countAvailableMitrasFromCache()`** — if we get the config-update Valkey publish wrong, the 5s poll will lie about availability for stale-cache duration. Mitigation: keep the cache TTL short (10s) as a backstop even when invalidate isn't published.
- **Server-side timer for the 20s returning-chat window** — uses `setTimeout` per the existing pattern. Will not survive a backend restart mid-window. Acceptable for single-instance deploy; for multi-instance, fold into the existing Valkey-keyspace-notification follow-up (memory item "Session Timer Scaling").
- **Background sweeper race** — `expireStalePaymentSessions` runs every 60s; an extension request that lands on an expiring payment session in the same minute could see a confusing state. Mitigate by checking expiry inline at every state transition (`confirmPaymentSession`, `consumePaymentSession`).
- **Failed-pairing copy is a placeholder** — PRD §2.5 explicitly notes the copy will be revised. Don't over-invest in the design.
---
# Open Questions
_None — ready to start coding from Stage 1._

View File

@@ -0,0 +1,124 @@
---
status: ANSWERED — ready for PRD (phase3.7.md)
captured: 2026-05-02
answered: 2026-05-03
---
# Phase 3.7 — Clarifying Questions (Answered)
Raw asks from user (2026-05-02 chat):
1. CTA Curhat on customer home is gated by mitra availability — pulled every 5 seconds.
2. New session flow: **CTA → payment screen → payment confirmed → blast → mitra accept (idempotent) → chat starts**. (Today: blast happens immediately on CTA, no payment in path.)
3. Customer can start a new session with the **same** mitra via a CTA on chat history.
4. Returning-chat (same-mitra) requests need mitra approval. **20-second** window, **auto-reject** on timeout. Timeout configurable via control center.
5. Session extension still requires approval, but **10-second** window with **auto-approve** on timeout (flip from today's auto-reject). Configurable via control center.
---
## Phase numbering — DECIDED
- Called **Phase 3.7** (next free after 3.6). Originally drafted as "Phase 4" but user prefers to keep 3.x numbering for this scope.
---
## Section 1 — CTA gated by mitra availability (5s poll)
| # | Q | Answer |
|---|---|---|
| 1.1 | Signal definition | **(b)** at least 1 mitra online **AND below max-customer capacity** |
| 1.2 | Endpoint shape | New lightweight `GET /api/client/mitra-availability``{ available: bool, count?: number }`. **Backend reads from Valkey only — must not hit Postgres on every poll.** Count optional in payload (CC debugging), client only reads `available` |
| 1.3 | Polling lifecycle | Foreground only. Pause on background, resume on foreground |
| 1.4 | Disabled-state UX | **(a)** Greyed CTA with subtitle "Belum ada bestie tersedia" |
| 1.5 | Visible count | Binary only — no number shown to user |
| 1.6 | Stale data on poll fail | **(b)** Default to disabled |
---
## Section 2 — New flow: CTA → Payment → Blast → Accept → Chat
### 2a. Payment screen
| # | Q | Answer |
|---|---|---|
| 2a.1 | Integration depth | **Mocked** — no real Xendit in 3.7. Real Xendit deferred to a later phase |
| 2a.2 | Pricing source | Keep existing Phase 3 mock pricing. Real pricing/tiers later (saved to memory) |
| 2a.3 | Free trial UX | **(b)** "Gratis" Rp 0 confirmation step on the same payment screen — does not skip to blast |
| 2a.4 | Abandonment | **(b)** Persist a "pending payment" / "abandoned" row; auto-expire after configurable timeout |
| 2a.5 | Payment timeout | **20 minutes default**, **CC-configurable** (`payment_session_timeout_minutes`) |
### 2b. Blast → Accept
| # | Q | Answer |
|---|---|---|
| 2b.1 | Customer screen during blast | Reuse existing "Searching for bestie..." screen. Real design later via Claude design |
| 2b.2 | Blast timeout | Same as Phase 2; verify it is CC-configurable, otherwise add `pairing_blast_timeout_seconds` |
| 2b.3 | No mitra accepts within window | **Persist payment + log failed-pairing event with a tag** (e.g. `no_mitra_available`, `all_mitras_rejected`, `targeted_mitra_offline`, `targeted_mitra_rejected`, `targeted_mitra_timeout`, `payment_session_expired`, `customer_cancelled`). Surface to **Control Center for manual review/refund decision**. Customer-facing: hard-fail message for now (CTA copy will be revised later) |
| 2b.4 | All mitras explicitly reject | Same as 2b.3 — different tag value |
| 2b.5 | Idempotency on accept | Confirmed — keep Phase 2's DB-level uniqueness on session acceptance |
### 2c. Migration
| # | Q | Answer |
|---|---|---|
| 2c.1 | Replacement strategy | **Replace entirely** — delete the old instant-blast path. No feature flag |
| 2c.2 | Existing screens | Reuse where possible; replace only when reuse is impractical |
---
## Section 3 — "Curhat lagi" with the same mitra (from chat history)
| # | Q | Answer |
|---|---|---|
| 3.1 | Per row or per partner | **(a)** CTA on every chat history row (simpler) |
| 3.2 | Payment first | Yes — same payment screen as regular curhat |
| 3.3 | Mitra offline at tap | **(a) but with popup** — show "Bestie sedang tidak online" popup. Offer "Chat dengan bestie lain" if any other mitra is available; otherwise just show the offline message |
| 3.4 | Mitra at capacity | Same as 3.3 |
| 3.5 | On rejection / 20s auto-reject | Same as 3.3. **Important:** payment is already taken by this point — **the same payment carries over to the general blast fallback (no double-charge)**. If fallback also fails, treat as 2b.3 (logged + CC review with appropriate tag) |
| 3.6 | Bypass general gating | **Independent** — depends only on the targeted mitra's status, not the section 1 availability poll |
| 3.7 | Anonymity | Unchanged (mitra always sees customer call_name) |
---
## Section 4 — Returning-chat approval window (20s, auto-reject)
| # | Q | Answer |
|---|---|---|
| 4.1 | Mitra UX | Reuse existing incoming-request notification component (FCM + foreground card), add visible 20s countdown |
| 4.2 | Customer UX during 20s | **(a)** Overlay "Menunggu konfirmasi bestie..." with cancel button |
| 4.3 | Auto-reject downstream | Same as 3.5 (popup → offer general blast fallback or fail; payment carries over) |
| 4.4 | Mitra offline at request time | **(a)** Auto-reject immediately, do not wait 20s |
| 4.5 | Control center config | New config row: `returning_chat_confirmation_timeout_seconds` (default 20). **Use a clear label and explanation in the CC UI** |
| 4.6 | Concurrency (mitra mid-session with someone else) | **(c)** Send the card and let mitra decide |
---
## Section 5 — Extension approval flip (10s, **auto-approve**)
| # | Q | Answer |
|---|---|---|
| 5.1 | Behavioral flip — confirm | **Confirmed intentional** — flip from auto-reject → auto-approve |
| 5.2 | Default value & config | **(c)** Keep existing extension-timeout row; add new `extension_default_action_on_timeout` enum (`auto_reject` \| `auto_approve`), default `auto_approve` |
| 5.3 | Customer overlay during 10s | Same overlay as today, just shorter timer |
| 5.4 | Charge timing for extension | **(b)** Charge at approval moment (auto-approve fires charge; explicit reject within 10s = no charge). **Important:** extension is NOT auto-charged — customer chooses time + price first (same UX as initial chat request, **without trial**) |
| 5.5 | Mitra UX | Same extension card; copy adjusted to reflect auto-approve |
| 5.6 | Mitra disconnected/offline during 10s | **(b)** Treat as auto-reject (safer for customer). **Domain rule (saved to memory): mitra can flip to offline mid-session — never use "in-session" as proxy for "online"** |
---
## Cross-cutting
| # | Q | Answer |
|---|---|---|
| X.1 | Refund / failed-pairing model | Single consistent model across 2b.3 / 3.5 / 4.3: payment row persists, event logged with **tag** (cause) for filtering/audit, surfaced to CC for manual review |
| X.2 | Old instant-blast code | **Delete entirely**, no kill-switch, no feature flag |
| X.3 | Free trial | Same payment screen UI, Rp 0 / "Gratis", with duration/tier picker shown |
| X.4 | Anonymity | Unchanged |
| X.5 | New CC configs | `pairing_blast_timeout_seconds` (only if not already), `payment_session_timeout_minutes` (default 20), `returning_chat_confirmation_timeout_seconds` (default 20), `extension_default_action_on_timeout` enum (default `auto_approve`) — no others |
| X.6 | Phase numbering | **Phase 3.7** |
---
## Next step
PRD `phase3.7.md`, then `phase3.7-plan.md`, then code.

View File

@@ -0,0 +1,251 @@
# Phase 3.7 — Test Run Report (2026-05-03 20:20 WITA)
> **Scope:** First execution of the Vitest / Playwright / Maestro test scaffolds set up earlier today. **No fixes were applied** — this is a status snapshot only. Failures are recorded with their root cause categorization (pre-req gap vs. real test failure vs. real code bug).
---
## Top-line summary (FINAL after fixes)
| Suite | Result | Pass | Fail | Blocked | Notes |
|---|---|---:|---:|---:|---|
| **Vitest (backend)** | ✅ All passing | **7** | 0 | 0 | Clean run, 5.01s end-to-end (re-verified after CORS fix) |
| **Playwright (CC)** | ✅ **All passing** | **4** | 0 | 0 | 7.3s green. Two real bugs fixed: CC login a11y + backend CORS |
| **Maestro (mobile)** | ⏸ Blocked (CLI not installed) | 0 | 0 | n/a | CLI install + device/emulator attach pending |
| **TOTAL EXECUTABLE** | | **11** | **0** | **n/a** | All 11 runnable tests pass. Two real bugs uncovered + fixed. |
## Bugs fixed during this run
### Bug 1 — CC LoginPage labels not associated with inputs (a11y + test blocker)
**Where:** `control_center/src/pages/login/LoginPage.jsx:50-58`
**Before:** sibling `<label>` text + bare `<input>` with no `htmlFor`/`id` linkage.
**After:** added `htmlFor="cc-login-email"` / `id="cc-login-email"` (and same for password).
**Real-world impact this fixes:** Screen readers can now announce the field labels. Click-on-label-to-focus-input works.
**Test impact:** `page.getByLabel('Email')` resolves correctly via the accessibility tree.
### Bug 2 — Backend internal CORS doesn't allow PATCH/PUT/DELETE (silent settings breakage in browsers)
**Where:** `backend/src/app.internal.js:18-23`
**Before:** `app.register(cors, { origin: ..., credentials: true })``@fastify/cors` defaults to allowing only `GET, HEAD, POST`.
**After:** explicit `methods: ['GET', 'HEAD', 'POST', 'PATCH', 'PUT', 'DELETE']`.
**Real-world impact this fixes (significant):** Every Settings page mutation (anonymity, max customers, free trial, extension timeout, early end, mitra ping, sensitivity, all 4 new Phase 3.7 configs) silently failed in any browser. The browser sent a CORS preflight, the backend replied "PATCH not allowed", the browser blocked the actual PATCH from being sent. axios's request was never resolved (no response, no error event hooked into). The Settings page UI accepted clicks/keystrokes but no save ever persisted.
**Why this was undetected:** Stage 4 (CC scaffolding) verified the backend round-trip via `curl`, which doesn't trigger CORS preflight. Browser-driven testing — i.e., this Playwright run — was the first to actually exercise the full path.
**Test impact:** Settings spec mutations now reach the backend.
### Test improvement (not a bug per se)
`tests/e2e/settings.spec.js` test helpers — switched the "wait for save" signal from `expect(input).toBeEnabled()` (which resolves immediately because `fill()` returns synchronously before React processes onChange) to `page.waitForResponse(r => /* PATCH /payment-session-timeout returns 200 */)`. Same pattern applied to the radio test. This is the recommended Playwright pattern for "wait until the API call truly completed."
---
## 1. Vitest (Backend) — ✅ ALL 7 PASS
**Command:** `cd backend && npm test`
**Output:**
```
RUN v4.1.5 /home/rama/workspaces/workspace-claude/halobestie-clone/backend
Test Files 3 passed (3)
Tests 7 passed (7)
Duration 4.77s (transform 197ms, setup 61ms, import 267ms, tests 4.10s, environment 0ms)
```
**Test files covered:**
| File | Tests | Result |
|---|---|---|
| `test/services/payment.service.test.js` | 3 | ✅ pass |
| `test/services/pairing.service.test.js` | 2 | ✅ pass |
| `test/routes/client.payment.routes.test.js` | 2 | ✅ pass |
**Pre-req status:**
-`npm install` had been run (vitest installed in `node_modules`)
- ✅ Test schema isolation (Option C — `halobestie_test` schema on remote `omv.sjamsani.id` Postgres) reachable
-`AUTH_JWT_SECRET` set in `.env.test`
- ✅ Migrations ran clean against the test schema
- ✅ Re-run idempotency confirmed (TRUNCATE between tests works)
**Verdict:** Backend test scaffold is fully functional. Suite is ready for new test additions.
---
## 2. Playwright (Control Center) — ❌ 4/4 FAIL (still environmental, deeper layer)
**Command:** `cd control_center && npx playwright test`
### History across this session
| Run | Trigger | Result |
|---|---|---|
| 1 | Initial run | 4/4 fail — browser binary missing (`chromium_headless_shell-1217` not cached) |
| 2 | After `npx playwright install chromium` (170 MB Chrome + 112 MB headless shell downloaded) | 4/4 fail — browser launches, but next env layer surfaces (CC dev server down + blank creds) |
| 3 | After full env bootstrap (created `playwright-runner@example.com` / `PlaywrightTest!2026` super_admin user in dev DB, filled `.env`, started CC dev server in background pid 882584) | 4/4 fail — **first real test/code issue found** at the login form |
| 4 | After fixing LoginPage.jsx (added `htmlFor`/`id` to associate labels with inputs) | 2/4 pass (failed-pairings both green), 2/4 fail (settings — value didn't persist) |
| 5 | After test helper fix (`waitForResponse` instead of `toBeEnabled`) | 2/4 pass, 2/4 fail (waitForResponse times out — no PATCH ever returns) |
| Diag | Network logging on the failing test | Revealed: PATCH was sent but no response came. Direct curl to backend worked → suspected CORS |
| 6 | After fixing backend CORS (`methods: ['GET','HEAD','POST','PATCH','PUT','DELETE']`) | **4/4 pass — 7.3s green** ✅ |
### Run 2 failure breakdown — two new root causes
| # | Test | Root cause | Category |
|---|---|---|---|
| 1 | failed-pairings renders the table | Backend login 401 (blank creds) | **Pre-req: CC test credentials** |
| 2 | failed-pairings filter narrows | Backend login 401 (blank creds) | **Pre-req: CC test credentials** |
| 3 | settings payment timeout | (a) CC dev server down AND (b) Backend login 401 | **Pre-req: CC dev server + creds** |
| 4 | settings extension default | (a) CC dev server down AND (b) Backend login 401 | **Pre-req: CC dev server + creds** |
### Pre-req status (post Chromium install)
| Pre-req | Status | Detail |
|---|---|---|
| `@playwright/test` npm package | ✅ installed | v1.59.1 in `control_center/node_modules/` |
| Chromium browser binary | ✅ installed | `~/.cache/ms-playwright/chromium-1217/` + `chromium_headless_shell-1217/` (282 MB total) |
| CC dev server (`http://localhost:5173`) | ❌ **not running** | `net::ERR_CONNECTION_REFUSED` on `page.goto('/login')`. Fix: `cd control_center && npm run dev` |
| Backend internal listener (`http://localhost:3001`) | ⚠️ reachable, but auth fails | Probably running, but the login attempt with blank creds returns 401 |
| `CC_TEST_EMAIL` / `CC_TEST_PASSWORD` in `.env` | ❌ both empty strings | Fix: edit `control_center/.env` with a real CC operator account from `control_center_users` |
### Errors captured (Run 2)
**Failures 1, 2 — Backend login 401 (blocks at test setup, before browser even matters):**
```
Error: Backend login failed (401):
{"success":false,"error":{"code":"INVALID_CREDENTIALS","message":"Invalid credentials"}}
at helpers/backend-api.js:37 (loginToBackend)
```
**Failure 4 — CC dev server connection refused (browser launches OK, then can't reach the page):**
```
Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:5173/login
at helpers/auth.js:32 (loginAsOperator)
```
### Important
**Still no CC code bug was hit.** All 4 failures occur **before** the test interacts with the CC UI — they fail at test-setup (helpers calling backend, or browser-can't-reach-CC). The actual CC components and `.spec.js` assertions remain unverified. Two more env gaps to close before we know whether the test logic + CC code works.
### Run 3 detail — first real test/code mismatch
**All 4 tests fail at the same line in the auth helper** (`tests/e2e/helpers/auth.js:33`):
```
TimeoutError: locator.fill: Timeout 10000ms exceeded.
Call log:
- waiting for getByLabel('Email')
```
**Root cause** — the CC `/login` page renders the form fields like this ([LoginPage.jsx:50-58](../control_center/src/pages/login/LoginPage.jsx#L50-L58)):
```jsx
<form onSubmit={handleSubmit}>
<div>
<label>Email</label>
<input type="email" value={email} onChange={...} />
</div>
<div>
<label>Password</label>
<input type="password" value={password} onChange={...} />
</div>
<button type="submit">{loading ? 'Loading...' : 'Masuk'}</button>
</form>
```
The `<label>` elements are NOT associated with their inputs — there's no `htmlFor` attribute and the inputs aren't nested inside the labels. Playwright's `getByLabel()` uses the same accessibility tree a screen reader uses, which requires either:
- `<label htmlFor="email">Email</label> <input id="email" ...>`
- `<label>Email <input ... /></label>` (nested)
- `<input aria-label="Email">`
None of those are in place, so the locator can't resolve. The helper times out after 10s.
**Two possible fixes (NOT applied per instruction):**
1. **Fix in CC code** — add `htmlFor` to each label + matching `id` to each input. This is also a real accessibility improvement (screen readers currently can't announce the labels).
2. **Fix in test helper** — change `getByLabel('Email')` to `page.locator('input[type="email"]')` (and same for password). Less semantic but works against the current markup.
Either fix would unblock all 4 tests. Recording only — letting the user decide which side to change.
### Other env actions taken in Run 3 (not "fixes", just bootstrapping)
| Action | Detail |
|---|---|
| Created CC test user | `playwright-runner@example.com` / `PlaywrightTest!2026`, super_admin role, in dev DB on `omv.sjamsani.id` |
| Filled `control_center/.env` | `CC_TEST_EMAIL` + `CC_TEST_PASSWORD` populated |
| Started CC dev server | Background, pid 882584, log at `/tmp/cc-dev-server.log`, ready in 174ms |
| Confirmed backend already running | Public 3000 + internal 3001 both responsive |
**To stop the background CC dev server** when done: `kill 882584` (or it'll auto-die when the parent shell exits).
### Note on Playwright MCP at `playwright.sjamsani.id`
The user has a Playwright MCP server hosted at `http://playwright.sjamsani.id/sse` (SSE transport, returns 200). This is a **separate concern** from the test runner above — Playwright MCP is for an LLM (me) to drive a browser interactively, not for running the `*.spec.js` test suite. The test suite stays on local Chromium per Option 1; the MCP server can be registered separately if interactive me-driven browser sessions are wanted. Not relevant to this run.
---
## 3. Maestro (Mobile, both apps) — ⏸ BLOCKED
**Command:** `maestro test client_app/.maestro/flows/01_smoke.yaml`
**Output:**
```
/bin/bash: line 17: maestro: command not found
```
### Pre-req status
| Pre-req | Status | Detail |
|---|---|---|
| Maestro CLI on PATH | ❌ not installed | `which maestro` → not found |
| Android emulator running | ❌ none attached | `adb devices` → "List of devices attached" (empty) |
| Real Android device attached | ❌ none attached | Same |
| `client_app` debug APK installed on a device | n/a (no device) | |
| `mitra_app` debug APK installed on a device | n/a (no device) | |
### Resolution path (per `client_app/.maestro/README.md`)
1. Install Maestro: `curl -Ls "https://get.maestro.mobile.dev" \| bash`
2. Start an emulator: `~/Android/Sdk/emulator/emulator -avd Medium_Phone_API_36.1 -no-snapshot-load`
3. Install one of the debug APKs (`flutter install` from inside `client_app/` or `mitra_app/`)
4. Re-run the smoke flow
**No flow was executed.** No data on whether the YAMLs themselves work yet.
---
## What this run tells us
**Backend logic is healthy.** All 7 backend tests pass (including the 2 Phase 3.7 regression tests covering the bugs the `/simplify` pass found and fixed). This is the most critical signal — the backend rewrites in `pairing.service.js` and `payment.service.js` work correctly under controlled assertions.
**CC and mobile signals are unknown.** Playwright and Maestro both bounced on environment setup before reaching their respective test bodies. No information about whether the CC UI or the Flutter apps work — those signals will land once the env gaps are closed.
---
## Recommended next actions (in priority order, no fixes applied yet)
1. **Unblock Playwright** — three independent steps, all env-side:
- `cd control_center && npx playwright install chromium` (~150 MB one-time)
- Start the CC dev server: `cd control_center && npm run dev`
- Edit `control_center/.env` and fill in `CC_TEST_EMAIL` + `CC_TEST_PASSWORD` with credentials of an existing CC operator account in the dev `control_center_users` table
- Re-run `npx playwright test`
2. **Unblock Maestro** — install CLI + start emulator + install one APK:
- `curl -Ls "https://get.maestro.mobile.dev" \| bash`
- Boot emulator, install client_app or mitra_app debug build
- `maestro test client_app/.maestro/flows/01_smoke.yaml`
3. **Add the test-only seed endpoint** flagged by the Stage 4 agent — `POST /internal/_test/seed-failed-pairing` (gated on `NODE_ENV !== 'production'`). Without this, `failed-pairings.spec.js` can only verify the page renders an empty state, not the row + filter behavior.
---
## Files captured during this run
- Vitest log: `/tmp/vitest-run.log`
- Playwright log: `/tmp/playwright-run.log`
- Maestro log: `/tmp/maestro-run.log`
- Playwright traces (per failed test): `control_center/test-results/*/trace.zip` — view with `npx playwright show-trace <path>`

View File

@@ -0,0 +1,201 @@
# Phase 3.7 Testing Checklist
End-to-end verification for Phase 3.7 (Paid Pairing Flow + Returning-Chat + Extension Flip). Tick boxes as you verify. Cluster labels: **[BE]** backend / curl, **[CC]** control_center, **[M]** mitra_app, **[C]** client_app.
Related docs: [phase3.7.md](./phase3.7.md), [phase3.7-plan.md](./phase3.7-plan.md), [phase3.7-questions.md](./phase3.7-questions.md).
> **Backend curl smoke (an) was executed in Stage 2** and re-validated after the `/simplify` Tier 1 bug fixes. This document focuses on the real-device E2E layer that the agent sandbox couldn't run, plus the regression checks for the bugs caught in `/simplify`.
---
## Setup
- [ ] Backend running on `192.168.88.247:3000` (public) + `:3001` (internal) — verify with `curl http://192.168.88.247:3000/health`
- [ ] Migrate has been re-run after Phase 3.7 (`payment_sessions`, `pairing_failures`, `chat_sessions.payment_session_id`, `idx_chat_sessions_mitra_status` all present)
- [ ] Seed has been re-run / 4 new app_config keys exist: `payment_session_timeout_minutes=20`, `returning_chat_confirmation_timeout_seconds=20`, `extension_default_action_on_timeout=auto_approve`, `pairing_blast_timeout_seconds=60`
- [ ] Existing `extension_timeout_seconds` is set to `10` (default flip in 3.7 — manual update needed on existing dev DBs)
- [ ] Both apps built with `--dart-define=API_BASE_URL=http://192.168.88.247:3000` and installed on real devices (or 1 device + 1 emulator)
- [ ] Control center reachable in browser (port 3001 or whatever the dev URL is) and CC operator logged in
- [ ] At least 2 mitra accounts available (1 to be online by default, 1 to flip during tests)
---
## Section A — Home CTA Gating + 5s Availability Poll
- [ ] **[C]** No mitra online → "Mulai Curhat" CTA is greyed-out with subtitle "Belum ada bestie tersedia"
- [ ] **[C]** Mitra goes online → CTA enables within 5 seconds (check the polling cadence in backend logs)
- [ ] **[C]** Last mitra hits capacity (start `max_customers_per_mitra` chats with that mitra) → CTA flips disabled within 5 seconds
- [ ] **[C]** Background the app for 60s → poll pauses (no per-5s log entries from this device); foreground → immediate poll fires
- [ ] **[C]** Pull-to-refresh on home → manual one-shot poll fires
- [ ] **[C]** Kill backend (or disconnect WiFi) → CTA flips disabled within 5s (poll-fail default per PRD §1.3)
- [ ] **[C]** Customer UI never displays the count — only the binary state (PRD §1.5)
- [ ] **[BE]** Backend logs confirm `mitra-availability` does NOT issue a Postgres query on every poll — only once per 10s TTL window (the `countAvailableMitrasFromCache()` cache backstop)
## Section B — Payment Screen + Happy-Path Blast
- [ ] **[C]** Tap CTA → `/payment` route renders the tier picker
- [ ] **[C]** Paid tier: pick a tier → "Bayar" CTA → searching screen → mitra accepts → chat starts
- [ ] **[BE]** After chat starts: `payment_sessions.status = consumed`, `chat_sessions.payment_session_id` is set
- [ ] **[C]** Free trial-eligible customer: payment screen shows "Gratis" / Rp 0 → "Mulai" CTA → searching → chat
- [ ] **[BE]** Free-trial flow creates a `payment_sessions` row with `amount=0, is_free_trial=true`
- [ ] **[C]** Customer hits Android back button on payment screen → `cancelIfPending` fires (verify `payment_sessions.status = abandoned` server-side)
- [ ] **[C]** Customer abandons payment screen, waits >20 minutes → row auto-expires (`payment_sessions.status = expired`) via the 60s sweeper
- [ ] **[C]** Customer confirms payment, then no mitra accepts within blast window (60s default) → terminal screen with copy "Maaf, kami tidak bisa menemukan bestie untuk sesimu. Tim kami akan menghubungimu segera." + "Kembali ke beranda" CTA
- [ ] **[BE]** Above produces a `pairing_failures` row tagged `no_mitra_available` + `payment_sessions.status = failed_pairing`
- [ ] **[C]** Customer confirms, all blasted mitras explicitly decline → terminal screen
- [ ] **[BE]** Above produces a row tagged `all_mitras_rejected`
## Section C — Customer Cancel (Regression for `/simplify` Tier 1 Bug Fix #3)
- [ ] **[C]** Customer hits "Batalkan" mid-search → routes to home, **NOT** to the failed-pairing terminal screen
- [ ] **[C]** No FCM "Sesi gagal" notification fires for the canceller's own action (test with app backgrounded — kill the app, send the cancel from another device, verify no FCM lands)
- [ ] **[BE]** Cancel still produces a `pairing_failures` row tagged `customer_cancelled` for CC visibility
- [ ] **[BE]** Backend logs show **no** `PAIRING_FAILED` WS push from `cancelPairingRequest` / `cancelPaymentSearch` paths
## Section D — Returning Chat ("Curhat lagi")
- [ ] **[C]** Chat history row has "Curhat lagi" CTA on every entry
- [ ] **[C]** Tap "Curhat lagi" → payment screen with `targeted_mitra_id` baked in → confirm → searching screen with **targeted-waiting overlay** showing mitra name + countdown
- [ ] **[C]** Countdown starts from `confirmation_timeout_seconds` value returned by backend (regression for `/simplify` Tier 1 fix #6 — was hardcoded to 20)
- [ ] **[CC]** Change `returning_chat_confirmation_timeout_seconds` from 20 → 7 in CC; next "Curhat lagi" overlay starts at 7
- [ ] **[C]** Mitra accepts within window → chat starts (`payment_sessions.status = consumed`)
- [ ] **[BE]** Verify exactly **one** `chat_sessions` row, FK'd to the original payment session
- [ ] **[C]** Mitra ignores the card → 20s elapses → bestie-unavailable popup ("Bestie sedang tidak online" + "Chat dengan bestie lain" + "Kembali")
- [ ] **[BE]** Above produces `pairing_failures` row tagged `targeted_mitra_timeout`; payment session **stays `confirmed`** (intermediate failure — verify via SQL)
- [ ] **[C]** Mitra explicitly declines within window → bestie-unavailable popup
- [ ] **[BE]** Above produces row tagged `targeted_mitra_rejected`; payment still `confirmed`
- [ ] **[C]** Tap "Chat dengan bestie lain" → general blast fires against the **same** payment session, no double-charge
- [ ] **[BE]** Verify `chat_sessions` table now shows TWO rows for one `payment_session_id` (original targeted = expired/cancelled, fallback blast = active or whatever it ends as). The CC Failed Pairings table will show the targeted attempt as a row keyed on its own `pairing_failures.id`.
- [ ] **[C]** Tap "Kembali" instead → home (no fallback fired)
- [ ] **[C]** Targeted mitra is offline at tap time → 409 from server → bestie-unavailable popup opens directly (no overlay was rendered)
- [ ] **[BE]** Above produces row tagged `targeted_mitra_offline`; payment stays `confirmed`
- [ ] **[C]** **Sensitive topic regression** (`/simplify` Tier 1 fix #7): start a sensitive-topic session, return-to-history, "Curhat lagi" → fallback to general blast → verify the new general blast carries the sensitive flag (mitra sees the warning badge). Pre-fix: was hardcoded to regular.
- [ ] **[C]** Mitra is at-capacity-but-mid-session-with-this-customer → returning request still goes through (PRD §3.4 carve-out)
- [ ] **[C]** Mitra is at-capacity with someone else (not this customer) → 409 → bestie-unavailable popup
## Section E — Extension Flip (10s Auto-Approve + Safeguard)
- [ ] **[C]** In an active chat, tap extension → picker shows tiers (no free-trial option per PRD §5.2)
- [ ] **[BE]** Customer cannot create an extension payment_session with `is_extension=true AND is_free_trial=true` (server returns 400)
- [ ] **[M]** Extension card on mitra side shows the new copy: "Tidak menjawab dalam {X} detik = otomatis disetujui" — `{X}` is read from WS payload `timeout_seconds`, NOT hardcoded
- [ ] **[CC]** Change `extension_timeout_seconds` from 10 → 6 in CC; next extension card shows "6 detik"
- [ ] **[M+C]** Mitra explicitly approves within 10s → extension applied, payment consumed, transaction recorded
- [ ] **[M+C]** Mitra explicitly declines within 10s → no extension, no charge, audit row tagged `extension_rejected`
- [ ] **[M+C]** Mitra ignores the card 10s, stays online + WS connected → **auto-approve** fires, charge applied
- [ ] **[M+C]** Mitra toggles offline mid-window → 10s expires → safeguard auto-rejects, no charge, audit row tagged `extension_safeguard_tripped`
- [ ] **[M+C]** Mitra force-quits the app mid-window → same as offline (WS disconnect detected by safeguard)
- [ ] **[CC]** Flip `extension_default_action_on_timeout` from `auto_approve``auto_reject`; next extension that times out is rejected (audit tagged `extension_rejected`)
- [ ] **[M+C]** Race regression (`/simplify` Tier 1 fix #4): mitra approves at exactly the moment timer fires → exactly one outcome wins; no double-action; no spurious "session closing" push if approve won
## Section F — Mitra App: Returning vs General Card
- [ ] **[M]** General blast WS arrives → overlay shows accept/decline (NO countdown)
- [ ] **[M]** Returning chat WS arrives → overlay shows accept/decline + visible countdown starting at `confirmation_timeout_seconds` from payload
- [ ] **[M]** Countdown ticks down 1Hz; at 0s, overlay auto-dismisses (server is source of truth — no client-side decline call)
- [ ] **[M]** Subsequent `chat_request_closed` WS confirms server already auto-rejected
- [ ] **[M]** Cold-start via FCM tap (kill app, tap notification): pending list correctly surfaces `request_type` for in-flight requests; returning ones get the countdown
- [ ] **[M]** Mitra accepts a general blast that another mitra also accepts simultaneously → exactly one wins, the other sees "request no longer available" (existing DB unique constraint regression check)
## Section G — Control Center
### G.1 Settings page
- [ ] **[CC]** New row "Batas Waktu Blast Pairing" — number input, current value visible, save fires PATCH
- [ ] **[CC]** New row "Batas Waktu Sesi Pembayaran" — minutes input, save works
- [ ] **[CC]** New row "Batas Waktu Konfirmasi Chat Lanjutan" — seconds input, save works
- [ ] **[CC]** New row "Aksi Default Jika Bestie Tidak Menjawab Extension" — radio group `Auto-approve` / `Auto-reject`, save works
- [ ] **[CC]** Each save triggers in-memory cache invalidation (verify by changing the value and seeing the new behavior on the next request, no backend restart needed)
### G.2 Failed Pairings page
- [ ] **[CC]** Sidebar entry "Failed Pairings" appears between Sesi and Users
- [ ] **[CC]** Page lists rows with: Created, Customer, Targeted Mitra (or "—"), Cause, Amount, Operator Action, Actioned By, Actioned At
- [ ] **[CC]** Cause-tag multi-select filter narrows the list correctly
- [ ] **[CC]** Date range filter narrows correctly
- [ ] **[CC]** Per-row action menu: "Mark as refunded" / "Mark as credited" / "Mark as no-action" — each updates the row
- [ ] **[CC]** Already-actioned rows show "—" instead of the action button (current client-side lock — see Open Question below)
- [ ] **[CC]** Pagination Prev/Next works at >50 rows (seed enough failures to test)
## Section H — Cross-Cutting + Hot-Path Verification
- [ ] **[BE]** `findAvailableMitras` projects `active_session_count` in one query (no per-mitra COUNT roundtrip) — confirm by enabling pg query logging during a blast with 5+ available mitras
- [ ] **[BE]** Blast loop runs notify+insert in parallel — verify total blast time scales sub-linearly with mitra count
- [ ] **[BE]** `expireStalePaymentSessions` sweeper runs every 60s; under load (manually create 20 stale rows via direct DB INSERT), single batch UPDATE flips them all + parallel notifies
- [ ] **[BE]** Composite index `idx_chat_sessions_mitra_status` exists; `EXPLAIN ANALYZE` on the per-mitra capacity subquery uses the index, not a seq scan
- [ ] **[C]** Availability notifier no-op guard: keep mitra availability stable, observe Riverpod listeners on home — no rebuild every 5s when the value is unchanged (use Flutter DevTools rebuild counter)
- [ ] **[BE]** Mitra goes offline mid-session → existing chat continues; mitra app shows offline toggle; new returning-chat requests to that mitra immediately auto-reject (Section D check)
## Section I — Anonymity Regression (existing rule, must not regress)
- [ ] **[M]** Mitra always sees customer's `call_name` in chat header, request card, history, returning-chat card
- [ ] **[M]** Mitra never sees customer phone, email, or social ID anywhere in 3.7-touched UI
## Section J — Multi-Actor Scenario: Mitra Goes Offline Mid-Session
Verifies the domain rule that the mitra offline toggle is independent of active session state — going offline mid-chat must not interrupt the in-progress session, but availability for *new* customers must reflect the offline state immediately.
> Memory reference: "Mitra Can Go Offline Mid-Session" — never use "in-session" as a proxy for "online".
### Setup
- [ ] Set `max_customers_per_mitra = 2` in CC (so a single chat doesn't auto-fill capacity and mask the test signal)
- [ ] Two customer accounts (Customer 1, Customer 2) signed in on two devices/emulators (or one of each)
- [ ] One mitra account (Mitra A) signed in on the mitra app
- [ ] No other mitra is online (verify via CC mitra dashboard)
### Steps
1. [ ] **[M]** Mitra A taps online toggle → status flips to online
2. [ ] **[C, Customer 1]** Home shows CTA enabled
3. [ ] **[C, Customer 1]** Tap "Mulai Curhat" → payment screen → "Bayar" → searching screen
4. [ ] **[BE]** Verify blast fires to Mitra A (check `chat_request_notifications` row inserted)
5. [ ] **[M]** Incoming-request overlay appears → tap "Terima"
6. [ ] **[C, Customer 1]** Searching → "Bestie Ditemukan" → chat screen with Mitra A
7. [ ] **[BE]** Verify `chat_sessions.status = active`, `payment_sessions.status = consumed`
8. [ ] **[C, Customer 1 + M]** Exchange messages both ways (e.g., "hai" → "halo apa kabar" → "baik" → "ada yang bisa dibantu") — each message renders on both sides with delivery + read status
9. [ ] **[C, Customer 2]** Open Customer 2's app — home CTA is **enabled** (Mitra A has 1/2 capacity, still available)
10. [ ] **[M]** Mitra A taps offline toggle **while still on the chat screen with Customer 1**
11. [ ] **[M]** Toggle succeeds — no "session ended" / "you're in a session" blocker dialog. Mitra A status now offline in CC dashboard
12. [ ] **[C, Customer 1]** Chat screen is **uninterrupted**:
- No "session ended" banner
- No closure dialog
- Session timer continues counting down
- Messages sent before are still visible
13. [ ] **[C, Customer 1 + M]** Exchange more messages after Mitra went offline ("masih ada?" → "ada, masih disini") — messages still flow normally via WS (mitra's WS connection is independent of the online toggle)
14. [ ] **[C, Customer 2]** Wait up to 5 seconds (one poll cycle) → CTA flips to **disabled** with subtitle "Belum ada bestie tersedia" (Mitra A is now offline, no other mitras online)
15. [ ] **[BE]** Verify `GET /api/client/mitra-availability` returns `{ available: false }`
16. [ ] **[C, Customer 1]** Let session run to natural expiry (or end early if `early_end_*` flag enabled) — verify normal session-close flow (closing screen → goodbye composer → completed)
17. [ ] **[BE]** Verify `chat_sessions.ended_by` reflects timer-expiry / customer / mitra-explicit-close — NOT a `mitra_offline` force-close
### Regression checks tied to this scenario
- [ ] **[C, Customer 1]** While Mitra A is offline mid-session, Customer 1 requests an extension → extension request fires normally (existing chat scope), but the safeguard tagged `extension_safeguard_tripped` fires when the 10s auto-approve timer hits because mitra is unreachable (PRD §5.5 — already in Section E.23, this is the multi-actor variant)
- [ ] **[C, Customer 2]** While Mitra A is offline + Customer 1's session is still active, Customer 2 attempting "Curhat lagi" against Mitra A from a *prior* chat history row → 409 `targeted_mitra_offline` → bestie-unavailable popup (auto-reject immediate per Section D)
- [ ] **[CC]** While Mitra A is offline mid-session, the active sessions admin view still lists Customer 1's session as active — operator should not be misled into thinking the session was force-closed
## Outstanding Open Questions (need user decision before resolving)
- [ ] **Re-action lock**: Failed Pairings rows are locked client-side after first operator action even though backend `setOperatorAction` allows overwriting. Decide: keep lock (current) or allow revisions.
- [ ] **Mitra pre-existing `ref.listen` in `build`** in `mitra_app/lib/core/chat/chat_request_notifier.dart` — flagged in `/simplify`, out of 3.7 scope, separate cleanup pass.
- [ ] **Browser smoke for Failed Pairings page** — Stage 4 verified backend round-trip but skipped real browser click-through (no headless browser in agent sandbox).
---
## Test Data Setup Helpers
Quick curl commands to seed test conditions:
```bash
# Force a mitra online for testing
curl -X POST http://192.168.88.247:3000/api/mitra/status/online \
-H "Authorization: Bearer <mitra-jwt>"
# Create a stale payment_session for sweeper testing
psql -c "INSERT INTO payment_sessions (customer_id, amount, duration_minutes, status, expires_at)
VALUES ('<customer-uuid>', 50000, 30, 'confirmed', NOW() - INTERVAL '1 minute')"
# Force a config invalidation event (simulating multi-instance)
redis-cli PUBLISH config:invalidate '{"key":"max_customers_per_mitra","ts":1234567890}'
# List all failed-pairing rows by tag
curl -s http://localhost:3001/internal/failed-pairings?cause_tags=customer_cancelled \
-H "Authorization: Bearer <cc-jwt>" | jq
```

308
requirement/phase3.7.md Normal file
View File

@@ -0,0 +1,308 @@
# PRD: Phase 3.7 — Paid Pairing Flow + Returning-Chat + Extension Flip
# Overview
**Goal:** Reshape the customer pairing path so that **payment precedes blast** (today: blast fires immediately on CTA, no payment in path), gate the home CTA on real-time mitra availability, add a "chat with the same bestie again" path from chat history, give mitras a short approval window on returning-chat requests (20s, auto-reject), and **flip extension behavior** from auto-reject to auto-approve (10s).
**Success looks like:**
- A customer can only tap "Mulai Curhat" when at least one mitra is online and has spare capacity.
- Tapping the CTA leads to a payment screen → confirmation → blast → mitra accept → chat.
- A customer can re-engage a previous bestie directly from chat history; that bestie has 20s to confirm before auto-reject.
- An in-progress chat extension is auto-approved if the mitra doesn't respond within 10s (current behavior auto-rejects); customer is charged at approval moment, never before.
- Every payment that does not result in a chat is persisted with a cause-tag and surfaced in the control center for manual review/refund.
**Affects:** `client_app`, `mitra_app`, `backend`, `control_center`
## Background
- **Phase 2** introduced instant blast on CTA tap — no payment in path, first-mitra-to-accept wins via DB-level uniqueness.
- **Phase 3** introduced WebSocket chat, an extension flow that **auto-rejects** on timeout, mock pricing service (free-trial + tiered prices), and FCM push for incoming requests.
- **Phase 3.5** exposed chat-request history for mitras with a CTA badge on home.
- **Pricing remains mocked** for Phase 3.7 — no real Xendit; real pricing/tiers + real Xendit are deferred to a later phase. (See memory: "Pricing Still Mocked Through Phase 3.7".)
- **Anonymity rule unchanged** (mitra always sees customer call_name; phone+email private; no legacy masking).
## Phase numbering note
Originally captured under "Phase 4" filename. Renamed to **Phase 3.7** at user request — same scope, different number.
---
# Functional Requirements
## 1. CTA Gating on Customer Home (mitra availability)
### 1.1 Polling
- The "Mulai Curhat" CTA on customer home polls `GET /api/client/mitra-availability` **every 5 seconds** while the home screen is **foregrounded**. Polling pauses on background and resumes on foreground.
- No background poll. No FCM-triggered refresh. Pull-to-refresh on home triggers an immediate poll.
### 1.2 Endpoint
- **New:** `GET /api/client/mitra-availability`
- **Response:** `{ "available": boolean, "count"?: number }`
- `available = true`**at least one mitra is online AND below their max-customer capacity** (per existing `app_config.max_customers_per_mitra`).
- `count` is optional, included for control-center debugging only — the client must read only `available`.
- **Implementation constraint:** the endpoint must compute availability from **Valkey only** (existing online-status keys + active-session counters). It must NOT issue per-poll Postgres queries.
### 1.3 CTA visual state
| State | Visual | Subtitle |
|---|---|---|
| `available = true` | Enabled (default style) | Existing copy ("Mulai cerita ke bestie") |
| `available = false` OR poll failed | Greyed-out, non-tappable | "Belum ada bestie tersedia" |
- Binary only — **never show the count** (e.g. "3 bestie tersedia") in the customer UI.
- On poll failure (network error, 5xx, timeout): default to greyed-out — do **not** keep the last-known state.
---
## 2. New Pairing Flow: CTA → Payment → Blast → Accept → Chat
### 2.1 Flow overview
```
[Customer Home: CTA enabled]
↓ tap
[Payment Screen]
↓ confirm (mock)
[Backend: payment_session row created (status=confirmed)]
[Existing "Searching for bestie..." screen]
↓ blast to all available mitras
↓ first accept wins (existing DB uniqueness)
[Chat starts — existing WS chat flow]
```
### 2.2 Payment screen (mocked)
- New screen: **Pilih Sesi & Bayar** (or whatever copy fits the existing style).
- Reuses the existing **mock pricing service** (Phase 3 tiers + free trial). No new pricing tables, no Xendit SDK, no webview.
- Layout: tier/duration picker + "Total" line + primary CTA "Bayar".
- **Free-trial-eligible customer:** the screen shows the tier picker but the Total displays "Gratis" / Rp 0. Primary CTA reads "Mulai" (still goes through the same confirm step — does **not** skip directly to blast).
- On confirm tap → backend creates a `payment_session` row with `status = confirmed`, returns `{ payment_session_id }`.
- Customer is then routed to the existing "Searching for bestie..." screen, carrying `payment_session_id`.
### 2.3 Payment-session lifecycle
- **Status enum:** `pending` (screen open, not confirmed yet) → `confirmed` (confirm tap succeeded) → `consumed` (chat session started against this payment) | `failed_pairing` (no mitra accepted) | `abandoned` (customer closed payment screen) | `expired` (TTL elapsed before confirm).
- **TTL:** payment session expires after `payment_session_timeout_minutes` (default **20 min**, **CC-configurable**) from creation. Background sweeper transitions stale `pending` rows to `expired`.
- **Abandonment:** if the customer closes the payment screen / backs out before tapping confirm, the row is left in `pending` (will eventually expire). No active rollback needed.
### 2.4 Blast & accept
- Once the screen receives `payment_session_id`, the existing Phase 2 blast logic runs as today, with one change: the resulting `chat_session` row carries a `payment_session_id` foreign key.
- **Idempotency unchanged:** the existing DB-level unique constraint on session acceptance (Phase 2) ensures first-mitra-wins.
- On successful accept → `payment_session.status = consumed`, chat begins (existing WS flow).
### 2.5 Failed pairing — single consistent model (also applies to Sections 5 and 6)
When a payment is `confirmed` but no chat starts (no mitra accepts within blast window, all mitras explicitly reject, targeted mitra rejects/auto-rejects, payment expires before consume, customer cancels mid-search):
1. The `payment_session` row stays in DB; status transitions to `failed_pairing` (or `expired` / `abandoned` as appropriate).
2. A **failed-pairing event** row is written to a new `pairing_failures` table (or equivalent) with a **`cause_tag`** for filter/audit:
- `no_mitra_available` — no mitra accepted within the general blast window
- `all_mitras_rejected` — every blasted mitra explicitly declined
- `targeted_mitra_offline` — returning-chat target was offline at request time
- `targeted_mitra_rejected` — returning-chat target explicitly declined within the 20s window
- `targeted_mitra_timeout` — returning-chat target did not respond within 20s
- `payment_session_expired` — TTL elapsed before customer confirmed or before chat started
- `customer_cancelled` — customer abandoned during searching/waiting
3. Surfaced to the **control center** in a new **"Failed Pairings"** review screen (filterable by `cause_tag` and date), where an operator can manually mark a row as: refunded / credit issued / no-action. The actual refund/credit operation is mock for Phase 3.7 (no real money movement) — operator action just records the decision in the row.
4. **Customer-facing copy** (used everywhere a failed pairing terminates the flow): _"Maaf, kami tidak bisa menemukan bestie untuk sesimu. Tim kami akan menghubungimu segera."_ — with a "Kembali ke beranda" CTA. This copy will be revised in a later phase; ship the placeholder for now.
### 2.6 Customer screen during blast
- Reuse the existing Phase 2 "Searching for bestie..." screen as-is. Real visual design will land in a later UI-design pass.
### 2.7 Blast timeout
- The general blast timeout reuses the Phase 2 value. If it is not already CC-configurable, expose it as `pairing_blast_timeout_seconds` in the same CC config screen.
---
## 3. "Curhat lagi" — Returning Chat from Chat History
### 3.1 CTA placement
- Every row in the customer's chat-history list gets a **"Curhat lagi"** CTA. Per-row, not per-unique-mitra.
- The CTA targets the mitra of that specific session.
### 3.2 Flow
```
[Chat History Row: "Curhat lagi"]
↓ tap
[Payment Screen — same as Section 2.2]
↓ confirm
[Backend: targeted-request created against this mitra]
↓ FCM push + WS event to that mitra
[Customer overlay: "Menunggu konfirmasi bestie..." + Cancel]
↓ within 20s: mitra accepts → chat starts
↓ within 20s: mitra rejects OR 20s expires (auto-reject)
↓ at request time: mitra is offline OR at-capacity-not-mid-session
[Popup: "Bestie sedang tidak online" (or analogous)
+ option "Chat dengan bestie lain" if any other mitra is available]
```
### 3.3 Pre-tap: targeted mitra status
- "Curhat lagi" does **not** depend on the Section 1 availability poll — it depends only on the targeted mitra's status (queried at tap time, not pre-cached on each row).
- The targeted-mitra status check happens server-side as part of creating the targeted request.
### 3.4 Targeted mitra unavailable at request time
If, at the moment of tapping "Curhat lagi" (before the 20s window starts):
- The targeted mitra is **offline** (not online in Valkey), or
- The targeted mitra is **at capacity AND not mid-session with the requesting customer**
then the request is **not** sent. Customer sees a popup:
- **Title:** "Bestie sedang tidak online"
- **Body:** brief explanation copy
- **CTAs:**
- If any other mitra is available (per Section 1 logic): primary "Chat dengan bestie lain" — invokes the general blast flow against the same payment session (no double-charge)
- Always: "Kembali"
### 3.5 Targeted mitra is mid-session with someone else (concurrency)
- If the targeted mitra is online and currently in another active chat: still send the targeted request card. Let the mitra decide (they may wrap up the active chat, or they may decline). No automatic block.
### 3.6 During the 20s window — mitra side
See Section 4.
### 3.7 During the 20s window — customer side
- Customer sees an overlay on top of the searching/payment-confirmed state:
- **Copy:** "Menunggu konfirmasi bestie..."
- **Cancel button:** customer can cancel before the mitra responds. On cancel: targeted request is closed, payment session enters `failed_pairing` with `cause_tag = customer_cancelled`.
### 3.8 On rejection / 20s auto-reject
- The same fallback popup as Section 3.4 is shown ("Bestie sedang tidak online" / similar copy):
- Primary CTA "Chat dengan bestie lain" (if other mitras available) — fires general blast with the **same payment session**, no double-charge.
- "Kembali" — terminates the flow; payment_session ends in `failed_pairing` with `cause_tag = targeted_mitra_rejected` or `targeted_mitra_timeout`.
- If general blast also fails: payment ends in `failed_pairing` with `cause_tag = no_mitra_available` (or `all_mitras_rejected` per Section 2.5), and the standard failed-pairing copy from Section 2.5 is shown.
### 3.9 Anonymity
- Unchanged. Mitra sees the customer's `call_name`. No phone, no email.
---
## 4. Returning-Chat Approval Window (Mitra Side, 20s, Auto-Reject)
### 4.1 Mitra UX
- Reuse the existing incoming-request notification component (FCM background push + foreground accept/decline card).
- **Add a visible 20s countdown** to the card (the only visual change).
- The card's accept/decline buttons retain current behavior; on tap → existing accept endpoint.
### 4.2 Auto-reject on timeout
- If the mitra does not tap accept or decline within 20s: backend marks the targeted request as auto-rejected (`response = ignored` or a new `auto_rejected` value — implementation detail for the plan doc).
- Triggers the customer-side fallback popup (Section 3.8).
### 4.3 Mitra offline at request time
- If the targeted mitra is offline in Valkey at the moment the request would be created: do **not** send the request. Trigger the customer-side popup (Section 3.4) immediately. Do not wait the full 20s.
### 4.4 Mitra mid-session with someone else
- See Section 3.5. Card is sent regardless; mitra decides.
### 4.5 New CC config
- **Row:** `returning_chat_confirmation_timeout_seconds`
- **Default:** `20`
- **CC label:** "Batas waktu konfirmasi chat lanjutan (detik)"
- **CC explanation:** "Berapa detik bestie punya waktu untuk menerima/menolak permintaan chat lanjutan dari customer sebelum otomatis ditolak."
- **Effective immediately** on save (no app restart required) — read by backend per request.
---
## 5. Extension Approval Flip (10s, Auto-Approve)
### 5.1 Behavior change — confirmed intentional
Phase 3 today: extension request **auto-rejects** on mitra non-response.
Phase 3.7: extension request **auto-approves** on mitra non-response within 10s, with safeguards (Section 5.5).
### 5.2 Customer flow (price-then-charge)
- Extension is **never auto-charged**. The customer first chooses extension duration + price (same UX as initial chat request, but **without the free trial path**).
- Once the customer confirms duration + price → request is sent to mitra with a 10s window.
- The customer sees an overlay: "Menunggu konfirmasi extension..." (existing copy, just shorter timer).
### 5.3 Charge timing
- **At approval moment**: when the mitra explicitly approves OR the 10s timer auto-approves, the extension is charged (mock charge — same `payment_session` mechanism as Section 2 but bound to the existing chat session).
- If the mitra explicitly rejects within 10s: **no charge**.
- If auto-approve fires due to mitra non-response → charge fires.
- If safeguard auto-rejects (Section 5.5) → no charge.
### 5.4 Mitra UX
- Existing extension card stays visually the same; copy adjusted to reflect auto-approve (e.g. "Tidak menjawab dalam 10 detik = otomatis disetujui").
- Buttons unchanged.
### 5.5 Safeguard — mitra disconnected/offline during the 10s
Auto-approve does **not** fire if the mitra:
- Is **disconnected** from WS at the moment the 10s timer expires, OR
- Has flipped themselves to **offline** (Valkey state) since receiving the extension request.
In either case, treat as **auto-reject** — no charge, customer sees the standard extension-rejected UX.
> **Domain rule (memory: "Mitra Can Go Offline Mid-Session"):** A mitra can go offline during an active session. Never use "in-session" as a proxy for "online".
### 5.6 New CC config
- **Existing row:** the Phase 3 `extension_timeout_seconds` config stays. Default value updated to **10** during the 3.7 migration.
- **New row:** `extension_default_action_on_timeout` — enum (`auto_reject` | `auto_approve`), default `auto_approve`.
- **CC label:** "Aksi default jika bestie tidak menjawab extension"
- **CC explanation:** "Jika bestie tidak merespon permintaan extension dalam batas waktu, sistem akan otomatis menerima atau menolak sesuai pilihan ini."
This data-driven approach means we can flip the behavior back to auto-reject without a deploy if needed.
---
## 6. Control Center — New Configs and Failed-Pairings Screen
### 6.1 Config additions
| Key | Type | Default | Label | Explanation |
|---|---|---|---|---|
| `pairing_blast_timeout_seconds` | int | (existing Phase 2 value, if not already configurable) | "Batas waktu blast pairing (detik)" | "Berapa lama backend menunggu mitra menerima sebelum blast dianggap gagal." |
| `payment_session_timeout_minutes` | int | 20 | "Batas waktu sesi pembayaran (menit)" | "Sesi pembayaran customer akan kedaluwarsa otomatis setelah durasi ini jika belum dikonfirmasi atau belum menghasilkan chat." |
| `returning_chat_confirmation_timeout_seconds` | int | 20 | "Batas waktu konfirmasi chat lanjutan (detik)" | (per Section 4.5) |
| `extension_default_action_on_timeout` | enum | `auto_approve` | "Aksi default jika bestie tidak menjawab extension" | (per Section 5.6) |
### 6.2 Failed Pairings screen
- New CC route: `/failed-pairings`.
- Table columns: `created_at`, `customer_call_name`, `targeted_mitra_call_name` (nullable), `cause_tag`, `payment_amount` (mock), `operator_action` (none / refunded / credited / no-action), `actioned_by`, `actioned_at`.
- Filters: `cause_tag` (multi-select), date range.
- Per-row action menu: **Mark as refunded**, **Mark as credited**, **Mark as no-action**. All three just record the decision (no real money movement in 3.7).
- No bulk actions in this phase.
### 6.3 Existing CC screens — no changes other than the config row additions and the failed-pairings entry in the navigation.
---
## 7. Migration — Replace Phase 2 Instant Blast Entirely
### 7.1 Removals (no kill-switch, no feature flag)
- Customer-side: the existing direct CTA → blast wiring in `client_app` is removed.
- Backend: the route(s) that bypass payment to start a blast are removed (existing endpoints are repurposed: blast no longer fires until a confirmed `payment_session` exists).
- Any client-side state assuming "tap CTA = immediate searching" is updated to route through payment first.
### 7.2 Reuse vs. replace (per 2c.2)
- **Reuse:** "Searching for bestie..." screen, "Found bestie" screen, "No bestie found" screen, ChatBloc, ChatRequestBloc/notifier, mitra incoming-request card.
- **Replace / new:** Payment screen (new), payment-session lifecycle service (new), `pairing_failures` table + service (new), failed-pairings CC screen (new).
### 7.3 Data migration
- New tables: `payment_sessions`, `pairing_failures`. Migrations created per backend convention.
- New column: `chat_sessions.payment_session_id` (nullable for backward compat with any pre-3.7 rows; required for newly-created rows).
- No backfill of historical sessions.
---
## 8. Edge Cases
- **Customer logs out mid-payment** — payment_session left as `pending`, expires on TTL.
- **Customer logs out mid-search** — payment_session transitions to `failed_pairing` with `cause_tag = customer_cancelled` (signal: WS disconnect + no consume within blast window).
- **Targeted mitra accepts after auto-reject fired** — race rejected by the existing DB uniqueness; mitra sees a "request no longer available" state via existing UX.
- **Customer hits "Curhat lagi" on a session whose mitra has been deactivated** — treat as `targeted_mitra_offline` (offline in Valkey will be true for deactivated mitras).
- **Free-trial customer abandons the Gratis confirmation** — payment_session left `pending` with mock `amount = 0`; expires on TTL; no failed-pairing event (never confirmed).
- **Customer taps CTA exactly as the last available mitra goes offline** — payment screen still renders (gating is best-effort, not transactional). After confirmation, blast fires and falls into the standard "no_mitra_available" failed-pairing path.
- **Extension auto-approve fires; mitra reconnects 1s later** — they see the extended session as already extended; no surprise UX needed.
- **Mitra force-quits app during the 20s returning-chat window** — auto-reject fires at 20s; this is the desired safer behavior (matches Section 4.2).
---
## 9. Non-Goals (this phase)
- Real Xendit checkout / webview / redirect (deferred).
- Real pricing or finalized tiers (deferred — keeps mock).
- Auto-refund / auto-credit transactions (CC operator records the decision; no actual money movement).
- New customer chat-history UI design (reuses existing rows + adds CTA only).
- New "Searching for bestie..." visual design (reuses existing).
- Bulk actions on failed-pairings.
- Mitra-side history of returning-chat requests (covered by Phase 3.5 chat-request history; same component).
- Push-notification preferences for the new fallback popup.
- Wallet / credit balance UX on the customer app (failed-pairings handled CC-side only for now).
---
# Open Questions
_None — ready for `phase3.7-plan.md`._