refractor code,
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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: 29900,
|
||||
GROWTH: 59900,
|
||||
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:3002/admin',
|
||||
)
|
||||
const resetUrl = `${adminUrl}/reset-password?token=${rawToken}`
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: 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
|
||||
}
|
||||
Reference in New Issue
Block a user