| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548 |
- generator client {
- provider = "prisma-client"
- output = "../src/generated/prisma"
- }
- datasource db {
- provider = "postgresql"
- }
- model User {
- id String @id @default(cuid())
- name String
- email String @unique
- emailVerified Boolean @default(false)
- image String?
- isSuperAdmin Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- twoFactorEnabled Boolean @default(false)
- termsAcceptedAt DateTime?
- lastLogin DateTime?
- lastSeen DateTime?
- /// App version this user last saw/dismissed the update banner for.
- /// Null = never seeded (seeded silently on first load, no banner).
- lastSeenVersion String?
- /// Per-user dashboard grid layout (card positions/sizes/hidden set).
- /// Null = default layout. Shape validated in dashboardLayoutActions.
- dashboardLayout Json?
- sessions Session[]
- accounts Account[]
- vehicles Vehicle[]
- customers Customer[]
- settings AppSetting[]
- inventoryParts InventoryPart[]
- quotes Quote[]
- customFieldDefinitions CustomFieldDefinition[]
- twoFactor TwoFactor?
- passkeys Passkey[]
- organizationMembers OrganizationMember[]
- dashboardWidgets DashboardWidget[]
- auditLogs AuditLog[]
- technicians Technician[]
- stockMovements StockMovement[]
- @@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[]
- @@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
- /// Null for parts-only / counter sales; such records use customerId
- /// (or neither, for anonymous walk-in sales)
- vehicleId String?
- vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- /// Direct customer for records without a vehicle. Vehicle-linked records
- /// leave this null and resolve the customer through the vehicle, so
- /// reassigning a vehicle keeps invoices following the vehicle's customer.
- customerId String?
- customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
- /// Set when the job came straight from an inspection, so the inspection can
- /// show what was raised from it. SetNull: deleting the inspection must not
- /// take the job — and its invoice — with it.
- inspectionId String?
- inspection Inspection? @relation(fields: [inspectionId], references: [id], onDelete: SetNull)
- /// Denormalized from the vehicle historically; now the canonical scope so
- /// records exist without a vehicle. Nullable to mirror Vehicle.organizationId.
- organizationId String?
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- technicianId String?
- technician Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
- sortOrder Int @default(0)
- partItems ServicePart[]
- laborItems ServiceLabor[]
- attachments ServiceAttachment[]
- payments Payment[]
- statusReports StatusReport[]
- stockMovements StockMovement[]
- discoveredFindings VehicleFinding[] @relation("FindingDiscoveredIn")
- resolvedFindings VehicleFinding[] @relation("FindingResolvedIn")
- @@index([vehicleId])
- @@index([technicianId])
- @@index([organizationId])
- @@index([customerId])
- @@index([inspectionId])
- @@map("service_records")
- }
- model Note {
- id String @id @default(cuid())
- title String
- content String
- isPinned Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- vehicleId String
- vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- @@index([vehicleId])
- @@map("notes")
- }
- model FuelLog {
- id String @id @default(cuid())
- date DateTime @default(now())
- mileage Int
- gallons Float
- pricePerGallon Float
- totalCost Float
- isFillUp Boolean @default(true)
- station String?
- notes String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- vehicleId String
- vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- @@index([vehicleId])
- @@map("fuel_logs")
- }
- model Reminder {
- id String @id @default(cuid())
- title String
- description String?
- dueDate DateTime?
- dueMileage Int?
- isCompleted Boolean @default(false)
- /// Per-reminder notification channels, chosen when creating the reminder
- notifyInApp Boolean @default(true)
- notifyEmail Boolean @default(false)
- /// Set by the due-reminder cron once notified; cleared when dueDate changes
- notifiedAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- /// A reminder relates to a vehicle, a customer, or just the workshop
- /// (both null). organizationId is the canonical scope for all three.
- vehicleId String?
- vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- customerId String?
- customer Customer? @relation(fields: [customerId], references: [id], onDelete: Cascade)
- organizationId String?
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- @@index([vehicleId])
- @@index([customerId])
- @@index([organizationId])
- @@map("reminders")
- }
- model VehicleFinding {
- id String @id @default(cuid())
- description String
- severity String @default("needs_work")
- status String @default("open")
- notes String?
- imageUrls String[] @default([])
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- vehicleId String
- vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- serviceRecordId String?
- serviceRecord ServiceRecord? @relation("FindingDiscoveredIn", fields: [serviceRecordId], references: [id], onDelete: SetNull)
- resolvedServiceRecordId String?
- resolvedServiceRecord ServiceRecord? @relation("FindingResolvedIn", fields: [resolvedServiceRecordId], references: [id], onDelete: SetNull)
- @@index([vehicleId])
- @@index([serviceRecordId])
- @@map("vehicle_findings")
- }
- model Customer {
- id String @id @default(cuid())
- /// Human-facing customer number: auto-assigned sequentially on create,
- /// editable afterwards. Unique per organization (nulls allowed for
- /// legacy customers).
- customerNumber String?
- name String
- email String?
- phone String?
- address String?
- company String?
- taxId String?
- taxExempt Boolean @default(false)
- notes String?
- telegramChatId String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- organizationId String?
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- vehicles Vehicle[]
- quotes Quote[]
- smsMessages SmsMessage[]
- telegramMessages TelegramMessage[]
- customerSessions CustomerSession[]
- scheduledMessages ScheduledMessage[]
- serviceRequests ServiceRequest[]
- serviceRecords ServiceRecord[]
- reminders Reminder[]
- @@unique([organizationId, customerNumber])
- @@index([organizationId])
- @@map("customers")
- }
- model Payment {
- id String @id @default(cuid())
- amount Float
- date DateTime @default(now())
- method String @default("other")
- note String?
- provider String?
- externalId String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- serviceRecordId String
- serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
- @@index([serviceRecordId])
- @@map("payments")
- }
- model ServiceAttachment {
- id String @id @default(cuid())
- fileName String
- fileUrl String
- fileType String
- fileSize Int
- category String @default("diagnostic")
- description String?
- includeInInvoice Boolean @default(true)
- createdAt DateTime @default(now())
- serviceRecordId String
- serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
- @@index([serviceRecordId])
- @@map("service_attachments")
- }
- model ServicePart {
- id String @id @default(cuid())
- partNumber String?
- name String
- quantity Float @default(1)
- unitPrice Float @default(0)
- total Float @default(0)
- unitCost Float @default(0)
- markupPercent Float @default(0)
- inventoryPartId String?
- serviceRecordId String
- serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
- @@index([serviceRecordId])
- @@map("service_parts")
- }
- model ServiceLabor {
- id String @id @default(cuid())
- description String
- hours Float @default(0)
- rate Float @default(0)
- total Float @default(0)
- pricingType String @default("hourly")
- serviceRecordId String
- serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
- @@index([serviceRecordId])
- @@map("service_labor")
- }
- model LaborPreset {
- id String @id @default(cuid())
- name String
- description String?
- isArchived Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- userId String
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- items LaborPresetItem[]
- parts LaborPresetPart[]
- @@index([organizationId])
- @@map("labor_presets")
- }
- model LaborPresetItem {
- id String @id @default(cuid())
- description String
- hours Float @default(0)
- rate Float @default(0)
- pricingType String @default("hourly")
- sortOrder Int @default(0)
- presetId String
- preset LaborPreset @relation(fields: [presetId], references: [id], onDelete: Cascade)
- @@index([presetId])
- @@map("labor_preset_items")
- }
- model LaborPresetPart {
- id String @id @default(cuid())
- name String
- partNumber String?
- quantity Float @default(1)
- unitPrice Float @default(0)
- inventoryPartId String?
- sortOrder Int @default(0)
- presetId String
- preset LaborPreset @relation(fields: [presetId], references: [id], onDelete: Cascade)
- @@index([presetId])
- @@map("labor_preset_parts")
- }
- model InventoryPart {
- id String @id @default(cuid())
- partNumber String?
- barcode String?
- name String
- description String?
- category String?
- quantity Int @default(0)
- minQuantity Int @default(0)
- unitCost Float @default(0)
- sellPrice Float @default(0)
- supplier String?
- supplierPhone String?
- supplierEmail String?
- supplierUrl String?
- imageUrl String?
- location String?
- isArchived Boolean @default(false)
- /// Set when a low-stock alert has been sent for the current dip below
- /// `minQuantity`, and cleared once stock climbs back above it. This
- /// hysteresis is what stops a part that sits at zero from re-alerting on
- /// every check — it alerts once per descent, not once per run.
- lowStockAlertedAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- organizationId String?
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- gallery StoredImage[] @relation("InventoryPartImages")
- movements StockMovement[]
- @@unique([organizationId, barcode])
- @@index([organizationId])
- @@map("inventory_parts")
- }
- /// Append-only audit trail of every change to InventoryPart.quantity.
- ///
- /// Written inside the same transaction as the stock mutation itself, so the
- /// ledger can never drift from the balance it describes. `quantityAfter` is
- /// captured from the atomic `UPDATE ... RETURNING`, giving an exact running
- /// balance even under concurrent writes.
- model StockMovement {
- id String @id @default(cuid())
- /// Signed change in whole units. Negative = consumed, positive = restocked.
- delta Int
- /// Part balance immediately after this movement was applied.
- quantityAfter Int
- /// Why the stock moved — see StockMovementReason in src/features/inventory/Lib/stockMovement.ts
- reason String
- note String?
- inventoryPartId String
- inventoryPart InventoryPart @relation(fields: [inventoryPartId], references: [id], onDelete: Cascade)
- /// The job the part was used on. Kept on SetNull so history survives
- /// deletion of the service record — "where was this used" outlives the job.
- serviceRecordId String?
- serviceRecord ServiceRecord? @relation(fields: [serviceRecordId], references: [id], onDelete: SetNull)
- /// Denormalised so history still reads correctly after a service record is
- /// deleted and serviceRecordId goes null.
- serviceRecordLabel String?
- userId String?
- user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- createdAt DateTime @default(now())
- @@index([inventoryPartId, createdAt(sort: Desc)])
- @@index([organizationId, createdAt(sort: Desc)])
- @@index([serviceRecordId])
- @@map("stock_movements")
- }
- model StoredImage {
- id String @id @default(cuid())
- url String
- fileName String?
- description String?
- sortOrder Int @default(0)
- createdAt DateTime @default(now())
- inventoryPartId String?
- inventoryPart InventoryPart? @relation("InventoryPartImages", fields: [inventoryPartId], references: [id], onDelete: Cascade)
- @@index([inventoryPartId])
- @@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)
- vehicleId String?
- vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
- inspectionId String?
- inspection Inspection? @relation(fields: [inspectionId], references: [id], onDelete: SetNull)
- partItems QuotePart[]
- laborItems QuoteLabor[]
- attachments QuoteAttachment[]
- @@index([organizationId])
- @@map("quotes")
- }
- model QuoteAttachment {
- id String @id @default(cuid())
- fileName String
- fileUrl String
- fileType String
- fileSize Int
- category String @default("image")
- description String?
- includeInInvoice Boolean @default(true)
- createdAt DateTime @default(now())
- quoteId String
- quote Quote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
- @@index([quoteId])
- @@map("quote_attachments")
- }
- model QuotePart {
- id String @id @default(cuid())
- partNumber String?
- name String
- quantity Float @default(1)
- /// Internal purchase cost — never shown to customers (quote PDF/share
- /// render unitPrice only)
- unitCost Float @default(0)
- markupPercent Float @default(0)
- unitPrice Float @default(0)
- total Float @default(0)
- excluded Boolean @default(false)
- /// Optional link to the stocked part this line was picked from. Carried into
- /// ServicePart on conversion so the job deducts the right inventory item.
- inventoryPartId String?
- quoteId String
- quote Quote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
- @@index([quoteId])
- @@map("quote_parts")
- }
- model QuoteLabor {
- id String @id @default(cuid())
- description String
- hours Float @default(0)
- rate Float @default(0)
- total Float @default(0)
- pricingType String @default("hourly")
- excluded Boolean @default(false)
- quoteId String
- quote Quote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
- @@index([quoteId])
- @@map("quote_labor")
- }
- model CustomFieldDefinition {
- id String @id @default(cuid())
- name String
- label String
- fieldType String @default("text")
- options String?
- required Boolean @default(false)
- entityType String
- sortOrder Int @default(0)
- isActive Boolean @default(true)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- organizationId String?
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- values CustomFieldValue[]
- @@unique([organizationId, name, entityType])
- @@index([organizationId])
- @@map("custom_field_definitions")
- }
- model CustomFieldValue {
- id String @id @default(cuid())
- value String
- entityId String
- entityType String
- fieldId String
- field CustomFieldDefinition @relation(fields: [fieldId], references: [id], onDelete: Cascade)
- @@unique([fieldId, entityId])
- @@index([fieldId])
- @@map("custom_field_values")
- }
- /// A user-defined dashboard card: a named, filtered table over one entity.
- /// The config shape (entity, filters, columns, limit) is validated against
- /// the field registry in customCardActions — nothing here reaches SQL raw.
- model DashboardWidget {
- id String @id @default(cuid())
- name String
- config Json
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- organizationId String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- @@index([userId, organizationId])
- @@map("dashboard_widgets")
- }
- model Organization {
- id String @id @default(cuid())
- name String
- portalSlug String? @unique
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- members OrganizationMember[]
- roles Role[]
- vehicles Vehicle[]
- serviceRecords ServiceRecord[]
- reminders Reminder[]
- customers Customer[]
- quotes Quote[]
- inventoryParts InventoryPart[]
- laborPresets LaborPreset[]
- customFieldDefinitions CustomFieldDefinition[]
- settings AppSetting[]
- subscription Subscription?
- invitations TeamInvitation[]
- inspectionTemplates InspectionTemplate[]
- smsMessages SmsMessage[]
- telegramMessages TelegramMessage[]
- technicians Technician[]
- auditLogs AuditLog[]
- statusReports StatusReport[]
- reportSchedules ReportSchedule[]
- scheduledMessages ScheduledMessage[]
- webhooks Webhook[]
- stockMovements StockMovement[]
- @@map("organizations")
- }
- model OrganizationMember {
- id String @id @default(cuid())
- role String @default("member")
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- roleId String?
- customRole Role? @relation(fields: [roleId], references: [id], onDelete: SetNull)
- @@unique([userId, organizationId])
- @@map("organization_members")
- }
- model Role {
- id String @id @default(cuid())
- name String
- isAdmin Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- permissions Permission[]
- members OrganizationMember[]
- teamInvitations TeamInvitation[]
- @@unique([organizationId, name])
- @@map("roles")
- }
- model Permission {
- id String @id @default(cuid())
- action String
- subject String
- roleId String
- role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
- @@unique([roleId, action, subject])
- @@index([roleId])
- @@map("permissions")
- }
- model SystemSetting {
- id String @id @default(cuid())
- key String @unique
- value String
- @@map("system_settings")
- }
- model TeamInvitation {
- id String @id @default(cuid())
- email String
- role String @default("member")
- token String @unique
- status String @default("pending") // pending | accepted | cancelled
- expiresAt DateTime
- createdAt DateTime @default(now())
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- invitedById String
- roleId String?
- customRole Role? @relation(fields: [roleId], references: [id], onDelete: SetNull)
- @@unique([email, organizationId])
- @@index([token])
- @@map("team_invitations")
- }
- // Stripe-ready models (implementation in follow-up)
- model SubscriptionPlan {
- id String @id @default(cuid())
- name String
- stripePriceId String? @unique
- price Float @default(0)
- interval String @default("month") // "month" | "year"
- maxMembers Int @default(1)
- features String? // JSON feature flags
- isActive Boolean @default(true)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- subscriptions Subscription[]
- @@map("subscription_plans")
- }
- model Subscription {
- id String @id @default(cuid())
- status String @default("active") // active | canceled | past_due | trialing
- stripeSubscriptionId String? @unique
- stripeCustomerId String?
- currentPeriodStart DateTime?
- currentPeriodEnd DateTime?
- cancelAtPeriodEnd Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- organizationId String @unique
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- planId String
- plan SubscriptionPlan @relation(fields: [planId], references: [id])
- @@map("subscriptions")
- }
- model RecurringInvoice {
- id String @id @default(cuid())
- title String
- description String?
- frequency String // "weekly" | "biweekly" | "monthly" | "quarterly" | "yearly"
- nextRunDate DateTime
- endDate DateTime?
- isActive Boolean @default(true)
- lastRunAt DateTime?
- runCount Int @default(0)
- // Template fields (copied to ServiceRecord on generation)
- type String @default("maintenance")
- cost Float @default(0)
- taxRate Float @default(0)
- taxInclusive Boolean @default(false)
- invoiceNotes String?
- vehicleId String
- vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- templateParts RecurringPart[]
- templateLabor RecurringLabor[]
- @@index([vehicleId])
- @@index([nextRunDate, isActive])
- @@map("recurring_invoices")
- }
- model RecurringPart {
- id String @id @default(cuid())
- name String
- partNumber String?
- quantity Int @default(1)
- unitPrice Float @default(0)
- recurringInvoiceId String
- recurringInvoice RecurringInvoice @relation(fields: [recurringInvoiceId], references: [id], onDelete: Cascade)
- @@map("recurring_parts")
- }
- model RecurringLabor {
- id String @id @default(cuid())
- description String
- hours Float @default(0)
- rate Float @default(0)
- pricingType String @default("hourly")
- recurringInvoiceId String
- recurringInvoice RecurringInvoice @relation(fields: [recurringInvoiceId], references: [id], onDelete: Cascade)
- @@map("recurring_labor")
- }
- model InspectionTemplate {
- id String @id @default(cuid())
- name String
- description String?
- isDefault Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- // Regulatory profile. `country` is an ISO-3166 alpha-2 code (or null for a
- // shop-specific checklist), `standard` names the regime the template follows
- // (e.g. "eu-2014-45"), and `severityScale` picks which defect vocabulary the
- // technician grades against — see Lib/conditions.ts.
- country String?
- standard String? @default("custom")
- severityScale String @default("eu")
- /// Provenance, for templates that came from a package rather than being
- /// written here. `packageId` is namespaced and stable across renames
- /// ("torqvoice/eu-roadworthiness"), which is what lets the library tell an
- /// update from a duplicate. Null on a template the workshop wrote itself.
- packageId String?
- packageVersion String?
- /// Where it came from: "builtin", "file" or "catalogue".
- packageSource String?
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- sections InspectionTemplateSection[]
- inspections Inspection[]
- @@index([organizationId])
- @@index([organizationId, packageId])
- @@map("inspection_templates")
- }
- model InspectionTemplateSection {
- id String @id @default(cuid())
- name String
- description String?
- // Regulation reference for the section, e.g. "1" for Braking equipment in
- // Directive 2014/45/EU Annex I.
- code String?
- sortOrder Int @default(0)
- templateId String
- template InspectionTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
- items InspectionTemplateItem[]
- @@index([templateId])
- @@map("inspection_template_sections")
- }
- model InspectionTemplateItem {
- id String @id @default(cuid())
- name String
- description String?
- // Regulation reference for the check, e.g. "1.1.13".
- code String?
- sortOrder Int @default(0)
- // How the technician records the result: "condition" (defect grading only),
- // "measurement" (numeric value in `unit`, graded against min/max),
- // "text" (free text) or "choice" (one of `choices`).
- inputType String @default("condition")
- unit String?
- minValue Float?
- maxValue Float?
- choices String[] @default([])
- required Boolean @default(false)
- photoRequired Boolean @default(false)
- // Defect severity applied when a measurement falls outside min/max.
- defaultSeverity String?
- // Phrases this workshop wants offered when a defect is recorded against the
- // check, on top of the built-in catalogue. See Lib/defectCatalogue.ts.
- defectSuggestions String[] @default([])
- sectionId String
- section InspectionTemplateSection @relation(fields: [sectionId], references: [id], onDelete: Cascade)
- @@index([sectionId])
- @@map("inspection_template_items")
- }
- model Inspection {
- id String @id @default(cuid())
- status String @default("in_progress")
- mileage Int?
- notes String?
- startDateTime DateTime?
- endDateTime DateTime?
- publicToken String? @unique
- completedAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- vehicleId String
- vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- templateId String
- template InspectionTemplate @relation(fields: [templateId], references: [id])
- technicianId String?
- technician Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
- sortOrder Int @default(0)
- // Roadworthiness certificate fields required by Directive 2014/45/EU Annex IV.
- // `inspectorName` and `certificateNumber` are snapshots taken at completion so
- // the issued certificate stays stable even if staff or settings change later.
- /// Snapshot of how this inspection was graded, taken when it was created.
- /// Read in preference to the template's current setting: a workshop that
- /// later switches a template to the EU scale must not relabel a certificate
- /// it already issued. Null on inspections that predate the snapshot; those
- /// fall back to the template, which the migration pinned to its old scale.
- severityScale String?
- country String?
- vehicleCategory String?
- nextTestDue DateTime?
- certificateNumber String?
- inspectorName String?
- testLocation String?
- organizationId String
- items InspectionItem[]
- quoteRequests InspectionQuoteRequest[]
- quotes Quote[]
- serviceRecords ServiceRecord[]
- @@index([vehicleId])
- @@index([organizationId])
- @@index([technicianId])
- @@map("inspections")
- }
- model InspectionItem {
- id String @id @default(cuid())
- name String
- section String
- sortOrder Int @default(0)
- condition String @default("not_inspected")
- notes String?
- imageUrls String[] @default([])
- // Copied from the template at creation so an inspection stays readable even
- // after its template is edited or deleted.
- description String?
- code String?
- sectionCode String?
- inputType String @default("condition")
- unit String?
- minValue Float?
- maxValue Float?
- choices String[] @default([])
- required Boolean @default(false)
- photoRequired Boolean @default(false)
- defaultSeverity String?
- defectSuggestions String[] @default([])
- measuredValue Float?
- textValue String?
- inspectionId String
- inspection Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
- @@index([inspectionId])
- @@map("inspection_items")
- }
- model InspectionQuoteRequest {
- id String @id @default(cuid())
- status String @default("pending")
- message String?
- selectedItemIds String[]
- createdAt DateTime @default(now())
- inspectionId String
- inspection Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
- organizationId String
- @@index([organizationId])
- @@index([inspectionId])
- @@map("inspection_quote_requests")
- }
- model Notification {
- id String @id @default(cuid())
- type String
- title String
- message String
- entityType String
- entityId String
- entityUrl String
- read Boolean @default(false)
- organizationId String
- createdAt DateTime @default(now())
- @@index([organizationId, createdAt(sort: Desc)])
- @@index([organizationId, read])
- @@map("notifications")
- }
- model SmsMessage {
- id String @id @default(cuid())
- direction String // "inbound" | "outbound"
- fromNumber String
- toNumber String
- body String
- status String @default("queued") // queued | sent | delivered | failed | received
- providerMsgId String?
- errorMessage String?
- relatedEntityType String?
- relatedEntityId String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- customerId String?
- customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
- @@index([organizationId, customerId, createdAt(sort: Desc)])
- @@index([organizationId, createdAt(sort: Desc)])
- @@index([toNumber, organizationId])
- @@map("sms_messages")
- }
- model TelegramMessage {
- id String @id @default(cuid())
- direction String // "inbound" | "outbound"
- chatId String // Telegram chat ID
- body String
- status String @default("queued") // queued | sent | delivered | failed | received
- telegramMessageId String? // Telegram's message ID for tracking
- errorMessage String?
- relatedEntityType String?
- relatedEntityId String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- customerId String?
- customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
- @@index([organizationId, customerId, createdAt(sort: Desc)])
- @@index([organizationId, chatId, createdAt(sort: Desc)])
- @@map("telegram_messages")
- }
- model Technician {
- id String @id @default(cuid())
- name String
- color String @default("#3b82f6")
- isActive Boolean @default(true)
- sortOrder Int @default(0)
- dailyCapacity Int @default(480)
- userId String?
- user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- serviceRecords ServiceRecord[]
- inspections Inspection[]
- statusReports StatusReport[]
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- @@index([organizationId])
- @@map("technicians")
- }
- model AuditLog {
- id String @id @default(cuid())
- timestamp DateTime @default(now())
- action String
- entity String?
- entityId String?
- message String?
- metadata Json?
- ip String?
- userAgent String?
- userId String?
- user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
- organizationId String?
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
- @@index([organizationId, timestamp(sort: Desc)])
- @@index([userId, timestamp(sort: Desc)])
- @@index([action, timestamp(sort: Desc)])
- @@map("audit_logs")
- }
- model CustomerSession {
- id String @id @default(cuid())
- token String @unique
- expiresAt DateTime
- createdAt DateTime @default(now())
- customerId String
- customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
- organizationId String
- @@index([token])
- @@index([customerId, organizationId])
- @@map("customer_sessions")
- }
- model CustomerMagicLink {
- id String @id @default(cuid())
- token String @unique
- email String
- organizationId String
- expiresAt DateTime
- usedAt DateTime?
- createdAt DateTime @default(now())
- @@index([token])
- @@map("customer_magic_links")
- }
- model CustomerSmsCode {
- id String @id @default(cuid())
- code String
- phone String
- organizationId String
- expiresAt DateTime
- usedAt DateTime?
- createdAt DateTime @default(now())
- @@index([phone, organizationId])
- @@index([organizationId])
- @@map("customer_sms_codes")
- }
- model ServiceRequest {
- id String @id @default(cuid())
- description String
- preferredDate DateTime?
- status String @default("pending")
- adminNotes String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- customerId String
- customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
- vehicleId String
- vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- organizationId String
- @@index([organizationId, status])
- @@index([customerId])
- @@map("service_requests")
- }
- model AiGeneratedMessage {
- id String @id @default(cuid())
- vehicleId String
- type String // "summary" | "common_issues" | etc.
- content String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
- @@unique([vehicleId, type])
- @@map("ai_generated_messages")
- }
- model AiChat {
- id String @id @default(cuid())
- title String @default("New chat")
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- userId String
- organizationId String
- messages AiChatMessage[]
- @@index([organizationId, userId, updatedAt(sort: Desc)])
- @@map("ai_chats")
- }
- model AiChatMessage {
- id String @id @default(cuid())
- role String // "user" | "assistant"
- content String
- createdAt DateTime @default(now())
- chatId String
- chat AiChat @relation(fields: [chatId], references: [id], onDelete: Cascade)
- @@index([chatId, createdAt])
- @@map("ai_chat_messages")
- }
- model StatusReport {
- id String @id @default(cuid())
- title String?
- message String? @db.Text
- videoUrl String?
- videoFileName String?
- publicToken String @unique
- status String @default("draft") // draft | published | sent | viewed
- sentVia String? // email, sms, telegram (comma-separated)
- sentAt DateTime?
- viewedAt DateTime?
- customerFeedback String? @db.Text
- feedbackAt DateTime?
- expiresAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- serviceRecordId String
- serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- technicianId String?
- technician Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
- @@index([serviceRecordId])
- @@index([organizationId])
- @@index([publicToken])
- @@index([expiresAt])
- @@map("status_reports")
- }
- model ReportSchedule {
- id String @id @default(cuid())
- name String @default("Scheduled Report")
- frequency String // "daily" | "weekly" | "monthly" etc.
- dateRange String @default("last30d") // "last1d" | "last7d" | "last30d" | "ytd" | "allTime" etc.
- sections String // JSON array of section keys
- recipients String // JSON array of user IDs
- nextRunDate DateTime
- endDate DateTime?
- isActive Boolean @default(true)
- lastRunAt DateTime?
- runCount Int @default(0)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- createdById String
- @@index([organizationId])
- @@index([nextRunDate, isActive])
- @@map("report_schedules")
- }
- /// A message the workshop lines up to go out on a given day and time, through
- /// whichever channel suits the customer. One-off by default; a frequency other
- /// than "once" reschedules itself after each send, the way ReportSchedule does.
- model ScheduledMessage {
- id String @id @default(cuid())
- channel String // "email" | "sms" | "telegram" | "in_app"
- subject String? // email only
- body String
- /// Where it goes when no customer is attached, or when the workshop typed an
- /// address by hand. Falls back to the customer's email/phone/chat id.
- recipient String?
- status String @default("scheduled") // scheduled | sent | failed | cancelled
- sendAt DateTime
- frequency String @default("once") // once | daily | weekly | biweekly | monthly | yearly
- endDate DateTime?
- lastRunAt DateTime?
- sentAt DateTime?
- runCount Int @default(0)
- errorMessage String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- customerId String?
- customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
- vehicleId String?
- vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
- createdById String
- @@index([organizationId, sendAt])
- @@index([status, sendAt])
- @@map("scheduled_messages")
- }
- model Webhook {
- id String @id @default(cuid())
- name String
- url String
- secret String
- events String // JSON array of event names; "*" matches all
- isActive Boolean @default(true)
- description String?
- lastTriggeredAt DateTime?
- lastSuccessAt DateTime?
- lastFailureAt DateTime?
- failureCount Int @default(0)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- createdById String
- deliveries WebhookDelivery[]
- @@index([organizationId])
- @@index([organizationId, isActive])
- @@map("webhooks")
- }
- model WebhookDelivery {
- id String @id @default(cuid())
- event String
- payload String // JSON-stringified event payload as sent
- status String @default("pending") // pending | success | failed | retrying
- statusCode Int?
- responseBody String?
- errorMessage String?
- attempt Int @default(0)
- maxAttempts Int @default(5)
- nextRetryAt DateTime?
- deliveredAt DateTime?
- durationMs Int?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- webhookId String
- webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
- @@index([webhookId, createdAt(sort: Desc)])
- @@index([status, nextRetryAt])
- @@map("webhook_deliveries")
- }
|