fix the upload issue and add driver license upload

This commit is contained in:
root
2026-05-20 22:54:09 -04:00
parent 32170acbb3
commit e74681e810
12 changed files with 186 additions and 8 deletions
+3 -2
View File
@@ -9,6 +9,7 @@ import jwt from 'jsonwebtoken'
import { z } from 'zod'
import { redis } from './lib/redis'
import { prisma } from './lib/prisma'
import { getStorageRoot } from './lib/storage'
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
// ─── Routes ───────────────────────────────────────────────────
@@ -122,8 +123,8 @@ subscriber.on('pmessage', (_pattern, channel, message) => {
})
// ─── Middleware ────────────────────────────────────────────────
// Serve local file storage
app.use('/storage', express.static(require('path').resolve(__dirname, 'lib/storage')))
// Serve uploaded assets from the configured storage root.
app.use('/storage', express.static(getStorageRoot()))
// Webhook must use raw body BEFORE express.json()
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
+17 -3
View File
@@ -2,7 +2,19 @@ import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
const STORAGE_ROOT = path.resolve(__dirname, 'storage')
const DEFAULT_STORAGE_ROOT = path.resolve(__dirname, '..', '..', 'storage')
export function getStorageRoot(): string {
return process.env.FILE_STORAGE_ROOT
? path.resolve(process.env.FILE_STORAGE_ROOT)
: DEFAULT_STORAGE_ROOT
}
function ensureStorageRoot(): string {
const storageRoot = getStorageRoot()
fs.mkdirSync(storageRoot, { recursive: true })
return storageRoot
}
function getApiBase(): string {
return (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '')
@@ -13,7 +25,8 @@ export async function uploadImage(
folder: string,
publicId?: string
): Promise<string> {
const folderPath = path.join(STORAGE_ROOT, folder)
const storageRoot = ensureStorageRoot()
const folderPath = path.join(storageRoot, folder)
fs.mkdirSync(folderPath, { recursive: true })
const filename = publicId
@@ -26,10 +39,11 @@ export async function uploadImage(
}
export async function deleteImage(imageUrl: string): Promise<void> {
const storageRoot = getStorageRoot()
const marker = '/storage/'
const idx = imageUrl.indexOf(marker)
if (idx === -1) return
const relative = imageUrl.slice(idx + marker.length)
const filePath = path.join(STORAGE_ROOT, relative)
const filePath = path.join(storageRoot, relative)
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
}
+28
View File
@@ -1,6 +1,8 @@
import { Router } from 'express'
import { z } from 'zod'
import multer from 'multer'
import { prisma } from '../lib/prisma'
import { deleteImage, uploadImage } from '../lib/storage'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
@@ -8,6 +10,7 @@ import { requireRole } from '../middleware/requireRole'
import { validateAndFlagLicense } from '../services/licenseValidationService'
const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
router.use(requireCompanyAuth, requireTenant, requireSubscription)
const paginationSchema = z.object({
@@ -113,6 +116,31 @@ router.post('/:id/validate-license', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/license-image', upload.single('file'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'missing_file', message: 'A license image file is required', statusCode: 400 })
}
if (!req.file.mimetype.startsWith('image/')) {
return res.status(400).json({ error: 'invalid_file_type', message: 'Only image uploads are supported', statusCode: 400 })
}
const customer = await prisma.customer.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
const url = await uploadImage(req.file.buffer, `companies/${req.companyId}/customers/${customer.id}`)
if (customer.licenseImageUrl) {
await deleteImage(customer.licenseImageUrl).catch(() => null)
}
const updated = await prisma.customer.update({
where: { id: customer.id },
data: { licenseImageUrl: url },
})
res.json({ data: updated })
} catch (err) { next(err) }
})
router.post('/:id/approve-license', requireRole('MANAGER'), async (req, res, next) => {
try {
const { decision, note } = z.object({ decision: z.enum(['APPROVE', 'DENY']), note: z.string().optional() }).parse(req.body)
@@ -12,6 +12,7 @@ interface CustomerRow {
phone: string | null
flagged: boolean
licenseValidationStatus: string
licenseImageUrl: string | null
}
export default function CustomersPage() {
@@ -26,6 +27,8 @@ export default function CustomersPage() {
contact: 'Contact',
license: 'License',
flags: 'Flags',
imageUploaded: 'Image uploaded',
imageMissing: 'No image',
noPhone: 'No phone',
flagged: 'Flagged',
clear: 'Clear',
@@ -38,6 +41,8 @@ export default function CustomersPage() {
contact: 'Contact',
license: 'Permis',
flags: 'Signalements',
imageUploaded: 'Image téléversée',
imageMissing: 'Pas dimage',
noPhone: 'Pas de téléphone',
flagged: 'Signalé',
clear: 'Aucun risque',
@@ -50,6 +55,8 @@ export default function CustomersPage() {
contact: 'التواصل',
license: 'الرخصة',
flags: 'الإشارات',
imageUploaded: 'تم رفع الصورة',
imageMissing: 'لا توجد صورة',
noPhone: 'لا يوجد هاتف',
flagged: 'مُعلّم',
clear: 'سليم',
@@ -91,7 +98,14 @@ export default function CustomersPage() {
<p className="text-sm text-slate-700">{row.email}</p>
<p className="text-xs text-slate-500">{row.phone ?? copy.noPhone}</p>
</td>
<td className="px-6 py-4"><span className="badge-gray">{row.licenseValidationStatus}</span></td>
<td className="px-6 py-4">
<div className="flex flex-wrap items-center gap-2">
<span className="badge-gray">{row.licenseValidationStatus}</span>
<span className={row.licenseImageUrl ? 'badge-green' : 'badge-gray'}>
{row.licenseImageUrl ? copy.imageUploaded : copy.imageMissing}
</span>
</div>
</td>
<td className="px-6 py-4">{row.flagged ? <span className="badge-red">{copy.flagged}</span> : <span className="badge-green">{copy.clear}</span>}</td>
</tr>
))}
@@ -35,6 +35,7 @@ interface ReservationDetail {
email: string
phone: string | null
driverLicense: string | null
licenseImageUrl: string | null
licenseValidationStatus: string
flagged: boolean
}
@@ -115,6 +116,8 @@ const detailCopy = {
checkInReadOnly: 'Check-in inspection is only editable after the reservation is confirmed and before return.',
checkOutReadOnly: 'Check-out inspection becomes editable once the vehicle is returned.',
inspectionClosed: 'This reservation is closed. Inspection edits are disabled.',
licenseImageLabel: 'License image',
noLicenseImage: 'No license image uploaded.',
},
fr: {
editBooking: 'Modifier la réservation',
@@ -150,6 +153,8 @@ const detailCopy = {
checkInReadOnly: 'Linspection de départ est modifiable après confirmation et avant le retour.',
checkOutReadOnly: 'Linspection de retour devient modifiable une fois le véhicule retourné.',
inspectionClosed: 'Cette réservation est clôturée. Les modifications dinspection sont désactivées.',
licenseImageLabel: 'Image du permis',
noLicenseImage: 'Aucune image de permis téléversée.',
},
ar: {
editBooking: 'تعديل الحجز',
@@ -185,6 +190,8 @@ const detailCopy = {
checkInReadOnly: 'يمكن تعديل فحص الاستلام بعد تأكيد الحجز وقبل الإرجاع فقط.',
checkOutReadOnly: 'يصبح فحص الإرجاع قابلاً للتعديل بعد عودة السيارة.',
inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
licenseImageLabel: 'صورة الرخصة',
noLicenseImage: 'لم يتم رفع صورة الرخصة.',
},
} as const
@@ -436,6 +443,20 @@ export default function ReservationDetailPage() {
<p className="text-slate-600">{reservation.customer.email}</p>
<p className="text-slate-600">{reservation.customer.phone ?? r.noPhone}</p>
<p className="text-slate-600">{r.licenseLabel} {reservation.customer.driverLicense ?? r.notCaptured}</p>
<div className="pt-2">
<p className="text-slate-600">{copy.licenseImageLabel}</p>
{reservation.customer.licenseImageUrl ? (
<a href={reservation.customer.licenseImageUrl} target="_blank" rel="noreferrer" className="mt-2 block max-w-sm">
<img
src={reservation.customer.licenseImageUrl}
alt="Driver license"
className="h-40 w-full rounded-xl border border-slate-200 object-cover"
/>
</a>
) : (
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
)}
</div>
<div className="flex flex-wrap gap-2 pt-2">
<span className="badge-gray">{reservation.customer.licenseValidationStatus}</span>
{reservation.customer.flagged && <span className="badge-red">{r.flaggedBadge}</span>}
@@ -11,14 +11,15 @@ type Customer = {
firstName: string
lastName: string
email: string
phone?: string | null
driverLicense?: string | null
licenseExpiry?: string | null
licenseIssuedAt?: string | null
licenseCountry?: string | null
licenseCategory?: string | null
licenseImageUrl?: string | null
}
type Vehicle = { id: string; make: string; model: string; licensePlate: string; status: string }
type CreatedCustomer = { id: string; firstName: string; lastName: string; email: string }
type AdditionalDriverForm = {
firstName: BilingualField
lastName: BilingualField
@@ -65,6 +66,9 @@ export default function NewReservationPage() {
const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
const [licenseCountry, setLicenseCountry] = useState('')
const [licenseCategory, setLicenseCategory] = useState('')
const [licenseImageUrl, setLicenseImageUrl] = useState<string | null>(null)
const [licenseImageFile, setLicenseImageFile] = useState<File | null>(null)
const [licenseImagePreviewUrl, setLicenseImagePreviewUrl] = useState<string | null>(null)
const [additionalDriver, setAdditionalDriver] = useState<AdditionalDriverForm>({
firstName: emptyBilingual(),
lastName: emptyBilingual(),
@@ -96,6 +100,11 @@ export default function NewReservationPage() {
licenseIssuedAt: 'License issued at',
licenseCountry: 'License country',
licenseCategory: 'License category',
licenseImage: 'Driver license image',
licenseImageHint: 'Upload a photo or scan of the primary driver license.',
licenseImageSelected: 'Selected file',
licenseImageCurrent: 'Current image',
noLicenseImage: 'No license image uploaded yet.',
addAdditionalDriver: 'Add additional driver',
additionalDriverInfo: 'Additional driver',
dateOfBirth: 'Date of birth',
@@ -145,6 +154,11 @@ export default function NewReservationPage() {
licenseIssuedAt: 'Permis délivré le',
licenseCountry: 'Pays du permis',
licenseCategory: 'Catégorie du permis',
licenseImage: 'Image du permis',
licenseImageHint: 'Téléversez une photo ou un scan du permis du conducteur principal.',
licenseImageSelected: 'Fichier sélectionné',
licenseImageCurrent: 'Image actuelle',
noLicenseImage: 'Aucune image de permis téléversée.',
addAdditionalDriver: 'Ajouter un conducteur supplémentaire',
additionalDriverInfo: 'Conducteur supplémentaire',
dateOfBirth: 'Date de naissance',
@@ -194,6 +208,11 @@ export default function NewReservationPage() {
licenseIssuedAt: 'تاريخ إصدار الرخصة',
licenseCountry: 'بلد الرخصة',
licenseCategory: 'فئة الرخصة',
licenseImage: 'صورة رخصة القيادة',
licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.',
licenseImageSelected: 'الملف المحدد',
licenseImageCurrent: 'الصورة الحالية',
noLicenseImage: 'لم يتم رفع صورة الرخصة بعد.',
addAdditionalDriver: 'إضافة سائق إضافي',
additionalDriverInfo: 'السائق الإضافي',
dateOfBirth: 'تاريخ الميلاد',
@@ -261,8 +280,38 @@ export default function NewReservationPage() {
setLicenseIssuedAt(selected.licenseIssuedAt ? selected.licenseIssuedAt.slice(0, 10) : '')
setLicenseCountry(selected.licenseCountry ?? '')
setLicenseCategory(selected.licenseCategory ?? '')
setLicenseImageUrl(selected.licenseImageUrl ?? null)
setLicenseImageFile(null)
}, [customerId, customers])
useEffect(() => {
if (!licenseImageFile) {
setLicenseImagePreviewUrl(null)
return
}
const objectUrl = URL.createObjectURL(licenseImageFile)
setLicenseImagePreviewUrl(objectUrl)
return () => URL.revokeObjectURL(objectUrl)
}, [licenseImageFile])
async function uploadLicenseImage(customerIdToUpdate: string) {
if (!licenseImageFile) return null
const formData = new FormData()
formData.append('file', licenseImageFile)
const updated = await apiFetch<Customer>(`/customers/${customerIdToUpdate}/license-image`, {
method: 'POST',
body: formData,
})
setCustomers((prev) => prev.map((customer) => customer.id === updated.id ? { ...customer, ...updated } : customer))
setLicenseImageUrl(updated.licenseImageUrl ?? null)
setLicenseImageFile(null)
return updated.licenseImageUrl ?? null
}
async function addCustomer() {
setError(null)
if (!bilingualPrimary(newCustomerFirstName).trim() || !bilingualPrimary(newCustomerLastName).trim() || !newCustomerEmail.trim() || !newCustomerPhone.trim()) {
@@ -271,7 +320,7 @@ export default function NewReservationPage() {
}
setSavingCustomer(true)
try {
const created = await apiFetch<CreatedCustomer>('/customers', {
const created = await apiFetch<Customer>('/customers', {
method: 'POST',
body: JSON.stringify({
firstName: bilingualPrimary(newCustomerFirstName).trim(),
@@ -295,6 +344,11 @@ export default function NewReservationPage() {
setNewCustomerLastName(emptyBilingual())
setNewCustomerEmail('')
setNewCustomerPhone('')
setLicenseImageUrl(created.licenseImageUrl ?? null)
if (licenseImageFile) {
await uploadLicenseImage(created.id)
}
} catch (err: any) {
setError(err.message)
} finally {
@@ -332,6 +386,10 @@ export default function NewReservationPage() {
}),
})
if (licenseImageFile) {
await uploadLicenseImage(customerId)
}
const created = await apiFetch<CreatedReservation>('/reservations', {
method: 'POST',
body: JSON.stringify({
@@ -477,6 +535,32 @@ export default function NewReservationPage() {
<input value={licenseCategory} onChange={(e) => setLicenseCategory(e.target.value)} className="input-field" />
</label>
</div>
<div className="space-y-2">
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.licenseImage}</span>
<input
type="file"
accept="image/*"
onChange={(e) => setLicenseImageFile(e.target.files?.[0] ?? null)}
className="input-field"
/>
</label>
<p className="text-xs text-slate-500">{copy.licenseImageHint}</p>
{licenseImageFile ? (
<p className="text-xs font-medium text-slate-700">{copy.licenseImageSelected}: {licenseImageFile.name}</p>
) : licenseImageUrl ? (
<p className="text-xs font-medium text-slate-700">{copy.licenseImageCurrent}</p>
) : (
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
)}
{licenseImagePreviewUrl || licenseImageUrl ? (
<img
src={licenseImagePreviewUrl ?? licenseImageUrl ?? ''}
alt="Driver license preview"
className="h-40 w-full max-w-sm rounded-xl border border-slate-200 object-cover"
/>
) : null}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">