technicians.prisma 10 KB

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