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
+106
View File
@@ -279,6 +279,112 @@ cron.schedule('0 9 * * *', async () => {
}
})
// Daily: notify companies about upcoming and overdue vehicle maintenance (date- and odometer-based).
// Repeats every day until the owner logs a new service entry that pushes the due date/mileage into the future.
cron.schedule('0 8 * * *', async () => {
const now = new Date()
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
// Fetch all candidate logs (date-due or has an odometer target), ordered newest-first per vehicle+type.
// We keep only the LATEST log per vehicle+type so that once the owner logs a new service the
// old overdue log is superseded and notifications stop automatically.
const allCandidates = await prisma.maintenanceLog.findMany({
where: {
OR: [
{ nextDueAt: { lte: in30Days } },
{ nextDueMileage: { not: null } },
],
},
include: { vehicle: { include: { company: { include: { employees: { where: { role: { in: ['OWNER', 'MANAGER'] }, isActive: true }, take: 1 } } } } } },
orderBy: { performedAt: 'desc' },
})
// Keep only the most-recent log per vehicle+type combination
const latestByKey = new Map<string, typeof allCandidates[number]>()
for (const log of allCandidates) {
const key = `${log.vehicleId}:${log.type}`
if (!latestByKey.has(key)) latestByKey.set(key, log)
}
for (const log of latestByKey.values()) {
const vehicle = log.vehicle
const company = vehicle.company
const recipient = company.employees[0]
if (!recipient) continue
// Determine date-based urgency
let isOverdueByDate = false
let daysLeft: number | null = null
let dueSoonByDate = false
if (log.nextDueAt) {
daysLeft = Math.ceil((log.nextDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
isOverdueByDate = log.nextDueAt <= now
dueSoonByDate = !isOverdueByDate && daysLeft <= 30
}
// Determine odometer-based urgency
let isOverdueByOdometer = false
let kmLeft: number | null = null
let dueSoonByOdometer = false
if (log.nextDueMileage != null && vehicle.mileage != null) {
kmLeft = log.nextDueMileage - vehicle.mileage
isOverdueByOdometer = kmLeft <= 0
dueSoonByOdometer = !isOverdueByOdometer && kmLeft <= 500
}
// Skip if the latest log is no longer due (owner has updated it)
const isOverdue = isOverdueByDate || isOverdueByOdometer
const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer)
if (!isOverdue && !isDueSoon) continue
// Dedup: don't send more than once per day for the same log
const alreadySent = await prisma.notification.findFirst({
where: {
type: 'VEHICLE_MAINTENANCE_DUE',
companyId: company.id,
createdAt: { gte: oneDayAgo },
data: { path: ['maintenanceLogId'], equals: log.id },
},
})
if (alreadySent) continue
// Build human-readable description
const dueParts: string[] = []
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
else if (dueSoonByDate && daysLeft != null) dueParts.push(`due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`)
if (isOverdueByOdometer) dueParts.push(`overdue by odometer (${Math.abs(kmLeft!).toLocaleString()} km ago)`)
else if (dueSoonByOdometer && kmLeft != null) dueParts.push(`${kmLeft.toLocaleString()} km remaining`)
const title = isOverdue
? `Overdue: ${log.type}${vehicle.make} ${vehicle.model}`
: `${log.type} due soon — ${vehicle.make} ${vehicle.model}`
const body = `${log.type} for ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate}): ${dueParts.join('; ')}. Please log the service to dismiss this reminder.`
await prisma.notification.create({
data: {
type: 'VEHICLE_MAINTENANCE_DUE',
title,
body,
data: {
vehicleId: vehicle.id,
maintenanceLogId: log.id,
maintenanceType: log.type,
isOverdue,
daysLeft,
kmLeft,
isOverdueByDate,
isOverdueByOdometer,
},
companyId: company.id,
employeeId: recipient.id,
channel: 'IN_APP',
status: 'DELIVERED',
},
})
}
})
// ─── Start ────────────────────────────────────────────────────
const PORT = process.env.API_PORT ?? 4000
server.listen(PORT, () => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+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) }
})