|
@@ -1,2273 +0,0 @@
|
|
|
-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
|
|
|
|
|
-
|
|
|
|
|
- /// First time this invoice reached the customer, by email or share link.
|
|
|
|
|
- /// Distinct from sharedAt, which tracks only the link and is cleared when
|
|
|
|
|
- /// the link is revoked; sending is not undone by withdrawing the link.
|
|
|
|
|
- sentAt DateTime?
|
|
|
|
|
-
|
|
|
|
|
- /// Set when an owner or admin deliberately reopens a locked document.
|
|
|
|
|
- /// Nullable rather than a boolean so the audit trail can show when, and the
|
|
|
|
|
- /// screen can say the document was reopened rather than never locked.
|
|
|
|
|
- editUnlockedAt DateTime?
|
|
|
|
|
- editUnlockedById String?
|
|
|
|
|
-
|
|
|
|
|
- /// 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
|
|
|
|
|
-
|
|
|
|
|
- /// Most recent time this quote reached the customer, by email or share
|
|
|
|
|
- /// link. Exists so an unlock is spent when the quote is issued again; the
|
|
|
|
|
- /// status alone cannot show that, because re-sending an accepted quote does
|
|
|
|
|
- /// not change it.
|
|
|
|
|
- sentAt DateTime?
|
|
|
|
|
-
|
|
|
|
|
- /// See ServiceRecord.editUnlockedAt.
|
|
|
|
|
- editUnlockedAt DateTime?
|
|
|
|
|
- editUnlockedById String?
|
|
|
|
|
-
|
|
|
|
|
- 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?
|
|
|
|
|
- defaultValue 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")
|
|
|
|
|
-}
|
|
|