refractor code,
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
export function presentAdminUser<T extends Record<string, any>>(admin: T) {
|
||||
const { passwordHash, totpSecret, ...safe } = admin
|
||||
return safe
|
||||
}
|
||||
|
||||
export function presentAdminSession<T extends Record<string, any>>(admin: T, token: string) {
|
||||
return {
|
||||
token,
|
||||
admin: presentAdminUser(admin),
|
||||
}
|
||||
}
|
||||
|
||||
export function presentPaginated<T>(
|
||||
data: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
const companyListInclude = {
|
||||
brand: { select: { displayName: true, logoUrl: true, subdomain: true } },
|
||||
subscription: { select: { plan: true, status: true } },
|
||||
_count: { select: { employees: true, vehicles: true } },
|
||||
} as const
|
||||
|
||||
const companyDetailInclude = {
|
||||
brand: true,
|
||||
contractSettings: true,
|
||||
accountingSettings: true,
|
||||
subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } },
|
||||
employees: true,
|
||||
_count: { select: { employees: true, vehicles: true, customers: true, reservations: true } },
|
||||
} as const
|
||||
|
||||
const billingInclude = {
|
||||
company: { select: { id: true, name: true, email: true, slug: true, status: true } },
|
||||
invoices: {
|
||||
select: { id: true, amount: true, currency: true, status: true, paidAt: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
},
|
||||
_count: { select: { invoices: true } },
|
||||
} as const
|
||||
|
||||
function parseOptionalDate(value?: string | null) {
|
||||
if (!value) return null
|
||||
return new Date(value)
|
||||
}
|
||||
|
||||
export function findAdminByEmail(email: string) {
|
||||
return prisma.adminUser.findUnique({ where: { email } })
|
||||
}
|
||||
|
||||
export function findAdminByIdOrThrow(id: string) {
|
||||
return prisma.adminUser.findUniqueOrThrow({ where: { id } })
|
||||
}
|
||||
|
||||
export function updateAdminLastLogin(id: string) {
|
||||
return prisma.adminUser.update({ where: { id }, data: { lastLoginAt: new Date() } })
|
||||
}
|
||||
|
||||
export function updateAdminTotpSecret(id: string, secret: string) {
|
||||
return prisma.adminUser.update({ where: { id }, data: { totpSecret: secret } })
|
||||
}
|
||||
|
||||
export function enableAdminTotp(id: string) {
|
||||
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true } })
|
||||
}
|
||||
|
||||
export function setAdminPasswordReset(id: string, token: string, expiresAt: Date) {
|
||||
return prisma.adminUser.update({
|
||||
where: { id },
|
||||
data: { passwordResetToken: token, passwordResetExpiresAt: expiresAt },
|
||||
})
|
||||
}
|
||||
|
||||
export function findAdminByResetToken(token: string) {
|
||||
return prisma.adminUser.findFirst({
|
||||
where: {
|
||||
passwordResetToken: token,
|
||||
passwordResetExpiresAt: { gt: new Date() },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAdminPassword(id: string, passwordHash: string) {
|
||||
return prisma.adminUser.update({
|
||||
where: { id },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordResetToken: null,
|
||||
passwordResetExpiresAt: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createAuditLog(data: Record<string, unknown>) {
|
||||
return prisma.auditLog.create({ data: data as any })
|
||||
}
|
||||
|
||||
export async function listCompaniesPage(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) {
|
||||
const where: any = {}
|
||||
if (query.status) where.status = query.status
|
||||
if (query.q) {
|
||||
where.OR = [
|
||||
{ name: { contains: query.q, mode: 'insensitive' } },
|
||||
{ email: { contains: query.q, mode: 'insensitive' } },
|
||||
{ slug: { contains: query.q, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
if (query.plan) where.subscription = { plan: query.plan }
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.company.findMany({
|
||||
where,
|
||||
include: companyListInclude,
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.company.count({ where }),
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}
|
||||
|
||||
export function getCompanyDetail(id: string) {
|
||||
return prisma.company.findUniqueOrThrow({ where: { id }, include: companyDetailInclude })
|
||||
}
|
||||
|
||||
export function getCompanyUpdateSnapshot(id: string) {
|
||||
return prisma.company.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { brand: true, contractSettings: true, accountingSettings: true, subscription: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function applyCompanyUpdate(id: string, body: any, current: { name: string; slug: string; brand?: { paymentMethodsEnabled?: any[] | null } | null }) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
if (body.company) await tx.company.update({ where: { id }, data: body.company })
|
||||
|
||||
if (body.subscription) {
|
||||
const sub = body.subscription
|
||||
await tx.subscription.upsert({
|
||||
where: { companyId: id },
|
||||
update: {
|
||||
...sub,
|
||||
trialStartAt: parseOptionalDate(sub.trialStartAt),
|
||||
trialEndAt: parseOptionalDate(sub.trialEndAt),
|
||||
currentPeriodStart: parseOptionalDate(sub.currentPeriodStart),
|
||||
currentPeriodEnd: parseOptionalDate(sub.currentPeriodEnd),
|
||||
cancelledAt: parseOptionalDate(sub.cancelledAt),
|
||||
},
|
||||
create: {
|
||||
companyId: id,
|
||||
plan: sub.plan ?? 'STARTER',
|
||||
billingPeriod: sub.billingPeriod ?? 'MONTHLY',
|
||||
status: sub.status ?? 'TRIALING',
|
||||
currency: sub.currency ?? 'MAD',
|
||||
trialStartAt: parseOptionalDate(sub.trialStartAt),
|
||||
trialEndAt: parseOptionalDate(sub.trialEndAt),
|
||||
currentPeriodStart: parseOptionalDate(sub.currentPeriodStart),
|
||||
currentPeriodEnd: parseOptionalDate(sub.currentPeriodEnd),
|
||||
cancelledAt: parseOptionalDate(sub.cancelledAt),
|
||||
cancelAtPeriodEnd: sub.cancelAtPeriodEnd ?? false,
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.brand) {
|
||||
await tx.brandSettings.upsert({
|
||||
where: { companyId: id },
|
||||
update: body.brand,
|
||||
create: {
|
||||
companyId: id,
|
||||
displayName: body.brand.displayName ?? current.name,
|
||||
subdomain: body.brand.subdomain ?? current.slug,
|
||||
paymentMethodsEnabled: current.brand?.paymentMethodsEnabled ?? [],
|
||||
...body.brand,
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.contractSettings) {
|
||||
await tx.contractSettings.upsert({
|
||||
where: { companyId: id },
|
||||
update: body.contractSettings,
|
||||
create: { companyId: id, ...body.contractSettings } as any,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.accountingSettings) {
|
||||
await tx.accountingSettings.upsert({
|
||||
where: { companyId: id },
|
||||
update: body.accountingSettings,
|
||||
create: { companyId: id, ...body.accountingSettings } as any,
|
||||
})
|
||||
}
|
||||
|
||||
return tx.company.findUniqueOrThrow({ where: { id }, include: companyDetailInclude })
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCompanyStatus(id: string, status: string) {
|
||||
return prisma.company.update({ where: { id }, data: { status: status as any } })
|
||||
}
|
||||
|
||||
export function deleteCompany(id: string) {
|
||||
return prisma.company.delete({ where: { id } })
|
||||
}
|
||||
|
||||
export function getCompanyForImpersonation(id: string) {
|
||||
return prisma.company.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { employees: { where: { role: 'OWNER' } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function listRentersPage(query: { q?: string; blocked?: string; page: number; pageSize: number }) {
|
||||
const where: any = {}
|
||||
if (query.blocked !== undefined) where.isActive = query.blocked === 'false'
|
||||
if (query.q) {
|
||||
where.OR = [
|
||||
{ firstName: { contains: query.q, mode: 'insensitive' } },
|
||||
{ email: { contains: query.q, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.renter.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
_count: { select: { reservations: true } },
|
||||
},
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.renter.count({ where }),
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}
|
||||
|
||||
export function updateRenterActive(id: string, isActive: boolean) {
|
||||
return prisma.renter.update({ where: { id }, data: { isActive } })
|
||||
}
|
||||
|
||||
export async function getPlatformMetricCounts() {
|
||||
const [
|
||||
totalCompanies,
|
||||
activeCompanies,
|
||||
trialingCompanies,
|
||||
suspendedCompanies,
|
||||
totalRenters,
|
||||
totalReservations,
|
||||
] = await Promise.all([
|
||||
prisma.company.count(),
|
||||
prisma.company.count({ where: { status: 'ACTIVE' } }),
|
||||
prisma.company.count({ where: { status: 'TRIALING' } }),
|
||||
prisma.company.count({ where: { status: 'SUSPENDED' } }),
|
||||
prisma.renter.count(),
|
||||
prisma.reservation.count(),
|
||||
])
|
||||
|
||||
return {
|
||||
totalCompanies,
|
||||
activeCompanies,
|
||||
trialingCompanies,
|
||||
suspendedCompanies,
|
||||
totalRenters,
|
||||
totalReservations,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAuditLogsPage(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) {
|
||||
const where: any = {}
|
||||
if (query.adminId) where.adminUserId = query.adminId
|
||||
if (query.action) where.action = { contains: query.action }
|
||||
if (query.companyId) where.companyId = query.companyId
|
||||
if (query.entityId) where.resourceId = query.entityId
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where,
|
||||
include: { adminUser: { select: { firstName: true, lastName: true, email: true } } },
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.auditLog.count({ where }),
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}
|
||||
|
||||
export function listAdmins() {
|
||||
return prisma.adminUser.findMany({
|
||||
include: { permissions: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function createAdmin(data: {
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
role: string
|
||||
passwordHash: string
|
||||
permissions?: any[]
|
||||
}) {
|
||||
return prisma.adminUser.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
role: data.role as any,
|
||||
passwordHash: data.passwordHash,
|
||||
permissions: data.permissions ? { create: data.permissions } : undefined,
|
||||
},
|
||||
include: { permissions: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAdminRole(id: string, role: string) {
|
||||
return prisma.adminUser.update({ where: { id }, data: { role: role as any } })
|
||||
}
|
||||
|
||||
export async function replaceAdminPermissions(id: string, permissions: any[]) {
|
||||
await prisma.adminUser.findUniqueOrThrow({ where: { id } })
|
||||
await prisma.$transaction([
|
||||
prisma.adminPermission.deleteMany({ where: { adminUserId: id } }),
|
||||
prisma.adminPermission.createMany({
|
||||
data: permissions.map((permission) => ({
|
||||
adminUserId: id,
|
||||
resource: permission.resource,
|
||||
actions: permission.actions,
|
||||
})) as any,
|
||||
}),
|
||||
])
|
||||
return prisma.adminUser.findUniqueOrThrow({ where: { id }, include: { permissions: true } })
|
||||
}
|
||||
|
||||
export async function listBillingPage(query: { status?: string; plan?: string; page: number; pageSize: number }) {
|
||||
const where: any = {}
|
||||
if (query.status) where.status = query.status
|
||||
if (query.plan) where.plan = query.plan
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.subscription.findMany({
|
||||
where,
|
||||
include: billingInclude,
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.subscription.count({ where }),
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}
|
||||
|
||||
export function listActiveSubscriptionsForMrr() {
|
||||
return prisma.subscription.findMany({
|
||||
where: { status: { in: ['ACTIVE', 'TRIALING'] as any[] } },
|
||||
select: { plan: true, billingPeriod: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBillingStatusCounts() {
|
||||
const [activeCount, trialingCount, pastDueCount, cancelledCount] = await Promise.all([
|
||||
prisma.subscription.count({ where: { status: 'ACTIVE' } }),
|
||||
prisma.subscription.count({ where: { status: 'TRIALING' } }),
|
||||
prisma.subscription.count({ where: { status: 'PAST_DUE' } }),
|
||||
prisma.subscription.count({ where: { status: 'CANCELLED' } }),
|
||||
])
|
||||
|
||||
return { activeCount, trialingCount, pastDueCount, cancelledCount }
|
||||
}
|
||||
|
||||
export async function listCompanyInvoicesPage(companyId: string, query: { page: number; pageSize: number }) {
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.subscriptionInvoice.findMany({
|
||||
where: { companyId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
prisma.subscriptionInvoice.count({ where: { companyId } }),
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}
|
||||
|
||||
export function getInvoicePdfRecord(invoiceId: string) {
|
||||
return prisma.subscriptionInvoice.findUniqueOrThrow({
|
||||
where: { id: invoiceId },
|
||||
include: {
|
||||
company: { select: { name: true, email: true, phone: true, address: true } },
|
||||
subscription: {
|
||||
select: {
|
||||
plan: true,
|
||||
billingPeriod: true,
|
||||
currency: true,
|
||||
currentPeriodStart: true,
|
||||
currentPeriodEnd: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAdminAuth, requireAdminRole } from '../../middleware/requireAdminAuth'
|
||||
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import * as service from './admin.service'
|
||||
import { presentAdminUser } from './admin.presenter'
|
||||
import {
|
||||
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
|
||||
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema,
|
||||
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
|
||||
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
|
||||
homepageUpdateSchema, idParamSchema, companyIdParamSchema, invoiceIdParamSchema,
|
||||
} from './admin.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ─── Auth (public) ─────────────────────────────────────────────
|
||||
|
||||
router.post('/auth/login', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password, totpCode } = parseBody(loginSchema, req)
|
||||
const result = await service.login(email, password, totpCode)
|
||||
if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
|
||||
if ('totpRequired' in result) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
|
||||
if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
|
||||
ok(res, { data: result })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/auth/forgot-password', async (req, res, next) => {
|
||||
try {
|
||||
const { email } = parseBody(forgotPasswordSchema, req)
|
||||
await service.forgotPassword(email)
|
||||
ok(res, { data: { message: 'If that email is registered, a reset link has been sent.' } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/auth/reset-password', async (req, res, next) => {
|
||||
try {
|
||||
const { token, password } = parseBody(resetPasswordSchema, req)
|
||||
const ok2 = await service.resetPassword(token, password)
|
||||
if (!ok2) return res.status(400).json({ error: 'invalid_token', message: 'Reset link is invalid or has expired.', statusCode: 400 })
|
||||
ok(res, { data: { message: 'Password updated successfully. You can now sign in.' } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Auth (protected) ──────────────────────────────────────────
|
||||
|
||||
router.get('/auth/me', requireAdminAuth, (req, res) => {
|
||||
ok(res, { data: presentAdminUser(req.admin as any) })
|
||||
})
|
||||
|
||||
router.post('/auth/2fa/setup', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.setupTotp(req.admin.id, req.admin.email) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { code } = parseBody(totpVerifySchema, req)
|
||||
const valid = await service.verifyTotp(req.admin.id, code)
|
||||
if (!valid) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Companies ─────────────────────────────────────────────────
|
||||
|
||||
router.get('/companies', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.listCompanies(parseQuery(companiesQuerySchema, req)))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/companies/:id', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
ok(res, { data: await service.getCompany(id) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/companies/:id', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(adminCompanyUpdateSchema, req)
|
||||
ok(res, { data: await service.updateCompany(id, body, req.admin.id, req.ip) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/companies/:id/status', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { status, reason } = parseBody(companyStatusSchema, req)
|
||||
ok(res, { data: await service.setCompanyStatus(id, status, reason, req.admin.id, req.ip) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.deleteCompany(id, req.admin.id, req.ip)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
ok(res, { data: await service.impersonateCompany(id, req.admin.id, req.ip) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Renters ───────────────────────────────────────────────────
|
||||
|
||||
router.get('/renters', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.listRenters(parseQuery(rentersQuerySchema, req)))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/renters/:id/block', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.setRenterActive(id, false)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/renters/:id/unblock', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.setRenterActive(id, true)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Metrics ───────────────────────────────────────────────────
|
||||
|
||||
router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.getPlatformMetrics() })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Audit logs ────────────────────────────────────────────────
|
||||
|
||||
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getAuditLogs(parseQuery(auditLogQuerySchema, req)))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Admin users ───────────────────────────────────────────────
|
||||
|
||||
router.get('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.listAdmins() })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
created(res, { data: await service.createAdmin(parseBody(createAdminSchema, req)) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { role } = parseBody(adminRoleSchema, req)
|
||||
await service.updateAdminRole(id, role)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { permissions } = parseBody(adminPermissionsSchema, req)
|
||||
ok(res, { data: await service.updateAdminPermissions(id, permissions) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Billing ───────────────────────────────────────────────────
|
||||
|
||||
router.get('/billing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getBilling(parseQuery(billingQuerySchema, req)))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/:companyId/invoices', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { companyId } = parseParams(companyIdParamSchema, req)
|
||||
ok(res, await service.getCompanyInvoices(companyId, parseQuery(invoicesQuerySchema, req)))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
|
||||
const { pdfBuffer, invoiceNumber } = await service.getInvoicePdf(invoiceId)
|
||||
res.setHeader('Content-Type', 'application/pdf')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${invoiceNumber}.pdf"`)
|
||||
res.setHeader('Content-Length', pdfBuffer.length)
|
||||
res.end(pdfBuffer)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Site config ───────────────────────────────────────────────
|
||||
|
||||
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.getMarketplaceHomepage() })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/site-config/marketplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { homepage } = parseBody(homepageUpdateSchema, req)
|
||||
ok(res, { data: await service.updateMarketplaceHomepage(homepage, req.admin.id, req.ip) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,176 @@
|
||||
import { z } from 'zod'
|
||||
import type { MarketplaceHomepageContent, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep } from '@rentaldrivego/types'
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
password: z.string().max(128),
|
||||
totpCode: z.string().length(6).optional(),
|
||||
})
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
})
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(8).max(128),
|
||||
})
|
||||
|
||||
export const totpVerifySchema = z.object({
|
||||
code: z.string().length(6),
|
||||
})
|
||||
|
||||
export const companiesQuerySchema = z.object({
|
||||
q: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
plan: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const rentersQuerySchema = z.object({
|
||||
q: z.string().optional(),
|
||||
blocked: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const auditLogQuerySchema = z.object({
|
||||
adminId: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
companyId: z.string().optional(),
|
||||
entityId: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
|
||||
export const billingQuerySchema = z.object({
|
||||
status: z.string().optional(),
|
||||
plan: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const invoicesQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const permissionSchema = z.object({
|
||||
resource: z.enum(['COMPANIES', 'COMPANY_EMPLOYEES', 'SUBSCRIPTIONS', 'PAYMENTS', 'OFFERS', 'RENTERS', 'ADMIN_USERS', 'AUDIT_LOGS', 'PLATFORM_METRICS']),
|
||||
actions: z.array(z.enum(['READ', 'CREATE', 'UPDATE', 'DELETE', 'SUSPEND', 'IMPERSONATE'])).min(1),
|
||||
})
|
||||
|
||||
export const createAdminSchema = z.object({
|
||||
email: z.string().email(),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
|
||||
password: z.string().min(8),
|
||||
permissions: z.array(permissionSchema).optional(),
|
||||
})
|
||||
|
||||
export const adminRoleSchema = z.object({
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
|
||||
})
|
||||
|
||||
export const adminPermissionsSchema = z.object({
|
||||
permissions: z.array(permissionSchema),
|
||||
})
|
||||
|
||||
export const companyStatusSchema = z.object({
|
||||
status: z.enum(['ACTIVE', 'SUSPENDED', 'CANCELLED']),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
const nullableString = z.union([z.string(), z.null()]).optional()
|
||||
const nullableEmail = z.union([z.string().email(), z.null()]).optional()
|
||||
const nullableUrl = z.union([z.string().url(), z.null()]).optional()
|
||||
const nullableDate = z.union([z.string().datetime(), z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.null()]).optional()
|
||||
|
||||
export const adminCompanyUpdateSchema = z.object({
|
||||
company: z.object({
|
||||
name: z.string().min(1).optional(), slug: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(), phone: nullableString,
|
||||
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
|
||||
subscriptionPaymentRef: nullableString,
|
||||
}).optional(),
|
||||
subscription: z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
|
||||
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
trialStartAt: nullableDate, trialEndAt: nullableDate,
|
||||
currentPeriodStart: nullableDate, currentPeriodEnd: nullableDate,
|
||||
cancelledAt: nullableDate, cancelAtPeriodEnd: z.boolean().optional(),
|
||||
}).optional(),
|
||||
brand: z.object({
|
||||
displayName: z.string().min(1).optional(), tagline: nullableString,
|
||||
subdomain: z.string().min(1).optional(), customDomain: nullableString,
|
||||
publicEmail: nullableEmail, publicPhone: nullableString, publicAddress: nullableString,
|
||||
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
|
||||
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
|
||||
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
homePageConfig: z.any().optional(),
|
||||
menuConfig: z.any().optional(),
|
||||
}).optional(),
|
||||
contractSettings: z.object({
|
||||
legalName: nullableString, registrationNumber: nullableString, taxId: nullableString,
|
||||
terms: z.string().optional(),
|
||||
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
|
||||
lateFeePerHour: z.number().int().nullable().optional(), taxRate: z.number().nullable().optional(),
|
||||
signatureRequired: z.boolean().optional(), showTax: z.boolean().optional(),
|
||||
}).optional(),
|
||||
accountingSettings: z.object({
|
||||
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
|
||||
fiscalYearStart: z.number().int().min(1).max(12).optional(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
accountantEmail: nullableEmail, accountantName: nullableString,
|
||||
autoSendReport: z.boolean().optional(),
|
||||
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
const marketplaceMetricSchema: z.ZodType<MarketplaceHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
|
||||
const marketplacePillarSchema: z.ZodType<MarketplaceHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) })
|
||||
const marketplaceStepSchema: z.ZodType<MarketplaceHomepageStep> = z.object({ step: z.string().min(1), title: z.string().min(1), body: z.string().min(1) })
|
||||
const marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'steps', 'closing'])
|
||||
|
||||
const marketplaceHomepageContentSchema: z.ZodType<MarketplaceHomepageContent> = z.object({
|
||||
sections: z.array(marketplaceSectionSchema).min(1),
|
||||
heroKicker: z.string().min(1), heroTitle: z.string().min(1), heroBody: z.string().min(1),
|
||||
startTrial: z.string().min(1), exploreVehicles: z.string().min(1),
|
||||
surfaceLabel: z.string().min(1), surfaceTitle: z.string().min(1), surfaceBody: z.string().min(1),
|
||||
liveLabel: z.string().min(1), trustedFleets: z.string().min(1), brandedFlows: z.string().min(1), multiTenant: z.string().min(1),
|
||||
companyKicker: z.string().min(1), companyTitle: z.string().min(1), companyBody: z.string().min(1),
|
||||
renterKicker: z.string().min(1), renterTitle: z.string().min(1), renterBody: z.string().min(1),
|
||||
pillars: z.array(marketplacePillarSchema).length(3),
|
||||
metrics: z.array(marketplaceMetricSchema).length(3),
|
||||
featureLabel: z.string().min(1), features: z.array(z.string().min(1)).min(1),
|
||||
stepsTitle: z.string().min(1), steps: z.array(marketplaceStepSchema).length(3), stepLabel: z.string().min(1),
|
||||
readyKicker: z.string().min(1), readyTitle: z.string().min(1), readyBody: z.string().min(1),
|
||||
viewPricing: z.string().min(1), createWorkspace: z.string().min(1),
|
||||
})
|
||||
|
||||
export const marketplaceHomepageConfigSchema = z.object({
|
||||
en: marketplaceHomepageContentSchema,
|
||||
fr: marketplaceHomepageContentSchema,
|
||||
ar: marketplaceHomepageContentSchema,
|
||||
})
|
||||
|
||||
export const idParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
|
||||
export const companyIdParamSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
})
|
||||
|
||||
export const invoiceIdParamSchema = z.object({
|
||||
invoiceId: z.string().min(1),
|
||||
})
|
||||
|
||||
export const homepageUpdateSchema = z.object({
|
||||
homepage: marketplaceHomepageConfigSchema,
|
||||
})
|
||||
@@ -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