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:
@@ -5,6 +5,7 @@ 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 ChatEvent extends Equatable {
|
||||
@@ -125,8 +126,8 @@ class ChatMessage {
|
||||
required this.id,
|
||||
required this.senderType,
|
||||
required this.content,
|
||||
this.type = 'text',
|
||||
this.status = 'sent',
|
||||
this.type = MessageType.text,
|
||||
this.status = MessageStatus.sent,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
@@ -164,6 +165,19 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
emit(ChatConnecting());
|
||||
|
||||
try {
|
||||
// Check session status before connecting
|
||||
final sessionInfo = await apiClient.get('/api/shared/chat/${event.sessionId}/info');
|
||||
final sessionData = sessionInfo['data'] as Map<String, dynamic>?;
|
||||
final sessionStatus = sessionData?['status'] as String?;
|
||||
if (sessionStatus == SessionStatus.completed ||
|
||||
sessionStatus == SessionStatus.cancelled ||
|
||||
sessionStatus == SessionStatus.expired) {
|
||||
emit(ChatError('Sesi sudah berakhir.'));
|
||||
return;
|
||||
}
|
||||
|
||||
final isClosing = sessionStatus == SessionStatus.closing;
|
||||
|
||||
// Load existing messages from API
|
||||
final response = await apiClient.get(
|
||||
'/api/shared/chat/${event.sessionId}/messages',
|
||||
@@ -173,8 +187,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
id: m['id'] as String,
|
||||
senderType: m['sender_type'] as String,
|
||||
content: m['content'] as String,
|
||||
type: m['type'] as String? ?? 'text',
|
||||
status: m['status'] as String? ?? 'sent',
|
||||
type: m['type'] as String? ?? MessageType.text,
|
||||
status: m['status'] as String? ?? MessageStatus.sent,
|
||||
createdAt: DateTime.parse(m['created_at'] as String),
|
||||
)).toList();
|
||||
|
||||
@@ -197,12 +211,15 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
|
||||
// Send auth message
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'auth',
|
||||
'type': WsMessage.auth,
|
||||
'token': token,
|
||||
'session_id': event.sessionId,
|
||||
}));
|
||||
|
||||
emit(ChatConnected(messages: messages));
|
||||
emit(ChatConnected(
|
||||
messages: messages,
|
||||
sessionClosing: isClosing,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(ChatError('Gagal terhubung ke chat.'));
|
||||
}
|
||||
@@ -221,7 +238,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final msg = ChatMessage(
|
||||
id: tempId,
|
||||
senderType: 'customer',
|
||||
senderType: UserType.customer,
|
||||
content: event.content,
|
||||
status: 'sending',
|
||||
createdAt: DateTime.now(),
|
||||
@@ -230,7 +247,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'message',
|
||||
'type': WsMessage.message,
|
||||
'content': event.content,
|
||||
'_temp_id': tempId,
|
||||
}));
|
||||
@@ -238,13 +255,13 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
|
||||
void _onSendTyping(SendTyping event, Emitter<ChatState> emit) {
|
||||
if (_channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({'type': 'typing'}));
|
||||
_channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
|
||||
}
|
||||
|
||||
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<ChatState> emit) {
|
||||
if (_channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'delivered',
|
||||
'type': WsMessage.delivered,
|
||||
'message_ids': event.messageIds,
|
||||
}));
|
||||
}
|
||||
@@ -252,7 +269,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
void _onMarkRead(MarkMessagesRead event, Emitter<ChatState> emit) {
|
||||
if (_channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'read',
|
||||
'type': WsMessage.read,
|
||||
'message_ids': event.messageIds,
|
||||
}));
|
||||
}
|
||||
@@ -264,17 +281,17 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
final type = data['type'] as String?;
|
||||
|
||||
switch (type) {
|
||||
case 'auth_ok':
|
||||
case WsMessage.authOk:
|
||||
// Already connected
|
||||
break;
|
||||
|
||||
case 'message':
|
||||
case WsMessage.message:
|
||||
final msg = ChatMessage(
|
||||
id: data['message_id'] as String,
|
||||
senderType: data['sender_type'] as String,
|
||||
content: data['content'] as String,
|
||||
type: data['message_type'] as String? ?? 'text',
|
||||
status: 'sent',
|
||||
type: data['message_type'] as String? ?? MessageType.text,
|
||||
status: MessageStatus.sent,
|
||||
createdAt: DateTime.parse(data['created_at'] as String),
|
||||
);
|
||||
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||
@@ -282,7 +299,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
add(MarkMessagesDelivered([msg.id]));
|
||||
break;
|
||||
|
||||
case 'message_ack':
|
||||
case WsMessage.messageAck:
|
||||
final messageId = data['message_id'] as String;
|
||||
final status = data['status'] as String;
|
||||
final updatedMessages = current.messages.map((m) {
|
||||
@@ -292,7 +309,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
return m;
|
||||
}).toList();
|
||||
// Replace temp ID with real ID
|
||||
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == 'customer');
|
||||
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.customer);
|
||||
if (idx >= 0) {
|
||||
final old = updatedMessages[idx];
|
||||
updatedMessages[idx] = ChatMessage(
|
||||
@@ -307,7 +324,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
emit(current.copyWith(messages: updatedMessages));
|
||||
break;
|
||||
|
||||
case 'message_status':
|
||||
case WsMessage.messageStatus:
|
||||
final messageIds = (data['message_ids'] as List<dynamic>).cast<String>();
|
||||
final status = data['status'] as String;
|
||||
final updatedMessages = current.messages.map((m) {
|
||||
@@ -319,7 +336,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
emit(current.copyWith(messages: updatedMessages));
|
||||
break;
|
||||
|
||||
case 'typing':
|
||||
case WsMessage.typing:
|
||||
emit(current.copyWith(isOtherTyping: true));
|
||||
_typingTimer?.cancel();
|
||||
_typingTimer = Timer(const Duration(seconds: 3), () {
|
||||
@@ -329,36 +346,41 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
});
|
||||
break;
|
||||
|
||||
case 'session_timer':
|
||||
case WsMessage.sessionTimer:
|
||||
final remaining = data['remaining_seconds'] as int?;
|
||||
emit(current.copyWith(remainingSeconds: remaining));
|
||||
break;
|
||||
|
||||
case 'session_expired':
|
||||
case WsMessage.sessionExpired:
|
||||
emit(current.copyWith(sessionExpired: true));
|
||||
break;
|
||||
|
||||
case 'session_paused':
|
||||
case WsMessage.sessionPaused:
|
||||
emit(current.copyWith(sessionPaused: true));
|
||||
break;
|
||||
|
||||
case 'session_resumed':
|
||||
emit(current.copyWith(sessionPaused: false, sessionExpired: false));
|
||||
case WsMessage.sessionResumed:
|
||||
emit(current.copyWith(sessionPaused: false, sessionExpired: false, sessionClosing: false));
|
||||
break;
|
||||
|
||||
case 'session_closing':
|
||||
case WsMessage.sessionClosing:
|
||||
emit(current.copyWith(sessionClosing: true));
|
||||
break;
|
||||
|
||||
case 'extension_response':
|
||||
emit(current.copyWith(extensionResponse: data));
|
||||
case WsMessage.extensionResponse:
|
||||
final accepted = data['accepted'] as bool? ?? false;
|
||||
emit(current.copyWith(
|
||||
extensionResponse: data,
|
||||
sessionPaused: accepted ? false : current.sessionPaused,
|
||||
sessionExpired: accepted ? false : current.sessionExpired,
|
||||
));
|
||||
break;
|
||||
|
||||
case 'session_completed':
|
||||
case WsMessage.sessionCompleted:
|
||||
_cleanup();
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
case WsMessage.error:
|
||||
// Keep connected but show error
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class RequestExtension extends SessionClosureEvent {
|
||||
}
|
||||
|
||||
class DeclineExtension extends SessionClosureEvent {}
|
||||
class ResetClosure extends SessionClosureEvent {}
|
||||
|
||||
class SubmitGoodbye extends SessionClosureEvent {
|
||||
final String sessionId;
|
||||
@@ -56,6 +57,7 @@ class SessionClosureBloc extends Bloc<SessionClosureEvent, SessionClosureState>
|
||||
SessionClosureBloc({required this.apiClient}) : super(ClosureInitial()) {
|
||||
on<RequestExtension>(_onRequestExtension);
|
||||
on<DeclineExtension>(_onDeclineExtension);
|
||||
on<ResetClosure>(_onReset);
|
||||
on<SubmitGoodbye>(_onSubmitGoodbye);
|
||||
}
|
||||
|
||||
@@ -76,6 +78,10 @@ class SessionClosureBloc extends Bloc<SessionClosureEvent, SessionClosureState>
|
||||
emit(ClosureShowGoodbye());
|
||||
}
|
||||
|
||||
void _onReset(ResetClosure event, Emitter<SessionClosureState> emit) {
|
||||
emit(ClosureInitial());
|
||||
}
|
||||
|
||||
Future<void> _onSubmitGoodbye(SubmitGoodbye event, Emitter<SessionClosureState> emit) async {
|
||||
emit(ClosureSubmitting());
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user