393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
import bcrypt from 'bcryptjs'
|
|
import jwt from 'jsonwebtoken'
|
|
import crypto from 'crypto'
|
|
import { authenticator } from 'otplib'
|
|
import qrcode from 'qrcode'
|
|
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
|
|
import { generateInvoicePdf, buildInvoiceNumber } from '../../services/invoicePdfService'
|
|
import { sendTransactionalEmail } from '../../services/notificationService'
|
|
import * as presenter from './admin.presenter'
|
|
import * as repo from './admin.repo'
|
|
|
|
const ADMIN_RESET_TTL_MINUTES = 60
|
|
|
|
const PLAN_MONTHLY_AMOUNT: Record<string, number> = {
|
|
STARTER: 2990,
|
|
GROWTH: 3990,
|
|
PRO: 99900,
|
|
}
|
|
|
|
function signAdminToken(adminId: string) {
|
|
return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' })
|
|
}
|
|
|
|
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) {
|
|
const admin = await repo.findAdminByEmail(email)
|
|
if (!admin || !admin.isActive) return null
|
|
|
|
const valid = await bcrypt.compare(password, admin.passwordHash)
|
|
if (!valid) return null
|
|
|
|
if (admin.totpEnabled) {
|
|
if (!totpCode) return { totpRequired: true } as const
|
|
if (!authenticator.verify({ token: totpCode, secret: admin.totpSecret! })) {
|
|
return { invalidTotp: true } as const
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
export async function setupTotp(adminId: string, email: string) {
|
|
const secret = authenticator.generateSecret()
|
|
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 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) await repo.enableAdminTotp(adminId)
|
|
return valid
|
|
}
|
|
|
|
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, 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) {
|
|
const company = await repo.getCompanyForImpersonation(id)
|
|
const token = jwt.sign(
|
|
{
|
|
sub: company.employees[0]?.id,
|
|
companyId: company.id,
|
|
isImpersonation: true,
|
|
type: 'employee',
|
|
},
|
|
process.env.JWT_SECRET!,
|
|
{ expiresIn: '30m' },
|
|
)
|
|
|
|
await repo.createAuditLog({
|
|
adminUserId: adminId,
|
|
action: 'IMPERSONATE_COMPANY',
|
|
resource: 'Company',
|
|
resourceId: id,
|
|
companyId: id,
|
|
ipAddress: ip,
|
|
})
|
|
|
|
return { token, expiresIn: 1800 }
|
|
}
|
|
|
|
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 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) => presenter.presentAdminUser(admin))
|
|
}
|
|
|
|
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; password: string; permissions?: any[] }) {
|
|
const admin = await repo.createAdmin({
|
|
...body,
|
|
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: { status?: string; plan?: string; page: number; pageSize: number }) {
|
|
const [{ data, total }, activeSubscriptions, stats] = await Promise.all([
|
|
repo.listBillingPage(query),
|
|
repo.listActiveSubscriptionsForMrr(),
|
|
repo.getBillingStatusCounts(),
|
|
])
|
|
|
|
const mrr = activeSubscriptions.reduce((acc, subscription) => {
|
|
const monthlyAmount = PLAN_MONTHLY_AMOUNT[subscription.plan] ?? 0
|
|
return acc + (subscription.billingPeriod === 'ANNUAL' ? Math.round(monthlyAmount * 10 / 12) : monthlyAmount)
|
|
}, 0)
|
|
|
|
return presenter.presentPaginated(data, total, query.page, query.pageSize, {
|
|
stats: { mrr, ...stats },
|
|
})
|
|
}
|
|
|
|
export async function getCompanyInvoices(companyId: string, query: { page: number; pageSize: number }) {
|
|
const { data, total } = await repo.listCompanyInvoicesPage(companyId, query)
|
|
return presenter.presentPaginated(data, total, query.page, query.pageSize)
|
|
}
|
|
|
|
export async function getInvoicePdf(invoiceId: string) {
|
|
const invoice = await repo.getInvoicePdfRecord(invoiceId)
|
|
const invoiceNumber = buildInvoiceNumber(invoice.id, invoice.createdAt)
|
|
const transactionId = invoice.amanpayTransactionId ?? invoice.paypalCaptureId ?? null
|
|
const pdfBuffer = await generateInvoicePdf({
|
|
invoiceNumber,
|
|
issueDate: invoice.createdAt.toISOString(),
|
|
dueDate: invoice.status === 'PENDING' ? invoice.createdAt.toISOString() : null,
|
|
company: {
|
|
name: invoice.company.name,
|
|
email: invoice.company.email,
|
|
phone: invoice.company.phone,
|
|
address: invoice.company.address,
|
|
},
|
|
subscription: {
|
|
plan: invoice.subscription.plan,
|
|
billingPeriod: invoice.subscription.billingPeriod,
|
|
currentPeriodStart: invoice.subscription.currentPeriodStart?.toISOString(),
|
|
currentPeriodEnd: invoice.subscription.currentPeriodEnd?.toISOString(),
|
|
currency: invoice.subscription.currency,
|
|
},
|
|
amount: invoice.amount,
|
|
currency: invoice.currency,
|
|
status: invoice.status,
|
|
paymentProvider: invoice.paymentProvider,
|
|
transactionId,
|
|
paidAt: invoice.paidAt?.toISOString(),
|
|
})
|
|
|
|
return { pdfBuffer, invoiceNumber }
|
|
}
|
|
|
|
export function getMarketplaceHomepage() {
|
|
return getMarketplaceHomepageContent()
|
|
}
|
|
|
|
export async function updateMarketplaceHomepage(homepage: any, adminId: string, ip?: string) {
|
|
const saved = await saveMarketplaceHomepageContent(homepage)
|
|
await repo.createAuditLog({
|
|
adminUserId: adminId,
|
|
action: 'UPDATE',
|
|
resource: 'MarketplaceHomepage',
|
|
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,
|
|
})
|
|
}
|