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/ dist/
.next/ .next/
out/ out/
apps/api/storage/
# Environment variables # Environment variables
.env .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. 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: Default dev platform administrator:
- email: `admin@rentaldrivego.com` - 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. 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 #### Updating after a code change
Pull the latest code and rebuild only the changed service: 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 { z } from 'zod'
import { redis } from './lib/redis' import { redis } from './lib/redis'
import { prisma } from './lib/prisma' import { prisma } from './lib/prisma'
import { getStorageRoot } from './lib/storage'
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter' import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
// ─── Routes ─────────────────────────────────────────────────── // ─── Routes ───────────────────────────────────────────────────
@@ -122,8 +123,8 @@ subscriber.on('pmessage', (_pattern, channel, message) => {
}) })
// ─── Middleware ──────────────────────────────────────────────── // ─── Middleware ────────────────────────────────────────────────
// Serve local file storage // Serve uploaded assets from the configured storage root.
app.use('/storage', express.static(require('path').resolve(__dirname, 'lib/storage'))) app.use('/storage', express.static(getStorageRoot()))
// Webhook must use raw body BEFORE express.json() // Webhook must use raw body BEFORE express.json()
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter) 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 path from 'path'
import crypto from 'crypto' 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 { function getApiBase(): string {
return (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '') return (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '')
@@ -13,7 +25,8 @@ export async function uploadImage(
folder: string, folder: string,
publicId?: string publicId?: string
): Promise<string> { ): Promise<string> {
const folderPath = path.join(STORAGE_ROOT, folder) const storageRoot = ensureStorageRoot()
const folderPath = path.join(storageRoot, folder)
fs.mkdirSync(folderPath, { recursive: true }) fs.mkdirSync(folderPath, { recursive: true })
const filename = publicId const filename = publicId
@@ -26,10 +39,11 @@ export async function uploadImage(
} }
export async function deleteImage(imageUrl: string): Promise<void> { export async function deleteImage(imageUrl: string): Promise<void> {
const storageRoot = getStorageRoot()
const marker = '/storage/' const marker = '/storage/'
const idx = imageUrl.indexOf(marker) const idx = imageUrl.indexOf(marker)
if (idx === -1) return if (idx === -1) return
const relative = imageUrl.slice(idx + marker.length) 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) if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
} }
+28
View File
@@ -1,6 +1,8 @@
import { Router } from 'express' import { Router } from 'express'
import { z } from 'zod' import { z } from 'zod'
import multer from 'multer'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { deleteImage, uploadImage } from '../lib/storage'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth' import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant' import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription' import { requireSubscription } from '../middleware/requireSubscription'
@@ -8,6 +10,7 @@ import { requireRole } from '../middleware/requireRole'
import { validateAndFlagLicense } from '../services/licenseValidationService' import { validateAndFlagLicense } from '../services/licenseValidationService'
const router = Router() const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
router.use(requireCompanyAuth, requireTenant, requireSubscription) router.use(requireCompanyAuth, requireTenant, requireSubscription)
const paginationSchema = z.object({ const paginationSchema = z.object({
@@ -113,6 +116,31 @@ router.post('/:id/validate-license', async (req, res, next) => {
} catch (err) { next(err) } } 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) => { router.post('/:id/approve-license', requireRole('MANAGER'), async (req, res, next) => {
try { try {
const { decision, note } = z.object({ decision: z.enum(['APPROVE', 'DENY']), note: z.string().optional() }).parse(req.body) 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 phone: string | null
flagged: boolean flagged: boolean
licenseValidationStatus: string licenseValidationStatus: string
licenseImageUrl: string | null
} }
export default function CustomersPage() { export default function CustomersPage() {
@@ -26,6 +27,8 @@ export default function CustomersPage() {
contact: 'Contact', contact: 'Contact',
license: 'License', license: 'License',
flags: 'Flags', flags: 'Flags',
imageUploaded: 'Image uploaded',
imageMissing: 'No image',
noPhone: 'No phone', noPhone: 'No phone',
flagged: 'Flagged', flagged: 'Flagged',
clear: 'Clear', clear: 'Clear',
@@ -38,6 +41,8 @@ export default function CustomersPage() {
contact: 'Contact', contact: 'Contact',
license: 'Permis', license: 'Permis',
flags: 'Signalements', flags: 'Signalements',
imageUploaded: 'Image téléversée',
imageMissing: 'Pas dimage',
noPhone: 'Pas de téléphone', noPhone: 'Pas de téléphone',
flagged: 'Signalé', flagged: 'Signalé',
clear: 'Aucun risque', clear: 'Aucun risque',
@@ -50,6 +55,8 @@ export default function CustomersPage() {
contact: 'التواصل', contact: 'التواصل',
license: 'الرخصة', license: 'الرخصة',
flags: 'الإشارات', flags: 'الإشارات',
imageUploaded: 'تم رفع الصورة',
imageMissing: 'لا توجد صورة',
noPhone: 'لا يوجد هاتف', noPhone: 'لا يوجد هاتف',
flagged: 'مُعلّم', flagged: 'مُعلّم',
clear: 'سليم', clear: 'سليم',
@@ -91,7 +98,14 @@ export default function CustomersPage() {
<p className="text-sm text-slate-700">{row.email}</p> <p className="text-sm text-slate-700">{row.email}</p>
<p className="text-xs text-slate-500">{row.phone ?? copy.noPhone}</p> <p className="text-xs text-slate-500">{row.phone ?? copy.noPhone}</p>
</td> </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> <td className="px-6 py-4">{row.flagged ? <span className="badge-red">{copy.flagged}</span> : <span className="badge-green">{copy.clear}</span>}</td>
</tr> </tr>
))} ))}
@@ -35,6 +35,7 @@ interface ReservationDetail {
email: string email: string
phone: string | null phone: string | null
driverLicense: string | null driverLicense: string | null
licenseImageUrl: string | null
licenseValidationStatus: string licenseValidationStatus: string
flagged: boolean flagged: boolean
} }
@@ -115,6 +116,8 @@ const detailCopy = {
checkInReadOnly: 'Check-in inspection is only editable after the reservation is confirmed and before return.', 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.', checkOutReadOnly: 'Check-out inspection becomes editable once the vehicle is returned.',
inspectionClosed: 'This reservation is closed. Inspection edits are disabled.', inspectionClosed: 'This reservation is closed. Inspection edits are disabled.',
licenseImageLabel: 'License image',
noLicenseImage: 'No license image uploaded.',
}, },
fr: { fr: {
editBooking: 'Modifier la réservation', 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.', 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é.', 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.', 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: { ar: {
editBooking: 'تعديل الحجز', editBooking: 'تعديل الحجز',
@@ -185,6 +190,8 @@ const detailCopy = {
checkInReadOnly: 'يمكن تعديل فحص الاستلام بعد تأكيد الحجز وقبل الإرجاع فقط.', checkInReadOnly: 'يمكن تعديل فحص الاستلام بعد تأكيد الحجز وقبل الإرجاع فقط.',
checkOutReadOnly: 'يصبح فحص الإرجاع قابلاً للتعديل بعد عودة السيارة.', checkOutReadOnly: 'يصبح فحص الإرجاع قابلاً للتعديل بعد عودة السيارة.',
inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.', inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
licenseImageLabel: 'صورة الرخصة',
noLicenseImage: 'لم يتم رفع صورة الرخصة.',
}, },
} as const } 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.email}</p>
<p className="text-slate-600">{reservation.customer.phone ?? r.noPhone}</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> <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"> <div className="flex flex-wrap gap-2 pt-2">
<span className="badge-gray">{reservation.customer.licenseValidationStatus}</span> <span className="badge-gray">{reservation.customer.licenseValidationStatus}</span>
{reservation.customer.flagged && <span className="badge-red">{r.flaggedBadge}</span>} {reservation.customer.flagged && <span className="badge-red">{r.flaggedBadge}</span>}
@@ -11,14 +11,15 @@ type Customer = {
firstName: string firstName: string
lastName: string lastName: string
email: string email: string
phone?: string | null
driverLicense?: string | null driverLicense?: string | null
licenseExpiry?: string | null licenseExpiry?: string | null
licenseIssuedAt?: string | null licenseIssuedAt?: string | null
licenseCountry?: string | null licenseCountry?: string | null
licenseCategory?: string | null licenseCategory?: string | null
licenseImageUrl?: string | null
} }
type Vehicle = { id: string; make: string; model: string; licensePlate: string; status: string } type Vehicle = { id: string; make: string; model: string; licensePlate: string; status: string }
type CreatedCustomer = { id: string; firstName: string; lastName: string; email: string }
type AdditionalDriverForm = { type AdditionalDriverForm = {
firstName: BilingualField firstName: BilingualField
lastName: BilingualField lastName: BilingualField
@@ -65,6 +66,9 @@ export default function NewReservationPage() {
const [licenseIssuedAt, setLicenseIssuedAt] = useState('') const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
const [licenseCountry, setLicenseCountry] = useState('') const [licenseCountry, setLicenseCountry] = useState('')
const [licenseCategory, setLicenseCategory] = 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>({ const [additionalDriver, setAdditionalDriver] = useState<AdditionalDriverForm>({
firstName: emptyBilingual(), firstName: emptyBilingual(),
lastName: emptyBilingual(), lastName: emptyBilingual(),
@@ -96,6 +100,11 @@ export default function NewReservationPage() {
licenseIssuedAt: 'License issued at', licenseIssuedAt: 'License issued at',
licenseCountry: 'License country', licenseCountry: 'License country',
licenseCategory: 'License category', 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', addAdditionalDriver: 'Add additional driver',
additionalDriverInfo: 'Additional driver', additionalDriverInfo: 'Additional driver',
dateOfBirth: 'Date of birth', dateOfBirth: 'Date of birth',
@@ -145,6 +154,11 @@ export default function NewReservationPage() {
licenseIssuedAt: 'Permis délivré le', licenseIssuedAt: 'Permis délivré le',
licenseCountry: 'Pays du permis', licenseCountry: 'Pays du permis',
licenseCategory: 'Catégorie 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', addAdditionalDriver: 'Ajouter un conducteur supplémentaire',
additionalDriverInfo: 'Conducteur supplémentaire', additionalDriverInfo: 'Conducteur supplémentaire',
dateOfBirth: 'Date de naissance', dateOfBirth: 'Date de naissance',
@@ -194,6 +208,11 @@ export default function NewReservationPage() {
licenseIssuedAt: 'تاريخ إصدار الرخصة', licenseIssuedAt: 'تاريخ إصدار الرخصة',
licenseCountry: 'بلد الرخصة', licenseCountry: 'بلد الرخصة',
licenseCategory: 'فئة الرخصة', licenseCategory: 'فئة الرخصة',
licenseImage: 'صورة رخصة القيادة',
licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.',
licenseImageSelected: 'الملف المحدد',
licenseImageCurrent: 'الصورة الحالية',
noLicenseImage: 'لم يتم رفع صورة الرخصة بعد.',
addAdditionalDriver: 'إضافة سائق إضافي', addAdditionalDriver: 'إضافة سائق إضافي',
additionalDriverInfo: 'السائق الإضافي', additionalDriverInfo: 'السائق الإضافي',
dateOfBirth: 'تاريخ الميلاد', dateOfBirth: 'تاريخ الميلاد',
@@ -261,8 +280,38 @@ export default function NewReservationPage() {
setLicenseIssuedAt(selected.licenseIssuedAt ? selected.licenseIssuedAt.slice(0, 10) : '') setLicenseIssuedAt(selected.licenseIssuedAt ? selected.licenseIssuedAt.slice(0, 10) : '')
setLicenseCountry(selected.licenseCountry ?? '') setLicenseCountry(selected.licenseCountry ?? '')
setLicenseCategory(selected.licenseCategory ?? '') setLicenseCategory(selected.licenseCategory ?? '')
setLicenseImageUrl(selected.licenseImageUrl ?? null)
setLicenseImageFile(null)
}, [customerId, customers]) }, [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() { async function addCustomer() {
setError(null) setError(null)
if (!bilingualPrimary(newCustomerFirstName).trim() || !bilingualPrimary(newCustomerLastName).trim() || !newCustomerEmail.trim() || !newCustomerPhone.trim()) { if (!bilingualPrimary(newCustomerFirstName).trim() || !bilingualPrimary(newCustomerLastName).trim() || !newCustomerEmail.trim() || !newCustomerPhone.trim()) {
@@ -271,7 +320,7 @@ export default function NewReservationPage() {
} }
setSavingCustomer(true) setSavingCustomer(true)
try { try {
const created = await apiFetch<CreatedCustomer>('/customers', { const created = await apiFetch<Customer>('/customers', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
firstName: bilingualPrimary(newCustomerFirstName).trim(), firstName: bilingualPrimary(newCustomerFirstName).trim(),
@@ -295,6 +344,11 @@ export default function NewReservationPage() {
setNewCustomerLastName(emptyBilingual()) setNewCustomerLastName(emptyBilingual())
setNewCustomerEmail('') setNewCustomerEmail('')
setNewCustomerPhone('') setNewCustomerPhone('')
setLicenseImageUrl(created.licenseImageUrl ?? null)
if (licenseImageFile) {
await uploadLicenseImage(created.id)
}
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
} finally { } finally {
@@ -332,6 +386,10 @@ export default function NewReservationPage() {
}), }),
}) })
if (licenseImageFile) {
await uploadLicenseImage(customerId)
}
const created = await apiFetch<CreatedReservation>('/reservations', { const created = await apiFetch<CreatedReservation>('/reservations', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
@@ -477,6 +535,32 @@ export default function NewReservationPage() {
<input value={licenseCategory} onChange={(e) => setLicenseCategory(e.target.value)} className="input-field" /> <input value={licenseCategory} onChange={(e) => setLicenseCategory(e.target.value)} className="input-field" />
</label> </label>
</div> </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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+4
View File
@@ -86,12 +86,15 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
env_file: env_file:
- .env.docker.dev - .env.docker.dev
environment:
FILE_STORAGE_ROOT: /var/lib/rentaldrivego/storage
command: ["npm", "run", "dev", "--workspace", "@rentaldrivego/api"] command: ["npm", "run", "dev", "--workspace", "@rentaldrivego/api"]
ports: ports:
- "4000:4000" - "4000:4000"
volumes: volumes:
- .:/app - .:/app
- app_node_modules:/app/node_modules - app_node_modules:/app/node_modules
- api_uploads_dev:/var/lib/rentaldrivego/storage
restart: unless-stopped restart: unless-stopped
profiles: ["api", "full"] profiles: ["api", "full"]
@@ -153,3 +156,4 @@ volumes:
pgmanage_dev_data: pgmanage_dev_data:
app_node_modules: app_node_modules:
postgres_bootstrap_state: postgres_bootstrap_state:
api_uploads_dev:
+4
View File
@@ -48,10 +48,13 @@ services:
- .env.docker.production - .env.docker.production
environment: environment:
DATABASE_URL_FROM_POSTGRES: ${DATABASE_URL_FROM_POSTGRES:-true} DATABASE_URL_FROM_POSTGRES: ${DATABASE_URL_FROM_POSTGRES:-true}
FILE_STORAGE_ROOT: /var/lib/rentaldrivego/storage
POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_HOST: ${POSTGRES_HOST:-postgres}
POSTGRES_PORT: ${POSTGRES_PORT:-5432} POSTGRES_PORT: ${POSTGRES_PORT:-5432}
POSTGRES_DB: ${POSTGRES_DB:-rentaldrivego} POSTGRES_DB: ${POSTGRES_DB:-rentaldrivego}
POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres}
volumes:
- api_uploads:/var/lib/rentaldrivego/storage
restart: unless-stopped restart: unless-stopped
labels: labels:
- traefik.enable=true - traefik.enable=true
@@ -176,3 +179,4 @@ volumes:
postgres_prod_data: postgres_prod_data:
redis_prod_data: redis_prod_data:
pgmanage_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? licenseExpiry DateTime?
licenseIssuedAt DateTime? licenseIssuedAt DateTime?
licenseCountry String? licenseCountry String?
licenseImageUrl String?
licenseNumber String? licenseNumber String?
licenseCategory String? licenseCategory String?
licenseExpired Boolean @default(false) licenseExpired Boolean @default(false)