frontend phase 14
This commit is contained in:
@@ -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';
|
||||
@@ -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 }),
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user