266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
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<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,
|
|
};
|
|
}
|
|
|
|
/** 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<number, TicketAuthorRole>(
|
|
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<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',
|
|
// 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<number | null> => null,
|
|
|
|
// ── Admin lens (b15). Global queue + admin thread (internal INCLUDED) + staff message post. ──
|
|
listAdminTickets: async (
|
|
filters: AdminTicketFilters,
|
|
params: PageParams,
|
|
): Promise<Paginated<AdminTicketSummary>> => {
|
|
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<ApiEnvelope<Paginated<TicketSummaryWire>>>(
|
|
`${API}/admin/tickets?${query.toString()}`,
|
|
),
|
|
);
|
|
return { ...wire, items: wire.items.map(mapAdminSummary) };
|
|
},
|
|
|
|
getAdminTicket: async (ticketId: number, viewerUserId?: number): Promise<AdminTicketDetail> => {
|
|
// The admin view keeps internal notes — do NOT filter them here; that is the point of this endpoint.
|
|
const wire = unwrap(await clientFetch<ApiEnvelope<TicketThreadWire>>(`${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<PostMessageResult> =>
|
|
unwrap(
|
|
await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
body: body.body,
|
|
isInternal: body.isInternal,
|
|
clientMessageId: body.clientMessageId,
|
|
}),
|
|
}),
|
|
),
|
|
|
|
};
|