frontend phase 15
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
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,
|
||||
@@ -100,6 +105,56 @@ function mapThread(w: TicketThreadWire, viewerUserId?: number): TicketDetail {
|
||||
};
|
||||
}
|
||||
|
||||
/** 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`).
|
||||
@@ -150,4 +205,40 @@ export const ticketsClientApi: TicketsApi = {
|
||||
body: JSON.stringify({ body: body.body }),
|
||||
}),
|
||||
),
|
||||
|
||||
// ── 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). `clientMessageId` stays client-only.
|
||||
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 }),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import type { Paginated } from '@/lib/api/types';
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
import { MOCK_SEND_FAIL_SENTINEL, MOCK_VIEWER_USER_ID } from '../constants';
|
||||
import type {
|
||||
AdminTicketDetail,
|
||||
AdminTicketFilters,
|
||||
AdminTicketMessage,
|
||||
AdminTicketSummary,
|
||||
OpenTicketRequest,
|
||||
OpenTicketResult,
|
||||
PostAdminMessageRequest,
|
||||
PostMessageRequest,
|
||||
PostMessageResult,
|
||||
TicketAuthorRole,
|
||||
@@ -93,6 +98,12 @@ let nextMessageId = 50_000;
|
||||
*/
|
||||
let lastViewerUserId = CUSTOMER;
|
||||
|
||||
/**
|
||||
* The viewer of the most recent `getAdminTicket` — tracked SEPARATELY from `lastViewerUserId` so an admin
|
||||
* reading a thread never re-attributes a user's `postMessage`. `postAdminMessage` appends as this id.
|
||||
*/
|
||||
let lastAdminViewerUserId = ADMIN;
|
||||
|
||||
const tickets: StoredTicket[] = [
|
||||
{
|
||||
id: 1201,
|
||||
@@ -206,6 +217,52 @@ function toDetail(t: StoredTicket, viewerUserId: number): TicketDetail {
|
||||
};
|
||||
}
|
||||
|
||||
/** Admin summary — the queue row (no unread/last-activity; that's a user-inbox concern). */
|
||||
function toAdminSummary(t: StoredTicket): AdminTicketSummary {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin detail — the INVERSE of `toDetail`: it KEEPS internal messages and carries `isInternal`, so the
|
||||
* admin thread shows the internal-styled bubble the user view drops (the no-leak test is demonstrable both
|
||||
* ways against the same store).
|
||||
*/
|
||||
function toAdminDetail(t: StoredTicket, viewerUserId: number): AdminTicketDetail {
|
||||
const roleBySender = new Map<number, TicketAuthorRole>(t.participants.map((p) => [p.userId, p.roleOnTicket]));
|
||||
const messages: AdminTicketMessage[] = t.messages.map((m) => ({
|
||||
id: m.id,
|
||||
ticketId: t.id,
|
||||
body: m.body,
|
||||
authorRole: roleBySender.get(m.senderId) ?? 'system',
|
||||
createdAt: m.sentAt,
|
||||
isMine: m.senderId === viewerUserId,
|
||||
isInternal: m.internal,
|
||||
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)}`;
|
||||
@@ -288,4 +345,56 @@ export const ticketsMockApi: TicketsApi = {
|
||||
t.messages.push({ id, senderId: lastViewerUserId, body: body.body, internal: false, sentAt });
|
||||
return { messageId: id, ticketId, sentAt };
|
||||
},
|
||||
|
||||
// ── Admin lens ──────────────────────────────────────────────────────────────────────────────────
|
||||
// The global queue over EVERY ticket (own-scoping is a user-view rule), filterable the way the b15
|
||||
// admin console filters it.
|
||||
listAdminTickets: async (
|
||||
filters: AdminTicketFilters,
|
||||
params: PageParams,
|
||||
): Promise<Paginated<AdminTicketSummary>> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
let all = [...tickets];
|
||||
if (filters.status) all = all.filter((t) => t.status === filters.status);
|
||||
if (filters.category) all = all.filter((t) => t.category === filters.category);
|
||||
if (filters.referenceCode) {
|
||||
const needle = filters.referenceCode.trim().toLowerCase();
|
||||
all = all.filter((t) => t.referenceCode.toLowerCase().includes(needle));
|
||||
}
|
||||
if (filters.bookingId != null) all = all.filter((t) => t.bookingId === filters.bookingId);
|
||||
if (filters.refundId != null) all = all.filter((t) => t.refundId === filters.refundId);
|
||||
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(toAdminSummary),
|
||||
total: all.length,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
},
|
||||
|
||||
// The admin thread — the FULL store INCLUDING the internal note (`isInternal: true`), so the admin
|
||||
// no-leak-inverse is demonstrable. Opening it does NOT clear a user's unread indicator.
|
||||
getAdminTicket: async (ticketId: number, viewerUserId?: number): Promise<AdminTicketDetail> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const t = findTicket(ticketId);
|
||||
lastAdminViewerUserId = viewerUserId ?? ADMIN;
|
||||
return toAdminDetail(t, lastAdminViewerUserId);
|
||||
},
|
||||
|
||||
// Staff post — appends with the given `internal` flag (the one caller allowed to). A staff caller may
|
||||
// also post to a closed ticket (contract: only NON-staff get a 403 there), so no closed-guard here.
|
||||
postAdminMessage: async (ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const t = findTicket(ticketId);
|
||||
if (body.body.trim() === MOCK_SEND_FAIL_SENTINEL) {
|
||||
throw new ApiError(500, 'Simulated send failure', 'mock_send_failed');
|
||||
}
|
||||
const id = nextMessageId++;
|
||||
const sentAt = new Date().toISOString();
|
||||
t.messages.push({ id, senderId: lastAdminViewerUserId, body: body.body, internal: body.isInternal, sentAt });
|
||||
return { messageId: id, ticketId, sentAt };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,6 +26,9 @@ export const TICKETS_LIST_STALE_TIME = 30 * 1000;
|
||||
export const TICKET_THREAD_STALE_TIME = 15 * 1000;
|
||||
export const TICKETS_GC_TIME = 5 * 60 * 1000;
|
||||
|
||||
/** The admin global queue is a live worklist — a short stale window keeps it fresh without hammering. */
|
||||
export const ADMIN_TICKETS_LIST_STALE_TIME = 20 * 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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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 admin ticket thread (b15 `GET /admin/tickets/{id}`) — header + participants + messages,
|
||||
* **internal notes INCLUDED**. A single cached `adminDetail(id)` entry (the contract returns the whole
|
||||
* thread in one call). The viewer id drives which bubbles are "mine"; `useTicketViewer` yields the admin
|
||||
* "me" under the admin console (real path uses the authenticated id, mock falls back to the admin id), and
|
||||
* the mock defaults to an admin viewer if none is passed. `usePostAdminMessage` mutates this same entry.
|
||||
*/
|
||||
export function useAdminTicket(ticketId: number | null) {
|
||||
const { userId } = useTicketViewer();
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.adminDetail(ticketId ?? -1),
|
||||
queryFn: () => ticketsApi.getAdminTicket(ticketId as number, userId),
|
||||
enabled: ticketId != null && ticketId > 0,
|
||||
staleTime: TICKET_THREAD_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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 { AdminTicketMessage } from '../types';
|
||||
import { useTicketViewer } from './useTicketViewer';
|
||||
|
||||
/**
|
||||
* Just the messages of an admin thread — a `select` over the same `adminDetail(id)` cache the admin header
|
||||
* reads (mirrors `useTicketThread`). One network fetch feeds both; the message list re-renders on a new
|
||||
* message without re-rendering the thread header. Because it is the admin view, the returned messages CARRY
|
||||
* `isInternal` (internal-styled bubbles render here — they never do in the user thread). Optimistic admin
|
||||
* sends mutate `adminDetail(id)`, so the list updates instantly.
|
||||
*/
|
||||
export function useAdminTicketThread(ticketId: number | null) {
|
||||
const { userId } = useTicketViewer();
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.adminDetail(ticketId ?? -1),
|
||||
queryFn: () => ticketsApi.getAdminTicket(ticketId as number, userId),
|
||||
enabled: ticketId != null && ticketId > 0,
|
||||
staleTime: TICKET_THREAD_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
select: (detail): AdminTicketMessage[] => detail.messages,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import { ticketsApi } from '../apis';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { ADMIN_TICKETS_LIST_STALE_TIME, TICKETS_GC_TIME, TICKETS_PAGE_SIZE } from '../constants';
|
||||
import type { AdminTicketFilters } from '../types';
|
||||
|
||||
/**
|
||||
* The admin global ticket queue (b15 `GET /admin/tickets`) — EVERY ticket, not one viewer's, filterable by
|
||||
* status/category/referenceCode/bookingId/refundId. The **filter object + page key the cache**, so each
|
||||
* filter/page combination caches independently and revisiting serves from cache; `keepPreviousData` avoids a
|
||||
* flash while a filter changes. Posting an admin message invalidates `adminLists()`, so new activity shows
|
||||
* without a manual refresh.
|
||||
*/
|
||||
export function useAdminTickets(filters: AdminTicketFilters = {}, page = 1) {
|
||||
const params: PageParams = { page, pageSize: TICKETS_PAGE_SIZE };
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.adminList(filters, params),
|
||||
queryFn: () => ticketsApi.listAdminTickets(filters, params),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: ADMIN_TICKETS_LIST_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { ticketsApi } from '../apis';
|
||||
import type { AdminTicketDetail, AdminTicketMessage, PostMessageResult } from '../types';
|
||||
import { useTicketViewer } from './useTicketViewer';
|
||||
|
||||
interface PostAdminMessageVars {
|
||||
body: string;
|
||||
/** Staff-only: a `true` posts an internal note (never reaches the user view). */
|
||||
isInternal: boolean;
|
||||
/** Client-generated id; the reconcile key so the optimistic bubble is never double-rendered (§3.5). */
|
||||
clientMessageId: string;
|
||||
}
|
||||
|
||||
interface PostAdminMessageContext {
|
||||
previous?: AdminTicketDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin optimistic message send — the same pattern as `usePostMessage`, but over the `adminDetail(id)`
|
||||
* cache and carrying `isInternal` so a pending internal note shows its internal styling immediately.
|
||||
*
|
||||
* `onMutate` appends a **pending** `AdminTicketMessage` (with its `isInternal`) after `cancelQueries` + a
|
||||
* snapshot. `onError` rolls the thread back (composer keeps the draft + offers retry). `onSuccess` reconciles
|
||||
* the pending bubble **by `clientMessageId`** with the server message (never double-rendered). `onSettled`
|
||||
* invalidates the admin thread + the admin queues (a new note moves the queue's activity).
|
||||
*/
|
||||
export function usePostAdminMessage(ticketId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
const { role } = useTicketViewer();
|
||||
|
||||
return useMutation<PostMessageResult, unknown, PostAdminMessageVars, PostAdminMessageContext>({
|
||||
mutationFn: ({ body, isInternal, clientMessageId }) =>
|
||||
ticketsApi.postAdminMessage(ticketId, { body, isInternal, clientMessageId }),
|
||||
|
||||
onMutate: async ({ body, isInternal, clientMessageId }) => {
|
||||
const key = ticketKeys.adminDetail(ticketId);
|
||||
await queryClient.cancelQueries({ queryKey: key });
|
||||
const previous = queryClient.getQueryData<AdminTicketDetail>(key);
|
||||
if (previous) {
|
||||
const pending: AdminTicketMessage = {
|
||||
id: null,
|
||||
clientMessageId,
|
||||
ticketId,
|
||||
body,
|
||||
authorRole: role,
|
||||
createdAt: new Date().toISOString(),
|
||||
isMine: true,
|
||||
isInternal,
|
||||
sendStatus: 'sending',
|
||||
};
|
||||
queryClient.setQueryData<AdminTicketDetail>(key, { ...previous, messages: [...previous.messages, pending] });
|
||||
}
|
||||
return { previous };
|
||||
},
|
||||
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(ticketKeys.adminDetail(ticketId), context.previous);
|
||||
},
|
||||
|
||||
onSuccess: (result, { clientMessageId }) => {
|
||||
const key = ticketKeys.adminDetail(ticketId);
|
||||
const current = queryClient.getQueryData<AdminTicketDetail>(key);
|
||||
if (current) {
|
||||
queryClient.setQueryData<AdminTicketDetail>(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.adminDetail(ticketId) });
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,3 +7,9 @@ export { useTicket } from './hooks/useTicket';
|
||||
export { useTicketThread } from './hooks/useTicketThread';
|
||||
export { useOpenTicket } from './hooks/useOpenTicket';
|
||||
export { usePostMessage } from './hooks/usePostMessage';
|
||||
|
||||
// Admin ticket lens (b15) — the global queue + admin thread (internal INCLUDED) + staff internal-note post.
|
||||
export { useAdminTickets } from './hooks/useAdminTickets';
|
||||
export { useAdminTicket } from './hooks/useAdminTicket';
|
||||
export { useAdminTicketThread } from './hooks/useAdminTicketThread';
|
||||
export { usePostAdminMessage } from './hooks/usePostAdminMessage';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TicketListParams } from './types';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import type { AdminTicketFilters, TicketListParams } from './types';
|
||||
|
||||
/**
|
||||
* React Query key factory for the tickets domain (hierarchical, per the `services/{domain}` pattern).
|
||||
@@ -18,4 +19,13 @@ export const ticketKeys = {
|
||||
|
||||
details: () => [...ticketKeys.all, 'detail'] as const,
|
||||
detail: (ticketId: number) => [...ticketKeys.details(), ticketId] as const,
|
||||
|
||||
// Admin lens — a separate subtree so the internal-carrying admin caches never collide with the user
|
||||
// caches above (and invalidating one never touches the other). Filters + page key the global queue.
|
||||
adminLists: () => [...ticketKeys.all, 'admin', 'list'] as const,
|
||||
adminList: (filters: AdminTicketFilters, params: PageParams) =>
|
||||
[...ticketKeys.adminLists(), filters, params] as const,
|
||||
|
||||
adminDetails: () => [...ticketKeys.all, 'admin', 'detail'] as const,
|
||||
adminDetail: (ticketId: number) => [...ticketKeys.adminDetails(), ticketId] as const,
|
||||
};
|
||||
|
||||
@@ -141,6 +141,62 @@ export interface PostMessageResult {
|
||||
sentAt: string;
|
||||
}
|
||||
|
||||
/* ── Admin ticket lens (b15 `GET /admin/tickets…`) ───────────────────────────────────────────────────
|
||||
* The **admin** surface is a DELIBERATELY SEPARATE model from the user types above. The admin view
|
||||
* INCLUDES internal notes (`GET /admin/tickets/{id}`), so its message/detail shapes carry `isInternal` —
|
||||
* a flag the user types (`TicketMessage`/`TicketDetail`) must NEVER gain (an internal note can't bleed
|
||||
* into the user app; contract "Critical rules" + phase §5). Keeping the two surfaces distinct is the
|
||||
* enforcement: there is no `isInternal` on the user side to accidentally read/render.
|
||||
*/
|
||||
|
||||
/** Admin thread message — INCLUDES the internal-note flag (user types never do). */
|
||||
export interface AdminTicketMessage {
|
||||
id: number | null;
|
||||
clientMessageId?: string;
|
||||
ticketId: number;
|
||||
body: string;
|
||||
authorRole: TicketAuthorRole;
|
||||
createdAt: string;
|
||||
isMine: boolean;
|
||||
isInternal: boolean;
|
||||
sendStatus: MessageSendStatus;
|
||||
}
|
||||
export interface AdminTicketDetail {
|
||||
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: AdminTicketMessage[];
|
||||
}
|
||||
export interface AdminTicketSummary {
|
||||
id: number;
|
||||
referenceCode: string;
|
||||
subject: string | null;
|
||||
status: TicketStatus;
|
||||
category: TicketCategory;
|
||||
bookingId: number | null;
|
||||
refundId: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface AdminTicketFilters {
|
||||
status?: TicketStatus;
|
||||
category?: TicketCategory;
|
||||
referenceCode?: string;
|
||||
bookingId?: number;
|
||||
refundId?: number;
|
||||
}
|
||||
export interface PostAdminMessageRequest {
|
||||
body: string;
|
||||
isInternal: boolean;
|
||||
clientMessageId: 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
|
||||
@@ -157,4 +213,11 @@ export interface TicketsApi {
|
||||
*/
|
||||
openTicket(body: OpenTicketRequest, viewerUserId?: number): Promise<OpenTicketResult>;
|
||||
postMessage(ticketId: number, body: PostMessageRequest): Promise<PostMessageResult>;
|
||||
|
||||
/* Admin lens (b15). Distinct methods so the internal-carrying admin view can never be reached through a
|
||||
* user-view call. `listAdminTickets` is the global queue (all tickets, filterable); `getAdminTicket`
|
||||
* returns the thread WITH internal notes; `postAdminMessage` may set `isInternal` (staff only). */
|
||||
listAdminTickets(filters: AdminTicketFilters, params: PageParams): Promise<Paginated<AdminTicketSummary>>;
|
||||
getAdminTicket(ticketId: number, viewerUserId?: number): Promise<AdminTicketDetail>;
|
||||
postAdminMessage(ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user