apply security fix
Build & Push / Build & Push Docker Image (push) Failing after 3m46s

This commit is contained in:
root
2026-07-19 22:22:33 -04:00
parent ec03e783e5
commit a010ad6811
41 changed files with 626 additions and 224 deletions
+5 -3
View File
@@ -1,4 +1,5 @@
import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
const companyListInclude = {
brand: { select: { displayName: true, logoUrl: true, subdomain: true } },
@@ -80,17 +81,18 @@ export function markAdminRecoveryCodeUsed(id: string) {
return prisma.adminRecoveryCode.update({ where: { id }, data: { usedAt: new Date() } })
}
export function setAdminPasswordReset(id: string, token: string, expiresAt: Date) {
export function setAdminPasswordReset(id: string, tokenHash: string, expiresAt: Date) {
return prisma.adminUser.update({
where: { id },
data: { passwordResetToken: token, passwordResetExpiresAt: expiresAt },
data: { passwordResetToken: tokenHash, passwordResetExpiresAt: expiresAt },
})
}
export function findAdminByResetToken(token: string) {
const tokenHash = hashPublicAccessToken(token)
return prisma.adminUser.findFirst({
where: {
passwordResetToken: token,
OR: [{ passwordResetToken: tokenHash }, { passwordResetToken: token }],
passwordResetExpiresAt: { gt: new Date() },
},
})
+2 -2
View File
@@ -1,5 +1,5 @@
import { Router } from 'express'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from '../../middleware/requireAdminAuth'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA, requireFreshAdmin2FAWhenEnabled } from '../../middleware/requireAdminAuth'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies'
@@ -80,7 +80,7 @@ router.get('/auth/me', requireAdminAuth, (req, res) => {
ok(res, presentAdminUser(req.admin as any))
})
router.post('/auth/2fa/setup', requireAdminAuth, async (req, res, next) => {
router.post('/auth/2fa/setup', requireAdminAuth, requireFreshAdmin2FAWhenEnabled, async (req, res, next) => {
try {
ok(res, await service.setupTotp(req.admin.id, req.admin.email))
} catch (err) { next(err) }
+2 -1
View File
@@ -1,5 +1,6 @@
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { authenticator } from 'otplib'
import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode'
@@ -144,7 +145,7 @@ export async function forgotPassword(email: string) {
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)
await repo.setAdminPasswordReset(admin.id, hashPublicAccessToken(rawToken), expiresAt)
const adminUrl = ensureAdminBasePath(
process.env.ADMIN_URL ?? process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3000/admin',
@@ -1,7 +1,7 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireSubscriptionRead } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseQuery } from '../../http/validate'
import { ok } from '../../http/respond'
@@ -10,22 +10,22 @@ import { summaryQuerySchema, reportQuerySchema } from './analytics.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
router.get('/summary', async (req, res, next) => {
router.get('/summary', requireRole('MANAGER'), 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) => {
router.get('/dashboard', requireRole('MANAGER'), async (req, res, next) => {
try {
ok(res, await service.getDashboard(req.companyId))
} catch (err) { next(err) }
})
router.get('/sources', async (req, res, next) => {
router.get('/sources', requireRole('MANAGER'), async (req, res, next) => {
try {
ok(res, await service.getSources(req.companyId))
} catch (err) { next(err) }
@@ -2,6 +2,7 @@ import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { sendTransactionalEmail } from '../../services/notificationService'
import * as repo from './auth.company.repo'
import type { output } from 'zod'
@@ -81,7 +82,7 @@ export async function startAccount(body: AccountStartInput) {
lastName: '',
email: body.email,
passwordHash,
emailVerificationToken: verificationToken,
emailVerificationToken: hashPublicAccessToken(verificationToken),
role: 'OWNER',
preferredLanguage: body.preferredLanguage,
isActive: true,
@@ -1,4 +1,5 @@
import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
export function findEmployeeWithCompanyById(id: string) {
return prisma.employee.findUnique({
@@ -52,9 +53,10 @@ export function updatePreferredLanguage(id: string, preferredLanguage: 'en' | 'f
}
export function findEmployeeByResetToken(token: string) {
const tokenHash = hashPublicAccessToken(token)
return prisma.employee.findFirst({
where: {
passwordResetToken: token,
OR: [{ passwordResetToken: tokenHash }, { passwordResetToken: token }],
passwordResetExpiresAt: { gt: new Date() },
},
})
@@ -71,16 +73,17 @@ export function resetPassword(id: string, passwordHash: string) {
})
}
export function setEmailVerificationToken(id: string, token: string) {
export function setEmailVerificationToken(id: string, tokenHash: string) {
return prisma.employee.update({
where: { id },
data: { emailVerificationToken: token },
data: { emailVerificationToken: tokenHash },
})
}
export function findEmployeeByVerificationToken(token: string) {
const tokenHash = hashPublicAccessToken(token)
return prisma.employee.findFirst({
where: { emailVerificationToken: token },
where: { OR: [{ emailVerificationToken: tokenHash }, { emailVerificationToken: token }] },
include: { company: true },
})
}
@@ -3,6 +3,7 @@ import crypto from 'crypto'
import jwt from 'jsonwebtoken'
import { signActorToken } from '../../security/tokens'
import { AppError } from '../../http/errors'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { sendTransactionalEmail } from '../../services/notificationService'
import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations'
import { presentEmployeeSession } from './auth.presenter'
@@ -204,7 +205,7 @@ export async function resendVerification(email: string) {
}
const verificationToken = crypto.randomBytes(32).toString('hex')
await repo.setEmailVerificationToken(employee.id, verificationToken)
await repo.setEmailVerificationToken(employee.id, hashPublicAccessToken(verificationToken))
const dashboardUrl = ensureDashboardBasePath(
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
@@ -1,7 +1,7 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireSubscriptionRead, requireSubscriptionFull } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseParams, parseQuery } from '../../http/validate'
import { ok } from '../../http/respond'
@@ -15,7 +15,7 @@ import {
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription, requireRole('OWNER'))
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead, requireRole('OWNER'))
router.get('/summary', async (req, res, next) => {
try {
@@ -38,7 +38,7 @@ router.get('/invoices/:invoiceId', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/invoices/:invoiceId/payments/manual', async (req, res, next) => {
router.post('/invoices/:invoiceId/payments/manual', requireSubscriptionFull, async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceParamSchema, req)
const body = parseBody(manualBillingPaymentSchema, req)
@@ -43,7 +43,7 @@ export async function findPublishedVehicles(where: any) {
export async function findVehicleForCarplace(vehicleId: string, companySlug: string) {
return prisma.vehicle.findFirst({
where: { id: vehicleId, isPublished: true, company: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug: companySlug }, { brand: { subdomain: companySlug } }] } },
where: { id: vehicleId, isPublished: true, status: 'AVAILABLE', company: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug: companySlug }, { brand: { subdomain: companySlug } }] } },
include: { company: { include: { brand: true } } },
})
}
@@ -53,6 +53,7 @@ export async function findVehicleForCarplaceById(vehicleId: string) {
where: {
id: vehicleId,
isPublished: true,
status: 'AVAILABLE',
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: true } },
},
include: { company: { include: { brand: true } } },
@@ -139,6 +140,7 @@ export async function createCarplaceFunnelEvent(data: {
id: data.vehicleId,
isPublished: true,
company: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug: data.companySlug }, { brand: { subdomain: data.companySlug } }] },
status: 'AVAILABLE',
},
select: { companyId: true },
})
@@ -1,8 +1,8 @@
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),
page: z.coerce.number().int().min(1).max(100).default(1),
pageSize: z.coerce.number().int().min(1).max(50).default(20),
})
export const searchSchema = z.object({
@@ -83,7 +83,7 @@ export const carplaceFunnelEventSchema = z.object({
vehicleId: z.string().cuid(),
sessionId: z.string().trim().max(100).optional(),
path: z.string().trim().max(500).optional(),
metadata: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
metadata: z.record(z.string().max(50), z.union([z.string().max(500), z.number(), z.boolean(), z.null()])).refine((value) => Object.keys(value).length <= 20, 'metadata may contain at most 20 keys').optional(),
})
export const reviewBodySchema = z.object({
@@ -1,7 +1,7 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { requireCompanyPolicy } from '../../middleware/requireCompanyPolicy'
import { parseBody, parseParams } from '../../http/validate'
@@ -22,7 +22,7 @@ import {
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
router.get('/me', async (req, res, next) => {
try {
@@ -31,7 +31,7 @@ router.get('/me', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/me', requireRole('OWNER'), async (req, res, next) => {
router.patch('/me', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(companySchema, req)
const company = await service.updateCompany(req.companyId, body)
@@ -46,7 +46,7 @@ router.get('/me/brand', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/me/brand', requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), async (req, res, next) => {
router.patch('/me/brand', requireSubscriptionWrite, requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), async (req, res, next) => {
try {
const body = parseBody(brandSchema, req)
const brand = await service.updateBrand(req.companyId, body, req.company.name, req.company.slug)
@@ -54,7 +54,7 @@ router.patch('/me/brand', requireRole('OWNER'), requireSettingsFeature('settings
} catch (err) { next(err) }
})
router.post('/me/brand/logo', requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), imageUpload.single('file'), async (req, res, next) => {
router.post('/me/brand/logo', requireSubscriptionWrite, requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), 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)
@@ -62,7 +62,7 @@ router.post('/me/brand/logo', requireRole('OWNER'), requireSettingsFeature('sett
} catch (err) { next(err) }
})
router.post('/me/brand/hero', requireRole('OWNER'), requireSettingsFeature('settings.branding_hero'), imageUpload.single('file'), async (req, res, next) => {
router.post('/me/brand/hero', requireSubscriptionWrite, requireRole('OWNER'), requireSettingsFeature('settings.branding_hero'), 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)
@@ -82,7 +82,7 @@ router.get('/me/settings-entitlements', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res, next) => {
router.post('/me/brand/subdomain/check', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => {
try {
const { subdomain } = parseBody(subdomainSchema, req)
const result = await service.checkSubdomainAvailability(subdomain, req.companyId)
@@ -90,7 +90,7 @@ router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res,
} catch (err) { next(err) }
})
router.post('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => {
router.post('/me/brand/custom-domain', requireSubscriptionWrite, 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)
@@ -105,21 +105,21 @@ router.get('/me/brand/custom-domain/status', async (req, res, next) => {
} catch (err) { next(err) }
})
router.delete('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => {
router.delete('/me/brand/custom-domain', requireSubscriptionWrite, 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) => {
router.get('/me/contract-settings', requireRole('MANAGER'), 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'), requireSettingsFeature('settings.rental_policies_basic'), async (req, res, next) => {
router.patch('/me/contract-settings', requireSubscriptionWrite, requireRole('MANAGER'), requireSettingsFeature('settings.rental_policies_basic'), async (req, res, next) => {
try {
const body = parseBody(contractSettingsSchema, req)
const settings = await service.updateContractSettings(req.companyId, body)
@@ -127,14 +127,14 @@ router.patch('/me/contract-settings', requireRole('MANAGER'), requireSettingsFea
} catch (err) { next(err) }
})
router.get('/me/insurance-policies', async (req, res, next) => {
router.get('/me/insurance-policies', requireRole('MANAGER'), 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'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
router.post('/me/insurance-policies', requireSubscriptionWrite, requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
try {
const body = parseBody(insurancePolicySchema, req)
const policy = await service.createInsurancePolicy(req.companyId, body)
@@ -142,7 +142,7 @@ router.post('/me/insurance-policies', requireRole('MANAGER'), requireSettingsFea
} catch (err) { next(err) }
})
router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
router.patch('/me/insurance-policies/:id', requireSubscriptionWrite, requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(insurancePolicySchema.partial(), req)
@@ -151,7 +151,7 @@ router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), requireSettin
} catch (err) { next(err) }
})
router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
router.delete('/me/insurance-policies/:id', requireSubscriptionWrite, requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deleteInsurancePolicy(id, req.companyId)
@@ -159,14 +159,14 @@ router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), requireSetti
} catch (err) { next(err) }
})
router.get('/me/pricing-rules', async (req, res, next) => {
router.get('/me/pricing-rules', requireRole('MANAGER'), 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'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
router.post('/me/pricing-rules', requireSubscriptionWrite, requireRole('MANAGER'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
try {
const body = parseBody(pricingRuleSchema, req)
const rule = await service.createPricingRule(req.companyId, body)
@@ -174,7 +174,7 @@ router.post('/me/pricing-rules', requireRole('MANAGER'), requireSettingsFeature(
} catch (err) { next(err) }
})
router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
router.patch('/me/pricing-rules/:id', requireSubscriptionWrite, requireRole('MANAGER'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(pricingRuleSchema.partial(), req)
@@ -183,7 +183,7 @@ router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), requireSettingsFea
} catch (err) { next(err) }
})
router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
router.delete('/me/pricing-rules/:id', requireSubscriptionWrite, requireCompanyPolicy('deletePricingRule'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deletePricingRule(id, req.companyId)
@@ -191,14 +191,14 @@ router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule')
} catch (err) { next(err) }
})
router.get('/me/accounting-settings', async (req, res, next) => {
router.get('/me/accounting-settings', requireRole('MANAGER'), 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'), requireSettingsFeature('settings.accounting_defaults'), async (req, res, next) => {
router.patch('/me/accounting-settings', requireSubscriptionWrite, requireCompanyPolicy('updateAccountingSettings'), requireSettingsFeature('settings.accounting_defaults'), async (req, res, next) => {
try {
const body = parseBody(accountingSettingsSchema, req)
const settings = await service.updateAccountingSettings(req.companyId, body)
@@ -213,7 +213,7 @@ router.get('/me/api-key', requireCompanyPolicy('viewApiKey'), async (req, res, n
} catch (err) { next(err) }
})
router.post('/me/api-key/regenerate', requireCompanyPolicy('regenerateApiKey'), async (req, res, next) => {
router.post('/me/api-key/regenerate', requireSubscriptionWrite, requireCompanyPolicy('regenerateApiKey'), async (req, res, next) => {
try {
const company = await service.regenerateApiKey(req.companyId)
ok(res, company)
@@ -1,7 +1,8 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireSubscriptionRead, requireSubscriptionWrite } 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 './complaint.service'
@@ -9,7 +10,7 @@ import { createSchema, updateSchema, listQuerySchema, idParamSchema } from './co
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
router.get('/', async (req, res, next) => {
try {
@@ -19,7 +20,7 @@ router.get('/', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/', async (req, res, next) => {
router.post('/', requireSubscriptionWrite, async (req, res, next) => {
try {
const body = parseBody(createSchema, req)
const complaint = await service.createComplaint(req.companyId, body)
@@ -35,7 +36,7 @@ router.get('/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/:id', async (req, res, next) => {
router.patch('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(updateSchema, req)
@@ -44,7 +45,7 @@ router.patch('/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.delete('/:id', async (req, res, next) => {
router.delete('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const result = await service.deleteComplaint(id, req.companyId)
@@ -1,7 +1,7 @@
import { Router } from 'express'
import { requireCompanyAuth, requireCompanyDocumentAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
@@ -11,7 +11,7 @@ import { customerSchema, listQuerySchema, approveLicenseSchema, flagSchema, idPa
const router = Router()
router.get('/:id/license-image', requireCompanyDocumentAuth, requireTenant, requireSubscription, async (req, res, next) => {
router.get('/:id/license-image', requireCompanyDocumentAuth, requireTenant, requireSubscriptionRead, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const filePath = await service.getLicenseImageFile(id, req.companyId)
@@ -19,7 +19,7 @@ router.get('/:id/license-image', requireCompanyDocumentAuth, requireTenant, requ
} catch (err) { next(err) }
})
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
router.get('/', async (req, res, next) => {
try {
const query = parseQuery(listQuerySchema, req)
@@ -28,7 +28,7 @@ router.get('/', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/', async (req, res, next) => {
router.post('/', requireSubscriptionWrite, async (req, res, next) => {
try {
const body = parseBody(customerSchema, req)
const customer = await service.createCustomer(body, req.companyId)
@@ -44,7 +44,7 @@ router.get('/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
router.patch('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(customerSchema.partial(), req)
@@ -53,7 +53,7 @@ router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/flag', requireRole('MANAGER'), async (req, res, next) => {
router.post('/:id/flag', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { reason } = parseBody(flagSchema, req)
@@ -62,7 +62,7 @@ router.post('/:id/flag', requireRole('MANAGER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.delete('/:id/flag', requireRole('MANAGER'), async (req, res, next) => {
router.delete('/:id/flag', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.unflagCustomer(id, req.companyId)
@@ -70,7 +70,7 @@ router.delete('/:id/flag', requireRole('MANAGER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/validate-license', async (req, res, next) => {
router.post('/:id/validate-license', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const result = await service.validateCustomerLicense(id, req.companyId)
@@ -78,7 +78,7 @@ router.post('/:id/validate-license', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/license-image', imageUpload.single('file'), async (req, res, next) => {
router.post('/:id/license-image', requireSubscriptionWrite, imageUpload.single('file'), async (req, res, next) => {
try {
assertImageFile(req.file, 'license image')
const { id } = parseParams(idParamSchema, req)
@@ -87,7 +87,7 @@ router.post('/:id/license-image', imageUpload.single('file'), async (req, res, n
} catch (err) { next(err) }
})
router.post('/:id/approve-license', requireRole('MANAGER'), async (req, res, next) => {
router.post('/:id/approve-license', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { decision, note } = parseBody(approveLicenseSchema, req)
@@ -1,7 +1,7 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription'
import { requireRenterAuth } from '../../middleware/requireRenterAuth'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok } from '../../http/respond'
@@ -10,24 +10,25 @@ import { preferencesSchema, idParamSchema, unreadQuerySchema, historyQuerySchema
const router = Router()
const companyAuth = [requireCompanyAuth, requireTenant, requireSubscription] as const
const companyReadAuth = [requireCompanyAuth, requireTenant, requireSubscriptionRead] as const
const companyWriteAuth = [requireCompanyAuth, requireTenant, requireSubscriptionRead, requireSubscriptionWrite] as const
// ─── Company notifications ────────────────────────────────────
router.get('/company', ...companyAuth, async (req, res, next) => {
router.get('/company', ...companyReadAuth, 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) => {
router.get('/unread-count', ...companyReadAuth, 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) => {
router.post('/company/:id/read', ...companyWriteAuth, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.markRead(id, req.companyId)
@@ -35,20 +36,20 @@ router.post('/company/:id/read', ...companyAuth, async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/company/read-all', ...companyAuth, async (req, res, next) => {
router.post('/company/read-all', ...companyWriteAuth, 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) => {
router.get('/company/preferences', ...companyReadAuth, 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) => {
router.patch('/company/preferences', ...companyWriteAuth, async (req, res, next) => {
try {
const prefs = parseBody(preferencesSchema, req)
await service.setPreferences(req.employee.id, prefs)
@@ -56,7 +57,7 @@ router.patch('/company/preferences', ...companyAuth, async (req, res, next) => {
} catch (err) { next(err) }
})
router.get('/history', ...companyAuth, async (req, res, next) => {
router.get('/history', ...companyReadAuth, async (req, res, next) => {
try {
const { channel, status, limit } = parseQuery(historyQuerySchema, req)
ok(res, await service.listCompanyHistory(req.companyId, { channel, status, limit }))
+7 -7
View File
@@ -1,7 +1,7 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
@@ -10,7 +10,7 @@ import { offerSchema, listQuerySchema, idParamSchema } from './offer.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
router.get('/', async (req, res, next) => {
try {
@@ -19,7 +19,7 @@ router.get('/', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/', requireRole('MANAGER'), async (req, res, next) => {
router.post('/', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { vehicleIds, ...body } = parseBody(offerSchema, req)
created(res, await service.createOffer(req.companyId, body, vehicleIds))
@@ -33,7 +33,7 @@ router.get('/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
router.patch('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { vehicleIds, ...body } = parseBody(offerSchema.partial(), req)
@@ -41,7 +41,7 @@ router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => {
router.delete('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deleteOffer(id, req.companyId)
@@ -49,7 +49,7 @@ router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/activate', requireRole('MANAGER'), async (req, res, next) => {
router.post('/:id/activate', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.setOfferActive(id, req.companyId, true)
@@ -57,7 +57,7 @@ router.post('/:id/activate', requireRole('MANAGER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/deactivate', requireRole('MANAGER'), async (req, res, next) => {
router.post('/:id/deactivate', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.setOfferActive(id, req.companyId, false)
@@ -85,6 +85,17 @@ export function updatePaypalCapture(id: string, captureId: string) {
return prisma.rentalPayment.update({ where: { id }, data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId } })
}
export async function updatePendingPaypalCapture(id: string, captureId: string) {
return prisma.$transaction(async (tx) => {
const result = await tx.rentalPayment.updateMany({
where: { id, status: 'PENDING' },
data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId },
})
if (result.count !== 1) throw new Error('Payment is no longer pending')
return tx.rentalPayment.findUniqueOrThrow({ where: { id } })
})
}
export function setReservationPaidAmount(reservationId: string, paidAmount: number, paymentStatus: string) {
return prisma.reservation.update({ where: { id: reservationId }, data: { paidAmount, paymentStatus: paymentStatus as any } })
}
@@ -1,7 +1,7 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireSubscriptionRead, requireSubscriptionFull } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseParams } from '../../http/validate'
import { ok } from '../../http/respond'
@@ -41,22 +41,22 @@ router.post('/webhooks/paypal', async (req, res, next) => {
// ─── Authenticated ────────────────────────────────────────────
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
router.get('/company', async (req, res, next) => {
router.get('/company', requireRole('MANAGER'), async (req, res, next) => {
try {
ok(res, await service.listByCompany(req.companyId))
} catch (err) { next(err) }
})
router.get('/reservations/:id', async (req, res, next) => {
router.get('/reservations/:id', requireRole('MANAGER'), 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) => {
router.post('/reservations/:id/charge', requireSubscriptionFull, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(reservationParamSchema, req)
const body = parseBody(chargeSchema, req)
@@ -64,7 +64,7 @@ router.post('/reservations/:id/charge', requireRole('MANAGER'), async (req, res,
} catch (err) { next(err) }
})
router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (req, res, next) => {
router.post('/reservations/:id/capture-paypal', requireSubscriptionFull, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(reservationParamSchema, req)
const { paypalOrderId } = parseBody(capturePaypalSchema, req)
@@ -72,7 +72,7 @@ router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (r
} catch (err) { next(err) }
})
router.post('/reservations/:id/manual', requireRole('OWNER'), async (req, res, next) => {
router.post('/reservations/:id/manual', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => {
try {
const { id } = parseParams(reservationParamSchema, req)
const body = parseBody(manualPaymentSchema, req)
@@ -80,7 +80,7 @@ router.post('/reservations/:id/manual', requireRole('OWNER'), async (req, res, n
} catch (err) { next(err) }
})
router.post('/reservations/:reservationId/payments/:paymentId/refund', requireRole('MANAGER'), async (req, res, next) => {
router.post('/reservations/:reservationId/payments/:paymentId/refund', requireSubscriptionFull, requireRole('MANAGER'), async (req, res, next) => {
try {
const { reservationId, paymentId } = parseParams(paymentParamSchema, req)
const { amount, reason } = parseBody(refundSchema, req)
@@ -122,11 +122,45 @@ export async function initCharge(reservationId: string, companyId: string, body:
return { payment, checkoutUrl }
}
function amountToMinorUnits(value: unknown) {
const numeric = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numeric)) return null
return Math.round(numeric * 100)
}
export async function capturePaypal(reservationId: string, companyId: string, paypalOrderId: string) {
const payment = await repo.findByPaypalForCompany(paypalOrderId, companyId)
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
const updated = await repo.updatePaypalCapture(payment.id, captureId)
const payment = await repo.findByPaypalForCompany(paypalOrderId, companyId)
if (payment.reservationId !== reservationId) {
throw new ValidationError('PayPal order does not belong to this reservation')
}
if (payment.status !== 'PENDING') {
throw new ConflictError('This payment has already been processed')
}
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
if (capture.status !== 'COMPLETED') {
throw new ValidationError('PayPal capture did not complete')
}
const unit = capture.purchase_units?.[0]
const providerCapture = unit?.payments?.captures?.[0]
const captureId = providerCapture?.id
const providerAmount = amountToMinorUnits(providerCapture?.amount?.value)
const providerCurrency = providerCapture?.amount?.currency_code
if (!captureId) throw new ValidationError('PayPal capture response did not include a capture id')
if (unit?.reference_id && !String(unit.reference_id).startsWith(reservationId)) {
throw new ValidationError('PayPal order reference does not match reservation')
}
if (providerAmount !== payment.amount) {
throw new ValidationError('PayPal capture amount does not match expected amount')
}
if (providerCurrency !== payment.currency) {
throw new ValidationError('PayPal capture currency does not match expected currency')
}
const updated = await repo.updatePendingPaypalCapture(payment.id, captureId)
if (payment.type === 'CHARGE') {
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
}
@@ -2,7 +2,8 @@ import { Router } from 'express'
import { z } from 'zod'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireSubscriptionRead, requireSubscriptionWrite } 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'
@@ -23,7 +24,7 @@ const photoTypeSchema = z.enum(['PICKUP', 'DROPOFF'])
const photoParamSchema = z.object({ id: z.string(), photoId: z.string() })
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
router.get('/', async (req, res, next) => {
try {
@@ -33,7 +34,7 @@ router.get('/', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/', async (req, res, next) => {
router.post('/', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const body = parseBody(createSchema, req)
const reservation = await service.createReservation(req.companyId, body)
@@ -49,7 +50,7 @@ router.get('/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/:id', async (req, res, next) => {
router.patch('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(updateSchema, req)
@@ -74,7 +75,7 @@ router.get('/:id/billing', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/confirm', async (req, res, next) => {
router.post('/:id/confirm', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const updated = await lifecycle.confirmReservation(id, req.companyId)
@@ -82,7 +83,7 @@ router.post('/:id/confirm', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/checkin', async (req, res, next) => {
router.post('/:id/checkin', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { mileage } = parseBody(checkinSchema, req)
@@ -91,7 +92,7 @@ router.post('/:id/checkin', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/checkout', async (req, res, next) => {
router.post('/:id/checkout', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { mileage } = parseBody(checkoutSchema, req)
@@ -100,7 +101,7 @@ router.post('/:id/checkout', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/close', async (req, res, next) => {
router.post('/:id/close', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const closedBy = `${req.employee.firstName} ${req.employee.lastName}`
@@ -109,7 +110,7 @@ router.post('/:id/close', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/extend', async (req, res, next) => {
router.post('/:id/extend', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { newEndDate, reason } = parseBody(extendSchema, req)
@@ -117,7 +118,7 @@ router.post('/:id/extend', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/cancel', async (req, res, next) => {
router.post('/:id/cancel', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { reason } = parseBody(cancelSchema, req)
@@ -134,7 +135,7 @@ router.get('/:id/inspections', async (req, res, next) => {
} catch (err) { next(err) }
})
router.put('/:id/inspections/:type', async (req, res, next) => {
router.put('/:id/inspections/:type', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id, type: rawType } = parseParams(inspectionParamSchema, req)
const type = inspectionTypeParam.parse(rawType.toUpperCase())
@@ -145,7 +146,7 @@ router.put('/:id/inspections/:type', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/:id/additional-drivers/:driverId/approval', async (req, res, next) => {
router.patch('/:id/additional-drivers/:driverId/approval', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id, driverId } = parseParams(driverParamSchema, req)
const { approved, note } = parseBody(approvalSchema, req)
@@ -162,7 +163,7 @@ router.get('/:id/photos', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/photos', imageUpload.single('photo'), async (req, res, next) => {
router.post('/:id/photos', requireSubscriptionWrite, imageUpload.single('photo'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
assertImageFile(req.file, 'photo')
@@ -172,7 +173,7 @@ router.post('/:id/photos', imageUpload.single('photo'), async (req, res, next) =
} catch (err) { next(err) }
})
router.delete('/:id/photos/:photoId', async (req, res, next) => {
router.delete('/:id/photos/:photoId', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id, photoId } = parseParams(photoParamSchema, req)
await photoService.deletePhoto(photoId, id, req.companyId)
@@ -1,7 +1,8 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok } from '../../http/respond'
import * as service from './review.service'
@@ -9,7 +10,7 @@ import { replySchema, listQuerySchema, idParamSchema } from './review.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
router.get('/', async (req, res, next) => {
try {
@@ -34,7 +35,7 @@ router.get('/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/:id/reply', async (req, res, next) => {
router.patch('/:id/reply', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { companyReply } = parseBody(replySchema, req)
@@ -43,7 +44,7 @@ router.patch('/:id/reply', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/remind', async (req, res, next) => {
router.post('/:id/remind', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const result = await service.sendReviewReminder(id, req.companyId)
+3 -3
View File
@@ -2,21 +2,21 @@ import { prisma } from '../../lib/prisma'
export async function findCompanyBySlug(slug: string) {
return prisma.company.findFirstOrThrow({
where: { slug, status: { in: ['ACTIVE', 'TRIALING', 'PAST_DUE', 'SUSPENDED'] } },
where: { slug, status: { in: ['ACTIVE', 'TRIALING', 'PAST_DUE'] } },
include: { brand: true, contractSettings: true },
})
}
export async function findPublishedVehicles(companyId: string) {
return prisma.vehicle.findMany({
where: { companyId, isPublished: true },
where: { companyId, isPublished: true, status: 'AVAILABLE' },
orderBy: { createdAt: 'desc' },
})
}
export async function findVehicleById(vehicleId: string, companyId: string) {
return prisma.vehicle.findFirstOrThrow({
where: { id: vehicleId, companyId, isPublished: true },
where: { id: vehicleId, companyId, isPublished: true, status: 'AVAILABLE' },
})
}
+13
View File
@@ -46,6 +46,13 @@ function assertAllowedPaymentRedirect(urlValue: string, company: any) {
}
}
function assertPublicBookingCompanyAllowed(company: any) {
if (!['ACTIVE', 'TRIALING'].includes(company.status)) {
throw new AppError('Public booking is not available for this company', 403, 'public_booking_disabled')
}
}
function generateBookingReference() {
const year = new Date().getUTCFullYear()
const random = Math.random().toString(36).slice(2, 8).toUpperCase()
@@ -89,6 +96,7 @@ export async function getBrand(slug: string) {
export async function getPublicVehicles(slug: string) {
const company = await repo.findCompanyBySlug(slug)
assertPublicBookingCompanyAllowed(company)
const vehicles = await repo.findPublishedVehicles(company.id)
return Promise.all(
vehicles.map(async (v: any) => {
@@ -100,6 +108,7 @@ export async function getPublicVehicles(slug: string) {
export async function getVehicleDetail(slug: string, vehicleId: string) {
const company = await repo.findCompanyBySlug(slug)
assertPublicBookingCompanyAllowed(company)
const vehicle = await repo.findVehicleById(vehicleId, company.id)
const a = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId })
return { ...vehicle, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
@@ -112,12 +121,14 @@ export async function getOffers(slug: string) {
export async function getBookingOptions(slug: string) {
const company = await repo.findCompanyBySlug(slug)
assertPublicBookingCompanyAllowed(company)
const insurancePolicies = await repo.findInsurancePolicies(company.id)
return { insurancePolicies, contractSettings: company.contractSettings }
}
export async function checkAvailability(slug: string, vehicleId: string, startDate: string, endDate: string) {
const company = await repo.findCompanyBySlug(slug)
assertPublicBookingCompanyAllowed(company)
const vehicle = await repo.findVehicleById(vehicleId, company.id)
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
companyId: company.id,
@@ -143,6 +154,7 @@ export async function createBooking(slug: string, body: {
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
}) {
const company = await repo.findCompanyBySlug(slug)
assertPublicBookingCompanyAllowed(company)
const vehicle = await repo.findVehicleById(body.vehicleId, company.id)
const start = new Date(body.startDate)
@@ -248,6 +260,7 @@ export async function initPayment(slug: string, reservationId: string, body: {
provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD'; successUrl: string; failureUrl: string; accessToken?: string
}) {
const company = await repo.findCompanyBySlug(slug)
assertPublicBookingCompanyAllowed(company)
await assertPublicBookingAccess(reservationId, body.accessToken)
const reservation = await repo.findReservationForPayment(reservationId, company.id)
@@ -49,3 +49,7 @@ export function hasFullAccess(status: string): boolean {
export function hasAnyAccess(status: string): boolean {
return getAccessLevel(status) !== 'none'
}
export function hasWriteAccess(status: string): boolean {
return getAccessLevel(status) === 'full'
}
@@ -1,6 +1,7 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscriptionRead, requireSubscriptionFull } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody } from '../../http/validate'
import { ok } from '../../http/respond'
@@ -63,7 +64,7 @@ webhookRouter.post('/webhooks/paypal', async (req, res, next) => {
// ─── PayPal capture (auth but no subscription check) ──────────
router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, res, next) => {
router.post('/capture-paypal', requireCompanyAuth, requireTenant, requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => {
try {
const { paypalOrderId } = parseBody(capturePaypalSchema, req)
ok(res, await service.capturePaypal(req.companyId, paypalOrderId))
@@ -72,7 +73,7 @@ router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, re
// ─── Authenticated ─────────────────────────────────────────────
router.use(requireCompanyAuth, requireTenant)
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
router.get('/me', async (req, res, next) => {
try { ok(res, await service.getSubscription(req.companyId)) } catch (err) { next(err) }
@@ -90,42 +91,42 @@ router.get('/entitlement', async (req, res, next) => {
try { ok(res, await service.getEntitlement(req.companyId)) } catch (err) { next(err) }
})
router.post('/trial', requireRole('OWNER'), async (req, res, next) => {
router.post('/trial', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(startTrialSchema, req)
ok(res, await service.startTrial(req.companyId, body.plan, body.billingPeriod, body.currency))
} catch (err) { next(err) }
})
router.post('/checkout', requireRole('OWNER'), async (req, res, next) => {
router.post('/checkout', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(checkoutSchema, req)
ok(res, await service.checkout(req.companyId, body))
} catch (err) { next(err) }
})
router.post('/reactivate', requireRole('OWNER'), async (req, res, next) => {
router.post('/reactivate', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(reactivateSchema, req)
ok(res, await service.reactivate(req.companyId, body))
} catch (err) { next(err) }
})
router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => {
router.post('/change-plan', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(changePlanSchema, req)
ok(res, await service.changePlan(req.companyId, body))
} catch (err) { next(err) }
})
router.post('/cancel', requireRole('OWNER'), async (req, res, next) => {
router.post('/cancel', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => {
try {
const { mode, reason } = parseBody(cancelSchema, req)
ok(res, await service.cancel(req.companyId, mode, reason))
} catch (err) { next(err) }
})
router.post('/resume', requireRole('OWNER'), async (req, res, next) => {
router.post('/resume', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => {
try { ok(res, await service.resume(req.companyId)) } catch (err) { next(err) }
})
+9 -9
View File
@@ -1,7 +1,7 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
@@ -10,28 +10,28 @@ import { inviteSchema, roleSchema, idParamSchema } from './team.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
router.get('/', async (req, res, next) => {
router.get('/', requireRole('MANAGER'), async (req, res, next) => {
try {
ok(res, await service.getMembers(req.companyId))
} catch (err) { next(err) }
})
router.get('/stats', async (req, res, next) => {
router.get('/stats', requireRole('MANAGER'), async (req, res, next) => {
try {
ok(res, await service.getMemberStats(req.companyId))
} catch (err) { next(err) }
})
router.post('/invite', requireRole('OWNER'), async (req, res, next) => {
router.post('/invite', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(inviteSchema, req)
created(res, { data: await service.inviteEmployee(req.companyId, req.employee.id, body) })
} catch (err) { next(err) }
})
router.patch('/:id/role', requireRole('OWNER'), async (req, res, next) => {
router.patch('/:id/role', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { role } = parseBody(roleSchema, req)
@@ -39,21 +39,21 @@ router.patch('/:id/role', requireRole('OWNER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/deactivate', requireRole('OWNER'), async (req, res, next) => {
router.post('/:id/deactivate', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await service.deactivateEmployee(req.companyId, req.employee.role, id))
} catch (err) { next(err) }
})
router.post('/:id/reactivate', requireRole('OWNER'), async (req, res, next) => {
router.post('/:id/reactivate', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await service.reactivateEmployee(req.companyId, req.employee.role, id))
} catch (err) { next(err) }
})
router.delete('/:id', requireRole('OWNER'), async (req, res, next) => {
router.delete('/:id', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await service.removeEmployee(req.companyId, req.employee.role, id))
+16 -16
View File
@@ -1,7 +1,7 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
@@ -16,7 +16,7 @@ import {
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
router.get('/', async (req, res, next) => {
try {
@@ -26,7 +26,7 @@ router.get('/', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/', requireRole('MANAGER'), async (req, res, next) => {
router.post('/', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const body = parseBody(vehicleSchema, req)
const vehicle = await service.createVehicle(body, req.companyId)
@@ -42,7 +42,7 @@ router.get('/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
router.patch('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(vehicleSchema.partial(), req)
@@ -51,7 +51,7 @@ router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => {
router.delete('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deleteVehicle(id, req.companyId)
@@ -59,7 +59,7 @@ router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/photos', requireRole('MANAGER'), imageUpload.array('photos', 10), async (req, res, next) => {
router.post('/:id/photos', requireSubscriptionWrite, requireRole('MANAGER'), imageUpload.array('photos', 5), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const files = req.files as Express.Multer.File[]
@@ -69,7 +69,7 @@ router.post('/:id/photos', requireRole('MANAGER'), imageUpload.array('photos', 1
} catch (err) { next(err) }
})
router.delete('/:id/photos/:idx', requireRole('MANAGER'), async (req, res, next) => {
router.delete('/:id/photos/:idx', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id, idx } = parseParams(photoIdxSchema, req)
const updated = await service.deletePhoto(id, req.companyId, parseInt(idx, 10))
@@ -77,7 +77,7 @@ router.delete('/:id/photos/:idx', requireRole('MANAGER'), async (req, res, next)
} catch (err) { next(err) }
})
router.patch('/:id/status', requireRole('MANAGER'), async (req, res, next) => {
router.patch('/:id/status', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { status } = parseBody(statusSchema, req)
@@ -85,7 +85,7 @@ router.patch('/:id/status', requireRole('MANAGER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/:id/publish', requireRole('MANAGER'), async (req, res, next) => {
router.patch('/:id/publish', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { isPublished } = parseBody(publishSchema, req)
@@ -112,7 +112,7 @@ router.get('/:id/calendar', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/calendar/blocks', requireRole('MANAGER'), async (req, res, next) => {
router.post('/:id/calendar/blocks', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(calendarBlockSchema, req)
@@ -121,7 +121,7 @@ router.post('/:id/calendar/blocks', requireRole('MANAGER'), async (req, res, nex
} catch (err) { next(err) }
})
router.delete('/:id/calendar/blocks/:blockId', requireRole('MANAGER'), async (req, res, next) => {
router.delete('/:id/calendar/blocks/:blockId', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id, blockId } = parseParams(blockIdParamSchema, req)
await service.deleteCalendarBlock(id, req.companyId, blockId)
@@ -137,7 +137,7 @@ router.get('/:id/maintenance', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/maintenance', requireRole('MANAGER'), async (req, res, next) => {
router.post('/:id/maintenance', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(maintenanceLogSchema, req)
@@ -153,7 +153,7 @@ router.get('/:id/pricing', async (req, res, next) => {
} catch (err) { next(err) }
})
router.put('/:id/pricing', requireRole('MANAGER'), async (req, res, next) => {
router.put('/:id/pricing', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(pricingConfigSchema, req)
@@ -161,7 +161,7 @@ router.put('/:id/pricing', requireRole('MANAGER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/pricing/rules', requireRole('MANAGER'), async (req, res, next) => {
router.post('/:id/pricing/rules', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(pricingRuleSchema, req)
@@ -169,7 +169,7 @@ router.post('/:id/pricing/rules', requireRole('MANAGER'), async (req, res, next)
} catch (err) { next(err) }
})
router.patch('/:id/pricing/rules/:ruleId', requireRole('MANAGER'), async (req, res, next) => {
router.patch('/:id/pricing/rules/:ruleId', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id, ruleId } = parseParams(pricingRuleParamSchema, req)
const body = parseBody(pricingRuleSchema.partial(), req)
@@ -177,7 +177,7 @@ router.patch('/:id/pricing/rules/:ruleId', requireRole('MANAGER'), async (req, r
} catch (err) { next(err) }
})
router.delete('/:id/pricing/rules/:ruleId', requireRole('MANAGER'), async (req, res, next) => {
router.delete('/:id/pricing/rules/:ruleId', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
try {
const { id, ruleId } = parseParams(pricingRuleParamSchema, req)
ok(res, await service.deleteVehiclePricingRule(id, req.companyId, ruleId, req.employee.id))