diff --git a/backend/src/routes/public/mitra.chat.routes.js b/backend/src/routes/public/mitra.chat.routes.js index b97a345..004a5cb 100644 --- a/backend/src/routes/public/mitra.chat.routes.js +++ b/backend/src/routes/public/mitra.chat.routes.js @@ -1,6 +1,6 @@ import { authenticate } from '../../plugins/auth.js' import { getMitraByFirebaseUid } from '../../services/mitra.service.js' -import { acceptPairingRequest, declinePairingRequest, getSessionStatus } from '../../services/pairing.service.js' +import { acceptPairingRequest, declinePairingRequest, getSessionStatus, getPendingRequestsForMitra } from '../../services/pairing.service.js' import { getActiveSessionsByMitra, getActiveSessionsByMitraWithUnread, endSession, getMitraHistory } from '../../services/session.service.js' import { respondToExtension } from '../../services/extension.service.js' import { EndedBy } from '../../constants.js' @@ -23,6 +23,12 @@ const resolveMitra = async (request, reply) => { } export const mitraChatRoutes = async (app) => { + // Get pending chat requests for this mitra + app.get('/pending', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { + const requests = await getPendingRequestsForMitra(request.mitra.id) + return reply.send({ success: true, data: requests }) + }) + // Check if a session is still pending acceptance (for notification validation) app.get('/:sessionId/status', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { const session = await getSessionStatus(request.params.sessionId) diff --git a/backend/src/services/pairing.service.js b/backend/src/services/pairing.service.js index 6e2efce..9c594d5 100644 --- a/backend/src/services/pairing.service.js +++ b/backend/src/services/pairing.service.js @@ -307,6 +307,19 @@ export const expirePairingRequest = async (sessionId) => { return session } +export const getPendingRequestsForMitra = async (mitraId) => { + const rows = await sql` + SELECT cs.id AS session_id, cs.duration_minutes, cs.is_free_trial, cs.created_at + FROM chat_request_notifications crn + JOIN chat_sessions cs ON cs.id = crn.session_id + WHERE crn.mitra_id = ${mitraId} + AND crn.response IS NULL + AND cs.status = ${SessionStatus.PENDING_ACCEPTANCE} + ORDER BY cs.created_at ASC + ` + return rows +} + export const getSessionStatus = async (sessionId) => { const [session] = await sql` SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by, diff --git a/mitra_app/lib/core/chat/chat_request_notifier.dart b/mitra_app/lib/core/chat/chat_request_notifier.dart index f7c730b..3401071 100644 --- a/mitra_app/lib/core/chat/chat_request_notifier.dart +++ b/mitra_app/lib/core/chat/chat_request_notifier.dart @@ -72,9 +72,54 @@ class ChatRequest extends _$ChatRequest { ApiClient get _apiClient => ref.read(apiClientProvider); + /// Number of active requests (currently displayed + queued). + int get activeRequestCount { + final current = state is ChatRequestIncomingData ? 1 : 0; + return current + _pendingQueue.length; + } + @override ChatRequestData build() => const ChatRequestIdleData(); + /// Fetch pending requests from backend and populate the queue. + Future loadPendingRequests() async { + try { + final response = await _apiClient.get('/api/mitra/chat-requests/pending'); + final requests = response['data'] as List; + if (requests.isEmpty) return; + + for (final r in requests) { + final sessionId = r['session_id'] as String; + // Skip if already showing or queued + if (state is ChatRequestIncomingData && + (state as ChatRequestIncomingData).sessionId == sessionId) continue; + if (_pendingQueue.any((q) => q['session_id'] == sessionId)) continue; + + final data = { + 'session_id': sessionId, + 'duration_minutes': r['duration_minutes'], + 'is_free_trial': r['is_free_trial'], + 'created_at': r['created_at'], + }; + + if (state is ChatRequestIncomingData || + state is ChatRequestStaleData || + state is ChatRequestAcceptingData) { + _pendingQueue.add(data); + } else { + state = ChatRequestIncomingData( + sessionId, + durationMinutes: r['duration_minutes'] as int?, + isFreeTrial: r['is_free_trial'] as bool?, + createdAt: r['created_at'] != null + ? DateTime.tryParse(r['created_at'] as String) + : null, + ); + } + } + } catch (_) {} + } + Future startListening() async { // Don't reset state if showing a request, stale message, or actively accepting if (state is ChatRequestIncomingData || diff --git a/mitra_app/lib/features/home/home_screen.dart b/mitra_app/lib/features/home/home_screen.dart index 72c795e..97d431f 100644 --- a/mitra_app/lib/features/home/home_screen.dart +++ b/mitra_app/lib/features/home/home_screen.dart @@ -17,10 +17,23 @@ class HomeScreen extends ConsumerWidget { ? authData.profile['display_name'] as String : ''; + // Load pending requests if mitra is already online + final statusState = ref.watch(onlineStatusProvider); + if (statusState is StatusLoadedData && statusState.isOnline) { + final requestState = ref.watch(chatRequestProvider); + if (requestState is ChatRequestIdleData) { + Future.microtask(() { + ref.read(chatRequestProvider.notifier).startListening(); + ref.read(chatRequestProvider.notifier).loadPendingRequests(); + }); + } + } + // Listen for status changes to start/stop chat request listening ref.listen(onlineStatusProvider, (prev, next) { if (next is StatusLoadedData && next.isOnline) { ref.read(chatRequestProvider.notifier).startListening(); + ref.read(chatRequestProvider.notifier).loadPendingRequests(); } else if (next is StatusLoadedData && !next.isOnline) { ref.read(chatRequestProvider.notifier).stopListening(); } @@ -44,6 +57,7 @@ class HomeScreen extends ConsumerWidget { const SizedBox(height: 32), const _StatusToggle(), const SizedBox(height: 16), + const _PendingRequestsBanner(), const _ActiveSessionsButton(), ], ), @@ -111,6 +125,51 @@ class _StatusToggle extends ConsumerWidget { } } +class _PendingRequestsBanner extends ConsumerWidget { + const _PendingRequestsBanner(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final requestState = ref.watch(chatRequestProvider); + final count = ref.read(chatRequestProvider.notifier).activeRequestCount; + + if (count == 0) return const SizedBox.shrink(); + + final isShowingOverlay = requestState is ChatRequestIncomingData || + requestState is ChatRequestStaleData; + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Card( + color: Colors.blue.shade50, + child: ListTile( + leading: Badge( + label: Text('$count'), + child: const Icon(Icons.notifications_active, color: Colors.blue), + ), + title: Text( + '$count permintaan chat menunggu', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: isShowingOverlay + ? null + : const Text('Ketuk untuk melihat'), + onTap: isShowingOverlay + ? null + : () { + // Re-advance queue to show the next request overlay + final notifier = ref.read(chatRequestProvider.notifier); + if (requestState is ChatRequestListeningData) { + // Requests are queued but none is displayed — trigger next + notifier.ignore(); + } + }, + ), + ), + ); + } +} + class _ActiveSessionsButton extends ConsumerWidget { const _ActiveSessionsButton();