582 lines
18 KiB
TypeScript
582 lines
18 KiB
TypeScript
import { prisma } from '../../lib/prisma'
|
|
|
|
const companyListInclude = {
|
|
brand: { select: { displayName: true, logoUrl: true, subdomain: true } },
|
|
contractSettings: { select: { legalName: true } },
|
|
subscription: { select: { plan: true, status: true } },
|
|
_count: { select: { employees: true, vehicles: true } },
|
|
} as const
|
|
|
|
const companyDetailInclude = {
|
|
brand: true,
|
|
contractSettings: true,
|
|
accountingSettings: true,
|
|
subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } },
|
|
employees: true,
|
|
_count: { select: { employees: true, vehicles: true, customers: true, reservations: true } },
|
|
} as const
|
|
|
|
const billingInclude = {
|
|
company: { select: { id: true, name: true, email: true, slug: true, status: true } },
|
|
invoices: {
|
|
select: { id: true, amount: true, currency: true, status: true, paidAt: true, createdAt: true },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 5,
|
|
},
|
|
_count: { select: { invoices: true } },
|
|
} as const
|
|
|
|
function parseOptionalDate(value?: string | null) {
|
|
if (!value) return null
|
|
return new Date(value)
|
|
}
|
|
|
|
export function findAdminByEmail(email: string) {
|
|
return prisma.adminUser.findFirst({
|
|
where: {
|
|
email: {
|
|
equals: email,
|
|
mode: 'insensitive',
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
export function findAdminByIdOrThrow(id: string) {
|
|
return prisma.adminUser.findUniqueOrThrow({ where: { id } })
|
|
}
|
|
|
|
export function updateAdminLastLogin(id: string) {
|
|
return prisma.adminUser.update({ where: { id }, data: { lastLoginAt: new Date() } })
|
|
}
|
|
|
|
export function updateAdminTotpSecret(id: string, secret: string) {
|
|
return prisma.adminUser.update({ where: { id }, data: { totpSecret: secret } })
|
|
}
|
|
|
|
export function enableAdminTotp(id: string) {
|
|
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true } })
|
|
}
|
|
|
|
|
|
export async function replaceAdminRecoveryCodes(adminUserId: string, codeHashes: string[]) {
|
|
return prisma.$transaction(async (tx) => {
|
|
await tx.adminRecoveryCode.deleteMany({ where: { adminUserId } })
|
|
await tx.adminRecoveryCode.createMany({
|
|
data: codeHashes.map((codeHash) => ({ adminUserId, codeHash })),
|
|
})
|
|
})
|
|
}
|
|
|
|
export function listUnusedAdminRecoveryCodes(adminUserId: string) {
|
|
return prisma.adminRecoveryCode.findMany({
|
|
where: { adminUserId, usedAt: null },
|
|
select: { id: true, codeHash: true },
|
|
orderBy: { createdAt: 'asc' },
|
|
})
|
|
}
|
|
|
|
export function markAdminRecoveryCodeUsed(id: string) {
|
|
return prisma.adminRecoveryCode.update({ where: { id }, data: { usedAt: new Date() } })
|
|
}
|
|
|
|
export function setAdminPasswordReset(id: string, token: string, expiresAt: Date) {
|
|
return prisma.adminUser.update({
|
|
where: { id },
|
|
data: { passwordResetToken: token, passwordResetExpiresAt: expiresAt },
|
|
})
|
|
}
|
|
|
|
export function findAdminByResetToken(token: string) {
|
|
return prisma.adminUser.findFirst({
|
|
where: {
|
|
passwordResetToken: token,
|
|
passwordResetExpiresAt: { gt: new Date() },
|
|
},
|
|
})
|
|
}
|
|
|
|
export function updateAdminPassword(id: string, passwordHash: string) {
|
|
return prisma.adminUser.update({
|
|
where: { id },
|
|
data: {
|
|
passwordHash,
|
|
passwordResetToken: null,
|
|
passwordResetExpiresAt: null,
|
|
},
|
|
})
|
|
}
|
|
|
|
export function createAuditLog(data: Record<string, unknown>) {
|
|
return prisma.auditLog.create({ data: data as any })
|
|
}
|
|
|
|
export async function listCompaniesPage(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) {
|
|
const where: any = {}
|
|
if (query.status) where.status = query.status
|
|
if (query.q) {
|
|
where.OR = [
|
|
{ name: { contains: query.q, mode: 'insensitive' } },
|
|
{ email: { contains: query.q, mode: 'insensitive' } },
|
|
{ slug: { contains: query.q, mode: 'insensitive' } },
|
|
]
|
|
}
|
|
if (query.plan) where.subscription = { plan: query.plan }
|
|
|
|
const [data, total] = await Promise.all([
|
|
prisma.company.findMany({
|
|
where,
|
|
include: companyListInclude,
|
|
skip: (query.page - 1) * query.pageSize,
|
|
take: query.pageSize,
|
|
orderBy: { createdAt: 'desc' },
|
|
}),
|
|
prisma.company.count({ where }),
|
|
])
|
|
|
|
return { data, total }
|
|
}
|
|
|
|
export function getCompanyDetail(id: string) {
|
|
return prisma.company.findUniqueOrThrow({ where: { id }, include: companyDetailInclude })
|
|
}
|
|
|
|
export function getCompanyUpdateSnapshot(id: string) {
|
|
return prisma.company.findUniqueOrThrow({
|
|
where: { id },
|
|
include: { brand: true, contractSettings: true, accountingSettings: true, subscription: true },
|
|
})
|
|
}
|
|
|
|
export async function applyCompanyUpdate(
|
|
id: string,
|
|
body: any,
|
|
current: {
|
|
name: string
|
|
slug: string
|
|
address?: unknown
|
|
brand?: { paymentMethodsEnabled?: any[] | null } | null
|
|
},
|
|
) {
|
|
return prisma.$transaction(async (tx: any) => {
|
|
if (body.company) {
|
|
const companyData = { ...body.company }
|
|
if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) {
|
|
const baseAddress = current.address && typeof current.address === 'object' && !Array.isArray(current.address)
|
|
? current.address as Record<string, unknown>
|
|
: {}
|
|
companyData.address = { ...baseAddress, ...companyData.address }
|
|
}
|
|
await tx.company.update({ where: { id }, data: companyData })
|
|
}
|
|
|
|
if (body.subscription) {
|
|
const sub = body.subscription
|
|
await tx.subscription.upsert({
|
|
where: { companyId: id },
|
|
update: {
|
|
...sub,
|
|
trialStartAt: parseOptionalDate(sub.trialStartAt),
|
|
trialEndAt: parseOptionalDate(sub.trialEndAt),
|
|
currentPeriodStart: parseOptionalDate(sub.currentPeriodStart),
|
|
currentPeriodEnd: parseOptionalDate(sub.currentPeriodEnd),
|
|
cancelledAt: parseOptionalDate(sub.cancelledAt),
|
|
},
|
|
create: {
|
|
companyId: id,
|
|
plan: sub.plan ?? 'STARTER',
|
|
billingPeriod: sub.billingPeriod ?? 'MONTHLY',
|
|
status: sub.status ?? 'TRIALING',
|
|
currency: sub.currency ?? 'MAD',
|
|
trialStartAt: parseOptionalDate(sub.trialStartAt),
|
|
trialEndAt: parseOptionalDate(sub.trialEndAt),
|
|
currentPeriodStart: parseOptionalDate(sub.currentPeriodStart),
|
|
currentPeriodEnd: parseOptionalDate(sub.currentPeriodEnd),
|
|
cancelledAt: parseOptionalDate(sub.cancelledAt),
|
|
cancelAtPeriodEnd: sub.cancelAtPeriodEnd ?? false,
|
|
} as any,
|
|
})
|
|
}
|
|
|
|
if (body.brand) {
|
|
await tx.brandSettings.upsert({
|
|
where: { companyId: id },
|
|
update: body.brand,
|
|
create: {
|
|
companyId: id,
|
|
displayName: body.brand.displayName ?? current.name,
|
|
subdomain: body.brand.subdomain ?? current.slug,
|
|
paymentMethodsEnabled: current.brand?.paymentMethodsEnabled ?? [],
|
|
...body.brand,
|
|
} as any,
|
|
})
|
|
}
|
|
|
|
if (body.contractSettings) {
|
|
await tx.contractSettings.upsert({
|
|
where: { companyId: id },
|
|
update: body.contractSettings,
|
|
create: { companyId: id, ...body.contractSettings } as any,
|
|
})
|
|
}
|
|
|
|
if (body.accountingSettings) {
|
|
await tx.accountingSettings.upsert({
|
|
where: { companyId: id },
|
|
update: body.accountingSettings,
|
|
create: { companyId: id, ...body.accountingSettings } as any,
|
|
})
|
|
}
|
|
|
|
return tx.company.findUniqueOrThrow({ where: { id }, include: companyDetailInclude })
|
|
})
|
|
}
|
|
|
|
export function updateCompanyStatus(id: string, status: string) {
|
|
return prisma.company.update({ where: { id }, data: { status: status as any } })
|
|
}
|
|
|
|
export function deleteCompany(id: string) {
|
|
return prisma.company.delete({ where: { id } })
|
|
}
|
|
|
|
export function getCompanyForImpersonation(id: string) {
|
|
return prisma.company.findUniqueOrThrow({
|
|
where: { id },
|
|
include: { employees: { where: { role: 'OWNER' } } },
|
|
})
|
|
}
|
|
|
|
export async function listRentersPage(query: { q?: string; blocked?: string; page: number; pageSize: number }) {
|
|
const where: any = {}
|
|
if (query.blocked !== undefined) where.isActive = query.blocked === 'false'
|
|
if (query.q) {
|
|
where.OR = [
|
|
{ firstName: { contains: query.q, mode: 'insensitive' } },
|
|
{ email: { contains: query.q, mode: 'insensitive' } },
|
|
]
|
|
}
|
|
|
|
const [data, total] = await Promise.all([
|
|
prisma.renter.findMany({
|
|
where,
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
email: true,
|
|
phone: true,
|
|
isActive: true,
|
|
createdAt: true,
|
|
_count: { select: { reservations: true } },
|
|
},
|
|
skip: (query.page - 1) * query.pageSize,
|
|
take: query.pageSize,
|
|
orderBy: { createdAt: 'desc' },
|
|
}),
|
|
prisma.renter.count({ where }),
|
|
])
|
|
|
|
return { data, total }
|
|
}
|
|
|
|
export function updateRenterActive(id: string, isActive: boolean) {
|
|
return prisma.renter.update({ where: { id }, data: { isActive } })
|
|
}
|
|
|
|
export async function getPlatformMetricCounts() {
|
|
const [
|
|
totalCompanies,
|
|
activeCompanies,
|
|
trialingCompanies,
|
|
suspendedCompanies,
|
|
totalRenters,
|
|
totalReservations,
|
|
] = await Promise.all([
|
|
prisma.company.count(),
|
|
prisma.company.count({ where: { status: 'ACTIVE' } }),
|
|
prisma.company.count({ where: { status: 'TRIALING' } }),
|
|
prisma.company.count({ where: { status: 'SUSPENDED' } }),
|
|
prisma.renter.count(),
|
|
prisma.reservation.count(),
|
|
])
|
|
|
|
return {
|
|
totalCompanies,
|
|
activeCompanies,
|
|
trialingCompanies,
|
|
suspendedCompanies,
|
|
totalRenters,
|
|
totalReservations,
|
|
}
|
|
}
|
|
|
|
export async function listAuditLogsPage(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) {
|
|
const where: any = {}
|
|
if (query.adminId) where.adminUserId = query.adminId
|
|
if (query.action) where.action = { contains: query.action }
|
|
if (query.companyId) where.companyId = query.companyId
|
|
if (query.entityId) where.resourceId = query.entityId
|
|
|
|
const [data, total] = await Promise.all([
|
|
prisma.auditLog.findMany({
|
|
where,
|
|
include: { adminUser: { select: { firstName: true, lastName: true, email: true } } },
|
|
skip: (query.page - 1) * query.pageSize,
|
|
take: query.pageSize,
|
|
orderBy: { createdAt: 'desc' },
|
|
}),
|
|
prisma.auditLog.count({ where }),
|
|
])
|
|
|
|
return { data, total }
|
|
}
|
|
|
|
export function listAdmins() {
|
|
return prisma.adminUser.findMany({
|
|
include: { permissions: true },
|
|
})
|
|
}
|
|
|
|
export function createAdmin(data: {
|
|
email: string
|
|
firstName: string
|
|
lastName: string
|
|
role: string
|
|
passwordHash: string
|
|
permissions?: any[]
|
|
}) {
|
|
return prisma.adminUser.create({
|
|
data: {
|
|
email: data.email,
|
|
firstName: data.firstName,
|
|
lastName: data.lastName,
|
|
role: data.role as any,
|
|
passwordHash: data.passwordHash,
|
|
permissions: data.permissions ? { create: data.permissions } : undefined,
|
|
},
|
|
include: { permissions: true },
|
|
})
|
|
}
|
|
|
|
export function updateAdminRole(id: string, role: string) {
|
|
return prisma.adminUser.update({ where: { id }, data: { role: role as any } })
|
|
}
|
|
|
|
export function updateAdmin(
|
|
id: string,
|
|
data: {
|
|
email?: string
|
|
firstName?: string
|
|
lastName?: string
|
|
role?: string
|
|
passwordHash?: string
|
|
isActive?: boolean
|
|
},
|
|
) {
|
|
return prisma.adminUser.update({
|
|
where: { id },
|
|
data: {
|
|
...(data.email !== undefined ? { email: data.email } : {}),
|
|
...(data.firstName !== undefined ? { firstName: data.firstName } : {}),
|
|
...(data.lastName !== undefined ? { lastName: data.lastName } : {}),
|
|
...(data.role !== undefined ? { role: data.role as any } : {}),
|
|
...(data.passwordHash !== undefined ? { passwordHash: data.passwordHash } : {}),
|
|
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
|
|
},
|
|
include: { permissions: true },
|
|
})
|
|
}
|
|
|
|
export async function replaceAdminPermissions(id: string, permissions: any[]) {
|
|
await prisma.adminUser.findUniqueOrThrow({ where: { id } })
|
|
await prisma.$transaction([
|
|
prisma.adminPermission.deleteMany({ where: { adminUserId: id } }),
|
|
prisma.adminPermission.createMany({
|
|
data: permissions.map((permission) => ({
|
|
adminUserId: id,
|
|
resource: permission.resource,
|
|
actions: permission.actions,
|
|
})) as any,
|
|
}),
|
|
])
|
|
return prisma.adminUser.findUniqueOrThrow({ where: { id }, include: { permissions: true } })
|
|
}
|
|
|
|
export async function listBillingPage(query: { status?: string; plan?: string; page: number; pageSize: number }) {
|
|
const where: any = {}
|
|
if (query.status) where.status = query.status
|
|
if (query.plan) where.plan = query.plan
|
|
|
|
const [data, total] = await Promise.all([
|
|
prisma.subscription.findMany({
|
|
where,
|
|
include: billingInclude,
|
|
skip: (query.page - 1) * query.pageSize,
|
|
take: query.pageSize,
|
|
orderBy: { createdAt: 'desc' },
|
|
}),
|
|
prisma.subscription.count({ where }),
|
|
])
|
|
|
|
return { data, total }
|
|
}
|
|
|
|
export function listActiveSubscriptionsForMrr() {
|
|
return prisma.subscription.findMany({
|
|
where: { status: { in: ['ACTIVE', 'TRIALING'] as any[] } },
|
|
select: { plan: true, billingPeriod: true },
|
|
})
|
|
}
|
|
|
|
export async function getBillingStatusCounts() {
|
|
const [activeCount, trialingCount, pastDueCount, cancelledCount] = await Promise.all([
|
|
prisma.subscription.count({ where: { status: 'ACTIVE' } }),
|
|
prisma.subscription.count({ where: { status: 'TRIALING' } }),
|
|
prisma.subscription.count({ where: { status: 'PAST_DUE' } }),
|
|
prisma.subscription.count({ where: { status: 'CANCELLED' } }),
|
|
])
|
|
|
|
return { activeCount, trialingCount, pastDueCount, cancelledCount }
|
|
}
|
|
|
|
export async function listCompanyInvoicesPage(companyId: string, query: { page: number; pageSize: number }) {
|
|
const [data, total] = await Promise.all([
|
|
prisma.subscriptionInvoice.findMany({
|
|
where: { companyId },
|
|
orderBy: { createdAt: 'desc' },
|
|
skip: (query.page - 1) * query.pageSize,
|
|
take: query.pageSize,
|
|
}),
|
|
prisma.subscriptionInvoice.count({ where: { companyId } }),
|
|
])
|
|
|
|
return { data, total }
|
|
}
|
|
|
|
export function getInvoicePdfRecord(invoiceId: string) {
|
|
return prisma.subscriptionInvoice.findUniqueOrThrow({
|
|
where: { id: invoiceId },
|
|
include: {
|
|
company: { select: { name: true, email: true, phone: true, address: true } },
|
|
subscription: {
|
|
select: {
|
|
plan: true,
|
|
billingPeriod: true,
|
|
currency: true,
|
|
currentPeriodStart: true,
|
|
currentPeriodEnd: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
export function listPricingConfigs() {
|
|
return prisma.pricingConfig.findMany({ orderBy: [{ plan: 'asc' }, { billingPeriod: 'asc' }] })
|
|
}
|
|
|
|
export function upsertPricingConfig(plan: string, billingPeriod: string, amount: number, updatedBy?: string) {
|
|
return prisma.pricingConfig.upsert({
|
|
where: { plan_billingPeriod: { plan, billingPeriod } },
|
|
update: { amount, updatedBy },
|
|
create: { id: `prc_${plan.toLowerCase()}_${billingPeriod.toLowerCase()}`, plan, billingPeriod, amount, updatedBy },
|
|
})
|
|
}
|
|
|
|
export function listPlanFeatures() {
|
|
return prisma.planFeature.findMany({
|
|
orderBy: [{ plan: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
|
|
})
|
|
}
|
|
|
|
export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder?: number }) {
|
|
return prisma.planFeature.create({
|
|
data: {
|
|
plan: data.plan,
|
|
label: data.label,
|
|
sortOrder: data.sortOrder ?? 0,
|
|
},
|
|
})
|
|
}
|
|
|
|
export function updatePlanFeature(id: string, data: Partial<{ plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder: number }>) {
|
|
return prisma.planFeature.update({
|
|
where: { id },
|
|
data,
|
|
})
|
|
}
|
|
|
|
export function deletePlanFeature(id: string) {
|
|
return prisma.planFeature.delete({ where: { id } })
|
|
}
|
|
|
|
// ─── Pricing promotions ────────────────────────────────────────────────────
|
|
|
|
export function listPromotions() {
|
|
return (prisma as any).pricingPromotion.findMany({ orderBy: { createdAt: 'desc' } })
|
|
}
|
|
|
|
export function createPromotion(data: {
|
|
code: string; name: string; description?: string | null
|
|
discountType: string; discountValue: number
|
|
plans: string[]; periods: string[]
|
|
maxUses?: number | null; validFrom: string; validUntil?: string | null
|
|
isActive: boolean; createdBy?: string | null
|
|
}) {
|
|
return (prisma as any).pricingPromotion.create({
|
|
data: {
|
|
...data,
|
|
validFrom: new Date(data.validFrom),
|
|
validUntil: data.validUntil ? new Date(data.validUntil) : null,
|
|
},
|
|
})
|
|
}
|
|
|
|
export function updatePromotion(id: string, data: Partial<{
|
|
code: string; name: string; description: string | null
|
|
discountType: string; discountValue: number
|
|
plans: string[]; periods: string[]
|
|
maxUses: number | null; validFrom: string; validUntil: string | null
|
|
isActive: boolean
|
|
}>) {
|
|
const { validFrom, validUntil, ...rest } = data
|
|
return (prisma as any).pricingPromotion.update({
|
|
where: { id },
|
|
data: {
|
|
...rest,
|
|
...(validFrom ? { validFrom: new Date(validFrom) } : {}),
|
|
...(validUntil !== undefined ? { validUntil: validUntil ? new Date(validUntil) : null } : {}),
|
|
},
|
|
})
|
|
}
|
|
|
|
export function deletePromotion(id: string) {
|
|
return (prisma as any).pricingPromotion.delete({ where: { id } })
|
|
}
|
|
|
|
export async function listNotificationsPage(query: {
|
|
channel?: string
|
|
status?: string
|
|
companyId?: string
|
|
page: number
|
|
pageSize: number
|
|
}) {
|
|
const where: any = {}
|
|
if (query.channel) where.channel = query.channel
|
|
if (query.status) where.status = query.status
|
|
if (query.companyId) where.companyId = query.companyId
|
|
|
|
const [data, total] = await Promise.all([
|
|
prisma.notification.findMany({
|
|
where,
|
|
orderBy: { createdAt: 'desc' },
|
|
skip: (query.page - 1) * query.pageSize,
|
|
take: query.pageSize,
|
|
include: { company: { select: { name: true } } },
|
|
}),
|
|
prisma.notification.count({ where }),
|
|
])
|
|
return { data, total }
|
|
}
|