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 = (v: T): Promise => new Promise((r) => setTimeout(() => r(v), LATENCY_MS)); function paginate(all: T[], params: PageParams): Paginated { 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 = { 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: '', new: '' }, 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); }, };