Files
halobestie-clone/requirement/phase3.5-plan.md
ramadhan sjamsani 89afd01899 Phase 3.5: Mitra Chat Request History (backend route + mitra app screens)
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<List<...>>,
  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) <noreply@anthropic.com>
2026-04-27 18:59:17 +08:00

250 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<List<RequestHistoryEntry>>`
- `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 128171) 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