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:
2026-04-09 00:17:25 +08:00
parent b4efcf14c2
commit b0502ac92b
58 changed files with 2148 additions and 709 deletions

View File

@@ -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;
}

View File

@@ -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 {