frontend phase 14

This commit is contained in:
hamid
2026-07-10 18:46:16 +03:30
parent 85488bc25b
commit bc51cf59b4
73 changed files with 3582 additions and 13 deletions
@@ -0,0 +1,73 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated, type PageParams } from '@/lib/api/types';
import { NOTIFICATIONS_PAGE_SIZE } from '../constants';
import { parseNotificationData } from '../parse';
import type { AppNotification, NotificationsApi } from '../types';
const API = '/api/v1';
/** Wire `NotificationDto` (camelCase; `dataJson` is a JSON string parsed into the typed union). */
interface NotificationWire {
id: number;
type: string;
title: string;
body: string | null;
dataJson: string | null;
isRead: boolean;
readAt: string | null;
createdAt: string;
}
function mapNotification(w: NotificationWire): AppNotification {
return {
id: w.id,
type: w.type,
title: w.title,
body: w.body,
isRead: w.isRead,
createdAt: w.createdAt,
data: parseNotificationData(w.type, w.dataJson),
};
}
/**
* Real HTTP implementation of the `NotificationsApi` seam (b1 contract, `notifications` controller). All four
* methods map published routes:
* - `listNotifications` → `GET notifications/get_notifications` (unread-first, paginated).
* - `getUnreadCount` → `GET notifications/get_unread_count` (`{ count }`).
* - `markRead` → `POST notifications/mark_notification_read` (`{ notificationId }`).
* - `markAllRead` → `POST notifications/mark_all_read`.
*
* NOT the primary implementation this phase (`USE_NOTIFICATIONS_MOCK = true`) — nothing dispatches
* notifications client-side yet (see `constants.ts`). Pagination follows the repo convention (`pageSize`,
* REQ-010).
*/
export const notificationsClientApi: NotificationsApi = {
listNotifications: async (params: PageParams): Promise<Paginated<AppNotification>> => {
const query = new URLSearchParams();
query.set('page', String(params.page ?? 1));
query.set('pageSize', String(params.pageSize ?? NOTIFICATIONS_PAGE_SIZE));
const wire = unwrap(
await clientFetch<ApiEnvelope<Paginated<NotificationWire>>>(
`${API}/notifications/get_notifications?${query.toString()}`,
),
);
return { ...wire, items: wire.items.map(mapNotification) };
},
getUnreadCount: async (): Promise<number> => {
const wire = unwrap(await clientFetch<ApiEnvelope<{ count: number }>>(`${API}/notifications/get_unread_count`));
return wire.count;
},
markRead: async (notificationId: number): Promise<void> => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/notifications/mark_notification_read`, {
method: 'POST',
body: JSON.stringify({ notificationId }),
});
},
markAllRead: async (): Promise<void> => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/notifications/mark_all_read`, { method: 'POST' });
},
};
@@ -0,0 +1,13 @@
import { USE_NOTIFICATIONS_MOCK } from '../constants';
import type { NotificationsApi } from '../types';
import { notificationsClientApi } from './clientApi';
import { notificationsMockApi } from './mockApi';
/**
* The selected `NotificationsApi` implementation — the single seam the hooks import. Selection is by config
* (`USE_NOTIFICATIONS_MOCK`), never scattered `if (mock)` checks. Mock-primary this phase (nothing dispatches
* notifications client-side yet); the swap is this one line.
*/
export const notificationsApi: NotificationsApi = USE_NOTIFICATIONS_MOCK
? notificationsMockApi
: notificationsClientApi;
@@ -0,0 +1,108 @@
import { sleep } from '@/utils';
import type { Paginated, PageParams } from '@/lib/api/types';
import { parseNotificationData } from '../parse';
import type { AppNotification, NotificationsApi } from '../types';
/**
* In-memory `NotificationsApi` — **the primary implementation this phase** (`USE_NOTIFICATIONS_MOCK = true`;
* nothing dispatches notifications client-side yet — see `constants.ts`).
*
* It seeds a realistic **unread-first** feed spanning every deep-link class so `notificationDeepLink` is
* exercisable end-to-end, keeps a `dataJson` string per row (snake_case, as the contract serves) that the
* list maps through the real `parseNotificationData`, and exposes a dev-only `__mockPushNotification` so a
* human can watch the bell badge increment within the poll interval (phase §7 step 4). Booking/ticket/nurse
* ids align with the f8 bookings + tickets mocks so a deep-link lands on a real screen.
*/
const MOCK_LATENCY_MS = 200;
interface StoredNotification {
id: number;
type: string;
title: string;
body: string | null;
dataJson: string | null;
isRead: boolean;
createdAt: string;
}
/** ISO instant `mins` in the past — seeded timestamps (rendered Shamsi client-side). */
function isoMinsAgo(mins: number): string {
return new Date(Date.now() - mins * 60_000).toISOString();
}
let nextNotificationId = 8100;
const notifications: StoredNotification[] = [
{ id: 8001, type: 'ticket_message', title: 'پیام جدید در تیکت', body: 'پرستار به هماهنگی ویزیت شما پاسخ داد.', dataJson: '{"ticket_id":1201}', isRead: false, createdAt: isoMinsAgo(90) },
{ id: 8002, type: 'booking_confirmed', title: 'رزرو شما تایید شد', body: 'ویزیت شما ثبت و تایید شد.', dataJson: '{"booking_id":5001}', isRead: false, createdAt: isoMinsAgo(240) },
{ id: 8003, type: 'refund_processed', title: 'بازپرداخت ثبت شد', body: 'بازپرداخت شما در حال انجام است.', dataJson: '{"booking_id":5004}', isRead: false, createdAt: isoMinsAgo(600) },
{ id: 8004, type: 'payment_captured', title: 'پرداخت انجام شد', body: 'مبلغ ویزیت با موفقیت پرداخت شد.', dataJson: '{"booking_id":5001}', isRead: true, createdAt: isoMinsAgo(1_440) },
{ id: 8005, type: 'payout_paid', title: 'تسویه واریز شد', body: 'درآمد این هفته به حساب شما واریز شد.', dataJson: '{"payout_id":7001}', isRead: true, createdAt: isoMinsAgo(2_880) },
{ id: 8006, type: 'review_published', title: 'نظر شما منتشر شد', body: null, dataJson: '{"nurse_profile_id":1}', isRead: true, createdAt: isoMinsAgo(4_320) },
// Unknown type + no payload → degrades to no deep-link (still a readable, informational row).
{ id: 8007, type: 'system_message', title: 'به بالین‌یار خوش آمدید', body: 'سلامت خانواده شما، اولویت ماست.', dataJson: null, isRead: true, createdAt: isoMinsAgo(10_080) },
];
function toAppNotification(n: StoredNotification): AppNotification {
return {
id: n.id,
type: n.type,
title: n.title,
body: n.body,
isRead: n.isRead,
createdAt: n.createdAt,
data: parseNotificationData(n.type, n.dataJson),
};
}
/** Unread first, then newest-first (server ordering). */
function ordered(): StoredNotification[] {
return [...notifications].sort((a, b) => {
if (a.isRead !== b.isRead) return a.isRead ? 1 : -1;
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
});
}
export const notificationsMockApi: NotificationsApi = {
listNotifications: async (params: PageParams): Promise<Paginated<AppNotification>> => {
await sleep(MOCK_LATENCY_MS);
const all = ordered();
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.max(1, params.pageSize ?? all.length);
const start = (page - 1) * pageSize;
return { items: all.slice(start, start + pageSize).map(toAppNotification), total: all.length, page, pageSize };
},
getUnreadCount: async (): Promise<number> => {
await sleep(MOCK_LATENCY_MS);
return notifications.filter((n) => !n.isRead).length;
},
markRead: async (notificationId: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
const n = notifications.find((x) => x.id === notificationId);
if (n) n.isRead = true;
},
markAllRead: async (): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
notifications.forEach((n) => {
n.isRead = true;
});
},
};
/**
* DEV-ONLY: simulate a fresh notification arriving so a human can watch the bell badge increment within the
* poll interval (phase §7 step 4) and open it → deep-link. Prepends an **unread** row. `dataJson` is a
* snake_case JSON string, exactly as the wire serves it. Call it from the console. Not wired into any screen.
*/
export function __mockPushNotification(
type: string,
title: string,
dataJson: string | null = null,
body: string | null = null,
): void {
notifications.unshift({ id: nextNotificationId++, type, title, body, dataJson, isRead: false, createdAt: new Date().toISOString() });
}
@@ -0,0 +1,27 @@
/**
* When true, the notifications domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `NotificationsApi` seam.
*
* **Mock is primary this phase.** The b1 endpoints are live and the real `clientApi.ts` maps them 1:1, but a
* notification is only produced by other backend domains dispatching one (`INotificationDispatcher`) — none
* of which run client-side while the upstream flows are mock-primary — so there'd be nothing to show. The
* mock seeds a realistic **unread-first** feed spanning every deep-link class (booking / payment / ticket /
* payout / review / refund) so `notificationDeepLink` is exercisable, and exposes a dev-only
* `__mockPushNotification` to simulate a fresh notification arriving (the bell increments within the poll
* interval — phase §7 step 4). Flip to `false` once the upstreams are real — no hook/component change.
*/
export const USE_NOTIFICATIONS_MOCK = true;
/** Notification-center page size (api-conventions `pageSize`). */
export const NOTIFICATIONS_PAGE_SIZE = 20;
/**
* **Poll politely.** Only the unread **count** revalidates on an interval (stale-while-revalidate): the
* cached count is served instantly, refetched every `UNREAD_COUNT_REFETCH_INTERVAL` and on window focus, and
* treated fresh for `UNREAD_COUNT_STALE_TIME` so we never hammer the endpoint. The **list is not polled** —
* only its own reads/mutations refetch it.
*/
export const UNREAD_COUNT_STALE_TIME = 45 * 1000;
export const UNREAD_COUNT_REFETCH_INTERVAL = 60 * 1000;
export const NOTIFICATIONS_LIST_STALE_TIME = 30 * 1000;
export const NOTIFICATIONS_GC_TIME = 5 * 60 * 1000;
@@ -0,0 +1,35 @@
import { notificationDeepLink } from './deepLink';
import type { AppNotification, NotificationData } from './types';
function make(data: NotificationData): AppNotification {
return { id: 1, type: 'x', title: 't', body: null, isRead: false, createdAt: '2026-07-01T00:00:00Z', data };
}
describe('notificationDeepLink', () => {
it('routes a booking to the customer booking detail vs the nurse visit detail', () => {
expect(notificationDeepLink(make({ kind: 'booking', bookingId: 5001 }), 'customer')).toBe('/bookings/5001');
expect(notificationDeepLink(make({ kind: 'booking', bookingId: 5001 }), 'nurse')).toBe('/nurse/visits/5001');
});
it('routes a ticket to the right shell', () => {
expect(notificationDeepLink(make({ kind: 'ticket', ticketId: 12 }), 'customer')).toBe('/support/tickets/12');
expect(notificationDeepLink(make({ kind: 'ticket', ticketId: 12 }), 'nurse')).toBe('/nurse/support/tickets/12');
});
it('routes a refund to the customer refund status', () => {
expect(notificationDeepLink(make({ kind: 'refund', bookingId: 5004 }), 'customer')).toBe('/bookings/5004/refund_status');
});
it('routes a payout to the nurse payout detail', () => {
expect(notificationDeepLink(make({ kind: 'payout', payoutId: 7001 }), 'nurse')).toBe('/nurse/earnings/payouts/7001');
});
it('routes a review to the public nurse profile for a customer, nowhere for a nurse', () => {
expect(notificationDeepLink(make({ kind: 'nurse_profile', nurseProfileId: 1 }), 'customer')).toBe('/search/nurse/1');
expect(notificationDeepLink(make({ kind: 'nurse_profile', nurseProfileId: 1 }), 'nurse')).toBeNull();
});
it('returns null (non-navigable, never broken) when there is nothing to open', () => {
expect(notificationDeepLink(make({ kind: 'none' }), 'customer')).toBeNull();
});
});
@@ -0,0 +1,35 @@
import {
ROUTES,
bookingRefundStatusPath,
nurseBookingDetailPath,
nursePayoutDetailPath,
ticketThreadPath,
} from '@/constants';
import type { AppNotification } from './types';
/**
* Map a notification's parsed `data` to an **app-relative** in-app route (no locale prefix — the caller adds
* it), role-aware so the same notification lands on the right shell (a booking → the customer's
* `/bookings/{id}` vs the nurse's `/nurse/visits/{id}`; a ticket → the right `support/tickets/{id}`).
* Centralised so the bell and the center deep-link identically. Returns `null` when there is nothing to open
* (`kind: 'none'`, or a target that doesn't apply to this role) — the row is then non-navigable, never broken.
*/
export function notificationDeepLink(notification: AppNotification, role: 'customer' | 'nurse'): string | null {
const { data } = notification;
switch (data.kind) {
case 'booking':
return role === 'nurse' ? nurseBookingDetailPath(data.bookingId) : `${ROUTES.BOOKINGS}/${data.bookingId}`;
case 'refund':
// A refund view is a customer surface; a nurse just reaches the booking.
return role === 'nurse' ? nurseBookingDetailPath(data.bookingId) : bookingRefundStatusPath(data.bookingId);
case 'payout':
return nursePayoutDetailPath(data.payoutId);
case 'ticket':
return ticketThreadPath(role, data.ticketId);
case 'nurse_profile':
return role === 'customer' ? `${ROUTES.SEARCH_NURSE}/${data.nurseProfileId}` : null;
case 'none':
default:
return null;
}
}
@@ -0,0 +1,43 @@
import { useMutation, useQueryClient, type QueryKey } from '@tanstack/react-query';
import type { Paginated } from '@/lib/api/types';
import { notificationKeys } from '../keys';
import { notificationsApi } from '../apis';
import type { AppNotification } from '../types';
interface MarkAllContext {
prevCount?: number;
prevLists: Array<[QueryKey, Paginated<AppNotification> | undefined]>;
}
/**
* "Mark all read" — **optimistic**: flip every cached list item to `isRead` and zero the cached unread count
* so the badge clears instantly, roll both back on error, and invalidate on settle.
*/
export function useMarkAllRead() {
const queryClient = useQueryClient();
return useMutation<void, unknown, void, MarkAllContext>({
mutationFn: () => notificationsApi.markAllRead(),
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: notificationKeys.all });
const prevCount = queryClient.getQueryData<number>(notificationKeys.unreadCount());
const prevLists = queryClient.getQueriesData<Paginated<AppNotification>>({ queryKey: notificationKeys.lists() });
queryClient.setQueriesData<Paginated<AppNotification>>({ queryKey: notificationKeys.lists() }, (data) =>
data ? { ...data, items: data.items.map((n) => ({ ...n, isRead: true })) } : data,
);
queryClient.setQueryData<number>(notificationKeys.unreadCount(), 0);
return { prevCount, prevLists };
},
onError: (_err, _vars, context) => {
if (context?.prevCount !== undefined) queryClient.setQueryData(notificationKeys.unreadCount(), context.prevCount);
context?.prevLists.forEach(([key, data]) => queryClient.setQueryData(key, data));
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
queryClient.invalidateQueries({ queryKey: notificationKeys.unreadCount() });
},
});
}
@@ -0,0 +1,51 @@
import { useMutation, useQueryClient, type QueryKey } from '@tanstack/react-query';
import type { Paginated } from '@/lib/api/types';
import { notificationKeys } from '../keys';
import { notificationsApi } from '../apis';
import type { AppNotification } from '../types';
interface MarkReadContext {
prevCount?: number;
prevLists: Array<[QueryKey, Paginated<AppNotification> | undefined]>;
}
/**
* Mark one notification read — **optimistic** (phase §3.4): flip `isRead` across every cached list page and
* decrement the cached unread count at once (so the row de-emphasises and the bell badge drops instantly),
* roll both back on error, and invalidate on settle (no full refetch on the happy path). The count only
* decrements when the notification was actually unread, so re-opening a read one never underflows.
*/
export function useMarkNotificationRead() {
const queryClient = useQueryClient();
return useMutation<void, unknown, number, MarkReadContext>({
mutationFn: (notificationId) => notificationsApi.markRead(notificationId),
onMutate: async (notificationId) => {
await queryClient.cancelQueries({ queryKey: notificationKeys.all });
const prevCount = queryClient.getQueryData<number>(notificationKeys.unreadCount());
const prevLists = queryClient.getQueriesData<Paginated<AppNotification>>({ queryKey: notificationKeys.lists() });
const wasUnread = prevLists.some(([, data]) =>
data?.items.some((n) => n.id === notificationId && !n.isRead),
);
queryClient.setQueriesData<Paginated<AppNotification>>({ queryKey: notificationKeys.lists() }, (data) =>
data ? { ...data, items: data.items.map((n) => (n.id === notificationId ? { ...n, isRead: true } : n)) } : data,
);
if (wasUnread && typeof prevCount === 'number') {
queryClient.setQueryData<number>(notificationKeys.unreadCount(), Math.max(0, prevCount - 1));
}
return { prevCount, prevLists };
},
onError: (_err, _id, context) => {
if (context?.prevCount !== undefined) queryClient.setQueryData(notificationKeys.unreadCount(), context.prevCount);
context?.prevLists.forEach(([key, data]) => queryClient.setQueryData(key, data));
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
queryClient.invalidateQueries({ queryKey: notificationKeys.unreadCount() });
},
});
}
@@ -0,0 +1,22 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { notificationsApi } from '../apis';
import { notificationKeys } from '../keys';
import { NOTIFICATIONS_GC_TIME, NOTIFICATIONS_LIST_STALE_TIME, NOTIFICATIONS_PAGE_SIZE } from '../constants';
/**
* The notification center list — **unread-first** (server ordering). `limit` grows for "load more" (page 1,
* a larger page) so the visible list accumulates without a fragile page-by-page merge (unread-first ordering
* shifts as rows are read). The limit keys the cache, so growing it caches independently and `keepPreviousData`
* avoids a flash. **Not polled** — only `useUnreadCount` revalidates on an interval; opening a notification /
* mark-all `setQueryData`s this cache and invalidates on settle.
*/
export function useNotifications(limit: number = NOTIFICATIONS_PAGE_SIZE) {
const params = { page: 1, pageSize: limit };
return useQuery({
queryKey: notificationKeys.list(params),
queryFn: () => notificationsApi.listNotifications(params),
placeholderData: keepPreviousData,
staleTime: NOTIFICATIONS_LIST_STALE_TIME,
gcTime: NOTIFICATIONS_GC_TIME,
});
}
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import { notificationsApi } from '../apis';
import { notificationKeys } from '../keys';
import { UNREAD_COUNT_REFETCH_INTERVAL, UNREAD_COUNT_STALE_TIME } from '../constants';
/**
* The **polled** unread count that drives the bell badge — stale-while-revalidate: the cached count is served
* instantly, treated fresh for `UNREAD_COUNT_STALE_TIME`, refetched every `UNREAD_COUNT_REFETCH_INTERVAL` and
* on window focus (background revalidation), so we never hammer the endpoint (phase §5). Gated on
* authentication so the bell never polls for a signed-out user. Returns the raw count (0 while loading) so
* only the bell — not the shell — re-renders on a change.
*/
export function useUnreadCount(): number {
const isAuthenticated = useIsAuthenticated();
const { data } = useQuery({
queryKey: notificationKeys.unreadCount(),
queryFn: () => notificationsApi.getUnreadCount(),
enabled: isAuthenticated,
staleTime: UNREAD_COUNT_STALE_TIME,
refetchInterval: UNREAD_COUNT_REFETCH_INTERVAL,
refetchOnWindowFocus: true,
});
return data ?? 0;
}
@@ -0,0 +1,10 @@
/**
* Notifications domain barrel — re-exports **hooks only** (per the `services/{domain}` convention) plus the
* shared `notificationDeepLink` util (the bell and the center deep-link identically through it). Import
* types/keys/apis directly from their files when needed.
*/
export { useNotifications } from './hooks/useNotifications';
export { useUnreadCount } from './hooks/useUnreadCount';
export { useMarkNotificationRead } from './hooks/useMarkNotificationRead';
export { useMarkAllRead } from './hooks/useMarkAllRead';
export { notificationDeepLink } from './deepLink';
+16
View File
@@ -0,0 +1,16 @@
import type { PageParams } from '@/lib/api/types';
/**
* React Query key factory for the notifications domain. The **page keys the list** (unread-first, paginated);
* `unreadCount` is its own tiny key so the polling bell reads the count **without** touching the list cache
* (the count re-fetches on an interval; the list does not). Mark-read mutations `setQueryData` both keys
* optimistically, then invalidate on settle.
*/
export const notificationKeys = {
all: ['notifications'] as const,
lists: () => [...notificationKeys.all, 'list'] as const,
list: (params: PageParams) => [...notificationKeys.lists(), params] as const,
unreadCount: () => [...notificationKeys.all, 'unread_count'] as const,
};
@@ -0,0 +1,28 @@
import { parseNotificationData } from './parse';
describe('parseNotificationData', () => {
it('parses a snake_case booking payload', () => {
expect(parseNotificationData('booking_confirmed', '{"booking_id":5001}')).toEqual({ kind: 'booking', bookingId: 5001 });
});
it('also accepts a camelCase payload', () => {
expect(parseNotificationData('ticket_message', '{"ticketId":12}')).toEqual({ kind: 'ticket', ticketId: 12 });
});
it('maps a refund notification to the booking id', () => {
expect(parseNotificationData('refund_processed', '{"booking_id":5004}')).toEqual({ kind: 'refund', bookingId: 5004 });
});
it('degrades to none for malformed JSON (never throws / trusts a blob)', () => {
expect(parseNotificationData('booking_confirmed', 'not-json')).toEqual({ kind: 'none' });
});
it('degrades to none for an unknown type', () => {
expect(parseNotificationData('mystery_event', '{"booking_id":1}')).toEqual({ kind: 'none' });
});
it('degrades to none when the expected id is missing', () => {
expect(parseNotificationData('payout_paid', '{}')).toEqual({ kind: 'none' });
expect(parseNotificationData('booking_confirmed', null)).toEqual({ kind: 'none' });
});
});
@@ -0,0 +1,68 @@
import type { NotificationData } from './types';
/**
* Parse a notification's wire `data_json` string into the typed `NotificationData` union, keyed off its
* `type` code. `data_json` is a **typed, versioned contract** (the contract example uses snake_case keys,
* e.g. `{"booking_id": 1}`), but we read both snake_case and camelCase defensively and **never trust an
* arbitrary blob**: a malformed JSON string, an unknown `type`, or a missing/invalid id all degrade to
* `{ kind: 'none' }` (no deep-link) rather than throwing.
*
* The many `type` codes collapse to a few deep-link classes so `notificationDeepLink` stays small.
*/
export function parseNotificationData(type: string, dataJson: string | null | undefined): NotificationData {
const raw = safeParse(dataJson);
const bookingId = num(raw, 'bookingId', 'booking_id');
const payoutId = num(raw, 'payoutId', 'payout_id', 'batchId', 'batch_id');
const ticketId = num(raw, 'ticketId', 'ticket_id');
const nurseProfileId = num(raw, 'nurseProfileId', 'nurse_profile_id', 'nurseId', 'nurse_id');
switch (type) {
case 'booking_confirmed':
case 'booking_reminder':
case 'session_reminder':
case 'payment_captured':
case 'booking_cancelled':
return bookingId != null ? { kind: 'booking', bookingId } : { kind: 'none' };
case 'refund_processed':
case 'refund_completed':
// A refund notification deep-links to the booking's refund status (that's how the customer reaches it).
return bookingId != null ? { kind: 'refund', bookingId } : { kind: 'none' };
case 'payout_paid':
case 'payout_failed':
return payoutId != null ? { kind: 'payout', payoutId } : { kind: 'none' };
case 'ticket_message':
case 'ticket_opened':
case 'ticket_closed':
return ticketId != null ? { kind: 'ticket', ticketId } : { kind: 'none' };
case 'review_published':
return nurseProfileId != null ? { kind: 'nurse_profile', nurseProfileId } : { kind: 'none' };
default:
return { kind: 'none' };
}
}
function safeParse(dataJson: string | null | undefined): Record<string, unknown> {
if (!dataJson) return {};
try {
const parsed: unknown = JSON.parse(dataJson);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
/** First key that resolves to a finite number (accepts a numeric string), else `null`. */
function num(obj: Record<string, unknown>, ...keys: string[]): number | null {
for (const key of keys) {
const v = obj[key];
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v);
}
return null;
}
@@ -0,0 +1,56 @@
import type { PageParams, Paginated } from '@/lib/api/types';
/**
* Notifications domain — the app's **pull** mechanism (b1). In-app only (no push at launch), **polled**, with
* a typed `data_json` payload that tells the front-end where to deep-link. 90-day read retention is a server
* concern. Shapes derive from the b1 contract (`config-reference.md` → `NotificationDto`; swagger
* `NotificationDto`/`UnreadCountResult`/`MarkNotificationReadCommand`).
*
* **Load-bearing rules (phase §5):**
* - **`data_json` is a typed contract** — parse it into the discriminated `NotificationData` union and
* deep-link off that; never `eval`/trust an arbitrary blob, and degrade gracefully (no deep-link) for an
* unknown `type` or a missing id.
* - **Poll the count politely** — only `useUnreadCount` polls (stale-while-revalidate); the list is not
* polled on an interval.
* - **Tenancy** — every read is scoped to the signed-in caller server-side; never fetch by a raw id.
*/
/**
* The parsed deep-link target of a notification, discriminated by `kind`. The many notification `type` codes
* collapse to a few route classes here (a booking, a refund view, a payout, a ticket, a nurse profile), or
* `none` when there's nothing to open. Parsed from `data_json` by `parseNotificationData`.
*/
export type NotificationData =
| { kind: 'booking'; bookingId: number }
| { kind: 'refund'; bookingId: number }
| { kind: 'payout'; payoutId: number }
| { kind: 'ticket'; ticketId: number }
| { kind: 'nurse_profile'; nurseProfileId: number }
| { kind: 'none' };
/** A client notification (`NotificationDto` + the parsed `data`). `type`/`title`/`body` are server-rendered. */
export interface AppNotification {
id: number;
/** Open string code (e.g. `booking_confirmed`) — the server owns the copy; we deep-link off `data`. */
type: string;
title: string;
body: string | null;
isRead: boolean;
/** UTC ISO-8601 — Shamsi display is the client's job. */
createdAt: string;
/** Parsed from the wire `dataJson` string into the typed union (never the raw blob). */
data: NotificationData;
}
/**
* The notifications API seam — the real HTTP client and the in-memory mock both implement this; selection is
* by config (`USE_NOTIFICATIONS_MOCK`). Reads are tenant-scoped server-side.
*/
export interface NotificationsApi {
/** Paged, **unread-first then newest-first** (server ordering). */
listNotifications(params: PageParams): Promise<Paginated<AppNotification>>;
/** Cheap index-backed count for the polling bell. */
getUnreadCount(): Promise<number>;
markRead(notificationId: number): Promise<void>;
markAllRead(): Promise<void>;
}
@@ -0,0 +1,153 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import { TICKETS_PAGE_SIZE } from '../constants';
import type {
OpenTicketRequest,
OpenTicketResult,
PostMessageRequest,
PostMessageResult,
TicketAuthorRole,
TicketDetail,
TicketListParams,
TicketMessage,
TicketSummary,
TicketsApi,
} from '../types';
const API = '/api/v1';
/** Wire `TicketSummaryDto` (camelCase, per api-conventions). */
interface TicketSummaryWire {
id: number;
referenceCode: string;
subject: string | null;
status: string;
category: string;
bookingId: number | null;
refundId: number | null;
createdAt: string;
}
/** Wire `TicketMessageDto`. `isInternal` is present on the DTO but is `false` in the user view (server-stripped). */
interface TicketMessageWire {
id: number;
senderId: number;
body: string;
isInternal: boolean;
sentAt: string;
}
/** Wire `TicketThreadDto` (user view). */
interface TicketThreadWire {
id: number;
referenceCode: string;
subject: string | null;
status: string;
category: string;
bookingId: number | null;
refundId: number | null;
openedById: number;
closedAt: string | null;
participants: Array<{ userId: number; roleOnTicket: string | null }>;
messages: TicketMessageWire[];
}
function mapSummary(w: TicketSummaryWire): TicketSummary {
return {
id: w.id,
referenceCode: w.referenceCode,
subject: w.subject,
status: w.status as TicketSummary['status'],
category: w.category as TicketSummary['category'],
bookingId: w.bookingId,
refundId: w.refundId,
createdAt: w.createdAt,
};
}
function mapThread(w: TicketThreadWire, viewerUserId?: number): TicketDetail {
const roleBySender = new Map<number, TicketAuthorRole>(
w.participants.map((p) => [p.userId, (p.roleOnTicket ?? 'system') as TicketAuthorRole]),
);
const messages: TicketMessage[] = w.messages
// Defensive: the user view is server-stripped of internal notes, but never render one if it leaks —
// that is a backend defect to file, not to surface (phase §5, contract "Critical rules").
.filter((m) => !m.isInternal)
.map((m) => ({
id: m.id,
ticketId: w.id,
body: m.body,
authorRole: roleBySender.get(m.senderId) ?? 'system',
createdAt: m.sentAt,
isMine: viewerUserId != null && m.senderId === viewerUserId,
sendStatus: 'sent' as const,
}));
return {
id: w.id,
referenceCode: w.referenceCode,
subject: w.subject,
status: w.status as TicketDetail['status'],
category: w.category as TicketDetail['category'],
bookingId: w.bookingId,
refundId: w.refundId,
openedById: w.openedById,
closedAt: w.closedAt,
participants: w.participants.map((p) => ({
userId: p.userId,
roleOnTicket: (p.roleOnTicket ?? 'system') as TicketAuthorRole,
})),
messages,
};
}
/**
* Real HTTP implementation of the `TicketsApi` seam (b15 contract). All four methods map published routes:
* - `listMyTickets` → `GET /tickets` (own, paginated; `Status`/`ReferenceCode`/`Page`/`PageSize`).
* - `getTicket` → `GET /tickets/{id}` (user view — internal messages already stripped server-side).
* - `openTicket` → `POST /tickets`.
* - `postMessage` → `POST /tickets/{id}/messages` (a non-staff caller never sets `isInternal`).
*
* NOT the primary implementation this phase (`USE_TICKETS_MOCK = true`) — see `constants.ts`. The wire
* summary has no `unreadCount`/`lastMessageAt` (REQ-028), so those stay undefined here (the inbox degrades).
* `clientMessageId` is client-only (optimistic reconcile) — not sent (the server has no field for it yet).
*/
export const ticketsClientApi: TicketsApi = {
listMyTickets: async (params: TicketListParams): Promise<Paginated<TicketSummary>> => {
const query = new URLSearchParams();
if (params.status) query.set('Status', params.status);
query.set('Page', String(params.page ?? 1));
query.set('PageSize', String(params.pageSize ?? TICKETS_PAGE_SIZE));
const wire = unwrap(
await clientFetch<ApiEnvelope<Paginated<TicketSummaryWire>>>(`${API}/tickets?${query.toString()}`),
);
return { ...wire, items: wire.items.map(mapSummary) };
},
getTicket: async (ticketId: number, viewerUserId?: number): Promise<TicketDetail> => {
const wire = unwrap(await clientFetch<ApiEnvelope<TicketThreadWire>>(`${API}/tickets/${ticketId}`));
return mapThread(wire, viewerUserId);
},
// The server infers the opener from auth — `viewerUserId` is only for the mock's attribution.
openTicket: async (body: OpenTicketRequest, _viewerUserId?: number): Promise<OpenTicketResult> =>
unwrap(
await clientFetch<ApiEnvelope<OpenTicketResult>>(`${API}/tickets`, {
method: 'POST',
body: JSON.stringify({
category: body.category,
subject: body.subject ?? null,
body: body.body,
bookingId: body.bookingId ?? null,
refundId: body.refundId ?? null,
}),
}),
),
postMessage: async (ticketId: number, body: PostMessageRequest): Promise<PostMessageResult> =>
unwrap(
await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, {
method: 'POST',
body: JSON.stringify({ body: body.body }),
}),
),
};
+11
View File
@@ -0,0 +1,11 @@
import { USE_TICKETS_MOCK } from '../constants';
import type { TicketsApi } from '../types';
import { ticketsClientApi } from './clientApi';
import { ticketsMockApi } from './mockApi';
/**
* The selected `TicketsApi` implementation — the single seam the hooks import. Selection is by config
* (`USE_TICKETS_MOCK`), never scattered `if (mock)` checks. Mock-primary this phase (linked bookings are
* mock-primary + REQ-028 summary gaps); the swap is this one line.
*/
export const ticketsApi: TicketsApi = USE_TICKETS_MOCK ? ticketsMockApi : ticketsClientApi;
+291
View File
@@ -0,0 +1,291 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type { Paginated } from '@/lib/api/types';
import { MOCK_SEND_FAIL_SENTINEL, MOCK_VIEWER_USER_ID } from '../constants';
import type {
OpenTicketRequest,
OpenTicketResult,
PostMessageRequest,
PostMessageResult,
TicketAuthorRole,
TicketCategory,
TicketDetail,
TicketListParams,
TicketMessage,
TicketParticipant,
TicketStatus,
TicketSummary,
TicketsApi,
} from '../types';
/**
* In-memory `TicketsApi` — **the primary implementation this phase** (`USE_TICKETS_MOCK = true`; see
* `constants.ts` for why: the linked bookings are themselves mock-primary, and the wire summary lacks
* `unreadCount`/`lastMessageAt`, REQ-028).
*
* It is engineered to demonstrate the whole ticket surface end-to-end:
* - **No internal-note leak.** A ticket stores an admin **internal** note that the user view (`getTicket`)
* **drops** — mimicking the server's `is_internal` stripping — so the phase §7 step-3 test (the user
* thread shows none of it, no styling, no affordance) is demonstrable against the mock.
* - **Booking-linked coordination.** A `coordination` ticket is seeded for booking 5001 (the f8 confirmed
* seed); `openTicket` is idempotent for `coordination + bookingId` so "Get support" from that booking
* **jumps to the existing thread** instead of spawning duplicates.
* - **Optimistic send.** `postMessage` appends as the current viewer (tracked from the last `getTicket`);
* posting the dev sentinel `MOCK_SEND_FAIL_SENTINEL` throws a `500` so the failure→retry, draft-preserved
* path is exercisable; posting to a **closed** ticket is a `403` (contract).
* - **Unread indicator.** Each ticket tracks an `unread` count the inbox renders; opening the thread
* (`getTicket`) clears it — mirroring the notification "mark read on open" feel.
*/
const MOCK_LATENCY_MS = 250;
/** A stored message. `internal` messages exist in the store but are **never** returned by the user view. */
interface StoredMessage {
id: number;
senderId: number;
body: string;
internal: boolean;
sentAt: string;
}
interface StoredTicket {
id: number;
referenceCode: string;
subject: string | null;
status: TicketStatus;
category: TicketCategory;
bookingId: number | null;
refundId: number | null;
openedById: number;
closedAt: string | null;
participants: TicketParticipant[];
messages: StoredMessage[];
/** Unread-for-the-viewer count the inbox renders; cleared when the thread is opened. */
unread: number;
}
const CUSTOMER = MOCK_VIEWER_USER_ID.customer;
const NURSE = MOCK_VIEWER_USER_ID.nurse;
const ADMIN = MOCK_VIEWER_USER_ID.admin;
const CUSTOMER_PARTICIPANT: TicketParticipant = { userId: CUSTOMER, roleOnTicket: 'customer' };
const NURSE_PARTICIPANT: TicketParticipant = { userId: NURSE, roleOnTicket: 'nurse' };
const ADMIN_PARTICIPANT: TicketParticipant = { userId: ADMIN, roleOnTicket: 'admin' };
/** The participant record for a viewer id (so an opener is attributed to the right actor). */
function participantFor(userId: number): TicketParticipant {
if (userId === NURSE) return NURSE_PARTICIPANT;
if (userId === ADMIN) return ADMIN_PARTICIPANT;
return CUSTOMER_PARTICIPANT;
}
/** ISO instant `mins` in the past — seeded message timestamps (rendered Shamsi client-side). */
function isoMinsAgo(mins: number): string {
return new Date(Date.now() - mins * 60_000).toISOString();
}
let nextTicketId = 1301;
let nextMessageId = 50_000;
/**
* The viewer of the most recent `getTicket` — so `postMessage` (which the seam gives no viewer) appends the
* message as the right sender, making it render as "mine" on the next fetch in whichever app is open.
*/
let lastViewerUserId = CUSTOMER;
const tickets: StoredTicket[] = [
{
id: 1201,
referenceCode: 'TKT-9F3K2A7Q',
subject: 'هماهنگی ویزیت',
status: 'open',
category: 'coordination',
bookingId: 5001,
refundId: null,
openedById: ADMIN,
closedAt: null,
participants: [CUSTOMER_PARTICIPANT, NURSE_PARTICIPANT, ADMIN_PARTICIPANT],
unread: 1,
messages: [
{ id: 40_001, senderId: ADMIN, body: 'این گفتگو برای هماهنگی ویزیت شما ایجاد شد. در صورت نیاز اینجا پیام بگذارید.', internal: false, sentAt: isoMinsAgo(600) },
{ id: 40_002, senderId: CUSTOMER, body: 'سلام، لطفاً ساعت ویزیت را به عصر منتقل کنید.', internal: false, sentAt: isoMinsAgo(540) },
// Internal admin note — stored, but the user view NEVER returns it (server-strip mimic; §5 no-leak).
{ id: 40_003, senderId: ADMIN, body: 'INTERNAL: customer requested reschedule, confirm nurse availability before replying.', internal: true, sentAt: isoMinsAgo(520) },
{ id: 40_004, senderId: NURSE, body: 'سلام، بله امکان‌پذیر است. ساعت ۵ عصر هماهنگ شد.', internal: false, sentAt: isoMinsAgo(120) },
],
},
{
id: 1202,
referenceCode: 'TKT-4B7X1M2P',
subject: 'سوال درباره سرویس',
status: 'open',
category: 'support',
bookingId: null,
refundId: null,
openedById: CUSTOMER,
closedAt: null,
participants: [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT],
unread: 0,
messages: [
{ id: 40_010, senderId: CUSTOMER, body: 'آیا امکان انتخاب پرستار خانم برای ویزیت بعدی هست؟', internal: false, sentAt: isoMinsAgo(2_880) },
{ id: 40_011, senderId: ADMIN, body: 'بله، هنگام جست‌وجو می‌توانید جنسیت مراقب را انتخاب کنید.', internal: false, sentAt: isoMinsAgo(2_820) },
],
},
{
id: 1203,
referenceCode: 'TKT-7C2D9E1F',
subject: 'پیگیری بازپرداخت',
status: 'closed',
category: 'refund',
bookingId: 5004,
refundId: 9001,
openedById: CUSTOMER,
closedAt: isoMinsAgo(4_000),
participants: [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT],
unread: 0,
messages: [
{ id: 40_020, senderId: CUSTOMER, body: 'بازپرداخت من چه زمانی انجام می‌شود؟', internal: false, sentAt: isoMinsAgo(5_760) },
{ id: 40_021, senderId: ADMIN, body: 'بازپرداخت شما ثبت و به کارت شما واریز شد. این گفتگو بسته می‌شود.', internal: false, sentAt: isoMinsAgo(4_010) },
],
},
];
function findTicket(id: number): StoredTicket {
const t = tickets.find((x) => x.id === id);
if (!t) throw new ApiError(404, 'Ticket not found', 'ticket_not_found');
return t;
}
/** Last non-internal message time — the inbox's "last activity" (REQ-028 stand-in for `lastMessageAt`). */
function lastMessageAt(t: StoredTicket): string {
const visible = t.messages.filter((m) => !m.internal);
return visible.length ? visible[visible.length - 1].sentAt : t.messages[0]?.sentAt ?? new Date().toISOString();
}
function toSummary(t: StoredTicket): TicketSummary {
return {
id: t.id,
referenceCode: t.referenceCode,
subject: t.subject,
status: t.status,
category: t.category,
bookingId: t.bookingId,
refundId: t.refundId,
createdAt: t.messages[0]?.sentAt ?? new Date().toISOString(),
lastMessageAt: lastMessageAt(t),
unreadCount: t.unread,
};
}
function toDetail(t: StoredTicket, viewerUserId: number): TicketDetail {
const roleBySender = new Map<number, TicketAuthorRole>(t.participants.map((p) => [p.userId, p.roleOnTicket]));
const messages: TicketMessage[] = t.messages
// The user view NEVER contains an internal message (server-strip mimic; phase §5).
.filter((m) => !m.internal)
.map((m) => ({
id: m.id,
ticketId: t.id,
body: m.body,
authorRole: roleBySender.get(m.senderId) ?? 'system',
createdAt: m.sentAt,
isMine: m.senderId === viewerUserId,
sendStatus: 'sent' as const,
}));
return {
id: t.id,
referenceCode: t.referenceCode,
subject: t.subject,
status: t.status,
category: t.category,
bookingId: t.bookingId,
refundId: t.refundId,
openedById: t.openedById,
closedAt: t.closedAt,
participants: t.participants,
messages,
};
}
/** `TKT-XXXXXXXX` — a stable, unique-looking reference (base36 of the id, not a real random code). */
function makeReferenceCode(id: number): string {
return `TKT-${(id * 2_654_435_761 % 0xffffffff).toString(36).toUpperCase().padStart(8, '0').slice(-8)}`;
}
export const ticketsMockApi: TicketsApi = {
listMyTickets: async (params: TicketListParams): Promise<Paginated<TicketSummary>> => {
await sleep(MOCK_LATENCY_MS);
// Own-scoping is server-enforced; the mock returns every seeded ticket (a demo world), newest-activity
// first, optionally narrowed by status.
let all = [...tickets];
if (params.status) all = all.filter((t) => t.status === params.status);
all.sort((a, b) => Date.parse(lastMessageAt(b)) - Date.parse(lastMessageAt(a)));
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.max(1, params.pageSize ?? all.length);
const start = (page - 1) * pageSize;
return {
items: all.slice(start, start + pageSize).map(toSummary),
total: all.length,
page,
pageSize,
};
},
getTicket: async (ticketId: number, viewerUserId?: number): Promise<TicketDetail> => {
await sleep(MOCK_LATENCY_MS);
const t = findTicket(ticketId);
lastViewerUserId = viewerUserId ?? CUSTOMER;
t.unread = 0; // opening the thread clears its unread indicator
return toDetail(t, lastViewerUserId);
},
openTicket: async (body: OpenTicketRequest, viewerUserId?: number): Promise<OpenTicketResult> => {
await sleep(MOCK_LATENCY_MS);
// Idempotent coordination: "Get support" from a booking jumps to its existing coordination thread.
if (body.category === 'coordination' && body.bookingId != null) {
const existing = tickets.find((t) => t.category === 'coordination' && t.bookingId === body.bookingId);
if (existing) {
return { ticketId: existing.id, referenceCode: existing.referenceCode, status: existing.status, category: existing.category };
}
}
// The opener is the caller (nurse or customer) — attribute the opening message + participant to them so
// it renders as "mine" in whichever app opened it. Falls back to the last-viewed thread's viewer.
const opener = viewerUserId ?? lastViewerUserId;
const id = nextTicketId++;
const participants: TicketParticipant[] = [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT];
if (body.bookingId != null && !participants.some((p) => p.userId === NURSE)) participants.push(NURSE_PARTICIPANT);
const openerParticipant = participantFor(opener);
if (!participants.some((p) => p.userId === openerParticipant.userId)) participants.push(openerParticipant);
const now = new Date().toISOString();
const ticket: StoredTicket = {
id,
referenceCode: makeReferenceCode(id),
subject: body.subject?.trim() || null,
status: 'open',
category: body.category,
bookingId: body.bookingId ?? null,
refundId: body.refundId ?? null,
openedById: opener,
closedAt: null,
participants,
unread: 0,
messages: [{ id: nextMessageId++, senderId: opener, body: body.body, internal: false, sentAt: now }],
};
tickets.unshift(ticket); // new ticket lands at the top of the inbox (phase §7 step 1)
return { ticketId: id, referenceCode: ticket.referenceCode, status: 'open', category: ticket.category };
},
postMessage: async (ticketId: number, body: PostMessageRequest): Promise<PostMessageResult> => {
await sleep(MOCK_LATENCY_MS);
const t = findTicket(ticketId);
// Dev-only failure trigger for the optimistic-send rollback/retry test (phase §7 step 2).
if (body.body.trim() === MOCK_SEND_FAIL_SENTINEL) {
throw new ApiError(500, 'Simulated send failure', 'mock_send_failed');
}
// Contract: a non-staff caller posting to a closed ticket → 403.
if (t.status === 'closed') throw new ApiError(403, 'Ticket is closed', 'ticket_closed');
const id = nextMessageId++;
const sentAt = new Date().toISOString();
t.messages.push({ id, senderId: lastViewerUserId, body: body.body, internal: false, sentAt });
return { messageId: id, ticketId, sentAt };
},
};
+47
View File
@@ -0,0 +1,47 @@
/**
* When true, the tickets domain is served by the in-memory mock (`apis/mockApi.ts`) behind the `TicketsApi`
* seam.
*
* **Mock is primary this phase.** b15 serves the ticket endpoints (open / list / thread / message), and the
* real `clientApi.ts` maps them 1:1 — but the ticket screens depend on **inputs that are themselves
* mock-primary** (the f8 bookings a coordination ticket links to only exist client-side under the bookings
* mock), and the wire summary lacks `unreadCount`/`lastMessageAt` (**REQ-028**). So the mock is the primary
* source: it seeds realistic tickets/threads **with no internal messages** (mimicking the server's user-view
* `is_internal` stripping — it even stores an internal admin note that the user view drops, so the
* no-leak test is demonstrable), supports the optimistic append, and makes a booking-linked coordination
* ticket reachable. Flip to `false` once the upstreams are real — no hook/component change (only the seam
* selection in `apis/index.ts`).
*/
export const USE_TICKETS_MOCK = true;
/** Inbox page size (api-conventions `pageSize`, default 50 / max 100). */
export const TICKETS_PAGE_SIZE = 20;
/**
* The inbox list moves at human speed — a moderate `staleTime` avoids refetching on every revisit but a
* mutation (open ticket / post message) invalidates it so a new ticket / new activity shows without a manual
* refresh. The **thread is short-lived** (an active conversation) so it revalidates more eagerly.
*/
export const TICKETS_LIST_STALE_TIME = 30 * 1000;
export const TICKET_THREAD_STALE_TIME = 15 * 1000;
export const TICKETS_GC_TIME = 5 * 60 * 1000;
/**
* DEV-ONLY trigger for the optimistic-send **failure** path (phase §7 step 2): posting this exact message
* body makes the mock throw a `500` so a human can watch the bubble roll back, the draft stay in the
* composer, and the retry succeed. Never a product string.
*/
export const MOCK_SEND_FAIL_SENTINEL = '/fail';
/**
* Stable per-role "me" ids for the **mock** world (the real path uses the authenticated `/me` id). The mock
* seeds the customer's / nurse's / support's messages with these sender ids; `useTicket` passes
* `currentUser?.id ?? MOCK_VIEWER_USER_ID[actorRole]` so a message renders as **mine** in whichever app is
* viewing (customer app → the customer's bubbles are mine; nurse app → the nurse's are). The fallback only
* fires under mock auth (no `/me` id); on the real path the authenticated id wins and this is dead weight.
*/
export const MOCK_VIEWER_USER_ID: Record<'customer' | 'nurse' | 'admin', number> = {
customer: 7001,
nurse: 7015,
admin: 7003,
};
@@ -0,0 +1,22 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
import { TICKETS_GC_TIME, TICKETS_LIST_STALE_TIME, TICKETS_PAGE_SIZE } from '../constants';
import type { TicketListParams } from '../types';
/**
* The "My Tickets" inbox — the caller's own tickets, newest-activity first, optionally narrowed by status.
* The **filter object keys the cache**, so paging/filtering caches independently and revisiting the inbox
* serves from cache; `keepPreviousData` avoids a flash on a status change. Opening a ticket or posting a
* message invalidates `tickets.lists()`, so new activity shows without a manual refresh.
*/
export function useMyTickets(params: TicketListParams = {}) {
const listParams: TicketListParams = { page: params.page ?? 1, pageSize: params.pageSize ?? TICKETS_PAGE_SIZE, status: params.status };
return useQuery({
queryKey: ticketKeys.list(listParams),
queryFn: () => ticketsApi.listMyTickets(listParams),
placeholderData: keepPreviousData,
staleTime: TICKETS_LIST_STALE_TIME,
gcTime: TICKETS_GC_TIME,
});
}
@@ -0,0 +1,23 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
import type { OpenTicketRequest, OpenTicketResult } from '../types';
import { useTicketViewer } from './useTicketViewer';
/**
* Open a ticket (support / coordination from a booking / …). We pass the viewer id so the mock attributes the
* opening message to the right actor (a nurse-opened ticket's first message renders as the nurse's, not the
* customer's). On success we invalidate the inbox lists so the new ticket appears at the top without a manual
* refresh (phase §7 step 1). Domain 4xx (e.g. a `403` on a disallowed booking link) surface to the caller's
* `onError`; the dialog keeps the draft.
*/
export function useOpenTicket() {
const queryClient = useQueryClient();
const { userId } = useTicketViewer();
return useMutation<OpenTicketResult, unknown, OpenTicketRequest>({
mutationFn: (body) => ticketsApi.openTicket(body, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
});
}
@@ -0,0 +1,78 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketKeys } from '../keys';
import { ticketsApi } from '../apis';
import type { PostMessageResult, TicketDetail, TicketMessage } from '../types';
import { useTicketViewer } from './useTicketViewer';
interface PostMessageVars {
body: string;
/** Client-generated id; the reconcile key so the optimistic bubble is never double-rendered (§3.5). */
clientMessageId: string;
}
interface PostMessageContext {
previous?: TicketDetail;
}
/**
* The optimistic message send — the interaction that must feel instant (phase §3.5).
*
* `onMutate` appends a **pending** bubble to `detail(id)` (after `cancelQueries` + a snapshot) so it shows
* immediately with a "sending" state. `onError` **rolls the thread back to the snapshot** (removing the
* pending bubble) and rejects — the composer keeps the typed draft and offers retry (never retype). `onSuccess`
* replaces the pending bubble **by `clientMessageId`** with the server message (so it never double-renders).
* `onSettled` invalidates the thread + the inbox lists (last-activity/unread move). The composer clears the
* draft **only** in its own `onSuccess`.
*/
export function usePostMessage(ticketId: number) {
const queryClient = useQueryClient();
const { role } = useTicketViewer();
return useMutation<PostMessageResult, unknown, PostMessageVars, PostMessageContext>({
mutationFn: ({ body, clientMessageId }) => ticketsApi.postMessage(ticketId, { body, clientMessageId }),
onMutate: async ({ body, clientMessageId }) => {
const key = ticketKeys.detail(ticketId);
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<TicketDetail>(key);
if (previous) {
const pending: TicketMessage = {
id: null,
clientMessageId,
ticketId,
body,
authorRole: role,
createdAt: new Date().toISOString(),
isMine: true,
sendStatus: 'sending',
};
queryClient.setQueryData<TicketDetail>(key, { ...previous, messages: [...previous.messages, pending] });
}
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) queryClient.setQueryData(ticketKeys.detail(ticketId), context.previous);
},
onSuccess: (result, { clientMessageId }) => {
const key = ticketKeys.detail(ticketId);
const current = queryClient.getQueryData<TicketDetail>(key);
if (current) {
queryClient.setQueryData<TicketDetail>(key, {
...current,
messages: current.messages.map((m) =>
m.clientMessageId === clientMessageId
? { ...m, id: result.messageId, createdAt: result.sentAt, sendStatus: 'sent' }
: m,
),
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ticketKeys.detail(ticketId) });
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
});
}
@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
import { TICKETS_GC_TIME, TICKET_THREAD_STALE_TIME } from '../constants';
import { useTicketViewer } from './useTicketViewer';
/**
* The full ticket thread (header + participants + messages, user view — internal messages already stripped).
* A single cached `detail(id)` entry: the contract returns the whole thread in one call (no message
* pagination). The viewer id (from `/me`, or the mock fallback) drives which bubbles are "mine".
* `usePostMessage` mutates this same entry optimistically.
*/
export function useTicket(ticketId: number | undefined) {
const { userId } = useTicketViewer();
return useQuery({
queryKey: ticketKeys.detail(ticketId ?? -1),
queryFn: () => ticketsApi.getTicket(ticketId as number, userId),
enabled: ticketId != null && ticketId > 0,
staleTime: TICKET_THREAD_STALE_TIME,
gcTime: TICKETS_GC_TIME,
});
}
@@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
import { TICKETS_GC_TIME, TICKET_THREAD_STALE_TIME } from '../constants';
import type { TicketMessage } from '../types';
import { useTicketViewer } from './useTicketViewer';
/**
* Just the messages of a thread — a `select` over the same `detail(id)` cache the header reads (mirrors the
* f8 `useBookingSessions = select over detail`). One network fetch feeds both; the message list re-renders on
* a new message without re-rendering the thread header. Optimistic sends mutate `detail(id)`, so the list
* updates instantly.
*/
export function useTicketThread(ticketId: number | undefined) {
const { userId } = useTicketViewer();
return useQuery({
queryKey: ticketKeys.detail(ticketId ?? -1),
queryFn: () => ticketsApi.getTicket(ticketId as number, userId),
enabled: ticketId != null && ticketId > 0,
staleTime: TICKET_THREAD_STALE_TIME,
gcTime: TICKETS_GC_TIME,
select: (detail): TicketMessage[] => detail.messages,
});
}
@@ -0,0 +1,18 @@
import { useAuth } from '@/context/auth';
import { useActorRole } from '@/hooks';
import { MOCK_VIEWER_USER_ID } from '../constants';
import type { TicketAuthorRole } from '../types';
/**
* The current viewer's `{ userId, role }` for the tickets domain. `userId` drives `isMine` (whose bubble is
* whose); `role` labels an optimistic bubble's author. On the real path the authenticated `/me` id wins;
* under mock auth (no id yet) it falls back to the per-role mock "me" so bubbles still mirror correctly in
* whichever app is open (customer vs nurse). Not exported from the barrel — an internal helper the domain
* hooks share.
*/
export function useTicketViewer(): { userId: number; role: TicketAuthorRole } {
const [auth] = useAuth();
const role = useActorRole();
const userId = auth.currentUser?.id ?? MOCK_VIEWER_USER_ID[role] ?? MOCK_VIEWER_USER_ID.customer;
return { userId, role };
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Tickets domain barrel — re-exports **hooks only** (per the `services/{domain}` convention). Import
* types/keys/apis directly from their files when needed.
*/
export { useMyTickets } from './hooks/useMyTickets';
export { useTicket } from './hooks/useTicket';
export { useTicketThread } from './hooks/useTicketThread';
export { useOpenTicket } from './hooks/useOpenTicket';
export { usePostMessage } from './hooks/usePostMessage';
+21
View File
@@ -0,0 +1,21 @@
import type { TicketListParams } from './types';
/**
* React Query key factory for the tickets domain (hierarchical, per the `services/{domain}` pattern).
*
* The **filter object keys the list** so paging/filtering caches independently. There is a single
* **`detail(id)`** for a ticket: the b15 contract returns the whole thread (header + participants +
* messages) in one `GET /tickets/{id}` call — there is **no server pagination of messages** — so the
* thread is not a separate cache entry; `useTicketThread` is a `select` over `detail(id)` (mirroring the f8
* `useBookingSessions = select over detail` precedent). `usePostMessage` optimistically mutates `detail(id)`
* and invalidates `lists()`/`detail(id)` on settle.
*/
export const ticketKeys = {
all: ['tickets'] as const,
lists: () => [...ticketKeys.all, 'list'] as const,
list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const,
details: () => [...ticketKeys.all, 'detail'] as const,
detail: (ticketId: number) => [...ticketKeys.details(), ticketId] as const,
};
+160
View File
@@ -0,0 +1,160 @@
import type { PageParams, Paginated } from '@/lib/api/types';
/**
* Tickets domain — the **only sanctioned post-booking channel** (b15). There is no live chat and no direct
* nurse↔customer messaging by design: every conversation is a ticket that admin can read in full
* (anti-disintermediation + patient safety — see `product/business/12-messaging-and-emergencies.md`). A
* booking-coordination ticket is auto-created on confirmation; users also open support/refund tickets.
*
* Shapes derive from the b15 contract (`dev/contracts/domains/messaging-notifications-admin.md` +
* `dev/contracts/openapi/swagger.v1.json` → `TicketSummaryDto`/`TicketThreadDto`/…), mapped to a client
* model that carries the display state the wire omits.
*
* **Load-bearing rules (contract §"Critical rules" + phase §5):**
* - **`is_internal` NEVER reaches the user app.** The server strips internal admin messages from the user
* view (`GET /tickets/{id}`). We do **not** model an `isInternal` field here, never render internal
* styling, and never expose an internal-note affordance — and the real client mapper drops any message
* that arrives flagged internal (a defensive guard; such a leak is a backend defect to file, not render).
* - **`referenceCode` is stable + unique** (`"TKT-9F3K2A7Q"`) — quoted to support, shown prominently.
* - **Ticket↔booking/refund links are optional** — `bookingId`/`refundId` are both nullable.
* - **No phone numbers, ever.** The only sanctioned out-of-band surface is the post-confirmation
* emergency `tel:` (from the f8 care-instructions read), never a contact directory.
*
* Enums cross the wire as stable string codes — mirrored here as string-literal unions.
*/
/** `ticket.status` (contract enum). */
export type TicketStatus = 'open' | 'closed';
/** `ticket.category` (contract enum). A booking-coordination ticket is auto-created on confirmation. */
export type TicketCategory = 'coordination' | 'support' | 'refund' | 'emergency';
/**
* The display role of a message author (from `ticket_participants.role_on_ticket`). `admin` renders as
* "support" in the user app — it is a **display label, never an auth source**. `system` is the fallback
* when a sender isn't in the participant list.
*/
export type TicketAuthorRole = 'customer' | 'nurse' | 'admin' | 'system';
/** Per-message optimistic send state — `sent` for any server-confirmed message. */
export type MessageSendStatus = 'sent' | 'sending' | 'failed';
/**
* A ticket row for the "My Tickets" inbox (`TicketSummaryDto`). `lastMessageAt`/`unreadCount` are **not**
* on the wire summary (REQ-028) — they are optional and only the mock supplies them today; the inbox
* renders the unread indicator / last-activity time only when present, else falls back to `createdAt`.
*/
export interface TicketSummary {
id: number;
referenceCode: string;
subject: string | null;
status: TicketStatus;
category: TicketCategory;
bookingId: number | null;
refundId: number | null;
createdAt: string;
/** REQ-028 gap — the wire summary has no last-activity timestamp; mock-only until delivered. */
lastMessageAt?: string | null;
/** REQ-028 gap — the wire summary has no unread count; mock-only until delivered. */
unreadCount?: number;
}
/** A participant on a ticket (`TicketParticipantDto`) — used to derive a message's author role. */
export interface TicketParticipant {
userId: number;
roleOnTicket: TicketAuthorRole;
}
/**
* A single message in a thread (client model). The wire `TicketMessageDto` carries only `senderId` (no
* author name, REQ-028) — we derive `authorRole` from the participant list and `isMine` from the viewer,
* and never show a raw name (privacy: the platform never turns a thread into a contact directory).
*/
export interface TicketMessage {
/** Server message id; `null` while an optimistic message is still pending. */
id: number | null;
/** Client-generated id for an optimistic message; the reconcile key so we never double-render (§3.5). */
clientMessageId?: string;
ticketId: number;
body: string;
authorRole: TicketAuthorRole;
/** UTC ISO-8601 — Shamsi display is the client's job. */
createdAt: string;
isMine: boolean;
sendStatus: MessageSendStatus;
}
/**
* The full thread (`TicketThreadDto`, user view). The contract returns the whole `messages[]` in one call
* (no server pagination), so this is the single cached detail; `useTicketThread` is a `select` over it.
*/
export interface TicketDetail {
id: number;
referenceCode: string;
subject: string | null;
status: TicketStatus;
category: TicketCategory;
bookingId: number | null;
refundId: number | null;
openedById: number;
closedAt: string | null;
participants: TicketParticipant[];
messages: TicketMessage[];
}
/** List filters for `GET /tickets` (own, paginated). `status` optionally narrows the inbox. */
export interface TicketListParams extends PageParams {
status?: TicketStatus;
}
/** `OpenTicketCommand`. `bookingId`/`refundId` optional; a booking link requires the caller be a party. */
export interface OpenTicketRequest {
category: TicketCategory;
subject?: string | null;
body: string;
bookingId?: number | null;
refundId?: number | null;
}
/** `OpenTicketResult` — the new ticket's stable `referenceCode` (shown on the confirmation). */
export interface OpenTicketResult {
ticketId: number;
referenceCode: string;
status: TicketStatus;
category: TicketCategory;
}
/**
* `PostMessageCommand` body. `clientMessageId` is client-generated for optimistic reconciliation; the
* server has no field for it today (REQ-028 — an idempotency key), so the real client does not send it — it
* lives only in the cache to reconcile the pending bubble by identity.
*/
export interface PostMessageRequest {
body: string;
clientMessageId: string;
}
/** `PostMessageResult`. */
export interface PostMessageResult {
messageId: number;
ticketId: number;
sentAt: string;
}
/**
* The tickets API seam — the real HTTP client and the in-memory mock both implement this; selection is by
* config (`USE_TICKETS_MOCK`), never scattered `if (mock)` checks. `getTicket` takes the viewer's user id
* so the real mapper can compute `isMine`; the mock is a self-contained world (its own "me").
*/
export interface TicketsApi {
listMyTickets(params: TicketListParams): Promise<Paginated<TicketSummary>>;
/** The full thread (user view — internal messages stripped). `viewerUserId` drives `isMine`. */
getTicket(ticketId: number, viewerUserId?: number): Promise<TicketDetail>;
/**
* Open a ticket. `viewerUserId` is the opener's id — the real server infers the sender from auth, but the
* mock needs it to attribute the opening message to the right actor (customer vs nurse) and add them as a
* participant, so an optimistic/rendered opener bubble is correctly "mine".
*/
openTicket(body: OpenTicketRequest, viewerUserId?: number): Promise<OpenTicketResult>;
postMessage(ticketId: number, body: PostMessageRequest): Promise<PostMessageResult>;
}