| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251 |
- 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)
- /// The mobile a technician signs in with, and the number a workshop holds
- /// for whoever this is.
- ///
- /// Deliberately not unique. When a desk creates an account for a mechanic
- /// standing at the counter, a second workshop may create another for the
- /// same human, and neither should be able to block the other. Every lookup
- /// reaches it through a technician row and so is already scoped to one
- /// workshop; the number alone is never the question being asked.
- phone String?
- 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[]
- pushDevices PushDevice[]
- appSetupCodes TechnicianSetupCode[]
- 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)
- /// Where in the shop the job is booked in: a lift, a ramp, an alignment
- /// stand. Independent of the technician, because a shop plans its bays and
- /// its people separately and the work board can lane by either.
- workBayId String?
- workBay WorkBay? @relation(fields: [workBayId], references: [id], onDelete: SetNull)
- sortOrder Int @default(0)
- partItems ServicePart[]
- laborItems ServiceLabor[]
- attachments ServiceAttachment[]
- payments Payment[]
- statusReports StatusReport[]
- stockMovements StockMovement[]
- timeEntries TimeEntry[]
- concerns ServiceConcern[]
- discoveredFindings VehicleFinding[] @relation("FindingDiscoveredIn")
- resolvedFindings VehicleFinding[] @relation("FindingResolvedIn")
- @@index([vehicleId])
- @@index([technicianId])
- @@index([workBayId])
- @@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")
- }
- /// One thing the customer asked about, in their words.
- ///
- /// A job is a list of these rather than a single note, because that is how the
- /// work actually divides: "it pulls right when braking" and "the aircon smells"
- /// are two conversations, two diagnoses and two lines on the bill. Storing them
- /// as one blob means the shop can answer one and quietly drop the other, which
- /// is the single most common reason a car comes back.
- ///
- /// This is the first of the trade's three Cs. The cause is a VehicleFinding
- /// pointing back here, carrying its own notes and photographs; the correction
- /// is the labour and parts on the job. Together they let the invoice explain
- /// itself: you said this, we found that, we did this about it.
- model ServiceConcern {
- id String @id @default(cuid())
- /// The customer's own words, kept as they said them.
- description String
- /// The order the customer raised them in, which is the order they care about.
- sortOrder Int @default(0)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- serviceRecordId String
- serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
- /// What the shop found when it went looking. Empty means nobody has answered
- /// this one yet, which is worth being able to ask about before closing a job.
- findings VehicleFinding[]
- @@index([serviceRecordId])
- @@map("service_concerns")
- }
- 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)
- /// The customer concern this finding answers, if it answers one.
- ///
- /// Nullable because plenty of findings are things nobody asked about: the
- /// perished bush somebody noticed while the car was on the lift. Those are
- /// worth just as much, they just are not part of the story the customer
- /// started. SetNull rather than Cascade because a finding is a fact about
- /// the vehicle and outlives the concern that prompted the look.
- concernId String?
- concern ServiceConcern? @relation(fields: [concernId], references: [id], onDelete: SetNull)
- @@index([vehicleId])
- @@index([serviceRecordId])
- @@index([concernId])
- @@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[]
- whatsappMessages WhatsappMessage[]
- 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)
- /// Unit of measure snapshotted from InventoryPart at pick time; null for
- /// free-text lines and legacy rows.
- unit String?
- 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)
- /// Unit of measure snapshotted from InventoryPart at pick time.
- unit String?
- 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 Float @default(0)
- minQuantity Float @default(0)
- /// Unit of measure for `quantity` (e.g. "pcs", "l", "kg"). Free text,
- /// snapshotted onto part lines at pick time so invoices keep the unit the
- /// part had when it was billed.
- unit String?
- 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 stock units (fractional allowed, e.g. 0.5 l).
- /// Negative = consumed, positive = restocked.
- delta Float
- /// Part balance immediately after this movement was applied.
- quantityAfter Float
- /// 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)
- /// Unit of measure snapshotted from InventoryPart at pick time; null for
- /// free-text lines and legacy rows.
- unit String?
- /// 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[]
- whatsappMessages WhatsappMessage[]
- technicians Technician[]
- workBays WorkBay[]
- timeEntries TimeEntry[]
- pushDevices PushDevice[]
- appSetupCodes TechnicianSetupCode[]
- technicianLoginCodes TechnicianLoginCode[]
- 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 Float @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)
- /// See ServiceRecord.workBayId — an inspection occupies a bay just as a
- /// repair does, and the work board lanes both the same way.
- workBayId String?
- workBay WorkBay? @relation(fields: [workBayId], 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])
- @@index([workBayId])
- @@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")
- }
- /// One WhatsApp message in either direction.
- ///
- /// `provider` records which adapter carried it, because a workshop can switch
- /// provider without its history becoming unreadable, and a status callback
- /// arriving later has to find the row it belongs to.
- model WhatsappMessage {
- id String @id @default(cuid())
- direction String // "inbound" | "outbound"
- provider String // "meta" | "twilio" | any adapter registered later
- /// E.164 without the "whatsapp:" prefix a provider may use on the wire.
- fromNumber String
- toNumber String
- body String?
- /// Media the message carried. Inbound URLs may need the provider's
- /// credentials to fetch, so they are not necessarily public.
- mediaUrl String?
- mediaType String? // image | document | video | audio | sticker
- mediaFilename String?
- /// Set when the message had to go out as an approved template, which is the
- /// only thing WhatsApp allows outside the 24 hour service window.
- templateName String?
- status String @default("queued") // queued | sent | delivered | read | failed | received
- providerMessageId 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([providerMessageId])
- @@map("whatsapp_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)
- loginCodes TechnicianLoginCode[]
- serviceRecords ServiceRecord[]
- inspections Inspection[]
- statusReports StatusReport[]
- timeEntries TimeEntry[]
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- @@index([organizationId])
- @@map("technicians")
- }
- /// One stretch of a technician actually working on a job, as clocked from the
- /// technician app.
- ///
- /// Deliberately separate from `ServiceRecord.startDateTime` / `endDateTime`:
- /// those are the work board's planned slot, one pair per job, owned by whoever
- /// schedules the day. This is what happened, many rows per job, one per person
- /// per stretch. A job worked on Monday, parked for parts and finished Thursday
- /// by someone else is two entries, and neither of them is the booking.
- ///
- /// `endedAt` null means the clock is still running. At most one open entry per
- /// technician is enforced in the API rather than the schema, because "open"
- /// is not a value a partial unique index can express portably.
- model TimeEntry {
- id String @id @default(cuid())
- startedAt DateTime @default(now())
- endedAt DateTime?
- /// Cached on stop so reports do not recompute across millions of rows, and
- /// so a correction to the timestamps stays visible against what was billed.
- durationMinutes Int?
- /// What the technician typed when stopping, if anything.
- note String?
- /// "app" | "web" | "manual". A manually corrected entry is not the same
- /// evidence as one clocked live, and Arbeitszeiterfassung cares about that.
- source String @default("app")
- /// Set when someone edited the times after the fact, so the audit trail can
- /// show the entry is no longer purely as-clocked.
- editedAt DateTime?
- editedByUserId String?
- technicianId String
- technician Technician @relation(fields: [technicianId], references: [id], onDelete: Cascade)
- serviceRecordId String
- serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- @@index([technicianId, startedAt])
- @@index([serviceRecordId])
- @@index([organizationId, startedAt])
- /// Finding the technician's open entry is the hottest query in the app: it
- /// runs on every launch and every job screen.
- @@index([technicianId, endedAt])
- @@map("time_entries")
- }
- /// A phone that has asked to be told about its technician's work.
- ///
- /// Keyed on the Expo push token rather than on the user, because the unit that
- /// receives a notification is a device: one technician may carry a personal
- /// phone and a shared bench tablet, and a shared tablet may be signed into by
- /// several people over a week. Both cases have to work.
- ///
- /// The token is a routing address, not a secret, but it is still deleted on
- /// sign-out: a device that has signed out must stop receiving a workshop's job
- /// details, and expiry alone would leave a window where it still did.
- /// A one-time code that puts a technician's phone onto a workshop.
- ///
- /// Typing a URL and a password is the worst moment in the app, and it lands on
- /// the person least equipped for it: a technician with dirty hands, on a
- /// phone, being watched. The desk shows a QR instead and the phone reads it.
- ///
- /// It is a bearer credential in a picture, so it is deliberately short-lived,
- /// single-use, and bound to one person in one workshop. It exchanges for a
- /// normal session and is then dead; it is never itself a way to authenticate.
- /// A one-time code that signs a technician back in.
- ///
- /// Modelled on CustomerSmsCode, which does the same job for the customer
- /// portal: org-scoped, single use, short-lived. The organisation is not
- /// optional and is never inferred from the phone number. A technician's phone
- /// is only ever looked up inside the workshop their app already belongs to,
- /// so nothing here can reach across workshops even if a person works at two.
- model TechnicianLoginCode {
- id String @id @default(cuid())
- /// SHA-256 of the digits. The code exists in a text message and in the
- /// technician's head, and nowhere that can be read back out of a database.
- codeHash String
- /// How it was sent, so the app can say "check your messages" or "check your
- /// email" rather than guessing on the technician's behalf.
- channel String
- /// Wrong guesses so far. Six digits is a million, which a rate limiter alone
- /// does not protect: this burns the code after a handful of attempts.
- attempts Int @default(0)
- expiresAt DateTime
- usedAt DateTime?
- technicianId String
- technician Technician @relation(fields: [technicianId], references: [id], onDelete: Cascade)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- createdAt DateTime @default(now())
- @@index([organizationId, technicianId])
- @@index([expiresAt])
- @@map("technician_login_codes")
- }
- model TechnicianSetupCode {
- id String @id @default(cuid())
- /// SHA-256 of the code. The code itself is shown once, on the screen that
- /// asked for it, and is never written down anywhere it could be read back.
- codeHash String @unique
- expiresAt DateTime
- /// Set on redemption. Kept rather than deleted so a second scan can say the
- /// code was already used, which is a different problem from a wrong code.
- usedAt DateTime?
- /// Who the code signs in.
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- /// Who issued it, for the audit trail. Not a relation: the issuer may be
- /// removed from the workshop later and that must not delete the record.
- issuedByUserId String
- createdAt DateTime @default(now())
- @@index([organizationId, expiresAt])
- @@index([userId])
- @@map("technician_setup_codes")
- }
- model PushDevice {
- id String @id @default(cuid())
- /// Expo push token, unique across the estate. A reinstall issues a new one
- /// and the old becomes invalid, which is why sends prune on rejection.
- token String @unique
- /// "ios" | "android". Only used to explain failures in logs.
- platform String
- /// Set false when Expo reports the token as dead, rather than deleting, so
- /// a flapping device does not churn rows.
- isActive Boolean @default(true)
- /// Last time the app confirmed this token still belongs to this user, so a
- /// device that stops checking in can be aged out.
- lastSeenAt DateTime @default(now())
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- @@index([userId, isActive])
- @@index([organizationId])
- @@map("push_devices")
- }
- /// A physical place work happens: a lift, a wash bay, an alignment rack. Shops
- /// plan by bay as often as by person — the bay is the scarce resource, and a
- /// technician moves between them during a day. The work board can group its
- /// lanes by either, so both are first-class.
- model WorkBay {
- id String @id @default(cuid())
- name String
- color String @default("#64748b")
- isActive Boolean @default(true)
- /// Ordering on the board, left to right. Shops name bays by position
- /// ("Bay 1", "Lift 2") and expect them in that order, not alphabetically.
- sortOrder Int @default(0)
- /// Bookable minutes per day, mirroring Technician.dailyCapacity so the board
- /// can show utilisation for whichever lane grouping is on.
- dailyCapacity Int @default(480)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- serviceRecords ServiceRecord[]
- inspections Inspection[]
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- @@index([organizationId])
- @@map("work_bays")
- }
- 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())
- /// SHA-256 of the digits, not the digits.
- ///
- /// The code reaches the customer in a text message and lives in their head
- /// for a minute. It has no business also sitting in a table where reading a
- /// row hands over somebody's portal session.
- code String
- /// Wrong guesses so far. Six digits is a million, which sounds like plenty
- /// and is not: limiting by address does nothing about a hundred machines
- /// sharing the work. Five attempts against one code makes the space
- /// irrelevant, because the code is gone long before the guessing arrives.
- attempts Int @default(0)
- 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")
- }
|