technicians.prisma 8.5 KB

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