fix and update language for company workspace

This commit is contained in:
root
2026-05-11 23:40:40 -04:00
committed by Administrator
parent 193aeae834
commit 1a39aa8433
43 changed files with 5034 additions and 802 deletions
+62
View File
@@ -28,6 +28,68 @@ function buildResetEmailHtml(resetUrl: string, firstName: string) {
`
}
router.get('/me', async (req, res, next) => {
try {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!token) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Authentication required',
statusCode: 401,
})
}
let employeeId = ''
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type !== 'employee') {
return res.status(401).json({
error: 'invalid_token',
message: 'Invalid or expired session token',
statusCode: 401,
})
}
employeeId = payload.sub
} catch {
return res.status(401).json({
error: 'invalid_token',
message: 'Invalid or expired session token',
statusCode: 401,
})
}
const employee = await prisma.employee.findUnique({
where: { id: employeeId },
include: { company: true },
})
if (!employee || !employee.isActive) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Employee account not found or inactive',
statusCode: 401,
})
}
res.json({
data: {
employee: {
id: employee.id,
email: employee.email,
firstName: employee.firstName,
lastName: employee.lastName,
role: employee.role,
companyId: employee.companyId,
companyName: employee.company.name,
companySlug: employee.company.slug,
},
},
})
} catch (err) { next(err) }
})
router.post('/login', async (req, res, next) => {
try {
const { email, password } = z.object({
+68 -5
View File
@@ -50,7 +50,7 @@ router.get('/companies', async (req, res, next) => {
where,
include: {
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } },
_count: { select: { vehicles: { where: { isPublished: true } } } },
_count: { select: { vehicles: { where: { isPublished: true, status: 'AVAILABLE' } } } },
},
skip: (page - 1) * pageSize,
take: pageSize,
@@ -77,7 +77,7 @@ router.get('/search', async (req, res, next) => {
const { city, startDate, endDate, category, maxPrice, transmission, make, model } = searchSchema.parse(req.query)
const { page, pageSize } = paginationSchema.parse(req.query)
const where: any = { isPublished: true, company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } } }
const where: any = { isPublished: true, status: 'AVAILABLE', company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } } }
if (category) where.category = category
if (maxPrice !== undefined) where.dailyRate = { lte: maxPrice }
if (transmission) where.transmission = transmission
@@ -249,7 +249,7 @@ router.get('/:slug', async (req, res, next) => {
where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } },
include: {
brand: true,
vehicles: { where: { isPublished: true }, orderBy: { createdAt: 'desc' } },
vehicles: { where: { isPublished: true, status: 'AVAILABLE' }, orderBy: { createdAt: 'desc' } },
offers: { where: { isPublic: true, isActive: true, validUntil: { gte: new Date() } }, orderBy: { isFeatured: 'desc' } },
},
})
@@ -294,7 +294,7 @@ router.get('/:slug/vehicles', async (req, res, next) => {
try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
const vehicles = await prisma.vehicle.findMany({
where: { companyId: company.id, isPublished: true },
where: { companyId: company.id, isPublished: true, status: 'AVAILABLE' },
orderBy: { createdAt: 'desc' },
})
res.json({ data: vehicles })
@@ -308,7 +308,7 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => {
try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
const vehicle = await prisma.vehicle.findFirstOrThrow({
where: { id: req.params.id, companyId: company.id, isPublished: true },
where: { id: req.params.id, companyId: company.id, isPublished: true, status: 'AVAILABLE' },
include: {
company: { include: { brand: true } },
},
@@ -343,6 +343,69 @@ router.get('/:slug/offers', async (req, res, next) => {
}
})
// ─── Review token endpoints ───────────────────────────────────
router.get('/review/:token', async (req, res, next) => {
try {
const reservation = await prisma.reservation.findUnique({
where: { reviewToken: req.params.token },
include: {
vehicle: { select: { make: true, model: true, year: true, photos: true } },
company: { include: { brand: { select: { displayName: true, logoUrl: true } } } },
review: { select: { id: true } },
},
})
if (!reservation) return res.status(404).json({ error: 'invalid_token', message: 'Review link is invalid or has expired', statusCode: 404 })
if (reservation.review) return res.status(409).json({ error: 'already_reviewed', message: 'A review has already been submitted for this reservation', statusCode: 409 })
res.json({
data: {
reservationId: reservation.id,
companyName: reservation.company.brand?.displayName ?? reservation.company.name,
companyLogoUrl: reservation.company.brand?.logoUrl ?? null,
vehicle: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`,
vehiclePhoto: reservation.vehicle.photos[0] ?? null,
},
})
} catch (err) { next(err) }
})
router.post('/review/:token', async (req, res, next) => {
try {
const body = z.object({
overallRating: z.number().int().min(1).max(5),
vehicleRating: z.number().int().min(1).max(5).optional(),
serviceRating: z.number().int().min(1).max(5).optional(),
comment: z.string().max(2000).optional(),
}).parse(req.body)
const reservation = await prisma.reservation.findUnique({
where: { reviewToken: req.params.token },
include: { review: { select: { id: true } } },
})
if (!reservation) return res.status(404).json({ error: 'invalid_token', message: 'Review link is invalid or has expired', statusCode: 404 })
if (reservation.review) return res.status(409).json({ error: 'already_reviewed', message: 'A review has already been submitted for this reservation', statusCode: 409 })
const review = await prisma.review.create({
data: {
reservationId: reservation.id,
companyId: reservation.companyId,
renterId: reservation.renterId ?? undefined,
overallRating: body.overallRating,
vehicleRating: body.vehicleRating,
serviceRating: body.serviceRating,
comment: body.comment,
isPublished: true,
},
})
// Invalidate token so the link can't be reused
await prisma.reservation.update({ where: { id: reservation.id }, data: { reviewToken: null } })
res.status(201).json({ data: { reviewId: review.id } })
} catch (err) { next(err) }
})
router.post('/offers/:code/validate', async (req, res, next) => {
try {
const offer = await prisma.offer.findFirst({
+37 -4
View File
@@ -8,7 +8,8 @@ import { applyAdditionalDriversToReservation } from '../services/additionalDrive
import { applyInsurancesToReservation } from '../services/insuranceService'
import { applyPricingRules } from '../services/pricingRuleService'
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
import { sendNotification } from '../services/notificationService'
import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
import crypto from 'crypto'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
@@ -239,13 +240,45 @@ router.post('/:id/checkin', async (req, res, next) => {
router.post('/:id/checkout', async (req, res, next) => {
try {
const { mileage } = z.object({ mileage: z.number().int().optional() }).parse(req.body)
const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
const reservation = await prisma.reservation.findFirstOrThrow({
where: { id: req.params.id, companyId: req.companyId },
include: { vehicle: true },
})
if (reservation.status !== 'ACTIVE') return res.status(400).json({ error: 'invalid_status', message: 'Only ACTIVE reservations can be checked out', statusCode: 400 })
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'COMPLETED', checkedOutAt: new Date(), checkOutMileage: mileage ?? null } })
const reviewToken = crypto.randomBytes(32).toString('hex')
const updated = await prisma.reservation.update({
where: { id: req.params.id },
data: { status: 'COMPLETED', checkedOutAt: new Date(), checkOutMileage: mileage ?? null, reviewToken },
})
await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'AVAILABLE', mileage: mileage ?? undefined } })
// Send "How was your rental?" email with secure one-time review link
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } })
await sendNotification({ type: 'REVIEW_REQUEST', title: 'How was your rental?', body: 'Please leave a review for your recent rental.', companyId: req.companyId, email: customer.email, channels: ['EMAIL'] }).catch(() => null)
const company = await prisma.company.findUnique({ where: { id: req.companyId }, include: { brand: { select: { displayName: true } } } })
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicleLabel = `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`
const marketplaceUrl = process.env.MARKETPLACE_URL ?? process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
const reviewUrl = `${marketplaceUrl}/review?token=${reviewToken}`
sendTransactionalEmail({
to: customer.email,
subject: `How was your rental? — ${vehicleLabel}`,
html: `
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
<h2 style="margin-bottom:4px">Hi ${customer.firstName},</h2>
<p style="color:#57534e">Thank you for renting with <strong>${companyName}</strong>. We hope you enjoyed your <strong>${vehicleLabel}</strong>.</p>
<p style="color:#57534e">Your feedback helps the company improve and helps other customers make informed choices. It only takes 30 seconds.</p>
<div style="margin:32px 0;text-align:center">
<a href="${reviewUrl}" style="background:#1c1917;color:#ffffff;padding:14px 32px;border-radius:999px;text-decoration:none;font-weight:600;font-size:15px;display:inline-block">
Leave a review
</a>
</div>
<p style="color:#a8a29e;font-size:12px">This link is unique to your reservation and can only be used once. If you did not rent a vehicle recently, you can ignore this email.</p>
</div>
`,
text: `Hi ${customer.firstName},\n\nThank you for renting with ${companyName}. We hope you enjoyed your ${vehicleLabel}.\n\nLeave a review here (one-time link):\n${reviewUrl}\n\nThank you!`,
}).catch(() => null)
res.json({ data: updated })
} catch (err) { next(err) }
+1
View File
@@ -308,6 +308,7 @@ router.post('/:slug/book', async (req, res, next) => {
customerId: customer.id,
offerId: body.offerId ?? null,
promoCodeUsed: body.promoCodeUsed ?? null,
vehicleCategory: vehicle.category,
source: body.source,
startDate: start,
endDate: end,
+9 -3
View File
@@ -28,6 +28,7 @@ const vehicleSchema = z.object({
mileage: z.number().int().optional(),
notes: z.string().optional(),
isPublished: z.boolean().default(true),
status: z.enum(['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE']).optional(),
})
router.get('/', async (req, res, next) => {
@@ -65,9 +66,14 @@ router.get('/:id', async (req, res, next) => {
router.patch('/:id', async (req, res, next) => {
try {
const body = vehicleSchema.partial().parse(req.body)
const vehicle = await prisma.vehicle.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: body })
if (vehicle.count === 0) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 })
const updated = await prisma.vehicle.findUniqueOrThrow({ where: { id: req.params.id } })
if (body.status === 'MAINTENANCE' || body.status === 'OUT_OF_SERVICE') {
body.isPublished = false
} else if (body.status === 'AVAILABLE' || body.status === 'RENTED') {
body.isPublished = true
}
const existing = await prisma.vehicle.findFirst({ where: { id: req.params.id, companyId: req.companyId } })
if (!existing) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 })
const updated = await prisma.vehicle.update({ where: { id: req.params.id }, data: body })
res.json({ data: updated })
} catch (err) { next(err) }
})