66877d66c2
Build & Push / Pipeline Tests (push) Successful in 1m53s
Test / Type Check (all packages) (push) Successful in 54s
Build & Push / Build & Push Docker Image (push) Successful in 3m29s
Test / API Unit Tests (push) Successful in 1m14s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 44s
Test / API Integration Tests (push) Successful in 1m8s
619 lines
21 KiB
TypeScript
619 lines
21 KiB
TypeScript
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<string, { code: string; expiresAt: number }>()
|
|
|
|
|
|
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: `<p>Hi ${admin.firstName ?? 'Admin'},</p><p>Your admin login code is <strong>${code}</strong>.</p><p>It expires in ${ADMIN_EMAIL_OTP_TTL_MINUTES} minutes.</p>`,
|
|
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<T>(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: `<p>Hi ${admin.firstName},</p><p><a href="${resetUrl}">Reset password</a></p><p>Expires in ${ADMIN_RESET_TTL_MINUTES} minutes.</p>`,
|
|
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<typeof billingService.updateBillingAccount>[1],
|
|
adminId: string,
|
|
ip?: string,
|
|
) {
|
|
return billingService.updateBillingAccount(billingAccountId, data, adminId, ip)
|
|
}
|
|
|
|
export function getBillingPlatformSettings() {
|
|
return billingService.getBillingPlatformSettings()
|
|
}
|
|
|
|
export function updateBillingPlatformSettings(
|
|
data: Parameters<typeof billingService.updateBillingPlatformSettings>[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<typeof billingService.createDraftInvoice>[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<typeof billingService.payInvoice>[1],
|
|
adminId: string,
|
|
ip?: string,
|
|
) {
|
|
return billingService.payInvoice(invoiceId, data, adminId, ip)
|
|
}
|
|
|
|
export function retryBillingInvoicePayment(
|
|
invoiceId: string,
|
|
data: Parameters<typeof billingService.retryInvoicePayment>[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<typeof billingService.issueCreditNote>[1],
|
|
adminId: string,
|
|
ip?: string,
|
|
) {
|
|
return billingService.issueCreditNote(invoiceId, data, adminId, ip)
|
|
}
|
|
|
|
export function issueBillingRefund(
|
|
invoiceId: string,
|
|
data: Parameters<typeof billingService.issueRefund>[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<typeof repo.createPlanFeature>[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<typeof repo.updatePlanFeature>[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<typeof repo.createPromotion>[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<typeof repo.updatePromotion>[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,
|
|
})
|
|
}
|