Phase 3.7: paid pairing flow + returning chat + extension flip

- Backend: payment_sessions + pairing_failures tables; payment.service.js
  and pairing-failure.service.js (new); rewritten pairing.service.js
  (payment-gated blast + targeted "Curhat lagi" + cancel + fallback);
  rewritten extension.service.js (data-driven auto-approve with offline
  safeguard, charge-at-approval); pricing.service.js (extension tiers
  without free trial); mitra-status.service.js (countAvailableMitras
  cached path); 60s sweeper for stale payment sessions
- Backend routes: client.payment.routes, client.mitra-availability.routes,
  internal/failed-pairings.routes; client.chat.routes rewritten for
  payment-gated start + /returning + /cancel + /fallback-to-blast;
  internal/config.routes adds 4 new keys with Valkey invalidate publish
- client_app: mitra-availability poll, payment screen + notifier, pairing
  notifier rewrite (PairingTargetedWaiting + PairingFailed states),
  targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi"
  CTA, failed-pairing terminal, extension via payment-session
- mitra_app: PairingRequestType enum, returning-chat 20s countdown
  auto-dismiss, extension card "otomatis disetujui" copy
- control_center: 4 new config rows in Settings, Failed Pairings page
  (filter + paginate + action menu), sidebar + route registered
- Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4
  pass), Maestro mobile scaffold (CLI install pending)
- Bugs found via Playwright + fixed: LoginPage labels not associated
  with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE
  in allow-methods (silent settings breakage in browsers since Stage 4)
- Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A),
  phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md
  (today's run results)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 23:02:49 +08:00
parent f3766813f3
commit d09e50af55
92 changed files with 9579 additions and 437 deletions

View File

@@ -20,9 +20,55 @@ class PairingInitialData extends PairingData {
const PairingInitialData();
}
/// General-blast in flight. The chat_session row exists; backend has already
/// notified all available mitras and is waiting for the first to accept.
class PairingSearchingData extends PairingData {
/// chat_session id (NOT payment_session id).
final String sessionId;
const PairingSearchingData(this.sessionId);
/// payment_session id — we keep it on the state so cancelSearch can call
/// the payment-session-scoped cancel endpoint without re-prompting.
final String paymentSessionId;
const PairingSearchingData({
required this.sessionId,
required this.paymentSessionId,
});
}
/// "Curhat lagi" 20s wait. The targeted-mitra request has been created and
/// we're waiting for either accept (→ paired), reject/timeout (→ bestie-unavailable
/// popup), or customer cancel (→ home).
///
/// `secondsRemaining` is decremented locally for the overlay countdown. The
/// server is the source of truth for the actual auto-reject; the local timer
/// is purely cosmetic.
class PairingTargetedWaitingData extends PairingData {
final String paymentSessionId;
final String mitraId;
final String mitraName;
final int secondsRemaining;
// Carried so the fallback-to-blast path preserves the customer's original choice
// — otherwise sensitive sessions silently get re-routed as regular.
final TopicSensitivity topicSensitivity;
const PairingTargetedWaitingData({
required this.paymentSessionId,
required this.mitraId,
required this.mitraName,
required this.secondsRemaining,
required this.topicSensitivity,
});
PairingTargetedWaitingData copyWith({int? secondsRemaining}) {
return PairingTargetedWaitingData(
paymentSessionId: paymentSessionId,
mitraId: mitraId,
mitraName: mitraName,
secondsRemaining: secondsRemaining ?? this.secondsRemaining,
topicSensitivity: topicSensitivity,
);
}
}
class PairingBestieFoundData extends PairingData {
@@ -37,8 +83,35 @@ class PairingActiveData extends PairingData {
const PairingActiveData({required this.sessionId, required this.mitraName});
}
class PairingNoBestieData extends PairingData {
const PairingNoBestieData();
/// Intermediate fail signalled by RETURNING_CHAT_TIMEOUT or RETURNING_CHAT_REJECTED,
/// or by a 409 `targeted_mitra_offline` at request time. Payment session is still
/// `confirmed` server-side — the customer can choose between fallback-to-blast
/// (general blast on the same payment) or going back home (which will leave the
/// payment to expire, no double-charge).
///
/// The UI surfaces this via the bestie-unavailable dialog.
class PairingTargetedUnavailableData extends PairingData {
final String paymentSessionId;
final String mitraName;
final PairingFailureCause cause;
// Carried so the fallback-to-blast call preserves the customer's original choice.
final TopicSensitivity topicSensitivity;
const PairingTargetedUnavailableData({
required this.paymentSessionId,
required this.mitraName,
required this.cause,
required this.topicSensitivity,
});
}
/// Terminal pairing failure — payment session is in `failed_pairing`. Routes
/// to the failed-pairing screen (no_bestie_screen).
class PairingFailedData extends PairingData {
final PairingFailureCause cause;
final String? paymentSessionId;
const PairingFailedData({required this.cause, this.paymentSessionId});
}
class PairingCancelledData extends PairingData {
@@ -52,7 +125,7 @@ class PairingErrorData extends PairingData {
@Riverpod(keepAlive: true)
class Pairing extends _$Pairing {
Timer? _timeoutTimer;
Timer? _localCountdownTimer;
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
@@ -61,58 +134,176 @@ class Pairing extends _$Pairing {
@override
PairingData build() => const PairingInitialData();
Future<void> requestPairing({required TopicSensitivity topicSensitivity}) async {
await _doPairingRequest({'topic_sensitivity': topicSensitivity.value});
}
Future<void> requestPairingWithTier({
int? durationMinutes,
int? price,
bool isFreeTrial = false,
/// General-blast against a confirmed payment session.
/// Returns once the chat_session row is created server-side; subsequent
/// transitions (paired / pairing_failed) arrive via WS.
Future<void> startSearch({
required String paymentSessionId,
required TopicSensitivity topicSensitivity,
}) async {
final body = <String, dynamic>{'topic_sensitivity': topicSensitivity.value};
if (isFreeTrial) {
body['is_free_trial'] = true;
} else {
body['duration_minutes'] = durationMinutes;
body['price'] = price;
}
await _doPairingRequest(body);
}
Future<void> _doPairingRequest(Map<String, dynamic> body) async {
if (state is! PairingInitialData) {
state = const PairingInitialData();
}
state = const PairingInitialData();
try {
await _connectWebSocket();
final response = await _apiClient.post('/api/client/chat/request', data: body);
final response = await _apiClient.post(
'/api/client/chat/request',
data: {
'payment_session_id': paymentSessionId,
'topic_sensitivity': topicSensitivity.value,
},
);
final data = response['data'] as Map<String, dynamic>;
final sessionId = data['id'] as String;
state = PairingSearchingData(sessionId);
_timeoutTimer = Timer(const Duration(seconds: 60), () {
_cleanup();
state = const PairingNoBestieData();
});
state = PairingSearchingData(
sessionId: sessionId,
paymentSessionId: paymentSessionId,
);
} on DioException catch (e) {
_cleanup();
final code = e.response?.data?['error']?['code'];
if (code == 'NO_MITRA_AVAILABLE') {
state = const PairingNoBestieData();
// Backend already failed the payment in this case — terminal.
state = PairingFailedData(
cause: PairingFailureCause.noMitraAvailable,
paymentSessionId: paymentSessionId,
);
} else if (code == 'ALREADY_ACTIVE') {
state = const PairingErrorData('Kamu sudah memiliki sesi aktif.');
} else if (code == 'FREE_TRIAL_INELIGIBLE') {
state = const PairingErrorData('Kamu tidak memenuhi syarat untuk free trial.');
} else {
state = const PairingErrorData('Gagal memulai. Coba lagi.');
}
}
}
/// Targeted "Curhat lagi" against a specific mitra. The backend creates a
/// single-recipient notification + 20s server-side timer. Locally we run a
/// cosmetic countdown for the overlay.
///
/// On 409 `targeted_mitra_offline`: backend recorded an audit-only failure
/// row (payment stays confirmed) — we transition to TargetedUnavailable so
/// the UI can offer the fallback dialog.
Future<void> startTargetedSearch({
required String paymentSessionId,
required String mitraId,
required String mitraName,
required TopicSensitivity topicSensitivity,
}) async {
state = const PairingInitialData();
try {
await _connectWebSocket();
final response = await _apiClient.post(
'/api/client/chat/chat-requests/returning',
data: {
'payment_session_id': paymentSessionId,
'mitra_id': mitraId,
'topic_sensitivity': topicSensitivity.value,
},
);
// Backend returns the configured returning_chat_confirmation_timeout_seconds so
// the overlay countdown matches the server-side timer exactly.
final sessionData = response['data'] as Map<String, dynamic>?;
final seconds = (sessionData?['confirmation_timeout_seconds'] as num?)?.toInt() ?? 20;
state = PairingTargetedWaitingData(
paymentSessionId: paymentSessionId,
mitraId: mitraId,
mitraName: mitraName,
secondsRemaining: seconds,
topicSensitivity: topicSensitivity,
);
_startLocalCountdown();
} on DioException catch (e) {
_cleanup();
final code = e.response?.data?['error']?['code'];
final reason = e.response?.data?['error']?['reason'];
if (code == 'TARGETED_MITRA_OFFLINE' || reason == 'targeted_mitra_offline') {
// Intermediate — payment session is still confirmed; show the
// bestie-unavailable popup with a "Chat dengan bestie lain" option.
state = PairingTargetedUnavailableData(
paymentSessionId: paymentSessionId,
mitraName: mitraName,
cause: PairingFailureCause.targetedMitraOffline,
topicSensitivity: topicSensitivity,
);
} else if (code == 'ALREADY_ACTIVE') {
state = const PairingErrorData('Kamu sudah memiliki sesi aktif.');
} else {
state = const PairingErrorData('Gagal memulai. Coba lagi.');
}
}
}
/// Customer-initiated cancel during a search/wait. Terminal — payment
/// session moves to `failed_pairing` server-side. We route the UI to home
/// (NOT to the failed-pairing screen) since the customer chose this.
Future<void> cancelSearch() async {
String? paymentSessionId;
final current = state;
if (current is PairingSearchingData) {
paymentSessionId = current.paymentSessionId;
} else if (current is PairingTargetedWaitingData) {
paymentSessionId = current.paymentSessionId;
}
if (paymentSessionId == null) {
_cleanup();
state = const PairingCancelledData();
return;
}
try {
await _apiClient.post(
'/api/client/chat/chat-requests/cancel',
data: {'payment_session_id': paymentSessionId},
);
} catch (_) {
// Best-effort. Backend will still fail the payment if/when it
// sweeps stale rows.
}
_cleanup();
state = const PairingCancelledData();
}
/// "Chat dengan bestie lain" tapped from the bestie-unavailable dialog.
/// Reuses the same payment session — backend transitions back into the
/// general-blast path.
Future<void> fallbackToBlast({
required String paymentSessionId,
required TopicSensitivity topicSensitivity,
}) async {
state = const PairingInitialData();
try {
await _connectWebSocket();
final response = await _apiClient.post(
'/api/client/chat/chat-requests/$paymentSessionId/fallback-to-blast',
data: {'topic_sensitivity': topicSensitivity.value},
);
final data = response['data'] as Map<String, dynamic>;
final sessionId = data['id'] as String;
state = PairingSearchingData(
sessionId: sessionId,
paymentSessionId: paymentSessionId,
);
} on DioException catch (e) {
_cleanup();
final code = e.response?.data?['error']?['code'];
if (code == 'NO_MITRA_AVAILABLE') {
state = PairingFailedData(
cause: PairingFailureCause.noMitraAvailable,
paymentSessionId: paymentSessionId,
);
} else {
state = const PairingErrorData('Gagal memulai. Coba lagi.');
}
}
}
/// Reset back to initial — used when the failed-pairing screen "Kembali ke
/// beranda" CTA is tapped, or when the bestie-unavailable dialog is
/// dismissed via "Kembali".
void reset() {
_cleanup();
state = const PairingInitialData();
}
// ---- Internal ---------------------------------------------------------
Future<void> _connectWebSocket() async {
_closeWebSocket();
final token = ref.read(authBridgeProvider).accessToken;
@@ -128,7 +319,7 @@ class Pairing extends _$Pairing {
(raw) {
final data = jsonDecode(raw as String) as Map<String, dynamic>;
if (data['type'] == WsMessage.authOk) return;
_onStatusUpdate(data);
_onWsEvent(data);
},
onError: (_) {},
onDone: () {},
@@ -140,42 +331,89 @@ class Pairing extends _$Pairing {
}));
}
Future<void> _onStatusUpdate(Map<String, dynamic> data) async {
Future<void> _onWsEvent(Map<String, dynamic> data) async {
final type = data['type'] as String?;
final current = state;
if (type == WsMessage.paired) {
_cleanup();
final mitraName = data['mitra_display_name'] as String? ?? 'Bestie';
final sessionId = data['session_id'] as String;
state = PairingBestieFoundData(sessionId: sessionId, mitraName: mitraName);
// A session now exists for this customer — refresh the shared snapshot
// so the home CTA reflects it immediately when the user returns.
// ignore: unawaited_futures
ref.read(activeSessionProvider.notifier).refresh();
await Future.delayed(const Duration(seconds: 2));
state = PairingActiveData(sessionId: sessionId, mitraName: mitraName);
} else if (type == SessionStatus.expired) {
return;
}
if (type == WsMessage.pairingFailed) {
// Terminal — payment_session is in failed_pairing server-side.
final causeTag = data['cause_tag'] as String?;
final paymentSessionId = data['payment_session_id'] as String?;
_cleanup();
state = const PairingNoBestieData();
state = PairingFailedData(
cause: PairingFailureCause.fromString(causeTag),
paymentSessionId: paymentSessionId,
);
return;
}
if (type == WsMessage.returningChatTimeout || type == WsMessage.returningChatRejected) {
// Intermediate — payment still confirmed. Show the bestie-unavailable
// dialog (UI surfaces via state listener).
_stopLocalCountdown();
final paymentSessionId = data['payment_session_id'] as String?;
// Pull mitra name + topic from the prior targeted-waiting state (we know it from
// the request payload). If we somehow lost it, fall back to safe defaults.
String mitraName = 'Bestie';
TopicSensitivity carriedTopic = TopicSensitivity.regular;
if (current is PairingTargetedWaitingData) {
mitraName = current.mitraName;
carriedTopic = current.topicSensitivity;
}
state = PairingTargetedUnavailableData(
paymentSessionId: paymentSessionId ?? (current is PairingTargetedWaitingData ? current.paymentSessionId : ''),
mitraName: mitraName,
topicSensitivity: carriedTopic,
cause: type == WsMessage.returningChatTimeout
? PairingFailureCause.targetedMitraTimeout
: PairingFailureCause.targetedMitraRejected,
);
return;
}
if (type == SessionStatus.expired) {
// Legacy event from the older pairing path — treat as terminal "no mitra".
_cleanup();
state = const PairingFailedData(cause: PairingFailureCause.noMitraAvailable);
}
}
Future<void> cancelPairing() async {
if (state is PairingSearchingData) {
final sessionId = (state as PairingSearchingData).sessionId;
try {
await _apiClient.post('/api/client/chat/request/$sessionId/cancel');
} catch (_) {}
_cleanup();
state = const PairingCancelledData();
}
void _startLocalCountdown() {
_stopLocalCountdown();
_localCountdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
final current = state;
if (current is! PairingTargetedWaitingData) {
_stopLocalCountdown();
return;
}
final next = current.secondsRemaining - 1;
if (next <= 0) {
_stopLocalCountdown();
// We don't transition here — the server is the source of truth for
// the actual auto-reject. The WS event will land within ~1s and
// transition us to TargetedUnavailable.
state = current.copyWith(secondsRemaining: 0);
} else {
state = current.copyWith(secondsRemaining: next);
}
});
}
void reset() {
_cleanup();
state = const PairingInitialData();
void _stopLocalCountdown() {
_localCountdownTimer?.cancel();
_localCountdownTimer = null;
}
void _closeWebSocket() {
@@ -186,8 +424,7 @@ class Pairing extends _$Pairing {
}
void _cleanup() {
_timeoutTimer?.cancel();
_timeoutTimer = null;
_stopLocalCountdown();
_closeWebSocket();
}
}

View File

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