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( 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> => { 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>>(`${API}/tickets?${query.toString()}`), ); return { ...wire, items: wire.items.map(mapSummary) }; }, getTicket: async (ticketId: number, viewerUserId?: number): Promise => { const wire = unwrap(await clientFetch>(`${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 => unwrap( await clientFetch>(`${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 => unwrap( await clientFetch>(`${API}/tickets/${ticketId}/messages`, { method: 'POST', body: JSON.stringify({ body: body.body }), }), ), };