197 lines
14 KiB
TypeScript
197 lines
14 KiB
TypeScript
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);
|
||
},
|
||
};
|