| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229 |
- 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")
- }
|