Phase 3 testing fixes: Fastify 5, SSE→WebSocket+FCM, enums, security, session lifecycle
- Upgrade Fastify 4→5 with all plugins (@fastify/websocket 11, cors 11, sensible 6) - Migrate all SSE endpoints to WebSocket + FCM push (mitra chat requests, customer pairing status) - Add flutter_local_notifications for foreground push notifications with sound - Add splash screen to both apps (hide auth loading flash) - Introduce constants/enums across entire codebase (no raw string literals) - Move price tiers from hardcoded array to app_config DB (data-driven, includes 1-min test tier) - Add session ownership validation on all shared chat routes - Add ownership checks on endSession, respondToExtension, requestExtension - Fix session timer: auto-complete expired/stale sessions on server restart - Add 5-min grace period for abandoned closing sessions - Fix extension flow: proper session_resumed handling, clearExtensionRequest, closure grace timer cleanup - Fix chat screens: ConnectChat in initState, session status check on connect - Fix customer expired view: 5-min countdown, closure state priority over expired state - Fix mitra extension UI: loading spinner, disable buttons, handle EXTENSION_RESOLVED error - Fix GoRouter navigation consistency (no more Navigator.pushNamed) - Fix goodbye view keyboard overflow (SingleChildScrollView) - Add active session card on customer home screen with refresh on navigate back - Fix PricingBottomSheet extension mode (RequestExtension instead of new pairing) - Send session_resumed to both parties on extension accept Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,11 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import '../api/api_client.dart';
|
||||
import '../constants.dart';
|
||||
|
||||
// Events
|
||||
abstract class PairingEvent extends Equatable {
|
||||
@@ -32,6 +35,7 @@ class _PairingStatusUpdate extends PairingEvent {
|
||||
}
|
||||
|
||||
class _PairingTimeout extends PairingEvent {}
|
||||
class _ConnectionError extends PairingEvent {}
|
||||
|
||||
// States
|
||||
abstract class PairingState extends Equatable {
|
||||
@@ -77,7 +81,8 @@ class PairingError extends PairingState {
|
||||
class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
||||
final ApiClient apiClient;
|
||||
Timer? _timeoutTimer;
|
||||
StreamSubscription? _sseSubscription;
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _wsSubscription;
|
||||
|
||||
PairingBloc({required this.apiClient}) : super(PairingInitial()) {
|
||||
on<RequestPairing>(_onRequestPairing);
|
||||
@@ -85,6 +90,7 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
||||
on<CancelPairing>(_onCancelPairing);
|
||||
on<_PairingStatusUpdate>(_onStatusUpdate);
|
||||
on<_PairingTimeout>(_onTimeout);
|
||||
on<_ConnectionError>(_onConnectionError);
|
||||
}
|
||||
|
||||
Future<void> _onRequestPairing(RequestPairing event, Emitter<PairingState> emit) async {
|
||||
@@ -107,6 +113,9 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
||||
emit(PairingInitial());
|
||||
}
|
||||
try {
|
||||
// Connect to WebSocket first to listen for pairing status
|
||||
await _connectWebSocket();
|
||||
|
||||
final response = await apiClient.post('/api/client/chat/request', data: body);
|
||||
final data = response['data'] as Map<String, dynamic>;
|
||||
final sessionId = data['id'] as String;
|
||||
@@ -116,9 +125,8 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
||||
_timeoutTimer = Timer(const Duration(seconds: 60), () {
|
||||
add(_PairingTimeout());
|
||||
});
|
||||
|
||||
_listenToSSE(sessionId);
|
||||
} on DioException catch (e) {
|
||||
_cleanup();
|
||||
final code = e.response?.data?['error']?['code'];
|
||||
if (code == 'NO_MITRA_AVAILABLE') {
|
||||
emit(PairingNoBestie());
|
||||
@@ -132,26 +140,45 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _listenToSSE(String sessionId) {
|
||||
apiClient.getStream('/api/client/chat/request/$sessionId/status').then((response) {
|
||||
final stream = response.data.stream as Stream<List<int>>;
|
||||
_sseSubscription = stream
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.where((line) => line.startsWith('data: '))
|
||||
.map((line) => jsonDecode(line.substring(6)) as Map<String, dynamic>)
|
||||
.listen(
|
||||
(data) => add(_PairingStatusUpdate(data)),
|
||||
onError: (_) {},
|
||||
);
|
||||
}).catchError((_) {});
|
||||
Future<void> _connectWebSocket() async {
|
||||
_closeWebSocket();
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
if (user == null) return;
|
||||
|
||||
final token = await user.getIdToken();
|
||||
final wsUrl = ApiClient.baseUrl
|
||||
.replaceFirst('https://', 'wss://')
|
||||
.replaceFirst('http://', 'ws://');
|
||||
|
||||
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
|
||||
|
||||
_wsSubscription = _channel!.stream.listen(
|
||||
(raw) {
|
||||
final data = jsonDecode(raw as String) as Map<String, dynamic>;
|
||||
if (data['type'] == WsMessage.authOk) return;
|
||||
add(_PairingStatusUpdate(data));
|
||||
},
|
||||
onError: (_) => add(_ConnectionError()),
|
||||
onDone: () => add(_ConnectionError()),
|
||||
);
|
||||
|
||||
// Authenticate without session_id — just for receiving pairing status
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': WsMessage.auth,
|
||||
'token': token,
|
||||
}));
|
||||
}
|
||||
|
||||
Future<void> _onConnectionError(_ConnectionError event, Emitter<PairingState> emit) async {
|
||||
// WebSocket disconnected during pairing — stay in current state,
|
||||
// FCM will still deliver notifications
|
||||
}
|
||||
|
||||
Future<void> _onStatusUpdate(_PairingStatusUpdate event, Emitter<PairingState> emit) async {
|
||||
final data = event.data;
|
||||
final type = data['type'] as String?;
|
||||
|
||||
if (type == 'paired') {
|
||||
if (type == WsMessage.paired) {
|
||||
_cleanup();
|
||||
final mitraName = data['mitra_display_name'] as String? ?? 'Bestie';
|
||||
final sessionId = data['session_id'] as String;
|
||||
@@ -160,7 +187,7 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
||||
// Brief delay then transition to active
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
emit(PairingActive(sessionId: sessionId, mitraName: mitraName));
|
||||
} else if (type == 'expired') {
|
||||
} else if (type == SessionStatus.expired) {
|
||||
_cleanup();
|
||||
emit(PairingNoBestie());
|
||||
}
|
||||
@@ -182,11 +209,17 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
||||
emit(PairingNoBestie());
|
||||
}
|
||||
|
||||
void _closeWebSocket() {
|
||||
_wsSubscription?.cancel();
|
||||
_wsSubscription = null;
|
||||
_channel?.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
void _cleanup() {
|
||||
_timeoutTimer?.cancel();
|
||||
_timeoutTimer = null;
|
||||
_sseSubscription?.cancel();
|
||||
_sseSubscription = null;
|
||||
_closeWebSocket();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
Reference in New Issue
Block a user