diff --git a/.gitignore b/.gitignore index 15a7330..564c677 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules/ dist/ .next/ out/ +apps/api/storage/ # Environment variables .env diff --git a/DOCKER.md b/DOCKER.md index c8319fa..3dff448 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -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: diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index bca5b3c..3b6cb92 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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) diff --git a/apps/api/src/lib/storage.ts b/apps/api/src/lib/storage.ts index 1f66983..2630279 100644 --- a/apps/api/src/lib/storage.ts +++ b/apps/api/src/lib/storage.ts @@ -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 { - 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 { + 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) } diff --git a/apps/api/src/routes/customers.ts b/apps/api/src/routes/customers.ts index 8c7fa9d..bf94210 100644 --- a/apps/api/src/routes/customers.ts +++ b/apps/api/src/routes/customers.ts @@ -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) diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx index 6a5e79a..85a61e6 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx @@ -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 d’image', 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() {

{row.email}

{row.phone ?? copy.noPhone}

- {row.licenseValidationStatus} + +
+ {row.licenseValidationStatus} + + {row.licenseImageUrl ? copy.imageUploaded : copy.imageMissing} + +
+ {row.flagged ? {copy.flagged} : {copy.clear}} ))} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/reservations/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/reservations/[id]/page.tsx index 16e5142..a9073ba 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/reservations/[id]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/reservations/[id]/page.tsx @@ -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: 'L’inspection de départ est modifiable après confirmation et avant le retour.', checkOutReadOnly: 'L’inspection de retour devient modifiable une fois le véhicule retourné.', inspectionClosed: 'Cette réservation est clôturée. Les modifications d’inspection 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() {

{reservation.customer.email}

{reservation.customer.phone ?? r.noPhone}

{r.licenseLabel} {reservation.customer.driverLicense ?? r.notCaptured}

+
+

{copy.licenseImageLabel}

+ {reservation.customer.licenseImageUrl ? ( + + Driver license + + ) : ( +

{copy.noLicenseImage}

+ )} +
{reservation.customer.licenseValidationStatus} {reservation.customer.flagged && {r.flaggedBadge}} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/reservations/new/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/reservations/new/page.tsx index aa82936..fa961d5 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/reservations/new/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/reservations/new/page.tsx @@ -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(null) + const [licenseImageFile, setLicenseImageFile] = useState(null) + const [licenseImagePreviewUrl, setLicenseImagePreviewUrl] = useState(null) const [additionalDriver, setAdditionalDriver] = useState({ 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(`/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('/customers', { + const created = await apiFetch('/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('/reservations', { method: 'POST', body: JSON.stringify({ @@ -477,6 +535,32 @@ export default function NewReservationPage() { setLicenseCategory(e.target.value)} className="input-field" />
+
+ +

{copy.licenseImageHint}

+ {licenseImageFile ? ( +

{copy.licenseImageSelected}: {licenseImageFile.name}

+ ) : licenseImageUrl ? ( +

{copy.licenseImageCurrent}

+ ) : ( +

{copy.noLicenseImage}

+ )} + {licenseImagePreviewUrl || licenseImageUrl ? ( + Driver license preview + ) : null} +
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a6c0961..098d447 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 0963970..0d94a80 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -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: diff --git a/packages/database/prisma/migrations/20260520000000_add_customer_license_image/migration.sql b/packages/database/prisma/migrations/20260520000000_add_customer_license_image/migration.sql new file mode 100644 index 0000000..962253f --- /dev/null +++ b/packages/database/prisma/migrations/20260520000000_add_customer_license_image/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "customers" +ADD COLUMN "licenseImageUrl" TEXT; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 705c66c..ae8b590 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -609,6 +609,7 @@ model Customer { licenseExpiry DateTime? licenseIssuedAt DateTime? licenseCountry String? + licenseImageUrl String? licenseNumber String? licenseCategory String? licenseExpired Boolean @default(false)