import { clientFetch } from '@/lib/api/client'; import { unwrap, type ApiEnvelope, type PageParams, type Paginated } from '@/lib/api/types'; import { TICKETS_PAGE_SIZE } from '../constants'; import type { AdminTicketDetail, AdminTicketFilters, AdminTicketMessage, AdminTicketSummary, OpenTicketRequest, OpenTicketResult, PostAdminMessageRequest, PostMessageRequest, PostMessageResult, TicketAuthorRole, TicketDetail, TicketListParams, TicketMessage, TicketSummary, TicketsApi, } from '../types'; const API = '/api/v1'; /** Wire `TicketSummaryDto` (camelCase). REQ-028 (delivered) added `lastMessageAt`/`unreadCount`. */ interface TicketSummaryWire { id: number; referenceCode: string; subject: string | null; status: string; category: string; bookingId: number | null; refundId: number | null; createdAt: string; lastMessageAt: string | null; unreadCount: number; // REQ-059 gap (extends REQ-028) — not yet on the wire; mapped as absent below until delivered. } /** 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, // REQ-028 (delivered): the inbox unread badge + last-activity sort now come off the wire. lastMessageAt: w.lastMessageAt, unreadCount: w.unreadCount, // REQ-059 gap — the wire summary has neither field yet; the card degrades gracefully (§3.1). lastMessagePreview: null, lastAuthorRole: null, }; } 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, }; } /** Admin summary shares the wire shape of the user summary (no `unread`/`lastMessageAt`). */ function mapAdminSummary(w: TicketSummaryWire): AdminTicketSummary { return { id: w.id, referenceCode: w.referenceCode, subject: w.subject, status: w.status as AdminTicketSummary['status'], category: w.category as AdminTicketSummary['category'], bookingId: w.bookingId, refundId: w.refundId, createdAt: w.createdAt, }; } /** * Admin thread mapper. Unlike `mapThread`, it **keeps internal messages** and carries `isInternal` through — * this IS the admin view, whose whole purpose is that staff see internal notes (contract §"Critical rules"). */ function mapAdminThread(w: TicketThreadWire, viewerUserId?: number): AdminTicketDetail { const roleBySender = new Map( w.participants.map((p) => [p.userId, (p.roleOnTicket ?? 'system') as TicketAuthorRole]), ); const messages: AdminTicketMessage[] = w.messages.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, isInternal: m.isInternal, sendStatus: 'sent' as const, })); return { id: w.id, referenceCode: w.referenceCode, subject: w.subject, status: w.status as AdminTicketDetail['status'], category: w.category as AdminTicketDetail['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`). * * PRIMARY once `USE_TICKETS_MOCK = false` (refinement-phase-4; REQ-028 delivered): the summary now carries * `unreadCount`/`lastMessageAt` (inbox badge + last-activity sort) and the message post sends the optimistic * `clientMessageId` (server dedupes + echoes it back). The user list still filters only by `Status`; the * "jump to the existing coordination ticket" by-booking lookup is a minor follow-up (REQ-028 #3 — * `GET /tickets?BookingId=` is served, but no client method targets 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', // REQ-028 (delivered): send the optimistic `clientMessageId` so the server dedupes a retried send // and echoes it back on `PostMessageResult` for reconciliation. body: JSON.stringify({ body: body.body, clientMessageId: body.clientMessageId }), }), ), // No wire aggregate yet (REQ-059) — the chrome badge (§3.1) renders only when a signal exists. getUnreadTotal: async (): Promise => null, // ── Admin lens (b15). Global queue + admin thread (internal INCLUDED) + staff message post. ── listAdminTickets: async ( filters: AdminTicketFilters, params: PageParams, ): Promise> => { const query = new URLSearchParams(); if (filters.status) query.set('Status', filters.status); if (filters.category) query.set('Category', filters.category); if (filters.referenceCode) query.set('ReferenceCode', filters.referenceCode); if (filters.bookingId != null) query.set('BookingId', String(filters.bookingId)); if (filters.refundId != null) query.set('RefundId', String(filters.refundId)); query.set('Page', String(params.page ?? 1)); query.set('PageSize', String(params.pageSize ?? TICKETS_PAGE_SIZE)); const wire = unwrap( await clientFetch>>( `${API}/admin/tickets?${query.toString()}`, ), ); return { ...wire, items: wire.items.map(mapAdminSummary) }; }, getAdminTicket: async (ticketId: number, viewerUserId?: number): Promise => { // The admin view keeps internal notes — do NOT filter them here; that is the point of this endpoint. const wire = unwrap(await clientFetch>(`${API}/admin/tickets/${ticketId}`)); return mapAdminThread(wire, viewerUserId); }, // Staff post — may set `isInternal` (the one caller allowed to). REQ-028: send `clientMessageId` too. postAdminMessage: async (ticketId: number, body: PostAdminMessageRequest): Promise => unwrap( await clientFetch>(`${API}/tickets/${ticketId}/messages`, { method: 'POST', body: JSON.stringify({ body: body.body, isInternal: body.isInternal, clientMessageId: body.clientMessageId, }), }), ), };