frontend phase 15

This commit is contained in:
hamid
2026-07-10 20:28:06 +03:30
parent bc51cf59b4
commit 70cf00ce4a
151 changed files with 10711 additions and 44 deletions
+92 -1
View File
@@ -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 }),
}),
),
};
+110 -1
View File
@@ -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 };
},
};
+3
View File
@@ -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() });
},
});
}
+6
View File
@@ -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';
+11 -1
View File
@@ -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,
};
+63
View File
@@ -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>;
}