archetecture security fix
This commit is contained in:
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,583 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
type AdminRecoveryCodeTransaction = Pick<typeof prisma, 'adminRecoveryCode'>
|
||||
|
||||
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: AdminRecoveryCodeTransaction) => {
|
||||
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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,407 @@
|
||||
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(),
|
||||
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 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 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',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { parseQuery } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import * as service from './analytics.service'
|
||||
import { summaryQuerySchema, reportQuerySchema } from './analytics.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
router.get('/summary', async (req, res, next) => {
|
||||
try {
|
||||
const { period } = parseQuery(summaryQuerySchema, req)
|
||||
ok(res, await service.getSummary(req.companyId, period))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/dashboard', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getDashboard(req.companyId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/sources', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getSources(req.companyId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/report', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const query = parseQuery(reportQuerySchema, req)
|
||||
const result = await service.getReport(req.companyId, query)
|
||||
if (result.format === 'CSV') {
|
||||
const start = result.startDate.toISOString().slice(0, 10)
|
||||
const end = result.endDate.toISOString().slice(0, 10)
|
||||
res.setHeader('Content-Type', 'text/csv')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="report-${start}-${end}.csv"`)
|
||||
return res.send(result.csv)
|
||||
}
|
||||
ok(res, result.report)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { reportQuerySchema, summaryQuerySchema } from './analytics.schemas'
|
||||
|
||||
describe('analytics schema contracts', () => {
|
||||
it('defaults summary period to the 30 day window', () => {
|
||||
expect(summaryQuerySchema.parse({})).toEqual({ period: '30d' })
|
||||
})
|
||||
|
||||
it('defaults reports to JSON while preserving explicit date range filters', () => {
|
||||
expect(reportQuerySchema.parse({ from: '2026-01-01', to: '2026-01-31' })).toEqual({
|
||||
from: '2026-01-01',
|
||||
to: '2026-01-31',
|
||||
format: 'JSON',
|
||||
})
|
||||
})
|
||||
|
||||
it('passes CSV format and period through without lowercasing surprises', () => {
|
||||
expect(reportQuerySchema.parse({ format: 'CSV', period: 'quarter' })).toEqual({
|
||||
format: 'CSV',
|
||||
period: 'quarter',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const summaryQuerySchema = z.object({
|
||||
period: z.string().default('30d'),
|
||||
})
|
||||
|
||||
export const reportQuerySchema = z.object({
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
format: z.string().default('JSON'),
|
||||
period: z.string().optional(),
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
reservation: {
|
||||
count: vi.fn(),
|
||||
aggregate: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
groupBy: vi.fn(),
|
||||
},
|
||||
vehicle: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
customer: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
subscription: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
accountingSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../services/financialReportService', () => ({
|
||||
generateFinancialReport: vi.fn(),
|
||||
toCsv: vi.fn(),
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { generateFinancialReport, toCsv } from '../../services/financialReportService'
|
||||
import { getDashboard, getReport, getSources, getSummary } from './analytics.service'
|
||||
|
||||
describe('analytics.service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
|
||||
})
|
||||
|
||||
it('summarizes reservations, available vehicles, revenue, and customers for a day window', async () => {
|
||||
vi.mocked(prisma.reservation.count).mockResolvedValue(7 as never)
|
||||
vi.mocked(prisma.vehicle.count).mockResolvedValue(12 as never)
|
||||
vi.mocked(prisma.reservation.aggregate).mockResolvedValue({ _sum: { totalAmount: 4800 } } as never)
|
||||
vi.mocked(prisma.customer.count).mockResolvedValue(42 as never)
|
||||
|
||||
const result = await getSummary('company_1', '14d')
|
||||
|
||||
expect(prisma.reservation.count).toHaveBeenCalledWith({
|
||||
where: { companyId: 'company_1', createdAt: { gte: new Date('2026-05-25T12:00:00.000Z') } },
|
||||
})
|
||||
expect(prisma.vehicle.count).toHaveBeenCalledWith({ where: { companyId: 'company_1', status: 'AVAILABLE' } })
|
||||
expect(result).toEqual({
|
||||
totalReservations: 7,
|
||||
activeVehicles: 12,
|
||||
totalRevenue: 4800,
|
||||
totalCustomers: 42,
|
||||
period: '14d',
|
||||
})
|
||||
})
|
||||
|
||||
it('builds dashboard KPIs, percentage deltas, recent reservation cards, source breakdown, and subscription summary', async () => {
|
||||
vi.mocked(prisma.reservation.count)
|
||||
.mockResolvedValueOnce(10 as never)
|
||||
.mockResolvedValueOnce(5 as never)
|
||||
vi.mocked(prisma.vehicle.count)
|
||||
.mockResolvedValueOnce(4 as never)
|
||||
.mockResolvedValueOnce(0 as never)
|
||||
vi.mocked(prisma.customer.count)
|
||||
.mockResolvedValueOnce(20 as never)
|
||||
.mockResolvedValueOnce(25 as never)
|
||||
vi.mocked(prisma.reservation.aggregate)
|
||||
.mockResolvedValueOnce({ _sum: { totalAmount: 9000 } } as never)
|
||||
.mockResolvedValueOnce({ _sum: { totalAmount: 3000 } } as never)
|
||||
vi.mocked(prisma.reservation.findMany).mockResolvedValue([
|
||||
{
|
||||
id: 'reservation_abc12345',
|
||||
contractNumber: null,
|
||||
customer: { firstName: 'Nora', lastName: 'Driver' },
|
||||
vehicle: { make: 'Dacia', model: 'Duster' },
|
||||
startDate: new Date('2026-06-10T00:00:00.000Z'),
|
||||
endDate: new Date('2026-06-12T00:00:00.000Z'),
|
||||
status: 'CONFIRMED',
|
||||
totalAmount: 1200,
|
||||
},
|
||||
] as never)
|
||||
vi.mocked(prisma.reservation.groupBy).mockResolvedValue([
|
||||
{ source: 'MARKETPLACE', _count: { id: 3 }, _sum: { totalAmount: 3600 } },
|
||||
{ source: 'DIRECT', _count: { id: 2 }, _sum: { totalAmount: null } },
|
||||
] as never)
|
||||
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
||||
status: 'ACTIVE',
|
||||
plan: 'PRO',
|
||||
trialEndAt: new Date('2026-06-30T00:00:00.000Z'),
|
||||
} as never)
|
||||
|
||||
const result = await getDashboard('company_1')
|
||||
|
||||
expect(result.kpis).toEqual({
|
||||
totalBookings: 10,
|
||||
activeVehicles: 4,
|
||||
monthlyRevenue: 9000,
|
||||
totalCustomers: 20,
|
||||
bookingsChange: 100,
|
||||
vehiclesChange: 100,
|
||||
revenueChange: 200,
|
||||
customersChange: -20,
|
||||
})
|
||||
expect(result.recentReservations).toEqual([expect.objectContaining({
|
||||
id: 'reservation_abc12345',
|
||||
bookingRef: 'ABC12345',
|
||||
customerName: 'Nora Driver',
|
||||
vehicleName: 'Dacia Duster',
|
||||
status: 'CONFIRMED',
|
||||
totalAmount: 1200,
|
||||
})])
|
||||
expect(result.sourceBreakdown).toEqual([
|
||||
{ source: 'MARKETPLACE', count: 3, revenue: 3600 },
|
||||
{ source: 'DIRECT', count: 2, revenue: 0 },
|
||||
])
|
||||
expect(result.subscription).toEqual({
|
||||
status: 'ACTIVE',
|
||||
planName: 'PRO',
|
||||
trialEndsAt: new Date('2026-06-30T00:00:00.000Z'),
|
||||
})
|
||||
})
|
||||
|
||||
it('returns source groups straight from reservation grouping', async () => {
|
||||
vi.mocked(prisma.reservation.groupBy).mockResolvedValue([{ source: 'DIRECT', _count: { id: 2 } }] as never)
|
||||
|
||||
await expect(getSources('company_1')).resolves.toEqual([{ source: 'DIRECT', _count: { id: 2 } }])
|
||||
expect(prisma.reservation.groupBy).toHaveBeenCalledWith({ by: ['source'], where: { companyId: 'company_1' }, _count: { id: true } })
|
||||
})
|
||||
|
||||
it('uses explicit report dates and emits CSV only when requested', async () => {
|
||||
vi.mocked(prisma.accountingSettings.findUnique).mockResolvedValue({ reportingPeriod: 'ANNUAL' } as never)
|
||||
vi.mocked(generateFinancialReport).mockResolvedValue({ rows: [{ label: 'Revenue', amount: 1000 }] } as never)
|
||||
vi.mocked(toCsv).mockReturnValue('label,amount\nRevenue,1000')
|
||||
|
||||
const result = await getReport('company_1', {
|
||||
from: '2026-05-01',
|
||||
to: '2026-05-31',
|
||||
format: 'CSV',
|
||||
})
|
||||
|
||||
expect(generateFinancialReport).toHaveBeenCalledWith('company_1', new Date('2026-05-01'), new Date('2026-05-31'))
|
||||
expect(toCsv).toHaveBeenCalledWith([{ label: 'Revenue', amount: 1000 }])
|
||||
expect(result.csv).toBe('label,amount\nRevenue,1000')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { generateFinancialReport, toCsv } from '../../services/financialReportService'
|
||||
|
||||
function getRangeFromPeriod(period: string) {
|
||||
const now = new Date()
|
||||
switch (period) {
|
||||
case 'WEEKLY': return { from: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), to: now }
|
||||
case 'QUARTERLY': return { from: new Date(now.getFullYear(), now.getMonth() - 2, 1), to: now }
|
||||
case 'ANNUAL': return { from: new Date(now.getFullYear(), 0, 1), to: now }
|
||||
default: return { from: new Date(now.getFullYear(), now.getMonth(), 1), to: now }
|
||||
}
|
||||
}
|
||||
|
||||
function pct(current: number, previous: number) {
|
||||
if (previous === 0) return current > 0 ? 100 : 0
|
||||
return Math.round(((current - previous) / previous) * 100)
|
||||
}
|
||||
|
||||
export async function getSummary(companyId: string, period: string) {
|
||||
const days = parseInt(period.replace('d', '')) || 30
|
||||
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
||||
const [totalReservations, activeVehicles, totalRevenue, totalCustomers] = await Promise.all([
|
||||
prisma.reservation.count({ where: { companyId, createdAt: { gte: since } } }),
|
||||
prisma.vehicle.count({ where: { companyId, status: 'AVAILABLE' } }),
|
||||
prisma.reservation.aggregate({ where: { companyId, status: { in: ['COMPLETED'] }, createdAt: { gte: since } }, _sum: { totalAmount: true } }),
|
||||
prisma.customer.count({ where: { companyId } }),
|
||||
])
|
||||
return { totalReservations, activeVehicles, totalRevenue: totalRevenue._sum.totalAmount ?? 0, totalCustomers, period }
|
||||
}
|
||||
|
||||
export async function getDashboard(companyId: string) {
|
||||
const now = new Date()
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const prevMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||
const prevMonthEnd = new Date(monthStart.getTime() - 1)
|
||||
|
||||
const [
|
||||
totalBookings, previousBookings, activeVehicles, previousActiveVehicles,
|
||||
totalCustomers, previousCustomers, monthlyRevenueAgg, previousRevenueAgg,
|
||||
recentReservations, sourceGroups, subscription,
|
||||
] = await Promise.all([
|
||||
prisma.reservation.count({ where: { companyId, createdAt: { gte: monthStart } } }),
|
||||
prisma.reservation.count({ where: { companyId, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } } }),
|
||||
prisma.vehicle.count({ where: { companyId, status: { in: ['AVAILABLE', 'RENTED'] } } }),
|
||||
prisma.vehicle.count({ where: { companyId, createdAt: { lte: prevMonthEnd }, status: { in: ['AVAILABLE', 'RENTED'] } } }),
|
||||
prisma.customer.count({ where: { companyId } }),
|
||||
prisma.customer.count({ where: { companyId, createdAt: { lte: prevMonthEnd } } }),
|
||||
prisma.reservation.aggregate({ where: { companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: monthStart } }, _sum: { totalAmount: true } }),
|
||||
prisma.reservation.aggregate({ where: { companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } }, _sum: { totalAmount: true } }),
|
||||
prisma.reservation.findMany({ where: { companyId }, include: { customer: true, vehicle: true }, orderBy: { createdAt: 'desc' }, take: 8 }),
|
||||
prisma.reservation.groupBy({ by: ['source'], where: { companyId }, _count: { id: true }, _sum: { totalAmount: true } }),
|
||||
prisma.subscription.findUnique({ where: { companyId } }),
|
||||
])
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
totalBookings,
|
||||
activeVehicles,
|
||||
monthlyRevenue: monthlyRevenueAgg._sum.totalAmount ?? 0,
|
||||
totalCustomers,
|
||||
bookingsChange: pct(totalBookings, previousBookings),
|
||||
vehiclesChange: pct(activeVehicles, previousActiveVehicles),
|
||||
revenueChange: pct(monthlyRevenueAgg._sum.totalAmount ?? 0, previousRevenueAgg._sum.totalAmount ?? 0),
|
||||
customersChange: pct(totalCustomers, previousCustomers),
|
||||
},
|
||||
recentReservations: (recentReservations as any[]).map((r) => ({
|
||||
id: r.id,
|
||||
bookingRef: r.contractNumber ?? r.id.slice(-8).toUpperCase(),
|
||||
customerName: `${r.customer.firstName} ${r.customer.lastName}`,
|
||||
vehicleName: `${r.vehicle.make} ${r.vehicle.model}`,
|
||||
startDate: r.startDate,
|
||||
endDate: r.endDate,
|
||||
status: r.status,
|
||||
totalAmount: r.totalAmount,
|
||||
})),
|
||||
sourceBreakdown: (sourceGroups as any[]).map((g) => ({
|
||||
source: g.source,
|
||||
count: g._count.id,
|
||||
revenue: g._sum.totalAmount ?? 0,
|
||||
})),
|
||||
subscription: subscription ? {
|
||||
status: subscription.status,
|
||||
planName: subscription.plan,
|
||||
trialEndsAt: subscription.trialEndAt,
|
||||
} : null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSources(companyId: string) {
|
||||
return prisma.reservation.groupBy({ by: ['source'], where: { companyId }, _count: { id: true } })
|
||||
}
|
||||
|
||||
export async function getReport(companyId: string, query: { from?: string; to?: string; format: string; period?: string }) {
|
||||
const accountingSettings = await prisma.accountingSettings.findUnique({ where: { companyId } })
|
||||
const derivedRange = getRangeFromPeriod(query.period || accountingSettings?.reportingPeriod || 'MONTHLY')
|
||||
const startDate = query.from ? new Date(query.from) : derivedRange.from
|
||||
const endDate = query.to ? new Date(query.to) : derivedRange.to
|
||||
const report = await generateFinancialReport(companyId, startDate, endDate)
|
||||
return { report, startDate, endDate, format: query.format, csv: query.format === 'CSV' ? toCsv(report.rows) : null }
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
|
||||
import { createCompanySignup } from './auth.company.repo'
|
||||
|
||||
function makeDb() {
|
||||
return {
|
||||
company: { create: vi.fn().mockResolvedValue({ id: 'company_1', name: 'Atlas Cars', slug: 'atlas-cars' }) },
|
||||
brandSettings: { create: vi.fn().mockResolvedValue({}) },
|
||||
contractSettings: { create: vi.fn().mockResolvedValue({}) },
|
||||
subscription: { create: vi.fn().mockResolvedValue({}) },
|
||||
employee: { create: vi.fn().mockResolvedValue({ id: 'employee_1', email: 'owner@example.com' }) },
|
||||
}
|
||||
}
|
||||
|
||||
const baseInput = {
|
||||
companyName: 'Atlas Cars',
|
||||
legalName: 'Atlas Cars SARL',
|
||||
slug: 'atlas-cars',
|
||||
ownerEmail: 'owner@example.com',
|
||||
companyEmail: 'contact@example.com',
|
||||
companyPhone: '+212600000000',
|
||||
streetAddress: '1 Fleet Street',
|
||||
city: 'Casablanca',
|
||||
country: 'Morocco',
|
||||
zipCode: '20000',
|
||||
legalForm: 'SARL',
|
||||
managerName: 'Aya Benali',
|
||||
iceNumber: 'ICE123',
|
||||
taxId: 'TAX123',
|
||||
operatingLicenseNumber: 'LIC123',
|
||||
operatingLicenseIssuedAt: '2026-01-01',
|
||||
operatingLicenseIssuedBy: 'Transport Authority',
|
||||
fax: '',
|
||||
yearsActive: '5',
|
||||
representativeName: '',
|
||||
representativeTitle: '',
|
||||
responsibleName: 'Omar Alaoui',
|
||||
responsibleRole: 'Manager',
|
||||
responsibleIdentityNumber: 'ID123',
|
||||
responsibleQualification: '',
|
||||
responsiblePhone: '+212611111111',
|
||||
responsibleEmail: 'responsible@example.com',
|
||||
currency: 'MAD' as const,
|
||||
registrationNumber: 'REG123',
|
||||
plan: 'GROWTH' as const,
|
||||
billingPeriod: 'ANNUAL' as const,
|
||||
preferredLanguage: 'fr' as const,
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
passwordHash: 'hashed-password',
|
||||
now: new Date('2026-06-01T00:00:00.000Z'),
|
||||
trialEndAt: new Date('2026-08-30T00:00:00.000Z'),
|
||||
}
|
||||
|
||||
describe('auth.company.repo.createCompanySignup', () => {
|
||||
it('creates the company, tenant settings, trial subscription, and active owner in one transaction client', async () => {
|
||||
const db = makeDb()
|
||||
|
||||
await expect(createCompanySignup(db as never, baseInput)).resolves.toEqual({
|
||||
company: { id: 'company_1', name: 'Atlas Cars', slug: 'atlas-cars' },
|
||||
employee: { id: 'employee_1', email: 'owner@example.com' },
|
||||
})
|
||||
|
||||
expect(db.company.create).toHaveBeenCalledWith({ data: expect.objectContaining({
|
||||
name: 'Atlas Cars',
|
||||
slug: 'atlas-cars',
|
||||
email: 'contact@example.com',
|
||||
status: 'TRIALING',
|
||||
address: expect.objectContaining({
|
||||
legalName: 'Atlas Cars SARL',
|
||||
companyEmail: 'contact@example.com',
|
||||
fax: null,
|
||||
representativeName: null,
|
||||
responsibleEmail: 'responsible@example.com',
|
||||
}),
|
||||
}) })
|
||||
|
||||
expect(db.brandSettings.create).toHaveBeenCalledWith({ data: expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
displayName: 'Atlas Cars',
|
||||
subdomain: 'atlas-cars',
|
||||
defaultLocale: 'fr',
|
||||
defaultCurrency: 'MAD',
|
||||
}) })
|
||||
|
||||
expect(db.contractSettings.create).toHaveBeenCalledWith({ data: expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
registrationNumber: 'REG123',
|
||||
taxId: 'TAX123',
|
||||
}) })
|
||||
|
||||
expect(db.subscription.create).toHaveBeenCalledWith({ data: expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
plan: 'GROWTH',
|
||||
billingPeriod: 'ANNUAL',
|
||||
status: 'TRIALING',
|
||||
trialStartAt: baseInput.now,
|
||||
trialEndAt: baseInput.trialEndAt,
|
||||
}) })
|
||||
|
||||
expect(db.employee.create).toHaveBeenCalledWith({ data: expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
clerkUserId: 'local_owner_company_1',
|
||||
email: 'owner@example.com',
|
||||
role: 'OWNER',
|
||||
preferredLanguage: 'fr',
|
||||
isActive: true,
|
||||
}) })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
type DbClient = Pick<typeof prisma, 'company' | 'brandSettings' | 'contractSettings' | 'subscription' | 'employee'>
|
||||
|
||||
type CompanySignupInput = {
|
||||
companyName: string
|
||||
legalName: string
|
||||
slug: string
|
||||
ownerEmail: string
|
||||
companyEmail: string
|
||||
companyPhone: string
|
||||
streetAddress: string
|
||||
city: string
|
||||
country: string
|
||||
zipCode: string
|
||||
legalForm: string
|
||||
managerName: string
|
||||
iceNumber: string
|
||||
taxId: string
|
||||
operatingLicenseNumber: string
|
||||
operatingLicenseIssuedAt: string
|
||||
operatingLicenseIssuedBy: string
|
||||
fax?: string
|
||||
yearsActive: string
|
||||
representativeName?: string
|
||||
representativeTitle?: string
|
||||
responsibleName: string
|
||||
responsibleRole: string
|
||||
responsibleIdentityNumber: string
|
||||
responsibleQualification?: string
|
||||
responsiblePhone: string
|
||||
responsibleEmail: string
|
||||
currency: 'MAD'
|
||||
registrationNumber: string
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
||||
billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
preferredLanguage: 'en' | 'fr' | 'ar'
|
||||
firstName: string
|
||||
lastName: string
|
||||
passwordHash: string
|
||||
now: Date
|
||||
trialEndAt: Date
|
||||
}
|
||||
|
||||
export function findCompanyBySlug(slug: string) {
|
||||
return prisma.company.findUnique({ where: { slug } })
|
||||
}
|
||||
|
||||
export function findCompanyByEmail(email: string) {
|
||||
return prisma.company.findUnique({ where: { email } })
|
||||
}
|
||||
|
||||
export function findEmployeeByEmail(email: string) {
|
||||
return prisma.employee.findFirst({ where: { email } })
|
||||
}
|
||||
|
||||
export async function createCompanySignup(db: DbClient, input: CompanySignupInput) {
|
||||
const company = await db.company.create({
|
||||
data: {
|
||||
name: input.companyName,
|
||||
slug: input.slug,
|
||||
email: input.companyEmail,
|
||||
phone: input.companyPhone,
|
||||
address: {
|
||||
streetAddress: input.streetAddress,
|
||||
city: input.city,
|
||||
country: input.country,
|
||||
zipCode: input.zipCode,
|
||||
legalName: input.legalName,
|
||||
legalForm: input.legalForm,
|
||||
companyEmail: input.companyEmail,
|
||||
managerName: input.managerName,
|
||||
iceNumber: input.iceNumber,
|
||||
operatingLicenseNumber: input.operatingLicenseNumber,
|
||||
operatingLicenseIssuedAt: input.operatingLicenseIssuedAt,
|
||||
operatingLicenseIssuedBy: input.operatingLicenseIssuedBy,
|
||||
fax: input.fax || null,
|
||||
yearsActive: input.yearsActive,
|
||||
representativeName: input.representativeName || null,
|
||||
representativeTitle: input.representativeTitle || null,
|
||||
responsibleName: input.responsibleName,
|
||||
responsibleRole: input.responsibleRole,
|
||||
responsibleIdentityNumber: input.responsibleIdentityNumber,
|
||||
responsibleQualification: input.responsibleQualification || null,
|
||||
responsiblePhone: input.responsiblePhone,
|
||||
responsibleEmail: input.responsibleEmail,
|
||||
},
|
||||
status: 'TRIALING',
|
||||
},
|
||||
})
|
||||
|
||||
await db.brandSettings.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
displayName: input.companyName,
|
||||
subdomain: input.slug,
|
||||
publicEmail: input.companyEmail,
|
||||
publicPhone: input.companyPhone,
|
||||
publicAddress: input.streetAddress,
|
||||
publicCity: input.city,
|
||||
publicCountry: input.country,
|
||||
defaultLocale: input.preferredLanguage,
|
||||
defaultCurrency: input.currency,
|
||||
},
|
||||
})
|
||||
|
||||
await db.contractSettings.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
legalName: input.legalName,
|
||||
registrationNumber: input.registrationNumber,
|
||||
taxId: input.taxId,
|
||||
},
|
||||
})
|
||||
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
plan: input.plan,
|
||||
billingPeriod: input.billingPeriod,
|
||||
currency: input.currency,
|
||||
status: 'TRIALING',
|
||||
trialStartAt: input.now,
|
||||
trialEndAt: input.trialEndAt,
|
||||
currentPeriodStart: input.now,
|
||||
currentPeriodEnd: input.trialEndAt,
|
||||
},
|
||||
})
|
||||
|
||||
const employee = await db.employee.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
clerkUserId: `local_owner_${company.id}`,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
email: input.ownerEmail,
|
||||
phone: input.companyPhone,
|
||||
passwordHash: input.passwordHash,
|
||||
role: 'OWNER',
|
||||
preferredLanguage: input.preferredLanguage,
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
return { company, employee }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Router } from 'express'
|
||||
import { parseBody } from '../../http/validate'
|
||||
import { created } from '../../http/respond'
|
||||
import { companySignupSchema } from './auth.company.schemas'
|
||||
import * as service from './auth.company.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/signup', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(companySignupSchema, req)
|
||||
created(res, await service.signup(body))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/complete-signup', (_req, res) => {
|
||||
service.completeSignupDisabled()
|
||||
res.end()
|
||||
})
|
||||
|
||||
router.post('/verify-email', (_req, res) => {
|
||||
service.verifyEmailDisabled()
|
||||
res.end()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const companySignupSchema = z.object({
|
||||
firstName: z.string().min(1).max(80),
|
||||
lastName: z.string().min(1).max(80),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(128),
|
||||
companyName: z.string().min(2).max(120),
|
||||
legalName: z.string().min(2).max(160),
|
||||
legalForm: z.string().min(1).max(80),
|
||||
registrationNumber: z.string().min(1).max(120),
|
||||
iceNumber: z.string().min(1).max(120),
|
||||
taxId: z.string().min(1).max(120),
|
||||
operatingLicenseNumber: z.string().min(1).max(120),
|
||||
operatingLicenseIssuedAt: z.string().min(1).max(40),
|
||||
operatingLicenseIssuedBy: z.string().min(1).max(160),
|
||||
streetAddress: z.string().min(1).max(200),
|
||||
city: z.string().min(1).max(120),
|
||||
country: z.string().min(1).max(120),
|
||||
zipCode: z.string().min(1).max(40),
|
||||
companyPhone: z.string().min(1).max(80),
|
||||
companyEmail: z.string().email(),
|
||||
fax: z.string().max(80).optional(),
|
||||
yearsActive: z.string().min(1).max(80),
|
||||
representativeName: z.string().max(160).optional(),
|
||||
representativeTitle: z.string().max(120).optional(),
|
||||
responsibleName: z.string().min(1).max(160),
|
||||
responsibleRole: z.string().min(1).max(120),
|
||||
responsibleIdentityNumber: z.string().min(1).max(120),
|
||||
responsibleQualification: z.string().max(200).optional(),
|
||||
responsiblePhone: z.string().min(1).max(80),
|
||||
responsibleEmail: z.string().email(),
|
||||
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.literal('MAD'),
|
||||
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AppError } from '../../http/errors'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as repo from './auth.company.repo'
|
||||
import * as service from './auth.company.service'
|
||||
import { sendNotification } from '../../services/notificationService'
|
||||
|
||||
vi.mock('bcryptjs', () => ({ default: { hash: vi.fn().mockResolvedValue('hashed-password') } }))
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: { $transaction: vi.fn() } }))
|
||||
vi.mock('./auth.company.repo', () => ({
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findCompanyByEmail: vi.fn(),
|
||||
findEmployeeByEmail: vi.fn(),
|
||||
createCompanySignup: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn() }))
|
||||
|
||||
const body = {
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'owner@example.com',
|
||||
password: 'super-secret',
|
||||
companyName: 'Atlas & Desert Cars!!!',
|
||||
legalName: 'Atlas Cars SARL',
|
||||
legalForm: 'SARL',
|
||||
registrationNumber: 'REG123',
|
||||
iceNumber: 'ICE123',
|
||||
taxId: 'TAX123',
|
||||
operatingLicenseNumber: 'LIC123',
|
||||
operatingLicenseIssuedAt: '2026-01-01',
|
||||
operatingLicenseIssuedBy: 'Transport Authority',
|
||||
streetAddress: '1 Fleet Street',
|
||||
city: 'Casablanca',
|
||||
country: 'Morocco',
|
||||
zipCode: '20000',
|
||||
companyPhone: '+212600000000',
|
||||
companyEmail: 'contact@example.com',
|
||||
yearsActive: '5',
|
||||
responsibleName: 'Omar Alaoui',
|
||||
responsibleRole: 'Manager',
|
||||
responsibleIdentityNumber: 'ID123',
|
||||
responsiblePhone: '+212611111111',
|
||||
responsibleEmail: 'responsible@example.com',
|
||||
preferredLanguage: 'fr' as const,
|
||||
plan: 'PRO' as const,
|
||||
billingPeriod: 'MONTHLY' as const,
|
||||
currency: 'MAD' as const,
|
||||
paymentProvider: 'PAYPAL' as const,
|
||||
}
|
||||
|
||||
describe('auth.company.service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(repo.findCompanyByEmail).mockResolvedValue(null)
|
||||
vi.mocked(repo.findEmployeeByEmail).mockResolvedValue(null)
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(null)
|
||||
vi.mocked(repo.createCompanySignup).mockResolvedValue({
|
||||
company: { id: 'company_1', name: 'Atlas & Desert Cars!!!', slug: 'atlas-desert-cars' },
|
||||
employee: { id: 'employee_1' },
|
||||
} as never)
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => fn({ tx: true }))
|
||||
vi.mocked(sendNotification).mockResolvedValue([{ channel: 'EMAIL', success: true }] as never)
|
||||
})
|
||||
|
||||
it('rejects duplicate company contact email before hashing or transaction work', async () => {
|
||||
vi.mocked(repo.findCompanyByEmail).mockResolvedValue({ id: 'company_existing' } as never)
|
||||
|
||||
await expect(service.signup(body)).rejects.toMatchObject({ statusCode: 409, error: 'company_email_taken' })
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects duplicate owner email before creating tenant data', async () => {
|
||||
vi.mocked(repo.findEmployeeByEmail).mockResolvedValue({ id: 'employee_existing' } as never)
|
||||
|
||||
await expect(service.signup(body)).rejects.toMatchObject({ statusCode: 409, error: 'owner_email_taken' })
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('generates a stable slug, creates the tenant signup, sends notification, and presents the workspace result', async () => {
|
||||
const result = await service.signup(body)
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
companyName: 'Atlas & Desert Cars!!!',
|
||||
slug: 'atlas-desert-cars',
|
||||
nextStep: 'workspace_created',
|
||||
emailDelivery: { channel: 'EMAIL', success: true },
|
||||
}))
|
||||
expect(result.trialEndsAt).toEqual(expect.any(String))
|
||||
expect(repo.findCompanyBySlug).toHaveBeenCalledWith('atlas-desert-cars')
|
||||
expect(repo.createCompanySignup).toHaveBeenCalledWith({ tx: true }, expect.objectContaining({
|
||||
slug: 'atlas-desert-cars',
|
||||
ownerEmail: 'owner@example.com',
|
||||
passwordHash: 'hashed-password',
|
||||
managerName: 'Aya Benali',
|
||||
trialEndAt: expect.any(Date),
|
||||
}))
|
||||
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'ACCOUNT_CREATED',
|
||||
companyId: 'company_1',
|
||||
employeeId: 'employee_1',
|
||||
email: 'owner@example.com',
|
||||
channels: ['EMAIL', 'IN_APP'],
|
||||
locale: 'fr',
|
||||
templateVariables: expect.objectContaining({
|
||||
firstName: 'Aya',
|
||||
companyName: 'Atlas & Desert Cars!!!',
|
||||
paymentProvider: 'PAYPAL',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('increments the slug when the clean base slug is already taken', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug)
|
||||
.mockResolvedValueOnce({ id: 'existing' } as never)
|
||||
.mockResolvedValueOnce(null)
|
||||
|
||||
await service.signup(body)
|
||||
|
||||
expect(repo.findCompanyBySlug).toHaveBeenNthCalledWith(1, 'atlas-desert-cars')
|
||||
expect(repo.findCompanyBySlug).toHaveBeenNthCalledWith(2, 'atlas-desert-cars-2')
|
||||
expect(repo.createCompanySignup).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ slug: 'atlas-desert-cars-2' }))
|
||||
})
|
||||
|
||||
it('keeps signup successful when notification delivery fails', async () => {
|
||||
vi.mocked(sendNotification).mockRejectedValue(new Error('smtp down'))
|
||||
|
||||
await expect(service.signup(body)).resolves.toMatchObject({
|
||||
companyId: 'company_1',
|
||||
emailDelivery: { attempted: false, success: false, error: null },
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes removed legacy auth endpoints as explicit gone errors', () => {
|
||||
expect(() => service.completeSignupDisabled()).toThrow(AppError)
|
||||
expect(() => service.verifyEmailDisabled()).toThrow(AppError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { AppError } from '../../http/errors'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { sendNotification } from '../../services/notificationService'
|
||||
import {
|
||||
coerceNotificationLocale,
|
||||
localizeBillingPeriod,
|
||||
localizePlanName,
|
||||
} from '../../services/notificationLocalizationService'
|
||||
import { presentCompanySignup } from './auth.presenter'
|
||||
import * as repo from './auth.company.repo'
|
||||
import { companySignupSchema } from './auth.company.schemas'
|
||||
import type { output } from 'zod'
|
||||
|
||||
type CompanySignupInput = output<typeof companySignupSchema>
|
||||
const TRIAL_PERIOD_DAYS = 90
|
||||
|
||||
function slugify(value: string) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company'
|
||||
}
|
||||
|
||||
async function generateUniqueSlug(baseName: string) {
|
||||
const base = slugify(baseName)
|
||||
|
||||
for (let attempt = 0; attempt < 25; attempt++) {
|
||||
const slug = attempt === 0 ? base : `${base}-${attempt + 1}`
|
||||
const existing = await repo.findCompanyBySlug(slug)
|
||||
if (!existing) return slug
|
||||
}
|
||||
|
||||
return `${base}-${Date.now().toString(36)}`
|
||||
}
|
||||
|
||||
export async function signup(body: CompanySignupInput) {
|
||||
if (await repo.findCompanyByEmail(body.companyEmail)) {
|
||||
throw new AppError('A company account with this contact email already exists', 409, 'company_email_taken')
|
||||
}
|
||||
|
||||
if (await repo.findEmployeeByEmail(body.email)) {
|
||||
throw new AppError('An employee account with this owner email already exists', 409, 'owner_email_taken')
|
||||
}
|
||||
|
||||
const slug = await generateUniqueSlug(body.companyName)
|
||||
const now = new Date()
|
||||
const trialEndAt = new Date(now.getTime() + TRIAL_PERIOD_DAYS * 24 * 60 * 60 * 1000)
|
||||
const passwordHash = await bcrypt.hash(body.password, 12)
|
||||
|
||||
const result = await prisma.$transaction((tx: any) => repo.createCompanySignup(tx, {
|
||||
companyName: body.companyName,
|
||||
legalName: body.legalName,
|
||||
slug,
|
||||
ownerEmail: body.email,
|
||||
companyEmail: body.companyEmail,
|
||||
companyPhone: body.companyPhone,
|
||||
streetAddress: body.streetAddress,
|
||||
city: body.city,
|
||||
country: body.country,
|
||||
zipCode: body.zipCode,
|
||||
legalForm: body.legalForm,
|
||||
managerName: `${body.firstName} ${body.lastName}`.trim(),
|
||||
iceNumber: body.iceNumber,
|
||||
taxId: body.taxId,
|
||||
operatingLicenseNumber: body.operatingLicenseNumber,
|
||||
operatingLicenseIssuedAt: body.operatingLicenseIssuedAt,
|
||||
operatingLicenseIssuedBy: body.operatingLicenseIssuedBy,
|
||||
fax: body.fax,
|
||||
yearsActive: body.yearsActive,
|
||||
representativeName: body.representativeName,
|
||||
representativeTitle: body.representativeTitle,
|
||||
responsibleName: body.responsibleName,
|
||||
responsibleRole: body.responsibleRole,
|
||||
responsibleIdentityNumber: body.responsibleIdentityNumber,
|
||||
responsibleQualification: body.responsibleQualification,
|
||||
responsiblePhone: body.responsiblePhone,
|
||||
responsibleEmail: body.responsibleEmail,
|
||||
currency: body.currency,
|
||||
registrationNumber: body.registrationNumber,
|
||||
plan: body.plan,
|
||||
billingPeriod: body.billingPeriod,
|
||||
preferredLanguage: body.preferredLanguage,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
passwordHash,
|
||||
now,
|
||||
trialEndAt,
|
||||
}))
|
||||
|
||||
const lang = coerceNotificationLocale(body.preferredLanguage)
|
||||
const emailResult = await sendNotification({
|
||||
type: 'ACCOUNT_CREATED',
|
||||
companyId: result.company.id,
|
||||
employeeId: result.employee.id,
|
||||
email: body.email,
|
||||
channels: ['EMAIL', 'IN_APP'],
|
||||
locale: lang,
|
||||
templateKey: 'account.created',
|
||||
templateVariables: {
|
||||
firstName: body.firstName,
|
||||
companyName: body.companyName,
|
||||
planName: localizePlanName(body.plan, lang),
|
||||
billingPeriodLabel: localizeBillingPeriod(body.billingPeriod, lang),
|
||||
currency: body.currency,
|
||||
paymentProvider: body.paymentProvider,
|
||||
trialEndDate: trialEndAt,
|
||||
},
|
||||
}).catch(() => [])
|
||||
|
||||
const emailDelivery = (emailResult as Array<{ channel?: string }>).find((entry) => entry.channel === 'EMAIL')
|
||||
|
||||
return presentCompanySignup({
|
||||
company: result.company,
|
||||
trialEndAt,
|
||||
emailDelivery,
|
||||
})
|
||||
}
|
||||
|
||||
export function completeSignupDisabled() {
|
||||
throw new AppError('Clerk-based signup has been removed. Use /auth/company/signup instead.', 410, 'disabled')
|
||||
}
|
||||
|
||||
export function verifyEmailDisabled() {
|
||||
throw new AppError('Email verification resend via Clerk has been removed from this project.', 410, 'disabled')
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
employee: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
import * as repo from './auth.employee.repo'
|
||||
|
||||
describe('auth.employee.repo query boundaries', () => {
|
||||
it('loads employee sessions with company context by id', async () => {
|
||||
await repo.findEmployeeWithCompanyById('employee_1')
|
||||
|
||||
expect(prismaMock.employee.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'employee_1' },
|
||||
include: { company: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('looks up employee login emails case-insensitively and includes company context', async () => {
|
||||
await repo.findEmployeeWithCompanyByEmail('Agent@Example.TEST')
|
||||
|
||||
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
|
||||
where: { email: { equals: 'Agent@Example.TEST', mode: 'insensitive' } },
|
||||
include: { company: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('only sends forgot-password emails to active employees', async () => {
|
||||
await repo.findActiveEmployeeByEmail('agent@example.test')
|
||||
|
||||
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
email: { equals: 'agent@example.test', mode: 'insensitive' },
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('requires unexpired reset tokens for stored-token password reset lookup', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
|
||||
|
||||
await repo.findEmployeeByResetToken('reset_123')
|
||||
|
||||
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
passwordResetToken: 'reset_123',
|
||||
passwordResetExpiresAt: { gt: new Date('2026-06-09T12:00:00.000Z') },
|
||||
},
|
||||
})
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('clears stored reset token fields when password changes', async () => {
|
||||
await repo.resetPassword('employee_1', 'hash_new')
|
||||
|
||||
expect(prismaMock.employee.update).toHaveBeenCalledWith({
|
||||
where: { id: 'employee_1' },
|
||||
data: {
|
||||
passwordHash: 'hash_new',
|
||||
passwordResetToken: null,
|
||||
passwordResetExpiresAt: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
employee: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { findActiveEmployeeByEmail, findEmployeeWithCompanyByEmail } from './auth.employee.repo'
|
||||
|
||||
describe('auth.employee.repo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('looks up employee login email case-insensitively', async () => {
|
||||
await findEmployeeWithCompanyByEmail('Owner@Example.com')
|
||||
|
||||
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
email: {
|
||||
equals: 'Owner@Example.com',
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
include: { company: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('looks up active employee reset email case-insensitively', async () => {
|
||||
await findActiveEmployeeByEmail('Owner@Example.com')
|
||||
|
||||
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
email: {
|
||||
equals: 'Owner@Example.com',
|
||||
mode: 'insensitive',
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export function findEmployeeWithCompanyById(id: string) {
|
||||
return prisma.employee.findUnique({
|
||||
where: { id },
|
||||
include: { company: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function findEmployeeById(id: string) {
|
||||
return prisma.employee.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export function findEmployeeWithCompanyByEmail(email: string) {
|
||||
return prisma.employee.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
include: { company: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function findActiveEmployeeByEmail(email: string) {
|
||||
return prisma.employee.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setPasswordResetToken(id: string, passwordResetToken: string, passwordResetExpiresAt: Date) {
|
||||
return prisma.employee.update({
|
||||
where: { id },
|
||||
data: { passwordResetToken, passwordResetExpiresAt },
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePreferredLanguage(id: string, preferredLanguage: 'en' | 'fr' | 'ar') {
|
||||
return prisma.employee.update({
|
||||
where: { id },
|
||||
data: { preferredLanguage },
|
||||
})
|
||||
}
|
||||
|
||||
export function findEmployeeByResetToken(token: string) {
|
||||
return prisma.employee.findFirst({
|
||||
where: {
|
||||
passwordResetToken: token,
|
||||
passwordResetExpiresAt: { gt: new Date() },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function resetPassword(id: string, passwordHash: string) {
|
||||
return prisma.employee.update({
|
||||
where: { id },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordResetToken: null,
|
||||
passwordResetExpiresAt: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { parseBody } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies'
|
||||
import { getEmployeeMenu } from '../menu/menu.service'
|
||||
import {
|
||||
employeeForgotPasswordSchema,
|
||||
employeeLanguageSchema,
|
||||
employeeLoginSchema,
|
||||
employeeResetPasswordSchema,
|
||||
} from './auth.employee.schemas'
|
||||
import * as service from './auth.employee.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/me', requireCompanyAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getMe(req.employee.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/menu', requireCompanyAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await getEmployeeMenu(req.employee.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(employeeLoginSchema, req)
|
||||
const result = await service.login(body)
|
||||
if ('token' in result) setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/logout', (_req, res) => {
|
||||
clearSessionCookie(res, 'employee')
|
||||
ok(res, { success: true })
|
||||
})
|
||||
|
||||
router.post('/forgot-password', async (req, res, next) => {
|
||||
try {
|
||||
const { email } = parseBody(employeeForgotPasswordSchema, req)
|
||||
ok(res, await service.forgotPassword(email))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/language', requireCompanyAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { language } = parseBody(employeeLanguageSchema, req)
|
||||
ok(res, await service.updateLanguage(req.employee.id, language))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reset-password', async (req, res, next) => {
|
||||
try {
|
||||
const { token, password } = parseBody(employeeResetPasswordSchema, req)
|
||||
ok(res, await service.resetPassword(token, password))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const employeeLoginSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
password: z.string().max(128),
|
||||
})
|
||||
|
||||
export const employeeForgotPasswordSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
})
|
||||
|
||||
export const employeeLanguageSchema = z.object({
|
||||
language: z.enum(['en', 'fr', 'ar']),
|
||||
})
|
||||
|
||||
export const employeeResetPasswordSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(8).max(128),
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('bcryptjs', () => ({
|
||||
default: {
|
||||
compare: vi.fn(),
|
||||
hash: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('../../services/notificationService', () => ({ sendTransactionalEmail: vi.fn() }))
|
||||
vi.mock('./auth.employee.repo', () => ({
|
||||
findEmployeeWithCompanyById: vi.fn(),
|
||||
findEmployeeWithCompanyByEmail: vi.fn(),
|
||||
findActiveEmployeeByEmail: vi.fn(),
|
||||
findEmployeeByResetToken: vi.fn(),
|
||||
findEmployeeById: vi.fn(),
|
||||
updatePreferredLanguage: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
}))
|
||||
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { AppError } from '../../http/errors'
|
||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||
import * as repo from './auth.employee.repo'
|
||||
import * as service from './auth.employee.service'
|
||||
|
||||
const employee = {
|
||||
id: 'employee_1',
|
||||
email: 'agent@example.test',
|
||||
firstName: 'Aya',
|
||||
lastName: 'Agent',
|
||||
role: 'AGENT',
|
||||
preferredLanguage: 'fr',
|
||||
companyId: 'company_1',
|
||||
isActive: true,
|
||||
passwordHash: 'hash_old',
|
||||
company: { name: 'Atlas Cars', slug: 'atlas' },
|
||||
}
|
||||
|
||||
describe('auth.employee.service edge behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.JWT_SECRET = 'test-secret'
|
||||
process.env.JWT_EXPIRY = '8h'
|
||||
delete process.env.DASHBOARD_URL
|
||||
delete process.env.NEXT_PUBLIC_DASHBOARD_URL
|
||||
vi.mocked(bcrypt.compare).mockResolvedValue(true as never)
|
||||
vi.mocked(bcrypt.hash).mockResolvedValue('hash_new' as never)
|
||||
})
|
||||
|
||||
it('rejects inactive employee sessions before presenting tenant data', async () => {
|
||||
vi.mocked(repo.findEmployeeWithCompanyById).mockResolvedValue({ ...employee, isActive: false } as never)
|
||||
|
||||
await expect(service.getMe('employee_1')).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
error: 'unauthenticated',
|
||||
})
|
||||
})
|
||||
|
||||
it('logs in active employees with a signed token and company context', async () => {
|
||||
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue(employee as never)
|
||||
|
||||
const result = await service.login({ email: 'agent@example.test', password: 'correct-password' })
|
||||
|
||||
expect(bcrypt.compare).toHaveBeenCalledWith('correct-password', 'hash_old')
|
||||
expect(result).toMatchObject({
|
||||
token: expect.any(String),
|
||||
employee: {
|
||||
id: 'employee_1',
|
||||
companyId: 'company_1',
|
||||
companyName: 'Atlas Cars',
|
||||
companySlug: 'atlas',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('distinguishes employees without passwords from wrong credentials', async () => {
|
||||
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({ ...employee, passwordHash: null } as never)
|
||||
|
||||
await expect(service.login({ email: 'agent@example.test', password: 'anything' })).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
error: 'password_not_set',
|
||||
})
|
||||
expect(bcrypt.compare).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends reset links to active employees using the dashboard base path exactly once', async () => {
|
||||
process.env.DASHBOARD_URL = 'https://tenant.example.test/app'
|
||||
vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue(employee as never)
|
||||
vi.mocked(sendTransactionalEmail).mockResolvedValue({ success: true } as never)
|
||||
|
||||
await expect(service.forgotPassword('agent@example.test')).resolves.toEqual({
|
||||
message: 'If that email is registered, a reset link has been sent.',
|
||||
})
|
||||
|
||||
expect(sendTransactionalEmail).toHaveBeenCalledWith(expect.objectContaining({
|
||||
to: 'agent@example.test',
|
||||
subject: expect.any(String),
|
||||
html: expect.stringContaining('https://tenant.example.test/app/dashboard/reset-password?token='),
|
||||
text: expect.stringContaining('https://tenant.example.test/app/dashboard/reset-password?token='),
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not disclose whether forgot-password emails exist', async () => {
|
||||
vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue(null)
|
||||
|
||||
await expect(service.forgotPassword('missing@example.test')).resolves.toEqual({
|
||||
message: 'If that email is registered, a reset link has been sent.',
|
||||
})
|
||||
expect(sendTransactionalEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates employee password from a stored reset token and clears reset state through the repo', async () => {
|
||||
vi.mocked(repo.findEmployeeByResetToken).mockResolvedValue(employee as never)
|
||||
|
||||
await expect(service.resetPassword('stored-token', 'new-secure-password')).resolves.toEqual({
|
||||
message: 'Password updated successfully. You can now sign in.',
|
||||
})
|
||||
|
||||
expect(bcrypt.hash).toHaveBeenCalledWith('new-secure-password', 12)
|
||||
expect(repo.resetPassword).toHaveBeenCalledWith('employee_1', 'hash_new')
|
||||
})
|
||||
|
||||
it('rejects invalid reset tokens before hashing new passwords', async () => {
|
||||
vi.mocked(repo.findEmployeeByResetToken).mockResolvedValue(null)
|
||||
vi.mocked(repo.findEmployeeById).mockResolvedValue(null)
|
||||
|
||||
await expect(service.resetPassword('bad-token', 'new-secure-password')).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
error: 'invalid_token',
|
||||
})
|
||||
expect(bcrypt.hash).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delegates language preference updates and returns a stable contract', async () => {
|
||||
vi.mocked(repo.updatePreferredLanguage).mockResolvedValue({} as never)
|
||||
|
||||
await expect(service.updateLanguage('employee_1', 'ar')).resolves.toEqual({ language: 'ar' })
|
||||
expect(repo.updatePreferredLanguage).toHaveBeenCalledWith('employee_1', 'ar')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./auth.employee.repo', () => ({
|
||||
findActiveEmployeeByEmail: vi.fn(),
|
||||
findEmployeeById: vi.fn(),
|
||||
findEmployeeByResetToken: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/notificationService', () => ({
|
||||
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
import * as repo from './auth.employee.repo'
|
||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||
import { forgotPassword, resetPassword } from './auth.employee.service'
|
||||
|
||||
describe('auth.employee.service forgotPassword', () => {
|
||||
const originalDashboardUrl = process.env.DASHBOARD_URL
|
||||
const originalJwtSecret = process.env.JWT_SECRET
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.DASHBOARD_URL = 'http://localhost:3000/dashboard'
|
||||
process.env.JWT_SECRET = 'test-secret'
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
process.env.DASHBOARD_URL = originalDashboardUrl
|
||||
process.env.JWT_SECRET = originalJwtSecret
|
||||
})
|
||||
|
||||
it('sends the reset email to the canonical stored address', async () => {
|
||||
vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue({
|
||||
id: 'emp_1',
|
||||
email: 'owner@company.com',
|
||||
firstName: 'Aya',
|
||||
preferredLanguage: 'en',
|
||||
passwordHash: '$2a$12$abcdefghijklmnopqrstuv',
|
||||
} as any)
|
||||
|
||||
await forgotPassword('Owner@Company.com')
|
||||
|
||||
expect(sendTransactionalEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: 'owner@company.com',
|
||||
html: expect.stringContaining('/dashboard/reset-password?token='),
|
||||
text: expect.stringContaining('/dashboard/reset-password?token='),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts stateless reset tokens', async () => {
|
||||
const employee = {
|
||||
id: 'emp_1',
|
||||
isActive: true,
|
||||
passwordHash: '$2a$12$abcdefghijklmnopqrstuv',
|
||||
} as any
|
||||
|
||||
vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue({
|
||||
...employee,
|
||||
email: 'owner@company.com',
|
||||
firstName: 'Aya',
|
||||
preferredLanguage: 'en',
|
||||
})
|
||||
vi.mocked(repo.findEmployeeByResetToken).mockResolvedValue(null)
|
||||
vi.mocked(repo.findEmployeeById).mockResolvedValue(employee)
|
||||
vi.mocked(repo.resetPassword).mockResolvedValue({} as any)
|
||||
|
||||
await forgotPassword('Owner@Company.com')
|
||||
|
||||
const emailCall = vi.mocked(sendTransactionalEmail).mock.calls[0]?.[0]
|
||||
const match = emailCall?.text.match(/token=([^\s]+)/)
|
||||
expect(match?.[1]).toBeTruthy()
|
||||
|
||||
await expect(resetPassword(decodeURIComponent(match![1]), 'new-password-123')).resolves.toEqual({
|
||||
message: 'Password updated successfully. You can now sign in.',
|
||||
})
|
||||
expect(repo.resetPassword).toHaveBeenCalledWith('emp_1', expect.any(String))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import crypto from 'crypto'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { signActorToken } from '../../security/tokens'
|
||||
import { AppError } from '../../http/errors'
|
||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||
import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations'
|
||||
import { presentEmployeeSession } from './auth.presenter'
|
||||
import * as repo from './auth.employee.repo'
|
||||
import { employeeLanguageSchema, employeeLoginSchema } from './auth.employee.schemas'
|
||||
import type { output } from 'zod'
|
||||
|
||||
const RESET_TOKEN_TTL_MINUTES = 60
|
||||
|
||||
type EmployeeLoginInput = output<typeof employeeLoginSchema>
|
||||
type EmployeeLanguageInput = output<typeof employeeLanguageSchema>
|
||||
type EmployeePasswordResetPayload = jwt.JwtPayload & {
|
||||
sub: string
|
||||
type: 'employee_password_reset'
|
||||
pwdv: string
|
||||
}
|
||||
|
||||
function signEmployeeToken(employeeId: string) {
|
||||
return signActorToken(employeeId, 'employee', {
|
||||
expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'],
|
||||
})
|
||||
}
|
||||
|
||||
function getEmployeePasswordResetVersion(passwordHash: string | null | undefined) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(`${process.env.JWT_SECRET!}:${passwordHash ?? 'no-password-set'}`)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
function signEmployeePasswordResetToken(employeeId: string, passwordHash: string | null | undefined) {
|
||||
return jwt.sign(
|
||||
{
|
||||
sub: employeeId,
|
||||
type: 'employee_password_reset',
|
||||
pwdv: getEmployeePasswordResetVersion(passwordHash),
|
||||
},
|
||||
process.env.JWT_SECRET!,
|
||||
{
|
||||
algorithm: 'HS256',
|
||||
issuer: 'rentaldrivego-api',
|
||||
audience: 'employee_password_reset',
|
||||
expiresIn: `${RESET_TOKEN_TTL_MINUTES}m`,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function verifyEmployeePasswordResetToken(token: string): EmployeePasswordResetPayload | null {
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'rentaldrivego-api',
|
||||
audience: 'employee_password_reset',
|
||||
}) as jwt.JwtPayload
|
||||
|
||||
if (
|
||||
typeof payload.sub !== 'string' ||
|
||||
payload.type !== 'employee_password_reset' ||
|
||||
typeof payload.pwdv !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return payload as EmployeePasswordResetPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDashboardBasePath(baseUrl: string) {
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
const pathname = url.pathname.replace(/\/$/, '')
|
||||
if (!pathname.endsWith('/dashboard')) url.pathname = `${pathname}/dashboard`
|
||||
return url.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
const trimmed = baseUrl.replace(/\/$/, '')
|
||||
return trimmed.endsWith('/dashboard') ? trimmed : `${trimmed}/dashboard`
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMe(employeeId: string) {
|
||||
const employee = await repo.findEmployeeWithCompanyById(employeeId)
|
||||
|
||||
if (!employee || !employee.isActive) {
|
||||
throw new AppError('Employee account not found or inactive', 401, 'unauthenticated')
|
||||
}
|
||||
|
||||
return presentEmployeeSession(employee)
|
||||
}
|
||||
|
||||
export async function login(body: EmployeeLoginInput) {
|
||||
const employee = await repo.findEmployeeWithCompanyByEmail(body.email)
|
||||
|
||||
if (!employee || !employee.isActive) {
|
||||
throw new AppError('Invalid email or password', 401, 'invalid_credentials')
|
||||
}
|
||||
|
||||
if (!employee.passwordHash) {
|
||||
throw new AppError('This account does not have a password yet. Use the invitation or reset email to set one first.', 401, 'password_not_set')
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(body.password, employee.passwordHash)
|
||||
if (!validPassword) {
|
||||
throw new AppError('Invalid email or password', 401, 'invalid_credentials')
|
||||
}
|
||||
|
||||
return presentEmployeeSession(employee, signEmployeeToken(employee.id))
|
||||
}
|
||||
|
||||
export async function forgotPassword(email: string) {
|
||||
const employee = await repo.findActiveEmployeeByEmail(email)
|
||||
|
||||
if (employee) {
|
||||
const resetToken = signEmployeePasswordResetToken(employee.id, employee.passwordHash)
|
||||
|
||||
const dashboardUrl = ensureDashboardBasePath(
|
||||
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
|
||||
)
|
||||
const resetUrl = `${dashboardUrl}/reset-password?token=${encodeURIComponent(resetToken)}`
|
||||
const lang = (employee.preferredLanguage as Lang) ?? 'fr'
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: employee.email,
|
||||
subject: resetPasswordEmail.subject(lang),
|
||||
html: resetPasswordEmail.html(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
|
||||
text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
|
||||
}).catch((err) => {
|
||||
console.error('[ForgotPassword] Email delivery failed:', err?.message)
|
||||
console.error('[ForgotPassword] Provider config — resendKey present:', !!process.env.RESEND_API_KEY, '| smtpHost:', process.env.MAIL_HOST ?? 'not set')
|
||||
})
|
||||
}
|
||||
|
||||
return { message: 'If that email is registered, a reset link has been sent.' }
|
||||
}
|
||||
|
||||
export async function updateLanguage(employeeId: string, language: EmployeeLanguageInput['language']) {
|
||||
await repo.updatePreferredLanguage(employeeId, language)
|
||||
return { language }
|
||||
}
|
||||
|
||||
async function findEmployeeFromResetToken(token: string) {
|
||||
try {
|
||||
const employee = await repo.findEmployeeByResetToken(token)
|
||||
if (employee) return employee
|
||||
} catch (err: any) {
|
||||
console.error('[EmployeeResetPassword] Stored token lookup failed:', err?.message ?? String(err))
|
||||
}
|
||||
|
||||
const payload = verifyEmployeePasswordResetToken(token)
|
||||
if (!payload) return null
|
||||
|
||||
const employee = await repo.findEmployeeById(payload.sub)
|
||||
if (!employee || !employee.isActive) return null
|
||||
|
||||
if (payload.pwdv !== getEmployeePasswordResetVersion(employee.passwordHash)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return employee
|
||||
}
|
||||
|
||||
export async function resetPassword(token: string, password: string) {
|
||||
const employee = await findEmployeeFromResetToken(token)
|
||||
|
||||
if (!employee) {
|
||||
throw new AppError('Reset link is invalid or has expired.', 400, 'invalid_token')
|
||||
}
|
||||
|
||||
await repo.resetPassword(employee.id, await bcrypt.hash(password, 12))
|
||||
return { message: 'Password updated successfully. You can now sign in.' }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { presentCompanySignup, presentEmployeeSession, presentRenterProfile } from './auth.presenter'
|
||||
|
||||
describe('auth presenters', () => {
|
||||
it('presents employee sessions without leaking company internals or requiring a token', () => {
|
||||
const employee = {
|
||||
id: 'employee_1',
|
||||
email: 'owner@example.com',
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
role: 'OWNER',
|
||||
preferredLanguage: 'fr',
|
||||
companyId: 'company_1',
|
||||
company: { name: 'Atlas Cars', slug: 'atlas-cars' },
|
||||
}
|
||||
|
||||
expect(presentEmployeeSession(employee)).toEqual({
|
||||
employee: {
|
||||
id: 'employee_1',
|
||||
email: 'owner@example.com',
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
role: 'OWNER',
|
||||
preferredLanguage: 'fr',
|
||||
companyId: 'company_1',
|
||||
companyName: 'Atlas Cars',
|
||||
companySlug: 'atlas-cars',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('adds the token only when one is supplied', () => {
|
||||
const employee = {
|
||||
id: 'employee_1',
|
||||
email: 'owner@example.com',
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
role: 'OWNER',
|
||||
preferredLanguage: null,
|
||||
companyId: 'company_1',
|
||||
company: { name: 'Atlas Cars', slug: 'atlas-cars' },
|
||||
}
|
||||
|
||||
expect(presentEmployeeSession(employee, 'jwt-token')).toEqual(expect.objectContaining({
|
||||
token: 'jwt-token',
|
||||
employee: expect.objectContaining({ id: 'employee_1' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('presents company signup with a deterministic next step and fallback email delivery state', () => {
|
||||
expect(presentCompanySignup({
|
||||
company: { id: 'company_1', name: 'Atlas Cars', slug: 'atlas-cars' },
|
||||
trialEndAt: new Date('2026-09-01T00:00:00.000Z'),
|
||||
})).toEqual({
|
||||
companyId: 'company_1',
|
||||
companyName: 'Atlas Cars',
|
||||
slug: 'atlas-cars',
|
||||
invitationId: null,
|
||||
trialEndsAt: '2026-09-01T00:00:00.000Z',
|
||||
nextStep: 'workspace_created',
|
||||
emailDelivery: { attempted: false, success: false, error: null },
|
||||
})
|
||||
})
|
||||
|
||||
it('hydrates saved renter companies in renter save order and leaves missing brand data explicit', () => {
|
||||
const renter = {
|
||||
id: 'renter_1',
|
||||
firstName: 'Youssef',
|
||||
lastName: 'Amrani',
|
||||
email: 'youssef@example.com',
|
||||
phone: null,
|
||||
preferredLocale: 'fr',
|
||||
preferredCurrency: 'MAD',
|
||||
emailVerified: true,
|
||||
savedCompanies: [{ companyId: 'company_2' }, { companyId: 'company_missing' }, { companyId: 'company_1' }],
|
||||
}
|
||||
|
||||
const result = presentRenterProfile(renter, [
|
||||
{ id: 'company_1', brand: { displayName: 'Atlas', subdomain: 'atlas', logoUrl: '/atlas.png' } },
|
||||
{ id: 'company_2', brand: null },
|
||||
])
|
||||
|
||||
expect(result.savedCompanies).toEqual([
|
||||
{ id: 'company_2', brand: null },
|
||||
{ id: 'company_missing', brand: null },
|
||||
{ id: 'company_1', brand: { displayName: 'Atlas', subdomain: 'atlas', logoUrl: '/atlas.png' } },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
type EmployeeWithCompany = {
|
||||
id: string
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
role: string
|
||||
preferredLanguage?: string | null
|
||||
companyId: string
|
||||
company: {
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
}
|
||||
|
||||
type CompanySignupResult = {
|
||||
company: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
trialEndAt: Date
|
||||
emailDelivery?: unknown
|
||||
}
|
||||
|
||||
type RenterProfile = {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string | null
|
||||
preferredLocale: string | null
|
||||
preferredCurrency: string | null
|
||||
emailVerified: boolean
|
||||
savedCompanies: Array<{ companyId: string }>
|
||||
}
|
||||
|
||||
type SavedCompany = {
|
||||
id: string
|
||||
brand: {
|
||||
displayName: string | null
|
||||
subdomain: string | null
|
||||
logoUrl: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export function presentEmployeeSession(employee: EmployeeWithCompany, token?: string) {
|
||||
const data = {
|
||||
employee: {
|
||||
id: employee.id,
|
||||
email: employee.email,
|
||||
firstName: employee.firstName,
|
||||
lastName: employee.lastName,
|
||||
role: employee.role,
|
||||
preferredLanguage: employee.preferredLanguage,
|
||||
companyId: employee.companyId,
|
||||
companyName: employee.company.name,
|
||||
companySlug: employee.company.slug,
|
||||
},
|
||||
}
|
||||
|
||||
return token ? { token, ...data } : data
|
||||
}
|
||||
|
||||
export function presentCompanySignup(result: CompanySignupResult) {
|
||||
return {
|
||||
companyId: result.company.id,
|
||||
companyName: result.company.name,
|
||||
slug: result.company.slug,
|
||||
invitationId: null,
|
||||
trialEndsAt: result.trialEndAt.toISOString(),
|
||||
nextStep: 'workspace_created',
|
||||
emailDelivery: result.emailDelivery ?? { attempted: false, success: false, error: null },
|
||||
}
|
||||
}
|
||||
|
||||
export function presentRenterProfile(renter: RenterProfile, companies: SavedCompany[]) {
|
||||
const companyMap = new Map(companies.map((company) => [company.id, company]))
|
||||
|
||||
return {
|
||||
...renter,
|
||||
savedCompanies: renter.savedCompanies.map((savedCompany) => ({
|
||||
id: savedCompany.companyId,
|
||||
brand: companyMap.get(savedCompany.companyId)?.brand ?? null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import type { output } from 'zod'
|
||||
import { renterUpdateSchema } from './auth.renter.schemas'
|
||||
|
||||
type RenterUpdateInput = output<typeof renterUpdateSchema>
|
||||
|
||||
export function findRenterProfile(id: string) {
|
||||
return prisma.renter.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
preferredLocale: true,
|
||||
preferredCurrency: true,
|
||||
emailVerified: true,
|
||||
savedCompanies: { select: { companyId: true } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function findSavedCompanies(companyIds: string[]) {
|
||||
return companyIds.length === 0
|
||||
? Promise.resolve([])
|
||||
: prisma.company.findMany({
|
||||
where: { id: { in: companyIds } },
|
||||
select: {
|
||||
id: true,
|
||||
brand: {
|
||||
select: { displayName: true, subdomain: true, logoUrl: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function updateRenterProfile(id: string, data: RenterUpdateInput) {
|
||||
return prisma.renter.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateRenterFcmToken(id: string, fcmToken: string) {
|
||||
return prisma.renter.update({
|
||||
where: { id },
|
||||
data: { fcmToken },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Router } from 'express'
|
||||
import { requireRenterAuth } from '../../middleware/requireRenterAuth'
|
||||
import { parseBody } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import { renterFcmTokenSchema, renterUpdateSchema } from './auth.renter.schemas'
|
||||
import * as service from './auth.renter.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/signup', (_req, res) => {
|
||||
service.signupDisabled()
|
||||
res.end()
|
||||
})
|
||||
|
||||
router.post('/login', (_req, res) => {
|
||||
service.loginDisabled()
|
||||
res.end()
|
||||
})
|
||||
|
||||
router.get('/me', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getMe(req.renterId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(renterUpdateSchema, req)
|
||||
ok(res, await service.updateMe(req.renterId, body))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/fcm-token', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { fcmToken } = parseBody(renterFcmTokenSchema, req)
|
||||
ok(res, await service.updateFcmToken(req.renterId, fcmToken))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const renterUpdateSchema = z.object({
|
||||
firstName: z.string().min(1).max(100).trim().optional(),
|
||||
lastName: z.string().min(1).max(100).trim().optional(),
|
||||
phone: z.string().max(30).trim().optional(),
|
||||
preferredLocale: z.enum(['en', 'fr', 'ar']).optional(),
|
||||
preferredCurrency: z.literal('MAD').optional(),
|
||||
})
|
||||
|
||||
export const renterFcmTokenSchema = z.object({
|
||||
fcmToken: z.string(),
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AppError } from '../../http/errors'
|
||||
import * as repo from './auth.renter.repo'
|
||||
import * as service from './auth.renter.service'
|
||||
|
||||
vi.mock('./auth.renter.repo', () => ({
|
||||
findRenterProfile: vi.fn(),
|
||||
findSavedCompanies: vi.fn(),
|
||||
updateRenterProfile: vi.fn(),
|
||||
updateRenterFcmToken: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('auth.renter.service', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('keeps renter self-signup disabled with an explicit product error', () => {
|
||||
expect(() => service.signupDisabled()).toThrow(AppError)
|
||||
try {
|
||||
service.signupDisabled()
|
||||
} catch (err) {
|
||||
expect(err).toMatchObject({ statusCode: 403, error: 'renter_signup_disabled' })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps renter login disabled with an explicit product error', () => {
|
||||
expect(() => service.loginDisabled()).toThrow(AppError)
|
||||
try {
|
||||
service.loginDisabled()
|
||||
} catch (err) {
|
||||
expect(err).toMatchObject({ statusCode: 403, error: 'renter_login_disabled' })
|
||||
}
|
||||
})
|
||||
|
||||
it('loads saved company brand data only for company IDs attached to the renter profile', async () => {
|
||||
vi.mocked(repo.findRenterProfile).mockResolvedValue({
|
||||
id: 'renter_1',
|
||||
firstName: 'Youssef',
|
||||
lastName: 'Amrani',
|
||||
email: 'youssef@example.com',
|
||||
phone: null,
|
||||
preferredLocale: 'fr',
|
||||
preferredCurrency: 'MAD',
|
||||
emailVerified: true,
|
||||
savedCompanies: [{ companyId: 'company_2' }, { companyId: 'company_1' }],
|
||||
} as never)
|
||||
vi.mocked(repo.findSavedCompanies).mockResolvedValue([
|
||||
{ id: 'company_1', brand: { displayName: 'Atlas', subdomain: 'atlas', logoUrl: null } },
|
||||
{ id: 'company_2', brand: { displayName: 'Desert Cars', subdomain: 'desert', logoUrl: '/desert.png' } },
|
||||
] as never)
|
||||
|
||||
await expect(service.getMe('renter_1')).resolves.toMatchObject({
|
||||
id: 'renter_1',
|
||||
savedCompanies: [
|
||||
{ id: 'company_2', brand: { displayName: 'Desert Cars' } },
|
||||
{ id: 'company_1', brand: { displayName: 'Atlas' } },
|
||||
],
|
||||
})
|
||||
expect(repo.findSavedCompanies).toHaveBeenCalledWith(['company_2', 'company_1'])
|
||||
})
|
||||
|
||||
it('delegates profile updates without inventing side effects in the service layer', async () => {
|
||||
vi.mocked(repo.updateRenterProfile).mockResolvedValue({ id: 'renter_1', firstName: 'Nora' } as never)
|
||||
|
||||
await expect(service.updateMe('renter_1', { firstName: 'Nora', preferredLocale: 'ar' })).resolves.toEqual({
|
||||
id: 'renter_1',
|
||||
firstName: 'Nora',
|
||||
})
|
||||
expect(repo.updateRenterProfile).toHaveBeenCalledWith('renter_1', { firstName: 'Nora', preferredLocale: 'ar' })
|
||||
})
|
||||
|
||||
it('updates the renter FCM token and returns a stable success contract', async () => {
|
||||
vi.mocked(repo.updateRenterFcmToken).mockResolvedValue({} as never)
|
||||
|
||||
await expect(service.updateFcmToken('renter_1', 'fcm_123')).resolves.toEqual({ success: true })
|
||||
expect(repo.updateRenterFcmToken).toHaveBeenCalledWith('renter_1', 'fcm_123')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AppError } from '../../http/errors'
|
||||
import { presentRenterProfile } from './auth.presenter'
|
||||
import * as repo from './auth.renter.repo'
|
||||
import type { output } from 'zod'
|
||||
import { renterUpdateSchema } from './auth.renter.schemas'
|
||||
|
||||
type RenterUpdateInput = output<typeof renterUpdateSchema>
|
||||
|
||||
export function signupDisabled() {
|
||||
throw new AppError('Renter account creation is disabled. Only company owners can sign in to the platform.', 403, 'renter_signup_disabled')
|
||||
}
|
||||
|
||||
export function loginDisabled() {
|
||||
throw new AppError('Renter sign-in is disabled. Only company owners can sign in to the platform.', 403, 'renter_login_disabled')
|
||||
}
|
||||
|
||||
export async function getMe(renterId: string) {
|
||||
const renter = await repo.findRenterProfile(renterId)
|
||||
const savedCompanies = await repo.findSavedCompanies(renter.savedCompanies.map((savedCompany: any) => savedCompany.companyId))
|
||||
return presentRenterProfile(renter, savedCompanies)
|
||||
}
|
||||
|
||||
export function updateMe(renterId: string, body: RenterUpdateInput) {
|
||||
return repo.updateRenterProfile(renterId, body)
|
||||
}
|
||||
|
||||
export async function updateFcmToken(renterId: string, fcmToken: string) {
|
||||
await repo.updateRenterFcmToken(renterId, fcmToken)
|
||||
return { success: true }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { companySignupSchema } from './auth.company.schemas'
|
||||
import { employeeForgotPasswordSchema, employeeLanguageSchema, employeeLoginSchema, employeeResetPasswordSchema } from './auth.employee.schemas'
|
||||
import { renterFcmTokenSchema, renterUpdateSchema } from './auth.renter.schemas'
|
||||
|
||||
const validCompanySignup = {
|
||||
firstName: 'Aya',
|
||||
lastName: 'Haddad',
|
||||
email: 'owner@example.test',
|
||||
password: 'securePass123',
|
||||
companyName: 'Atlas Cars',
|
||||
legalName: 'Atlas Cars SARL',
|
||||
legalForm: 'SARL',
|
||||
registrationNumber: 'RC-1',
|
||||
iceNumber: 'ICE-1',
|
||||
taxId: 'TAX-1',
|
||||
operatingLicenseNumber: 'LIC-1',
|
||||
operatingLicenseIssuedAt: '2025-01-01',
|
||||
operatingLicenseIssuedBy: 'Transport Authority',
|
||||
streetAddress: '1 Main Street',
|
||||
city: 'Casablanca',
|
||||
country: 'Morocco',
|
||||
zipCode: '20000',
|
||||
companyPhone: '+212600000000',
|
||||
companyEmail: 'company@example.test',
|
||||
yearsActive: '3',
|
||||
responsibleName: 'Aya Haddad',
|
||||
responsibleRole: 'Owner',
|
||||
responsibleIdentityNumber: 'ID-1',
|
||||
responsiblePhone: '+212600000001',
|
||||
responsibleEmail: 'responsible@example.test',
|
||||
plan: 'GROWTH',
|
||||
billingPeriod: 'MONTHLY',
|
||||
currency: 'MAD',
|
||||
paymentProvider: 'AMANPAY',
|
||||
}
|
||||
|
||||
describe('role-specific auth schema contracts', () => {
|
||||
it('defaults company signup language and keeps billing provider constrained', () => {
|
||||
expect(companySignupSchema.parse(validCompanySignup)).toMatchObject({ preferredLanguage: 'en', paymentProvider: 'AMANPAY' })
|
||||
expect(() => companySignupSchema.parse({ ...validCompanySignup, paymentProvider: 'WIRE_TRANSFER' })).toThrow()
|
||||
expect(() => companySignupSchema.parse({ ...validCompanySignup, currency: 'EUR' })).toThrow()
|
||||
})
|
||||
|
||||
it('normalizes employee email fields and validates reset password strength', () => {
|
||||
expect(employeeLoginSchema.parse({ email: 'OWNER@EXAMPLE.TEST', password: 'secret' })).toEqual({
|
||||
email: 'owner@example.test',
|
||||
password: 'secret',
|
||||
})
|
||||
expect(employeeForgotPasswordSchema.parse({ email: 'HELP@EXAMPLE.TEST' })).toEqual({ email: 'help@example.test' })
|
||||
expect(employeeResetPasswordSchema.parse({ token: 'token_1', password: 'new-pass-123' })).toEqual({ token: 'token_1', password: 'new-pass-123' })
|
||||
expect(() => employeeResetPasswordSchema.parse({ token: '', password: 'short' })).toThrow()
|
||||
})
|
||||
|
||||
it('limits employee language changes to supported locales', () => {
|
||||
expect(employeeLanguageSchema.parse({ language: 'fr' })).toEqual({ language: 'fr' })
|
||||
expect(() => employeeLanguageSchema.parse({ language: 'es' })).toThrow()
|
||||
})
|
||||
|
||||
it('normalizes renter profile updates while keeping MAD as the only supported currency', () => {
|
||||
expect(renterUpdateSchema.parse({ firstName: ' Salma ', lastName: ' Idrissi ', preferredLocale: 'ar', preferredCurrency: 'MAD' })).toEqual({
|
||||
firstName: 'Salma',
|
||||
lastName: 'Idrissi',
|
||||
preferredLocale: 'ar',
|
||||
preferredCurrency: 'MAD',
|
||||
})
|
||||
expect(() => renterUpdateSchema.parse({ preferredCurrency: 'EUR' })).toThrow()
|
||||
})
|
||||
|
||||
it('requires a renter FCM token payload key', () => {
|
||||
expect(renterFcmTokenSchema.parse({ fcmToken: 'token_abc' })).toEqual({ fcmToken: 'token_abc' })
|
||||
expect(() => renterFcmTokenSchema.parse({ token: 'token_abc' })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { companySignupSchema } from './auth.company.schemas'
|
||||
import { employeeForgotPasswordSchema, employeeLanguageSchema, employeeLoginSchema, employeeResetPasswordSchema } from './auth.employee.schemas'
|
||||
import { renterFcmTokenSchema, renterUpdateSchema } from './auth.renter.schemas'
|
||||
|
||||
describe('auth schemas', () => {
|
||||
const validCompanySignup = {
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'owner@example.test',
|
||||
password: 'safe-password',
|
||||
companyName: 'Atlas Cars',
|
||||
legalName: 'Atlas Cars SARL',
|
||||
legalForm: 'SARL',
|
||||
registrationNumber: 'RC-1',
|
||||
iceNumber: 'ICE-1',
|
||||
taxId: 'TAX-1',
|
||||
operatingLicenseNumber: 'LIC-1',
|
||||
operatingLicenseIssuedAt: '2024-01-01',
|
||||
operatingLicenseIssuedBy: 'Rabat',
|
||||
streetAddress: '1 Avenue',
|
||||
city: 'Rabat',
|
||||
country: 'Morocco',
|
||||
zipCode: '10000',
|
||||
companyPhone: '+212600000000',
|
||||
companyEmail: 'company@example.test',
|
||||
yearsActive: '3',
|
||||
responsibleName: 'Aya Benali',
|
||||
responsibleRole: 'Owner',
|
||||
responsibleIdentityNumber: 'ID-1',
|
||||
responsiblePhone: '+212600000001',
|
||||
responsibleEmail: 'owner@example.test',
|
||||
plan: 'GROWTH',
|
||||
billingPeriod: 'MONTHLY',
|
||||
currency: 'MAD',
|
||||
paymentProvider: 'AMANPAY',
|
||||
} as const
|
||||
|
||||
it('defaults company signup language and rejects unsupported commercial choices', () => {
|
||||
const parsed = companySignupSchema.parse(validCompanySignup)
|
||||
|
||||
expect(parsed.preferredLanguage).toBe('en')
|
||||
expect(companySignupSchema.safeParse({ ...validCompanySignup, plan: 'ENTERPRISE' }).success).toBe(false)
|
||||
expect(companySignupSchema.safeParse({ ...validCompanySignup, currency: 'EUR' }).success).toBe(false)
|
||||
expect(companySignupSchema.safeParse({ ...validCompanySignup, paymentProvider: 'STRIPE' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes employee auth fields and rejects weak reset payloads', () => {
|
||||
expect(employeeLoginSchema.parse({ email: 'Agent@Example.COM', password: 'password' })).toEqual({
|
||||
email: 'agent@example.com',
|
||||
password: 'password',
|
||||
})
|
||||
expect(employeeForgotPasswordSchema.parse({ email: 'Reset@Example.COM' }).email).toBe('reset@example.com')
|
||||
expect(employeeLanguageSchema.safeParse({ language: 'ar' }).success).toBe(true)
|
||||
expect(employeeLanguageSchema.safeParse({ language: 'es' }).success).toBe(false)
|
||||
expect(employeeResetPasswordSchema.safeParse({ token: '', password: 'long-enough' }).success).toBe(false)
|
||||
expect(employeeResetPasswordSchema.safeParse({ token: 'token_1', password: 'short' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('trims renter profile updates and keeps renter push token required', () => {
|
||||
expect(renterUpdateSchema.parse({ firstName: ' Aya ', lastName: ' Benali ', phone: ' 0600000000 ', preferredLocale: 'fr', preferredCurrency: 'MAD' })).toEqual({
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
phone: '0600000000',
|
||||
preferredLocale: 'fr',
|
||||
preferredCurrency: 'MAD',
|
||||
})
|
||||
expect(renterUpdateSchema.safeParse({ preferredLocale: 'es' }).success).toBe(false)
|
||||
expect(renterUpdateSchema.safeParse({ preferredCurrency: 'EUR' }).success).toBe(false)
|
||||
expect(renterFcmTokenSchema.safeParse({ fcmToken: 'token_1' }).success).toBe(true)
|
||||
expect(renterFcmTokenSchema.safeParse({}).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { presentBrand } from './company.presenter'
|
||||
|
||||
const fullBrand = {
|
||||
id: 'brand_1',
|
||||
companyId: 'comp_1',
|
||||
displayName: 'Test Rentals',
|
||||
subdomain: 'test-rentals',
|
||||
logoUrl: 'https://example.com/logo.jpg',
|
||||
primaryColor: '#1A56DB',
|
||||
defaultLocale: 'en',
|
||||
defaultCurrency: 'MAD',
|
||||
amanpayMerchantId: 'merchant-abc',
|
||||
amanpaySecretKey: 'super-secret-key',
|
||||
paypalEmail: 'paypal@example.com',
|
||||
paypalMerchantId: 'paypal-merchant-xyz',
|
||||
isListedOnMarketplace: true,
|
||||
}
|
||||
|
||||
describe('presentBrand', () => {
|
||||
it('strips amanpaySecretKey from the response', () => {
|
||||
const result = presentBrand(fullBrand)
|
||||
expect(result).not.toHaveProperty('amanpaySecretKey')
|
||||
})
|
||||
|
||||
it('strips amanpayMerchantId from the response', () => {
|
||||
const result = presentBrand(fullBrand)
|
||||
expect(result).not.toHaveProperty('amanpayMerchantId')
|
||||
})
|
||||
|
||||
it('strips paypalEmail from the response', () => {
|
||||
const result = presentBrand(fullBrand)
|
||||
expect(result).not.toHaveProperty('paypalEmail')
|
||||
})
|
||||
|
||||
it('strips paypalMerchantId from the response', () => {
|
||||
const result = presentBrand(fullBrand)
|
||||
expect(result).not.toHaveProperty('paypalMerchantId')
|
||||
})
|
||||
|
||||
it('sets amanpayConfigured=true when both amanpay credentials are present', () => {
|
||||
const result = presentBrand(fullBrand)
|
||||
expect(result.amanpayConfigured).toBe(true)
|
||||
})
|
||||
|
||||
it('sets amanpayConfigured=false when amanpaySecretKey is null', () => {
|
||||
const result = presentBrand({ ...fullBrand, amanpaySecretKey: null })
|
||||
expect(result.amanpayConfigured).toBe(false)
|
||||
})
|
||||
|
||||
it('sets amanpayConfigured=false when amanpayMerchantId is null', () => {
|
||||
const result = presentBrand({ ...fullBrand, amanpayMerchantId: null })
|
||||
expect(result.amanpayConfigured).toBe(false)
|
||||
})
|
||||
|
||||
it('sets amanpayConfigured=false when both amanpay fields are null', () => {
|
||||
const result = presentBrand({ ...fullBrand, amanpayMerchantId: null, amanpaySecretKey: null })
|
||||
expect(result.amanpayConfigured).toBe(false)
|
||||
})
|
||||
|
||||
it('sets paypalConfigured=true when paypalEmail is set', () => {
|
||||
const result = presentBrand({ ...fullBrand, paypalMerchantId: null })
|
||||
expect(result.paypalConfigured).toBe(true)
|
||||
})
|
||||
|
||||
it('sets paypalConfigured=true when paypalMerchantId is set', () => {
|
||||
const result = presentBrand({ ...fullBrand, paypalEmail: null })
|
||||
expect(result.paypalConfigured).toBe(true)
|
||||
})
|
||||
|
||||
it('sets paypalConfigured=false when both paypal fields are null', () => {
|
||||
const result = presentBrand({ ...fullBrand, paypalEmail: null, paypalMerchantId: null })
|
||||
expect(result.paypalConfigured).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves all non-credential fields', () => {
|
||||
const result = presentBrand(fullBrand)
|
||||
expect(result).toMatchObject({
|
||||
id: 'brand_1',
|
||||
companyId: 'comp_1',
|
||||
displayName: 'Test Rentals',
|
||||
subdomain: 'test-rentals',
|
||||
logoUrl: 'https://example.com/logo.jpg',
|
||||
primaryColor: '#1A56DB',
|
||||
defaultLocale: 'en',
|
||||
defaultCurrency: 'MAD',
|
||||
isListedOnMarketplace: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when brand is null', () => {
|
||||
expect(presentBrand(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns undefined when brand is undefined', () => {
|
||||
expect(presentBrand(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
export function presentCompany(company: any) {
|
||||
return company
|
||||
}
|
||||
|
||||
export function presentBrand(brand: any) {
|
||||
if (!brand) return brand
|
||||
const { amanpaySecretKey, amanpayMerchantId, paypalEmail, paypalMerchantId, ...safe } = brand
|
||||
return {
|
||||
...safe,
|
||||
amanpayConfigured: !!(amanpayMerchantId && amanpaySecretKey),
|
||||
paypalConfigured: !!(paypalEmail || paypalMerchantId),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
company: { findUniqueOrThrow: vi.fn(), update: vi.fn(), findFirst: vi.fn() },
|
||||
brandSettings: { findUnique: vi.fn(), upsert: vi.fn(), findFirst: vi.fn(), updateMany: vi.fn() },
|
||||
contractSettings: { findUnique: vi.fn(), upsert: vi.fn() },
|
||||
insurancePolicy: { findMany: vi.fn(), create: vi.fn(), updateMany: vi.fn(), findUniqueOrThrow: vi.fn(), deleteMany: vi.fn() },
|
||||
pricingRule: { findMany: vi.fn(), create: vi.fn(), updateMany: vi.fn(), findUniqueOrThrow: vi.fn(), deleteMany: vi.fn() },
|
||||
accountingSettings: { findUnique: vi.fn(), upsert: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as repo from './company.repo'
|
||||
|
||||
describe('company.repo edge queries', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('loads a company dashboard envelope with brand, subscription and counts', async () => {
|
||||
await repo.findCompany('company_1')
|
||||
|
||||
expect(prisma.company.findUniqueOrThrow).toHaveBeenCalledWith({
|
||||
where: { id: 'company_1' },
|
||||
include: {
|
||||
brand: true,
|
||||
subscription: true,
|
||||
_count: { select: { vehicles: true, customers: true, reservations: true } },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('upserts brand settings with companyId only in the create branch', async () => {
|
||||
await repo.upsertBrand('company_1', { logoUrl: '/new.png' }, { primaryColor: '#111111' })
|
||||
|
||||
expect(prisma.brandSettings.upsert).toHaveBeenCalledWith({
|
||||
where: { companyId: 'company_1' },
|
||||
update: { logoUrl: '/new.png' },
|
||||
create: { companyId: 'company_1', primaryColor: '#111111' },
|
||||
})
|
||||
})
|
||||
|
||||
it('orders insurance policies by required status, sort order and name', async () => {
|
||||
await repo.findInsurancePolicies('company_1')
|
||||
|
||||
expect(prisma.insurancePolicy.findMany).toHaveBeenCalledWith({
|
||||
where: { companyId: 'company_1' },
|
||||
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('updates and deletes pricing rules inside the tenant boundary', async () => {
|
||||
await repo.updatePricingRule('rule_1', 'company_1', { isActive: false })
|
||||
await repo.deletePricingRule('rule_1', 'company_1')
|
||||
|
||||
expect(prisma.pricingRule.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'rule_1', companyId: 'company_1' },
|
||||
data: { isActive: false },
|
||||
})
|
||||
expect(prisma.pricingRule.deleteMany).toHaveBeenCalledWith({ where: { id: 'rule_1', companyId: 'company_1' } })
|
||||
})
|
||||
|
||||
it('detects duplicate public branding outside the current company', async () => {
|
||||
await repo.findBrandBySubdomain('atlas', 'company_1')
|
||||
await repo.findBrandByCustomDomain('cars.example.test', 'company_1')
|
||||
|
||||
expect(prisma.brandSettings.findFirst).toHaveBeenNthCalledWith(1, {
|
||||
where: { subdomain: 'atlas', companyId: { not: 'company_1' } },
|
||||
})
|
||||
expect(prisma.brandSettings.findFirst).toHaveBeenNthCalledWith(2, {
|
||||
where: { customDomain: 'cars.example.test', companyId: { not: 'company_1' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('clears custom domain verification metadata in one tenant-scoped mutation', async () => {
|
||||
await repo.clearCustomDomain('company_1')
|
||||
|
||||
expect(prisma.brandSettings.updateMany).toHaveBeenCalledWith({
|
||||
where: { companyId: 'company_1' },
|
||||
data: { customDomain: null, customDomainVerified: false, customDomainAddedAt: null },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { generateCompanyApiKey } from '../../security/apiKeys'
|
||||
|
||||
export async function findCompany(companyId: string) {
|
||||
return prisma.company.findUniqueOrThrow({
|
||||
where: { id: companyId },
|
||||
include: {
|
||||
brand: true,
|
||||
subscription: true,
|
||||
_count: { select: { vehicles: true, customers: true, reservations: true } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateCompany(companyId: string, data: any) {
|
||||
return prisma.company.update({ where: { id: companyId }, data })
|
||||
}
|
||||
|
||||
export async function findBrand(companyId: string) {
|
||||
return prisma.brandSettings.findUnique({ where: { companyId } })
|
||||
}
|
||||
|
||||
export async function upsertBrand(companyId: string, updateData: any, createData: any) {
|
||||
return prisma.brandSettings.upsert({
|
||||
where: { companyId },
|
||||
update: updateData,
|
||||
create: { companyId, ...createData },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findContractSettings(companyId: string) {
|
||||
return prisma.contractSettings.findUnique({ where: { companyId } })
|
||||
}
|
||||
|
||||
export async function upsertContractSettings(companyId: string, data: any) {
|
||||
return prisma.contractSettings.upsert({
|
||||
where: { companyId },
|
||||
update: data,
|
||||
create: { companyId, ...data },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findInsurancePolicies(companyId: string) {
|
||||
return prisma.insurancePolicy.findMany({
|
||||
where: { companyId },
|
||||
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
export async function createInsurancePolicy(companyId: string, data: any) {
|
||||
return prisma.insurancePolicy.create({ data: { companyId, ...data } })
|
||||
}
|
||||
|
||||
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
|
||||
return prisma.insurancePolicy.updateMany({ where: { id, companyId }, data })
|
||||
}
|
||||
|
||||
export async function findInsurancePolicyOrThrow(id: string) {
|
||||
return prisma.insurancePolicy.findUniqueOrThrow({ where: { id } })
|
||||
}
|
||||
|
||||
export async function deleteInsurancePolicy(id: string, companyId: string) {
|
||||
return prisma.insurancePolicy.deleteMany({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export async function findPricingRules(companyId: string) {
|
||||
return prisma.pricingRule.findMany({
|
||||
where: { companyId },
|
||||
orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
export async function createPricingRule(companyId: string, data: any) {
|
||||
return prisma.pricingRule.create({ data: { companyId, ...data } })
|
||||
}
|
||||
|
||||
export async function updatePricingRule(id: string, companyId: string, data: any) {
|
||||
return prisma.pricingRule.updateMany({ where: { id, companyId }, data })
|
||||
}
|
||||
|
||||
export async function findPricingRuleOrThrow(id: string) {
|
||||
return prisma.pricingRule.findUniqueOrThrow({ where: { id } })
|
||||
}
|
||||
|
||||
export async function deletePricingRule(id: string, companyId: string) {
|
||||
return prisma.pricingRule.deleteMany({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export async function findAccountingSettings(companyId: string) {
|
||||
return prisma.accountingSettings.findUnique({ where: { companyId } })
|
||||
}
|
||||
|
||||
export async function upsertAccountingSettings(companyId: string, data: any) {
|
||||
return prisma.accountingSettings.upsert({
|
||||
where: { companyId },
|
||||
update: data,
|
||||
create: { companyId, ...data },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findApiKey(companyId: string) {
|
||||
const apiKeys = await (prisma as any).companyApiKey.findMany({
|
||||
where: { companyId, revokedAt: null },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, name: true, prefix: true, lastUsedAt: true, createdAt: true, revokedAt: true },
|
||||
})
|
||||
|
||||
return { apiKeys }
|
||||
}
|
||||
|
||||
export async function regenerateApiKey(companyId: string) {
|
||||
const nextKey = generateCompanyApiKey()
|
||||
|
||||
await prisma.$transaction(async (tx: any) => {
|
||||
await (tx as any).companyApiKey.updateMany({
|
||||
where: { companyId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
})
|
||||
await (tx as any).companyApiKey.create({
|
||||
data: {
|
||||
companyId,
|
||||
name: 'Default API key',
|
||||
prefix: nextKey.prefix,
|
||||
keyHash: nextKey.keyHash,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return { apiKey: nextKey.rawKey, prefix: nextKey.prefix, warning: 'This raw API key is shown once. Store it securely.' }
|
||||
}
|
||||
|
||||
export async function findBrandBySubdomain(subdomain: string, excludeCompanyId: string) {
|
||||
return prisma.brandSettings.findFirst({ where: { subdomain, companyId: { not: excludeCompanyId } } })
|
||||
}
|
||||
|
||||
export async function findBrandByCustomDomain(customDomain: string, excludeCompanyId: string) {
|
||||
return prisma.brandSettings.findFirst({ where: { customDomain, companyId: { not: excludeCompanyId } } })
|
||||
}
|
||||
|
||||
export async function clearCustomDomain(companyId: string) {
|
||||
return prisma.brandSettings.updateMany({
|
||||
where: { companyId },
|
||||
data: { customDomain: null, customDomainVerified: false, customDomainAddedAt: null },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { requireCompanyPolicy } from '../../middleware/requireCompanyPolicy'
|
||||
import { parseBody, parseParams } from '../../http/validate'
|
||||
import { ok, created, noContent } from '../../http/respond'
|
||||
import { imageUpload, assertImageFile } from '../../http/upload'
|
||||
import * as service from './company.service'
|
||||
import {
|
||||
companySchema, brandSchema, contractSettingsSchema,
|
||||
insurancePolicySchema, pricingRuleSchema, accountingSettingsSchema,
|
||||
subdomainSchema, customDomainSchema, idParamSchema,
|
||||
} from './company.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
router.get('/me', async (req, res, next) => {
|
||||
try {
|
||||
const company = await service.getCompany(req.companyId)
|
||||
ok(res, company)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(companySchema, req)
|
||||
const company = await service.updateCompany(req.companyId, body)
|
||||
ok(res, company)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/brand', async (req, res, next) => {
|
||||
try {
|
||||
const brand = await service.getBrand(req.companyId)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/brand', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(brandSchema, req)
|
||||
const brand = await service.updateBrand(req.companyId, body, req.company.name, req.company.slug)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/logo', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
assertImageFile(req.file, 'logo')
|
||||
const brand = await service.uploadLogo(req.companyId, req.company.name, req.company.slug, req.file.buffer)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/hero', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
assertImageFile(req.file, 'hero image')
|
||||
const brand = await service.uploadHeroImage(req.companyId, req.company.name, req.company.slug, req.file.buffer)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const { subdomain } = parseBody(subdomainSchema, req)
|
||||
const result = await service.checkSubdomainAvailability(subdomain, req.companyId)
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const { customDomain } = parseBody(customDomainSchema, req)
|
||||
const brand = await service.setCustomDomain(req.companyId, req.company.name, req.company.slug, customDomain)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/brand/custom-domain/status', async (req, res, next) => {
|
||||
try {
|
||||
const status = await service.getCustomDomainStatus(req.companyId)
|
||||
ok(res, status)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await service.removeCustomDomain(req.companyId)
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/contract-settings', async (req, res, next) => {
|
||||
try {
|
||||
const settings = await service.getContractSettings(req.companyId)
|
||||
ok(res, settings)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/contract-settings', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(contractSettingsSchema, req)
|
||||
const settings = await service.updateContractSettings(req.companyId, body)
|
||||
ok(res, settings)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/insurance-policies', async (req, res, next) => {
|
||||
try {
|
||||
const policies = await service.getInsurancePolicies(req.companyId)
|
||||
ok(res, policies)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/insurance-policies', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(insurancePolicySchema, req)
|
||||
const policy = await service.createInsurancePolicy(req.companyId, body)
|
||||
created(res, policy)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(insurancePolicySchema.partial(), req)
|
||||
const policy = await service.updateInsurancePolicy(id, req.companyId, body)
|
||||
ok(res, policy)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.deleteInsurancePolicy(id, req.companyId)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/pricing-rules', async (req, res, next) => {
|
||||
try {
|
||||
const rules = await service.getPricingRules(req.companyId)
|
||||
ok(res, rules)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/pricing-rules', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(pricingRuleSchema, req)
|
||||
const rule = await service.createPricingRule(req.companyId, body)
|
||||
created(res, rule)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(pricingRuleSchema.partial(), req)
|
||||
const rule = await service.updatePricingRule(id, req.companyId, body)
|
||||
ok(res, rule)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.deletePricingRule(id, req.companyId)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/accounting-settings', async (req, res, next) => {
|
||||
try {
|
||||
const settings = await service.getAccountingSettings(req.companyId)
|
||||
ok(res, settings)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/accounting-settings', requireCompanyPolicy('updateAccountingSettings'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(accountingSettingsSchema, req)
|
||||
const settings = await service.updateAccountingSettings(req.companyId, body)
|
||||
ok(res, settings)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/api-key', requireCompanyPolicy('viewApiKey'), async (req, res, next) => {
|
||||
try {
|
||||
const company = await service.getApiKey(req.companyId)
|
||||
ok(res, company)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/api-key/regenerate', requireCompanyPolicy('regenerateApiKey'), async (req, res, next) => {
|
||||
try {
|
||||
const company = await service.regenerateApiKey(req.companyId)
|
||||
ok(res, company)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { accountingSettingsSchema, brandSchema, companySchema, contractSettingsSchema, customDomainSchema, insurancePolicySchema, pricingRuleSchema, subdomainSchema } from './company.schemas'
|
||||
|
||||
describe('company schemas edge cases', () => {
|
||||
it('validates company and brand public configuration boundaries', () => {
|
||||
expect(companySchema.parse({ email: 'ops@example.test', address: { city: 'Rabat' } })).toMatchObject({ email: 'ops@example.test' })
|
||||
expect(companySchema.safeParse({ email: 'bad-email' }).success).toBe(false)
|
||||
|
||||
const brand = brandSchema.parse({
|
||||
displayName: 'Atlas',
|
||||
websiteUrl: 'https://atlas.example.test',
|
||||
paypalEmail: 'paypal@example.test',
|
||||
defaultCurrency: 'MAD',
|
||||
homePageConfig: { showOffers: true, layout: { items: [{ id: 'hero-1', type: 'hero', x: 1, y: 1, w: 12, h: 4 }] } },
|
||||
})
|
||||
expect(brand.homePageConfig?.layout?.items[0].type).toBe('hero')
|
||||
expect(brandSchema.safeParse({ websiteUrl: 'not-url' }).success).toBe(false)
|
||||
expect(brandSchema.safeParse({ defaultCurrency: 'EUR' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps contract, accounting, insurance, and pricing settings inside known enums', () => {
|
||||
expect(contractSettingsSchema.parse({ fuelPolicyType: 'FULL_TO_FULL', additionalDriverCharge: 'PER_DAY', taxRate: 20 })).toMatchObject({ fuelPolicyType: 'FULL_TO_FULL' })
|
||||
expect(contractSettingsSchema.safeParse({ fuelPolicyType: 'CHAOS' }).success).toBe(false)
|
||||
expect(accountingSettingsSchema.parse({ reportingPeriod: 'MONTHLY', fiscalYearStart: 1, currency: 'MAD' })).toMatchObject({ fiscalYearStart: 1 })
|
||||
expect(accountingSettingsSchema.safeParse({ fiscalYearStart: 13 }).success).toBe(false)
|
||||
|
||||
expect(insurancePolicySchema.parse({ name: 'CDW', type: 'CDW', chargeType: 'PER_DAY', chargeValue: 100 })).toMatchObject({ isActive: true, isRequired: false, sortOrder: 0 })
|
||||
expect(insurancePolicySchema.safeParse({ name: 'CDW', type: 'CDW', chargeType: 'PER_DAY', chargeValue: -1 }).success).toBe(false)
|
||||
expect(pricingRuleSchema.safeParse({ name: 'Young driver', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 10 }).success).toBe(true)
|
||||
expect(pricingRuleSchema.safeParse({ name: '', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 10 }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('requires minimal custom domain and subdomain lengths', () => {
|
||||
expect(subdomainSchema.safeParse({ subdomain: 'ab' }).success).toBe(false)
|
||||
expect(customDomainSchema.safeParse({ customDomain: 'x.io' }).success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const companySchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().optional(),
|
||||
address: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export const brandSchema = z.object({
|
||||
displayName: z.string().min(1).optional(),
|
||||
tagline: z.string().optional(),
|
||||
primaryColor: z.string().optional(),
|
||||
accentColor: z.string().optional(),
|
||||
publicEmail: z.string().email().optional(),
|
||||
publicPhone: z.string().optional(),
|
||||
publicAddress: z.string().optional(),
|
||||
publicCity: z.string().optional(),
|
||||
publicCountry: z.string().optional(),
|
||||
websiteUrl: z.string().url().optional(),
|
||||
whatsappNumber: z.string().optional(),
|
||||
defaultLocale: z.string().optional(),
|
||||
defaultCurrency: z.literal('MAD').optional(),
|
||||
amanpayMerchantId: z.string().optional(),
|
||||
amanpaySecretKey: z.string().optional(),
|
||||
paypalEmail: z.string().email().optional(),
|
||||
paypalMerchantId: z.string().optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
homePageConfig: z.object({
|
||||
heroTitle: z.union([z.string(), z.null()]).optional(),
|
||||
heroDescription: z.union([z.string(), z.null()]).optional(),
|
||||
viewOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
viewPricingLabel: z.union([z.string(), z.null()]).optional(),
|
||||
contactCompanyLabel: z.union([z.string(), z.null()]).optional(),
|
||||
activeOffersTitle: z.union([z.string(), z.null()]).optional(),
|
||||
seeAllOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
noActiveOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
publishedVehiclesTitle: z.union([z.string(), z.null()]).optional(),
|
||||
viewVehicleLabel: z.union([z.string(), z.null()]).optional(),
|
||||
pricingEyebrow: z.union([z.string(), z.null()]).optional(),
|
||||
pricingTitle: z.union([z.string(), z.null()]).optional(),
|
||||
pricingDescription: z.union([z.string(), z.null()]).optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
layout: z.object({
|
||||
items: z.array(z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(['hero', 'offers', 'vehicles', 'pricing']),
|
||||
x: z.number().int().min(1),
|
||||
y: z.number().int().min(1),
|
||||
w: z.number().int().min(1),
|
||||
h: z.number().int().min(1),
|
||||
})),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
menuConfig: z.object({
|
||||
aboutLabel: z.union([z.string(), z.null()]).optional(),
|
||||
vehiclesLabel: z.union([z.string(), z.null()]).optional(),
|
||||
offersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
pricingLabel: z.union([z.string(), z.null()]).optional(),
|
||||
blogLabel: z.union([z.string(), z.null()]).optional(),
|
||||
contactLabel: z.union([z.string(), z.null()]).optional(),
|
||||
bookCarLabel: z.union([z.string(), z.null()]).optional(),
|
||||
siteNavigationLabel: z.union([z.string(), z.null()]).optional(),
|
||||
showAbout: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
showBlog: z.boolean().optional(),
|
||||
showContact: z.boolean().optional(),
|
||||
pageSections: z.object({
|
||||
home: z.object({
|
||||
hero: z.boolean().optional(), offers: z.boolean().optional(),
|
||||
vehicles: z.boolean().optional(), pricing: z.boolean().optional(),
|
||||
}).optional(),
|
||||
about: z.object({
|
||||
hero: z.boolean().optional(), highlights: z.boolean().optional(), details: z.boolean().optional(),
|
||||
}).optional(),
|
||||
offers: z.object({ header: z.boolean().optional(), grid: z.boolean().optional() }).optional(),
|
||||
pricing: z.object({
|
||||
hero: z.boolean().optional(), plans: z.boolean().optional(),
|
||||
payments: z.boolean().optional(), faq: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicles: z.object({
|
||||
header: z.boolean().optional(), filters: z.boolean().optional(), grid: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicleDetail: z.object({ gallery: z.boolean().optional(), summary: z.boolean().optional() }).optional(),
|
||||
blog: z.object({ hero: z.boolean().optional(), posts: z.boolean().optional() }).optional(),
|
||||
contact: z.object({ content: z.boolean().optional() }).optional(),
|
||||
booking: z.object({ content: z.boolean().optional() }).optional(),
|
||||
bookingConfirmation: z.object({
|
||||
hero: z.boolean().optional(), summary: z.boolean().optional(), actions: z.boolean().optional(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
export const contractSettingsSchema = z.object({
|
||||
legalName: z.string().optional(),
|
||||
registrationNumber: z.string().optional(),
|
||||
taxId: z.string().optional(),
|
||||
terms: z.string().optional(),
|
||||
fuelPolicy: z.string().optional(),
|
||||
depositPolicy: z.string().optional(),
|
||||
lateFeePolicy: z.string().optional(),
|
||||
damagePolicy: z.string().optional(),
|
||||
contractFooterNote: z.string().optional(),
|
||||
invoiceFooterNote: z.string().optional(),
|
||||
signatureRequired: z.boolean().optional(),
|
||||
showTax: z.boolean().optional(),
|
||||
taxRate: z.number().optional(),
|
||||
taxLabel: z.string().optional(),
|
||||
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
|
||||
fuelPolicyNote: z.string().optional(),
|
||||
fuelChargePerLiter: z.number().int().optional(),
|
||||
fuelShortfallFee: z.number().int().optional(),
|
||||
lateFeePerHour: z.number().int().optional(),
|
||||
additionalDriverCharge: z.enum(['FREE', 'PER_DAY', 'FLAT']).optional(),
|
||||
additionalDriverDailyRate: z.number().int().optional(),
|
||||
additionalDriverFlatRate: z.number().int().optional(),
|
||||
})
|
||||
|
||||
export const insurancePolicySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(['CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'FULL', 'BASIC', 'ROADSIDE', 'PERSONAL', 'CUSTOM']),
|
||||
chargeType: z.enum(['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL']),
|
||||
chargeValue: z.number().int().min(0),
|
||||
isRequired: z.boolean().default(false),
|
||||
isActive: z.boolean().default(true),
|
||||
sortOrder: z.number().int().default(0),
|
||||
})
|
||||
|
||||
export const pricingRuleSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.enum(['SURCHARGE', 'DISCOUNT']),
|
||||
condition: z.enum(['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN']),
|
||||
conditionValue: z.number().int(),
|
||||
adjustmentType: z.enum(['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL']),
|
||||
adjustmentValue: z.number().int(),
|
||||
isActive: z.boolean().default(true),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
export const accountingSettingsSchema = 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: z.string().email().optional(),
|
||||
accountantName: z.string().optional(),
|
||||
autoSendReport: z.boolean().optional(),
|
||||
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
|
||||
})
|
||||
|
||||
export const subdomainSchema = z.object({ subdomain: z.string().min(3) })
|
||||
export const customDomainSchema = z.object({ customDomain: z.string().min(3) })
|
||||
export const idParamSchema = z.object({ id: z.string() })
|
||||
@@ -0,0 +1,132 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/storage', () => ({ uploadImage: vi.fn().mockResolvedValue('/storage/companies/company_1/brand/logo.jpg') }))
|
||||
vi.mock('./company.repo', () => ({
|
||||
findCompany: vi.fn(),
|
||||
updateCompany: vi.fn(),
|
||||
findBrand: vi.fn(),
|
||||
upsertBrand: vi.fn(),
|
||||
findBrandBySubdomain: vi.fn(),
|
||||
findBrandByCustomDomain: vi.fn(),
|
||||
clearCustomDomain: vi.fn(),
|
||||
findContractSettings: vi.fn(),
|
||||
upsertContractSettings: vi.fn(),
|
||||
findInsurancePolicies: vi.fn(),
|
||||
createInsurancePolicy: vi.fn(),
|
||||
updateInsurancePolicy: vi.fn(),
|
||||
findInsurancePolicyOrThrow: vi.fn(),
|
||||
deleteInsurancePolicy: vi.fn(),
|
||||
findPricingRules: vi.fn(),
|
||||
createPricingRule: vi.fn(),
|
||||
updatePricingRule: vi.fn(),
|
||||
findPricingRuleOrThrow: vi.fn(),
|
||||
deletePricingRule: vi.fn(),
|
||||
findAccountingSettings: vi.fn(),
|
||||
upsertAccountingSettings: vi.fn(),
|
||||
findApiKey: vi.fn(),
|
||||
regenerateApiKey: vi.fn(),
|
||||
}))
|
||||
|
||||
const repo = await import('./company.repo')
|
||||
const service = await import('./company.service')
|
||||
|
||||
const currentBrand = {
|
||||
id: 'brand_1',
|
||||
companyId: 'company_1',
|
||||
displayName: 'Atlas Cars',
|
||||
subdomain: 'atlas',
|
||||
amanpayMerchantId: 'merchant_1',
|
||||
amanpaySecretKey: 'secret_1',
|
||||
paypalEmail: null,
|
||||
paypalMerchantId: null,
|
||||
paymentMethodsEnabled: ['AMANPAY'],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
delete process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET
|
||||
})
|
||||
|
||||
describe('company.service edge behavior', () => {
|
||||
it('preserves existing AmanPay credentials when recomputing enabled payment methods', async () => {
|
||||
vi.mocked(repo.findBrand).mockResolvedValue(currentBrand as any)
|
||||
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...currentBrand, paypalEmail: 'billing@example.test' } as any)
|
||||
|
||||
const result = await service.updateBrand('company_1', { paypalEmail: 'billing@example.test' }, 'Atlas Cars', 'atlas')
|
||||
|
||||
expect(repo.upsertBrand).toHaveBeenCalledWith(
|
||||
'company_1',
|
||||
expect.objectContaining({
|
||||
paypalEmail: 'billing@example.test',
|
||||
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
displayName: 'Atlas Cars',
|
||||
subdomain: 'atlas',
|
||||
paypalEmail: 'billing@example.test',
|
||||
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
|
||||
}),
|
||||
)
|
||||
expect(result).not.toHaveProperty('paypalEmail')
|
||||
expect(result.paypalConfigured).toBe(true)
|
||||
})
|
||||
|
||||
it('does not report AmanPay enabled when only one credential is available', async () => {
|
||||
vi.mocked(repo.findBrand).mockResolvedValue({ ...currentBrand, amanpaySecretKey: null } as any)
|
||||
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...currentBrand, amanpaySecretKey: null } as any)
|
||||
|
||||
await service.updateBrand('company_1', {}, 'Atlas Cars', 'atlas')
|
||||
|
||||
expect(repo.upsertBrand).toHaveBeenCalledWith(
|
||||
'company_1',
|
||||
expect.objectContaining({ paymentMethodsEnabled: [] }),
|
||||
expect.objectContaining({ paymentMethodsEnabled: [] }),
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes custom domains and marks them pending verification', async () => {
|
||||
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue(null as any)
|
||||
vi.mocked(repo.upsertBrand).mockResolvedValue({ id: 'brand_1', customDomain: 'cars.example.com' } as any)
|
||||
|
||||
await service.setCustomDomain('company_1', 'Atlas Cars', 'atlas', ' CARS.Example.COM ')
|
||||
|
||||
expect(repo.findBrandByCustomDomain).toHaveBeenCalledWith('cars.example.com', 'company_1')
|
||||
expect(repo.upsertBrand).toHaveBeenCalledWith(
|
||||
'company_1',
|
||||
expect.objectContaining({ customDomain: 'cars.example.com', customDomainVerified: false, customDomainAddedAt: expect.any(Date) }),
|
||||
expect.objectContaining({ displayName: 'Atlas Cars', subdomain: 'atlas', customDomain: 'cars.example.com', customDomainVerified: false, customDomainAddedAt: expect.any(Date) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects custom domains already owned by another company', async () => {
|
||||
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue({ id: 'other_brand' } as any)
|
||||
|
||||
await expect(service.setCustomDomain('company_1', 'Atlas Cars', 'atlas', 'cars.example.com')).rejects.toMatchObject({ statusCode: 409 })
|
||||
expect(repo.upsertBrand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns deterministic custom-domain status and honors configured DNS target', async () => {
|
||||
process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET = 'tenant-target.example.net'
|
||||
vi.mocked(repo.findBrand).mockResolvedValue({ customDomain: 'cars.example.com', customDomainVerified: false } as any)
|
||||
|
||||
await expect(service.getCustomDomainStatus('company_1')).resolves.toEqual({
|
||||
customDomain: 'cars.example.com',
|
||||
verified: false,
|
||||
status: 'pending_dns',
|
||||
dnsTarget: 'tenant-target.example.net',
|
||||
})
|
||||
})
|
||||
|
||||
it('raises not found when updating a missing insurance policy', async () => {
|
||||
vi.mocked(repo.updateInsurancePolicy).mockResolvedValue({ count: 0 } as any)
|
||||
|
||||
await expect(service.updateInsurancePolicy('policy_1', 'company_1', { name: 'CDW' })).rejects.toMatchObject({ statusCode: 404 })
|
||||
expect(repo.findInsurancePolicyOrThrow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('raises not found when deleting a missing pricing rule', async () => {
|
||||
vi.mocked(repo.deletePricingRule).mockResolvedValue({ count: 0 } as any)
|
||||
|
||||
await expect(service.deletePricingRule('rule_1', 'company_1')).rejects.toMatchObject({ statusCode: 404 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { uploadImage } from '../../lib/storage'
|
||||
import { ConflictError, NotFoundError } from '../../http/errors'
|
||||
import { presentCompany, presentBrand } from './company.presenter'
|
||||
import * as repo from './company.repo'
|
||||
|
||||
function buildPaymentMethodsEnabled(input: {
|
||||
amanpayMerchantId?: string | null
|
||||
amanpaySecretKey?: string | null
|
||||
paypalEmail?: string | null
|
||||
}) {
|
||||
const methods: Array<'AMANPAY' | 'PAYPAL'> = []
|
||||
if (input.amanpayMerchantId && input.amanpaySecretKey) methods.push('AMANPAY')
|
||||
if (input.paypalEmail) methods.push('PAYPAL')
|
||||
return methods
|
||||
}
|
||||
|
||||
export async function getCompany(companyId: string) {
|
||||
return presentCompany(await repo.findCompany(companyId))
|
||||
}
|
||||
|
||||
export async function updateCompany(companyId: string, data: any) {
|
||||
return presentCompany(await repo.updateCompany(companyId, data))
|
||||
}
|
||||
|
||||
export async function getBrand(companyId: string) {
|
||||
return presentBrand(await repo.findBrand(companyId))
|
||||
}
|
||||
|
||||
export async function updateBrand(companyId: string, body: any, companyName: string, companySlug: string) {
|
||||
const current = await repo.findBrand(companyId)
|
||||
const paymentMethodsEnabled = buildPaymentMethodsEnabled({
|
||||
amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId,
|
||||
amanpaySecretKey: body.amanpaySecretKey ?? current?.amanpaySecretKey,
|
||||
paypalEmail: body.paypalEmail ?? current?.paypalEmail,
|
||||
})
|
||||
return presentBrand(await repo.upsertBrand(
|
||||
companyId,
|
||||
{ ...body, paymentMethodsEnabled },
|
||||
{ displayName: body.displayName ?? companyName, subdomain: companySlug, paymentMethodsEnabled, ...body },
|
||||
))
|
||||
}
|
||||
|
||||
export async function uploadLogo(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
||||
const url = await uploadImage(file, `companies/${companyId}/brand`, 'logo')
|
||||
return presentBrand(await repo.upsertBrand(
|
||||
companyId,
|
||||
{ logoUrl: url },
|
||||
{ displayName: companyName, subdomain: companySlug, logoUrl: url },
|
||||
))
|
||||
}
|
||||
|
||||
export async function uploadHeroImage(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
||||
const url = await uploadImage(file, `companies/${companyId}/brand`, 'hero')
|
||||
return presentBrand(await repo.upsertBrand(
|
||||
companyId,
|
||||
{ heroImageUrl: url },
|
||||
{ displayName: companyName, subdomain: companySlug, heroImageUrl: url },
|
||||
))
|
||||
}
|
||||
|
||||
export async function checkSubdomainAvailability(subdomain: string, companyId: string) {
|
||||
const existing = await repo.findBrandBySubdomain(subdomain, companyId)
|
||||
return { available: !existing }
|
||||
}
|
||||
|
||||
export async function setCustomDomain(companyId: string, companyName: string, companySlug: string, customDomain: string) {
|
||||
const normalized = customDomain.toLowerCase().trim()
|
||||
const existing = await repo.findBrandByCustomDomain(normalized, companyId)
|
||||
if (existing) throw new ConflictError('This custom domain is already in use')
|
||||
return repo.upsertBrand(
|
||||
companyId,
|
||||
{ customDomain: normalized, customDomainVerified: false, customDomainAddedAt: new Date() },
|
||||
{ displayName: companyName, subdomain: companySlug, customDomain: normalized, customDomainVerified: false, customDomainAddedAt: new Date() },
|
||||
)
|
||||
}
|
||||
|
||||
export async function getCustomDomainStatus(companyId: string) {
|
||||
const brand = await repo.findBrand(companyId)
|
||||
const customDomain = brand?.customDomain ?? null
|
||||
return {
|
||||
customDomain,
|
||||
verified: brand?.customDomainVerified ?? false,
|
||||
status: customDomain ? (brand?.customDomainVerified ? 'verified' : 'pending_dns') : 'not_configured',
|
||||
dnsTarget: process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET ?? 'cname.vercel-dns.com',
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCustomDomain(companyId: string) {
|
||||
const result = await repo.clearCustomDomain(companyId)
|
||||
return { success: result.count > 0 }
|
||||
}
|
||||
|
||||
export async function getContractSettings(companyId: string) {
|
||||
return repo.findContractSettings(companyId)
|
||||
}
|
||||
|
||||
export async function updateContractSettings(companyId: string, data: any) {
|
||||
return repo.upsertContractSettings(companyId, data)
|
||||
}
|
||||
|
||||
export async function getInsurancePolicies(companyId: string) {
|
||||
return repo.findInsurancePolicies(companyId)
|
||||
}
|
||||
|
||||
export async function createInsurancePolicy(companyId: string, data: any) {
|
||||
return repo.createInsurancePolicy(companyId, data)
|
||||
}
|
||||
|
||||
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
|
||||
const result = await repo.updateInsurancePolicy(id, companyId, data)
|
||||
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
|
||||
return repo.findInsurancePolicyOrThrow(id)
|
||||
}
|
||||
|
||||
export async function deleteInsurancePolicy(id: string, companyId: string) {
|
||||
const result = await repo.deleteInsurancePolicy(id, companyId)
|
||||
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
|
||||
}
|
||||
|
||||
export async function getPricingRules(companyId: string) {
|
||||
return repo.findPricingRules(companyId)
|
||||
}
|
||||
|
||||
export async function createPricingRule(companyId: string, data: any) {
|
||||
return repo.createPricingRule(companyId, data)
|
||||
}
|
||||
|
||||
export async function updatePricingRule(id: string, companyId: string, data: any) {
|
||||
const result = await repo.updatePricingRule(id, companyId, data)
|
||||
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
|
||||
return repo.findPricingRuleOrThrow(id)
|
||||
}
|
||||
|
||||
export async function deletePricingRule(id: string, companyId: string) {
|
||||
const result = await repo.deletePricingRule(id, companyId)
|
||||
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
|
||||
}
|
||||
|
||||
export async function getAccountingSettings(companyId: string) {
|
||||
return repo.findAccountingSettings(companyId)
|
||||
}
|
||||
|
||||
export async function updateAccountingSettings(companyId: string, data: any) {
|
||||
return repo.upsertAccountingSettings(companyId, data)
|
||||
}
|
||||
|
||||
export async function getApiKey(companyId: string) {
|
||||
return repo.findApiKey(companyId)
|
||||
}
|
||||
|
||||
export async function regenerateApiKey(companyId: string) {
|
||||
return repo.regenerateApiKey(companyId)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as repo from './company.repo'
|
||||
import * as service from './company.service'
|
||||
|
||||
vi.mock('./company.repo')
|
||||
vi.mock('../../lib/storage', () => ({
|
||||
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/companies/comp_1/brand/logo.jpg'),
|
||||
}))
|
||||
|
||||
const mockCompany = {
|
||||
id: 'comp_1',
|
||||
name: 'Test Rentals',
|
||||
slug: 'test-rentals',
|
||||
email: 'info@test.com',
|
||||
brand: null,
|
||||
subscription: null,
|
||||
_count: { vehicles: 5, customers: 10, reservations: 20 },
|
||||
}
|
||||
|
||||
const mockBrand = {
|
||||
id: 'brand_1',
|
||||
companyId: 'comp_1',
|
||||
displayName: 'Test Rentals',
|
||||
subdomain: 'test-rentals',
|
||||
logoUrl: null,
|
||||
heroImageUrl: null,
|
||||
customDomain: null,
|
||||
customDomainVerified: false,
|
||||
amanpayMerchantId: null,
|
||||
amanpaySecretKey: null,
|
||||
paypalEmail: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('company.service', () => {
|
||||
describe('getCompany', () => {
|
||||
it('returns company with brand and subscription', async () => {
|
||||
vi.mocked(repo.findCompany).mockResolvedValue(mockCompany as any)
|
||||
const result = await service.getCompany('comp_1')
|
||||
expect(result).toEqual(mockCompany)
|
||||
expect(repo.findCompany).toHaveBeenCalledWith('comp_1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCompany', () => {
|
||||
it('updates company profile', async () => {
|
||||
vi.mocked(repo.updateCompany).mockResolvedValue({ ...mockCompany, name: 'Updated Rentals' } as any)
|
||||
const result = await service.updateCompany('comp_1', { name: 'Updated Rentals' })
|
||||
expect(repo.updateCompany).toHaveBeenCalledWith('comp_1', { name: 'Updated Rentals' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadLogo', () => {
|
||||
it('uploads logo and upserts brand', async () => {
|
||||
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...mockBrand, logoUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' } as any)
|
||||
const result = await service.uploadLogo('comp_1', 'Test Rentals', 'test-rentals', Buffer.from(''))
|
||||
expect(repo.upsertBrand).toHaveBeenCalledWith(
|
||||
'comp_1',
|
||||
expect.objectContaining({ logoUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' }),
|
||||
expect.objectContaining({ logoUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadHeroImage', () => {
|
||||
it('uploads hero image and upserts brand', async () => {
|
||||
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...mockBrand, heroImageUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' } as any)
|
||||
await service.uploadHeroImage('comp_1', 'Test Rentals', 'test-rentals', Buffer.from(''))
|
||||
expect(repo.upsertBrand).toHaveBeenCalledWith(
|
||||
'comp_1',
|
||||
expect.objectContaining({ heroImageUrl: expect.any(String) }),
|
||||
expect.objectContaining({ heroImageUrl: expect.any(String) }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkSubdomainAvailability', () => {
|
||||
it('returns available=true when subdomain is free', async () => {
|
||||
vi.mocked(repo.findBrandBySubdomain).mockResolvedValue(null)
|
||||
const result = await service.checkSubdomainAvailability('my-rental', 'comp_1')
|
||||
expect(result.available).toBe(true)
|
||||
})
|
||||
|
||||
it('returns available=false when subdomain is taken', async () => {
|
||||
vi.mocked(repo.findBrandBySubdomain).mockResolvedValue(mockBrand as any)
|
||||
const result = await service.checkSubdomainAvailability('taken-rental', 'comp_1')
|
||||
expect(result.available).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCustomDomain', () => {
|
||||
it('throws ConflictError when custom domain is already in use', async () => {
|
||||
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue(mockBrand as any)
|
||||
await expect(service.setCustomDomain('comp_1', 'Test Rentals', 'test-rentals', 'taken.com')).rejects.toThrow('This custom domain is already in use')
|
||||
})
|
||||
|
||||
it('sets custom domain when available', async () => {
|
||||
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue(null)
|
||||
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...mockBrand, customDomain: 'my-rental.com' } as any)
|
||||
await service.setCustomDomain('comp_1', 'Test Rentals', 'test-rentals', 'My-Rental.com')
|
||||
expect(repo.upsertBrand).toHaveBeenCalledWith(
|
||||
'comp_1',
|
||||
expect.objectContaining({ customDomain: 'my-rental.com' }),
|
||||
expect.objectContaining({ customDomain: 'my-rental.com' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateInsurancePolicy', () => {
|
||||
it('throws NotFoundError when policy does not belong to company', async () => {
|
||||
vi.mocked(repo.updateInsurancePolicy).mockResolvedValue({ count: 0 } as any)
|
||||
await expect(service.updateInsurancePolicy('pol_1', 'comp_1', { name: 'CDW' })).rejects.toThrow('Insurance policy not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletePricingRule', () => {
|
||||
it('throws NotFoundError when rule does not belong to company', async () => {
|
||||
vi.mocked(repo.deletePricingRule).mockResolvedValue({ count: 0 } as any)
|
||||
await expect(service.deletePricingRule('rule_1', 'comp_1')).rejects.toThrow('Pricing rule not found')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
complaint: { findMany: vi.fn(), count: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as repo from './complaint.repo'
|
||||
|
||||
describe('complaint.repo edge behavior', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('lists complaints with company-scoped filters and deterministic ordering', async () => {
|
||||
vi.mocked(prisma.complaint.findMany).mockResolvedValue([] as never)
|
||||
vi.mocked(prisma.complaint.count).mockResolvedValue(0 as never)
|
||||
|
||||
await repo.findMany('company_1', { status: 'OPEN', severity: 'LEVEL_2' }, 40, 20)
|
||||
|
||||
const where = { companyId: 'company_1', status: 'OPEN', severity: 'LEVEL_2' }
|
||||
expect(prisma.complaint.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where,
|
||||
skip: 40,
|
||||
take: 20,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}))
|
||||
expect(prisma.complaint.count).toHaveBeenCalledWith({ where })
|
||||
})
|
||||
|
||||
it('creates complaints with linked reservation, review and customer ids preserved', async () => {
|
||||
await repo.create({
|
||||
companyId: 'company_1',
|
||||
reservationId: 'reservation_1',
|
||||
reviewId: 'review_1',
|
||||
customerId: 'customer_1',
|
||||
severity: 'LEVEL_3',
|
||||
category: 'DAMAGE_CLAIM',
|
||||
subject: 'Damage dispute',
|
||||
assignedTo: 'employee_1',
|
||||
})
|
||||
|
||||
expect(prisma.complaint.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: {
|
||||
companyId: 'company_1',
|
||||
reservationId: 'reservation_1',
|
||||
reviewId: 'review_1',
|
||||
customerId: 'customer_1',
|
||||
severity: 'LEVEL_3',
|
||||
category: 'DAMAGE_CLAIM',
|
||||
subject: 'Damage dispute',
|
||||
assignedTo: 'employee_1',
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it('updates by complaint id without accepting a company id bypass from caller data', async () => {
|
||||
await repo.updateById('complaint_1', { status: 'RESOLVED', resolution: 'Refunded deposit' })
|
||||
|
||||
expect(prisma.complaint.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'complaint_1' },
|
||||
data: { status: 'RESOLVED', resolution: 'Refunded deposit' },
|
||||
}))
|
||||
})
|
||||
|
||||
it('deletes by primary id', async () => {
|
||||
await repo.deleteById('complaint_1')
|
||||
|
||||
expect(prisma.complaint.delete).toHaveBeenCalledWith({ where: { id: 'complaint_1' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
const COMPLAINT_INCLUDE = {
|
||||
reservation: {
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
status: true,
|
||||
vehicle: { select: { make: true, model: true, year: true } },
|
||||
},
|
||||
},
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
},
|
||||
},
|
||||
review: {
|
||||
select: {
|
||||
id: true,
|
||||
overallRating: true,
|
||||
comment: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export async function findMany(
|
||||
companyId: string,
|
||||
where: Record<string, unknown>,
|
||||
skip: number,
|
||||
take: number,
|
||||
) {
|
||||
const baseWhere = { companyId, ...where }
|
||||
return Promise.all([
|
||||
prisma.complaint.findMany({
|
||||
where: baseWhere,
|
||||
include: COMPLAINT_INCLUDE,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.complaint.count({ where: baseWhere }),
|
||||
])
|
||||
}
|
||||
|
||||
export async function findById(id: string, companyId: string) {
|
||||
return prisma.complaint.findFirst({
|
||||
where: { id, companyId },
|
||||
include: COMPLAINT_INCLUDE,
|
||||
})
|
||||
}
|
||||
|
||||
export async function create(data: {
|
||||
companyId: string
|
||||
reservationId?: string
|
||||
reviewId?: string
|
||||
customerId?: string
|
||||
severity: string
|
||||
category: string
|
||||
subject: string
|
||||
description?: string
|
||||
assignedTo?: string
|
||||
}) {
|
||||
return prisma.complaint.create({
|
||||
data: data as any,
|
||||
include: COMPLAINT_INCLUDE,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateById(id: string, data: Record<string, unknown>) {
|
||||
return prisma.complaint.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: COMPLAINT_INCLUDE,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteById(id: string) {
|
||||
return prisma.complaint.delete({ where: { id } })
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import * as service from './complaint.service'
|
||||
import { createSchema, updateSchema, listQuerySchema, idParamSchema } from './complaint.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const query = parseQuery(listQuerySchema, req)
|
||||
const result = await service.listComplaints(req.companyId, query)
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(createSchema, req)
|
||||
const complaint = await service.createComplaint(req.companyId, body)
|
||||
created(res, complaint)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const complaint = await service.getComplaint(id, req.companyId)
|
||||
ok(res, complaint)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(updateSchema, req)
|
||||
const updated = await service.updateComplaint(id, req.companyId, body)
|
||||
ok(res, updated)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const result = await service.deleteComplaint(id, req.companyId)
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createSchema, updateSchema, listQuerySchema } from './complaint.schemas'
|
||||
|
||||
describe('complaint schemas', () => {
|
||||
it('defaults new complaints to the lowest severity when not provided', () => {
|
||||
expect(createSchema.parse({ category: 'BILLING', subject: 'Incorrect invoice' })).toMatchObject({
|
||||
category: 'BILLING',
|
||||
subject: 'Incorrect invoice',
|
||||
severity: 'LEVEL_1',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects empty complaint subjects', () => {
|
||||
expect(() => createSchema.parse({ category: 'BILLING', subject: '' })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts lifecycle updates without requiring immutable creation fields', () => {
|
||||
expect(updateSchema.parse({ status: 'RESOLVED', resolution: 'Refund issued' })).toEqual({
|
||||
status: 'RESOLVED',
|
||||
resolution: 'Refund issued',
|
||||
})
|
||||
})
|
||||
|
||||
it('coerces pagination and applies default page size for list queries', () => {
|
||||
expect(listQuerySchema.parse({ page: '2', severity: 'LEVEL_3' })).toEqual({
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
severity: 'LEVEL_3',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects pathological page sizes before they hit the database', () => {
|
||||
expect(() => listQuerySchema.parse({ pageSize: '101' })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const CATEGORIES = [
|
||||
'BOOKING', 'PICKUP', 'VEHICLE_CLEANLINESS', 'VEHICLE_CONDITION', 'STAFF_SERVICE',
|
||||
'PRICING', 'DEPOSIT', 'INSURANCE', 'DAMAGE_CLAIM', 'RETURN_PROCESS',
|
||||
'COMMUNICATION', 'ROADSIDE_ASSISTANCE', 'BILLING', 'OTHER',
|
||||
] as const
|
||||
|
||||
const SEVERITIES = ['LEVEL_1', 'LEVEL_2', 'LEVEL_3'] as const
|
||||
const STATUSES = ['OPEN', 'INVESTIGATING', 'RESOLVED', 'CLOSED'] as const
|
||||
|
||||
export const createSchema = z.object({
|
||||
reservationId: z.string().optional(),
|
||||
reviewId: z.string().optional(),
|
||||
customerId: z.string().optional(),
|
||||
severity: z.enum(SEVERITIES).default('LEVEL_1'),
|
||||
category: z.enum(CATEGORIES),
|
||||
subject: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
assignedTo: z.string().optional(),
|
||||
})
|
||||
|
||||
export const updateSchema = z.object({
|
||||
status: z.enum(STATUSES).optional(),
|
||||
severity: z.enum(SEVERITIES).optional(),
|
||||
category: z.enum(CATEGORIES).optional(),
|
||||
subject: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
resolution: z.string().optional(),
|
||||
assignedTo: z.string().optional(),
|
||||
})
|
||||
|
||||
export const listQuerySchema = z.object({
|
||||
status: z.string().optional(),
|
||||
severity: z.string().optional(),
|
||||
category: 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 idParamSchema = z.object({
|
||||
id: z.string(),
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./complaint.repo', () => ({
|
||||
findMany: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updateById: vi.fn(),
|
||||
deleteById: vi.fn(),
|
||||
}))
|
||||
|
||||
import { NotFoundError } from '../../http/errors'
|
||||
import * as repo from './complaint.repo'
|
||||
import { createComplaint, deleteComplaint, getComplaint, listComplaints, updateComplaint } from './complaint.service'
|
||||
|
||||
describe('complaint.service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
|
||||
})
|
||||
|
||||
it('builds filtered paginated complaint queries without leaking transport concerns into the repo', async () => {
|
||||
vi.mocked(repo.findMany).mockResolvedValue([[{ id: 'complaint_1' }], 11] as never)
|
||||
|
||||
const result = await listComplaints('company_1', {
|
||||
status: 'OPEN',
|
||||
severity: 'HIGH',
|
||||
category: 'BILLING',
|
||||
page: 3,
|
||||
pageSize: 5,
|
||||
})
|
||||
|
||||
expect(repo.findMany).toHaveBeenCalledWith('company_1', {
|
||||
status: 'OPEN',
|
||||
severity: 'HIGH',
|
||||
category: 'BILLING',
|
||||
}, 10, 5)
|
||||
expect(result).toEqual({
|
||||
data: [{ id: 'complaint_1' }],
|
||||
meta: { total: 11, page: 3, pageSize: 5, totalPages: 3 },
|
||||
})
|
||||
})
|
||||
|
||||
it('throws NotFoundError when a complaint cannot be found in the company tenant', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(null as never)
|
||||
|
||||
await expect(getComplaint('complaint_1', 'company_1')).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
it('sets resolvedAt exactly when the complaint transitions into RESOLVED', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue({ id: 'complaint_1', status: 'OPEN' } as never)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ id: 'complaint_1', status: 'RESOLVED' } as never)
|
||||
|
||||
await updateComplaint('complaint_1', 'company_1', { status: 'RESOLVED', resolution: 'Refund issued' })
|
||||
|
||||
expect(repo.updateById).toHaveBeenCalledWith('complaint_1', {
|
||||
status: 'RESOLVED',
|
||||
resolution: 'Refund issued',
|
||||
resolvedAt: new Date('2026-06-08T12:00:00.000Z'),
|
||||
})
|
||||
})
|
||||
|
||||
it('does not overwrite resolvedAt when an already resolved complaint is edited', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue({ id: 'complaint_1', status: 'RESOLVED' } as never)
|
||||
|
||||
await updateComplaint('complaint_1', 'company_1', { notes: 'Follow-up call logged' })
|
||||
|
||||
expect(repo.updateById).toHaveBeenCalledWith('complaint_1', { notes: 'Follow-up call logged' })
|
||||
})
|
||||
|
||||
it('creates and deletes complaints through tenant-scoped repository calls', async () => {
|
||||
vi.mocked(repo.create).mockResolvedValue({ id: 'complaint_1' } as never)
|
||||
vi.mocked(repo.findById).mockResolvedValue({ id: 'complaint_1' } as never)
|
||||
|
||||
await createComplaint('company_1', {
|
||||
reservationId: 'reservation_1',
|
||||
severity: 'MEDIUM',
|
||||
category: 'SERVICE',
|
||||
subject: 'Late pickup',
|
||||
})
|
||||
const deleted = await deleteComplaint('complaint_1', 'company_1')
|
||||
|
||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'company_1', reservationId: 'reservation_1' }))
|
||||
expect(repo.deleteById).toHaveBeenCalledWith('complaint_1')
|
||||
expect(deleted).toEqual({ success: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { NotFoundError } from '../../http/errors'
|
||||
import * as repo from './complaint.repo'
|
||||
|
||||
export async function listComplaints(
|
||||
companyId: string,
|
||||
query: { status?: string; severity?: string; category?: string; page: number; pageSize: number },
|
||||
) {
|
||||
const { status, severity, category, page, pageSize } = query
|
||||
const where: Record<string, unknown> = {}
|
||||
if (status) where.status = status
|
||||
if (severity) where.severity = severity
|
||||
if (category) where.category = category
|
||||
|
||||
const [complaints, total] = await repo.findMany(companyId, where, (page - 1) * pageSize, pageSize)
|
||||
return {
|
||||
data: complaints,
|
||||
meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
|
||||
}
|
||||
}
|
||||
|
||||
export async function getComplaint(id: string, companyId: string) {
|
||||
const complaint = await repo.findById(id, companyId)
|
||||
if (!complaint) throw new NotFoundError('Complaint not found')
|
||||
return complaint
|
||||
}
|
||||
|
||||
export async function createComplaint(
|
||||
companyId: string,
|
||||
data: {
|
||||
reservationId?: string
|
||||
reviewId?: string
|
||||
customerId?: string
|
||||
severity: string
|
||||
category: string
|
||||
subject: string
|
||||
description?: string
|
||||
assignedTo?: string
|
||||
},
|
||||
) {
|
||||
return repo.create({ companyId, ...data })
|
||||
}
|
||||
|
||||
export async function updateComplaint(
|
||||
id: string,
|
||||
companyId: string,
|
||||
data: {
|
||||
status?: string
|
||||
severity?: string
|
||||
category?: string
|
||||
subject?: string
|
||||
description?: string
|
||||
notes?: string
|
||||
resolution?: string
|
||||
assignedTo?: string
|
||||
},
|
||||
) {
|
||||
const existing = await repo.findById(id, companyId)
|
||||
if (!existing) throw new NotFoundError('Complaint not found')
|
||||
|
||||
const updateData: Record<string, unknown> = { ...data }
|
||||
|
||||
// When status changes to RESOLVED, set resolvedAt
|
||||
if (data.status === 'RESOLVED' && existing.status !== 'RESOLVED') {
|
||||
updateData.resolvedAt = new Date()
|
||||
}
|
||||
|
||||
return repo.updateById(id, updateData)
|
||||
}
|
||||
|
||||
export async function deleteComplaint(id: string, companyId: string) {
|
||||
const existing = await repo.findById(id, companyId)
|
||||
if (!existing) throw new NotFoundError('Complaint not found')
|
||||
await repo.deleteById(id)
|
||||
return { success: true }
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({ redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() } }))
|
||||
|
||||
vi.mock('./customer.repo')
|
||||
vi.mock('../../services/licenseValidationService', () => ({
|
||||
validateAndFlagLicense: vi.fn().mockResolvedValue({ status: 'VALID' }),
|
||||
}))
|
||||
vi.mock('../../lib/storage', () => ({
|
||||
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/companies/company_1/customers/customer_1/new-license.jpg'),
|
||||
deleteImage: vi.fn().mockResolvedValue(undefined),
|
||||
resolveStoredFilePath: vi.fn().mockReturnValue('/tmp/license.jpg'),
|
||||
getProtectedCustomerLicenseImageUrl: vi.fn((customerId: string) => `http://localhost:3000/dashboard/api/v1/customers/${customerId}/license-image`),
|
||||
}))
|
||||
|
||||
import * as repo from './customer.repo'
|
||||
import * as service from './customer.service'
|
||||
import { validateAndFlagLicense } from '../../services/licenseValidationService'
|
||||
import { deleteImage, resolveStoredFilePath, uploadImage } from '../../lib/storage'
|
||||
|
||||
const customer = {
|
||||
id: 'customer_1',
|
||||
companyId: 'company_1',
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.test',
|
||||
licenseImageUrl: null,
|
||||
flagged: false,
|
||||
flagReason: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(resolveStoredFilePath).mockReturnValue('/tmp/license.jpg')
|
||||
})
|
||||
|
||||
describe('customer service edge cases', () => {
|
||||
it('trims and caps customer search text before building the query', async () => {
|
||||
vi.mocked(repo.findMany).mockResolvedValue([[], 0] as any)
|
||||
const longQuery = ` ${'x'.repeat(150)} `
|
||||
|
||||
await service.listCustomers('company_1', { q: longQuery, page: 2, pageSize: 10 })
|
||||
|
||||
const [where, skip, take] = vi.mocked(repo.findMany).mock.calls[0]
|
||||
expect(skip).toBe(10)
|
||||
expect(take).toBe(10)
|
||||
expect(where.OR[0].firstName.contains).toHaveLength(100)
|
||||
expect(where.OR[0].firstName.contains).not.toMatch(/^\s|\s$/)
|
||||
})
|
||||
|
||||
it('parses customer date fields and triggers async license validation on create', async () => {
|
||||
vi.mocked(repo.create).mockResolvedValue({ ...customer, licenseExpiry: new Date('2028-01-01T00:00:00.000Z') } as any)
|
||||
|
||||
await service.createCustomer({
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.test',
|
||||
dateOfBirth: '1998-05-20',
|
||||
licenseIssuedAt: '2024-01-01',
|
||||
licenseExpiry: '2028-01-01',
|
||||
}, 'company_1')
|
||||
|
||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
dateOfBirth: expect.any(Date),
|
||||
licenseIssuedAt: expect.any(Date),
|
||||
licenseExpiry: expect.any(Date),
|
||||
}))
|
||||
expect(validateAndFlagLicense).toHaveBeenCalledWith('customer_1', 'company_1')
|
||||
})
|
||||
|
||||
it('parses changed license dates and validates refreshed customer licenses on update', async () => {
|
||||
vi.mocked(repo.updateMany).mockResolvedValue({ count: 1 } as any)
|
||||
vi.mocked(repo.findUniqueOrThrow).mockResolvedValue({ ...customer, licenseExpiry: new Date('2029-01-01T00:00:00.000Z') } as any)
|
||||
|
||||
await service.updateCustomer('customer_1', 'company_1', { licenseExpiry: '2029-01-01' })
|
||||
|
||||
expect(repo.updateMany).toHaveBeenCalledWith('customer_1', 'company_1', expect.objectContaining({
|
||||
licenseExpiry: expect.any(Date),
|
||||
dateOfBirth: undefined,
|
||||
licenseIssuedAt: undefined,
|
||||
}))
|
||||
expect(validateAndFlagLicense).toHaveBeenCalledWith('customer_1', 'company_1')
|
||||
})
|
||||
|
||||
it('replaces existing license images without failing the upload when old-file deletion rejects', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue({ ...customer, licenseImageUrl: 'http://localhost:4000/storage/old-license.jpg' } as any)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ ...customer, licenseImageUrl: 'http://localhost:4000/storage/companies/company_1/customers/customer_1/new-license.jpg' } as any)
|
||||
vi.mocked(deleteImage).mockRejectedValue(new Error('old file missing'))
|
||||
|
||||
const result = await service.uploadLicenseImage('customer_1', 'company_1', { buffer: Buffer.from('image') } as any)
|
||||
|
||||
expect(uploadImage).toHaveBeenCalledWith(expect.any(Buffer), 'companies/company_1/customers/customer_1')
|
||||
expect(deleteImage).toHaveBeenCalledWith('http://localhost:4000/storage/old-license.jpg')
|
||||
expect(result.licenseImageUrl).toBe('http://localhost:3000/dashboard/api/v1/customers/customer_1/license-image')
|
||||
})
|
||||
|
||||
it('throws not found when a stored license URL cannot be resolved to a safe file path', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue({ ...customer, licenseImageUrl: 'https://evil.example/license.jpg' } as any)
|
||||
vi.mocked(resolveStoredFilePath).mockReturnValue(null)
|
||||
|
||||
await expect(service.getLicenseImageFile('customer_1', 'company_1')).rejects.toThrow('License image not found')
|
||||
})
|
||||
|
||||
it('records approver name, timestamp, and note when denying a license', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue(customer as any)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ ...customer, licenseValidationStatus: 'DENIED' } as any)
|
||||
|
||||
await service.approveLicense('customer_1', 'company_1', 'DENY', 'Unreadable scan', 'Ops Lead')
|
||||
|
||||
expect(repo.updateById).toHaveBeenCalledWith('customer_1', expect.objectContaining({
|
||||
licenseValidationStatus: 'DENIED',
|
||||
licenseApprovedBy: 'Ops Lead',
|
||||
licenseApprovedAt: expect.any(Date),
|
||||
licenseApprovalNote: 'Unreadable scan',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { presentCustomer, presentCustomerList } from './customer.presenter'
|
||||
|
||||
describe('customer.presenter', () => {
|
||||
const originalDashboardUrl = process.env.DASHBOARD_URL
|
||||
const originalPublicDashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.DASHBOARD_URL = 'https://dashboard.example.test'
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL = ''
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
process.env.DASHBOARD_URL = originalDashboardUrl
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL = originalPublicDashboardUrl
|
||||
})
|
||||
|
||||
it('rewrites raw customer license images to the protected dashboard endpoint', () => {
|
||||
expect(presentCustomer({
|
||||
id: 'customer_1',
|
||||
firstName: 'Aya',
|
||||
licenseImageUrl: '/storage/companies/company_1/customers/customer_1/license.jpg',
|
||||
})).toMatchObject({
|
||||
id: 'customer_1',
|
||||
firstName: 'Aya',
|
||||
licenseImageUrl: 'https://dashboard.example.test/api/v1/customers/customer_1/license-image',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps missing license images null and wraps customer lists with metadata', () => {
|
||||
expect(presentCustomer({ id: 'customer_1', licenseImageUrl: null }).licenseImageUrl).toBeNull()
|
||||
expect(presentCustomerList([{ id: 'customer_1' }], { total: 1, page: 1, pageSize: 20, totalPages: 1 })).toEqual({
|
||||
data: [{ id: 'customer_1' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getProtectedCustomerLicenseImageUrl } from '../../lib/storage'
|
||||
|
||||
export function presentCustomer(customer: any) {
|
||||
return {
|
||||
...customer,
|
||||
licenseImageUrl: customer?.licenseImageUrl
|
||||
? getProtectedCustomerLicenseImageUrl(customer.id)
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function presentCustomerList(customers: any[], meta: { total: number; page: number; pageSize: number; totalPages: number }) {
|
||||
return { data: customers, ...meta }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
$queryRawUnsafe: vi.fn(),
|
||||
customer: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
describe('customer.repo storage-column compatibility', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('includes licenseImageUrl when the deployed schema supports the column', async () => {
|
||||
vi.mocked(prisma.$queryRawUnsafe).mockResolvedValue([{ exists: true }] as never)
|
||||
const repo = await import('./customer.repo')
|
||||
|
||||
await repo.findMany({ companyId: 'company_1' }, 0, 20)
|
||||
|
||||
expect(prisma.customer.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
select: expect.objectContaining({ licenseImageUrl: true }),
|
||||
}))
|
||||
expect(prisma.customer.count).toHaveBeenCalledWith({ where: { companyId: 'company_1' } })
|
||||
})
|
||||
|
||||
it('strips licenseImageUrl from writes when the old deployed schema lacks the column', async () => {
|
||||
vi.mocked(prisma.$queryRawUnsafe).mockResolvedValue([{ exists: false }] as never)
|
||||
const repo = await import('./customer.repo')
|
||||
|
||||
await repo.create({ companyId: 'company_1', email: 'renter@example.test', licenseImageUrl: '/storage/private.png' })
|
||||
|
||||
expect(prisma.customer.create).toHaveBeenCalledWith({
|
||||
data: { companyId: 'company_1', email: 'renter@example.test' },
|
||||
select: expect.not.objectContaining({ licenseImageUrl: true }),
|
||||
})
|
||||
})
|
||||
|
||||
it('scopes customer updates by id and company id without querying schema support', async () => {
|
||||
const repo = await import('./customer.repo')
|
||||
|
||||
await repo.updateMany('customer_1', 'company_1', { flagged: true })
|
||||
|
||||
expect(prisma.customer.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'customer_1', companyId: 'company_1' },
|
||||
data: { flagged: true },
|
||||
})
|
||||
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
let customerLicenseImageColumnSupported: boolean | null = null
|
||||
|
||||
async function hasCustomerLicenseImageColumn() {
|
||||
if (customerLicenseImageColumnSupported !== null) {
|
||||
return customerLicenseImageColumnSupported
|
||||
}
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ exists: boolean }>>(
|
||||
"select exists(select 1 from information_schema.columns where table_schema = 'public' and table_name = 'customers' and column_name = 'licenseImageUrl') as exists"
|
||||
)
|
||||
customerLicenseImageColumnSupported = Boolean(rows[0]?.exists)
|
||||
return customerLicenseImageColumnSupported
|
||||
}
|
||||
|
||||
function buildCustomerSelect(includeLicenseImageUrl: boolean) {
|
||||
return {
|
||||
id: true,
|
||||
companyId: true,
|
||||
renterId: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
driverLicense: true,
|
||||
dateOfBirth: true,
|
||||
nationality: true,
|
||||
address: true,
|
||||
notes: true,
|
||||
flagged: true,
|
||||
flagReason: true,
|
||||
licenseExpiry: true,
|
||||
licenseIssuedAt: true,
|
||||
licenseCountry: true,
|
||||
licenseNumber: true,
|
||||
licenseCategory: true,
|
||||
licenseExpired: true,
|
||||
licenseExpiringSoon: true,
|
||||
licenseValidationStatus: true,
|
||||
licenseApprovedBy: true,
|
||||
licenseApprovedAt: true,
|
||||
licenseApprovalNote: true,
|
||||
...(includeLicenseImageUrl ? { licenseImageUrl: true } : {}),
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}
|
||||
}
|
||||
|
||||
async function getCustomerSelect() {
|
||||
return buildCustomerSelect(await hasCustomerLicenseImageColumn())
|
||||
}
|
||||
|
||||
async function sanitizeCustomerData(data: Record<string, unknown>) {
|
||||
if (await hasCustomerLicenseImageColumn()) {
|
||||
return data
|
||||
}
|
||||
|
||||
const { licenseImageUrl, ...rest } = data
|
||||
void licenseImageUrl
|
||||
return rest as Record<string, unknown>
|
||||
}
|
||||
|
||||
export async function findMany(where: any, skip: number, take: number) {
|
||||
const select = await getCustomerSelect()
|
||||
return Promise.all([
|
||||
prisma.customer.findMany({ where, skip, take, orderBy: { createdAt: 'desc' }, select }),
|
||||
prisma.customer.count({ where }),
|
||||
])
|
||||
}
|
||||
|
||||
export async function findById(id: string, companyId: string) {
|
||||
const select = await getCustomerSelect()
|
||||
return prisma.customer.findFirst({
|
||||
where: { id, companyId },
|
||||
select: {
|
||||
...select,
|
||||
reservations: { include: { vehicle: true }, orderBy: { createdAt: 'desc' }, take: 20 },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function findByIdSimple(id: string, companyId: string) {
|
||||
return prisma.customer.findFirst({ where: { id, companyId }, select: await getCustomerSelect() })
|
||||
}
|
||||
|
||||
export async function create(data: any) {
|
||||
return prisma.customer.create({ data: (await sanitizeCustomerData(data)) as any, select: await getCustomerSelect() })
|
||||
}
|
||||
|
||||
export async function updateMany(id: string, companyId: string, data: any) {
|
||||
return prisma.customer.updateMany({ where: { id, companyId }, data })
|
||||
}
|
||||
|
||||
export async function updateById(id: string, data: any) {
|
||||
return prisma.customer.update({
|
||||
where: { id },
|
||||
data: (await sanitizeCustomerData(data)) as any,
|
||||
select: await getCustomerSelect(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function findUniqueOrThrow(id: string, companyId?: string) {
|
||||
const select = await getCustomerSelect()
|
||||
if (companyId) {
|
||||
return prisma.customer.findFirstOrThrow({ where: { id, companyId }, select })
|
||||
}
|
||||
return prisma.customer.findUniqueOrThrow({ where: { id }, select })
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth, requireCompanyDocumentAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import { imageUpload, assertImageFile } from '../../http/upload'
|
||||
import * as service from './customer.service'
|
||||
import { customerSchema, listQuerySchema, approveLicenseSchema, flagSchema, idParamSchema } from './customer.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/:id/license-image', requireCompanyDocumentAuth, requireTenant, requireSubscription, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const filePath = await service.getLicenseImageFile(id, req.companyId)
|
||||
res.sendFile(filePath)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const query = parseQuery(listQuerySchema, req)
|
||||
const result = await service.listCustomers(req.companyId, query)
|
||||
res.json(result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(customerSchema, req)
|
||||
const customer = await service.createCustomer(body, req.companyId)
|
||||
created(res, customer)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const customer = await service.getCustomer(id, req.companyId)
|
||||
ok(res, customer)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(customerSchema.partial(), req)
|
||||
const customer = await service.updateCustomer(id, req.companyId, body)
|
||||
ok(res, customer)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/flag', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { reason } = parseBody(flagSchema, req)
|
||||
await service.flagCustomer(id, req.companyId, reason)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:id/flag', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.unflagCustomer(id, req.companyId)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/validate-license', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const result = await service.validateCustomerLicense(id, req.companyId)
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/license-image', imageUpload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
assertImageFile(req.file, 'license image')
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const updated = await service.uploadLicenseImage(id, req.companyId, req.file)
|
||||
ok(res, updated)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/approve-license', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { decision, note } = parseBody(approveLicenseSchema, req)
|
||||
const approverName = `${req.employee.firstName} ${req.employee.lastName}`
|
||||
const updated = await service.approveLicense(id, req.companyId, decision, note, approverName)
|
||||
ok(res, updated)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { approveLicenseSchema, customerSchema, flagSchema, listQuerySchema } from './customer.schemas'
|
||||
|
||||
describe('customer schema contracts', () => {
|
||||
it('normalizes create payload identity fields and optional text', () => {
|
||||
const parsed = customerSchema.parse({
|
||||
firstName: ' Aya ',
|
||||
lastName: ' Haddad ',
|
||||
email: 'AYA@EXAMPLE.TEST',
|
||||
phone: ' +212600000000 ',
|
||||
notes: ' VIP renter ',
|
||||
licenseNumber: ' B-123456 ',
|
||||
dateOfBirth: '1994-05-01T00:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
firstName: 'Aya',
|
||||
lastName: 'Haddad',
|
||||
email: 'aya@example.test',
|
||||
phone: '+212600000000',
|
||||
notes: 'VIP renter',
|
||||
licenseNumber: 'B-123456',
|
||||
})
|
||||
})
|
||||
|
||||
it('coerces list pagination and preserves supported filters', () => {
|
||||
expect(listQuerySchema.parse({ page: '3', pageSize: '40', q: 'aya', flagged: 'true' })).toEqual({
|
||||
page: 3,
|
||||
pageSize: 40,
|
||||
q: 'aya',
|
||||
flagged: 'true',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects impossible pagination and unsafe customer field lengths', () => {
|
||||
expect(() => listQuerySchema.parse({ page: '0' })).toThrow()
|
||||
expect(() => customerSchema.parse({ firstName: '', lastName: 'Haddad', email: 'aya@example.test' })).toThrow()
|
||||
expect(() => customerSchema.parse({ firstName: 'Aya', lastName: 'Haddad', email: 'not-email' })).toThrow()
|
||||
expect(() => customerSchema.parse({ firstName: 'Aya', lastName: 'Haddad', email: 'aya@example.test', notes: 'x'.repeat(2001) })).toThrow()
|
||||
})
|
||||
|
||||
it('limits license approvals to explicit approve or deny decisions', () => {
|
||||
expect(approveLicenseSchema.parse({ decision: 'APPROVE', note: 'Verified manually' })).toEqual({
|
||||
decision: 'APPROVE',
|
||||
note: 'Verified manually',
|
||||
})
|
||||
expect(() => approveLicenseSchema.parse({ decision: 'MAYBE' })).toThrow()
|
||||
})
|
||||
|
||||
it('keeps customer flagging reason optional', () => {
|
||||
expect(flagSchema.parse({})).toEqual({})
|
||||
expect(flagSchema.parse({ reason: 'Chargeback risk' })).toEqual({ reason: 'Chargeback risk' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(10000).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const customerSchema = z.object({
|
||||
firstName: z.string().min(1).max(100).trim(),
|
||||
lastName: z.string().min(1).max(100).trim(),
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
phone: z.string().max(30).trim().optional(),
|
||||
driverLicense: z.string().max(50).trim().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().max(100).trim().optional(),
|
||||
address: z.record(z.unknown()).optional(),
|
||||
notes: z.string().max(2000).trim().optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
licenseCountry: z.string().max(100).trim().optional(),
|
||||
licenseNumber: z.string().max(50).trim().optional(),
|
||||
licenseCategory: z.string().max(20).trim().optional(),
|
||||
})
|
||||
|
||||
export const listQuerySchema = paginationSchema.extend({
|
||||
q: z.string().max(100).optional(),
|
||||
flagged: z.enum(['true', 'false']).optional(),
|
||||
})
|
||||
|
||||
export const approveLicenseSchema = z.object({
|
||||
decision: z.enum(['APPROVE', 'DENY']),
|
||||
note: z.string().optional(),
|
||||
})
|
||||
|
||||
export const flagSchema = z.object({
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export const idParamSchema = z.object({ id: z.string() })
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../services/licenseValidationService', () => ({
|
||||
validateAndFlagLicense: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../lib/storage', () => ({
|
||||
uploadImage: vi.fn(),
|
||||
deleteImage: vi.fn(),
|
||||
resolveStoredFilePath: vi.fn(),
|
||||
}))
|
||||
vi.mock('./customer.presenter', () => ({
|
||||
presentCustomer: vi.fn((customer: any) => ({ presented: true, ...customer })),
|
||||
presentCustomerList: vi.fn((customers: any[], meta: any) => ({ data: customers, meta })),
|
||||
}))
|
||||
vi.mock('./customer.repo', () => ({
|
||||
findMany: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findByIdSimple: vi.fn(),
|
||||
updateById: vi.fn(),
|
||||
}))
|
||||
|
||||
import { NotFoundError } from '../../http/errors'
|
||||
import { validateAndFlagLicense } from '../../services/licenseValidationService'
|
||||
import { uploadImage, deleteImage, resolveStoredFilePath } from '../../lib/storage'
|
||||
import * as repo from './customer.repo'
|
||||
import * as service from './customer.service'
|
||||
|
||||
describe('customer.service boundary behavior', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('trims and caps search while applying flagged and pagination filters', async () => {
|
||||
vi.mocked(repo.findMany).mockResolvedValue([[{ id: 'customer_1' }], 41] as never)
|
||||
|
||||
const result = await service.listCustomers('company_1', {
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
q: ` ${'x'.repeat(150)} `,
|
||||
flagged: 'false',
|
||||
})
|
||||
|
||||
const expectedQ = 'x'.repeat(100)
|
||||
expect(repo.findMany).toHaveBeenCalledWith({
|
||||
companyId: 'company_1',
|
||||
flagged: false,
|
||||
OR: [
|
||||
{ firstName: { contains: expectedQ, mode: 'insensitive' } },
|
||||
{ lastName: { contains: expectedQ, mode: 'insensitive' } },
|
||||
{ email: { contains: expectedQ, mode: 'insensitive' } },
|
||||
],
|
||||
}, 20, 10)
|
||||
expect(result.meta).toEqual({ total: 41, page: 3, pageSize: 10, totalPages: 5 })
|
||||
})
|
||||
|
||||
it('normalizes customer date fields and swallows async license validation failures after create', async () => {
|
||||
vi.mocked(repo.create).mockResolvedValue({ id: 'customer_1', email: 'renter@example.test' } as never)
|
||||
vi.mocked(validateAndFlagLicense).mockRejectedValue(new Error('provider down') as never)
|
||||
|
||||
await service.createCustomer({
|
||||
firstName: 'Nora',
|
||||
dateOfBirth: '1992-02-03',
|
||||
licenseExpiry: '2027-01-01',
|
||||
licenseIssuedAt: '2022-01-01',
|
||||
}, 'company_1')
|
||||
|
||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
dateOfBirth: new Date('1992-02-03'),
|
||||
licenseExpiry: new Date('2027-01-01'),
|
||||
licenseIssuedAt: new Date('2022-01-01'),
|
||||
}))
|
||||
expect(validateAndFlagLicense).toHaveBeenCalledWith('customer_1', 'company_1')
|
||||
})
|
||||
|
||||
it('throws not found when tenant-scoped update touches no rows', async () => {
|
||||
vi.mocked(repo.updateMany).mockResolvedValue({ count: 0 } as never)
|
||||
|
||||
await expect(service.updateCustomer('customer_1', 'company_1', { firstName: 'Nope' })).rejects.toBeInstanceOf(NotFoundError)
|
||||
expect(repo.findUniqueOrThrow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uploads the new license image and best-effort deletes the previous one', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue({ id: 'customer_1', licenseImageUrl: '/storage/old.png' } as never)
|
||||
vi.mocked(uploadImage).mockResolvedValue('/storage/new.png' as never)
|
||||
vi.mocked(deleteImage).mockRejectedValue(new Error('already gone') as never)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ id: 'customer_1', licenseImageUrl: '/storage/new.png' } as never)
|
||||
|
||||
const file = { buffer: Buffer.from('fake') } as Express.Multer.File
|
||||
const result = await service.uploadLicenseImage('customer_1', 'company_1', file)
|
||||
|
||||
expect(uploadImage).toHaveBeenCalledWith(file.buffer, 'companies/company_1/customers/customer_1')
|
||||
expect(deleteImage).toHaveBeenCalledWith('/storage/old.png')
|
||||
expect(repo.updateById).toHaveBeenCalledWith('customer_1', { licenseImageUrl: '/storage/new.png' })
|
||||
expect(result.licenseImageUrl).toBe('/storage/new.png')
|
||||
})
|
||||
|
||||
it('rejects license-image access when the stored URL cannot resolve to a file path', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue({ id: 'customer_1', licenseImageUrl: '/storage/missing.png' } as never)
|
||||
vi.mocked(resolveStoredFilePath).mockReturnValue(null)
|
||||
|
||||
await expect(service.getLicenseImageFile('customer_1', 'company_1')).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
it('records approver metadata when manually approving a license', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue({ id: 'customer_1' } as never)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ id: 'customer_1', licenseValidationStatus: 'APPROVED' } as never)
|
||||
|
||||
await service.approveLicense('customer_1', 'company_1', 'APPROVE', undefined, 'Mina Manager')
|
||||
|
||||
expect(repo.updateById).toHaveBeenCalledWith('customer_1', expect.objectContaining({
|
||||
licenseValidationStatus: 'APPROVED',
|
||||
licenseApprovedBy: 'Mina Manager',
|
||||
licenseApprovalNote: null,
|
||||
licenseApprovedAt: expect.any(Date),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { validateAndFlagLicense } from '../../services/licenseValidationService'
|
||||
import { uploadImage, deleteImage, resolveStoredFilePath } from '../../lib/storage'
|
||||
import { NotFoundError } from '../../http/errors'
|
||||
import { presentCustomer, presentCustomerList } from './customer.presenter'
|
||||
import * as repo from './customer.repo'
|
||||
|
||||
export async function listCustomers(companyId: string, query: { page?: number; pageSize?: number; q?: string; flagged?: string }) {
|
||||
const page = query.page ?? 1
|
||||
const pageSize = query.pageSize ?? 20
|
||||
const { q, flagged } = query
|
||||
const safeQ = q ? q.trim().slice(0, 100) : undefined
|
||||
const where: any = { companyId }
|
||||
if (flagged !== undefined) where.flagged = flagged === 'true'
|
||||
if (safeQ) {
|
||||
where.OR = [
|
||||
{ firstName: { contains: safeQ, mode: 'insensitive' } },
|
||||
{ lastName: { contains: safeQ, mode: 'insensitive' } },
|
||||
{ email: { contains: safeQ, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
const [customers, total] = await repo.findMany(where, (page - 1) * pageSize, pageSize)
|
||||
return presentCustomerList(customers.map(presentCustomer), { total, page, pageSize, totalPages: Math.ceil(total / pageSize) })
|
||||
}
|
||||
|
||||
export async function getCustomer(id: string, companyId: string) {
|
||||
const customer = await repo.findById(id, companyId)
|
||||
if (!customer) throw new NotFoundError('Customer not found')
|
||||
return presentCustomer(customer)
|
||||
}
|
||||
|
||||
export async function createCustomer(data: any, companyId: string) {
|
||||
const customer = await repo.create({
|
||||
...data,
|
||||
companyId,
|
||||
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null,
|
||||
licenseExpiry: data.licenseExpiry ? new Date(data.licenseExpiry) : null,
|
||||
licenseIssuedAt: data.licenseIssuedAt ? new Date(data.licenseIssuedAt) : null,
|
||||
})
|
||||
if (data.licenseExpiry) {
|
||||
await validateAndFlagLicense(customer.id, companyId).catch(() => null)
|
||||
}
|
||||
return presentCustomer(customer)
|
||||
}
|
||||
|
||||
export async function updateCustomer(id: string, companyId: string, data: any) {
|
||||
const result = await repo.updateMany(id, companyId, {
|
||||
...data,
|
||||
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : undefined,
|
||||
licenseExpiry: data.licenseExpiry ? new Date(data.licenseExpiry) : undefined,
|
||||
licenseIssuedAt: data.licenseIssuedAt ? new Date(data.licenseIssuedAt) : undefined,
|
||||
})
|
||||
if (result.count === 0) throw new NotFoundError('Customer not found')
|
||||
if (data.licenseExpiry) await validateAndFlagLicense(id, companyId).catch(() => null)
|
||||
return presentCustomer(await repo.findUniqueOrThrow(id, companyId))
|
||||
}
|
||||
|
||||
export async function flagCustomer(id: string, companyId: string, reason?: string) {
|
||||
await repo.updateMany(id, companyId, { flagged: true, flagReason: reason ?? null })
|
||||
}
|
||||
|
||||
export async function unflagCustomer(id: string, companyId: string) {
|
||||
await repo.updateMany(id, companyId, { flagged: false, flagReason: null })
|
||||
}
|
||||
|
||||
export async function validateCustomerLicense(id: string, companyId: string) {
|
||||
const customer = await repo.findByIdSimple(id, companyId)
|
||||
if (!customer) throw new NotFoundError('Customer not found')
|
||||
return validateAndFlagLicense(customer.id, companyId)
|
||||
}
|
||||
|
||||
export async function uploadLicenseImage(id: string, companyId: string, file: Express.Multer.File) {
|
||||
const customer = await repo.findByIdSimple(id, companyId)
|
||||
if (!customer) throw new NotFoundError('Customer not found')
|
||||
const url = await uploadImage(file.buffer, `companies/${companyId}/customers/${customer.id}`)
|
||||
if (customer.licenseImageUrl) {
|
||||
await deleteImage(customer.licenseImageUrl).catch(() => null)
|
||||
}
|
||||
return presentCustomer(await repo.updateById(customer.id, { licenseImageUrl: url }))
|
||||
}
|
||||
|
||||
export async function getLicenseImageFile(id: string, companyId: string) {
|
||||
const customer = await repo.findByIdSimple(id, companyId)
|
||||
if (!customer || !customer.licenseImageUrl) throw new NotFoundError('License image not found')
|
||||
|
||||
const filePath = resolveStoredFilePath(customer.licenseImageUrl)
|
||||
if (!filePath) throw new NotFoundError('License image not found')
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
export async function approveLicense(id: string, companyId: string, decision: 'APPROVE' | 'DENY', note: string | undefined, approverName: string) {
|
||||
const customer = await repo.findByIdSimple(id, companyId)
|
||||
if (!customer) throw new NotFoundError('Customer not found')
|
||||
return presentCustomer(await repo.updateById(customer.id, {
|
||||
licenseValidationStatus: decision === 'APPROVE' ? 'APPROVED' : 'DENIED',
|
||||
licenseApprovedBy: approverName,
|
||||
licenseApprovedAt: new Date(),
|
||||
licenseApprovalNote: note ?? null,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as repo from './customer.repo'
|
||||
import * as service from './customer.service'
|
||||
|
||||
vi.mock('./customer.repo')
|
||||
vi.mock('../../services/licenseValidationService', () => ({
|
||||
validateAndFlagLicense: vi.fn().mockResolvedValue({ status: 'VALID', daysUntilExpiry: 365, requiresApproval: false, message: 'Valid' }),
|
||||
}))
|
||||
vi.mock('../../lib/storage', () => ({
|
||||
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/test.jpg'),
|
||||
deleteImage: vi.fn().mockResolvedValue(undefined),
|
||||
resolveStoredFilePath: vi.fn().mockReturnValue('/tmp/license.jpg'),
|
||||
getProtectedCustomerLicenseImageUrl: vi.fn((customerId: string) => `http://localhost:3000/dashboard/api/v1/customers/${customerId}/license-image`),
|
||||
}))
|
||||
|
||||
const mockCustomer = {
|
||||
id: 'cust_1',
|
||||
companyId: 'comp_1',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john@example.com',
|
||||
licenseImageUrl: null,
|
||||
flagged: false,
|
||||
flagReason: null,
|
||||
licenseExpiry: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('customer.service', () => {
|
||||
describe('listCustomers', () => {
|
||||
it('returns paginated customer list', async () => {
|
||||
vi.mocked(repo.findMany).mockResolvedValue([[mockCustomer], 1] as any)
|
||||
const result = await service.listCustomers('comp_1', { page: 1, pageSize: 20 })
|
||||
expect(result.total).toBe(1)
|
||||
expect(result.data).toHaveLength(1)
|
||||
expect(result.totalPages).toBe(1)
|
||||
})
|
||||
|
||||
it('filters by search query', async () => {
|
||||
vi.mocked(repo.findMany).mockResolvedValue([[], 0] as any)
|
||||
await service.listCustomers('comp_1', { page: 1, pageSize: 20, q: 'john' })
|
||||
expect(repo.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ OR: expect.arrayContaining([expect.objectContaining({ firstName: expect.anything() })]) }),
|
||||
0, 20,
|
||||
)
|
||||
})
|
||||
|
||||
it('filters by flagged status', async () => {
|
||||
vi.mocked(repo.findMany).mockResolvedValue([[], 0] as any)
|
||||
await service.listCustomers('comp_1', { page: 1, pageSize: 20, flagged: 'true' })
|
||||
expect(repo.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ flagged: true }),
|
||||
0, 20,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createCustomer', () => {
|
||||
it('creates customer and returns it', async () => {
|
||||
vi.mocked(repo.create).mockResolvedValue(mockCustomer as any)
|
||||
const result = await service.createCustomer({ firstName: 'John', lastName: 'Doe', email: 'john@example.com' }, 'comp_1')
|
||||
expect(result).toEqual({
|
||||
...mockCustomer,
|
||||
licenseImageUrl: null,
|
||||
})
|
||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCustomer', () => {
|
||||
it('throws NotFoundError when customer does not belong to company', async () => {
|
||||
vi.mocked(repo.updateMany).mockResolvedValue({ count: 0 } as any)
|
||||
await expect(service.updateCustomer('cust_1', 'comp_1', { firstName: 'Jane' })).rejects.toThrow('Customer not found')
|
||||
})
|
||||
|
||||
it('updates customer and returns refreshed record', async () => {
|
||||
vi.mocked(repo.updateMany).mockResolvedValue({ count: 1 } as any)
|
||||
vi.mocked(repo.findUniqueOrThrow).mockResolvedValue({ ...mockCustomer, firstName: 'Jane' } as any)
|
||||
const result = await service.updateCustomer('cust_1', 'comp_1', { firstName: 'Jane' })
|
||||
expect(result.firstName).toBe('Jane')
|
||||
expect(result.licenseImageUrl).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('flagCustomer', () => {
|
||||
it('sets flagged=true with reason', async () => {
|
||||
vi.mocked(repo.updateMany).mockResolvedValue({ count: 1 } as any)
|
||||
await service.flagCustomer('cust_1', 'comp_1', 'Suspicious behavior')
|
||||
expect(repo.updateMany).toHaveBeenCalledWith('cust_1', 'comp_1', { flagged: true, flagReason: 'Suspicious behavior' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('unflagCustomer', () => {
|
||||
it('clears flag and reason', async () => {
|
||||
vi.mocked(repo.updateMany).mockResolvedValue({ count: 1 } as any)
|
||||
await service.unflagCustomer('cust_1', 'comp_1')
|
||||
expect(repo.updateMany).toHaveBeenCalledWith('cust_1', 'comp_1', { flagged: false, flagReason: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadLicenseImage', () => {
|
||||
it('uploads image and updates customer record', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue(mockCustomer as any)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ ...mockCustomer, licenseImageUrl: 'http://localhost:4000/storage/test.jpg' } as any)
|
||||
const result = await service.uploadLicenseImage('cust_1', 'comp_1', { buffer: Buffer.from(''), mimetype: 'image/jpeg' } as any)
|
||||
expect(result.licenseImageUrl).toBe('http://localhost:3000/dashboard/api/v1/customers/cust_1/license-image')
|
||||
})
|
||||
|
||||
it('resolves a stored file path for protected downloads', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue({ ...mockCustomer, licenseImageUrl: 'http://localhost:4000/storage/test.jpg' } as any)
|
||||
const result = await service.getLicenseImageFile('cust_1', 'comp_1')
|
||||
expect(result).toBe('/tmp/license.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('approveLicense', () => {
|
||||
it('sets APPROVED status', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue(mockCustomer as any)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ ...mockCustomer, licenseValidationStatus: 'APPROVED' } as any)
|
||||
const result = await service.approveLicense('cust_1', 'comp_1', 'APPROVE', undefined, 'Admin User')
|
||||
expect(repo.updateById).toHaveBeenCalledWith('cust_1', expect.objectContaining({ licenseValidationStatus: 'APPROVED' }))
|
||||
})
|
||||
|
||||
it('sets DENIED status', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue(mockCustomer as any)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ ...mockCustomer, licenseValidationStatus: 'DENIED' } as any)
|
||||
await service.approveLicense('cust_1', 'comp_1', 'DENY', 'Expired document', 'Admin User')
|
||||
expect(repo.updateById).toHaveBeenCalledWith('cust_1', expect.objectContaining({ licenseValidationStatus: 'DENIED' }))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { presentVehicleWithAvailability } from './marketplace.presenter'
|
||||
|
||||
describe('marketplace.presenter', () => {
|
||||
it('adds public availability fields without mutating the original vehicle shape', () => {
|
||||
const nextAvailableAt = new Date('2026-08-01T10:00:00.000Z')
|
||||
const vehicle = { id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 }
|
||||
|
||||
expect(presentVehicleWithAvailability(vehicle, {
|
||||
available: false,
|
||||
status: 'RESERVED',
|
||||
nextAvailableAt,
|
||||
})).toEqual({
|
||||
id: 'vehicle_1',
|
||||
make: 'Dacia',
|
||||
model: 'Logan',
|
||||
dailyRate: 25000,
|
||||
availability: false,
|
||||
availabilityStatus: 'RESERVED',
|
||||
nextAvailableAt,
|
||||
})
|
||||
|
||||
expect(vehicle).toEqual({ id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
export function presentVehicleWithAvailability<T extends { id: string }>(
|
||||
vehicle: T,
|
||||
availability: { available: boolean; status: string; nextAvailableAt: Date | null },
|
||||
) {
|
||||
return {
|
||||
...vehicle,
|
||||
availability: availability.available,
|
||||
availabilityStatus: availability.status,
|
||||
nextAvailableAt: availability.nextAvailableAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
offer: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
company: { findMany: vi.fn(), findFirst: vi.fn(), findFirstOrThrow: vi.fn() },
|
||||
vehicle: { findMany: vi.fn(), findFirst: vi.fn(), findFirstOrThrow: vi.fn() },
|
||||
reservation: { create: vi.fn(), findUnique: vi.fn(), update: vi.fn() },
|
||||
customer: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
|
||||
review: { findMany: vi.fn(), create: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
import * as repo from './marketplace.repo'
|
||||
|
||||
describe('marketplace.repo public query and write boundaries', () => {
|
||||
it('finds public offers using active/public/current-window filters and featured ordering', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-09T10:00:00.000Z'))
|
||||
|
||||
await repo.findPublicOffers()
|
||||
|
||||
expect(prismaMock.offer.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: {
|
||||
isPublic: true,
|
||||
isActive: true,
|
||||
validFrom: { lte: new Date('2026-06-09T10:00:00.000Z') },
|
||||
validUntil: { gte: new Date('2026-06-09T10:00:00.000Z') },
|
||||
},
|
||||
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
|
||||
take: 50,
|
||||
}))
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('lists marketplace cities only from active listed companies with a public city', async () => {
|
||||
await repo.findCitiesFromCompanies()
|
||||
|
||||
expect(prismaMock.company.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: { in: ['ACTIVE', 'TRIALING'] },
|
||||
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
|
||||
},
|
||||
select: { brand: { select: { publicCity: true } } },
|
||||
})
|
||||
})
|
||||
|
||||
it('requires published vehicles and an active company when loading marketplace vehicle details', async () => {
|
||||
await repo.findVehicleForMarketplace('vehicle_1', 'atlas')
|
||||
|
||||
expect(prismaMock.vehicle.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: 'vehicle_1',
|
||||
isPublished: true,
|
||||
company: { slug: 'atlas', status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
},
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
})
|
||||
|
||||
it('patches marketplace customer address fields without deleting existing address metadata', async () => {
|
||||
prismaMock.customer.findUnique.mockResolvedValueOnce({
|
||||
id: 'customer_1',
|
||||
address: { fullAddress: 'Old address', loyaltyNote: 'keep', internationalLicenseNumber: 'INT-1' },
|
||||
})
|
||||
|
||||
await repo.upsertMarketplaceCustomer('company_1', {
|
||||
email: 'renter@example.test',
|
||||
firstName: 'Nora',
|
||||
lastName: 'Renter',
|
||||
identityDocumentNumber: 'ID-9',
|
||||
internationalLicenseNumber: undefined,
|
||||
})
|
||||
|
||||
expect(prismaMock.customer.update).toHaveBeenCalledWith({
|
||||
where: { id: 'customer_1' },
|
||||
data: expect.objectContaining({
|
||||
firstName: 'Nora',
|
||||
address: {
|
||||
fullAddress: 'Old address',
|
||||
loyaltyNote: 'keep',
|
||||
internationalLicenseNumber: 'INT-1',
|
||||
identityDocumentNumber: 'ID-9',
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('creates marketplace reservations as draft marketplace-sourced records', async () => {
|
||||
const startDate = new Date('2026-07-01T10:00:00.000Z')
|
||||
const endDate = new Date('2026-07-05T10:00:00.000Z')
|
||||
|
||||
await repo.createMarketplaceReservation({
|
||||
companyId: 'company_1',
|
||||
vehicleId: 'vehicle_1',
|
||||
customerId: 'customer_1',
|
||||
startDate,
|
||||
endDate,
|
||||
dailyRate: 500,
|
||||
totalDays: 4,
|
||||
totalAmount: 2000,
|
||||
pickupLocation: 'Airport',
|
||||
returnLocation: null,
|
||||
})
|
||||
|
||||
expect(prismaMock.reservation.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
vehicleId: 'vehicle_1',
|
||||
customerId: 'customer_1',
|
||||
source: 'MARKETPLACE',
|
||||
status: 'DRAFT',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('submits public reviews as published records and prevents null renter ids from becoming explicit nulls', async () => {
|
||||
await repo.createReview({
|
||||
reservationId: 'reservation_1',
|
||||
companyId: 'company_1',
|
||||
renterId: null,
|
||||
overallRating: 5,
|
||||
vehicleRating: 4,
|
||||
serviceRating: 5,
|
||||
comment: 'Clean car',
|
||||
})
|
||||
|
||||
expect(prismaMock.review.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
reservationId: 'reservation_1',
|
||||
companyId: 'company_1',
|
||||
renterId: undefined,
|
||||
overallRating: 5,
|
||||
vehicleRating: 4,
|
||||
serviceRating: 5,
|
||||
comment: 'Clean car',
|
||||
isPublished: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export async function findPublicOffers() {
|
||||
return prisma.offer.findMany({
|
||||
where: { isPublic: true, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
|
||||
include: { company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, primaryColor: true } } } } },
|
||||
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
|
||||
take: 50,
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCitiesFromCompanies() {
|
||||
return prisma.company.findMany({
|
||||
where: {
|
||||
status: { in: ['ACTIVE', 'TRIALING'] },
|
||||
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
|
||||
},
|
||||
select: { brand: { select: { publicCity: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findListedCompanies(where: any, skip: number, take: number) {
|
||||
return prisma.company.findMany({
|
||||
where,
|
||||
include: {
|
||||
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } },
|
||||
_count: { select: { vehicles: { where: { isPublished: true, status: 'AVAILABLE' } } } },
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
})
|
||||
}
|
||||
|
||||
export async function findPublishedVehicles(where: any) {
|
||||
return prisma.vehicle.findMany({
|
||||
where,
|
||||
include: {
|
||||
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, marketplaceRating: true, primaryColor: true } } } },
|
||||
},
|
||||
orderBy: { dailyRate: 'asc' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findVehicleForMarketplace(vehicleId: string, companySlug: string) {
|
||||
return prisma.vehicle.findFirst({
|
||||
where: { id: vehicleId, isPublished: true, company: { slug: companySlug, status: { in: ['ACTIVE', 'TRIALING'] } } },
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function upsertMarketplaceCustomer(companyId: string, data: {
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
phone?: string
|
||||
dateOfBirth?: string
|
||||
nationality?: string
|
||||
identityDocumentNumber?: string
|
||||
fullAddress?: string
|
||||
driverLicense?: string
|
||||
licenseExpiry?: string
|
||||
licenseIssuedAt?: string
|
||||
licenseCountry?: string
|
||||
licenseCategory?: string
|
||||
internationalLicenseNumber?: string
|
||||
}) {
|
||||
const existing = await prisma.customer.findUnique({
|
||||
where: { companyId_email: { companyId, email: data.email } },
|
||||
select: { id: true, address: true },
|
||||
})
|
||||
|
||||
// Only patch the address JSON with fields that were actually provided
|
||||
const prevAddress = (existing?.address && typeof existing.address === 'object' && !Array.isArray(existing.address))
|
||||
? existing.address as Record<string, unknown>
|
||||
: {}
|
||||
const hasAddressPatch = data.fullAddress || data.identityDocumentNumber || data.internationalLicenseNumber !== undefined
|
||||
const address = hasAddressPatch
|
||||
? {
|
||||
...prevAddress,
|
||||
...(data.fullAddress ? { fullAddress: data.fullAddress } : {}),
|
||||
...(data.identityDocumentNumber ? { identityDocumentNumber: data.identityDocumentNumber } : {}),
|
||||
...(data.internationalLicenseNumber !== undefined ? { internationalLicenseNumber: data.internationalLicenseNumber ?? null } : {}),
|
||||
}
|
||||
: undefined
|
||||
|
||||
const payload = {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
email: data.email,
|
||||
...(data.phone ? { phone: data.phone } : {}),
|
||||
...(data.driverLicense ? { driverLicense: data.driverLicense, licenseNumber: data.driverLicense } : {}),
|
||||
...(data.dateOfBirth ? { dateOfBirth: new Date(data.dateOfBirth) } : {}),
|
||||
...(data.nationality ? { nationality: data.nationality } : {}),
|
||||
...(address !== undefined ? { address } : {}),
|
||||
...(data.licenseExpiry ? { licenseExpiry: new Date(data.licenseExpiry) } : {}),
|
||||
...(data.licenseIssuedAt ? { licenseIssuedAt: new Date(data.licenseIssuedAt) } : {}),
|
||||
...(data.licenseCountry ? { licenseCountry: data.licenseCountry } : {}),
|
||||
...(data.licenseCategory ? { licenseCategory: data.licenseCategory } : {}),
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
return prisma.customer.create({ data: { companyId, ...payload } })
|
||||
}
|
||||
return prisma.customer.update({ where: { id: existing.id }, data: payload })
|
||||
}
|
||||
|
||||
export async function createMarketplaceReservation(data: {
|
||||
companyId: string; vehicleId: string; customerId: string
|
||||
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
|
||||
dailyRate: number; totalDays: number; totalAmount: number; notes?: string
|
||||
}) {
|
||||
return prisma.reservation.create({
|
||||
data: { ...data, source: 'MARKETPLACE', status: 'DRAFT' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCompanyPage(slug: string) {
|
||||
return prisma.company.findFirst({
|
||||
where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
include: {
|
||||
brand: true,
|
||||
vehicles: { where: { isPublished: true, status: 'AVAILABLE' }, orderBy: { createdAt: 'desc' } },
|
||||
offers: { where: { isPublic: true, isActive: true, validUntil: { gte: new Date() } }, orderBy: { isFeatured: 'desc' } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCompanyBySlug(slug: string) {
|
||||
return prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
||||
}
|
||||
|
||||
export async function findCompanyReviews(companyId: string) {
|
||||
return prisma.review.findMany({
|
||||
where: { companyId, isPublished: true },
|
||||
include: { renter: { select: { firstName: true, lastName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCompanyVehicles(companyId: string) {
|
||||
return prisma.vehicle.findMany({
|
||||
where: { companyId, isPublished: true, status: 'AVAILABLE' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findVehicleById(slug: string, vehicleId: string) {
|
||||
const company = await prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
||||
return prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: vehicleId, companyId: company.id, isPublished: true, status: 'AVAILABLE' },
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCompanyOffers(companyId: string) {
|
||||
return prisma.offer.findMany({
|
||||
where: { companyId, isPublic: true, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
|
||||
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
}
|
||||
|
||||
export async function findReservationByReviewToken(token: string) {
|
||||
return prisma.reservation.findUnique({
|
||||
where: { reviewToken: token },
|
||||
include: {
|
||||
vehicle: { select: { make: true, model: true, year: true, photos: true } },
|
||||
company: { include: { brand: { select: { displayName: true, logoUrl: true } } } },
|
||||
review: { select: { id: true } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function findReservationForReviewSubmit(token: string) {
|
||||
return prisma.reservation.findUnique({
|
||||
where: { reviewToken: token },
|
||||
include: { review: { select: { id: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createReview(data: {
|
||||
reservationId: string; companyId: string; renterId?: string | null
|
||||
overallRating: number; vehicleRating?: number; serviceRating?: number; comment?: string
|
||||
}) {
|
||||
return prisma.review.create({
|
||||
data: { ...data, renterId: data.renterId ?? undefined, isPublished: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function invalidateReviewToken(reservationId: string) {
|
||||
return prisma.reservation.update({ where: { id: reservationId }, data: { reviewToken: null } })
|
||||
}
|
||||
|
||||
export async function findOfferByCode(code: string) {
|
||||
return prisma.offer.findFirst({
|
||||
where: { promoCode: code, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Router } from 'express'
|
||||
import { optionalRenterAuth } from '../../middleware/requireRenterAuth'
|
||||
import { parseBody, parseParams, parseQuery } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
|
||||
import * as service from './marketplace.service'
|
||||
import {
|
||||
paginationSchema, searchSchema, companiesQuerySchema, marketplaceReservationSchema,
|
||||
reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema,
|
||||
} from './marketplace.schemas'
|
||||
|
||||
const router = Router()
|
||||
router.use(optionalRenterAuth)
|
||||
|
||||
router.get('/offers', async (_req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getPublicOffers())
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/cities', async (_req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getCities())
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/companies', async (req, res, next) => {
|
||||
try {
|
||||
const { city, hasOffer } = parseQuery(companiesQuerySchema, req)
|
||||
const { page, pageSize } = parseQuery(paginationSchema, req)
|
||||
ok(res, await service.getListedCompanies({ city, hasOffer, page, pageSize }))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/search', async (req, res, next) => {
|
||||
try {
|
||||
const filters = parseQuery(searchSchema, req)
|
||||
const pagination = parseQuery(paginationSchema, req)
|
||||
ok(res, await service.searchVehicles({ ...filters, ...pagination }))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/reservations', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(marketplaceReservationSchema, req)
|
||||
const result = await service.createMarketplaceReservation(body)
|
||||
created(res, result)
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Service temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/review/:token', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = parseParams(reviewTokenSchema, req)
|
||||
ok(res, await service.getReviewContext(token))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/review/:token', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = parseParams(reviewTokenSchema, req)
|
||||
const body = parseBody(reviewBodySchema, req)
|
||||
const result = await service.submitReview(token, body)
|
||||
created(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/offers/:code/validate', async (req, res, next) => {
|
||||
try {
|
||||
const { code } = parseParams(offerCodeParamSchema, req)
|
||||
ok(res, await service.validateOfferCode(code))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Offer validation is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getCompanyPage(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Marketplace data is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/reviews', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getCompanyReviews(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getCompanyVehicles(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { slug, id } = parseParams(vehicleParamSchema, req)
|
||||
ok(res, await service.getVehicleDetail(slug, id))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/offers', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getCompanyOffers(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { marketplaceReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './marketplace.schemas'
|
||||
|
||||
describe('marketplace.schemas', () => {
|
||||
it('coerces pagination and caps untrusted public page sizes', () => {
|
||||
expect(paginationSchema.parse({ page: '2', pageSize: '40' })).toEqual({ page: 2, pageSize: 40 })
|
||||
expect(paginationSchema.parse({})).toEqual({ page: 1, pageSize: 20 })
|
||||
expect(() => paginationSchema.parse({ pageSize: '500' })).toThrow()
|
||||
})
|
||||
|
||||
it('trims search filters and coerces maxPrice', () => {
|
||||
expect(searchSchema.parse({ city: ' Rabat ', maxPrice: '500', dropoffMode: 'different' })).toEqual({
|
||||
city: 'Rabat',
|
||||
maxPrice: 500,
|
||||
dropoffMode: 'different',
|
||||
})
|
||||
expect(() => searchSchema.parse({ dropoffMode: 'teleport' })).toThrow()
|
||||
expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow()
|
||||
})
|
||||
|
||||
it('defaults marketplace reservation language while keeping date and email validation strict', () => {
|
||||
const parsed = marketplaceReservationSchema.parse({
|
||||
vehicleId: 'ckvvehicle000000000000001',
|
||||
companySlug: 'atlas-cars',
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.com',
|
||||
startDate: '2026-07-01T10:00:00.000Z',
|
||||
endDate: '2026-07-03T10:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(parsed.language).toBe('fr')
|
||||
expect(() => marketplaceReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
|
||||
expect(() => marketplaceReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
|
||||
})
|
||||
|
||||
it('validates review ratings as bounded integers', () => {
|
||||
expect(reviewBodySchema.parse({ overallRating: 5, comment: 'Clean car' })).toEqual({ overallRating: 5, comment: 'Clean car' })
|
||||
expect(() => reviewBodySchema.parse({ overallRating: 6 })).toThrow()
|
||||
expect(() => reviewBodySchema.parse({ overallRating: 4.5 })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(10000).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const searchSchema = z.object({
|
||||
city: z.string().trim().max(100).optional(),
|
||||
pickupLocation: z.string().trim().max(100).optional(),
|
||||
dropoffLocation: z.string().trim().max(100).optional(),
|
||||
dropoffMode: z.enum(['same', 'different']).optional(),
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
category: z.string().trim().max(50).optional(),
|
||||
maxPrice: z.coerce.number().int().min(0).max(1_000_000).optional(),
|
||||
transmission: z.string().trim().max(20).optional(),
|
||||
make: z.string().trim().max(60).optional(),
|
||||
model: z.string().trim().max(60).optional(),
|
||||
})
|
||||
|
||||
export const companiesQuerySchema = z.object({
|
||||
city: z.string().trim().max(100).optional(),
|
||||
hasOffer: z.string().optional(),
|
||||
})
|
||||
|
||||
export const marketplaceReservationSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
companySlug: z.string().trim().max(100),
|
||||
firstName: z.string().min(1).max(100),
|
||||
lastName: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
// Contact info — collected at reservation time
|
||||
phone: z.string().min(1).max(30).optional(),
|
||||
// Identity & license — optional at reservation, collected at pickup
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().min(1).max(100).optional(),
|
||||
identityDocumentNumber: z.string().min(1).max(100).optional(),
|
||||
fullAddress: z.string().min(1).max(500).optional(),
|
||||
driverLicense: z.string().min(1).max(50).optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
licenseCountry: z.string().min(1).max(100).optional(),
|
||||
licenseCategory: z.string().min(1).max(20).optional(),
|
||||
internationalLicenseNumber: z.string().trim().max(100).optional(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
pickupLocation: z.string().trim().max(100).optional(),
|
||||
returnLocation: z.string().trim().max(100).optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
||||
})
|
||||
|
||||
export const reviewBodySchema = z.object({
|
||||
overallRating: z.number().int().min(1).max(5),
|
||||
vehicleRating: z.number().int().min(1).max(5).optional(),
|
||||
serviceRating: z.number().int().min(1).max(5).optional(),
|
||||
comment: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
export const slugParamSchema = z.object({ slug: z.string() })
|
||||
export const vehicleParamSchema = z.object({ slug: z.string(), id: z.string() })
|
||||
export const reviewTokenSchema = z.object({ token: z.string() })
|
||||
export const offerCodeParamSchema = z.object({ code: z.string() })
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./marketplace.repo', () => ({
|
||||
findCompanyPage: vi.fn(),
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findCompanyReviews: vi.fn(),
|
||||
findCompanyVehicles: vi.fn(),
|
||||
findVehicleById: vi.fn(),
|
||||
findCompanyOffers: vi.fn(),
|
||||
findOfferByCode: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
|
||||
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn(), sendTransactionalEmail: vi.fn() }))
|
||||
vi.mock('../../lib/emailTranslations', () => ({
|
||||
marketplaceReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
|
||||
}))
|
||||
|
||||
import * as repo from './marketplace.repo'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './marketplace.service'
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe('marketplace.service public page edges', () => {
|
||||
it('enriches company page vehicles with availability without changing company metadata', async () => {
|
||||
const nextAvailableAt = new Date('2026-07-14T09:00:00.000Z')
|
||||
vi.mocked(repo.findCompanyPage).mockResolvedValue({
|
||||
id: 'company_1',
|
||||
name: 'Atlas Cars',
|
||||
vehicles: [{ id: 'vehicle_1', make: 'Dacia' }, { id: 'vehicle_2', make: 'Renault' }],
|
||||
} as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary)
|
||||
.mockResolvedValueOnce({ available: false, status: 'RESERVED', nextAvailableAt, blockingReason: 'RESERVATION' })
|
||||
.mockResolvedValueOnce({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
|
||||
const result = await getCompanyPage('atlas-cars')
|
||||
|
||||
expect(result).toMatchObject({ id: 'company_1', name: 'Atlas Cars' })
|
||||
expect(result.vehicles).toEqual([
|
||||
expect.objectContaining({ id: 'vehicle_1', availability: false, availabilityStatus: 'RESERVED', nextAvailableAt }),
|
||||
expect.objectContaining({ id: 'vehicle_2', availability: true, availabilityStatus: 'AVAILABLE', nextAvailableAt: null }),
|
||||
])
|
||||
})
|
||||
|
||||
it('throws when a public company page cannot be found', async () => {
|
||||
vi.mocked(repo.findCompanyPage).mockResolvedValue(null)
|
||||
await expect(getCompanyPage('missing-company')).rejects.toThrow('Company not found')
|
||||
})
|
||||
|
||||
it('resolves public company child resources through the company id, not the slug directly', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue({ id: 'company_1' } as any)
|
||||
vi.mocked(repo.findCompanyReviews).mockResolvedValue([{ id: 'review_1' }] as any)
|
||||
vi.mocked(repo.findCompanyVehicles).mockResolvedValue([{ id: 'vehicle_1' }] as any)
|
||||
vi.mocked(repo.findCompanyOffers).mockResolvedValue([{ id: 'offer_1' }] as any)
|
||||
|
||||
await expect(getCompanyReviews('atlas-cars')).resolves.toEqual([{ id: 'review_1' }])
|
||||
await expect(getCompanyVehicles('atlas-cars')).resolves.toEqual([{ id: 'vehicle_1' }])
|
||||
await expect(getCompanyOffers('atlas-cars')).resolves.toEqual([{ id: 'offer_1' }])
|
||||
|
||||
expect(repo.findCompanyReviews).toHaveBeenCalledWith('company_1')
|
||||
expect(repo.findCompanyVehicles).toHaveBeenCalledWith('company_1')
|
||||
expect(repo.findCompanyOffers).toHaveBeenCalledWith('company_1')
|
||||
})
|
||||
|
||||
it('adds availability to vehicle detail responses', async () => {
|
||||
const nextAvailableAt = new Date('2026-12-01T00:00:00.000Z')
|
||||
vi.mocked(repo.findVehicleById).mockResolvedValue({ id: 'vehicle_1', make: 'Hyundai' } as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'MAINTENANCE', nextAvailableAt, blockingReason: 'BLOCK' })
|
||||
|
||||
await expect(getVehicleDetail('atlas-cars', 'vehicle_1')).resolves.toEqual(expect.objectContaining({
|
||||
id: 'vehicle_1',
|
||||
availability: false,
|
||||
availabilityStatus: 'MAINTENANCE',
|
||||
nextAvailableAt,
|
||||
}))
|
||||
})
|
||||
|
||||
it('rejects exhausted offer codes before returning them as valid', async () => {
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue({ code: 'SUMMER', maxRedemptions: 10, redemptionCount: 10 } as any)
|
||||
|
||||
await expect(validateOfferCode('SUMMER')).rejects.toThrow('maximum redemptions')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,292 @@
|
||||
import { AppError, NotFoundError, ConflictError } from '../../http/errors'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
|
||||
import { marketplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
|
||||
import * as repo from './marketplace.repo'
|
||||
|
||||
function normalizeLocation(value?: string | null) {
|
||||
const normalized = value?.trim()
|
||||
return normalized ? normalized : null
|
||||
}
|
||||
|
||||
function normalizeLocationList(values?: string[] | null) {
|
||||
if (!Array.isArray(values)) return []
|
||||
return values
|
||||
.map((value) => normalizeLocation(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
}
|
||||
|
||||
function includesLocation(values: string[], target: string) {
|
||||
const key = target.toLocaleLowerCase()
|
||||
return values.some((value) => value.toLocaleLowerCase() === key)
|
||||
}
|
||||
|
||||
function matchesVehicleLocationRules(
|
||||
vehicle: { pickupLocations?: string[]; dropoffLocations?: string[]; allowDifferentDropoff?: boolean },
|
||||
params: { pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different' },
|
||||
) {
|
||||
const pickupLocation = normalizeLocation(params.pickupLocation)
|
||||
const dropoffLocation = normalizeLocation(params.dropoffLocation)
|
||||
const pickupLocations = normalizeLocationList(vehicle.pickupLocations)
|
||||
const dropoffLocations = normalizeLocationList(vehicle.dropoffLocations)
|
||||
|
||||
if (pickupLocation && pickupLocations.length > 0 && !includesLocation(pickupLocations, pickupLocation)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (params.dropoffMode === 'different') {
|
||||
if (!vehicle.allowDifferentDropoff) return false
|
||||
if (dropoffLocation && dropoffLocations.length > 0 && !includesLocation(dropoffLocations, dropoffLocation)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export async function getPublicOffers() {
|
||||
return repo.findPublicOffers()
|
||||
}
|
||||
|
||||
export async function getCities() {
|
||||
const rows = await repo.findCitiesFromCompanies()
|
||||
const map = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
const city = row.brand?.publicCity?.trim()
|
||||
if (!city) continue
|
||||
const key = city.toLocaleLowerCase()
|
||||
if (!map.has(key)) map.set(key, city)
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||||
}
|
||||
|
||||
export async function getListedCompanies(params: {
|
||||
city?: string; hasOffer?: string; page?: number; pageSize?: number
|
||||
}) {
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const where: any = { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } }
|
||||
if (params.city) where.brand = { ...where.brand, publicCity: { contains: params.city, mode: 'insensitive' } }
|
||||
if (params.hasOffer === 'true') {
|
||||
where.offers = { some: { isPublic: true, isActive: true, validUntil: { gte: new Date() } } }
|
||||
}
|
||||
return repo.findListedCompanies(where, (page - 1) * pageSize, pageSize)
|
||||
}
|
||||
|
||||
export async function searchVehicles(params: {
|
||||
city?: string; pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different'
|
||||
startDate?: string; endDate?: string; category?: string
|
||||
maxPrice?: number; transmission?: string; make?: string; model?: string
|
||||
page?: number; pageSize?: number
|
||||
}) {
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const where: any = {
|
||||
isPublished: true,
|
||||
status: 'AVAILABLE',
|
||||
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } },
|
||||
}
|
||||
if (params.category) where.category = params.category
|
||||
if (params.maxPrice !== undefined) where.dailyRate = { lte: params.maxPrice }
|
||||
if (params.transmission) where.transmission = params.transmission
|
||||
if (params.make) where.make = { contains: params.make, mode: 'insensitive' }
|
||||
if (params.model) where.model = { contains: params.model, mode: 'insensitive' }
|
||||
if (params.city) {
|
||||
where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: params.city, mode: 'insensitive' } } }
|
||||
}
|
||||
|
||||
const vehicles = await repo.findPublishedVehicles(where) as any[]
|
||||
const locationFiltered = vehicles.filter((vehicle: any) => matchesVehicleLocationRules(vehicle, params))
|
||||
const pagedVehicles = locationFiltered.slice((page - 1) * pageSize, page * pageSize)
|
||||
|
||||
const availability = await Promise.all(
|
||||
pagedVehicles.map(async (v: any) => {
|
||||
const a = await getVehicleAvailabilitySummary(v.id, {
|
||||
companyId: v.companyId,
|
||||
range: params.startDate && params.endDate
|
||||
? { startDate: new Date(params.startDate), endDate: new Date(params.endDate) }
|
||||
: undefined,
|
||||
})
|
||||
return [v.id, a] as const
|
||||
}),
|
||||
)
|
||||
const availMap = new Map<string, any>(availability)
|
||||
|
||||
return pagedVehicles.map((v: any) => ({
|
||||
...v,
|
||||
availability: availMap.get(v.id)?.available ?? null,
|
||||
availabilityStatus: availMap.get(v.id)?.status ?? 'AVAILABLE',
|
||||
nextAvailableAt: availMap.get(v.id)?.nextAvailableAt ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function createMarketplaceReservation(body: {
|
||||
vehicleId: string; companySlug: string; firstName: string; lastName: string
|
||||
email: string; phone?: string; dateOfBirth?: string; nationality?: string
|
||||
identityDocumentNumber?: string; fullAddress?: string
|
||||
driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string
|
||||
licenseCountry?: string; licenseCategory?: string; internationalLicenseNumber?: string
|
||||
startDate: string; endDate: string
|
||||
pickupLocation?: string; returnLocation?: string; notes?: string; language?: string
|
||||
}) {
|
||||
const startDate = new Date(body.startDate)
|
||||
const endDate = new Date(body.endDate)
|
||||
|
||||
if (endDate <= startDate) {
|
||||
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
}
|
||||
|
||||
const vehicle = await repo.findVehicleForMarketplace(body.vehicleId, body.companySlug)
|
||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||
|
||||
const pickupLocation = normalizeLocation(body.pickupLocation)
|
||||
const returnLocation = normalizeLocation(body.returnLocation) ?? pickupLocation
|
||||
const pickupLocations = normalizeLocationList(vehicle.pickupLocations)
|
||||
const dropoffLocations = normalizeLocationList(vehicle.dropoffLocations)
|
||||
|
||||
if (pickupLocation && pickupLocations.length > 0 && !includesLocation(pickupLocations, pickupLocation)) {
|
||||
throw new AppError('Selected pickup location is not available for this vehicle', 400, 'invalid_pickup_location')
|
||||
}
|
||||
|
||||
if (pickupLocation && returnLocation && pickupLocation.toLocaleLowerCase() !== returnLocation.toLocaleLowerCase()) {
|
||||
if (!vehicle.allowDifferentDropoff) {
|
||||
throw new AppError('This vehicle must be returned to the same pickup location', 400, 'same_dropoff_required')
|
||||
}
|
||||
if (dropoffLocations.length > 0 && !includesLocation(dropoffLocations, returnLocation)) {
|
||||
throw new AppError('Selected return location is not available for this vehicle', 400, 'invalid_return_location')
|
||||
}
|
||||
}
|
||||
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId, range: { startDate, endDate } })
|
||||
if (!availability.available) {
|
||||
throw new AppError('Vehicle is not available for the selected dates', 409, 'unavailable', {
|
||||
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const customer = await repo.upsertMarketplaceCustomer(vehicle.companyId, {
|
||||
email: body.email,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
phone: body.phone,
|
||||
dateOfBirth: body.dateOfBirth,
|
||||
nationality: body.nationality,
|
||||
identityDocumentNumber: body.identityDocumentNumber,
|
||||
fullAddress: body.fullAddress,
|
||||
driverLicense: body.driverLicense,
|
||||
licenseExpiry: body.licenseExpiry,
|
||||
licenseIssuedAt: body.licenseIssuedAt,
|
||||
licenseCountry: body.licenseCountry,
|
||||
licenseCategory: body.licenseCategory,
|
||||
internationalLicenseNumber: body.internationalLicenseNumber,
|
||||
})
|
||||
|
||||
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
|
||||
const totalAmount = vehicle.dailyRate * totalDays
|
||||
|
||||
const reservation = await repo.createMarketplaceReservation({
|
||||
companyId: vehicle.companyId, vehicleId: body.vehicleId, customerId: customer.id,
|
||||
startDate, endDate, pickupLocation, returnLocation, dailyRate: vehicle.dailyRate, totalDays, totalAmount, notes: body.notes,
|
||||
})
|
||||
|
||||
const lang = (body.language ?? 'fr') as Lang
|
||||
const companyName = vehicle.company.brand?.displayName ?? (vehicle.company as any).name
|
||||
const emailOpts = {
|
||||
firstName: body.firstName, vehicleYear: vehicle.year, vehicleMake: vehicle.make, vehicleModel: vehicle.model,
|
||||
companyName, startDate, endDate, totalDays,
|
||||
rateDisplay: (vehicle.dailyRate / 100).toFixed(2),
|
||||
totalDisplay: (totalAmount / 100).toFixed(2),
|
||||
email: body.email, phone: body.phone,
|
||||
}
|
||||
|
||||
sendTransactionalEmail({
|
||||
to: body.email,
|
||||
subject: marketplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
|
||||
html: marketplaceReservationEmail.html(emailOpts, lang),
|
||||
text: marketplaceReservationEmail.text(emailOpts, lang),
|
||||
}).catch(() => null)
|
||||
|
||||
sendNotification({
|
||||
type: 'NEW_BOOKING',
|
||||
title: 'New Marketplace Reservation Request',
|
||||
body: `${body.firstName} ${body.lastName} requested ${vehicle.make} ${vehicle.model} from ${startDate.toISOString().slice(0, 10)} to ${endDate.toISOString().slice(0, 10)}.`,
|
||||
companyId: vehicle.companyId,
|
||||
channels: ['IN_APP'],
|
||||
}).catch(() => null)
|
||||
|
||||
return { reservationId: reservation.id }
|
||||
}
|
||||
|
||||
export async function getCompanyPage(slug: string) {
|
||||
const company = await repo.findCompanyPage(slug)
|
||||
if (!company) throw new NotFoundError('Company not found')
|
||||
|
||||
const vehicles = await Promise.all(
|
||||
(company.vehicles as any[]).map(async (v: any) => {
|
||||
const a = await getVehicleAvailabilitySummary(v.id, { companyId: v.companyId })
|
||||
return { ...v, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
|
||||
}),
|
||||
)
|
||||
return { ...company, vehicles }
|
||||
}
|
||||
|
||||
export async function getCompanyReviews(slug: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
return repo.findCompanyReviews(company.id)
|
||||
}
|
||||
|
||||
export async function getCompanyVehicles(slug: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
return repo.findCompanyVehicles(company.id)
|
||||
}
|
||||
|
||||
export async function getVehicleDetail(slug: string, vehicleId: string) {
|
||||
const vehicle = await repo.findVehicleById(slug, vehicleId)
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId })
|
||||
return { ...vehicle, availability: availability.available, availabilityStatus: availability.status, nextAvailableAt: availability.nextAvailableAt }
|
||||
}
|
||||
|
||||
export async function getCompanyOffers(slug: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
return repo.findCompanyOffers(company.id)
|
||||
}
|
||||
|
||||
export async function getReviewContext(token: string) {
|
||||
const reservation = await repo.findReservationByReviewToken(token)
|
||||
if (!reservation) throw new NotFoundError('Review link is invalid or has expired')
|
||||
if (reservation.review) throw new ConflictError('A review has already been submitted for this reservation')
|
||||
|
||||
return {
|
||||
reservationId: reservation.id,
|
||||
companyName: reservation.company.brand?.displayName ?? (reservation.company as any).name,
|
||||
companyLogoUrl: reservation.company.brand?.logoUrl ?? null,
|
||||
vehicle: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`,
|
||||
vehiclePhoto: reservation.vehicle.photos[0] ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitReview(token: string, body: {
|
||||
overallRating: number; vehicleRating?: number; serviceRating?: number; comment?: string
|
||||
}) {
|
||||
const reservation = await repo.findReservationForReviewSubmit(token)
|
||||
if (!reservation) throw new NotFoundError('Review link is invalid or has expired')
|
||||
if (reservation.review) throw new ConflictError('A review has already been submitted for this reservation')
|
||||
|
||||
const review = await repo.createReview({
|
||||
reservationId: reservation.id,
|
||||
companyId: reservation.companyId,
|
||||
renterId: reservation.renterId,
|
||||
...body,
|
||||
})
|
||||
await repo.invalidateReviewToken(reservation.id)
|
||||
return { reviewId: review.id }
|
||||
}
|
||||
|
||||
export async function validateOfferCode(code: string) {
|
||||
const offer = await repo.findOfferByCode(code)
|
||||
if (!offer) throw new NotFoundError('Promo code not found or expired')
|
||||
if (offer.maxRedemptions && offer.redemptionCount >= offer.maxRedemptions) {
|
||||
throw new ConflictError('Promo code has reached its maximum redemptions')
|
||||
}
|
||||
return offer
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NotFoundError, ConflictError, AppError } from '../../http/errors'
|
||||
|
||||
vi.mock('./marketplace.repo', () => ({
|
||||
findPublicOffers: vi.fn(),
|
||||
findCitiesFromCompanies: vi.fn(),
|
||||
findListedCompanies: vi.fn(),
|
||||
findPublishedVehicles: vi.fn(),
|
||||
findVehicleForMarketplace: vi.fn(),
|
||||
upsertMarketplaceCustomer: vi.fn(),
|
||||
createMarketplaceReservation: vi.fn(),
|
||||
findCompanyPage: vi.fn(),
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findCompanyReviews: vi.fn(),
|
||||
findCompanyVehicles: vi.fn(),
|
||||
findVehicleById: vi.fn(),
|
||||
findCompanyOffers: vi.fn(),
|
||||
findReservationByReviewToken: vi.fn(),
|
||||
findReservationForReviewSubmit: vi.fn(),
|
||||
createReview: vi.fn(),
|
||||
invalidateReviewToken: vi.fn(),
|
||||
findOfferByCode: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/vehicleAvailabilityService', () => ({
|
||||
getVehicleAvailabilitySummary: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/notificationService', () => ({
|
||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
||||
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/emailTranslations', () => ({
|
||||
marketplaceReservationEmail: {
|
||||
subject: vi.fn(() => 'Subject'),
|
||||
html: vi.fn(() => '<html>'),
|
||||
text: vi.fn(() => 'text'),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as repo from './marketplace.repo'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import {
|
||||
getCities, searchVehicles, createMarketplaceReservation,
|
||||
getReviewContext, submitReview, validateOfferCode,
|
||||
} from './marketplace.service'
|
||||
|
||||
const SLUG = 'test-company'
|
||||
|
||||
function makeVehicle(overrides: object = {}) {
|
||||
return {
|
||||
id: 'v-1', dailyRate: 10000, year: 2022, make: 'Toyota', model: 'Camry',
|
||||
isPublished: true, status: 'AVAILABLE', companyId: 'co-1',
|
||||
pickupLocations: ['Casablanca'],
|
||||
allowDifferentDropoff: false,
|
||||
dropoffLocations: [],
|
||||
company: { name: 'Test Co', brand: { displayName: 'Test Co' } },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('getCities', () => {
|
||||
it('deduplicates and sorts cities case-insensitively', async () => {
|
||||
vi.mocked(repo.findCitiesFromCompanies).mockResolvedValue([
|
||||
{ brand: { publicCity: 'Casablanca' } },
|
||||
{ brand: { publicCity: 'casablanca' } },
|
||||
{ brand: { publicCity: 'Rabat' } },
|
||||
{ brand: null },
|
||||
] as any)
|
||||
|
||||
const result = await getCities()
|
||||
expect(result).toEqual(['Casablanca', 'Rabat'])
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('searchVehicles', () => {
|
||||
it('returns vehicles enriched with availability data', async () => {
|
||||
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
|
||||
const result = await searchVehicles({ city: 'Casablanca' })
|
||||
|
||||
expect(repo.findPublishedVehicles).toHaveBeenCalledOnce()
|
||||
expect(result[0].availability).toBe(true)
|
||||
expect(result[0].availabilityStatus).toBe('AVAILABLE')
|
||||
})
|
||||
|
||||
it('passes date range to availability service when provided', async () => {
|
||||
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: new Date('2025-07-01'), blockingReason: 'RESERVATION' })
|
||||
|
||||
const result = await searchVehicles({ startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z' })
|
||||
|
||||
expect(getVehicleAvailabilitySummary).toHaveBeenCalledWith('v-1', {
|
||||
companyId: 'co-1',
|
||||
range: { startDate: new Date('2025-06-01T00:00:00.000Z'), endDate: new Date('2025-06-04T00:00:00.000Z') },
|
||||
})
|
||||
expect(result[0].availability).toBe(false)
|
||||
})
|
||||
|
||||
it('filters out vehicles that do not support different drop-off when requested', async () => {
|
||||
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([
|
||||
makeVehicle({ id: 'same-only' }),
|
||||
makeVehicle({ id: 'one-way', allowDifferentDropoff: true, dropoffLocations: ['Rabat'] }),
|
||||
] as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
|
||||
const result = await searchVehicles({ pickupLocation: 'Casablanca', dropoffLocation: 'Rabat', dropoffMode: 'different' })
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('one-way')
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('createMarketplaceReservation', () => {
|
||||
const baseBody = {
|
||||
vehicleId: 'v-1', companySlug: SLUG,
|
||||
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
||||
phone: '+212600000000',
|
||||
dateOfBirth: '1990-01-01T00:00:00.000Z',
|
||||
nationality: 'Moroccan',
|
||||
identityDocumentNumber: 'CIN123456',
|
||||
fullAddress: '123 Avenue Hassan II, Casablanca',
|
||||
driverLicense: 'DL-123456',
|
||||
licenseExpiry: '2030-01-01T00:00:00.000Z',
|
||||
licenseIssuedAt: '2015-01-01T00:00:00.000Z',
|
||||
licenseCountry: 'Morocco',
|
||||
licenseCategory: 'B',
|
||||
startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z',
|
||||
}
|
||||
|
||||
it('creates reservation and returns reservationId', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
vi.mocked(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
||||
vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1' } as any)
|
||||
|
||||
const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
|
||||
expect(result.reservationId).toBe('r-1')
|
||||
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
|
||||
identityDocumentNumber: 'CIN123456',
|
||||
fullAddress: '123 Avenue Hassan II, Casablanca',
|
||||
driverLicense: 'DL-123456',
|
||||
}))
|
||||
expect(repo.createMarketplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
}))
|
||||
})
|
||||
|
||||
it('throws NotFoundError when vehicle not found', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(null)
|
||||
|
||||
await expect(createMarketplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
it('throws AppError when vehicle is unavailable', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
|
||||
|
||||
await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
||||
})
|
||||
|
||||
it('rejects different return locations when the vehicle requires same drop-off', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
|
||||
|
||||
await expect(
|
||||
createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
|
||||
).rejects.toMatchObject({ error: 'same_dropoff_required' })
|
||||
})
|
||||
|
||||
it('throws AppError for invalid date range', async () => {
|
||||
await expect(
|
||||
createMarketplaceReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
|
||||
).rejects.toMatchObject({ error: 'invalid_dates' })
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('getReviewContext', () => {
|
||||
it('returns review context for a valid unreviewd token', async () => {
|
||||
vi.mocked(repo.findReservationByReviewToken).mockResolvedValue({
|
||||
id: 'r-1', companyId: 'co-1', renterId: null, reviewToken: 'tok',
|
||||
vehicle: { year: 2022, make: 'Toyota', model: 'Camry', photos: ['photo.jpg'] },
|
||||
company: { name: 'Test Co', brand: { displayName: 'Test Co', logoUrl: 'logo.png' } },
|
||||
review: null,
|
||||
} as any)
|
||||
|
||||
const result = await getReviewContext('tok')
|
||||
expect(result.reservationId).toBe('r-1')
|
||||
expect(result.vehiclePhoto).toBe('photo.jpg')
|
||||
})
|
||||
|
||||
it('throws NotFoundError for unknown token', async () => {
|
||||
vi.mocked(repo.findReservationByReviewToken).mockResolvedValue(null)
|
||||
await expect(getReviewContext('bad')).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
it('throws ConflictError when already reviewed', async () => {
|
||||
vi.mocked(repo.findReservationByReviewToken).mockResolvedValue({
|
||||
id: 'r-1', companyId: 'co-1', renterId: null,
|
||||
vehicle: { year: 2022, make: 'Toyota', model: 'Camry', photos: [] },
|
||||
company: { name: 'Test Co', brand: null },
|
||||
review: { id: 'rev-1' },
|
||||
} as any)
|
||||
|
||||
await expect(getReviewContext('tok')).rejects.toBeInstanceOf(ConflictError)
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('submitReview', () => {
|
||||
it('creates review and invalidates token', async () => {
|
||||
vi.mocked(repo.findReservationForReviewSubmit).mockResolvedValue({ id: 'r-1', companyId: 'co-1', renterId: null, review: null } as any)
|
||||
vi.mocked(repo.createReview).mockResolvedValue({ id: 'rev-1' } as any)
|
||||
vi.mocked(repo.invalidateReviewToken).mockResolvedValue(undefined as any)
|
||||
|
||||
const result = await submitReview('tok', { overallRating: 5 })
|
||||
|
||||
expect(repo.createReview).toHaveBeenCalledOnce()
|
||||
expect(repo.invalidateReviewToken).toHaveBeenCalledWith('r-1')
|
||||
expect(result.reviewId).toBe('rev-1')
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('validateOfferCode', () => {
|
||||
it('returns offer when code is valid and not exhausted', async () => {
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue({ id: 'o-1', maxRedemptions: null, redemptionCount: 0 } as any)
|
||||
const result = await validateOfferCode('PROMO10')
|
||||
expect((result as any).id).toBe('o-1')
|
||||
})
|
||||
|
||||
it('throws NotFoundError for unknown code', async () => {
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue(null)
|
||||
await expect(validateOfferCode('BAD')).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
it('throws ConflictError when max redemptions reached', async () => {
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue({ id: 'o-1', maxRedemptions: 10, redemptionCount: 10 } as any)
|
||||
await expect(validateOfferCode('PROMO10')).rejects.toBeInstanceOf(ConflictError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
menuItem: {
|
||||
findUnique: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
menuItemRoleVisibility: { deleteMany: vi.fn(), createMany: vi.fn() },
|
||||
subscriptionMenuItem: { deleteMany: vi.fn(), createMany: vi.fn() },
|
||||
companyMenuItem: { deleteMany: vi.fn(), createMany: vi.fn() },
|
||||
company: { findMany: vi.fn(), findUniqueOrThrow: vi.fn() },
|
||||
employee: { findUniqueOrThrow: vi.fn() },
|
||||
auditLog: { create: vi.fn(), findMany: vi.fn(), count: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import {
|
||||
assignMenuItemToCompanies,
|
||||
assignMenuItemToPlans,
|
||||
createMenuItem,
|
||||
listMenuAuditLogs,
|
||||
setMenuItemStatus,
|
||||
} from './menu.service'
|
||||
|
||||
const admin = { id: 'admin_1', role: 'ADMIN' }
|
||||
|
||||
describe('menu.service mutation boundaries', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(prisma.menuItem.findFirst).mockResolvedValue(null as never)
|
||||
vi.mocked(prisma.menuItem.findUnique).mockResolvedValue(null as never)
|
||||
vi.mocked(prisma.company.findMany).mockResolvedValue([] as never)
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => callback(prisma))
|
||||
})
|
||||
|
||||
it('rejects external links that are not HTTPS before any write', async () => {
|
||||
await expect(createMenuItem({
|
||||
label: 'Docs',
|
||||
itemType: 'EXTERNAL_LINK',
|
||||
routeOrUrl: 'http://docs.example.test',
|
||||
}, admin)).rejects.toMatchObject({ statusCode: 400, code: 'validation_error' })
|
||||
|
||||
expect(prisma.menuItem.create).not.toHaveBeenCalled()
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects duplicate routes under the same parent with a conflict error', async () => {
|
||||
vi.mocked(prisma.menuItem.findFirst).mockResolvedValue({ id: 'item_existing' } as never)
|
||||
|
||||
await expect(createMenuItem({
|
||||
label: 'Fleet',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '/fleet',
|
||||
parentId: 'parent_1',
|
||||
}, admin)).rejects.toMatchObject({ statusCode: 409, code: 'duplicate_menu_route' })
|
||||
})
|
||||
|
||||
it('normalizes create payloads and syncs role, plan and company assignments transactionally', async () => {
|
||||
vi.mocked(prisma.company.findMany).mockResolvedValue([{ id: 'company_1' }] as never)
|
||||
vi.mocked(prisma.menuItem.create).mockResolvedValue({ id: 'item_1' } as never)
|
||||
vi.mocked(prisma.menuItem.findUniqueOrThrow).mockResolvedValue({ id: 'item_1', label: 'Fleet' } as never)
|
||||
|
||||
await createMenuItem({
|
||||
systemKey: ' fleet ',
|
||||
label: ' Fleet ',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: ' /fleet ',
|
||||
icon: ' Car ',
|
||||
displayOrder: 7,
|
||||
roles: ['MANAGER', 'MANAGER', 'AGENT'],
|
||||
subscriptionPlans: [{ plan: 'GROWTH' }, { plan: 'PRO', displayOrder: 3, isActive: false }],
|
||||
companyAssignments: [{ companyId: 'company_1' }],
|
||||
}, admin)
|
||||
|
||||
expect(prisma.menuItem.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
systemKey: 'fleet',
|
||||
label: 'Fleet',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '/fleet',
|
||||
icon: 'Car',
|
||||
displayOrder: 7,
|
||||
openInNewTab: false,
|
||||
isRequired: false,
|
||||
isActive: true,
|
||||
createdBy: 'admin_1',
|
||||
updatedBy: 'admin_1',
|
||||
}),
|
||||
})
|
||||
expect(prisma.menuItemRoleVisibility.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ menuItemId: 'item_1', role: 'MANAGER' },
|
||||
{ menuItemId: 'item_1', role: 'AGENT' },
|
||||
],
|
||||
})
|
||||
expect(prisma.subscriptionMenuItem.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ menuItemId: 'item_1', plan: 'GROWTH', displayOrder: 7, isActive: true },
|
||||
{ menuItemId: 'item_1', plan: 'PRO', displayOrder: 3, isActive: false },
|
||||
],
|
||||
})
|
||||
expect(prisma.companyMenuItem.createMany).toHaveBeenCalledWith({
|
||||
data: [{ menuItemId: 'item_1', companyId: 'company_1', displayOrder: 7, isActive: true }],
|
||||
})
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: 'CREATE_MENU_ITEM', resource: 'MenuItem', resourceId: 'item_1' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('protects required menu items from non-super-admin disable attempts', async () => {
|
||||
vi.mocked(prisma.menuItem.findUniqueOrThrow).mockResolvedValue({ id: 'item_core', isRequired: true, isActive: true } as never)
|
||||
|
||||
await expect(setMenuItemStatus('item_core', false, admin)).rejects.toMatchObject({ statusCode: 403, code: 'forbidden' })
|
||||
|
||||
expect(prisma.menuItem.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replaces only requested plan assignments and writes an audit entry', async () => {
|
||||
vi.mocked(prisma.menuItem.findUniqueOrThrow).mockResolvedValue({ id: 'item_1' } as never)
|
||||
|
||||
await assignMenuItemToPlans('item_1', [{ plan: 'STARTER' }, { plan: 'PRO', displayOrder: 4, isActive: false }], admin)
|
||||
|
||||
expect(prisma.subscriptionMenuItem.deleteMany).toHaveBeenCalledWith({
|
||||
where: { menuItemId: 'item_1', plan: { in: ['STARTER', 'PRO'] } },
|
||||
})
|
||||
expect(prisma.subscriptionMenuItem.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ menuItemId: 'item_1', plan: 'STARTER', displayOrder: 0, isActive: true },
|
||||
{ menuItemId: 'item_1', plan: 'PRO', displayOrder: 4, isActive: false },
|
||||
],
|
||||
})
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: 'ASSIGN_MENU_ITEM_TO_SUBSCRIPTIONS', resource: 'MenuSubscriptionAssignment' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects company assignments when any company is cancelled or missing', async () => {
|
||||
vi.mocked(prisma.menuItem.findUniqueOrThrow).mockResolvedValue({ id: 'item_1' } as never)
|
||||
vi.mocked(prisma.company.findMany).mockResolvedValue([{ id: 'company_1' }] as never)
|
||||
|
||||
await expect(assignMenuItemToCompanies('item_1', [
|
||||
{ companyId: 'company_1' },
|
||||
{ companyId: 'company_cancelled' },
|
||||
], admin)).rejects.toMatchObject({ statusCode: 400, code: 'validation_error' })
|
||||
|
||||
expect(prisma.companyMenuItem.deleteMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('paginates menu audit logs across menu-related resources only', async () => {
|
||||
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([{ id: 'log_1' }] as never)
|
||||
vi.mocked(prisma.auditLog.count).mockResolvedValue(41 as never)
|
||||
|
||||
const result = await listMenuAuditLogs({ page: 3, pageSize: 20 })
|
||||
|
||||
expect(prisma.auditLog.findMany).toHaveBeenCalledWith({
|
||||
where: { resource: { in: ['MenuItem', 'MenuSubscriptionAssignment', 'MenuCompanyAssignment', 'MenuRoleVisibility'] } },
|
||||
include: { adminUser: { select: { firstName: true, lastName: true, email: true } } },
|
||||
skip: 40,
|
||||
take: 20,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
expect(result).toEqual({ data: [{ id: 'log_1' }], total: 41, page: 3, pageSize: 20, totalPages: 3 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
menuItem: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
employee: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
},
|
||||
company: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { getEmployeeMenu, previewCompanyMenu } from './menu.service'
|
||||
|
||||
describe('menu.service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('builds the employee menu from subscription defaults and company overrides', async () => {
|
||||
vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({
|
||||
id: 'employee_1',
|
||||
role: 'MANAGER',
|
||||
isActive: true,
|
||||
companyId: 'company_1',
|
||||
} as never)
|
||||
|
||||
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({
|
||||
id: 'company_1',
|
||||
name: 'Atlas Cars',
|
||||
status: 'ACTIVE',
|
||||
subscription: { plan: 'GROWTH', status: 'ACTIVE' },
|
||||
} as never)
|
||||
|
||||
vi.mocked(prisma.menuItem.findMany).mockResolvedValue([
|
||||
{
|
||||
id: 'item_dashboard',
|
||||
systemKey: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '/',
|
||||
icon: 'LayoutDashboard',
|
||||
parentId: null,
|
||||
openInNewTab: false,
|
||||
isRequired: true,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
roleVisibilities: [{ role: 'MANAGER' }],
|
||||
subscriptionAssignments: [],
|
||||
companyAssignments: [],
|
||||
},
|
||||
{
|
||||
id: 'item_reports',
|
||||
systemKey: 'reports',
|
||||
label: 'Reports',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '/reports',
|
||||
icon: 'BarChart2',
|
||||
parentId: null,
|
||||
openInNewTab: false,
|
||||
isRequired: false,
|
||||
isActive: true,
|
||||
displayOrder: 80,
|
||||
roleVisibilities: [{ role: 'MANAGER' }],
|
||||
subscriptionAssignments: [{ plan: 'GROWTH', displayOrder: 80, isActive: true }],
|
||||
companyAssignments: [],
|
||||
},
|
||||
{
|
||||
id: 'item_finance',
|
||||
systemKey: null,
|
||||
label: 'Finance Portal',
|
||||
itemType: 'EXTERNAL_LINK',
|
||||
routeOrUrl: 'https://finance.example.com',
|
||||
icon: null,
|
||||
parentId: null,
|
||||
openInNewTab: true,
|
||||
isRequired: false,
|
||||
isActive: true,
|
||||
displayOrder: 200,
|
||||
roleVisibilities: [{ role: 'MANAGER' }],
|
||||
subscriptionAssignments: [],
|
||||
companyAssignments: [{ companyId: 'company_1', displayOrder: 5, isActive: true }],
|
||||
},
|
||||
{
|
||||
id: 'item_owner_only',
|
||||
systemKey: 'settings',
|
||||
label: 'Settings',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '/settings',
|
||||
icon: 'Settings',
|
||||
parentId: null,
|
||||
openInNewTab: false,
|
||||
isRequired: false,
|
||||
isActive: true,
|
||||
displayOrder: 150,
|
||||
roleVisibilities: [{ role: 'OWNER' }],
|
||||
subscriptionAssignments: [{ plan: 'GROWTH', displayOrder: 150, isActive: true }],
|
||||
companyAssignments: [],
|
||||
},
|
||||
] as never)
|
||||
|
||||
const result = await getEmployeeMenu('employee_1')
|
||||
|
||||
expect(result.items).toHaveLength(3)
|
||||
expect(result.items.map((item) => item.label)).toEqual(['Dashboard', 'Finance Portal', 'Reports'])
|
||||
expect(result.items[0]).toMatchObject({
|
||||
label: 'Dashboard',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
})
|
||||
expect(result.items[1]).toMatchObject({
|
||||
label: 'Finance Portal',
|
||||
itemType: 'EXTERNAL_LINK',
|
||||
openInNewTab: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('explains hidden items in preview results', async () => {
|
||||
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({
|
||||
id: 'company_1',
|
||||
name: 'Atlas Cars',
|
||||
status: 'ACTIVE',
|
||||
subscription: { plan: 'STARTER', status: 'ACTIVE' },
|
||||
} as never)
|
||||
|
||||
vi.mocked(prisma.menuItem.findMany).mockResolvedValue([
|
||||
{
|
||||
id: 'item_dashboard',
|
||||
systemKey: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '/',
|
||||
icon: 'LayoutDashboard',
|
||||
parentId: null,
|
||||
openInNewTab: false,
|
||||
isRequired: true,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
roleVisibilities: [{ role: 'MANAGER' }],
|
||||
subscriptionAssignments: [],
|
||||
companyAssignments: [],
|
||||
},
|
||||
{
|
||||
id: 'item_reports',
|
||||
systemKey: 'reports',
|
||||
label: 'Reports',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '/reports',
|
||||
icon: 'BarChart2',
|
||||
parentId: null,
|
||||
openInNewTab: false,
|
||||
isRequired: false,
|
||||
isActive: true,
|
||||
displayOrder: 80,
|
||||
roleVisibilities: [{ role: 'MANAGER' }],
|
||||
subscriptionAssignments: [{ plan: 'GROWTH', displayOrder: 80, isActive: true }],
|
||||
companyAssignments: [],
|
||||
},
|
||||
{
|
||||
id: 'item_direct',
|
||||
systemKey: null,
|
||||
label: 'Finance Portal',
|
||||
itemType: 'EXTERNAL_LINK',
|
||||
routeOrUrl: 'https://finance.example.com',
|
||||
icon: null,
|
||||
parentId: null,
|
||||
openInNewTab: true,
|
||||
isRequired: false,
|
||||
isActive: true,
|
||||
displayOrder: 20,
|
||||
roleVisibilities: [{ role: 'MANAGER' }],
|
||||
subscriptionAssignments: [],
|
||||
companyAssignments: [{ companyId: 'company_1', displayOrder: 10, isActive: true }],
|
||||
},
|
||||
] as never)
|
||||
|
||||
const result = await previewCompanyMenu({ companyId: 'company_1', role: 'MANAGER' })
|
||||
|
||||
expect(result.items.find((item) => item.label === 'Dashboard')).toMatchObject({
|
||||
visible: true,
|
||||
source: 'subscription',
|
||||
})
|
||||
expect(result.items.find((item) => item.label === 'Dashboard')?.reasons.join(' ')).toContain('defaults to all subscription plans')
|
||||
expect(result.items.find((item) => item.label === 'Finance Portal')).toMatchObject({
|
||||
visible: true,
|
||||
source: 'company',
|
||||
})
|
||||
expect(result.items.find((item) => item.label === 'Reports')).toMatchObject({
|
||||
visible: false,
|
||||
})
|
||||
expect(result.items.find((item) => item.label === 'Reports')?.reasons.join(' ')).toContain('STARTER does not include this menu item')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,659 @@
|
||||
import { AppError } from '../../http/errors'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||
type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
|
||||
|
||||
type AdminActor = {
|
||||
id: string
|
||||
role: string
|
||||
}
|
||||
|
||||
type MenuItemInput = {
|
||||
systemKey?: string | null
|
||||
label: string
|
||||
itemType: MenuItemType
|
||||
routeOrUrl?: string | null
|
||||
icon?: string | null
|
||||
parentId?: string | null
|
||||
displayOrder?: number
|
||||
openInNewTab?: boolean
|
||||
isRequired?: boolean
|
||||
isActive?: boolean
|
||||
roles?: EmployeeRole[]
|
||||
subscriptionPlans?: Array<{
|
||||
plan: Plan
|
||||
displayOrder?: number
|
||||
isActive?: boolean
|
||||
}>
|
||||
companyAssignments?: Array<{
|
||||
companyId: string
|
||||
displayOrder?: number
|
||||
isActive?: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
type MenuPreviewInput = {
|
||||
companyId: string
|
||||
role: EmployeeRole
|
||||
}
|
||||
|
||||
type MenuAuditQuery = {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
type EvaluatedMenuItem = {
|
||||
id: string
|
||||
systemKey: string | null
|
||||
label: string
|
||||
itemType: MenuItemType
|
||||
routeOrUrl: string | null
|
||||
icon: string | null
|
||||
parentId: string | null
|
||||
openInNewTab: boolean
|
||||
isRequired: boolean
|
||||
isActive: boolean
|
||||
displayOrder: number
|
||||
source: 'company' | 'subscription' | 'none'
|
||||
visible: boolean
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
const EMPLOYEE_ROLES: EmployeeRole[] = ['OWNER', 'MANAGER', 'AGENT']
|
||||
const MENU_AUDIT_RESOURCES = ['MenuItem', 'MenuSubscriptionAssignment', 'MenuCompanyAssignment', 'MenuRoleVisibility']
|
||||
const MENU_ITEM_TYPES_WITHOUT_ROUTE = new Set([
|
||||
'PARENT_MENU',
|
||||
'SECTION_LABEL',
|
||||
'DIVIDER',
|
||||
])
|
||||
|
||||
function normalizeNullableString(value?: string | null) {
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function sortByDisplayOrder<T extends { displayOrder: number; label?: string | null }>(items: T[]) {
|
||||
return [...items].sort((a, b) => {
|
||||
if (a.displayOrder !== b.displayOrder) return a.displayOrder - b.displayOrder
|
||||
return (a.label ?? '').localeCompare(b.label ?? '')
|
||||
})
|
||||
}
|
||||
|
||||
function toPlanValue(plan: Plan) {
|
||||
return plan
|
||||
}
|
||||
|
||||
function toItemTypeValue(itemType: MenuItemType) {
|
||||
return itemType
|
||||
}
|
||||
|
||||
function describeEntitlement(
|
||||
plan: Plan | null,
|
||||
hasSubscriptionAssignment: boolean,
|
||||
hasCompanyAssignment: boolean,
|
||||
defaultsToAllSubscriptions: boolean,
|
||||
) {
|
||||
if (hasCompanyAssignment) return 'Visible because it is assigned directly to this company.'
|
||||
if (defaultsToAllSubscriptions) return 'Visible because this menu item defaults to all subscription plans.'
|
||||
if (hasSubscriptionAssignment && plan) return `Visible because ${plan} includes this menu item.`
|
||||
if (plan) return `Hidden because ${plan} does not include this menu item.`
|
||||
return 'Hidden because the company does not have an active subscription assignment for this item.'
|
||||
}
|
||||
|
||||
async function createAuditLog(admin: AdminActor, action: string, resource: string, resourceId: string | null, before?: unknown, after?: unknown) {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
adminUserId: admin.id,
|
||||
action,
|
||||
resource,
|
||||
resourceId,
|
||||
before: before === undefined ? undefined : JSON.parse(JSON.stringify(before)),
|
||||
after: after === undefined ? undefined : JSON.parse(JSON.stringify(after)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function assertMenuItemPayload(input: MenuItemInput, menuItemId?: string) {
|
||||
const label = input.label.trim()
|
||||
if (!label) throw new AppError('Menu label is required.', 400, 'validation_error')
|
||||
|
||||
const itemType = toItemTypeValue(input.itemType)
|
||||
const routeOrUrl = normalizeNullableString(input.routeOrUrl)
|
||||
const parentId = normalizeNullableString(input.parentId)
|
||||
|
||||
if (MENU_ITEM_TYPES_WITHOUT_ROUTE.has(itemType)) {
|
||||
if (routeOrUrl) {
|
||||
throw new AppError('This menu item type cannot define a route or URL.', 400, 'validation_error')
|
||||
}
|
||||
} else if (!routeOrUrl) {
|
||||
throw new AppError('Route or URL is required for this menu item type.', 400, 'validation_error')
|
||||
}
|
||||
|
||||
if (routeOrUrl && itemType === 'EXTERNAL_LINK') {
|
||||
if (!routeOrUrl.startsWith('https://')) {
|
||||
throw new AppError('External links must use HTTPS.', 400, 'validation_error')
|
||||
}
|
||||
}
|
||||
|
||||
if (routeOrUrl && itemType === 'INTERNAL_PAGE' && !routeOrUrl.startsWith('/')) {
|
||||
throw new AppError('Internal page routes must start with "/".', 400, 'validation_error')
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
if (menuItemId && parentId === menuItemId) {
|
||||
throw new AppError('A menu item cannot be its own parent.', 400, 'validation_error')
|
||||
}
|
||||
|
||||
const parent = await prisma.menuItem.findUnique({ where: { id: parentId } })
|
||||
if (!parent) throw new AppError('Parent menu item was not found.', 400, 'validation_error')
|
||||
if (parent.itemType !== 'PARENT_MENU') {
|
||||
throw new AppError('Only parent menu items can contain children.', 400, 'validation_error')
|
||||
}
|
||||
}
|
||||
|
||||
if (routeOrUrl && !MENU_ITEM_TYPES_WITHOUT_ROUTE.has(itemType)) {
|
||||
const duplicate = await prisma.menuItem.findFirst({
|
||||
where: {
|
||||
routeOrUrl,
|
||||
parentId,
|
||||
...(menuItemId ? { id: { not: menuItemId } } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (duplicate) {
|
||||
throw new AppError('Duplicate route under the same parent is not allowed.', 409, 'duplicate_menu_route')
|
||||
}
|
||||
}
|
||||
|
||||
const roles = Array.from(new Set(input.roles ?? []))
|
||||
if (roles.some((role) => !EMPLOYEE_ROLES.includes(role))) {
|
||||
throw new AppError('One or more role visibility values are invalid.', 400, 'validation_error')
|
||||
}
|
||||
|
||||
const companyIds = Array.from(new Set((input.companyAssignments ?? []).map((assignment) => assignment.companyId)))
|
||||
if (companyIds.length > 0) {
|
||||
const companies = await prisma.company.findMany({
|
||||
where: { id: { in: companyIds }, status: { not: 'CANCELLED' } },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (companies.length !== companyIds.length) {
|
||||
throw new AppError('Menu items can only be assigned to active companies.', 400, 'validation_error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncMenuAssignments(
|
||||
tx: Omit<typeof prisma, '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'>,
|
||||
menuItemId: string,
|
||||
input: MenuItemInput,
|
||||
) {
|
||||
await tx.menuItemRoleVisibility.deleteMany({ where: { menuItemId } })
|
||||
if ((input.roles ?? []).length > 0) {
|
||||
await tx.menuItemRoleVisibility.createMany({
|
||||
data: Array.from(new Set(input.roles)).map((role) => ({ menuItemId, role })),
|
||||
})
|
||||
}
|
||||
|
||||
await tx.subscriptionMenuItem.deleteMany({ where: { menuItemId } })
|
||||
if ((input.subscriptionPlans ?? []).length > 0) {
|
||||
await tx.subscriptionMenuItem.createMany({
|
||||
data: input.subscriptionPlans!.map((assignment) => ({
|
||||
menuItemId,
|
||||
plan: toPlanValue(assignment.plan),
|
||||
displayOrder: assignment.displayOrder ?? input.displayOrder ?? 0,
|
||||
isActive: assignment.isActive ?? true,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
await tx.companyMenuItem.deleteMany({ where: { menuItemId } })
|
||||
if ((input.companyAssignments ?? []).length > 0) {
|
||||
await tx.companyMenuItem.createMany({
|
||||
data: input.companyAssignments!.map((assignment) => ({
|
||||
menuItemId,
|
||||
companyId: assignment.companyId,
|
||||
displayOrder: assignment.displayOrder ?? input.displayOrder ?? 0,
|
||||
isActive: assignment.isActive ?? true,
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMenuItemDetail(id: string) {
|
||||
return prisma.menuItem.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
parent: { select: { id: true, label: true } },
|
||||
children: { select: { id: true, label: true } },
|
||||
roleVisibilities: { orderBy: { role: 'asc' } },
|
||||
subscriptionAssignments: { orderBy: [{ plan: 'asc' }, { displayOrder: 'asc' }] },
|
||||
companyAssignments: {
|
||||
include: { company: { select: { id: true, name: true } } },
|
||||
orderBy: [{ displayOrder: 'asc' }, { companyId: 'asc' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function listMenuItems() {
|
||||
return prisma.menuItem.findMany({
|
||||
include: {
|
||||
parent: { select: { id: true, label: true } },
|
||||
roleVisibilities: { orderBy: { role: 'asc' } },
|
||||
subscriptionAssignments: { orderBy: [{ plan: 'asc' }, { displayOrder: 'asc' }] },
|
||||
companyAssignments: {
|
||||
include: { company: { select: { id: true, name: true } } },
|
||||
orderBy: [{ displayOrder: 'asc' }, { companyId: 'asc' }],
|
||||
},
|
||||
},
|
||||
orderBy: [{ displayOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
export async function getMenuItem(id: string) {
|
||||
return loadMenuItemDetail(id)
|
||||
}
|
||||
|
||||
export async function createMenuItem(input: MenuItemInput, admin: AdminActor) {
|
||||
await assertMenuItemPayload(input)
|
||||
|
||||
const created = await prisma.$transaction(async (tx: any) => {
|
||||
const menuItem = await tx.menuItem.create({
|
||||
data: {
|
||||
systemKey: normalizeNullableString(input.systemKey) ?? undefined,
|
||||
label: input.label.trim(),
|
||||
itemType: toItemTypeValue(input.itemType),
|
||||
routeOrUrl: normalizeNullableString(input.routeOrUrl) ?? undefined,
|
||||
icon: normalizeNullableString(input.icon) ?? undefined,
|
||||
parentId: normalizeNullableString(input.parentId) ?? undefined,
|
||||
displayOrder: input.displayOrder ?? 0,
|
||||
openInNewTab: input.openInNewTab ?? false,
|
||||
isRequired: input.isRequired ?? false,
|
||||
isActive: input.isActive ?? true,
|
||||
createdBy: admin.id,
|
||||
updatedBy: admin.id,
|
||||
},
|
||||
})
|
||||
|
||||
await syncMenuAssignments(tx as any, menuItem.id, input)
|
||||
return menuItem
|
||||
})
|
||||
|
||||
const detail = await loadMenuItemDetail(created.id)
|
||||
await createAuditLog(admin, 'CREATE_MENU_ITEM', 'MenuItem', created.id, undefined, detail)
|
||||
return detail
|
||||
}
|
||||
|
||||
export async function updateMenuItem(id: string, input: MenuItemInput, admin: AdminActor) {
|
||||
const before = await loadMenuItemDetail(id)
|
||||
await assertMenuItemPayload(input, id)
|
||||
|
||||
const updated = await prisma.$transaction(async (tx: any) => {
|
||||
const menuItem = await tx.menuItem.update({
|
||||
where: { id },
|
||||
data: {
|
||||
systemKey: normalizeNullableString(input.systemKey) ?? undefined,
|
||||
label: input.label.trim(),
|
||||
itemType: toItemTypeValue(input.itemType),
|
||||
routeOrUrl: normalizeNullableString(input.routeOrUrl) ?? undefined,
|
||||
icon: normalizeNullableString(input.icon) ?? undefined,
|
||||
parentId: normalizeNullableString(input.parentId) ?? undefined,
|
||||
displayOrder: input.displayOrder ?? 0,
|
||||
openInNewTab: input.openInNewTab ?? false,
|
||||
isRequired: input.isRequired ?? false,
|
||||
isActive: input.isActive ?? true,
|
||||
updatedBy: admin.id,
|
||||
},
|
||||
})
|
||||
|
||||
await syncMenuAssignments(tx as any, id, input)
|
||||
return menuItem
|
||||
})
|
||||
|
||||
const detail = await loadMenuItemDetail(updated.id)
|
||||
await createAuditLog(admin, 'UPDATE_MENU_ITEM', 'MenuItem', id, before, detail)
|
||||
return detail
|
||||
}
|
||||
|
||||
export async function setMenuItemStatus(id: string, isActive: boolean, admin: AdminActor) {
|
||||
const before = await prisma.menuItem.findUniqueOrThrow({ where: { id } })
|
||||
if (!isActive && before.isRequired && admin.role !== 'SUPER_ADMIN') {
|
||||
throw new AppError('Required system items can only be disabled by a Super Admin.', 403, 'forbidden')
|
||||
}
|
||||
|
||||
const updated = await prisma.menuItem.update({
|
||||
where: { id },
|
||||
data: { isActive, updatedBy: admin.id },
|
||||
})
|
||||
|
||||
await createAuditLog(
|
||||
admin,
|
||||
isActive ? 'ENABLE_MENU_ITEM' : 'DISABLE_MENU_ITEM',
|
||||
'MenuItem',
|
||||
id,
|
||||
{ isActive: before.isActive },
|
||||
{ isActive: updated.isActive },
|
||||
)
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function deleteMenuItem(id: string, admin: AdminActor) {
|
||||
const before = await loadMenuItemDetail(id)
|
||||
if (before.isRequired && admin.role !== 'SUPER_ADMIN') {
|
||||
throw new AppError('Required system items can only be deleted by a Super Admin.', 403, 'forbidden')
|
||||
}
|
||||
|
||||
await prisma.menuItem.delete({ where: { id } })
|
||||
await createAuditLog(admin, 'DELETE_MENU_ITEM', 'MenuItem', id, before, undefined)
|
||||
}
|
||||
|
||||
export async function assignMenuItemToPlans(
|
||||
id: string,
|
||||
assignments: Array<{ plan: Plan; displayOrder?: number; isActive?: boolean }>,
|
||||
admin: AdminActor,
|
||||
) {
|
||||
await prisma.menuItem.findUniqueOrThrow({ where: { id } })
|
||||
|
||||
const payload = assignments.map((assignment) => ({
|
||||
menuItemId: id,
|
||||
plan: toPlanValue(assignment.plan),
|
||||
displayOrder: assignment.displayOrder ?? 0,
|
||||
isActive: assignment.isActive ?? true,
|
||||
}))
|
||||
|
||||
await prisma.subscriptionMenuItem.deleteMany({
|
||||
where: { menuItemId: id, plan: { in: payload.map((assignment) => assignment.plan) } },
|
||||
})
|
||||
|
||||
if (payload.length > 0) {
|
||||
await prisma.subscriptionMenuItem.createMany({ data: payload })
|
||||
}
|
||||
|
||||
await createAuditLog(admin, 'ASSIGN_MENU_ITEM_TO_SUBSCRIPTIONS', 'MenuSubscriptionAssignment', id, undefined, payload)
|
||||
return loadMenuItemDetail(id)
|
||||
}
|
||||
|
||||
export async function removeMenuItemFromPlan(id: string, plan: Plan, admin: AdminActor) {
|
||||
await prisma.subscriptionMenuItem.deleteMany({
|
||||
where: {
|
||||
menuItemId: id,
|
||||
plan: toPlanValue(plan),
|
||||
},
|
||||
})
|
||||
|
||||
await createAuditLog(admin, 'REMOVE_MENU_ITEM_FROM_SUBSCRIPTION', 'MenuSubscriptionAssignment', id, { plan }, undefined)
|
||||
return loadMenuItemDetail(id)
|
||||
}
|
||||
|
||||
export async function assignMenuItemToCompanies(
|
||||
id: string,
|
||||
assignments: Array<{ companyId: string; displayOrder?: number; isActive?: boolean }>,
|
||||
admin: AdminActor,
|
||||
) {
|
||||
await prisma.menuItem.findUniqueOrThrow({ where: { id } })
|
||||
const companyIds = assignments.map((assignment) => assignment.companyId)
|
||||
const companies = await prisma.company.findMany({
|
||||
where: { id: { in: companyIds }, status: { not: 'CANCELLED' } },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (companies.length !== new Set(companyIds).size) {
|
||||
throw new AppError('Menu items can only be assigned to active companies.', 400, 'validation_error')
|
||||
}
|
||||
|
||||
await prisma.companyMenuItem.deleteMany({
|
||||
where: {
|
||||
menuItemId: id,
|
||||
companyId: { in: companyIds },
|
||||
},
|
||||
})
|
||||
|
||||
if (assignments.length > 0) {
|
||||
await prisma.companyMenuItem.createMany({
|
||||
data: assignments.map((assignment) => ({
|
||||
menuItemId: id,
|
||||
companyId: assignment.companyId,
|
||||
displayOrder: assignment.displayOrder ?? 0,
|
||||
isActive: assignment.isActive ?? true,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
await createAuditLog(admin, 'ASSIGN_MENU_ITEM_TO_COMPANIES', 'MenuCompanyAssignment', id, undefined, assignments)
|
||||
return loadMenuItemDetail(id)
|
||||
}
|
||||
|
||||
export async function removeMenuItemFromCompany(id: string, companyId: string, admin: AdminActor) {
|
||||
await prisma.companyMenuItem.deleteMany({
|
||||
where: { menuItemId: id, companyId },
|
||||
})
|
||||
|
||||
await createAuditLog(admin, 'REMOVE_MENU_ITEM_FROM_COMPANY', 'MenuCompanyAssignment', id, { companyId }, undefined)
|
||||
return loadMenuItemDetail(id)
|
||||
}
|
||||
|
||||
async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, employeeIsActive = true) {
|
||||
const company = await prisma.company.findUniqueOrThrow({
|
||||
where: { id: companyId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
subscription: {
|
||||
select: {
|
||||
plan: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const menuItems = await prisma.menuItem.findMany({
|
||||
include: {
|
||||
roleVisibilities: true,
|
||||
subscriptionAssignments: true,
|
||||
companyAssignments: {
|
||||
where: { companyId },
|
||||
},
|
||||
},
|
||||
orderBy: [{ displayOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
|
||||
const currentPlan = company.subscription?.plan ?? null
|
||||
const currentPlanName = currentPlan as Plan | null
|
||||
|
||||
const evaluated: EvaluatedMenuItem[] = (menuItems as any[]).map((item: any) => {
|
||||
const defaultsToAllSubscriptions = item.subscriptionAssignments.length === 0
|
||||
const subscriptionAssignment = item.subscriptionAssignments.find(
|
||||
(assignment: any) => assignment.isActive && (!currentPlan || assignment.plan === currentPlan),
|
||||
)
|
||||
const companyAssignment = item.companyAssignments.find((assignment: any) => assignment.isActive)
|
||||
const roleAllowed = item.roleVisibilities.length === 0 || item.roleVisibilities.some((visibility: any) => visibility.role === role)
|
||||
const subscriptionEntitled = Boolean(subscriptionAssignment || (defaultsToAllSubscriptions && currentPlan))
|
||||
const entitled = Boolean(subscriptionEntitled || companyAssignment)
|
||||
const visible = Boolean(item.isActive && entitled && roleAllowed && employeeIsActive)
|
||||
const displayOrder = companyAssignment?.displayOrder ?? subscriptionAssignment?.displayOrder ?? item.displayOrder
|
||||
|
||||
const reasons: string[] = []
|
||||
if (!item.isActive) reasons.push('Hidden because the menu item is inactive.')
|
||||
if (!employeeIsActive) reasons.push('Hidden because the user account is inactive.')
|
||||
if (!entitled) reasons.push(describeEntitlement(currentPlanName, false, false, false))
|
||||
if (entitled) {
|
||||
reasons.push(
|
||||
describeEntitlement(
|
||||
currentPlanName,
|
||||
Boolean(subscriptionAssignment),
|
||||
Boolean(companyAssignment),
|
||||
Boolean(defaultsToAllSubscriptions && !companyAssignment),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (!roleAllowed) reasons.push(`Hidden because the ${role} role is not allowed to see this item.`)
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
systemKey: item.systemKey,
|
||||
label: item.label ?? '',
|
||||
itemType: item.itemType as MenuItemType,
|
||||
routeOrUrl: item.routeOrUrl,
|
||||
icon: item.icon,
|
||||
parentId: item.parentId,
|
||||
openInNewTab: item.openInNewTab,
|
||||
isRequired: item.isRequired,
|
||||
isActive: item.isActive,
|
||||
displayOrder,
|
||||
source: companyAssignment ? 'company' : subscriptionEntitled ? 'subscription' : 'none',
|
||||
visible,
|
||||
reasons,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
company,
|
||||
role,
|
||||
currentPlan: currentPlanName,
|
||||
items: sortByDisplayOrder(evaluated),
|
||||
}
|
||||
}
|
||||
|
||||
function buildMenuTree(items: Array<{
|
||||
id: string
|
||||
systemKey: string | null
|
||||
label: string
|
||||
itemType: MenuItemType
|
||||
routeOrUrl: string | null
|
||||
icon: string | null
|
||||
parentId: string | null
|
||||
openInNewTab: boolean
|
||||
displayOrder: number
|
||||
}>) {
|
||||
const byId = new Map<string, any>()
|
||||
const roots: any[] = []
|
||||
|
||||
for (const item of items) {
|
||||
byId.set(item.id, {
|
||||
id: item.id,
|
||||
systemKey: item.systemKey,
|
||||
label: item.label ?? '',
|
||||
itemType: item.itemType as MenuItemType,
|
||||
routeOrUrl: item.routeOrUrl,
|
||||
icon: item.icon,
|
||||
parentId: item.parentId,
|
||||
openInNewTab: item.openInNewTab,
|
||||
displayOrder: item.displayOrder,
|
||||
children: [],
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const node = byId.get(item.id)
|
||||
if (item.parentId && byId.has(item.parentId)) {
|
||||
byId.get(item.parentId).children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
const sortTree = (nodes: any[]) => {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.displayOrder !== b.displayOrder) return a.displayOrder - b.displayOrder
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
nodes.forEach((node) => sortTree(node.children))
|
||||
}
|
||||
|
||||
sortTree(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
export async function previewCompanyMenu(input: MenuPreviewInput) {
|
||||
const context = await getMenuEvaluationContext(input.companyId, input.role)
|
||||
return {
|
||||
company: context.company,
|
||||
role: context.role,
|
||||
subscriptionPlan: context.currentPlan,
|
||||
items: context.items,
|
||||
finalMenu: buildMenuTree(
|
||||
context.items
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
systemKey: item.systemKey,
|
||||
label: item.label,
|
||||
itemType: item.itemType,
|
||||
routeOrUrl: item.routeOrUrl,
|
||||
icon: item.icon,
|
||||
parentId: item.parentId,
|
||||
openInNewTab: item.openInNewTab,
|
||||
displayOrder: item.displayOrder,
|
||||
})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEmployeeMenu(employeeId: string) {
|
||||
const employee = await prisma.employee.findUniqueOrThrow({
|
||||
where: { id: employeeId },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
companyId: true,
|
||||
},
|
||||
})
|
||||
|
||||
const context = await getMenuEvaluationContext(employee.companyId, employee.role, employee.isActive)
|
||||
const visibleItems = context.items.filter((item) => item.visible)
|
||||
|
||||
return {
|
||||
companyId: employee.companyId,
|
||||
role: employee.role,
|
||||
subscriptionPlan: context.currentPlan,
|
||||
items: buildMenuTree(
|
||||
visibleItems.map((item) => ({
|
||||
id: item.id,
|
||||
systemKey: item.systemKey,
|
||||
label: item.label,
|
||||
itemType: item.itemType,
|
||||
routeOrUrl: item.routeOrUrl,
|
||||
icon: item.icon,
|
||||
parentId: item.parentId,
|
||||
openInNewTab: item.openInNewTab,
|
||||
displayOrder: item.displayOrder,
|
||||
})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMenuAuditLogs(query: MenuAuditQuery) {
|
||||
const where = {
|
||||
resource: {
|
||||
in: MENU_AUDIT_RESOURCES,
|
||||
},
|
||||
} as const
|
||||
|
||||
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,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
totalPages: Math.ceil(total / query.pageSize),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
notification: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
notificationPreference: {
|
||||
findMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
import * as repo from './notification.repo'
|
||||
|
||||
describe('notification.repo query boundaries', () => {
|
||||
it('scopes company in-app notification lists and applies unread filtering only when requested', async () => {
|
||||
await repo.findCompany('company_1', 'true')
|
||||
|
||||
expect(prismaMock.notification.findMany).toHaveBeenCalledWith({
|
||||
where: { companyId: 'company_1', channel: 'IN_APP', readAt: null },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it('marks a single company notification read with tenant scoping', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-09T13:00:00.000Z'))
|
||||
|
||||
await repo.markRead('notification_1', 'company_1')
|
||||
|
||||
expect(prismaMock.notification.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'notification_1', companyId: 'company_1' },
|
||||
data: { readAt: new Date('2026-06-09T13:00:00.000Z'), status: 'READ' },
|
||||
})
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps renter bulk read updates constrained to renter in-app notifications', async () => {
|
||||
await repo.markAllRenterRead('renter_1')
|
||||
|
||||
expect(prismaMock.notification.updateMany).toHaveBeenCalledWith({
|
||||
where: { renterId: 'renter_1', channel: 'IN_APP', readAt: null },
|
||||
data: { readAt: expect.any(Date), status: 'READ' },
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults company notification history to a capped newest-first query', async () => {
|
||||
await repo.findCompanyHistory('company_1')
|
||||
|
||||
expect(prismaMock.notification.findMany).toHaveBeenCalledWith({
|
||||
where: { companyId: 'company_1' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
})
|
||||
})
|
||||
|
||||
it('upserts employee preferences by composite key without cross-employee mutation', async () => {
|
||||
await repo.upsertEmployeePreferences('employee_1', [
|
||||
{ notificationType: 'RESERVATION_CREATED', channel: 'EMAIL', enabled: false },
|
||||
{ notificationType: 'PAYMENT_RECEIVED', channel: 'IN_APP', enabled: true },
|
||||
])
|
||||
|
||||
expect(prismaMock.notificationPreference.upsert).toHaveBeenCalledTimes(2)
|
||||
expect(prismaMock.notificationPreference.upsert).toHaveBeenNthCalledWith(1, {
|
||||
where: {
|
||||
employeeId_notificationType_channel: {
|
||||
employeeId: 'employee_1',
|
||||
notificationType: 'RESERVATION_CREATED',
|
||||
channel: 'EMAIL',
|
||||
},
|
||||
},
|
||||
create: {
|
||||
employeeId: 'employee_1',
|
||||
notificationType: 'RESERVATION_CREATED',
|
||||
channel: 'EMAIL',
|
||||
enabled: false,
|
||||
},
|
||||
update: { enabled: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('upserts renter preferences by renter composite key', async () => {
|
||||
await repo.upsertRenterPreferences('renter_1', [
|
||||
{ notificationType: 'RESERVATION_CONFIRMED', channel: 'PUSH', enabled: true },
|
||||
])
|
||||
|
||||
expect(prismaMock.notificationPreference.upsert).toHaveBeenCalledWith({
|
||||
where: {
|
||||
renterId_notificationType_channel: {
|
||||
renterId: 'renter_1',
|
||||
notificationType: 'RESERVATION_CONFIRMED',
|
||||
channel: 'PUSH',
|
||||
},
|
||||
},
|
||||
create: {
|
||||
renterId: 'renter_1',
|
||||
notificationType: 'RESERVATION_CONFIRMED',
|
||||
channel: 'PUSH',
|
||||
enabled: true,
|
||||
},
|
||||
update: { enabled: true },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
|
||||
|
||||
// ─── Company notifications ────────────────────────────────────
|
||||
|
||||
export function findCompany(companyId: string, unread?: string) {
|
||||
const where: any = { companyId, channel: 'IN_APP' }
|
||||
if (unread === 'true') where.readAt = null
|
||||
return prisma.notification.findMany({ where, orderBy: { createdAt: 'desc' }, take: 50 })
|
||||
}
|
||||
|
||||
export function countUnread(companyId: string) {
|
||||
return prisma.notification.count({ where: { companyId, channel: 'IN_APP', readAt: null } })
|
||||
}
|
||||
|
||||
export function markRead(id: string, companyId: string) {
|
||||
return prisma.notification.updateMany({ where: { id, companyId }, data: { readAt: new Date(), status: 'READ' } })
|
||||
}
|
||||
|
||||
export function markAllRead(companyId: string) {
|
||||
return prisma.notification.updateMany({ where: { companyId, readAt: null }, data: { readAt: new Date(), status: 'READ' } })
|
||||
}
|
||||
|
||||
export function findEmployeePreferences(employeeId: string) {
|
||||
return prisma.notificationPreference.findMany({ where: { employeeId } })
|
||||
}
|
||||
|
||||
export async function upsertEmployeePreferences(employeeId: string, prefs: Array<{ notificationType: string; channel: string; enabled: boolean }>) {
|
||||
for (const pref of prefs) {
|
||||
await prisma.notificationPreference.upsert({
|
||||
where: { employeeId_notificationType_channel: { employeeId, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel } },
|
||||
create: { employeeId, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel, enabled: pref.enabled },
|
||||
update: { enabled: pref.enabled },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function findCompanyHistory(
|
||||
companyId: string,
|
||||
opts?: { channel?: string; status?: string; limit?: number },
|
||||
) {
|
||||
const where: any = { companyId }
|
||||
if (opts?.channel) where.channel = opts.channel
|
||||
if (opts?.status) where.status = opts.status
|
||||
return prisma.notification.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: opts?.limit ?? 200,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Renter notifications ─────────────────────────────────────
|
||||
|
||||
export function findRenter(renterId: string) {
|
||||
return prisma.notification.findMany({ where: { renterId, channel: 'IN_APP' }, orderBy: { createdAt: 'desc' }, take: 50 })
|
||||
}
|
||||
|
||||
export function markRenterRead(id: string, renterId: string) {
|
||||
return prisma.notification.updateMany({ where: { id, renterId }, data: { readAt: new Date(), status: 'READ' } })
|
||||
}
|
||||
|
||||
export function markAllRenterRead(renterId: string) {
|
||||
return prisma.notification.updateMany({ where: { renterId, channel: 'IN_APP', readAt: null }, data: { readAt: new Date(), status: 'READ' } })
|
||||
}
|
||||
|
||||
export function findRenterPreferences(renterId: string) {
|
||||
return prisma.notificationPreference.findMany({ where: { renterId } })
|
||||
}
|
||||
|
||||
export async function upsertRenterPreferences(renterId: string, prefs: Array<{ notificationType: string; channel: string; enabled: boolean }>) {
|
||||
for (const pref of prefs) {
|
||||
await prisma.notificationPreference.upsert({
|
||||
where: { renterId_notificationType_channel: { renterId, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel } },
|
||||
create: { renterId, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel, enabled: pref.enabled },
|
||||
update: { enabled: pref.enabled },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRenterAuth } from '../../middleware/requireRenterAuth'
|
||||
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import * as service from './notification.service'
|
||||
import { preferencesSchema, idParamSchema, unreadQuerySchema, historyQuerySchema } from './notification.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const companyAuth = [requireCompanyAuth, requireTenant, requireSubscription] as const
|
||||
|
||||
// ─── Company notifications ────────────────────────────────────
|
||||
|
||||
router.get('/company', ...companyAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { unread } = parseQuery(unreadQuerySchema, req)
|
||||
ok(res, await service.listCompany(req.companyId, unread))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/unread-count', ...companyAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { unread: await service.countUnread(req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/company/:id/read', ...companyAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.markRead(id, req.companyId)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/company/read-all', ...companyAuth, async (req, res, next) => {
|
||||
try {
|
||||
await service.markAllRead(req.companyId)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/company/preferences', ...companyAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getPreferences(req.employee.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/company/preferences', ...companyAuth, async (req, res, next) => {
|
||||
try {
|
||||
const prefs = parseBody(preferencesSchema, req)
|
||||
await service.setPreferences(req.employee.id, prefs)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/history', ...companyAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { channel, status, limit } = parseQuery(historyQuerySchema, req)
|
||||
ok(res, await service.listCompanyHistory(req.companyId, { channel, status, limit }))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Renter notifications ─────────────────────────────────────
|
||||
|
||||
router.get('/renter', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.listRenter(req.renterId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/renter/:id/read', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.markRenterRead(id, req.renterId)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/renter/read-all', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
await service.markAllRenterRead(req.renterId)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/renter/preferences', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getRenterPreferences(req.renterId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/renter/preferences', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
const prefs = parseBody(preferencesSchema, req)
|
||||
await service.setRenterPreferences(req.renterId, prefs)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { historyQuerySchema, idParamSchema, preferencesSchema, unreadQuerySchema } from './notification.schemas'
|
||||
|
||||
describe('notification schema contracts', () => {
|
||||
it('requires complete preference entries', () => {
|
||||
expect(preferencesSchema.parse([{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL', enabled: true }])).toEqual([
|
||||
{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL', enabled: true },
|
||||
])
|
||||
expect(() => preferencesSchema.parse([{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL' }])).toThrow()
|
||||
})
|
||||
|
||||
it('coerces history limits and caps bulk history requests', () => {
|
||||
expect(historyQuerySchema.parse({ channel: 'EMAIL', status: 'SENT', limit: '25' })).toEqual({
|
||||
channel: 'EMAIL',
|
||||
status: 'SENT',
|
||||
limit: 25,
|
||||
})
|
||||
expect(() => historyQuerySchema.parse({ limit: '0' })).toThrow()
|
||||
expect(() => historyQuerySchema.parse({ limit: '501' })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts unread query passthrough but rejects empty ids', () => {
|
||||
expect(unreadQuerySchema.parse({ unread: 'true' })).toEqual({ unread: 'true' })
|
||||
expect(idParamSchema.parse({ id: 'notification_1' })).toEqual({ id: 'notification_1' })
|
||||
expect(() => idParamSchema.parse({ id: '' })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const preferenceItemSchema = z.object({
|
||||
notificationType: z.string(),
|
||||
channel: z.string(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
|
||||
export const preferencesSchema = z.array(preferenceItemSchema)
|
||||
|
||||
export const idParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
|
||||
export const unreadQuerySchema = z.object({
|
||||
unread: z.string().optional(),
|
||||
})
|
||||
|
||||
export const historyQuerySchema = z.object({
|
||||
channel: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./notification.repo', () => ({
|
||||
findCompany: vi.fn(),
|
||||
findCompanyHistory: vi.fn(),
|
||||
countUnread: vi.fn(),
|
||||
markRead: vi.fn(),
|
||||
markAllRead: vi.fn(),
|
||||
findEmployeePreferences: vi.fn(),
|
||||
upsertEmployeePreferences: vi.fn(),
|
||||
findRenter: vi.fn(),
|
||||
markRenterRead: vi.fn(),
|
||||
markAllRenterRead: vi.fn(),
|
||||
findRenterPreferences: vi.fn(),
|
||||
upsertRenterPreferences: vi.fn(),
|
||||
}))
|
||||
|
||||
import * as repo from './notification.repo'
|
||||
import * as service from './notification.service'
|
||||
|
||||
describe('notification.service', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('delegates company notification reads and history filters without rewriting query semantics', async () => {
|
||||
vi.mocked(repo.findCompany).mockResolvedValue([{ id: 'n1' }] as never)
|
||||
vi.mocked(repo.findCompanyHistory).mockResolvedValue([{ id: 'h1' }] as never)
|
||||
vi.mocked(repo.countUnread).mockResolvedValue(4 as never)
|
||||
|
||||
await expect(service.listCompany('company_1', 'true')).resolves.toEqual([{ id: 'n1' }])
|
||||
await expect(service.listCompanyHistory('company_1', { channel: 'EMAIL', status: 'FAILED', limit: 25 })).resolves.toEqual([{ id: 'h1' }])
|
||||
await expect(service.countUnread('company_1')).resolves.toBe(4)
|
||||
|
||||
expect(repo.findCompany).toHaveBeenCalledWith('company_1', 'true')
|
||||
expect(repo.findCompanyHistory).toHaveBeenCalledWith('company_1', { channel: 'EMAIL', status: 'FAILED', limit: 25 })
|
||||
expect(repo.countUnread).toHaveBeenCalledWith('company_1')
|
||||
})
|
||||
|
||||
it('marks company notifications read only within the company tenant', async () => {
|
||||
await service.markRead('notification_1', 'company_1')
|
||||
await service.markAllRead('company_1')
|
||||
|
||||
expect(repo.markRead).toHaveBeenCalledWith('notification_1', 'company_1')
|
||||
expect(repo.markAllRead).toHaveBeenCalledWith('company_1')
|
||||
})
|
||||
|
||||
it('reads and writes employee preferences through the employee identity, not the company id', async () => {
|
||||
const prefs = [{ notificationType: 'BOOKING_CONFIRMED', channel: 'EMAIL', enabled: true }]
|
||||
vi.mocked(repo.findEmployeePreferences).mockResolvedValue(prefs as never)
|
||||
|
||||
await expect(service.getPreferences('employee_1')).resolves.toEqual(prefs)
|
||||
await service.setPreferences('employee_1', prefs)
|
||||
|
||||
expect(repo.findEmployeePreferences).toHaveBeenCalledWith('employee_1')
|
||||
expect(repo.upsertEmployeePreferences).toHaveBeenCalledWith('employee_1', prefs)
|
||||
})
|
||||
|
||||
it('keeps renter notification operations scoped to the renter identity', async () => {
|
||||
const prefs = [{ notificationType: 'REVIEW_REQUEST', channel: 'IN_APP', enabled: false }]
|
||||
vi.mocked(repo.findRenter).mockResolvedValue([{ id: 'rn1' }] as never)
|
||||
vi.mocked(repo.findRenterPreferences).mockResolvedValue(prefs as never)
|
||||
|
||||
await service.listRenter('renter_1')
|
||||
await service.markRenterRead('notification_1', 'renter_1')
|
||||
await service.markAllRenterRead('renter_1')
|
||||
await service.getRenterPreferences('renter_1')
|
||||
await service.setRenterPreferences('renter_1', prefs)
|
||||
|
||||
expect(repo.findRenter).toHaveBeenCalledWith('renter_1')
|
||||
expect(repo.markRenterRead).toHaveBeenCalledWith('notification_1', 'renter_1')
|
||||
expect(repo.markAllRenterRead).toHaveBeenCalledWith('renter_1')
|
||||
expect(repo.findRenterPreferences).toHaveBeenCalledWith('renter_1')
|
||||
expect(repo.upsertRenterPreferences).toHaveBeenCalledWith('renter_1', prefs)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as repo from './notification.repo'
|
||||
|
||||
export const listCompany = (companyId: string, unread?: string) => repo.findCompany(companyId, unread)
|
||||
export const listCompanyHistory = (companyId: string, opts?: { channel?: string; status?: string; limit?: number }) => repo.findCompanyHistory(companyId, opts)
|
||||
export const countUnread = (companyId: string) => repo.countUnread(companyId)
|
||||
export const markRead = (id: string, companyId: string) => repo.markRead(id, companyId)
|
||||
export const markAllRead = (companyId: string) => repo.markAllRead(companyId)
|
||||
export const getPreferences = (employeeId: string) => repo.findEmployeePreferences(employeeId)
|
||||
export const setPreferences = (employeeId: string, prefs: any[]) => repo.upsertEmployeePreferences(employeeId, prefs)
|
||||
|
||||
export const listRenter = (renterId: string) => repo.findRenter(renterId)
|
||||
export const markRenterRead = (id: string, renterId: string) => repo.markRenterRead(id, renterId)
|
||||
export const markAllRenterRead = (renterId: string) => repo.markAllRenterRead(renterId)
|
||||
export const getRenterPreferences = (renterId: string) => repo.findRenterPreferences(renterId)
|
||||
export const setRenterPreferences = (renterId: string, prefs: any[]) => repo.upsertRenterPreferences(renterId, prefs)
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const tx = vi.hoisted(() => ({
|
||||
offer: { update: vi.fn(), findUniqueOrThrow: vi.fn() },
|
||||
offerVehicle: { deleteMany: vi.fn(), createMany: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
offer: { findMany: vi.fn(), findFirstOrThrow: vi.fn(), findFirst: vi.fn(), create: vi.fn(), deleteMany: vi.fn(), updateMany: vi.fn() },
|
||||
reservation: { count: vi.fn() },
|
||||
$transaction: vi.fn(async (callback: (txArg: typeof tx) => unknown) => callback(tx)),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as repo from './offer.repo'
|
||||
|
||||
describe('offer.repo edge behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
tx.offer.update.mockReset()
|
||||
tx.offer.findUniqueOrThrow.mockReset()
|
||||
tx.offerVehicle.deleteMany.mockReset()
|
||||
tx.offerVehicle.createMany.mockReset()
|
||||
})
|
||||
|
||||
it('converts active and public query strings into boolean filters', () => {
|
||||
repo.findMany('company_1', { active: 'false', public: 'true' })
|
||||
|
||||
expect(prisma.offer.findMany).toHaveBeenCalledWith({
|
||||
where: { companyId: 'company_1', isActive: false, isPublic: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
})
|
||||
|
||||
it('creates vehicle links only when explicit vehicle ids are supplied', async () => {
|
||||
await repo.create('company_1', {
|
||||
title: 'Summer',
|
||||
type: 'PERCENTAGE',
|
||||
discountValue: 10,
|
||||
validFrom: '2026-07-01T00:00:00.000Z',
|
||||
validUntil: '2026-07-31T23:59:59.000Z',
|
||||
}, ['vehicle_1', 'vehicle_2'])
|
||||
|
||||
expect(prisma.offer.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
validFrom: new Date('2026-07-01T00:00:00.000Z'),
|
||||
validUntil: new Date('2026-07-31T23:59:59.000Z'),
|
||||
vehicles: { create: [{ vehicleId: 'vehicle_1' }, { vehicleId: 'vehicle_2' }] },
|
||||
}),
|
||||
include: { vehicles: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces vehicle links inside the update transaction when vehicleIds are provided', async () => {
|
||||
await repo.update('offer_1', { title: 'Updated', validFrom: '2026-08-01T00:00:00.000Z' }, ['vehicle_3'])
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalled()
|
||||
expect(tx.offer.update).toHaveBeenCalledWith({
|
||||
where: { id: 'offer_1' },
|
||||
data: expect.objectContaining({ title: 'Updated', validFrom: new Date('2026-08-01T00:00:00.000Z') }),
|
||||
})
|
||||
expect(tx.offerVehicle.deleteMany).toHaveBeenCalledWith({ where: { offerId: 'offer_1' } })
|
||||
expect(tx.offerVehicle.createMany).toHaveBeenCalledWith({ data: [{ offerId: 'offer_1', vehicleId: 'vehicle_3' }] })
|
||||
expect(tx.offer.findUniqueOrThrow).toHaveBeenCalledWith({ where: { id: 'offer_1' }, include: { vehicles: true } })
|
||||
})
|
||||
|
||||
it('does not touch vehicle links when update omits vehicleIds', async () => {
|
||||
await repo.update('offer_1', { description: 'Copy only' })
|
||||
|
||||
expect(tx.offerVehicle.deleteMany).not.toHaveBeenCalled()
|
||||
expect(tx.offerVehicle.createMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('combines redemption counter and reservation count for stats', async () => {
|
||||
vi.mocked(prisma.offer.findFirstOrThrow).mockResolvedValue({ id: 'offer_1', redemptionCount: 7 } as never)
|
||||
vi.mocked(prisma.reservation.count).mockResolvedValue(3 as never)
|
||||
|
||||
await expect(repo.getStats('offer_1', 'company_1')).resolves.toEqual({ redemptions: 7, reservations: 3 })
|
||||
expect(prisma.reservation.count).toHaveBeenCalledWith({ where: { offerId: 'offer_1' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export function findMany(companyId: string, filters: { active?: string; public?: string }) {
|
||||
const where: any = { companyId }
|
||||
if (filters.active !== undefined) where.isActive = filters.active === 'true'
|
||||
if (filters.public !== undefined) where.isPublic = filters.public === 'true'
|
||||
return prisma.offer.findMany({ where, orderBy: { createdAt: 'desc' } })
|
||||
}
|
||||
|
||||
export function findByIdOrThrow(id: string, companyId: string) {
|
||||
return prisma.offer.findFirstOrThrow({ where: { id, companyId }, include: { vehicles: true } })
|
||||
}
|
||||
|
||||
export function findFirst(id: string, companyId: string) {
|
||||
return prisma.offer.findFirst({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export async function create(companyId: string, data: any, vehicleIds: string[]) {
|
||||
return prisma.offer.create({
|
||||
data: {
|
||||
...data,
|
||||
companyId,
|
||||
validFrom: new Date(data.validFrom),
|
||||
validUntil: new Date(data.validUntil),
|
||||
vehicles: vehicleIds.length > 0 ? { create: vehicleIds.map((id) => ({ vehicleId: id })) } : undefined,
|
||||
},
|
||||
include: { vehicles: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function update(id: string, data: any, vehicleIds?: string[]) {
|
||||
return prisma.$transaction(async (tx: any) => {
|
||||
await tx.offer.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
validFrom: data.validFrom ? new Date(data.validFrom) : undefined,
|
||||
validUntil: data.validUntil ? new Date(data.validUntil) : undefined,
|
||||
},
|
||||
})
|
||||
if (vehicleIds !== undefined) {
|
||||
await tx.offerVehicle.deleteMany({ where: { offerId: id } })
|
||||
if (vehicleIds.length > 0) {
|
||||
await tx.offerVehicle.createMany({ data: vehicleIds.map((vehicleId) => ({ offerId: id, vehicleId })) })
|
||||
}
|
||||
}
|
||||
return tx.offer.findUniqueOrThrow({ where: { id }, include: { vehicles: true } })
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteMany(id: string, companyId: string) {
|
||||
return prisma.offer.deleteMany({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export function setActive(id: string, companyId: string, isActive: boolean) {
|
||||
return prisma.offer.updateMany({ where: { id, companyId }, data: { isActive } })
|
||||
}
|
||||
|
||||
export async function getStats(id: string, companyId: string) {
|
||||
const offer = await prisma.offer.findFirstOrThrow({ where: { id, companyId } })
|
||||
const reservations = await prisma.reservation.count({ where: { offerId: offer.id } })
|
||||
return { redemptions: offer.redemptionCount, reservations }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import * as service from './offer.service'
|
||||
import { offerSchema, listQuerySchema, idParamSchema } from './offer.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const query = parseQuery(listQuerySchema, req)
|
||||
ok(res, await service.listOffers(req.companyId, query))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { vehicleIds, ...body } = parseBody(offerSchema, req)
|
||||
created(res, await service.createOffer(req.companyId, body, vehicleIds))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
ok(res, await service.getOffer(id, req.companyId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { vehicleIds, ...body } = parseBody(offerSchema.partial(), req)
|
||||
ok(res, await service.updateOffer(id, req.companyId, body, vehicleIds))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.deleteOffer(id, req.companyId)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/activate', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.setOfferActive(id, req.companyId, true)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/deactivate', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.setOfferActive(id, req.companyId, false)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:id/stats', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
ok(res, await service.getOfferStats(id, req.companyId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { offerSchema, listQuerySchema } from './offer.schemas'
|
||||
|
||||
const validOffer = {
|
||||
title: 'Summer deal',
|
||||
type: 'PERCENTAGE',
|
||||
discountValue: 15,
|
||||
validFrom: '2026-07-01T00:00:00.000Z',
|
||||
validUntil: '2026-07-31T23:59:59.000Z',
|
||||
}
|
||||
|
||||
describe('offer schemas', () => {
|
||||
it('applies safe defaults for public all-fleet offers', () => {
|
||||
expect(offerSchema.parse(validOffer)).toMatchObject({
|
||||
appliesToAll: true,
|
||||
categories: [],
|
||||
vehicleIds: [],
|
||||
isActive: true,
|
||||
isPublic: true,
|
||||
isFeatured: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts targeted vehicle and category offers', () => {
|
||||
expect(offerSchema.parse({
|
||||
...validOffer,
|
||||
appliesToAll: false,
|
||||
categories: ['SUV', 'LUXURY'],
|
||||
vehicleIds: ['vehicle_1', 'vehicle_2'],
|
||||
})).toMatchObject({ appliesToAll: false, categories: ['SUV', 'LUXURY'], vehicleIds: ['vehicle_1', 'vehicle_2'] })
|
||||
})
|
||||
|
||||
it('rejects non-integer discounts and malformed dates', () => {
|
||||
expect(() => offerSchema.parse({ ...validOffer, discountValue: 12.5 })).toThrow()
|
||||
expect(() => offerSchema.parse({ ...validOffer, validFrom: 'tomorrow' })).toThrow()
|
||||
})
|
||||
|
||||
it('keeps list filters as strings because route logic interprets them', () => {
|
||||
expect(listQuerySchema.parse({ active: 'true', public: 'false' })).toEqual({ active: 'true', public: 'false' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const offerSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
termsAndConds: z.string().optional(),
|
||||
type: z.enum(['PERCENTAGE', 'FIXED_AMOUNT', 'FREE_DAY', 'SPECIAL_RATE']),
|
||||
discountValue: z.number().int().min(0),
|
||||
specialRate: z.number().int().optional(),
|
||||
appliesToAll: z.boolean().default(true),
|
||||
categories: z.array(z.enum(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'])).default([]),
|
||||
minRentalDays: z.number().int().optional(),
|
||||
maxRentalDays: z.number().int().optional(),
|
||||
promoCode: z.string().optional(),
|
||||
maxRedemptions: z.number().int().optional(),
|
||||
validFrom: z.string().datetime(),
|
||||
validUntil: z.string().datetime(),
|
||||
isActive: z.boolean().default(true),
|
||||
isPublic: z.boolean().default(true),
|
||||
isFeatured: z.boolean().default(false),
|
||||
vehicleIds: z.array(z.string()).default([]),
|
||||
})
|
||||
|
||||
export const listQuerySchema = z.object({
|
||||
active: z.string().optional(),
|
||||
public: z.string().optional(),
|
||||
})
|
||||
|
||||
export const idParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./offer.repo', () => ({
|
||||
findMany: vi.fn(),
|
||||
findByIdOrThrow: vi.fn(),
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
setActive: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
}))
|
||||
|
||||
import { NotFoundError } from '../../http/errors'
|
||||
import * as repo from './offer.repo'
|
||||
import { createOffer, deleteOffer, getOffer, getOfferStats, listOffers, setOfferActive, updateOffer } from './offer.service'
|
||||
|
||||
describe('offer.service', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('keeps listing filters tenant-scoped and delegates them unchanged to the repository', async () => {
|
||||
vi.mocked(repo.findMany).mockResolvedValue([{ id: 'offer_1' }] as never)
|
||||
|
||||
const result = await listOffers('company_1', { active: 'true', public: 'false' })
|
||||
|
||||
expect(repo.findMany).toHaveBeenCalledWith('company_1', { active: 'true', public: 'false' })
|
||||
expect(result).toEqual([{ id: 'offer_1' }])
|
||||
})
|
||||
|
||||
it('creates offers with separate offer data and vehicle assignment ids', async () => {
|
||||
vi.mocked(repo.create).mockResolvedValue({ id: 'offer_1' } as never)
|
||||
|
||||
const payload = { title: 'Weekend deal', type: 'PERCENTAGE', discountValue: 15 }
|
||||
await createOffer('company_1', payload, ['vehicle_1', 'vehicle_2'])
|
||||
|
||||
expect(repo.create).toHaveBeenCalledWith('company_1', payload, ['vehicle_1', 'vehicle_2'])
|
||||
})
|
||||
|
||||
it('throws NotFoundError before updating an offer outside the tenant boundary', async () => {
|
||||
vi.mocked(repo.findFirst).mockResolvedValue(null as never)
|
||||
|
||||
await expect(updateOffer('offer_1', 'company_1', { title: 'Nope' })).rejects.toBeInstanceOf(NotFoundError)
|
||||
expect(repo.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates an existing offer and preserves explicit vehicle assignment updates', async () => {
|
||||
vi.mocked(repo.findFirst).mockResolvedValue({ id: 'offer_1' } as never)
|
||||
vi.mocked(repo.update).mockResolvedValue({ id: 'offer_1', title: 'Summer' } as never)
|
||||
|
||||
const result = await updateOffer('offer_1', 'company_1', { title: 'Summer' }, [])
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith('offer_1', { title: 'Summer' }, [])
|
||||
expect(result).toEqual({ id: 'offer_1', title: 'Summer' })
|
||||
})
|
||||
|
||||
it('activates, deactivates, deletes, and reads stats through tenant-scoped repository methods', async () => {
|
||||
vi.mocked(repo.setActive).mockResolvedValue({ id: 'offer_1', isActive: true } as never)
|
||||
vi.mocked(repo.deleteMany).mockResolvedValue({ count: 1 } as never)
|
||||
vi.mocked(repo.getStats).mockResolvedValue({ redemptions: 3 } as never)
|
||||
vi.mocked(repo.findByIdOrThrow).mockResolvedValue({ id: 'offer_1' } as never)
|
||||
|
||||
await getOffer('offer_1', 'company_1')
|
||||
await setOfferActive('offer_1', 'company_1', true)
|
||||
await deleteOffer('offer_1', 'company_1')
|
||||
await getOfferStats('offer_1', 'company_1')
|
||||
|
||||
expect(repo.findByIdOrThrow).toHaveBeenCalledWith('offer_1', 'company_1')
|
||||
expect(repo.setActive).toHaveBeenCalledWith('offer_1', 'company_1', true)
|
||||
expect(repo.deleteMany).toHaveBeenCalledWith('offer_1', 'company_1')
|
||||
expect(repo.getStats).toHaveBeenCalledWith('offer_1', 'company_1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NotFoundError } from '../../http/errors'
|
||||
import * as repo from './offer.repo'
|
||||
|
||||
export function listOffers(companyId: string, filters: { active?: string; public?: string }) {
|
||||
return repo.findMany(companyId, filters)
|
||||
}
|
||||
|
||||
export function getOffer(id: string, companyId: string) {
|
||||
return repo.findByIdOrThrow(id, companyId)
|
||||
}
|
||||
|
||||
export function createOffer(companyId: string, data: any, vehicleIds: string[]) {
|
||||
return repo.create(companyId, data, vehicleIds)
|
||||
}
|
||||
|
||||
export async function updateOffer(id: string, companyId: string, data: any, vehicleIds?: string[]) {
|
||||
const existing = await repo.findFirst(id, companyId)
|
||||
if (!existing) throw new NotFoundError('Offer not found')
|
||||
return repo.update(id, data, vehicleIds)
|
||||
}
|
||||
|
||||
export function deleteOffer(id: string, companyId: string) {
|
||||
return repo.deleteMany(id, companyId)
|
||||
}
|
||||
|
||||
export function setOfferActive(id: string, companyId: string, isActive: boolean) {
|
||||
return repo.setActive(id, companyId, isActive)
|
||||
}
|
||||
|
||||
export function getOfferStats(id: string, companyId: string) {
|
||||
return repo.getStats(id, companyId)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createSchema as complaintCreateSchema, listQuerySchema as complaintListQuerySchema, updateSchema as complaintUpdateSchema } from './complaints/complaint.schemas'
|
||||
import { historyQuerySchema, preferencesSchema, unreadQuerySchema } from './notifications/notification.schemas'
|
||||
import { offerSchema } from './offers/offer.schemas'
|
||||
import { cancelSchema, checkoutSchema, startTrialSchema } from './subscriptions/subscription.schemas'
|
||||
import { inviteSchema, roleSchema } from './team/team.schemas'
|
||||
import { listQuerySchema as reviewListQuerySchema, replySchema } from './reviews/review.schemas'
|
||||
import { reportQuerySchema, summaryQuerySchema } from './analytics/analytics.schemas'
|
||||
|
||||
describe('operational schemas', () => {
|
||||
it('validates offers, complaints, and reviews without inventing impossible statuses', () => {
|
||||
expect(offerSchema.parse({
|
||||
title: 'Summer',
|
||||
type: 'PERCENTAGE',
|
||||
discountValue: 15,
|
||||
validFrom: '2026-07-01T00:00:00.000Z',
|
||||
validUntil: '2026-08-01T00:00:00.000Z',
|
||||
})).toMatchObject({ appliesToAll: true, categories: [], vehicleIds: [], isActive: true, isPublic: true })
|
||||
expect(offerSchema.safeParse({ title: '', type: 'PERCENTAGE', discountValue: 15, validFrom: 'bad', validUntil: 'bad' }).success).toBe(false)
|
||||
|
||||
expect(complaintCreateSchema.parse({ category: 'BILLING', subject: 'Wrong invoice' })).toMatchObject({ severity: 'LEVEL_1' })
|
||||
expect(complaintUpdateSchema.safeParse({ status: 'IGNORED' }).success).toBe(false)
|
||||
expect(complaintListQuerySchema.parse({ page: '2', pageSize: '25' })).toMatchObject({ page: 2, pageSize: 25 })
|
||||
expect(replySchema.safeParse({ companyReply: '' }).success).toBe(false)
|
||||
expect(reviewListQuerySchema.safeParse({ rating: '6' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('validates subscriptions, team roles, notifications, and analytics query defaults', () => {
|
||||
expect(checkoutSchema.safeParse({ plan: 'PRO', billingPeriod: 'MONTHLY', currency: 'MAD', provider: 'PAYPAL', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' }).success).toBe(true)
|
||||
expect(startTrialSchema.parse({ plan: 'STARTER', billingPeriod: 'ANNUAL' })).toMatchObject({ currency: 'MAD' })
|
||||
expect(cancelSchema.parse({})).toEqual({ mode: 'period_end' })
|
||||
expect(inviteSchema.safeParse({ firstName: 'A', lastName: 'B', email: 'agent@example.test', role: 'OWNER' }).success).toBe(false)
|
||||
expect(roleSchema.safeParse({ role: 'AGENT' }).success).toBe(true)
|
||||
|
||||
expect(preferencesSchema.safeParse([{ notificationType: 'BOOKING', channel: 'EMAIL', enabled: true }]).success).toBe(true)
|
||||
expect(unreadQuerySchema.parse({ unread: 'true' })).toEqual({ unread: 'true' })
|
||||
expect(historyQuerySchema.parse({ limit: '100' })).toEqual({ limit: 100 })
|
||||
expect(historyQuerySchema.safeParse({ limit: '501' }).success).toBe(false)
|
||||
expect(summaryQuerySchema.parse({})).toEqual({ period: '30d' })
|
||||
expect(reportQuerySchema.parse({})).toEqual({ format: 'JSON' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
rentalPayment: { findMany: vi.fn(), findFirst: vi.fn(), findFirstOrThrow: vi.fn(), update: vi.fn(), updateMany: vi.fn(), create: vi.fn() },
|
||||
reservation: { findFirstOrThrow: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as repo from './payment.repo'
|
||||
|
||||
describe('payment.repo edge queries', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('loads recent company payments with reservation, customer and vehicle context', async () => {
|
||||
await repo.findByCompany('company_1')
|
||||
|
||||
expect(prisma.rentalPayment.findMany).toHaveBeenCalledWith({
|
||||
where: { companyId: 'company_1' },
|
||||
include: { reservation: { include: { customer: true, vehicle: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
})
|
||||
})
|
||||
|
||||
it('finds payment by id only within company and reservation scope', async () => {
|
||||
await repo.findPaymentOrThrow('payment_1', 'company_1', 'reservation_1')
|
||||
|
||||
expect(prisma.rentalPayment.findFirstOrThrow).toHaveBeenCalledWith({
|
||||
where: { id: 'payment_1', companyId: 'company_1', reservationId: 'reservation_1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('marks a payment as succeeded with a fresh paidAt timestamp', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-01T12:00:00.000Z'))
|
||||
|
||||
await repo.markPaymentSucceeded('payment_1')
|
||||
|
||||
expect(prisma.rentalPayment.update).toHaveBeenCalledWith({
|
||||
where: { id: 'payment_1' },
|
||||
data: { status: 'SUCCEEDED', paidAt: new Date('2026-08-01T12:00:00.000Z') },
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('increments paid amount and promotes reservation payment status to paid', async () => {
|
||||
await repo.incrementReservationPaid('reservation_1', 2500)
|
||||
|
||||
expect(prisma.reservation.update).toHaveBeenCalledWith({
|
||||
where: { id: 'reservation_1' },
|
||||
data: { paymentStatus: 'PAID', paidAmount: { increment: 2500 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('maps partial refunds to the correct payment status', async () => {
|
||||
await repo.setPaymentRefunded('payment_1', true)
|
||||
await repo.setPaymentRefunded('payment_2', false)
|
||||
|
||||
expect(prisma.rentalPayment.update).toHaveBeenNthCalledWith(1, {
|
||||
where: { id: 'payment_1' },
|
||||
data: { status: 'PARTIALLY_REFUNDED' },
|
||||
})
|
||||
expect(prisma.rentalPayment.update).toHaveBeenNthCalledWith(2, {
|
||||
where: { id: 'payment_2' },
|
||||
data: { status: 'REFUNDED' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export function findByCompany(companyId: string) {
|
||||
return prisma.rentalPayment.findMany({
|
||||
where: { companyId },
|
||||
include: { reservation: { include: { customer: true, vehicle: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
})
|
||||
}
|
||||
|
||||
export function findByReservation(reservationId: string, companyId: string) {
|
||||
return prisma.rentalPayment.findMany({ where: { reservationId, companyId }, orderBy: { createdAt: 'desc' } })
|
||||
}
|
||||
|
||||
export function findByAmanpay(transactionId: string) {
|
||||
return prisma.rentalPayment.findFirst({ where: { amanpayTransactionId: transactionId } })
|
||||
}
|
||||
|
||||
export function findByPaypal(captureId: string) {
|
||||
return prisma.rentalPayment.findFirst({ where: { paypalCaptureId: captureId } })
|
||||
}
|
||||
|
||||
export function findByPaypalForCompany(paypalOrderId: string, companyId: string) {
|
||||
return prisma.rentalPayment.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId } })
|
||||
}
|
||||
|
||||
export function findPaymentOrThrow(paymentId: string, companyId: string, reservationId: string) {
|
||||
return prisma.rentalPayment.findFirstOrThrow({ where: { id: paymentId, companyId, reservationId } })
|
||||
}
|
||||
|
||||
export function findReservationOrThrow(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({
|
||||
where: { id, companyId },
|
||||
include: { vehicle: true, customer: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function findReservation(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export function markPaymentSucceeded(id: string) {
|
||||
return prisma.rentalPayment.update({ where: { id }, data: { status: 'SUCCEEDED', paidAt: new Date() } })
|
||||
}
|
||||
|
||||
export function markPaymentFailed(query: { amanpayTransactionId?: string; paypalCaptureId?: string }) {
|
||||
return prisma.rentalPayment.updateMany({ where: query, data: { status: 'FAILED' } })
|
||||
}
|
||||
|
||||
export function incrementReservationPaid(reservationId: string, amount: number) {
|
||||
return prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: amount } } })
|
||||
}
|
||||
|
||||
export function createPayment(data: {
|
||||
companyId: string; reservationId: string; amount: number; currency: string
|
||||
status: string; type: string; paymentProvider: string
|
||||
amanpayTransactionId?: string | null; paypalCaptureId?: string | null; paymentMethod?: string; paidAt?: Date
|
||||
}) {
|
||||
return prisma.rentalPayment.create({ data: data as any })
|
||||
}
|
||||
|
||||
export function updatePaypalCapture(id: string, captureId: string) {
|
||||
return prisma.rentalPayment.update({ where: { id }, data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId } })
|
||||
}
|
||||
|
||||
export function setReservationPaidAmount(reservationId: string, paidAmount: number, paymentStatus: string) {
|
||||
return prisma.reservation.update({ where: { id: reservationId }, data: { paidAmount, paymentStatus: paymentStatus as any } })
|
||||
}
|
||||
|
||||
export function setReservationRefunded(reservationId: string) {
|
||||
return prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'REFUNDED' } })
|
||||
}
|
||||
|
||||
export function setPaymentRefunded(id: string, partial: boolean) {
|
||||
return prisma.rentalPayment.update({ where: { id }, data: { status: partial ? 'PARTIALLY_REFUNDED' : 'REFUNDED' } })
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { parseBody, parseParams } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import { getRawBodyString, parseRawJsonBody } from '../../http/webhooks'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as service from './payment.service'
|
||||
import { chargeSchema, manualPaymentSchema, refundSchema, capturePaypalSchema, reservationParamSchema, paymentParamSchema } from './payment.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ─── Webhooks (no auth) ────────────────────────────────────────
|
||||
|
||||
router.post('/webhooks/amanpay', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = getRawBodyString(req)
|
||||
const payload = parseRawJsonBody(req)
|
||||
const signature = (req.headers['x-amanpay-signature'] as string) ?? ''
|
||||
if (!amanpay.isConfigured() || !amanpay.verifyWebhookSignature(rawBody, signature)) {
|
||||
return res.status(401).json({ error: 'invalid_signature' })
|
||||
}
|
||||
await service.handleAmanpayWebhook(payload, rawBody)
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/webhooks/paypal', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = getRawBodyString(req)
|
||||
const payload = parseRawJsonBody(req)
|
||||
const isValid = await paypal.verifyWebhookEvent(req.headers as Record<string, string>, rawBody)
|
||||
if (!paypal.isConfigured() || !isValid) return res.status(401).json({ error: 'invalid_signature' })
|
||||
await service.handlePaypalWebhook(payload, rawBody)
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Authenticated ────────────────────────────────────────────
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
router.get('/company', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.listByCompany(req.companyId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/reservations/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(reservationParamSchema, req)
|
||||
ok(res, await service.listByReservation(id, req.companyId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:id/charge', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(reservationParamSchema, req)
|
||||
const body = parseBody(chargeSchema, req)
|
||||
ok(res, await service.initCharge(id, req.companyId, body))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(reservationParamSchema, req)
|
||||
const { paypalOrderId } = parseBody(capturePaypalSchema, req)
|
||||
ok(res, await service.capturePaypal(id, req.companyId, paypalOrderId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:id/manual', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(reservationParamSchema, req)
|
||||
const body = parseBody(manualPaymentSchema, req)
|
||||
ok(res, await service.recordManualPayment(id, req.companyId, body))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:reservationId/payments/:paymentId/refund', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { reservationId, paymentId } = parseParams(paymentParamSchema, req)
|
||||
const { amount, reason } = parseBody(refundSchema, req)
|
||||
ok(res, await service.refundPayment(reservationId, paymentId, req.companyId, amount, reason))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { capturePaypalSchema, chargeSchema, manualPaymentSchema, paymentParamSchema, refundSchema, reservationParamSchema } from './payment.schemas'
|
||||
|
||||
describe('payment schemas edge cases', () => {
|
||||
it('defaults charge and manual payment currency/type while rejecting unsupported providers', () => {
|
||||
expect(chargeSchema.parse({ provider: 'PAYPAL', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' })).toMatchObject({
|
||||
provider: 'PAYPAL',
|
||||
type: 'CHARGE',
|
||||
currency: 'MAD',
|
||||
})
|
||||
expect(chargeSchema.safeParse({ provider: 'STRIPE', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' }).success).toBe(false)
|
||||
expect(manualPaymentSchema.parse({ amount: 500, paymentMethod: 'CASH' })).toMatchObject({ amount: 500, currency: 'MAD', type: 'CHARGE' })
|
||||
expect(manualPaymentSchema.safeParse({ amount: 0, paymentMethod: 'CASH' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('validates refund/capture/payment parameter payloads', () => {
|
||||
expect(refundSchema.parse({})).toEqual({})
|
||||
expect(refundSchema.safeParse({ amount: -1 }).success).toBe(false)
|
||||
expect(capturePaypalSchema.safeParse({ paypalOrderId: 'order_1' }).success).toBe(true)
|
||||
expect(capturePaypalSchema.safeParse({}).success).toBe(false)
|
||||
expect(reservationParamSchema.safeParse({ id: '' }).success).toBe(false)
|
||||
expect(paymentParamSchema.safeParse({ reservationId: 'reservation_1', paymentId: 'payment_1' }).success).toBe(true)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user