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
+1
View File
@@ -7,6 +7,7 @@ node_modules/
dist/
.next/
out/
apps/api/storage/
# Environment variables
.env
+4
View File
@@ -43,6 +43,8 @@ Notes:
On startup, Docker now waits for Postgres to become healthy, runs a one-shot `migrate` service, and only then starts the selected app container. For development, that bootstrap runs `db:generate` every time, but `db:deploy` and `db:seed` only the first time for a persisted dev database, so your local data survives rebuilds and normal restarts.
Uploaded assets handled by the API, including vehicle photos and company branding images, are stored in the named Docker volume `api_uploads_dev` at `/var/lib/rentaldrivego/storage` inside the container. They stay available across normal container rebuilds and restarts.
Default dev platform administrator:
- email: `admin@rentaldrivego.com`
@@ -188,6 +190,8 @@ Docker will:
Traefik automatically picks up the containers and provisions TLS certificates. Services are live at their `https://` URLs within ~30 seconds.
Uploaded assets handled by the API, including vehicle photos and company branding images, are persisted in the named Docker volume `api_uploads`, mounted inside the API container at `/var/lib/rentaldrivego/storage`. Rebuilding or redeploying the API no longer clears those files as long as that volume is kept.
#### Updating after a code change
Pull the latest code and rebuild only the changed service:
+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">
+4
View File
@@ -86,12 +86,15 @@ services:
condition: service_completed_successfully
env_file:
- .env.docker.dev
environment:
FILE_STORAGE_ROOT: /var/lib/rentaldrivego/storage
command: ["npm", "run", "dev", "--workspace", "@rentaldrivego/api"]
ports:
- "4000:4000"
volumes:
- .:/app
- app_node_modules:/app/node_modules
- api_uploads_dev:/var/lib/rentaldrivego/storage
restart: unless-stopped
profiles: ["api", "full"]
@@ -153,3 +156,4 @@ volumes:
pgmanage_dev_data:
app_node_modules:
postgres_bootstrap_state:
api_uploads_dev:
+4
View File
@@ -48,10 +48,13 @@ services:
- .env.docker.production
environment:
DATABASE_URL_FROM_POSTGRES: ${DATABASE_URL_FROM_POSTGRES:-true}
FILE_STORAGE_ROOT: /var/lib/rentaldrivego/storage
POSTGRES_HOST: ${POSTGRES_HOST:-postgres}
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
POSTGRES_DB: ${POSTGRES_DB:-rentaldrivego}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
volumes:
- api_uploads:/var/lib/rentaldrivego/storage
restart: unless-stopped
labels:
- traefik.enable=true
@@ -176,3 +179,4 @@ volumes:
postgres_prod_data:
redis_prod_data:
pgmanage_prod_data:
api_uploads:
@@ -0,0 +1,2 @@
ALTER TABLE "customers"
ADD COLUMN "licenseImageUrl" TEXT;
+1
View File
@@ -609,6 +609,7 @@ model Customer {
licenseExpiry DateTime?
licenseIssuedAt DateTime?
licenseCountry String?
licenseImageUrl String?
licenseNumber String?
licenseCategory String?
licenseExpired Boolean @default(false)