fix the upload issue and add driver license upload
This commit is contained in:
@@ -7,6 +7,7 @@ node_modules/
|
||||
dist/
|
||||
.next/
|
||||
out/
|
||||
apps/api/storage/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 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() {
|
||||
<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: '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() {
|
||||
<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">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "customers"
|
||||
ADD COLUMN "licenseImageUrl" TEXT;
|
||||
@@ -609,6 +609,7 @@ model Customer {
|
||||
licenseExpiry DateTime?
|
||||
licenseIssuedAt DateTime?
|
||||
licenseCountry String?
|
||||
licenseImageUrl String?
|
||||
licenseNumber String?
|
||||
licenseCategory String?
|
||||
licenseExpired Boolean @default(false)
|
||||
|
||||
Reference in New Issue
Block a user