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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$pairingHash() => r'e1c3074239cc4efda99885331ff91b3e0f903c8d';
|
||||
String _$pairingHash() => r'0fe1e5646fe70b90f5489919ec8dd557c998daad';
|
||||
|
||||
/// See also [Pairing].
|
||||
@ProviderFor(Pairing)
|
||||
|
||||
Reference in New Issue
Block a user