redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s

This commit is contained in:
root
2026-06-26 16:27:21 -04:00
parent 256ff0814e
commit d7fb7b7a7b
1030 changed files with 107374 additions and 2657 deletions
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
billingAccountUpdateSchema,
billingCreditNoteSchema,
billingRefundSchema,
createBillingInvoiceSchema,
payBillingInvoiceSchema,
} from './admin.schemas'
describe('admin billing schemas', () => {
it('accepts complete draft invoice payloads and preserves nullable billing fields', () => {
const parsed = createBillingInvoiceSchema.parse({
subscriptionId: null,
invoiceType: 'MANUAL',
currency: 'MAD',
dueAt: '2026-08-10',
isSubscriptionBlocking: false,
adminReason: null,
lineItems: [{
type: 'MANUAL_ADJUSTMENT',
description: 'Manual correction',
quantity: 2,
unitAmount: 1500,
periodStart: null,
periodEnd: '2026-08-31T00:00:00.000Z',
}],
})
expect(parsed.lineItems[0]).toMatchObject({ quantity: 2, unitAmount: 1500, periodStart: null })
})
it('rejects invoices without line items because empty invoices are bookkeeping cosplay', () => {
expect(() => createBillingInvoiceSchema.parse({ invoiceType: 'MANUAL', lineItems: [] })).toThrow()
})
it('bounds billing account net terms and validates email formatting', () => {
expect(billingAccountUpdateSchema.parse({
legalName: 'Atlas Cars LLC',
billingEmail: 'billing@example.test',
invoiceTerms: 'NET_30',
netTermsDays: 30,
})).toMatchObject({ netTermsDays: 30 })
expect(() => billingAccountUpdateSchema.parse({ billingEmail: 'not-email', netTermsDays: 366 })).toThrow()
})
it('requires positive money movements for payments, credit notes, and refunds', () => {
expect(payBillingInvoiceSchema.parse({ amount: 5000, paymentMethodId: null })).toEqual({ amount: 5000, paymentMethodId: null })
expect(() => payBillingInvoiceSchema.parse({ amount: 0 })).toThrow()
expect(() => billingCreditNoteSchema.parse({ amount: -1, reason: 'Bad credit' })).toThrow()
expect(() => billingRefundSchema.parse({ amount: 0, reason: 'Bad refund' })).toThrow()
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
menuItemSchema,
menuPlanAssignmentsSchema,
menuCompanyAssignmentsSchema,
menuPreviewSchema,
promotionCreateSchema,
promotionUpdateSchema,
} from './admin.schemas'
describe('admin menu and promotion schemas', () => {
it('trims labels and applies safe menu item defaults', () => {
expect(menuItemSchema.parse({ label: ' Fleet ', itemType: 'INTERNAL_PAGE' })).toMatchObject({
label: 'Fleet',
itemType: 'INTERNAL_PAGE',
displayOrder: 0,
openInNewTab: false,
isRequired: false,
isActive: true,
roles: [],
subscriptionPlans: [],
companyAssignments: [],
})
})
it('rejects negative menu ordering and invalid role previews', () => {
expect(() => menuItemSchema.parse({ label: 'Fleet', itemType: 'INTERNAL_PAGE', displayOrder: -1 })).toThrow()
expect(() => menuPreviewSchema.parse({ companyId: 'company_1', role: 'SUPER_ADMIN' })).toThrow()
})
it('requires at least one plan or company assignment', () => {
expect(() => menuPlanAssignmentsSchema.parse({ assignments: [] })).toThrow()
expect(() => menuCompanyAssignmentsSchema.parse({ assignments: [] })).toThrow()
expect(menuPlanAssignmentsSchema.parse({ assignments: [{ plan: 'PRO' }] })).toEqual({ assignments: [{ plan: 'PRO' }] })
})
it('accepts only uppercase promotion codes and preserves update partiality', () => {
const valid = {
code: 'SUMMER_26',
name: 'Summer 2026',
discountType: 'PERCENTAGE',
discountValue: 20,
plans: ['STARTER', 'GROWTH'],
periods: ['MONTHLY'],
validFrom: '2026-07-01T00:00:00.000Z',
}
expect(promotionCreateSchema.parse(valid)).toMatchObject({ ...valid, isActive: true })
expect(() => promotionCreateSchema.parse({ ...valid, code: 'summer' })).toThrow()
expect(promotionUpdateSchema.parse({ name: 'Updated name' })).toEqual({ name: 'Updated name' })
})
})
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { presentAdminSession, presentAdminUser, presentPaginated } from './admin.presenter'
describe('admin.presenter', () => {
it('removes admin secrets from user responses', () => {
const result = presentAdminUser({
id: 'admin_1',
email: 'admin@example.com',
role: 'SUPER_ADMIN',
passwordHash: 'hash',
totpSecret: 'secret',
})
expect(result).toEqual({ id: 'admin_1', email: 'admin@example.com', role: 'SUPER_ADMIN' })
})
it('wraps sessions without leaking credentials', () => {
expect(presentAdminSession({ id: 'admin_1', passwordHash: 'hash', totpSecret: 'secret' }, 'jwt-token')).toEqual({
token: 'jwt-token',
admin: { id: 'admin_1' },
})
})
it('computes pagination metadata and preserves extra aggregate fields', () => {
expect(presentPaginated([{ id: 'row_1' }], 41, 2, 20, { active: 11 })).toEqual({
data: [{ id: 'row_1' }],
total: 41,
page: 2,
pageSize: 20,
totalPages: 3,
active: 11,
})
})
})
+28
View File
@@ -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,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
adminUser: { findFirst: vi.fn(), findUniqueOrThrow: vi.fn(), update: vi.fn() },
auditLog: { create: vi.fn() },
company: { findMany: vi.fn(), count: vi.fn(), findUniqueOrThrow: vi.fn(), update: vi.fn(), delete: vi.fn() },
billingAccount: { findMany: vi.fn(), count: vi.fn() },
$transaction: vi.fn(),
},
}))
import { prisma } from '../../lib/prisma'
import * as repo from './admin.repo'
describe('admin.repo edge queries', () => {
beforeEach(() => vi.clearAllMocks())
it('clears reset token metadata when updating an admin password', async () => {
await repo.updateAdminPassword('admin_1', 'hash_1')
expect(prisma.adminUser.update).toHaveBeenCalledWith({
where: { id: 'admin_1' },
data: {
passwordHash: 'hash_1',
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
})
it('filters company list by search, status and plan with pagination', async () => {
vi.mocked(prisma.company.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.company.count).mockResolvedValue(0 as never)
await repo.listCompaniesPage({ q: 'atlas', status: 'ACTIVE', plan: 'PRO', page: 3, pageSize: 25 })
const where = {
status: 'ACTIVE',
OR: [
{ name: { contains: 'atlas', mode: 'insensitive' } },
{ email: { contains: 'atlas', mode: 'insensitive' } },
{ slug: { contains: 'atlas', mode: 'insensitive' } },
],
subscription: { plan: 'PRO' },
}
expect(prisma.company.findMany).toHaveBeenCalledWith(expect.objectContaining({
where,
skip: 50,
take: 25,
orderBy: { createdAt: 'desc' },
}))
expect(prisma.company.count).toHaveBeenCalledWith({ where })
})
it('looks up reset tokens only when they have not expired', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z'))
await repo.findAdminByResetToken('reset-token')
expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({
where: {
passwordResetToken: 'reset-token',
passwordResetExpiresAt: { gt: new Date('2026-06-01T00:00:00.000Z') },
},
})
vi.useRealTimers()
})
})
+31
View File
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
adminUser: {
findFirst: vi.fn(),
},
},
}))
import { prisma } from '../../lib/prisma'
import { findAdminByEmail } from './admin.repo'
describe('admin.repo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('looks up admin email case-insensitively', async () => {
await findAdminByEmail('Admin@Example.com')
expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({
where: {
email: {
equals: 'Admin@Example.com',
mode: 'insensitive',
},
},
})
})
})
+581
View File
@@ -0,0 +1,581 @@
import { prisma } from '../../lib/prisma'
const companyListInclude = {
brand: { select: { displayName: true, logoUrl: true, subdomain: true } },
contractSettings: { select: { legalName: 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.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
})
}
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 async function replaceAdminRecoveryCodes(adminUserId: string, codeHashes: string[]) {
return prisma.$transaction(async (tx) => {
await tx.adminRecoveryCode.deleteMany({ where: { adminUserId } })
await tx.adminRecoveryCode.createMany({
data: codeHashes.map((codeHash) => ({ adminUserId, codeHash })),
})
})
}
export function listUnusedAdminRecoveryCodes(adminUserId: string) {
return prisma.adminRecoveryCode.findMany({
where: { adminUserId, usedAt: null },
select: { id: true, codeHash: true },
orderBy: { createdAt: 'asc' },
})
}
export function markAdminRecoveryCodeUsed(id: string) {
return prisma.adminRecoveryCode.update({ where: { id }, data: { usedAt: new Date() } })
}
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
address?: unknown
brand?: { paymentMethodsEnabled?: any[] | null } | null
},
) {
return prisma.$transaction(async (tx: any) => {
if (body.company) {
const companyData = { ...body.company }
if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) {
const baseAddress = current.address && typeof current.address === 'object' && !Array.isArray(current.address)
? current.address as Record<string, unknown>
: {}
companyData.address = { ...baseAddress, ...companyData.address }
}
await tx.company.update({ where: { id }, data: companyData })
}
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 function updateAdmin(
id: string,
data: {
email?: string
firstName?: string
lastName?: string
role?: string
passwordHash?: string
isActive?: boolean
},
) {
return prisma.adminUser.update({
where: { id },
data: {
...(data.email !== undefined ? { email: data.email } : {}),
...(data.firstName !== undefined ? { firstName: data.firstName } : {}),
...(data.lastName !== undefined ? { lastName: data.lastName } : {}),
...(data.role !== undefined ? { role: data.role as any } : {}),
...(data.passwordHash !== undefined ? { passwordHash: data.passwordHash } : {}),
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
},
include: { permissions: true },
})
}
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,
},
},
},
})
}
export function listPricingConfigs() {
return prisma.pricingConfig.findMany({ orderBy: [{ plan: 'asc' }, { billingPeriod: 'asc' }] })
}
export function upsertPricingConfig(plan: string, billingPeriod: string, amount: number, updatedBy?: string) {
return prisma.pricingConfig.upsert({
where: { plan_billingPeriod: { plan, billingPeriod } },
update: { amount, updatedBy },
create: { id: `prc_${plan.toLowerCase()}_${billingPeriod.toLowerCase()}`, plan, billingPeriod, amount, updatedBy },
})
}
export function listPlanFeatures() {
return prisma.planFeature.findMany({
orderBy: [{ plan: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
})
}
export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder?: number }) {
return prisma.planFeature.create({
data: {
plan: data.plan,
label: data.label,
sortOrder: data.sortOrder ?? 0,
},
})
}
export function updatePlanFeature(id: string, data: Partial<{ plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder: number }>) {
return prisma.planFeature.update({
where: { id },
data,
})
}
export function deletePlanFeature(id: string) {
return prisma.planFeature.delete({ where: { id } })
}
// ─── Pricing promotions ────────────────────────────────────────────────────
export function listPromotions() {
return (prisma as any).pricingPromotion.findMany({ orderBy: { createdAt: 'desc' } })
}
export function createPromotion(data: {
code: string; name: string; description?: string | null
discountType: string; discountValue: number
plans: string[]; periods: string[]
maxUses?: number | null; validFrom: string; validUntil?: string | null
isActive: boolean; createdBy?: string | null
}) {
return (prisma as any).pricingPromotion.create({
data: {
...data,
validFrom: new Date(data.validFrom),
validUntil: data.validUntil ? new Date(data.validUntil) : null,
},
})
}
export function updatePromotion(id: string, data: Partial<{
code: string; name: string; description: string | null
discountType: string; discountValue: number
plans: string[]; periods: string[]
maxUses: number | null; validFrom: string; validUntil: string | null
isActive: boolean
}>) {
const { validFrom, validUntil, ...rest } = data
return (prisma as any).pricingPromotion.update({
where: { id },
data: {
...rest,
...(validFrom ? { validFrom: new Date(validFrom) } : {}),
...(validUntil !== undefined ? { validUntil: validUntil ? new Date(validUntil) : null } : {}),
},
})
}
export function deletePromotion(id: string) {
return (prisma as any).pricingPromotion.delete({ where: { id } })
}
export async function listNotificationsPage(query: {
channel?: string
status?: string
companyId?: string
page: number
pageSize: number
}) {
const where: any = {}
if (query.channel) where.channel = query.channel
if (query.status) where.status = query.status
if (query.companyId) where.companyId = query.companyId
const [data, total] = await Promise.all([
prisma.notification.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
include: { company: { select: { name: true } } },
}),
prisma.notification.count({ where }),
])
return { data, total }
}
+575
View File
@@ -0,0 +1,575 @@
import { Router } from 'express'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from '../../middleware/requireAdminAuth'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies'
import * as service from './admin.service'
import * as subService from '../subscriptions/subscription.service'
import * as menuService from '../menu/menu.service'
import { presentAdminUser } from './admin.presenter'
import {
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, notificationsQuerySchema,
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
updateAdminSchema,
homepageUpdateSchema, idParamSchema, companyIdParamSchema, billingAccountIdParamSchema, invoiceIdParamSchema,
pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema,
promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema,
billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema,
retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema,
menuItemSchema, menuItemStatusSchema, menuPlanAssignmentsSchema, menuCompanyAssignmentsSchema,
menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema,
} from './admin.schemas'
import { z } from 'zod'
const adminSubOverrideSchema = z.object({
reason: z.string().min(1).max(500),
})
const adminExtendTrialSchema = z.object({
extraDays: z.number().int().positive(),
reason: z.string().min(1).max(500),
})
const adminImpersonationSchema = z.object({
reason: z.string().min(5).max(500),
durationMinutes: z.number().int().min(1).max(30).default(15),
})
const subIdParamSchema = z.object({ subscriptionId: z.string() })
const router = Router()
// ─── Auth (public) ─────────────────────────────────────────────
router.post('/auth/login', async (req, res, next) => {
try {
const { email, password, totpCode, recoveryCode } = parseBody(loginSchema, req)
const result = await service.login(email, password, totpCode, recoveryCode)
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 })
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
ok(res, result)
} catch (err) { next(err) }
})
router.post('/auth/logout', (_req, res) => {
clearSessionCookie(res, 'admin')
ok(res, { success: true })
})
router.post('/auth/forgot-password', async (req, res, next) => {
try {
const { email } = parseBody(forgotPasswordSchema, req)
await service.forgotPassword(email)
ok(res, { 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, { message: 'Password updated successfully. You can now sign in.' })
} catch (err) { next(err) }
})
// ─── Auth (protected) ──────────────────────────────────────────
router.get('/auth/me', requireAdminAuth, (req, res) => {
ok(res, presentAdminUser(req.admin as any))
})
router.post('/auth/2fa/setup', requireAdminAuth, async (req, res, next) => {
try {
ok(res, 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 result = await service.verifyTotp(req.admin.id, code)
if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
ok(res, { success: true, admin: result.admin, recoveryCodes: result.recoveryCodes })
} catch (err) { next(err) }
})
router.post('/auth/2fa/recovery-codes/regenerate', requireAdminAuth, requireFreshAdmin2FA, async (req, res, next) => {
try {
ok(res, await service.regenerateRecoveryCodes(req.admin.id))
} 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, 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, 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, await service.setCompanyStatus(id, status, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deleteCompany(id, req.admin.id, req.ip)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { reason, durationMinutes } = parseBody(adminImpersonationSchema, req)
ok(res, await service.impersonateCompany(id, req.admin.id, req.ip, reason, durationMinutes))
} catch (err) { next(err) }
})
// ─── Menu management ──────────────────────────────────────────
router.get('/menu-items', requireAdminAuth, requireAdminRole('SUPPORT'), async (_req, res, next) => {
try {
ok(res, await menuService.listMenuItems())
} catch (err) { next(err) }
})
router.post('/menu-items', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
created(res, { data: await menuService.createMenuItem(parseBody(menuItemSchema, req), req.admin) })
} catch (err) { next(err) }
})
router.get('/menu-items/:id', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await menuService.getMenuItem(id))
} catch (err) { next(err) }
})
router.put('/menu-items/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await menuService.updateMenuItem(id, parseBody(menuItemSchema, req), req.admin))
} catch (err) { next(err) }
})
router.patch('/menu-items/:id/status', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { isActive } = parseBody(menuItemStatusSchema, req)
ok(res, await menuService.setMenuItemStatus(id, isActive, req.admin))
} catch (err) { next(err) }
})
router.delete('/menu-items/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await menuService.deleteMenuItem(id, req.admin)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.post('/menu-items/:id/subscriptions', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { assignments } = parseBody(menuPlanAssignmentsSchema, req)
ok(res, await menuService.assignMenuItemToPlans(id, assignments, req.admin))
} catch (err) { next(err) }
})
router.delete('/menu-items/:id/subscriptions/:plan', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id, plan } = parseParams(menuPlanParamSchema, req)
ok(res, await menuService.removeMenuItemFromPlan(id, plan, req.admin))
} catch (err) { next(err) }
})
router.post('/menu-items/:id/companies', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { assignments } = parseBody(menuCompanyAssignmentsSchema, req)
ok(res, await menuService.assignMenuItemToCompanies(id, assignments, req.admin))
} catch (err) { next(err) }
})
router.delete('/menu-items/:id/companies/:companyId', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id, companyId } = parseParams(menuCompanyParamSchema, req)
ok(res, await menuService.removeMenuItemFromCompany(id, companyId, req.admin))
} catch (err) { next(err) }
})
router.post('/menu-preview', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
ok(res, await menuService.previewCompanyMenu(parseBody(menuPreviewSchema, req)))
} catch (err) { next(err) }
})
router.get('/menu-audit-logs', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
ok(res, await menuService.listMenuAuditLogs(parseQuery(menuAuditLogQuerySchema, req)))
} 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, { 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, { success: true })
} catch (err) { next(err) }
})
// ─── Metrics ───────────────────────────────────────────────────
router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
ok(res, await service.getPlatformMetrics())
} catch (err) { next(err) }
})
// ─── Notifications ─────────────────────────────────────────────
router.get('/notifications', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
ok(res, await service.listNotifications(parseQuery(notificationsQuerySchema, req)))
} 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, await service.listAdmins())
} catch (err) { next(err) }
})
router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
created(res, { data: await service.createAdmin(parseBody(createAdminSchema, req)) })
} catch (err) { next(err) }
})
router.patch('/admins/:id', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await service.updateAdmin(id, parseBody(updateAdminSchema, req)))
} catch (err) { next(err) }
})
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { role } = parseBody(adminRoleSchema, req)
await service.updateAdminRole(id, role)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { permissions } = parseBody(adminPermissionsSchema, req)
ok(res, 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/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) }
})
router.get('/billing/:companyId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { companyId } = parseParams(companyIdParamSchema, req)
ok(res, await service.getBillingAccountDetail(companyId))
} 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.patch('/billing/accounts/:billingAccountId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { billingAccountId } = parseParams(billingAccountIdParamSchema, req)
ok(res, await service.updateBillingAccount(billingAccountId, parseBody(billingAccountUpdateSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/accounts/:billingAccountId/pause-dunning', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { billingAccountId } = parseParams(billingAccountIdParamSchema, req)
ok(res, await service.setDunningPaused(billingAccountId, true, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/accounts/:billingAccountId/resume-dunning', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { billingAccountId } = parseParams(billingAccountIdParamSchema, req)
ok(res, await service.setDunningPaused(billingAccountId, false, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/accounts/:billingAccountId/invoices', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { billingAccountId } = parseParams(billingAccountIdParamSchema, req)
created(res, await service.createBillingInvoice(billingAccountId, parseBody(createBillingInvoiceSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/finalize', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.finalizeBillingInvoice(invoiceId, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/pay', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.payBillingInvoice(invoiceId, parseBody(payBillingInvoiceSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/retry-payment', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.retryBillingInvoicePayment(invoiceId, parseBody(retryBillingInvoiceSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/void', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
const { reason } = parseBody(billingReasonSchema, req)
ok(res, await service.voidBillingInvoice(invoiceId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/uncollectible', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
const { reason } = parseBody(billingReasonSchema, req)
ok(res, await service.markBillingInvoiceUncollectible(invoiceId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/credit-notes', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.issueBillingCreditNote(invoiceId, parseBody(billingCreditNoteSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/refunds', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.issueBillingRefund(invoiceId, parseBody(billingRefundSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
// ─── Pricing config ────────────────────────────────────────────
router.get('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
ok(res, await service.getPricingConfigs())
} catch (err) { next(err) }
})
router.patch('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { entries } = parseBody(pricingUpdateSchema, req)
ok(res, await service.updatePricingConfigs(entries, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.get('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), async (_req, res, next) => {
try {
ok(res, await service.listPlanFeatures())
} catch (err) { next(err) }
})
router.post('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const data = parseBody(planFeatureCreateSchema, req)
created(res, await service.createPlanFeature(data, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.patch('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { featureId } = parseParams(planFeatureIdParamSchema, req)
const data = parseBody(planFeatureUpdateSchema, req)
ok(res, await service.updatePlanFeature(featureId, data, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.delete('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { featureId } = parseParams(planFeatureIdParamSchema, req)
await service.deletePlanFeature(featureId, req.admin.id, req.ip)
ok(res, { success: true })
} catch (err) { next(err) }
})
// ─── Promotions ────────────────────────────────────────────────────────────
router.get('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
ok(res, await service.listPromotions())
} catch (err) { next(err) }
})
router.post('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const data = parseBody(promotionCreateSchema, req)
created(res, await service.createPromotion(data as any, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.patch('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { promotionId } = parseParams(promotionIdParamSchema, req)
const data = parseBody(promotionUpdateSchema, req)
ok(res, await service.updatePromotion(promotionId, data as any, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.delete('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { promotionId } = parseParams(promotionIdParamSchema, req)
await service.deletePromotion(promotionId, req.admin.id, req.ip)
ok(res, { success: true })
} catch (err) { next(err) }
})
// ─── Subscription admin overrides ─────────────────────────────
router.get('/subscriptions/:subscriptionId/events', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
ok(res, await subService.getEventsBySubscriptionId(subscriptionId))
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/extend-trial', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { extraDays, reason } = parseBody(adminExtendTrialSchema, req)
ok(res, await subService.adminExtendTrial(subscriptionId, extraDays, req.admin.id, reason))
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/extend-grace-period', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
ok(res, await subService.adminExtendGracePeriod(subscriptionId, 7, req.admin.id, reason))
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/suspend', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
ok(res, await subService.adminSuspendSubscription(subscriptionId, req.admin.id, reason))
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/reactivate', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
ok(res, await subService.adminReactivateSubscription(subscriptionId, req.admin.id, reason))
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
ok(res, await subService.adminCancelSubscription(subscriptionId, req.admin.id, reason))
} catch (err) { next(err) }
})
// ─── Site config ───────────────────────────────────────────────
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
try {
ok(res, 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, await service.updateMarketplaceHomepage(homepage, req.admin.id, req.ip))
} catch (err) { next(err) }
})
export default router
+411
View File
@@ -0,0 +1,411 @@
import { z } from 'zod'
import type { MarketplaceHomepageContent, MarketplaceHomepageHowItWorksStep, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep, MarketplaceHomepageTestimonial } 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(),
recoveryCode: z.string().min(8).max(32).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 notificationsQuerySchema = z.object({
channel: z.string().optional(),
status: z.string().optional(),
companyId: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
})
export const billingQuerySchema = 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 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().trim().toLowerCase(),
firstName: z.string().min(1).max(100).trim(),
lastName: z.string().min(1).max(100).trim(),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
password: z.string().min(8),
permissions: z.array(permissionSchema).optional(),
})
export const updateAdminSchema = z.object({
email: z.string().email().trim().toLowerCase().optional(),
firstName: z.string().min(1).max(100).trim().optional(),
lastName: z.string().min(1).max(100).trim().optional(),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']).optional(),
password: z.string().min(8).optional(),
isActive: z.boolean().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,
address: z.object({
streetAddress: nullableString,
city: nullableString,
country: nullableString,
zipCode: nullableString,
legalName: nullableString,
legalForm: nullableString,
companyEmail: nullableEmail,
managerName: nullableString,
iceNumber: nullableString,
operatingLicenseNumber: nullableString,
operatingLicenseIssuedAt: nullableString,
operatingLicenseIssuedBy: nullableString,
fax: nullableString,
yearsActive: nullableString,
representativeName: nullableString,
representativeTitle: nullableString,
responsibleName: nullableString,
responsibleRole: nullableString,
responsibleIdentityNumber: nullableString,
responsibleQualification: nullableString,
responsiblePhone: nullableString,
responsibleEmail: nullableEmail,
}).optional(),
}).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.literal('MAD').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.literal('MAD').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.literal('MAD').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 marketplaceHowItWorksStepSchema: z.ZodType<MarketplaceHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
const marketplaceTestimonialSchema: z.ZodType<MarketplaceHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() })
const marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', '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),
howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(marketplaceHowItWorksStepSchema).min(1),
stepsTitle: z.string().min(1), steps: z.array(marketplaceStepSchema).length(3), stepLabel: z.string().min(1),
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(marketplaceTestimonialSchema).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 billingAccountIdParamSchema = z.object({
billingAccountId: z.string().min(1),
})
export const invoiceIdParamSchema = z.object({
invoiceId: z.string().min(1),
})
export const billingAccountUpdateSchema = z.object({
legalName: z.string().min(1).max(255).optional(),
billingEmail: z.string().email().optional(),
billingAddress: z.any().optional(),
taxId: z.union([z.string().max(120), z.null()]).optional(),
taxExempt: z.boolean().optional(),
invoiceTerms: z.enum(['DUE_ON_RECEIPT', 'NET_7', 'NET_15', 'NET_30', 'NET_45', 'NET_60']).optional(),
netTermsDays: z.number().int().min(0).max(365).optional(),
})
export const billingLineItemInputSchema = z.object({
type: z.enum([
'SUBSCRIPTION_FEE',
'SEAT_FEE',
'USAGE_FEE',
'SETUP_FEE',
'DISCOUNT',
'TAX',
'CREDIT',
'PRORATION',
'REFUND_ADJUSTMENT',
'MANUAL_ADJUSTMENT',
'PROFESSIONAL_SERVICES',
'CANCELLATION_FEE',
]),
description: z.string().min(1).max(255),
quantity: z.number().int().positive().max(100000),
unitAmount: z.number().int(),
periodStart: nullableDate,
periodEnd: nullableDate,
})
export const createBillingInvoiceSchema = z.object({
subscriptionId: z.union([z.string(), z.null()]).optional(),
invoiceType: z.enum([
'SUBSCRIPTION_INITIAL',
'SUBSCRIPTION_RENEWAL',
'TRIAL_CONVERSION',
'SUBSCRIPTION_UPGRADE',
'SUBSCRIPTION_DOWNGRADE_CREDIT',
'USAGE_BASED',
'ONE_TIME',
'MANUAL',
'CORRECTION',
'CANCELLATION_FEE',
]),
currency: z.string().min(3).max(8).optional(),
dueAt: nullableDate,
isSubscriptionBlocking: z.boolean().optional(),
adminReason: z.union([z.string().max(500), z.null()]).optional(),
lineItems: z.array(billingLineItemInputSchema).min(1),
})
export const payBillingInvoiceSchema = z.object({
amount: z.number().int().positive().optional(),
paymentMethodId: z.union([z.string(), z.null()]).optional(),
providerPaymentId: z.union([z.string(), z.null()]).optional(),
})
export const retryBillingInvoiceSchema = z.object({
paymentMethodId: z.union([z.string(), z.null()]).optional(),
})
export const billingReasonSchema = z.object({
reason: z.string().min(1).max(500),
})
export const billingCreditNoteSchema = z.object({
amount: z.number().int().positive(),
reason: z.string().min(1).max(500),
})
export const billingRefundSchema = z.object({
amount: z.number().int().positive(),
reason: z.string().min(1).max(500),
})
export const homepageUpdateSchema = z.object({
homepage: marketplaceHomepageConfigSchema,
})
export const pricingUpdateSchema = z.object({
entries: z.array(z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
amount: z.number().int().positive(),
})).min(1),
})
const planFeatureSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
label: z.string().min(1).max(120),
sortOrder: z.number().int().min(0).default(0),
})
export const planFeatureCreateSchema = planFeatureSchema
export const planFeatureUpdateSchema = planFeatureSchema.partial()
export const promotionCreateSchema = z.object({
code: z.string().min(2).max(30).regex(/^[A-Z0-9_-]+$/, 'Code must be uppercase A-Z, 0-9, _ or -'),
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
discountType: z.enum(['PERCENTAGE', 'FIXED']),
discountValue: z.number().int().positive(),
plans: z.array(z.enum(['STARTER', 'GROWTH', 'PRO'])),
periods: z.array(z.enum(['MONTHLY', 'ANNUAL'])),
maxUses: z.number().int().positive().nullable().optional(),
validFrom: z.string().datetime(),
validUntil: z.string().datetime().nullable().optional(),
isActive: z.boolean().default(true),
})
export const promotionUpdateSchema = promotionCreateSchema.partial()
export const planFeatureIdParamSchema = z.object({ featureId: z.string().min(1) })
export const promotionIdParamSchema = z.object({ promotionId: z.string().min(1) })
const employeeRoleSchema = z.enum(['OWNER', 'MANAGER', 'AGENT'])
const planSchema = z.enum(['STARTER', 'GROWTH', 'PRO'])
const menuItemTypeSchema = z.enum(['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER'])
export const menuItemSchema = z.object({
systemKey: z.string().trim().min(1).max(120).optional().nullable(),
label: z.string().trim().min(1).max(120),
itemType: menuItemTypeSchema,
routeOrUrl: z.string().trim().max(400).optional().nullable(),
icon: z.string().trim().max(120).optional().nullable(),
parentId: z.string().trim().min(1).optional().nullable(),
displayOrder: z.number().int().min(0).default(0),
openInNewTab: z.boolean().default(false),
isRequired: z.boolean().default(false),
isActive: z.boolean().default(true),
roles: z.array(employeeRoleSchema).default([]),
subscriptionPlans: z.array(z.object({
plan: planSchema,
displayOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
})).default([]),
companyAssignments: z.array(z.object({
companyId: z.string().trim().min(1),
displayOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
})).default([]),
})
export const menuItemStatusSchema = z.object({
isActive: z.boolean(),
})
export const menuPlanAssignmentsSchema = z.object({
assignments: z.array(z.object({
plan: planSchema,
displayOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
})).min(1),
})
export const menuCompanyAssignmentsSchema = z.object({
assignments: z.array(z.object({
companyId: z.string().trim().min(1),
displayOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
})).min(1),
})
export const menuPreviewSchema = z.object({
companyId: z.string().trim().min(1),
role: employeeRoleSchema,
})
export const menuAuditLogQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const menuItemIdParamSchema = z.object({
id: z.string().min(1),
})
export const menuPlanParamSchema = z.object({
id: z.string().min(1),
plan: planSchema,
})
export const menuCompanyParamSchema = z.object({
id: z.string().min(1),
companyId: z.string().min(1),
})
@@ -0,0 +1,49 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./admin.repo', () => ({
findAdminByEmail: vi.fn(),
setAdminPasswordReset: vi.fn(),
}))
vi.mock('../../services/notificationService', () => ({
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
}))
import * as repo from './admin.repo'
import { sendTransactionalEmail } from '../../services/notificationService'
import { forgotPassword } from './admin.service'
describe('admin.service forgotPassword', () => {
const originalAdminUrl = process.env.ADMIN_URL
beforeEach(() => {
vi.clearAllMocks()
process.env.ADMIN_URL = 'http://localhost:3000/admin'
})
afterAll(() => {
process.env.ADMIN_URL = originalAdminUrl
})
it('sends the reset email to the canonical stored admin address', async () => {
vi.mocked(repo.findAdminByEmail).mockResolvedValue({
id: 'admin_1',
email: 'admin@rentaldrivego.com',
firstName: 'Samir',
isActive: true,
} as any)
await forgotPassword('Admin@RentalDriveGo.com')
expect(repo.setAdminPasswordReset).toHaveBeenCalledWith(
'admin_1',
expect.any(String),
expect.any(Date),
)
expect(sendTransactionalEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'admin@rentaldrivego.com',
}),
)
})
})
+514
View File
@@ -0,0 +1,514 @@
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { authenticator } from 'otplib'
import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode'
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService'
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
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 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
if (admin.totpEnabled) {
if (!totpCode && !recoveryCode) return { totpRequired: true } as const
const validTotp = totpCode
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
: false
const validRecoveryCode = !validTotp && recoveryCode
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
: false
if (!validTotp && !validRecoveryCode) {
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, admin.totpEnabled ? Date.now() : undefined))
}
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) 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, 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; 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
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.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 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 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,
})
}