From 4158fb94326c88492d5b9e03f476e0710271862b Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Thu, 9 Apr 2026 22:09:25 +0800 Subject: [PATCH] Phase 3.2 docs + Phase 3.1 testing fixes - Add phase3.2.md requirement: overlay UX, mitra activity log - Add phase3.2-plan.md implementation plan - Fix stale request validation: add GET /:sessionId/status endpoint - Fix notification tap flow: setIncomingFromNotification + onChatRequestTapped - IncomingRequestSheet shows stale message instead of auto-dismiss - Home screen validates on resume, shows immediately on fresh WS Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/routes/public/mitra.chat.routes.js | 11 +- .../lib/core/chat/chat_request_notifier.dart | 11 +- .../notifications/notification_service.dart | 9 +- mitra_app/lib/features/home/home_screen.dart | 10 +- mitra_app/lib/main.dart | 4 + requirement/phase3.2-plan.md | 258 ++++++++++++++++++ requirement/phase3.2.md | 137 ++++++++++ 7 files changed, 427 insertions(+), 13 deletions(-) create mode 100644 requirement/phase3.2-plan.md create mode 100644 requirement/phase3.2.md diff --git a/backend/src/routes/public/mitra.chat.routes.js b/backend/src/routes/public/mitra.chat.routes.js index 40ba302..b97a345 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 } from '../../services/pairing.service.js' +import { acceptPairingRequest, declinePairingRequest, getSessionStatus } 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,15 @@ const resolveMitra = async (request, reply) => { } export const mitraChatRoutes = async (app) => { + // 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) + if (!session) { + return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Session not found' } }) + } + return reply.send({ success: true, data: { status: session.status } }) + }) + app.post('/:sessionId/accept', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { const session = await acceptPairingRequest(request.params.sessionId, request.mitra.id) return reply.send({ success: true, data: session }) diff --git a/mitra_app/lib/core/chat/chat_request_notifier.dart b/mitra_app/lib/core/chat/chat_request_notifier.dart index f05667f..e3f2656 100644 --- a/mitra_app/lib/core/chat/chat_request_notifier.dart +++ b/mitra_app/lib/core/chat/chat_request_notifier.dart @@ -130,19 +130,26 @@ class ChatRequest extends _$ChatRequest { } } + /// Called when user taps a chat_request notification. Sets the incoming state + /// with the given session and validates it's still pending. + Future setIncomingFromNotification(String sessionId) async { + state = ChatRequestIncomingData(sessionId); + await validateIncomingRequest(); + } + /// Check if the current incoming request is still valid (pending_acceptance). /// If stale, reset to listening state. Future validateIncomingRequest() async { if (state is! ChatRequestIncomingData) return; final sessionId = (state as ChatRequestIncomingData).sessionId; try { - final response = await _apiClient.get('/api/shared/chat/$sessionId/info'); + final response = await _apiClient.get('/api/mitra/chat-requests/$sessionId/status'); final status = response['data']?['status'] as String?; if (status != 'pending_acceptance') { state = const ChatRequestListeningData(); } } catch (_) { - state = const ChatRequestListeningData(); + // On error, keep current state — don't dismiss valid requests } } diff --git a/mitra_app/lib/core/notifications/notification_service.dart b/mitra_app/lib/core/notifications/notification_service.dart index cb70a5e..227c3c9 100644 --- a/mitra_app/lib/core/notifications/notification_service.dart +++ b/mitra_app/lib/core/notifications/notification_service.dart @@ -8,6 +8,10 @@ class NotificationService { static final _localNotifications = FlutterLocalNotificationsPlugin(); static GoRouter? _router; + /// Callback for when a chat request notification is tapped. + /// Set this from the app to bridge notifications → Riverpod state. + static void Function(String sessionId)? onChatRequestTapped; + static const _channel = AndroidNotificationChannel( 'chat_messages', 'Chat Messages', @@ -119,8 +123,9 @@ class NotificationService { final type = data['type'] as String?; final action = data['action'] as String?; - if (type == 'chat_request' && action == 'open_accept') { - // Navigate to home where incoming request sheet will show + if (type == 'chat_request' && action == 'open_accept' && sessionId != null) { + // Update the notifier state with this session, then navigate + onChatRequestTapped?.call(sessionId); _router!.go('/home'); } else if (type == 'session_closing' || type == 'session_expired') { // Navigate to the chat session closure screen diff --git a/mitra_app/lib/features/home/home_screen.dart b/mitra_app/lib/features/home/home_screen.dart index fc01918..69a2dcd 100644 --- a/mitra_app/lib/features/home/home_screen.dart +++ b/mitra_app/lib/features/home/home_screen.dart @@ -68,16 +68,10 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse } }); - // Listen for incoming chat requests + // Listen for incoming chat requests — fresh from WS, show immediately ref.listen(chatRequestProvider, (prev, next) { if (next is ChatRequestIncomingData) { - // Validate request is still pending before showing sheet - ref.read(chatRequestProvider.notifier).validateIncomingRequest().then((_) { - final current = ref.read(chatRequestProvider); - if (current is ChatRequestIncomingData) { - _showIncomingRequest(current.sessionId); - } - }); + _showIncomingRequest(next.sessionId); } else if (next is ChatRequestAcceptedData) { final session = next.session; final sessionId = session['session_id'] as String? ?? session['id'] as String; diff --git a/mitra_app/lib/main.dart b/mitra_app/lib/main.dart index fca2292..0d7cc93 100644 --- a/mitra_app/lib/main.dart +++ b/mitra_app/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/api/api_client_provider.dart'; import 'core/auth/auth_notifier.dart'; import 'core/status/status_notifier.dart'; +import 'core/chat/chat_request_notifier.dart'; import 'core/notifications/notification_service.dart'; import 'firebase_options.dart'; import 'router.dart'; @@ -78,6 +79,9 @@ class _AppState extends ConsumerState with WidgetsBindingObserver { final router = ref.watch(routerProvider); NotificationService.initialize(router); + NotificationService.onChatRequestTapped = (sessionId) { + ref.read(chatRequestProvider.notifier).setIncomingFromNotification(sessionId); + }; return MaterialApp.router( title: 'Halo Bestie Mitra', diff --git a/requirement/phase3.2-plan.md b/requirement/phase3.2-plan.md new file mode 100644 index 0000000..9406c1c --- /dev/null +++ b/requirement/phase3.2-plan.md @@ -0,0 +1,258 @@ +# Phase 3.2 Implementation Plan: Incoming Chat Request Overlay & Mitra Request Activity Log + +## Summary of Clarified Requirements + +| Topic | Decision | +|---|---| +| Work stream order | Overlay (mitra_app) and Activity Log (backend + CC) can be parallelized | +| Overlay approach | `Stack` wrapping `MaterialApp.router` in `main.dart` + `AnimatedPositioned` | +| Overlay state source | Watches `chatRequestProvider` exclusively — no per-page listeners | +| Multiple requests | Queued in notifier. Show one at a time. Next appears when current is resolved. | +| Swipe down behavior | Dismiss = ignore (no reject sent to backend) | +| Stale request messages | Must show specific reason: cancelled, accepted by other, expired | +| Stale auto-dismiss | No auto-dismiss — mitra must acknowledge by tapping OK or swiping | +| Background dimming | Partially dimmed to get mitra's attention | +| `missed` vs `ignored` | Backend must distinguish: `missed` = another mitra accepted; `ignored` = 60s timeout | +| `active_session_count` | Recorded at notification creation time (snapshot of mitra load) | +| `response_time_seconds` | Calculated column, not stored (`responded_at - notified_at`) | +| Control center page | New `/mitra-activity` page with acceptance rate, avg response time, filters | +| Mitra QC auto-flag | Out of scope for this phase (tracked in memory for future) | + +--- + +## Work Stream 1: Incoming Chat Request Overlay (mitra_app) + +### 1.1 Backend: Add `reason` Field to `chat_request_closed` WebSocket Message + +The current `chat_request_closed` message is sent identically for three different scenarios. The overlay must show a specific message for each case. + +**File:** `backend/src/services/pairing.service.js` + +**Change 1 — `acceptPairingRequest`:** When notifying other mitras, add `reason: 'accepted_by_other'` + +**Change 2 — `cancelPairingRequest`:** Add `reason: 'cancelled_by_customer'` + +**Change 3 — `expirePairingRequest`:** Add `reason: 'expired'` + +### 1.2 Backend: Include Session Metadata in `chat_request` WS Message + +**File:** `backend/src/services/pairing.service.js` + +Add `duration_minutes` and `is_free_trial` to the `notifyMitra` call in `createPairingRequest`. + +### 1.3 ChatRequestNotifier: Queue Support and Stale Reason + +**File:** `mitra_app/lib/core/chat/chat_request_notifier.dart` + +#### New State Classes + +```dart +class ChatRequestIncomingData extends ChatRequestData { + final String sessionId; + final int? durationMinutes; + final DateTime? createdAt; + const ChatRequestIncomingData(this.sessionId, {this.durationMinutes, this.createdAt}); +} + +class ChatRequestStaleData extends ChatRequestData { + final String sessionId; + final StaleReason reason; + const ChatRequestStaleData(this.sessionId, this.reason); +} + +enum StaleReason { + cancelledByCustomer, // "Permintaan dibatalkan oleh customer" + acceptedByOther, // "Permintaan diterima oleh Bestie lain" + expired, // "Permintaan kedaluwarsa" +} +``` + +#### Queue Implementation + +Add `List _pendingQueue` field: +- New request arrives while showing another → add to queue +- `chat_request_closed` for queued request → remove silently from queue +- `chat_request_closed` for displayed request → transition to `ChatRequestStaleData` + +#### New Methods + +- `ignore()` — swipe down on active request, advance queue +- `acknowledgeStale()` — OK/swipe on stale message, advance queue + +### 1.4 Overlay Widget: `ChatRequestOverlay` + +**New file:** `mitra_app/lib/core/chat/widgets/chat_request_overlay.dart` + +A `ConsumerStatefulWidget` wrapping the app that manages the overlay: + +- **Layout:** `Stack` → app child + positioned overlay at bottom + semi-transparent dim layer +- **Animation:** `SlideTransition` from `Offset(0, 1)` to `Offset(0, 0)` for bottom-up slide +- **Swipe-to-dismiss:** `GestureDetector` with `onVerticalDragEnd` detecting downward swipe +- **State listening:** `ref.listen(chatRequestProvider)` to show/hide: + - `ChatRequestIncomingData` → show overlay with accept/reject/swipe + - `ChatRequestStaleData` → show overlay with reason message + OK button + - `ChatRequestAcceptedData` → hide overlay, navigate to chat + - Any other state → hide overlay + +**Active request content:** +``` +[Chat icon] +"Ada permintaan chat baru!" +Durasi: 30 Menit + +[Tolak] [Terima] +(swipe down to close) +``` + +**Stale request content:** +``` +[Info icon] +"Permintaan dibatalkan oleh customer" +[OK] +``` + +**Navigation:** Uses `ref.read(routerProvider)` for `GoRouter` access (overlay sits above the router). + +### 1.5 App Root Changes + +**File:** `mitra_app/lib/main.dart` + +Wrap `MaterialApp.router` with `ChatRequestOverlay`: +```dart +return ChatRequestOverlay( + child: MaterialApp.router( + title: 'Halo Bestie Mitra', + routerConfig: router, + ), +); +``` + +### 1.6 Cleanup Old Bottom Sheet Code + +**File:** `mitra_app/lib/features/home/home_screen.dart` +- Remove `_showIncomingRequest` method +- Remove `didChangeAppLifecycleState` incoming request check +- Remove `ref.listen(chatRequestProvider, ...)` block for bottom sheet + navigation +- Remove `IncomingRequestSheet` import +- Convert from `ConsumerStatefulWidget` to `ConsumerWidget` (no lifecycle observer needed) + +**Delete:** `mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart` + +--- + +## Work Stream 2: Mitra Request Activity Log (Backend + CC) + +### 2.1 Database Migration + +**File:** `backend/src/db/migrate.js` + +```sql +ALTER TABLE chat_request_notifications +ADD COLUMN IF NOT EXISTS active_session_count INT NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_chat_request_notifications_mitra_notified +ON chat_request_notifications (mitra_id, notified_at); +``` + +### 2.2 Constants: Add `MISSED` Response Type + +**File:** `backend/src/constants.js` + +Add `MISSED: 'missed'` to `NotificationResponse`. + +### 2.3 Pairing Service Updates + +**File:** `backend/src/services/pairing.service.js` + +- `createPairingRequest`: record `active_session_count` when creating notification rows +- `acceptPairingRequest`: change `IGNORED` → `MISSED` when marking other mitras' notifications +- `expirePairingRequest`: keep `IGNORED` (correct — 60s timeout) + +### 2.4 New Service: Mitra Activity + +**New file:** `backend/src/services/mitra-activity.service.js` + +- `getMitraActivityLog({ mitra_id, date_from, date_to, page, limit })` — paginated detail log +- `getMitraActivitySummary({ mitra_id, date_from, date_to })` — per-mitra aggregates: total, accepted, rejected, missed, ignored, acceptance rate %, avg response time + +### 2.5 New Internal Routes + +**New file:** `backend/src/routes/internal/mitra-activity.routes.js` + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/internal/mitra-activity/log` | Paginated detail log | +| `GET` | `/internal/mitra-activity/summary` | Per-mitra summary stats | + +Register in `backend/src/app.internal.js`. + +### 2.6 Control Center: Mitra Activity Page + +**New file:** `control_center/src/pages/mitra-activity/MitraActivityPage.jsx` + +**Filters:** Date range (from/to), mitra dropdown (optional) + +**Summary table columns:** + +| Mitra | Total | Accepted | Rejected | Missed | Ignored | Rate (%) | Avg Response (s) | + +**Detail log table columns:** + +| Mitra | Session | Response | Response Time | Active Sessions | Notified At | Responded At | + +Response values color-coded: `accepted` green, `rejected` red, `missed` orange, `ignored` grey. + +Register route in `App.jsx`, add nav link "Aktivitas Mitra" in `Layout.jsx`. + +--- + +## 3. Implementation Order + +| Step | What | Apps Affected | Dependencies | +|---|---|---|---| +| **Work Stream 1: Overlay** | | | | +| 1 | Backend: add `reason` to `chat_request_closed` WS messages | Backend | None | +| 2 | Backend: include `duration_minutes` in `chat_request` WS message | Backend | None | +| 3 | ChatRequestNotifier: add `ChatRequestStaleData`, `StaleReason`, queue, `ignore()`, `acknowledgeStale()` | mitra_app | Steps 1–2 | +| 4 | Create `ChatRequestOverlay` widget | mitra_app | Step 3 | +| 5 | Integrate overlay into `main.dart` | mitra_app | Step 4 | +| 6 | Cleanup: remove bottom sheet code from home screen, delete `IncomingRequestSheet` | mitra_app | Step 5 | +| 7 | E2E test overlay flows | mitra_app | Step 6 | +| **Work Stream 2: Activity Log** | | | | +| 8 | DB migration: `active_session_count` column + index | Backend | None | +| 9 | Constants: add `MISSED` to `NotificationResponse` | Backend | None | +| 10 | Pairing service: record `active_session_count`, use `MISSED` | Backend | Steps 8–9 | +| 11 | New `mitra-activity.service.js` | Backend | Step 10 | +| 12 | New `mitra-activity.routes.js` + register | Backend | Step 11 | +| 13 | Control center: `MitraActivityPage.jsx` | Control center | Step 12 | +| 14 | Control center: register route + nav link | Control center | Step 13 | +| 15 | E2E test activity log + summary | All | Step 14 | + +--- + +## 4. New Files + +| File | Purpose | +|---|---| +| `mitra_app/lib/core/chat/widgets/chat_request_overlay.dart` | App-wide overlay for incoming chat requests | +| `backend/src/services/mitra-activity.service.js` | Mitra activity log query functions | +| `backend/src/routes/internal/mitra-activity.routes.js` | Internal API routes for activity data | +| `control_center/src/pages/mitra-activity/MitraActivityPage.jsx` | CC mitra activity page | + +## 5. Deleted Files + +| File | Reason | +|---|---| +| `mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart` | Replaced by `ChatRequestOverlay` | + +--- + +## 6. Risks & Mitigations + +| Risk | Mitigation | +|---|---| +| Overlay wrapping `MaterialApp.router` can't use `GoRouter.of(context)` | Use `ref.read(routerProvider)` directly | +| Swipe gesture conflicts with overlay content | Overlay content is short (non-scrollable); wrap only drag-handle area | +| Multiple rapid WS messages cause queue issues | Queue ops are synchronous in Dart's single-threaded event loop | +| `chat_request_closed` arrives during slide-up animation | Transition to stale state immediately; animation handles it smoothly | +| Old `ignored` values in DB now ambiguous | Only new requests post-deploy get correct `missed` values; document in CC | diff --git a/requirement/phase3.2.md b/requirement/phase3.2.md new file mode 100644 index 0000000..d236da4 --- /dev/null +++ b/requirement/phase3.2.md @@ -0,0 +1,137 @@ +# PRD: Mitra Chat Request Overlay & Pairing UX + +# Overview + +**Goal:** Reliable incoming chat request experience for Mitra — always visible, non-blocking, works in all app states (foreground, background, killed) + +**Success looks like:** When a customer requests a chat, the mitra is always notified and can accept/reject without leaving their current screen. The notification works regardless of whether the app is in foreground, background, or opened from a push notification. + +## Background +- Current bottom sheet approach (`showModalBottomSheet`) fails silently when the app is backgrounded +- Flutter UI cannot render when the app is not in foreground +- Push notifications work in background but the in-app response (bottom sheet) doesn't show reliably when the user taps the notification +- The state change from WebSocket gets "consumed" by listeners while backgrounded, so returning to foreground shows stale data +- Need a solution that is non-blocking (doesn't interrupt active chat sessions) and works on any page + + +# Functional Requirement + +## Incoming Chat Request Overlay + +### Trigger +- Overlay watches `chatRequestProvider` state — it does NOT depend on any specific page or lifecycle event +- Three sources can trigger the overlay: + 1. **WebSocket** — real-time delivery when app is in foreground + 2. **FCM notification tap** — user taps push notification, app opens, state is set from notification payload + 3. **App resume** — app returns to foreground, validates pending request with backend + +### Appearance +- Slides up from the bottom of the screen, like a bottom sheet +- Rounded top corners +- Page behind is partially dimmed to get mitra's attention +- Shows on top of ANY page (home, chat, history, settings, etc.) + +### Content +- Session information (duration, etc.) +- Accept button +- Reject button +- Swipe down to close (= ignore, NOT reject) + +### Behavior +- **Accept** → overlay dismisses, navigate to chat session with customer +- **Reject** → overlay dismisses, mitra continues what they were doing +- **Swipe down / close** → overlay dismisses, request is ignored (no explicit reject sent to backend) +- **Request cancelled by customer** → overlay updates to show "Permintaan dibatalkan oleh customer" +- **Request accepted by other mitra** → overlay updates to show "Permintaan diterima oleh Bestie lain" +- **Request expired (60s timeout)** → overlay updates to show "Permintaan kedaluwarsa" +- Stale messages do NOT auto-dismiss — mitra must acknowledge by tapping OK or swiping down + +### Multiple Requests +- Show one request at a time +- Requests are queued — when current request is resolved (accepted/rejected/ignored/expired), next queued request appears +- Future enhancement: dedicated `/chat-requests` page with full list + +## Push Notification (Background/Killed) + +### When App is Backgrounded +- Local notification with sound + vibration (already implemented via WebSocket listener) +- FCM push notification as fallback when WebSocket is disconnected + +### When Notification is Tapped +- App opens → `chatRequestProvider` state is set from notification payload (`session_id`) +- Overlay appears with the request detail +- Backend validation confirms request is still pending before showing accept/reject + +### Stale Notification Handling +- If user taps a notification for an already-resolved request, overlay shows appropriate message ("dibatalkan" / "diterima Bestie lain" / "kedaluwarsa") then auto-dismisses + + +## Mitra Request Activity Log + +### Goal +Track every mitra's response to incoming chat requests for QC measurement — how many accepted, rejected, ignored (expired without action), and how many active sessions they had at the time. + +### Logged Data +For each incoming request delivered to a mitra, log: +- `mitra_id` — which mitra received the request +- `session_id` — which chat request +- `response` — `accepted`, `rejected`, `ignored` (expired without action), `missed` (taken by other mitra before response) +- `response_time_seconds` — how long it took the mitra to respond (null if ignored) +- `active_session_count` — how many active sessions the mitra had at the time of the request +- `notified_at` — when the request was delivered to the mitra +- `responded_at` — when the mitra responded (null if ignored) + +### When to Log +All logging is **backend-side and event-driven** — no polling or constant monitoring needed. The frontend only triggers `accepted` and `rejected` through existing API calls. + +| Response | Triggered by | Frontend action? | +|---|---|---| +| `accepted` | Backend — when mitra calls `POST /:sessionId/accept` | Yes — mitra taps Accept | +| `rejected` | Backend — when mitra calls `POST /:sessionId/decline` | Yes — mitra taps Reject | +| `missed` | Backend — when `acceptPairingRequest` marks other mitras' notifications (another mitra accepted first) | No — fully backend | +| `ignored` | Backend — when `expirePairingRequest` fires after 60s timeout with no response | No — fully backend | + +### Existing Infrastructure +The `chat_request_notifications` table already tracks `notified_at`, `response`, `responded_at`. Current changes needed: +- Distinguish `missed` from `ignored` (currently both stored as `ignored`) +- Add `active_session_count` column — recorded when the notification is created +- `response_time_seconds` can be calculated from `responded_at - notified_at` (no new column needed) + +### Control Center +- New dedicated page: Mitra Request Activity +- Dashboard: mitra acceptance rate, average response time, rejection count, ignore count per mitra +- Filter by date range and mitra +- Auto-flagging mitras with high rejection/ignore rate is **out of scope** for this phase (planned for future) + + +## Technical Implementation + +### Overlay Component +- Single `OverlayEntry` managed from `main.dart` +- Wrapped around `MaterialApp.router` in a `Stack` +- Watches `chatRequestProvider` — shows/hides based on state +- Uses `AnimatedPositioned` or `SlideTransition` for bottom-up animation +- `GestureDetector` for swipe-to-dismiss + +### State Flow +``` +WebSocket ──→ +FCM tap ──→ chatRequestProvider ──→ Overlay shows/hides +App resume ──→ +``` + +### No Changes Required +- Backend pairing service (already sends WS + FCM) +- Push notification payload (already contains session_id + type) +- Chat request notifier (already manages state from WS + FCM) + +### Cleanup from Phase 3.1 +- Remove `showModalBottomSheet` for incoming requests from home screen +- Remove `IncomingRequestSheet` widget +- Remove `didChangeAppLifecycleState` incoming request check from home screen + + +# Tech Stack +- Flutter `Overlay` / `OverlayEntry` or `Stack` with `AnimatedPositioned` +- Existing Riverpod `chatRequestProvider` +- No backend changes