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

@@ -0,0 +1,78 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client_provider.dart';
part 'mitra_availability_notifier.g.dart';
/// Customer-home availability poll.
///
/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home
/// screen is in the foreground. Polling is gated by the home screen calling
/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
/// - resumed → setActive(true)
/// - paused/inactive → setActive(false)
///
/// On any HTTP error we emit `false` (never display stale state).
///
/// The endpoint also returns a `count`, but the customer UI must only read the
/// binary `available` field — the count is for CC/debug only.
@Riverpod(keepAlive: true)
class MitraAvailability extends _$MitraAvailability {
Timer? _pollTimer;
bool _active = false;
@override
Future<bool> build() async {
ref.onDispose(_stopPolling);
// Default to disabled until the first poll returns. Never optimistically
// show the CTA as enabled.
return false;
}
/// Called by the home screen via `WidgetsBindingObserver` to gate polling
/// to the foregrounded state. Polling is paused on `paused` / `inactive`
/// and resumed on `resumed` (and an immediate poll fires on resume).
void setActive(bool active) {
if (_active == active) return;
_active = active;
if (_active) {
_startPolling();
// Fire-and-forget an immediate poll on resume so the CTA reflects
// current availability without waiting up to 5s.
// ignore: unawaited_futures
_pollOnce();
} else {
_stopPolling();
}
}
/// Manual one-shot refresh — used for pull-to-refresh on the home screen.
Future<void> refresh() => _pollOnce();
void _startPolling() {
_stopPolling();
_pollTimer = Timer.periodic(const Duration(seconds: 5), (_) => _pollOnce());
}
void _stopPolling() {
_pollTimer?.cancel();
_pollTimer = null;
}
Future<void> _pollOnce() async {
bool available;
try {
final api = ref.read(apiClientProvider);
final response = await api.get('/api/client/mitra-availability');
final data = response['data'] as Map<String, dynamic>?;
available = data?['available'] as bool? ?? false;
} catch (_) {
// Poll failure → default to disabled. Never keep the last-known state.
available = false;
}
// Skip the assignment when the value didn't change — Riverpod allocates a
// new AsyncData each call, which would re-notify all listeners every 5s.
if (state.valueOrNull == available) return;
state = AsyncData(available);
}
}

View File

@@ -0,0 +1,39 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'mitra_availability_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$mitraAvailabilityHash() => r'7429862ccffbae1fc7bbe7368359e1624fe28ec9';
/// Phase 3.7 §1: customer-home availability poll.
///
/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home
/// screen is in the foreground. Polling is gated by the home screen calling
/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
/// - resumed → setActive(true)
/// - paused/inactive → setActive(false)
///
/// On any HTTP error we emit `false` (PRD §1.3: never display stale state).
///
/// The endpoint also returns a `count`, but per PRD §1.3 the customer UI must
/// only read the binary `available` field — the count is for CC/debug only.
///
/// Copied from [MitraAvailability].
@ProviderFor(MitraAvailability)
final mitraAvailabilityProvider =
AsyncNotifierProvider<MitraAvailability, bool>.internal(
MitraAvailability.new,
name: r'mitraAvailabilityProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$mitraAvailabilityHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$MitraAvailability = AsyncNotifier<bool>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package