From 89afd01899c1c90f5d658f74665e833a6420fced Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Mon, 27 Apr 2026 18:59:17 +0800 Subject: [PATCH] Phase 3.5: Mitra Chat Request History (backend route + mitra app screens) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the home-screen pending-requests banner with a "Riwayat Permintaan" CTA that opens a list of the mitra's last 20 chat requests (any status). Pending rows pin to the top; non-pending rows open a read-only detail screen with a "Lihat percakapan" CTA on accepted rows. Backend: - New service `getRecentRequestsForMitra(mitraId, { limit })` capped at 20, pending pinned via `(response IS NULL AND status='pending_acceptance') DESC`. Customer call_name returned verbatim, with `'Anonim'` only as null-safety fallback (no anonymity-flag masking — see project memory). - New route `GET /api/mitra/chat-requests/recent`. Strictly per-mitra scoped via the existing `resolveMitra` preHandler. Mitra app: - New `RequestResponse` enum in core/constants.dart. - New Riverpod notifier `requestHistoryProvider` (AsyncValue>, keepAlive) — pull-to-refresh + screen-mount fetch only, no WS. - Two new screens (history list + detail) and two new GoRoutes. - Home screen: `_PendingRequestsBanner` removed → `_RequestHistoryButton` Card with red count badge. Live count comes from the existing chatRequestProvider so nothing changes about the WS-driven badge math. Plan + acceptance criteria in requirement/phase3.5-plan.md. flutter analyze clean (zero new issues). Backend smoke-tested against real DB. Real-device E2E pending. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/routes/public/mitra.chat.routes.js | 10 + .../src/services/mitra-activity.service.js | 26 +- mitra_app/lib/core/auth/auth_notifier.g.dart | 2 +- .../lib/core/chat/mitra_chat_notifier.g.dart | 2 +- mitra_app/lib/core/constants.dart | 20 ++ .../chat/models/request_history_entry.dart | 42 +++ .../notifiers/request_history_notifier.dart | 37 +++ .../notifiers/request_history_notifier.g.dart | 26 ++ .../request_history_detail_screen.dart | 159 +++++++++++ .../chat/screens/request_history_screen.dart | 153 +++++++++++ .../chat/utils/request_status_label.dart | 77 ++++++ mitra_app/lib/features/home/home_screen.dart | 100 +++---- mitra_app/lib/router.dart | 12 + requirement/phase3.5-plan.md | 249 ++++++++++++++++++ 14 files changed, 866 insertions(+), 49 deletions(-) create mode 100644 mitra_app/lib/features/chat/models/request_history_entry.dart create mode 100644 mitra_app/lib/features/chat/notifiers/request_history_notifier.dart create mode 100644 mitra_app/lib/features/chat/notifiers/request_history_notifier.g.dart create mode 100644 mitra_app/lib/features/chat/screens/request_history_detail_screen.dart create mode 100644 mitra_app/lib/features/chat/screens/request_history_screen.dart create mode 100644 mitra_app/lib/features/chat/utils/request_status_label.dart create mode 100644 requirement/phase3.5-plan.md diff --git a/backend/src/routes/public/mitra.chat.routes.js b/backend/src/routes/public/mitra.chat.routes.js index f53db85..9418188 100644 --- a/backend/src/routes/public/mitra.chat.routes.js +++ b/backend/src/routes/public/mitra.chat.routes.js @@ -3,6 +3,7 @@ import { getMitraById } from '../../services/mitra.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 { getRecentRequestsForMitra } from '../../services/mitra-activity.service.js' import { EndedBy, UserType } from '../../constants.js' const resolveMitra = async (request, reply) => { @@ -35,6 +36,15 @@ export const mitraChatRoutes = async (app) => { return reply.send({ success: true, data: requests }) }) + // Phase 3.5: Get last N (max 20) chat requests for this mitra (any status, pending pinned first) + app.get('/recent', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { + const { limit } = request.query + const items = await getRecentRequestsForMitra(request.mitra.id, { + limit: limit ? parseInt(limit) : 20, + }) + return reply.send({ success: true, data: items }) + }) + // 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/mitra-activity.service.js b/backend/src/services/mitra-activity.service.js index 8f32efa..3cbb5e7 100644 --- a/backend/src/services/mitra-activity.service.js +++ b/backend/src/services/mitra-activity.service.js @@ -1,5 +1,5 @@ import { getDb } from '../db/client.js' -import { TopicSensitivity } from '../constants.js' +import { SessionStatus, TopicSensitivity } from '../constants.js' const sql = getDb() @@ -39,6 +39,30 @@ export const getMitraActivityLog = async ({ mitra_id, date_from, date_to, page = return { items, total: Number(count), page, limit } } +export const getRecentRequestsForMitra = async (mitraId, { limit = 20 } = {}) => { + const cap = Math.min(Math.max(1, Number(limit) || 20), 20) + const items = await sql` + SELECT + crn.id AS notification_id, + crn.session_id, + crn.notified_at, + crn.responded_at, + crn.response, + cs.status AS session_status, + cs.topic_sensitivity, + COALESCE(NULLIF(trim(c.display_name), ''), 'Anonim') AS customer_call_name + FROM chat_request_notifications crn + LEFT JOIN chat_sessions cs ON cs.id = crn.session_id + LEFT JOIN customers c ON c.id = cs.customer_id + WHERE crn.mitra_id = ${mitraId} + ORDER BY + (crn.response IS NULL AND cs.status = ${SessionStatus.PENDING_ACCEPTANCE}) DESC, + crn.notified_at DESC + LIMIT ${cap} + ` + return items +} + export const getMitraActivitySummary = async ({ mitra_id, date_from, date_to } = {}) => { const conditions = [] diff --git a/mitra_app/lib/core/auth/auth_notifier.g.dart b/mitra_app/lib/core/auth/auth_notifier.g.dart index c3e5f57..c47b7c5 100644 --- a/mitra_app/lib/core/auth/auth_notifier.g.dart +++ b/mitra_app/lib/core/auth/auth_notifier.g.dart @@ -6,7 +6,7 @@ part of 'auth_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$mitraAuthHash() => r'c353c4b6cc0335c6276baa4029e361f4ec3b4a36'; +String _$mitraAuthHash() => r'342f0ec2a59b8d48084396201999bfe6450902c9'; /// See also [MitraAuth]. @ProviderFor(MitraAuth) diff --git a/mitra_app/lib/core/chat/mitra_chat_notifier.g.dart b/mitra_app/lib/core/chat/mitra_chat_notifier.g.dart index 3d646f8..cf11fb9 100644 --- a/mitra_app/lib/core/chat/mitra_chat_notifier.g.dart +++ b/mitra_app/lib/core/chat/mitra_chat_notifier.g.dart @@ -6,7 +6,7 @@ part of 'mitra_chat_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$mitraChatHash() => r'd5f4819264b9c71ce29a640ee2cfee608ead5e9e'; +String _$mitraChatHash() => r'da0c625b192c9ab24acd8ecc364b5f789c1ede41'; /// See also [MitraChat]. @ProviderFor(MitraChat) diff --git a/mitra_app/lib/core/constants.dart b/mitra_app/lib/core/constants.dart index 64dc5f9..f56c4f6 100644 --- a/mitra_app/lib/core/constants.dart +++ b/mitra_app/lib/core/constants.dart @@ -42,6 +42,26 @@ class ExtensionStatus { ExtensionStatus._(); } +/// Chat request notification responses (Phase 3.5). +/// `null` on the wire means "still pending" — no enum value. +enum RequestResponse { + accepted('accepted'), + declined('declined'), + missed('missed'), + ignored('ignored'); + + final String value; + const RequestResponse(this.value); + + static RequestResponse? fromString(String? v) { + if (v == null) return null; + for (final e in values) { + if (e.value == v) return e; + } + return null; + } +} + /// Session topic sensitivity enum TopicSensitivity { regular('regular'), diff --git a/mitra_app/lib/features/chat/models/request_history_entry.dart b/mitra_app/lib/features/chat/models/request_history_entry.dart new file mode 100644 index 0000000..60c4320 --- /dev/null +++ b/mitra_app/lib/features/chat/models/request_history_entry.dart @@ -0,0 +1,42 @@ +import '../../../core/constants.dart'; + +class RequestHistoryEntry { + final String notificationId; + final String sessionId; + final DateTime notifiedAt; + final DateTime? respondedAt; + final RequestResponse? response; + final String sessionStatus; + final String customerCallName; + final TopicSensitivity topicSensitivity; + + const RequestHistoryEntry({ + required this.notificationId, + required this.sessionId, + required this.notifiedAt, + required this.respondedAt, + required this.response, + required this.sessionStatus, + required this.customerCallName, + required this.topicSensitivity, + }); + + bool get isPending => + response == null && sessionStatus == SessionStatus.pendingAcceptance; + + factory RequestHistoryEntry.fromJson(Map json) { + return RequestHistoryEntry( + notificationId: json['notification_id'] as String, + sessionId: json['session_id'] as String, + notifiedAt: DateTime.parse(json['notified_at'] as String).toLocal(), + respondedAt: json['responded_at'] != null + ? DateTime.parse(json['responded_at'] as String).toLocal() + : null, + response: RequestResponse.fromString(json['response'] as String?), + sessionStatus: json['session_status'] as String? ?? '', + customerCallName: json['customer_call_name'] as String? ?? 'Anonim', + topicSensitivity: + TopicSensitivity.fromString(json['topic_sensitivity'] as String?), + ); + } +} diff --git a/mitra_app/lib/features/chat/notifiers/request_history_notifier.dart b/mitra_app/lib/features/chat/notifiers/request_history_notifier.dart new file mode 100644 index 0000000..c411491 --- /dev/null +++ b/mitra_app/lib/features/chat/notifiers/request_history_notifier.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/api/api_client_provider.dart'; +import '../models/request_history_entry.dart'; + +part 'request_history_notifier.g.dart'; + +@Riverpod(keepAlive: true) +class RequestHistory extends _$RequestHistory { + @override + FutureOr> build() async { + return _fetch(); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(_fetch); + } + + Future> _fetch() async { + final api = ref.read(apiClientProvider); + final response = await api.get('/api/mitra/chat-requests/recent'); + final items = (response['data'] as List).cast>(); + return items.map(RequestHistoryEntry.fromJson).toList(); + } + + RequestHistoryEntry? findById(String notificationId) { + final list = state.valueOrNull; + if (list == null) return null; + for (final e in list) { + if (e.notificationId == notificationId) return e; + } + return null; + } +} diff --git a/mitra_app/lib/features/chat/notifiers/request_history_notifier.g.dart b/mitra_app/lib/features/chat/notifiers/request_history_notifier.g.dart new file mode 100644 index 0000000..28bf362 --- /dev/null +++ b/mitra_app/lib/features/chat/notifiers/request_history_notifier.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'request_history_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$requestHistoryHash() => r'f97e75e7fa5088293c606fd3cec1ad7bd4f96480'; + +/// See also [RequestHistory]. +@ProviderFor(RequestHistory) +final requestHistoryProvider = + AsyncNotifierProvider>.internal( + RequestHistory.new, + name: r'requestHistoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$requestHistoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$RequestHistory = AsyncNotifier>; +// 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 diff --git a/mitra_app/lib/features/chat/screens/request_history_detail_screen.dart b/mitra_app/lib/features/chat/screens/request_history_detail_screen.dart new file mode 100644 index 0000000..2f4f77e --- /dev/null +++ b/mitra_app/lib/features/chat/screens/request_history_detail_screen.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/chat/widgets/sensitivity_badge.dart'; +import '../../../core/constants.dart'; +import '../models/request_history_entry.dart'; +import '../notifiers/request_history_notifier.dart'; +import '../utils/request_status_label.dart'; + +class RequestHistoryDetailScreen extends ConsumerWidget { + final String notificationId; + const RequestHistoryDetailScreen({super.key, required this.notificationId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(requestHistoryProvider); + final entry = state.valueOrNull == null + ? null + : ref.read(requestHistoryProvider.notifier).findById(notificationId); + + return Scaffold( + appBar: AppBar(title: const Text('Detail Permintaan')), + body: state.isLoading && entry == null + ? const Center(child: CircularProgressIndicator()) + : entry == null + ? _NotFound( + onRetry: () => + ref.read(requestHistoryProvider.notifier).refresh(), + ) + : _Detail(entry: entry), + ); + } +} + +class _NotFound extends StatelessWidget { + final VoidCallback onRetry; + const _NotFound({required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.search_off, size: 48, color: Colors.grey), + const SizedBox(height: 12), + const Text('Permintaan tidak ditemukan'), + const SizedBox(height: 12), + TextButton(onPressed: onRetry, child: const Text('Muat ulang')), + ], + ), + ), + ); + } +} + +class _Detail extends StatelessWidget { + final RequestHistoryEntry entry; + const _Detail({required this.entry}); + + @override + Widget build(BuildContext context) { + final status = labelFor(entry); + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + entry.customerCallName, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (entry.topicSensitivity == TopicSensitivity.sensitive) ...[ + const SizedBox(width: 8), + const SensitivityBadge( + sensitivity: TopicSensitivity.sensitive, + fontSize: 11, + ), + ], + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: status.background, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + status.label, + style: TextStyle( + fontSize: 12, + color: status.foreground, + fontWeight: FontWeight.w600, + ), + ), + ), + const Divider(height: 32), + _LabeledRow('Diterima notifikasi', formatAbsolute(entry.notifiedAt)), + if (entry.respondedAt != null) ...[ + const SizedBox(height: 8), + _LabeledRow( + 'Direspon pada', + formatAbsolute(entry.respondedAt!), + ), + ], + if (entry.response == RequestResponse.accepted) ...[ + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + icon: const Icon(Icons.chat_bubble_outline), + label: const Text('Lihat percakapan'), + onPressed: () => + context.push('/chat/history/${entry.sessionId}'), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _LabeledRow extends StatelessWidget { + final String label; + final String value; + const _LabeledRow(this.label, this.value); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 140, + child: Text(label, style: TextStyle(color: Colors.grey.shade700)), + ), + Expanded(child: Text(value)), + ], + ); + } +} diff --git a/mitra_app/lib/features/chat/screens/request_history_screen.dart b/mitra_app/lib/features/chat/screens/request_history_screen.dart new file mode 100644 index 0000000..45ccbb1 --- /dev/null +++ b/mitra_app/lib/features/chat/screens/request_history_screen.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/chat/chat_request_notifier.dart'; +import '../../../core/chat/widgets/sensitivity_badge.dart'; +import '../../../core/constants.dart'; +import '../models/request_history_entry.dart'; +import '../notifiers/request_history_notifier.dart'; +import '../utils/request_status_label.dart'; + +class RequestHistoryScreen extends ConsumerWidget { + const RequestHistoryScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(requestHistoryProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Riwayat Permintaan')), + body: RefreshIndicator( + onRefresh: () => ref.read(requestHistoryProvider.notifier).refresh(), + child: state.when( + loading: () => const _CenterScroll(child: CircularProgressIndicator()), + error: (err, _) => _CenterScroll( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.grey), + const SizedBox(height: 12), + const Text('Gagal memuat riwayat'), + const SizedBox(height: 12), + TextButton( + onPressed: () => + ref.read(requestHistoryProvider.notifier).refresh(), + child: const Text('Coba lagi'), + ), + ], + ), + ), + ), + data: (entries) { + if (entries.isEmpty) { + return const _CenterScroll( + child: Padding( + padding: EdgeInsets.all(24), + child: Text('Belum ada permintaan chat'), + ), + ); + } + return ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: entries.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) => _RequestRow(entries[index]), + ); + }, + ), + ), + ); + } +} + +class _CenterScroll extends StatelessWidget { + final Widget child; + const _CenterScroll({required this.child}); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, c) => SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: SizedBox( + height: c.maxHeight, + child: Center(child: child), + ), + ), + ); + } +} + +class _RequestRow extends ConsumerWidget { + final RequestHistoryEntry entry; + const _RequestRow(this.entry); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final status = labelFor(entry); + return ListTile( + title: Row( + children: [ + Flexible( + child: Text( + entry.customerCallName, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + if (entry.topicSensitivity == TopicSensitivity.sensitive) ...[ + const SizedBox(width: 8), + const SensitivityBadge( + sensitivity: TopicSensitivity.sensitive, + fontSize: 10, + ), + ], + ], + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: status.background, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + status.label, + style: TextStyle( + fontSize: 11, + color: status.foreground, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Text( + formatRelative(entry.notifiedAt), + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _onTap(context, ref), + ); + } + + Future _onTap(BuildContext context, WidgetRef ref) async { + if (entry.isPending) { + await ref + .read(chatRequestProvider.notifier) + .setIncomingFromNotification(entry.sessionId); + } else { + context.push('/chat/requests/history/${entry.notificationId}'); + } + } +} diff --git a/mitra_app/lib/features/chat/utils/request_status_label.dart b/mitra_app/lib/features/chat/utils/request_status_label.dart new file mode 100644 index 0000000..9d77c0e --- /dev/null +++ b/mitra_app/lib/features/chat/utils/request_status_label.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import '../../../core/constants.dart'; +import '../models/request_history_entry.dart'; + +class RequestStatusLabel { + final String label; + final Color background; + final Color foreground; + const RequestStatusLabel(this.label, this.background, this.foreground); +} + +RequestStatusLabel labelFor(RequestHistoryEntry entry) { + if (entry.isPending) { + return RequestStatusLabel( + 'Menunggu respon', + Colors.amber.shade100, + Colors.amber.shade900, + ); + } + switch (entry.response) { + case RequestResponse.accepted: + return RequestStatusLabel( + 'Diterima', + Colors.green.shade100, + Colors.green.shade900, + ); + case RequestResponse.declined: + return RequestStatusLabel( + 'Ditolak', + Colors.grey.shade300, + Colors.grey.shade800, + ); + case RequestResponse.missed: + return RequestStatusLabel( + 'Terlewat', + Colors.grey.shade300, + Colors.grey.shade800, + ); + case RequestResponse.ignored: + if (entry.sessionStatus == SessionStatus.cancelled) { + return RequestStatusLabel( + 'Dibatalkan', + Colors.grey.shade300, + Colors.grey.shade800, + ); + } + return RequestStatusLabel( + 'Kedaluwarsa', + Colors.grey.shade300, + Colors.grey.shade800, + ); + case null: + // Non-pending null (session moved past pending_acceptance without a logged response) + return RequestStatusLabel( + 'Kedaluwarsa', + Colors.grey.shade300, + Colors.grey.shade800, + ); + } +} + +String formatRelative(DateTime when) { + final now = DateTime.now(); + final diff = now.difference(when); + if (diff.inSeconds < 60) return 'Baru saja'; + if (diff.inMinutes < 60) return '${diff.inMinutes} menit lalu'; + if (diff.inHours < 24) return '${diff.inHours} jam lalu'; + if (diff.inDays < 2) return 'Kemarin'; + return '${diff.inDays} hari lalu'; +} + +String _two(int n) => n.toString().padLeft(2, '0'); + +String formatAbsolute(DateTime when) { + return '${when.day}/${when.month}/${when.year} ${_two(when.hour)}:${_two(when.minute)}'; +} diff --git a/mitra_app/lib/features/home/home_screen.dart b/mitra_app/lib/features/home/home_screen.dart index 97d431f..a1a6c74 100644 --- a/mitra_app/lib/features/home/home_screen.dart +++ b/mitra_app/lib/features/home/home_screen.dart @@ -57,7 +57,6 @@ class HomeScreen extends ConsumerWidget { const SizedBox(height: 32), const _StatusToggle(), const SizedBox(height: 16), - const _PendingRequestsBanner(), const _ActiveSessionsButton(), ], ), @@ -125,51 +124,6 @@ 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(); @@ -192,6 +146,7 @@ class _ActiveSessionsButton extends ConsumerWidget { onTap: () => context.push('/sessions'), ), ), + const _RequestHistoryButton(), Card( child: ListTile( leading: const Icon(Icons.history), @@ -204,3 +159,56 @@ class _ActiveSessionsButton extends ConsumerWidget { ); } } + +class _RequestHistoryButton extends ConsumerWidget { + const _RequestHistoryButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Watch state to rebuild when requests arrive/clear; count comes from + // the notifier which tracks both displayed + queued requests. + ref.watch(chatRequestProvider); + final count = ref.read(chatRequestProvider.notifier).activeRequestCount; + + final hasPending = count > 0; + final trailing = hasPending + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '$count', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + const SizedBox(width: 4), + const Icon(Icons.chevron_right), + ], + ) + : const Icon(Icons.chevron_right); + + return Card( + child: ListTile( + leading: const Icon(Icons.notifications_outlined), + title: const Text('Riwayat Permintaan'), + subtitle: Text( + hasPending + ? '$count permintaan baru' + : 'Lihat permintaan chat sebelumnya', + ), + trailing: trailing, + onTap: () => context.push('/chat/requests/history'), + ), + ); + } +} diff --git a/mitra_app/lib/router.dart b/mitra_app/lib/router.dart index b1157bd..8cb88f5 100644 --- a/mitra_app/lib/router.dart +++ b/mitra_app/lib/router.dart @@ -10,6 +10,8 @@ import 'features/chat/screens/active_sessions_screen.dart'; import 'features/chat/screens/mitra_chat_screen.dart'; import 'features/chat/screens/chat_history_screen.dart'; import 'features/chat/screens/chat_transcript_screen.dart'; +import 'features/chat/screens/request_history_screen.dart'; +import 'features/chat/screens/request_history_detail_screen.dart'; class RouterNotifier extends ChangeNotifier { final Ref _ref; @@ -71,6 +73,16 @@ GoRouter buildRouter(Ref ref) { GoRoute(path: '/chat/history/:sessionId', builder: (context, state) { return MitraChatTranscriptScreen(sessionId: state.pathParameters['sessionId']!); }), + GoRoute( + path: '/chat/requests/history', + builder: (_, __) => const RequestHistoryScreen(), + ), + GoRoute( + path: '/chat/requests/history/:notificationId', + builder: (context, state) => RequestHistoryDetailScreen( + notificationId: state.pathParameters['notificationId']!, + ), + ), ], ); } diff --git a/requirement/phase3.5-plan.md b/requirement/phase3.5-plan.md new file mode 100644 index 0000000..27d711d --- /dev/null +++ b/requirement/phase3.5-plan.md @@ -0,0 +1,249 @@ +# Phase 3.5 Implementation Plan: Mitra Chat Request History + +> **Source PRD:** [requirement/phase3.5.md](phase3.5.md) — open questions already resolved. + +## Summary of Clarified Requirements + +| Topic | Decision | +|---|---| +| Replaces | Home `_PendingRequestsBanner` Card | +| New CTA | "Riwayat Permintaan" Card with red count badge when pending > 0 | +| List size | Last 20 rows, no pagination, no filters, no search | +| List ordering | Pending rows pinned to top (newest-first), then everything else by `notified_at DESC` | +| Pending definition | `crn.response IS NULL` AND `cs.status = 'pending_acceptance'` | +| Customer identity | Always show `call_name`. Fall back to `"Anonim"` only when call_name is NULL/empty (e.g., anonymous customer or OAuth without name). **Never** phone / email / social id / user id. The legacy `anonymity_enabled` config flag is being retired (set to `false` 2026-04-27) — it does nothing functional today and should not be re-introduced into business logic. | +| Live update on history list | None. Pull-to-refresh only. | +| Live update on home badge | Yes. Driven by existing `chatRequestProvider`. | +| Pending row tap | Re-opens the existing incoming accept/decline overlay | +| Non-pending row tap | Opens read-only detail screen; only `accepted` rows surface a "Lihat percakapan" CTA → existing transcript screen | +| Status labels | Pending → "Menunggu respon" (yellow); Accepted → "Diterima" (green); Declined → "Ditolak" (gray); Missed → "Terlewat" (gray); Ignored+cancelled → "Dibatalkan" (gray); Ignored+expired → "Kedaluwarsa" (gray) | +| Sensitivity badge | Reuse Phase 3.3 yellow "Topik sensitif" badge | +| New tables / columns | None | + +--- + +## Prerequisites + +None. No new env vars, no schema changes, no new dependencies. + +--- + +## Work Stream 1: Backend + +### 1.1 New service function + +**File:** [backend/src/services/mitra-activity.service.js](backend/src/services/mitra-activity.service.js) + +Add a new export `getRecentRequestsForMitra(mitraId, { limit = 20 } = {})`. Capped at 20 even if the caller passes a higher value. + +**SQL** (single query — call_name always returned, "Anonim" only as null-safety fallback): + +```sql +SELECT + crn.id AS notification_id, + crn.session_id, + crn.notified_at, + crn.responded_at, + crn.response, + cs.status AS session_status, + cs.topic_sensitivity, + COALESCE(NULLIF(trim(c.display_name), ''), 'Anonim') AS customer_call_name +FROM chat_request_notifications crn +LEFT JOIN chat_sessions cs ON cs.id = crn.session_id +LEFT JOIN customers c ON c.id = cs.customer_id +WHERE crn.mitra_id = ${mitraId} +ORDER BY + -- pending pinned first (TRUE before FALSE in DESC ordering) + (crn.response IS NULL AND cs.status = 'pending_acceptance') DESC, + crn.notified_at DESC +LIMIT ${Math.min(limit, 20)} +``` + +**Privacy rule (corrected 2026-04-27):** The mitra-facing API returns the customer's **call_name** (the name they chose to be called). It is fine for that name to be a casual / non-real identifier — that's what the customer wants. The hard rule is: **never return phone, email, social id, or internal user id** to the mitra. Don't re-introduce a global anonymity toggle into business logic. + +### 1.2 New route + +**File:** [backend/src/routes/public/mitra.chat.routes.js](backend/src/routes/public/mitra.chat.routes.js) + +``` +GET /api/mitra/chat-requests/recent +``` + +Note the prefix on this router is `/api/mitra/chat-requests` (see `app.public.js:29`), so the in-file path is `/recent`. Final URL ends up `/api/mitra/chat-requests/recent` — semantically equivalent to the PRD's `/api/mitra/chat/requests/recent`. Calling it out so the URL matches the prefix instead of inventing a new register. + +- Auth: `[authenticate, resolveMitra]` (same as siblings) +- Query: optional `?limit=N` (clamped to 20) +- Response: `{ success: true, data: [ { notification_id, session_id, notified_at, responded_at, response, session_status, customer_call_name, topic_sensitivity }, ... ] }` + +### 1.3 No changes + +- No migration +- No constants +- No WS event additions +- No new permissions + +--- + +## Work Stream 2: Mitra App + +### 2.1 New API method + +**File:** [mitra_app/lib/core/api/api_client.dart](mitra_app/lib/core/api/api_client.dart) — already supports `get(path)`. No new method needed; the new provider calls `apiClient.get('/api/mitra/chat-requests/recent')` directly. + +### 2.2 New Riverpod provider + +**File:** `mitra_app/lib/features/chat/notifiers/request_history_notifier.dart` (new) + +- `AsyncNotifier>` +- `build()` → `[]` (empty until first fetch); does NOT auto-fetch on construction. Screen calls `refresh()` in initState. +- `refresh()` → calls `GET /api/mitra/chat-requests/recent`, replaces state. +- No WS subscription — pull-to-refresh + screen-mount fetch only (PRD §4). + +**Model** (`request_history_entry.dart` next to the notifier): + +```dart +class RequestHistoryEntry { + final String notificationId; + final String sessionId; + final DateTime notifiedAt; + final DateTime? respondedAt; + final RequestResponse? response; // enum: accepted, declined, missed, ignored, null + final SessionStatus sessionStatus; // enum + final String customerCallName; // already masked server-side + final TopicSensitivity topicSensitivity; +} +``` + +`RequestResponse` and `SessionStatus` enums go in `core/constants.dart` if not already there (per `feedback_use_enums.md`). Verify what already exists before adding. + +### 2.3 Replace home banner with CTA + +**File:** [mitra_app/lib/features/home/home_screen.dart](mitra_app/lib/features/home/home_screen.dart) + +- Delete `_PendingRequestsBanner` widget (line 128–171) entirely. +- Add a new `_RequestHistoryButton` widget styled like `_ActiveSessionsButton` (Card + ListTile + chevron). +- Source the badge count from `chatRequestProvider.notifier.activeRequestCount` — same source today's banner uses, so live-updates come for free. +- Subtitle: `"$count permintaan baru"` when `count > 0`, else `"Lihat permintaan chat sebelumnya"`. +- Trailing: `Badge(label: Text('$count'))` wrapping the chevron when `count > 0`; chevron only when `count == 0`. +- `onTap`: `context.push('/chat/requests/history')`. + +Keep the existing `Future.microtask` block that drives `loadPendingRequests()` on mount — the live count still depends on it. + +### 2.4 Two new routes + +**File:** [mitra_app/lib/router.dart](mitra_app/lib/router.dart) + +```dart +GoRoute( + path: '/chat/requests/history', + builder: (_, __) => const RequestHistoryScreen(), +), +GoRoute( + path: '/chat/requests/history/:notificationId', + builder: (context, state) => RequestHistoryDetailScreen( + notificationId: state.pathParameters['notificationId']!, + ), +), +``` + +### 2.5 Request History screen + +**File:** `mitra_app/lib/features/chat/screens/request_history_screen.dart` (new) + +- Scaffold + AppBar "Riwayat Permintaan" +- Body: + - `RefreshIndicator` wrapping a `ListView.separated` + - `initState` → `ref.read(requestHistoryProvider.notifier).refresh()` + - States: loading spinner / error retry / empty ("Belum ada permintaan chat") / list +- Each row: + - Leading: nothing, or a small status dot + - Title: `entry.customerCallName` + - Subtitle row: status badge (colored, localized text) + relative timestamp + - Trailing: sensitivity badge ("Topik sensitif" yellow) when sensitive, else null + - Tap: see §2.7 + +### 2.6 Detail screen (non-pending rows only) + +**File:** `mitra_app/lib/features/chat/screens/request_history_detail_screen.dart` (new) + +- Receives `notificationId` only. Pulls the matching entry from `requestHistoryProvider`'s cached state — if not present (e.g., deep link from a future notification), refetches. +- Body: vertical card showing call name, sensitivity, status, absolute `notified_at`, `responded_at` if present. +- Footer CTA when `response == accepted`: `FilledButton.icon(label: "Lihat percakapan")` → `context.push('/chat/history/$sessionId')`. + +### 2.7 Tap dispatch + +In the list tile `onTap`: + +```dart +final isPending = entry.response == null + && entry.sessionStatus == SessionStatus.pendingAcceptance; +if (isPending) { + // Re-open the incoming overlay for this session. + // The overlay is rendered by the global IncomingRequestOverlay listener, + // so we just feed the chatRequestProvider: + await ref.read(chatRequestProvider.notifier).setIncomingFromNotification(entry.sessionId); +} else { + context.push('/chat/requests/history/${entry.notificationId}'); +} +``` + +`setIncomingFromNotification` already exists ([chat_request_notifier.dart:247](mitra_app/lib/core/chat/chat_request_notifier.dart#L247)) and chains to `validateIncomingRequest()` so a stale row gracefully transitions to the "no longer available" stale state. + +### 2.8 Status labels + colors helper + +`mitra_app/lib/features/chat/utils/request_status_label.dart` (new) — small pure function `(response, sessionStatus) → (label, color)` so the list tile + detail screen share one source of truth. + +### 2.9 Relative timestamp helper + +If a `formatRelative(DateTime)` helper already exists (likely from chat history screen), reuse it. If not, add a small one to the same utils file: "Baru saja" (<1m), "N menit lalu" (<60m), "N jam lalu" (<24h), "Kemarin" (<48h), "N hari lalu" (≥48h). + +--- + +## Work Stream 3: Control Center + +**No changes.** PRD §0 lists CC as out of scope. + +--- + +## Acceptance / Test Plan + +| # | Scenario | Expected | +|---|---|---| +| 1 | Mitra opens home with 0 pending requests | "Riwayat Permintaan" card visible, no badge, subtitle "Lihat permintaan chat sebelumnya" | +| 2 | Mitra opens home with 2 pending | Card shows red `2` badge, subtitle "2 permintaan baru" | +| 3 | New `CHAT_REQUEST` arrives while on home | Badge increments without refresh | +| 4 | Customer cancels a pending request | Badge decrements without refresh | +| 5 | Tap "Riwayat Permintaan" with empty history | Empty state "Belum ada permintaan chat" | +| 6 | Tap with mixed history (1 pending, 5 accepted, 3 declined, 2 missed, 1 cancelled, 1 expired) | Pending pinned at top; rest sorted by `notified_at DESC` | +| 7 | Customer call_name = "Sasa" | Row shows "Sasa" | +| 8 | Customer call_name NULL or "" | Row shows "Anonim" | +| 10 | Sensitive-topic row | Yellow "Topik sensitif" badge present | +| 11 | Tap pending row | Incoming overlay reopens; shows accept/decline if still pending; shows stale state if taken/expired | +| 12 | Tap accepted row | Detail screen with "Lihat percakapan" CTA → existing transcript screen for that session | +| 13 | Tap declined / missed / ignored row | Detail screen, no transcript CTA | +| 14 | Pull-to-refresh on history screen | Re-fetches; UI updates | +| 15 | Open another mitra's session via direct URL hack | Backend returns own scope only — confirmed via SQL `WHERE crn.mitra_id = self`, never exposes other mitras | +| 16 | `?limit=1000` on the endpoint | Backend caps at 20 | + +Real-device E2E to perform on the SM G998B + Mac/iOS: +- (1)–(14) above +- Verify the badge stays in sync across logout/login +- Verify timestamps use the device locale's idea of "Kemarin" boundary correctly + +--- + +## Tech Debt Surfaced (Out of Scope) + +1. **`anonymity_enabled` config is dead code.** The flag exists in `app_config`, has GET/PATCH routes (`/internal/config/anonymity`, `/api/shared/config/anonymity`), and a CC settings UI — but **no service consumes it**. Worth a follow-up cleanup: rip out the service functions, routes, CC page, and migration seed. (DB row + JS default already flipped to `enabled: false` so it's harmless until then.) +2. The PRD path `/api/mitra/chat/requests/recent` differs from the actual register prefix `/api/mitra/chat-requests/`. Going with `/api/mitra/chat-requests/recent` to stay consistent. If it matters for external API hygiene, we can add a route-level rename pass later. + +--- + +## Implementation Order + +1. Backend: service function + route + smoke-curl ✅ green before touching app +2. Mitra app: provider + model + status helper +3. Mitra app: replace home banner +4. Mitra app: history screen +5. Mitra app: detail screen +6. Real-device E2E pass per the table above