Files
baya-monorepo/client/src/services/admin/apis/mockApi.ts
T
2026-07-10 20:28:06 +03:30

197 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
},
};