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:
403
requirement/phase3.7-plan.md
Normal file
403
requirement/phase3.7-plan.md
Normal 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._
|
||||
124
requirement/phase3.7-questions.md
Normal file
124
requirement/phase3.7-questions.md
Normal 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.
|
||||
251
requirement/phase3.7-test-run-2026-05-03.md
Normal file
251
requirement/phase3.7-test-run-2026-05-03.md
Normal 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>`
|
||||
201
requirement/phase3.7-testing.md
Normal file
201
requirement/phase3.7-testing.md
Normal 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 (a–n) 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
308
requirement/phase3.7.md
Normal 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`._
|
||||
Reference in New Issue
Block a user