fix and update language for company workspace
This commit is contained in:
@@ -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 |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user