frontend phase 14
This commit is contained in:
@@ -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