frontend phase 15
This commit is contained in:
@@ -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 }),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 0–1 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);
|
||||
},
|
||||
};
|
||||
@@ -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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 0–1) 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user