Răsfoiți Sursa

Show who else has a work order open, and keep it current live (#411)

One socket for the whole app, owned by a collaboration manager, replaces the
five each feature used to open. Every write announces itself through a Prisma
extension after its transaction commits, pages re-read through a governor
that cannot turn into polling, and presence chips show who else is on the
record. Rooms are keyed by the workshop from the socket's own session.

Also fixes the notes editor reporting an edit on mount, which made every
opened work order save itself five seconds later, and tells a member why
they cannot assign a technician.
Bernt Christian Egeland 1 săptămână în urmă
părinte
comite
f7cb5f3b0d
81 a modificat fișierele cu 5440 adăugiri și 527 ștergeri
  1. 4 0
      messages/de/realtime.json
  2. 1 0
      messages/de/service.json
  3. 4 0
      messages/en/realtime.json
  4. 1 0
      messages/en/service.json
  5. 4 0
      messages/es/realtime.json
  6. 1 0
      messages/es/service.json
  7. 4 0
      messages/fr/realtime.json
  8. 1 0
      messages/fr/service.json
  9. 4 0
      messages/it/realtime.json
  10. 1 0
      messages/it/service.json
  11. 4 0
      messages/lt/realtime.json
  12. 1 0
      messages/lt/service.json
  13. 4 0
      messages/nb/realtime.json
  14. 1 0
      messages/nb/service.json
  15. 4 0
      messages/nl/realtime.json
  16. 1 0
      messages/nl/service.json
  17. 4 0
      messages/pl/realtime.json
  18. 1 0
      messages/pl/service.json
  19. 4 0
      messages/pt-BR/realtime.json
  20. 1 0
      messages/pt-BR/service.json
  21. 4 0
      messages/pt-PT/realtime.json
  22. 1 0
      messages/pt-PT/service.json
  23. 4 0
      messages/ru/realtime.json
  24. 1 0
      messages/ru/service.json
  25. 4 0
      messages/tr/realtime.json
  26. 1 0
      messages/tr/service.json
  27. 2 0
      package-lock.json
  28. 2 0
      package.json
  29. 372 0
      src/__tests__/features/realtime/manager.test.ts
  30. 202 0
      src/__tests__/features/realtime/presence-rendering.test.tsx
  31. 146 0
      src/__tests__/features/realtime/refresh-governor.test.ts
  32. 64 0
      src/__tests__/features/vehicles/rich-text-editor-quiet.test.tsx
  33. 20 16
      src/__tests__/lib/notification-roles.test.ts
  34. 147 0
      src/__tests__/lib/realtime/after-commit.test.ts
  35. 272 0
      src/__tests__/lib/realtime/announces-writes.test.ts
  36. 176 0
      src/__tests__/lib/realtime/bus.test.ts
  37. 258 0
      src/__tests__/lib/realtime/presence.test.ts
  38. 267 0
      src/__tests__/lib/realtime/protocol.test.ts
  39. 156 0
      src/__tests__/lib/realtime/rooms.test.ts
  40. 54 0
      src/__tests__/lib/realtime/shared-state.test.ts
  41. 10 2
      src/__tests__/lib/with-auth.test.ts
  42. 33 27
      src/app/(authenticated)/layout.tsx
  43. 2 2
      src/app/api/protected/backup/import/route.ts
  44. 258 68
      src/app/api/protected/ws/route.ts
  45. 11 47
      src/components/broadcast-live.tsx
  46. 2 1
      src/components/ui/rich-text-editor.tsx
  47. 3 2
      src/features/inventory/Lib/reconcileStock.ts
  48. 47 75
      src/features/notifications/hooks/useNotificationWebSocket.ts
  49. 81 0
      src/features/realtime/Components/PresenceChips.tsx
  50. 66 0
      src/features/realtime/RealtimeProvider.tsx
  51. 166 0
      src/features/realtime/hooks.ts
  52. 558 0
      src/features/realtime/manager.ts
  53. 109 0
      src/features/realtime/refresh-governor.ts
  54. 2 3
      src/features/settings/Lib/armFeatureHints.ts
  55. 9 2
      src/features/team/Lib/technicianRole.ts
  56. 14 29
      src/features/team/hooks/useTechnicianConnected.ts
  57. 23 50
      src/features/time-tracking/Components/TimeClockProvider.tsx
  58. 3 6
      src/features/tire-hotel/Actions/tireSetActions.ts
  59. 2 2
      src/features/tire-hotel/Lib/addTireLine.ts
  60. 2 6
      src/features/tire-hotel/Lib/copySetFilesToJob.ts
  61. 12 2
      src/features/vehicles/Components/service-edit/RichTextEditor.tsx
  62. 3 1
      src/features/vehicles/Components/service-edit/ScheduleTimesSection.tsx
  63. 17 12
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  64. 4 0
      src/features/vehicles/Components/service-page/UnifiedServiceHeader.tsx
  65. 2 3
      src/features/vehicles/Lib/retotalServiceRecord.ts
  66. 116 143
      src/features/workboard/hooks/useWorkBoardWebSocket.ts
  67. 2 0
      src/i18n/request.ts
  68. 33 16
      src/lib/cached-session.ts
  69. 63 2
      src/lib/db.ts
  70. 35 0
      src/lib/realtime/actor.server.ts
  71. 67 0
      src/lib/realtime/authorize.server.ts
  72. 210 0
      src/lib/realtime/bus.server.ts
  73. 159 0
      src/lib/realtime/events.ts
  74. 333 0
      src/lib/realtime/presence.server.ts
  75. 301 0
      src/lib/realtime/prisma-realtime.server.ts
  76. 158 0
      src/lib/realtime/protocol.server.ts
  77. 127 0
      src/lib/realtime/publish.server.ts
  78. 136 0
      src/lib/realtime/rooms.server.ts
  79. 27 0
      src/lib/realtime/shared-state.server.ts
  80. 13 8
      src/lib/with-api-auth.ts
  81. 18 2
      src/lib/with-auth.ts

+ 4 - 0
messages/de/realtime.json

@@ -0,0 +1,4 @@
+{
+  "alsoHere": "{count, plural, one {Eine weitere Person hat das geöffnet} other {# weitere Personen haben das geöffnet}}",
+  "alsoHereName": "{name} hat das ebenfalls geöffnet"
+}

+ 1 - 0
messages/de/service.json

@@ -381,6 +381,7 @@
     "assign": "Tafel zuweisen",
     "assign": "Tafel zuweisen",
     "assigned": "Der Arbeitstafel zugewiesen",
     "assigned": "Der Arbeitstafel zugewiesen",
     "failedAssign": "Zuweisung fehlgeschlagen",
     "failedAssign": "Zuweisung fehlgeschlagen",
+    "assignNotAllowed": "Sie haben keine Berechtigung, Techniker zuzuweisen. Bitten Sie einen Inhaber oder Administrator, Ihrer Rolle Änderungen an der Werkstatttafel zu erlauben.",
     "endBeforeStart": "Endzeit muss nach der Startzeit liegen",
     "endBeforeStart": "Endzeit muss nach der Startzeit liegen",
     "failedUpdate": "Zeitplan konnte nicht aktualisiert werden",
     "failedUpdate": "Zeitplan konnte nicht aktualisiert werden",
     "splitCreated": "Auftrag erstreckt sich über mehrere Tage — Fortsetzung für den nächsten Tag erstellt",
     "splitCreated": "Auftrag erstreckt sich über mehrere Tage — Fortsetzung für den nächsten Tag erstellt",

+ 4 - 0
messages/en/realtime.json

@@ -0,0 +1,4 @@
+{
+  "alsoHere": "{count, plural, one {One other person has this open} other {# other people have this open}}",
+  "alsoHereName": "{name} has this open too"
+}

+ 1 - 0
messages/en/service.json

@@ -381,6 +381,7 @@
     "assign": "Assign to Board",
     "assign": "Assign to Board",
     "assigned": "Assigned to work board",
     "assigned": "Assigned to work board",
     "failedAssign": "Failed to assign",
     "failedAssign": "Failed to assign",
+    "assignNotAllowed": "You don't have permission to assign technicians. Ask an owner or admin to allow work board changes for your role.",
     "endBeforeStart": "End time must be after start time",
     "endBeforeStart": "End time must be after start time",
     "failedUpdate": "Failed to update schedule times",
     "failedUpdate": "Failed to update schedule times",
     "splitCreated": "Job spans multiple days — continuation created for next day",
     "splitCreated": "Job spans multiple days — continuation created for next day",

+ 4 - 0
messages/es/realtime.json

@@ -0,0 +1,4 @@
+{
+  "alsoHere": "{count, plural, one {Otra persona tiene esto abierto} other {Otras # personas tienen esto abierto}}",
+  "alsoHereName": "{name} también tiene esto abierto"
+}

+ 1 - 0
messages/es/service.json

@@ -381,6 +381,7 @@
     "assign": "Asignar al tablero",
     "assign": "Asignar al tablero",
     "assigned": "Asignado al tablero de trabajo",
     "assigned": "Asignado al tablero de trabajo",
     "failedAssign": "Error al asignar",
     "failedAssign": "Error al asignar",
+    "assignNotAllowed": "No tienes permiso para asignar técnicos. Pide a un propietario o administrador que permita a tu rol modificar el tablero de trabajo.",
     "endBeforeStart": "La hora de fin debe ser posterior a la hora de inicio",
     "endBeforeStart": "La hora de fin debe ser posterior a la hora de inicio",
     "failedUpdate": "Error al actualizar el horario",
     "failedUpdate": "Error al actualizar el horario",
     "splitCreated": "El trabajo abarca varios días — continuación creada para el día siguiente",
     "splitCreated": "El trabajo abarca varios días — continuación creada para el día siguiente",

+ 4 - 0
messages/fr/realtime.json

@@ -0,0 +1,4 @@
+{
+  "alsoHere": "{count, plural, one {Une autre personne a ceci d’ouvert} other {# autres personnes ont ceci d’ouvert}}",
+  "alsoHereName": "{name} a également ceci d’ouvert"
+}

+ 1 - 0
messages/fr/service.json

@@ -381,6 +381,7 @@
     "assign": "Assigner au tableau",
     "assign": "Assigner au tableau",
     "assigned": "Assigné au tableau de travail",
     "assigned": "Assigné au tableau de travail",
     "failedAssign": "Échec de l'assignation",
     "failedAssign": "Échec de l'assignation",
+    "assignNotAllowed": "Vous n'avez pas l'autorisation d'affecter des techniciens. Demandez à un propriétaire ou à un administrateur d'autoriser votre rôle à modifier le tableau de travail.",
     "endBeforeStart": "L'heure de fin doit être après l'heure de début",
     "endBeforeStart": "L'heure de fin doit être après l'heure de début",
     "failedUpdate": "Impossible de mettre à jour le planning",
     "failedUpdate": "Impossible de mettre à jour le planning",
     "splitCreated": "Le travail s'étend sur plusieurs jours — suite créée pour le jour suivant",
     "splitCreated": "Le travail s'étend sur plusieurs jours — suite créée pour le jour suivant",

+ 4 - 0
messages/it/realtime.json

@@ -0,0 +1,4 @@
+{
+  "alsoHere": "{count, plural, one {Un’altra persona ha questo aperto} other {Altre # persone hanno questo aperto}}",
+  "alsoHereName": "{name} ha questo aperto anche lui"
+}

+ 1 - 0
messages/it/service.json

@@ -381,6 +381,7 @@
     "assign": "Assegna alla bacheca",
     "assign": "Assegna alla bacheca",
     "assigned": "Assegnato alla bacheca di lavoro",
     "assigned": "Assegnato alla bacheca di lavoro",
     "failedAssign": "Assegnazione fallita",
     "failedAssign": "Assegnazione fallita",
+    "assignNotAllowed": "Non hai l'autorizzazione per assegnare i tecnici. Chiedi a un proprietario o a un amministratore di consentire al tuo ruolo di modificare la bacheca di lavoro.",
     "endBeforeStart": "L'ora di fine deve essere dopo l'ora di inizio",
     "endBeforeStart": "L'ora di fine deve essere dopo l'ora di inizio",
     "failedUpdate": "Impossibile aggiornare la pianificazione",
     "failedUpdate": "Impossibile aggiornare la pianificazione",
     "splitCreated": "Il lavoro si estende su più giorni — continuazione creata per il giorno successivo",
     "splitCreated": "Il lavoro si estende su più giorni — continuazione creata per il giorno successivo",

+ 4 - 0
messages/lt/realtime.json

@@ -0,0 +1,4 @@
+{
+  "alsoHere": "{count, plural, one {Tai atidaręs dar # žmogus} few {Tai atidarę dar # žmonės} many {Tai atidarę dar # žmogaus} other {Tai atidarę dar # žmonių}}",
+  "alsoHereName": "{name} taip pat tai atidaręs"
+}

+ 1 - 0
messages/lt/service.json

@@ -381,6 +381,7 @@
     "assign": "Priskirti lentai",
     "assign": "Priskirti lentai",
     "assigned": "Priskirta darbų lentai",
     "assigned": "Priskirta darbų lentai",
     "failedAssign": "Nepavyko priskirti",
     "failedAssign": "Nepavyko priskirti",
+    "assignNotAllowed": "Neturite leidimo priskirti technikų. Paprašykite savininko arba administratoriaus leisti jūsų rolei keisti darbų lentą.",
     "endBeforeStart": "Pabaigos laikas turi būti po pradžios laiko",
     "endBeforeStart": "Pabaigos laikas turi būti po pradžios laiko",
     "failedUpdate": "Nepavyko atnaujinti grafiko laikų",
     "failedUpdate": "Nepavyko atnaujinti grafiko laikų",
     "splitCreated": "Darbas apima kelias dienas — tęsinys sukurtas kitai dienai",
     "splitCreated": "Darbas apima kelias dienas — tęsinys sukurtas kitai dienai",

+ 4 - 0
messages/nb/realtime.json

@@ -0,0 +1,4 @@
+{
+  "alsoHere": "{count, plural, one {Én annen har denne åpen} other {# andre har denne åpen}}",
+  "alsoHereName": "{name} har denne åpen også"
+}

+ 1 - 0
messages/nb/service.json

@@ -381,6 +381,7 @@
     "assign": "Tilordne til tavle",
     "assign": "Tilordne til tavle",
     "assigned": "Tilordnet arbeidstavle",
     "assigned": "Tilordnet arbeidstavle",
     "failedAssign": "Kunne ikke tilordne",
     "failedAssign": "Kunne ikke tilordne",
+    "assignNotAllowed": "Du har ikke tillatelse til å tilordne teknikere. Be en eier eller administrator om å gi rollen din tilgang til å endre arbeidstavlen.",
     "endBeforeStart": "Sluttid må være etter starttid",
     "endBeforeStart": "Sluttid må være etter starttid",
     "failedUpdate": "Kunne ikke oppdatere tidsplan",
     "failedUpdate": "Kunne ikke oppdatere tidsplan",
     "splitCreated": "Jobben går over flere dager — fortsettelse opprettet for neste dag",
     "splitCreated": "Jobben går over flere dager — fortsettelse opprettet for neste dag",

+ 4 - 0
messages/nl/realtime.json

@@ -0,0 +1,4 @@
+{
+  "alsoHere": "{count, plural, one {Nog iemand heeft dit open} other {Nog # mensen hebben dit open}}",
+  "alsoHereName": "{name} heeft dit ook open"
+}

+ 1 - 0
messages/nl/service.json

@@ -381,6 +381,7 @@
     "assign": "Toewijzen aan bord",
     "assign": "Toewijzen aan bord",
     "assigned": "Toegewezen aan werkbord",
     "assigned": "Toegewezen aan werkbord",
     "failedAssign": "Toewijzing mislukt",
     "failedAssign": "Toewijzing mislukt",
+    "assignNotAllowed": "Je hebt geen toestemming om monteurs toe te wijzen. Vraag een eigenaar of beheerder om jouw rol wijzigingen op het werkbord toe te staan.",
     "endBeforeStart": "Eindtijd moet na de starttijd zijn",
     "endBeforeStart": "Eindtijd moet na de starttijd zijn",
     "failedUpdate": "Planning bijwerken mislukt",
     "failedUpdate": "Planning bijwerken mislukt",
     "splitCreated": "Taak beslaat meerdere dagen — vervolg aangemaakt voor de volgende dag",
     "splitCreated": "Taak beslaat meerdere dagen — vervolg aangemaakt voor de volgende dag",

+ 4 - 0
messages/pl/realtime.json

@@ -0,0 +1,4 @@
+{
+  "alsoHere": "{count, plural, one {Jeszcze jedna osoba ma to otwarte} few {Jeszcze # osoby mają to otwarte} many {Jeszcze # osób ma to otwarte} other {Jeszcze # osób ma to otwarte}}",
+  "alsoHereName": "{name} też ma to otwarte"
+}

+ 1 - 0
messages/pl/service.json

@@ -381,6 +381,7 @@
     "assign": "Przypisz do tablicy",
     "assign": "Przypisz do tablicy",
     "assigned": "Przypisano do tablicy roboczej",
     "assigned": "Przypisano do tablicy roboczej",
     "failedAssign": "Przypisanie nie powiodło się",
     "failedAssign": "Przypisanie nie powiodło się",
+    "assignNotAllowed": "Nie masz uprawnień do przypisywania techników. Poproś właściciela lub administratora o zezwolenie Twojej roli na zmiany na tablicy pracy.",
     "endBeforeStart": "Czas zakończenia musi być po czasie rozpoczęcia",
     "endBeforeStart": "Czas zakończenia musi być po czasie rozpoczęcia",
     "failedUpdate": "Nie udało się zaktualizować harmonogramu",
     "failedUpdate": "Nie udało się zaktualizować harmonogramu",
     "splitCreated": "Zadanie obejmuje wiele dni — kontynuacja utworzona na następny dzień",
     "splitCreated": "Zadanie obejmuje wiele dni — kontynuacja utworzona na następny dzień",

+ 4 - 0
messages/pt-BR/realtime.json

@@ -0,0 +1,4 @@
+{
+  "alsoHere": "{count, plural, one {Outra pessoa está com isto aberto} other {Outras # pessoas estão com isto aberto}}",
+  "alsoHereName": "{name} também está com isto aberto"
+}

+ 1 - 0
messages/pt-BR/service.json

@@ -381,6 +381,7 @@
     "assign": "Atribuir ao quadro",
     "assign": "Atribuir ao quadro",
     "assigned": "Atribuído ao quadro de trabalho",
     "assigned": "Atribuído ao quadro de trabalho",
     "failedAssign": "Falha ao atribuir",
     "failedAssign": "Falha ao atribuir",
+    "assignNotAllowed": "Você não tem permissão para atribuir técnicos. Peça a um proprietário ou administrador que permita à sua função alterar o quadro de trabalho.",
     "endBeforeStart": "A hora de término deve ser após a hora de início",
     "endBeforeStart": "A hora de término deve ser após a hora de início",
     "failedUpdate": "Falha ao atualizar o agendamento",
     "failedUpdate": "Falha ao atualizar o agendamento",
     "splitCreated": "O trabalho abrange vários dias. Continuação criada para o dia seguinte",
     "splitCreated": "O trabalho abrange vários dias. Continuação criada para o dia seguinte",

+ 4 - 0
messages/pt-PT/realtime.json

@@ -0,0 +1,4 @@
+{
+  "alsoHere": "{count, plural, one {Outra pessoa tem isto aberto} other {Outras # pessoas têm isto aberto}}",
+  "alsoHereName": "{name} também tem isto aberto"
+}

+ 1 - 0
messages/pt-PT/service.json

@@ -381,6 +381,7 @@
     "assign": "Atribuir ao quadro",
     "assign": "Atribuir ao quadro",
     "assigned": "Atribuído ao quadro de trabalho",
     "assigned": "Atribuído ao quadro de trabalho",
     "failedAssign": "Não foi possível atribuir",
     "failedAssign": "Não foi possível atribuir",
+    "assignNotAllowed": "Não tem permissão para atribuir técnicos. Peça a um proprietário ou administrador que permita à sua função alterar o quadro de trabalho.",
     "endBeforeStart": "A hora de fim tem de ser posterior à hora de início",
     "endBeforeStart": "A hora de fim tem de ser posterior à hora de início",
     "failedUpdate": "Não foi possível atualizar a marcação",
     "failedUpdate": "Não foi possível atualizar a marcação",
     "splitCreated": "O trabalho ocupa vários dias, por isso foi criada uma continuação para o dia seguinte",
     "splitCreated": "O trabalho ocupa vários dias, por isso foi criada uma continuação para o dia seguinte",

+ 4 - 0
messages/ru/realtime.json

@@ -0,0 +1,4 @@
+{
+  "alsoHere": "{count, plural, one {Ещё # человек открыл это} few {Ещё # человека открыли это} many {Ещё # человек открыли это} other {Ещё # человек открыли это}}",
+  "alsoHereName": "{name} тоже открыл это"
+}

+ 1 - 0
messages/ru/service.json

@@ -381,6 +381,7 @@
     "assign": "Назначить на доску",
     "assign": "Назначить на доску",
     "assigned": "Назначено на доску работ",
     "assigned": "Назначено на доску работ",
     "failedAssign": "Не удалось назначить",
     "failedAssign": "Не удалось назначить",
+    "assignNotAllowed": "У вас нет разрешения назначать техников. Попросите владельца или администратора разрешить вашей роли изменять рабочую доску.",
     "endBeforeStart": "Время окончания должно быть позже времени начала",
     "endBeforeStart": "Время окончания должно быть позже времени начала",
     "failedUpdate": "Не удалось обновить время в расписании",
     "failedUpdate": "Не удалось обновить время в расписании",
     "splitCreated": "Работа занимает несколько дней — создано продолжение на следующий день",
     "splitCreated": "Работа занимает несколько дней — создано продолжение на следующий день",

+ 4 - 0
messages/tr/realtime.json

@@ -0,0 +1,4 @@
+{
+  "alsoHere": "{count, plural, one {Bunu bir kişi daha açık tutuyor} other {Bunu # kişi daha açık tutuyor}}",
+  "alsoHereName": "{name} da bunu açık tutuyor"
+}

+ 1 - 0
messages/tr/service.json

@@ -381,6 +381,7 @@
     "assign": "Panoya ata",
     "assign": "Panoya ata",
     "assigned": "Çalışma panosuna atandı",
     "assigned": "Çalışma panosuna atandı",
     "failedAssign": "Atama başarısız",
     "failedAssign": "Atama başarısız",
+    "assignNotAllowed": "Teknisyen atama izniniz yok. Bir sahipten veya yöneticiden rolünüze iş panosunda değişiklik yapma izni vermesini isteyin.",
     "endBeforeStart": "Bitiş saati başlangıç saatinden sonra olmalıdır",
     "endBeforeStart": "Bitiş saati başlangıç saatinden sonra olmalıdır",
     "failedUpdate": "Zamanlama güncellenemedi",
     "failedUpdate": "Zamanlama güncellenemedi",
     "splitCreated": "İş birden fazla güne yayılıyor — sonraki gün için devam oluşturuldu",
     "splitCreated": "İş birden fazla güne yayılıyor — sonraki gün için devam oluşturuldu",

+ 2 - 0
package-lock.json

@@ -52,6 +52,7 @@
         "openai": "^6.27.0",
         "openai": "^6.27.0",
         "papaparse": "^5.7.0",
         "papaparse": "^5.7.0",
         "pdf-lib": "^1.17.1",
         "pdf-lib": "^1.17.1",
+        "pg": "^8.20.0",
         "posthog-js": "^1.417.1",
         "posthog-js": "^1.417.1",
         "postmark": "^4.0.7",
         "postmark": "^4.0.7",
         "prisma": "^7.9.1",
         "prisma": "^7.9.1",
@@ -89,6 +90,7 @@
         "@types/node": "^20",
         "@types/node": "^20",
         "@types/nodemailer": "^7.0.9",
         "@types/nodemailer": "^7.0.9",
         "@types/papaparse": "^5.5.2",
         "@types/papaparse": "^5.5.2",
+        "@types/pg": "^8.20.0",
         "@types/react": "^19",
         "@types/react": "^19",
         "@types/react-dom": "^19",
         "@types/react-dom": "^19",
         "@types/smtp-server": "^3.5.13",
         "@types/smtp-server": "^3.5.13",

+ 2 - 0
package.json

@@ -63,6 +63,7 @@
     "openai": "^6.27.0",
     "openai": "^6.27.0",
     "papaparse": "^5.7.0",
     "papaparse": "^5.7.0",
     "pdf-lib": "^1.17.1",
     "pdf-lib": "^1.17.1",
+    "pg": "^8.20.0",
     "posthog-js": "^1.417.1",
     "posthog-js": "^1.417.1",
     "postmark": "^4.0.7",
     "postmark": "^4.0.7",
     "prisma": "^7.9.1",
     "prisma": "^7.9.1",
@@ -100,6 +101,7 @@
     "@types/node": "^20",
     "@types/node": "^20",
     "@types/nodemailer": "^7.0.9",
     "@types/nodemailer": "^7.0.9",
     "@types/papaparse": "^5.5.2",
     "@types/papaparse": "^5.5.2",
+    "@types/pg": "^8.20.0",
     "@types/react": "^19",
     "@types/react": "^19",
     "@types/react-dom": "^19",
     "@types/react-dom": "^19",
     "@types/smtp-server": "^3.5.13",
     "@types/smtp-server": "^3.5.13",

+ 372 - 0
src/__tests__/features/realtime/manager.test.ts

@@ -0,0 +1,372 @@
+/**
+ * The collaboration manager, driven with a pretend socket.
+ *
+ * Every test here is a fault this feature actually had, or a promise it makes
+ * about memory. The first version passed fifty-two tests and did nothing in a
+ * browser, because the things that broke it only exist between a socket and
+ * the code holding it: a frame sent before the server was listening, an old
+ * socket closing late and taking its successor's place with it. So the socket
+ * here is a stand-in that behaves like the real one where it matters: it
+ * closes asynchronously, and it remembers exactly what was sent and when.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+  CollaborationManager,
+  READY_TIMEOUT_MS,
+  RETRY_MS,
+  type SocketLike,
+} from '@/features/realtime/manager'
+import { PRESENCE_HEARTBEAT_MS, type PresenceUser } from '@/lib/realtime/events'
+
+class PretendSocket implements SocketLike {
+  readyState = 1
+  sent: { t: string; [key: string]: unknown }[] = []
+  onopen: SocketLike['onopen'] = null
+  onmessage: SocketLike['onmessage'] = null
+  onclose: SocketLike['onclose'] = null
+  onerror: SocketLike['onerror'] = null
+  closedByPage = false
+
+  send(data: string) {
+    this.sent.push(JSON.parse(data))
+  }
+  close() {
+    this.closedByPage = true
+    this.readyState = 3
+  }
+  /** What the server would say. */
+  say(message: unknown) {
+    this.onmessage?.({ data: JSON.stringify(message) })
+  }
+  ready() {
+    this.say({ t: 'ready', you: { userId: 'u-1', name: 'Christian', color: '#2563eb' } })
+  }
+  /** The link going away, as the browser reports it. */
+  drop(code = 1006) {
+    this.readyState = 3
+    this.onclose?.({ code })
+  }
+  types() {
+    return this.sent.map((message) => message.t)
+  }
+}
+
+const ROOM = 'rec:serviceRecord:job-1'
+const MARCO: PresenceUser = {
+  userId: 'u-2',
+  name: 'Marco',
+  color: '#db2777',
+  devices: 1,
+}
+
+let sockets: PretendSocket[]
+let manager: CollaborationManager
+
+const latest = () => sockets[sockets.length - 1]
+/** Starts it and lets the tick pass that it waits before connecting. */
+const begin = () => {
+  manager.start()
+  vi.advanceTimersByTime(0)
+}
+
+beforeEach(() => {
+  vi.useFakeTimers()
+  sockets = []
+  manager = new CollaborationManager({
+    url: () => 'ws://test/api/protected/ws',
+    random: () => 0.5,
+    createSocket: () => {
+      const socket = new PretendSocket()
+      sockets.push(socket)
+      return socket
+    },
+  })
+})
+
+afterEach(() => {
+  manager.stop()
+  vi.useRealTimers()
+})
+
+describe('saying what the page wants', () => {
+  it('sends nothing until the server is listening, then all of it in order', () => {
+    // The server reads the session before it listens. Anything sent in that
+    // window used to be dropped, which is why nobody's chip ever appeared.
+    manager.watch(ROOM, () => undefined)
+    manager.join(ROOM)
+    begin()
+
+    expect(latest().sent).toEqual([])
+
+    latest().ready()
+
+    expect(latest().sent).toEqual([
+      { t: 'sub', rooms: [ROOM] },
+      { t: 'enter', room: ROOM },
+    ])
+  })
+
+  it('asks at once for a room wanted after the link is up', () => {
+    begin()
+    latest().ready()
+
+    manager.join(ROOM)
+
+    expect(latest().sent).toEqual([
+      { t: 'sub', rooms: [ROOM] },
+      { t: 'enter', room: ROOM },
+    ])
+  })
+
+  it('counts a room wanted twice as one room', () => {
+    begin()
+    latest().ready()
+
+    const leaveChips = manager.join(ROOM)
+    const leaveFocus = manager.join(ROOM)
+    const stopWatching = manager.watch(ROOM, () => undefined)
+    expect(latest().types()).toEqual(['sub', 'enter'])
+
+    leaveChips()
+    stopWatching()
+    expect(latest().types()).toEqual(['sub', 'enter'])
+
+    leaveFocus()
+    expect(latest().types()).toEqual(['sub', 'enter', 'leave', 'unsub'])
+    expect(manager.stats().rooms).toBe(0)
+  })
+})
+
+describe('one socket, and only its own events', () => {
+  it('opens one socket when React mounts it twice', () => {
+    // Development runs every effect twice: start, stop, start, all in one
+    // tick. The first start used to open a socket only for the cleanup to
+    // close it mid-handshake, which Chrome reports as "WebSocket is closed
+    // before the connection is established".
+    manager.join(ROOM)
+    manager.start()
+    manager.stop()
+    manager.start()
+    vi.advanceTimersByTime(0)
+
+    expect(sockets).toHaveLength(1)
+    latest().ready()
+    expect(latest().types()).toEqual(['sub', 'enter'])
+  })
+
+  it('is not taken down by an earlier socket that closes late', () => {
+    // A stop and a start a moment apart, as a fast remount is. The first
+    // socket reports its close afterwards; it used to clear the shared
+    // reference on its way out, leaving the second open and unreachable.
+    manager.join(ROOM)
+    begin()
+    const first = latest()
+    manager.stop()
+    begin()
+    const second = latest()
+    expect(second).not.toBe(first)
+
+    first.onclose?.({ code: 1000 })
+    second.ready()
+
+    expect(first.closedByPage).toBe(true)
+    expect(second.types()).toEqual(['sub', 'enter'])
+    expect(manager.getState().status).toBe('ready')
+    expect(sockets).toHaveLength(2)
+  })
+})
+
+describe('a link that goes away', () => {
+  it('comes back, says everything again, and tells pages to read again', () => {
+    const resync = vi.fn()
+    manager.onResync(resync)
+    manager.join(ROOM)
+    begin()
+    latest().ready()
+    // A page rendered a moment ago has nothing to catch up on.
+    expect(resync).not.toHaveBeenCalled()
+
+    latest().drop()
+    expect(manager.getState().status).toBe('waiting')
+    vi.advanceTimersByTime(RETRY_MS[0] * 1.2)
+    expect(sockets).toHaveLength(2)
+    latest().ready()
+
+    expect(latest().sent).toEqual([
+      { t: 'sub', rooms: [ROOM] },
+      { t: 'enter', room: ROOM },
+    ])
+    expect(resync).toHaveBeenCalledTimes(1)
+  })
+
+  it('empties the chips while it is down, rather than showing who was there', () => {
+    const changed = vi.fn()
+    manager.join(ROOM)
+    manager.watchPresence(ROOM, changed)
+    begin()
+    latest().ready()
+    latest().say({ t: 'presence', room: ROOM, users: [MARCO] })
+    expect(manager.presenceOf(ROOM)).toEqual([MARCO])
+
+    latest().drop()
+
+    expect(manager.presenceOf(ROOM)).toEqual([])
+    expect(changed).toHaveBeenCalledTimes(2)
+  })
+
+  it('does not hammer a server that stays down', () => {
+    begin()
+    // Ten minutes of a server that refuses every connection.
+    const until = Date.now() + 10 * 60_000
+    while (Date.now() < until) {
+      latest().drop()
+      vi.advanceTimersByTime(1_000)
+      while (sockets.length > 0 && latest().readyState !== 1 && Date.now() < until) {
+        vi.advanceTimersByTime(1_000)
+      }
+    }
+    // 1, 2, 5, 10, 30, 60, 120 seconds, then one every five minutes.
+    expect(sockets.length).toBeLessThanOrEqual(10)
+  })
+
+  it('stops for good when the server says the session is gone', () => {
+    begin()
+    latest().drop(4001)
+
+    vi.advanceTimersByTime(60 * 60_000)
+
+    expect(sockets).toHaveLength(1)
+    expect(manager.getState().status).toBe('refused')
+    expect(manager.stats().timers).toBe(0)
+  })
+
+  it('gives up on a socket the server never recognises', () => {
+    begin()
+    vi.advanceTimersByTime(READY_TIMEOUT_MS + 1)
+
+    expect(sockets[0].closedByPage).toBe(true)
+    expect(manager.getState().status).toBe('waiting')
+  })
+
+  it('finds a dead link by a ping nobody answered', () => {
+    begin()
+    latest().ready()
+
+    vi.advanceTimersByTime(PRESENCE_HEARTBEAT_MS)
+    expect(latest().types()).toEqual(['ping'])
+    latest().say({ t: 'pong' })
+    vi.advanceTimersByTime(PRESENCE_HEARTBEAT_MS)
+    expect(manager.getState().status).toBe('ready')
+
+    // This one is never answered.
+    vi.advanceTimersByTime(PRESENCE_HEARTBEAT_MS)
+
+    expect(manager.getState().status).toBe('waiting')
+    expect(sockets[0].closedByPage).toBe(true)
+  })
+})
+
+describe('what it hears', () => {
+  it('hands a change to the room it belongs to, and to nobody else', () => {
+    const mine = vi.fn()
+    const other = vi.fn()
+    manager.watch(ROOM, mine)
+    manager.watch('rec:serviceRecord:job-2', other)
+    begin()
+    latest().ready()
+
+    latest().say({
+      t: 'record',
+      change: { kind: 'serviceRecord', id: 'job-1', organizationId: 'org-1', action: 'updated' },
+    })
+
+    expect(mine).toHaveBeenCalledTimes(1)
+    expect(other).not.toHaveBeenCalled()
+  })
+
+  it('hands back the same list of people until it changes', () => {
+    // useSyncExternalStore compares by identity: a fresh array each read is
+    // "changed again", and React renders until it gives up.
+    manager.join(ROOM)
+    begin()
+    latest().ready()
+    expect(manager.presenceOf(ROOM)).toBe(manager.presenceOf(ROOM))
+    expect(manager.presenceOf('rec:serviceRecord:never')).toBe(manager.presenceOf(ROOM))
+
+    latest().say({ t: 'presence', room: ROOM, users: [MARCO] })
+    expect(manager.presenceOf(ROOM)).toBe(manager.presenceOf(ROOM))
+  })
+
+  it('survives a frame it cannot read', () => {
+    begin()
+    latest().ready()
+    latest().onmessage?.({ data: 'not json' })
+    latest().say({ t: 'something new' })
+    expect(manager.getState().status).toBe('ready')
+  })
+})
+
+describe('leaving nothing behind', () => {
+  it('holds nothing after a thousand records are opened and closed', () => {
+    begin()
+    latest().ready()
+
+    for (let i = 0; i < 1_000; i++) {
+      const room = `rec:serviceRecord:job-${i}`
+      const stopWatching = manager.watch(room, () => undefined)
+      const leave = manager.join(room)
+      const stopPresence = manager.watchPresence(room, () => undefined)
+      latest().say({ t: 'presence', room, users: [MARCO] })
+      stopPresence()
+      leave()
+      stopWatching()
+    }
+
+    expect(manager.stats()).toMatchObject({
+      rooms: 0,
+      handlers: 0,
+      standing: 0,
+      presenceWatchers: 0,
+    })
+  })
+
+  it('does not keep a frame about a room the page has left', () => {
+    begin()
+    latest().ready()
+    manager.join(ROOM)()
+
+    latest().say({ t: 'presence', room: ROOM, users: [MARCO] })
+
+    expect(manager.stats().rooms).toBe(0)
+    expect(manager.presenceOf(ROOM)).toEqual([])
+  })
+
+  it('clears every timer and the socket when it stops', () => {
+    manager.join(ROOM)
+    begin()
+    latest().ready()
+    expect(manager.stats().timers).toBeGreaterThan(0)
+
+    manager.stop()
+
+    expect(manager.stats()).toMatchObject({ timers: 0, socket: false })
+    expect(latest().closedByPage).toBe(true)
+    // And nothing fires afterwards.
+    vi.advanceTimersByTime(60 * 60_000)
+    expect(sockets).toHaveLength(1)
+  })
+
+  it('unsubscribing twice is the same as once', () => {
+    begin()
+    latest().ready()
+    const leaveA = manager.join(ROOM)
+    const leaveB = manager.join(ROOM)
+
+    leaveA()
+    leaveA()
+
+    expect(manager.stats().standing).toBe(1)
+    leaveB()
+    expect(manager.stats().rooms).toBe(0)
+  })
+})

+ 202 - 0
src/__tests__/features/realtime/presence-rendering.test.tsx

@@ -0,0 +1,202 @@
+/**
+ * The presence layer, rendered, the way the app renders it.
+ *
+ * Two things only exist once React is involved, and both took this feature
+ * down in a browser while every server test passed:
+ *
+ * - `useSyncExternalStore` compares snapshots by identity, so a store that
+ *   hands back a new array each read renders until React gives up.
+ * - Development mounts every effect twice. The first socket is closed by the
+ *   cleanup and says so afterwards; it used to clear the shared reference on
+ *   its way out, leaving the real socket open and unreachable for the rest of
+ *   the page's life. So everything here runs under StrictMode, with a
+ *   stand-in socket that reports its close late, as a real one does.
+ *
+ * The stand-in also refuses to be spoken to before it has said `ready`, which
+ * is the server's behaviour: it reads the session first, and a frame sent
+ * before that went nowhere.
+ */
+import { StrictMode } from 'react'
+import { act, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { NextIntlClientProvider } from 'next-intl'
+import { RealtimeProvider } from '@/features/realtime/RealtimeProvider'
+import { PresenceChips } from '@/features/realtime/Components/PresenceChips'
+import { useRecordPresence } from '@/features/realtime/hooks'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import messages from '../../../../messages/en/realtime.json'
+
+class StandInSocket {
+  static instances: StandInSocket[] = []
+  static OPEN = 1
+  static CLOSED = 3
+  readyState = StandInSocket.OPEN
+  sent: { t: string; [key: string]: unknown }[] = []
+  tooEarly: unknown[] = []
+  listening = false
+  onopen: (() => void) | null = null
+  onclose: ((event: { code: number }) => void) | null = null
+  onerror: (() => void) | null = null
+  onmessage: ((event: { data: string }) => void) | null = null
+
+  constructor(public url: string) {
+    StandInSocket.instances.push(this)
+  }
+  send(data: string) {
+    const message = JSON.parse(data)
+    if (this.listening) this.sent.push(message)
+    else this.tooEarly.push(message)
+  }
+  close() {
+    this.readyState = StandInSocket.CLOSED
+    // Reported afterwards, like the real thing.
+    const report = this.onclose
+    setTimeout(() => report?.({ code: 1000 }), 0)
+  }
+  deliver(message: unknown) {
+    this.onmessage?.({ data: JSON.stringify(message) })
+  }
+  ready() {
+    this.listening = true
+    this.deliver({ t: 'ready', you: { userId: 'me', name: 'Me', color: '#000' } })
+  }
+}
+
+const ROOM = 'rec:serviceRecord:job-1'
+const MARCO = { userId: 'u-2', name: 'Marco Rossi', color: '#db2777', devices: 1 }
+const ME = { userId: 'me', name: 'Me', color: '#000', devices: 1 }
+let renders = 0
+
+function Probe() {
+  renders++
+  const { others } = useRecordPresence('serviceRecord', 'job-1')
+  return <span data-testid="others">{others.map((user) => user.name).join(', ')}</span>
+}
+
+/** A work order page in miniature: something reading presence, and the chips. */
+function Page() {
+  return (
+    <main>
+      <Probe />
+      <PresenceChips kind="serviceRecord" id="job-1" />
+    </main>
+  )
+}
+
+/**
+ * The provider lives in the app shell and outlives any page, so the page is
+ * mounted and unmounted underneath it, as in the app.
+ */
+function mount() {
+  const tree = (open: boolean) => (
+    <StrictMode>
+      <NextIntlClientProvider locale="en" messages={{ realtime: messages }} timeZone="UTC">
+        <TooltipProvider>
+          <RealtimeProvider>{open ? <Page /> : null}</RealtimeProvider>
+        </TooltipProvider>
+      </NextIntlClientProvider>
+    </StrictMode>
+  )
+  const view = render(tree(true))
+  return { ...view, closePage: () => view.rerender(tree(false)) }
+}
+
+/** The socket the page ends up on, after React has mounted everything twice. */
+const socket = () => StandInSocket.instances[StandInSocket.instances.length - 1]
+const live = () => StandInSocket.instances.filter((s) => s.readyState === StandInSocket.OPEN)
+
+beforeEach(() => {
+  vi.useFakeTimers()
+  renders = 0
+  StandInSocket.instances = []
+  vi.stubGlobal('WebSocket', StandInSocket)
+})
+
+afterEach(() => {
+  vi.useRealTimers()
+  vi.unstubAllGlobals()
+  vi.restoreAllMocks()
+})
+
+/** Mounts, lets the first socket's late close land, and has the server answer. */
+function open() {
+  const view = mount()
+  act(() => {
+    vi.advanceTimersByTime(5)
+  })
+  act(() => socket().ready())
+  return view
+}
+
+describe('the socket under React', () => {
+  it('is one socket, still reachable after being mounted twice', () => {
+    open()
+
+    // One was ever opened: the first mount's start is cancelled by its cleanup.
+    expect(StandInSocket.instances).toHaveLength(1)
+    expect(live()).toHaveLength(1)
+    expect(socket().sent).toEqual([
+      { t: 'sub', rooms: [ROOM] },
+      { t: 'enter', room: ROOM },
+    ])
+  })
+
+  it('never speaks before the server is listening', () => {
+    open()
+    expect(StandInSocket.instances.flatMap((s) => s.tooEarly)).toEqual([])
+  })
+
+  it('stands in the room once, however many components ask', () => {
+    // The probe and the chips both want the same room.
+    open()
+    expect(socket().sent.filter((message) => message.t === 'enter')).toHaveLength(1)
+  })
+
+  it('leaves the room when the page goes, and keeps the socket for the next page', () => {
+    const view = open()
+    socket().sent.length = 0
+
+    act(() => view.closePage())
+
+    expect(socket().sent).toEqual([
+      { t: 'leave', room: ROOM },
+      { t: 'unsub', rooms: [ROOM] },
+    ])
+    expect(live()).toHaveLength(1)
+  })
+
+  it('closes the socket when the whole app goes', () => {
+    const view = open()
+    act(() => view.unmount())
+    expect(live()).toHaveLength(0)
+  })
+})
+
+describe('who else is here', () => {
+  it('settles instead of rendering forever', () => {
+    open()
+    // A handful of renders is mounting twice; a loop is hundreds, and React throws.
+    expect(renders).toBeLessThan(12)
+    expect(screen.getByTestId('others').textContent).toBe('')
+  })
+
+  it('shows a chip for the other person, and none for you', () => {
+    open()
+    const before = renders
+
+    act(() => socket().deliver({ t: 'presence', room: ROOM, users: [MARCO, ME] }))
+
+    expect(screen.getByTestId('others').textContent).toBe('Marco Rossi')
+    const chips = screen.getAllByTestId('presence-chip')
+    expect(chips).toHaveLength(1)
+    expect(chips[0].textContent).toBe('MR')
+    expect(renders - before).toBeLessThan(6)
+  })
+
+  it('takes the chip away when they leave', () => {
+    open()
+    act(() => socket().deliver({ t: 'presence', room: ROOM, users: [MARCO, ME] }))
+    act(() => socket().deliver({ t: 'presence', room: ROOM, users: [ME] }))
+    expect(screen.queryByTestId('presence-chip')).toBeNull()
+  })
+})

+ 146 - 0
src/__tests__/features/realtime/refresh-governor.test.ts

@@ -0,0 +1,146 @@
+/**
+ * When a page may read its record again.
+ *
+ * The complaint that started this file was a page that sent a GET to the
+ * server every second, for ever. A live event is answered with a request, so
+ * anything that turns a request back into an event is a page that polls, and
+ * it looks exactly like the feature working. These tests are the ceiling on
+ * what that can ever cost.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createRefreshGovernor, MAX_GAP_MS } from '@/features/realtime/refresh-governor'
+
+let hidden = false
+let refresh: ReturnType<typeof vi.fn<() => void>>
+
+const make = () => createRefreshGovernor({ refresh, isHidden: () => hidden })
+
+beforeEach(() => {
+  vi.useFakeTimers()
+  hidden = false
+  refresh = vi.fn<() => void>()
+})
+afterEach(() => vi.useRealTimers())
+
+describe('an ordinary change', () => {
+  it('is read at once', () => {
+    const governor = make()
+    governor.request()
+    vi.advanceTimersByTime(0)
+    expect(refresh).toHaveBeenCalledTimes(1)
+  })
+
+  it('is read once when a save announces itself three times', () => {
+    const governor = make()
+    governor.request()
+    governor.request()
+    governor.request()
+    vi.advanceTimersByTime(5_000)
+    expect(refresh).toHaveBeenCalledTimes(1)
+  })
+
+  it('reads again for a change that arrives after the read', () => {
+    const governor = make()
+    governor.request()
+    vi.advanceTimersByTime(0)
+    governor.request()
+    vi.advanceTimersByTime(999)
+    expect(refresh).toHaveBeenCalledTimes(1)
+    vi.advanceTimersByTime(1)
+    expect(refresh).toHaveBeenCalledTimes(2)
+  })
+})
+
+describe('a loop, from any cause', () => {
+  it('cannot cost a request a second', () => {
+    const governor = make()
+    // Every read causes another event, immediately, for ten minutes.
+    refresh.mockImplementation(() => governor.request())
+    governor.request()
+
+    vi.advanceTimersByTime(10 * 60_000)
+
+    // One a second would be 600. The gap doubles to thirty seconds and the
+    // window only ever holds a few reads, so it settles far below that.
+    expect(refresh.mock.calls.length).toBeLessThan(60)
+  })
+
+  it('says so, so development can see it by name', () => {
+    const onPressure = vi.fn()
+    const governor = createRefreshGovernor({ refresh, isHidden: () => false, onPressure })
+    refresh.mockImplementation(() => governor.request())
+    governor.request()
+    vi.advanceTimersByTime(60_000)
+    expect(onPressure).toHaveBeenCalled()
+    expect(Math.max(...onPressure.mock.calls.map(([gap]) => gap))).toBeLessThanOrEqual(MAX_GAP_MS)
+  })
+
+  it('goes back to reading at once when things are quiet again', () => {
+    const governor = make()
+    let looping = true
+    refresh.mockImplementation(() => {
+      if (looping) governor.request()
+    })
+    governor.request()
+    vi.advanceTimersByTime(2 * 60_000)
+    looping = false
+    vi.advanceTimersByTime(2 * 60_000)
+    refresh.mockClear()
+
+    governor.request()
+    vi.advanceTimersByTime(0)
+
+    expect(refresh).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('a tab nobody is looking at', () => {
+  it('asks the server for nothing, and reads once when it is looked at', () => {
+    const governor = make()
+    hidden = true
+    for (let i = 0; i < 50; i++) governor.request()
+    vi.advanceTimersByTime(60_000)
+    expect(refresh).not.toHaveBeenCalled()
+
+    hidden = false
+    governor.visible()
+    vi.advanceTimersByTime(0)
+
+    expect(refresh).toHaveBeenCalledTimes(1)
+  })
+
+  it('reads nothing on being looked at when nothing changed', () => {
+    const governor = make()
+    governor.visible()
+    vi.advanceTimersByTime(5_000)
+    expect(refresh).not.toHaveBeenCalled()
+  })
+})
+
+describe('a page that has gone', () => {
+  it('never reads again', () => {
+    const governor = make()
+    governor.request()
+    governor.dispose()
+    vi.advanceTimersByTime(60_000)
+    governor.request()
+    vi.advanceTimersByTime(60_000)
+    expect(refresh).not.toHaveBeenCalled()
+    expect(governor.pending()).toBe(false)
+  })
+})
+
+describe('a record that is honestly busy', () => {
+  it('is never slowed: a colleague saving every twenty seconds is read at once each time', () => {
+    const governor = make()
+    const delays: number[] = []
+    for (let i = 0; i < 30; i++) {
+      const asked = Date.now()
+      refresh.mockImplementationOnce(() => delays.push(Date.now() - asked))
+      governor.request()
+      vi.advanceTimersByTime(20_000)
+    }
+    expect(delays).toHaveLength(30)
+    expect(Math.max(...delays)).toBe(0)
+  })
+})

+ 64 - 0
src/__tests__/features/vehicles/rich-text-editor-quiet.test.tsx

@@ -0,0 +1,64 @@
+/**
+ * Opening a work order is not editing it.
+ *
+ * The notes editor reported an update when it mounted, because tiptap emits
+ * one from `setEditable` and `setContent` unless told not to. The work order
+ * treats an update as an edit and saves five seconds later, so every page
+ * that was merely opened wrote the job back, lines and all. Alone that was a
+ * wasted save. With live updates and two people on one job it was a loop:
+ * each save woke the other page, which mounted, which saved.
+ */
+import { act, render, waitFor } from '@testing-library/react'
+import { NextIntlClientProvider } from 'next-intl'
+import { describe, expect, it, vi } from 'vitest'
+import { RichTextEditor } from '@/features/vehicles/Components/service-edit/RichTextEditor'
+import service from '../../../../messages/en/service.json'
+
+function mount(props: { content: string; editable?: boolean; onChange: (html: string) => void }) {
+  const tree = (next: typeof props) => (
+    <NextIntlClientProvider locale="en" messages={{ service }} timeZone="UTC">
+      <RichTextEditor {...next} />
+    </NextIntlClientProvider>
+  )
+  const view = render(tree(props))
+  return { ...view, update: (next: typeof props) => view.rerender(tree(next)) }
+}
+
+const editorIn = (container: HTMLElement) =>
+  waitFor(() => {
+    const element = container.querySelector('.tiptap-content')
+    if (!element) throw new Error('editor not mounted yet')
+    return element as HTMLElement
+  })
+
+describe('the notes editor', () => {
+  for (const content of ['', 'plain text from an old record', '<p>Brake pads worn</p>']) {
+    it(`reports nothing when it opens with ${JSON.stringify(content)}`, async () => {
+      const onChange = vi.fn()
+      const { container } = mount({ content, onChange })
+      await editorIn(container)
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 20))
+      })
+      expect(onChange).not.toHaveBeenCalled()
+    })
+  }
+
+  it('reports nothing when the page locks or unlocks it', async () => {
+    const onChange = vi.fn()
+    const view = mount({ content: '<p>Notes</p>', editable: true, onChange })
+    await editorIn(view.container)
+    act(() => view.update({ content: '<p>Notes</p>', editable: false, onChange }))
+    act(() => view.update({ content: '<p>Notes</p>', editable: true, onChange }))
+    expect(onChange).not.toHaveBeenCalled()
+  })
+
+  it('reports nothing when a colleague saved and the page put their text in', async () => {
+    const onChange = vi.fn()
+    const view = mount({ content: '<p>Notes</p>', onChange })
+    const editor = await editorIn(view.container)
+    act(() => view.update({ content: '<p>Notes, and a second line</p>', onChange }))
+    expect(editor.textContent).toBe('Notes, and a second line')
+    expect(onChange).not.toHaveBeenCalled()
+  })
+})

+ 20 - 16
src/__tests__/lib/notification-roles.test.ts

@@ -21,6 +21,7 @@ import { readsNotifications } from '@/lib/notification-roles'
 const read = (file: string) => fs.readFileSync(path.join(process.cwd(), file), 'utf-8')
 const read = (file: string) => fs.readFileSync(path.join(process.cwd(), file), 'utf-8')
 const SOCKET = 'src/app/api/protected/ws/route.ts'
 const SOCKET = 'src/app/api/protected/ws/route.ts'
 const ACTION = 'src/features/notifications/Actions/notificationActions.ts'
 const ACTION = 'src/features/notifications/Actions/notificationActions.ts'
+const PROTOCOL = 'src/lib/realtime/protocol.server.ts'
 
 
 describe('the notification feed', () => {
 describe('the notification feed', () => {
   it('is for the roles that can already read it', () => {
   it('is for the roles that can already read it', () => {
@@ -34,11 +35,11 @@ describe('the notification feed', () => {
 
 
   it('is filtered by the socket before a frame goes out', () => {
   it('is filtered by the socket before a frame goes out', () => {
     const source = read(SOCKET)
     const source = read(SOCKET)
-    const listener = source.slice(
-      source.indexOf("notificationBus.on('notification'"),
-      source.indexOf("notificationBus.on('workboard'")
-    )
-    expect(listener).toMatch(/readsNotifications\(client\.role\)/)
+    // The feed travels with the other workshop-wide channels; the filter sits
+    // on the send, so a member who is not an owner or admin simply is not
+    // among the sockets it reaches.
+    const relay = source.slice(source.indexOf("if (message.t !== 'legacy') return"))
+    expect(relay).toMatch(/channel === 'notification' && !readsNotifications\(socket\.role\)/)
   })
   })
 
 
   it('and the action and the socket share the one rule', () => {
   it('and the action and the socket share the one rule', () => {
@@ -50,17 +51,20 @@ describe('the notification feed', () => {
   })
   })
 })
 })
 
 
-describe('the work board channel', () => {
-  it('reaches every member, so a work order stays current for whoever is at it', () => {
+describe('what every member hears', () => {
+  it('connects, whatever their role', () => {
+    // A front desk on a lesser role has to see a work order stay current.
+    expect(read(SOCKET)).not.toMatch(/Insufficient role/)
+  })
+
+  it('is given only the rooms it asked for, and only its own workshop’s', () => {
+    // A record's change goes to that record's room and its workshop's room,
+    // never to every socket on the instance.
     const source = read(SOCKET)
     const source = read(SOCKET)
-    // The connection is not refused by role any more.
-    expect(source).not.toMatch(/Insufficient role/)
-    const listener = source.slice(
-      source.indexOf("notificationBus.on('workboard'"),
-      source.indexOf("notificationBus.on('broadcast'")
-    )
-    expect(listener).not.toMatch(/readsNotifications/)
-    // Still only the workshop the event belongs to.
-    expect(listener).toMatch(/client\.organizationId === event\.organizationId/)
+    expect(source).toMatch(/recordRoom\(change\.kind, change\.id\)/)
+    expect(source).toMatch(/orgRoom\(change\.organizationId\)/)
+    // And a room is only joined after the server has checked it belongs to
+    // this socket's workshop (the protocol, which the route hands frames to).
+    expect(read(PROTOCOL)).toMatch(/await mayJoin\(room, client\.organizationId\)/)
   })
   })
 })
 })

+ 147 - 0
src/__tests__/lib/realtime/after-commit.test.ts

@@ -0,0 +1,147 @@
+/**
+ * @vitest-environment node
+ *
+ * A change is announced when its transaction commits, never before.
+ *
+ * A viewer answers an event by reading the record again, at once. Told while
+ * the transaction was still open, it read the row as it was before the save,
+ * showed that, and was never told again: the update simply looked lost, some
+ * of the time, depending on which was quicker. That is the kind of fault that
+ * is never reproduced on a developer's machine and always on a busy server.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const sent: { id: string | null }[] = []
+vi.mock('@/lib/realtime/bus.server', () => ({
+  publishToBus: (message: { change: { id: string | null } }) => sent.push(message.change),
+}))
+
+import {
+  flushRecordChanges,
+  holdingChanges,
+  publishRecordChange,
+} from '@/lib/realtime/publish.server'
+
+const change = (id: string) => ({ kind: 'serviceRecord' as const, id, organizationId: 'org-1' })
+const ids = () => sent.map((entry) => entry.id)
+
+beforeEach(() => {
+  sent.length = 0
+  vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+})
+afterEach(() => {
+  flushRecordChanges()
+  vi.restoreAllMocks()
+})
+
+describe('inside a transaction', () => {
+  it('says nothing until it has committed', async () => {
+    let commit: () => void = () => undefined
+    const transaction = holdingChanges(async () => {
+      publishRecordChange(change('job-1'))
+      await new Promise<void>((resolve) => {
+        commit = resolve
+      })
+    })
+    await new Promise((resolve) => setTimeout(resolve, 5))
+    flushRecordChanges()
+    expect(ids()).toEqual([])
+
+    commit()
+    await transaction
+    flushRecordChanges()
+
+    expect(ids()).toEqual(['job-1'])
+  })
+
+  it('says nothing at all when it rolls back', async () => {
+    await expect(
+      holdingChanges(async () => {
+        publishRecordChange(change('job-1'))
+        throw new Error('constraint failed')
+      })
+    ).rejects.toThrow('constraint failed')
+    flushRecordChanges()
+
+    expect(ids()).toEqual([])
+  })
+
+  it('follows the write five calls down, without being passed anything', async () => {
+    const deep = async (n: number): Promise<void> => {
+      await Promise.resolve()
+      if (n > 0) return deep(n - 1)
+      publishRecordChange(change('job-1'))
+    }
+    let during: (string | null)[] = []
+    await holdingChanges(async () => {
+      await deep(5)
+      flushRecordChanges()
+      during = ids()
+    })
+    flushRecordChanges()
+
+    expect(during).toEqual([])
+    expect(ids()).toEqual(['job-1'])
+  })
+
+  it('says a record once however many of its rows were written', async () => {
+    await holdingChanges(async () => {
+      for (let i = 0; i < 20; i++) publishRecordChange(change('job-1'))
+    })
+    flushRecordChanges()
+    expect(ids()).toEqual(['job-1'])
+  })
+
+  it('belongs to the outer transaction when one is inside another', async () => {
+    let afterInner: (string | null)[] = ['not checked']
+    await holdingChanges(async () => {
+      await holdingChanges(async () => publishRecordChange(change('job-1')))
+      flushRecordChanges()
+      afterInner = ids()
+    })
+    flushRecordChanges()
+
+    expect(afterInner).toEqual([])
+    expect(ids()).toEqual(['job-1'])
+  })
+
+  it('does not swallow a write that outlives its transaction', async () => {
+    let late: () => void = () => undefined
+    await holdingChanges(async () => {
+      void new Promise<void>((resolve) => {
+        late = resolve
+      }).then(() => publishRecordChange(change('job-late')))
+    })
+    late()
+    await new Promise((resolve) => setTimeout(resolve, 5))
+    flushRecordChanges()
+
+    expect(ids()).toEqual(['job-late'])
+  })
+})
+
+describe('outside a transaction', () => {
+  it('is announced on the next tick, as before', () => {
+    publishRecordChange(change('job-1'))
+    flushRecordChanges()
+    expect(ids()).toEqual(['job-1'])
+  })
+
+  it('keeps two transactions at once apart', async () => {
+    let commitA: () => void = () => undefined
+    const a = holdingChanges(async () => {
+      publishRecordChange(change('job-a'))
+      await new Promise<void>((resolve) => {
+        commitA = resolve
+      })
+    })
+    await holdingChanges(async () => publishRecordChange(change('job-b')))
+    flushRecordChanges()
+    expect(ids()).toEqual(['job-b'])
+
+    commitA()
+    await a
+    flushRecordChanges()
+    expect(ids()).toEqual(['job-b', 'job-a'])
+  })
+})

+ 272 - 0
src/__tests__/lib/realtime/announces-writes.test.ts

@@ -0,0 +1,272 @@
+/**
+ * @vitest-environment node
+ *
+ * Every write tells the screens, without being asked.
+ *
+ * This is the test for the thing that kept going wrong. Announcing a change
+ * used to be a line a developer had to remember in whichever place wrote the
+ * row, so a feature written afterwards was silently not live: the technician
+ * app's labour endpoint wrote a line nobody was told about, and its status
+ * endpoint sent a shape no listener knew. Neither was a socket bug. Both were
+ * a forgotten line.
+ *
+ * The announcement now happens in the Prisma extension, which every write in
+ * the app passes through. These tests drive that extension the way Prisma
+ * does and check what came out.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const published: unknown[] = []
+const authors: unknown[] = []
+vi.mock('@/lib/realtime/publish.server', () => ({
+  publishRecordChange: ({ by, ...input }: { by: unknown }) => {
+    published.push(input)
+    authors.push(by)
+  },
+}))
+
+import { runAsActor } from '@/lib/realtime/actor.server'
+import {
+  realtimeQueryHook,
+  resetRealtimeMemory,
+  REALTIME_CHILD_MODELS,
+  REALTIME_RECORD_MODELS,
+} from '@/lib/realtime/prisma-realtime.server'
+
+/** Runs one Prisma operation through the extension, as the client would. */
+async function write(
+  model: string,
+  operation: string,
+  args: Record<string, unknown>,
+  result: unknown = {},
+  organizationOf: (kind: string, id: string) => Promise<string | null> = async () => null
+) {
+  return realtimeQueryHook({ organizationOf })({
+    model,
+    operation,
+    args,
+    query: async () => result,
+  })
+}
+
+beforeEach(() => {
+  published.length = 0
+  authors.length = 0
+  resetRealtimeMemory()
+})
+
+describe('a record written directly', () => {
+  it('announces the job that was updated', async () => {
+    await write(
+      'ServiceRecord',
+      'update',
+      { where: { id: 'job-1' }, data: { status: 'completed' } },
+      { id: 'job-1', organizationId: 'org-1' }
+    )
+
+    expect(published).toEqual([
+      { kind: 'serviceRecord', id: 'job-1', organizationId: 'org-1', action: 'updated' },
+    ])
+  })
+
+  it('announces a create, a delete and every kind a screen follows', async () => {
+    await write('Vehicle', 'create', { data: {} }, { id: 'v-1', organizationId: 'org-1' })
+    await write('Quote', 'delete', { where: { id: 'q-1', organizationId: 'org-1' } })
+
+    expect(published).toEqual([
+      { kind: 'vehicle', id: 'v-1', organizationId: 'org-1', action: 'created' },
+      { kind: 'quote', id: 'q-1', organizationId: 'org-1', action: 'deleted' },
+    ])
+  })
+
+  it('looks the workshop up when the write did not name one, once per record', async () => {
+    const organizationOf = vi.fn(async () => 'org-7')
+
+    await write('ServiceRecord', 'update', { where: { id: 'job-9' } }, {}, organizationOf)
+    await write('ServiceRecord', 'update', { where: { id: 'job-9' } }, {}, organizationOf)
+
+    expect(organizationOf).toHaveBeenCalledTimes(1)
+    expect(published).toHaveLength(2)
+    expect(published[1]).toMatchObject({ id: 'job-9', organizationId: 'org-7' })
+  })
+
+  it('says nothing when there is no workshop to say it to', async () => {
+    await write('ServiceRecord', 'update', { where: { id: 'job-x' } }, {})
+    expect(published).toEqual([])
+  })
+})
+
+describe('a row that belongs to a record', () => {
+  it('announces the job, because that is what a screen is showing', async () => {
+    // The technician app adding a line of work: the desk is looking at the
+    // job, not at the labour row.
+    await write(
+      'ServiceLabor',
+      'create',
+      { data: { serviceRecordId: 'job-1', hours: 1.5 } },
+      { id: 'lab-1', serviceRecordId: 'job-1' },
+      async () => 'org-1'
+    )
+
+    expect(published).toEqual([
+      {
+        kind: 'serviceRecord',
+        id: 'job-1',
+        organizationId: 'org-1',
+        hint: 'labor',
+      },
+    ])
+  })
+
+  it('does the same for a photo, a payment and a clock entry', async () => {
+    const org = async () => 'org-1'
+    await write('ServiceAttachment', 'delete', { where: { serviceRecordId: 'job-1' } }, {}, org)
+    await write('Payment', 'create', { data: { serviceRecordId: 'job-1' } }, {}, org)
+    await write('TimeEntry', 'update', { where: { serviceRecordId: 'job-1' } }, {}, org)
+
+    expect(published.map((p) => (p as { hint: string }).hint)).toEqual([
+      'attachments',
+      'payments',
+      'clock',
+    ])
+  })
+})
+
+describe('a bulk write', () => {
+  it('names no record, so only the workshop lists hear it', async () => {
+    await write('ServiceRecord', 'updateMany', {
+      where: { organizationId: 'org-1', status: 'pending' },
+      data: { status: 'in-progress' },
+    })
+
+    expect(published).toEqual([
+      { kind: 'serviceRecord', id: null, organizationId: 'org-1', action: 'updated' },
+    ])
+  })
+})
+
+describe('what is not announced', () => {
+  it('a read', async () => {
+    await write('ServiceRecord', 'findMany', { where: { organizationId: 'org-1' } }, [])
+    await write('ServiceRecord', 'count', { where: { organizationId: 'org-1' } }, 3)
+    expect(published).toEqual([])
+  })
+
+  it('a model no screen follows', async () => {
+    await write('AuditLog', 'create', { data: { organizationId: 'org-1' } }, { id: 'a-1' })
+    expect(published).toEqual([])
+  })
+})
+
+describe('a write that goes wrong', () => {
+  it('fails the way it would have, and tells nobody', async () => {
+    const hook = realtimeQueryHook({ organizationOf: async () => 'org-1' })
+
+    await expect(
+      hook({
+        model: 'ServiceRecord',
+        operation: 'update',
+        args: { where: { id: 'job-1' } },
+        query: async () => {
+          throw new Error('unique constraint')
+        },
+      })
+    ).rejects.toThrow('unique constraint')
+
+    expect(published).toEqual([])
+  })
+})
+
+describe('the models it knows', () => {
+  it('covers the records a page can follow, and their parts', () => {
+    // Guards the guard: this list is what makes a feature live by default, so
+    // a record kind added without a model here would be silently static.
+    expect(Object.values(REALTIME_RECORD_MODELS).sort()).toEqual([
+      'customer',
+      'inspection',
+      'inventoryPart',
+      'quote',
+      'serviceRecord',
+      'tireSet',
+      'vehicle',
+    ])
+    for (const [model, child] of Object.entries(REALTIME_CHILD_MODELS)) {
+      expect(Object.values(REALTIME_RECORD_MODELS), model).toContain(child.parent)
+      expect(child.fk, model).toMatch(/Id$/)
+    }
+  })
+})
+
+describe('a work order saving its lines', () => {
+  // The save deletes every line and writes them again. Both are plural
+  // writes, and both name the one job they belong to, so that job's page is
+  // told rather than the write being treated as anonymous.
+  it('announces the job when its lines are deleted together', async () => {
+    await write(
+      'ServiceLabor',
+      'deleteMany',
+      { where: { serviceRecordId: 'job-1' } },
+      { count: 4 },
+      async () => 'org-1'
+    )
+
+    expect(published).toEqual([
+      { kind: 'serviceRecord', id: 'job-1', organizationId: 'org-1', hint: 'labor' },
+    ])
+  })
+
+  it('announces the job when its lines are written together', async () => {
+    await write(
+      'ServicePart',
+      'createMany',
+      { data: [{ serviceRecordId: 'job-1' }, { serviceRecordId: 'job-1' }] },
+      { count: 2 },
+      async () => 'org-1'
+    )
+
+    expect(published).toEqual([
+      { kind: 'serviceRecord', id: 'job-1', organizationId: 'org-1', hint: 'parts' },
+    ])
+  })
+
+  it('tells only the workshop when one write touches more jobs than is worth naming', async () => {
+    const data = Array.from({ length: 40 }, (_, i) => ({
+      serviceRecordId: `job-${i}`,
+      organizationId: 'org-1',
+    }))
+    await write('ServicePart', 'createMany', { data }, { count: 40 })
+
+    expect(published).toEqual([
+      { kind: 'serviceRecord', id: null, organizationId: 'org-1', hint: 'parts' },
+    ])
+  })
+})
+
+describe('whose change it was', () => {
+  it('is the person who asked, even when the query answers from somewhere else', async () => {
+    // Prisma resolves a query from its own machinery. Whoever is "current"
+    // by then is not to be trusted, so the author is read before the query.
+    const christian = { userId: 'u-1', name: 'Christian', source: 'web' as const }
+    await runAsActor(christian, async () =>
+      realtimeQueryHook({ organizationOf: async () => null })({
+        model: 'ServiceRecord',
+        operation: 'update',
+        args: { where: { id: 'job-1' }, data: {} },
+        // Answers outside the actor's scope, as a pooled connection would.
+        query: () =>
+          new Promise((resolve) => {
+            runAsActor({ userId: 'somebody-else', name: null, source: 'web' }, () =>
+              setTimeout(() => resolve({ id: 'job-1', organizationId: 'org-1' }), 0)
+            )
+          }),
+      })
+    )
+
+    expect(authors).toEqual([christian])
+  })
+
+  it('is the system when nobody asked: a scheduled job, a script', async () => {
+    await write('ServiceRecord', 'update', { where: { id: 'job-1', organizationId: 'org-1' } })
+    expect(authors).toEqual([{ userId: null, name: null, source: 'system' }])
+  })
+})

+ 176 - 0
src/__tests__/lib/realtime/bus.test.ts

@@ -0,0 +1,176 @@
+/**
+ * @vitest-environment node
+ *
+ * Events reaching the other app instances.
+ *
+ * A workshop's desk may be on one container and its technician's phone on
+ * another, and neither knows the other exists. Postgres carries the event
+ * between them: `pg_notify` here, `LISTEN` there. Redis would do the same job
+ * and be one more thing to operate.
+ *
+ * What has to hold: an instance delivers its own events locally and does not
+ * apply them a second time when they come back off the wire; a notification
+ * stays inside the size Postgres will accept; and a write never fails because
+ * the relay did.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const pg = vi.hoisted(() => {
+  const notifications: { channel: string; payload: string }[] = []
+  const listeners: ((message: { channel: string; payload: string }) => void)[] = []
+  const queries: string[] = []
+  /** Kept across tests: the bridge connects once for the life of a process. */
+  const listening: string[] = []
+  let failNotify = false
+  class Client {
+    on(event: string, handler: (arg: unknown) => void) {
+      if (event === 'notification') {
+        listeners.push(handler as (message: { channel: string; payload: string }) => void)
+      }
+    }
+    async connect() {
+      return undefined
+    }
+    async query(text: string, values?: unknown[]) {
+      queries.push(text)
+      if (text.startsWith('LISTEN ')) listening.push(text)
+      if (text.startsWith('SELECT pg_notify')) {
+        if (failNotify) throw new Error('connection lost')
+        const [channel, payload] = values as [string, string]
+        notifications.push({ channel, payload })
+      }
+      return {}
+    }
+    async end() {
+      return undefined
+    }
+  }
+  return {
+    Client,
+    notifications,
+    queries,
+    listening,
+    /** Delivers a frame as Postgres would, to every LISTEN handler. */
+    deliver(payload: string) {
+      for (const listener of listeners) listener({ channel: 'torqvoice_realtime', payload })
+    },
+    setFailNotify(value: boolean) {
+      failNotify = value
+    },
+    /**
+     * Between tests, not between processes: the connection and its LISTEN
+     * belong to the instance and are made once, exactly as in production.
+     */
+    reset() {
+      notifications.length = 0
+      queries.length = 0
+      failNotify = false
+    },
+  }
+})
+
+vi.mock('pg', () => ({ Client: pg.Client }))
+
+import { INSTANCE_ID, onBusMessage, publishToBus } from '@/lib/realtime/bus.server'
+import type { RecordChange } from '@/lib/realtime/events'
+
+const change: RecordChange = {
+  kind: 'serviceRecord',
+  id: 'job-1',
+  organizationId: 'org-1',
+  action: 'updated',
+  by: { userId: 'u-1', name: 'Christian', source: 'web' },
+  at: 1,
+}
+
+/** Waits for the relay, which is deliberately not awaited by the publisher. */
+const settle = () => new Promise((resolve) => setTimeout(resolve, 5))
+
+beforeEach(() => {
+  process.env.DATABASE_URL = 'postgresql://test/test'
+  pg.reset()
+})
+
+afterEach(() => {
+  vi.restoreAllMocks()
+})
+
+describe('publishing', () => {
+  it('delivers here at once, without waiting for the database', () => {
+    const heard: unknown[] = []
+    const stop = onBusMessage((message) => heard.push(message))
+
+    publishToBus({ t: 'record', change })
+
+    // Delivered before any await: a screen in this process does not wait on a
+    // round trip to see a change made in this process.
+    expect(heard).toEqual([{ t: 'record', change }])
+    stop()
+  })
+
+  it('sends it on to the other instances, stamped with who sent it', async () => {
+    publishToBus({ t: 'record', change })
+    await settle()
+
+    expect(pg.notifications).toHaveLength(1)
+    const sent = JSON.parse(pg.notifications[0].payload)
+    expect(sent).toMatchObject({ t: 'record', from: INSTANCE_ID })
+    expect(pg.notifications[0].channel).toBe('torqvoice_realtime')
+    // Small enough for Postgres, which refuses anything over 8000 bytes.
+    expect(pg.notifications[0].payload.length).toBeLessThan(7_000)
+  })
+
+  it('listens as soon as it connects, so an instance nobody writes on still hears', async () => {
+    publishToBus({ t: 'record', change })
+    await settle()
+    expect(pg.listening).toContain('LISTEN torqvoice_realtime')
+  })
+
+  it('does not fail the write when the relay cannot send', async () => {
+    pg.setFailNotify(true)
+    const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+
+    expect(() => publishToBus({ t: 'record', change })).not.toThrow()
+    await settle()
+
+    expect(errors).toHaveBeenCalled()
+  })
+})
+
+describe('receiving', () => {
+  it('applies an event from another instance', async () => {
+    publishToBus({ t: 'record', change })
+    await settle()
+    const heard: unknown[] = []
+    const stop = onBusMessage((message) => heard.push(message))
+
+    pg.deliver(JSON.stringify({ t: 'record', change, from: 'another-instance' }))
+
+    expect(heard).toEqual([{ t: 'record', change }])
+    stop()
+  })
+
+  it('ignores its own, which it has already delivered', async () => {
+    publishToBus({ t: 'record', change })
+    await settle()
+    const heard: unknown[] = []
+    const stop = onBusMessage((message) => heard.push(message))
+
+    pg.deliver(JSON.stringify({ t: 'record', change, from: INSTANCE_ID }))
+
+    expect(heard).toEqual([])
+    stop()
+  })
+
+  it('shrugs off a frame it cannot read', async () => {
+    publishToBus({ t: 'record', change })
+    await settle()
+    const heard: unknown[] = []
+    const stop = onBusMessage((message) => heard.push(message))
+
+    expect(() => pg.deliver('{ not json')).not.toThrow()
+
+    expect(heard).toEqual([])
+    stop()
+  })
+})

+ 258 - 0
src/__tests__/lib/realtime/presence.test.ts

@@ -0,0 +1,258 @@
+/**
+ * @vitest-environment node
+ *
+ * Who is on a record, across however many app instances are running.
+ *
+ * Presence is the one piece of this that is not derived from the database:
+ * it lives in memory while sockets do. That buys two obligations, and both
+ * are tested here. It must not leak, because a room is per record and a
+ * workshop opens thousands; and it must forget an instance that dies without
+ * saying goodbye, or a crashed container would leave people standing in a
+ * room forever, visible to everybody, contactable by nobody.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const published: unknown[] = []
+vi.mock('@/lib/realtime/bus.server', () => ({
+  INSTANCE_ID: 'this-instance',
+  publishToBus: (message: unknown) => published.push(message),
+  onBusMessage: () => () => undefined,
+}))
+
+import {
+  acceptRemotePresence,
+  enterRoom,
+  flushPresence,
+  leaveAllRooms,
+  leaveRoom,
+  onPresenceChange,
+  presenceOf,
+  presenceStats,
+  resetPresence,
+  sweep,
+} from '@/lib/realtime/presence.server'
+import { presenceColor } from '@/lib/realtime/events'
+
+const ROOM = 'rec:serviceRecord:job-1'
+const socket = (name: string) => ({ name })
+const CHRISTIAN = { userId: 'u-1', name: 'Christian' }
+const MARCO = { userId: 'u-2', name: 'Marco' }
+const NOTHING_HELD = { localRooms: 0, remoteRooms: 0, sockets: 0, indexedSockets: 0, waiting: 0 }
+
+beforeEach(() => {
+  published.length = 0
+})
+afterEach(() => resetPresence())
+
+describe('being in a room', () => {
+  it('shows the people in it, with a colour each and their own', () => {
+    enterRoom(ROOM, socket('a'), CHRISTIAN)
+    enterRoom(ROOM, socket('b'), MARCO)
+
+    expect(presenceOf(ROOM)).toEqual([
+      { userId: 'u-1', name: 'Christian', color: presenceColor('u-1'), devices: 1 },
+      { userId: 'u-2', name: 'Marco', color: presenceColor('u-2'), devices: 1 },
+    ])
+  })
+
+  it('counts one person on a laptop and a phone as one person', () => {
+    enterRoom(ROOM, socket('laptop'), CHRISTIAN)
+    enterRoom(ROOM, socket('phone'), CHRISTIAN)
+
+    const [christian, ...rest] = presenceOf(ROOM)
+    expect(rest).toEqual([])
+    expect(christian.devices).toBe(2)
+  })
+
+  it('tells the room whenever it changes', () => {
+    const seen: { room: string; names: string[] }[] = []
+    const stop = onPresenceChange((room, users) =>
+      seen.push({ room, names: users.map((u) => u.name) })
+    )
+
+    const desk = socket('desk')
+    enterRoom(ROOM, desk, CHRISTIAN)
+    flushPresence()
+    enterRoom(ROOM, socket('bay'), MARCO)
+    flushPresence()
+    leaveRoom(ROOM, desk)
+    flushPresence()
+    stop()
+
+    expect(seen.map((s) => s.names)).toEqual([['Christian'], ['Christian', 'Marco'], ['Marco']])
+  })
+
+  it('says it once when several people arrive together', () => {
+    // A shift change is three people opening the same job in the same
+    // second: one frame to the room, carrying who is there now, not three.
+    const seen: string[][] = []
+    const stop = onPresenceChange((_room, users) => seen.push(users.map((u) => u.name)))
+
+    enterRoom(ROOM, socket('desk'), CHRISTIAN)
+    enterRoom(ROOM, socket('bay'), MARCO)
+    flushPresence()
+    stop()
+
+    expect(seen).toEqual([['Christian', 'Marco']])
+    expect(published).toHaveLength(1)
+  })
+
+  it('sends by itself a moment later, without being asked', () => {
+    vi.useFakeTimers()
+    try {
+      const seen: string[] = []
+      const stop = onPresenceChange((room) => seen.push(room))
+      enterRoom(ROOM, socket('desk'), CHRISTIAN)
+      expect(seen).toEqual([])
+      vi.advanceTimersByTime(30)
+      expect(seen).toEqual([ROOM])
+      expect(presenceStats().waiting).toBe(0)
+      stop()
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+})
+
+describe('leaving nothing behind', () => {
+  it('forgets a room when its last person goes', () => {
+    const a = socket('a')
+    const b = socket('b')
+    enterRoom(ROOM, a, CHRISTIAN)
+    enterRoom(ROOM, b, MARCO)
+
+    leaveRoom(ROOM, a)
+    expect(presenceStats().localRooms).toBe(1)
+
+    leaveRoom(ROOM, b)
+    flushPresence()
+    expect(presenceStats()).toEqual(NOTHING_HELD)
+  })
+
+  it('drops every room a closing socket stood in', () => {
+    const laptop = socket('laptop')
+    for (const id of ['1', '2', '3']) enterRoom(`rec:serviceRecord:${id}`, laptop, CHRISTIAN)
+    expect(presenceStats().localRooms).toBe(3)
+
+    leaveAllRooms(laptop)
+
+    flushPresence()
+    expect(presenceStats()).toEqual(NOTHING_HELD)
+  })
+
+  it('holds nothing after a thousand records are opened and closed', () => {
+    const laptop = socket('laptop')
+    for (let i = 0; i < 1_000; i++) {
+      const room = `rec:serviceRecord:${i}`
+      enterRoom(room, laptop, CHRISTIAN)
+      leaveRoom(room, laptop)
+    }
+    flushPresence()
+    expect(presenceStats()).toEqual(NOTHING_HELD)
+  })
+})
+
+describe('the other instances', () => {
+  it('announces the people on this instance, and only those', () => {
+    enterRoom(ROOM, socket('a'), CHRISTIAN)
+    flushPresence()
+
+    expect(published).toEqual([
+      {
+        t: 'presence',
+        room: ROOM,
+        instanceId: 'this-instance',
+        users: [{ userId: 'u-1', name: 'Christian', color: presenceColor('u-1'), devices: 1 }],
+        ttlMs: expect.any(Number),
+      },
+    ])
+  })
+
+  it('says a room is empty, so the others drop it too', () => {
+    const a = socket('a')
+    enterRoom(ROOM, a, CHRISTIAN)
+    flushPresence()
+    published.length = 0
+
+    leaveRoom(ROOM, a)
+    flushPresence()
+
+    expect(published).toEqual([
+      {
+        t: 'presence',
+        room: ROOM,
+        instanceId: 'this-instance',
+        users: [],
+        ttlMs: expect.any(Number),
+      },
+    ])
+  })
+
+  it('renews a lease without telling the room, when an instance repeats itself', () => {
+    // Every instance repeats what it holds twice a minute. Telling the room
+    // each time would be a frame to every viewer, for ever, about nothing.
+    const away = [{ userId: 'u-9', name: 'Away', color: '#000', devices: 1 }]
+    const seen: string[] = []
+    const stop = onPresenceChange((room) => seen.push(room))
+
+    acceptRemotePresence(ROOM, away, 55_000, 'other-instance')
+    acceptRemotePresence(ROOM, [{ ...away[0] }], 55_000, 'other-instance')
+    acceptRemotePresence(ROOM, [{ ...away[0] }], 55_000, 'other-instance')
+    stop()
+
+    expect(seen).toEqual([ROOM])
+    // And the lease really was renewed: it survives what would have expired it.
+    sweep(Date.now() + 50_000)
+    expect(presenceOf(ROOM)).toHaveLength(1)
+  })
+
+  it('tells the room when an instance that died takes its people with it', () => {
+    acceptRemotePresence(
+      ROOM,
+      [{ userId: 'u-9', name: 'Away', color: '#000', devices: 1 }],
+      55_000,
+      'other-instance'
+    )
+    const seen: number[] = []
+    const stop = onPresenceChange((_room, users) => seen.push(users.length))
+    sweep(Date.now() + 120_000)
+    stop()
+    expect(seen).toEqual([0])
+  })
+
+  it('sweeps away an instance that stopped saying it was there', () => {
+    // Nothing of ours in the room, only what another instance claimed.
+    acceptRemotePresence(
+      ROOM,
+      [{ userId: 'u-9', name: 'Away', color: '#000', devices: 1 }],
+      55_000,
+      'other-instance'
+    )
+    expect(presenceOf(ROOM)).toHaveLength(1)
+
+    // A minute later it has not repeated itself: it is gone, and so is the room.
+    sweep(Date.now() + 120_000)
+
+    expect(presenceOf(ROOM)).toEqual([])
+    expect(presenceStats().remoteRooms).toBe(0)
+  })
+})
+
+describe('a module that is loaded again', () => {
+  it('has one timer and one bridge, however many times it is started', async () => {
+    // Development reloads the socket route on every edit, and each load starts
+    // presence. The predecessor's timer has to go, or old code keeps running
+    // beside the new on state the new code wrote.
+    vi.useFakeTimers()
+    try {
+      const { startPresence } = await import('@/lib/realtime/presence.server')
+      startPresence()
+      const afterFirst = vi.getTimerCount()
+      startPresence()
+      startPresence()
+      expect(vi.getTimerCount()).toBe(afterFirst)
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+})

+ 267 - 0
src/__tests__/lib/realtime/protocol.test.ts

@@ -0,0 +1,267 @@
+/**
+ * @vitest-environment node
+ *
+ * What a browser asks the socket for.
+ *
+ * The test that matters here is the boring-looking one about order. A page
+ * subscribes to a room and then stands in it, and the subscribe is checked
+ * against the database while the "enter" is already on its way. Handled
+ * concurrently, the enter arrives before the room has been joined, is
+ * refused, and nobody's chip ever appears: two people on one work order, and
+ * neither sees the other, with nothing in any log to say why.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mayJoin = vi.hoisted(() => vi.fn(async (_room: string, _organizationId: string) => true))
+vi.mock('@/lib/realtime/authorize.server', () => ({ mayJoin }))
+
+import {
+  handleClientMessage,
+  MAX_FRAME_BYTES,
+  MAX_QUEUED_FRAMES,
+  queueFor,
+  type Client,
+} from '@/lib/realtime/protocol.server'
+import {
+  flushPresence,
+  onPresenceChange,
+  presenceOf,
+  resetPresence,
+} from '@/lib/realtime/presence.server'
+import { broadcast, isInRoom, resetRooms, stats } from '@/lib/realtime/rooms.server'
+import type { ServerMessage } from '@/lib/realtime/events'
+
+const ROOM = 'rec:serviceRecord:job-1'
+/** The same room as the server keeps it: under the desk's own workshop. */
+const KEPT = 'org-1|rec:serviceRecord:job-1'
+
+function desk(): { client: Client; socket: object; sent: ServerMessage[] } {
+  const sent: ServerMessage[] = []
+  return {
+    client: {
+      userId: 'u-1',
+      userName: 'Christian',
+      organizationId: 'org-1',
+      send: (message) => sent.push(message),
+    },
+    socket: { name: 'desk' },
+    sent,
+  }
+}
+
+const say = (client: Client, socket: object, message: unknown) =>
+  handleClientMessage(client, socket, JSON.stringify(message))
+
+/** Lets a queued chain run to the end. */
+async function settle() {
+  for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0))
+}
+
+beforeEach(() => {
+  mayJoin.mockReset().mockResolvedValue(true)
+})
+
+afterEach(() => {
+  resetRooms()
+  resetPresence()
+})
+
+describe('subscribing', () => {
+  it('joins a room the workshop owns, and says which', async () => {
+    const { client, socket, sent } = desk()
+
+    await say(client, socket, { t: 'sub', rooms: [ROOM] })
+
+    expect(isInRoom(socket, KEPT)).toBe(true)
+    expect(sent).toEqual([{ t: 'subscribed', rooms: [ROOM] }])
+  })
+
+  it('refuses a room from another workshop, silently', async () => {
+    mayJoin.mockResolvedValue(false)
+    const { client, socket, sent } = desk()
+
+    await say(client, socket, { t: 'sub', rooms: ['rec:serviceRecord:someone-elses'] })
+
+    expect(stats().rooms).toBe(0)
+    // Named as not joined rather than refused: a socket learns nothing about
+    // whether that record exists.
+    expect(sent).toEqual([{ t: 'subscribed', rooms: [] }])
+  })
+
+  it('leaves on unsub, taking the room with it', async () => {
+    const { client, socket } = desk()
+    await say(client, socket, { t: 'sub', rooms: [ROOM] })
+    await say(client, socket, { t: 'enter', room: ROOM })
+
+    await say(client, socket, { t: 'unsub', rooms: [ROOM] })
+
+    expect(stats()).toEqual({ rooms: 0, members: 0, subscriptions: 0 })
+    expect(presenceOf(KEPT)).toEqual([])
+  })
+})
+
+describe('presence', () => {
+  it('stands in a room it holds, and the room is told', async () => {
+    const { client, socket } = desk()
+    const told: string[][] = []
+    const stop = onPresenceChange((_room, users) => told.push(users.map((user) => user.name)))
+    await say(client, socket, { t: 'sub', rooms: [ROOM] })
+
+    await say(client, socket, { t: 'enter', room: ROOM })
+    flushPresence()
+    stop()
+
+    // The newcomer hears it the way everybody else does, through the room
+    // (the route sends a presence change to every socket in it): one frame,
+    // from one place.
+    expect(presenceOf(KEPT).map((user) => user.name)).toEqual(['Christian'])
+    expect(told).toEqual([['Christian']])
+  })
+
+  it('cannot stand in a room it does not hold', async () => {
+    const { client, socket } = desk()
+
+    await say(client, socket, { t: 'enter', room: ROOM })
+
+    expect(presenceOf(KEPT)).toEqual([])
+  })
+})
+
+describe('two messages sent together', () => {
+  it('handles them in order, so "enter" is not refused while "sub" is still checking', async () => {
+    // The real shape of it: the subscribe waits on the database.
+    let allow: (value: boolean) => void = () => undefined
+    mayJoin.mockImplementation(
+      () =>
+        new Promise<boolean>((resolve) => {
+          allow = resolve
+        })
+    )
+    const { client, socket, sent } = desk()
+    const queue = queueFor(client, socket)
+
+    queue(JSON.stringify({ t: 'sub', rooms: [ROOM] }))
+    queue(JSON.stringify({ t: 'enter', room: ROOM }))
+    await settle()
+
+    // The check is still out, so neither message has finished: this is the
+    // window in which the enter used to be refused.
+    expect(isInRoom(socket, KEPT)).toBe(false)
+    expect(presenceOf(KEPT)).toEqual([])
+
+    allow(true)
+    await settle()
+
+    expect(isInRoom(socket, KEPT)).toBe(true)
+    expect(presenceOf(KEPT).map((user) => user.name)).toEqual(['Christian'])
+    expect(sent).toContainEqual({ t: 'subscribed', rooms: [ROOM] })
+  })
+
+  it('keeps going after one of them fails', async () => {
+    const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    mayJoin.mockRejectedValueOnce(new Error('database gone')).mockResolvedValue(true)
+    const { client, socket, sent } = desk()
+    const queue = queueFor(client, socket)
+
+    queue(JSON.stringify({ t: 'sub', rooms: [ROOM] }))
+    queue(JSON.stringify({ t: 'ping' }))
+    await settle()
+
+    expect(errors).toHaveBeenCalled()
+    expect(sent).toContainEqual({ t: 'pong' })
+  })
+})
+
+describe('a socket that is not a page', () => {
+  it('ignores a frame far larger than any page sends', async () => {
+    const { client, socket, sent } = desk()
+    const rooms = Array.from({ length: 2_000 }, (_, i) => `rec:serviceRecord:job-${i}`)
+
+    await say(client, socket, { t: 'sub', rooms })
+
+    expect(JSON.stringify({ t: 'sub', rooms }).length).toBeGreaterThan(MAX_FRAME_BYTES)
+    expect(sent).toEqual([])
+    expect(stats().rooms).toBe(0)
+  })
+
+  it('stops queueing behind a check that never returns', async () => {
+    mayJoin.mockImplementation(() => new Promise<boolean>(() => undefined))
+    const { client, socket, sent } = desk()
+    const queue = queueFor(client, socket)
+
+    queue(JSON.stringify({ t: 'sub', rooms: [ROOM] }))
+    for (let i = 0; i < MAX_QUEUED_FRAMES * 3; i++) queue(JSON.stringify({ t: 'ping' }))
+    await settle()
+
+    // Nothing behind the stuck one has run, and nothing beyond the limit was
+    // kept to run later: memory held for this socket is bounded.
+    expect(sent).toEqual([])
+  })
+})
+
+describe('anything else', () => {
+  it('is ignored rather than answered', async () => {
+    const { client, socket, sent } = desk()
+
+    await handleClientMessage(client, socket, 'not json')
+    await say(client, socket, { t: 'nonsense' })
+    await say(client, socket, { t: 'sub', rooms: 'not an array' })
+
+    expect(sent).toEqual([])
+    expect(stats().rooms).toBe(0)
+  })
+})
+
+describe('two workshops', () => {
+  const other = (): { client: Client; socket: object; sent: ServerMessage[] } => {
+    const sent: ServerMessage[] = []
+    return {
+      client: {
+        userId: 'u-9',
+        userName: 'Stranger',
+        organizationId: 'org-2',
+        send: (message) => sent.push(message),
+      },
+      socket: { name: 'another workshop' },
+      sent,
+    }
+  }
+
+  it('never share a room, even if the check that should refuse one says yes', async () => {
+    // The worst case on purpose: the ownership check is broken and lets a
+    // stranger "join" another workshop's work order by name.
+    mayJoin.mockResolvedValue(true)
+    const ours = desk()
+    const theirs = other()
+    await say(ours.client, ours.socket, { t: 'sub', rooms: [ROOM] })
+    await say(ours.client, ours.socket, { t: 'enter', room: ROOM })
+    await say(theirs.client, theirs.socket, { t: 'sub', rooms: [ROOM] })
+    await say(theirs.client, theirs.socket, { t: 'enter', room: ROOM })
+
+    // A change to our job goes to our workshop's room: the stranger is not in it.
+    const told: object[] = []
+    broadcast('org-1|rec:serviceRecord:job-1', (member) => told.push(member))
+    expect(told).toEqual([ours.socket])
+
+    // And neither sees the other standing there.
+    expect(presenceOf('org-1|rec:serviceRecord:job-1').map((u) => u.name)).toEqual(['Christian'])
+    expect(presenceOf('org-2|rec:serviceRecord:job-1').map((u) => u.name)).toEqual(['Stranger'])
+  })
+
+  it('cannot reach another workshop by writing its id into the room name', async () => {
+    mayJoin.mockImplementation(async (room: string) => !room.includes('org-1'))
+    const theirs = other()
+
+    await say(theirs.client, theirs.socket, {
+      t: 'sub',
+      rooms: ['org:org-1', 'org-1|rec:serviceRecord:job-1', ROOM],
+    })
+
+    // Whatever was granted is kept under the stranger's own workshop.
+    const told: object[] = []
+    broadcast('org-1|rec:serviceRecord:job-1', (member) => told.push(member))
+    broadcast('org-1|org:org-1', (member) => told.push(member))
+    expect(told).toEqual([])
+    expect(isInRoom(theirs.socket, 'org-2|rec:serviceRecord:job-1')).toBe(true)
+  })
+})

+ 156 - 0
src/__tests__/lib/realtime/rooms.test.ts

@@ -0,0 +1,156 @@
+/**
+ * @vitest-environment node
+ *
+ * Rooms exist only while somebody is in them.
+ *
+ * A room is per record, and a workshop opens thousands of records a month.
+ * If a room outlived its occupants the server would accumulate one entry per
+ * work order anybody ever opened, which is a leak that shows up as a restart
+ * in month three rather than as a failure anybody can trace. So the rule is
+ * absolute: the last socket out takes the room with it, and a socket that
+ * closes takes all of its rooms.
+ *
+ * `stats()` is the proof, and these tests read it after every path out.
+ */
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+  broadcast,
+  isInRoom,
+  join,
+  leave,
+  leaveAll,
+  MAX_ROOMS_PER_SOCKET,
+  membersOf,
+  resetRooms,
+  roomsOf,
+  stats,
+} from '@/lib/realtime/rooms.server'
+
+const socket = (name: string) => ({ name })
+
+afterEach(() => resetRooms())
+
+describe('joining and leaving', () => {
+  it('puts a socket in a room and takes it out again, leaving nothing', () => {
+    const desk = socket('desk')
+    expect(join(desk, 'rec:serviceRecord:1')).toBe(true)
+    expect(isInRoom(desk, 'rec:serviceRecord:1')).toBe(true)
+    expect(stats()).toEqual({ rooms: 1, members: 1, subscriptions: 1 })
+
+    leave(desk, 'rec:serviceRecord:1')
+
+    expect(isInRoom(desk, 'rec:serviceRecord:1')).toBe(false)
+    expect(stats()).toEqual({ rooms: 0, members: 0, subscriptions: 0 })
+  })
+
+  it('keeps the room while anybody is still in it', () => {
+    const desk = socket('desk')
+    const bay = socket('bay')
+    join(desk, 'rec:serviceRecord:1')
+    join(bay, 'rec:serviceRecord:1')
+
+    leave(desk, 'rec:serviceRecord:1')
+
+    expect(membersOf('rec:serviceRecord:1').size).toBe(1)
+    expect(stats().rooms).toBe(1)
+
+    leave(bay, 'rec:serviceRecord:1')
+    expect(stats()).toEqual({ rooms: 0, members: 0, subscriptions: 0 })
+  })
+
+  it('drops everything a closing socket held, in one call', () => {
+    const desk = socket('desk')
+    for (const id of ['1', '2', '3']) join(desk, `rec:serviceRecord:${id}`)
+    join(desk, 'org:org-1')
+
+    expect(leaveAll(desk).sort()).toEqual([
+      'org:org-1',
+      'rec:serviceRecord:1',
+      'rec:serviceRecord:2',
+      'rec:serviceRecord:3',
+    ])
+    expect(stats()).toEqual({ rooms: 0, members: 0, subscriptions: 0 })
+  })
+
+  it('survives a month of work orders opened and closed', () => {
+    const desk = socket('desk')
+    for (let i = 0; i < 2_000; i++) {
+      const room = `rec:serviceRecord:${i}`
+      join(desk, room)
+      leave(desk, room)
+    }
+    expect(stats()).toEqual({ rooms: 0, members: 0, subscriptions: 0 })
+  })
+
+  it('is safe to leave twice, or to leave what was never joined', () => {
+    const desk = socket('desk')
+    join(desk, 'rec:quote:9')
+    leave(desk, 'rec:quote:9')
+    leave(desk, 'rec:quote:9')
+    leave(socket('nobody'), 'rec:quote:9')
+    expect(leaveAll(socket('nobody'))).toEqual([])
+    expect(stats()).toEqual({ rooms: 0, members: 0, subscriptions: 0 })
+  })
+})
+
+describe('the limit on one socket', () => {
+  it('refuses more rooms than any page needs, so a bad client cannot grow the map', () => {
+    const rogue = socket('rogue')
+    for (let i = 0; i < MAX_ROOMS_PER_SOCKET; i++) {
+      expect(join(rogue, `rec:serviceRecord:${i}`)).toBe(true)
+    }
+    expect(join(rogue, 'rec:serviceRecord:too-many')).toBe(false)
+    expect(roomsOf(rogue).size).toBe(MAX_ROOMS_PER_SOCKET)
+    expect(stats().rooms).toBe(MAX_ROOMS_PER_SOCKET)
+  })
+
+  it('still lets it re-join a room it already holds', () => {
+    const rogue = socket('rogue')
+    for (let i = 0; i < MAX_ROOMS_PER_SOCKET; i++) join(rogue, `rec:serviceRecord:${i}`)
+    expect(join(rogue, 'rec:serviceRecord:0')).toBe(true)
+  })
+})
+
+describe('broadcast', () => {
+  it('reaches everyone in the room and nobody else', () => {
+    const desk = socket('desk')
+    const bay = socket('bay')
+    const elsewhere = socket('elsewhere')
+    join(desk, 'rec:serviceRecord:1')
+    join(bay, 'rec:serviceRecord:1')
+    join(elsewhere, 'rec:serviceRecord:2')
+
+    const told: string[] = []
+    const reached = broadcast('rec:serviceRecord:1', (member) =>
+      told.push((member as { name: string }).name)
+    )
+
+    expect(reached).toBe(2)
+    expect(told.sort()).toEqual(['bay', 'desk'])
+  })
+
+  it('costs nothing for a room nobody is in', () => {
+    expect(broadcast('rec:serviceRecord:cold', () => undefined)).toBe(0)
+    expect(stats().rooms).toBe(0)
+  })
+})
+
+describe('a room belongs to a workshop', () => {
+  it('is kept under the workshop, and read back apart', async () => {
+    const { parseTenantRoom, tenantRoom } = await import('@/lib/realtime/rooms.server')
+    const key = tenantRoom('org-1', 'rec:serviceRecord:job-1')
+    expect(key).toBe('org-1|rec:serviceRecord:job-1')
+    expect(parseTenantRoom(key)).toEqual({
+      organizationId: 'org-1',
+      room: 'rec:serviceRecord:job-1',
+    })
+  })
+
+  it('refuses a workshop id that could be mistaken for part of the room', async () => {
+    const { parseTenantRoom, tenantRoom } = await import('@/lib/realtime/rooms.server')
+    expect(() => tenantRoom('', 'rec:serviceRecord:job-1')).toThrow()
+    expect(() => tenantRoom('org-1|org-2', 'rec:serviceRecord:job-1')).toThrow()
+    expect(parseTenantRoom('no separator')).toBeNull()
+    expect(parseTenantRoom('|rec:serviceRecord:job-1')).toBeNull()
+  })
+})

+ 54 - 0
src/__tests__/lib/realtime/shared-state.test.ts

@@ -0,0 +1,54 @@
+/**
+ * @vitest-environment node
+ *
+ * One process, one of each.
+ *
+ * Next loads a module once per bundle, and development loads it again on
+ * every edit. State kept in a module's own `const` is therefore several
+ * things at once: `withAuth` wrote the author into one copy of the actor
+ * store while the long-lived Prisma hook read another, and a browser was put
+ * into one copy of the room map while changes went to the people in another.
+ * Both looked like the feature simply doing nothing.
+ */
+import { describe, expect, it, vi } from 'vitest'
+
+describe('state shared across bundles', () => {
+  it('is the same room map when the module is loaded a second time', async () => {
+    const first = await import('@/lib/realtime/rooms.server')
+    vi.resetModules()
+    const second = await import('@/lib/realtime/rooms.server')
+    expect(second).not.toBe(first)
+
+    const socket = { name: 'desk' }
+    first.join(socket, 'org-1|rec:serviceRecord:job-1')
+
+    // Joined through one copy, delivered through the other.
+    const told: object[] = []
+    second.broadcast('org-1|rec:serviceRecord:job-1', (member) => told.push(member))
+    expect(told).toEqual([socket])
+    first.resetRooms()
+  })
+
+  it('is the same author when the writer and the reader are different copies', async () => {
+    const writer = await import('@/lib/realtime/actor.server')
+    vi.resetModules()
+    const reader = await import('@/lib/realtime/actor.server')
+    expect(reader).not.toBe(writer)
+
+    const christian = { userId: 'u-1', name: 'Christian', source: 'web' as const }
+    const seen = await writer.runAsActor(christian, async () => reader.currentActor())
+
+    expect(seen).toEqual(christian)
+  })
+
+  it('is the same presence when the module is loaded a second time', async () => {
+    const first = await import('@/lib/realtime/presence.server')
+    vi.resetModules()
+    const second = await import('@/lib/realtime/presence.server')
+
+    first.enterRoom('org-1|rec:serviceRecord:job-1', { name: 'desk' }, { userId: 'u-1', name: 'C' })
+
+    expect(second.presenceOf('org-1|rec:serviceRecord:job-1').map((u) => u.userId)).toEqual(['u-1'])
+    first.resetPresence()
+  })
+})

+ 10 - 2
src/__tests__/lib/with-auth.test.ts

@@ -80,7 +80,11 @@ describe('withAuth', () => {
         { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
         { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
       ],
       ],
     })
     })
-    expect(result).toEqual({ success: false, error: 'Insufficient permissions' })
+    expect(result).toEqual({
+      success: false,
+      error: 'Insufficient permissions',
+      forbidden: true,
+    })
   })
   })
 
 
   it('owner bypasses permission check', async () => {
   it('owner bypasses permission check', async () => {
@@ -158,7 +162,11 @@ describe('withAuth', () => {
         { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
         { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
       ],
       ],
     })
     })
-    expect(result).toEqual({ success: false, error: 'Insufficient permissions' })
+    expect(result).toEqual({
+      success: false,
+      error: 'Insufficient permissions',
+      forbidden: true,
+    })
   })
   })
 
 
   it('super admin with membership bypasses all permission checks', async () => {
   it('super admin with membership bypasses all permission checks', async () => {

+ 33 - 27
src/app/(authenticated)/layout.tsx

@@ -44,6 +44,7 @@ import { countUnreadMessages } from '@/features/messaging/Lib/unreadCount'
 import { addZonedDays, safeTimeZone, startOfZonedDay } from '@/lib/timezone'
 import { addZonedDays, safeTimeZone, startOfZonedDay } from '@/lib/timezone'
 import { technicianIdsForUser } from '@/features/time-tracking/Lib/timeEntries'
 import { technicianIdsForUser } from '@/features/time-tracking/Lib/timeEntries'
 import { TimeClockProvider } from '@/features/time-tracking/Components/TimeClockProvider'
 import { TimeClockProvider } from '@/features/time-tracking/Components/TimeClockProvider'
+import { RealtimeProvider } from '@/features/realtime/RealtimeProvider'
 
 
 export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
 export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
   const data = await getLayoutData()
   const data = await getLayoutData()
@@ -318,37 +319,42 @@ export default async function DashboardLayout({ children }: { children: React.Re
                       initialSeen={seenHints}
                       initialSeen={seenHints}
                       pending={[...pendingHints, ...announcements]}
                       pending={[...pendingHints, ...announcements]}
                     >
                     >
-                      <TimeClockProvider technicianIds={technicianIds}>
-                        <AppSidebar
-                          companyLogo={data.companyLogo}
-                          organizations={data.organizations}
-                          activeOrgId={data.organizationId}
-                          isSuperAdmin={data.isSuperAdmin}
-                          features={features}
-                          tireHotelEnabled={tireHotelEnabled}
-                          aiEnabled={aiEnabled}
-                          visibleSubjects={visibleSubjects}
-                          announcement={announcements[0] ?? null}
-                          isAdminOrOwner={isOwnerOrAdmin}
-                          counts={sidebarCounts}
-                          isTechnician={technicianIds.length > 0}
-                        />
-                        <SidebarInset>
-                          {/* A flex column with a real height, so the `flex-1` every
+                      {/* One socket for the whole app: record changes, who
+                          else is on a record, and the older workshop-wide
+                          channels the remaining pages still read. */}
+                      <RealtimeProvider>
+                        <TimeClockProvider technicianIds={technicianIds}>
+                          <AppSidebar
+                            companyLogo={data.companyLogo}
+                            organizations={data.organizations}
+                            activeOrgId={data.organizationId}
+                            isSuperAdmin={data.isSuperAdmin}
+                            features={features}
+                            tireHotelEnabled={tireHotelEnabled}
+                            aiEnabled={aiEnabled}
+                            visibleSubjects={visibleSubjects}
+                            announcement={announcements[0] ?? null}
+                            isAdminOrOwner={isOwnerOrAdmin}
+                            counts={sidebarCounts}
+                            isTechnician={technicianIds.length > 0}
+                          />
+                          <SidebarInset>
+                            {/* A flex column with a real height, so the `flex-1` every
                           page already writes on its wrapper actually resolves.
                           page already writes on its wrapper actually resolves.
                           Without it a page that wants to fill the window (the
                           Without it a page that wants to fill the window (the
                           work board's week timeline) stopped at its content and
                           work board's week timeline) stopped at its content and
                           left the rest of the screen blank. */}
                           left the rest of the screen blank. */}
-                          <div className="flex min-h-0 flex-1 flex-col pb-14 md:pb-0">
-                            {children}
-                          </div>
-                        </SidebarInset>
-                        <SearchCommand />
-                        <PlateLookupCommand />
-                        {isOwnerOrAdmin && <NotificationInitializer />}
-                        <OnlineTracker />
-                        <InstallBanner />
-                      </TimeClockProvider>
+                            <div className="flex min-h-0 flex-1 flex-col pb-14 md:pb-0">
+                              {children}
+                            </div>
+                          </SidebarInset>
+                          <SearchCommand />
+                          <PlateLookupCommand />
+                          {isOwnerOrAdmin && <NotificationInitializer />}
+                          <OnlineTracker />
+                          <InstallBanner />
+                        </TimeClockProvider>
+                      </RealtimeProvider>
                     </FeatureHintProvider>
                     </FeatureHintProvider>
                   </PlateLookupProvider>
                   </PlateLookupProvider>
                 </ConfirmProvider>
                 </ConfirmProvider>

+ 2 - 2
src/app/api/protected/backup/import/route.ts

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
 import { assertContentLength, assertZipWithinLimits } from '@/lib/backup/zip-guard'
 import { assertContentLength, assertZipWithinLimits } from '@/lib/backup/zip-guard'
 import { rateLimit } from '@/lib/rate-limit'
 import { rateLimit } from '@/lib/rate-limit'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { getAuthContext } from '@/lib/get-auth-context'
-import { db } from '@/lib/db'
+import { db, type TxClient } from '@/lib/db'
 import { isDemoMode } from '@/lib/demo'
 import { isDemoMode } from '@/lib/demo'
 import { clearPlanFor, UPLOAD_CATEGORIES } from '@/lib/backup/manifest'
 import { clearPlanFor, UPLOAD_CATEGORIES } from '@/lib/backup/manifest'
 import { rewriteFileUrl, rewriteFileUrlsWithin, withFileUrls } from '@/lib/backup/file-urls'
 import { rewriteFileUrl, rewriteFileUrlsWithin, withFileUrls } from '@/lib/backup/file-urls'
@@ -80,7 +80,7 @@ function keptReference(id: unknown, restored: ReadonlySet<string> | undefined):
  * and counter sales (top-level records without a vehicle).
  * and counter sales (top-level records without a vehicle).
  */
  */
 async function importServiceRecordTree(
 async function importServiceRecordTree(
-  tx: Prisma.TransactionClient,
+  tx: TxClient,
   sr: Record<string, unknown>,
   sr: Record<string, unknown>,
   opts: {
   opts: {
     organizationId: string
     organizationId: string

+ 258 - 68
src/app/api/protected/ws/route.ts

@@ -1,9 +1,38 @@
 import type { IncomingMessage } from 'node:http'
 import type { IncomingMessage } from 'node:http'
 import type WebSocket from 'ws'
 import type WebSocket from 'ws'
 import { cookies } from 'next/headers'
 import { cookies } from 'next/headers'
+import { resolveMembership } from '@/lib/cached-session'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { notificationBus } from '@/lib/notification-bus'
 import { notificationBus } from '@/lib/notification-bus'
 import { readsNotifications } from '@/lib/notification-roles'
 import { readsNotifications } from '@/lib/notification-roles'
+import { onBusMessage, publishToBus } from '@/lib/realtime/bus.server'
+import { queueFor } from '@/lib/realtime/protocol.server'
+import {
+  broadcast,
+  leaveAll,
+  parseTenantRoom,
+  roomsOf,
+  tenantRoom,
+} from '@/lib/realtime/rooms.server'
+import { leaveAllRooms, onPresenceChange, startPresence } from '@/lib/realtime/presence.server'
+import { orgRoom, presenceColor, recordRoom, type ServerMessage } from '@/lib/realtime/events'
+import { shared } from '@/lib/realtime/shared-state.server'
+
+/**
+ * The one socket a browser holds.
+ *
+ * It carries three things: what changed (by room), who else is on the record
+ * (presence), and the older workshop-wide channels that pages have not moved
+ * off yet. One connection rather than the five the app used to open, because
+ * every one of them was an authentication, a reconnect loop and a place for a
+ * bug of its own.
+ *
+ * Delivery is by room, and a room is only ever joined after the server has
+ * checked it belongs to this socket's workshop (authorize.server.ts). A room
+ * stops existing the moment its last socket leaves, and every room a socket
+ * held is dropped when it closes, so a workshop that opens ten thousand work
+ * orders in a month leaves nothing behind.
+ */
 
 
 // Required so Next.js route validator recognizes this as a valid route module
 // Required so Next.js route validator recognizes this as a valid route module
 export function GET() {
 export function GET() {
@@ -12,52 +41,104 @@ export function GET() {
 
 
 export interface TaggedWebSocket extends WebSocket {
 export interface TaggedWebSocket extends WebSocket {
   userId: string
   userId: string
+  userName: string
   organizationId: string
   organizationId: string
   role: string
   role: string
   isAlive: boolean
   isAlive: boolean
 }
 }
 
 
-// Track all authenticated clients in a Set so the bus listener can broadcast
-const clients = new Set<TaggedWebSocket>()
-
-// Single listener on the global bus — broadcasts to matching org clients.
-// Notifications are the workshop's own feed, which the panel serves to owners
-// and admins only (notificationActions.getNotifications), so the socket holds
-// the same line: every member may connect, not every member is told this.
-notificationBus.on('notification', (notification: { organizationId: string }) => {
-  const payload = JSON.stringify({ type: 'notification', data: notification })
-  for (const client of clients) {
-    if (
-      client.organizationId === notification.organizationId &&
-      readsNotifications(client.role) &&
-      client.readyState === 1 // OPEN
-    ) {
-      client.send(payload)
+/** Every authenticated socket on this instance, for the legacy channels. */
+const clients = shared('route.clients', () => new Set<TaggedWebSocket>())
+
+/** Development only: the socket's own story, for when a page misbehaves. */
+function trace(what: string): void {
+  if (process.env.NODE_ENV !== 'production') console.warn(`[realtime] ${what}`)
+}
+
+function send(socket: TaggedWebSocket, message: ServerMessage): void {
+  if (socket.readyState !== 1) return
+  socket.send(JSON.stringify(message))
+}
+
+/* ------------------------------------------------------- what changed --- */
+
+/**
+ * The listeners that carry events to sockets, registered by whichever copy of
+ * this module was loaded last.
+ *
+ * They used to be registered once and guarded by a flag, which in development
+ * meant the first version of this file to load kept handling every event for
+ * the life of the process, whatever was edited afterwards: old code reading
+ * state that new code had written, failing in ways no restart-free session
+ * could explain. Each load now takes the previous listeners down and puts its
+ * own up. In production the module loads once and this is the same as before.
+ */
+const wiring = shared('route.wiring', () => ({ stop: [] as (() => void)[] }))
+for (const stop of wiring.stop.splice(0)) stop()
+
+startPresence()
+
+// Record changes, from this instance and from every other one.
+wiring.stop.push(
+  onBusMessage((message) => {
+    if (message.t !== 'record') return
+    const { change } = message
+    const rooms = change.id
+      ? [recordRoom(change.kind, change.id), orgRoom(change.organizationId)]
+      : [orgRoom(change.organizationId)]
+    const told = new Set<TaggedWebSocket>()
+    for (const room of rooms) {
+      // Sent to the changed record's own workshop and nowhere else: the room
+      // key begins with it, and a socket is checked against it again on the
+      // way out, so neither wall has to be perfect for the other to hold.
+      broadcast(tenantRoom(change.organizationId, room), (member) => {
+        const socket = member as TaggedWebSocket
+        if (socket.organizationId !== change.organizationId) return
+        if (told.has(socket)) return
+        told.add(socket)
+        send(socket, { t: 'record', change })
+      })
     }
     }
-  }
-})
-
-// Work board events — broadcasts board updates to matching org clients
-notificationBus.on('workboard', (event: { organizationId: string }) => {
-  const payload = JSON.stringify({ type: 'workboard', data: event })
-  for (const client of clients) {
-    if (
-      client.organizationId === event.organizationId &&
-      client.readyState === 1 // OPEN
-    ) {
-      client.send(payload)
+  })
+)
+
+// Who is on a record, whenever it changes here or anywhere else.
+wiring.stop.push(
+  onPresenceChange((key, users) => {
+    const tenant = parseTenantRoom(key)
+    if (!tenant) return
+    broadcast(key, (member) => {
+      const socket = member as TaggedWebSocket
+      if (socket.organizationId !== tenant.organizationId) return
+      // The browser hears the room by the name it asked for.
+      send(socket, { t: 'presence', room: tenant.room, users })
+    })
+  })
+)
+
+// The channels that predate rooms. Still workshop-wide, still filtered by
+// role for the notification feed, and relayed between instances now so a
+// second container does not silently halve who hears them.
+for (const channel of ['notification', 'workboard', 'broadcast'] as const) {
+  const relay = (data: unknown) => publishToBus({ t: 'legacy', channel, data })
+  notificationBus.on(channel, relay)
+  wiring.stop.push(() => notificationBus.off(channel, relay))
+}
+wiring.stop.push(
+  onBusMessage((message) => {
+    if (message.t !== 'legacy') return
+    const { channel, data } = message
+    const organizationId = (data as { organizationId?: string })?.organizationId
+    for (const socket of clients) {
+      if (socket.readyState !== 1) continue
+      if (channel !== 'broadcast' && socket.organizationId !== organizationId) continue
+      if (channel === 'notification' && !readsNotifications(socket.role)) continue
+      send(socket, { t: 'legacy', channel, data })
     }
     }
-  }
-})
-
-// Platform-wide notice. The only event here that ignores organizationId:
-// an infrastructure incident is not one workshop's business, it is everyone's.
-notificationBus.on('broadcast', (broadcast: unknown) => {
-  const payload = JSON.stringify({ type: 'broadcast', data: broadcast })
-  for (const client of clients) {
-    if (client.readyState === 1) client.send(payload)
-  }
-})
+  })
+)
+
+/* -------------------------------------------------------------- session -- */
 
 
 /**
 /**
  * Resolve the session token from the Next.js cookie store.
  * Resolve the session token from the Next.js cookie store.
@@ -90,10 +171,75 @@ function getSessionToken(cookieStore: Awaited<ReturnType<typeof cookies>>): stri
   return dotIndex > 0 ? raw.substring(0, dotIndex) : raw
   return dotIndex > 0 ? raw.substring(0, dotIndex) : raw
 }
 }
 
 
+/* ------------------------------------------------------------ the socket -- */
+
+/** Frames a browser may send before it has been recognised; then it is cut. */
+const MAX_FRAMES_BEFORE_AUTH = 32
+/** A socket that has not been recognised by now never will be. */
+const AUTH_TIMEOUT_MS = 15_000
+/** The server's own ping, which is what finds a link that died silently. */
+const PING_EVERY_MS = 30_000
+/** Every tenth ping, ask whether this session and this membership still exist. */
+const RECHECK_EVERY_PINGS = 10
+
 export function UPGRADE(ws: WebSocket, _server: unknown, _request: IncomingMessage) {
 export function UPGRADE(ws: WebSocket, _server: unknown, _request: IncomingMessage) {
   const client = ws as TaggedWebSocket
   const client = ws as TaggedWebSocket
   client.isAlive = true
   client.isAlive = true
 
 
+  /**
+   * Both listeners go on before anything is awaited.
+   *
+   * Recognising the session takes two database reads, and a browser does not
+   * wait for them: what it sends in that window arrives at a socket nobody is
+   * listening to yet, and `ws` drops a frame with no listener. So frames are
+   * kept until the socket is known and then handled in order. The same goes
+   * for a close during those reads: heard late, it would leave the socket in
+   * `clients` with a timer running for the life of the process.
+   */
+  let closed = false
+  let handle: ((raw: unknown) => void) | null = null
+  let early: unknown[] | null = []
+  let pingInterval: ReturnType<typeof setInterval> | undefined
+
+  const authTimeout = setTimeout(() => {
+    if (!handle) ws.close(4001, 'Not recognised in time')
+  }, AUTH_TIMEOUT_MS)
+  authTimeout.unref?.()
+
+  ws.on('message', (raw: unknown) => {
+    if (handle) return handle(raw)
+    if (!early) return
+    if (early.length >= MAX_FRAMES_BEFORE_AUTH) {
+      early = null
+      ws.close(1008, 'Too much, too early')
+      return
+    }
+    early.push(raw)
+  })
+
+  // Everything this socket held, in one place, on every way out. A socket
+  // that dies without a close event is terminated by the ping below, which
+  // fires 'close' in its turn.
+  ws.on('close', (code: number, reason: unknown) => {
+    closed = true
+    early = null
+    handle = null
+    clearTimeout(authTimeout)
+    if (pingInterval) clearInterval(pingInterval)
+    clients.delete(client)
+    leaveAllRooms(client)
+    leaveAll(client)
+    if (client.userName) {
+      trace(`socket closed for ${client.userName} (${code}${reason ? ` ${reason}` : ''})`)
+    }
+  })
+
+  ws.on('error', () => ws.terminate())
+
+  ws.on('pong', () => {
+    client.isAlive = true
+  })
+
   ;(async () => {
   ;(async () => {
     try {
     try {
       // next-ws patches cookies() to resolve WebSocket request cookies
       // next-ws patches cookies() to resolve WebSocket request cookies
@@ -101,75 +247,119 @@ export function UPGRADE(ws: WebSocket, _server: unknown, _request: IncomingMessa
 
 
       const sessionToken = getSessionToken(cookieStore)
       const sessionToken = getSessionToken(cookieStore)
       if (!sessionToken) {
       if (!sessionToken) {
+        // Names only, never values: enough to see a cookie that is called
+        // something this did not expect.
+        const names = cookieStore.getAll().map((cookie) => cookie.name)
+        trace(`socket refused: no session cookie among [${names.join(', ')}]`)
         ws.close(4001, 'No session token')
         ws.close(4001, 'No session token')
         return
         return
       }
       }
 
 
-      // Look up session in DB
       const session = await db.session.findUnique({
       const session = await db.session.findUnique({
         where: { token: sessionToken },
         where: { token: sessionToken },
-        select: { userId: true, expiresAt: true },
+        select: { userId: true, expiresAt: true, user: { select: { name: true, email: true } } },
       })
       })
 
 
       if (!session || session.expiresAt < new Date()) {
       if (!session || session.expiresAt < new Date()) {
+        trace(
+          `socket refused: ${session ? 'the session has expired' : 'no session has that token'}`
+        )
         ws.close(4001, 'Invalid or expired session')
         ws.close(4001, 'Invalid or expired session')
         return
         return
       }
       }
 
 
-      // Get org membership
       const activeOrgId = cookieStore.get('active-org-id')?.value
       const activeOrgId = cookieStore.get('active-org-id')?.value
 
 
-      const membership = activeOrgId
-        ? await db.organizationMember.findFirst({
-            where: { userId: session.userId, organizationId: activeOrgId },
-            select: { organizationId: true, role: true },
-          })
-        : await db.organizationMember.findFirst({
-            where: { userId: session.userId },
-            select: { organizationId: true, role: true },
-          })
+      // Decided where the rest of the app decides it, so the socket and the
+      // pages can never disagree about which workshop this person is in.
+      const membership = await resolveMembership(session.userId, activeOrgId)
 
 
       if (!membership) {
       if (!membership) {
+        trace(`socket refused: ${session.user?.name ?? session.userId} is in no workshop`)
         ws.close(4001, 'No organization')
         ws.close(4001, 'No organization')
         return
         return
       }
       }
 
 
-      // Every member of the workshop may listen. What they are told is
-      // decided per channel above: work board events (a clock started, a line
-      // of work added) reach everyone, because they say only which record
-      // changed and each listener then reads it back through the actions that
-      // check its own permissions. The notification feed stays with the roles
-      // that can already read it.
+      // It went away while it was being looked up: nothing to register.
+      if (closed || ws.readyState !== 1) return
+
+      // Every member of the workshop may listen. What they are told is decided
+      // per room and per channel: a record's room is checked against this
+      // workshop before it is joined, and the notification feed stays with the
+      // roles that can already read it.
       client.userId = session.userId
       client.userId = session.userId
+      client.userName = session.user?.name || session.user?.email || 'Someone'
       client.organizationId = membership.organizationId
       client.organizationId = membership.organizationId
       client.role = membership.role
       client.role = membership.role
 
 
       clients.add(client)
       clients.add(client)
+      clearTimeout(authTimeout)
 
 
-      // Ping/pong keepalive
-      const pingInterval = setInterval(() => {
+      let pings = 0
+      pingInterval = setInterval(() => {
         if (!client.isAlive) {
         if (!client.isAlive) {
-          clearInterval(pingInterval)
           ws.terminate()
           ws.terminate()
           return
           return
         }
         }
         client.isAlive = false
         client.isAlive = false
         ws.ping()
         ws.ping()
-      }, 30_000)
 
 
-      ws.on('pong', () => {
-        client.isAlive = true
-      })
+        // A session ended from the devices list, or somebody taken out of the
+        // workshop, must stop hearing it. The socket was recognised once, so
+        // it is asked again now and then rather than trusted for ever.
+        if (++pings % RECHECK_EVERY_PINGS !== 0) return
+        Promise.all([
+          db.session.findUnique({ where: { token: sessionToken }, select: { expiresAt: true } }),
+          db.organizationMember.findFirst({
+            where: { userId: client.userId, organizationId: client.organizationId },
+            select: { id: true },
+          }),
+        ])
+          .then(([current, stillMember]) => {
+            if (!current || current.expiresAt < new Date()) ws.close(4001, 'Session ended')
+            else if (!stillMember) ws.close(4001, 'No longer in this workshop')
+          })
+          .catch(() => undefined)
+      }, PING_EVERY_MS)
+      pingInterval.unref?.()
+
+      // One at a time, in the order they were sent (see protocol.server.ts).
+      const queue = queueFor(
+        {
+          userId: client.userId,
+          userName: client.userName,
+          organizationId: client.organizationId,
+          send: (message) => send(client, message),
+        },
+        client,
+        trace
+      )
 
 
-      ws.on('close', () => {
-        clearInterval(pingInterval)
-        clients.delete(client)
+      trace(`socket open for ${client.userName} in ${client.organizationId}`)
+      send(client, {
+        t: 'ready',
+        you: {
+          userId: client.userId,
+          name: client.userName,
+          color: presenceColor(client.userId),
+        },
       })
       })
 
 
-      ws.send(JSON.stringify({ type: 'connected' }))
+      // Whatever arrived while the session was being read, then everything after.
+      const waiting = early ?? []
+      early = null
+      handle = queue
+      for (const raw of waiting) queue(raw)
     } catch (err) {
     } catch (err) {
       console.error('[WS] Auth error:', err)
       console.error('[WS] Auth error:', err)
       ws.close(4500, 'Auth error')
       ws.close(4500, 'Auth error')
     }
     }
   })()
   })()
 }
 }
+
+/** What this instance is holding, for a health readout and for the tests. */
+export function socketStats(): { sockets: number; rooms: number } {
+  let rooms = 0
+  for (const socket of clients) rooms += roomsOf(socket).size
+  return { sockets: clients.size, rooms }
+}

+ 11 - 47
src/components/broadcast-live.tsx

@@ -1,6 +1,7 @@
 'use client'
 'use client'
 
 
 import { useEffect } from 'react'
 import { useEffect } from 'react'
+import { useRealtime } from '@/features/realtime/RealtimeProvider'
 import { setLiveBroadcast } from './broadcast-store'
 import { setLiveBroadcast } from './broadcast-store'
 import type { Broadcast } from '@/lib/broadcast'
 import type { Broadcast } from '@/lib/broadcast'
 
 
@@ -12,56 +13,19 @@ import type { Broadcast } from '@/lib/broadcast'
  * would only ever fail and retry. Someone signed out still sees the notice,
  * would only ever fail and retry. Someone signed out still sees the notice,
  * just on the page they load rather than the moment it is posted.
  * just on the page they load rather than the moment it is posted.
  *
  *
- * A socket of its own, not the notification one. That is mounted for owners
- * and admins only, and an outage notice has to reach the technician in the bay
- * as much as the person who owns the shop.
+ * On the app's one socket (features/realtime), which every signed-in person
+ * holds, technician in the bay included: an outage notice is the one thing
+ * here that ignores which workshop you are in.
  */
  */
 export function BroadcastLive() {
 export function BroadcastLive() {
-  useEffect(() => {
-    // Liveness as a closure, not a ref: StrictMode remounts in dev, and a ref
-    // lets the old socket's onclose schedule a reconnect against the new run,
-    // leaving two sockets open.
-    let alive = true
-    let socket: WebSocket | null = null
-    let retry: ReturnType<typeof setTimeout> | undefined
-    // Backs off so a server that is down, which is exactly when a notice gets
-    // posted, does not get hammered by every open tab in every workshop.
-    let attempt = 0
-
-    const connect = () => {
-      if (!alive) return
-      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
-      socket = new WebSocket(`${protocol}//${window.location.host}/api/protected/ws`)
-
-      socket.onopen = () => {
-        attempt = 0
-      }
-
-      socket.onmessage = (event) => {
-        try {
-          const message = JSON.parse(event.data)
-          if (message.type !== 'broadcast') return
-          setLiveBroadcast((message.data as Broadcast | null) ?? null)
-        } catch {
-          // Someone else's frame, or a truncated one. Not ours to report.
-        }
-      }
+  const realtime = useRealtime()
 
 
-      socket.onclose = () => {
-        if (!alive) return
-        attempt += 1
-        retry = setTimeout(connect, Math.min(30_000, 1000 * 2 ** attempt))
-      }
-    }
-
-    connect()
-
-    return () => {
-      alive = false
-      clearTimeout(retry)
-      socket?.close()
-    }
-  }, [])
+  useEffect(() => {
+    if (!realtime) return
+    return realtime.onLegacy('broadcast', (data) => {
+      setLiveBroadcast((data as Broadcast | null) ?? null)
+    })
+  }, [realtime])
 
 
   return null
   return null
 }
 }

+ 2 - 1
src/components/ui/rich-text-editor.tsx

@@ -74,7 +74,8 @@ export function RichTextEditor({
   // Sync external content changes (e.g. when switching between create/edit)
   // Sync external content changes (e.g. when switching between create/edit)
   useEffect(() => {
   useEffect(() => {
     if (editor && content !== editor.getHTML()) {
     if (editor && content !== editor.getHTML()) {
-      editor.commands.setContent(content)
+      // Putting the saved text in is not an edit, and must not be reported as one.
+      editor.commands.setContent(content, { emitUpdate: false })
     }
     }
   }, [content, editor])
   }, [content, editor])
 
 

+ 3 - 2
src/features/inventory/Lib/reconcileStock.ts

@@ -1,3 +1,4 @@
+import type { TxClient } from '@/lib/db'
 import type { Prisma } from '@/generated/prisma/client'
 import type { Prisma } from '@/generated/prisma/client'
 
 
 /**
 /**
@@ -84,7 +85,7 @@ function stockUnits(quantity: number): number {
  * together.
  * together.
  */
  */
 export async function reconcileInventoryForParts(
 export async function reconcileInventoryForParts(
-  tx: Prisma.TransactionClient,
+  tx: TxClient,
   organizationId: string,
   organizationId: string,
   previous: readonly StockPartLine[],
   previous: readonly StockPartLine[],
   next: readonly StockPartLine[],
   next: readonly StockPartLine[],
@@ -154,7 +155,7 @@ export async function reconcileInventoryForParts(
  * Returns false when no row matched — unknown part, or wrong organization.
  * Returns false when no row matched — unknown part, or wrong organization.
  */
  */
 export async function recordAbsoluteStockChange(
 export async function recordAbsoluteStockChange(
-  tx: Prisma.TransactionClient,
+  tx: TxClient,
   organizationId: string,
   organizationId: string,
   inventoryPartId: string,
   inventoryPartId: string,
   newQuantity: number,
   newQuantity: number,

+ 47 - 75
src/features/notifications/hooks/useNotificationWebSocket.ts

@@ -1,14 +1,20 @@
 'use client'
 'use client'
 
 
-import { useEffect, useRef } from 'react'
+import { useEffect } from 'react'
+import { useRealtime, useRealtimeState } from '@/features/realtime/RealtimeProvider'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
-import { useNotificationStore } from '../store/notificationStore'
+import { useNotificationStore, type Notification } from '../store/notificationStore'
 import { getNotifications, markNotificationRead } from '../Actions/notificationActions'
 import { getNotifications, markNotificationRead } from '../Actions/notificationActions'
 import { getActiveSmsCustomerId } from '@/features/sms/activeSmsView'
 import { getActiveSmsCustomerId } from '@/features/sms/activeSmsView'
 
 
 export function useNotificationWebSocket() {
 export function useNotificationWebSocket() {
-  const wsRef = useRef<WebSocket | null>(null)
-  const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
+  const realtime = useRealtime()
+  const { status } = useRealtimeState()
+
+  useEffect(() => {
+    useNotificationStore.getState().setConnected(status === 'ready')
+    return () => useNotificationStore.getState().setConnected(false)
+  }, [status])
 
 
   useEffect(() => {
   useEffect(() => {
     // Liveness is a per-effect-run closure, not a shared ref: with a ref, a
     // Liveness is a per-effect-run closure, not a shared ref: with a ref, a
@@ -28,79 +34,45 @@ export function useNotificationWebSocket() {
       }
       }
     })
     })
 
 
-    function connect() {
-      if (!alive) return
-
-      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
-      const url = `${protocol}//${window.location.host}/api/protected/ws`
-      const ws = new WebSocket(url)
-      wsRef.current = ws
-
-      ws.onopen = () => {
-        useNotificationStore.getState().setConnected(true)
-      }
-
-      ws.onmessage = (event) => {
-        try {
-          const msg = JSON.parse(event.data)
-          if (msg.type === 'notification') {
-            const data = msg.data
-
-            // If user is already viewing SMS for this customer, auto-read and skip toast
-            const activeSmsCid = getActiveSmsCustomerId()
-            if (
-              activeSmsCid &&
-              data.type === 'sms_inbound' &&
-              data.entityUrl === `/messages?customerId=${activeSmsCid}`
-            ) {
-              // Still add it to the store but immediately mark as read
-              const added = { ...data, read: true }
-              useNotificationStore.getState().addNotification(added)
-              // Decrement the unread count that addNotification just bumped
-              useNotificationStore.setState((s) => ({
-                unreadCount: Math.max(0, s.unreadCount - 1),
-              }))
-              markNotificationRead(data.id)
-              return
-            }
-
-            useNotificationStore.getState().addNotification(data)
-            const isSms = data.type === 'sms_inbound'
-            toast(data.title, {
-              description: data.message,
-              ...(isSms && { duration: 5 * 60 * 1000 }),
-              action: {
-                label: 'View',
-                onClick: () => {
-                  window.location.href = data.entityUrl
-                },
-              },
-            })
-          }
-        } catch {
-          // ignore malformed messages
-        }
-      }
+    return () => {
+      alive = false
+    }
+  }, [])
 
 
-      ws.onclose = () => {
-        useNotificationStore.getState().setConnected(false)
-        wsRef.current = null
-        if (alive) {
-          reconnectTimer.current = setTimeout(connect, 3000)
-        }
-      }
+  // The workshop's feed, on the app's one socket. The server only sends it to
+  // the roles that may read it (lib/notification-roles), so nothing here has
+  // to filter by role.
+  useEffect(() => {
+    if (!realtime) return
+    return realtime.onLegacy('notification', (raw) => {
+      const data = raw as Notification
+      if (!data?.id) return
 
 
-      ws.onerror = () => {
-        ws.close()
+      // Already looking at this conversation: file it read, and stay quiet.
+      const activeSmsCid = getActiveSmsCustomerId()
+      if (
+        activeSmsCid &&
+        data.type === 'sms_inbound' &&
+        data.entityUrl === `/messages?customerId=${activeSmsCid}`
+      ) {
+        useNotificationStore.getState().addNotification({ ...data, read: true })
+        useNotificationStore.setState((s) => ({ unreadCount: Math.max(0, s.unreadCount - 1) }))
+        markNotificationRead(data.id)
+        return
       }
       }
-    }
 
 
-    connect()
-
-    return () => {
-      alive = false
-      clearTimeout(reconnectTimer.current)
-      wsRef.current?.close()
-    }
-  }, []) // no deps — mount once
+      useNotificationStore.getState().addNotification(data)
+      const isSms = data.type === 'sms_inbound'
+      toast(data.title, {
+        description: data.message,
+        ...(isSms && { duration: 5 * 60 * 1000 }),
+        action: {
+          label: 'View',
+          onClick: () => {
+            window.location.href = data.entityUrl
+          },
+        },
+      })
+    })
+  }, [realtime])
 }
 }

+ 81 - 0
src/features/realtime/Components/PresenceChips.tsx

@@ -0,0 +1,81 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import type { PresenceUser, RecordKind } from '@/lib/realtime/events'
+import { useRecordPresence } from '../hooks'
+
+/**
+ * Who else has this record open.
+ *
+ * The point is not decoration: two people on one work order used to find out
+ * by saving over each other. A chip says "Marco is in here too" before
+ * anybody types, which is the cheapest possible way to prevent it.
+ *
+ * Only other people are shown. Your own chip would be a permanent fixture
+ * that says nothing, and the space on a work order header is worth more than
+ * that.
+ */
+
+function initialsOf(name: string): string {
+  const parts = name.trim().split(/\s+/).filter(Boolean)
+  if (parts.length === 0) return '?'
+  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
+  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
+}
+
+/** At most this many faces; the rest become "+3". */
+const SHOWN = 3
+
+export function PresenceChips({
+  kind,
+  id,
+  className,
+}: {
+  kind: RecordKind
+  id: string
+  className?: string
+}) {
+  const t = useTranslations('realtime')
+  const { others } = useRecordPresence(kind, id)
+  if (others.length === 0) return null
+
+  const shown = others.slice(0, SHOWN)
+  const hidden = others.length - shown.length
+
+  return (
+    <div
+      data-testid="presence-chips"
+      className={cn('flex items-center -space-x-1.5', className)}
+      aria-label={t('alsoHere', { count: others.length })}
+    >
+      {shown.map((user) => (
+        <Chip key={user.userId} user={user} label={t('alsoHereName', { name: user.name })} />
+      ))}
+      {hidden > 0 && (
+        <span className="z-10 flex h-6 min-w-6 items-center justify-center rounded-full border border-background bg-muted px-1 text-[10px] font-semibold text-muted-foreground">
+          +{hidden}
+        </span>
+      )}
+    </div>
+  )
+}
+
+function Chip({ user, label }: { user: PresenceUser; label: string }) {
+  return (
+    <Tooltip>
+      <TooltipTrigger asChild>
+        <span
+          data-testid="presence-chip"
+          data-user={user.userId}
+          style={{ backgroundColor: user.color }}
+          className="flex h-6 w-6 items-center justify-center rounded-full border border-background text-[10px] font-semibold text-white shadow-sm"
+        >
+          {initialsOf(user.name)}
+        </span>
+      </TooltipTrigger>
+      <TooltipContent side="bottom">{label}</TooltipContent>
+    </Tooltip>
+  )
+}

+ 66 - 0
src/features/realtime/RealtimeProvider.tsx

@@ -0,0 +1,66 @@
+'use client'
+
+import {
+  createContext,
+  useContext,
+  useEffect,
+  useState,
+  useSyncExternalStore,
+  type ReactNode,
+} from 'react'
+import { CollaborationManager, type ManagerState } from './manager'
+
+/**
+ * Hands the page its collaboration manager, and nothing else.
+ *
+ * The app used to open five sockets, one per feature. There is one now, and
+ * it lives in `CollaborationManager`: this component only decides when the
+ * manager runs (while the app shell is mounted) and makes it reachable.
+ *
+ * What is in the context is the manager itself, which is the same object for
+ * the life of the tab. That matters more than it looks: a context value that
+ * changed whenever the connection did would re-run every effect that depends
+ * on it, and each of those is a room left and joined again for no reason.
+ * Connection state is read separately, through `useRealtimeState`, by the few
+ * things that draw it.
+ */
+
+const RealtimeContext = createContext<CollaborationManager | null>(null)
+
+export function RealtimeProvider({ children }: { children: ReactNode }) {
+  const [manager] = useState(() => new CollaborationManager())
+
+  // Children's effects run before this one, so by the time the socket opens
+  // the manager already knows what the page wants. In development React runs
+  // this twice: `stop` then `start` is one socket closed and one opened, with
+  // everything the page asked for kept across it.
+  useEffect(() => {
+    manager.start()
+    return () => manager.stop()
+  }, [manager])
+
+  return <RealtimeContext.Provider value={manager}>{children}</RealtimeContext.Provider>
+}
+
+/**
+ * Quiet without a provider, on purpose: live updates are how a page stays
+ * current, never how it works. A page rendered outside the app shell, or in a
+ * test, renders and simply does not update by itself.
+ */
+export function useRealtime(): CollaborationManager | null {
+  return useContext(RealtimeContext)
+}
+
+const OFFLINE: ManagerState = { status: 'idle', me: null }
+const nothingToWatch = () => () => undefined
+const offline = () => OFFLINE
+
+/** Whether the link is up, and who this browser is signed in as. */
+export function useRealtimeState(): ManagerState {
+  const manager = useRealtime()
+  return useSyncExternalStore(
+    manager ? manager.watchState : nothingToWatch,
+    manager ? manager.getState : offline,
+    offline
+  )
+}

+ 166 - 0
src/features/realtime/hooks.ts

@@ -0,0 +1,166 @@
+'use client'
+
+import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
+import { useRouter } from 'next/navigation'
+import {
+  recordRoom,
+  type PresenceUser,
+  type RecordChange,
+  type RecordKind,
+} from '@/lib/realtime/events'
+import type { Me } from './manager'
+import { useRealtime, useRealtimeState } from './RealtimeProvider'
+import { createRefreshGovernor } from './refresh-governor'
+
+/**
+ * What a page uses. Everything here is a thin layer over the collaboration
+ * manager, and every one of them is safe to call when there is no provider:
+ * the page renders, and does not update by itself.
+ *
+ * Making a new kind of page collaborative is two lines: `useLiveRecord` to
+ * stay current, and `<PresenceChips>` to show who else is there.
+ */
+
+/**
+ * Tells you when this record changed somewhere else, and after a
+ * reconnection, when something may have changed while the socket was down.
+ *
+ * The handler is called with the change for the first case and with `null`
+ * for the second, because a resync has no single change to describe.
+ */
+export function useRecordChanges(
+  kind: RecordKind,
+  id: string | null | undefined,
+  handler: (change: RecordChange | null) => void
+): void {
+  const realtime = useRealtime()
+  const ref = useRef(handler)
+  useEffect(() => {
+    ref.current = handler
+  }, [handler])
+
+  useEffect(() => {
+    if (!realtime || !id) return
+    const stopWatching = realtime.watch(recordRoom(kind, id), (change) => ref.current(change))
+    const stopResync = realtime.onResync(() => ref.current(null))
+    return () => {
+      stopWatching()
+      stopResync()
+    }
+  }, [realtime, kind, id])
+}
+
+/**
+ * The record kept current on screen: re-read the page's own server data when
+ * it changes somewhere else.
+ *
+ * Your own changes are skipped, because the page that made them has already
+ * shown the result and re-reading would fight the form somebody is typing in.
+ * When and how often the re-read happens is the governor's decision
+ * (refresh-governor.ts), which is what makes it impossible for this hook to
+ * turn into a page that polls.
+ */
+export function useLiveRecord(
+  kind: RecordKind,
+  id: string | null | undefined,
+  options: { onChange?: (change: RecordChange | null) => void } = {}
+): void {
+  const router = useRouter()
+  const realtime = useRealtime()
+  const onChange = useRef(options.onChange)
+  useEffect(() => {
+    onChange.current = options.onChange
+  }, [options.onChange])
+
+  const governor = useRef<ReturnType<typeof createRefreshGovernor> | null>(null)
+  useEffect(() => {
+    const created = createRefreshGovernor({
+      refresh: () => router.refresh(),
+      onPressure: (gapMs) => {
+        if (process.env.NODE_ENV !== 'production') {
+          console.warn(
+            `[realtime] ${kind}:${id} is changing unusually often; re-reading every ${gapMs} ms`
+          )
+        }
+      },
+    })
+    governor.current = created
+    const onVisible = () => {
+      if (document.visibilityState === 'visible') created.visible()
+    }
+    document.addEventListener('visibilitychange', onVisible)
+    return () => {
+      document.removeEventListener('visibilitychange', onVisible)
+      created.dispose()
+      governor.current = null
+    }
+  }, [router, kind, id])
+
+  useRecordChanges(
+    kind,
+    id,
+    useCallback(
+      (change) => {
+        const me = realtime?.getState().me
+        if (change?.by.userId && change.by.userId === me?.userId && id) {
+          // Unless this person has the record open somewhere else as well: a
+          // save on the laptop has to reach their own tablet in the bay. The
+          // room's presence says so, as `devices`, when the page shows chips.
+          const mine = realtime
+            ?.presenceOf(recordRoom(kind, id))
+            .find((user) => user.userId === me.userId)
+          if (!mine || mine.devices < 2) return
+        }
+        onChange.current?.(change)
+        governor.current?.request()
+      },
+      [realtime, kind, id]
+    )
+  )
+}
+
+const NOBODY: PresenceUser[] = []
+const nothingToWatch = () => () => undefined
+
+/**
+ * Who else has this record open.
+ *
+ * Standing in the room is what makes this browser appear in everyone else's
+ * chips, so a page that only wants to watch changes should use
+ * `useRecordChanges` and not this. Leaving happens on unmount, and on the
+ * socket closing. Any number of components may call this for one record:
+ * the manager counts them, and the server sees one person.
+ */
+export function useRecordPresence(
+  kind: RecordKind,
+  id: string | null | undefined
+): { others: PresenceUser[]; me: Me | null } {
+  const realtime = useRealtime()
+  const { me } = useRealtimeState()
+  const room = id ? recordRoom(kind, id) : null
+
+  useEffect(() => {
+    if (!realtime || !room) return
+    return realtime.join(room)
+  }, [realtime, room])
+
+  const subscribe = useMemo(
+    () =>
+      realtime && room
+        ? (onChange: () => void) => realtime.watchPresence(room, onChange)
+        : nothingToWatch,
+    [realtime, room]
+  )
+
+  const everyone = useSyncExternalStore(
+    subscribe,
+    () => (realtime && room ? realtime.presenceOf(room) : NOBODY),
+    () => NOBODY
+  )
+
+  const others = useMemo(
+    () => everyone.filter((user) => user.userId !== me?.userId),
+    [everyone, me?.userId]
+  )
+  return { others, me }
+}

+ 558 - 0
src/features/realtime/manager.ts

@@ -0,0 +1,558 @@
+import {
+  PRESENCE_HEARTBEAT_MS,
+  REALTIME_PATH,
+  type ClientMessage,
+  type PresenceUser,
+  type RecordChange,
+  type ServerMessage,
+} from '@/lib/realtime/events'
+
+/**
+ * The browser's half of the live layer, and the one place that knows what
+ * this tab is following.
+ *
+ * It is a plain class, not a component, for two reasons. Its lifetime is the
+ * tab's, not a render's: React mounts, unmounts and re-runs effects as it
+ * likes (twice over in development), and a socket whose existence depends on
+ * that is a socket that gets lost. And a class can be driven by a test with a
+ * pretend socket, which is how the rules below are proven rather than hoped.
+ *
+ * **What it keeps.** One entry per room (`rooms`), holding everything about
+ * it: who is listening for changes, how many components stand in it, and who
+ * else is there. An entry exists while
+ * something on the page wants the room and is deleted the moment nothing
+ * does. There is no second map to fall out of step with it.
+ *
+ * **What it promises.**
+ *
+ * - *Wanted, not sent.* Pages say what they want; the manager makes the
+ *   server agree. Nothing goes out before the server has said `ready`, and on
+ *   every `ready` the whole of what is wanted is said again, so a reconnect
+ *   and a first connection are the same code.
+ * - *One socket, and only its own events.* Every handler is tied to the
+ *   socket it was made for. A socket that was replaced can close as late as
+ *   it likes without touching its successor.
+ * - *A dead link is found.* A ping that is not answered by the next one is a
+ *   lost connection, whatever the browser believes.
+ * - *It gives up politely.* Retries back off to five minutes, with jitter so
+ *   a deploy is not met by every tab at once, and stop entirely when the
+ *   server says the session is gone.
+ * - *Nothing is left behind.* `stop()` clears every timer and listener, and
+ *   `stats()` is what the tests read to prove it.
+ */
+
+export type RecordHandler = (change: RecordChange) => void
+export type LegacyChannel = 'notification' | 'workboard' | 'broadcast'
+export type LegacyHandler = (data: unknown) => void
+
+export type ConnectionStatus =
+  /** Not started, or stopped. */
+  | 'idle'
+  /** A socket is opening, or open and waiting to be recognised. */
+  | 'connecting'
+  /** Recognised: everything wanted has been asked for. */
+  | 'ready'
+  /** Lost, and waiting to try again. */
+  | 'waiting'
+  /** The server refused the session. Not retried: a sign-in is a page load. */
+  | 'refused'
+
+export interface Me {
+  userId: string
+  name: string
+  color: string
+}
+
+export interface ManagerState {
+  status: ConnectionStatus
+  me: Me | null
+}
+
+/** The part of a WebSocket this uses, so a test can stand in for one. */
+export interface SocketLike {
+  readyState: number
+  send(data: string): void
+  close(code?: number, reason?: string): void
+  onopen: ((event: unknown) => void) | null
+  onmessage: ((event: { data: unknown }) => void) | null
+  onclose: ((event: { code?: number }) => void) | null
+  onerror: ((event: unknown) => void) | null
+}
+
+export interface ManagerOptions {
+  createSocket?: (url: string) => SocketLike
+  url?: () => string
+  /** 0..1, for the jitter. Fixed in tests. */
+  random?: () => number
+}
+
+interface Room {
+  /** Listening for record changes. */
+  handlers: Set<RecordHandler>
+  /** Components that put this person in the room's presence. */
+  standing: number
+  /** Re-render when the people in the room change. */
+  presenceWatchers: Set<() => void>
+  /** What this socket has been asked for: the subscription, and presence. */
+  asked: boolean
+  entered: boolean
+  users: PresenceUser[]
+}
+
+const OPEN = 1
+/** Close codes the server uses for "this session is not valid". */
+const REFUSED = new Set([4001])
+export const RETRY_MS = [1_000, 2_000, 5_000, 10_000, 30_000, 60_000, 120_000, 300_000]
+/** Recognition takes two database reads; longer than this is a stuck socket. */
+export const READY_TIMEOUT_MS = 15_000
+
+/** Shared, so an empty room is the same array every time it is read. */
+const NOBODY: PresenceUser[] = []
+
+export class CollaborationManager {
+  private readonly rooms = new Map<string, Room>()
+  private readonly resyncWatchers = new Set<() => void>()
+  private readonly legacyWatchers = new Map<LegacyChannel, Set<LegacyHandler>>()
+  private readonly stateWatchers = new Set<() => void>()
+
+  private state: ManagerState = { status: 'idle', me: null }
+  private socket: SocketLike | null = null
+  private started = false
+  /** True once any socket of this page has been ready: the next is a *re*connect. */
+  private wasReady = false
+  private attempt = 0
+  private awaitingPong = false
+
+  private connectTimer: ReturnType<typeof setTimeout> | null = null
+  private retryTimer: ReturnType<typeof setTimeout> | null = null
+  private readyTimer: ReturnType<typeof setTimeout> | null = null
+  private heartbeat: ReturnType<typeof setInterval> | null = null
+
+  private readonly createSocket: (url: string) => SocketLike
+  private readonly url: () => string
+  private readonly random: () => number
+
+  constructor(options: ManagerOptions = {}) {
+    this.createSocket =
+      options.createSocket ?? ((url) => new WebSocket(url) as unknown as SocketLike)
+    this.url =
+      options.url ??
+      (() => {
+        const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+        return `${protocol}//${window.location.host}${REALTIME_PATH}`
+      })
+    this.random = options.random ?? Math.random
+  }
+
+  /* ---------------------------------------------------------- lifetime -- */
+
+  /** Safe to call twice; what pages asked for before it is kept and sent. */
+  start(): void {
+    if (this.started) return
+    this.started = true
+    if (typeof window !== 'undefined') {
+      window.addEventListener('online', this.wake)
+      document.addEventListener('visibilitychange', this.wake)
+    }
+    // A tick later, not now. Development mounts the app shell twice, and a
+    // socket opened by the first mount is closed by its cleanup before it has
+    // connected: a wasted handshake, and "WebSocket is closed before the
+    // connection is established" in every developer's console. A start that
+    // is stopped straight away now opens nothing.
+    this.connectTimer = setTimeout(() => {
+      this.connectTimer = null
+      this.connect()
+    }, 0)
+  }
+
+  /**
+   * Closes the socket and clears every timer and listener. What pages want is
+   * kept, because they have not stopped wanting it: `start()` picks it up.
+   */
+  stop(): void {
+    if (!this.started) return
+    this.started = false
+    if (typeof window !== 'undefined') {
+      window.removeEventListener('online', this.wake)
+      document.removeEventListener('visibilitychange', this.wake)
+    }
+    this.clearTimers()
+    const socket = this.socket
+    this.socket = null
+    if (socket) this.discard(socket)
+    this.forgetServerState()
+    this.attempt = 0
+    this.setState({ status: 'idle', me: this.state.me })
+  }
+
+  /* ------------------------------------------------------ what pages ask -- */
+
+  /** Hear about changes to a room's record. Returns the way to stop. */
+  watch(room: string, handler: RecordHandler): () => void {
+    const entry = this.roomFor(room)
+    entry.handlers.add(handler)
+    this.ask(room, entry)
+    let stopped = false
+    return () => {
+      if (stopped) return
+      stopped = true
+      entry.handlers.delete(handler)
+      this.release(room, entry)
+    }
+  }
+
+  /**
+   * Stand in a room, which is what puts this person in everyone else's chips.
+   * Counted: two components on one page are one presence, gone when the last
+   * of them is.
+   */
+  join(room: string): () => void {
+    const entry = this.roomFor(room)
+    entry.standing++
+    this.ask(room, entry)
+    this.enter(room, entry)
+    let left = false
+    return () => {
+      if (left) return
+      left = true
+      entry.standing--
+      if (entry.standing === 0) {
+        if (entry.entered) this.send({ t: 'leave', room })
+        entry.entered = false
+      }
+      this.release(room, entry)
+    }
+  }
+
+  /** The people in a room, as last told. The same array until it changes. */
+  presenceOf(room: string): PresenceUser[] {
+    return this.rooms.get(room)?.users ?? NOBODY
+  }
+
+  /** For useSyncExternalStore. Watching alone does not hold the room. */
+  watchPresence(room: string, onChange: () => void): () => void {
+    const entry = this.roomFor(room)
+    entry.presenceWatchers.add(onChange)
+    return () => {
+      entry.presenceWatchers.delete(onChange)
+      this.release(room, entry)
+    }
+  }
+
+  /** Called after a *re*connection, when changes may have been missed. */
+  onResync(handler: () => void): () => void {
+    this.resyncWatchers.add(handler)
+    return () => {
+      this.resyncWatchers.delete(handler)
+    }
+  }
+
+  /** The old workshop-wide channels, until every page has moved to rooms. */
+  onLegacy(channel: LegacyChannel, handler: LegacyHandler): () => void {
+    const handlers = this.legacyWatchers.get(channel) ?? new Set<LegacyHandler>()
+    handlers.add(handler)
+    this.legacyWatchers.set(channel, handlers)
+    return () => {
+      handlers.delete(handler)
+      if (handlers.size === 0) this.legacyWatchers.delete(channel)
+    }
+  }
+
+  getState = (): ManagerState => this.state
+
+  watchState = (onChange: () => void): (() => void) => {
+    this.stateWatchers.add(onChange)
+    return () => {
+      this.stateWatchers.delete(onChange)
+    }
+  }
+
+  /** What is being held right now. The tests read this to prove nothing leaks. */
+  stats(): {
+    rooms: number
+    handlers: number
+    standing: number
+    presenceWatchers: number
+    resyncWatchers: number
+    legacyWatchers: number
+    stateWatchers: number
+    timers: number
+    socket: boolean
+  } {
+    let handlers = 0
+    let standing = 0
+    let presenceWatchers = 0
+    for (const room of this.rooms.values()) {
+      handlers += room.handlers.size
+      standing += room.standing
+      presenceWatchers += room.presenceWatchers.size
+    }
+    let legacyWatchers = 0
+    for (const set of this.legacyWatchers.values()) legacyWatchers += set.size
+    const timers = [this.connectTimer, this.retryTimer, this.readyTimer, this.heartbeat].filter(
+      (timer) => timer !== null
+    ).length
+    return {
+      rooms: this.rooms.size,
+      handlers,
+      standing,
+      presenceWatchers,
+      resyncWatchers: this.resyncWatchers.size,
+      legacyWatchers,
+      stateWatchers: this.stateWatchers.size,
+      timers,
+      socket: this.socket !== null,
+    }
+  }
+
+  /* ---------------------------------------------------------------- rooms -- */
+
+  private roomFor(room: string): Room {
+    let entry = this.rooms.get(room)
+    if (!entry) {
+      entry = {
+        handlers: new Set(),
+        standing: 0,
+        presenceWatchers: new Set(),
+        asked: false,
+        entered: false,
+        users: NOBODY,
+      }
+      this.rooms.set(room, entry)
+    }
+    return entry
+  }
+
+  /** Held means the server should have this socket in the room. */
+  private isHeld(entry: Room): boolean {
+    return entry.handlers.size > 0 || entry.standing > 0
+  }
+
+  private ask(room: string, entry: Room): void {
+    if (entry.asked) return
+    if (this.send({ t: 'sub', rooms: [room] })) entry.asked = true
+  }
+
+  /** After something let go: tell the server, and drop the entry when it is empty. */
+  private release(room: string, entry: Room): void {
+    if (this.rooms.get(room) !== entry) return
+    if (this.isHeld(entry)) return
+    if (entry.asked) this.send({ t: 'unsub', rooms: [room] })
+    entry.asked = false
+    entry.entered = false
+    if (entry.users !== NOBODY) {
+      entry.users = NOBODY
+      for (const watcher of [...entry.presenceWatchers]) watcher()
+    }
+    if (entry.presenceWatchers.size === 0) this.rooms.delete(room)
+  }
+
+  private enter(room: string, entry: Room): void {
+    if (entry.entered) return
+    if (this.send({ t: 'enter', room })) entry.entered = true
+  }
+
+  /** Everything wanted, said again: the first thing done on every `ready`. */
+  private sayWhatIsWanted(): void {
+    const held: string[] = []
+    for (const [room, entry] of this.rooms) {
+      if (!this.isHeld(entry)) continue
+      held.push(room)
+      entry.asked = true
+    }
+    if (held.length > 0) this.send({ t: 'sub', rooms: held })
+    for (const [room, entry] of this.rooms) {
+      if (entry.standing > 0) this.enter(room, entry)
+    }
+  }
+
+  /** A new socket knows nothing the old one was told. */
+  private forgetServerState(): void {
+    for (const entry of this.rooms.values()) {
+      entry.asked = false
+      entry.entered = false
+      if (entry.users !== NOBODY) {
+        entry.users = NOBODY
+        for (const watcher of [...entry.presenceWatchers]) watcher()
+      }
+    }
+  }
+
+  /* --------------------------------------------------------------- socket -- */
+
+  private send(message: ClientMessage): boolean {
+    const socket = this.socket
+    if (!socket || this.state.status !== 'ready' || socket.readyState !== OPEN) return false
+    socket.send(JSON.stringify(message))
+    return true
+  }
+
+  private connect(): void {
+    if (!this.started || this.socket) return
+    let socket: SocketLike
+    try {
+      socket = this.createSocket(this.url())
+    } catch {
+      this.retryLater()
+      return
+    }
+    this.socket = socket
+    this.setState({ status: 'connecting', me: this.state.me })
+
+    // If the server never says `ready`, this is not a connection worth keeping.
+    this.readyTimer = setTimeout(() => {
+      this.readyTimer = null
+      if (this.socket === socket && this.state.status !== 'ready') this.lost(socket)
+    }, READY_TIMEOUT_MS)
+
+    socket.onmessage = (event) => {
+      if (this.socket !== socket) return
+      this.awaitingPong = false
+      this.receive(event.data)
+    }
+    socket.onclose = (event) => {
+      if (this.socket !== socket) return
+      this.lost(socket, event?.code)
+    }
+    socket.onerror = () => {
+      if (this.socket !== socket) return
+      this.lost(socket)
+    }
+  }
+
+  /** Detaches a socket so that nothing it does later reaches this manager. */
+  private discard(socket: SocketLike): void {
+    socket.onopen = null
+    socket.onmessage = null
+    socket.onclose = null
+    socket.onerror = null
+    try {
+      socket.close()
+    } catch {
+      /* already gone */
+    }
+  }
+
+  /** The one way a connection ends, however it ended. */
+  private lost(socket: SocketLike, code?: number): void {
+    if (this.socket !== socket) return
+    this.socket = null
+    this.discard(socket)
+    this.clearTimers()
+    this.forgetServerState()
+    if (code !== undefined && REFUSED.has(code)) {
+      this.setState({ status: 'refused', me: null })
+      return
+    }
+    this.retryLater()
+  }
+
+  private retryLater(): void {
+    if (!this.started) return
+    this.setState({ status: 'waiting', me: this.state.me })
+    const base = RETRY_MS[Math.min(this.attempt, RETRY_MS.length - 1)]
+    this.attempt++
+    // Up to a fifth either way, so a restart is not met by every tab at once.
+    const delay = Math.round(base * (0.8 + this.random() * 0.4))
+    this.retryTimer = setTimeout(() => {
+      this.retryTimer = null
+      this.connect()
+    }, delay)
+  }
+
+  /** The network came back, or the tab was looked at again. */
+  private wake = (): void => {
+    if (!this.started) return
+    if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
+    if (this.state.status === 'waiting') {
+      if (this.retryTimer) clearTimeout(this.retryTimer)
+      this.retryTimer = null
+      this.attempt = 0
+      this.connect()
+      return
+    }
+    // A laptop that slept believes its socket is fine. Ask.
+    if (this.state.status === 'ready' && !this.awaitingPong) this.beat()
+  }
+
+  private beat = (): void => {
+    const socket = this.socket
+    if (!socket || this.state.status !== 'ready') return
+    // Counted in beats, not in seconds: a hidden tab's timers are slowed to
+    // one a minute, and a clock would call that a dead link every time.
+    if (this.awaitingPong) {
+      this.lost(socket)
+      return
+    }
+    this.awaitingPong = true
+    this.send({ t: 'ping' })
+  }
+
+  private clearTimers(): void {
+    if (this.connectTimer) clearTimeout(this.connectTimer)
+    if (this.retryTimer) clearTimeout(this.retryTimer)
+    if (this.readyTimer) clearTimeout(this.readyTimer)
+    if (this.heartbeat) clearInterval(this.heartbeat)
+    this.connectTimer = null
+    this.retryTimer = null
+    this.readyTimer = null
+    this.heartbeat = null
+    this.awaitingPong = false
+  }
+
+  /* ------------------------------------------------------------- incoming -- */
+
+  private receive(raw: unknown): void {
+    let message: ServerMessage
+    try {
+      message = JSON.parse(String(raw)) as ServerMessage
+    } catch {
+      return
+    }
+    switch (message?.t) {
+      case 'ready': {
+        if (this.readyTimer) clearTimeout(this.readyTimer)
+        this.readyTimer = null
+        this.attempt = 0
+        this.setState({ status: 'ready', me: message.you })
+        if (!this.heartbeat) this.heartbeat = setInterval(this.beat, PRESENCE_HEARTBEAT_MS)
+        this.sayWhatIsWanted()
+        // The first connection of a page has nothing to catch up on: the page
+        // was rendered a moment ago. Every later one missed whatever happened
+        // while the link was down, and no screen may keep showing what it had.
+        if (this.wasReady) for (const watcher of [...this.resyncWatchers]) watcher()
+        this.wasReady = true
+        return
+      }
+      case 'record': {
+        const { change } = message
+        const room = change.id ? `rec:${change.kind}:${change.id}` : `org:${change.organizationId}`
+        for (const handler of [...(this.rooms.get(room)?.handlers ?? [])]) handler(change)
+        return
+      }
+      case 'presence': {
+        // A frame for a room this page has left is not kept: it would be an
+        // entry nothing ever deletes.
+        const entry = this.rooms.get(message.room)
+        if (!entry || !this.isHeld(entry)) return
+        entry.users = message.users.length > 0 ? message.users : NOBODY
+        for (const watcher of [...entry.presenceWatchers]) watcher()
+        return
+      }
+      case 'legacy': {
+        for (const handler of [...(this.legacyWatchers.get(message.channel) ?? [])]) {
+          handler(message.data)
+        }
+        return
+      }
+      default:
+        return
+    }
+  }
+
+  private setState(next: ManagerState): void {
+    if (next.status === this.state.status && next.me?.userId === this.state.me?.userId) return
+    this.state = next
+    for (const watcher of [...this.stateWatchers]) watcher()
+  }
+}

+ 109 - 0
src/features/realtime/refresh-governor.ts

@@ -0,0 +1,109 @@
+/**
+ * Decides when a page may read its record again.
+ *
+ * A live event is answered with a request, and that is the one place this
+ * layer can cost the server something: a request that causes an event that
+ * causes a request is a page polling for ever, and it looks like a feature
+ * working. So the rule is not in the hook that happens to be written
+ * carefully; it is here, and every live re-read goes through it.
+ *
+ * - **One at a time, a second apart.** A save writes a job, its lines and its
+ *   totals; that is one re-read, not three.
+ * - **Not while nobody is looking.** A hidden tab remembers that something
+ *   changed and reads once when it is looked at again.
+ * - **It recognises its own echo.** A change that arrives just after a re-read,
+ *   time after time, is the re-read causing it. After a few of those in a row
+ *   the gap doubles each time, up to thirty seconds, so a loop from any cause
+ *   costs two requests a minute and not sixty. An honest busy record is not
+ *   slowed at all: a colleague saving every twenty seconds is never an echo,
+ *   and the streak ends with the first re-read that nothing follows.
+ */
+
+export interface GovernorOptions {
+  refresh: () => void
+  isHidden?: () => boolean
+  now?: () => number
+  /** Told when the gap has grown, so development can see a loop by name. */
+  onPressure?: (gapMs: number) => void
+}
+
+export const MIN_GAP_MS = 1_000
+export const MAX_GAP_MS = 30_000
+/** A change this soon after a re-read may have been caused by it. */
+export const ECHO_MS = 3_000
+/** Echoes in a row that are allowed to be coincidence: a save in three writes. */
+export const FREE_ECHOES = 3
+
+export interface RefreshGovernor {
+  /** Something changed: read again, when it is allowed. */
+  request(): void
+  /** The tab was looked at again. */
+  visible(): void
+  dispose(): void
+  /** For tests. */
+  pending(): boolean
+}
+
+export function createRefreshGovernor(options: GovernorOptions): RefreshGovernor {
+  const now = options.now ?? Date.now
+  const isHidden =
+    options.isHidden ??
+    (() => typeof document !== 'undefined' && document.visibilityState === 'hidden')
+
+  let timer: ReturnType<typeof setTimeout> | null = null
+  let missedWhileHidden = false
+  let disposed = false
+  let lastRefresh: number | null = null
+  /** Re-reads in a row that were each followed at once by another change. */
+  let streak = 0
+  let echoed = false
+
+  const gap = (): number =>
+    streak < FREE_ECHOES
+      ? MIN_GAP_MS
+      : Math.min(MAX_GAP_MS, MIN_GAP_MS * 2 ** (streak - FREE_ECHOES + 1))
+
+  const run = () => {
+    timer = null
+    if (disposed) return
+    if (isHidden()) {
+      missedWhileHidden = true
+      return
+    }
+    streak = echoed ? streak + 1 : 0
+    echoed = false
+    lastRefresh = now()
+    options.refresh()
+  }
+
+  const request = () => {
+    if (disposed) return
+    const at = now()
+    if (lastRefresh !== null && at - lastRefresh <= ECHO_MS) echoed = true
+    if (timer) return
+    if (isHidden()) {
+      missedWhileHidden = true
+      return
+    }
+    const wait = gap()
+    if (wait > MIN_GAP_MS) options.onPressure?.(wait)
+    const delay = lastRefresh === null ? 0 : Math.max(0, lastRefresh + wait - at)
+    timer = setTimeout(run, delay)
+  }
+
+  return {
+    request,
+    visible() {
+      if (!missedWhileHidden) return
+      missedWhileHidden = false
+      request()
+    },
+    dispose() {
+      disposed = true
+      if (timer) clearTimeout(timer)
+      timer = null
+      missedWhileHidden = false
+    },
+    pending: () => timer !== null || missedWhileHidden,
+  }
+}

+ 2 - 3
src/features/settings/Lib/armFeatureHints.ts

@@ -1,9 +1,8 @@
-import type { Prisma } from '@/generated/prisma/client'
-import { db as prisma } from '@/lib/db'
+import { db as prisma, type TxClient } from '@/lib/db'
 import { SETTING_KEYS } from '../Schema/settingsSchema'
 import { SETTING_KEYS } from '../Schema/settingsSchema'
 import { HINT_FOR_SETTING, hintsToArm, parseHintIds } from './featureHints'
 import { HINT_FOR_SETTING, hintsToArm, parseHintIds } from './featureHints'
 
 
-type Db = Prisma.TransactionClient | typeof prisma
+type Db = TxClient | typeof prisma
 
 
 /**
 /**
  * Raises the hints for any setting this write is switching on.
  * Raises the hints for any setting this write is switching on.

+ 9 - 2
src/features/team/Lib/technicianRole.ts

@@ -1,4 +1,4 @@
-import type { PrismaClient } from '@/generated/prisma/client'
+import type { TxClient } from '@/lib/db'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 
 
 /**
 /**
@@ -74,7 +74,14 @@ export const TECHNICIAN_ROLE_NAME = 'Technician'
 /** The value the role dropdown uses for it, alongside `admin` and `member`. */
 /** The value the role dropdown uses for it, alongside `admin` and `member`. */
 export const TECHNICIAN_ROLE_VALUE = 'technician'
 export const TECHNICIAN_ROLE_VALUE = 'technician'
 
 
-type Tx = Pick<PrismaClient, 'role'>
+/**
+ * The role delegate, from the client or from a transaction.
+ *
+ * Both carry the realtime extension now (see TxClient in lib/db), so this
+ * takes the extended shape rather than Prisma's plain one, and the two call
+ * styles stay interchangeable.
+ */
+type Tx = Pick<TxClient, 'role'>
 
 
 /**
 /**
  * The workshop's technician role, made once and reused after that.
  * The workshop's technician role, made once and reused after that.

+ 14 - 29
src/features/team/hooks/useTechnicianConnected.ts

@@ -1,6 +1,7 @@
 'use client'
 'use client'
 
 
 import { useEffect, useRef } from 'react'
 import { useEffect, useRef } from 'react'
+import { useRealtime } from '@/features/realtime/RealtimeProvider'
 
 
 /**
 /**
  * Waits for a technician's phone to come through, while the desk holds the QR.
  * Waits for a technician's phone to come through, while the desk holds the QR.
@@ -10,9 +11,8 @@ import { useEffect, useRef } from 'react'
  * looking at this screen. Rather than have the desk guess and close it, the
  * looking at this screen. Rather than have the desk guess and close it, the
  * scan itself ends the dialog.
  * scan itself ends the dialog.
  *
  *
- * Open only while a code is on screen. The connection costs nothing the rest
- * of the time and would be one more socket held open on a page nobody is
- * using.
+ * Listens on the app's one socket for as long as a code is on screen, and
+ * stops when the dialog closes: it used to open a WebSocket of its own.
  */
  */
 export function useTechnicianConnected(userId: string | null, onConnected: () => void) {
 export function useTechnicianConnected(userId: string | null, onConnected: () => void) {
   // Read through a ref, so a caller passing a fresh closure on every render
   // Read through a ref, so a caller passing a fresh closure on every render
@@ -22,31 +22,16 @@ export function useTechnicianConnected(userId: string | null, onConnected: () =>
     handler.current = onConnected
     handler.current = onConnected
   }, [onConnected])
   }, [onConnected])
 
 
+  const realtime = useRealtime()
   useEffect(() => {
   useEffect(() => {
-    if (!userId) return
-
-    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
-    const ws = new WebSocket(`${protocol}//${window.location.host}/api/protected/ws`)
-
-    ws.onmessage = (event) => {
-      try {
-        const message = JSON.parse(event.data)
-        if (message.type !== 'workboard') return
-        const data = message.data as { type?: string; userId?: string }
-        if (data.type !== 'technician_app_connected') return
-        // Somebody else being set up at the same counter is not this dialog's
-        // business.
-        if (data.userId !== userId) return
-        handler.current()
-      } catch {
-        /* a frame we do not understand is not worth breaking the dialog over */
-      }
-    }
-
-    return () => {
-      // Deliberately no reconnect. This lives as long as one dialog, and a
-      // socket that kept coming back would outlive the thing that wanted it.
-      ws.close()
-    }
-  }, [userId])
+    if (!userId || !realtime) return
+    return realtime.onLegacy('workboard', (raw) => {
+      const data = raw as { type?: string; userId?: string }
+      if (data?.type !== 'technician_app_connected') return
+      // Somebody else being set up at the same counter is not this dialog's
+      // business.
+      if (data.userId !== userId) return
+      handler.current()
+    })
+  }, [userId, realtime])
 }
 }

+ 23 - 50
src/features/time-tracking/Components/TimeClockProvider.tsx

@@ -12,6 +12,7 @@ import {
 } from 'react'
 } from 'react'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
+import { useRealtime } from '@/features/realtime/RealtimeProvider'
 import { getMyClock, startMyClock, stopMyClock, type MyClock } from '../Actions/timeClockActions'
 import { getMyClock, startMyClock, stopMyClock, type MyClock } from '../Actions/timeClockActions'
 import type { JobLaborEvent, JobStatusChangedEvent } from '@/features/vehicles/Lib/jobEvents'
 import type { JobLaborEvent, JobStatusChangedEvent } from '@/features/vehicles/Lib/jobEvents'
 import type { ClockEvent } from '../Lib/timeEntries'
 import type { ClockEvent } from '../Lib/timeEntries'
@@ -51,8 +52,6 @@ interface TimeClockValue {
 
 
 const TimeClockContext = createContext<TimeClockValue | null>(null)
 const TimeClockContext = createContext<TimeClockValue | null>(null)
 
 
-const RETRY_MS = [1_000, 2_000, 5_000, 10_000, 30_000]
-
 export function TimeClockProvider({
 export function TimeClockProvider({
   technicianIds,
   technicianIds,
   children,
   children,
@@ -78,58 +77,32 @@ export function TimeClockProvider({
     void refresh()
     void refresh()
   }, [refresh])
   }, [refresh])
 
 
-  // One socket for the life of the app shell. Mirrors the work board hook:
-  // liveness is a per-run closure so a StrictMode remount cannot leave two
-  // sockets each reconnecting on the other's behalf.
+  // The work board channel, off the app's one socket (features/realtime).
+  // This used to be a WebSocket of its own, with its own authentication and
+  // its own reconnect loop, which is what the app had five of.
+  const realtime = useRealtime()
   useEffect(() => {
   useEffect(() => {
-    let alive = true
-    let attempt = 0
-    let timer: ReturnType<typeof setTimeout> | undefined
-    let ws: WebSocket | null = null
-
-    const connect = () => {
-      if (!alive) return
-      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
-      ws = new WebSocket(`${protocol}//${window.location.host}/api/protected/ws`)
-      ws.onopen = () => {
-        attempt = 0
-        // Anything that changed while the socket was down.
+    if (!realtime) return
+    const stop = realtime.onLegacy('workboard', (data) => {
+      const event = data as Partial<WorkshopEvent>
+      if (typeof event?.type !== 'string') return
+      const workshopEvent = event as WorkshopEvent
+      if (
+        (workshopEvent.type === 'clock_started' || workshopEvent.type === 'clock_stopped') &&
+        idsRef.current.includes(workshopEvent.technicianId)
+      ) {
         void refresh()
         void refresh()
       }
       }
-      ws.onmessage = (event) => {
-        try {
-          const msg = JSON.parse(event.data)
-          if (msg.type !== 'workboard') return
-          const data = msg.data as Partial<WorkshopEvent>
-          if (typeof data?.type !== 'string') return
-          const workshopEvent = data as WorkshopEvent
-          if (
-            (workshopEvent.type === 'clock_started' || workshopEvent.type === 'clock_stopped') &&
-            idsRef.current.includes(workshopEvent.technicianId)
-          ) {
-            void refresh()
-          }
-          for (const listener of listeners.current) listener(workshopEvent)
-        } catch {
-          /* a frame we do not understand is not worth a broken clock */
-        }
-      }
-      ws.onclose = () => {
-        if (!alive) return
-        const delay = RETRY_MS[Math.min(attempt, RETRY_MS.length - 1)]
-        attempt++
-        timer = setTimeout(connect, delay)
-      }
-      ws.onerror = () => ws?.close()
-    }
+      for (const listener of listeners.current) listener(workshopEvent)
+    })
+    return stop
+  }, [realtime, refresh])
 
 
-    connect()
-    return () => {
-      alive = false
-      if (timer) clearTimeout(timer)
-      ws?.close()
-    }
-  }, [refresh])
+  // A reconnection means events were missed, so the clock is read again.
+  useEffect(() => {
+    if (!realtime) return
+    return realtime.onResync(() => void refresh())
+  }, [realtime, refresh])
 
 
   const subscribe = useCallback((listener: Listener) => {
   const subscribe = useCallback((listener: Listener) => {
     listeners.current.add(listener)
     listeners.current.add(listener)

+ 3 - 6
src/features/tire-hotel/Actions/tireSetActions.ts

@@ -1,7 +1,7 @@
 'use server'
 'use server'
 
 
 import { revalidatePath } from 'next/cache'
 import { revalidatePath } from 'next/cache'
-import { db } from '@/lib/db'
+import { db, type TxClient } from '@/lib/db'
 import { Prisma } from '@/generated/prisma/client'
 import { Prisma } from '@/generated/prisma/client'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
@@ -34,10 +34,7 @@ function revalidateTireHotel() {
  * of reading out a cuid. Derived from the highest existing number rather than
  * of reading out a cuid. Derived from the highest existing number rather than
  * a counter row, which keeps it correct after imports and deletions.
  * a counter row, which keeps it correct after imports and deletions.
  */
  */
-async function nextReference(
-  tx: Prisma.TransactionClient,
-  organizationId: string
-): Promise<string> {
+async function nextReference(tx: TxClient, organizationId: string): Promise<string> {
   const latest = await tx.tireSet.findFirst({
   const latest = await tx.tireSet.findFirst({
     where: { organizationId, reference: { not: null } },
     where: { organizationId, reference: { not: null } },
     orderBy: { createdAt: 'desc' },
     orderBy: { createdAt: 'desc' },
@@ -67,7 +64,7 @@ function measurementRows(measurements: MeasurementInput[] | undefined, userId: s
  * pass the check and overfill the shelf.
  * pass the check and overfill the shelf.
  */
  */
 async function assertRoom(
 async function assertRoom(
-  tx: Prisma.TransactionClient,
+  tx: TxClient,
   locationId: string,
   locationId: string,
   organizationId: string,
   organizationId: string,
   quantity: number,
   quantity: number,

+ 2 - 2
src/features/tire-hotel/Lib/addTireLine.ts

@@ -1,4 +1,4 @@
-import type { Prisma } from '@/generated/prisma/client'
+import type { TxClient } from '@/lib/db'
 import { reconcileInventoryForParts } from '@/features/inventory/Lib/reconcileStock'
 import { reconcileInventoryForParts } from '@/features/inventory/Lib/reconcileStock'
 
 
 export type TireLineInput = {
 export type TireLineInput = {
@@ -36,7 +36,7 @@ export type TireLineInput = {
  * commit or roll back together.
  * commit or roll back together.
  */
  */
 export async function addTireLineToRecord(
 export async function addTireLineToRecord(
-  tx: Prisma.TransactionClient,
+  tx: TxClient,
   organizationId: string,
   organizationId: string,
   userId: string | null,
   userId: string | null,
   line: TireLineInput
   line: TireLineInput

+ 2 - 6
src/features/tire-hotel/Lib/copySetFilesToJob.ts

@@ -1,4 +1,4 @@
-import type { Prisma } from '@/generated/prisma/client'
+import type { TxClient } from '@/lib/db'
 
 
 export interface SetFile {
 export interface SetFile {
   fileName: string
   fileName: string
@@ -21,11 +21,7 @@ export interface SetFile {
  * season a set is billed. Removing it from the set later leaves the invoice
  * season a set is billed. Removing it from the set later leaves the invoice
  * intact, which is the right way round for a document a customer may hold.
  * intact, which is the right way round for a document a customer may hold.
  */
  */
-export async function copySetFilesToJob(
-  tx: Prisma.TransactionClient,
-  serviceRecordId: string,
-  files: SetFile[]
-) {
+export async function copySetFilesToJob(tx: TxClient, serviceRecordId: string, files: SetFile[]) {
   const photos = files.filter((file) => file.fileType.startsWith('image/'))
   const photos = files.filter((file) => file.fileType.startsWith('image/'))
   if (photos.length === 0) return
   if (photos.length === 0) return
   await tx.serviceAttachment.createMany({
   await tx.serviceAttachment.createMany({

+ 12 - 2
src/features/vehicles/Components/service-edit/RichTextEditor.tsx

@@ -67,6 +67,10 @@ export function RichTextEditor({
     content,
     content,
     onUpdate: ({ editor }) => {
     onUpdate: ({ editor }) => {
       const html = editor.getHTML()
       const html = editor.getHTML()
+      // Only what somebody typed is a change. The work order autosaves five
+      // seconds after one, so an update reported for anything else is a save
+      // nobody asked for.
+      if (html === lastContentRef.current) return
       lastContentRef.current = html
       lastContentRef.current = html
       onChange(html)
       onChange(html)
     },
     },
@@ -77,15 +81,21 @@ export function RichTextEditor({
     },
     },
   })
   })
 
 
+  // Quietly: tiptap reports an update from `setEditable` and `setContent`
+  // unless told not to. Left to its default, mounting this editor marked the
+  // work order as edited, and every page that was merely opened saved itself
+  // five seconds later. With two people on one job each of those saves woke
+  // the other's page, which mounted, which saved: requests without end, and
+  // the job's lines rewritten each time.
   useEffect(() => {
   useEffect(() => {
-    editor?.setEditable(editable)
+    editor?.setEditable(editable, false)
   }, [editor, editable])
   }, [editor, editable])
 
 
   // Sync editor when content is updated externally (e.g. AI generation)
   // Sync editor when content is updated externally (e.g. AI generation)
   useEffect(() => {
   useEffect(() => {
     if (editor && content !== lastContentRef.current) {
     if (editor && content !== lastContentRef.current) {
       lastContentRef.current = content
       lastContentRef.current = content
-      editor.commands.setContent(content)
+      editor.commands.setContent(content, { emitUpdate: false })
     }
     }
   }, [content, editor])
   }, [content, editor])
 
 

+ 3 - 1
src/features/vehicles/Components/service-edit/ScheduleTimesSection.tsx

@@ -310,7 +310,9 @@ export function ScheduleTimesSection({
       void refreshDayLoad()
       void refreshDayLoad()
       if (startDateTime && endDateTime) void checkSlot(startDateTime, endDateTime, techId, bayId)
       if (startDateTime && endDateTime) void checkSlot(startDateTime, endDateTime, techId, bayId)
     } else {
     } else {
-      toast.error(t('failedAssign'))
+      // A role that may not change the work board is told so, and who can
+      // change that, rather than that "it failed" with nothing to act on.
+      toast.error(res.forbidden ? t('assignNotAllowed') : t('failedAssign'))
       setSelectedTechId(initialTechnicianId || '')
       setSelectedTechId(initialTechnicianId || '')
     }
     }
   }
   }

+ 17 - 12
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -58,7 +58,8 @@ import { rememberWorkOrderLayout, type WorkOrderLayout } from '@/lib/work-order-
 import { registerAnalyticsProperties, track } from '@/lib/analytics'
 import { registerAnalyticsProperties, track } from '@/lib/analytics'
 import { TryNewLayoutBanner } from './TryNewLayoutBanner'
 import { TryNewLayoutBanner } from './TryNewLayoutBanner'
 import { LaborAddedBanner } from './LaborAddedBanner'
 import { LaborAddedBanner } from './LaborAddedBanner'
-import { useWorkshopEvents } from '@/features/time-tracking/Components/TimeClockProvider'
+import { useLiveRecord } from '@/features/realtime/hooks'
+import { PresenceChips } from '@/features/realtime/Components/PresenceChips'
 import { ModernDetails } from './modern/ModernDetails'
 import { ModernDetails } from './modern/ModernDetails'
 import { ModernHero } from './modern/ModernHero'
 import { ModernHero } from './modern/ModernHero'
 import { lineTotal, resolvePartPrice } from '@/features/inventory/Lib/partPricing'
 import { lineTotal, resolvePartPrice } from '@/features/inventory/Lib/partPricing'
@@ -205,16 +206,14 @@ export function ServicePageClient({
     locked: lockState.locked,
     locked: lockState.locked,
   })
   })
 
 
-  // What happened to this job somewhere else: a technician billing their
-  // time from the app, or marking the job complete in the bay. The page reads
-  // the job again, and the form state decides what to do with it: a status
-  // moves the bar at once, a line of work waits for the banner below while
-  // someone is typing. Only this job's events.
-  useWorkshopEvents((event) => {
-    if (event.type !== 'job_labor_added' && event.type !== 'job_status_changed') return
-    if (event.serviceRecordId !== record.id) return
-    router.refresh()
-  })
+  // Anything that happens to this job somewhere else: a technician billing
+  // their time from the app, marking it complete in the bay, or the person at
+  // the next desk saving it. Every write reaches here, because every write
+  // announces itself (lib/realtime), rather than each feature having to
+  // remember to. The page reads the job again and the form state decides what
+  // to do with it: a status moves the bar at once, a line of work waits for
+  // the banner below while somebody is typing.
+  useLiveRecord('serviceRecord', record.id)
 
 
   const checkDates = useCallback(async () => {
   const checkDates = useCallback(async () => {
     if (!areDatesExpired || formState.paymentStatus === 'paid') return true
     if (!areDatesExpired || formState.paymentStatus === 'paid') return true
@@ -556,7 +555,12 @@ export function ServicePageClient({
           status={formState.status}
           status={formState.status}
           paymentStatus={formState.paymentStatus}
           paymentStatus={formState.paymentStatus}
           warranty={formState.warranty}
           warranty={formState.warranty}
-          actions={<ServiceHeaderActions showSave {...headerActionProps} />}
+          actions={
+            <>
+              <PresenceChips kind="serviceRecord" id={record.id} className="mr-1" />
+              <ServiceHeaderActions showSave {...headerActionProps} />
+            </>
+          }
           title={title}
           title={title}
           onTitleChange={(next) => {
           onTitleChange={(next) => {
             setTitle(next)
             setTitle(next)
@@ -568,6 +572,7 @@ export function ServicePageClient({
         />
         />
       ) : (
       ) : (
         <UnifiedServiceHeader
         <UnifiedServiceHeader
+          presence={<PresenceChips kind="serviceRecord" id={record.id} />}
           vehicleId={vehicleId}
           vehicleId={vehicleId}
           vehicleName={formState.vehicleName}
           vehicleName={formState.vehicleName}
           title={record.title}
           title={record.title}

+ 4 - 0
src/features/vehicles/Components/service-page/UnifiedServiceHeader.tsx

@@ -67,6 +67,8 @@ interface UnifiedServiceHeaderProps {
   hasCustomer?: boolean
   hasCustomer?: boolean
   /** The invoice-design submenu, when this invoice has a frozen look to change. */
   /** The invoice-design submenu, when this invoice has a frozen look to change. */
   designMenu?: React.ReactNode
   designMenu?: React.ReactNode
+  /** Who else has this job open, drawn before the actions. */
+  presence?: React.ReactNode
   /** Video call link from a connected calendar, when one exists. */
   /** Video call link from a connected calendar, when one exists. */
   meetingUrl?: string | null
   meetingUrl?: string | null
   /** Which page this sits on, so the menu can offer the way to the other one. */
   /** Which page this sits on, so the menu can offer the way to the other one. */
@@ -96,6 +98,7 @@ export function UnifiedServiceHeader({
   onNotifyCustomer,
   onNotifyCustomer,
   hasCustomer = false,
   hasCustomer = false,
   designMenu,
   designMenu,
+  presence,
   meetingUrl = null,
   meetingUrl = null,
   layout = 'classic',
   layout = 'classic',
   onSwitchLayout,
   onSwitchLayout,
@@ -135,6 +138,7 @@ export function UnifiedServiceHeader({
             <p className="truncate text-xs text-muted-foreground">{vehicleName}</p>
             <p className="truncate text-xs text-muted-foreground">{vehicleName}</p>
           </div>
           </div>
         </Link>
         </Link>
+        {presence}
         <ServiceHeaderActions
         <ServiceHeaderActions
           showSave={activeTab === 'details'}
           showSave={activeTab === 'details'}
           downloading={downloading}
           downloading={downloading}

+ 2 - 3
src/features/vehicles/Lib/retotalServiceRecord.ts

@@ -1,5 +1,4 @@
-import { db } from '@/lib/db'
-import type { Prisma } from '@/generated/prisma/client'
+import { db, type TxClient } from '@/lib/db'
 import { documentTotals } from '@/features/settings/Lib/workshopTax'
 import { documentTotals } from '@/features/settings/Lib/workshopTax'
 
 
 /**
 /**
@@ -15,7 +14,7 @@ import { documentTotals } from '@/features/settings/Lib/workshopTax'
  */
  */
 export async function retotalServiceRecord(
 export async function retotalServiceRecord(
   serviceRecordId: string,
   serviceRecordId: string,
-  tx: Prisma.TransactionClient | typeof db = db
+  tx: TxClient | typeof db = db
 ): Promise<void> {
 ): Promise<void> {
   const record = await tx.serviceRecord.findUnique({
   const record = await tx.serviceRecord.findUnique({
     where: { id: serviceRecordId },
     where: { id: serviceRecordId },

+ 116 - 143
src/features/workboard/hooks/useWorkBoardWebSocket.ts

@@ -1,162 +1,135 @@
 'use client'
 'use client'
 
 
-import { useEffect, useRef } from 'react'
+import { useEffect } from 'react'
+import { useRealtime, useRealtimeState } from '@/features/realtime/RealtimeProvider'
 import { useWorkBoardStore } from '../store/workboardStore'
 import { useWorkBoardStore } from '../store/workboardStore'
 import type { WorkBoardJob } from '../Actions/boardActions'
 import type { WorkBoardJob } from '../Actions/boardActions'
 
 
 export function useWorkBoardWebSocket() {
 export function useWorkBoardWebSocket() {
-  const wsRef = useRef<WebSocket | null>(null)
-  const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
-  const mountedRef = useRef(true)
+  const realtime = useRealtime()
+  const { status } = useRealtimeState()
 
 
+  // The board's "live" light says what the link is actually doing, not that
+  // this hook has mounted.
   useEffect(() => {
   useEffect(() => {
-    mountedRef.current = true
+    useWorkBoardStore
+      .getState()
+      .setConnection(
+        status === 'ready' ? 'open' : status === 'connecting' ? 'connecting' : 'closed'
+      )
+    return () => useWorkBoardStore.getState().setConnection('closed')
+  }, [status])
 
 
-    function connect() {
-      if (!mountedRef.current) return
+  useEffect(() => {
+    if (!realtime) return
+    return realtime.onLegacy('workboard', (raw) => {
+      const store = useWorkBoardStore.getState()
+      // The board's own events, which still travel on the workshop channel
+      // (see lib/realtime for the record rooms that will replace them).
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const data = raw as any
+      if (!data?.type) return
+      switch (data.type) {
+        case 'job_assigned':
+          store.addJob(data.job as WorkBoardJob)
+          // Remove from unassigned lists based on job type and id
+          if (data.job.type === 'serviceRecord') {
+            store.removeFromUnassigned(data.job.id, 'serviceRecord')
+          } else if (data.job.type === 'inspection') {
+            store.removeFromUnassigned(data.job.id, 'inspection')
+          }
+          break
+
+        case 'job_moved':
+        case 'job_scheduled':
+          store.updateJob(data.job as WorkBoardJob)
+          break
+
+        case 'job_unassigned':
+          store.removeJob(data.jobId)
+          break
+
+        case 'technician_created':
+          store.addTechnician(data.technician)
+          break
+
+        case 'work_bay_created':
+        case 'work_bay_updated':
+          store.upsertWorkBay(data.workBay)
+          break
+
+        case 'work_bay_removed':
+          store.removeWorkBay(data.workBayId as string)
+          break
+
+        case 'technician_updated':
+        case 'technician_removed':
+          // Reload full technician list for updates/removals
+          import('../Actions/technicianActions').then(({ getTechnicians }) => {
+            getTechnicians().then((res) => {
+              if (res.success && res.data) {
+                store.setTechnicians(res.data as Parameters<typeof store.setTechnicians>[0])
+              }
+            })
+          })
+          break
+
+        case 'service_times_updated': {
+          const { serviceRecordId, startDateTime, endDateTime } = data
+          const updatedJobs = store.jobs.map((j) =>
+            j.type === 'serviceRecord' && j.id === serviceRecordId
+              ? { ...j, startDateTime, endDateTime }
+              : j
+          )
+          store.setJobs(updatedJobs)
+          break
+        }
 
 
-      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
-      const url = `${protocol}//${window.location.host}/api/protected/ws`
-      const ws = new WebSocket(url)
-      wsRef.current = ws
+        case 'inspection_times_updated': {
+          const { inspectionId, startDateTime, endDateTime } = data
+          const updatedJobs = store.jobs.map((j) =>
+            j.type === 'inspection' && j.id === inspectionId
+              ? { ...j, startDateTime, endDateTime }
+              : j
+          )
+          store.setJobs(updatedJobs)
+          break
+        }
 
 
-      ws.onopen = () => {
-        useWorkBoardStore.getState().setConnection('open')
-      }
+        case 'job_status_changed': {
+          const { serviceRecordId, inspectionId, status, serviceRecord } = data
+          const activeStatuses = ['pending', 'in-progress', 'waiting-parts', 'scheduled']
 
 
-      ws.onmessage = (event) => {
-        try {
-          const msg = JSON.parse(event.data)
-          if (msg.type !== 'workboard') return
-
-          const store = useWorkBoardStore.getState()
-          const data = msg.data
-
-          switch (data.type) {
-            case 'job_assigned':
-              store.addJob(data.job as WorkBoardJob)
-              // Remove from unassigned lists based on job type and id
-              if (data.job.type === 'serviceRecord') {
-                store.removeFromUnassigned(data.job.id, 'serviceRecord')
-              } else if (data.job.type === 'inspection') {
-                store.removeFromUnassigned(data.job.id, 'inspection')
-              }
-              break
-
-            case 'job_moved':
-            case 'job_scheduled':
-              store.updateJob(data.job as WorkBoardJob)
-              break
-
-            case 'job_unassigned':
-              store.removeJob(data.jobId)
-              break
-
-            case 'technician_created':
-              store.addTechnician(data.technician)
-              break
-
-            case 'work_bay_created':
-            case 'work_bay_updated':
-              store.upsertWorkBay(data.workBay)
-              break
-
-            case 'work_bay_removed':
-              store.removeWorkBay(data.workBayId as string)
-              break
-
-            case 'technician_updated':
-            case 'technician_removed':
-              // Reload full technician list for updates/removals
-              import('../Actions/technicianActions').then(({ getTechnicians }) => {
-                getTechnicians().then((res) => {
-                  if (res.success && res.data) {
-                    store.setTechnicians(res.data as Parameters<typeof store.setTechnicians>[0])
-                  }
-                })
-              })
-              break
-
-            case 'service_times_updated': {
-              const { serviceRecordId, startDateTime, endDateTime } = data
-              const updatedJobs = store.jobs.map((j) =>
-                j.type === 'serviceRecord' && j.id === serviceRecordId
-                  ? { ...j, startDateTime, endDateTime }
-                  : j
-              )
-              store.setJobs(updatedJobs)
-              break
+          // Update the status on matching jobs in-place
+          const updated = store.jobs.map((j) => {
+            if (serviceRecordId && j.type === 'serviceRecord' && j.id === serviceRecordId) {
+              return { ...j, status }
             }
             }
-
-            case 'inspection_times_updated': {
-              const { inspectionId, startDateTime, endDateTime } = data
-              const updatedJobs = store.jobs.map((j) =>
-                j.type === 'inspection' && j.id === inspectionId
-                  ? { ...j, startDateTime, endDateTime }
-                  : j
-              )
-              store.setJobs(updatedJobs)
-              break
+            if (inspectionId && j.type === 'inspection' && j.id === inspectionId) {
+              return { ...j, status }
             }
             }
-
-            case 'job_status_changed': {
-              const { serviceRecordId, inspectionId, status, serviceRecord } = data
-              const activeStatuses = ['pending', 'in-progress', 'waiting-parts', 'scheduled']
-
-              // Update the status on matching jobs in-place
-              const updated = store.jobs.map((j) => {
-                if (serviceRecordId && j.type === 'serviceRecord' && j.id === serviceRecordId) {
-                  return { ...j, status }
-                }
-                if (inspectionId && j.type === 'inspection' && j.id === inspectionId) {
-                  return { ...j, status }
-                }
-                return j
-              })
-              store.setJobs(updated)
-
-              // Add/remove from unassigned pool for service records
-              if (serviceRecordId && serviceRecord) {
-                const isAssigned = store.jobs.some(
-                  (j) => j.type === 'serviceRecord' && j.id === serviceRecordId
-                )
-                const isAlreadyUnassigned = store.unassignedServiceRecords.some(
-                  (sr) => sr.id === serviceRecordId
-                )
-
-                if (activeStatuses.includes(status) && !isAssigned && !isAlreadyUnassigned) {
-                  store.addToUnassigned(serviceRecord, 'serviceRecord')
-                } else if (!activeStatuses.includes(status)) {
-                  store.removeFromUnassigned(serviceRecordId, 'serviceRecord')
-                }
-              }
-              break
+            return j
+          })
+          store.setJobs(updated)
+
+          // Add/remove from unassigned pool for service records
+          if (serviceRecordId && serviceRecord) {
+            const isAssigned = store.jobs.some(
+              (j) => j.type === 'serviceRecord' && j.id === serviceRecordId
+            )
+            const isAlreadyUnassigned = store.unassignedServiceRecords.some(
+              (sr) => sr.id === serviceRecordId
+            )
+
+            if (activeStatuses.includes(status) && !isAssigned && !isAlreadyUnassigned) {
+              store.addToUnassigned(serviceRecord, 'serviceRecord')
+            } else if (!activeStatuses.includes(status)) {
+              store.removeFromUnassigned(serviceRecordId, 'serviceRecord')
             }
             }
           }
           }
-        } catch {
-          // ignore malformed messages
-        }
-      }
-
-      ws.onclose = () => {
-        useWorkBoardStore.getState().setConnection('closed')
-        wsRef.current = null
-        if (mountedRef.current) {
-          reconnectTimer.current = setTimeout(connect, 3000)
+          break
         }
         }
       }
       }
-
-      ws.onerror = () => {
-        ws.close()
-      }
-    }
-
-    connect()
-
-    return () => {
-      mountedRef.current = false
-      clearTimeout(reconnectTimer.current)
-      wsRef.current?.close()
-    }
-  }, [])
+    })
+  }, [realtime])
 }
 }

+ 2 - 0
src/i18n/request.ts

@@ -78,6 +78,7 @@ export default getRequestConfig(async () => {
   const integrations = (await import(`../../messages/${locale}/integrations.json`)).default
   const integrations = (await import(`../../messages/${locale}/integrations.json`)).default
   const timeTracking = (await import(`../../messages/${locale}/timeTracking.json`)).default
   const timeTracking = (await import(`../../messages/${locale}/timeTracking.json`)).default
   const email = (await import(`../../messages/${locale}/email.json`)).default
   const email = (await import(`../../messages/${locale}/email.json`)).default
+  const realtime = (await import(`../../messages/${locale}/realtime.json`)).default
 
 
   return {
   return {
     locale,
     locale,
@@ -125,6 +126,7 @@ export default getRequestConfig(async () => {
       integrations,
       integrations,
       timeTracking,
       timeTracking,
       email,
       email,
+      realtime,
     },
     },
   }
   }
 })
 })

+ 33 - 16
src/lib/cached-session.ts

@@ -25,22 +25,39 @@ export const getCachedMembership = cache(async (userId: string) => {
   const activeOrgHeader = (await headers()).get('x-org-id') ?? undefined
   const activeOrgHeader = (await headers()).get('x-org-id') ?? undefined
   const activeOrgCookie = activeOrgHeader ?? cookieStore.get('active-org-id')?.value
   const activeOrgCookie = activeOrgHeader ?? cookieStore.get('active-org-id')?.value
 
 
-  const select = {
-    organizationId: true,
-    role: true,
-    roleId: true,
-    customRole: {
-      select: { isAdmin: true, permissions: { select: { action: true, subject: true } } },
-    },
-  } as const
+  return resolveMembership(userId, activeOrgCookie)
+})
+
+const MEMBERSHIP_SELECT = {
+  organizationId: true,
+  role: true,
+  roleId: true,
+  customRole: {
+    select: { isAdmin: true, permissions: { select: { action: true, subject: true } } },
+  },
+} as const
 
 
-  if (activeOrgCookie) {
-    const m = await db.organizationMember.findFirst({
-      where: { userId, organizationId: activeOrgCookie },
-      select,
+/**
+ * The workshop a person is acting in: the one they named when they belong to
+ * it, otherwise one they do belong to.
+ *
+ * The one place this is decided. The live-update socket used to decide it for
+ * itself and treated the cookie as a requirement instead of a preference, so
+ * a browser holding a stale `active-org-id` (left over from somebody else's
+ * sign-in on the same machine) used the whole app normally and was refused
+ * its socket: no live updates and nobody's chips, with nothing to say why.
+ *
+ * The name is only ever a preference. What is returned is always a membership
+ * this person really has, so nothing here can place anybody in a workshop
+ * they are not a member of.
+ */
+export async function resolveMembership(userId: string, preferredOrganizationId?: string) {
+  if (preferredOrganizationId) {
+    const preferred = await db.organizationMember.findFirst({
+      where: { userId, organizationId: preferredOrganizationId },
+      select: MEMBERSHIP_SELECT,
     })
     })
-    if (m) return m
+    if (preferred) return preferred
   }
   }
-
-  return db.organizationMember.findFirst({ where: { userId }, select })
-})
+  return db.organizationMember.findFirst({ where: { userId }, select: MEMBERSHIP_SELECT })
+}

+ 63 - 2
src/lib/db.ts

@@ -1,8 +1,22 @@
 import { PrismaClient } from '@/generated/prisma/client'
 import { PrismaClient } from '@/generated/prisma/client'
 import { PrismaPg } from '@prisma/adapter-pg'
 import { PrismaPg } from '@prisma/adapter-pg'
+import { realtimeExtension } from '@/lib/realtime/prisma-realtime.server'
+import { holdingChanges } from '@/lib/realtime/publish.server'
+import type { RecordKind } from '@/lib/realtime/events'
 
 
 const globalForPrisma = globalThis as unknown as {
 const globalForPrisma = globalThis as unknown as {
-  prisma: PrismaClient | undefined
+  prisma: ReturnType<typeof createPrismaClient> | undefined
+}
+
+/** The model each followed record lives in, for the workshop lookup below. */
+const DELEGATE_OF: Record<RecordKind, string> = {
+  serviceRecord: 'serviceRecord',
+  inspection: 'inspection',
+  quote: 'quote',
+  vehicle: 'vehicle',
+  customer: 'customer',
+  inventoryPart: 'inventoryPart',
+  tireSet: 'tireSet',
 }
 }
 
 
 function createPrismaClient() {
 function createPrismaClient() {
@@ -12,12 +26,59 @@ function createPrismaClient() {
     connectionTimeoutMillis: 5000,
     connectionTimeoutMillis: 5000,
     idleTimeoutMillis: 30000,
     idleTimeoutMillis: 30000,
   })
   })
-  return new PrismaClient({
+  const base = new PrismaClient({
     adapter,
     adapter,
     log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
     log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
   })
   })
+
+  /**
+   * Every write tells the screens that are watching it (lib/realtime). The
+   * extension asks here for the workshop of a record a write did not name,
+   * reading through the unextended client so the lookup cannot announce
+   * anything of its own.
+   */
+  const extended = base.$extends(
+    realtimeExtension({
+      organizationOf: async (kind, id) => {
+        const delegate = (base as unknown as Record<string, unknown>)[DELEGATE_OF[kind]] as
+          | { findUnique(args: unknown): Promise<{ organizationId: string | null } | null> }
+          | undefined
+        if (!delegate) return null
+        const row = await delegate.findUnique({
+          where: { id },
+          select: { organizationId: true },
+        })
+        return row?.organizationId ?? null
+      },
+    })
+  )
+
+  /**
+   * A change made inside a transaction is announced when it commits, never
+   * before: a screen told earlier re-reads the row as it was and shows that.
+   * Only `$transaction` is wrapped, and its type is untouched, so every caller
+   * stays as it is.
+   */
+  return new Proxy(extended, {
+    get(target, property) {
+      const value = Reflect.get(target, property, target)
+      if (property !== '$transaction' || typeof value !== 'function') return value
+      return (...args: unknown[]) =>
+        holdingChanges(() => Reflect.apply(value, target, args) as Promise<unknown>)
+    },
+  })
 }
 }
 
 
 export const db = globalForPrisma.prisma ?? createPrismaClient()
 export const db = globalForPrisma.prisma ?? createPrismaClient()
 
 
+/**
+ * The client a `db.$transaction(...)` callback is handed.
+ *
+ * Prisma's own `Prisma.TransactionClient` describes the unextended client,
+ * so a helper annotated with it no longer accepts what a transaction hands
+ * over now that the client carries the realtime extension. Every helper that
+ * takes a transaction takes this instead.
+ */
+export type TxClient = Parameters<Parameters<typeof db.$transaction>[0]>[0]
+
 globalForPrisma.prisma = db
 globalForPrisma.prisma = db

+ 35 - 0
src/lib/realtime/actor.server.ts

@@ -0,0 +1,35 @@
+import 'server-only'
+
+import { AsyncLocalStorage } from 'node:async_hooks'
+import type { ChangeAuthor } from './events'
+import { shared } from './shared-state.server'
+
+/**
+ * Who is making the change, carried without being passed.
+ *
+ * A live update says "Christian changed this", and the write that caused it
+ * is often five calls deep in a server action that never took a user as an
+ * argument. Threading one through every function would be a refactor of the
+ * whole codebase and would be forgotten in exactly the places that matter.
+ *
+ * `withAuth` and `withApiAuth` already know who is calling, so they run their
+ * handler inside this store and anything underneath can ask. Node's async
+ * context follows awaits, so a `publish` inside a transaction three calls down
+ * still knows whose work it was.
+ *
+ * Nothing breaks without it: a background job or a script has no actor and
+ * the change is attributed to the system, which is the honest answer.
+ */
+
+const SYSTEM: ChangeAuthor = { userId: null, name: null, source: 'system' }
+
+/** One per process: the writer and the reader are rarely in the same bundle. */
+const storage = shared('actor', () => new AsyncLocalStorage<ChangeAuthor>())
+
+export function runAsActor<T>(actor: ChangeAuthor, run: () => T): T {
+  return storage.run(actor, run)
+}
+
+export function currentActor(): ChangeAuthor {
+  return storage.getStore() ?? SYSTEM
+}

+ 67 - 0
src/lib/realtime/authorize.server.ts

@@ -0,0 +1,67 @@
+import 'server-only'
+
+import { db } from '@/lib/db'
+import { parseOrgRoom, parseRecordRoom, type RecordKind } from './events'
+
+/**
+ * Whether a socket may join a room.
+ *
+ * The room name comes from the browser, so it is a request, never a fact. A
+ * workshop room is granted only to its own members, and a record room only
+ * when that record belongs to the socket's workshop. Without this, a room
+ * name typed into a console would be a window onto another workshop's work,
+ * which is the whole tenancy of the app undone by a string.
+ *
+ * Answers are remembered for a few minutes: a record does not change
+ * workshop, and a page that subscribes on every navigation must not mean a
+ * query each time.
+ */
+
+const WHERE: Record<RecordKind, (id: string, organizationId: string) => Promise<boolean>> = {
+  serviceRecord: (id, organizationId) => exists('serviceRecord', id, organizationId),
+  inspection: (id, organizationId) => exists('inspection', id, organizationId),
+  quote: (id, organizationId) => exists('quote', id, organizationId),
+  vehicle: (id, organizationId) => exists('vehicle', id, organizationId),
+  customer: (id, organizationId) => exists('customer', id, organizationId),
+  inventoryPart: (id, organizationId) => exists('inventoryPart', id, organizationId),
+  tireSet: (id, organizationId) => exists('tireSet', id, organizationId),
+}
+
+async function exists(delegate: string, id: string, organizationId: string): Promise<boolean> {
+  const model = (db as unknown as Record<string, unknown>)[delegate] as {
+    count(args: unknown): Promise<number>
+  }
+  const found = await model.count({ where: { id, organizationId } })
+  return found > 0
+}
+
+const TTL_MS = 5 * 60_000
+const MAX_CACHED = 5_000
+const answers = new Map<string, { allowed: boolean; expiresAt: number }>()
+
+export async function mayJoin(room: string, socketOrganizationId: string): Promise<boolean> {
+  const org = parseOrgRoom(room)
+  // A workshop room is the socket's own membership, decided at sign-in.
+  if (org) return org === socketOrganizationId
+
+  const record = parseRecordRoom(room)
+  if (!record) return false
+
+  const key = `${socketOrganizationId}:${room}`
+  const cached = answers.get(key)
+  const now = Date.now()
+  if (cached && cached.expiresAt > now) return cached.allowed
+
+  const allowed = await WHERE[record.kind](record.id, socketOrganizationId).catch(() => false)
+  if (answers.size >= MAX_CACHED) {
+    const oldest = answers.keys().next().value
+    if (oldest) answers.delete(oldest)
+  }
+  answers.set(key, { allowed, expiresAt: now + TTL_MS })
+  return allowed
+}
+
+/** Only for tests. */
+export function resetJoinCache(): void {
+  answers.clear()
+}

+ 210 - 0
src/lib/realtime/bus.server.ts

@@ -0,0 +1,210 @@
+import 'server-only'
+
+import { randomUUID } from 'node:crypto'
+import { EventEmitter } from 'node:events'
+import type { PresenceUser, RecordChange } from './events'
+import { shared } from './shared-state.server'
+
+/**
+ * One workshop's live events, across however many app instances are running.
+ *
+ * Inside a process this is an EventEmitter. Between processes it is Postgres,
+ * which the app already runs: `pg_notify` on one instance, `LISTEN` on the
+ * others. No Redis to operate, and the properties fit what is being sent:
+ *
+ * - **Tiny payloads.** Postgres refuses a notification over 8000 bytes, and
+ *   an event here is ids and a name. Presence snapshots are capped for the
+ *   same reason (see `publishPresence`).
+ * - **At most once.** A notification is not stored and not acknowledged; a
+ *   listener that is down misses it. That is survivable because no screen
+ *   holds state from the socket alone: every one of them re-reads what it has
+ *   open when the connection comes back, so a lost frame costs a refresh, not
+ *   correctness.
+ * - **After commit.** `pg_notify` from inside a transaction fires when the
+ *   transaction commits, which is exactly when the change is real.
+ *
+ * Every instance stamps its own id on what it sends and drops what comes back
+ * with that id, because it has already delivered it locally.
+ */
+
+const CHANNEL = 'torqvoice_realtime'
+/** Postgres refuses more than 8000 bytes; stay well under it. */
+const MAX_PAYLOAD_BYTES = 7000
+
+/**
+ * One per process, not one per copy of this module: a second copy with its own
+ * id would take this process's frames for another instance's, deliver them
+ * twice, and count everybody in a room as being there on two devices.
+ */
+export const INSTANCE_ID = shared('bus.instanceId', () => randomUUID())
+
+/** What travels between instances. Local delivery uses the same shapes. */
+export type BusMessage =
+  | { t: 'record'; change: RecordChange }
+  | {
+      t: 'presence'
+      room: string
+      /** Which instance is speaking, so each one keeps its own slot. */
+      instanceId: string
+      /** The sending instance's own occupants of that room. */
+      users: PresenceUser[]
+      /** Millis after which this instance's list is stale and dropped. */
+      ttlMs: number
+    }
+  | { t: 'legacy'; channel: 'notification' | 'workboard' | 'broadcast'; data: unknown }
+
+type Envelope = BusMessage & { from: string }
+
+const globalForBus = globalThis as unknown as {
+  torqvoiceRealtimeBus?: EventEmitter
+  torqvoiceRealtimeBridge?: Bridge
+}
+
+/** Survives hot reload in development, like the Prisma client does. */
+const emitter = (globalForBus.torqvoiceRealtimeBus ??= new EventEmitter().setMaxListeners(0))
+
+/** Runs `handler` for every message, this instance's own included. */
+export function onBusMessage(handler: (message: BusMessage) => void): () => void {
+  emitter.on('message', handler)
+  return () => {
+    emitter.off('message', handler)
+  }
+}
+
+/**
+ * Sends a message to every instance, including this one.
+ *
+ * Delivered locally first and without waiting: a screen in this process must
+ * not wait on a database round trip to see a change made in this process.
+ */
+export function publishToBus(message: BusMessage): void {
+  emitter.emit('message', message)
+  void relay(message)
+}
+
+async function relay(message: BusMessage): Promise<void> {
+  const bridge = ensureBridge()
+  if (!bridge) return
+  const payload = JSON.stringify({ ...message, from: INSTANCE_ID } satisfies Envelope)
+  if (Buffer.byteLength(payload, 'utf8') > MAX_PAYLOAD_BYTES) {
+    // Nothing sent here is anywhere near the limit, so this is a bug rather
+    // than a case to handle: say so, and let the other instances miss it
+    // instead of failing the write that caused it.
+    console.error(`[realtime] refusing to relay ${payload.length} bytes on ${CHANNEL}`)
+    return
+  }
+  await bridge.notify(payload)
+}
+
+/* ------------------------------------------------------------- bridge --- */
+
+interface Bridge {
+  notify(payload: string): Promise<void>
+}
+
+/**
+ * The Postgres side, started the first time something is published and kept
+ * for the life of the process.
+ *
+ * Its own connection, not one of Prisma's: `LISTEN` belongs to a session, and
+ * a pooled connection handed back after the query would stop listening. The
+ * same connection sends, so one socket per instance carries both directions.
+ */
+function ensureBridge(): Bridge | null {
+  if (globalForBus.torqvoiceRealtimeBridge) return globalForBus.torqvoiceRealtimeBridge
+  const url = process.env.DATABASE_URL
+  if (!url) return null
+  if (process.env.REALTIME_BRIDGE === 'off') return null
+
+  const bridge = createBridge(url)
+  globalForBus.torqvoiceRealtimeBridge = bridge
+  return bridge
+}
+
+/** Reconnect delays: quick at first, then patient, like the browser's. */
+const RETRY_MS = [1_000, 2_000, 5_000, 10_000, 30_000]
+
+function createBridge(connectionString: string): Bridge {
+  // Imported lazily so nothing in the browser bundle or in a unit test pulls
+  // a database driver in on module load.
+  type PgClient = {
+    connect(): Promise<void>
+    query(text: string, values?: unknown[]): Promise<unknown>
+    on(event: string, handler: (arg: unknown) => void): void
+    end(): Promise<void>
+  }
+
+  let client: PgClient | null = null
+  let connecting: Promise<PgClient | null> | null = null
+  let attempt = 0
+
+  const connect = async (): Promise<PgClient | null> => {
+    const { Client } = (await import('pg')) as unknown as {
+      Client: new (config: { connectionString: string }) => PgClient
+    }
+    const next = new Client({ connectionString })
+    next.on('notification', (raw) => {
+      const message = (raw ?? {}) as { channel?: string; payload?: string }
+      if (message.channel !== CHANNEL || !message.payload) return
+      try {
+        const envelope = JSON.parse(message.payload) as Envelope
+        // Already delivered locally when this instance published it.
+        if (envelope.from === INSTANCE_ID) return
+        const { from: _from, ...rest } = envelope
+        emitter.emit('message', rest as BusMessage)
+      } catch {
+        /* a frame we cannot read is not worth a crashed listener */
+      }
+    })
+    next.on('error', (error) => {
+      console.error('[realtime] bridge connection lost:', error)
+      client = null
+      connecting = null
+      schedule()
+    })
+    await next.connect()
+    await next.query(`LISTEN ${CHANNEL}`)
+    attempt = 0
+    client = next
+    return next
+  }
+
+  const schedule = () => {
+    const delay = RETRY_MS[Math.min(attempt, RETRY_MS.length - 1)]
+    attempt++
+    setTimeout(() => {
+      void ensure().catch(() => undefined)
+    }, delay).unref?.()
+  }
+
+  const ensure = async (): Promise<PgClient | null> => {
+    if (client) return client
+    if (!connecting) {
+      connecting = connect().catch((error) => {
+        console.error('[realtime] bridge could not connect:', error)
+        connecting = null
+        schedule()
+        return null
+      })
+    }
+    return connecting
+  }
+
+  // Start listening at once: an instance that only ever receives, because
+  // nobody on it writes anything, still has to hear the others.
+  void ensure().catch(() => undefined)
+
+  return {
+    async notify(payload: string) {
+      const connection = await ensure()
+      if (!connection) return
+      try {
+        await connection.query('SELECT pg_notify($1, $2)', [CHANNEL, payload])
+      } catch (error) {
+        // The write that caused this has already happened and must not fail
+        // because the other instances could not be told.
+        console.error('[realtime] could not relay an event:', error)
+      }
+    },
+  }
+}

+ 159 - 0
src/lib/realtime/events.ts

@@ -0,0 +1,159 @@
+/**
+ * What the app tells its own screens, and who hears it.
+ *
+ * Every live update in the app used to be its own arrangement: a writer
+ * emitted a shape it invented, a page listened for the shape it happened to
+ * know, and anything written later was silently not live. A technician
+ * marking a job complete moved nothing on the desk, because the phone sent
+ * `job_updated` and the only listener was looking for `job_status_changed`.
+ *
+ * So there is one fact on the wire: *this record changed*. It carries ids and
+ * nothing else. The screen that cares re-reads the record through the same
+ * action it would use on a page load, which keeps permissions where they are
+ * enforced already and keeps a field somebody may not see off the socket.
+ *
+ * Delivery is by room. A tab subscribes to the records it is showing, the
+ * server checks each room belongs to that tab's workshop before joining it,
+ * and an event reaches only the sockets in the room. A desk on one work order
+ * never receives another's traffic.
+ *
+ * This file is the contract only: no server imports, so the browser and the
+ * server agree by reading the same types.
+ */
+
+/** Records a screen can follow. The string is on the wire; keep it stable. */
+export const RECORD_KINDS = [
+  'serviceRecord',
+  'inspection',
+  'quote',
+  'vehicle',
+  'customer',
+  'inventoryPart',
+  'tireSet',
+] as const
+
+export type RecordKind = (typeof RECORD_KINDS)[number]
+
+export function isRecordKind(value: unknown): value is RecordKind {
+  return typeof value === 'string' && (RECORD_KINDS as readonly string[]).includes(value)
+}
+
+export type RecordAction = 'created' | 'updated' | 'deleted'
+
+/** Who made the change, for the "edited by" line and for ignoring your own. */
+export interface ChangeAuthor {
+  userId: string | null
+  name: string | null
+  /** Where the change came from, so a page can say "from the app". */
+  source: 'web' | 'app' | 'system'
+}
+
+export interface RecordChange {
+  kind: RecordKind
+  /**
+   * The record that changed, or null when a write touched several at once
+   * (a bulk update names a filter, not rows). A null goes to the workshop
+   * room only, where a list re-reads itself; a record's own page is told by
+   * its own id and is never woken by somebody else's bulk write.
+   */
+  id: string | null
+  organizationId: string
+  action: RecordAction
+  by: ChangeAuthor
+  /** Epoch millis, for dropping a frame that arrives after a newer read. */
+  at: number
+  /**
+   * What changed, when the writer knows: 'labor', 'status', 'attachments'.
+   * Only ever a hint for how loudly to react; a screen that does not
+   * recognise it re-reads the record as it would for any other change.
+   */
+  hint?: string
+}
+
+/* ---------------------------------------------------------------- rooms -- */
+
+/** Everything in one workshop: for pages that watch a list, not a record. */
+export function orgRoom(organizationId: string): string {
+  return `org:${organizationId}`
+}
+
+/** One record: what a work order page and its presence chips subscribe to. */
+export function recordRoom(kind: RecordKind, id: string): string {
+  return `rec:${kind}:${id}`
+}
+
+export function parseRecordRoom(room: string): { kind: RecordKind; id: string } | null {
+  const parts = room.split(':')
+  if (parts.length !== 3 || parts[0] !== 'rec') return null
+  if (!isRecordKind(parts[1]) || !parts[2]) return null
+  return { kind: parts[1], id: parts[2] }
+}
+
+export function parseOrgRoom(room: string): string | null {
+  const parts = room.split(':')
+  return parts.length === 2 && parts[0] === 'org' && parts[1] ? parts[1] : null
+}
+
+/* ------------------------------------------------------------- presence -- */
+
+/**
+ * Somebody else on the same record.
+ *
+ * Presence is ephemeral: it lives in memory for as long as a socket does and
+ * is never written down. One person with the work order open on a laptop and
+ * a phone is one chip, with `devices` saying how many, the way Phoenix's
+ * presence and every chat app treat multiple sessions.
+ */
+export interface PresenceUser {
+  userId: string
+  name: string
+  /** Stable per user, so the same person is the same colour on every screen. */
+  color: string
+  devices: number
+}
+
+/** Eight colours that read clearly on both themes, picked by user id. */
+const PRESENCE_COLORS = [
+  '#2563eb',
+  '#db2777',
+  '#059669',
+  '#d97706',
+  '#7c3aed',
+  '#0891b2',
+  '#dc2626',
+  '#4d7c0f',
+]
+
+export function presenceColor(userId: string): string {
+  let hash = 0
+  for (let i = 0; i < userId.length; i++) hash = (hash * 31 + userId.charCodeAt(i)) >>> 0
+  return PRESENCE_COLORS[hash % PRESENCE_COLORS.length]
+}
+
+/* -------------------------------------------------------------- the wire -- */
+
+/** What a browser sends. Anything else is ignored rather than answered. */
+export type ClientMessage =
+  | { t: 'sub'; rooms: string[] }
+  | { t: 'unsub'; rooms: string[] }
+  /** Join the room's presence, so other people see the chip. */
+  | { t: 'enter'; room: string }
+  | { t: 'leave'; room: string }
+  | { t: 'ping' }
+
+/** What the server sends. */
+export type ServerMessage =
+  | { t: 'ready'; you: { userId: string; name: string; color: string } }
+  /** The rooms this socket is now in, after a sub: what was refused is absent. */
+  | { t: 'subscribed'; rooms: string[] }
+  | { t: 'record'; change: RecordChange }
+  | { t: 'presence'; room: string; users: PresenceUser[] }
+  | { t: 'pong' }
+  /** The old org-wide channels, until every page has moved to rooms. */
+  | { t: 'legacy'; channel: 'notification' | 'workboard' | 'broadcast'; data: unknown }
+
+export const REALTIME_PATH = '/api/protected/ws'
+
+/** How often a browser says it is still there, and when the server gives up. */
+export const PRESENCE_HEARTBEAT_MS = 20_000
+export const PRESENCE_TTL_MS = 55_000

+ 333 - 0
src/lib/realtime/presence.server.ts

@@ -0,0 +1,333 @@
+import 'server-only'
+
+import { INSTANCE_ID, onBusMessage, publishToBus } from './bus.server'
+import { PRESENCE_TTL_MS, type PresenceUser, presenceColor } from './events'
+import { shared } from './shared-state.server'
+
+/**
+ * Who else is on this record, right now.
+ *
+ * Ephemeral by design: it lives in these maps for as long as a socket does
+ * and is never written to the database. Nothing is lost when a process
+ * restarts, because every browser announces itself again the moment its
+ * socket reconnects.
+ *
+ * Two halves make a workshop's answer:
+ *
+ * - **Local**: the sockets on this instance, keyed by the socket itself.
+ * - **Remote**: what other instances say about the same room, each entry
+ *   stamped with an expiry. An instance that dies stops re-announcing and its
+ *   people fade out of the chips a minute later, rather than haunting the
+ *   room forever. That is the same heartbeat-and-evict arrangement Phoenix's
+ *   presence uses, and it is why no cleanup message is required on a crash.
+ *
+ * Every map entry is deleted the moment it empties: a room only exists while
+ * somebody is in it.
+ */
+
+export interface PresenceIdentity {
+  userId: string
+  name: string
+}
+
+type LocalEntry = PresenceIdentity
+
+interface RemoteEntry {
+  users: PresenceUser[]
+  expiresAt: number
+}
+
+type Socket = object
+
+const local = shared('presence.local', () => new Map<string, Map<Socket, LocalEntry>>())
+/**
+ * The same membership from the socket's side, so a closing socket is taken
+ * out of exactly the rooms it stood in. Without it, every close walked every
+ * occupied room in the process, which is the cost that grows with the number
+ * of workshops rather than with what one person had open.
+ */
+const roomsBySocket = shared('presence.bySocket', () => new Map<Socket, Set<string>>())
+const remote = shared('presence.remote', () => new Map<string, Map<string, RemoteEntry>>())
+const watchers = shared(
+  'presence.watchers',
+  () => new Set<(room: string, users: PresenceUser[]) => void>()
+)
+
+/** Told when a room's occupants change, so the socket route can send them. */
+export function onPresenceChange(
+  handler: (room: string, users: PresenceUser[]) => void
+): () => void {
+  watchers.add(handler)
+  return () => {
+    watchers.delete(handler)
+  }
+}
+
+/** Somebody opened the record. Idempotent: a second call just refreshes it. */
+export function enterRoom(room: string, socket: Socket, who: PresenceIdentity): void {
+  const occupants = local.get(room) ?? new Map<Socket, LocalEntry>()
+  occupants.set(socket, { ...who })
+  local.set(room, occupants)
+  const held = roomsBySocket.get(socket) ?? new Set<string>()
+  held.add(room)
+  roomsBySocket.set(socket, held)
+  announce(room)
+}
+
+/** Somebody closed it, or left the page. */
+export function leaveRoom(room: string, socket: Socket): void {
+  const occupants = local.get(room)
+  if (!occupants?.delete(socket)) return
+  if (occupants.size === 0) local.delete(room)
+  const held = roomsBySocket.get(socket)
+  if (held) {
+    held.delete(room)
+    if (held.size === 0) roomsBySocket.delete(socket)
+  }
+  announce(room)
+}
+
+/** Every room a closing socket was in, and only those. */
+export function leaveAllRooms(socket: Socket): void {
+  const held = roomsBySocket.get(socket)
+  if (!held) return
+  roomsBySocket.delete(socket)
+  for (const room of held) {
+    const occupants = local.get(room)
+    if (!occupants?.delete(socket)) continue
+    if (occupants.size === 0) local.delete(room)
+    announce(room)
+  }
+}
+
+/** What this instance would show for a room: its own people and everyone else's. */
+export function presenceOf(room: string): PresenceUser[] {
+  const merged = new Map<string, PresenceUser>()
+  for (const user of [...ownUsers(room), ...remoteUsers(room)]) {
+    const already = merged.get(user.userId)
+    if (!already) {
+      merged.set(user.userId, { ...user })
+      continue
+    }
+    // The same person on a laptop and a phone is one chip.
+    already.devices += user.devices
+  }
+  return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name))
+}
+
+/** This instance's own occupants, folded per person. */
+function ownUsers(room: string): PresenceUser[] {
+  const occupants = local.get(room)
+  if (!occupants) return []
+  const byUser = new Map<string, PresenceUser>()
+  for (const entry of occupants.values()) {
+    const already = byUser.get(entry.userId)
+    if (already) {
+      already.devices += 1
+      continue
+    }
+    byUser.set(entry.userId, {
+      userId: entry.userId,
+      name: entry.name,
+      color: presenceColor(entry.userId),
+      devices: 1,
+    })
+  }
+  return [...byUser.values()]
+}
+
+function remoteUsers(room: string): PresenceUser[] {
+  const instances = remote.get(room)
+  if (!instances) return []
+  const now = Date.now()
+  const users: PresenceUser[] = []
+  for (const [instanceId, entry] of instances) {
+    if (entry.expiresAt <= now) {
+      instances.delete(instanceId)
+      continue
+    }
+    users.push(...entry.users)
+  }
+  if (instances.size === 0) remote.delete(room)
+  return users
+}
+
+/**
+ * Tells this instance's watchers, and the other instances.
+ *
+ * Gathered for a moment first. Tabbing from one field to the next is a blur
+ * and a focus a millisecond apart, and a page opening is an enter and a focus:
+ * each would otherwise be its own frame to everybody in the room. One room,
+ * one frame per tick, carrying where things ended up.
+ */
+const ANNOUNCE_AFTER_MS = 25
+const dirty = shared('presence.dirty', () => new Set<string>())
+const announcer = shared('presence.announcer', () => ({
+  timer: null as ReturnType<typeof setTimeout> | null,
+}))
+
+function announce(room: string): void {
+  dirty.add(room)
+  if (announcer.timer) return
+  announcer.timer = setTimeout(flushPresence, ANNOUNCE_AFTER_MS)
+  announcer.timer.unref?.()
+}
+
+/** Sends what is waiting at once. The timer calls it; so do the tests. */
+export function flushPresence(): void {
+  if (announcer.timer) clearTimeout(announcer.timer)
+  announcer.timer = null
+  const rooms = [...dirty]
+  dirty.clear()
+  for (const room of rooms) {
+    const users = presenceOf(room)
+    for (const watcher of watchers) watcher(room, users)
+    publishToBus({
+      t: 'presence',
+      room,
+      instanceId: INSTANCE_ID,
+      users: ownUsers(room),
+      ttlMs: PRESENCE_TTL_MS,
+    })
+  }
+}
+
+/**
+ * Another instance's view of a room. Called by the bridge below, and directly
+ * by the tests, which have no second process to send from.
+ */
+export function acceptRemotePresence(
+  room: string,
+  users: PresenceUser[],
+  ttlMs: number,
+  from: string
+): void {
+  const instances = remote.get(room) ?? new Map<string, RemoteEntry>()
+  // Every instance repeats what it holds twice a minute so the others do not
+  // expire it. A repeat that says nothing new only renews the lease: telling
+  // the room again would be a frame to every viewer, for ever, about nothing.
+  const known = instances.get(from)
+  if (known && users.length > 0 && sameUsers(known.users, users)) {
+    known.expiresAt = Date.now() + ttlMs
+    return
+  }
+  if (!known && users.length === 0) return
+  if (users.length === 0) {
+    instances.delete(from)
+    if (instances.size === 0) remote.delete(room)
+    else remote.set(room, instances)
+  } else {
+    instances.set(from, { users, expiresAt: Date.now() + ttlMs })
+    remote.set(room, instances)
+  }
+  const merged = presenceOf(room)
+  for (const watcher of watchers) watcher(room, merged)
+}
+
+function sameUsers(a: PresenceUser[], b: PresenceUser[]): boolean {
+  if (a.length !== b.length) return false
+  return a.every((user, i) => {
+    const other = b[i]
+    return (
+      user.userId === other.userId && user.name === other.name && user.devices === other.devices
+    )
+  })
+}
+
+/**
+ * Re-announcing and sweeping.
+ *
+ * Every instance repeats what it holds well inside the others' expiry, so a
+ * room stays populated while people sit on it; and a sweep drops whatever has
+ * gone stale, which is what removes an instance that died without saying
+ * goodbye. Both run on one timer, unref'd so it never holds the process open.
+ */
+const REPEAT_MS = Math.floor(PRESENCE_TTL_MS / 2)
+
+const globalForPresence = globalThis as unknown as {
+  torqvoicePresenceTimer?: ReturnType<typeof setInterval>
+  torqvoicePresenceBridge?: () => void
+}
+
+export function startPresence(): void {
+  // Whoever calls this last owns the bridge and the timer: the previous ones
+  // are taken down first, so a reloaded module never leaves its predecessor's
+  // code running on a timer beside it, and there is only ever one of each.
+  globalForPresence.torqvoicePresenceBridge?.()
+  if (globalForPresence.torqvoicePresenceTimer) {
+    clearInterval(globalForPresence.torqvoicePresenceTimer)
+  }
+  globalForPresence.torqvoicePresenceBridge = onBusMessage((message) => {
+    if (message.t !== 'presence') return
+    // Our own snapshot comes back through the local emitter; it is already
+    // in `local`, and counting it again would double every chip.
+    if (message.instanceId === INSTANCE_ID) return
+    acceptRemotePresence(message.room, message.users, message.ttlMs, message.instanceId)
+  })
+  const timer = setInterval(() => {
+    for (const room of [...local.keys()]) {
+      publishToBus({
+        t: 'presence',
+        room,
+        instanceId: INSTANCE_ID,
+        users: ownUsers(room),
+        ttlMs: PRESENCE_TTL_MS,
+      })
+    }
+    sweep()
+  }, REPEAT_MS)
+  timer.unref?.()
+  globalForPresence.torqvoicePresenceTimer = timer
+}
+
+/** Drops expired remote entries and any room that is now empty. */
+export function sweep(now: number = Date.now()): void {
+  for (const [room, instances] of [...remote]) {
+    let expired = false
+    for (const [instanceId, entry] of [...instances]) {
+      if (entry.expiresAt > now) continue
+      instances.delete(instanceId)
+      expired = true
+    }
+    if (instances.size === 0) remote.delete(room)
+    // An instance that died never said goodbye: the people it held leave the
+    // chips here, rather than staying until somebody else happens to move.
+    if (expired) {
+      const users = presenceOf(room)
+      for (const watcher of watchers) watcher(room, users)
+    }
+  }
+  for (const [room, occupants] of [...local]) {
+    if (occupants.size === 0) local.delete(room)
+  }
+}
+
+/** What is held right now: the tests read this to prove nothing leaks. */
+export function presenceStats(): {
+  localRooms: number
+  remoteRooms: number
+  sockets: number
+  indexedSockets: number
+  waiting: number
+} {
+  const sockets = new Set<Socket>()
+  for (const occupants of local.values()) for (const socket of occupants.keys()) sockets.add(socket)
+  return {
+    localRooms: local.size,
+    remoteRooms: remote.size,
+    sockets: sockets.size,
+    indexedSockets: roomsBySocket.size,
+    waiting: dirty.size,
+  }
+}
+
+/** Only for tests. */
+export function resetPresence(): void {
+  local.clear()
+  roomsBySocket.clear()
+  remote.clear()
+  watchers.clear()
+  dirty.clear()
+  if (announcer.timer) clearTimeout(announcer.timer)
+  announcer.timer = null
+}

+ 301 - 0
src/lib/realtime/prisma-realtime.server.ts

@@ -0,0 +1,301 @@
+import 'server-only'
+
+import { Prisma } from '@/generated/prisma/client'
+import { currentActor } from './actor.server'
+import { publishRecordChange } from './publish.server'
+import type { ChangeAuthor, RecordAction, RecordKind } from './events'
+
+/**
+ * Every write the app makes, announced without being asked.
+ *
+ * This is the answer to why live updates kept getting missed. Announcing used
+ * to be a line somebody had to remember in the one place that wrote the row,
+ * and a feature written later simply did not have it: the technician app's
+ * labour endpoint wrote a line nobody was told about, and its status endpoint
+ * announced a shape no listener knew. Neither was a bug in the socket. They
+ * were both a forgotten line.
+ *
+ * So the announcement moved to the only place every write has to pass
+ * through. A new server action, a new API route, a cron job, a script: all of
+ * them write through this client, so all of them are live.
+ *
+ * What it can work out, and what it cannot:
+ *
+ * - A write to a record this app follows is announced with that record's id.
+ * - A write to a row that *belongs* to one (a labour line, an attachment, a
+ *   time entry) is announced as a change of its parent, because that is what
+ *   a screen is showing. The parent's id comes off the row being written.
+ * - A bulk write names a filter rather than rows, so it is announced without
+ *   an id: the workshop's lists re-read themselves, and no record's page is
+ *   woken by it.
+ * - A write with no workshop to be found is not announced at all. That is a
+ *   deliberate silence: without a workshop there is no room to send it to.
+ */
+
+/** Records a screen follows, by the Prisma model that holds them. */
+const RECORD_MODELS: Record<string, RecordKind> = {
+  ServiceRecord: 'serviceRecord',
+  Inspection: 'inspection',
+  Quote: 'quote',
+  Vehicle: 'vehicle',
+  Customer: 'customer',
+  InventoryPart: 'inventoryPart',
+  TireSet: 'tireSet',
+}
+
+/**
+ * Rows that belong to one of those records: the foreign key that says which,
+ * and the hint a page can use to react more precisely than "something changed".
+ */
+const CHILD_MODELS: Record<string, { parent: RecordKind; fk: string; hint: string }> = {
+  ServiceLabor: { parent: 'serviceRecord', fk: 'serviceRecordId', hint: 'labor' },
+  ServicePart: { parent: 'serviceRecord', fk: 'serviceRecordId', hint: 'parts' },
+  ServiceAttachment: { parent: 'serviceRecord', fk: 'serviceRecordId', hint: 'attachments' },
+  ServiceConcern: { parent: 'serviceRecord', fk: 'serviceRecordId', hint: 'concerns' },
+  StatusReport: { parent: 'serviceRecord', fk: 'serviceRecordId', hint: 'statusReports' },
+  TimeEntry: { parent: 'serviceRecord', fk: 'serviceRecordId', hint: 'clock' },
+  Payment: { parent: 'serviceRecord', fk: 'serviceRecordId', hint: 'payments' },
+  QuotePart: { parent: 'quote', fk: 'quoteId', hint: 'parts' },
+  QuoteLabor: { parent: 'quote', fk: 'quoteId', hint: 'labor' },
+  QuoteAttachment: { parent: 'quote', fk: 'quoteId', hint: 'attachments' },
+  InspectionItem: { parent: 'inspection', fk: 'inspectionId', hint: 'items' },
+  VehicleFinding: { parent: 'vehicle', fk: 'vehicleId', hint: 'findings' },
+  TireSetAttachment: { parent: 'tireSet', fk: 'tireSetId', hint: 'attachments' },
+  TireMeasurement: { parent: 'tireSet', fk: 'tireSetId', hint: 'measurements' },
+}
+
+const WRITES: Record<string, RecordAction | 'bulk'> = {
+  create: 'created',
+  createMany: 'bulk',
+  createManyAndReturn: 'bulk',
+  update: 'updated',
+  updateMany: 'bulk',
+  updateManyAndReturn: 'bulk',
+  upsert: 'updated',
+  delete: 'deleted',
+  deleteMany: 'bulk',
+}
+
+/**
+ * Which workshop a record belongs to, remembered.
+ *
+ * A row's id never changes workshop, so one lookup per record is enough for
+ * the life of the process. Bounded, and oldest out first: a shop that touches
+ * a hundred thousand rows must not grow this without end.
+ */
+const MAX_REMEMBERED = 5_000
+const orgById = new Map<string, string>()
+
+function remember(id: string, organizationId: string): void {
+  if (orgById.size >= MAX_REMEMBERED) {
+    const oldest = orgById.keys().next().value
+    if (oldest) orgById.delete(oldest)
+  }
+  orgById.set(id, organizationId)
+}
+
+type Row = Record<string, unknown>
+
+const stringOf = (value: unknown): string | null => (typeof value === 'string' ? value : null)
+
+/** The workshop named anywhere in this operation's arguments or result. */
+function organizationFrom(args: Row, result: unknown): string | null {
+  const where = (args.where ?? {}) as Row
+  // A plural create hands over a list of rows; any one of them names the workshop.
+  const data = ((Array.isArray(args.data) ? args.data[0] : args.data) ?? {}) as Row
+  const row = (result ?? {}) as Row
+  return (
+    stringOf(row.organizationId) ??
+    stringOf(data.organizationId) ??
+    stringOf(where.organizationId) ??
+    null
+  )
+}
+
+/** The row this operation acted on, when it names exactly one. */
+function idFrom(args: Row, result: unknown): string | null {
+  const row = (result ?? {}) as Row
+  const where = (args.where ?? {}) as Row
+  return stringOf(row.id) ?? stringOf(where.id) ?? null
+}
+
+/** The parent record a child row belongs to. */
+function parentIdFrom(fk: string, args: Row, result: unknown): string | null {
+  const row = (result ?? {}) as Row
+  const data = (args.data ?? {}) as Row
+  const where = (args.where ?? {}) as Row
+  return stringOf(row[fk]) ?? stringOf(data[fk]) ?? stringOf(where[fk]) ?? null
+}
+
+/** More parents than this in one bulk write and only the workshop is told. */
+const MAX_BULK_PARENTS = 20
+
+/**
+ * The parents a bulk write over child rows names outright.
+ *
+ * A work order saves its lines by deleting all of them and writing them again:
+ * `deleteMany({ where: { serviceRecordId } })`, then `createMany` with the
+ * same id on every row. Both name the one record they belong to, so the record
+ * is told, rather than the write being treated as anonymous because it was
+ * plural.
+ */
+function bulkParentIds(fk: string, args: Row): string[] {
+  const where = (args.where ?? {}) as Row
+  const direct = stringOf(where[fk])
+  if (direct) return [direct]
+  const rows = Array.isArray(args.data) ? (args.data as Row[]) : []
+  const ids = new Set<string>()
+  for (const row of rows) {
+    const id = stringOf(row?.[fk])
+    if (id) ids.add(id)
+    if (ids.size > MAX_BULK_PARENTS) return []
+  }
+  return [...ids]
+}
+
+export interface RealtimeHooks {
+  /** Looks a record's workshop up when the write did not carry one. */
+  organizationOf: (kind: RecordKind, id: string) => Promise<string | null>
+}
+
+/**
+ * The hook itself: run the operation, then say what it changed.
+ *
+ * Built with the workshop lookup handed in, so this module never imports the
+ * client it extends.
+ *
+ * Separate from the extension around it so it can be driven directly, which
+ * is how the tests prove that every kind of write announces itself.
+ */
+export function realtimeQueryHook(hooks: RealtimeHooks) {
+  return async function $allOperations({
+    model,
+    operation,
+    args,
+    query,
+  }: {
+    model?: string
+    operation: string
+    args: unknown
+    query: (args: unknown) => Promise<unknown>
+  }): Promise<unknown> {
+    // Asked before the query runs, not after. Prisma resolves a query from
+    // its own machinery, and what follows the await no longer knows whose
+    // request it was: every save came out as the system's, so no page could
+    // tell its own change from a colleague's.
+    const by = currentActor()
+    const result = await query(args)
+    try {
+      if (model) await announce(model, operation, (args ?? {}) as Row, result, hooks, by)
+    } catch (error) {
+      // A write must never fail because the screens could not be told.
+      console.error('[realtime] could not announce a write:', error)
+    }
+    return result
+  }
+}
+
+export function realtimeExtension(hooks: RealtimeHooks) {
+  return Prisma.defineExtension({
+    name: 'torqvoice-realtime',
+    query: { $allModels: { $allOperations: realtimeQueryHook(hooks) } },
+  })
+}
+
+async function announce(
+  model: string,
+  operation: string,
+  args: Row,
+  result: unknown,
+  hooks: RealtimeHooks,
+  by: ChangeAuthor
+): Promise<void> {
+  const action = WRITES[operation]
+  if (!action) return
+
+  const kind = RECORD_MODELS[model]
+  const child = CHILD_MODELS[model]
+  if (!kind && !child) return
+
+  if (kind) {
+    const id = action === 'bulk' ? null : idFrom(args, result)
+    const organizationId =
+      organizationFrom(args, result) ?? (id ? await organizationOf(kind, id, hooks) : null)
+    if (!organizationId) return
+    if (id) remember(id, organizationId)
+    publishRecordChange({
+      by,
+      kind,
+      id,
+      organizationId,
+      action: action === 'bulk' ? 'updated' : action,
+    })
+    return
+  }
+
+  if (!child) return
+  if (action === 'bulk') {
+    const parents = bulkParentIds(child.fk, args)
+    for (const id of parents) {
+      const organizationId =
+        organizationFrom(args, result) ?? (await organizationOf(child.parent, id, hooks))
+      if (!organizationId) continue
+      remember(id, organizationId)
+      publishRecordChange({
+        by,
+        kind: child.parent,
+        id,
+        organizationId,
+        hint: child.hint,
+      })
+    }
+    if (parents.length > 0) return
+  }
+  const parentId = action === 'bulk' ? null : parentIdFrom(child.fk, args, result)
+  if (!parentId) {
+    // A bulk write over children names no parent: the workshop hears it if
+    // the write said which one, and otherwise nothing is announced.
+    const organizationId = organizationFrom(args, result)
+    if (organizationId) {
+      publishRecordChange({
+        by,
+        kind: child.parent,
+        id: null,
+        organizationId,
+        hint: child.hint,
+      })
+    }
+    return
+  }
+  const organizationId =
+    organizationFrom(args, result) ?? (await organizationOf(child.parent, parentId, hooks))
+  if (!organizationId) return
+  remember(parentId, organizationId)
+  publishRecordChange({
+    by,
+    kind: child.parent,
+    id: parentId,
+    organizationId,
+    hint: child.hint,
+  })
+}
+
+async function organizationOf(
+  kind: RecordKind,
+  id: string,
+  hooks: RealtimeHooks
+): Promise<string | null> {
+  const known = orgById.get(id)
+  if (known) return known
+  const found = await hooks.organizationOf(kind, id)
+  if (found) remember(id, found)
+  return found
+}
+
+/** Only for tests: forget which workshop every record belongs to. */
+export function resetRealtimeMemory(): void {
+  orgById.clear()
+}
+
+export const REALTIME_RECORD_MODELS = RECORD_MODELS
+export const REALTIME_CHILD_MODELS = CHILD_MODELS

+ 158 - 0
src/lib/realtime/protocol.server.ts

@@ -0,0 +1,158 @@
+import 'server-only'
+
+import { mayJoin } from './authorize.server'
+import { enterRoom, leaveRoom } from './presence.server'
+import { isInRoom, join, leave, MAX_ROOMS_PER_SOCKET, tenantRoom } from './rooms.server'
+import type { ClientMessage, ServerMessage } from './events'
+
+/**
+ * What a browser asks for, and what it is given.
+ *
+ * Kept out of the route so it can be driven directly by a test. The ordering
+ * rule below is the reason: it is not visible in the shape of the code, only
+ * in what happens when two messages arrive together, and that is exactly the
+ * kind of fault that reaches production looking like "presence does not
+ * work sometimes".
+ *
+ * **One message at a time, in the order they were sent.** A browser
+ * subscribes to a room and then stands in it. The subscribe is checked
+ * against the database, so if the two were handled concurrently the "enter"
+ * would arrive while the socket was not yet in the room, be refused, and
+ * nobody's chip would appear. `queueFor` is what the route wraps a socket in.
+ */
+
+export interface Client {
+  userId: string
+  userName: string
+  organizationId: string
+  send(message: ServerMessage): void
+}
+
+/**
+ * The most one frame may weigh. The largest honest one is a reconnecting tab
+ * naming fifty rooms, which is a couple of kilobytes.
+ */
+export const MAX_FRAME_BYTES = 8 * 1024
+
+/**
+ * How many frames may wait behind the one being handled. A page sends a
+ * handful when it opens; a socket with hundreds queued is not a page, and its
+ * frames are dropped rather than kept in memory for it.
+ */
+export const MAX_QUEUED_FRAMES = 128
+
+function sizeOf(raw: unknown): number {
+  if (typeof raw === 'string') return raw.length
+  if (raw instanceof ArrayBuffer) return raw.byteLength
+  if (ArrayBuffer.isView(raw)) return raw.byteLength
+  if (Array.isArray(raw)) return raw.reduce((sum: number, part) => sum + sizeOf(part), 0)
+  return 0
+}
+
+/** Anything unknown is ignored rather than answered. */
+export async function handleClientMessage(
+  client: Client,
+  socket: object,
+  raw: unknown,
+  trace: (what: string) => void = () => undefined
+): Promise<void> {
+  if (sizeOf(raw) > MAX_FRAME_BYTES) return
+  let message: ClientMessage
+  try {
+    message = JSON.parse(String(raw)) as ClientMessage
+  } catch {
+    return
+  }
+  if (!message || typeof message.t !== 'string') return
+
+  // Every room this socket touches is its own workshop's (rooms.server.ts).
+  // The browser's name for a room is only ever used to answer the browser.
+  const mine = (room: string) => tenantRoom(client.organizationId, room)
+
+  switch (message.t) {
+    case 'ping':
+      client.send({ t: 'pong' })
+      return
+
+    case 'sub': {
+      if (!Array.isArray(message.rooms)) return
+      const joined: string[] = []
+      for (const room of message.rooms.slice(0, MAX_ROOMS_PER_SOCKET)) {
+        if (typeof room !== 'string') continue
+        if (isInRoom(socket, mine(room))) {
+          joined.push(room)
+          continue
+        }
+        // The room name came from the browser, so it is a request, never a
+        // fact: the server decides whether it belongs to this workshop.
+        if (!(await mayJoin(room, client.organizationId))) continue
+        if (!join(socket, mine(room))) continue
+        joined.push(room)
+      }
+      trace(
+        `${client.userName} subscribed to ${joined.join(', ') || 'nothing'} ` +
+          `(asked for ${message.rooms.join(', ')})`
+      )
+      client.send({ t: 'subscribed', rooms: joined })
+      return
+    }
+
+    case 'unsub': {
+      if (!Array.isArray(message.rooms)) return
+      for (const room of message.rooms) {
+        if (typeof room !== 'string') continue
+        leaveRoom(mine(room), socket)
+        leave(socket, mine(room))
+      }
+      return
+    }
+
+    case 'enter': {
+      // Presence follows a subscription, so the check on the way in is the
+      // only gate needed here.
+      if (typeof message.room !== 'string') return
+      if (!isInRoom(socket, mine(message.room))) {
+        trace(`${client.userName} tried to enter ${message.room} without holding it`)
+        return
+      }
+      trace(`${client.userName} entered ${message.room}`)
+      // The room is told, the newcomer included, a tick later (presence
+      // gathers its announcements): one frame, and one place that sends it.
+      enterRoom(mine(message.room), socket, { userId: client.userId, name: client.userName })
+      return
+    }
+
+    case 'leave': {
+      if (typeof message.room !== 'string') return
+      leaveRoom(mine(message.room), socket)
+      return
+    }
+  }
+}
+
+/**
+ * A socket's messages, handled one after another.
+ *
+ * Returns the function the route hands every incoming frame to. Errors are
+ * logged and swallowed, so one bad frame cannot stop the ones behind it.
+ */
+export function queueFor(
+  client: Client,
+  socket: object,
+  trace?: (what: string) => void
+): (raw: unknown) => void {
+  let queue: Promise<void> = Promise.resolve()
+  let waiting = 0
+  return (raw: unknown) => {
+    if (waiting >= MAX_QUEUED_FRAMES) return
+    waiting++
+    queue = queue
+      .then(() => handleClientMessage(client, socket, raw, trace))
+      .catch((error) => {
+        console.error('[realtime] a message could not be handled:', error)
+      })
+      .finally(() => {
+        waiting--
+      })
+  }
+}

+ 127 - 0
src/lib/realtime/publish.server.ts

@@ -0,0 +1,127 @@
+import 'server-only'
+
+import { AsyncLocalStorage } from 'node:async_hooks'
+import { publishToBus } from './bus.server'
+import { currentActor } from './actor.server'
+import type { ChangeAuthor, RecordAction, RecordChange, RecordKind } from './events'
+import { shared } from './shared-state.server'
+
+/**
+ * Saying that a record changed.
+ *
+ * One entry point, so there is one shape on the wire and one place to change
+ * how announcing works. Callers almost never reach this directly: the Prisma
+ * extension (prisma-realtime.server.ts) announces every write the app makes,
+ * which is what stops a new feature from being quietly not live.
+ *
+ * Two properties worth knowing:
+ *
+ * - **Coalesced.** A save that writes a job, deletes its labour lines and
+ *   writes them again is one change to the screen reading it, not four. The
+ *   same record and action within a tick collapse, so a big save wakes each
+ *   viewer once.
+ * - **Never before the transaction commits.** A viewer answers an event by
+ *   reading the record again, at once. Told while the transaction was still
+ *   open, it read the row as it was before the save, showed that, and was
+ *   never told again: the update looked lost. So a change made inside
+ *   `db.$transaction` is held (`holdingChanges`, wired in lib/db.ts, so no
+ *   writer has to remember) and sent when the transaction resolves. One that
+ *   rolls back says nothing, because nothing happened.
+ */
+
+type Pending = Map<string, RecordChange>
+
+/** Changes made inside one open transaction, waiting for it to commit. */
+interface HeldChanges {
+  held: Pending
+  done: boolean
+}
+
+const transaction = shared('publish.transaction', () => new AsyncLocalStorage<HeldChanges>())
+
+const keyOf = (change: RecordChange): string =>
+  `${change.organizationId}:${change.kind}:${change.id ?? '*'}:${change.action}`
+
+/**
+ * Runs a transaction, and announces what it changed once it has committed.
+ *
+ * Node's async context follows the awaits inside the callback, so a write
+ * five calls down is held without being passed anything. A transaction inside
+ * another belongs to the outer one, which is the one that commits.
+ */
+export async function holdingChanges<T>(run: () => Promise<T>): Promise<T> {
+  if (transaction.getStore()) return run()
+  const scope: HeldChanges = { held: new Map(), done: false }
+  try {
+    const result = await transaction.run(scope, run)
+    for (const change of scope.held.values()) enqueue(change)
+    return result
+  } finally {
+    // A write still running after its transaction ended is announced at once
+    // rather than dropped into a scope nobody will ever flush.
+    scope.done = true
+    scope.held.clear()
+  }
+}
+
+const pending = shared<Pending>('publish.pending', () => new Map())
+const flusher = shared('publish.flusher', () => ({
+  timer: null as ReturnType<typeof setTimeout> | null,
+}))
+
+export interface PublishInput {
+  kind: RecordKind
+  /** Null when a bulk write touched several rows: only the workshop hears it. */
+  id: string | null
+  organizationId: string
+  action?: RecordAction
+  /** What part of the record changed, when the writer knows. */
+  hint?: string
+  /** Overrides the ambient actor; for a job acting on somebody's behalf. */
+  by?: ChangeAuthor
+}
+
+export function publishRecordChange(input: PublishInput): void {
+  if (!input.organizationId) return
+  const change: RecordChange = {
+    kind: input.kind,
+    id: input.id,
+    organizationId: input.organizationId,
+    action: input.action ?? 'updated',
+    by: input.by ?? currentActor(),
+    at: Date.now(),
+    hint: input.hint,
+  }
+  if (process.env.NODE_ENV !== 'production') {
+    console.warn(
+      `[realtime] publish ${change.kind}:${change.id ?? '*'} ${change.action}` +
+        `${change.hint ? ` (${change.hint})` : ''} by ${change.by.userId ?? 'system'}/${change.by.source}`
+    )
+  }
+  const scope = transaction.getStore()
+  if (scope && !scope.done) {
+    scope.held.set(keyOf(change), change)
+    return
+  }
+  enqueue(change)
+}
+
+function enqueue(change: RecordChange): void {
+  pending.set(keyOf(change), change)
+  if (flusher.timer) return
+  flusher.timer = setTimeout(flush, 0)
+  flusher.timer.unref?.()
+}
+
+function flush(): void {
+  flusher.timer = null
+  const changes = [...pending.values()]
+  pending.clear()
+  for (const change of changes) publishToBus({ t: 'record', change })
+}
+
+/** Sends whatever is waiting at once; for tests and for a clean shutdown. */
+export function flushRecordChanges(): void {
+  if (flusher.timer) clearTimeout(flusher.timer)
+  flush()
+}

+ 136 - 0
src/lib/realtime/rooms.server.ts

@@ -0,0 +1,136 @@
+import 'server-only'
+
+import { shared } from './shared-state.server'
+
+/**
+ * Which socket is in which room, and nothing else.
+ *
+ * A room is not an object with a lifetime: it is an entry in this map that
+ * exists exactly as long as somebody is in it. The last socket to leave takes
+ * the entry with it, because a workshop can open thousands of work orders in
+ * a week and a server that keeps an empty room for each one has a leak that
+ * only shows up in month three.
+ *
+ * Three rules keep that true:
+ *
+ * 1. `leave` deletes the entry when the set empties, rather than leaving an
+ *    empty Set behind.
+ * 2. Every socket's rooms are dropped in one call when it closes, and a
+ *    socket that dies without a close event is dropped by the ping timeout
+ *    that already terminates it.
+ * 3. A socket may hold at most MAX_ROOMS_PER_SOCKET rooms, so a page with a
+ *    bug, or a client someone wrote themselves, cannot grow the map without
+ *    bound. It is far more than any real page needs.
+ *
+ * `stats()` is what the tests read to prove there is nothing left behind.
+ */
+
+export const MAX_ROOMS_PER_SOCKET = 50
+
+/**
+ * A room as the server keeps it: the workshop first, then the name the
+ * browser knows it by.
+ *
+ * This is the second wall between workshops, and it does not depend on the
+ * first. A socket may only join a room after `mayJoin` has checked the record
+ * belongs to its workshop; but a check is code, and code has bugs. So the
+ * key itself carries the workshop, taken from the socket's own session and
+ * never from anything the browser sent. Workshop A's sockets can only ever
+ * stand in rooms that begin with A, and a change in workshop B is only ever
+ * sent to rooms that begin with B: there is no room the two could share,
+ * whatever either of them asks for.
+ */
+const SEPARATOR = '|'
+
+export function tenantRoom(organizationId: string, room: string): string {
+  if (!organizationId || organizationId.includes(SEPARATOR)) {
+    throw new Error('a room needs a workshop, and a workshop id has no separator in it')
+  }
+  return `${organizationId}${SEPARATOR}${room}`
+}
+
+export function parseTenantRoom(key: string): { organizationId: string; room: string } | null {
+  const at = key.indexOf(SEPARATOR)
+  if (at <= 0 || at === key.length - 1) return null
+  return { organizationId: key.slice(0, at), room: key.slice(at + 1) }
+}
+
+/** Anything with an identity; the socket itself in production. */
+export type Member = object
+
+const members = shared('rooms.members', () => new Map<Member, Set<string>>())
+const rooms = shared('rooms.rooms', () => new Map<string, Set<Member>>())
+
+/** Adds a socket to a room. False when it is already holding its limit. */
+export function join(member: Member, room: string): boolean {
+  const held = members.get(member) ?? new Set<string>()
+  if (!held.has(room) && held.size >= MAX_ROOMS_PER_SOCKET) return false
+  held.add(room)
+  members.set(member, held)
+
+  const occupants = rooms.get(room) ?? new Set<Member>()
+  occupants.add(member)
+  rooms.set(room, occupants)
+  return true
+}
+
+/** Removes a socket from one room, and the room when it was the last one. */
+export function leave(member: Member, room: string): void {
+  const held = members.get(member)
+  if (held) {
+    held.delete(room)
+    if (held.size === 0) members.delete(member)
+  }
+  const occupants = rooms.get(room)
+  if (!occupants) return
+  occupants.delete(member)
+  if (occupants.size === 0) rooms.delete(room)
+}
+
+/** Everything a socket was in, on its way out. Returns the rooms it held. */
+export function leaveAll(member: Member): string[] {
+  const held = members.get(member)
+  if (!held) return []
+  const left = [...held]
+  for (const room of left) {
+    const occupants = rooms.get(room)
+    if (!occupants) continue
+    occupants.delete(member)
+    if (occupants.size === 0) rooms.delete(room)
+  }
+  members.delete(member)
+  return left
+}
+
+export function roomsOf(member: Member): ReadonlySet<string> {
+  return members.get(member) ?? new Set()
+}
+
+export function membersOf(room: string): ReadonlySet<Member> {
+  return rooms.get(room) ?? new Set()
+}
+
+export function isInRoom(member: Member, room: string): boolean {
+  return members.get(member)?.has(room) ?? false
+}
+
+/** Runs `send` for every socket in the room; no allocation when it is empty. */
+export function broadcast(room: string, send: (member: Member) => void): number {
+  const occupants = rooms.get(room)
+  if (!occupants) return 0
+  for (const member of occupants) send(member)
+  return occupants.size
+}
+
+/** What is being held right now. For the tests, and for a health readout. */
+export function stats(): { rooms: number; members: number; subscriptions: number } {
+  let subscriptions = 0
+  for (const held of members.values()) subscriptions += held.size
+  return { rooms: rooms.size, members: members.size, subscriptions }
+}
+
+/** Only for tests: forget everything. */
+export function resetRooms(): void {
+  members.clear()
+  rooms.clear()
+}

+ 27 - 0
src/lib/realtime/shared-state.server.ts

@@ -0,0 +1,27 @@
+import 'server-only'
+
+/**
+ * State that has to be one thing per process, however many times its module
+ * is loaded.
+ *
+ * A module's top-level `const` is not that. Next builds server actions, pages
+ * and route handlers as separate bundles, each with its own copy of a module,
+ * and development re-evaluates a module every time it is edited. The Prisma
+ * client and the event bus were already kept on `globalThis` for this reason,
+ * and the rest was not, which broke the layer in two quiet ways:
+ *
+ * - `withAuth` recorded who was writing in one copy of the actor store while
+ *   the Prisma hook, created once and kept for the life of the process, read
+ *   another. Every save came out as the system's, so no page could recognise
+ *   its own change.
+ * - The socket route put a browser into one copy of the room map while
+ *   changes were sent to the people in another: subscribed, and told nothing.
+ *
+ * Everything the live layer shares across requests is created through here.
+ */
+export function shared<T>(key: string, create: () => T): T {
+  const store = globalThis as unknown as Record<string, unknown>
+  const name = `torqvoice.realtime.${key}`
+  if (!(name in store)) store[name] = create()
+  return store[name] as T
+}

+ 13 - 8
src/lib/with-api-auth.ts

@@ -1,4 +1,5 @@
 import { NextResponse } from 'next/server'
 import { NextResponse } from 'next/server'
+import { runAsActor } from '@/lib/realtime/actor.server'
 import { ZodError } from 'zod'
 import { ZodError } from 'zod'
 import { auth } from './auth'
 import { auth } from './auth'
 import { db } from './db'
 import { db } from './db'
@@ -177,14 +178,18 @@ export async function withApiAuth(
   }
   }
 
 
   try {
   try {
-    return await handler({
-      userId,
-      organizationId,
-      role: isSuperAdmin ? 'super_admin' : (membership.role ?? 'member'),
-      isSuperAdmin,
-      isAdmin: isSuperAdmin || isOwnerOrAdmin || roleIsAdmin,
-      technicianIds,
-    })
+    // Inside the actor, so a write from the phone tells the desk's screens
+    // that it came from the app and who made it (lib/realtime).
+    return await runAsActor({ userId, name: null, source: 'app' }, () =>
+      handler({
+        userId,
+        organizationId,
+        role: isSuperAdmin ? 'super_admin' : (membership.role ?? 'member'),
+        isSuperAdmin,
+        isAdmin: isSuperAdmin || isOwnerOrAdmin || roleIsAdmin,
+        technicianIds,
+      })
+    )
   } catch (err) {
   } catch (err) {
     // Zod messages describe the caller's own payload, so they are safe and
     // Zod messages describe the caller's own payload, so they are safe and
     // genuinely useful to return. Everything else is ours and stays here.
     // genuinely useful to return. Everything else is ours and stays here.

+ 18 - 2
src/lib/with-auth.ts

@@ -8,6 +8,7 @@ import { logAudit } from '@/lib/audit'
 import type { AuditEvent } from '@/lib/audit'
 import type { AuditEvent } from '@/lib/audit'
 import type { FeatureGatedError } from '@/lib/features'
 import type { FeatureGatedError } from '@/lib/features'
 import { publicErrorMessage } from '@/lib/public-error-message'
 import { publicErrorMessage } from '@/lib/public-error-message'
+import { runAsActor } from '@/lib/realtime/actor.server'
 
 
 /** What the plan refused, and the number it stopped at when there is one. */
 /** What the plan refused, and the number it stopped at when there is one. */
 export type GatedFeature = { feature: string; limit?: number }
 export type GatedFeature = { feature: string; limit?: number }
@@ -18,6 +19,12 @@ export type ActionResult<T = unknown> = {
   error?: string
   error?: string
   /** Set when the action was refused by the plan rather than by a failure. */
   /** Set when the action was refused by the plan rather than by a failure. */
   gated?: GatedFeature
   gated?: GatedFeature
+  /**
+   * Set when this person's role does not allow the action. `error` is an
+   * English sentence for the log; a screen that can say something more useful
+   * than "it failed" (who to ask, what to ask for) checks this instead.
+   */
+  forbidden?: boolean
 }
 }
 
 
 export type AuthContext = {
 export type AuthContext = {
@@ -120,12 +127,21 @@ export async function withAuth<T>(
           }).catch(() => {
           }).catch(() => {
             /* best-effort */
             /* best-effort */
           })
           })
-          return { success: false, error: 'Insufficient permissions' }
+          return { success: false, error: 'Insufficient permissions', forbidden: true }
         }
         }
       }
       }
     }
     }
 
 
-    const data = await action(ctx)
+    // Run inside the actor, so every write underneath knows whose it was and
+    // the live update can say who changed the record (lib/realtime).
+    const data = await runAsActor(
+      {
+        userId: ctx.userId,
+        name: session.user.name || session.user.email || null,
+        source: 'web',
+      },
+      () => action(ctx)
+    )
 
 
     // Post-success audit logging (fire-and-forget, logAudit handles its own errors)
     // Post-success audit logging (fire-and-forget, logAudit handles its own errors)
     if (options.audit) {
     if (options.audit) {