import bcrypt from 'bcryptjs' import crypto from 'crypto' import { hashPublicAccessToken } from '../../security/publicAccessTokens' import { authenticator } from 'otplib' import { signActorToken } from '../../security/tokens' import qrcode from 'qrcode' import { getCarplaceHomepageContent, saveCarplaceHomepageContent } from '../../services/platformContentService' import { sendTransactionalEmail } from '../../services/notificationService' import { redis } from '../../lib/redis' import * as presenter from './admin.presenter' import * as repo from './admin.repo' import * as billingService from './admin.billing.service' const ADMIN_RESET_TTL_MINUTES = 60 const ADMIN_RECOVERY_CODE_COUNT = 10 const ADMIN_EMAIL_OTP_TTL_MINUTES = 10 const ADMIN_EMAIL_OTP_TTL_SECONDS = ADMIN_EMAIL_OTP_TTL_MINUTES * 60 const pendingAdminEmailOtps = new Map() function generateRecoveryCode() { const raw = crypto.randomBytes(9).toString('base64url').replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 12) return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}` } async function issueAdminRecoveryCodes(adminId: string) { const codes = Array.from({ length: ADMIN_RECOVERY_CODE_COUNT }, generateRecoveryCode) const hashes = await Promise.all(codes.map((code) => bcrypt.hash(code, 12))) await repo.replaceAdminRecoveryCodes(adminId, hashes) await repo.createAuditLog({ adminUserId: adminId, action: 'ADMIN_2FA_RECOVERY_CODES_ISSUED', resource: 'AdminUser', resourceId: adminId, }) return codes } async function consumeAdminRecoveryCode(adminId: string, code: string) { const normalized = code.trim().toUpperCase() if (!normalized) return false const codes = await repo.listUnusedAdminRecoveryCodes(adminId) for (const candidate of codes) { if (await bcrypt.compare(normalized, candidate.codeHash)) { await repo.markAdminRecoveryCodeUsed(candidate.id) await repo.createAuditLog({ adminUserId: adminId, action: 'ADMIN_2FA_RECOVERY_CODE_USED', resource: 'AdminUser', resourceId: adminId, }) return true } } return false } function signAdminToken(adminId: string, last2faAt?: number) { return signActorToken(adminId, 'admin', { expiresIn: '8h', last2faAt }) } function generateAdminEmailOtp() { return crypto.randomInt(100000, 1000000).toString() } function adminEmailOtpKey(adminId: string) { return `admin:email-otp:${adminId}` } async function sendAdminEmailOtp(admin: { id: string; email: string; firstName?: string | null }) { const code = generateAdminEmailOtp() const codeHash = hashPublicAccessToken(code) pendingAdminEmailOtps.set(admin.id, { code: codeHash, expiresAt: Date.now() + ADMIN_EMAIL_OTP_TTL_MINUTES * 60 * 1000, }) await redis .set(adminEmailOtpKey(admin.id), codeHash, 'EX', ADMIN_EMAIL_OTP_TTL_SECONDS) .catch((err) => console.error('[AdminLoginEmailOtpRedisSet]', err?.message)) await sendTransactionalEmail({ to: admin.email, subject: 'Your RentalDriveGo admin login code', html: `

Hi ${admin.firstName ?? 'Admin'},

Your admin login code is ${code}.

It expires in ${ADMIN_EMAIL_OTP_TTL_MINUTES} minutes.

`, text: `Hi ${admin.firstName ?? 'Admin'},\n\nYour admin login code is ${code}.\n\nIt expires in ${ADMIN_EMAIL_OTP_TTL_MINUTES} minutes.`, }).catch((err) => console.error('[AdminLoginEmailOtp]', err?.message)) } async function consumeAdminEmailOtp(adminId: string, code: string | undefined) { if (!code) return false const codeHash = hashPublicAccessToken(code.trim()) const key = adminEmailOtpKey(adminId) const persistedHash = await redis .get(key) .catch((err) => { console.error('[AdminLoginEmailOtpRedisGet]', err?.message) return null }) if (persistedHash) { if (persistedHash !== codeHash) return false await redis.del(key).catch((err) => console.error('[AdminLoginEmailOtpRedisDel]', err?.message)) pendingAdminEmailOtps.delete(adminId) return true } const pending = pendingAdminEmailOtps.get(adminId) if (!pending) return false if (pending.expiresAt <= Date.now()) { pendingAdminEmailOtps.delete(adminId) return false } if (pending.code !== codeHash) return false pendingAdminEmailOtps.delete(adminId) return true } function toAuditJson(value: T) { return JSON.parse(JSON.stringify(value)) } function ensureAdminBasePath(baseUrl: string) { try { const url = new URL(baseUrl) const pathname = url.pathname.replace(/\/$/, '') if (!pathname.endsWith('/admin')) url.pathname = `${pathname}/admin` return url.toString().replace(/\/$/, '') } catch { const trimmed = baseUrl.replace(/\/$/, '') return trimmed.endsWith('/admin') ? trimmed : `${trimmed}/admin` } } export async function login(email: string, password: string, totpCode?: string, recoveryCode?: string) { const admin = await repo.findAdminByEmail(email) if (!admin || !admin.isActive) return null const valid = await bcrypt.compare(password, admin.passwordHash) if (!valid) return null let last2faAt: number | undefined if (admin.totpEnabled && (totpCode || recoveryCode)) { const validTotp = totpCode && admin.totpSecret ? authenticator.verify({ token: totpCode, secret: admin.totpSecret! }) : false const validEmailOtp = !validTotp && !admin.totpSecret && await consumeAdminEmailOtp(admin.id, totpCode) const validRecoveryCode = !validTotp && !validEmailOtp && recoveryCode ? await consumeAdminRecoveryCode(admin.id, recoveryCode) : false if (!validTotp && !validEmailOtp && !validRecoveryCode) return { invalidTotp: true } as const last2faAt = Date.now() } await repo.updateAdminLastLogin(admin.id) await repo.createAuditLog({ adminUserId: admin.id, action: 'ADMIN_LOGIN', resource: 'AdminUser', resourceId: admin.id, }) return presenter.presentAdminSession(admin, signAdminToken(admin.id, last2faAt)) } export async function setupTotp(adminId: string, email: string) { const admin = await repo.findAdminByIdOrThrow(adminId) const secret = admin.totpSecret && !admin.totpEnabled ? admin.totpSecret : authenticator.generateSecret() if (secret !== admin.totpSecret) { await repo.updateAdminTotpSecret(adminId, secret) } const otpauth = authenticator.keyuri(email, 'RentalDriveGo Admin', secret) const qrCode = await qrcode.toDataURL(otpauth) return { secret, qrCode } } export async function setupEmail2fa(adminId: string) { const admin = await repo.findAdminByIdOrThrow(adminId) await sendAdminEmailOtp(admin) return { message: 'Verification code sent.' } } export async function verifyEmail2fa(adminId: string, code: string) { const admin = await repo.findAdminByIdOrThrow(adminId) const valid = await consumeAdminEmailOtp(adminId, code) if (!valid) return false const updated = await repo.enableAdminEmail2fa(adminId) await repo.createAuditLog({ adminUserId: adminId, action: 'ADMIN_2FA_EMAIL_ENABLED', resource: 'AdminUser', resourceId: adminId, }) const recoveryCodes = await issueAdminRecoveryCodes(adminId) return { ...presenter.presentAdminSession({ ...admin, ...updated, totpEnabled: true }, signAdminToken(adminId, Date.now())), recoveryCodes, } } export async function verifyTotp(adminId: string, code: string) { const admin = await repo.findAdminByIdOrThrow(adminId) if (!admin.totpSecret) return false const valid = authenticator.verify({ token: code, secret: admin.totpSecret }) if (!valid) return false await repo.enableAdminTotp(adminId) await repo.createAuditLog({ adminUserId: adminId, action: 'ADMIN_2FA_VERIFIED', resource: 'AdminUser', resourceId: adminId, }) const recoveryCodes = await issueAdminRecoveryCodes(adminId) return { ...presenter.presentAdminSession({ ...admin, totpEnabled: true }, signAdminToken(adminId, Date.now())), recoveryCodes, } } export async function regenerateRecoveryCodes(adminId: string) { return { recoveryCodes: await issueAdminRecoveryCodes(adminId) } } export async function forgotPassword(email: string) { const admin = await repo.findAdminByEmail(email) if (!admin || !admin.isActive) return const rawToken = crypto.randomBytes(32).toString('hex') const expiresAt = new Date(Date.now() + ADMIN_RESET_TTL_MINUTES * 60 * 1000) await repo.setAdminPasswordReset(admin.id, hashPublicAccessToken(rawToken), expiresAt) const adminUrl = ensureAdminBasePath( process.env.ADMIN_URL ?? process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3000/admin', ) const resetUrl = `${adminUrl}/reset-password?token=${rawToken}` await sendTransactionalEmail({ to: admin.email, subject: 'Reset your RentalDriveGo admin password', html: `

Hi ${admin.firstName},

Reset password

Expires in ${ADMIN_RESET_TTL_MINUTES} minutes.

`, text: `Hi ${admin.firstName},\n\nReset here: ${resetUrl}\n\nExpires in ${ADMIN_RESET_TTL_MINUTES} minutes.`, }).catch((err) => console.error('[AdminForgotPassword]', err?.message)) } export async function resetPassword(token: string, password: string) { const admin = await repo.findAdminByResetToken(token) if (!admin) return false await repo.updateAdminPassword(admin.id, await bcrypt.hash(password, 12)) return true } export async function listCompanies(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) { const { data, total } = await repo.listCompaniesPage(query) return presenter.presentPaginated(data, total, query.page, query.pageSize) } export function getCompany(id: string) { return repo.getCompanyDetail(id) } export async function updateCompany(id: string, body: any, adminId: string, ip?: string) { const before = await repo.getCompanyUpdateSnapshot(id) const updated = await repo.applyCompanyUpdate(id, body, before) await repo.createAuditLog({ adminUserId: adminId, action: 'UPDATE_COMPANY', resource: 'Company', resourceId: id, companyId: id, before: toAuditJson(before), after: toAuditJson(body), ipAddress: ip, }) return updated } export async function setCompanyStatus(id: string, status: string, reason: string | undefined, adminId: string, ip?: string) { const before = await repo.getCompanyUpdateSnapshot(id) const updated = await repo.updateCompanyStatus(id, status) await repo.createAuditLog({ adminUserId: adminId, action: `SET_COMPANY_STATUS_${status}`, resource: 'Company', resourceId: id, companyId: id, before: { status: before.status }, after: { status }, note: reason, ipAddress: ip, }) return updated } export async function deleteCompany(id: string, adminId: string, ip?: string) { await repo.deleteCompany(id) await repo.createAuditLog({ adminUserId: adminId, action: 'DELETE_COMPANY', resource: 'Company', resourceId: id, ipAddress: ip, }) } export async function impersonateCompany(id: string, adminId: string, ip?: string, reason?: string, durationMinutes = 15) { const company = await repo.getCompanyForImpersonation(id) const ttlMinutes = Math.min(Math.max(durationMinutes, 1), 30) const employeeId = company.employees[0]?.id if (!employeeId) throw new Error('Company has no employee account to impersonate') const token = signActorToken(employeeId, 'employee', { expiresIn: `${ttlMinutes}m` as any }) await repo.createAuditLog({ adminUserId: adminId, action: 'IMPERSONATE_COMPANY', resource: 'Company', resourceId: id, companyId: id, note: reason, before: { originalAdminId: adminId }, after: { targetCompanyId: id, durationMinutes: ttlMinutes }, ipAddress: ip, }) return { token, expiresIn: ttlMinutes * 60, impersonation: { companyId: id, reason, durationMinutes: ttlMinutes } } } export async function listRenters(query: { q?: string; blocked?: string; page: number; pageSize: number }) { const { data, total } = await repo.listRentersPage(query) return presenter.presentPaginated(data, total, query.page, query.pageSize) } export function setRenterActive(id: string, isActive: boolean) { return repo.updateRenterActive(id, isActive) } export function getPlatformMetrics() { return repo.getPlatformMetricCounts() } export async function listNotifications(query: { channel?: string; status?: string; companyId?: string; page: number; pageSize: number }) { const { data, total } = await repo.listNotificationsPage(query) return presenter.presentPaginated(data, total, query.page, query.pageSize) } export async function getAuditLogs(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) { const { data, total } = await repo.listAuditLogsPage(query) return presenter.presentPaginated(data, total, query.page, query.pageSize) } export async function listAdmins() { const admins = await repo.listAdmins() return admins.map((admin: any) => presenter.presentAdminUser(admin)) } export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; preferredLocale: string; password: string; permissions?: any[] }) { const admin = await repo.createAdmin({ ...body, passwordHash: await bcrypt.hash(body.password, 12), }) return presenter.presentAdminUser(admin) } export async function updateAdmin( id: string, body: { email?: string firstName?: string lastName?: string role?: string preferredLocale?: string password?: string isActive?: boolean }, ) { const admin = await repo.updateAdmin(id, { ...(body.email !== undefined ? { email: body.email } : {}), ...(body.firstName !== undefined ? { firstName: body.firstName } : {}), ...(body.lastName !== undefined ? { lastName: body.lastName } : {}), ...(body.role !== undefined ? { role: body.role } : {}), ...(body.preferredLocale !== undefined ? { preferredLocale: body.preferredLocale } : {}), ...(body.isActive !== undefined ? { isActive: body.isActive } : {}), ...(body.password ? { passwordHash: await bcrypt.hash(body.password, 12) } : {}), }) return presenter.presentAdminUser(admin) } export function updateAdminRole(id: string, role: string) { return repo.updateAdminRole(id, role) } export async function updateAdminPermissions(id: string, permissions: any[]) { const admin = await repo.replaceAdminPermissions(id, permissions) return presenter.presentAdminUser(admin) } export async function getBilling(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) { const { data, total, stats } = await billingService.listBillingAccounts(query) return presenter.presentPaginated(data, total, query.page, query.pageSize, { stats }) } export async function getCompanyInvoices(companyId: string, query: { page: number; pageSize: number }) { const detail = await billingService.getBillingAccountDetail(companyId) const start = (query.page - 1) * query.pageSize const end = start + query.pageSize const data = detail.invoices.slice(start, end) const total = detail.invoices.length return presenter.presentPaginated(data, total, query.page, query.pageSize) } export async function getInvoicePdf(invoiceId: string) { return billingService.getInvoicePdf(invoiceId) } export function getBillingAccountDetail(companyId: string) { return billingService.getBillingAccountDetail(companyId) } export function updateBillingAccount( billingAccountId: string, data: Parameters[1], adminId: string, ip?: string, ) { return billingService.updateBillingAccount(billingAccountId, data, adminId, ip) } export function getBillingPlatformSettings() { return billingService.getBillingPlatformSettings() } export function updateBillingPlatformSettings( data: Parameters[0], adminId: string, ip?: string, ) { return billingService.updateBillingPlatformSettings(data, adminId, ip) } export function setDunningPaused(billingAccountId: string, paused: boolean, adminId: string, ip?: string) { return billingService.setDunningPaused(billingAccountId, paused, adminId, ip) } export function createBillingInvoice( billingAccountId: string, data: Parameters[1], adminId: string, ip?: string, ) { return billingService.createDraftInvoice(billingAccountId, data, adminId, ip) } export function finalizeBillingInvoice(invoiceId: string, adminId: string, ip?: string) { return billingService.finalizeInvoice(invoiceId, adminId, ip) } export function payBillingInvoice( invoiceId: string, data: Parameters[1], adminId: string, ip?: string, ) { return billingService.payInvoice(invoiceId, data, adminId, ip) } export function retryBillingInvoicePayment( invoiceId: string, data: Parameters[1], adminId: string, ip?: string, ) { return billingService.retryInvoicePayment(invoiceId, data, adminId, ip) } export function voidBillingInvoice(invoiceId: string, reason: string, adminId: string, ip?: string) { return billingService.voidInvoice(invoiceId, reason, adminId, ip) } export function markBillingInvoiceUncollectible(invoiceId: string, reason: string, adminId: string, ip?: string) { return billingService.markInvoiceUncollectible(invoiceId, reason, adminId, ip) } export function issueBillingCreditNote( invoiceId: string, data: Parameters[1], adminId: string, ip?: string, ) { return billingService.issueCreditNote(invoiceId, data, adminId, ip) } export function issueBillingRefund( invoiceId: string, data: Parameters[1], adminId: string, ip?: string, ) { return billingService.issueRefund(invoiceId, data, adminId, ip) } export function getCarplaceHomepage() { return getCarplaceHomepageContent() } export async function updateCarplaceHomepage(homepage: any, adminId: string, ip?: string) { const saved = await saveCarplaceHomepageContent(homepage) await repo.createAuditLog({ adminUserId: adminId, action: 'UPDATE', resource: 'CarplaceHomepage', after: toAuditJson(saved), ipAddress: ip, userAgent: undefined, }) return saved } export function getPricingConfigs() { return repo.listPricingConfigs() } export async function updatePricingConfigs(entries: { plan: string; billingPeriod: string; amount: number }[], adminId: string, ip?: string) { const results = await Promise.all( entries.map((e) => repo.upsertPricingConfig(e.plan, e.billingPeriod, e.amount, adminId)), ) await repo.createAuditLog({ adminUserId: adminId, action: 'UPDATE', resource: 'PricingConfig', after: toAuditJson(results), ipAddress: ip, userAgent: undefined, }) return results } export function listPlanFeatures() { return repo.listPlanFeatures() } export async function createPlanFeature(data: Parameters[0], adminId: string, ip?: string) { const feature = await repo.createPlanFeature(data) await repo.createAuditLog({ adminUserId: adminId, action: 'CREATE', resource: 'PlanFeature', after: toAuditJson(feature), ipAddress: ip, userAgent: undefined, }) return feature } export async function updatePlanFeature(id: string, data: Parameters[1], adminId: string, ip?: string) { const feature = await repo.updatePlanFeature(id, data) await repo.createAuditLog({ adminUserId: adminId, action: 'UPDATE', resource: 'PlanFeature', resourceId: id, after: toAuditJson(feature), ipAddress: ip, userAgent: undefined, }) return feature } export async function deletePlanFeature(id: string, adminId: string, ip?: string) { await repo.deletePlanFeature(id) await repo.createAuditLog({ adminUserId: adminId, action: 'DELETE', resource: 'PlanFeature', resourceId: id, ipAddress: ip, userAgent: undefined, }) } // ─── Promotions ──────────────────────────────────────────────────────────── export function listPromotions() { return repo.listPromotions() } export async function createPromotion(data: Parameters[0], adminId: string, ip?: string) { const promo = await repo.createPromotion({ ...data, createdBy: adminId }) await repo.createAuditLog({ adminUserId: adminId, action: 'CREATE', resource: 'PricingPromotion', after: toAuditJson(promo), ipAddress: ip, userAgent: undefined, }) return promo } export async function updatePromotion(id: string, data: Parameters[1], adminId: string, ip?: string) { const promo = await repo.updatePromotion(id, data) await repo.createAuditLog({ adminUserId: adminId, action: 'UPDATE', resource: 'PricingPromotion', after: toAuditJson(promo), ipAddress: ip, userAgent: undefined, }) return promo } export async function deletePromotion(id: string, adminId: string, ip?: string) { await repo.deletePromotion(id) await repo.createAuditLog({ adminUserId: adminId, action: 'DELETE', resource: 'PricingPromotion', entityId: id, ipAddress: ip, userAgent: undefined, }) }