archetecture security fix

This commit is contained in:
root
2026-06-11 03:22:12 -04:00
parent 6def9993da
commit 9483750161
3126 changed files with 177194 additions and 37211 deletions
@@ -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 }
}