technicians.prisma 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. model Technician {
  2. id String @id @default(cuid())
  3. name String
  4. color String @default("#3b82f6")
  5. isActive Boolean @default(true)
  6. sortOrder Int @default(0)
  7. dailyCapacity Int @default(480)
  8. /// What this person is good at, in the workshop's own words ("Diagnostics,
  9. /// EV certified"). Shown beside their name where work is handed out.
  10. skills String?
  11. userId String?
  12. user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
  13. organizationId String
  14. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  15. loginCodes TechnicianLoginCode[]
  16. serviceRecords ServiceRecord[]
  17. inspections Inspection[]
  18. statusReports StatusReport[]
  19. timeEntries TimeEntry[]
  20. createdAt DateTime @default(now())
  21. updatedAt DateTime @updatedAt
  22. @@index([organizationId])
  23. @@map("technicians")
  24. }
  25. /// One stretch of a technician actually working on a job, as clocked from the
  26. /// technician app.
  27. ///
  28. /// Deliberately separate from `ServiceRecord.startDateTime` / `endDateTime`:
  29. /// those are the work board's planned slot, one pair per job, owned by whoever
  30. /// schedules the day. This is what happened, many rows per job, one per person
  31. /// per stretch. A job worked on Monday, parked for parts and finished Thursday
  32. /// by someone else is two entries, and neither of them is the booking.
  33. ///
  34. /// `endedAt` null means the clock is still running. At most one open entry per
  35. /// technician is enforced in the API rather than the schema, because "open"
  36. /// is not a value a partial unique index can express portably.
  37. model TimeEntry {
  38. id String @id @default(cuid())
  39. startedAt DateTime @default(now())
  40. endedAt DateTime?
  41. /// Cached on stop so reports do not recompute across millions of rows, and
  42. /// so a correction to the timestamps stays visible against what was billed.
  43. durationMinutes Int?
  44. /// What the technician typed when stopping, if anything.
  45. note String?
  46. /// "app" | "web" | "manual". A manually corrected entry is not the same
  47. /// evidence as one clocked live, and Arbeitszeiterfassung cares about that.
  48. source String @default("app")
  49. /// Set when someone edited the times after the fact, so the audit trail can
  50. /// show the entry is no longer purely as-clocked.
  51. editedAt DateTime?
  52. editedByUserId String?
  53. technicianId String
  54. technician Technician @relation(fields: [technicianId], references: [id], onDelete: Cascade)
  55. serviceRecordId String
  56. serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
  57. organizationId String
  58. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  59. createdAt DateTime @default(now())
  60. updatedAt DateTime @updatedAt
  61. @@index([technicianId, startedAt])
  62. @@index([serviceRecordId])
  63. @@index([organizationId, startedAt])
  64. /// Finding the technician's open entry is the hottest query in the app: it
  65. /// runs on every launch and every job screen.
  66. @@index([technicianId, endedAt])
  67. @@map("time_entries")
  68. }
  69. /// A phone that has asked to be told about its technician's work.
  70. ///
  71. /// Keyed on the Expo push token rather than on the user, because the unit that
  72. /// receives a notification is a device: one technician may carry a personal
  73. /// phone and a shared bench tablet, and a shared tablet may be signed into by
  74. /// several people over a week. Both cases have to work.
  75. ///
  76. /// The token is a routing address, not a secret, but it is still deleted on
  77. /// sign-out: a device that has signed out must stop receiving a workshop's job
  78. /// details, and expiry alone would leave a window where it still did.
  79. /// A one-time code that puts a technician's phone onto a workshop.
  80. ///
  81. /// Typing a URL and a password is the worst moment in the app, and it lands on
  82. /// the person least equipped for it: a technician with dirty hands, on a
  83. /// phone, being watched. The desk shows a QR instead and the phone reads it.
  84. ///
  85. /// It is a bearer credential in a picture, so it is deliberately short-lived,
  86. /// single-use, and bound to one person in one workshop. It exchanges for a
  87. /// normal session and is then dead; it is never itself a way to authenticate.
  88. /// A one-time code that signs a technician back in.
  89. ///
  90. /// Modelled on CustomerSmsCode, which does the same job for the customer
  91. /// portal: org-scoped, single use, short-lived. The organisation is not
  92. /// optional and is never inferred from the phone number. A technician's phone
  93. /// is only ever looked up inside the workshop their app already belongs to,
  94. /// so nothing here can reach across workshops even if a person works at two.
  95. model TechnicianLoginCode {
  96. id String @id @default(cuid())
  97. /// SHA-256 of the digits. The code exists in a text message and in the
  98. /// technician's head, and nowhere that can be read back out of a database.
  99. codeHash String
  100. /// How it was sent, so the app can say "check your messages" or "check your
  101. /// email" rather than guessing on the technician's behalf.
  102. channel String
  103. /// Wrong guesses so far. Six digits is a million, which a rate limiter alone
  104. /// does not protect: this burns the code after a handful of attempts.
  105. attempts Int @default(0)
  106. expiresAt DateTime
  107. usedAt DateTime?
  108. technicianId String
  109. technician Technician @relation(fields: [technicianId], references: [id], onDelete: Cascade)
  110. organizationId String
  111. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  112. createdAt DateTime @default(now())
  113. @@index([organizationId, technicianId])
  114. @@index([expiresAt])
  115. @@map("technician_login_codes")
  116. }
  117. model TechnicianSetupCode {
  118. id String @id @default(cuid())
  119. /// SHA-256 of the code. The code itself is shown once, on the screen that
  120. /// asked for it, and is never written down anywhere it could be read back.
  121. codeHash String @unique
  122. expiresAt DateTime
  123. /// Set on redemption. Kept rather than deleted so a second scan can say the
  124. /// code was already used, which is a different problem from a wrong code.
  125. usedAt DateTime?
  126. /// Who the code signs in.
  127. userId String
  128. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  129. organizationId String
  130. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  131. /// Who issued it, for the audit trail. Not a relation: the issuer may be
  132. /// removed from the workshop later and that must not delete the record.
  133. issuedByUserId String
  134. createdAt DateTime @default(now())
  135. @@index([organizationId, expiresAt])
  136. @@index([userId])
  137. @@map("technician_setup_codes")
  138. }
  139. model PushDevice {
  140. id String @id @default(cuid())
  141. /// Expo push token, unique across the estate. A reinstall issues a new one
  142. /// and the old becomes invalid, which is why sends prune on rejection.
  143. token String @unique
  144. /// "ios" | "android". Only used to explain failures in logs.
  145. platform String
  146. /// Set false when Expo reports the token as dead, rather than deleting, so
  147. /// a flapping device does not churn rows.
  148. isActive Boolean @default(true)
  149. /// Last time the app confirmed this token still belongs to this user, so a
  150. /// device that stops checking in can be aged out.
  151. lastSeenAt DateTime @default(now())
  152. userId String
  153. user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  154. organizationId String
  155. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  156. createdAt DateTime @default(now())
  157. updatedAt DateTime @updatedAt
  158. @@index([userId, isActive])
  159. @@index([organizationId])
  160. @@map("push_devices")
  161. }
  162. /// A physical place work happens: a lift, a wash bay, an alignment rack. Shops
  163. /// plan by bay as often as by person — the bay is the scarce resource, and a
  164. /// technician moves between them during a day. The work board can group its
  165. /// lanes by either, so both are first-class.
  166. model WorkBay {
  167. id String @id @default(cuid())
  168. name String
  169. color String @default("#64748b")
  170. isActive Boolean @default(true)
  171. /// Ordering on the board, left to right. Shops name bays by position
  172. /// ("Bay 1", "Lift 2") and expect them in that order, not alphabetically.
  173. sortOrder Int @default(0)
  174. /// Bookable minutes per day, mirroring Technician.dailyCapacity so the board
  175. /// can show utilisation for whichever lane grouping is on.
  176. dailyCapacity Int @default(480)
  177. organizationId String
  178. organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  179. serviceRecords ServiceRecord[]
  180. inspections Inspection[]
  181. createdAt DateTime @default(now())
  182. updatedAt DateTime @updatedAt
  183. @@index([organizationId])
  184. @@map("work_bays")
  185. }