- 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>
404 lines
23 KiB
Markdown
404 lines
23 KiB
Markdown
# 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._
|