schema.prisma 73 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251
  1. generator client {
  2. provider = "prisma-client"
  3. output = "../src/generated/prisma"
  4. }
  5. datasource db {
  6. provider = "postgresql"
  7. }
  8. model User {
  9. id String @id @default(cuid())
  10. name String
  11. email String @unique
  12. emailVerified Boolean @default(false)
  13. /// The mobile a technician signs in with, and the number a workshop holds
  14. /// for whoever this is.
  15. ///
  16. /// Deliberately not unique. When a desk creates an account for a mechanic
  17. /// standing at the counter, a second workshop may create another for the
  18. /// same human, and neither should be able to block the other. Every lookup
  19. /// reaches it through a technician row and so is already scoped to one
  20. /// workshop; the number alone is never the question being asked.
  21. phone String?
  22. image String?
  23. isSuperAdmin Boolean @default(false)
  24. createdAt DateTime @default(now())
  25. updatedAt DateTime @updatedAt
  26. twoFactorEnabled Boolean @default(false)
  27. termsAcceptedAt DateTime?
  28. lastLogin DateTime?
  29. lastSeen DateTime?
  30. /// App version this user last saw/dismissed the update banner for.
  31. /// Null = never seeded (seeded silently on first load, no banner).
  32. lastSeenVersion String?
  33. /// Per-user dashboard grid layout (card positions/sizes/hidden set).
  34. /// Null = default layout. Shape validated in dashboardLayoutActions.
  35. dashboardLayout Json?
  36. sessions Session[]
  37. accounts Account[]
  38. pushDevices PushDevice[]
  39. appSetupCodes TechnicianSetupCode[]
  40. vehicles Vehicle[]
  41. customers Customer[]
  42. settings AppSetting[]
  43. inventoryParts InventoryPart[]
  44. quotes Quote[]
  45. customFieldDefinitions CustomFieldDefinition[]
  46. twoFactor TwoFactor?
  47. passkeys Passkey[]
  48. organizationMembers OrganizationMember[]
  49. dashboardWidgets DashboardWidget[]
  50. auditLogs AuditLog[]
  51. technicians Technician[]
  52. stockMovements StockMovement[]
  53. tireWarehouses TireWarehouse[]
  54. tireSets TireSet[]
  55. tireSetAttachments TireSetAttachment[]
  56. tireMeasurements TireMeasurement[]
  57. tireMovements TireMovement[]
  58. tireTreatments TireTreatment[]
  59. @@map("users")
  60. }
  61. model AppSetting {
  62. id String @id @default(cuid())
  63. key String
  64. value String
  65. userId String
  66. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  67. organizationId String?
  68. organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  69. @@unique([organizationId, key])
  70. @@index([organizationId])
  71. @@map("app_settings")
  72. }
  73. model Session {
  74. id String @id @default(cuid())
  75. expiresAt DateTime
  76. token String @unique
  77. createdAt DateTime @default(now())
  78. updatedAt DateTime @updatedAt
  79. ipAddress String?
  80. userAgent String?
  81. userId String
  82. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  83. @@index([userId])
  84. @@map("sessions")
  85. }
  86. model Account {
  87. id String @id @default(cuid())
  88. accountId String
  89. providerId String
  90. userId String
  91. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  92. accessToken String?
  93. refreshToken String?
  94. idToken String?
  95. accessTokenExpiresAt DateTime?
  96. refreshTokenExpiresAt DateTime?
  97. scope String?
  98. password String?
  99. createdAt DateTime @default(now())
  100. updatedAt DateTime @updatedAt
  101. @@index([userId])
  102. @@map("accounts")
  103. }
  104. model Verification {
  105. id String @id @default(cuid())
  106. identifier String @unique
  107. value String
  108. expiresAt DateTime
  109. createdAt DateTime? @default(now())
  110. updatedAt DateTime? @updatedAt
  111. @@map("verifications")
  112. }
  113. model TwoFactor {
  114. id String @id @default(cuid())
  115. secret String
  116. backupCodes String
  117. userId String @unique
  118. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  119. @@map("two_factor")
  120. }
  121. model Passkey {
  122. id String @id @default(cuid())
  123. name String?
  124. publicKey String
  125. userId String
  126. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  127. credentialID String @unique
  128. counter Int
  129. deviceType String
  130. backedUp Boolean
  131. transports String?
  132. aaguid String?
  133. createdAt DateTime @default(now())
  134. @@index([userId])
  135. @@map("passkeys")
  136. }
  137. model Vehicle {
  138. id String @id @default(cuid())
  139. make String
  140. model String
  141. year Int
  142. vin String?
  143. licensePlate String?
  144. color String?
  145. mileage Int @default(0)
  146. fuelType String? @default("gasoline")
  147. transmission String? @default("automatic")
  148. engineSize String?
  149. engineCode String?
  150. purchaseDate DateTime?
  151. purchasePrice Float?
  152. imageUrl String?
  153. isArchived Boolean @default(false)
  154. archiveReason String?
  155. maintenanceDismissed Boolean @default(false)
  156. maintenanceDismissedAt DateTime?
  157. aiMessages AiGeneratedMessage[]
  158. createdAt DateTime @default(now())
  159. updatedAt DateTime @updatedAt
  160. userId String
  161. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  162. organizationId String?
  163. organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  164. customerId String?
  165. customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
  166. serviceRecords ServiceRecord[]
  167. notes Note[]
  168. fuelLogs FuelLog[]
  169. reminders Reminder[]
  170. quotes Quote[]
  171. recurringInvoices RecurringInvoice[]
  172. inspections Inspection[]
  173. serviceRequests ServiceRequest[]
  174. findings VehicleFinding[]
  175. scheduledMessages ScheduledMessage[]
  176. tireSets TireSet[]
  177. @@index([organizationId])
  178. @@map("vehicles")
  179. }
  180. model ServiceRecord {
  181. id String @id @default(cuid())
  182. title String
  183. description String?
  184. type String @default("maintenance")
  185. status String @default("completed")
  186. cost Float @default(0)
  187. mileage Int?
  188. serviceDate DateTime @default(now())
  189. startDateTime DateTime?
  190. endDateTime DateTime?
  191. shopName String?
  192. techName String?
  193. parts String?
  194. laborHours Float?
  195. diagnosticNotes String?
  196. invoiceNotes String?
  197. subtotal Float @default(0)
  198. taxRate Float @default(0)
  199. taxAmount Float @default(0)
  200. taxInclusive Boolean @default(false)
  201. totalAmount Float @default(0)
  202. invoiceNumber String?
  203. invoiceDate DateTime?
  204. invoiceDueDate DateTime?
  205. discountType String?
  206. discountValue Float @default(0)
  207. discountAmount Float @default(0)
  208. manuallyPaid Boolean @default(false)
  209. // Warranty tracking
  210. warrantyMonths Int?
  211. warrantyMileage Int?
  212. warrantyExpiresAt DateTime?
  213. warrantyNotes String?
  214. publicToken String? @unique
  215. sharedAt DateTime?
  216. viewCount Int @default(0)
  217. lastViewedAt DateTime?
  218. createdAt DateTime @default(now())
  219. updatedAt DateTime @updatedAt
  220. /// The stored set this job is about, when it came out of the tire hotel.
  221. /// Without it a tire-change work order says nothing about which tires, and
  222. /// the technician has to go and ask.
  223. tireSetId String?
  224. tireSet TireSet? @relation(fields: [tireSetId], references: [id], onDelete: SetNull)
  225. /// Null for parts-only / counter sales; such records use customerId
  226. /// (or neither, for anonymous walk-in sales)
  227. vehicleId String?
  228. vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
  229. /// Direct customer for records without a vehicle. Vehicle-linked records
  230. /// leave this null and resolve the customer through the vehicle, so
  231. /// reassigning a vehicle keeps invoices following the vehicle's customer.
  232. customerId String?
  233. customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
  234. /// Set when the job came straight from an inspection, so the inspection can
  235. /// show what was raised from it. SetNull: deleting the inspection must not
  236. /// take the job — and its invoice — with it.
  237. inspectionId String?
  238. inspection Inspection? @relation(fields: [inspectionId], references: [id], onDelete: SetNull)
  239. /// Denormalized from the vehicle historically; now the canonical scope so
  240. /// records exist without a vehicle. Nullable to mirror Vehicle.organizationId.
  241. organizationId String?
  242. organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  243. technicianId String?
  244. technician Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
  245. /// Where in the shop the job is booked in: a lift, a ramp, an alignment
  246. /// stand. Independent of the technician, because a shop plans its bays and
  247. /// its people separately and the work board can lane by either.
  248. workBayId String?
  249. workBay WorkBay? @relation(fields: [workBayId], references: [id], onDelete: SetNull)
  250. sortOrder Int @default(0)
  251. partItems ServicePart[]
  252. laborItems ServiceLabor[]
  253. attachments ServiceAttachment[]
  254. payments Payment[]
  255. statusReports StatusReport[]
  256. stockMovements StockMovement[]
  257. timeEntries TimeEntry[]
  258. concerns ServiceConcern[]
  259. discoveredFindings VehicleFinding[] @relation("FindingDiscoveredIn")
  260. resolvedFindings VehicleFinding[] @relation("FindingResolvedIn")
  261. @@index([vehicleId])
  262. @@index([technicianId])
  263. @@index([workBayId])
  264. @@index([organizationId])
  265. @@index([customerId])
  266. @@index([inspectionId])
  267. @@map("service_records")
  268. }
  269. model Note {
  270. id String @id @default(cuid())
  271. title String
  272. content String
  273. isPinned Boolean @default(false)
  274. createdAt DateTime @default(now())
  275. updatedAt DateTime @updatedAt
  276. vehicleId String
  277. vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
  278. @@index([vehicleId])
  279. @@map("notes")
  280. }
  281. model FuelLog {
  282. id String @id @default(cuid())
  283. date DateTime @default(now())
  284. mileage Int
  285. gallons Float
  286. pricePerGallon Float
  287. totalCost Float
  288. isFillUp Boolean @default(true)
  289. station String?
  290. notes String?
  291. createdAt DateTime @default(now())
  292. updatedAt DateTime @updatedAt
  293. vehicleId String
  294. vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
  295. @@index([vehicleId])
  296. @@map("fuel_logs")
  297. }
  298. model Reminder {
  299. id String @id @default(cuid())
  300. title String
  301. description String?
  302. dueDate DateTime?
  303. dueMileage Int?
  304. isCompleted Boolean @default(false)
  305. /// Per-reminder notification channels, chosen when creating the reminder
  306. notifyInApp Boolean @default(true)
  307. notifyEmail Boolean @default(false)
  308. /// Set by the due-reminder cron once notified; cleared when dueDate changes
  309. notifiedAt DateTime?
  310. createdAt DateTime @default(now())
  311. updatedAt DateTime @updatedAt
  312. /// A reminder relates to a vehicle, a customer, or just the workshop
  313. /// (both null). organizationId is the canonical scope for all three.
  314. vehicleId String?
  315. vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
  316. customerId String?
  317. customer Customer? @relation(fields: [customerId], references: [id], onDelete: Cascade)
  318. organizationId String?
  319. organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  320. @@index([vehicleId])
  321. @@index([customerId])
  322. @@index([organizationId])
  323. @@map("reminders")
  324. }
  325. /// One thing the customer asked about, in their words.
  326. ///
  327. /// A job is a list of these rather than a single note, because that is how the
  328. /// work actually divides: "it pulls right when braking" and "the aircon smells"
  329. /// are two conversations, two diagnoses and two lines on the bill. Storing them
  330. /// as one blob means the shop can answer one and quietly drop the other, which
  331. /// is the single most common reason a car comes back.
  332. ///
  333. /// This is the first of the trade's three Cs. The cause is a VehicleFinding
  334. /// pointing back here, carrying its own notes and photographs; the correction
  335. /// is the labour and parts on the job. Together they let the invoice explain
  336. /// itself: you said this, we found that, we did this about it.
  337. model ServiceConcern {
  338. id String @id @default(cuid())
  339. /// The customer's own words, kept as they said them.
  340. description String
  341. /// The order the customer raised them in, which is the order they care about.
  342. sortOrder Int @default(0)
  343. createdAt DateTime @default(now())
  344. updatedAt DateTime @updatedAt
  345. serviceRecordId String
  346. serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
  347. /// What the shop found when it went looking. Empty means nobody has answered
  348. /// this one yet, which is worth being able to ask about before closing a job.
  349. findings VehicleFinding[]
  350. @@index([serviceRecordId])
  351. @@map("service_concerns")
  352. }
  353. model VehicleFinding {
  354. id String @id @default(cuid())
  355. description String
  356. severity String @default("needs_work")
  357. status String @default("open")
  358. notes String?
  359. imageUrls String[] @default([])
  360. createdAt DateTime @default(now())
  361. updatedAt DateTime @updatedAt
  362. vehicleId String
  363. vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
  364. serviceRecordId String?
  365. serviceRecord ServiceRecord? @relation("FindingDiscoveredIn", fields: [serviceRecordId], references: [id], onDelete: SetNull)
  366. resolvedServiceRecordId String?
  367. resolvedServiceRecord ServiceRecord? @relation("FindingResolvedIn", fields: [resolvedServiceRecordId], references: [id], onDelete: SetNull)
  368. /// The customer concern this finding answers, if it answers one.
  369. ///
  370. /// Nullable because plenty of findings are things nobody asked about: the
  371. /// perished bush somebody noticed while the car was on the lift. Those are
  372. /// worth just as much, they just are not part of the story the customer
  373. /// started. SetNull rather than Cascade because a finding is a fact about
  374. /// the vehicle and outlives the concern that prompted the look.
  375. concernId String?
  376. concern ServiceConcern? @relation(fields: [concernId], references: [id], onDelete: SetNull)
  377. @@index([vehicleId])
  378. @@index([serviceRecordId])
  379. @@index([concernId])
  380. @@map("vehicle_findings")
  381. }
  382. model Customer {
  383. id String @id @default(cuid())
  384. /// Human-facing customer number: auto-assigned sequentially on create,
  385. /// editable afterwards. Unique per organization (nulls allowed for
  386. /// legacy customers).
  387. customerNumber String?
  388. name String
  389. email String?
  390. phone String?
  391. address String?
  392. company String?
  393. taxId String?
  394. taxExempt Boolean @default(false)
  395. notes String?
  396. telegramChatId String?
  397. createdAt DateTime @default(now())
  398. updatedAt DateTime @updatedAt
  399. userId String
  400. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  401. organizationId String?
  402. organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  403. vehicles Vehicle[]
  404. quotes Quote[]
  405. smsMessages SmsMessage[]
  406. telegramMessages TelegramMessage[]
  407. whatsappMessages WhatsappMessage[]
  408. customerSessions CustomerSession[]
  409. scheduledMessages ScheduledMessage[]
  410. serviceRequests ServiceRequest[]
  411. serviceRecords ServiceRecord[]
  412. reminders Reminder[]
  413. tireSets TireSet[]
  414. @@unique([organizationId, customerNumber])
  415. @@index([organizationId])
  416. @@map("customers")
  417. }
  418. model Payment {
  419. id String @id @default(cuid())
  420. amount Float
  421. date DateTime @default(now())
  422. method String @default("other")
  423. note String?
  424. provider String?
  425. externalId String?
  426. createdAt DateTime @default(now())
  427. updatedAt DateTime @updatedAt
  428. serviceRecordId String
  429. serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
  430. @@index([serviceRecordId])
  431. @@map("payments")
  432. }
  433. model ServiceAttachment {
  434. id String @id @default(cuid())
  435. fileName String
  436. fileUrl String
  437. fileType String
  438. fileSize Int
  439. category String @default("diagnostic")
  440. description String?
  441. includeInInvoice Boolean @default(true)
  442. createdAt DateTime @default(now())
  443. serviceRecordId String
  444. serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
  445. @@index([serviceRecordId])
  446. @@map("service_attachments")
  447. }
  448. model ServicePart {
  449. id String @id @default(cuid())
  450. partNumber String?
  451. name String
  452. quantity Float @default(1)
  453. /// Unit of measure snapshotted from InventoryPart at pick time; null for
  454. /// free-text lines and legacy rows.
  455. unit String?
  456. unitPrice Float @default(0)
  457. total Float @default(0)
  458. unitCost Float @default(0)
  459. markupPercent Float @default(0)
  460. inventoryPartId String?
  461. serviceRecordId String
  462. serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
  463. @@index([serviceRecordId])
  464. @@map("service_parts")
  465. }
  466. model ServiceLabor {
  467. id String @id @default(cuid())
  468. description String
  469. hours Float @default(0)
  470. rate Float @default(0)
  471. total Float @default(0)
  472. pricingType String @default("hourly")
  473. serviceRecordId String
  474. serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
  475. @@index([serviceRecordId])
  476. @@map("service_labor")
  477. }
  478. model LaborPreset {
  479. id String @id @default(cuid())
  480. name String
  481. description String?
  482. isArchived Boolean @default(false)
  483. createdAt DateTime @default(now())
  484. updatedAt DateTime @updatedAt
  485. userId String
  486. organizationId String
  487. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  488. items LaborPresetItem[]
  489. parts LaborPresetPart[]
  490. @@index([organizationId])
  491. @@map("labor_presets")
  492. }
  493. model LaborPresetItem {
  494. id String @id @default(cuid())
  495. description String
  496. hours Float @default(0)
  497. rate Float @default(0)
  498. pricingType String @default("hourly")
  499. sortOrder Int @default(0)
  500. presetId String
  501. preset LaborPreset @relation(fields: [presetId], references: [id], onDelete: Cascade)
  502. @@index([presetId])
  503. @@map("labor_preset_items")
  504. }
  505. model LaborPresetPart {
  506. id String @id @default(cuid())
  507. name String
  508. partNumber String?
  509. quantity Float @default(1)
  510. /// Unit of measure snapshotted from InventoryPart at pick time.
  511. unit String?
  512. unitPrice Float @default(0)
  513. inventoryPartId String?
  514. sortOrder Int @default(0)
  515. presetId String
  516. preset LaborPreset @relation(fields: [presetId], references: [id], onDelete: Cascade)
  517. @@index([presetId])
  518. @@map("labor_preset_parts")
  519. }
  520. model InventoryPart {
  521. id String @id @default(cuid())
  522. partNumber String?
  523. barcode String?
  524. name String
  525. description String?
  526. category String?
  527. quantity Float @default(0)
  528. minQuantity Float @default(0)
  529. /// Unit of measure for `quantity` (e.g. "pcs", "l", "kg"). Free text,
  530. /// snapshotted onto part lines at pick time so invoices keep the unit the
  531. /// part had when it was billed.
  532. unit String?
  533. unitCost Float @default(0)
  534. sellPrice Float @default(0)
  535. supplier String?
  536. supplierPhone String?
  537. supplierEmail String?
  538. supplierUrl String?
  539. imageUrl String?
  540. location String?
  541. isArchived Boolean @default(false)
  542. /// Set when a low-stock alert has been sent for the current dip below
  543. /// `minQuantity`, and cleared once stock climbs back above it. This
  544. /// hysteresis is what stops a part that sits at zero from re-alerting on
  545. /// every check — it alerts once per descent, not once per run.
  546. lowStockAlertedAt DateTime?
  547. createdAt DateTime @default(now())
  548. updatedAt DateTime @updatedAt
  549. userId String
  550. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  551. organizationId String?
  552. organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  553. gallery StoredImage[] @relation("InventoryPartImages")
  554. movements StockMovement[]
  555. @@unique([organizationId, barcode])
  556. @@index([organizationId])
  557. @@map("inventory_parts")
  558. }
  559. /// Append-only audit trail of every change to InventoryPart.quantity.
  560. ///
  561. /// Written inside the same transaction as the stock mutation itself, so the
  562. /// ledger can never drift from the balance it describes. `quantityAfter` is
  563. /// captured from the atomic `UPDATE ... RETURNING`, giving an exact running
  564. /// balance even under concurrent writes.
  565. model StockMovement {
  566. id String @id @default(cuid())
  567. /// Signed change in stock units (fractional allowed, e.g. 0.5 l).
  568. /// Negative = consumed, positive = restocked.
  569. delta Float
  570. /// Part balance immediately after this movement was applied.
  571. quantityAfter Float
  572. /// Why the stock moved — see StockMovementReason in src/features/inventory/Lib/stockMovement.ts
  573. reason String
  574. note String?
  575. inventoryPartId String
  576. inventoryPart InventoryPart @relation(fields: [inventoryPartId], references: [id], onDelete: Cascade)
  577. /// The job the part was used on. Kept on SetNull so history survives
  578. /// deletion of the service record — "where was this used" outlives the job.
  579. serviceRecordId String?
  580. serviceRecord ServiceRecord? @relation(fields: [serviceRecordId], references: [id], onDelete: SetNull)
  581. /// Denormalised so history still reads correctly after a service record is
  582. /// deleted and serviceRecordId goes null.
  583. serviceRecordLabel String?
  584. userId String?
  585. user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
  586. organizationId String
  587. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  588. createdAt DateTime @default(now())
  589. @@index([inventoryPartId, createdAt(sort: Desc)])
  590. @@index([organizationId, createdAt(sort: Desc)])
  591. @@index([serviceRecordId])
  592. @@map("stock_movements")
  593. }
  594. model StoredImage {
  595. id String @id @default(cuid())
  596. url String
  597. fileName String?
  598. description String?
  599. sortOrder Int @default(0)
  600. createdAt DateTime @default(now())
  601. inventoryPartId String?
  602. inventoryPart InventoryPart? @relation("InventoryPartImages", fields: [inventoryPartId], references: [id], onDelete: Cascade)
  603. /// Condition photos taken when a tire is measured. Second parent on this
  604. /// model — an image belongs to exactly one of them.
  605. tireMeasurementId String?
  606. tireMeasurement TireMeasurement? @relation("TireMeasurementImages", fields: [tireMeasurementId], references: [id], onDelete: Cascade)
  607. @@index([inventoryPartId])
  608. @@index([tireMeasurementId])
  609. @@map("stored_images")
  610. }
  611. model Quote {
  612. id String @id @default(cuid())
  613. quoteNumber String?
  614. title String
  615. description String?
  616. status String @default("draft")
  617. validUntil DateTime?
  618. subtotal Float @default(0)
  619. taxRate Float @default(0)
  620. taxAmount Float @default(0)
  621. taxInclusive Boolean @default(false)
  622. discountType String?
  623. discountValue Float @default(0)
  624. discountAmount Float @default(0)
  625. totalAmount Float @default(0)
  626. notes String?
  627. publicToken String?
  628. sharedAt DateTime?
  629. viewCount Int @default(0)
  630. lastViewedAt DateTime?
  631. customerMessage String?
  632. convertedToId String?
  633. createdAt DateTime @default(now())
  634. updatedAt DateTime @updatedAt
  635. userId String
  636. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  637. organizationId String?
  638. organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  639. customerId String?
  640. customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
  641. /// The stored set this quote is about, when it came out of the tire hotel.
  642. /// SetNull rather than Cascade: a quote already sent is a document the
  643. /// customer holds, and deleting the set must not erase it.
  644. tireSetId String?
  645. tireSet TireSet? @relation(fields: [tireSetId], references: [id], onDelete: SetNull)
  646. vehicleId String?
  647. vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
  648. inspectionId String?
  649. inspection Inspection? @relation(fields: [inspectionId], references: [id], onDelete: SetNull)
  650. partItems QuotePart[]
  651. laborItems QuoteLabor[]
  652. attachments QuoteAttachment[]
  653. @@index([organizationId])
  654. @@map("quotes")
  655. }
  656. model QuoteAttachment {
  657. id String @id @default(cuid())
  658. fileName String
  659. fileUrl String
  660. fileType String
  661. fileSize Int
  662. category String @default("image")
  663. description String?
  664. includeInInvoice Boolean @default(true)
  665. createdAt DateTime @default(now())
  666. quoteId String
  667. quote Quote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
  668. @@index([quoteId])
  669. @@map("quote_attachments")
  670. }
  671. model QuotePart {
  672. id String @id @default(cuid())
  673. partNumber String?
  674. name String
  675. quantity Float @default(1)
  676. /// Unit of measure snapshotted from InventoryPart at pick time; null for
  677. /// free-text lines and legacy rows.
  678. unit String?
  679. /// Internal purchase cost — never shown to customers (quote PDF/share
  680. /// render unitPrice only)
  681. unitCost Float @default(0)
  682. markupPercent Float @default(0)
  683. unitPrice Float @default(0)
  684. total Float @default(0)
  685. excluded Boolean @default(false)
  686. /// Optional link to the stocked part this line was picked from. Carried into
  687. /// ServicePart on conversion so the job deducts the right inventory item.
  688. inventoryPartId String?
  689. quoteId String
  690. quote Quote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
  691. @@index([quoteId])
  692. @@map("quote_parts")
  693. }
  694. model QuoteLabor {
  695. id String @id @default(cuid())
  696. description String
  697. hours Float @default(0)
  698. rate Float @default(0)
  699. total Float @default(0)
  700. pricingType String @default("hourly")
  701. excluded Boolean @default(false)
  702. quoteId String
  703. quote Quote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
  704. @@index([quoteId])
  705. @@map("quote_labor")
  706. }
  707. model CustomFieldDefinition {
  708. id String @id @default(cuid())
  709. name String
  710. label String
  711. fieldType String @default("text")
  712. options String?
  713. required Boolean @default(false)
  714. entityType String
  715. sortOrder Int @default(0)
  716. isActive Boolean @default(true)
  717. createdAt DateTime @default(now())
  718. updatedAt DateTime @updatedAt
  719. userId String
  720. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  721. organizationId String?
  722. organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  723. values CustomFieldValue[]
  724. @@unique([organizationId, name, entityType])
  725. @@index([organizationId])
  726. @@map("custom_field_definitions")
  727. }
  728. model CustomFieldValue {
  729. id String @id @default(cuid())
  730. value String
  731. entityId String
  732. entityType String
  733. fieldId String
  734. field CustomFieldDefinition @relation(fields: [fieldId], references: [id], onDelete: Cascade)
  735. @@unique([fieldId, entityId])
  736. @@index([fieldId])
  737. @@map("custom_field_values")
  738. }
  739. /// A user-defined dashboard card: a named, filtered table over one entity.
  740. /// The config shape (entity, filters, columns, limit) is validated against
  741. /// the field registry in customCardActions — nothing here reaches SQL raw.
  742. model DashboardWidget {
  743. id String @id @default(cuid())
  744. name String
  745. config Json
  746. userId String
  747. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  748. organizationId String
  749. createdAt DateTime @default(now())
  750. updatedAt DateTime @updatedAt
  751. @@index([userId, organizationId])
  752. @@map("dashboard_widgets")
  753. }
  754. model Organization {
  755. id String @id @default(cuid())
  756. name String
  757. portalSlug String? @unique
  758. createdAt DateTime @default(now())
  759. updatedAt DateTime @updatedAt
  760. members OrganizationMember[]
  761. roles Role[]
  762. vehicles Vehicle[]
  763. serviceRecords ServiceRecord[]
  764. reminders Reminder[]
  765. customers Customer[]
  766. quotes Quote[]
  767. inventoryParts InventoryPart[]
  768. laborPresets LaborPreset[]
  769. customFieldDefinitions CustomFieldDefinition[]
  770. settings AppSetting[]
  771. subscription Subscription?
  772. invitations TeamInvitation[]
  773. inspectionTemplates InspectionTemplate[]
  774. smsMessages SmsMessage[]
  775. telegramMessages TelegramMessage[]
  776. whatsappMessages WhatsappMessage[]
  777. technicians Technician[]
  778. workBays WorkBay[]
  779. timeEntries TimeEntry[]
  780. pushDevices PushDevice[]
  781. appSetupCodes TechnicianSetupCode[]
  782. technicianLoginCodes TechnicianLoginCode[]
  783. auditLogs AuditLog[]
  784. statusReports StatusReport[]
  785. reportSchedules ReportSchedule[]
  786. scheduledMessages ScheduledMessage[]
  787. webhooks Webhook[]
  788. stockMovements StockMovement[]
  789. tireWarehouses TireWarehouse[]
  790. tireLocations TireLocation[]
  791. tireSets TireSet[]
  792. tireSetAttachments TireSetAttachment[]
  793. tireMovements TireMovement[]
  794. tireTreatments TireTreatment[]
  795. @@map("organizations")
  796. }
  797. model OrganizationMember {
  798. id String @id @default(cuid())
  799. role String @default("member")
  800. userId String
  801. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  802. organizationId String
  803. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  804. roleId String?
  805. customRole Role? @relation(fields: [roleId], references: [id], onDelete: SetNull)
  806. @@unique([userId, organizationId])
  807. @@map("organization_members")
  808. }
  809. model Role {
  810. id String @id @default(cuid())
  811. name String
  812. isAdmin Boolean @default(false)
  813. createdAt DateTime @default(now())
  814. updatedAt DateTime @updatedAt
  815. organizationId String
  816. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  817. permissions Permission[]
  818. members OrganizationMember[]
  819. teamInvitations TeamInvitation[]
  820. @@unique([organizationId, name])
  821. @@map("roles")
  822. }
  823. model Permission {
  824. id String @id @default(cuid())
  825. action String
  826. subject String
  827. roleId String
  828. role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
  829. @@unique([roleId, action, subject])
  830. @@index([roleId])
  831. @@map("permissions")
  832. }
  833. model SystemSetting {
  834. id String @id @default(cuid())
  835. key String @unique
  836. value String
  837. @@map("system_settings")
  838. }
  839. model TeamInvitation {
  840. id String @id @default(cuid())
  841. email String
  842. role String @default("member")
  843. token String @unique
  844. status String @default("pending") // pending | accepted | cancelled
  845. expiresAt DateTime
  846. createdAt DateTime @default(now())
  847. organizationId String
  848. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  849. invitedById String
  850. roleId String?
  851. customRole Role? @relation(fields: [roleId], references: [id], onDelete: SetNull)
  852. @@unique([email, organizationId])
  853. @@index([token])
  854. @@map("team_invitations")
  855. }
  856. // Stripe-ready models (implementation in follow-up)
  857. model SubscriptionPlan {
  858. id String @id @default(cuid())
  859. name String
  860. stripePriceId String? @unique
  861. price Float @default(0)
  862. interval String @default("month") // "month" | "year"
  863. maxMembers Int @default(1)
  864. features String? // JSON feature flags
  865. isActive Boolean @default(true)
  866. createdAt DateTime @default(now())
  867. updatedAt DateTime @updatedAt
  868. subscriptions Subscription[]
  869. @@map("subscription_plans")
  870. }
  871. model Subscription {
  872. id String @id @default(cuid())
  873. status String @default("active") // active | canceled | past_due | trialing
  874. stripeSubscriptionId String? @unique
  875. stripeCustomerId String?
  876. currentPeriodStart DateTime?
  877. currentPeriodEnd DateTime?
  878. cancelAtPeriodEnd Boolean @default(false)
  879. createdAt DateTime @default(now())
  880. updatedAt DateTime @updatedAt
  881. organizationId String @unique
  882. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  883. planId String
  884. plan SubscriptionPlan @relation(fields: [planId], references: [id])
  885. @@map("subscriptions")
  886. }
  887. model RecurringInvoice {
  888. id String @id @default(cuid())
  889. title String
  890. description String?
  891. frequency String // "weekly" | "biweekly" | "monthly" | "quarterly" | "yearly"
  892. nextRunDate DateTime
  893. endDate DateTime?
  894. isActive Boolean @default(true)
  895. lastRunAt DateTime?
  896. runCount Int @default(0)
  897. // Template fields (copied to ServiceRecord on generation)
  898. type String @default("maintenance")
  899. cost Float @default(0)
  900. taxRate Float @default(0)
  901. taxInclusive Boolean @default(false)
  902. invoiceNotes String?
  903. vehicleId String
  904. vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
  905. createdAt DateTime @default(now())
  906. updatedAt DateTime @updatedAt
  907. templateParts RecurringPart[]
  908. templateLabor RecurringLabor[]
  909. @@index([vehicleId])
  910. @@index([nextRunDate, isActive])
  911. @@map("recurring_invoices")
  912. }
  913. model RecurringPart {
  914. id String @id @default(cuid())
  915. name String
  916. partNumber String?
  917. quantity Float @default(1)
  918. unitPrice Float @default(0)
  919. recurringInvoiceId String
  920. recurringInvoice RecurringInvoice @relation(fields: [recurringInvoiceId], references: [id], onDelete: Cascade)
  921. @@map("recurring_parts")
  922. }
  923. model RecurringLabor {
  924. id String @id @default(cuid())
  925. description String
  926. hours Float @default(0)
  927. rate Float @default(0)
  928. pricingType String @default("hourly")
  929. recurringInvoiceId String
  930. recurringInvoice RecurringInvoice @relation(fields: [recurringInvoiceId], references: [id], onDelete: Cascade)
  931. @@map("recurring_labor")
  932. }
  933. model InspectionTemplate {
  934. id String @id @default(cuid())
  935. name String
  936. description String?
  937. isDefault Boolean @default(false)
  938. createdAt DateTime @default(now())
  939. updatedAt DateTime @updatedAt
  940. // Regulatory profile. `country` is an ISO-3166 alpha-2 code (or null for a
  941. // shop-specific checklist), `standard` names the regime the template follows
  942. // (e.g. "eu-2014-45"), and `severityScale` picks which defect vocabulary the
  943. // technician grades against — see Lib/conditions.ts.
  944. country String?
  945. standard String? @default("custom")
  946. severityScale String @default("eu")
  947. /// Provenance, for templates that came from a package rather than being
  948. /// written here. `packageId` is namespaced and stable across renames
  949. /// ("torqvoice/eu-roadworthiness"), which is what lets the library tell an
  950. /// update from a duplicate. Null on a template the workshop wrote itself.
  951. packageId String?
  952. packageVersion String?
  953. /// Where it came from: "builtin", "file" or "catalogue".
  954. packageSource String?
  955. organizationId String
  956. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  957. sections InspectionTemplateSection[]
  958. inspections Inspection[]
  959. @@index([organizationId])
  960. @@index([organizationId, packageId])
  961. @@map("inspection_templates")
  962. }
  963. model InspectionTemplateSection {
  964. id String @id @default(cuid())
  965. name String
  966. description String?
  967. // Regulation reference for the section, e.g. "1" for Braking equipment in
  968. // Directive 2014/45/EU Annex I.
  969. code String?
  970. sortOrder Int @default(0)
  971. templateId String
  972. template InspectionTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
  973. items InspectionTemplateItem[]
  974. @@index([templateId])
  975. @@map("inspection_template_sections")
  976. }
  977. model InspectionTemplateItem {
  978. id String @id @default(cuid())
  979. name String
  980. description String?
  981. // Regulation reference for the check, e.g. "1.1.13".
  982. code String?
  983. sortOrder Int @default(0)
  984. // How the technician records the result: "condition" (defect grading only),
  985. // "measurement" (numeric value in `unit`, graded against min/max),
  986. // "text" (free text) or "choice" (one of `choices`).
  987. inputType String @default("condition")
  988. unit String?
  989. minValue Float?
  990. maxValue Float?
  991. choices String[] @default([])
  992. required Boolean @default(false)
  993. photoRequired Boolean @default(false)
  994. // Defect severity applied when a measurement falls outside min/max.
  995. defaultSeverity String?
  996. // Phrases this workshop wants offered when a defect is recorded against the
  997. // check, on top of the built-in catalogue. See Lib/defectCatalogue.ts.
  998. defectSuggestions String[] @default([])
  999. sectionId String
  1000. section InspectionTemplateSection @relation(fields: [sectionId], references: [id], onDelete: Cascade)
  1001. @@index([sectionId])
  1002. @@map("inspection_template_items")
  1003. }
  1004. model Inspection {
  1005. id String @id @default(cuid())
  1006. status String @default("in_progress")
  1007. mileage Int?
  1008. notes String?
  1009. startDateTime DateTime?
  1010. endDateTime DateTime?
  1011. publicToken String? @unique
  1012. completedAt DateTime?
  1013. createdAt DateTime @default(now())
  1014. updatedAt DateTime @updatedAt
  1015. vehicleId String
  1016. vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
  1017. templateId String
  1018. template InspectionTemplate @relation(fields: [templateId], references: [id])
  1019. technicianId String?
  1020. technician Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
  1021. /// See ServiceRecord.workBayId — an inspection occupies a bay just as a
  1022. /// repair does, and the work board lanes both the same way.
  1023. workBayId String?
  1024. workBay WorkBay? @relation(fields: [workBayId], references: [id], onDelete: SetNull)
  1025. sortOrder Int @default(0)
  1026. // Roadworthiness certificate fields required by Directive 2014/45/EU Annex IV.
  1027. // `inspectorName` and `certificateNumber` are snapshots taken at completion so
  1028. // the issued certificate stays stable even if staff or settings change later.
  1029. /// Snapshot of how this inspection was graded, taken when it was created.
  1030. /// Read in preference to the template's current setting: a workshop that
  1031. /// later switches a template to the EU scale must not relabel a certificate
  1032. /// it already issued. Null on inspections that predate the snapshot; those
  1033. /// fall back to the template, which the migration pinned to its old scale.
  1034. severityScale String?
  1035. country String?
  1036. vehicleCategory String?
  1037. nextTestDue DateTime?
  1038. certificateNumber String?
  1039. inspectorName String?
  1040. testLocation String?
  1041. organizationId String
  1042. items InspectionItem[]
  1043. quoteRequests InspectionQuoteRequest[]
  1044. quotes Quote[]
  1045. serviceRecords ServiceRecord[]
  1046. @@index([vehicleId])
  1047. @@index([organizationId])
  1048. @@index([technicianId])
  1049. @@index([workBayId])
  1050. @@map("inspections")
  1051. }
  1052. model InspectionItem {
  1053. id String @id @default(cuid())
  1054. name String
  1055. section String
  1056. sortOrder Int @default(0)
  1057. condition String @default("not_inspected")
  1058. notes String?
  1059. imageUrls String[] @default([])
  1060. // Copied from the template at creation so an inspection stays readable even
  1061. // after its template is edited or deleted.
  1062. description String?
  1063. code String?
  1064. sectionCode String?
  1065. inputType String @default("condition")
  1066. unit String?
  1067. minValue Float?
  1068. maxValue Float?
  1069. choices String[] @default([])
  1070. required Boolean @default(false)
  1071. photoRequired Boolean @default(false)
  1072. defaultSeverity String?
  1073. defectSuggestions String[] @default([])
  1074. measuredValue Float?
  1075. textValue String?
  1076. inspectionId String
  1077. inspection Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
  1078. @@index([inspectionId])
  1079. @@map("inspection_items")
  1080. }
  1081. model InspectionQuoteRequest {
  1082. id String @id @default(cuid())
  1083. status String @default("pending")
  1084. message String?
  1085. selectedItemIds String[]
  1086. createdAt DateTime @default(now())
  1087. inspectionId String
  1088. inspection Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
  1089. organizationId String
  1090. @@index([organizationId])
  1091. @@index([inspectionId])
  1092. @@map("inspection_quote_requests")
  1093. }
  1094. model Notification {
  1095. id String @id @default(cuid())
  1096. type String
  1097. title String
  1098. message String
  1099. entityType String
  1100. entityId String
  1101. entityUrl String
  1102. read Boolean @default(false)
  1103. organizationId String
  1104. createdAt DateTime @default(now())
  1105. @@index([organizationId, createdAt(sort: Desc)])
  1106. @@index([organizationId, read])
  1107. @@map("notifications")
  1108. }
  1109. model SmsMessage {
  1110. id String @id @default(cuid())
  1111. direction String // "inbound" | "outbound"
  1112. fromNumber String
  1113. toNumber String
  1114. body String
  1115. status String @default("queued") // queued | sent | delivered | failed | received
  1116. providerMsgId String?
  1117. errorMessage String?
  1118. relatedEntityType String?
  1119. relatedEntityId String?
  1120. createdAt DateTime @default(now())
  1121. updatedAt DateTime @updatedAt
  1122. organizationId String
  1123. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1124. customerId String?
  1125. customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
  1126. @@index([organizationId, customerId, createdAt(sort: Desc)])
  1127. @@index([organizationId, createdAt(sort: Desc)])
  1128. @@index([toNumber, organizationId])
  1129. @@map("sms_messages")
  1130. }
  1131. model TelegramMessage {
  1132. id String @id @default(cuid())
  1133. direction String // "inbound" | "outbound"
  1134. chatId String // Telegram chat ID
  1135. body String
  1136. status String @default("queued") // queued | sent | delivered | failed | received
  1137. telegramMessageId String? // Telegram's message ID for tracking
  1138. errorMessage String?
  1139. relatedEntityType String?
  1140. relatedEntityId String?
  1141. createdAt DateTime @default(now())
  1142. updatedAt DateTime @updatedAt
  1143. organizationId String
  1144. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1145. customerId String?
  1146. customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
  1147. @@index([organizationId, customerId, createdAt(sort: Desc)])
  1148. @@index([organizationId, chatId, createdAt(sort: Desc)])
  1149. @@map("telegram_messages")
  1150. }
  1151. /// One WhatsApp message in either direction.
  1152. ///
  1153. /// `provider` records which adapter carried it, because a workshop can switch
  1154. /// provider without its history becoming unreadable, and a status callback
  1155. /// arriving later has to find the row it belongs to.
  1156. model WhatsappMessage {
  1157. id String @id @default(cuid())
  1158. direction String // "inbound" | "outbound"
  1159. provider String // "meta" | "twilio" | any adapter registered later
  1160. /// E.164 without the "whatsapp:" prefix a provider may use on the wire.
  1161. fromNumber String
  1162. toNumber String
  1163. body String?
  1164. /// Media the message carried. Inbound URLs may need the provider's
  1165. /// credentials to fetch, so they are not necessarily public.
  1166. mediaUrl String?
  1167. mediaType String? // image | document | video | audio | sticker
  1168. mediaFilename String?
  1169. /// Set when the message had to go out as an approved template, which is the
  1170. /// only thing WhatsApp allows outside the 24 hour service window.
  1171. templateName String?
  1172. status String @default("queued") // queued | sent | delivered | read | failed | received
  1173. providerMessageId String?
  1174. errorMessage String?
  1175. relatedEntityType String?
  1176. relatedEntityId String?
  1177. createdAt DateTime @default(now())
  1178. updatedAt DateTime @updatedAt
  1179. organizationId String
  1180. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1181. customerId String?
  1182. customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
  1183. @@index([organizationId, customerId, createdAt(sort: Desc)])
  1184. @@index([organizationId, createdAt(sort: Desc)])
  1185. @@index([providerMessageId])
  1186. @@map("whatsapp_messages")
  1187. }
  1188. model Technician {
  1189. id String @id @default(cuid())
  1190. name String
  1191. color String @default("#3b82f6")
  1192. isActive Boolean @default(true)
  1193. sortOrder Int @default(0)
  1194. dailyCapacity Int @default(480)
  1195. userId String?
  1196. user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
  1197. organizationId String
  1198. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1199. loginCodes TechnicianLoginCode[]
  1200. serviceRecords ServiceRecord[]
  1201. inspections Inspection[]
  1202. statusReports StatusReport[]
  1203. timeEntries TimeEntry[]
  1204. createdAt DateTime @default(now())
  1205. updatedAt DateTime @updatedAt
  1206. @@index([organizationId])
  1207. @@map("technicians")
  1208. }
  1209. /// One stretch of a technician actually working on a job, as clocked from the
  1210. /// technician app.
  1211. ///
  1212. /// Deliberately separate from `ServiceRecord.startDateTime` / `endDateTime`:
  1213. /// those are the work board's planned slot, one pair per job, owned by whoever
  1214. /// schedules the day. This is what happened, many rows per job, one per person
  1215. /// per stretch. A job worked on Monday, parked for parts and finished Thursday
  1216. /// by someone else is two entries, and neither of them is the booking.
  1217. ///
  1218. /// `endedAt` null means the clock is still running. At most one open entry per
  1219. /// technician is enforced in the API rather than the schema, because "open"
  1220. /// is not a value a partial unique index can express portably.
  1221. model TimeEntry {
  1222. id String @id @default(cuid())
  1223. startedAt DateTime @default(now())
  1224. endedAt DateTime?
  1225. /// Cached on stop so reports do not recompute across millions of rows, and
  1226. /// so a correction to the timestamps stays visible against what was billed.
  1227. durationMinutes Int?
  1228. /// What the technician typed when stopping, if anything.
  1229. note String?
  1230. /// "app" | "web" | "manual". A manually corrected entry is not the same
  1231. /// evidence as one clocked live, and Arbeitszeiterfassung cares about that.
  1232. source String @default("app")
  1233. /// Set when someone edited the times after the fact, so the audit trail can
  1234. /// show the entry is no longer purely as-clocked.
  1235. editedAt DateTime?
  1236. editedByUserId String?
  1237. technicianId String
  1238. technician Technician @relation(fields: [technicianId], references: [id], onDelete: Cascade)
  1239. serviceRecordId String
  1240. serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
  1241. organizationId String
  1242. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1243. createdAt DateTime @default(now())
  1244. updatedAt DateTime @updatedAt
  1245. @@index([technicianId, startedAt])
  1246. @@index([serviceRecordId])
  1247. @@index([organizationId, startedAt])
  1248. /// Finding the technician's open entry is the hottest query in the app: it
  1249. /// runs on every launch and every job screen.
  1250. @@index([technicianId, endedAt])
  1251. @@map("time_entries")
  1252. }
  1253. /// A phone that has asked to be told about its technician's work.
  1254. ///
  1255. /// Keyed on the Expo push token rather than on the user, because the unit that
  1256. /// receives a notification is a device: one technician may carry a personal
  1257. /// phone and a shared bench tablet, and a shared tablet may be signed into by
  1258. /// several people over a week. Both cases have to work.
  1259. ///
  1260. /// The token is a routing address, not a secret, but it is still deleted on
  1261. /// sign-out: a device that has signed out must stop receiving a workshop's job
  1262. /// details, and expiry alone would leave a window where it still did.
  1263. /// A one-time code that puts a technician's phone onto a workshop.
  1264. ///
  1265. /// Typing a URL and a password is the worst moment in the app, and it lands on
  1266. /// the person least equipped for it: a technician with dirty hands, on a
  1267. /// phone, being watched. The desk shows a QR instead and the phone reads it.
  1268. ///
  1269. /// It is a bearer credential in a picture, so it is deliberately short-lived,
  1270. /// single-use, and bound to one person in one workshop. It exchanges for a
  1271. /// normal session and is then dead; it is never itself a way to authenticate.
  1272. /// A one-time code that signs a technician back in.
  1273. ///
  1274. /// Modelled on CustomerSmsCode, which does the same job for the customer
  1275. /// portal: org-scoped, single use, short-lived. The organisation is not
  1276. /// optional and is never inferred from the phone number. A technician's phone
  1277. /// is only ever looked up inside the workshop their app already belongs to,
  1278. /// so nothing here can reach across workshops even if a person works at two.
  1279. model TechnicianLoginCode {
  1280. id String @id @default(cuid())
  1281. /// SHA-256 of the digits. The code exists in a text message and in the
  1282. /// technician's head, and nowhere that can be read back out of a database.
  1283. codeHash String
  1284. /// How it was sent, so the app can say "check your messages" or "check your
  1285. /// email" rather than guessing on the technician's behalf.
  1286. channel String
  1287. /// Wrong guesses so far. Six digits is a million, which a rate limiter alone
  1288. /// does not protect: this burns the code after a handful of attempts.
  1289. attempts Int @default(0)
  1290. expiresAt DateTime
  1291. usedAt DateTime?
  1292. technicianId String
  1293. technician Technician @relation(fields: [technicianId], references: [id], onDelete: Cascade)
  1294. organizationId String
  1295. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1296. createdAt DateTime @default(now())
  1297. @@index([organizationId, technicianId])
  1298. @@index([expiresAt])
  1299. @@map("technician_login_codes")
  1300. }
  1301. model TechnicianSetupCode {
  1302. id String @id @default(cuid())
  1303. /// SHA-256 of the code. The code itself is shown once, on the screen that
  1304. /// asked for it, and is never written down anywhere it could be read back.
  1305. codeHash String @unique
  1306. expiresAt DateTime
  1307. /// Set on redemption. Kept rather than deleted so a second scan can say the
  1308. /// code was already used, which is a different problem from a wrong code.
  1309. usedAt DateTime?
  1310. /// Who the code signs in.
  1311. userId String
  1312. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  1313. organizationId String
  1314. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1315. /// Who issued it, for the audit trail. Not a relation: the issuer may be
  1316. /// removed from the workshop later and that must not delete the record.
  1317. issuedByUserId String
  1318. createdAt DateTime @default(now())
  1319. @@index([organizationId, expiresAt])
  1320. @@index([userId])
  1321. @@map("technician_setup_codes")
  1322. }
  1323. model PushDevice {
  1324. id String @id @default(cuid())
  1325. /// Expo push token, unique across the estate. A reinstall issues a new one
  1326. /// and the old becomes invalid, which is why sends prune on rejection.
  1327. token String @unique
  1328. /// "ios" | "android". Only used to explain failures in logs.
  1329. platform String
  1330. /// Set false when Expo reports the token as dead, rather than deleting, so
  1331. /// a flapping device does not churn rows.
  1332. isActive Boolean @default(true)
  1333. /// Last time the app confirmed this token still belongs to this user, so a
  1334. /// device that stops checking in can be aged out.
  1335. lastSeenAt DateTime @default(now())
  1336. userId String
  1337. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  1338. organizationId String
  1339. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1340. createdAt DateTime @default(now())
  1341. updatedAt DateTime @updatedAt
  1342. @@index([userId, isActive])
  1343. @@index([organizationId])
  1344. @@map("push_devices")
  1345. }
  1346. /// A physical place work happens: a lift, a wash bay, an alignment rack. Shops
  1347. /// plan by bay as often as by person — the bay is the scarce resource, and a
  1348. /// technician moves between them during a day. The work board can group its
  1349. /// lanes by either, so both are first-class.
  1350. model WorkBay {
  1351. id String @id @default(cuid())
  1352. name String
  1353. color String @default("#64748b")
  1354. isActive Boolean @default(true)
  1355. /// Ordering on the board, left to right. Shops name bays by position
  1356. /// ("Bay 1", "Lift 2") and expect them in that order, not alphabetically.
  1357. sortOrder Int @default(0)
  1358. /// Bookable minutes per day, mirroring Technician.dailyCapacity so the board
  1359. /// can show utilisation for whichever lane grouping is on.
  1360. dailyCapacity Int @default(480)
  1361. organizationId String
  1362. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1363. serviceRecords ServiceRecord[]
  1364. inspections Inspection[]
  1365. createdAt DateTime @default(now())
  1366. updatedAt DateTime @updatedAt
  1367. @@index([organizationId])
  1368. @@map("work_bays")
  1369. }
  1370. model AuditLog {
  1371. id String @id @default(cuid())
  1372. timestamp DateTime @default(now())
  1373. action String
  1374. entity String?
  1375. entityId String?
  1376. message String?
  1377. metadata Json?
  1378. ip String?
  1379. userAgent String?
  1380. userId String?
  1381. user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
  1382. organizationId String?
  1383. organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
  1384. @@index([organizationId, timestamp(sort: Desc)])
  1385. @@index([userId, timestamp(sort: Desc)])
  1386. @@index([action, timestamp(sort: Desc)])
  1387. @@map("audit_logs")
  1388. }
  1389. model CustomerSession {
  1390. id String @id @default(cuid())
  1391. token String @unique
  1392. expiresAt DateTime
  1393. createdAt DateTime @default(now())
  1394. customerId String
  1395. customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
  1396. organizationId String
  1397. @@index([token])
  1398. @@index([customerId, organizationId])
  1399. @@map("customer_sessions")
  1400. }
  1401. model CustomerMagicLink {
  1402. id String @id @default(cuid())
  1403. token String @unique
  1404. email String
  1405. organizationId String
  1406. expiresAt DateTime
  1407. usedAt DateTime?
  1408. createdAt DateTime @default(now())
  1409. @@index([token])
  1410. @@map("customer_magic_links")
  1411. }
  1412. model CustomerSmsCode {
  1413. id String @id @default(cuid())
  1414. /// SHA-256 of the digits, not the digits.
  1415. ///
  1416. /// The code reaches the customer in a text message and lives in their head
  1417. /// for a minute. It has no business also sitting in a table where reading a
  1418. /// row hands over somebody's portal session.
  1419. code String
  1420. /// Wrong guesses so far. Six digits is a million, which sounds like plenty
  1421. /// and is not: limiting by address does nothing about a hundred machines
  1422. /// sharing the work. Five attempts against one code makes the space
  1423. /// irrelevant, because the code is gone long before the guessing arrives.
  1424. attempts Int @default(0)
  1425. phone String
  1426. organizationId String
  1427. expiresAt DateTime
  1428. usedAt DateTime?
  1429. createdAt DateTime @default(now())
  1430. @@index([phone, organizationId])
  1431. @@index([organizationId])
  1432. @@map("customer_sms_codes")
  1433. }
  1434. model ServiceRequest {
  1435. id String @id @default(cuid())
  1436. description String
  1437. preferredDate DateTime?
  1438. status String @default("pending")
  1439. adminNotes String?
  1440. createdAt DateTime @default(now())
  1441. updatedAt DateTime @updatedAt
  1442. customerId String
  1443. customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
  1444. vehicleId String
  1445. vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
  1446. organizationId String
  1447. @@index([organizationId, status])
  1448. @@index([customerId])
  1449. @@map("service_requests")
  1450. }
  1451. model AiGeneratedMessage {
  1452. id String @id @default(cuid())
  1453. vehicleId String
  1454. type String // "summary" | "common_issues" | etc.
  1455. content String
  1456. createdAt DateTime @default(now())
  1457. updatedAt DateTime @updatedAt
  1458. vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
  1459. @@unique([vehicleId, type])
  1460. @@map("ai_generated_messages")
  1461. }
  1462. model AiChat {
  1463. id String @id @default(cuid())
  1464. title String @default("New chat")
  1465. createdAt DateTime @default(now())
  1466. updatedAt DateTime @updatedAt
  1467. userId String
  1468. organizationId String
  1469. messages AiChatMessage[]
  1470. @@index([organizationId, userId, updatedAt(sort: Desc)])
  1471. @@map("ai_chats")
  1472. }
  1473. model AiChatMessage {
  1474. id String @id @default(cuid())
  1475. role String // "user" | "assistant"
  1476. content String
  1477. createdAt DateTime @default(now())
  1478. chatId String
  1479. chat AiChat @relation(fields: [chatId], references: [id], onDelete: Cascade)
  1480. @@index([chatId, createdAt])
  1481. @@map("ai_chat_messages")
  1482. }
  1483. model StatusReport {
  1484. id String @id @default(cuid())
  1485. title String?
  1486. message String? @db.Text
  1487. videoUrl String?
  1488. videoFileName String?
  1489. publicToken String @unique
  1490. status String @default("draft") // draft | published | sent | viewed
  1491. sentVia String? // email, sms, telegram (comma-separated)
  1492. sentAt DateTime?
  1493. viewedAt DateTime?
  1494. customerFeedback String? @db.Text
  1495. feedbackAt DateTime?
  1496. expiresAt DateTime?
  1497. createdAt DateTime @default(now())
  1498. updatedAt DateTime @updatedAt
  1499. serviceRecordId String
  1500. serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
  1501. organizationId String
  1502. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1503. technicianId String?
  1504. technician Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
  1505. @@index([serviceRecordId])
  1506. @@index([organizationId])
  1507. @@index([publicToken])
  1508. @@index([expiresAt])
  1509. @@map("status_reports")
  1510. }
  1511. model ReportSchedule {
  1512. id String @id @default(cuid())
  1513. name String @default("Scheduled Report")
  1514. frequency String // "daily" | "weekly" | "monthly" etc.
  1515. dateRange String @default("last30d") // "last1d" | "last7d" | "last30d" | "ytd" | "allTime" etc.
  1516. sections String // JSON array of section keys
  1517. recipients String // JSON array of user IDs
  1518. nextRunDate DateTime
  1519. endDate DateTime?
  1520. isActive Boolean @default(true)
  1521. lastRunAt DateTime?
  1522. runCount Int @default(0)
  1523. createdAt DateTime @default(now())
  1524. updatedAt DateTime @updatedAt
  1525. organizationId String
  1526. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1527. createdById String
  1528. @@index([organizationId])
  1529. @@index([nextRunDate, isActive])
  1530. @@map("report_schedules")
  1531. }
  1532. /// A message the workshop lines up to go out on a given day and time, through
  1533. /// whichever channel suits the customer. One-off by default; a frequency other
  1534. /// than "once" reschedules itself after each send, the way ReportSchedule does.
  1535. model ScheduledMessage {
  1536. id String @id @default(cuid())
  1537. channel String // "email" | "sms" | "telegram" | "in_app"
  1538. subject String? // email only
  1539. body String
  1540. /// Where it goes when no customer is attached, or when the workshop typed an
  1541. /// address by hand. Falls back to the customer's email/phone/chat id.
  1542. recipient String?
  1543. status String @default("scheduled") // scheduled | sent | failed | cancelled
  1544. sendAt DateTime
  1545. frequency String @default("once") // once | daily | weekly | biweekly | monthly | yearly
  1546. endDate DateTime?
  1547. lastRunAt DateTime?
  1548. sentAt DateTime?
  1549. runCount Int @default(0)
  1550. errorMessage String?
  1551. createdAt DateTime @default(now())
  1552. updatedAt DateTime @updatedAt
  1553. organizationId String
  1554. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1555. customerId String?
  1556. customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
  1557. vehicleId String?
  1558. vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
  1559. createdById String
  1560. @@index([organizationId, sendAt])
  1561. @@index([status, sendAt])
  1562. @@map("scheduled_messages")
  1563. }
  1564. model Webhook {
  1565. id String @id @default(cuid())
  1566. name String
  1567. url String
  1568. secret String
  1569. events String // JSON array of event names; "*" matches all
  1570. isActive Boolean @default(true)
  1571. description String?
  1572. lastTriggeredAt DateTime?
  1573. lastSuccessAt DateTime?
  1574. lastFailureAt DateTime?
  1575. failureCount Int @default(0)
  1576. createdAt DateTime @default(now())
  1577. updatedAt DateTime @updatedAt
  1578. organizationId String
  1579. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1580. createdById String
  1581. deliveries WebhookDelivery[]
  1582. @@index([organizationId])
  1583. @@index([organizationId, isActive])
  1584. @@map("webhooks")
  1585. }
  1586. model WebhookDelivery {
  1587. id String @id @default(cuid())
  1588. event String
  1589. payload String // JSON-stringified event payload as sent
  1590. status String @default("pending") // pending | success | failed | retrying
  1591. statusCode Int?
  1592. responseBody String?
  1593. errorMessage String?
  1594. attempt Int @default(0)
  1595. maxAttempts Int @default(5)
  1596. nextRetryAt DateTime?
  1597. deliveredAt DateTime?
  1598. durationMs Int?
  1599. createdAt DateTime @default(now())
  1600. updatedAt DateTime @updatedAt
  1601. webhookId String
  1602. webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
  1603. @@index([webhookId, createdAt(sort: Desc)])
  1604. @@index([status, nextRetryAt])
  1605. @@map("webhook_deliveries")
  1606. }
  1607. // ---------------------------------------------------------------------------
  1608. // Tire hotel
  1609. //
  1610. // Seasonal tire storage. Opt-in per organization via the `tireHotel.enabled`
  1611. // app setting — every model here stays empty and every route stays hidden
  1612. // until a workshop switches it on.
  1613. //
  1614. // Capacity is counted in individual tires rather than sets, because a shelf
  1615. // that holds eight 15" tires does not hold eight 21" tires, and customers
  1616. // store anything from a single spare to a five-wheel set. Counting the real
  1617. // unit is the only thing that stays true in every market.
  1618. // ---------------------------------------------------------------------------
  1619. /// A physical building or room holding tire storage. Most workshops have one;
  1620. /// multi-site operators have several.
  1621. model TireWarehouse {
  1622. id String @id @default(cuid())
  1623. name String
  1624. address String?
  1625. notes String?
  1626. /// Preselected when checking tires in. Exactly one per organization.
  1627. isDefault Boolean @default(false)
  1628. isArchived Boolean @default(false)
  1629. organizationId String
  1630. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1631. userId String
  1632. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  1633. locations TireLocation[]
  1634. createdAt DateTime @default(now())
  1635. updatedAt DateTime @updatedAt
  1636. @@index([organizationId])
  1637. @@map("tire_warehouses")
  1638. }
  1639. /// One addressable storage slot: a shelf, a rack level, a pallet, a floor
  1640. /// square. The workshop names its own geography, so `zone`/`rack`/`shelf` are
  1641. /// free text rather than a fixed hierarchy — "Room B / Rack 4 / Shelf 2" and
  1642. /// "Container 1 / -- / Pallet 7" both fit.
  1643. model TireLocation {
  1644. id String @id @default(cuid())
  1645. /// Short human code used on labels and when directing a technician,
  1646. /// e.g. "B-04-2". Unique within a warehouse.
  1647. code String
  1648. zone String?
  1649. rack String?
  1650. shelf String?
  1651. position String?
  1652. /// How many individual tires fit here. The shelf overview divides stored
  1653. /// tires by this to show how full the location is.
  1654. capacity Int @default(8)
  1655. notes String?
  1656. isArchived Boolean @default(false)
  1657. warehouseId String
  1658. warehouse TireWarehouse @relation(fields: [warehouseId], references: [id], onDelete: Cascade)
  1659. organizationId String
  1660. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1661. tireSets TireSet[]
  1662. movesFrom TireMovement[] @relation("TireMovementFrom")
  1663. movesTo TireMovement[] @relation("TireMovementTo")
  1664. createdAt DateTime @default(now())
  1665. updatedAt DateTime @updatedAt
  1666. @@unique([warehouseId, code])
  1667. @@index([organizationId])
  1668. @@index([warehouseId, isArchived])
  1669. @@map("tire_locations")
  1670. }
  1671. /// A group of tires stored together for one vehicle. Usually four, but the
  1672. /// count is explicit so a pair or a five-wheel set works the same way.
  1673. model TireSet {
  1674. id String @id @default(cuid())
  1675. /// Sequential per organization, shown to staff and printed on labels.
  1676. reference String?
  1677. /// summer | winter | all_season | other — see TIRE_SEASONS in
  1678. /// src/features/tire-hotel/Lib/tireConstants.ts
  1679. season String @default("summer")
  1680. /// Studded winter tires are legal in some markets and banned in others, so
  1681. /// this is a flag on the season rather than a season of its own.
  1682. studded Boolean @default(false)
  1683. brand String?
  1684. model String?
  1685. /// Free text so every regional notation fits: "225/45R17", "P225/45R17 94V",
  1686. /// "31x10.5R15".
  1687. size String?
  1688. /// DOT / TIN week-year code used to judge tire age.
  1689. dotCode String?
  1690. loadSpeedIndex String?
  1691. /// Mounted on rims, which changes both handling and what a shelf holds.
  1692. withRims Boolean @default(false)
  1693. rimType String?
  1694. /// Tire pressure monitoring sensors present — they need care on refit.
  1695. hasTpms Boolean @default(false)
  1696. /// Individual tires in this set. Drives every capacity calculation.
  1697. quantity Int @default(4)
  1698. /// stored | released | disposed — see TIRE_SET_STATUSES
  1699. status String @default("stored")
  1700. notes String?
  1701. /// Where it physically sits. Null once released to the customer.
  1702. locationId String?
  1703. location TireLocation? @relation(fields: [locationId], references: [id], onDelete: SetNull)
  1704. vehicleId String?
  1705. vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
  1706. customerId String?
  1707. customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
  1708. organizationId String
  1709. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1710. userId String
  1711. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  1712. checkedInAt DateTime?
  1713. checkedOutAt DateTime?
  1714. measurements TireMeasurement[]
  1715. movements TireMovement[]
  1716. treatments TireTreatment[]
  1717. attachments TireSetAttachment[]
  1718. quotes Quote[]
  1719. serviceRecords ServiceRecord[]
  1720. createdAt DateTime @default(now())
  1721. updatedAt DateTime @updatedAt
  1722. @@unique([organizationId, reference])
  1723. @@index([organizationId, status])
  1724. @@index([locationId])
  1725. @@index([vehicleId])
  1726. @@index([customerId])
  1727. @@map("tire_sets")
  1728. }
  1729. /// Condition of one tire at one moment. Kept as history rather than current
  1730. /// state so wear between seasons is visible, and so a damage dispute has a
  1731. /// dated record behind it.
  1732. /// A photo or document held against a stored set: a picture of a kerbed rim,
  1733. /// a damage report, the customer's written agreement to store them.
  1734. ///
  1735. /// Deliberately the same shape as ServiceAttachment, because that is where
  1736. /// these end up when the set is billed. Copying a row between two models with
  1737. /// the same fields cannot lose anything in translation, and the invoice PDF
  1738. /// already knows how to render one.
  1739. model TireSetAttachment {
  1740. id String @id @default(cuid())
  1741. fileName String
  1742. fileUrl String
  1743. fileType String
  1744. fileSize Int
  1745. /// What the technician wants said about it, e.g. "kerbing on the near side".
  1746. /// Travels onto the invoice as the caption under the image.
  1747. description String?
  1748. /// Whether it should reach the customer's invoice when this set is billed.
  1749. /// On by default: a photo taken at check-in is usually taken to be shown.
  1750. includeInInvoice Boolean @default(true)
  1751. sortOrder Int @default(0)
  1752. tireSetId String
  1753. tireSet TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
  1754. organizationId String
  1755. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1756. uploadedById String?
  1757. uploadedBy User? @relation(fields: [uploadedById], references: [id], onDelete: SetNull)
  1758. createdAt DateTime @default(now())
  1759. @@index([tireSetId])
  1760. @@index([organizationId])
  1761. @@map("tire_set_attachments")
  1762. }
  1763. model TireMeasurement {
  1764. id String @id @default(cuid())
  1765. /// front_left | front_right | rear_left | rear_right | spare | unspecified
  1766. position String @default("unspecified")
  1767. /// Always stored in millimetres. The UI converts to 32nds of an inch for
  1768. /// workshops on imperial units, so one number means the same thing in
  1769. /// every market.
  1770. treadDepthMm Float?
  1771. /// Always stored in bar. Converted to psi/kPa for display.
  1772. pressureBar Float?
  1773. /// good | fair | replace — see TIRE_CONDITIONS
  1774. condition String @default("good")
  1775. damage String?
  1776. notes String?
  1777. tireSetId String
  1778. tireSet TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
  1779. /// Groups the four readings taken during one check-in or inspection so the
  1780. /// history reads as events rather than loose numbers.
  1781. movementId String?
  1782. movement TireMovement? @relation(fields: [movementId], references: [id], onDelete: SetNull)
  1783. measuredById String?
  1784. measuredBy User? @relation(fields: [measuredById], references: [id], onDelete: SetNull)
  1785. images StoredImage[] @relation("TireMeasurementImages")
  1786. measuredAt DateTime @default(now())
  1787. @@index([tireSetId, measuredAt(sort: Desc)])
  1788. @@index([movementId])
  1789. @@map("tire_measurements")
  1790. }
  1791. /// Every physical handling of a set: arrival, departure, a move between
  1792. /// shelves, disposal. This is the storage audit trail — "where has this been
  1793. /// and who touched it" has to outlive the set's current row.
  1794. model TireMovement {
  1795. id String @id @default(cuid())
  1796. /// check_in | check_out | relocate | dispose — see TIRE_MOVEMENT_TYPES
  1797. type String
  1798. /// Denormalised location codes so history still reads correctly after a
  1799. /// shelf is renamed or deleted.
  1800. fromCode String?
  1801. toCode String?
  1802. fromLocationId String?
  1803. fromLocation TireLocation? @relation("TireMovementFrom", fields: [fromLocationId], references: [id], onDelete: SetNull)
  1804. toLocationId String?
  1805. toLocation TireLocation? @relation("TireMovementTo", fields: [toLocationId], references: [id], onDelete: SetNull)
  1806. note String?
  1807. tireSetId String
  1808. tireSet TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
  1809. organizationId String
  1810. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1811. performedById String?
  1812. performedBy User? @relation(fields: [performedById], references: [id], onDelete: SetNull)
  1813. measurements TireMeasurement[]
  1814. createdAt DateTime @default(now())
  1815. @@index([tireSetId, createdAt(sort: Desc)])
  1816. @@index([organizationId, createdAt(sort: Desc)])
  1817. @@map("tire_movements")
  1818. }
  1819. /// Work a set needs before it goes back on the car, or before it goes on the
  1820. /// shelf: washing, balancing, a TPMS service.
  1821. ///
  1822. /// A task with a state rather than a flag on the set, because the tire
  1823. /// department's real question is "what is left to do on this one", and a
  1824. /// boolean cannot answer that once someone has started. Recording who
  1825. /// finished it also settles the "was this washed?" conversation at pickup.
  1826. model TireTreatment {
  1827. id String @id @default(cuid())
  1828. /// wash_tires | wash_rims | balance | tpms_service | new_valves | repair
  1829. /// — see TREATMENT_TYPES
  1830. type String
  1831. /// pending | done | skipped — see TREATMENT_STATUSES
  1832. status String @default("pending")
  1833. notes String?
  1834. tireSetId String
  1835. tireSet TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
  1836. completedAt DateTime?
  1837. completedById String?
  1838. completedBy User? @relation(fields: [completedById], references: [id], onDelete: SetNull)
  1839. organizationId String
  1840. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  1841. createdAt DateTime @default(now())
  1842. updatedAt DateTime @updatedAt
  1843. /// One row per kind of work per set: asking for a wash twice is the same
  1844. /// request, not two.
  1845. @@unique([tireSetId, type])
  1846. @@index([organizationId, status])
  1847. @@map("tire_treatments")
  1848. }