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 }),
|
||||
}),
|
||||
),
|
||||
};
|
||||
Reference in New Issue
Block a user