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
+178
View File
@@ -0,0 +1,178 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type PageParams, type Paginated } from '@/lib/api/types';
import { ADMIN_PAGE_SIZE } from '../constants';
import type {
AdminApi,
AdminRole,
AuditFilters,
AuditLogEntry,
ConfigChange,
Holiday,
HolidayFilters,
HolidayInput,
PlatformConfig,
RoleGrant,
SupportAlert,
SupportAlertFilters,
} from '../types';
const API = '/api/v1';
/** Build a `page`/`page_size` query (snake_case per b1 api-conventions). */
function pageQuery(params: PageParams, extra?: Record<string, string | undefined>): string {
const q = new URLSearchParams();
q.set('page', String(params.page ?? 1));
q.set('page_size', String(params.pageSize ?? ADMIN_PAGE_SIZE));
for (const [k, v] of Object.entries(extra ?? {})) if (v != null && v !== '') q.set(k, v);
return q.toString();
}
/** Parse the b1 `changedFieldsJson` (`{ "Field": { "old": …, "new": … } }`) into a typed record. */
function parseChangedFields(json: string | null): AuditLogEntry['changedFields'] {
if (!json) return null;
try {
return JSON.parse(json) as AuditLogEntry['changedFields'];
} catch {
return null;
}
}
/** Collapse a config change's `changedFieldsJson` into the single value delta the drawer shows. */
function parseValueDelta(json: string | null): { oldValue: string | null; newValue: string | null } {
const parsed = parseChangedFields(json);
const field = parsed && (parsed['Value'] ?? Object.values(parsed)[0]);
const toStr = (v: unknown): string | null => (v == null ? null : String(v));
return { oldValue: toStr(field?.old), newValue: toStr(field?.new) };
}
interface ConfigChangeWire {
id: number;
action: ConfigChange['action'];
changedFieldsJson: string | null;
actorUserId: number | null;
occurredAt: string;
}
interface AuditWire {
id: number;
entityType: string;
entityId: string;
action: string;
changedFieldsJson: string | null;
actorUserId: number | null;
occurredAt: string;
}
/**
* Real HTTP implementation of the `AdminApi` seam (b1 config/holiday/audit/support-alert routes + the b15
* RBAC routes). **Not primary this phase** (`USE_ADMIN_MOCK = true`) — the config audit columns and rich
* audit filters aren't on the wire (REQ-029/030) and the RBAC routes don't exist yet (REQ-031). When each
* upstream lands, flip the seam in `apis/index.ts`; the hooks/screens are unchanged.
*/
export const adminClientApi: AdminApi = {
listConfigs: async (params) =>
unwrap(await clientFetch<ApiEnvelope<Paginated<PlatformConfig>>>(`${API}/platform_config/get_platform_configs?${pageQuery(params)}`)),
updateConfig: async (key, value) => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/platform_config/update_platform_config`, {
method: 'POST',
body: JSON.stringify({ key, value }),
});
},
getConfigHistory: async (key, params) => {
const wire = unwrap(
await clientFetch<ApiEnvelope<Paginated<ConfigChangeWire>>>(
`${API}/platform_config/get_config_change_history?${pageQuery(params, { key })}`,
),
);
return {
...wire,
items: wire.items.map((w): ConfigChange => ({
id: w.id,
action: w.action,
actorUserId: w.actorUserId,
occurredAt: w.occurredAt,
...parseValueDelta(w.changedFieldsJson),
})),
};
},
listHolidays: async (filters: HolidayFilters, params) =>
unwrap(
await clientFetch<ApiEnvelope<Paginated<Holiday>>>(
`${API}/holidays/get_holidays?${pageQuery(params, { from: filters.from, to: filters.to })}`,
),
),
upsertHoliday: async (input: HolidayInput) => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/holidays/upsert_holiday`, {
method: 'POST',
body: JSON.stringify(input),
});
},
listAuditLogs: async (filters: AuditFilters, params) => {
const wire = unwrap(
await clientFetch<ApiEnvelope<Paginated<AuditWire>>>(
`${API}/audit/get_audit_trail?${pageQuery(params, { entity_type: filters.entityType, entity_id: filters.entityId })}`,
),
);
return {
...wire,
items: wire.items.map((w): AuditLogEntry => ({
id: w.id,
entityType: w.entityType,
entityId: w.entityId,
action: w.action,
actorUserId: w.actorUserId,
occurredAt: w.occurredAt,
changedFields: parseChangedFields(w.changedFieldsJson),
})),
};
},
listSupportAlerts: async (filters: SupportAlertFilters, params) =>
unwrap(
await clientFetch<ApiEnvelope<Paginated<SupportAlert>>>(
`${API}/support_alerts/get_support_alerts?${pageQuery(params, {
type: filters.type,
status: filters.status,
owner_user_id: filters.ownerUserId != null ? String(filters.ownerUserId) : undefined,
})}`,
),
),
assignSupportAlert: async (alertId, ownerUserId) => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/support_alerts/assign_support_alert`, {
method: 'POST',
body: JSON.stringify({ alertId, ownerUserId }),
});
},
resolveSupportAlert: async (alertId, note) => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/support_alerts/resolve_support_alert`, {
method: 'POST',
body: JSON.stringify({ alertId, note }),
});
},
// RBAC (REQ-031 — routes proposed; not live). Kept real-shaped so the swap is one line once they ship.
listRoles: async (userId?: number) =>
unwrap(
await clientFetch<ApiEnvelope<RoleGrant[]>>(
`${API}/admin_roles/list_roles${userId != null ? `?user_id=${userId}` : ''}`,
),
),
grantRole: async (userId, role: AdminRole) => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/admin_roles/grant_role`, {
method: 'POST',
body: JSON.stringify({ userId, role }),
});
},
revokeRole: async (userId, role: AdminRole) => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/admin_roles/revoke_role`, {
method: 'POST',
body: JSON.stringify({ userId, role }),
});
},
};
+10
View File
@@ -0,0 +1,10 @@
import { USE_ADMIN_MOCK } from '../constants';
import type { AdminApi } from '../types';
import { adminClientApi } from './clientApi';
import { adminMockApi } from './mockApi';
/**
* The selected `AdminApi` implementation — the single seam the hooks import. Mock-primary this phase
* (REQ-029/030/031); flipping to the real client is this one line once the upstream endpoints/columns land.
*/
export const adminApi: AdminApi = USE_ADMIN_MOCK ? adminMockApi : adminClientApi;
+196
View File
@@ -0,0 +1,196 @@
import type { PageParams, Paginated } from '@/lib/api/types';
import type {
AdminApi,
AdminRole,
AuditFilters,
AuditLogEntry,
ConfigChange,
Holiday,
HolidayFilters,
HolidayInput,
PlatformConfig,
RoleGrant,
SupportAlert,
SupportAlertFilters,
} from '../types';
/**
* In-memory `AdminApi` — **the primary implementation this phase** (REQ-029/030/031: config audit
* columns, rich audit filters, and the whole RBAC surface are gaps). The fixtures are engineered to
* exercise every console state: one config **per `dataType`** (so all typed inputs + the 01 rate
* validation are reachable), a config change-history trail, holidays with **bank-closed** days, a paged
* audit log with `changedFields` diffs, a support-alert list spanning **every** alert type/status (so the
* worklist filters are testable), and RBAC grants. Mutations mutate the in-memory arrays so a save shows
* on the next read. Timestamps are relative to now so Shamsi rendering always reads sensibly.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
const isoDaysAgo = (d: number): string => new Date(Date.now() - d * DAY_MS).toISOString();
const dateDaysAgo = (d: number): string => isoDaysAgo(d).slice(0, 10);
const dateDaysAhead = (d: number): string => new Date(Date.now() + d * DAY_MS).toISOString().slice(0, 10);
const LATENCY_MS = 200;
const delay = <T>(v: T): Promise<T> => new Promise((r) => setTimeout(() => r(v), LATENCY_MS));
function paginate<T>(all: T[], params: PageParams): Paginated<T> {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.max(1, params.pageSize ?? (all.length || 1));
const start = (page - 1) * pageSize;
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
}
// ── Config (one row per data_type) ─────────────────────────────────────────────────────────────────────
const CONFIGS: PlatformConfig[] = [
{ key: 'platform_fee_rate', value: '0.15', dataType: 'decimal', description: 'Platform commission fraction of gross.', updatedAt: isoDaysAgo(30), updatedBy: 'admin@balinyaar' },
{ key: 'vat_rate', value: '0.10', dataType: 'decimal', description: 'VAT applied to the platform commission line only.', updatedAt: isoDaysAgo(90), updatedBy: 'finance@balinyaar' },
{ key: 'dispute_window_hours', value: '72', dataType: 'int', description: 'Hours after check-out before a payout becomes eligible.', updatedAt: isoDaysAgo(120), updatedBy: 'admin@balinyaar' },
{ key: 'nurse_payout_interval_days', value: '7', dataType: 'int', description: 'Payout batch cadence in days.', updatedAt: isoDaysAgo(120), updatedBy: 'admin@balinyaar' },
{ key: 'evv_location_tolerance_meters', value: '150', dataType: 'int', description: 'Advisory EVV geofence radius in meters.', updatedAt: isoDaysAgo(45), updatedBy: 'admin@balinyaar' },
{ key: 'payout_satna_threshold_irr', value: '150000000', dataType: 'int', description: 'Above this amount payouts use SATNA instead of PAYA.', updatedAt: isoDaysAgo(60), updatedBy: 'finance@balinyaar' },
{ key: 'min_rating_for_support_alert', value: '2', dataType: 'int', description: 'Reviews at or below this rating raise a low-rating alert.', updatedAt: isoDaysAgo(200), updatedBy: 'admin@balinyaar' },
{ key: 'bnpl_provider_enabled', value: 'true', dataType: 'bool', description: 'Whether the BNPL checkout branch is offered.', updatedAt: isoDaysAgo(15), updatedBy: 'admin@balinyaar' },
{ key: 'cancellation_policy_tiers', value: '{"tier1":1,"tier2":0.5,"tier3":0}', dataType: 'json', description: 'Refund fraction per cancellation lead-time tier.', updatedAt: isoDaysAgo(75), updatedBy: 'admin@balinyaar' },
{ key: 'support_contact_line', value: 'پشتیبانی بالین‌یار', dataType: 'string', description: 'Display name used in support messages.', updatedAt: isoDaysAgo(10), updatedBy: 'support@balinyaar' },
];
const CONFIG_HISTORY: Record<string, ConfigChange[]> = {
vat_rate: [
{ id: 301, action: 'updated', actorUserId: 3, occurredAt: isoDaysAgo(90), oldValue: '0.09', newValue: '0.10' },
{ id: 300, action: 'updated', actorUserId: 3, occurredAt: isoDaysAgo(365), oldValue: '0.08', newValue: '0.09' },
],
platform_fee_rate: [
{ id: 310, action: 'updated', actorUserId: 2, occurredAt: isoDaysAgo(30), oldValue: '0.18', newValue: '0.15' },
],
};
// ── Holidays ───────────────────────────────────────────────────────────────────────────────────────────
const HOLIDAYS: Holiday[] = [
{ id: 501, holidayDate: dateDaysAhead(3), nameFa: 'عید فطر', type: 'religious', isBankClosed: true },
{ id: 502, holidayDate: dateDaysAgo(2), nameFa: 'رحلت امام', type: 'religious', isBankClosed: true },
{ id: 503, holidayDate: dateDaysAgo(20), nameFa: 'روز جمهوری اسلامی', type: 'national', isBankClosed: true },
{ id: 504, holidayDate: dateDaysAhead(30), nameFa: 'تعطیلی اداری', type: 'official', isBankClosed: false },
];
// ── Audit log ──────────────────────────────────────────────────────────────────────────────────────────
const AUDIT: AuditLogEntry[] = [
{ id: 901, entityType: 'PlatformConfig', entityId: 'platform_fee_rate', action: 'updated', actorUserId: 2, occurredAt: isoDaysAgo(30), changedFields: { Value: { old: '0.18', new: '0.15' } } },
{ id: 902, entityType: 'Refund', entityId: '7', action: 'created', actorUserId: 4, occurredAt: isoDaysAgo(4), changedFields: { Status: { old: null, new: 'succeeded' }, Amount: { old: null, new: '10000000' } } },
{ id: 903, entityType: 'NurseVerification', entityId: '15', action: 'updated', actorUserId: 2, occurredAt: isoDaysAgo(6), changedFields: { Status: { old: 'in_review', new: 'approved' }, IsVerified: { old: false, new: true } } },
{ id: 904, entityType: 'PayoutBatch', entityId: '7003', action: 'created', actorUserId: 5, occurredAt: isoDaysAgo(1), changedFields: { Status: { old: null, new: 'processing' }, PayoutCount: { old: null, new: 12 } } },
{ id: 905, entityType: 'Review', entityId: '44', action: 'updated', actorUserId: 6, occurredAt: isoDaysAgo(2), changedFields: { ModerationStatus: { old: 'pending_moderation', new: 'published' } } },
{ id: 906, entityType: 'PartnerCenter', entityId: '1', action: 'updated', actorUserId: 2, occurredAt: isoDaysAgo(8), changedFields: { SettlementIban: { old: '<redacted>', new: '<redacted>' }, IsActive: { old: false, new: true } } },
];
// ── Support alerts (one of every type; all statuses) ────────────────────────────────────────────────────
const ALERTS: SupportAlert[] = [
{ id: 801, type: 'low_rating', severity: 'medium', status: 'open', entityType: 'Review', entityId: '44', bookingId: 5001, reviewId: 44, ownerUserId: null, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(1) },
{ id: 802, type: 'evv_no_show', severity: 'high', status: 'open', entityType: 'Booking', entityId: '5002', bookingId: 5002, reviewId: null, ownerUserId: null, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(2) },
{ id: 803, type: 'evv_location_mismatch', severity: 'medium', status: 'assigned', entityType: 'Booking', entityId: '5003', bookingId: 5003, reviewId: null, ownerUserId: 3, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(3) },
{ id: 804, type: 'verification_expired', severity: 'high', status: 'open', entityType: 'NurseVerification', entityId: '15', bookingId: null, reviewId: null, ownerUserId: null, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(4) },
{ id: 805, type: 'shared_sim', severity: 'high', status: 'assigned', entityType: 'NurseVerification', entityId: '16', bookingId: null, reviewId: null, ownerUserId: 3, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(5) },
{ id: 806, type: 'payment_anomaly', severity: 'high', status: 'open', entityType: 'PaymentTransaction', entityId: '3001', bookingId: 5004, reviewId: null, ownerUserId: null, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(6) },
{ id: 807, type: 'fraud_signal', severity: 'high', status: 'open', entityType: 'User', entityId: '7099', bookingId: null, reviewId: null, ownerUserId: null, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(7) },
{ id: 808, type: 'nurse_clawback', severity: 'medium', status: 'resolved', entityType: 'NurseClawback', entityId: '210', bookingId: 5004, reviewId: null, ownerUserId: 4, resolutionNote: 'Netted in the next payout batch.', resolvedAt: isoDaysAgo(1), createdAt: isoDaysAgo(9) },
{ id: 809, type: 'emergency', severity: 'high', status: 'resolved', entityType: 'Booking', entityId: '5001', bookingId: 5001, reviewId: null, ownerUserId: 3, resolutionNote: 'Called 115; patient stable.', resolvedAt: isoDaysAgo(2), createdAt: isoDaysAgo(10) },
];
// ── RBAC grants ──────────────────────────────────────────────────────────────────────────────────────
const ROLES: RoleGrant[] = [
{ userId: 2, role: 'admin', grantedBy: 1, grantedAt: isoDaysAgo(400), revokedAt: null },
{ userId: 3, role: 'support', grantedBy: 2, grantedAt: isoDaysAgo(200), revokedAt: null },
{ userId: 4, role: 'finance', grantedBy: 2, grantedAt: isoDaysAgo(180), revokedAt: null },
{ userId: 5, role: 'finance', grantedBy: 2, grantedAt: isoDaysAgo(90), revokedAt: null },
{ userId: 6, role: 'moderation', grantedBy: 2, grantedAt: isoDaysAgo(60), revokedAt: null },
];
export const adminMockApi: AdminApi = {
listConfigs: async (params) => delay(paginate([...CONFIGS], params)),
updateConfig: async (key, value) => {
const row = CONFIGS.find((c) => c.key === key);
if (!row) throw new Error(`Mock config ${key} not found`);
const old = row.value;
row.value = value;
row.updatedAt = new Date().toISOString();
row.updatedBy = 'you@balinyaar';
(CONFIG_HISTORY[key] ??= []).unshift({
id: Math.floor(1000 + (CONFIG_HISTORY[key]?.length ?? 0)),
action: 'updated',
actorUserId: 1,
occurredAt: row.updatedAt,
oldValue: old,
newValue: value,
});
return delay(undefined);
},
getConfigHistory: async (key, params) => delay(paginate([...(CONFIG_HISTORY[key] ?? [])], params)),
listHolidays: async (filters, params) => {
let items = [...HOLIDAYS];
if (filters.from) items = items.filter((h) => h.holidayDate >= filters.from!);
if (filters.to) items = items.filter((h) => h.holidayDate <= filters.to!);
items.sort((a, b) => a.holidayDate.localeCompare(b.holidayDate));
return delay(paginate(items, params));
},
upsertHoliday: async (input) => {
const existing = HOLIDAYS.find((h) => h.holidayDate === input.holidayDate);
if (existing) Object.assign(existing, input);
else HOLIDAYS.push({ id: Math.max(0, ...HOLIDAYS.map((h) => h.id)) + 1, ...input });
return delay(undefined);
},
listAuditLogs: async (filters, params) => {
let items = [...AUDIT];
if (filters.entityType) items = items.filter((a) => a.entityType.toLowerCase().includes(filters.entityType!.toLowerCase()));
if (filters.entityId) items = items.filter((a) => a.entityId === filters.entityId);
if (filters.actorUserId != null) items = items.filter((a) => a.actorUserId === filters.actorUserId);
if (filters.action) items = items.filter((a) => a.action === filters.action);
if (filters.from) items = items.filter((a) => a.occurredAt >= filters.from!);
if (filters.to) items = items.filter((a) => a.occurredAt <= filters.to!);
items.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
return delay(paginate(items, params));
},
listSupportAlerts: async (filters, params) => {
let items = [...ALERTS];
if (filters.type) items = items.filter((a) => a.type === filters.type);
if (filters.status) items = items.filter((a) => a.status === filters.status);
if (filters.ownerUserId != null) items = items.filter((a) => a.ownerUserId === filters.ownerUserId);
items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return delay(paginate(items, params));
},
assignSupportAlert: async (alertId, ownerUserId) => {
const a = ALERTS.find((x) => x.id === alertId);
if (!a || a.status === 'resolved') throw new Error(`Mock alert ${alertId} not assignable`);
a.status = 'assigned';
a.ownerUserId = ownerUserId;
return delay(undefined);
},
resolveSupportAlert: async (alertId, note) => {
const a = ALERTS.find((x) => x.id === alertId);
if (!a || a.status === 'resolved') throw new Error(`Mock alert ${alertId} not resolvable`);
a.status = 'resolved';
a.resolutionNote = note;
a.resolvedAt = new Date().toISOString();
return delay(undefined);
},
listRoles: async (userId) => delay(userId == null ? [...ROLES] : ROLES.filter((r) => r.userId === userId)),
grantRole: async (userId, role) => {
const existing = ROLES.find((r) => r.userId === userId && r.role === role);
if (existing) existing.revokedAt = null;
else ROLES.push({ userId, role, grantedBy: 1, grantedAt: new Date().toISOString(), revokedAt: null });
return delay(undefined);
},
revokeRole: async (userId, role) => {
const r = ROLES.find((x) => x.userId === userId && x.role === role && !x.revokedAt);
if (r) r.revokedAt = new Date().toISOString();
return delay(undefined);
},
};
+39
View File
@@ -0,0 +1,39 @@
/**
* When true, the admin domain is served by the in-memory mock (`apis/mockApi.ts`) behind the `AdminApi`
* seam. **Mock is primary this phase:** the b1 config/holiday/audit/support-alert endpoints are live and
* the real `clientApi.ts` maps them 1:1, but (a) config `updatedAt/updatedBy` and the actor/action/date
* audit filters aren't on the wire yet (REQ-029/030) and (b) the RBAC role endpoints don't exist in the
* b15 contract at all (REQ-031). The mock supplies a realistic, filter-exercising world for every console;
* flip to `false` per area once each upstream is complete — the swap is the one line in `apis/index.ts`.
*/
export const USE_ADMIN_MOCK = true;
/** Worklist page sizes (api-conventions `pageSize`, default 50 / max 100). */
export const ADMIN_PAGE_SIZE = 20;
export const AUDIT_PAGE_SIZE = 25;
/**
* Config + holidays are near-static reference data — a long `staleTime` avoids refetching on revisit; a
* mutation invalidates the relevant key so the change shows immediately. Audit + support-alerts move at
* ops speed (moderate staleness). All are gc'd after a few minutes off-screen.
*/
export const ADMIN_CONFIG_STALE_TIME = 5 * 60 * 1000;
export const ADMIN_HOLIDAYS_STALE_TIME = 5 * 60 * 1000;
export const ADMIN_AUDIT_STALE_TIME = 30 * 1000;
export const ADMIN_ALERTS_STALE_TIME = 20 * 1000;
export const ADMIN_GC_TIME = 5 * 60 * 1000;
/** Config keys that are **rates** and must validate to the closed-open interval [0, 1). */
export const RATE_CONFIG_KEYS: readonly string[] = [
'platform_fee_rate',
'vat_rate',
];
/** Grouping of config keys into UI sections (any key not listed falls into "other"). */
export const CONFIG_GROUPS: Record<string, readonly string[]> = {
fees: ['platform_fee_rate', 'vat_rate'],
deadlines: ['dispute_window_hours', 'nurse_payout_interval_days', 'payout_satna_threshold_irr', 'booking_request_response_deadline_minutes', 'payment_window_minutes'],
evv: ['evv_location_tolerance_meters'],
bnpl: ['bnpl_provider_enabled', 'bnpl_min_amount_irr'],
cancellation: ['cancellation_tier1_refund_rate', 'cancellation_tier2_refund_rate', 'cancellation_tier3_refund_rate'],
};
@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
import { ADMIN_CONFIG_STALE_TIME, ADMIN_GC_TIME } from '../constants';
/** The RBAC role grants (all, or scoped to one user). Deferred-if-missing — mock-primary (REQ-031). */
export function useAdminRoles(userId?: number) {
return useQuery({
queryKey: adminKeys.roleList(userId),
queryFn: () => adminApi.listRoles(userId),
staleTime: ADMIN_CONFIG_STALE_TIME,
gcTime: ADMIN_GC_TIME,
});
}
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
/** Assign an alert to an owner (open → assigned). Invalidate the alert worklist on success. */
export function useAssignSupportAlert() {
const queryClient = useQueryClient();
return useMutation<void, unknown, { alertId: number; ownerUserId: number }>({
mutationFn: ({ alertId, ownerUserId }) => adminApi.assignSupportAlert(alertId, ownerUserId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: adminKeys.supportAlerts() });
},
});
}
@@ -0,0 +1,16 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
import { ADMIN_AUDIT_STALE_TIME, ADMIN_GC_TIME, AUDIT_PAGE_SIZE } from '../constants';
import type { AuditFilters } from '../types';
/** The append-only audit trail (read-only), filtered + paginated. Filters + page key the cache. */
export function useAuditLogs(filters: AuditFilters, page = 1) {
return useQuery({
queryKey: adminKeys.auditList(filters, { page, pageSize: AUDIT_PAGE_SIZE }),
queryFn: () => adminApi.listAuditLogs(filters, { page, pageSize: AUDIT_PAGE_SIZE }),
staleTime: ADMIN_AUDIT_STALE_TIME,
gcTime: ADMIN_GC_TIME,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,19 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
import { ADMIN_CONFIG_STALE_TIME, ADMIN_GC_TIME, ADMIN_PAGE_SIZE } from '../constants';
/**
* The audited change history for one config key (newest first) — finance proves the rate in effect at any
* past moment. `enabled` gates the fetch to when the drawer is open (a closed drawer never fetches).
*/
export function useConfigChangeHistory(key: string | null, page = 1, enabled = true) {
return useQuery({
queryKey: adminKeys.configHistory(key ?? '', { page, pageSize: ADMIN_PAGE_SIZE }),
queryFn: () => adminApi.getConfigHistory(key!, { page, pageSize: ADMIN_PAGE_SIZE }),
enabled: enabled && !!key,
staleTime: ADMIN_CONFIG_STALE_TIME,
gcTime: ADMIN_GC_TIME,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
import type { AdminRole } from '../types';
/** Grant an admin role to a user (records `grantedBy`/`grantedAt`). Invalidate the roles list. */
export function useGrantRole() {
const queryClient = useQueryClient();
return useMutation<void, unknown, { userId: number; role: AdminRole }>({
mutationFn: ({ userId, role }) => adminApi.grantRole(userId, role),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: adminKeys.roles() });
},
});
}
@@ -0,0 +1,16 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
import { ADMIN_GC_TIME, ADMIN_HOLIDAYS_STALE_TIME, ADMIN_PAGE_SIZE } from '../constants';
import type { HolidayFilters } from '../types';
/** The `iranian_holidays` calendar for a date range. The filter object keys the cache separately. */
export function useHolidays(filters: HolidayFilters, page = 1) {
return useQuery({
queryKey: adminKeys.holidayList(filters, { page, pageSize: ADMIN_PAGE_SIZE }),
queryFn: () => adminApi.listHolidays(filters, { page, pageSize: ADMIN_PAGE_SIZE }),
staleTime: ADMIN_HOLIDAYS_STALE_TIME,
gcTime: ADMIN_GC_TIME,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,15 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
import { ADMIN_CONFIG_STALE_TIME, ADMIN_GC_TIME, ADMIN_PAGE_SIZE } from '../constants';
/** All `platform_configs` rows (paginated). Near-static reference data → long `staleTime`. */
export function usePlatformConfigs(page = 1) {
return useQuery({
queryKey: adminKeys.configList({ page, pageSize: ADMIN_PAGE_SIZE }),
queryFn: () => adminApi.listConfigs({ page, pageSize: ADMIN_PAGE_SIZE }),
staleTime: ADMIN_CONFIG_STALE_TIME,
gcTime: ADMIN_GC_TIME,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
/** Resolve an alert with a note (→ resolved). Invalidate the alert worklist on success. */
export function useResolveSupportAlert() {
const queryClient = useQueryClient();
return useMutation<void, unknown, { alertId: number; note: string }>({
mutationFn: ({ alertId, note }) => adminApi.resolveSupportAlert(alertId, note),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: adminKeys.supportAlerts() });
},
});
}
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
import type { AdminRole } from '../types';
/** Revoke an admin role from a user (sets `revokedAt`). Invalidate the roles list. */
export function useRevokeRole() {
const queryClient = useQueryClient();
return useMutation<void, unknown, { userId: number; role: AdminRole }>({
mutationFn: ({ userId, role }) => adminApi.revokeRole(userId, role),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: adminKeys.roles() });
},
});
}
@@ -0,0 +1,19 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
import { ADMIN_ALERTS_STALE_TIME, ADMIN_GC_TIME, ADMIN_PAGE_SIZE } from '../constants';
import type { SupportAlertFilters } from '../types';
/**
* The internal support-alert worklist, filtered by type/status/owner + paginated. **Internal-only** — this
* query is admin-scoped and its data never reaches a non-admin surface (phase §5). Filters + page key the cache.
*/
export function useSupportAlerts(filters: SupportAlertFilters, page = 1) {
return useQuery({
queryKey: adminKeys.supportAlertList(filters, { page, pageSize: ADMIN_PAGE_SIZE }),
queryFn: () => adminApi.listSupportAlerts(filters, { page, pageSize: ADMIN_PAGE_SIZE }),
staleTime: ADMIN_ALERTS_STALE_TIME,
gcTime: ADMIN_GC_TIME,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
/**
* Update one config row (audited server-side). On success invalidate the whole config sub-tree — both the
* list and the change-history drawer — so the new value + the new history row show without a manual refresh.
* A config change is **not** retroactive (the confirmation copy says so); the client never re-prices.
*/
export function useUpdatePlatformConfig() {
const queryClient = useQueryClient();
return useMutation<void, unknown, { key: string; value: string }>({
mutationFn: ({ key, value }) => adminApi.updateConfig(key, value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: adminKeys.config() });
},
});
}
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
import type { HolidayInput } from '../types';
/**
* Add or edit a holiday (upsert keyed on `holidayDate`). Invalidate the holidays sub-tree so every cached
* range reflects the change. The client never computes the next-business-day shift — it only maintains the
* calendar the server uses for payout scheduling (phase §5).
*/
export function useUpsertHoliday() {
const queryClient = useQueryClient();
return useMutation<void, unknown, HolidayInput>({
mutationFn: (input) => adminApi.upsertHoliday(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: adminKeys.holidays() });
},
});
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Admin domain barrel — re-exports **hooks only** (per the `services/{domain}` convention). Import
* types/keys/apis directly from their files when needed (e.g. `import type { PlatformConfig } from
* '@/services/admin/types'`).
*/
export { usePlatformConfigs } from './hooks/usePlatformConfigs';
export { useUpdatePlatformConfig } from './hooks/useUpdatePlatformConfig';
export { useConfigChangeHistory } from './hooks/useConfigChangeHistory';
export { useHolidays } from './hooks/useHolidays';
export { useUpsertHoliday } from './hooks/useUpsertHoliday';
export { useAuditLogs } from './hooks/useAuditLogs';
export { useSupportAlerts } from './hooks/useSupportAlerts';
export { useAssignSupportAlert } from './hooks/useAssignSupportAlert';
export { useResolveSupportAlert } from './hooks/useResolveSupportAlert';
export { useAdminRoles } from './hooks/useAdminRoles';
export { useGrantRole } from './hooks/useGrantRole';
export { useRevokeRole } from './hooks/useRevokeRole';
+30
View File
@@ -0,0 +1,30 @@
import type { PageParams } from '@/lib/api/types';
import type { AuditFilters, HolidayFilters, SupportAlertFilters } from './types';
/**
* React Query key factory for the admin domain (hierarchical, per the `services/{domain}` pattern). The
* **filters + page object keys each list** so every filter/page combination caches independently — paging
* or switching a worklist filter never refetches data already held (phase §5). Mutations invalidate the
* relevant sub-tree (`config()`/`holidays()`/`audit()`/`supportAlerts()`/`roles()`).
*/
export const adminKeys = {
all: ['admin'] as const,
config: () => [...adminKeys.all, 'config'] as const,
configList: (params: PageParams) => [...adminKeys.config(), 'list', params] as const,
configHistory: (key: string, params: PageParams) => [...adminKeys.config(), 'history', key, params] as const,
holidays: () => [...adminKeys.all, 'holidays'] as const,
holidayList: (filters: HolidayFilters, params: PageParams) =>
[...adminKeys.holidays(), 'list', filters, params] as const,
audit: () => [...adminKeys.all, 'audit'] as const,
auditList: (filters: AuditFilters, params: PageParams) => [...adminKeys.audit(), 'list', filters, params] as const,
supportAlerts: () => [...adminKeys.all, 'supportAlerts'] as const,
supportAlertList: (filters: SupportAlertFilters, params: PageParams) =>
[...adminKeys.supportAlerts(), 'list', filters, params] as const,
roles: () => [...adminKeys.all, 'roles'] as const,
roleList: (userId?: number) => [...adminKeys.roles(), 'list', userId ?? null] as const,
};
+183
View File
@@ -0,0 +1,183 @@
import type { PageParams, Paginated } from '@/lib/api/types';
/**
* Admin domain — the backoffice-owned data the f15 consoles read and act on: platform config, the
* Iranian-holiday calendar, the append-only audit trail, the internal support-alert worklist, and RBAC
* role grants. Shapes derive from the **b1** config/reference contract
* (`dev/contracts/domains/config-reference.md`) and the **b15** admin contract
* (`dev/contracts/domains/messaging-notifications-admin.md`); the wire is camelCase and `clientFetch`
* unwraps the `ApiResult<T>` envelope, so these are the post-`unwrap()` payloads.
*
* Load-bearing rules (phase §5):
* - **Internal-only:** `SupportAlert`s are staff-only and must never reach a non-admin surface.
* - **Server is the authority:** config parsing is *rendering by `dataType`* only; the client validates at
* the boundary (a rate is 01) but never re-derives money, holiday shifts, or eligibility.
* - **Append-only audit:** there is no edit/delete — the viewer is read-only.
*
* Enums cross the wire as stable string codes, mirrored here as string-literal unions.
*/
// ── Config ────────────────────────────────────────────────────────────────────────────────────────────
/** `platform_configs.data_type` — how to parse (and which typed input to render for) a config `value`. */
export type ConfigDataType = 'string' | 'int' | 'decimal' | 'bool' | 'json';
/**
* `PlatformConfigDto` (b1). `value` is the **raw string** — parse per `dataType`. `updatedAt`/`updatedBy`
* are **not** on the b1 wire (REQ-029) — optional and mock-supplied until delivered; the row degrades
* gracefully without them.
*/
export interface PlatformConfig {
key: string;
value: string;
dataType: ConfigDataType;
description: string | null;
updatedAt?: string | null;
updatedBy?: string | null;
}
/** `action` on an audit/config-change row. */
export type AuditAction = 'created' | 'updated' | 'deleted';
/**
* A config change-history row, derived from `ConfigChangeDto` (b1). The wire carries `changedFieldsJson`
* (`{ "Value": { "old": …, "new": … } }`); the client parses the value delta into `oldValue`/`newValue`
* for the drawer so finance can prove the rate in effect at any past moment.
*/
export interface ConfigChange {
id: number;
action: AuditAction;
actorUserId: number | null;
occurredAt: string;
oldValue: string | null;
newValue: string | null;
}
// ── Holidays ──────────────────────────────────────────────────────────────────────────────────────────
/** `iranian_holidays.type`. */
export type HolidayType = 'official' | 'religious' | 'national';
/** `HolidayDto` (b1). `isBankClosed` is what shifts payout scheduling (server-computed). */
export interface Holiday {
id: number;
holidayDate: string;
nameFa: string;
type: HolidayType;
isBankClosed: boolean;
}
/** Upsert body (`upsert_holiday`, keyed on `holidayDate`). */
export interface HolidayInput {
holidayDate: string;
nameFa: string;
type: HolidayType;
isBankClosed: boolean;
}
/** Holiday list range. */
export interface HolidayFilters {
from?: string;
to?: string;
}
// ── Audit ─────────────────────────────────────────────────────────────────────────────────────────────
/** `AuditLogDto` (b1). `changedFields` is the parsed `changedFieldsJson` (field → {old,new}); PII redacted. */
export interface AuditLogEntry {
id: number;
entityType: string;
entityId: string;
action: string;
actorUserId: number | null;
occurredAt: string;
changedFields: Record<string, { old: unknown; new: unknown }> | null;
}
/**
* Audit filters. The b1 wire supports only `entityType`/`entityId` (REQ-030 covers actor/action/date) —
* the extra filters are honoured by the mock and requested from the backend; the real client passes the
* supported ones and lets the rest degrade.
*/
export interface AuditFilters {
entityType?: string;
entityId?: string;
actorUserId?: number;
action?: string;
from?: string;
to?: string;
}
// ── Support alerts ────────────────────────────────────────────────────────────────────────────────────
/** `support_alert.type` — the broad b15 union (superset of the b1 list). */
export type SupportAlertType =
| 'low_rating'
| 'evv_no_show'
| 'evv_location_mismatch'
| 'verification_expired'
| 'shared_sim'
| 'payment_anomaly'
| 'fraud_signal'
| 'nurse_clawback'
| 'emergency';
export type SupportAlertSeverity = 'low' | 'medium' | 'high';
export type SupportAlertStatus = 'open' | 'assigned' | 'resolved';
/** `SupportAlertDto` (b1/b15). **Internal-only** — never rendered outside an admin route. */
export interface SupportAlert {
id: number;
type: SupportAlertType;
severity: SupportAlertSeverity;
status: SupportAlertStatus;
entityType: string;
entityId: string;
bookingId: number | null;
reviewId: number | null;
ownerUserId: number | null;
resolutionNote: string | null;
resolvedAt: string | null;
createdAt: string;
}
export interface SupportAlertFilters {
type?: SupportAlertType;
status?: SupportAlertStatus;
ownerUserId?: number;
}
// ── RBAC (b15 — role endpoints not yet in the contract; mock-primary, REQ-031) ─────────────────────────
/** The fine-grained admin roles the RBAC grid grants/revokes (aligned with the b2 `AdminRole` enum). */
export type AdminRole = 'super_admin' | 'admin' | 'support' | 'finance' | 'moderation';
/** A role grant row (`RoleGrant`). `revokedAt` set once revoked. */
export interface RoleGrant {
userId: number;
role: AdminRole;
grantedBy: number | null;
grantedAt: string;
revokedAt: string | null;
}
// ── The seam ──────────────────────────────────────────────────────────────────────────────────────────
/**
* The admin API seam — the real HTTP client and the in-memory mock both implement it; selection is by
* config (`USE_ADMIN_MOCK`), never scattered `if (mock)` checks. Mutations return void (the wire returns
* `true`); hooks invalidate the affected keys.
*/
export interface AdminApi {
// config
listConfigs(params: PageParams): Promise<Paginated<PlatformConfig>>;
updateConfig(key: string, value: string): Promise<void>;
getConfigHistory(key: string, params: PageParams): Promise<Paginated<ConfigChange>>;
// holidays
listHolidays(filters: HolidayFilters, params: PageParams): Promise<Paginated<Holiday>>;
upsertHoliday(input: HolidayInput): Promise<void>;
// audit
listAuditLogs(filters: AuditFilters, params: PageParams): Promise<Paginated<AuditLogEntry>>;
// support alerts
listSupportAlerts(filters: SupportAlertFilters, params: PageParams): Promise<Paginated<SupportAlert>>;
assignSupportAlert(alertId: number, ownerUserId: number): Promise<void>;
resolveSupportAlert(alertId: number, note: string): Promise<void>;
// rbac (deferred-if-missing)
listRoles(userId?: number): Promise<RoleGrant[]>;
grantRole(userId: number, role: AdminRole): Promise<void>;
revokeRole(userId: number, role: AdminRole): Promise<void>;
}
@@ -17,6 +17,11 @@ export function useSessionRoleSync(): void {
useEffect(() => {
if (!me) return;
dispatch({ type: 'LOG_IN', user: { id: me.id, phone: me.phone, roles: toAppRoles(me.roles) } });
dispatch({
type: 'LOG_IN',
// `roleCodes` preserves the server's fine-grained codes for the f15 admin-capability gate;
// the collapsed `roles` still drive shell chrome.
user: { id: me.id, phone: me.phone, roles: toAppRoles(me.roles), roleCodes: me.roles },
});
}, [me, dispatch]);
}
@@ -0,0 +1,120 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type PageParams, type Paginated } from '@/lib/api/types';
import { PARTNER_PAGE_SIZE } from '../constants';
import type {
CenterInvoice,
PartnerCenter,
PartnerCenterApi,
PartnerCenterFilters,
PartnerCenterInput,
SponsoredBooking,
SponsoredBookingFilters,
SponsoredNurse,
} from '../types';
import { deriveCenterState } from '../types';
const API = '/api/v1';
/** Wire `PartnerCenterDto` (b15) — `onboardingState` is derived client-side from `isActive`/`verifiedAt`. */
interface CenterWire {
id: number;
name: string;
legalEntityType: string;
mohEstablishmentPermitNo: string;
technicalDirectorNurseUserId: number | null;
technicalDirectorLicenseNo: string | null;
enamadCode: string | null;
settlementIbanMasked: string | null;
isMerchantOfRecord: boolean;
commissionRate: number;
adminUserId: number | null;
isActive: boolean;
verifiedAt: string | null;
sponsoredNurseCount: number;
createdAt: string;
}
function mapCenter(w: CenterWire): PartnerCenter {
return { ...w, onboardingState: deriveCenterState(w.isActive, w.verifiedAt) };
}
/**
* Real HTTP implementation of the `PartnerCenterApi` seam. **Not primary this phase** (`USE_PARTNER_MOCK =
* true`). Admin CRUD/verify/sponsor map the live b15 routes; the activate/suspend toggle and the portal's
* split reads (my-center / nurses / bookings / settlement) are proposed routes (REQ-032/033) kept
* real-shaped so the seam flips in one file once they land.
*/
export const partnerCenterClientApi: PartnerCenterApi = {
listCenters: async (filters: PartnerCenterFilters, params) => {
const q = new URLSearchParams();
q.set('page', String(params.page ?? 1));
q.set('pageSize', String(params.pageSize ?? PARTNER_PAGE_SIZE));
if (filters.isMerchantOfRecord != null) q.set('isMerchantOfRecord', String(filters.isMerchantOfRecord));
if (filters.isActive != null) q.set('isActive', String(filters.isActive));
const wire = unwrap(await clientFetch<ApiEnvelope<Paginated<CenterWire>>>(`${API}/admin/partner-centers?${q}`));
return { ...wire, items: wire.items.map(mapCenter) };
},
getCenter: async (id) => mapCenter(unwrap(await clientFetch<ApiEnvelope<CenterWire>>(`${API}/admin/partner-centers/${id}`))),
createCenter: async (input: PartnerCenterInput) =>
mapCenter(
unwrap(
await clientFetch<ApiEnvelope<CenterWire>>(`${API}/admin/partner-centers`, {
method: 'POST',
body: JSON.stringify(input),
}),
),
),
updateCenter: async (id, input: PartnerCenterInput) =>
mapCenter(
unwrap(
await clientFetch<ApiEnvelope<CenterWire>>(`${API}/admin/partner-centers/${id}`, {
method: 'PATCH',
body: JSON.stringify(input),
}),
),
),
verifyCenter: async (id) => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/admin/partner-centers/${id}/verify`, { method: 'POST' });
},
// REQ-032 — no activate/suspend route in the b15 contract yet; proposed shape.
setCenterActive: async (id, isActive) => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/admin/partner-centers/${id}/set-active`, {
method: 'POST',
body: JSON.stringify({ isActive }),
});
},
assignNurse: async (id, nurseProfileId, unlink) => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/admin/partner-centers/${id}/sponsor-nurse`, {
method: 'POST',
body: JSON.stringify({ nurseProfileId, unlink }),
});
},
// REQ-032 — admin roster read (the b15 dashboard is portal-auth); proposed shape.
getCenterSponsoredNurses: async (id) =>
unwrap(await clientFetch<ApiEnvelope<SponsoredNurse[]>>(`${API}/admin/partner-centers/${id}/nurses`)),
// ── portal (center-scoped; REQ-032/033 — proposed split reads over `GET /centers/{id}/dashboard`) ──
getMyCenter: async () => mapCenter(unwrap(await clientFetch<ApiEnvelope<CenterWire>>(`${API}/centers/me`))),
listMySponsoredNurses: async () =>
unwrap(await clientFetch<ApiEnvelope<SponsoredNurse[]>>(`${API}/centers/me/nurses`)),
listMySponsoredBookings: async (filters: SponsoredBookingFilters, params) => {
const q = new URLSearchParams();
q.set('page', String(params.page ?? 1));
q.set('pageSize', String(params.pageSize ?? PARTNER_PAGE_SIZE));
if (filters.status) q.set('status', filters.status);
return unwrap(await clientFetch<ApiEnvelope<Paginated<SponsoredBooking>>>(`${API}/centers/me/bookings?${q}`));
},
listMySettlement: async (params) => {
const q = new URLSearchParams();
q.set('page', String(params.page ?? 1));
q.set('pageSize', String(params.pageSize ?? PARTNER_PAGE_SIZE));
return unwrap(await clientFetch<ApiEnvelope<Paginated<CenterInvoice>>>(`${API}/centers/me/settlement?${q}`));
},
};
@@ -0,0 +1,10 @@
import { USE_PARTNER_MOCK } from '../constants';
import type { PartnerCenterApi } from '../types';
import { partnerCenterClientApi } from './clientApi';
import { partnerCenterMockApi } from './mockApi';
/**
* The selected `PartnerCenterApi` implementation — the single seam the hooks import. Mock-primary this
* phase (REQ-032/033); the swap to the real client is this one line.
*/
export const partnerCenterApi: PartnerCenterApi = USE_PARTNER_MOCK ? partnerCenterMockApi : partnerCenterClientApi;
@@ -0,0 +1,250 @@
import type { PageParams, Paginated } from '@/lib/api/types';
import { MOCK_MY_CENTER_ID } from '../constants';
import type {
CenterInvoice,
PartnerCenter,
PartnerCenterApi,
PartnerCenterFilters,
PartnerCenterInput,
SponsoredBooking,
SponsoredBookingFilters,
SponsoredNurse,
} from '../types';
import { deriveCenterState } from '../types';
/**
* In-memory `PartnerCenterApi` — **the primary implementation this phase** (REQ-032/033). Fixtures:
* - center **#1 = merchant-of-record** (settlement/invoice view renders) and **#2 = non-MoR** (the
* "settlement runs through Balinyaar" state) plus a **draft** center #3 (unverified banner);
* - sponsored nurses (verified + unverified) and sponsored bookings;
* - commission invoices whose **platform commission + BNPL commission + VAT = total** (VAT on the
* commission line only), a fake 22-digit مودیان reference, and a stub PDF url;
* - `settlementIbanMasked` is **last-4 only** — the full IBAN never leaves the mock.
* Admin mutations mutate the in-memory arrays; "my center" resolves to `MOCK_MY_CENTER_ID`.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
const isoDaysAgo = (d: number): string => new Date(Date.now() - d * DAY_MS).toISOString();
const dateDaysAgo = (d: number): string => isoDaysAgo(d).slice(0, 10);
const LATENCY_MS = 220;
const delay = <T>(v: T): Promise<T> => new Promise((r) => setTimeout(() => r(v), LATENCY_MS));
function paginate<T>(all: T[], params: PageParams): Paginated<T> {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.max(1, params.pageSize ?? (all.length || 1));
const start = (page - 1) * pageSize;
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
}
/** Mask an IBAN to last-4 (`"••••0001"`) — the mock never surfaces the full value. */
function maskIban(full: string): string {
return `••••${full.slice(-4)}`;
}
const CENTERS: PartnerCenter[] = [
{
id: 1,
name: 'مرکز پرستاری آسان‌گستر',
legalEntityType: 'llc',
mohEstablishmentPermitNo: 'MOH-12345',
technicalDirectorNurseUserId: 7015,
technicalDirectorLicenseNo: 'INO-88231',
enamadCode: 'EN-999',
settlementIbanMasked: '••••0001',
isMerchantOfRecord: true,
commissionRate: 0.05,
adminUserId: 8,
isActive: true,
verifiedAt: isoDaysAgo(40),
sponsoredNurseCount: 3,
onboardingState: 'verified',
createdAt: isoDaysAgo(120),
},
{
id: 2,
name: 'خانه سلامت مهرآوران',
legalEntityType: 'cooperative',
mohEstablishmentPermitNo: 'MOH-55621',
technicalDirectorNurseUserId: null,
technicalDirectorLicenseNo: 'INO-44120',
enamadCode: 'EN-514',
settlementIbanMasked: '••••7788',
isMerchantOfRecord: false,
commissionRate: 0.05,
adminUserId: 9,
isActive: true,
verifiedAt: isoDaysAgo(15),
sponsoredNurseCount: 1,
onboardingState: 'verified',
createdAt: isoDaysAgo(60),
},
{
id: 3,
name: 'مرکز نمونه (پیش‌نویس)',
legalEntityType: 'llc',
mohEstablishmentPermitNo: 'MOH-00099',
technicalDirectorNurseUserId: null,
technicalDirectorLicenseNo: null,
enamadCode: null,
settlementIbanMasked: null,
isMerchantOfRecord: false,
commissionRate: 0.05,
adminUserId: 10,
isActive: false,
verifiedAt: null,
sponsoredNurseCount: 0,
onboardingState: 'pending_verification',
createdAt: isoDaysAgo(5),
},
];
const NURSES: Record<number, SponsoredNurse[]> = {
1: [
{ nurseProfileId: 15, name: 'زهرا موسوی', isVerified: true },
{ nurseProfileId: 16, name: 'مریم رضایی', isVerified: true },
{ nurseProfileId: 17, name: 'سارا کاظمی', isVerified: false },
],
2: [{ nurseProfileId: 22, name: 'نگار احمدی', isVerified: true }],
3: [],
};
const BOOKINGS: Record<number, SponsoredBooking[]> = {
1: [
{ bookingId: 5001, patientName: 'حاج‌آقا موسوی', scheduledDate: dateDaysAgo(1), status: 'completed' },
{ bookingId: 5002, patientName: 'خانم احمدی', scheduledDate: dateDaysAgo(3), status: 'in_progress' },
{ bookingId: 5003, patientName: 'آقای کریمی', scheduledDate: dateDaysAgo(9), status: 'completed' },
],
2: [{ bookingId: 5101, patientName: 'خانم صادقی', scheduledDate: dateDaysAgo(2), status: 'confirmed' }],
3: [],
};
/** Build a reconciling commission invoice (VAT on the commission line only; total = comm + bnpl + vat). */
function makeInvoice(id: number, bookingId: number, grossIrr: bigint, commissionIrr: bigint, bnplIrr: bigint, vatRate: number, days: number): CenterInvoice {
const vatIrr = (commissionIrr * BigInt(Math.round(vatRate * 100))) / BigInt(100);
const total = commissionIrr + bnplIrr + vatIrr;
return {
id,
bookingId,
invoiceNumber: `INV-1405-${1000 + id}`,
grossIrr: String(grossIrr),
platformCommissionIrr: String(commissionIrr),
bnplCommissionIrr: bnplIrr > BigInt(0) ? String(bnplIrr) : null,
vatRate,
vatIrr: String(vatIrr),
totalIrr: String(total),
moadianReferenceNumber: id % 2 === 0 ? '1234567890123456789012' : null,
moadianStatus: id % 2 === 0 ? 'registered' : 'pending',
pdfUrl: `https://mock.balinyaar.local/invoices/${id}.pdf`,
issuedAt: isoDaysAgo(days),
};
}
const INVOICES: CenterInvoice[] = [
makeInvoice(1, 5001, BigInt(5_000_000), BigInt(750_000), BigInt(0), 0.1, 2),
makeInvoice(2, 5003, BigInt(5_000_000), BigInt(750_000), BigInt(60_000), 0.1, 9),
];
function centerById(id: number): PartnerCenter {
const c = CENTERS.find((x) => x.id === id);
if (!c) throw new Error(`Mock center ${id} not found`);
return c;
}
export const partnerCenterMockApi: PartnerCenterApi = {
listCenters: async (filters: PartnerCenterFilters, params) => {
let items = [...CENTERS];
if (filters.isMerchantOfRecord != null) items = items.filter((c) => c.isMerchantOfRecord === filters.isMerchantOfRecord);
if (filters.isActive != null) items = items.filter((c) => c.isActive === filters.isActive);
return delay(paginate(items, params));
},
getCenter: async (id) => delay(centerById(id)),
createCenter: async (input: PartnerCenterInput) => {
const id = Math.max(0, ...CENTERS.map((c) => c.id)) + 1;
const center: PartnerCenter = {
id,
name: input.name,
legalEntityType: input.legalEntityType,
mohEstablishmentPermitNo: input.mohEstablishmentPermitNo,
technicalDirectorNurseUserId: input.technicalDirectorNurseUserId ?? null,
technicalDirectorLicenseNo: input.technicalDirectorLicenseNo ?? null,
enamadCode: input.enamadCode ?? null,
settlementIbanMasked: input.settlementIban ? maskIban(input.settlementIban) : null,
isMerchantOfRecord: input.isMerchantOfRecord,
commissionRate: input.commissionRate,
adminUserId: input.adminUserId ?? null,
isActive: false,
verifiedAt: null,
sponsoredNurseCount: 0,
onboardingState: 'pending_verification',
createdAt: new Date().toISOString(),
};
CENTERS.push(center);
NURSES[id] = [];
BOOKINGS[id] = [];
return delay(center);
},
updateCenter: async (id, input: PartnerCenterInput) => {
const c = centerById(id);
Object.assign(c, {
name: input.name,
legalEntityType: input.legalEntityType,
mohEstablishmentPermitNo: input.mohEstablishmentPermitNo,
technicalDirectorNurseUserId: input.technicalDirectorNurseUserId ?? null,
technicalDirectorLicenseNo: input.technicalDirectorLicenseNo ?? null,
enamadCode: input.enamadCode ?? null,
isMerchantOfRecord: input.isMerchantOfRecord,
commissionRate: input.commissionRate,
adminUserId: input.adminUserId ?? null,
});
// write-then-masked: a supplied full IBAN is stored masked; never echoed back in plaintext
if (input.settlementIban) c.settlementIbanMasked = maskIban(input.settlementIban);
return delay(c);
},
verifyCenter: async (id) => {
const c = centerById(id);
c.verifiedAt = new Date().toISOString();
c.isActive = true;
c.onboardingState = 'verified';
return delay(undefined);
},
setCenterActive: async (id, isActive) => {
const c = centerById(id);
c.isActive = isActive;
c.onboardingState = deriveCenterState(c.isActive, c.verifiedAt);
return delay(undefined);
},
assignNurse: async (id, nurseProfileId, unlink) => {
const roster = (NURSES[id] ??= []);
if (unlink) {
NURSES[id] = roster.filter((n) => n.nurseProfileId !== nurseProfileId);
} else if (!roster.some((n) => n.nurseProfileId === nurseProfileId)) {
roster.push({ nurseProfileId, name: `پرستار #${nurseProfileId}`, isVerified: false });
}
centerById(id).sponsoredNurseCount = (NURSES[id] ?? []).length;
return delay(undefined);
},
getCenterSponsoredNurses: async (id) => delay([...(NURSES[id] ?? [])]),
// ── portal (my center) ──
getMyCenter: async () => delay(centerById(MOCK_MY_CENTER_ID)),
listMySponsoredNurses: async () => delay([...(NURSES[MOCK_MY_CENTER_ID] ?? [])]),
listMySponsoredBookings: async (filters: SponsoredBookingFilters, params) => {
let items = [...(BOOKINGS[MOCK_MY_CENTER_ID] ?? [])];
if (filters.status) items = items.filter((b) => b.status === filters.status);
return delay(paginate(items, params));
},
listMySettlement: async (params) => {
const center = centerById(MOCK_MY_CENTER_ID);
// Non-MoR centers issue no commission invoices here — the portal renders the "via Balinyaar" state.
const items = center.isMerchantOfRecord ? [...INVOICES] : [];
return delay(paginate(items, params));
},
};
@@ -0,0 +1,19 @@
/**
* When true, the partner-center domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `PartnerCenterApi` seam. **Mock is primary this phase:** the b15 contract exposes admin CRUD/verify/
* sponsor + a single `GET /centers/{id}/dashboard` portal endpoint, but the portal's split reads
* (my-center / sponsored-nurses / sponsored-bookings / settlement invoices), the activate/suspend toggle,
* and the write-then-masked IBAN flow are gaps (REQ-032/033). The mock returns **both** a merchant-of-record
* center (settlement view renders) and a non-MoR center (the "settlement via Balinyaar" state), verified +
* unverified nurses, sponsored bookings, and commission invoices with a fake مودیان reference + stub PDF.
*/
export const USE_PARTNER_MOCK = true;
/** Which mock center the portal ("my center") resolves to — flip to demo the MoR vs non-MoR states. */
export const MOCK_MY_CENTER_ID = 1;
export const PARTNER_PAGE_SIZE = 20;
export const PARTNER_LIST_STALE_TIME = 60 * 1000;
export const PARTNER_DETAIL_STALE_TIME = 30 * 1000;
export const PARTNER_GC_TIME = 5 * 60 * 1000;
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
/** Set/clear a nurse's sponsorship link to a center. Invalidate the roster + detail. */
export function useAssignNurseToPartnerCenter(id: number) {
const queryClient = useQueryClient();
return useMutation<void, unknown, { nurseProfileId: number; unlink: boolean }>({
mutationFn: ({ nurseProfileId, unlink }) => partnerCenterApi.assignNurse(id, nurseProfileId, unlink),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: centerKeys.sponsoredNurses(id) });
queryClient.invalidateQueries({ queryKey: centerKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: centerKeys.lists() });
},
});
}
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_DETAIL_STALE_TIME, PARTNER_GC_TIME } from '../constants';
/** The sponsored-nurse roster for one center (admin detail). */
export function useCenterSponsoredNurses(id: number | null) {
return useQuery({
queryKey: centerKeys.sponsoredNurses(id ?? -1),
queryFn: () => partnerCenterApi.getCenterSponsoredNurses(id!),
enabled: id != null && id > 0,
staleTime: PARTNER_DETAIL_STALE_TIME,
gcTime: PARTNER_GC_TIME,
});
}
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import type { PartnerCenter, PartnerCenterInput } from '../types';
/** Create a partner center (inactive until verified). Invalidate the center lists. */
export function useCreatePartnerCenter() {
const queryClient = useQueryClient();
return useMutation<PartnerCenter, unknown, PartnerCenterInput>({
mutationFn: (input) => partnerCenterApi.createCenter(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: centerKeys.lists() });
},
});
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_DETAIL_STALE_TIME, PARTNER_GC_TIME } from '../constants';
/**
* The signed-in center admin's **own** center (portal scope; server-resolved — never a raw id). Also the
* de-facto access gate for the `/partner` shell: a resolved center means in-scope; a 403/404 means the
* caller has no center (access-denied state).
*/
export function useMyPartnerCenter() {
return useQuery({
queryKey: centerKeys.myCenter(),
queryFn: () => partnerCenterApi.getMyCenter(),
staleTime: PARTNER_DETAIL_STALE_TIME,
gcTime: PARTNER_GC_TIME,
retry: false,
});
}
@@ -0,0 +1,19 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_GC_TIME, PARTNER_LIST_STALE_TIME, PARTNER_PAGE_SIZE } from '../constants';
/**
* The center's per-booking commission invoices (portal settlement view). Only meaningful for a
* merchant-of-record center — a non-MoR center returns an empty page and the portal shows the
* "settlement runs through Balinyaar" state.
*/
export function useMySettlement(page = 1) {
return useQuery({
queryKey: centerKeys.mySettlement({ page, pageSize: PARTNER_PAGE_SIZE }),
queryFn: () => partnerCenterApi.listMySettlement({ page, pageSize: PARTNER_PAGE_SIZE }),
staleTime: PARTNER_LIST_STALE_TIME,
gcTime: PARTNER_GC_TIME,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,16 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_GC_TIME, PARTNER_LIST_STALE_TIME, PARTNER_PAGE_SIZE } from '../constants';
import type { SponsoredBookingFilters } from '../types';
/** The bookings the signed-in center legally covers (portal; read-only summaries). Filter+page key cache. */
export function useMySponsoredBookings(filters: SponsoredBookingFilters, page = 1) {
return useQuery({
queryKey: centerKeys.mySponsoredBookings(filters, { page, pageSize: PARTNER_PAGE_SIZE }),
queryFn: () => partnerCenterApi.listMySponsoredBookings(filters, { page, pageSize: PARTNER_PAGE_SIZE }),
staleTime: PARTNER_LIST_STALE_TIME,
gcTime: PARTNER_GC_TIME,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_LIST_STALE_TIME, PARTNER_GC_TIME } from '../constants';
/** The nurses the signed-in center sponsors (portal). */
export function useMySponsoredNurses() {
return useQuery({
queryKey: centerKeys.mySponsoredNurses(),
queryFn: () => partnerCenterApi.listMySponsoredNurses(),
staleTime: PARTNER_LIST_STALE_TIME,
gcTime: PARTNER_GC_TIME,
});
}
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_DETAIL_STALE_TIME, PARTNER_GC_TIME } from '../constants';
/** Admin partner-center detail (IBAN masked last-4). */
export function usePartnerCenter(id: number | null) {
return useQuery({
queryKey: centerKeys.detail(id ?? -1),
queryFn: () => partnerCenterApi.getCenter(id!),
enabled: id != null && id > 0,
staleTime: PARTNER_DETAIL_STALE_TIME,
gcTime: PARTNER_GC_TIME,
});
}
@@ -0,0 +1,16 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_GC_TIME, PARTNER_LIST_STALE_TIME, PARTNER_PAGE_SIZE } from '../constants';
import type { PartnerCenterFilters } from '../types';
/** Admin list of partner centers (no IBAN, sponsored-nurse counts). Filters + page key the cache. */
export function usePartnerCenters(filters: PartnerCenterFilters, page = 1) {
return useQuery({
queryKey: centerKeys.list(filters, { page, pageSize: PARTNER_PAGE_SIZE }),
queryFn: () => partnerCenterApi.listCenters(filters, { page, pageSize: PARTNER_PAGE_SIZE }),
staleTime: PARTNER_LIST_STALE_TIME,
gcTime: PARTNER_GC_TIME,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
/** Activate / suspend a center (REQ-032). Invalidate list + detail. */
export function useSetPartnerCenterActive(id: number) {
const queryClient = useQueryClient();
return useMutation<void, unknown, boolean>({
mutationFn: (isActive) => partnerCenterApi.setCenterActive(id, isActive),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: centerKeys.lists() });
queryClient.invalidateQueries({ queryKey: centerKeys.detail(id) });
},
});
}
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import type { PartnerCenter, PartnerCenterInput } from '../types';
/** Update a partner center (replace semantics; IBAN write-then-masked). Invalidate list + detail. */
export function useUpdatePartnerCenter(id: number) {
const queryClient = useQueryClient();
return useMutation<PartnerCenter, unknown, PartnerCenterInput>({
mutationFn: (input) => partnerCenterApi.updateCenter(id, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: centerKeys.lists() });
queryClient.invalidateQueries({ queryKey: centerKeys.detail(id) });
},
});
}
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
/** Record licensing approval and activate a center. Invalidate list + detail. */
export function useVerifyPartnerCenter(id: number) {
const queryClient = useQueryClient();
return useMutation<void, unknown, void>({
mutationFn: () => partnerCenterApi.verifyCenter(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: centerKeys.lists() });
queryClient.invalidateQueries({ queryKey: centerKeys.detail(id) });
},
});
}
@@ -0,0 +1,16 @@
/**
* Partner-center domain barrel — re-exports **hooks only** (per the `services/{domain}` convention).
* Import types/keys/apis directly from their files when needed.
*/
export { usePartnerCenters } from './hooks/usePartnerCenters';
export { usePartnerCenter } from './hooks/usePartnerCenter';
export { useCenterSponsoredNurses } from './hooks/useCenterSponsoredNurses';
export { useCreatePartnerCenter } from './hooks/useCreatePartnerCenter';
export { useUpdatePartnerCenter } from './hooks/useUpdatePartnerCenter';
export { useVerifyPartnerCenter } from './hooks/useVerifyPartnerCenter';
export { useSetPartnerCenterActive } from './hooks/useSetPartnerCenterActive';
export { useAssignNurseToPartnerCenter } from './hooks/useAssignNurseToPartnerCenter';
export { useMyPartnerCenter } from './hooks/useMyPartnerCenter';
export { useMySponsoredNurses } from './hooks/useMySponsoredNurses';
export { useMySponsoredBookings } from './hooks/useMySponsoredBookings';
export { useMySettlement } from './hooks/useMySettlement';
+25
View File
@@ -0,0 +1,25 @@
import type { PageParams } from '@/lib/api/types';
import type { PartnerCenterFilters, SponsoredBookingFilters } from './types';
/**
* React Query key factory for the partner-center domain. Admin lists key on filters+page; the portal keys
* are scoped to "my center" (the server resolves the caller's own center — never a raw id). Mutations
* invalidate the affected sub-tree.
*/
export const centerKeys = {
all: ['partnerCenter'] as const,
lists: () => [...centerKeys.all, 'list'] as const,
list: (filters: PartnerCenterFilters, params: PageParams) => [...centerKeys.lists(), filters, params] as const,
details: () => [...centerKeys.all, 'detail'] as const,
detail: (id: number) => [...centerKeys.details(), id] as const,
sponsoredNurses: (id: number) => [...centerKeys.detail(id), 'sponsoredNurses'] as const,
// portal (my center)
myCenter: () => [...centerKeys.all, 'me'] as const,
mySponsoredNurses: () => [...centerKeys.myCenter(), 'nurses'] as const,
mySponsoredBookings: (filters: SponsoredBookingFilters, params: PageParams) =>
[...centerKeys.myCenter(), 'bookings', filters, params] as const,
mySettlement: (params: PageParams) => [...centerKeys.myCenter(), 'settlement', params] as const,
};
+138
View File
@@ -0,0 +1,138 @@
import type { PageParams, Paginated } from '@/lib/api/types';
/**
* Partner-center domain — the licensed sponsoring centers (پروانه تأسیس + مسئول فنی + نماد اعتماد
* الکترونیکی) that may be the **merchant-of-record / invoice issuer**, and the two audiences that read
* them: Balinyaar **admins** (list/create/verify/activate/sponsor) and a **center admin** in the separate
* partner-portal scope (their own center only). Shapes derive from the b15 contract
* (`dev/contracts/domains/messaging-notifications-admin.md`) and the b11 invoice shape.
*
* Load-bearing rules (phase §5):
* - **`settlementIban` is never returned in plaintext** — only a masked last-4 (`"••••0001"`). On create
* it is **write-then-masked** (submit the full IBAN, only last-4 shows afterwards).
* - **Merchant-of-record drives the settlement view** — the invoice/settlement surface renders only when
* `isMerchantOfRecord === true`.
* - **VAT is on the commission line only**, config-driven — never hardcode 10%.
* - **Tenancy** — a center admin sees only their own center (server-enforced); never fetch a raw id they
* don't own.
*/
/** A center's onboarding/verification lifecycle (derived from `isActive`/`verifiedAt`; the mock sets it). */
export type CenterOnboardingState = 'draft' | 'pending_verification' | 'verified' | 'suspended';
/** `PartnerCenter` detail (admin + portal). `settlementIbanMasked` is last-4 only, never the full IBAN. */
export interface PartnerCenter {
id: number;
name: string;
legalEntityType: string;
mohEstablishmentPermitNo: string;
technicalDirectorNurseUserId: number | null;
technicalDirectorLicenseNo: string | null;
enamadCode: string | null;
settlementIbanMasked: string | null;
isMerchantOfRecord: boolean;
commissionRate: number;
adminUserId: number | null;
isActive: boolean;
verifiedAt: string | null;
sponsoredNurseCount: number;
onboardingState: CenterOnboardingState;
createdAt: string;
}
/**
* Create/update body. `settlementIban` is the **full** IBAN, write-only — the server stores it masked and
* only ever returns the last-4. Required when `isMerchantOfRecord`. `commissionRate ∈ [0, 1)`.
*/
export interface PartnerCenterInput {
name: string;
legalEntityType: string;
mohEstablishmentPermitNo: string;
technicalDirectorNurseUserId?: number | null;
technicalDirectorLicenseNo?: string | null;
enamadCode?: string | null;
settlementIban?: string | null;
isMerchantOfRecord: boolean;
commissionRate: number;
adminUserId?: number | null;
}
/** A nurse sponsored by a center (roster + portal list). */
export interface SponsoredNurse {
nurseProfileId: number;
name: string;
isVerified: boolean;
}
/** A booking the center legally covers (portal list; read-only summary, no extra PII). */
export interface SponsoredBooking {
bookingId: number;
patientName: string;
scheduledDate: string;
status: string;
}
/** `invoices.moadian_status`. */
export type MoadianStatus = 'pending' | 'submitted' | 'registered' | 'failed';
/**
* A per-booking commission invoice (only meaningful when the center is merchant-of-record). The
* reconciling breakdown is **platform commission + BNPL commission + VAT = total**; `grossIrr` is shown
* as context, not part of the total (VAT is on the commission line, never the gross service fee). Money is
* IRR digit-strings.
*/
export interface CenterInvoice {
id: number;
bookingId: number;
invoiceNumber: string;
grossIrr: string;
platformCommissionIrr: string;
bnplCommissionIrr: string | null;
vatRate: number;
vatIrr: string;
/** commission + bnpl commission + vat (REQ-033 — the wire lacks a total; summed from served legs). */
totalIrr: string;
moadianReferenceNumber: string | null;
moadianStatus: MoadianStatus | null;
pdfUrl: string | null;
issuedAt: string;
}
/** Admin list filters. */
export interface PartnerCenterFilters {
isMerchantOfRecord?: boolean;
isActive?: boolean;
}
/** Bookings list filter (portal). */
export interface SponsoredBookingFilters {
status?: string;
}
/**
* The partner-center API seam — admin-side management + the center-scoped portal reads. The real client
* and the in-memory mock both implement it (selection by `USE_PARTNER_MOCK`).
*/
export interface PartnerCenterApi {
// admin-side
listCenters(filters: PartnerCenterFilters, params: PageParams): Promise<Paginated<PartnerCenter>>;
getCenter(id: number): Promise<PartnerCenter>;
createCenter(input: PartnerCenterInput): Promise<PartnerCenter>;
updateCenter(id: number, input: PartnerCenterInput): Promise<PartnerCenter>;
verifyCenter(id: number): Promise<void>;
setCenterActive(id: number, isActive: boolean): Promise<void>;
assignNurse(id: number, nurseProfileId: number, unlink: boolean): Promise<void>;
getCenterSponsoredNurses(id: number): Promise<SponsoredNurse[]>;
// portal (center-scoped)
getMyCenter(): Promise<PartnerCenter>;
listMySponsoredNurses(): Promise<SponsoredNurse[]>;
listMySponsoredBookings(filters: SponsoredBookingFilters, params: PageParams): Promise<Paginated<SponsoredBooking>>;
listMySettlement(params: PageParams): Promise<Paginated<CenterInvoice>>;
}
/** Derive the onboarding state from a center's `isActive`/`verifiedAt` (the real-path fallback). */
export function deriveCenterState(isActive: boolean, verifiedAt: string | null): CenterOnboardingState {
if (verifiedAt && isActive) return 'verified';
if (verifiedAt && !isActive) return 'suspended';
return 'pending_verification';
}
@@ -2,17 +2,27 @@ import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import { PAYOUTS_PAGE_SIZE } from '../constants';
import type {
AdminPayoutBatchDetail,
AdminPayoutRow,
EarningsListParams,
EligibleNurseEarnings,
NurseEarningsItem,
NurseEarningsSummary,
NursePayoutDetail,
NursePayoutHistoryItem,
PayoutBatchFilters,
PayoutBatchStatus,
PayoutBatchSummary,
PayoutStatus,
PayoutsApi,
} from '../types';
import type { PageParams } from '@/lib/api/types';
const NURSE_PAYOUTS = '/api/v1/nurse_payouts';
const ADMIN_PAYOUTS = '/api/v1/admin_payouts';
/** The header b13's process/retry read the per-run idempotency key from (same convention as b10/b12). */
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
/**
* The b13 nurse history payload (`GET nurse_payouts/history` → `NursePayoutHistoryDto`). Note it carries
@@ -51,6 +61,91 @@ function toHistoryItem(wire: NursePayoutHistoryWire): NursePayoutHistoryItem {
};
}
// ── Admin batch wire DTOs (`admin_payouts/*`) ─────────────────────────────────────────────────────────
/** `PayoutBatchDto` — note it has `initiatedByAdminId` but **no** `holidayShifted` flag (REQ-036). */
interface PayoutBatchWire {
id: number;
periodStart: string;
periodEnd: string;
processingDate: string;
totalAmount: string;
payoutCount: number;
status: PayoutBatchStatus;
processedAt: string | null;
failureNotes: string | null;
createdAt: string;
}
/** `PayoutDto` — note the transferred amount is `amount`, mapped to `amountIrr`. */
interface PayoutWire {
id: number;
nurseId: number;
nurseName: string | null;
maskedIban: string;
grossEarningsIrr: string;
clawbackAppliedIrr: string;
netAmountIrr: string;
amount: string;
status: PayoutStatus;
transferReference: string | null;
paidAt: string | null;
failureReason: string | null;
bookings: { bookingId: number; sessionId: number | null; payoutAmountIrr: string }[];
}
interface PayoutBatchDetailWire {
batch: PayoutBatchWire;
payouts: PayoutWire[];
total: number;
page: number;
pageSize: number;
}
interface GeneratePayoutBatchResultWire {
batch: PayoutBatchWire;
}
function toBatchSummary(wire: PayoutBatchWire): PayoutBatchSummary {
return {
id: wire.id,
periodStart: wire.periodStart,
periodEnd: wire.periodEnd,
processingDate: wire.processingDate,
totalAmount: wire.totalAmount,
payoutCount: wire.payoutCount,
status: wire.status,
processedAt: wire.processedAt,
failureNotes: wire.failureNotes,
createdAt: wire.createdAt,
// REQ-036: PayoutBatchDto exposes no holidayShifted flag — a single preview endpoint returning
// eligible+skipped+processingDate+holidayShifted would carry it. Defaults false until then.
holidayShifted: false,
};
}
function toPayoutRow(wire: PayoutWire): AdminPayoutRow {
return {
id: wire.id,
nurseId: wire.nurseId,
nurseName: wire.nurseName,
maskedIban: wire.maskedIban,
grossEarningsIrr: wire.grossEarningsIrr,
clawbackAppliedIrr: wire.clawbackAppliedIrr,
netAmountIrr: wire.netAmountIrr,
amountIrr: wire.amount,
status: wire.status,
transferReference: wire.transferReference,
paidAt: wire.paidAt,
failureReason: wire.failureReason,
bookings: wire.bookings.map((b) => ({
bookingId: b.bookingId,
sessionId: b.sessionId,
payoutAmountIrr: b.payoutAmountIrr,
})),
};
}
/**
* Real HTTP implementation of the `PayoutsApi` seam (b13 contract `dev/contracts/domains/payouts.md`,
* swagger `dev/contracts/openapi/swagger.v1.json`). Only `getNursePayoutHistory` maps a **published** nurse
@@ -90,4 +185,87 @@ export const payoutsClientApi: PayoutsApi = {
getNursePayoutDetail: async (payoutId: number) =>
unwrap(await clientFetch<ApiEnvelope<NursePayoutDetail>>(`${NURSE_PAYOUTS}/${payoutId}`)),
// ── Admin batch actions (`admin_payouts/*`) ─────────────────────────────────────────────────────────
listPayoutBatches: async (
filters: PayoutBatchFilters,
params: PageParams,
): Promise<Paginated<PayoutBatchSummary>> => {
const query = new URLSearchParams();
if (filters.status) query.set('status', filters.status);
query.set('page', String(params.page ?? 1));
query.set('pageSize', String(params.pageSize ?? PAYOUTS_PAGE_SIZE));
const page = unwrap(
await clientFetch<ApiEnvelope<Paginated<PayoutBatchWire>>>(`${ADMIN_PAYOUTS}/batches?${query.toString()}`),
);
return { ...page, items: page.items.map(toBatchSummary) };
},
previewPayoutBatch: async (periodStart: string, periodEnd: string) => {
const query = new URLSearchParams({ periodStart, periodEnd });
const page = unwrap(
await clientFetch<ApiEnvelope<Paginated<EligibleNurseEarnings>>>(
`${ADMIN_PAYOUTS}/eligible?${query.toString()}`,
),
);
const eligible = page.items;
const totalNet = eligible.reduce((sum, e) => sum + BigInt(e.netAmountIrr), BigInt(0));
// REQ-036: a single preview endpoint returning eligible + skipped + processingDate + holidayShifted in
// one shot. The b13 `eligible` read is paged and returns only the eligible rows — no skipped list (that
// is materialized by the generate call) and no processingDate/holidayShifted — so carry what we can:
// processingDate falls back to periodEnd and skipped is empty until the preview route lands.
return {
periodStart,
periodEnd,
processingDate: periodEnd,
holidayShifted: false,
eligible,
skipped: [],
totalNetIrr: String(totalNet),
};
},
runPayoutBatch: async (periodStart: string, periodEnd: string, idempotencyKey: string) => {
const result = unwrap(
await clientFetch<ApiEnvelope<GeneratePayoutBatchResultWire>>(`${ADMIN_PAYOUTS}/batches`, {
method: 'POST',
headers: { [IDEMPOTENCY_KEY_HEADER]: idempotencyKey },
body: JSON.stringify({ periodStart, periodEnd }),
}),
);
return toBatchSummary(result.batch);
},
getPayoutBatchDetail: async (batchId: number, page: number): Promise<AdminPayoutBatchDetail> => {
const query = new URLSearchParams({ page: String(page) });
const detail = unwrap(
await clientFetch<ApiEnvelope<PayoutBatchDetailWire>>(
`${ADMIN_PAYOUTS}/batches/${batchId}?${query.toString()}`,
),
);
return {
batch: toBatchSummary(detail.batch),
payouts: detail.payouts.map(toPayoutRow),
total: detail.total,
page: detail.page,
pageSize: detail.pageSize,
};
},
retryPayout: async (payoutId: number, idempotencyKey: string) => {
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_PAYOUTS}/${payoutId}/retry`, {
method: 'POST',
headers: { [IDEMPOTENCY_KEY_HEADER]: idempotencyKey },
});
},
recordTransferReference: async (payoutId: number, reference: string) => {
// REQ-036: b13 has `mark_failed` but no record-transfer-reference route — a manually reconciled bank
// transfer reference has nowhere to land. Proposed action-style slug; 404s until the backend adds it.
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_PAYOUTS}/${payoutId}/transfer_reference`, {
method: 'POST',
body: JSON.stringify({ reference }),
});
},
};
+371 -1
View File
@@ -1,14 +1,21 @@
import type { Paginated } from '@/lib/api/types';
import type { PageParams } from '@/lib/api/types';
import { MOCK_SCENARIO } from '../constants';
import { ADMIN_BATCH_DETAIL_PAGE_SIZE, MOCK_SCENARIO } from '../constants';
import type {
AdminPayoutBatchDetail,
AdminPayoutRow,
EarningsListParams,
EarningsState,
EligibleNurseEarnings,
NurseEarningsItem,
NurseEarningsSummary,
NursePayoutDetail,
NursePayoutHistoryItem,
PayoutBatchFilters,
PayoutBatchPreview,
PayoutBatchSummary,
PayoutsApi,
SkippedNurse,
} from '../types';
/**
@@ -320,6 +327,262 @@ function delay<T>(value: T): Promise<T> {
const STATE_ORDER: Record<EarningsState, number> = { pending: 0, eligible: 1, paid: 2, clawback_applied: 3 };
// ══ Admin batch actions (b13 admin_payouts/*) — mutable in-memory state ════════════════════════════════
//
// Engineered to exercise every admin UI state and stay money-correct: batches spanning completed /
// partially_failed / processing, at least one holiday-shifted; per-payout rows including a `failed` row
// (so retry is demonstrable) and `paid` rows with a last-4 masked IBAN + transfer reference. Every row
// reconciles `gross clawback = net = amount`, its booking links sum to its gross, and each batch's
// `totalAmount = Σ its payouts' net`. Rows are **mutated in place** by retry / record-reference so state
// persists across calls within the session.
/** ISO `YYYY-MM-DD` shifted `days` off `isoDate` (holiday-shifted processing date). */
function isoDateShift(isoDate: string, days: number): string {
return new Date(new Date(isoDate).getTime() + days * DAY_MS).toISOString().slice(0, 10);
}
/** IRR-string sum (integer-safe BigInt; never a float). */
function sumIrr(values: string[]): string {
return String(values.reduce((total, v) => total + BigInt(v), BigInt(0)));
}
const ADMIN_MASKED_IBAN_A = 'IR••••••••••••••••••4821';
const ADMIN_MASKED_IBAN_B = 'IR••••••••••••••••••7734';
const ADMIN_MASKED_IBAN_C = 'IR••••••••••••••••••1092';
/** Per-nurse payout rows keyed by batch id. Mutated in place by `retryPayout` / `recordTransferReference`. */
const BATCH_DETAILS: Record<number, AdminPayoutRow[]> = {
// 7101 — partially_failed: one paid + one failed (retryable). Σ net = 4,250,000 + 3,400,000 = 7,650,000.
7101: [
{
id: 9201,
nurseId: 301,
nurseName: 'زهرا موسوی',
maskedIban: ADMIN_MASKED_IBAN_A,
grossEarningsIrr: '4250000',
clawbackAppliedIrr: '0',
netAmountIrr: '4250000',
amountIrr: '4250000',
status: 'paid',
transferReference: 'PAYA-14050412-9201',
paidAt: isoFromNowHours(-20),
failureReason: null,
bookings: [{ bookingId: 5003, sessionId: 1, payoutAmountIrr: '4250000' }],
},
{
id: 9202,
nurseId: 302,
nurseName: 'مریم احمدی',
maskedIban: ADMIN_MASKED_IBAN_B,
grossEarningsIrr: '3400000',
clawbackAppliedIrr: '0',
netAmountIrr: '3400000',
amountIrr: '3400000',
status: 'failed',
transferReference: null,
paidAt: null,
failureReason: 'invalid_sheba',
bookings: [{ bookingId: 5002, sessionId: 1, payoutAmountIrr: '3400000' }],
},
],
// 7102 — completed: a clawback-netted paid + a clean paid. Σ net = 4,250,000 + 2,000,000 = 6,250,000.
7102: [
{
id: 9203,
nurseId: 303,
nurseName: 'فاطمه کریمی',
maskedIban: ADMIN_MASKED_IBAN_C,
grossEarningsIrr: '5000000',
clawbackAppliedIrr: '750000',
netAmountIrr: '4250000',
amountIrr: '4250000',
status: 'paid',
transferReference: 'SATNA-14050405-9203',
paidAt: isoFromNowHours(-96),
failureReason: null,
bookings: [
{ bookingId: 4990, sessionId: 1, payoutAmountIrr: '2750000' },
{ bookingId: 4991, sessionId: 1, payoutAmountIrr: '2250000' },
],
},
{
id: 9204,
nurseId: 304,
nurseName: 'سکینه رضایی',
maskedIban: ADMIN_MASKED_IBAN_A,
grossEarningsIrr: '2000000',
clawbackAppliedIrr: '0',
netAmountIrr: '2000000',
amountIrr: '2000000',
status: 'paid',
transferReference: 'PAYA-14050405-9204',
paidAt: isoFromNowHours(-100),
failureReason: null,
bookings: [{ bookingId: 5006, sessionId: 1, payoutAmountIrr: '2000000' }],
},
],
// 7103 — processing: submitted, awaiting settlement. Σ net = 3,000,000.
7103: [
{
id: 9205,
nurseId: 305,
nurseName: 'اکرم حسینی',
maskedIban: ADMIN_MASKED_IBAN_B,
grossEarningsIrr: '3000000',
clawbackAppliedIrr: '0',
netAmountIrr: '3000000',
amountIrr: '3000000',
status: 'submitted',
transferReference: null,
paidAt: null,
failureReason: null,
bookings: [{ bookingId: 5010, sessionId: 1, payoutAmountIrr: '3000000' }],
},
],
// 7104 — completed (holiday-shifted), older. Σ net = 4,250,000.
7104: [
{
id: 9206,
nurseId: 301,
nurseName: 'زهرا موسوی',
maskedIban: ADMIN_MASKED_IBAN_A,
grossEarningsIrr: '4250000',
clawbackAppliedIrr: '0',
netAmountIrr: '4250000',
amountIrr: '4250000',
status: 'paid',
transferReference: 'SATNA-14050328-9206',
paidAt: isoFromNowHours(-260),
failureReason: null,
bookings: [{ bookingId: 4980, sessionId: 1, payoutAmountIrr: '4250000' }],
},
],
};
/** Batch headers (newest-first by `createdAt` at read time). `totalAmount = Σ its detail rows' net`. */
const BATCHES: PayoutBatchSummary[] = [
{
id: 7103,
periodStart: isoDateDaysAgo(7),
periodEnd: isoDateDaysAgo(1),
processingDate: isoDateDaysAgo(0),
totalAmount: '3000000',
payoutCount: 1,
status: 'processing',
processedAt: null,
failureNotes: null,
createdAt: isoFromNowHours(-6),
holidayShifted: false,
},
{
id: 7101,
periodStart: isoDateDaysAgo(14),
periodEnd: isoDateDaysAgo(8),
processingDate: isoDateDaysAgo(6),
totalAmount: '7650000',
payoutCount: 2,
status: 'partially_failed',
processedAt: isoFromNowHours(-20),
failureNotes: '۱ انتقال توسط سامانه بانکی رد شد (invalid_sheba)',
createdAt: isoFromNowHours(-26),
holidayShifted: true,
},
{
id: 7102,
periodStart: isoDateDaysAgo(14),
periodEnd: isoDateDaysAgo(8),
processingDate: isoDateDaysAgo(7),
totalAmount: '6250000',
payoutCount: 2,
status: 'completed',
processedAt: isoFromNowHours(-96),
failureNotes: null,
createdAt: isoFromNowHours(-120),
holidayShifted: false,
},
{
id: 7104,
periodStart: isoDateDaysAgo(21),
periodEnd: isoDateDaysAgo(15),
processingDate: isoDateDaysAgo(13),
totalAmount: '4250000',
payoutCount: 1,
status: 'completed',
processedAt: isoFromNowHours(-260),
failureNotes: null,
createdAt: isoFromNowHours(-264),
holidayShifted: true,
},
];
/** Idempotency ledgers: the SAME key returns the SAME result (never a double-run / double-pay). */
const RUN_BATCH_IDEMPOTENCY = new Map<string, PayoutBatchSummary>();
const RETRY_IDEMPOTENCY = new Set<string>();
let nextBatchId = 7200;
let nextAdminPayoutId = 9300;
/**
* The eligibility dry-run: 3 eligible nurses (one with a netted clawback), one flagged with no verified
* IBAN (`hasVerifiedPrimaryIban:false` shown, not dropped), one skipped (`no_verified_primary_iban`), and
* a holiday-shifted processing date. `totalNetIrr = Σ eligible.netAmountIrr`. The server owns eligibility +
* the shifted date; the client only renders this.
*/
function buildPreview(periodStart: string, periodEnd: string): PayoutBatchPreview {
const eligible: EligibleNurseEarnings[] = [
{
nurseId: 301,
nurseName: 'زهرا موسوی',
bookingCount: 2,
grossEarningsIrr: '5000000',
clawbackAppliedIrr: '0',
netAmountIrr: '5000000',
hasVerifiedPrimaryIban: true,
},
{
nurseId: 302,
nurseName: 'مریم احمدی',
bookingCount: 1,
grossEarningsIrr: '3400000',
clawbackAppliedIrr: '0',
netAmountIrr: '3400000',
hasVerifiedPrimaryIban: true,
},
{
// netted clawback: 6,000,000 gross 1,500,000 clawback = 4,500,000 net
nurseId: 303,
nurseName: 'فاطمه کریمی',
bookingCount: 3,
grossEarningsIrr: '6000000',
clawbackAppliedIrr: '1500000',
netAmountIrr: '4500000',
hasVerifiedPrimaryIban: true,
},
{
// flagged (no verified primary IBAN) — surfaced with the flag, NOT dropped (contract semantics)
nurseId: 306,
nurseName: 'نرگس علوی',
bookingCount: 1,
grossEarningsIrr: '2000000',
clawbackAppliedIrr: '0',
netAmountIrr: '2000000',
hasVerifiedPrimaryIban: false,
},
];
const skipped: SkippedNurse[] = [
{ nurseId: 307, nurseName: 'طاهره یوسفی', grossEarningsIrr: '1200000', reason: 'no_verified_primary_iban' },
];
return {
periodStart,
periodEnd,
// holiday-shifted a couple days past periodEnd (the server owns the shift; the client renders it)
processingDate: isoDateShift(periodEnd, 2),
holidayShifted: true,
eligible,
skipped,
totalNetIrr: sumIrr(eligible.map((e) => e.netAmountIrr)),
};
}
export const payoutsMockApi: PayoutsApi = {
getNurseEarningsBalance: async () => delay(buildSummary()),
@@ -336,4 +599,111 @@ export const payoutsMockApi: PayoutsApi = {
if (!detail) throw new Error(`Mock payout ${payoutId} not found`);
return delay(detail);
},
// ── Admin batch actions ─────────────────────────────────────────────────────────────────────────────
listPayoutBatches: async (filters: PayoutBatchFilters, params: PageParams) => {
const sorted = [...BATCHES].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
const filtered = filters.status ? sorted.filter((b) => b.status === filters.status) : sorted;
return delay(paginate(filtered, params));
},
previewPayoutBatch: async (periodStart: string, periodEnd: string) =>
delay(buildPreview(periodStart, periodEnd)),
runPayoutBatch: async (periodStart: string, periodEnd: string, idempotencyKey: string) => {
// Idempotency: the same key returns the same batch — a retried run never opens a second batch.
const prior = RUN_BATCH_IDEMPOTENCY.get(idempotencyKey);
if (prior) return delay(prior);
const preview = buildPreview(periodStart, periodEnd);
// Only nurses with a verified primary IBAN are materialized into payouts (the flagged ones are skipped).
const payable = preview.eligible.filter((e) => e.hasVerifiedPrimaryIban);
const id = nextBatchId++;
const batch: PayoutBatchSummary = {
id,
periodStart,
periodEnd,
processingDate: preview.processingDate,
totalAmount: sumIrr(payable.map((e) => e.netAmountIrr)),
payoutCount: payable.length,
status: 'processing',
processedAt: null,
failureNotes: null,
createdAt: new Date().toISOString(),
holidayShifted: preview.holidayShifted,
};
BATCHES.unshift(batch);
BATCH_DETAILS[id] = payable.map((e) => ({
id: nextAdminPayoutId++,
nurseId: e.nurseId,
nurseName: e.nurseName,
maskedIban: ADMIN_MASKED_IBAN_A,
grossEarningsIrr: e.grossEarningsIrr,
clawbackAppliedIrr: e.clawbackAppliedIrr,
netAmountIrr: e.netAmountIrr,
amountIrr: e.netAmountIrr,
status: 'submitted',
transferReference: null,
paidAt: null,
failureReason: null,
bookings: [{ bookingId: 5000 + e.nurseId, sessionId: 1, payoutAmountIrr: e.grossEarningsIrr }],
}));
RUN_BATCH_IDEMPOTENCY.set(idempotencyKey, batch);
return delay(batch);
},
getPayoutBatchDetail: async (batchId: number, page: number) => {
const batch = BATCHES.find((b) => b.id === batchId);
if (!batch) throw new Error(`Mock payout batch ${batchId} not found`);
const rows = BATCH_DETAILS[batchId] ?? [];
const pageSize = ADMIN_BATCH_DETAIL_PAGE_SIZE;
const p = Math.max(1, page);
const start = (p - 1) * pageSize;
const detail: AdminPayoutBatchDetail = {
batch,
payouts: rows.slice(start, start + pageSize),
total: rows.length,
page: p,
pageSize,
};
return delay(detail);
},
retryPayout: async (payoutId: number, idempotencyKey: string) => {
// Idempotency: a re-fired retry with the same key never re-applies (no double-pay).
if (RETRY_IDEMPOTENCY.has(idempotencyKey)) return delay(undefined);
RETRY_IDEMPOTENCY.add(idempotencyKey);
for (const [key, rows] of Object.entries(BATCH_DETAILS)) {
const row = rows.find((r) => r.id === payoutId);
if (!row) continue;
if (row.status === 'failed') {
row.status = 'paid';
row.failureReason = null;
row.paidAt = new Date().toISOString();
row.transferReference = `PAYA-RETRY-${payoutId}`;
row.amountIrr = row.netAmountIrr;
const batch = BATCHES.find((b) => b.id === Number(key));
// if that was the last failure in the batch, it re-settles partially_failed → completed
if (batch && batch.status === 'partially_failed' && rows.every((r) => r.status !== 'failed')) {
batch.status = 'completed';
batch.processedAt = new Date().toISOString();
batch.failureNotes = null;
}
}
break;
}
return delay(undefined);
},
recordTransferReference: async (payoutId: number, reference: string) => {
for (const rows of Object.values(BATCH_DETAILS)) {
const row = rows.find((r) => r.id === payoutId);
if (row) {
row.transferReference = reference;
break;
}
}
return delay(undefined);
},
};
+10
View File
@@ -34,5 +34,15 @@ export const PAYOUT_HISTORY_STALE_TIME = 5 * 60 * 1000;
export const PAYOUT_DETAIL_STALE_TIME = 10 * 60 * 1000;
export const PAYOUTS_GC_TIME = 15 * 60 * 1000;
/**
* The **admin** reconciliation surfaces are more volatile than the nurse read within one session an admin
* opens a draft, runs it, retries a failed payout, records a reference. A short `staleTime`, backed by
* explicit invalidation on every mutation (`useRunPayoutBatch`/`useRetryPayout`/`useRecordTransferReference`).
*/
export const ADMIN_BATCHES_STALE_TIME = 30 * 1000;
export const ADMIN_BATCH_DETAIL_STALE_TIME = 30 * 1000;
/** Page size for the admin batch-detail payout rows (contract default 50). */
export const ADMIN_BATCH_DETAIL_PAGE_SIZE = 50;
/** Page size for the earnings + payout-history lists (api-conventions `pageSize`). */
export const PAYOUTS_PAGE_SIZE = 10;
@@ -0,0 +1,20 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { payoutsApi } from '../apis';
import { payoutKeys } from '../keys';
import { ADMIN_BATCH_DETAIL_STALE_TIME, PAYOUTS_GC_TIME } from '../constants';
/**
* One batch expanded its header + paginated per-payout rows (status, net, masked IBAN, transfer reference,
* booking links). Disabled until a batch is selected (`batchId` present); each page keys separately and
* `keepPreviousData` holds the prior page while the next loads. Invalidated by retry / record-reference.
*/
export function usePayoutBatchDetail(batchId: number | null, page: number) {
return useQuery({
queryKey: payoutKeys.adminBatchDetail(batchId ?? -1, page),
queryFn: () => payoutsApi.getPayoutBatchDetail(batchId as number, page),
enabled: batchId != null && batchId > 0,
staleTime: ADMIN_BATCH_DETAIL_STALE_TIME,
gcTime: PAYOUTS_GC_TIME,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,22 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { payoutsApi } from '../apis';
import { payoutKeys } from '../keys';
import { ADMIN_BATCHES_STALE_TIME, PAYOUTS_GC_TIME, PAYOUTS_PAGE_SIZE } from '../constants';
import type { PayoutBatchFilters } from '../types';
/**
* The admin reconciliation list of payout batches (newest first). The **filter + page params are part of the
* query key** so each status filter / page caches independently; `keepPreviousData` avoids an empty flash
* while paging or switching filters. Volatile relative to the nurse read a short `staleTime`, refreshed by
* explicit invalidation from the batch mutations.
*/
export function usePayoutBatches(filters: PayoutBatchFilters, page: number) {
const params = { page, pageSize: PAYOUTS_PAGE_SIZE };
return useQuery({
queryKey: payoutKeys.adminBatches(filters, params),
queryFn: () => payoutsApi.listPayoutBatches(filters, params),
staleTime: ADMIN_BATCHES_STALE_TIME,
gcTime: PAYOUTS_GC_TIME,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,15 @@
import { useMutation } from '@tanstack/react-query';
import { payoutsApi } from '../apis';
import type { PayoutBatchPreview } from '../types';
/**
* The eligibility dry-run for a window a **mutation**, not an auto-fetching query: it runs only when the
* admin explicitly asks to preview (never on mount), and its result (the eligible/skipped breakdown + the
* server's holiday-shifted processing date) is read from the mutation's `data`. The client renders it; it
* never computes eligibility or the shifted date.
*/
export function usePreviewPayoutBatch() {
return useMutation<PayoutBatchPreview, unknown, { periodStart: string; periodEnd: string }>({
mutationFn: ({ periodStart, periodEnd }) => payoutsApi.previewPayoutBatch(periodStart, periodEnd),
});
}
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { payoutsApi } from '../apis';
import { payoutKeys } from '../keys';
/**
* Record a manually reconciled bank transfer reference on a payout (REQ-036 b13 has `mark_failed` but no
* record-reference route). On success we invalidate the owning batch's detail so the reference renders on the
* row without a manual refresh.
*/
export function useRecordTransferReference() {
const queryClient = useQueryClient();
return useMutation<void, unknown, { payoutId: number; reference: string; batchId: number }>({
mutationFn: ({ payoutId, reference }) => payoutsApi.recordTransferReference(payoutId, reference),
onSuccess: (_data, { batchId }) => {
queryClient.invalidateQueries({ queryKey: [...payoutKeys.adminBatchDetails(), batchId] });
},
});
}
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { payoutsApi } from '../apis';
import { payoutKeys } from '../keys';
/**
* Re-submit a single `failed` payout to the bank rail. **Idempotency-keyed**: a re-fired retry with the same
* key never re-pays. On success we invalidate the owning batch's detail (the row flips `failed → paid`, and
* if it was the batch's last failure the batch re-settles `partially_failed → completed`) and the batches
* list (its status may have changed).
*/
export function useRetryPayout() {
const queryClient = useQueryClient();
return useMutation<void, unknown, { payoutId: number; idempotencyKey: string; batchId: number }>({
mutationFn: ({ payoutId, idempotencyKey }) => payoutsApi.retryPayout(payoutId, idempotencyKey),
onSuccess: (_data, { batchId }) => {
queryClient.invalidateQueries({ queryKey: [...payoutKeys.adminBatchDetails(), batchId] });
queryClient.invalidateQueries({ queryKey: payoutKeys.adminBatchLists() });
},
});
}
@@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { payoutsApi } from '../apis';
import { payoutKeys } from '../keys';
import type { PayoutBatchSummary } from '../types';
/**
* Open + run a payout batch for a window. **Idempotency-keyed**: the caller passes a stable `idempotencyKey`
* per run so a retried submit converges on the same batch (never a double-run). On success we invalidate the
* batches list so the new batch appears at the top without a manual refresh. Domain 4xx (e.g. no eligible
* bookings) surface to the caller's `onError`.
*/
export function useRunPayoutBatch() {
const queryClient = useQueryClient();
return useMutation<
PayoutBatchSummary,
unknown,
{ periodStart: string; periodEnd: string; idempotencyKey: string }
>({
mutationFn: ({ periodStart, periodEnd, idempotencyKey }) =>
payoutsApi.runPayoutBatch(periodStart, periodEnd, idempotencyKey),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: payoutKeys.adminBatchLists() });
},
});
}
+8
View File
@@ -6,3 +6,11 @@ export { useNurseEarningsBalance } from './hooks/useNurseEarningsBalance';
export { useNurseEarnings } from './hooks/useNurseEarnings';
export { useNursePayoutHistory } from './hooks/useNursePayoutHistory';
export { useNursePayoutDetail } from './hooks/useNursePayoutDetail';
// Admin batch actions (b13 admin_payouts/*).
export { usePayoutBatches } from './hooks/usePayoutBatches';
export { usePayoutBatchDetail } from './hooks/usePayoutBatchDetail';
export { usePreviewPayoutBatch } from './hooks/usePreviewPayoutBatch';
export { useRunPayoutBatch } from './hooks/useRunPayoutBatch';
export { useRetryPayout } from './hooks/useRetryPayout';
export { useRecordTransferReference } from './hooks/useRecordTransferReference';
+12 -1
View File
@@ -1,4 +1,5 @@
import type { EarningsState } from './types';
import type { PageParams } from '@/lib/api/types';
import type { EarningsState, PayoutBatchFilters } from './types';
/**
* React Query key factory for the payouts domain (hierarchical, per the `services/{domain}` pattern).
@@ -22,4 +23,14 @@ export const payoutKeys = {
details: () => [...payoutKeys.all, 'detail'] as const,
detail: (payoutId: number) => [...payoutKeys.details(), payoutId] as const,
// Admin batch actions. The filter object + page params are part of the key (so each filter/page caches
// separately); mutations invalidate the `adminBatchLists` / `adminBatchDetails` prefixes.
adminBatchLists: () => [...payoutKeys.all, 'admin_batches'] as const,
adminBatches: (filters: PayoutBatchFilters, params: PageParams) =>
[...payoutKeys.adminBatchLists(), filters, params] as const,
adminBatchDetails: () => [...payoutKeys.all, 'admin_batch_detail'] as const,
adminBatchDetail: (batchId: number, page: number) =>
[...payoutKeys.adminBatchDetails(), batchId, page] as const,
};
+111 -2
View File
@@ -173,14 +173,123 @@ export interface EarningsListParams extends PageParams {
state?: EarningsState;
}
// ── Admin payout-batch actions (b13 `admin_payouts/*`) ────────────────────────────────────────────────
//
// The admin side of the same weekly engine: preview eligible earnings, open/run a batch, read batches +
// per-payout rows, retry a failed payout, record a reconciled transfer reference. These map published b13
// routes 1:1 (`clientApi.ts`) but the domain stays **mock-primary** this phase (see `constants.ts`).
// Money is IRR digit-strings; the client renders the **server's** eligibility + holiday-shifted date — it
// never computes them. Invariants: per eligible nurse / payout `gross clawback = net`; a batch's
// `totalAmount = Σ its payouts' net`; a payout's booking links sum to its `grossEarningsIrr`.
/** One nurse's payout-eligible, unpaid earnings for a window (`EligibleNurseEarningsDto`). A nurse missing a
* verified primary IBAN is **flagged** (`hasVerifiedPrimaryIban:false`) here, not dropped. */
export interface EligibleNurseEarnings {
nurseId: number;
nurseName: string | null;
bookingCount: number;
grossEarningsIrr: string;
clawbackAppliedIrr: string;
/** `= grossEarningsIrr clawbackAppliedIrr` (clawbacks netted into the preview). */
netAmountIrr: string;
hasVerifiedPrimaryIban: boolean;
}
/** A nurse excluded from a generated batch, with the reason (`SkippedNurseDto`; e.g. `no_verified_primary_iban`). */
export interface SkippedNurse {
nurseId: number;
nurseName: string | null;
grossEarningsIrr: string;
reason: string;
}
/** A `nurse_payout_batches` header for the admin reconciliation list (`PayoutBatchDto` + `holidayShifted`). */
export interface PayoutBatchSummary {
id: number;
/** Holiday-shifted server-side; ISO dates `YYYY-MM-DD`. */
periodStart: string;
periodEnd: string;
processingDate: string;
/** `= Σ its payouts' net_amount_irr`. IRR digit-string. */
totalAmount: string;
payoutCount: number;
status: PayoutBatchStatus;
processedAt: string | null;
failureNotes: string | null;
createdAt: string;
/** Whether `processingDate` was shifted off a bank-closed day (server truth; the client only renders it). */
holidayShifted: boolean;
}
/** The dry-run before a batch: the eligible nurses, the ones that would be skipped, and the shifted date. */
export interface PayoutBatchPreview {
periodStart: string;
periodEnd: string;
processingDate: string;
holidayShifted: boolean;
eligible: EligibleNurseEarnings[];
skipped: SkippedNurse[];
/** `= Σ eligible.netAmountIrr`. IRR digit-string. */
totalNetIrr: string;
}
/** One `nurse_payouts` row expanded for the admin batch detail (`PayoutDto`). Every row reconciles:
* `grossEarningsIrr clawbackAppliedIrr = netAmountIrr`; on a clean payout `amountIrr = netAmountIrr`. */
export interface AdminPayoutRow {
id: number;
nurseId: number;
nurseName: string | null;
/** Masked, **last-4 only** — an encrypted field; never a full IBAN. */
maskedIban: string;
grossEarningsIrr: string;
clawbackAppliedIrr: string;
netAmountIrr: string;
/** What was actually transferred (`PayoutDto.amount`); `= netAmountIrr` on a clean payout. */
amountIrr: string;
status: PayoutStatus;
transferReference: string | null;
paidAt: string | null;
/** `failed` only. Empty otherwise. */
failureReason: string | null;
/** The bookings this payout covered; `Σ payoutAmountIrr = grossEarningsIrr`. */
bookings: { bookingId: number; sessionId: number | null; payoutAmountIrr: string }[];
}
/** A batch header + its paginated payout rows (`PayoutBatchDetailDto`). */
export interface AdminPayoutBatchDetail {
batch: PayoutBatchSummary;
payouts: AdminPayoutRow[];
total: number;
page: number;
pageSize: number;
}
/** `listPayoutBatches` filter — the optional status is part of the query key so each filter caches separately. */
export interface PayoutBatchFilters {
status?: PayoutBatchStatus;
}
/**
* The payouts API seam the real HTTP client and the in-memory mock both implement this interface;
* selection is by config (`USE_PAYOUTS_MOCK`), never scattered `if (mock)` checks. **All reads; no
* mutations** (a nurse never writes payout state).
* selection is by config (`USE_PAYOUTS_MOCK`), never scattered `if (mock)` checks.
*
* The **nurse read** methods are all reads (a nurse never writes payout state). The **admin** methods
* (`admin_payouts/*`) add the batch actions: previewing eligibility, running a batch, retrying a failed
* payout, and recording a transfer reference. `runPayoutBatch`/`retryPayout` are **idempotency-keyed**
* the same key returns the same result (never a double-pay).
*/
export interface PayoutsApi {
// Nurse read side.
getNurseEarningsBalance(): Promise<NurseEarningsSummary>;
getNurseEarnings(params: EarningsListParams): Promise<Paginated<NurseEarningsItem>>;
getNursePayoutHistory(params: PageParams): Promise<Paginated<NursePayoutHistoryItem>>;
getNursePayoutDetail(payoutId: number): Promise<NursePayoutDetail>;
// Admin batch actions.
listPayoutBatches(filters: PayoutBatchFilters, params: PageParams): Promise<Paginated<PayoutBatchSummary>>;
previewPayoutBatch(periodStart: string, periodEnd: string): Promise<PayoutBatchPreview>;
runPayoutBatch(periodStart: string, periodEnd: string, idempotencyKey: string): Promise<PayoutBatchSummary>;
getPayoutBatchDetail(batchId: number, page: number): Promise<AdminPayoutBatchDetail>;
retryPayout(payoutId: number, idempotencyKey: string): Promise<void>;
recordTransferReference(payoutId: number, reference: string): Promise<void>;
}
@@ -2,9 +2,12 @@ import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
import { ApiError } from '@/lib/api/errors';
import type {
AdminRefundResult,
CancelBookingInput,
CancellationPolicyPreview,
InitiateRefundInput,
RefundChannel,
RefundPreview,
RefundStatus,
RefundSummary,
RefundsApi,
@@ -12,6 +15,7 @@ import type {
const BOOKINGS = '/api/v1/bookings';
const REFUNDS = '/api/v1/refunds';
const ADMIN_REFUNDS = '/api/v1/admin_refunds';
/**
* The thin b11 customer refund payload (`GET refunds/{id}/status`) the only refund shape the contract
@@ -93,4 +97,52 @@ export const refundsClientApi: RefundsApi = {
getRefund: async (refundId: number) =>
toSummary(unwrap(await clientFetch<ApiEnvelope<RefundStatusWire>>(`${REFUNDS}/${refundId}/status`))),
// REQ-035: refund preview endpoint. b11 computes the fee-leg decomposition only *on create* (there is no
// read-only preview route), yet the admin console must disclose the split before initiating. Filed as a
// proposed `GET api/v1/admin_refunds/preview?booking_id=&ticket_id=`; the mock serves it today.
getRefundPreview: async (bookingId: number, ticketId: number | null) => {
const params = new URLSearchParams({ booking_id: String(bookingId) });
if (ticketId != null) params.set('ticket_id', String(ticketId));
return unwrap(
await clientFetch<ApiEnvelope<RefundPreview>>(`${ADMIN_REFUNDS}/preview?${params.toString()}`),
);
},
// Create + immediately execute a ticket-linked refund (b11 `POST api/v1/admin_refunds`). The response is
// already `AdminRefundResult`-shaped (refundId/status/channel/decomposed legs/eta/clawbackId).
initiateRefund: async (input: InitiateRefundInput) =>
unwrap(
await clientFetch<ApiEnvelope<AdminRefundResult>>(ADMIN_REFUNDS, {
method: 'POST',
body: JSON.stringify({
bookingId: input.bookingId,
ticketId: input.ticketId,
refundPercentage: input.refundPercentage,
refundChannel: input.refundChannel,
reasonCategory: input.reasonCategory,
reasonNotes: input.reasonNotes,
}),
}),
),
// REQ-035: approve/reject a refund. b11 has no separate approve/reject step — `POST admin_refunds` both
// creates and executes — so a failed-channel retry and an explicit rejection are proposed as
// `POST api/v1/admin_refunds/{id}/approve` and `.../{id}/reject`; the mock serves both today.
approveRefund: async (refundId: number) =>
unwrap(
await clientFetch<ApiEnvelope<AdminRefundResult>>(`${ADMIN_REFUNDS}/${refundId}/approve`, {
method: 'POST',
}),
),
// REQ-035: see approveRefund. Reject records a reason and moves the refund to `rejected`.
rejectRefund: async (refundId: number, reason: string) => {
unwrap(
await clientFetch<ApiEnvelope<true>>(`${ADMIN_REFUNDS}/${refundId}/reject`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
);
},
};
+163
View File
@@ -5,11 +5,15 @@ import { BNPL_REFUND_ETA_BUSINESS_DAYS, MOCK_POLICY_TIERS } from '../constants';
import {
isBookingCancellable,
isTerminalRefundStatus,
type AdminRefundResult,
type CancelBookingInput,
type CancellationPolicyCode,
type CancellationPolicyPreview,
type CancellationSessionPreview,
type InitiateRefundInput,
type RefundChannel,
type RefundPreview,
type RefundStatus,
type RefundSummary,
type RefundsApi,
} from '../types';
@@ -184,6 +188,99 @@ function advanceRefund(refund: MockRefund): void {
}
}
/* --------------------------------------------------------------------------------------------------
* Admin refund tooling mock (b11 `admin_refunds`). Self-contained fixtures keyed by a **sentinel booking
* id** one per console panel state kept separate from the customer booking store above so every admin
* state is deterministic. Each fixture reconciles `platformFeeRefunded + nursePayoutRefunded === amount`
* (BigInt). The states:
* 6001 normal card: `psp_card`, `succeeded` immediately, no ETA, no clawback.
* 6002 BNPL: `bnpl_revert`, `processing` on initiate, a ~9-business-day `expectedCustomerRefundEta`.
* 6003 post-payout: `willCreateClawback` (nurse already paid), initiate returns a `clawbackId`.
* 6004 provider decline: the FIRST `initiateRefund` returns `failed` (channel refused); a subsequent
* `approveRefund` (retry) succeeds so the retry path is demonstrable.
* ------------------------------------------------------------------------------------------------ */
/** Sentinel booking whose first refund attempt the channel declines (`failed`), then a retry succeeds. */
const PROVIDER_DECLINE_BOOKING_ID = 6004;
/** The admin decomposition preview per fixture booking (the BNPL ETA is filled dynamically at read time). */
const ADMIN_PREVIEW_FIXTURES: Record<number, Omit<RefundPreview, 'expectedCustomerRefundEta'>> = {
6001: {
bookingId: 6001,
refundPercentageApplied: 1,
amountIrr: '10000000',
platformFeeRefundedIrr: '1500000',
nursePayoutRefundedIrr: '8500000',
refundChannel: 'psp_card',
willCreateClawback: false,
cancellationPolicyCode: 'free_24h',
},
6002: {
bookingId: 6002,
refundPercentageApplied: 1,
amountIrr: '6000000',
platformFeeRefundedIrr: '900000',
nursePayoutRefundedIrr: '5100000',
refundChannel: 'bnpl_revert',
willCreateClawback: false,
cancellationPolicyCode: 'free_24h',
},
6003: {
bookingId: 6003,
refundPercentageApplied: 0.5,
amountIrr: '5000000',
platformFeeRefundedIrr: '750000',
nursePayoutRefundedIrr: '4250000',
refundChannel: 'psp_card',
willCreateClawback: true,
cancellationPolicyCode: 'partial_under_24h',
},
[PROVIDER_DECLINE_BOOKING_ID]: {
bookingId: PROVIDER_DECLINE_BOOKING_ID,
refundPercentageApplied: 1,
amountIrr: '8000000',
platformFeeRefundedIrr: '1200000',
nursePayoutRefundedIrr: '6800000',
refundChannel: 'psp_card',
willCreateClawback: false,
cancellationPolicyCode: 'free_24h',
},
};
/** The BNPL admin ETA — ~9 business days out (Fridays skipped), within the product's ~710-day window. */
const ADMIN_BNPL_ETA_BUSINESS_DAYS = 9;
/** Assert the fee legs sum to the total (BigInt) — a preview must reconcile to the rial before it ships. */
function assertReconciles(preview: Omit<RefundPreview, 'expectedCustomerRefundEta'>): void {
const sum = parseIrr(preview.platformFeeRefundedIrr) + parseIrr(preview.nursePayoutRefundedIrr);
if (sum !== parseIrr(preview.amountIrr)) {
throw new Error(`Admin refund preview ${preview.bookingId} does not reconcile`);
}
}
/** Resolve the full preview for a fixture booking (filling the BNPL ETA); `404` on an unknown booking. */
function adminPreviewFor(bookingId: number): RefundPreview {
const base = ADMIN_PREVIEW_FIXTURES[bookingId];
if (!base) throw new ApiError(404, 'No captured payment for this booking', 'not_found');
assertReconciles(base);
return {
...base,
expectedCustomerRefundEta:
base.refundChannel === 'bnpl_revert' ? businessDaysFromNow(ADMIN_BNPL_ETA_BUSINESS_DAYS) : null,
};
}
/** The executed status for a channel: a card refund succeeds immediately; BNPL/manual go to `processing`. */
function executedStatusFor(channel: RefundChannel): RefundStatus {
return channel === 'psp_card' ? 'succeeded' : 'processing';
}
let nextAdminRefundId = 9001;
let nextClawbackId = 4001;
const adminRefundsById: Record<number, AdminRefundResult> = {};
// Per-booking initiate counter — drives the provider-decline sentinel's first-attempt failure.
const adminInitiateAttempts: Record<number, number> = {};
/**
* In-memory mock behind the `RefundsApi` seam the whole customer cancel + refund surface b11 doesn't
* serve (admin-only refunds; no cancel command / policy preview / refund-by-booking / decomposition on the
@@ -258,4 +355,70 @@ export const refundsMockApi: RefundsApi = {
advanceRefund(refund);
return toRefundSummary(refund);
},
// --- Admin refund tooling (ticket-linked; the mock serves the whole console this phase). ---
getRefundPreview: async (bookingId, _ticketId) => {
await sleep(MOCK_LATENCY_MS);
return adminPreviewFor(bookingId);
},
initiateRefund: async (input: InitiateRefundInput) => {
await sleep(MOCK_LATENCY_MS);
const preview = adminPreviewFor(input.bookingId); // 404 if unknown
const channel = input.refundChannel ?? preview.refundChannel;
const attempt = (adminInitiateAttempts[input.bookingId] ?? 0) + 1;
adminInitiateAttempts[input.bookingId] = attempt;
// The provider-decline fixture fails its first attempt (channel refused); a later approve/retry recovers.
const declined = input.bookingId === PROVIDER_DECLINE_BOOKING_ID && attempt === 1;
const status: RefundStatus = declined ? 'failed' : executedStatusFor(channel);
const result: AdminRefundResult = {
refundId: nextAdminRefundId++,
bookingId: input.bookingId,
status,
refundChannel: channel,
// The client renders the server's decomposition verbatim; it never recomputes the legs.
amount: preview.amountIrr,
platformFeeRefundedIrr: preview.platformFeeRefundedIrr,
nursePayoutRefundedIrr: preview.nursePayoutRefundedIrr,
expectedCustomerRefundEta: status === 'failed' ? null : preview.expectedCustomerRefundEta,
// Post-payout: the nurse was already paid, so executing opens a pending clawback — never on a decline.
clawbackId: preview.willCreateClawback && !declined ? nextClawbackId++ : null,
};
adminRefundsById[result.refundId] = result;
return result;
},
approveRefund: async (refundId) => {
await sleep(MOCK_LATENCY_MS);
const existing = adminRefundsById[refundId];
if (!existing) throw new ApiError(404, 'Refund not found', 'not_found');
// Approve/retry only applies to a refund awaiting execution or one the channel declined.
if (existing.status !== 'failed' && existing.status !== 'requested') {
throw new ApiError(409, 'Refund is not awaiting approval', 'not_approvable');
}
const status = executedStatusFor(existing.refundChannel);
const updated: AdminRefundResult = {
...existing,
status,
expectedCustomerRefundEta:
existing.refundChannel === 'bnpl_revert' ? businessDaysFromNow(ADMIN_BNPL_ETA_BUSINESS_DAYS) : null,
};
adminRefundsById[refundId] = updated;
return updated;
},
rejectRefund: async (refundId, reason) => {
await sleep(MOCK_LATENCY_MS);
if (!reason?.trim()) throw new ApiError(400, 'A rejection reason is required', 'reason_required');
const existing = adminRefundsById[refundId];
if (!existing) throw new ApiError(404, 'Refund not found', 'not_found');
if (isTerminalRefundStatus(existing.status)) {
throw new ApiError(409, 'Refund is already terminal', 'not_rejectable');
}
adminRefundsById[refundId] = { ...existing, status: 'rejected' };
},
};
+6
View File
@@ -50,6 +50,12 @@ export const MOCK_POLICY_TIERS: Record<
customer_no_show: { refundFraction: 0, leadTimeLabel: 'started' },
};
/**
* The admin decomposition preview depends on the booking's current payout/dispute state (a post-payout
* refund forks to a clawback), so keep it short-lived the console must never initiate off a stale split.
*/
export const ADMIN_REFUND_PREVIEW_STALE_TIME = 10 * 1000;
/**
* The BNPL customer cash-back window the mock projects onto `expectedCustomerRefundEta` the product's
* ~710 business-day truth, Fridays skipped (see `cancellation-and-payout.md`). Surface it honestly;
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { refundsApi } from '../apis';
import { invalidateAfterAdminRefund } from '../invalidations';
import type { AdminRefundResult } from '../types';
/**
* Approve / retry a refund (the console's action on a channel-declined `failed` refund, or an
* approval-gated `requested` one). The result carries the booking id, so on success we invalidate that
* booking's refund + previews via the shared helper. Domain 4xx (`404` not found, `409` not awaiting
* approval) surface via `mutation.error`.
*/
export function useApproveRefund() {
const queryClient = useQueryClient();
return useMutation<AdminRefundResult, unknown, number>({
mutationFn: (refundId) => refundsApi.approveRefund(refundId),
onSuccess: (result) => invalidateAfterAdminRefund(queryClient, result.bookingId),
});
}
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { refundsApi } from '../apis';
import { invalidateAfterAdminRefund } from '../invalidations';
import type { AdminRefundResult, InitiateRefundInput } from '../types';
/**
* Initiate (create + immediately execute) a ticket-linked admin refund. On success the customer-facing refund
* read, the booking, every admin preview, and the linked support ticket are invalidated so the console and the
* customer's status both reflect the outcome without a manual refetch. Domain 4xx (`400` invalid amount, `404`
* no captured payment, `409` over-refund, `400` channel refused) surface via `mutation.error` for the panel to
* render inline; the fetch layer already toasts 401/403/5xx, so this hook never double-toasts. A `failed`
* result (channel declined) is a **success** here (the request completed) the panel offers the retry.
*/
export function useInitiateRefund() {
const queryClient = useQueryClient();
return useMutation<AdminRefundResult, unknown, InitiateRefundInput>({
mutationFn: (input) => refundsApi.initiateRefund(input),
onSuccess: (_result, input) => invalidateAfterAdminRefund(queryClient, input.bookingId, input.ticketId),
});
}
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { refundsApi } from '../apis';
import { refundKeys } from '../keys';
import { ADMIN_REFUND_PREVIEW_STALE_TIME } from '../constants';
/**
* The admin refund decomposition preview for a booking the server's fee/payout split, applied percentage,
* channel, ETA, and the `willCreateClawback` warning disclosed **before** the admin initiates. Keyed by the
* booking **and** the linking ticket (a refund is always previewed in a ticket's context). Short `staleTime`
* (the split forks on the booking's live payout state); enabled only when a booking id is present. The
* client renders the served numbers verbatim it never recomputes the split.
*/
export function useRefundPreview(bookingId: number | null, ticketId: number | null = null) {
return useQuery({
queryKey: refundKeys.adminPreview(bookingId ?? -1, ticketId),
queryFn: () => refundsApi.getRefundPreview(bookingId as number, ticketId),
enabled: bookingId != null && bookingId > 0,
staleTime: ADMIN_REFUND_PREVIEW_STALE_TIME,
});
}
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { refundsApi } from '../apis';
import { invalidateAfterRefundRejection } from '../invalidations';
/**
* Reject a refund with a required reason (moves it to terminal `rejected`). Returns `void`, so on success we
* invalidate the by-refund read + the admin previews via the shared helper. Domain 4xx (`400` missing reason,
* `404` not found, `409` already terminal) surface via `mutation.error`.
*/
export function useRejectRefund() {
const queryClient = useQueryClient();
return useMutation<void, unknown, { refundId: number; reason: string }>({
mutationFn: ({ refundId, reason }) => refundsApi.rejectRefund(refundId, reason),
onSuccess: (_void, { refundId }) => invalidateAfterRefundRejection(queryClient, refundId),
});
}
+6
View File
@@ -5,3 +5,9 @@
export { useCancellationPolicyPreview } from './hooks/useCancellationPolicyPreview';
export { useCancelBooking } from './hooks/useCancelBooking';
export { useRefundStatus } from './hooks/useRefundStatus';
// Admin refund tooling (b11 admin_refunds; ticket-linked).
export { useRefundPreview } from './hooks/useRefundPreview';
export { useInitiateRefund } from './hooks/useInitiateRefund';
export { useApproveRefund } from './hooks/useApproveRefund';
export { useRejectRefund } from './hooks/useRejectRefund';
@@ -1,5 +1,6 @@
import type { QueryClient } from '@tanstack/react-query';
import { bookingKeys } from '@/services/bookings/keys';
import { ticketKeys } from '@/services/tickets/keys';
import { refundKeys } from './keys';
import type { RefundSummary } from './types';
@@ -20,3 +21,34 @@ export function invalidateAfterCancellation(
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
queryClient.invalidateQueries({ queryKey: refundKeys.policyPreview(bookingId) });
}
/**
* After an admin **initiates** a refund (or approves a failed one): the booking's refund now exists / moved,
* so refresh the customer-facing refund read (`byBooking`), the booking detail + lists (status/refund
* changed), every admin decomposition preview (the booking is no longer previewable the same way), and the
* linked support ticket if one was passed (the console posts the outcome onto the ticket). Never a blanket
* refetch. Called from `useInitiateRefund` / `useApproveRefund`.
*/
export function invalidateAfterAdminRefund(
queryClient: QueryClient,
bookingId: number,
ticketId?: number | null,
): void {
queryClient.invalidateQueries({ queryKey: refundKeys.byBooking(bookingId) });
queryClient.invalidateQueries({ queryKey: refundKeys.details() });
queryClient.invalidateQueries({ queryKey: refundKeys.adminPreviews() });
queryClient.invalidateQueries({ queryKey: bookingKeys.bookingDetail(bookingId) });
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
if (ticketId != null) {
queryClient.invalidateQueries({ queryKey: ticketKeys.detail(ticketId) });
}
}
/**
* After an admin **rejects** a refund. Reject returns no booking id, so we refresh the by-refund read and
* the admin previews (the refund is now terminal `rejected`); the worklist re-reads it on next fetch.
*/
export function invalidateAfterRefundRejection(queryClient: QueryClient, refundId: number): void {
queryClient.invalidateQueries({ queryKey: refundKeys.detail(refundId) });
queryClient.invalidateQueries({ queryKey: refundKeys.adminPreviews() });
}
+7
View File
@@ -15,4 +15,11 @@ export const refundKeys = {
details: () => [...refundKeys.all, 'detail'] as const,
detail: (refundId: number) => [...refundKeys.details(), refundId] as const,
// Admin refund tooling: the decomposition preview is keyed by the booking **and** the linking ticket
// (a refund is always initiated in a ticket's context), so re-previewing under a different ticket caches
// independently.
adminPreviews: () => [...refundKeys.all, 'admin_preview'] as const,
adminPreview: (bookingId: number, ticketId: number | null) =>
[...refundKeys.adminPreviews(), bookingId, ticketId] as const,
};
+65
View File
@@ -182,6 +182,64 @@ export function isBookingCancellable(status: BookingStatus): boolean {
return status === 'confirmed' || status === 'in_progress';
}
/* ----------------------------------------------------------------------------------------------------
* Admin refund tooling (b11 `POST`/`GET api/v1/admin_refunds`). The console half of the story: an admin
* previews the fee-leg decomposition, then initiates (create + immediately execute) a **ticket-linked**
* refund every initiate carries a `ticketId`. The server computes the split; the client only renders it.
* Money stays an IRR digit-string; the client NEVER recomputes the percentage or the fee/payout legs
* `platformFeeRefundedIrr + nursePayoutRefundedIrr === amount` is a server invariant, rendered as served.
* -------------------------------------------------------------------------------------------------- */
/**
* The server's refund decomposition preview for a booking (admin console, before initiate). Reconciles by
* construction: `platformFeeRefundedIrr + nursePayoutRefundedIrr === amountIrr`. `willCreateClawback` warns
* that the nurse was already paid (a post-payout refund opens a `pending` clawback + support alert); the
* `bnpl_revert` channel carries the ~710-business-day `expectedCustomerRefundEta` (a `YYYY-MM-DD` date).
*/
export interface RefundPreview {
bookingId: number;
refundPercentageApplied: number;
amountIrr: string;
platformFeeRefundedIrr: string;
nursePayoutRefundedIrr: string;
refundChannel: RefundChannel; // 'psp_card' | 'bnpl_revert' | 'manual'
expectedCustomerRefundEta: string | null;
willCreateClawback: boolean;
cancellationPolicyCode: string | null;
}
/**
* `POST api/v1/admin_refunds` input. Supply **either** `refundPercentage` (01) **or** neither (the server
* falls back to the booking's b9 cancellation-snapshot percentage). `ticketId` links the refund to its
* support ticket nullable only until `refund_ticket_required` config is enforced (b15).
*/
export interface InitiateRefundInput {
bookingId: number;
ticketId: number | null;
refundPercentage?: number;
refundChannel?: RefundChannel;
reasonCategory?: string;
reasonNotes?: string;
}
/**
* The `POST api/v1/admin_refunds` result the refund was created **and executed**. A card refund returns
* `succeeded` immediately (no ETA); a BNPL/manual refund returns `processing` with an
* `expectedCustomerRefundEta`; a post-payout refund sets `clawbackId`. `status` reuses the domain's
* `RefundStatus` (a channel refusal returns `failed`, retryable via approve).
*/
export interface AdminRefundResult {
refundId: number;
bookingId: number;
status: RefundStatus;
refundChannel: RefundChannel;
amount: string;
platformFeeRefundedIrr: string;
nursePayoutRefundedIrr: string;
expectedCustomerRefundEta: string | null;
clawbackId: number | null;
}
/**
* The refunds API seam the real HTTP client and the in-memory mock both implement this interface;
* selection is by config (`USE_REFUNDS_MOCK`), never scattered `if (mock)` checks.
@@ -192,4 +250,11 @@ export interface RefundsApi {
/** `null` when the booking has no refund (e.g. not cancelled) — a clean empty state, not an error. */
getRefundByBooking(bookingId: number): Promise<RefundSummary | null>;
getRefund(refundId: number): Promise<RefundSummary>;
/* --- Admin refund tooling (b11 admin_refunds; every initiate is ticket-linked). --- */
/** The server's fee-leg decomposition preview for a booking (`ticketId` = the linking ticket, or null). */
getRefundPreview(bookingId: number, ticketId: number | null): Promise<RefundPreview>;
initiateRefund(input: InitiateRefundInput): Promise<AdminRefundResult>;
approveRefund(refundId: number): Promise<AdminRefundResult>;
rejectRefund(refundId: number, reason: string): Promise<void>;
}
@@ -4,6 +4,10 @@ import type { PageParams } from '@/lib/api/types';
import { REVIEWS_PAGE_SIZE } from '../constants';
import type {
CreateReviewRequest,
ModerateReviewResult,
ModerationAction,
ModerationQueueFilters,
ModerationQueueItem,
MyReviewState,
NurseReviews,
ReviewEligibility,
@@ -20,6 +24,12 @@ interface NurseReviewsWire {
reviews: Paginated<ReviewListItem>;
}
/**
* Wire `ModerationQueueItemDto`. Per the b14 contract it does **not** carry `tagCodes` (REQ-037 the admin
* card can't show the review's tags); the client defaults it to `[]` on map.
*/
type ModerationQueueItemWire = Omit<ModerationQueueItem, 'tagCodes'>;
/**
* Real HTTP implementation of the `ReviewsApi` seam (b14 contract `dev/contracts/domains/reviews-records.md`,
* swagger `dev/contracts/openapi/swagger.v1.json`). Two of the four methods map **published** b14 routes:
@@ -63,4 +73,30 @@ export const reviewsClientApi: ReviewsApi = {
body: JSON.stringify({ rating: body.rating, body: body.body ?? null, tagCodes: body.tagCodes ?? [] }),
}),
),
listModerationQueue: async (filters, params): Promise<Paginated<ModerationQueueItem>> => {
const query = new URLSearchParams();
query.set('status', filters.status ?? 'pending_moderation');
query.set('page', String(params.page ?? 1));
query.set('pageSize', String(params.pageSize ?? REVIEWS_PAGE_SIZE));
const wire = unwrap(
await clientFetch<ApiEnvelope<Paginated<ModerationQueueItemWire>>>(
`${API}/admin/reviews/moderation_queue?${query.toString()}`,
),
);
// REQ-037: the wire dto omits tagCodes — default to [] so the admin card renders without them.
return { ...wire, items: wire.items.map((item) => ({ ...item, tagCodes: [] })) };
},
moderateReview: async (
reviewId: number,
action: ModerationAction,
reason?: string,
): Promise<ModerateReviewResult> =>
unwrap(
await clientFetch<ApiEnvelope<ModerateReviewResult>>(`${API}/reviews/${reviewId}/status`, {
method: 'PATCH',
body: JSON.stringify({ action, reason: reason ?? null }),
}),
),
};
+87 -3
View File
@@ -5,6 +5,10 @@ import { mockGetBookingForReview } from '@/services/bookings/apis/mockApi';
import { MIN_RATING_FOR_SUPPORT_ALERT } from '../constants';
import type {
CreateReviewRequest,
ModerateReviewResult,
ModerationAction,
ModerationQueueFilters,
ModerationQueueItem,
ModerationStatus,
MyReviewState,
NurseReviews,
@@ -28,9 +32,13 @@ import type {
* - **Submission tracking** `createReview` records the customer's review as `pending_moderation` (it does
* **not** enter any public list), so eligibility flips to `already_reviewed` and `getMyReviewForBooking`
* returns the persistent "under review" state.
* - **`__mockPublishSubmittedReview(bookingId)`** dev-only stand-in for the deferred (f15) admin
* moderation queue, so a human can watch a submitted review move to `published` and appear on the nurse
* profile (the aggregate + count updating on the next fetch). Never wired into a customer/nurse screen.
* - **Admin moderation queue** `listModerationQueue`/`moderateReview` back the admin worklist: a seeded set
* of `pending_moderation` rows (incl. low-rating reviews with a linked `lowRatingAlertId`) filtered by
* status, transitioned statefully in place. A transition returns a **plausible recomputed** nurse aggregate
* (the numbers are server-authoritative the client renders, never computes them).
* - **`__mockPublishSubmittedReview(bookingId)`** dev-only stand-in that moves a *customer-submitted* review
* to `published` and onto the nurse profile so a human can watch it appear (aggregate + count updating on the
* next fetch). Distinct from the seeded moderation queue above; never wired into a customer/nurse screen.
*
* Booking/nurse ids align with the f8 bookings seeds (nurse 1; completed booking 5005) so a submit from a
* completed booking deep-links correctly.
@@ -82,6 +90,30 @@ const PUBLISHED: Record<number, ReviewListItem[]> = {
const submissions = new Map<number, SubmittedReview>();
// ── Admin moderation queue (the f13 admin worklist reads this) ────────────────────────────────────────────
/**
* A seeded moderation worklist: mostly `pending_moderation` rows (the default view) with a couple already
* transitioned so a status filter has something to show. It **includes low-rating reviews** (rating 1/2 with a
* linked `lowRatingAlertId`) alongside normal ones. Rows are mutated in place by `moderateReview`, so once a
* row leaves `pending_moderation` it drops out of the pending view (filtered by status) and appears under its
* new status statefully, for the session.
*/
const MODERATION_QUEUE: ModerationQueueItem[] = [
{ id: 9301, bookingId: 5011, nurseProfileId: 1, customerProfileId: 7001, rating: 2, body: 'کمی دیر رسید و ارتباط ضعیفی داشت.', tagCodes: ['communicative'], moderationStatus: 'pending_moderation', moderationReason: null, lowRatingAlertId: 4501, createdAt: isoDaysAgo(1) },
{ id: 9302, bookingId: 5012, nurseProfileId: 2, customerProfileId: 7002, rating: 5, body: 'مراقبت عالی و حرفه‌ای؛ کاملاً راضی بودیم.', tagCodes: ['professional', 'kind'], moderationStatus: 'pending_moderation', moderationReason: null, lowRatingAlertId: null, createdAt: isoDaysAgo(1) },
{ id: 9303, bookingId: 5013, nurseProfileId: 1, customerProfileId: 7003, rating: 1, body: 'اصلاً سر وقت نیامد.', tagCodes: [], moderationStatus: 'pending_moderation', moderationReason: null, lowRatingAlertId: 4502, createdAt: isoDaysAgo(2) },
{ id: 9304, bookingId: 5014, nurseProfileId: 3, customerProfileId: 7004, rating: 4, body: 'تمیز و منظم بود.', tagCodes: ['clean', 'punctual'], moderationStatus: 'pending_moderation', moderationReason: null, lowRatingAlertId: null, createdAt: isoDaysAgo(3) },
{ id: 9305, bookingId: 5015, nurseProfileId: 4, customerProfileId: 7005, rating: 5, body: null, tagCodes: ['professional'], moderationStatus: 'published', moderationReason: null, lowRatingAlertId: null, createdAt: isoDaysAgo(6) },
];
/** action → resulting moderation status (the human decision; always overrides the AI pre-screen). */
const ACTION_TO_STATUS: Record<ModerationAction, ModerationStatus> = {
publish: 'published',
hide: 'hidden',
reject: 'rejected',
unpublish: 'pending_moderation',
};
/** 2-dp average over a nurse's currently-published reviews (server-recomputed; mirrored here). */
function aggregateFor(nurseProfileId: number): { averageRating: number; publishedCount: number } {
const list = PUBLISHED[nurseProfileId] ?? [];
@@ -102,6 +134,24 @@ function isReviewableStatus(status: string): boolean {
return status === 'completed' || status === 'closed';
}
/**
* A **plausible recomputed-looking** nurse aggregate returned after a transition. The real server recomputes
* `averageRating`/`totalReviews` from source in the same transaction the numbers are server-authoritative; the
* mock just returns believable values. A `publish` folds the moderated review's rating into the nurse's
* published rollup; the other actions leave the published set unchanged here.
*/
function recomputedAggregateFor(
nurseProfileId: number,
action: ModerationAction,
rating: number,
): { averageRating: number; totalReviews: number } {
const base = aggregateFor(nurseProfileId);
const totalReviews = base.publishedCount + (action === 'publish' ? 1 : 0);
if (totalReviews === 0) return { averageRating: 0, totalReviews: 0 };
const foldedSum = base.averageRating * base.publishedCount + (action === 'publish' ? rating : 0);
return { averageRating: Math.round((foldedSum / totalReviews) * 100) / 100, totalReviews };
}
export const reviewsMockApi: ReviewsApi = {
getNurseReviews: async (nurseProfileId: number, params: PageParams): Promise<NurseReviews> => {
await sleep(MOCK_LATENCY_MS);
@@ -164,6 +214,40 @@ export const reviewsMockApi: ReviewsApi = {
lowRatingAlertRaised: body.rating <= MIN_RATING_FOR_SUPPORT_ALERT,
};
},
listModerationQueue: async (
filters: ModerationQueueFilters,
params: PageParams,
): Promise<Paginated<ModerationQueueItem>> => {
await sleep(MOCK_LATENCY_MS);
const status = filters.status ?? 'pending_moderation';
const list = MODERATION_QUEUE.filter((q) => q.moderationStatus === status).sort(
(a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt),
);
return paginate(list, params);
},
moderateReview: async (
reviewId: number,
action: ModerationAction,
reason?: string,
): Promise<ModerateReviewResult> => {
await sleep(MOCK_LATENCY_MS);
// hide/reject demand a reason (contract) — surface a 400 so the caller's onError keeps the dialog open.
if ((action === 'hide' || action === 'reject') && !reason?.trim()) {
throw new ApiError(400, 'A reason is required to hide or reject a review', 'reason_required');
}
const item = MODERATION_QUEUE.find((q) => q.id === reviewId);
if (!item) throw new ApiError(404, 'Review not found', 'review_not_found');
const nextStatus = ACTION_TO_STATUS[action];
// Mutate in place: once it leaves pending_moderation it drops out of the pending view (filtered by status).
item.moderationStatus = nextStatus;
item.moderationReason = action === 'hide' || action === 'reject' ? reason?.trim() ?? null : null;
const agg = recomputedAggregateFor(item.nurseProfileId, action, item.rating);
return { id: reviewId, moderationStatus: nextStatus, averageRating: agg.averageRating, totalReviews: agg.totalReviews };
},
};
/**
+7
View File
@@ -25,6 +25,13 @@ export const NURSE_REVIEWS_STALE_TIME = 2 * 60 * 1000;
export const REVIEW_ELIGIBILITY_STALE_TIME = 30 * 1000;
export const REVIEWS_GC_TIME = 10 * 60 * 1000;
/**
* The admin moderation worklist is an actively-worked queue a short `staleTime` keeps it fresh as items are
* moderated by this (or another) admin, while still serving from cache during quick filter/page switches. A
* moderation mutation invalidates it immediately, so this only governs background freshness.
*/
export const MODERATION_QUEUE_STALE_TIME = 20 * 1000;
/**
* The low-rating support-alert threshold (server config, default 2). The mock echoes it on
* `SubmitReviewResult.lowRatingAlertRaised`; the **UI never surfaces it** it exists only so the mock's
@@ -0,0 +1,30 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { reviewsApi } from '../apis';
import { reviewKeys } from '../keys';
import type { ModerateReviewResult, ModerationAction } from '../types';
interface ModerateReviewVars {
reviewId: number;
action: ModerationAction;
/** Required for `hide`/`reject` (≤ 500); a missing reason is a domain `400` surfaced to `onError`. */
reason?: string;
}
/**
* Transition a review (admin/moderator). On success we invalidate the whole moderation queue subtree (the row
* has moved out of / into a status view) **and** every nurse public list: the server recomputed the affected
* nurse's `averageRating`/`totalReviews` from source, but that nurse's id isn't reachable from the mutation
* vars or the result, so we drop all nurse lists rather than compute anything client-side (the client **never**
* computes the aggregate). Domain 4xx (a missing `reason` on hide/reject) surface to the caller's `onError`, so
* the moderation dialog keeps its draft.
*/
export function useModerateReview() {
const queryClient = useQueryClient();
return useMutation<ModerateReviewResult, unknown, ModerateReviewVars>({
mutationFn: ({ reviewId, action, reason }) => reviewsApi.moderateReview(reviewId, action, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: reviewKeys.moderationQueues() });
queryClient.invalidateQueries({ queryKey: reviewKeys.nurseLists() });
},
});
}
@@ -0,0 +1,22 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { reviewsApi } from '../apis';
import { reviewKeys } from '../keys';
import { MODERATION_QUEUE_STALE_TIME, REVIEWS_GC_TIME, REVIEWS_PAGE_SIZE } from '../constants';
import type { ModerationQueueFilters } from '../types';
/**
* The admin moderation worklist reviews awaiting a decision, filtered by `status` (default
* `pending_moderation`) + paginated. **Admin-scoped**: these rows carry moderation internals (`moderationReason`,
* `lowRatingAlertId`) and are never rendered on a customer/nurse surface. Filters + page key the cache so
* switching a status tab or paging never refetches a page already held; `keepPreviousData` avoids an empty flash
* while the next page/tab loads. A `useModerateReview` mutation invalidates this queue on success.
*/
export function useModerationQueue(filters: ModerationQueueFilters, page = 1) {
return useQuery({
queryKey: reviewKeys.moderationQueue(filters, { page, pageSize: REVIEWS_PAGE_SIZE }),
queryFn: () => reviewsApi.listModerationQueue(filters, { page, pageSize: REVIEWS_PAGE_SIZE }),
staleTime: MODERATION_QUEUE_STALE_TIME,
gcTime: REVIEWS_GC_TIME,
placeholderData: keepPreviousData,
});
}
+2
View File
@@ -6,3 +6,5 @@ export { useNurseReviews } from './hooks/useNurseReviews';
export { useReviewEligibility } from './hooks/useReviewEligibility';
export { useMyReviewForBooking } from './hooks/useMyReviewForBooking';
export { useCreateReview } from './hooks/useCreateReview';
export { useModerationQueue } from './hooks/useModerationQueue';
export { useModerateReview } from './hooks/useModerateReview';
+10
View File
@@ -1,3 +1,6 @@
import type { PageParams } from '@/lib/api/types';
import type { ModerationQueueFilters } from './types';
/**
* React Query key factory for the reviews domain (hierarchical, per the `services/{domain}` pattern).
*
@@ -5,6 +8,8 @@
* cache; `eligibility` and `myReviewForBooking` key per booking so the leave-a-review CTA reads its own state
* independently. A `createReview` mutation invalidates **only** `eligibility` + `myReviewForBooking` for the
* booking the public list/aggregate is **never** touched (the new review is `pending_moderation`, not public).
* A moderation transition (admin) invalidates `moderationQueues()` **and** `nurseLists()` (the affected nurse's
* public list its id isn't reachable from the mutation, so all nurse lists are dropped).
*/
export const reviewKeys = {
all: ['reviews'] as const,
@@ -15,4 +20,9 @@ export const reviewKeys = {
eligibility: (bookingId: number) => [...reviewKeys.all, 'eligibility', bookingId] as const,
myReviewForBooking: (bookingId: number) => [...reviewKeys.all, 'my_review', bookingId] as const,
moderationQueues: () => [...reviewKeys.all, 'moderation_queue'] as const,
/** The admin worklist. Filters + page key the cache so switching a status filter or paging never refetches. */
moderationQueue: (filters: ModerationQueueFilters, params: PageParams) =>
[...reviewKeys.moderationQueues(), filters, params] as const,
};
+49
View File
@@ -103,6 +103,51 @@ export interface MyReviewState {
createdAt: string | null;
}
// ── Admin moderation queue (b14 admin routes; admin/moderator-only) ─────────────────────────────────────────
/**
* Moderation **action** (the `PATCH .../status` body). `hide`/`reject` require a non-empty `reason`;
* `unpublish` returns a `published` review to `pending_moderation`. The human decision always overrides the AI.
*/
export type ModerationAction = 'publish' | 'hide' | 'reject' | 'unpublish';
/**
* A row in the admin moderation worklist (`ModerationQueueItemDto`). Unlike the public `ReviewListItem` this
* **does** carry moderation internals the linked `lowRatingAlertId` (id only; support alerts stay internal),
* the `moderationReason`, and the customer/nurse profile ids the moderator needs. Never rendered on a user
* surface. Note: the wire dto does **not** carry `tagCodes` (REQ-037) the client defaults it to `[]`.
*/
export interface ModerationQueueItem {
id: number;
bookingId: number;
nurseProfileId: number;
customerProfileId: number;
rating: number;
body: string | null;
tagCodes: string[];
moderationStatus: ModerationStatus;
moderationReason: string | null;
lowRatingAlertId: number | null;
createdAt: string;
}
/** Moderation-queue filter. `status` defaults to `pending_moderation` (the worklist) when omitted. */
export interface ModerationQueueFilters {
status?: ModerationStatus;
}
/**
* `ModerateReviewResult` the outcome of a transition plus the **server-recomputed-from-source** nurse
* aggregate (`averageRating`/`totalReviews`). The client never computes these; it renders them and invalidates
* the affected nurse's public list so the new average/count show up.
*/
export interface ModerateReviewResult {
id: number;
moderationStatus: ModerationStatus;
averageRating: number;
totalReviews: number;
}
/**
* The reviews API seam the real HTTP client and the in-memory mock both implement this; selection is by
* config (`USE_REVIEWS_MOCK`), never scattered `if (mock)` checks.
@@ -116,4 +161,8 @@ export interface ReviewsApi {
getMyReviewForBooking(bookingId: number): Promise<MyReviewState>;
/** Submit the one review for a completed booking (`409` if already reviewed). */
createReview(bookingId: number, body: CreateReviewRequest): Promise<SubmitReviewResult>;
/** Admin — the moderation worklist, filtered by status + paginated. */
listModerationQueue(filters: ModerationQueueFilters, params: PageParams): Promise<Paginated<ModerationQueueItem>>;
/** Admin — transition a review; returns the recomputed nurse aggregate. `hide`/`reject` need a `reason`. */
moderateReview(reviewId: number, action: ModerationAction, reason?: string): Promise<ModerateReviewResult>;
}
+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>;
}
@@ -1,20 +1,112 @@
import { clientFetch } from '@/lib/api/client';
import { ApiError } from '@/lib/api/errors';
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
import { unwrap, type ApiEnvelope, type Paginated, type PageParams } from '@/lib/api/types';
import { ADMIN_QUEUE_PAGE_SIZE } from '../constants';
import type {
AdminVerificationCase,
AdminVerificationQueueFilters,
AdminVerificationQueueItem,
AdminVerificationStepDetail,
CredentialDetailsInput,
DecideStepInput,
DecideStepResult,
DocumentConfirmedResult,
IdentityKycInput,
NurseCredential,
RunStepResult,
SignedDocumentUrl,
TrustBadge,
UploadUrlResult,
VerificationAggregateStatus,
VerificationApi,
VerificationDocument,
VerificationStatus,
VerificationStepStatus,
} from '../types';
const BASE = '/api/v1/nurse_verification';
const NURSES_BASE = '/api/v1/nurses';
const ADMIN_BASE = '/api/v1/admin_verifications';
/** `AdminPendingStepDto` — one **row per step** awaiting attention (not per nurse); documents carry signed GET URLs. */
interface AdminPendingStepWire {
nurseVerificationId: number;
nurseId: number;
nurseName: string;
stepId: number;
stepCode: string;
stepDisplayName: string;
status: VerificationStepStatus;
submittedAt: string | null;
documents: VerificationDocument[];
}
/** `AdminStepDetailDto` — note the id lives under `stepId` on the wire (mapped to `id`). */
interface AdminStepDetailWire {
stepId: number;
code: string;
displayName: string;
status: VerificationStepStatus;
isAutomated: boolean;
expiresAt: string | null;
failureReason: string | null;
documents: VerificationDocument[];
}
/** `AdminVerificationDetailDto`. */
interface AdminVerificationDetailWire {
nurseVerificationId: number;
nurseId: number;
identityName: string;
status: VerificationAggregateStatus;
steps: AdminStepDetailWire[];
credentials: NurseCredential[];
}
/**
* REQ-034: the queue DTO is **per step**, so we fold rows to one item per nurse for the queue UI. This is
* lossy the per-step page carries no whole-nurse aggregate (`stepsPassed`/`stepsTotal`/expiry), and a
* nurse's steps can straddle page boundaries which is why a nurse-level queue endpoint is filed. We map
* what the row gives (nurse identity, the step as `nextPendingStepCode`, `submittedAt`) and leave the
* unavailable aggregate fields at neutral defaults.
*/
function foldQueueRows(rows: AdminPendingStepWire[]): AdminVerificationQueueItem[] {
const byNurse = new Map<number, AdminVerificationQueueItem>();
for (const row of rows) {
const existing = byNurse.get(row.nurseVerificationId);
if (existing) {
if (row.submittedAt && (existing.submittedAt == null || row.submittedAt < existing.submittedAt)) {
existing.submittedAt = row.submittedAt;
}
continue;
}
byNurse.set(row.nurseVerificationId, {
nurseVerificationId: row.nurseVerificationId,
nurseId: row.nurseId,
nurseName: row.nurseName,
status: 'in_review',
stepsPassed: 0,
stepsTotal: 0,
nextPendingStepCode: row.stepCode,
submittedAt: row.submittedAt,
hasExpiringCredential: false,
});
}
return Array.from(byNurse.values());
}
function toStepDetail(wire: AdminStepDetailWire): AdminVerificationStepDetail {
return {
id: wire.stepId,
code: wire.code,
displayName: wire.displayName,
status: wire.status,
isAutomated: wire.isAutomated,
expiresAt: wire.expiresAt,
failureReason: wire.failureReason,
documents: wire.documents,
};
}
/**
* Computes the browser-side integrity hash the confirm endpoint records against the uploaded bytes
@@ -120,4 +212,62 @@ export const verificationClientApi: VerificationApi = {
getTrustBadge: async (nurseId) =>
unwrap(await clientFetch<ApiEnvelope<TrustBadge>>(`${NURSES_BASE}/${nurseId}/trust_badge`)),
listVerificationQueue: async (
filters: AdminVerificationQueueFilters,
params: PageParams,
): Promise<Paginated<AdminVerificationQueueItem>> => {
const query = new URLSearchParams();
if (filters.status) query.set('status', filters.status);
query.set('page', String(params.page ?? 1));
query.set('page_size', String(params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE));
const page = unwrap(
await clientFetch<ApiEnvelope<Paginated<AdminPendingStepWire>>>(`${ADMIN_BASE}?${query.toString()}`),
);
// REQ-034: `total`/`page`/`pageSize` stay the wire (per-step) values until a nurse-level queue endpoint
// exists — folding to one item per nurse (see foldQueueRows) makes the count nominal, not exact.
return { items: foldQueueRows(page.items), total: page.total, page: page.page, pageSize: page.pageSize };
},
getVerificationCase: async (nurseVerificationId: number): Promise<AdminVerificationCase> => {
const wire = unwrap(
await clientFetch<ApiEnvelope<AdminVerificationDetailWire>>(`${ADMIN_BASE}/${nurseVerificationId}`),
);
return {
nurseVerificationId: wire.nurseVerificationId,
nurseId: wire.nurseId,
identityName: wire.identityName,
status: wire.status,
steps: wire.steps.map(toStepDetail),
credentials: wire.credentials,
};
},
// REQ-034: b6 has no per-document signed-URL route (documents already carry a short-lived signed `url` on
// the case detail). This targets a proposed `GET admin_verifications/documents/{documentId}/url` for an
// on-demand re-sign; until it ships, callers can re-fetch the case to get a fresh document `url`.
getDocumentSignedUrl: async (documentId: number): Promise<SignedDocumentUrl> =>
unwrap(await clientFetch<ApiEnvelope<SignedDocumentUrl>>(`${ADMIN_BASE}/documents/${documentId}/url`)),
decideStep: async (stepId: number, input: DecideStepInput): Promise<DecideStepResult> =>
unwrap(
await clientFetch<ApiEnvelope<DecideStepResult>>(`${ADMIN_BASE}/steps/${stepId}/decide`, {
method: 'POST',
body: JSON.stringify(input),
}),
),
// REQ-034: b6 has no whole-verification approve/reject route — approval emerges from the final step
// `decide` re-aggregating `is_verified`. These target proposed `POST admin_verifications/{id}/approve` and
// `/reject` for an explicit admin action (until they ship, approve by deciding the last pending step).
approveVerification: async (nurseVerificationId: number): Promise<void> => {
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_BASE}/${nurseVerificationId}/approve`, { method: 'POST' });
},
rejectVerification: async (nurseVerificationId: number, reason: string): Promise<void> => {
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_BASE}/${nurseVerificationId}/reject`, {
method: 'POST',
body: JSON.stringify({ reason }),
});
},
};
@@ -1,7 +1,12 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type {
AdminVerificationCase,
AdminVerificationQueueItem,
AdminVerificationStepDetail,
CredentialType,
IdentityKycInput,
NurseCredential,
StepTypeCode,
TrustBadge,
VerificationApi,
@@ -10,7 +15,7 @@ import type {
VerificationStep,
VerificationStepStatus,
} from '../types';
import { NATIONAL_ID_LENGTH } from '../constants';
import { ADMIN_QUEUE_PAGE_SIZE, NATIONAL_ID_LENGTH } from '../constants';
const MOCK_LATENCY_MS = 300;
@@ -73,6 +78,194 @@ function seedSteps(): void {
}));
}
/* --- Admin review queue fixtures + state ---------------------------------------------------------
* A small in-memory review desk: three nurses spanning `pending` / `in_review`, each a full case with
* ordered steps (automated steps `passed`, a manual credential-bearing step `in_review` with a document),
* one nurse carrying an expiring credential. Decisions mutate this state so a human can watch a case move
* through decide approve/reject and drop off the queue. Timestamps are relative to module-load `Date.now()`.
*/
const CREDENTIAL_BEARING: ReadonlySet<string> = new Set([
'moh_competency_license',
'ino_membership',
'criminal_record',
]);
/** Internal record: the admin case + the queue-only metadata (`nurseName`, `submittedAt`, expiry flag). */
interface AdminCaseRecord extends AdminVerificationCase {
nurseName: string;
submittedAt: string | null;
hasExpiringCredential: boolean;
}
let nextAdminStepId = 1;
let nextCredentialId = 8001;
const adminNowMs = Date.now();
const daysAgo = (n: number): string => new Date(adminNowMs - n * 24 * 60 * 60 * 1000).toISOString();
const daysFromNow = (n: number): string => new Date(adminNowMs + n * 24 * 60 * 60 * 1000).toISOString();
function mkStep(
code: StepTypeCode,
status: VerificationStepStatus,
isAutomated: boolean,
extra: Partial<Pick<AdminVerificationStepDetail, 'expiresAt' | 'failureReason' | 'documents'>> = {},
): AdminVerificationStepDetail {
return {
id: nextAdminStepId++,
code,
displayName: code,
status,
isAutomated,
expiresAt: extra.expiresAt ?? null,
failureReason: extra.failureReason ?? null,
documents: extra.documents ?? [],
};
}
function mkDoc(id: number, originalFileName: string): VerificationDocument {
return {
id,
contentType: 'application/pdf',
fileSizeBytes: 482_000,
originalFileName,
// A short-lived signed GET URL; the on-demand `getDocumentSignedUrl` re-signs it fresh each open.
url: `https://mock.balinyaar.local/docs/${id}`,
};
}
function mkCredential(
credentialType: CredentialType,
holderNameSnapshot: string,
issuingAuthority: string,
opts: { issuedAt?: string | null; expiresAt?: string | null } = {},
): NurseCredential {
return {
id: nextCredentialId++,
credentialType,
holderNameSnapshot,
issuingAuthority,
issuedAt: opts.issuedAt ?? null,
expiresAt: opts.expiresAt ?? null,
verificationMethod: 'manual',
};
}
const adminCases: AdminCaseRecord[] = [
{
nurseVerificationId: 501,
nurseId: 101,
identityName: 'مریم رضایی',
nurseName: 'مریم رضایی',
status: 'in_review',
submittedAt: daysAgo(2),
hasExpiringCredential: false,
steps: [
mkStep('identity_kyc', 'passed', true),
mkStep('shahkar_match', 'passed', true),
mkStep('moh_competency_license', 'in_review', false, { documents: [mkDoc(9001, 'moh-license.pdf')] }),
mkStep('ino_membership', 'pending', false),
mkStep('criminal_record', 'pending', false),
mkStep('bank_account_verification', 'passed', true),
],
credentials: [],
},
{
nurseVerificationId: 502,
nurseId: 102,
identityName: 'زهرا محمدی',
nurseName: 'زهرا محمدی',
status: 'pending',
submittedAt: daysAgo(5),
hasExpiringCredential: true,
steps: [
mkStep('identity_kyc', 'passed', true),
mkStep('shahkar_match', 'passed', true),
mkStep('moh_competency_license', 'pending', false),
mkStep('ino_membership', 'pending', false),
mkStep('criminal_record', 'passed', false, { expiresAt: daysFromNow(18) }),
mkStep('bank_account_verification', 'passed', true),
],
// A recorded criminal-record credential lapsing soon — drives the `hasExpiringCredential` queue chip.
credentials: [mkCredential('criminal_record', 'زهرا محمدی', 'ناجا', { issuedAt: daysAgo(347), expiresAt: daysFromNow(18) })],
},
{
nurseVerificationId: 503,
nurseId: 103,
identityName: 'علی کریمی',
nurseName: 'علی کریمی',
status: 'in_review',
submittedAt: daysAgo(1),
hasExpiringCredential: false,
steps: [
mkStep('identity_kyc', 'passed', true),
mkStep('shahkar_match', 'passed', true),
mkStep('moh_competency_license', 'passed', false),
mkStep('ino_membership', 'in_review', false, { documents: [mkDoc(9002, 'ino-membership.pdf')] }),
mkStep('criminal_record', 'passed', false, { expiresAt: daysFromNow(300) }),
mkStep('bank_account_verification', 'passed', true),
],
credentials: [
mkCredential('moh_competency_license', 'علی کریمی', 'وزارت بهداشت', { issuedAt: daysAgo(120) }),
mkCredential('criminal_record', 'علی کریمی', 'ناجا', { issuedAt: daysAgo(65), expiresAt: daysFromNow(300) }),
],
},
];
function findCaseById(nurseVerificationId: number): AdminCaseRecord | undefined {
return adminCases.find((record) => record.nurseVerificationId === nurseVerificationId);
}
function findCaseByStepId(stepId: number): { record: AdminCaseRecord; step: AdminVerificationStepDetail } | undefined {
for (const record of adminCases) {
const step = record.steps.find((candidate) => candidate.id === stepId);
if (step) return { record, step };
}
return undefined;
}
/** The next step needing an admin's eyes: an `in_review` (uploaded, awaiting decision) step first, else a `pending` one. */
function nextPendingCode(steps: AdminVerificationStepDetail[]): string | null {
return (
steps.find((step) => step.status === 'in_review')?.code ??
steps.find((step) => step.status === 'pending')?.code ??
null
);
}
/** Re-aggregate the case status exactly as the server would after a step decision. */
function reaggregateCase(record: AdminCaseRecord): void {
const allPassed = record.steps.every((step) => step.status === 'passed');
const anyInReview = record.steps.some((step) => step.status === 'in_review');
record.status = allPassed ? 'approved' : anyInReview ? 'in_review' : 'pending';
}
function toQueueItem(record: AdminCaseRecord): AdminVerificationQueueItem {
return {
nurseVerificationId: record.nurseVerificationId,
nurseId: record.nurseId,
nurseName: record.nurseName,
status: record.status,
stepsPassed: record.steps.filter((step) => step.status === 'passed').length,
stepsTotal: record.steps.length,
nextPendingStepCode: nextPendingCode(record.steps),
submittedAt: record.submittedAt,
hasExpiringCredential: record.hasExpiringCredential,
};
}
/** Return the admin-case view (drops the queue-only metadata; deep-copies so callers can't mutate the store). */
function toCaseView(record: AdminCaseRecord): AdminVerificationCase {
return {
nurseVerificationId: record.nurseVerificationId,
nurseId: record.nurseId,
identityName: record.identityName,
status: record.status,
steps: record.steps.map((step) => ({ ...step, documents: step.documents.map((doc) => ({ ...doc })) })),
credentials: record.credentials.map((credential) => ({ ...credential })),
};
}
/**
* In-memory mock behind the VerificationApi seam. Drives the whole nurse journey end-to-end the
* automated runs (identity/shahkar/bank), the manual document uploads ( in_review), the structured
@@ -170,6 +363,88 @@ export const verificationMockApi: VerificationApi = {
credentialTypes: agg.status === 'approved' ? ['moh_competency_license', 'ino_membership'] : [],
} satisfies TrustBadge;
},
listVerificationQueue: async (filters, params) => {
await sleep(MOCK_LATENCY_MS);
// Default (no status filter) shows the whole desk — both `pending` and `in_review`.
const wanted: ReadonlyArray<AdminCaseRecord['status']> = filters.status ? [filters.status] : ['pending', 'in_review'];
const matched = adminCases.filter((record) => wanted.includes(record.status)).map(toQueueItem);
const page = params.page ?? 1;
const pageSize = params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE;
const start = (page - 1) * pageSize;
return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize };
},
getVerificationCase: async (nurseVerificationId) => {
await sleep(MOCK_LATENCY_MS);
const record = findCaseById(nurseVerificationId);
if (!record) throw new ApiError(404, 'Verification not found', 'not_found');
return toCaseView(record);
},
getDocumentSignedUrl: async (documentId) => {
await sleep(MOCK_LATENCY_MS);
// Sentinel for the viewer's error/re-request path: this document can never be signed.
if (documentId === 9999) {
throw new ApiError(404, 'Document not found', 'document_not_found');
}
// A FRESH short-lived URL each call — the signature + timestamp differ so it is never re-used from cache.
const sig = Math.random().toString(36).slice(2, 12);
return {
url: `https://mock.balinyaar.local/docs/${documentId}?sig=${sig}&t=${Date.now()}`,
expiresInSeconds: 60,
};
},
decideStep: async (stepId, input) => {
await sleep(MOCK_LATENCY_MS);
const found = findCaseByStepId(stepId);
if (!found) throw new ApiError(404, 'Step not found', 'not_found');
const { record, step } = found;
if (input.approve) {
step.status = 'passed';
step.failureReason = null;
let credentialId: number | null = null;
// On approving a credential-bearing step with a credential number, record the (encrypted-at-rest,
// never re-serialized) credential — mirroring the server's `nurse_credentials` write.
if (CREDENTIAL_BEARING.has(step.code) && input.credentialNumber) {
const credential = mkCredential(
step.code as CredentialType,
input.holderName ?? record.identityName,
input.issuingAuthority ?? '',
{ issuedAt: input.issuedAt ?? null, expiresAt: input.expiresAt ?? null },
);
record.credentials.push(credential);
credentialId = credential.id;
}
reaggregateCase(record);
return { stepId, stepStatus: step.status, credentialId };
}
const reason = input.rejectionReason?.trim();
if (!reason) throw new ApiError(400, 'Rejection reason is required', 'rejection_reason_required');
step.status = 'failed';
step.failureReason = reason;
reaggregateCase(record);
return { stepId, stepStatus: step.status, credentialId: null };
},
approveVerification: async (nurseVerificationId) => {
await sleep(MOCK_LATENCY_MS);
const record = findCaseById(nurseVerificationId);
if (!record) throw new ApiError(404, 'Verification not found', 'not_found');
record.steps = record.steps.map((step) => ({ ...step, status: 'passed', failureReason: null }));
// Aggregate → `approved`, which drops it out of the queue's `pending`/`in_review` filter.
record.status = 'approved';
},
rejectVerification: async (nurseVerificationId, reason) => {
await sleep(MOCK_LATENCY_MS);
const record = findCaseById(nurseVerificationId);
if (!record) throw new ApiError(404, 'Verification not found', 'not_found');
if (reason.trim().length === 0) throw new ApiError(400, 'Rejection reason is required', 'rejection_reason_required');
// Aggregate → `rejected`, dropping it out of the queue.
record.status = 'rejected';
},
};
/**
@@ -25,3 +25,21 @@ export const MAX_DOCUMENT_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
/** The national-ID is a 10-digit code with an official checksum — validated before the KYC run. */
export const NATIONAL_ID_LENGTH = 10;
/**
* Admin review queue moderately fresh; a decision invalidates it. `keepPreviousData` + a per-page key
* make paging flicker-free, so a short `staleTime` is enough.
*/
export const ADMIN_QUEUE_STALE_TIME = 20_000;
export const ADMIN_QUEUE_PAGE_SIZE = 20;
/** A single admin case — same freshness as the queue; invalidated on every decide / approve / reject. */
export const ADMIN_CASE_STALE_TIME = 20_000;
/**
* A document's **signed GET URL is short-lived** (server issues ~60 s URLs). Fetch it on demand and keep it
* out of long-term cache: a short `staleTime` re-fetches a fresh URL on reopen; a short `gcTime` drops the
* stale URL soon after the viewer closes (never retry a failed/expired sign is surfaced, not re-hammered).
*/
export const SIGNED_DOCUMENT_URL_STALE_TIME = 30_000;
export const SIGNED_DOCUMENT_URL_GC_TIME = 60_000;
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
/**
* Approve the whole verification (all required steps pass aggregate `approved`), removing it from the
* queue. Takes the `nurseVerificationId`; invalidates the queue and that case so both reflect the flip.
*/
export function useApproveVerification() {
const queryClient = useQueryClient();
return useMutation<void, unknown, number>({
mutationFn: (nurseVerificationId) => verificationApi.approveVerification(nurseVerificationId),
onSuccess: (_void, nurseVerificationId) => {
queryClient.invalidateQueries({ queryKey: verificationKeys.adminQueues() });
queryClient.invalidateQueries({ queryKey: verificationKeys.adminCase(nurseVerificationId) });
},
});
}
@@ -0,0 +1,29 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
import type { DecideStepInput, DecideStepResult } from '../types';
export interface DecideStepVars {
stepId: number;
/** The case this step belongs to — used to invalidate exactly that case on success. */
nurseVerificationId: number;
input: DecideStepInput;
}
/**
* Approve or reject a manual step. On approving a credential-bearing step the (encrypted) credential is
* recorded server-side and its `credentialId` comes back; a reject requires `input.rejectionReason`. On
* success we invalidate the case (its steps re-render) and the queue (the aggregate/counts may have moved,
* or the case may have dropped off). A domain 4xx (missing reason, holder-name mismatch) surfaces to the
* caller's `onError`.
*/
export function useDecideStep() {
const queryClient = useQueryClient();
return useMutation<DecideStepResult, unknown, DecideStepVars>({
mutationFn: ({ stepId, input }) => verificationApi.decideStep(stepId, input),
onSuccess: (_result, { nurseVerificationId }) => {
queryClient.invalidateQueries({ queryKey: verificationKeys.adminCase(nurseVerificationId) });
queryClient.invalidateQueries({ queryKey: verificationKeys.adminQueues() });
},
});
}
@@ -0,0 +1,23 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
export interface RejectVerificationVars {
nurseVerificationId: number;
reason: string;
}
/**
* Reject the whole verification (aggregate `rejected`), removing it from the queue. Takes the
* `nurseVerificationId` + a `reason`; invalidates the queue and that case so both reflect the change.
*/
export function useRejectVerification() {
const queryClient = useQueryClient();
return useMutation<void, unknown, RejectVerificationVars>({
mutationFn: ({ nurseVerificationId, reason }) => verificationApi.rejectVerification(nurseVerificationId, reason),
onSuccess: (_void, { nurseVerificationId }) => {
queryClient.invalidateQueries({ queryKey: verificationKeys.adminQueues() });
queryClient.invalidateQueries({ queryKey: verificationKeys.adminCase(nurseVerificationId) });
},
});
}
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
import { ADMIN_CASE_STALE_TIME } from '../constants';
/**
* The full admin case for one nurse steps + documents + credentials + the identity name for cross-check.
* Keyed per `nurseVerificationId` and only enabled once one is selected (pass `null` from the queue until a
* row is opened). Invalidated on every decide / approve / reject so the case reflects the new step states.
*/
export function useVerificationCase(nurseVerificationId: number | null) {
return useQuery({
queryKey: verificationKeys.adminCase(nurseVerificationId ?? -1),
queryFn: () => verificationApi.getVerificationCase(nurseVerificationId as number),
enabled: nurseVerificationId != null,
staleTime: ADMIN_CASE_STALE_TIME,
});
}
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
import { SIGNED_DOCUMENT_URL_GC_TIME, SIGNED_DOCUMENT_URL_STALE_TIME } from '../constants';
/**
* A document's **short-lived signed GET URL**, fetched on demand when the viewer opens a document (pass
* `null` while none is open). Short `staleTime` + short `gcTime` keep the URL out of long-term cache a
* reopen re-signs a fresh URL rather than reusing an expired one. `retry: false`: a failed/expired sign is
* surfaced to the viewer's error/re-request path, not silently re-hammered.
*/
export function useVerificationDocumentUrl(documentId: number | null) {
return useQuery({
queryKey: verificationKeys.adminDocumentUrl(documentId ?? -1),
queryFn: () => verificationApi.getDocumentSignedUrl(documentId as number),
enabled: documentId != null,
staleTime: SIGNED_DOCUMENT_URL_STALE_TIME,
gcTime: SIGNED_DOCUMENT_URL_GC_TIME,
retry: false,
});
}
@@ -0,0 +1,21 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
import { ADMIN_QUEUE_PAGE_SIZE, ADMIN_QUEUE_STALE_TIME } from '../constants';
import type { AdminVerificationQueueFilters } from '../types';
/**
* The admin review queue (one item per nurse), filtered by `status` and paginated. `filters` + `params`
* are part of the query key, so switching the status filter or paging reuses cached pages; `keepPreviousData`
* avoids an empty flash while the next page loads. A decision (`useDecideStep` / approve / reject) invalidates
* the queue so the desk re-renders without a manual refetch.
*/
export function useVerificationQueue(filters: AdminVerificationQueueFilters, page: number) {
const params = { page, pageSize: ADMIN_QUEUE_PAGE_SIZE };
return useQuery({
queryKey: verificationKeys.adminQueue(filters, params),
queryFn: () => verificationApi.listVerificationQueue(filters, params),
staleTime: ADMIN_QUEUE_STALE_TIME,
placeholderData: keepPreviousData,
});
}
@@ -5,3 +5,11 @@ export { useRunBankVerification } from './hooks/useRunBankVerification';
export { useUploadVerificationDocument } from './hooks/useUploadVerificationDocument';
export { useSubmitCredentials } from './hooks/useSubmitCredentials';
export { useNurseTrustBadge } from './hooks/useNurseTrustBadge';
// Admin review queue (b6 AdminVerificationsController)
export { useVerificationQueue } from './hooks/useVerificationQueue';
export { useVerificationCase } from './hooks/useVerificationCase';
export { useVerificationDocumentUrl } from './hooks/useVerificationDocumentUrl';
export { useDecideStep } from './hooks/useDecideStep';
export { useApproveVerification } from './hooks/useApproveVerification';
export { useRejectVerification } from './hooks/useRejectVerification';
+24
View File
@@ -1,8 +1,15 @@
import type { AdminVerificationQueueFilters } from './types';
import type { PageParams } from '@/lib/api/types';
/**
* React Query key factory for the verification domain. The nurse's own `status()` is the **single
* cached source** that both B3 (checklist) and B6 (under-review) read one query, two views. Every
* submit/upload/run mutation invalidates `status()` so the checklist re-renders from cache with no
* manual refetch. The public `badge(nurseId)` is longer-lived and reused by search/f6.
*
* The admin subtree (`admin()` queue / case / document-url) mirrors the same hierarchy: each queue
* variant (filters+params) and each case keys independently, and the `adminQueues()` / `adminCases()`
* prefixes let a decision invalidate every queue page and a single case in one call.
*/
export const verificationKeys = {
all: ['verification'] as const,
@@ -15,4 +22,21 @@ export const verificationKeys = {
// The public trust badge — keyed per nurse; reused by the own-profile view and f6 search/profile.
badge: (nurseId: number) => [...verificationKeys.all, 'badge', nurseId] as const,
// --- Admin review queue (b6 AdminVerificationsController) ---
admin: () => [...verificationKeys.all, 'admin'] as const,
// The review queue — `filters` + `params` are part of the key, so paging / changing the status filter
// never refetches a page already in cache (React Query hashes keys deterministically).
adminQueues: () => [...verificationKeys.admin(), 'queue'] as const,
adminQueue: (filters: AdminVerificationQueueFilters, params: PageParams) =>
[...verificationKeys.adminQueues(), filters, params] as const,
// A single nurse's full case — invalidated on every decide / approve / reject.
adminCases: () => [...verificationKeys.admin(), 'case'] as const,
adminCase: (nurseVerificationId: number) => [...verificationKeys.adminCases(), nurseVerificationId] as const,
// A document's short-lived signed URL — keyed per document; kept out of long-term cache (fetched on demand).
adminDocumentUrls: () => [...verificationKeys.admin(), 'document_url'] as const,
adminDocumentUrl: (documentId: number) => [...verificationKeys.adminDocumentUrls(), documentId] as const,
};
+97
View File
@@ -15,6 +15,8 @@
* credential **types** only.
*/
import type { PageParams, Paginated } from '@/lib/api/types';
/** The aggregate `nurse_verifications.status` — the single source of verification truth. */
export type VerificationAggregateStatus =
| 'not_started'
@@ -146,6 +148,87 @@ export interface CredentialDetailsInput {
expiresAt?: string | null;
}
/* --- Admin review queue (b6 `AdminVerificationsController`) ---------------------------------------
* The admin-side surface of the same trust engine. The nurse builds the checklist above; an admin
* reviews it here works the queue, opens a case, decides each manual step, and (via a re-aggregate)
* flips `is_verified`. `credentialNumber` is accepted only as **input** on a decide; it is NEVER on any
* response DTO (encrypted at rest). Signed document URLs are short-lived fetched on demand, not cached.
*/
/**
* `AdminPendingStepDto`, folded to **one row per nurse** the review queue item. The b6 endpoint returns
* one row per *step* awaiting attention; the nurse-level aggregate (`stepsPassed`/`stepsTotal`/
* `nextPendingStepCode`/`hasExpiringCredential`) is the shape the queue UI needs (a nurse-level queue
* endpoint is filed as REQ-034; the client maps what it can).
*/
export interface AdminVerificationQueueItem {
nurseVerificationId: number;
nurseId: number;
nurseName: string;
status: VerificationAggregateStatus;
stepsPassed: number;
stepsTotal: number;
nextPendingStepCode: string | null;
submittedAt: string | null;
hasExpiringCredential: boolean;
}
/** Queue filter — `status` defaults to `in_review` server-side when omitted. */
export interface AdminVerificationQueueFilters {
status?: 'pending' | 'in_review';
}
/** `AdminStepDetailDto` — one step of the admin case view, carrying its documents (signed GET URLs). */
export interface AdminVerificationStepDetail {
id: number;
code: string;
displayName: string;
status: VerificationStepStatus;
isAutomated: boolean;
expiresAt: string | null;
failureReason: string | null;
documents: VerificationDocument[];
}
/** `AdminVerificationDetailDto` — the full case: ordered steps, recorded credentials, + the identity name for cross-check. */
export interface AdminVerificationCase {
nurseVerificationId: number;
nurseId: number;
identityName: string;
status: VerificationAggregateStatus;
steps: AdminVerificationStepDetail[];
credentials: NurseCredential[];
}
/**
* Body for `POST admin_verifications/steps/{stepId}/decide`. `rejectionReason` is required when
* `approve=false`; the credential fields (recorded only on approving a credential-bearing step) include
* `credentialNumber` accepted as **input** here, but never echoed back on any response.
*/
export interface DecideStepInput {
approve: boolean;
rejectionReason?: string;
credentialNumber?: string;
holderName?: string;
issuingAuthority?: string;
issuedAt?: string | null;
expiresAt?: string | null;
verificationSource?: string;
}
/** `ReviewStepResult` — the step's new status after a decision; `credentialId` is set only when one was recorded. */
export interface DecideStepResult {
stepId: number;
stepStatus: VerificationStepStatus;
credentialId: number | null;
}
/** A freshly-signed, short-lived GET URL for a document — fetched on demand (never long-cached). */
export interface SignedDocumentUrl {
url: string;
expiresInSeconds: number;
}
/**
* The verification domain's API seam the real HTTP client and the in-memory mock both implement
* this interface; selection is by config (`USE_VERIFICATION_MOCK`), never scattered `if (mock)` checks.
@@ -170,6 +253,20 @@ export interface VerificationApi {
submitCredentialDetails(input: CredentialDetailsInput): Promise<void>;
/** The public trust badge for a nurse (types only). */
getTrustBadge(nurseId: number): Promise<TrustBadge>;
// --- Admin review queue (b6 AdminVerificationsController) ---
/** The review queue, folded to one item per nurse. `status` filters (default `in_review`); paginated. */
listVerificationQueue(filters: AdminVerificationQueueFilters, params: PageParams): Promise<Paginated<AdminVerificationQueueItem>>;
/** The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check. */
getVerificationCase(nurseVerificationId: number): Promise<AdminVerificationCase>;
/** A freshly-signed, short-lived GET URL for a document — fetched on demand (URLs expire; never long-cached). */
getDocumentSignedUrl(documentId: number): Promise<SignedDocumentUrl>;
/** Approve or reject a manual step; on a credential-bearing step, records the (encrypted) credential. Re-aggregates. */
decideStep(stepId: number, input: DecideStepInput): Promise<DecideStepResult>;
/** Approve the whole verification (all required steps pass → `approved`), removing it from the queue. */
approveVerification(nurseVerificationId: number): Promise<void>;
/** Reject the whole verification (`rejected`), removing it from the queue. */
rejectVerification(nurseVerificationId: number, reason: string): Promise<void>;
}
/** The specialties offered as ready-made chips in B5 (nurse can add their own). Stable codes → i18n labels. */