| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898 |
- generator client {
- provider = "prisma-client"
- output = "../src/generated/prisma"
- }
- datasource db {
- provider = "postgresql"
- }
- model User {
- id String @id @default(cuid())
- name String
- email String @unique
- emailVerified Boolean @default(false)
- image String?
- isSuperAdmin Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- twoFactorEnabled Boolean @default(false)
- termsAcceptedAt DateTime?
- lastLogin DateTime?
- lastSeen DateTime?
- /// App version this user last saw/dismissed the update banner for.
- /// Null = never seeded (seeded silently on first load, no banner).
- lastSeenVersion String?
- /// Per-user dashboard grid layout (card positions/sizes/hidden set).
- /// Null = default layout. Shape validated in dashboardLayoutActions.
- dashboardLayout Json?
- sessions Session[]
- accounts Account[]
- vehicles Vehicle[]
- customers Customer[]
- settings AppSetting[]
- inventoryParts InventoryPart[]
- quotes Quote[]
- customFieldDefinitions CustomFieldDefinition[]
- twoFactor TwoFactor?
- passkeys Passkey[]
- organizationMembers OrganizationMember[]
- dashboardWidgets DashboardWidget[]
- auditLogs AuditLog[]
- technicians Technician[]
- stockMovements StockMovement[]
- tireWarehouses TireWarehouse[]
- tireSets TireSet[]
- tireSetAttachments TireSetAttachment[]
- tireMeasurements TireMeasurement[]
- tireMovements TireMovement[]
- tireTreatments TireTreatment[]
- @@map("users")
- }
- model AppSetting {
- id String @id @default(cuid())
- key String
- value String
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- organizationId String?
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- @@unique([organizationId, key])
- @@index([organizationId])
- @@map("app_settings")
- }
- model Session {
- id String @id @default(cuid())
- expiresAt DateTime
- token String @unique
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- ipAddress String?
- userAgent String?
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- @@index([userId])
- @@map("sessions")
- }
- model Account {
- id String @id @default(cuid())
- accountId String
- providerId String
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- accessToken String?
- refreshToken String?
- idToken String?
- accessTokenExpiresAt DateTime?
- refreshTokenExpiresAt DateTime?
- scope String?
- password String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- @@index([userId])
- @@map("accounts")
- }
- model Verification {
- id String @id @default(cuid())
- identifier String @unique
- value String
- expiresAt DateTime
- createdAt DateTime? @default(now())
- updatedAt DateTime? @updatedAt
- @@map("verifications")
- }
- model TwoFactor {
- id String @id @default(cuid())
- secret String
- backupCodes String
- userId String @unique
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- @@map("two_factor")
- }
- model Passkey {
- id String @id @default(cuid())
- name String?
- publicKey String
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- credentialID String @unique
- counter Int
- deviceType String
- backedUp Boolean
- transports String?
- aaguid String?
- createdAt DateTime @default(now())
- @@index([userId])
- @@map("passkeys")
- }
- model Vehicle {
- id String @id @default(cuid())
- make String
- model String
- year Int
- vin String?
- licensePlate String?
- color String?
- mileage Int @default(0)
- fuelType String? @default("gasoline")
- transmission String? @default("automatic")
- engineSize String?
- engineCode String?
- purchaseDate DateTime?
- purchasePrice Float?
- imageUrl String?
- isArchived Boolean @default(false)
- archiveReason String?
- maintenanceDismissed Boolean @default(false)
- maintenanceDismissedAt DateTime?
- aiMessages AiGeneratedMessage[]
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- organizationId String?
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- customerId String?
- customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
- serviceRecords ServiceRecord[]
- notes Note[]
- fuelLogs FuelLog[]
- reminders Reminder[]
- quotes Quote[]
- recurringInvoices RecurringInvoice[]
- inspections Inspection[]
- serviceRequests ServiceRequest[]
- findings VehicleFinding[]
- scheduledMessages ScheduledMessage[]
- tireSets TireSet[]
- @@index([organizationId])
- @@map("vehicles")
- }
- model ServiceRecord {
- id String @id @default(cuid())
- title String
- description String?
- type String @default("maintenance")
- status String @default("completed")
- cost Float @default(0)
- mileage Int?
- serviceDate DateTime @default(now())
- startDateTime DateTime?
- endDateTime DateTime?
- shopName String?
- techName String?
- parts String?
- laborHours Float?
- diagnosticNotes String?
- invoiceNotes String?
- subtotal Float @default(0)
- taxRate Float @default(0)
- taxAmount Float @default(0)
- taxInclusive Boolean @default(false)
- totalAmount Float @default(0)
- invoiceNumber String?
- invoiceDate DateTime?
- invoiceDueDate DateTime?
- discountType String?
- discountValue Float @default(0)
- discountAmount Float @default(0)
- manuallyPaid Boolean @default(false)
- // Warranty tracking
- warrantyMonths Int?
- warrantyMileage Int?
- warrantyExpiresAt DateTime?
- warrantyNotes String?
- publicToken String? @unique
- sharedAt DateTime?
- viewCount Int @default(0)
- lastViewedAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- /// The stored set this job is about, when it came out of the tire hotel.
- /// Without it a tire-change work order says nothing about which tires, and
- /// the technician has to go and ask.
- tireSetId String?
- tireSet TireSet? @relation(fields: [tireSetId], references: [id], onDelete: SetNull)
- /// Null for parts-only / counter sales; such records use customerId
- /// (or neither, for anonymous walk-in sales)
- vehicleId String?
- vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- /// Direct customer for records without a vehicle. Vehicle-linked records
- /// leave this null and resolve the customer through the vehicle, so
- /// reassigning a vehicle keeps invoices following the vehicle's customer.
- customerId String?
- customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
- /// Set when the job came straight from an inspection, so the inspection can
- /// show what was raised from it. SetNull: deleting the inspection must not
- /// take the job — and its invoice — with it.
- inspectionId String?
- inspection Inspection? @relation(fields: [inspectionId], references: [id], onDelete: SetNull)
- /// Denormalized from the vehicle historically; now the canonical scope so
- /// records exist without a vehicle. Nullable to mirror Vehicle.organizationId.
- organizationId String?
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- technicianId String?
- technician Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
- sortOrder Int @default(0)
- partItems ServicePart[]
- laborItems ServiceLabor[]
- attachments ServiceAttachment[]
- payments Payment[]
- statusReports StatusReport[]
- stockMovements StockMovement[]
- discoveredFindings VehicleFinding[] @relation("FindingDiscoveredIn")
- resolvedFindings VehicleFinding[] @relation("FindingResolvedIn")
- @@index([vehicleId])
- @@index([technicianId])
- @@index([organizationId])
- @@index([customerId])
- @@index([inspectionId])
- @@map("service_records")
- }
- model Note {
- id String @id @default(cuid())
- title String
- content String
- isPinned Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- vehicleId String
- vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- @@index([vehicleId])
- @@map("notes")
- }
- model FuelLog {
- id String @id @default(cuid())
- date DateTime @default(now())
- mileage Int
- gallons Float
- pricePerGallon Float
- totalCost Float
- isFillUp Boolean @default(true)
- station String?
- notes String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- vehicleId String
- vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- @@index([vehicleId])
- @@map("fuel_logs")
- }
- model Reminder {
- id String @id @default(cuid())
- title String
- description String?
- dueDate DateTime?
- dueMileage Int?
- isCompleted Boolean @default(false)
- /// Per-reminder notification channels, chosen when creating the reminder
- notifyInApp Boolean @default(true)
- notifyEmail Boolean @default(false)
- /// Set by the due-reminder cron once notified; cleared when dueDate changes
- notifiedAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- /// A reminder relates to a vehicle, a customer, or just the workshop
- /// (both null). organizationId is the canonical scope for all three.
- vehicleId String?
- vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- customerId String?
- customer Customer? @relation(fields: [customerId], references: [id], onDelete: Cascade)
- organizationId String?
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- @@index([vehicleId])
- @@index([customerId])
- @@index([organizationId])
- @@map("reminders")
- }
- model VehicleFinding {
- id String @id @default(cuid())
- description String
- severity String @default("needs_work")
- status String @default("open")
- notes String?
- imageUrls String[] @default([])
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- vehicleId String
- vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- serviceRecordId String?
- serviceRecord ServiceRecord? @relation("FindingDiscoveredIn", fields: [serviceRecordId], references: [id], onDelete: SetNull)
- resolvedServiceRecordId String?
- resolvedServiceRecord ServiceRecord? @relation("FindingResolvedIn", fields: [resolvedServiceRecordId], references: [id], onDelete: SetNull)
- @@index([vehicleId])
- @@index([serviceRecordId])
- @@map("vehicle_findings")
- }
- model Customer {
- id String @id @default(cuid())
- /// Human-facing customer number: auto-assigned sequentially on create,
- /// editable afterwards. Unique per organization (nulls allowed for
- /// legacy customers).
- customerNumber String?
- name String
- email String?
- phone String?
- address String?
- company String?
- taxId String?
- taxExempt Boolean @default(false)
- notes String?
- telegramChatId String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- organizationId String?
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- vehicles Vehicle[]
- quotes Quote[]
- smsMessages SmsMessage[]
- telegramMessages TelegramMessage[]
- customerSessions CustomerSession[]
- scheduledMessages ScheduledMessage[]
- serviceRequests ServiceRequest[]
- serviceRecords ServiceRecord[]
- reminders Reminder[]
- tireSets TireSet[]
- @@unique([organizationId, customerNumber])
- @@index([organizationId])
- @@map("customers")
- }
- model Payment {
- id String @id @default(cuid())
- amount Float
- date DateTime @default(now())
- method String @default("other")
- note String?
- provider String?
- externalId String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- serviceRecordId String
- serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
- @@index([serviceRecordId])
- @@map("payments")
- }
- model ServiceAttachment {
- id String @id @default(cuid())
- fileName String
- fileUrl String
- fileType String
- fileSize Int
- category String @default("diagnostic")
- description String?
- includeInInvoice Boolean @default(true)
- createdAt DateTime @default(now())
- serviceRecordId String
- serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
- @@index([serviceRecordId])
- @@map("service_attachments")
- }
- model ServicePart {
- id String @id @default(cuid())
- partNumber String?
- name String
- quantity Float @default(1)
- unitPrice Float @default(0)
- total Float @default(0)
- unitCost Float @default(0)
- markupPercent Float @default(0)
- inventoryPartId String?
- serviceRecordId String
- serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
- @@index([serviceRecordId])
- @@map("service_parts")
- }
- model ServiceLabor {
- id String @id @default(cuid())
- description String
- hours Float @default(0)
- rate Float @default(0)
- total Float @default(0)
- pricingType String @default("hourly")
- serviceRecordId String
- serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
- @@index([serviceRecordId])
- @@map("service_labor")
- }
- model LaborPreset {
- id String @id @default(cuid())
- name String
- description String?
- isArchived Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- userId String
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- items LaborPresetItem[]
- parts LaborPresetPart[]
- @@index([organizationId])
- @@map("labor_presets")
- }
- model LaborPresetItem {
- id String @id @default(cuid())
- description String
- hours Float @default(0)
- rate Float @default(0)
- pricingType String @default("hourly")
- sortOrder Int @default(0)
- presetId String
- preset LaborPreset @relation(fields: [presetId], references: [id], onDelete: Cascade)
- @@index([presetId])
- @@map("labor_preset_items")
- }
- model LaborPresetPart {
- id String @id @default(cuid())
- name String
- partNumber String?
- quantity Float @default(1)
- unitPrice Float @default(0)
- inventoryPartId String?
- sortOrder Int @default(0)
- presetId String
- preset LaborPreset @relation(fields: [presetId], references: [id], onDelete: Cascade)
- @@index([presetId])
- @@map("labor_preset_parts")
- }
- model InventoryPart {
- id String @id @default(cuid())
- partNumber String?
- barcode String?
- name String
- description String?
- category String?
- quantity Int @default(0)
- minQuantity Int @default(0)
- unitCost Float @default(0)
- sellPrice Float @default(0)
- supplier String?
- supplierPhone String?
- supplierEmail String?
- supplierUrl String?
- imageUrl String?
- location String?
- isArchived Boolean @default(false)
- /// Set when a low-stock alert has been sent for the current dip below
- /// `minQuantity`, and cleared once stock climbs back above it. This
- /// hysteresis is what stops a part that sits at zero from re-alerting on
- /// every check — it alerts once per descent, not once per run.
- lowStockAlertedAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- organizationId String?
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- gallery StoredImage[] @relation("InventoryPartImages")
- movements StockMovement[]
- @@unique([organizationId, barcode])
- @@index([organizationId])
- @@map("inventory_parts")
- }
- /// Append-only audit trail of every change to InventoryPart.quantity.
- ///
- /// Written inside the same transaction as the stock mutation itself, so the
- /// ledger can never drift from the balance it describes. `quantityAfter` is
- /// captured from the atomic `UPDATE ... RETURNING`, giving an exact running
- /// balance even under concurrent writes.
- model StockMovement {
- id String @id @default(cuid())
- /// Signed change in whole units. Negative = consumed, positive = restocked.
- delta Int
- /// Part balance immediately after this movement was applied.
- quantityAfter Int
- /// Why the stock moved — see StockMovementReason in src/features/inventory/Lib/stockMovement.ts
- reason String
- note String?
- inventoryPartId String
- inventoryPart InventoryPart @relation(fields: [inventoryPartId], references: [id], onDelete: Cascade)
- /// The job the part was used on. Kept on SetNull so history survives
- /// deletion of the service record — "where was this used" outlives the job.
- serviceRecordId String?
- serviceRecord ServiceRecord? @relation(fields: [serviceRecordId], references: [id], onDelete: SetNull)
- /// Denormalised so history still reads correctly after a service record is
- /// deleted and serviceRecordId goes null.
- serviceRecordLabel String?
- userId String?
- user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- createdAt DateTime @default(now())
- @@index([inventoryPartId, createdAt(sort: Desc)])
- @@index([organizationId, createdAt(sort: Desc)])
- @@index([serviceRecordId])
- @@map("stock_movements")
- }
- model StoredImage {
- id String @id @default(cuid())
- url String
- fileName String?
- description String?
- sortOrder Int @default(0)
- createdAt DateTime @default(now())
- inventoryPartId String?
- inventoryPart InventoryPart? @relation("InventoryPartImages", fields: [inventoryPartId], references: [id], onDelete: Cascade)
- /// Condition photos taken when a tire is measured. Second parent on this
- /// model — an image belongs to exactly one of them.
- tireMeasurementId String?
- tireMeasurement TireMeasurement? @relation("TireMeasurementImages", fields: [tireMeasurementId], references: [id], onDelete: Cascade)
- @@index([inventoryPartId])
- @@index([tireMeasurementId])
- @@map("stored_images")
- }
- model Quote {
- id String @id @default(cuid())
- quoteNumber String?
- title String
- description String?
- status String @default("draft")
- validUntil DateTime?
- subtotal Float @default(0)
- taxRate Float @default(0)
- taxAmount Float @default(0)
- taxInclusive Boolean @default(false)
- discountType String?
- discountValue Float @default(0)
- discountAmount Float @default(0)
- totalAmount Float @default(0)
- notes String?
- publicToken String?
- sharedAt DateTime?
- viewCount Int @default(0)
- lastViewedAt DateTime?
- customerMessage String?
- convertedToId String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- organizationId String?
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- customerId String?
- customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
- /// The stored set this quote is about, when it came out of the tire hotel.
- /// SetNull rather than Cascade: a quote already sent is a document the
- /// customer holds, and deleting the set must not erase it.
- tireSetId String?
- tireSet TireSet? @relation(fields: [tireSetId], references: [id], onDelete: SetNull)
- vehicleId String?
- vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
- inspectionId String?
- inspection Inspection? @relation(fields: [inspectionId], references: [id], onDelete: SetNull)
- partItems QuotePart[]
- laborItems QuoteLabor[]
- attachments QuoteAttachment[]
- @@index([organizationId])
- @@map("quotes")
- }
- model QuoteAttachment {
- id String @id @default(cuid())
- fileName String
- fileUrl String
- fileType String
- fileSize Int
- category String @default("image")
- description String?
- includeInInvoice Boolean @default(true)
- createdAt DateTime @default(now())
- quoteId String
- quote Quote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
- @@index([quoteId])
- @@map("quote_attachments")
- }
- model QuotePart {
- id String @id @default(cuid())
- partNumber String?
- name String
- quantity Float @default(1)
- /// Internal purchase cost — never shown to customers (quote PDF/share
- /// render unitPrice only)
- unitCost Float @default(0)
- markupPercent Float @default(0)
- unitPrice Float @default(0)
- total Float @default(0)
- excluded Boolean @default(false)
- /// Optional link to the stocked part this line was picked from. Carried into
- /// ServicePart on conversion so the job deducts the right inventory item.
- inventoryPartId String?
- quoteId String
- quote Quote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
- @@index([quoteId])
- @@map("quote_parts")
- }
- model QuoteLabor {
- id String @id @default(cuid())
- description String
- hours Float @default(0)
- rate Float @default(0)
- total Float @default(0)
- pricingType String @default("hourly")
- excluded Boolean @default(false)
- quoteId String
- quote Quote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
- @@index([quoteId])
- @@map("quote_labor")
- }
- model CustomFieldDefinition {
- id String @id @default(cuid())
- name String
- label String
- fieldType String @default("text")
- options String?
- required Boolean @default(false)
- entityType String
- sortOrder Int @default(0)
- isActive Boolean @default(true)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- organizationId String?
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- values CustomFieldValue[]
- @@unique([organizationId, name, entityType])
- @@index([organizationId])
- @@map("custom_field_definitions")
- }
- model CustomFieldValue {
- id String @id @default(cuid())
- value String
- entityId String
- entityType String
- fieldId String
- field CustomFieldDefinition @relation(fields: [fieldId], references: [id], onDelete: Cascade)
- @@unique([fieldId, entityId])
- @@index([fieldId])
- @@map("custom_field_values")
- }
- /// A user-defined dashboard card: a named, filtered table over one entity.
- /// The config shape (entity, filters, columns, limit) is validated against
- /// the field registry in customCardActions — nothing here reaches SQL raw.
- model DashboardWidget {
- id String @id @default(cuid())
- name String
- config Json
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- organizationId String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- @@index([userId, organizationId])
- @@map("dashboard_widgets")
- }
- model Organization {
- id String @id @default(cuid())
- name String
- portalSlug String? @unique
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- members OrganizationMember[]
- roles Role[]
- vehicles Vehicle[]
- serviceRecords ServiceRecord[]
- reminders Reminder[]
- customers Customer[]
- quotes Quote[]
- inventoryParts InventoryPart[]
- laborPresets LaborPreset[]
- customFieldDefinitions CustomFieldDefinition[]
- settings AppSetting[]
- subscription Subscription?
- invitations TeamInvitation[]
- inspectionTemplates InspectionTemplate[]
- smsMessages SmsMessage[]
- telegramMessages TelegramMessage[]
- technicians Technician[]
- auditLogs AuditLog[]
- statusReports StatusReport[]
- reportSchedules ReportSchedule[]
- scheduledMessages ScheduledMessage[]
- webhooks Webhook[]
- stockMovements StockMovement[]
- tireWarehouses TireWarehouse[]
- tireLocations TireLocation[]
- tireSets TireSet[]
- tireSetAttachments TireSetAttachment[]
- tireMovements TireMovement[]
- tireTreatments TireTreatment[]
- @@map("organizations")
- }
- model OrganizationMember {
- id String @id @default(cuid())
- role String @default("member")
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- roleId String?
- customRole Role? @relation(fields: [roleId], references: [id], onDelete: SetNull)
- @@unique([userId, organizationId])
- @@map("organization_members")
- }
- model Role {
- id String @id @default(cuid())
- name String
- isAdmin Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- permissions Permission[]
- members OrganizationMember[]
- teamInvitations TeamInvitation[]
- @@unique([organizationId, name])
- @@map("roles")
- }
- model Permission {
- id String @id @default(cuid())
- action String
- subject String
- roleId String
- role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
- @@unique([roleId, action, subject])
- @@index([roleId])
- @@map("permissions")
- }
- model SystemSetting {
- id String @id @default(cuid())
- key String @unique
- value String
- @@map("system_settings")
- }
- model TeamInvitation {
- id String @id @default(cuid())
- email String
- role String @default("member")
- token String @unique
- status String @default("pending") // pending | accepted | cancelled
- expiresAt DateTime
- createdAt DateTime @default(now())
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- invitedById String
- roleId String?
- customRole Role? @relation(fields: [roleId], references: [id], onDelete: SetNull)
- @@unique([email, organizationId])
- @@index([token])
- @@map("team_invitations")
- }
- // Stripe-ready models (implementation in follow-up)
- model SubscriptionPlan {
- id String @id @default(cuid())
- name String
- stripePriceId String? @unique
- price Float @default(0)
- interval String @default("month") // "month" | "year"
- maxMembers Int @default(1)
- features String? // JSON feature flags
- isActive Boolean @default(true)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- subscriptions Subscription[]
- @@map("subscription_plans")
- }
- model Subscription {
- id String @id @default(cuid())
- status String @default("active") // active | canceled | past_due | trialing
- stripeSubscriptionId String? @unique
- stripeCustomerId String?
- currentPeriodStart DateTime?
- currentPeriodEnd DateTime?
- cancelAtPeriodEnd Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- organizationId String @unique
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- planId String
- plan SubscriptionPlan @relation(fields: [planId], references: [id])
- @@map("subscriptions")
- }
- model RecurringInvoice {
- id String @id @default(cuid())
- title String
- description String?
- frequency String // "weekly" | "biweekly" | "monthly" | "quarterly" | "yearly"
- nextRunDate DateTime
- endDate DateTime?
- isActive Boolean @default(true)
- lastRunAt DateTime?
- runCount Int @default(0)
- // Template fields (copied to ServiceRecord on generation)
- type String @default("maintenance")
- cost Float @default(0)
- taxRate Float @default(0)
- taxInclusive Boolean @default(false)
- invoiceNotes String?
- vehicleId String
- vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- templateParts RecurringPart[]
- templateLabor RecurringLabor[]
- @@index([vehicleId])
- @@index([nextRunDate, isActive])
- @@map("recurring_invoices")
- }
- model RecurringPart {
- id String @id @default(cuid())
- name String
- partNumber String?
- quantity Int @default(1)
- unitPrice Float @default(0)
- recurringInvoiceId String
- recurringInvoice RecurringInvoice @relation(fields: [recurringInvoiceId], references: [id], onDelete: Cascade)
- @@map("recurring_parts")
- }
- model RecurringLabor {
- id String @id @default(cuid())
- description String
- hours Float @default(0)
- rate Float @default(0)
- pricingType String @default("hourly")
- recurringInvoiceId String
- recurringInvoice RecurringInvoice @relation(fields: [recurringInvoiceId], references: [id], onDelete: Cascade)
- @@map("recurring_labor")
- }
- model InspectionTemplate {
- id String @id @default(cuid())
- name String
- description String?
- isDefault Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- // Regulatory profile. `country` is an ISO-3166 alpha-2 code (or null for a
- // shop-specific checklist), `standard` names the regime the template follows
- // (e.g. "eu-2014-45"), and `severityScale` picks which defect vocabulary the
- // technician grades against — see Lib/conditions.ts.
- country String?
- standard String? @default("custom")
- severityScale String @default("eu")
- /// Provenance, for templates that came from a package rather than being
- /// written here. `packageId` is namespaced and stable across renames
- /// ("torqvoice/eu-roadworthiness"), which is what lets the library tell an
- /// update from a duplicate. Null on a template the workshop wrote itself.
- packageId String?
- packageVersion String?
- /// Where it came from: "builtin", "file" or "catalogue".
- packageSource String?
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- sections InspectionTemplateSection[]
- inspections Inspection[]
- @@index([organizationId])
- @@index([organizationId, packageId])
- @@map("inspection_templates")
- }
- model InspectionTemplateSection {
- id String @id @default(cuid())
- name String
- description String?
- // Regulation reference for the section, e.g. "1" for Braking equipment in
- // Directive 2014/45/EU Annex I.
- code String?
- sortOrder Int @default(0)
- templateId String
- template InspectionTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
- items InspectionTemplateItem[]
- @@index([templateId])
- @@map("inspection_template_sections")
- }
- model InspectionTemplateItem {
- id String @id @default(cuid())
- name String
- description String?
- // Regulation reference for the check, e.g. "1.1.13".
- code String?
- sortOrder Int @default(0)
- // How the technician records the result: "condition" (defect grading only),
- // "measurement" (numeric value in `unit`, graded against min/max),
- // "text" (free text) or "choice" (one of `choices`).
- inputType String @default("condition")
- unit String?
- minValue Float?
- maxValue Float?
- choices String[] @default([])
- required Boolean @default(false)
- photoRequired Boolean @default(false)
- // Defect severity applied when a measurement falls outside min/max.
- defaultSeverity String?
- // Phrases this workshop wants offered when a defect is recorded against the
- // check, on top of the built-in catalogue. See Lib/defectCatalogue.ts.
- defectSuggestions String[] @default([])
- sectionId String
- section InspectionTemplateSection @relation(fields: [sectionId], references: [id], onDelete: Cascade)
- @@index([sectionId])
- @@map("inspection_template_items")
- }
- model Inspection {
- id String @id @default(cuid())
- status String @default("in_progress")
- mileage Int?
- notes String?
- startDateTime DateTime?
- endDateTime DateTime?
- publicToken String? @unique
- completedAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- vehicleId String
- vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- templateId String
- template InspectionTemplate @relation(fields: [templateId], references: [id])
- technicianId String?
- technician Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
- sortOrder Int @default(0)
- // Roadworthiness certificate fields required by Directive 2014/45/EU Annex IV.
- // `inspectorName` and `certificateNumber` are snapshots taken at completion so
- // the issued certificate stays stable even if staff or settings change later.
- /// Snapshot of how this inspection was graded, taken when it was created.
- /// Read in preference to the template's current setting: a workshop that
- /// later switches a template to the EU scale must not relabel a certificate
- /// it already issued. Null on inspections that predate the snapshot; those
- /// fall back to the template, which the migration pinned to its old scale.
- severityScale String?
- country String?
- vehicleCategory String?
- nextTestDue DateTime?
- certificateNumber String?
- inspectorName String?
- testLocation String?
- organizationId String
- items InspectionItem[]
- quoteRequests InspectionQuoteRequest[]
- quotes Quote[]
- serviceRecords ServiceRecord[]
- @@index([vehicleId])
- @@index([organizationId])
- @@index([technicianId])
- @@map("inspections")
- }
- model InspectionItem {
- id String @id @default(cuid())
- name String
- section String
- sortOrder Int @default(0)
- condition String @default("not_inspected")
- notes String?
- imageUrls String[] @default([])
- // Copied from the template at creation so an inspection stays readable even
- // after its template is edited or deleted.
- description String?
- code String?
- sectionCode String?
- inputType String @default("condition")
- unit String?
- minValue Float?
- maxValue Float?
- choices String[] @default([])
- required Boolean @default(false)
- photoRequired Boolean @default(false)
- defaultSeverity String?
- defectSuggestions String[] @default([])
- measuredValue Float?
- textValue String?
- inspectionId String
- inspection Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
- @@index([inspectionId])
- @@map("inspection_items")
- }
- model InspectionQuoteRequest {
- id String @id @default(cuid())
- status String @default("pending")
- message String?
- selectedItemIds String[]
- createdAt DateTime @default(now())
- inspectionId String
- inspection Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
- organizationId String
- @@index([organizationId])
- @@index([inspectionId])
- @@map("inspection_quote_requests")
- }
- model Notification {
- id String @id @default(cuid())
- type String
- title String
- message String
- entityType String
- entityId String
- entityUrl String
- read Boolean @default(false)
- organizationId String
- createdAt DateTime @default(now())
- @@index([organizationId, createdAt(sort: Desc)])
- @@index([organizationId, read])
- @@map("notifications")
- }
- model SmsMessage {
- id String @id @default(cuid())
- direction String // "inbound" | "outbound"
- fromNumber String
- toNumber String
- body String
- status String @default("queued") // queued | sent | delivered | failed | received
- providerMsgId String?
- errorMessage String?
- relatedEntityType String?
- relatedEntityId String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- customerId String?
- customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
- @@index([organizationId, customerId, createdAt(sort: Desc)])
- @@index([organizationId, createdAt(sort: Desc)])
- @@index([toNumber, organizationId])
- @@map("sms_messages")
- }
- model TelegramMessage {
- id String @id @default(cuid())
- direction String // "inbound" | "outbound"
- chatId String // Telegram chat ID
- body String
- status String @default("queued") // queued | sent | delivered | failed | received
- telegramMessageId String? // Telegram's message ID for tracking
- errorMessage String?
- relatedEntityType String?
- relatedEntityId String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- customerId String?
- customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
- @@index([organizationId, customerId, createdAt(sort: Desc)])
- @@index([organizationId, chatId, createdAt(sort: Desc)])
- @@map("telegram_messages")
- }
- model Technician {
- id String @id @default(cuid())
- name String
- color String @default("#3b82f6")
- isActive Boolean @default(true)
- sortOrder Int @default(0)
- dailyCapacity Int @default(480)
- userId String?
- user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- serviceRecords ServiceRecord[]
- inspections Inspection[]
- statusReports StatusReport[]
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- @@index([organizationId])
- @@map("technicians")
- }
- model AuditLog {
- id String @id @default(cuid())
- timestamp DateTime @default(now())
- action String
- entity String?
- entityId String?
- message String?
- metadata Json?
- ip String?
- userAgent String?
- userId String?
- user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
- organizationId String?
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
- @@index([organizationId, timestamp(sort: Desc)])
- @@index([userId, timestamp(sort: Desc)])
- @@index([action, timestamp(sort: Desc)])
- @@map("audit_logs")
- }
- model CustomerSession {
- id String @id @default(cuid())
- token String @unique
- expiresAt DateTime
- createdAt DateTime @default(now())
- customerId String
- customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
- organizationId String
- @@index([token])
- @@index([customerId, organizationId])
- @@map("customer_sessions")
- }
- model CustomerMagicLink {
- id String @id @default(cuid())
- token String @unique
- email String
- organizationId String
- expiresAt DateTime
- usedAt DateTime?
- createdAt DateTime @default(now())
- @@index([token])
- @@map("customer_magic_links")
- }
- model CustomerSmsCode {
- id String @id @default(cuid())
- code String
- phone String
- organizationId String
- expiresAt DateTime
- usedAt DateTime?
- createdAt DateTime @default(now())
- @@index([phone, organizationId])
- @@index([organizationId])
- @@map("customer_sms_codes")
- }
- model ServiceRequest {
- id String @id @default(cuid())
- description String
- preferredDate DateTime?
- status String @default("pending")
- adminNotes String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- customerId String
- customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
- vehicleId String
- vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- organizationId String
- @@index([organizationId, status])
- @@index([customerId])
- @@map("service_requests")
- }
- model AiGeneratedMessage {
- id String @id @default(cuid())
- vehicleId String
- type String // "summary" | "common_issues" | etc.
- content String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- @@unique([vehicleId, type])
- @@map("ai_generated_messages")
- }
- model AiChat {
- id String @id @default(cuid())
- title String @default("New chat")
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- userId String
- organizationId String
- messages AiChatMessage[]
- @@index([organizationId, userId, updatedAt(sort: Desc)])
- @@map("ai_chats")
- }
- model AiChatMessage {
- id String @id @default(cuid())
- role String // "user" | "assistant"
- content String
- createdAt DateTime @default(now())
- chatId String
- chat AiChat @relation(fields: [chatId], references: [id], onDelete: Cascade)
- @@index([chatId, createdAt])
- @@map("ai_chat_messages")
- }
- model StatusReport {
- id String @id @default(cuid())
- title String?
- message String? @db.Text
- videoUrl String?
- videoFileName String?
- publicToken String @unique
- status String @default("draft") // draft | published | sent | viewed
- sentVia String? // email, sms, telegram (comma-separated)
- sentAt DateTime?
- viewedAt DateTime?
- customerFeedback String? @db.Text
- feedbackAt DateTime?
- expiresAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- serviceRecordId String
- serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- technicianId String?
- technician Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
- @@index([serviceRecordId])
- @@index([organizationId])
- @@index([publicToken])
- @@index([expiresAt])
- @@map("status_reports")
- }
- model ReportSchedule {
- id String @id @default(cuid())
- name String @default("Scheduled Report")
- frequency String // "daily" | "weekly" | "monthly" etc.
- dateRange String @default("last30d") // "last1d" | "last7d" | "last30d" | "ytd" | "allTime" etc.
- sections String // JSON array of section keys
- recipients String // JSON array of user IDs
- nextRunDate DateTime
- endDate DateTime?
- isActive Boolean @default(true)
- lastRunAt DateTime?
- runCount Int @default(0)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- createdById String
- @@index([organizationId])
- @@index([nextRunDate, isActive])
- @@map("report_schedules")
- }
- /// A message the workshop lines up to go out on a given day and time, through
- /// whichever channel suits the customer. One-off by default; a frequency other
- /// than "once" reschedules itself after each send, the way ReportSchedule does.
- model ScheduledMessage {
- id String @id @default(cuid())
- channel String // "email" | "sms" | "telegram" | "in_app"
- subject String? // email only
- body String
- /// Where it goes when no customer is attached, or when the workshop typed an
- /// address by hand. Falls back to the customer's email/phone/chat id.
- recipient String?
- status String @default("scheduled") // scheduled | sent | failed | cancelled
- sendAt DateTime
- frequency String @default("once") // once | daily | weekly | biweekly | monthly | yearly
- endDate DateTime?
- lastRunAt DateTime?
- sentAt DateTime?
- runCount Int @default(0)
- errorMessage String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- customerId String?
- customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
- vehicleId String?
- vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
- createdById String
- @@index([organizationId, sendAt])
- @@index([status, sendAt])
- @@map("scheduled_messages")
- }
- model Webhook {
- id String @id @default(cuid())
- name String
- url String
- secret String
- events String // JSON array of event names; "*" matches all
- isActive Boolean @default(true)
- description String?
- lastTriggeredAt DateTime?
- lastSuccessAt DateTime?
- lastFailureAt DateTime?
- failureCount Int @default(0)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- createdById String
- deliveries WebhookDelivery[]
- @@index([organizationId])
- @@index([organizationId, isActive])
- @@map("webhooks")
- }
- model WebhookDelivery {
- id String @id @default(cuid())
- event String
- payload String // JSON-stringified event payload as sent
- status String @default("pending") // pending | success | failed | retrying
- statusCode Int?
- responseBody String?
- errorMessage String?
- attempt Int @default(0)
- maxAttempts Int @default(5)
- nextRetryAt DateTime?
- deliveredAt DateTime?
- durationMs Int?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- webhookId String
- webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
- @@index([webhookId, createdAt(sort: Desc)])
- @@index([status, nextRetryAt])
- @@map("webhook_deliveries")
- }
- // ---------------------------------------------------------------------------
- // Tire hotel
- //
- // Seasonal tire storage. Opt-in per organization via the `tireHotel.enabled`
- // app setting — every model here stays empty and every route stays hidden
- // until a workshop switches it on.
- //
- // Capacity is counted in individual tires rather than sets, because a shelf
- // that holds eight 15" tires does not hold eight 21" tires, and customers
- // store anything from a single spare to a five-wheel set. Counting the real
- // unit is the only thing that stays true in every market.
- // ---------------------------------------------------------------------------
- /// A physical building or room holding tire storage. Most workshops have one;
- /// multi-site operators have several.
- model TireWarehouse {
- id String @id @default(cuid())
- name String
- address String?
- notes String?
- /// Preselected when checking tires in. Exactly one per organization.
- isDefault Boolean @default(false)
- isArchived Boolean @default(false)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- locations TireLocation[]
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- @@index([organizationId])
- @@map("tire_warehouses")
- }
- /// One addressable storage slot: a shelf, a rack level, a pallet, a floor
- /// square. The workshop names its own geography, so `zone`/`rack`/`shelf` are
- /// free text rather than a fixed hierarchy — "Room B / Rack 4 / Shelf 2" and
- /// "Container 1 / -- / Pallet 7" both fit.
- model TireLocation {
- id String @id @default(cuid())
- /// Short human code used on labels and when directing a technician,
- /// e.g. "B-04-2". Unique within a warehouse.
- code String
- zone String?
- rack String?
- shelf String?
- position String?
- /// How many individual tires fit here. The shelf overview divides stored
- /// tires by this to show how full the location is.
- capacity Int @default(8)
- notes String?
- isArchived Boolean @default(false)
- warehouseId String
- warehouse TireWarehouse @relation(fields: [warehouseId], references: [id], onDelete: Cascade)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- tireSets TireSet[]
- movesFrom TireMovement[] @relation("TireMovementFrom")
- movesTo TireMovement[] @relation("TireMovementTo")
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- @@unique([warehouseId, code])
- @@index([organizationId])
- @@index([warehouseId, isArchived])
- @@map("tire_locations")
- }
- /// A group of tires stored together for one vehicle. Usually four, but the
- /// count is explicit so a pair or a five-wheel set works the same way.
- model TireSet {
- id String @id @default(cuid())
- /// Sequential per organization, shown to staff and printed on labels.
- reference String?
- /// summer | winter | all_season | other — see TIRE_SEASONS in
- /// src/features/tire-hotel/Lib/tireConstants.ts
- season String @default("summer")
- /// Studded winter tires are legal in some markets and banned in others, so
- /// this is a flag on the season rather than a season of its own.
- studded Boolean @default(false)
- brand String?
- model String?
- /// Free text so every regional notation fits: "225/45R17", "P225/45R17 94V",
- /// "31x10.5R15".
- size String?
- /// DOT / TIN week-year code used to judge tire age.
- dotCode String?
- loadSpeedIndex String?
- /// Mounted on rims, which changes both handling and what a shelf holds.
- withRims Boolean @default(false)
- rimType String?
- /// Tire pressure monitoring sensors present — they need care on refit.
- hasTpms Boolean @default(false)
- /// Individual tires in this set. Drives every capacity calculation.
- quantity Int @default(4)
- /// stored | released | disposed — see TIRE_SET_STATUSES
- status String @default("stored")
- notes String?
- /// Where it physically sits. Null once released to the customer.
- locationId String?
- location TireLocation? @relation(fields: [locationId], references: [id], onDelete: SetNull)
- vehicleId String?
- vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
- customerId String?
- customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- checkedInAt DateTime?
- checkedOutAt DateTime?
- measurements TireMeasurement[]
- movements TireMovement[]
- treatments TireTreatment[]
- attachments TireSetAttachment[]
- quotes Quote[]
- serviceRecords ServiceRecord[]
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- @@unique([organizationId, reference])
- @@index([organizationId, status])
- @@index([locationId])
- @@index([vehicleId])
- @@index([customerId])
- @@map("tire_sets")
- }
- /// Condition of one tire at one moment. Kept as history rather than current
- /// state so wear between seasons is visible, and so a damage dispute has a
- /// dated record behind it.
- /// A photo or document held against a stored set: a picture of a kerbed rim,
- /// a damage report, the customer's written agreement to store them.
- ///
- /// Deliberately the same shape as ServiceAttachment, because that is where
- /// these end up when the set is billed. Copying a row between two models with
- /// the same fields cannot lose anything in translation, and the invoice PDF
- /// already knows how to render one.
- model TireSetAttachment {
- id String @id @default(cuid())
- fileName String
- fileUrl String
- fileType String
- fileSize Int
- /// What the technician wants said about it, e.g. "kerbing on the near side".
- /// Travels onto the invoice as the caption under the image.
- description String?
- /// Whether it should reach the customer's invoice when this set is billed.
- /// On by default: a photo taken at check-in is usually taken to be shown.
- includeInInvoice Boolean @default(true)
- sortOrder Int @default(0)
- tireSetId String
- tireSet TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- uploadedById String?
- uploadedBy User? @relation(fields: [uploadedById], references: [id], onDelete: SetNull)
- createdAt DateTime @default(now())
- @@index([tireSetId])
- @@index([organizationId])
- @@map("tire_set_attachments")
- }
- model TireMeasurement {
- id String @id @default(cuid())
- /// front_left | front_right | rear_left | rear_right | spare | unspecified
- position String @default("unspecified")
- /// Always stored in millimetres. The UI converts to 32nds of an inch for
- /// workshops on imperial units, so one number means the same thing in
- /// every market.
- treadDepthMm Float?
- /// Always stored in bar. Converted to psi/kPa for display.
- pressureBar Float?
- /// good | fair | replace — see TIRE_CONDITIONS
- condition String @default("good")
- damage String?
- notes String?
- tireSetId String
- tireSet TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
- /// Groups the four readings taken during one check-in or inspection so the
- /// history reads as events rather than loose numbers.
- movementId String?
- movement TireMovement? @relation(fields: [movementId], references: [id], onDelete: SetNull)
- measuredById String?
- measuredBy User? @relation(fields: [measuredById], references: [id], onDelete: SetNull)
- images StoredImage[] @relation("TireMeasurementImages")
- measuredAt DateTime @default(now())
- @@index([tireSetId, measuredAt(sort: Desc)])
- @@index([movementId])
- @@map("tire_measurements")
- }
- /// Every physical handling of a set: arrival, departure, a move between
- /// shelves, disposal. This is the storage audit trail — "where has this been
- /// and who touched it" has to outlive the set's current row.
- model TireMovement {
- id String @id @default(cuid())
- /// check_in | check_out | relocate | dispose — see TIRE_MOVEMENT_TYPES
- type String
- /// Denormalised location codes so history still reads correctly after a
- /// shelf is renamed or deleted.
- fromCode String?
- toCode String?
- fromLocationId String?
- fromLocation TireLocation? @relation("TireMovementFrom", fields: [fromLocationId], references: [id], onDelete: SetNull)
- toLocationId String?
- toLocation TireLocation? @relation("TireMovementTo", fields: [toLocationId], references: [id], onDelete: SetNull)
- note String?
- tireSetId String
- tireSet TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- performedById String?
- performedBy User? @relation(fields: [performedById], references: [id], onDelete: SetNull)
- measurements TireMeasurement[]
- createdAt DateTime @default(now())
- @@index([tireSetId, createdAt(sort: Desc)])
- @@index([organizationId, createdAt(sort: Desc)])
- @@map("tire_movements")
- }
- /// Work a set needs before it goes back on the car, or before it goes on the
- /// shelf: washing, balancing, a TPMS service.
- ///
- /// A task with a state rather than a flag on the set, because the tire
- /// department's real question is "what is left to do on this one", and a
- /// boolean cannot answer that once someone has started. Recording who
- /// finished it also settles the "was this washed?" conversation at pickup.
- model TireTreatment {
- id String @id @default(cuid())
- /// wash_tires | wash_rims | balance | tpms_service | new_valves | repair
- /// — see TREATMENT_TYPES
- type String
- /// pending | done | skipped — see TREATMENT_STATUSES
- status String @default("pending")
- notes String?
- tireSetId String
- tireSet TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
- completedAt DateTime?
- completedById String?
- completedBy User? @relation(fields: [completedById], references: [id], onDelete: SetNull)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- /// One row per kind of work per set: asking for a wash twice is the same
- /// request, not two.
- @@unique([tireSetId, type])
- @@index([organizationId, status])
- @@map("tire_treatments")
- }
|