Bladeren bron

Technician app API, time tracking and the role fix (#268)

* Technician API: bearer auth, job list and job clock

Adds the token-authenticated surface the technician app talks to. Server
actions cannot serve a native client, so this is the first real API in the
product beyond health and cron.

- TimeEntry records what a technician actually worked, separate from the work
  board's planned slot. Many rows per job, one per person per stretch.
- withApiAuth resolves bearer tokens through Better Auth rather than querying
  the session table, so revoking a web session kills the phone's too.
- Permission semantics mirror withAuth exactly, including that a member with
  no custom role is unrestricted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Let the technician app reach the API from a browser in development

Expo serves the app on its own port, so every request to the workshop is
cross-origin and the browser blocks it before the app sees a response. From
inside the app that is indistinguishable from being offline.

Development only. The real client is native and not subject to the same-origin
policy, so production sends no CORS headers at all and should not: the only
thing they would enable there is a browser on someone else's origin holding a
workshop's bearer token.

Covers the auth endpoints too, since sign-in happens before the app has a
session, and exposes set-auth-token so the browser will hand the session token
to the app rather than hiding it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Use proxy.ts, not middleware.ts, for dev CORS

Next 16 renamed the file. On 16.3.1 the old name still loads and runs, but
every matched API route then answers 404 whatever the handler returns, even a
no-op that returns undefined. That reads as a missing route rather than a
misplaced file, so the reason is written down in the file itself.

Also widens the trusted Expo origins across the ports Expo walks up through
when 8081 is busy, since a fixed port goes stale the first time two dev
servers overlap and sign-in then fails for a reason nothing explains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Trust the Expo dev server origin whatever port it lands on

Signing in from Expo web was refused with 'Invalid origin', which reads as a
rejected password when it is really a CSRF check doing its job.

Wildcarded on the port rather than pinned: Expo walks up from 8081 when a port
is busy, so a pinned list goes stale the first time two dev servers overlap.
The LAN host is derived from the app URL rather than hardcoded so it survives
a change of network. Development only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Return the real error to the app in development

A native client runs somewhere other than the server console, so a generic
500 means walking away from the device to find out what happened. The person
holding the phone is the person who can fix it.

Production still says nothing: an internal message is a map of the server
drawn for whoever asked for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Let a member be marked a technician from the team page

Technicians could only be linked from the work board's technician dialog, on
a scheduling screen a shop owner may never open. Adding someone to the team
and saying 'this person works jobs' is one thought, so it should be one
screen.

The work board keeps the full editor: colour, capacity, and technicians who
have no login. This is the yes-or-no half of the same idea.

Turning it off deactivates the row rather than deleting it. Past jobs,
inspections and status reports point at that row, so removing it would rewrite
history to say nobody did the work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Technician API: parts, photos, observations, status reports, push

The rest of what the app needs to be useful in a bay.

- Part booking shares its logic with the web action rather than duplicating it.
  Two implementations of 'add a part' drift on the least visible and most
  expensive detail: whether stock actually moved.
- The client never sends a price. Letting a phone name the money puts the
  shop's margin on the far side of a network boundary.
- Photos and status-report videos upload and attach in one request. The web's
  two-step leaves orphaned files on disk when a bay loses signal between them,
  and nothing to notice it.
- Filenames are always generated. A name that arrives over the wire is an
  attacker-controlled path.
- Status reports are created, never sent. Messaging a customer is an
  outward-facing decision that does not belong to a phone in a noisy bay.
- PushDevice is keyed on the device token, so a shared bench tablet moves to
  whoever signs in and stops notifying whoever signed out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Name the status-report upload field 'file' like every other upload

One shape on the client instead of a per-endpoint special case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Technician WebSocket, and opt-in sending for customer updates

The work board's socket closes any connection that is not owner or admin, and
broadcasts whole job objects to every client in the organization. Letting
technicians onto it would put every job in the shop on every technician's
phone, so this is a second socket with a different exposure of the same
notificationBus events.

It carries no detail at all: it says something changed and the app re-reads
through the endpoint that already filters to that technician's own work. That
keeps who-may-see-which-job in exactly one place.

Auth is the bearer token, in the WebSocket subprotocol rather than the query
string, resolved through Better Auth so a revoked session cannot hold a socket
open that a request could not open. The proxy matcher excludes this path,
since an upgrade handshake is not an ordinary response to put CORS headers on.

Status reports also gain an opt-in send, defaulting to off, going out on
whatever channels the customer actually has on file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Job status, labour lines, and one source for the minimum app version

Status closes the loop the app could not: it could start work and never
finish it. Completing or parking a job also stops its clock, since a
technician who has moved on will not remember to.

Labour lines take their rate from workshop settings rather than the client,
for the same reason part prices do: a phone naming the shop's money puts
pricing on the far side of a network boundary.

minAppVersion now comes from one constant and is returned by /me as well as
/health. /health is only read at setup, so a minimum the app never re-checks
is a minimum that cannot be raised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Decode the hex-encoded socket token

Matches the client. A subprotocol name may only use RFC 7230 token characters
and a session token is base64, so it cannot travel as-is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Resolve the workshop the same way everywhere

The technician app holds a bearer token and sends no cookies, so it names its
workshop in a header. Only withApiAuth read that. Everything else resolved a
multi-workshop user to whichever membership came back first, so a technician
was refused photos from their second workshop and their live-update socket
subscribed to the wrong one.

getCachedMembership now reads the header before the cookie, which fixes it for
file serving, server actions and everything else in one place. The socket
takes the workshop as a second subprotocol, since a handshake carries no
headers of ours. Neither value grants anything: both only select, and the
membership lookup still decides.

Rate limits are now keyed on a hash of the caller's token rather than their
IP. A workshop is one public address, so eight technicians on the shop wifi
were dividing a single budget and the busiest bay exhausted it for everyone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Say 'New job assignment', and name the car as well as the work

Plate and title together, matching the running-clock notification. A plate
says which car without saying what to do to it; a title says the opposite, and
on a lock screen the technician gets one look.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* A member with no role now has no permissions

The settings screen said 'No custom role means read-only access.' The code
skipped the permission check entirely for those members, which meant
unrestricted: anyone added as a Member and never given a role could reach
billing, settings and the team, and nothing on screen said so. The comment in
withAuth stated it as intent, so it was a decision, but the screen has been
telling admins the opposite ever since.

Fixed in both wrappers, which must keep agreeing: an API that disagrees with
the web app about who may do what is a hole shaped like a client. The
settings copy now describes what actually happens, in all twelve locales.

scripts/backfill-member-roles.ts has to run before this deploys. It gives
every existing roleless member an explicit role recording what they already
have, so nobody is locked out mid-shift, an admin can finally see on the team
page that those accounts have full access, and they can be narrowed
deliberately afterwards. It restricts nobody by itself; the improvement is
that the default becomes deny and the truth becomes visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Add a job notes endpoint for the technician app

Appends rather than replaces. The field is shared with the office, and a phone
sending the whole thing back would silently overwrite whatever was added while
the technician had the screen open. Losing a colleague's note is worse than an
untidy one.

Each entry is stamped with the technician's name and the date, because a
shared field with several authors and no attribution becomes unreadable within
a week, and escaped on the way in, because the web renders it as HTML and
'pads < 2mm' should not produce markup nobody can see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Photo deletion, and an optional labour description

A technician photographing a dark wheel arch takes three before one is usable.
Being able to take a photo and not remove it left the other two on the job
forever, in front of the customer. The row is deleted before the file, because
a row without its file is a broken image they can delete again while a file
without its row is invisible and stays on disk.

The labour description is optional now. Requiring it put a keyboard between a
technician and clocking off, for a line the job already names. Blank falls
back to the job title so it still reads as something on an invoice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Return the minutes a job has already banked with the open entry

So the running bar counts from the same place the job screen does. Without it
the two showed different numbers for the same job, since only the job screen
knew what had been logged before the current stretch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Tell the desk when a technician moves a job from the app

The point of the technician app is that the office stops walking into the bay
to ask how a car is getting on. That only works if the office finds out, and
nothing told them: a job could be finished on a phone and the desk would learn
about it by refreshing the board.

Uses the existing notify() helper, so it lands in the in-app notification list
and reaches connected clients over the socket like everything else. Not
awaited: the status change succeeded the moment the row was written, and a
notification failing must not undo it.

Settings, Alerts has a switch for it. On unless turned off, because a shop
that has never opened that page should still be told, and opting out has to be
a decision somebody made rather than one they never encountered. Strings in
all twelve locales.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Name the technician app in full on the alerts card

'Technician app' could be any app. Naming it Torqvoice Technician App and its
platforms tells an admin which thing the switch governs, which matters on a
settings page that already has three cards about notifications.

Product name kept in every locale, descriptive half translated, platforms left
as written since Android and iOS are proper nouns throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Use the alerts page's own save pattern on the technician card

I had invented alerts.save and alerts.saved, which do not exist: every card
here scopes its own keys, and the page threw a missing-message error on
render. The card also skipped the SaveButton guard, so it would have shown a
working save button to a member who may not edit settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Backfill roleless members in the deploy, not by hand

The role fix needed a data migration run against production before the
code shipped, and it was a script somebody had to remember to run, with
the right connection string, from a shell on the box. That is not a step
that survives contact with a Friday afternoon.

It is now a migration. The container's entrypoint already runs
prisma migrate deploy before starting the app, so it happens in the same
deploy as the code that depends on it, in the right order, with no
opportunity to point it at the wrong database.

Explicitly wrapped in BEGIN/COMMIT. Prisma does not put a migration file
in a transaction of its own, and a failure partway through would
otherwise leave some workshops with a role and others without. Verified
by failing one deliberately: nothing is written, the migration is
recorded as failed, and every later deploy refuses with P3009 rather than
starting the new code against half-migrated data.

Set-based rather than a loop per workshop. Worst case measured at 15s for
5,000 affected workshops, which is inside the deploy's 60s health-check
window and far shorter than the pre-deploy pg_dump that already gates it.

Verified against a seeded database: owners and admins untouched, members
already given a narrower role untouched, workshops with nobody affected
get no role, and re-running changes nothing.

* Drop the role backfill; production does not need it

Cloud production has no roleless members outside owners and admins, so
the code change locks nobody out and there is nothing to record. A
migration that writes zero rows is a thing to maintain and reason about
for no return.

The reasoning is now a comment where the check lives, including what it
means for a self-hosted install that does hold one: that person loses
access on upgrade and needs a role assigning. That is the correct end
state, it just arrives unannounced, so it belongs in the release note.

* test

* Back up clocked time, and fix the tests the role change broke

TimeEntry and PushDevice were added to the schema without being
classified for backup, which the manifest test catches. TimeEntry needed
carrying: it is what a technician's hours were billed from and, in
Germany, a statutory record, and a restore deleted it with nothing to put
it back. It now travels inside its service record like a status report,
in both the export and the import. PushDevice is excluded with a reason:
a push token belongs to one phone and one user account, and a backup
carries neither.

Entries whose technician was not part of the restore are dropped rather
than failing the import. technicianId is not nullable, so a vehicles-only
restore would otherwise die on a foreign key and roll back everything.

Also closes the hole that let this ship: the route tests only checked
top-level keys, so a nested entity could be classified in the manifest
and carried by neither route. The import half is now checked too.

Three test files asserted the old permission bypass. Two isolation
fixtures used a member with no role, which passed only because withAuth
skipped permission checks for those; they now hold the permission the
test is actually about. The team-invite pair asserted the action's own
"only owners and admins" message, which a member no longer reaches
because the permission check stops them one layer earlier.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Bernt Christian Egeland 1 maand geleden
bovenliggende
commit
23659115c5
100 gewijzigde bestanden met toevoegingen van 5668 en 2522 verwijderingen
  1. 14 2
      messages/de/settings.json
  2. 14 2
      messages/en/settings.json
  3. 14 2
      messages/es/settings.json
  4. 14 2
      messages/fr/settings.json
  5. 14 2
      messages/it/settings.json
  6. 14 2
      messages/lt/settings.json
  7. 14 2
      messages/nb/settings.json
  8. 14 2
      messages/nl/settings.json
  9. 14 2
      messages/pl/settings.json
  10. 14 2
      messages/pt-BR/settings.json
  11. 14 2
      messages/ru/settings.json
  12. 14 2
      messages/tr/settings.json
  13. 39 0
      prisma/migrations/20260827095320_technician_time_entries/migration.sql
  14. 29 0
      prisma/migrations/20260827111920_push_devices/migration.sql
  15. 102 0
      prisma/schema.prisma
  16. 22 5
      src/__tests__/auth/permission-enforcement.test.ts
  17. 22 0
      src/__tests__/lib/backup-manifest.test.ts
  18. 170 132
      src/__tests__/lib/with-auth.test.ts
  19. 123 98
      src/__tests__/multitenancy/service-record-isolation.test.ts
  20. 421 411
      src/__tests__/multitenancy/team-invite-isolation.test.ts
  21. 242 220
      src/app/(authenticated)/settings/alerts/alerts-settings.tsx
  22. 6 6
      src/app/(authenticated)/settings/alerts/page.tsx
  23. 16 10
      src/app/(authenticated)/settings/team/page.tsx
  24. 54 0
      src/app/(authenticated)/settings/team/team-settings.tsx
  25. 2 0
      src/app/api/protected/backup/export/route.ts
  26. 39 0
      src/app/api/protected/backup/import/route.ts
  27. 72 0
      src/app/api/v1/tech/devices/route.ts
  28. 27 0
      src/app/api/v1/tech/health/route.ts
  29. 66 0
      src/app/api/v1/tech/jobs/[id]/attachments/[attachmentId]/route.ts
  30. 145 0
      src/app/api/v1/tech/jobs/[id]/attachments/route.ts
  31. 78 0
      src/app/api/v1/tech/jobs/[id]/findings/route.ts
  32. 114 0
      src/app/api/v1/tech/jobs/[id]/labor/route.ts
  33. 83 0
      src/app/api/v1/tech/jobs/[id]/notes/route.ts
  34. 142 0
      src/app/api/v1/tech/jobs/[id]/parts/route.ts
  35. 94 0
      src/app/api/v1/tech/jobs/[id]/route.ts
  36. 185 0
      src/app/api/v1/tech/jobs/[id]/status-report/route.ts
  37. 156 0
      src/app/api/v1/tech/jobs/[id]/status/route.ts
  38. 86 0
      src/app/api/v1/tech/jobs/route.ts
  39. 64 0
      src/app/api/v1/tech/me/route.ts
  40. 62 0
      src/app/api/v1/tech/parts/lookup/route.ts
  41. 41 0
      src/app/api/v1/tech/time/entries/route.ts
  42. 59 0
      src/app/api/v1/tech/time/start/route.ts
  43. 42 0
      src/app/api/v1/tech/time/stop/route.ts
  44. 176 0
      src/app/api/v1/tech/ws/route.ts
  45. 18 18
      src/features/notifications/Actions/notificationActions.ts
  46. 4 4
      src/features/notifications/Components/NotificationInitializer.tsx
  47. 77 63
      src/features/notifications/Components/NotificationPanel.tsx
  48. 115 0
      src/features/notifications/Lib/pushToTechnician.ts
  49. 49 50
      src/features/notifications/hooks/useNotificationWebSocket.ts
  50. 33 32
      src/features/notifications/store/notificationStore.ts
  51. 9 0
      src/features/settings/Schema/settingsSchema.ts
  52. 141 0
      src/features/team/Actions/setMemberTechnician.ts
  53. 199 0
      src/features/time-tracking/Lib/timeEntries.ts
  54. 12 99
      src/features/vehicles/Actions/addPartToServiceRecord.ts
  55. 124 0
      src/features/vehicles/Lib/addPart.ts
  56. 18 0
      src/features/workboard/Actions/boardActions/assignments.ts
  57. 32 38
      src/lib/audit.ts
  58. 7 7
      src/lib/auth-client.ts
  59. 55 7
      src/lib/auth.ts
  60. 10 10
      src/lib/backup-heartbeat.ts
  61. 3 0
      src/lib/backup/manifest.ts
  62. 1 3
      src/lib/broadcast.ts
  63. 27 13
      src/lib/cached-session.ts
  64. 28 34
      src/lib/compress-image.ts
  65. 22 4
      src/lib/cron/check-licenses.ts
  66. 35 6
      src/lib/cron/check-subscriptions.ts
  67. 3 1
      src/lib/cron/cleanup-audit-logs.ts
  68. 1 4
      src/lib/cron/cleanup-portal-sessions.ts
  69. 14 4
      src/lib/cron/recurring-invoices.ts
  70. 71 73
      src/lib/cron/reminder-alerts.ts
  71. 584 271
      src/lib/cron/report-schedules.ts
  72. 32 32
      src/lib/cron/scheduled-messages.ts
  73. 23 26
      src/lib/cron/webhook-deliveries.ts
  74. 18 16
      src/lib/customer-session.ts
  75. 6 6
      src/lib/date-sort.ts
  76. 9 9
      src/lib/db.ts
  77. 9 7
      src/lib/demo.ts
  78. 230 248
      src/lib/email.ts
  79. 3 4
      src/lib/format.ts
  80. 19 19
      src/lib/get-auth-context.ts
  81. 35 35
      src/lib/get-layout-data.ts
  82. 7 7
      src/lib/interactive-row.ts
  83. 10 10
      src/lib/invoice-utils.ts
  84. 5 6
      src/lib/notification-bus.ts
  85. 13 13
      src/lib/notify.ts
  86. 23 23
      src/lib/packages/format.ts
  87. 19 19
      src/lib/packages/registry.ts
  88. 31 33
      src/lib/payment-providers/index.ts
  89. 62 76
      src/lib/payment-providers/paypal.ts
  90. 13 18
      src/lib/payment-providers/stripe.ts
  91. 14 14
      src/lib/payment-providers/types.ts
  92. 47 56
      src/lib/payment-providers/vipps.ts
  93. 99 107
      src/lib/permissions.ts
  94. 9 11
      src/lib/portal-slug.ts
  95. 4 7
      src/lib/qr.ts
  96. 5 7
      src/lib/query-provider.tsx
  97. 48 30
      src/lib/rate-limit.ts
  98. 8 8
      src/lib/resolve-upload-path.ts
  99. 8 8
      src/lib/safe-path.ts
  100. 20 20
      src/lib/sanitize-html.ts

+ 14 - 2
messages/de/settings.json

@@ -380,7 +380,7 @@
     "adminLabel": "Administrator",
     "adminDescription": "Kann Mitglieder einladen/entfernen und gemeinsame Daten verwalten.",
     "memberLabel": "Mitglied",
-    "memberDescription": "Berechtigungen werden durch die zugewiesene benutzerdefinierte Rolle bestimmt. Ohne benutzerdefinierte Rolle nur Lesezugriff.",
+    "memberDescription": "Die Rechte ergeben sich aus der zugewiesenen Rolle. Ohne Rolle kann dieses Mitglied nichts tun.",
     "roleAssigned": "Rolle aktualisiert",
     "failedAssignRole": "Rolle konnte nicht aktualisiert werden",
     "removeMemberTitle": "Mitglied entfernen",
@@ -392,7 +392,10 @@
     "failedUpdateRole": "Rolle konnte nicht aktualisiert werden",
     "removeMember": "Remove member",
     "cancelInvite": "Cancel invitation",
-    "editRole": "Edit role"
+    "editRole": "Edit role",
+    "technician": "Techniker",
+    "technicianHint": "Kann Aufträge zugewiesen bekommen und die Techniker-App nutzen",
+    "technicianFailed": "Konnte nicht geändert werden"
   },
   "invoice": {
     "title": "Rechnungslayout",
@@ -1449,6 +1452,15 @@
       "checkFailed": "Prüfung fehlgeschlagen",
       "configurePerPart": "Mindestmenge eines Teils auf der Lagerseite festlegen →",
       "readMore": "Mehr erfahren"
+    },
+    "technicianStatus": {
+      "title": "Torqvoice Techniker-App (Android/iOS)",
+      "description": "Meldung, wenn ein Techniker einen Auftrag über sein Telefon ändert.",
+      "inApp": "Büro benachrichtigen",
+      "inAppHint": "Eine Meldung, wenn ein Auftrag abgeschlossen, auf Teile gesetzt oder wieder aufgenommen wird.",
+      "save": "Speichern",
+      "saved": "Benachrichtigungen gespeichert",
+      "saveFailed": "Einstellungen konnten nicht gespeichert werden"
     }
   },
   "support": {

+ 14 - 2
messages/en/settings.json

@@ -380,7 +380,7 @@
     "adminLabel": "Admin",
     "adminDescription": "Can invite/remove members and manage shared data.",
     "memberLabel": "Member",
-    "memberDescription": "Permissions determined by assigned custom role. No custom role means read-only access.",
+    "memberDescription": "Permissions are set by the assigned role. Without a role, this member cannot do anything.",
     "roleAssigned": "Role updated",
     "failedAssignRole": "Failed to update role",
     "removeMemberTitle": "Remove Member",
@@ -392,7 +392,10 @@
     "failedUpdateRole": "Failed to update role",
     "removeMember": "Remove member",
     "cancelInvite": "Cancel invitation",
-    "editRole": "Edit role"
+    "editRole": "Edit role",
+    "technician": "Technician",
+    "technicianHint": "Can be assigned jobs and use the technician app",
+    "technicianFailed": "Could not change that"
   },
   "invoice": {
     "title": "Invoice & Quotes",
@@ -1449,6 +1452,15 @@
       "checkFailed": "Check failed",
       "configurePerPart": "Set a part's own Min Qty on the inventory page →",
       "readMore": "Read More"
+    },
+    "technicianStatus": {
+      "title": "Torqvoice Technician App (Android/iOS)",
+      "description": "Told when a technician moves a job from their phone.",
+      "inApp": "Notify the desk",
+      "inAppHint": "A notification when a job is finished, parked for parts, or picked back up.",
+      "save": "Save",
+      "saved": "Alert settings saved",
+      "saveFailed": "Could not save the settings"
     }
   },
   "support": {

+ 14 - 2
messages/es/settings.json

@@ -380,7 +380,7 @@
     "adminLabel": "Administrador",
     "adminDescription": "Puede invitar/eliminar miembros y gestionar datos compartidos.",
     "memberLabel": "Miembro",
-    "memberDescription": "Permisos determinados por el rol personalizado asignado. Sin rol personalizado significa acceso de solo lectura.",
+    "memberDescription": "Los permisos dependen del rol asignado. Sin rol, este miembro no puede hacer nada.",
     "roleAssigned": "Rol actualizado",
     "failedAssignRole": "Error al actualizar el rol",
     "removeMemberTitle": "Eliminar miembro",
@@ -392,7 +392,10 @@
     "failedUpdateRole": "Error al actualizar el rol",
     "removeMember": "Remove member",
     "cancelInvite": "Cancel invitation",
-    "editRole": "Edit role"
+    "editRole": "Edit role",
+    "technician": "Técnico",
+    "technicianHint": "Puede recibir trabajos asignados y usar la aplicación de técnico",
+    "technicianFailed": "No se pudo cambiar"
   },
   "invoice": {
     "title": "Diseño de factura",
@@ -1449,6 +1452,15 @@
       "checkFailed": "Error al comprobar",
       "configurePerPart": "Defina la cantidad mínima de una pieza en la página de inventario →",
       "readMore": "Leer más"
+    },
+    "technicianStatus": {
+      "title": "Aplicación de técnico Torqvoice (Android/iOS)",
+      "description": "Aviso cuando un técnico cambia un trabajo desde su teléfono.",
+      "inApp": "Avisar a recepción",
+      "inAppHint": "Un aviso cuando un trabajo se termina, queda a la espera de piezas o se retoma.",
+      "save": "Guardar",
+      "saved": "Ajustes de avisos guardados",
+      "saveFailed": "No se pudieron guardar los ajustes"
     }
   },
   "support": {

+ 14 - 2
messages/fr/settings.json

@@ -380,7 +380,7 @@
     "adminLabel": "Administrateur",
     "adminDescription": "Peut inviter/supprimer des membres et gérer les données partagées.",
     "memberLabel": "Membre",
-    "memberDescription": "Permissions determinees par le role personnalise assigne. Sans role personnalise, accès en lecture seule.",
+    "memberDescription": "Les droits découlent du rôle attribué. Sans rôle, ce membre ne peut rien faire.",
     "roleAssigned": "Role mis a jour",
     "failedAssignRole": "Échec de la mise a jour du role",
     "removeMemberTitle": "Retirer le membre",
@@ -392,7 +392,10 @@
     "failedUpdateRole": "Échec de la mise a jour du role",
     "removeMember": "Remove member",
     "cancelInvite": "Cancel invitation",
-    "editRole": "Edit role"
+    "editRole": "Edit role",
+    "technician": "Technicien",
+    "technicianHint": "Peut se voir attribuer des travaux et utiliser l’application technicien",
+    "technicianFailed": "Modification impossible"
   },
   "invoice": {
     "title": "Mise en page de facture",
@@ -1449,6 +1452,15 @@
       "checkFailed": "Échec de la vérification",
       "configurePerPart": "Définir la Qté min d'une pièce sur la page inventaire →",
       "readMore": "En savoir plus"
+    },
+    "technicianStatus": {
+      "title": "Application technicien Torqvoice (Android/iOS)",
+      "description": "Notification lorsqu'un technicien modifie un travail depuis son téléphone.",
+      "inApp": "Prévenir l'accueil",
+      "inAppHint": "Une notification lorsqu'un travail est terminé, en attente de pièces ou repris.",
+      "save": "Enregistrer",
+      "saved": "Paramètres de notification enregistrés",
+      "saveFailed": "Impossible d'enregistrer les paramètres"
     }
   },
   "support": {

+ 14 - 2
messages/it/settings.json

@@ -380,7 +380,7 @@
     "adminLabel": "Amministratore",
     "adminDescription": "Puo invitare/rimuovere membri e gestire i dati condivisi.",
     "memberLabel": "Membro",
-    "memberDescription": "Autorizzazioni determinate dal ruolo personalizzato assegnato. Senza ruolo personalizzato, accesso in sola lettura.",
+    "memberDescription": "I permessi dipendono dal ruolo assegnato. Senza ruolo, questo membro non può fare nulla.",
     "roleAssigned": "Ruolo aggiornato",
     "failedAssignRole": "Aggiornamento ruolo non riuscito",
     "removeMemberTitle": "Rimuovi membro",
@@ -392,7 +392,10 @@
     "failedUpdateRole": "Aggiornamento ruolo non riuscito",
     "removeMember": "Remove member",
     "cancelInvite": "Cancel invitation",
-    "editRole": "Edit role"
+    "editRole": "Edit role",
+    "technician": "Tecnico",
+    "technicianHint": "Può ricevere lavori assegnati e usare l’app tecnico",
+    "technicianFailed": "Impossibile modificare"
   },
   "invoice": {
     "title": "Layout fattura",
@@ -1449,6 +1452,15 @@
       "checkFailed": "Controllo non riuscito",
       "configurePerPart": "Imposta la Qtà min di un ricambio nella pagina magazzino →",
       "readMore": "Scopri di più"
+    },
+    "technicianStatus": {
+      "title": "App tecnico Torqvoice (Android/iOS)",
+      "description": "Avviso quando un tecnico modifica un lavoro dal telefono.",
+      "inApp": "Avvisa l'accettazione",
+      "inAppHint": "Un avviso quando un lavoro è finito, in attesa di ricambi o ripreso.",
+      "save": "Salva",
+      "saved": "Impostazioni avvisi salvate",
+      "saveFailed": "Impossibile salvare le impostazioni"
     }
   },
   "support": {

+ 14 - 2
messages/lt/settings.json

@@ -380,7 +380,7 @@
     "adminLabel": "Administratorius",
     "adminDescription": "Gali kviesti/šalinti narius ir valdyti bendrinamus duomenis.",
     "memberLabel": "Narys",
-    "memberDescription": "Leidimai nustatomi pagal priskirtą pasirinktinę rolę. Be pasirinktinės rolės — tik skaitymo prieiga.",
+    "memberDescription": "Teisės priklauso nuo priskirto vaidmens. Be vaidmens šis narys nieko negali daryti.",
     "roleAssigned": "Rolė atnaujinta",
     "failedAssignRole": "Nepavyko atnaujinti rolės",
     "removeMemberTitle": "Pašalinti narį",
@@ -392,7 +392,10 @@
     "failedUpdateRole": "Nepavyko atnaujinti rolės",
     "removeMember": "Remove member",
     "cancelInvite": "Cancel invitation",
-    "editRole": "Edit role"
+    "editRole": "Edit role",
+    "technician": "Technikas",
+    "technicianHint": "Gali gauti priskirtus darbus ir naudotis techniko programa",
+    "technicianFailed": "Nepavyko pakeisti"
   },
   "invoice": {
     "title": "Sąskaitos ir pasiūlymai",
@@ -1449,6 +1452,15 @@
       "checkFailed": "Patikra nepavyko",
       "configurePerPart": "Nustatykite dalies min. kiekį sandėlio puslapyje →",
       "readMore": "Skaityti daugiau"
+    },
+    "technicianStatus": {
+      "title": "Torqvoice techniko programėlė (Android/iOS)",
+      "description": "Pranešimas, kai technikas pakeičia darbą telefone.",
+      "inApp": "Pranešti registratūrai",
+      "inAppHint": "Pranešimas, kai darbas baigtas, laukia dalių arba vėl pradėtas.",
+      "save": "Išsaugoti",
+      "saved": "Pranešimų nustatymai išsaugoti",
+      "saveFailed": "Nepavyko išsaugoti nustatymų"
     }
   },
   "support": {

+ 14 - 2
messages/nb/settings.json

@@ -380,7 +380,7 @@
     "adminLabel": "Administrator",
     "adminDescription": "Kan invitere/fjerne medlemmer og administrere delte data.",
     "memberLabel": "Medlem",
-    "memberDescription": "Tillatelser bestemt av tildelt egendefinert rolle. Ingen egendefinert rolle betyr kun lesetilgang.",
+    "memberDescription": "Rettigheter følger av tildelt rolle. Uten rolle kan dette medlemmet ikke gjøre noe.",
     "roleAssigned": "Rolle oppdatert",
     "failedAssignRole": "Kunne ikke oppdatere rolle",
     "removeMemberTitle": "Fjern medlem",
@@ -392,7 +392,10 @@
     "failedUpdateRole": "Kunne ikke oppdatere rolle",
     "removeMember": "Remove member",
     "cancelInvite": "Cancel invitation",
-    "editRole": "Edit role"
+    "editRole": "Edit role",
+    "technician": "Tekniker",
+    "technicianHint": "Kan tildeles jobber og bruke teknikerappen",
+    "technicianFailed": "Kunne ikke endres"
   },
   "invoice": {
     "title": "Faktura og tilbud",
@@ -1449,6 +1452,15 @@
       "checkFailed": "Sjekken mislyktes",
       "configurePerPart": "Sett delens min. antall på lagersiden →",
       "readMore": "Les mer"
+    },
+    "technicianStatus": {
+      "title": "Torqvoice teknikerapp (Android/iOS)",
+      "description": "Varsel når en tekniker endrer en jobb fra telefonen.",
+      "inApp": "Varsle kontoret",
+      "inAppHint": "Et varsel når en jobb er ferdig, venter på deler eller tas opp igjen.",
+      "save": "Lagre",
+      "saved": "Varselinnstillinger lagret",
+      "saveFailed": "Kunne ikke lagre innstillingene"
     }
   },
   "support": {

+ 14 - 2
messages/nl/settings.json

@@ -380,7 +380,7 @@
     "adminLabel": "Beheerder",
     "adminDescription": "Kan leden uitnodigen/verwijderen en gedeelde gegevens beheren.",
     "memberLabel": "Lid",
-    "memberDescription": "Machtigingen bepaald door toegewezen aangepaste rol. Geen aangepaste rol betekent alleen-lezen toegang.",
+    "memberDescription": "Rechten volgen uit de toegewezen rol. Zonder rol kan dit lid niets doen.",
     "roleAssigned": "Rol bijgewerkt",
     "failedAssignRole": "Rol bijwerken mislukt",
     "removeMemberTitle": "Lid verwijderen",
@@ -392,7 +392,10 @@
     "failedUpdateRole": "Rol bijwerken mislukt",
     "removeMember": "Remove member",
     "cancelInvite": "Cancel invitation",
-    "editRole": "Edit role"
+    "editRole": "Edit role",
+    "technician": "Technicus",
+    "technicianHint": "Kan opdrachten toegewezen krijgen en de technicus-app gebruiken",
+    "technicianFailed": "Kon niet worden gewijzigd"
   },
   "invoice": {
     "title": "Factuurindeling",
@@ -1449,6 +1452,15 @@
       "checkFailed": "Controle mislukt",
       "configurePerPart": "Stel het min. aantal van een onderdeel in op de voorraadpagina →",
       "readMore": "Lees meer"
+    },
+    "technicianStatus": {
+      "title": "Torqvoice monteurs-app (Android/iOS)",
+      "description": "Melding wanneer een monteur een opdracht wijzigt vanaf de telefoon.",
+      "inApp": "Balie waarschuwen",
+      "inAppHint": "Een melding wanneer een opdracht klaar is, op onderdelen wacht of weer wordt opgepakt.",
+      "save": "Opslaan",
+      "saved": "Meldingsinstellingen opgeslagen",
+      "saveFailed": "Kon de instellingen niet opslaan"
     }
   },
   "support": {

+ 14 - 2
messages/pl/settings.json

@@ -380,7 +380,7 @@
     "adminLabel": "Administrator",
     "adminDescription": "Może zapraszac/usuwac czlonkow i zarządzać wspoldzielonymi danymi.",
     "memberLabel": "Czlonek",
-    "memberDescription": "Uprawnienia okreslone przez przypisana role niestandardowa. Brak roli niestandardowej oznacza dostęp tylko do odczytu.",
+    "memberDescription": "Uprawnienia wynikają z przypisanej roli. Bez roli ten członek nie może nic zrobić.",
     "roleAssigned": "Rola zaktualizowana",
     "failedAssignRole": "Nie udało się zaktualizować roli",
     "removeMemberTitle": "Usuń członka",
@@ -392,7 +392,10 @@
     "failedUpdateRole": "Nie udało się zaktualizować roli",
     "removeMember": "Remove member",
     "cancelInvite": "Cancel invitation",
-    "editRole": "Edit role"
+    "editRole": "Edit role",
+    "technician": "Technik",
+    "technicianHint": "Może otrzymywać zlecenia i korzystać z aplikacji technika",
+    "technicianFailed": "Nie udało się zmienić"
   },
   "invoice": {
     "title": "Układ faktury",
@@ -1449,6 +1452,15 @@
       "checkFailed": "Sprawdzenie nie powiodło się",
       "configurePerPart": "Ustaw min. ilość części na stronie magazynu →",
       "readMore": "Czytaj więcej"
+    },
+    "technicianStatus": {
+      "title": "Aplikacja technika Torqvoice (Android/iOS)",
+      "description": "Powiadomienie, gdy technik zmienia zlecenie z telefonu.",
+      "inApp": "Powiadom biuro",
+      "inAppHint": "Powiadomienie, gdy zlecenie zostanie zakończone, wstrzymane na części lub wznowione.",
+      "save": "Zapisz",
+      "saved": "Ustawienia powiadomień zapisane",
+      "saveFailed": "Nie udało się zapisać ustawień"
     }
   },
   "support": {

+ 14 - 2
messages/pt-BR/settings.json

@@ -380,7 +380,7 @@
     "adminLabel": "Administrador",
     "adminDescription": "Pode convidar/remover membros e gerenciar dados compartilhados.",
     "memberLabel": "Membro",
-    "memberDescription": "Permissoes determinadas pela função personalizada atribuída. Sem função personalizada significa acesso somente leitura.",
+    "memberDescription": "As permissões vêm da função atribuída. Sem função, este membro não pode fazer nada.",
     "roleAssigned": "Função atualizada",
     "failedAssignRole": "Falha ao atualizar a função",
     "removeMemberTitle": "Remover membro",
@@ -392,7 +392,10 @@
     "failedUpdateRole": "Falha ao atualizar a função",
     "removeMember": "Remove member",
     "cancelInvite": "Cancel invitation",
-    "editRole": "Edit role"
+    "editRole": "Edit role",
+    "technician": "Técnico",
+    "technicianHint": "Pode receber serviços atribuídos e usar o aplicativo do técnico",
+    "technicianFailed": "Não foi possível alterar"
   },
   "invoice": {
     "title": "Layout da fatura",
@@ -1449,6 +1452,15 @@
       "checkFailed": "Falha na verificação",
       "configurePerPart": "Defina a Qtd. mín. de uma peça na página de estoque →",
       "readMore": "Saiba mais"
+    },
+    "technicianStatus": {
+      "title": "Aplicativo do técnico Torqvoice (Android/iOS)",
+      "description": "Aviso quando um técnico altera um serviço pelo telefone.",
+      "inApp": "Avisar a recepção",
+      "inAppHint": "Um aviso quando um serviço é concluído, fica aguardando peças ou é retomado.",
+      "save": "Salvar",
+      "saved": "Configurações de aviso salvas",
+      "saveFailed": "Não foi possível salvar as configurações"
     }
   },
   "support": {

+ 14 - 2
messages/ru/settings.json

@@ -380,7 +380,7 @@
     "adminLabel": "Администратор",
     "adminDescription": "Может приглашать/удалять участников и управлять общими данными.",
     "memberLabel": "Участник",
-    "memberDescription": "Разрешения определяются назначенной пользовательской ролью. Без пользовательской роли — доступ только для чтения.",
+    "memberDescription": "Права определяются назначенной ролью. Без роли участник ничего не может делать.",
     "roleAssigned": "Роль обновлена",
     "failedAssignRole": "Не удалось обновить роль",
     "removeMemberTitle": "Удалить участника",
@@ -392,7 +392,10 @@
     "failedUpdateRole": "Не удалось обновить роль",
     "removeMember": "Remove member",
     "cancelInvite": "Cancel invitation",
-    "editRole": "Edit role"
+    "editRole": "Edit role",
+    "technician": "Механик",
+    "technicianHint": "Может получать заказы и пользоваться приложением механика",
+    "technicianFailed": "Не удалось изменить"
   },
   "invoice": {
     "title": "Счета и предложения",
@@ -1449,6 +1452,15 @@
       "checkFailed": "Ошибка проверки",
       "configurePerPart": "Задайте мин. количество детали на странице склада →",
       "readMore": "Подробнее"
+    },
+    "technicianStatus": {
+      "title": "Приложение механика Torqvoice (Android/iOS)",
+      "description": "Уведомление, когда механик меняет заказ со своего телефона.",
+      "inApp": "Уведомлять приёмку",
+      "inAppHint": "Уведомление, когда заказ завершён, ожидает запчасти или снова взят в работу.",
+      "save": "Сохранить",
+      "saved": "Настройки уведомлений сохранены",
+      "saveFailed": "Не удалось сохранить настройки"
     }
   },
   "support": {

+ 14 - 2
messages/tr/settings.json

@@ -380,7 +380,7 @@
     "adminLabel": "Yönetici",
     "adminDescription": "Üye davet edebilir/kaldırabilir ve paylaşılan verileri yönetebilir.",
     "memberLabel": "Üye",
-    "memberDescription": "İzinler atanan özel role göre belirlenir. Özel rol atanmamışsa salt okunur erişim.",
+    "memberDescription": "Yetkiler atanan role göre belirlenir. Rol atanmazsa bu üye hiçbir şey yapamaz.",
     "roleAssigned": "Rol güncellendi",
     "failedAssignRole": "Rol güncellenemedi",
     "removeMemberTitle": "Üyeyi Kaldır",
@@ -392,7 +392,10 @@
     "failedUpdateRole": "Rol güncellenemedi",
     "removeMember": "Remove member",
     "cancelInvite": "Cancel invitation",
-    "editRole": "Edit role"
+    "editRole": "Edit role",
+    "technician": "Teknisyen",
+    "technicianHint": "İş atanabilir ve teknisyen uygulamasını kullanabilir",
+    "technicianFailed": "Değiştirilemedi"
   },
   "invoice": {
     "title": "Fatura Düzeni",
@@ -1449,6 +1452,15 @@
       "checkFailed": "Kontrol başarısız",
       "configurePerPart": "Bir parçanın min. miktarını stok sayfasında ayarlayın →",
       "readMore": "Devamını oku"
+    },
+    "technicianStatus": {
+      "title": "Torqvoice Teknisyen Uygulaması (Android/iOS)",
+      "description": "Bir teknisyen işi telefonundan değiştirdiğinde bildirim.",
+      "inApp": "Ofisi bilgilendir",
+      "inAppHint": "Bir iş tamamlandığında, parça beklemeye alındığında veya yeniden başlatıldığında bildirim.",
+      "save": "Kaydet",
+      "saved": "Bildirim ayarları kaydedildi",
+      "saveFailed": "Ayarlar kaydedilemedi"
     }
   },
   "support": {

+ 39 - 0
prisma/migrations/20260827095320_technician_time_entries/migration.sql

@@ -0,0 +1,39 @@
+-- CreateTable
+CREATE TABLE "time_entries" (
+    "id" TEXT NOT NULL,
+    "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "endedAt" TIMESTAMP(3),
+    "durationMinutes" INTEGER,
+    "note" TEXT,
+    "source" TEXT NOT NULL DEFAULT 'app',
+    "editedAt" TIMESTAMP(3),
+    "editedByUserId" TEXT,
+    "technicianId" TEXT NOT NULL,
+    "serviceRecordId" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "time_entries_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "time_entries_technicianId_startedAt_idx" ON "time_entries"("technicianId", "startedAt");
+
+-- CreateIndex
+CREATE INDEX "time_entries_serviceRecordId_idx" ON "time_entries"("serviceRecordId");
+
+-- CreateIndex
+CREATE INDEX "time_entries_organizationId_startedAt_idx" ON "time_entries"("organizationId", "startedAt");
+
+-- CreateIndex
+CREATE INDEX "time_entries_technicianId_endedAt_idx" ON "time_entries"("technicianId", "endedAt");
+
+-- AddForeignKey
+ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_technicianId_fkey" FOREIGN KEY ("technicianId") REFERENCES "technicians"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_serviceRecordId_fkey" FOREIGN KEY ("serviceRecordId") REFERENCES "service_records"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 29 - 0
prisma/migrations/20260827111920_push_devices/migration.sql

@@ -0,0 +1,29 @@
+-- CreateTable
+CREATE TABLE "push_devices" (
+    "id" TEXT NOT NULL,
+    "token" TEXT NOT NULL,
+    "platform" TEXT NOT NULL,
+    "isActive" BOOLEAN NOT NULL DEFAULT true,
+    "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "userId" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "push_devices_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "push_devices_token_key" ON "push_devices"("token");
+
+-- CreateIndex
+CREATE INDEX "push_devices_userId_isActive_idx" ON "push_devices"("userId", "isActive");
+
+-- CreateIndex
+CREATE INDEX "push_devices_organizationId_idx" ON "push_devices"("organizationId");
+
+-- AddForeignKey
+ALTER TABLE "push_devices" ADD CONSTRAINT "push_devices_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "push_devices" ADD CONSTRAINT "push_devices_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 102 - 0
prisma/schema.prisma

@@ -30,6 +30,7 @@ model User {
 
   sessions               Session[]
   accounts               Account[]
+  pushDevices            PushDevice[]
   vehicles               Vehicle[]
   customers              Customer[]
   settings               AppSetting[]
@@ -280,6 +281,7 @@ model ServiceRecord {
   payments       Payment[]
   statusReports  StatusReport[]
   stockMovements StockMovement[]
+  timeEntries    TimeEntry[]
 
   discoveredFindings VehicleFinding[] @relation("FindingDiscoveredIn")
   resolvedFindings   VehicleFinding[] @relation("FindingResolvedIn")
@@ -856,6 +858,8 @@ model Organization {
   whatsappMessages       WhatsappMessage[]
   technicians            Technician[]
   workBays               WorkBay[]
+  timeEntries            TimeEntry[]
+  pushDevices            PushDevice[]
   auditLogs              AuditLog[]
   statusReports          StatusReport[]
   reportSchedules        ReportSchedule[]
@@ -1371,6 +1375,7 @@ model Technician {
   serviceRecords ServiceRecord[]
   inspections    Inspection[]
   statusReports  StatusReport[]
+  timeEntries    TimeEntry[]
 
   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt
@@ -1379,6 +1384,103 @@ model Technician {
   @@map("technicians")
 }
 
+/// One stretch of a technician actually working on a job, as clocked from the
+/// technician app.
+///
+/// Deliberately separate from `ServiceRecord.startDateTime` / `endDateTime`:
+/// those are the work board's planned slot, one pair per job, owned by whoever
+/// schedules the day. This is what happened, many rows per job, one per person
+/// per stretch. A job worked on Monday, parked for parts and finished Thursday
+/// by someone else is two entries, and neither of them is the booking.
+///
+/// `endedAt` null means the clock is still running. At most one open entry per
+/// technician is enforced in the API rather than the schema, because "open"
+/// is not a value a partial unique index can express portably.
+model TimeEntry {
+  id String @id @default(cuid())
+
+  startedAt DateTime  @default(now())
+  endedAt   DateTime?
+
+  /// Cached on stop so reports do not recompute across millions of rows, and
+  /// so a correction to the timestamps stays visible against what was billed.
+  durationMinutes Int?
+
+  /// What the technician typed when stopping, if anything.
+  note String?
+
+  /// "app" | "web" | "manual". A manually corrected entry is not the same
+  /// evidence as one clocked live, and Arbeitszeiterfassung cares about that.
+  source String @default("app")
+
+  /// Set when someone edited the times after the fact, so the audit trail can
+  /// show the entry is no longer purely as-clocked.
+  editedAt       DateTime?
+  editedByUserId String?
+
+  technicianId String
+  technician   Technician @relation(fields: [technicianId], references: [id], onDelete: Cascade)
+
+  serviceRecordId String
+  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@index([technicianId, startedAt])
+  @@index([serviceRecordId])
+  @@index([organizationId, startedAt])
+  /// Finding the technician's open entry is the hottest query in the app: it
+  /// runs on every launch and every job screen.
+  @@index([technicianId, endedAt])
+  @@map("time_entries")
+}
+
+/// A phone that has asked to be told about its technician's work.
+///
+/// Keyed on the Expo push token rather than on the user, because the unit that
+/// receives a notification is a device: one technician may carry a personal
+/// phone and a shared bench tablet, and a shared tablet may be signed into by
+/// several people over a week. Both cases have to work.
+///
+/// The token is a routing address, not a secret, but it is still deleted on
+/// sign-out: a device that has signed out must stop receiving a workshop's job
+/// details, and expiry alone would leave a window where it still did.
+model PushDevice {
+  id String @id @default(cuid())
+
+  /// Expo push token, unique across the estate. A reinstall issues a new one
+  /// and the old becomes invalid, which is why sends prune on rejection.
+  token String @unique
+
+  /// "ios" | "android". Only used to explain failures in logs.
+  platform String
+
+  /// Set false when Expo reports the token as dead, rather than deleting, so
+  /// a flapping device does not churn rows.
+  isActive Boolean @default(true)
+
+  /// Last time the app confirmed this token still belongs to this user, so a
+  /// device that stops checking in can be aged out.
+  lastSeenAt DateTime @default(now())
+
+  userId String
+  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@index([userId, isActive])
+  @@index([organizationId])
+  @@map("push_devices")
+}
+
 /// A physical place work happens: a lift, a wash bay, an alignment rack. Shops
 /// plan by bay as often as by person — the bay is the scarce resource, and a
 /// technician moves between them during a day. The work board can group its

+ 22 - 5
src/__tests__/auth/permission-enforcement.test.ts

@@ -409,18 +409,35 @@ describe('Permission bypass — privileged roles skip permission checks', () =>
     expect(result.error).not.toBe('Insufficient permissions')
   })
 
-  it('member without custom role bypasses permission checks (full access by design)', async () => {
-    setupMemberNoCustomRole()
+  it('super admin bypasses all permission checks even without org membership', async () => {
+    setupSuperAdmin()
     vi.mocked(db.vehicle.findMany).mockResolvedValue([])
     const result = await getVehicles()
     expect(result.error).not.toBe('Insufficient permissions')
   })
+})
 
-  it('super admin bypasses all permission checks even without org membership', async () => {
-    setupSuperAdmin()
+describe('Permission denial — member with no role at all', () => {
+  // This used to be listed as a bypass, "full access by design". It was not by
+  // design: withAuth skipped the check whenever a member had no custom role,
+  // while the settings screen told admins the opposite. A member with no role
+  // is a member with no permissions.
+
+  it('member without a custom role is denied', async () => {
+    setupMemberNoCustomRole()
     vi.mocked(db.vehicle.findMany).mockResolvedValue([])
     const result = await getVehicles()
-    expect(result.error).not.toBe('Insufficient permissions')
+    expect(result).toEqual(
+      expect.objectContaining({ success: false, error: 'Insufficient permissions' })
+    )
+  })
+
+  it('member without a custom role is still denied on a write', async () => {
+    setupMemberNoCustomRole()
+    const result = await createVehicle({})
+    expect(result).toEqual(
+      expect.objectContaining({ success: false, error: 'Insufficient permissions' })
+    )
   })
 })
 

+ 22 - 0
src/__tests__/lib/backup-manifest.test.ts

@@ -166,6 +166,28 @@ describe('the routes implement the manifest', () => {
   const exportRoute = fs.readFileSync('src/app/api/protected/backup/export/route.ts', 'utf-8')
   const importRoute = fs.readFileSync('src/app/api/protected/backup/import/route.ts', 'utf-8')
 
+  it('restores every nested entity on the way back in', () => {
+    // The two tests below only cover top-level keys, because a nested entity
+    // has no key of its own. That left a hole: a model could be classified in
+    // the manifest, so the schema test passed, while neither route carried it
+    // and a restore quietly dropped the rows. TimeEntry shipped that way.
+    //
+    // The export side cannot be checked this way, because a relation is named
+    // for the field rather than the model (ServicePart arrives as partItems).
+    // The import side always reaches the model through its Prisma accessor.
+    const missing = BACKUP_ENTITIES.filter((entity) => entity.nestedUnder)
+      .filter((entity) => {
+        const accessor = entity.model.charAt(0).toLowerCase() + entity.model.slice(1)
+        return !importRoute.includes(`tx.${accessor}.`)
+      })
+      .map((entity) => entity.model)
+
+    expect(
+      missing,
+      `Nested in the manifest, but the import never writes them: ${missing.join(', ')}`
+    ).toEqual([])
+  })
+
   it('writes every top-level key on the way out', () => {
     const missing = topLevelEntities()
       .filter((entity) => !exportRoute.includes(`data.${entity.key} =`))

+ 170 - 132
src/__tests__/lib/with-auth.test.ts

@@ -1,172 +1,210 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { ZodError } from "zod";
-import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { ZodError } from 'zod'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 
 // Hoisted mocks
-vi.mock("@/lib/cached-session", () => ({
+vi.mock('@/lib/cached-session', () => ({
   getCachedSession: vi.fn(),
   getCachedMembership: vi.fn(),
-}));
+}))
 
-vi.mock("@/lib/db", () => ({
+vi.mock('@/lib/db', () => ({
   db: {
     user: {
       findUnique: vi.fn(),
     },
   },
-}));
+}))
 
-import { withAuth } from "@/lib/with-auth";
-import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
-import { db } from "@/lib/db";
+import { withAuth } from '@/lib/with-auth'
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
 
-const mockGetCachedSession = vi.mocked(getCachedSession);
-const mockGetCachedMembership = vi.mocked(getCachedMembership);
-const mockFindUnique = vi.mocked(db.user.findUnique);
+const mockGetCachedSession = vi.mocked(getCachedSession)
+const mockGetCachedMembership = vi.mocked(getCachedMembership)
+const mockFindUnique = vi.mocked(db.user.findUnique)
 
-const SESSION = { user: { id: "user-1", email: "user@example.com" } };
+const SESSION = { user: { id: 'user-1', email: 'user@example.com' } }
 const MEMBERSHIP = {
-  organizationId: "org-1",
-  role: "member",
+  organizationId: 'org-1',
+  role: 'member',
   roleId: null,
   customRole: null,
-};
+}
 
 beforeEach(() => {
-  vi.resetAllMocks();
-});
-
-describe("withAuth", () => {
-  it("returns Unauthorized when there is no session", async () => {
-    mockGetCachedSession.mockResolvedValue(null);
-    const result = await withAuth(async () => "ok");
-    expect(result).toEqual({ success: false, error: "Unauthorized" });
-  });
-
-  it("returns No organization found when user has no membership and is not super admin", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
-    mockGetCachedMembership.mockResolvedValue(null);
-    const result = await withAuth(async () => "ok");
-    expect(result).toEqual({ success: false, error: "No organization found" });
-  });
-
-  it("super admin with no membership returns No organization found", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: true } as any);
-    mockGetCachedMembership.mockResolvedValue(null);
-    const result = await withAuth(async (ctx) => ctx);
-    expect(result).toEqual({ success: false, error: "No organization found" });
-  });
-
-  it("regular member with no required permissions — action runs", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
-    mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any);
-    const result = await withAuth(async (ctx) => ctx);
-    expect(result.success).toBe(true);
-    expect((result.data as any).organizationId).toBe("org-1");
-  });
-
-  it("owner bypasses permission check", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
-    mockGetCachedMembership.mockResolvedValue({ ...MEMBERSHIP, role: "owner" } as any);
+  vi.resetAllMocks()
+})
+
+describe('withAuth', () => {
+  it('returns Unauthorized when there is no session', async () => {
+    mockGetCachedSession.mockResolvedValue(null)
+    const result = await withAuth(async () => 'ok')
+    expect(result).toEqual({ success: false, error: 'Unauthorized' })
+  })
+
+  it('returns No organization found when user has no membership and is not super admin', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
+    mockGetCachedMembership.mockResolvedValue(null)
+    const result = await withAuth(async () => 'ok')
+    expect(result).toEqual({ success: false, error: 'No organization found' })
+  })
+
+  it('super admin with no membership returns No organization found', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: true } as any)
+    mockGetCachedMembership.mockResolvedValue(null)
+    const result = await withAuth(async (ctx) => ctx)
+    expect(result).toEqual({ success: false, error: 'No organization found' })
+  })
+
+  it('regular member with no required permissions — action runs', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
+    mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any)
+    const result = await withAuth(async (ctx) => ctx)
+    expect(result.success).toBe(true)
+    expect((result.data as any).organizationId).toBe('org-1')
+  })
+
+  it('member with no custom role is denied when permissions are required', async () => {
+    // The bug this covers: withAuth skipped the check entirely for a member
+    // with no role, so the account was unrestricted while the settings screen
+    // said "No custom role means read-only access."
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
+    mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any)
+    const result = await withAuth(async () => 'ok', {
+      requiredPermissions: [
+        { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
+      ],
+    })
+    expect(result).toEqual({ success: false, error: 'Insufficient permissions' })
+  })
+
+  it('owner bypasses permission check', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
+    mockGetCachedMembership.mockResolvedValue({ ...MEMBERSHIP, role: 'owner' } as any)
     const result = await withAuth(async (ctx) => ctx, {
-      requiredPermissions: [{ action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS }],
-    });
-    expect(result.success).toBe(true);
-  });
-
-  it("admin bypasses permission check", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
-    mockGetCachedMembership.mockResolvedValue({ ...MEMBERSHIP, role: "admin" } as any);
+      requiredPermissions: [
+        { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
+      ],
+    })
+    expect(result.success).toBe(true)
+  })
+
+  it('admin bypasses permission check', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
+    mockGetCachedMembership.mockResolvedValue({ ...MEMBERSHIP, role: 'admin' } as any)
     const result = await withAuth(async (ctx) => ctx, {
-      requiredPermissions: [{ action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS }],
-    });
-    expect(result.success).toBe(true);
-  });
-
-  it("custom role user with isAdmin=true bypasses permission check", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+      requiredPermissions: [
+        { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
+      ],
+    })
+    expect(result.success).toBe(true)
+  })
+
+  it('custom role user with isAdmin=true bypasses permission check', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
     mockGetCachedMembership.mockResolvedValue({
       ...MEMBERSHIP,
-      roleId: "role-1",
+      roleId: 'role-1',
       customRole: { isAdmin: true, permissions: [] },
-    } as any);
+    } as any)
     const result = await withAuth(async (ctx) => ctx, {
-      requiredPermissions: [{ action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS }],
-    });
-    expect(result.success).toBe(true);
-  });
-
-  it("custom role user with correct permissions — passes", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+      requiredPermissions: [
+        { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
+      ],
+    })
+    expect(result.success).toBe(true)
+  })
+
+  it('custom role user with correct permissions — passes', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
     mockGetCachedMembership.mockResolvedValue({
       ...MEMBERSHIP,
-      roleId: "role-1",
+      roleId: 'role-1',
       customRole: {
         isAdmin: false,
         permissions: [{ action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS }],
       },
-    } as any);
+    } as any)
     const result = await withAuth(async (ctx) => ctx, {
-      requiredPermissions: [{ action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS }],
-    });
-    expect(result.success).toBe(true);
-  });
-
-  it("custom role user missing required permissions — Insufficient permissions", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+      requiredPermissions: [
+        { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
+      ],
+    })
+    expect(result.success).toBe(true)
+  })
+
+  it('custom role user missing required permissions — Insufficient permissions', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
     mockGetCachedMembership.mockResolvedValue({
       ...MEMBERSHIP,
-      roleId: "role-1",
+      roleId: 'role-1',
       customRole: {
         isAdmin: false,
         permissions: [{ action: PermissionAction.READ, subject: PermissionSubject.QUOTES }],
       },
-    } as any);
-    const result = await withAuth(async () => "ok", {
-      requiredPermissions: [{ action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS }],
-    });
-    expect(result).toEqual({ success: false, error: "Insufficient permissions" });
-  });
-
-  it("super admin with membership bypasses all permission checks", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: true } as any);
-    mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any);
+    } as any)
+    const result = await withAuth(async () => 'ok', {
+      requiredPermissions: [
+        { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
+      ],
+    })
+    expect(result).toEqual({ success: false, error: 'Insufficient permissions' })
+  })
+
+  it('super admin with membership bypasses all permission checks', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: true } as any)
+    mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any)
     const result = await withAuth(async (ctx) => ctx, {
-      requiredPermissions: [{ action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS }],
-    });
-    expect(result.success).toBe(true);
-    expect((result.data as any).isSuperAdmin).toBe(true);
-    expect((result.data as any).organizationId).toBe("org-1");
-  });
-
-  it("action throwing ZodError returns formatted field errors", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
-    mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any);
+      requiredPermissions: [
+        { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
+      ],
+    })
+    expect(result.success).toBe(true)
+    expect((result.data as any).isSuperAdmin).toBe(true)
+    expect((result.data as any).organizationId).toBe('org-1')
+  })
+
+  it('action throwing ZodError returns formatted field errors', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
+    mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any)
 
     const zodErr = new ZodError([
-      { code: "too_small", path: ["email"], message: "Required", minimum: 1, origin: "string", inclusive: true },
-    ]);
-    const result = await withAuth(async () => { throw zodErr; });
-    expect(result.success).toBe(false);
-    expect(result.error).toContain("email");
-    expect(result.error).toContain("Required");
-  });
-
-  it("action throwing a generic Error returns the error message", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
-    mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any);
-    const result = await withAuth(async () => { throw new Error("something went wrong"); });
-    expect(result).toEqual({ success: false, error: "something went wrong" });
-  });
-});
+      {
+        code: 'too_small',
+        path: ['email'],
+        message: 'Required',
+        minimum: 1,
+        origin: 'string',
+        inclusive: true,
+      },
+    ])
+    const result = await withAuth(async () => {
+      throw zodErr
+    })
+    expect(result.success).toBe(false)
+    expect(result.error).toContain('email')
+    expect(result.error).toContain('Required')
+  })
+
+  it('action throwing a generic Error returns the error message', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
+    mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any)
+    const result = await withAuth(async () => {
+      throw new Error('something went wrong')
+    })
+    expect(result).toEqual({ success: false, error: 'something went wrong' })
+  })
+})

+ 123 - 98
src/__tests__/multitenancy/service-record-isolation.test.ts

@@ -4,149 +4,174 @@
  * Verifies that getServiceRecord, updateServiceRecord, and deleteServiceRecord
  * are properly scoped to the caller's organization and reject cross-org access.
  */
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
 
-vi.mock("@/lib/cached-session", () => ({
+vi.mock('@/lib/cached-session', () => ({
   getCachedSession: vi.fn(),
   getCachedMembership: vi.fn(),
-}));
-vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
-vi.mock("@/lib/resolve-upload-path", () => ({
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/resolve-upload-path', () => ({
   resolveUploadPath: vi.fn((url: string) => `/uploads/${url}`),
-}));
-vi.mock("@/lib/notification-bus", () => ({
+}))
+vi.mock('@/lib/notification-bus', () => ({
   notificationBus: { emit: vi.fn() },
-}));
-vi.mock("@/lib/db", () => ({
+}))
+vi.mock('@/lib/db', () => ({
   db: {
     user: { findUnique: vi.fn() },
     vehicle: { findFirst: vi.fn(), update: vi.fn() },
     serviceRecord: { findFirst: vi.fn(), findMany: vi.fn(), update: vi.fn(), delete: vi.fn() },
     $transaction: vi.fn(),
   },
-}));
+}))
 
-import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
-import { db } from "@/lib/db";
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
 import {
   getServiceRecord,
   updateServiceRecord,
   deleteServiceRecord,
-} from "@/features/vehicles/Actions/serviceActions";
+} from '@/features/vehicles/Actions/serviceActions'
 
-const mockSession = vi.mocked(getCachedSession);
-const mockMembership = vi.mocked(getCachedMembership);
-const mockUserFindUnique = vi.mocked(db.user.findUnique);
-const ORG_A = "org-a";
-const ORG_B = "org-b";
+const mockSession = vi.mocked(getCachedSession)
+const mockMembership = vi.mocked(getCachedMembership)
+const mockUserFindUnique = vi.mocked(db.user.findUnique)
+const ORG_A = 'org-a'
+const ORG_B = 'org-b'
 
 function setupOrgAOwner() {
-  mockSession.mockResolvedValue({ user: { id: "user-a", email: "a@example.com" } } as any);
+  mockSession.mockResolvedValue({ user: { id: 'user-a', email: 'a@example.com' } } as any)
   mockMembership.mockResolvedValue({
-    organizationId: ORG_A, role: "owner", roleId: null, customRole: null,
-  } as any);
-  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+    organizationId: ORG_A,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as any)
+  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
 }
 
+// A member who is allowed to read service records, which is what these tests
+// are about. This used to be a member with no role at all, which passed only
+// because withAuth skipped the permission check for those; the test would then
+// have gone on passing if read:services had stopped being enforced.
 function setupOrgAMember() {
-  mockSession.mockResolvedValue({ user: { id: "user-m", email: "m@example.com" } } as any);
+  mockSession.mockResolvedValue({ user: { id: 'user-m', email: 'm@example.com' } } as any)
   mockMembership.mockResolvedValue({
-    organizationId: ORG_A, role: "member", roleId: null, customRole: null,
-  } as any);
-  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+    organizationId: ORG_A,
+    role: 'member',
+    roleId: 'role-reader',
+    customRole: { isAdmin: false, permissions: [{ action: 'read', subject: 'services' }] },
+  } as any)
+  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
 }
 
 const ORG_A_RECORD = {
-  id: "sr-a", vehicleId: "veh-a", title: "Oil Change", status: "pending", attachments: [],
-  vehicle: { id: "veh-a", mileage: 50000, make: "Toyota", model: "Camry", year: 2020, licensePlate: "ABC123" },
-};
+  id: 'sr-a',
+  vehicleId: 'veh-a',
+  title: 'Oil Change',
+  status: 'pending',
+  attachments: [],
+  vehicle: {
+    id: 'veh-a',
+    mileage: 50000,
+    make: 'Toyota',
+    model: 'Camry',
+    year: 2020,
+    licensePlate: 'ABC123',
+  },
+}
 
 // Service records carry their own organizationId (counter sales have no
 // vehicle), so every ownership check scopes on the record directly.
 const orgWhereClause = expect.objectContaining({
   where: expect.objectContaining({ organizationId: ORG_A }),
-});
+})
 
-beforeEach(() => { vi.resetAllMocks(); });
+beforeEach(() => {
+  vi.resetAllMocks()
+})
 
-describe("getServiceRecord — cross-org isolation", () => {
+describe('getServiceRecord — cross-org isolation', () => {
   it("returns null data when querying another org's service record", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null);
-    const result = await getServiceRecord(`${ORG_B}-record-id`);
-    expect(result.success).toBe(true);
-    expect(result.data).toBeNull();
-  });
+    setupOrgAOwner()
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+    const result = await getServiceRecord(`${ORG_B}-record-id`)
+    expect(result.success).toBe(true)
+    expect(result.data).toBeNull()
+  })
 
   it("scopes the query to the caller's organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null);
-    await getServiceRecord("sr-a");
-    expect(vi.mocked(db.serviceRecord.findFirst)).toHaveBeenCalledWith(orgWhereClause);
-  });
+    setupOrgAOwner()
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+    await getServiceRecord('sr-a')
+    expect(vi.mocked(db.serviceRecord.findFirst)).toHaveBeenCalledWith(orgWhereClause)
+  })
 
   it("returns the service record when it belongs to the caller's org", async () => {
-    setupOrgAMember();
-    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(ORG_A_RECORD as any);
-    const result = await getServiceRecord("sr-a");
-    expect(result.success).toBe(true);
-    expect((result.data as any)?.id).toBe("sr-a");
-  });
-});
-
-describe("updateServiceRecord — cross-org isolation", () => {
+    setupOrgAMember()
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(ORG_A_RECORD as any)
+    const result = await getServiceRecord('sr-a')
+    expect(result.success).toBe(true)
+    expect((result.data as any)?.id).toBe('sr-a')
+  })
+})
+
+describe('updateServiceRecord — cross-org isolation', () => {
   it("returns error when targeting another org's service record", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null);
-    const result = await updateServiceRecord({ id: `${ORG_B}-record-id` });
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Service record not found");
-  });
-
-  it("ownership check always includes organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null);
-    await updateServiceRecord({ id: "sr-x" });
-    expect(vi.mocked(db.serviceRecord.findFirst)).toHaveBeenCalledWith(orgWhereClause);
-  });
+    setupOrgAOwner()
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+    const result = await updateServiceRecord({ id: `${ORG_B}-record-id` })
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Service record not found')
+  })
+
+  it('ownership check always includes organizationId', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+    await updateServiceRecord({ id: 'sr-x' })
+    expect(vi.mocked(db.serviceRecord.findFirst)).toHaveBeenCalledWith(orgWhereClause)
+  })
 
   it("successfully updates a service record belonging to the caller's org", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(ORG_A_RECORD as any);
-    const updatedRecord = { ...ORG_A_RECORD, title: "Brake Pads" };
+    setupOrgAOwner()
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(ORG_A_RECORD as any)
+    const updatedRecord = { ...ORG_A_RECORD, title: 'Brake Pads' }
     vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
       fn({ serviceRecord: { update: vi.fn().mockResolvedValue(updatedRecord) } })
-    );
-    const result = await updateServiceRecord({ id: "sr-a", title: "Brake Pads" });
-    expect(result.success).toBe(true);
-    expect((result.data as any)?.title).toBe("Brake Pads");
-  });
-});
-
-describe("deleteServiceRecord — cross-org isolation", () => {
+    )
+    const result = await updateServiceRecord({ id: 'sr-a', title: 'Brake Pads' })
+    expect(result.success).toBe(true)
+    expect((result.data as any)?.title).toBe('Brake Pads')
+  })
+})
+
+describe('deleteServiceRecord — cross-org isolation', () => {
   it("returns error when deleting another org's service record", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null);
-    const result = await deleteServiceRecord(`${ORG_B}-record-id`);
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Record not found");
-  });
-
-  it("ownership check always includes organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null);
-    await deleteServiceRecord("sr-x");
-    expect(vi.mocked(db.serviceRecord.findFirst)).toHaveBeenCalledWith(orgWhereClause);
-  });
+    setupOrgAOwner()
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+    const result = await deleteServiceRecord(`${ORG_B}-record-id`)
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Record not found')
+  })
+
+  it('ownership check always includes organizationId', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+    await deleteServiceRecord('sr-x')
+    expect(vi.mocked(db.serviceRecord.findFirst)).toHaveBeenCalledWith(orgWhereClause)
+  })
 
   it("successfully deletes a service record belonging to the caller's org", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
-      id: "sr-a", vehicleId: "veh-a", attachments: [],
-    } as any);
-    vi.mocked(db.serviceRecord.delete).mockResolvedValue({} as any);
-    const result = await deleteServiceRecord("sr-a");
-    expect(result.success).toBe(true);
-    expect((result.data as any)?.recordId).toBe("sr-a");
-  });
-});
+      id: 'sr-a',
+      vehicleId: 'veh-a',
+      attachments: [],
+    } as any)
+    vi.mocked(db.serviceRecord.delete).mockResolvedValue({} as any)
+    const result = await deleteServiceRecord('sr-a')
+    expect(result.success).toBe(true)
+    expect((result.data as any)?.recordId).toBe('sr-a')
+  })
+})

+ 421 - 411
src/__tests__/multitenancy/team-invite-isolation.test.ts

@@ -10,26 +10,26 @@
  *  6. Custom isAdmin roles are respected by cancelInvitation and assignRole
  */
 
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
 
-vi.mock("@/lib/cached-session", () => ({
+vi.mock('@/lib/cached-session', () => ({
   getCachedSession: vi.fn(),
   getCachedMembership: vi.fn(),
-}));
+}))
 
-vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
 
-vi.mock("next/headers", () => ({
+vi.mock('next/headers', () => ({
   headers: vi.fn().mockResolvedValue(new Headers()),
   cookies: vi.fn().mockResolvedValue({ set: vi.fn(), get: vi.fn() }),
-}));
+}))
 
-vi.mock("@/lib/email", () => ({
+vi.mock('@/lib/email', () => ({
   sendOrgMail: vi.fn(),
   getOrgFromAddress: vi.fn(),
-}));
+}))
 
-vi.mock("@/lib/db", () => ({
+vi.mock('@/lib/db', () => ({
   db: {
     user: { findUnique: vi.fn(), update: vi.fn() },
     teamInvitation: {
@@ -52,60 +52,60 @@ vi.mock("@/lib/db", () => ({
     },
     $transaction: vi.fn(),
   },
-}));
-
-import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
-import { db } from "@/lib/db";
-import { cookies } from "next/headers";
-import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
-import { acceptInvitation } from "@/features/team/Actions/acceptInvitation";
-import { sendInvitation } from "@/features/team/Actions/sendInvitation";
-import { cancelInvitation } from "@/features/team/Actions/cancelInvitation";
-import { getPendingInvitations } from "@/features/team/Actions/getPendingInvitations";
-import { assignRole } from "@/features/team/Actions/assignRole";
-
-const mockSession = vi.mocked(getCachedSession);
-const mockMembership = vi.mocked(getCachedMembership);
-const mockUserFindUnique = vi.mocked(db.user.findUnique);
-const mockSendOrgMail = vi.mocked(sendOrgMail);
-const mockGetOrgFromAddress = vi.mocked(getOrgFromAddress);
-
-const ORG_A = "org-a";
-const ORG_B = "org-b";
+}))
+
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { cookies } from 'next/headers'
+import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
+import { acceptInvitation } from '@/features/team/Actions/acceptInvitation'
+import { sendInvitation } from '@/features/team/Actions/sendInvitation'
+import { cancelInvitation } from '@/features/team/Actions/cancelInvitation'
+import { getPendingInvitations } from '@/features/team/Actions/getPendingInvitations'
+import { assignRole } from '@/features/team/Actions/assignRole'
+
+const mockSession = vi.mocked(getCachedSession)
+const mockMembership = vi.mocked(getCachedMembership)
+const mockUserFindUnique = vi.mocked(db.user.findUnique)
+const mockSendOrgMail = vi.mocked(sendOrgMail)
+const mockGetOrgFromAddress = vi.mocked(getOrgFromAddress)
+
+const ORG_A = 'org-a'
+const ORG_B = 'org-b'
 
 // ---------- Auth setup helpers ----------
 
 function setupOrgAOwner() {
-  mockSession.mockResolvedValue({ user: { id: "user-a", email: "owner@orgA.com" } } as any);
+  mockSession.mockResolvedValue({ user: { id: 'user-a', email: 'owner@orgA.com' } } as any)
   mockMembership.mockResolvedValue({
     organizationId: ORG_A,
-    role: "owner",
+    role: 'owner',
     roleId: null,
     customRole: null,
-  } as any);
-  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+  } as any)
+  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
 }
 
 function setupOrgAAdmin() {
-  mockSession.mockResolvedValue({ user: { id: "user-a", email: "admin@orgA.com" } } as any);
+  mockSession.mockResolvedValue({ user: { id: 'user-a', email: 'admin@orgA.com' } } as any)
   mockMembership.mockResolvedValue({
     organizationId: ORG_A,
-    role: "admin",
+    role: 'admin',
     roleId: null,
     customRole: null,
-  } as any);
-  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+  } as any)
+  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
 }
 
 function setupOrgAMember() {
-  mockSession.mockResolvedValue({ user: { id: "user-a", email: "member@orgA.com" } } as any);
+  mockSession.mockResolvedValue({ user: { id: 'user-a', email: 'member@orgA.com' } } as any)
   mockMembership.mockResolvedValue({
     organizationId: ORG_A,
-    role: "member",
+    role: 'member',
     roleId: null,
     customRole: null,
-  } as any);
-  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+  } as any)
+  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
 }
 
 /**
@@ -114,684 +114,694 @@ function setupOrgAMember() {
  * work correctly.
  */
 function setupOrgACustomAdmin() {
-  mockSession.mockResolvedValue({ user: { id: "user-a", email: "cadmin@orgA.com" } } as any);
+  mockSession.mockResolvedValue({ user: { id: 'user-a', email: 'cadmin@orgA.com' } } as any)
   mockMembership.mockResolvedValue({
     organizationId: ORG_A,
-    role: "member",
-    roleId: "custom-admin-role-id",
+    role: 'member',
+    roleId: 'custom-admin-role-id',
     customRole: { isAdmin: true, permissions: [] },
-  } as any);
-  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+  } as any)
+  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
 }
 
 // ---------- Shared fixtures ----------
 
 const VALID_INVITATION = {
-  id: "inv-1",
-  token: "tok-abc",
-  email: "invited@example.com",
+  id: 'inv-1',
+  token: 'tok-abc',
+  email: 'invited@example.com',
   organizationId: ORG_A,
-  status: "pending",
-  role: "member",
+  status: 'pending',
+  role: 'member',
   roleId: null,
   expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
-};
+}
 
 beforeEach(() => {
-  vi.resetAllMocks();
-  vi.mocked(cookies).mockResolvedValue({ set: vi.fn(), get: vi.fn() } as any);
-});
+  vi.resetAllMocks()
+  vi.mocked(cookies).mockResolvedValue({ set: vi.fn(), get: vi.fn() } as any)
+})
 
 // ============================================================================
 // acceptInvitation — role and org assignment correctness
 // ============================================================================
 
-describe("acceptInvitation — role and org assignment correctness", () => {
+describe('acceptInvitation — role and org assignment correctness', () => {
   beforeEach(() => {
     // acceptInvitation uses getCachedSession directly (not withAuth)
     mockSession.mockResolvedValue({
-      user: { id: "invited-user", email: "invited@example.com" },
-    } as any);
-    vi.mocked(db.organizationMember.findFirst).mockResolvedValue(null);
-    vi.mocked(db.$transaction).mockResolvedValue([{}, {}, {}] as any);
-  });
+      user: { id: 'invited-user', email: 'invited@example.com' },
+    } as any)
+    vi.mocked(db.organizationMember.findFirst).mockResolvedValue(null)
+    vi.mocked(db.$transaction).mockResolvedValue([{}, {}, {}] as any)
+  })
 
   it("creates membership in the invitation's org, not any other org", async () => {
     vi.mocked(db.teamInvitation.findUnique).mockResolvedValue({
       ...VALID_INVITATION,
       organizationId: ORG_A,
-    } as any);
+    } as any)
 
-    await acceptInvitation({ token: "tok-abc" });
+    await acceptInvitation({ token: 'tok-abc' })
 
     expect(vi.mocked(db.organizationMember.create)).toHaveBeenCalledWith(
       expect.objectContaining({
         data: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
   it("assigns the role specified in the invitation — 'admin'", async () => {
     vi.mocked(db.teamInvitation.findUnique).mockResolvedValue({
       ...VALID_INVITATION,
-      role: "admin",
-    } as any);
+      role: 'admin',
+    } as any)
 
-    await acceptInvitation({ token: "tok-abc" });
+    await acceptInvitation({ token: 'tok-abc' })
 
     expect(vi.mocked(db.organizationMember.create)).toHaveBeenCalledWith(
       expect.objectContaining({
-        data: expect.objectContaining({ role: "admin" }),
+        data: expect.objectContaining({ role: 'admin' }),
       })
-    );
-  });
+    )
+  })
 
   it("assigns the role specified in the invitation — 'member'", async () => {
     vi.mocked(db.teamInvitation.findUnique).mockResolvedValue({
       ...VALID_INVITATION,
-      role: "member",
-    } as any);
+      role: 'member',
+    } as any)
 
-    await acceptInvitation({ token: "tok-abc" });
+    await acceptInvitation({ token: 'tok-abc' })
 
     expect(vi.mocked(db.organizationMember.create)).toHaveBeenCalledWith(
       expect.objectContaining({
-        data: expect.objectContaining({ role: "member" }),
+        data: expect.objectContaining({ role: 'member' }),
       })
-    );
-  });
+    )
+  })
 
-  it("assigns the custom roleId from the invitation when present", async () => {
+  it('assigns the custom roleId from the invitation when present', async () => {
     vi.mocked(db.teamInvitation.findUnique).mockResolvedValue({
       ...VALID_INVITATION,
-      roleId: "custom-technician-role",
-    } as any);
+      roleId: 'custom-technician-role',
+    } as any)
 
-    await acceptInvitation({ token: "tok-abc" });
+    await acceptInvitation({ token: 'tok-abc' })
 
     expect(vi.mocked(db.organizationMember.create)).toHaveBeenCalledWith(
       expect.objectContaining({
-        data: expect.objectContaining({ roleId: "custom-technician-role" }),
+        data: expect.objectContaining({ roleId: 'custom-technician-role' }),
       })
-    );
-  });
+    )
+  })
 
-  it("assigns null roleId when the invitation has no custom role", async () => {
+  it('assigns null roleId when the invitation has no custom role', async () => {
     vi.mocked(db.teamInvitation.findUnique).mockResolvedValue({
       ...VALID_INVITATION,
       roleId: null,
-    } as any);
+    } as any)
 
-    await acceptInvitation({ token: "tok-abc" });
+    await acceptInvitation({ token: 'tok-abc' })
 
     expect(vi.mocked(db.organizationMember.create)).toHaveBeenCalledWith(
       expect.objectContaining({
         data: expect.objectContaining({ roleId: null }),
       })
-    );
-  });
+    )
+  })
 
   it("sets the active-org cookie to the invitation's organizationId after accept", async () => {
     vi.mocked(db.teamInvitation.findUnique).mockResolvedValue({
       ...VALID_INVITATION,
       organizationId: ORG_A,
-    } as any);
-    const mockSet = vi.fn();
-    vi.mocked(cookies).mockResolvedValue({ set: mockSet, get: vi.fn() } as any);
+    } as any)
+    const mockSet = vi.fn()
+    vi.mocked(cookies).mockResolvedValue({ set: mockSet, get: vi.fn() } as any)
 
-    await acceptInvitation({ token: "tok-abc" });
+    await acceptInvitation({ token: 'tok-abc' })
 
-    expect(mockSet).toHaveBeenCalledWith("active-org-id", ORG_A, expect.any(Object));
-  });
+    expect(mockSet).toHaveBeenCalledWith('active-org-id', ORG_A, expect.any(Object))
+  })
 
-  it("does not create a second membership when the user is already a member", async () => {
-    vi.mocked(db.teamInvitation.findUnique).mockResolvedValue(VALID_INVITATION as any);
-    vi.mocked(db.organizationMember.findFirst).mockResolvedValue({ id: "existing-mem" } as any);
-    vi.mocked(db.teamInvitation.update).mockResolvedValue({} as any);
+  it('does not create a second membership when the user is already a member', async () => {
+    vi.mocked(db.teamInvitation.findUnique).mockResolvedValue(VALID_INVITATION as any)
+    vi.mocked(db.organizationMember.findFirst).mockResolvedValue({ id: 'existing-mem' } as any)
+    vi.mocked(db.teamInvitation.update).mockResolvedValue({} as any)
 
-    const result = await acceptInvitation({ token: "tok-abc" });
+    const result = await acceptInvitation({ token: 'tok-abc' })
 
-    expect(result.success).toBe(true);
-    expect(vi.mocked(db.organizationMember.create)).not.toHaveBeenCalled();
-    expect(vi.mocked(db.$transaction)).not.toHaveBeenCalled();
-  });
+    expect(result.success).toBe(true)
+    expect(vi.mocked(db.organizationMember.create)).not.toHaveBeenCalled()
+    expect(vi.mocked(db.$transaction)).not.toHaveBeenCalled()
+  })
 
   it("email mismatch prevents joining another org's team via a stolen token", async () => {
     mockSession.mockResolvedValue({
-      user: { id: "attacker", email: "attacker@evil.com" },
-    } as any);
-    vi.mocked(db.teamInvitation.findUnique).mockResolvedValue(VALID_INVITATION as any);
+      user: { id: 'attacker', email: 'attacker@evil.com' },
+    } as any)
+    vi.mocked(db.teamInvitation.findUnique).mockResolvedValue(VALID_INVITATION as any)
 
-    const result = await acceptInvitation({ token: "tok-abc" });
+    const result = await acceptInvitation({ token: 'tok-abc' })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("This invitation was sent to a different email address");
-    expect(vi.mocked(db.organizationMember.create)).not.toHaveBeenCalled();
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('This invitation was sent to a different email address')
+    expect(vi.mocked(db.organizationMember.create)).not.toHaveBeenCalled()
+  })
 
-  it("cancelled invitation cannot be accepted", async () => {
+  it('cancelled invitation cannot be accepted', async () => {
     vi.mocked(db.teamInvitation.findUnique).mockResolvedValue({
       ...VALID_INVITATION,
-      status: "cancelled",
-    } as any);
+      status: 'cancelled',
+    } as any)
 
-    const result = await acceptInvitation({ token: "tok-abc" });
+    const result = await acceptInvitation({ token: 'tok-abc' })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("This invitation is no longer valid");
-    expect(vi.mocked(db.organizationMember.create)).not.toHaveBeenCalled();
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('This invitation is no longer valid')
+    expect(vi.mocked(db.organizationMember.create)).not.toHaveBeenCalled()
+  })
 
-  it("already-accepted invitation cannot be re-accepted", async () => {
+  it('already-accepted invitation cannot be re-accepted', async () => {
     vi.mocked(db.teamInvitation.findUnique).mockResolvedValue({
       ...VALID_INVITATION,
-      status: "accepted",
-    } as any);
+      status: 'accepted',
+    } as any)
 
-    const result = await acceptInvitation({ token: "tok-abc" });
+    const result = await acceptInvitation({ token: 'tok-abc' })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("This invitation is no longer valid");
-    expect(vi.mocked(db.organizationMember.create)).not.toHaveBeenCalled();
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('This invitation is no longer valid')
+    expect(vi.mocked(db.organizationMember.create)).not.toHaveBeenCalled()
+  })
 
-  it("expired invitation cannot be accepted", async () => {
+  it('expired invitation cannot be accepted', async () => {
     vi.mocked(db.teamInvitation.findUnique).mockResolvedValue({
       ...VALID_INVITATION,
       expiresAt: new Date(Date.now() - 1000),
-    } as any);
+    } as any)
 
-    const result = await acceptInvitation({ token: "tok-abc" });
+    const result = await acceptInvitation({ token: 'tok-abc' })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("This invitation has expired");
-    expect(vi.mocked(db.organizationMember.create)).not.toHaveBeenCalled();
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('This invitation has expired')
+    expect(vi.mocked(db.organizationMember.create)).not.toHaveBeenCalled()
+  })
 
   it("unknown/garbage token returns 'Invitation not found'", async () => {
-    vi.mocked(db.teamInvitation.findUnique).mockResolvedValue(null);
+    vi.mocked(db.teamInvitation.findUnique).mockResolvedValue(null)
 
-    const result = await acceptInvitation({ token: "garbage-token" });
+    const result = await acceptInvitation({ token: 'garbage-token' })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Invitation not found");
-    expect(vi.mocked(db.organizationMember.create)).not.toHaveBeenCalled();
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Invitation not found')
+    expect(vi.mocked(db.organizationMember.create)).not.toHaveBeenCalled()
+  })
 
-  it("unauthenticated call returns an error without touching the DB", async () => {
-    mockSession.mockResolvedValue(null);
+  it('unauthenticated call returns an error without touching the DB', async () => {
+    mockSession.mockResolvedValue(null)
 
-    const result = await acceptInvitation({ token: "tok-abc" });
+    const result = await acceptInvitation({ token: 'tok-abc' })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("You must be signed in to accept an invitation");
-    expect(vi.mocked(db.teamInvitation.findUnique)).not.toHaveBeenCalled();
-  });
-});
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('You must be signed in to accept an invitation')
+    expect(vi.mocked(db.teamInvitation.findUnique)).not.toHaveBeenCalled()
+  })
+})
 
 // ============================================================================
 // sendInvitation — org scoping, role storage, roleId validation
 // ============================================================================
 
-describe("sendInvitation — org scoping and role validation", () => {
+describe('sendInvitation — org scoping and role validation', () => {
   beforeEach(() => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      id: "mem-1",
+      id: 'mem-1',
       organizationId: ORG_A,
-      role: "owner",
-      organization: { name: "Org A Auto" },
-    } as any);
-    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue(null);
-    vi.mocked(db.teamInvitation.deleteMany).mockResolvedValue({ count: 0 } as any);
+      role: 'owner',
+      organization: { name: 'Org A Auto' },
+    } as any)
+    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue(null)
+    vi.mocked(db.teamInvitation.deleteMany).mockResolvedValue({ count: 0 } as any)
     vi.mocked(db.teamInvitation.create).mockResolvedValue({
-      id: "inv-new",
-      token: "new-tok",
+      id: 'inv-new',
+      token: 'new-tok',
       organizationId: ORG_A,
-    } as any);
-    mockSendOrgMail.mockResolvedValue(undefined as any);
-    mockGetOrgFromAddress.mockResolvedValue("noreply@org.com" as any);
-  });
+    } as any)
+    mockSendOrgMail.mockResolvedValue(undefined as any)
+    mockGetOrgFromAddress.mockResolvedValue('noreply@org.com' as any)
+  })
 
   it("invitation is scoped to the caller's organizationId (from auth context)", async () => {
-    await sendInvitation({ email: "new@example.com", role: "member" });
+    await sendInvitation({ email: 'new@example.com', role: 'member' })
 
     expect(vi.mocked(db.teamInvitation.create)).toHaveBeenCalledWith(
       expect.objectContaining({
         data: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
-  it("stores the requested role in the invitation", async () => {
-    await sendInvitation({ email: "new@example.com", role: "admin" });
+  it('stores the requested role in the invitation', async () => {
+    await sendInvitation({ email: 'new@example.com', role: 'admin' })
 
     expect(vi.mocked(db.teamInvitation.create)).toHaveBeenCalledWith(
       expect.objectContaining({
-        data: expect.objectContaining({ role: "admin" }),
+        data: expect.objectContaining({ role: 'admin' }),
       })
-    );
-  });
+    )
+  })
 
   it("stores the invitedById as the caller's userId", async () => {
-    await sendInvitation({ email: "new@example.com", role: "member" });
+    await sendInvitation({ email: 'new@example.com', role: 'member' })
 
     expect(vi.mocked(db.teamInvitation.create)).toHaveBeenCalledWith(
       expect.objectContaining({
-        data: expect.objectContaining({ invitedById: "user-a" }),
+        data: expect.objectContaining({ invitedById: 'user-a' }),
       })
-    );
-  });
+    )
+  })
 
-  it("rejects a duplicate invitation for the same email in the same org", async () => {
-    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue({ id: "existing-inv" } as any);
+  it('rejects a duplicate invitation for the same email in the same org', async () => {
+    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue({ id: 'existing-inv' } as any)
 
-    const result = await sendInvitation({ email: "existing@example.com", role: "member" });
+    const result = await sendInvitation({ email: 'existing@example.com', role: 'member' })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("An invitation has already been sent to this email");
-    expect(vi.mocked(db.teamInvitation.create)).not.toHaveBeenCalled();
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('An invitation has already been sent to this email')
+    expect(vi.mocked(db.teamInvitation.create)).not.toHaveBeenCalled()
+  })
 
-  it("cleans up stale non-pending invitations before creating a fresh one", async () => {
-    await sendInvitation({ email: "returning@example.com", role: "member" });
+  it('cleans up stale non-pending invitations before creating a fresh one', async () => {
+    await sendInvitation({ email: 'returning@example.com', role: 'member' })
 
     expect(vi.mocked(db.teamInvitation.deleteMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({
-          email: "returning@example.com",
+          email: 'returning@example.com',
           organizationId: ORG_A,
-          status: { not: "pending" },
+          status: { not: 'pending' },
         }),
       })
-    );
-  });
+    )
+  })
 
-  it("rolls back the invitation record when the email send fails", async () => {
-    mockSendOrgMail.mockRejectedValue(new Error("SMTP error") as any);
-    vi.mocked(db.teamInvitation.delete).mockResolvedValue({} as any);
+  it('rolls back the invitation record when the email send fails', async () => {
+    mockSendOrgMail.mockRejectedValue(new Error('SMTP error') as any)
+    vi.mocked(db.teamInvitation.delete).mockResolvedValue({} as any)
 
-    const result = await sendInvitation({ email: "new@example.com", role: "member" });
+    const result = await sendInvitation({ email: 'new@example.com', role: 'member' })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Failed to send invitation email. Please try again.");
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Failed to send invitation email. Please try again.')
     expect(vi.mocked(db.teamInvitation.delete)).toHaveBeenCalledWith(
-      expect.objectContaining({ where: { id: "inv-new" } })
-    );
-  });
+      expect.objectContaining({ where: { id: 'inv-new' } })
+    )
+  })
 
   it("duplicate check is scoped to the caller's org — same email in different org is allowed", async () => {
     // findFirst with { email, organizationId: ORG_A, status: "pending" } returns null
-    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue(null);
+    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue(null)
 
-    const result = await sendInvitation({ email: "shared@example.com", role: "member" });
+    const result = await sendInvitation({ email: 'shared@example.com', role: 'member' })
 
-    expect(result.success).toBe(true);
-    expect(vi.mocked(db.teamInvitation.create)).toHaveBeenCalled();
-  });
+    expect(result.success).toBe(true)
+    expect(vi.mocked(db.teamInvitation.create)).toHaveBeenCalled()
+  })
 
-  it("rejects a roleId from another org (org-scoped validation)", async () => {
+  it('rejects a roleId from another org (org-scoped validation)', async () => {
     // role.findFirst with { id, organizationId: ORG_A } returns null for a foreign role
-    vi.mocked(db.role.findFirst).mockResolvedValue(null);
+    vi.mocked(db.role.findFirst).mockResolvedValue(null)
 
     const result = await sendInvitation({
-      email: "new@example.com",
-      role: "member",
-      roleId: "org-b-role-id",
-    });
+      email: 'new@example.com',
+      role: 'member',
+      roleId: 'org-b-role-id',
+    })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Role not found");
-    expect(vi.mocked(db.teamInvitation.create)).not.toHaveBeenCalled();
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Role not found')
+    expect(vi.mocked(db.teamInvitation.create)).not.toHaveBeenCalled()
+  })
 
   it("roleId lookup is scoped to the caller's organizationId", async () => {
-    vi.mocked(db.role.findFirst).mockResolvedValue(null);
+    vi.mocked(db.role.findFirst).mockResolvedValue(null)
 
     await sendInvitation({
-      email: "new@example.com",
-      role: "member",
-      roleId: "some-role-id",
-    });
+      email: 'new@example.com',
+      role: 'member',
+      roleId: 'some-role-id',
+    })
 
     expect(vi.mocked(db.role.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
 // ============================================================================
 // cancelInvitation — org isolation and authorization
 // ============================================================================
 
-describe("cancelInvitation — org isolation and authorization", () => {
+describe('cancelInvitation — org isolation and authorization', () => {
   it("owner can cancel their org's pending invitation", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      id: "mem-1",
-      role: "owner",
+      id: 'mem-1',
+      role: 'owner',
       organizationId: ORG_A,
-    } as any);
-    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue({ ...VALID_INVITATION } as any);
-    vi.mocked(db.teamInvitation.update).mockResolvedValue({} as any);
+    } as any)
+    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue({ ...VALID_INVITATION } as any)
+    vi.mocked(db.teamInvitation.update).mockResolvedValue({} as any)
 
-    const result = await cancelInvitation({ invitationId: "inv-1" });
+    const result = await cancelInvitation({ invitationId: 'inv-1' })
 
-    expect(result.success).toBe(true);
-  });
+    expect(result.success).toBe(true)
+  })
 
   it("admin can cancel their org's pending invitation", async () => {
-    setupOrgAAdmin();
+    setupOrgAAdmin()
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      id: "mem-1",
-      role: "admin",
+      id: 'mem-1',
+      role: 'admin',
       organizationId: ORG_A,
-    } as any);
-    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue({ ...VALID_INVITATION } as any);
-    vi.mocked(db.teamInvitation.update).mockResolvedValue({} as any);
+    } as any)
+    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue({ ...VALID_INVITATION } as any)
+    vi.mocked(db.teamInvitation.update).mockResolvedValue({} as any)
 
-    const result = await cancelInvitation({ invitationId: "inv-1" });
+    const result = await cancelInvitation({ invitationId: 'inv-1' })
 
-    expect(result.success).toBe(true);
-  });
+    expect(result.success).toBe(true)
+  })
 
-  it("plain member (built-in role) cannot cancel an invitation", async () => {
-    setupOrgAMember();
+  it('plain member (built-in role) cannot cancel an invitation', async () => {
+    setupOrgAMember()
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      id: "mem-1",
-      role: "member",
+      id: 'mem-1',
+      role: 'member',
       organizationId: ORG_A,
-    } as any);
-
-    const result = await cancelInvitation({ invitationId: "inv-1" });
-
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Only owners and admins can cancel invitations");
-    expect(vi.mocked(db.teamInvitation.update)).not.toHaveBeenCalled();
-  });
-
-  it("custom isAdmin member can cancel invitations (isAdmin covers custom roles)", async () => {
-    setupOrgACustomAdmin();
+    } as any)
+
+    const result = await cancelInvitation({ invitationId: 'inv-1' })
+
+    expect(result.success).toBe(false)
+    // Denied by the permission check now, one layer earlier than the
+    // action's own owner/admin guard. A member used to reach that guard
+    // only because withAuth skipped permissions for a member with no
+    // role; manage:settings is not grantable through the UI, so no
+    // member reaches it any more. Still refused, still no write.
+    expect(result.error).toBe('Insufficient permissions')
+    expect(vi.mocked(db.teamInvitation.update)).not.toHaveBeenCalled()
+  })
+
+  it('custom isAdmin member can cancel invitations (isAdmin covers custom roles)', async () => {
+    setupOrgACustomAdmin()
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      id: "mem-1",
-      role: "member",
+      id: 'mem-1',
+      role: 'member',
       organizationId: ORG_A,
-    } as any);
-    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue({ ...VALID_INVITATION } as any);
-    vi.mocked(db.teamInvitation.update).mockResolvedValue({} as any);
+    } as any)
+    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue({ ...VALID_INVITATION } as any)
+    vi.mocked(db.teamInvitation.update).mockResolvedValue({} as any)
 
-    const result = await cancelInvitation({ invitationId: "inv-1" });
+    const result = await cancelInvitation({ invitationId: 'inv-1' })
 
-    expect(result.success).toBe(true);
-  });
+    expect(result.success).toBe(true)
+  })
 
-  it("cannot cancel an invitation that belongs to another org", async () => {
-    setupOrgAOwner();
+  it('cannot cancel an invitation that belongs to another org', async () => {
+    setupOrgAOwner()
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      id: "mem-1",
-      role: "owner",
+      id: 'mem-1',
+      role: 'owner',
       organizationId: ORG_A,
-    } as any);
+    } as any)
     // The invitation lookup is scoped to organizationId: ORG_A → returns null for Org B's invite
-    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue(null);
+    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue(null)
 
-    const result = await cancelInvitation({ invitationId: "org-b-inv-id" });
+    const result = await cancelInvitation({ invitationId: 'org-b-inv-id' })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Invitation not found");
-    expect(vi.mocked(db.teamInvitation.update)).not.toHaveBeenCalled();
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Invitation not found')
+    expect(vi.mocked(db.teamInvitation.update)).not.toHaveBeenCalled()
+  })
 
-  it("cannot cancel an already-cancelled invitation", async () => {
-    setupOrgAOwner();
+  it('cannot cancel an already-cancelled invitation', async () => {
+    setupOrgAOwner()
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      id: "mem-1",
-      role: "owner",
+      id: 'mem-1',
+      role: 'owner',
       organizationId: ORG_A,
-    } as any);
+    } as any)
     // status:"pending" filter excludes cancelled invitations
-    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue(null);
+    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue(null)
 
-    const result = await cancelInvitation({ invitationId: "inv-1" });
+    const result = await cancelInvitation({ invitationId: 'inv-1' })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Invitation not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Invitation not found')
+  })
 
   it("invitation lookup is scoped to the caller's organizationId", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      id: "mem-1",
-      role: "owner",
+      id: 'mem-1',
+      role: 'owner',
       organizationId: ORG_A,
-    } as any);
-    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue(null);
+    } as any)
+    vi.mocked(db.teamInvitation.findFirst).mockResolvedValue(null)
 
-    await cancelInvitation({ invitationId: "inv-x" });
+    await cancelInvitation({ invitationId: 'inv-x' })
 
     expect(vi.mocked(db.teamInvitation.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
 // ============================================================================
 // getPendingInvitations — org scoping
 // ============================================================================
 
-describe("getPendingInvitations — org scoping", () => {
+describe('getPendingInvitations — org scoping', () => {
   it("query is scoped to the caller's organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.teamInvitation.findMany).mockResolvedValue([]);
+    setupOrgAOwner()
+    vi.mocked(db.teamInvitation.findMany).mockResolvedValue([])
 
-    await getPendingInvitations();
+    await getPendingInvitations()
 
     expect(vi.mocked(db.teamInvitation.findMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
-  it("filters out expired invitations via expiresAt > now", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.teamInvitation.findMany).mockResolvedValue([]);
+  it('filters out expired invitations via expiresAt > now', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.teamInvitation.findMany).mockResolvedValue([])
 
-    await getPendingInvitations();
+    await getPendingInvitations()
 
     expect(vi.mocked(db.teamInvitation.findMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({
-          status: "pending",
+          status: 'pending',
           expiresAt: expect.objectContaining({ gt: expect.any(Date) }),
         }),
       })
-    );
-  });
+    )
+  })
 
   it("returns the caller's org's invitations", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     const orgAInvites = [
-      { id: "inv-1", email: "a@example.com", role: "member", organizationId: ORG_A },
-    ];
-    vi.mocked(db.teamInvitation.findMany).mockResolvedValue(orgAInvites as any);
+      { id: 'inv-1', email: 'a@example.com', role: 'member', organizationId: ORG_A },
+    ]
+    vi.mocked(db.teamInvitation.findMany).mockResolvedValue(orgAInvites as any)
 
-    const result = await getPendingInvitations();
+    const result = await getPendingInvitations()
 
-    expect(result.success).toBe(true);
-    expect(result.data).toHaveLength(1);
-    expect((result.data as any)[0].id).toBe("inv-1");
-  });
+    expect(result.success).toBe(true)
+    expect(result.data).toHaveLength(1)
+    expect((result.data as any)[0].id).toBe('inv-1')
+  })
 
-  it("owner from Org B gets an empty list even if Org A has pending invitations", async () => {
+  it('owner from Org B gets an empty list even if Org A has pending invitations', async () => {
     // Setup: Org B owner's auth context
-    mockSession.mockResolvedValue({ user: { id: "user-b", email: "owner@orgB.com" } } as any);
+    mockSession.mockResolvedValue({ user: { id: 'user-b', email: 'owner@orgB.com' } } as any)
     mockMembership.mockResolvedValue({
       organizationId: ORG_B,
-      role: "owner",
+      role: 'owner',
       roleId: null,
       customRole: null,
-    } as any);
-    mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+    } as any)
+    mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
     // DB returns empty because the where clause uses organizationId: ORG_B
-    vi.mocked(db.teamInvitation.findMany).mockResolvedValue([]);
+    vi.mocked(db.teamInvitation.findMany).mockResolvedValue([])
 
-    const result = await getPendingInvitations();
+    const result = await getPendingInvitations()
 
-    expect(result.success).toBe(true);
-    expect(result.data).toHaveLength(0);
+    expect(result.success).toBe(true)
+    expect(result.data).toHaveLength(0)
     // Verify the query used ORG_B, not ORG_A
     expect(vi.mocked(db.teamInvitation.findMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_B }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
 // ============================================================================
 // assignRole — org isolation and role validation
 // ============================================================================
 
-describe("assignRole — org isolation and role validation", () => {
-  it("owner can assign a built-in role to a member", async () => {
-    setupOrgAOwner();
+describe('assignRole — org isolation and role validation', () => {
+  it('owner can assign a built-in role to a member', async () => {
+    setupOrgAOwner()
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      id: "mem-2",
+      id: 'mem-2',
       organizationId: ORG_A,
-      role: "member",
-    } as any);
-    vi.mocked(db.organizationMember.update).mockResolvedValue({} as any);
+      role: 'member',
+    } as any)
+    vi.mocked(db.organizationMember.update).mockResolvedValue({} as any)
 
-    const result = await assignRole({ memberId: "mem-2", role: "admin", roleId: null });
+    const result = await assignRole({ memberId: 'mem-2', role: 'admin', roleId: null })
 
-    expect(result.success).toBe(true);
-  });
+    expect(result.success).toBe(true)
+  })
 
   it("member not in the caller's org returns 'Member not found'", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     // findFirst scoped to organizationId: ORG_A returns null for a foreign member
-    vi.mocked(db.organizationMember.findFirst).mockResolvedValue(null);
+    vi.mocked(db.organizationMember.findFirst).mockResolvedValue(null)
 
-    const result = await assignRole({ memberId: "org-b-member-id", roleId: null });
+    const result = await assignRole({ memberId: 'org-b-member-id', roleId: null })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Member not found");
-    expect(vi.mocked(db.organizationMember.update)).not.toHaveBeenCalled();
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Member not found')
+    expect(vi.mocked(db.organizationMember.update)).not.toHaveBeenCalled()
+  })
 
   it("member lookup always includes the caller's organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.organizationMember.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.organizationMember.findFirst).mockResolvedValue(null)
 
-    await assignRole({ memberId: "mem-x", roleId: null });
+    await assignRole({ memberId: 'mem-x', roleId: null })
 
     expect(vi.mocked(db.organizationMember.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
-  it("cannot assign a role to the org owner", async () => {
-    setupOrgAOwner();
+  it('cannot assign a role to the org owner', async () => {
+    setupOrgAOwner()
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      id: "mem-owner",
+      id: 'mem-owner',
       organizationId: ORG_A,
-      role: "owner",
-    } as any);
+      role: 'owner',
+    } as any)
 
-    const result = await assignRole({ memberId: "mem-owner", role: "admin", roleId: null });
+    const result = await assignRole({ memberId: 'mem-owner', role: 'admin', roleId: null })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Cannot assign a role to the owner");
-    expect(vi.mocked(db.organizationMember.update)).not.toHaveBeenCalled();
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Cannot assign a role to the owner')
+    expect(vi.mocked(db.organizationMember.update)).not.toHaveBeenCalled()
+  })
 
   it("rejects a roleId that does not belong to the caller's org", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      id: "mem-2",
+      id: 'mem-2',
       organizationId: ORG_A,
-      role: "member",
-    } as any);
+      role: 'member',
+    } as any)
     // role.findFirst({ where: { id, organizationId: ORG_A } }) returns null for a foreign role
-    vi.mocked(db.role.findFirst).mockResolvedValue(null);
+    vi.mocked(db.role.findFirst).mockResolvedValue(null)
 
-    const result = await assignRole({ memberId: "mem-2", roleId: "org-b-role-id" });
+    const result = await assignRole({ memberId: 'mem-2', roleId: 'org-b-role-id' })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Role not found");
-    expect(vi.mocked(db.organizationMember.update)).not.toHaveBeenCalled();
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Role not found')
+    expect(vi.mocked(db.organizationMember.update)).not.toHaveBeenCalled()
+  })
 
   it("role lookup always includes the caller's organizationId", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      id: "mem-2",
+      id: 'mem-2',
       organizationId: ORG_A,
-      role: "member",
-    } as any);
-    vi.mocked(db.role.findFirst).mockResolvedValue(null);
+      role: 'member',
+    } as any)
+    vi.mocked(db.role.findFirst).mockResolvedValue(null)
 
-    await assignRole({ memberId: "mem-2", roleId: "some-role-id" });
+    await assignRole({ memberId: 'mem-2', roleId: 'some-role-id' })
 
     expect(vi.mocked(db.role.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-
-  it("plain member (built-in role) cannot assign roles", async () => {
-    setupOrgAMember();
-
-    const result = await assignRole({ memberId: "mem-2", role: "admin", roleId: null });
-
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Only owners and admins can assign roles");
-    expect(vi.mocked(db.organizationMember.update)).not.toHaveBeenCalled();
-  });
-
-  it("custom isAdmin member can assign roles (isAdmin covers custom roles)", async () => {
-    setupOrgACustomAdmin();
+    )
+  })
+
+  it('plain member (built-in role) cannot assign roles', async () => {
+    setupOrgAMember()
+
+    const result = await assignRole({ memberId: 'mem-2', role: 'admin', roleId: null })
+
+    expect(result.success).toBe(false)
+    // Denied by the permission check now, one layer earlier than the
+    // action's own owner/admin guard. A member used to reach that guard
+    // only because withAuth skipped permissions for a member with no
+    // role; manage:settings is not grantable through the UI, so no
+    // member reaches it any more. Still refused, still no write.
+    expect(result.error).toBe('Insufficient permissions')
+    expect(vi.mocked(db.organizationMember.update)).not.toHaveBeenCalled()
+  })
+
+  it('custom isAdmin member can assign roles (isAdmin covers custom roles)', async () => {
+    setupOrgACustomAdmin()
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      id: "mem-2",
+      id: 'mem-2',
       organizationId: ORG_A,
-      role: "member",
-    } as any);
-    vi.mocked(db.organizationMember.update).mockResolvedValue({} as any);
+      role: 'member',
+    } as any)
+    vi.mocked(db.organizationMember.update).mockResolvedValue({} as any)
 
-    const result = await assignRole({ memberId: "mem-2", role: "member", roleId: null });
+    const result = await assignRole({ memberId: 'mem-2', role: 'member', roleId: null })
 
-    expect(result.success).toBe(true);
-  });
+    expect(result.success).toBe(true)
+  })
 
-  it("admin can assign a custom role to a member", async () => {
-    setupOrgAAdmin();
+  it('admin can assign a custom role to a member', async () => {
+    setupOrgAAdmin()
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      id: "mem-2",
+      id: 'mem-2',
       organizationId: ORG_A,
-      role: "member",
-    } as any);
+      role: 'member',
+    } as any)
     vi.mocked(db.role.findFirst).mockResolvedValue({
-      id: "tech-role-id",
-      name: "Technician",
+      id: 'tech-role-id',
+      name: 'Technician',
       organizationId: ORG_A,
-    } as any);
-    vi.mocked(db.organizationMember.update).mockResolvedValue({} as any);
+    } as any)
+    vi.mocked(db.organizationMember.update).mockResolvedValue({} as any)
 
-    const result = await assignRole({ memberId: "mem-2", roleId: "tech-role-id" });
+    const result = await assignRole({ memberId: 'mem-2', roleId: 'tech-role-id' })
 
-    expect(result.success).toBe(true);
+    expect(result.success).toBe(true)
     expect(vi.mocked(db.organizationMember.update)).toHaveBeenCalledWith(
       expect.objectContaining({
-        data: expect.objectContaining({ roleId: "tech-role-id" }),
+        data: expect.objectContaining({ roleId: 'tech-role-id' }),
       })
-    );
-  });
-});
+    )
+  })
+})

+ 242 - 220
src/app/(authenticated)/settings/alerts/alerts-settings.tsx

@@ -1,22 +1,22 @@
-"use client";
+'use client'
 
-import { useState } from "react";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { AppCard } from "@/components/app-card";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Button } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
-import { Switch } from "@/components/ui/switch";
-import { toast } from "sonner";
-import { setSettings } from "@/features/settings/Actions/settingsActions";
-import { runLowStockCheck } from "@/features/inventory/Actions/runLowStockCheck";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { parseInvalidRecipients } from "@/features/portal/Lib/serviceRequestAlert";
-import { Loader2, PackageSearch, PlayCircle, Save, Wrench } from "lucide-react";
-import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from "../read-only-guard";
+import { useState } from 'react'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { AppCard } from '@/components/app-card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Button } from '@/components/ui/button'
+import { Separator } from '@/components/ui/separator'
+import { Switch } from '@/components/ui/switch'
+import { toast } from 'sonner'
+import { setSettings } from '@/features/settings/Actions/settingsActions'
+import { runLowStockCheck } from '@/features/inventory/Actions/runLowStockCheck'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { parseInvalidRecipients } from '@/features/portal/Lib/serviceRequestAlert'
+import { Loader2, PackageSearch, PlayCircle, Save, Smartphone, Wrench } from 'lucide-react'
+import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
 
 /**
  * Alert preferences.
@@ -26,192 +26,226 @@ import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from "../read-only-guard"
  * means dropping in a sibling card rather than reworking this page.
  */
 export function AlertsSettings({ settings }: { settings: Record<string, string> }) {
-  const t = useTranslations("settings");
+  const t = useTranslations('settings')
 
   return (
     <div className="space-y-6">
       <ReadOnlyBanner />
       <div>
-        <h2 className="text-lg font-semibold">{t("alerts.title")}</h2>
-        <p className="text-sm text-muted-foreground">{t("alerts.description")}</p>
+        <h2 className="text-lg font-semibold">{t('alerts.title')}</h2>
+        <p className="text-sm text-muted-foreground">{t('alerts.description')}</p>
       </div>
       <ReadOnlyWrapper>
         <div className="space-y-6">
+          <TechnicianStatusAlerts settings={settings} />
           <ServiceRequestAlertCard settings={settings} />
           <LowStockAlertCard settings={settings} />
         </div>
       </ReadOnlyWrapper>
     </div>
-  );
+  )
+}
+
+function TechnicianStatusAlerts({ settings }: { settings: Record<string, string> }) {
+  const t = useTranslations('settings')
+  const [saving, setSaving] = useState(false)
+  const [enabled, setEnabled] = useState(
+    // Absent means on. A shop that has never opened this page should still be
+    // told when a technician moves a job; opting out has to be deliberate.
+    settings[SETTING_KEYS.TECHNICIAN_STATUS_ALERTS] !== 'false'
+  )
+
+  async function handleSave() {
+    setSaving(true)
+    const result = await setSettings({
+      [SETTING_KEYS.TECHNICIAN_STATUS_ALERTS]: enabled ? 'true' : 'false',
+    })
+    setSaving(false)
+    if (result.success) toast.success(t('alerts.technicianStatus.saved'))
+    else toast.error(result.error || t('alerts.technicianStatus.saveFailed'))
+  }
+
+  return (
+    <AppCard
+      icon={Smartphone}
+      title={t('alerts.technicianStatus.title')}
+      contentClassName="space-y-6"
+    >
+      <p className="text-sm text-muted-foreground">{t('alerts.technicianStatus.description')}</p>
+
+      <div className="flex items-center justify-between">
+        <div className="space-y-0.5 pr-4">
+          <Label htmlFor="technicianStatus">{t('alerts.technicianStatus.inApp')}</Label>
+          <p className="text-xs text-muted-foreground">{t('alerts.technicianStatus.inAppHint')}</p>
+        </div>
+        <Switch id="technicianStatus" checked={enabled} onCheckedChange={setEnabled} />
+      </div>
+
+      {/* Hides the button entirely for a member who may not edit settings,
+          the same as every other card here. */}
+      <SaveButton>
+        <Button onClick={handleSave} disabled={saving}>
+          {saving ? (
+            <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+          ) : (
+            <Save className="mr-2 h-4 w-4" />
+          )}
+          {t('alerts.technicianStatus.save')}
+        </Button>
+      </SaveButton>
+    </AppCard>
+  )
 }
 
 function ServiceRequestAlertCard({ settings }: { settings: Record<string, string> }) {
-  const router = useRouter();
-  const t = useTranslations("settings");
-  const [saving, setSaving] = useState(false);
+  const router = useRouter()
+  const t = useTranslations('settings')
+  const [saving, setSaving] = useState(false)
 
-  const [email, setEmail] = useState(
-    settings[SETTING_KEYS.SERVICE_REQUEST_ALERTS_EMAIL] === "true",
-  );
+  const [email, setEmail] = useState(settings[SETTING_KEYS.SERVICE_REQUEST_ALERTS_EMAIL] === 'true')
   const [recipients, setRecipients] = useState(
-    settings[SETTING_KEYS.SERVICE_REQUEST_ALERTS_RECIPIENTS] || "",
-  );
+    settings[SETTING_KEYS.SERVICE_REQUEST_ALERTS_RECIPIENTS] || ''
+  )
 
   // Addresses are validated on save rather than while typing, so the field does
   // not flag a half-written address on every keystroke.
-  const invalid = parseInvalidRecipients(recipients);
+  const invalid = parseInvalidRecipients(recipients)
 
   const handleSave = async () => {
     if (email && invalid.length > 0) {
-      toast.error(t("alerts.serviceRequest.invalidRecipients", { list: invalid.join(", ") }));
-      return;
+      toast.error(t('alerts.serviceRequest.invalidRecipients', { list: invalid.join(', ') }))
+      return
     }
-    setSaving(true);
+    setSaving(true)
     await setSettings({
-      [SETTING_KEYS.SERVICE_REQUEST_ALERTS_EMAIL]: email ? "true" : "false",
+      [SETTING_KEYS.SERVICE_REQUEST_ALERTS_EMAIL]: email ? 'true' : 'false',
       [SETTING_KEYS.SERVICE_REQUEST_ALERTS_RECIPIENTS]: recipients.trim(),
-    });
-    setSaving(false);
-    router.refresh();
-    toast.success(t("alerts.serviceRequest.saved"));
-  };
+    })
+    setSaving(false)
+    router.refresh()
+    toast.success(t('alerts.serviceRequest.saved'))
+  }
 
   return (
-    <AppCard icon={Wrench} title={t("alerts.serviceRequest.title")} contentClassName="space-y-6">
-        <p className="text-sm text-muted-foreground">
-          {t("alerts.serviceRequest.description")}
-        </p>
+    <AppCard icon={Wrench} title={t('alerts.serviceRequest.title')} contentClassName="space-y-6">
+      <p className="text-sm text-muted-foreground">{t('alerts.serviceRequest.description')}</p>
 
-        <div className="flex items-center justify-between">
-          <div className="space-y-0.5 pr-4">
-            <Label htmlFor="serviceRequestEmail">{t("alerts.serviceRequest.email")}</Label>
-            <p className="text-xs text-muted-foreground">
-              {t("alerts.serviceRequest.emailHint")}
-            </p>
-          </div>
-          <Switch
-            id="serviceRequestEmail"
-            checked={email}
-            onCheckedChange={setEmail}
-          />
+      <div className="flex items-center justify-between">
+        <div className="space-y-0.5 pr-4">
+          <Label htmlFor="serviceRequestEmail">{t('alerts.serviceRequest.email')}</Label>
+          <p className="text-xs text-muted-foreground">{t('alerts.serviceRequest.emailHint')}</p>
         </div>
+        <Switch id="serviceRequestEmail" checked={email} onCheckedChange={setEmail} />
+      </div>
 
-        {email && (
-          <>
-            <Separator />
-
-            <div className="space-y-2">
-              <Label htmlFor="serviceRequestRecipients">
-                {t("alerts.serviceRequest.recipients")}
-              </Label>
-              <Input
-                id="serviceRequestRecipients"
-                type="text"
-                placeholder="service@example.com, owner@example.com"
-                value={recipients}
-                onChange={(e) => setRecipients(e.target.value)}
-              />
-              <p className="text-xs text-muted-foreground">
-                {t("alerts.serviceRequest.recipientsHint")}
-              </p>
-              {invalid.length > 0 && (
-                <p className="text-xs text-destructive">
-                  {t("alerts.serviceRequest.invalidRecipients", { list: invalid.join(", ") })}
-                </p>
-              )}
-            </div>
+      {email && (
+        <>
+          <Separator />
 
+          <div className="space-y-2">
+            <Label htmlFor="serviceRequestRecipients">
+              {t('alerts.serviceRequest.recipients')}
+            </Label>
+            <Input
+              id="serviceRequestRecipients"
+              type="text"
+              placeholder="service@example.com, owner@example.com"
+              value={recipients}
+              onChange={(e) => setRecipients(e.target.value)}
+            />
             <p className="text-xs text-muted-foreground">
-              {t("alerts.serviceRequest.emailSetupHint")}{" "}
-              <Link href="/settings/email" className="text-primary hover:underline">
-                {t("alerts.serviceRequest.emailSetupLink")}
-              </Link>
+              {t('alerts.serviceRequest.recipientsHint')}
             </p>
-          </>
-        )}
-
-        <SaveButton>
-          <Button onClick={handleSave} disabled={saving}>
-            {saving ? (
-              <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-            ) : (
-              <Save className="mr-2 h-4 w-4" />
+            {invalid.length > 0 && (
+              <p className="text-xs text-destructive">
+                {t('alerts.serviceRequest.invalidRecipients', { list: invalid.join(', ') })}
+              </p>
             )}
-            {t("alerts.serviceRequest.save")}
-          </Button>
-        </SaveButton>
+          </div>
+
+          <p className="text-xs text-muted-foreground">
+            {t('alerts.serviceRequest.emailSetupHint')}{' '}
+            <Link href="/settings/email" className="text-primary hover:underline">
+              {t('alerts.serviceRequest.emailSetupLink')}
+            </Link>
+          </p>
+        </>
+      )}
+
+      <SaveButton>
+        <Button onClick={handleSave} disabled={saving}>
+          {saving ? (
+            <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+          ) : (
+            <Save className="mr-2 h-4 w-4" />
+          )}
+          {t('alerts.serviceRequest.save')}
+        </Button>
+      </SaveButton>
     </AppCard>
-  );
+  )
 }
 
 function LowStockAlertCard({ settings }: { settings: Record<string, string> }) {
-  const router = useRouter();
-  const t = useTranslations("settings");
-  const [saving, setSaving] = useState(false);
-  const [checking, setChecking] = useState(false);
+  const router = useRouter()
+  const t = useTranslations('settings')
+  const [saving, setSaving] = useState(false)
+  const [checking, setChecking] = useState(false)
 
-  const [enabled, setEnabled] = useState(
-    settings[SETTING_KEYS.LOW_STOCK_ALERTS_ENABLED] === "true",
-  );
-  const [inApp, setInApp] = useState(
-    settings[SETTING_KEYS.LOW_STOCK_ALERTS_IN_APP] !== "false",
-  );
-  const [email, setEmail] = useState(
-    settings[SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL] === "true",
-  );
+  const [enabled, setEnabled] = useState(settings[SETTING_KEYS.LOW_STOCK_ALERTS_ENABLED] === 'true')
+  const [inApp, setInApp] = useState(settings[SETTING_KEYS.LOW_STOCK_ALERTS_IN_APP] !== 'false')
+  const [email, setEmail] = useState(settings[SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL] === 'true')
   const [interval, setInterval] = useState(
-    settings[SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL_MIN_INTERVAL_HOURS] || "24",
-  );
+    settings[SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL_MIN_INTERVAL_HOURS] || '24'
+  )
   const [defaultThreshold, setDefaultThreshold] = useState(
-    settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD] || "0",
-  );
+    settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD] || '0'
+  )
 
   const handleSave = async () => {
-    setSaving(true);
+    setSaving(true)
     await setSettings({
-      [SETTING_KEYS.LOW_STOCK_ALERTS_ENABLED]: enabled ? "true" : "false",
-      [SETTING_KEYS.LOW_STOCK_ALERTS_IN_APP]: inApp ? "true" : "false",
-      [SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL]: email ? "true" : "false",
+      [SETTING_KEYS.LOW_STOCK_ALERTS_ENABLED]: enabled ? 'true' : 'false',
+      [SETTING_KEYS.LOW_STOCK_ALERTS_IN_APP]: inApp ? 'true' : 'false',
+      [SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL]: email ? 'true' : 'false',
       [SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL_MIN_INTERVAL_HOURS]: String(
-        Math.max(0, Number(interval) || 24),
+        Math.max(0, Number(interval) || 24)
       ),
       [SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]: String(
-        Math.max(0, Number(defaultThreshold) || 0),
+        Math.max(0, Number(defaultThreshold) || 0)
       ),
-    });
-    setSaving(false);
-    router.refresh();
-    toast.success(t("alerts.lowStock.saved"));
-  };
+    })
+    setSaving(false)
+    router.refresh()
+    toast.success(t('alerts.lowStock.saved'))
+  }
 
   // Runs the same evaluation the schedule does, so the operator can confirm
   // the setup works instead of waiting for the next sweep.
   const handleCheckNow = async () => {
-    setChecking(true);
-    const result = await runLowStockCheck();
-    setChecking(false);
+    setChecking(true)
+    const result = await runLowStockCheck()
+    setChecking(false)
     if (!result.success || !result.data) {
-      toast.error(result.error ?? t("alerts.lowStock.checkFailed"));
-      return;
+      toast.error(result.error ?? t('alerts.lowStock.checkFailed'))
+      return
     }
     if (!result.data.enabled) {
-      toast.error(t("alerts.lowStock.checkDisabled"));
-      return;
+      toast.error(t('alerts.lowStock.checkDisabled'))
+      return
     }
     if (result.data.alerted === 0) {
-      toast.success(t("alerts.lowStock.checkNothingNew"));
+      toast.success(t('alerts.lowStock.checkNothingNew'))
     } else {
-      toast.success(
-        t("alerts.lowStock.checkAlerted", { count: result.data.alerted }),
-      );
-      router.refresh();
+      toast.success(t('alerts.lowStock.checkAlerted', { count: result.data.alerted }))
+      router.refresh()
     }
-  };
+  }
 
   return (
     <AppCard
       icon={PackageSearch}
-      title={t("alerts.lowStock.title")}
+      title={t('alerts.lowStock.title')}
       action={
         <a
           href="https://torqvoice.com/docs/features/low-stock-alerts"
@@ -219,109 +253,97 @@ function LowStockAlertCard({ settings }: { settings: Record<string, string> }) {
           rel="noopener noreferrer"
           className="text-[11px] text-muted-foreground transition-colors hover:text-foreground"
         >
-          {t("alerts.lowStock.readMore")} →
+          {t('alerts.lowStock.readMore')} →
         </a>
       }
       contentClassName="space-y-6"
     >
-        <p className="text-sm text-muted-foreground">
-          {t("alerts.lowStock.description")}
-        </p>
+      <p className="text-sm text-muted-foreground">{t('alerts.lowStock.description')}</p>
+
+      <div className="flex items-center justify-between">
+        <div className="space-y-0.5 pr-4">
+          <Label htmlFor="lowStockEnabled">{t('alerts.lowStock.enable')}</Label>
+          <p className="text-xs text-muted-foreground">{t('alerts.lowStock.enableHint')}</p>
+        </div>
+        <Switch id="lowStockEnabled" checked={enabled} onCheckedChange={setEnabled} />
+      </div>
+
+      {enabled && (
+        <>
+          <Separator />
 
-        <div className="flex items-center justify-between">
-          <div className="space-y-0.5 pr-4">
-            <Label htmlFor="lowStockEnabled">{t("alerts.lowStock.enable")}</Label>
+          <div className="space-y-2">
+            <Label htmlFor="lowStockThreshold">{t('alerts.lowStock.defaultThreshold')}</Label>
+            <Input
+              id="lowStockThreshold"
+              type="number"
+              min="0"
+              value={defaultThreshold}
+              onChange={(e) => setDefaultThreshold(e.target.value)}
+              className="max-w-[160px]"
+            />
             <p className="text-xs text-muted-foreground">
-              {t("alerts.lowStock.enableHint")}
+              {t('alerts.lowStock.defaultThresholdHint')}
             </p>
+            {/* At 0 nothing org-wide is watched, so point at where the
+                  per-part value actually lives instead of leaving the operator
+                  to work it out. */}
+            {Number(defaultThreshold) === 0 && (
+              <p className="text-xs text-muted-foreground">
+                <Link href="/inventory" className="text-primary hover:underline">
+                  {t('alerts.lowStock.configurePerPart')}
+                </Link>
+              </p>
+            )}
+          </div>
+
+          <Separator />
+
+          <div className="flex items-center justify-between">
+            <div className="space-y-0.5 pr-4">
+              <Label htmlFor="lowStockInApp">{t('alerts.lowStock.inApp')}</Label>
+              <p className="text-xs text-muted-foreground">{t('alerts.lowStock.inAppHint')}</p>
+            </div>
+            <Switch id="lowStockInApp" checked={inApp} onCheckedChange={setInApp} />
           </div>
-          <Switch id="lowStockEnabled" checked={enabled} onCheckedChange={setEnabled} />
-        </div>
 
-        {enabled && (
-          <>
-            <Separator />
+          <div className="flex items-center justify-between">
+            <div className="space-y-0.5 pr-4">
+              <Label htmlFor="lowStockEmail">{t('alerts.lowStock.email')}</Label>
+              <p className="text-xs text-muted-foreground">{t('alerts.lowStock.emailHint')}</p>
+            </div>
+            <Switch id="lowStockEmail" checked={email} onCheckedChange={setEmail} />
+          </div>
 
+          {email && (
             <div className="space-y-2">
-              <Label htmlFor="lowStockThreshold">
-                {t("alerts.lowStock.defaultThreshold")}
-              </Label>
+              <Label htmlFor="lowStockInterval">{t('alerts.lowStock.emailInterval')}</Label>
               <Input
-                id="lowStockThreshold"
+                id="lowStockInterval"
                 type="number"
                 min="0"
-                value={defaultThreshold}
-                onChange={(e) => setDefaultThreshold(e.target.value)}
+                max="168"
+                value={interval}
+                onChange={(e) => setInterval(e.target.value)}
                 className="max-w-[160px]"
               />
               <p className="text-xs text-muted-foreground">
-                {t("alerts.lowStock.defaultThresholdHint")}
+                {t('alerts.lowStock.emailIntervalHint')}
               </p>
-              {/* At 0 nothing org-wide is watched, so point at where the
-                  per-part value actually lives instead of leaving the operator
-                  to work it out. */}
-              {Number(defaultThreshold) === 0 && (
-                <p className="text-xs text-muted-foreground">
-                  <Link href="/inventory" className="text-primary hover:underline">
-                    {t("alerts.lowStock.configurePerPart")}
-                  </Link>
-                </p>
-              )}
             </div>
+          )}
+        </>
+      )}
 
-            <Separator />
-
-            <div className="flex items-center justify-between">
-              <div className="space-y-0.5 pr-4">
-                <Label htmlFor="lowStockInApp">{t("alerts.lowStock.inApp")}</Label>
-                <p className="text-xs text-muted-foreground">
-                  {t("alerts.lowStock.inAppHint")}
-                </p>
-              </div>
-              <Switch id="lowStockInApp" checked={inApp} onCheckedChange={setInApp} />
-            </div>
-
-            <div className="flex items-center justify-between">
-              <div className="space-y-0.5 pr-4">
-                <Label htmlFor="lowStockEmail">{t("alerts.lowStock.email")}</Label>
-                <p className="text-xs text-muted-foreground">
-                  {t("alerts.lowStock.emailHint")}
-                </p>
-              </div>
-              <Switch id="lowStockEmail" checked={email} onCheckedChange={setEmail} />
-            </div>
-
-            {email && (
-              <div className="space-y-2">
-                <Label htmlFor="lowStockInterval">
-                  {t("alerts.lowStock.emailInterval")}
-                </Label>
-                <Input
-                  id="lowStockInterval"
-                  type="number"
-                  min="0"
-                  max="168"
-                  value={interval}
-                  onChange={(e) => setInterval(e.target.value)}
-                  className="max-w-[160px]"
-                />
-                <p className="text-xs text-muted-foreground">
-                  {t("alerts.lowStock.emailIntervalHint")}
-                </p>
-              </div>
-            )}
-          </>
-        )}
-
-        <SaveButton>
-          <div className="flex flex-wrap gap-2">
+      <SaveButton>
+        <div className="flex flex-wrap gap-2">
           <Button onClick={handleSave} disabled={saving}>
             {saving ? (
               <Loader2 className="mr-2 h-4 w-4 animate-spin" />
             ) : (
               <Save className="mr-2 h-4 w-4" />
             )}
-            {t("alerts.lowStock.save")}
+            {t('alerts.lowStock.save')}
           </Button>
           {enabled && (
             <Button variant="outline" onClick={handleCheckNow} disabled={checking}>
@@ -330,11 +352,11 @@ function LowStockAlertCard({ settings }: { settings: Record<string, string> }) {
               ) : (
                 <PlayCircle className="mr-2 h-4 w-4" />
               )}
-              {t("alerts.lowStock.checkNow")}
+              {t('alerts.lowStock.checkNow')}
             </Button>
           )}
-          </div>
-        </SaveButton>
+        </div>
+      </SaveButton>
     </AppCard>
-  );
+  )
 }

+ 6 - 6
src/app/(authenticated)/settings/alerts/page.tsx

@@ -1,6 +1,6 @@
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { AlertsSettings } from "./alerts-settings";
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { AlertsSettings } from './alerts-settings'
 
 export default async function AlertsSettingsPage() {
   const result = await getSettings([
@@ -11,8 +11,8 @@ export default async function AlertsSettingsPage() {
     SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL_MIN_INTERVAL_HOURS,
     SETTING_KEYS.SERVICE_REQUEST_ALERTS_EMAIL,
     SETTING_KEYS.SERVICE_REQUEST_ALERTS_RECIPIENTS,
-  ]);
-  const settings = result.success && result.data ? result.data : {};
+  ])
+  const settings = result.success && result.data ? result.data : {}
 
-  return <AlertsSettings settings={settings} />;
+  return <AlertsSettings settings={settings} />
 }

+ 16 - 10
src/app/(authenticated)/settings/team/page.tsx

@@ -1,17 +1,22 @@
-import { getOrganization } from "@/features/team/Actions/teamActions";
-import { getRoles } from "@/features/team/Actions/getRoles";
-import { getPendingInvitations } from "@/features/team/Actions/getPendingInvitations";
-import { TeamSettings } from "./team-settings";
+import { getOrganization } from '@/features/team/Actions/teamActions'
+import { getRoles } from '@/features/team/Actions/getRoles'
+import { getPendingInvitations } from '@/features/team/Actions/getPendingInvitations'
+import { getTechnicianUserIds } from '@/features/team/Actions/setMemberTechnician'
+import { TeamSettings } from './team-settings'
 
 export default async function TeamPage() {
-  const [result, rolesResult, invitationsResult] = await Promise.all([
+  const [result, rolesResult, invitationsResult, technicianResult] = await Promise.all([
     getOrganization(),
     getRoles(),
     getPendingInvitations(),
-  ]);
-  const orgData = result.success ? result.data : null;
-  const roles = rolesResult.success && rolesResult.data ? rolesResult.data : [];
-  const pendingInvitations = invitationsResult.success && invitationsResult.data ? invitationsResult.data : [];
+    getTechnicianUserIds(),
+  ])
+  const orgData = result.success ? result.data : null
+  const roles = rolesResult.success && rolesResult.data ? rolesResult.data : []
+  const pendingInvitations =
+    invitationsResult.success && invitationsResult.data ? invitationsResult.data : []
+  const technicianUserIds =
+    technicianResult.success && technicianResult.data ? technicianResult.data : []
 
   return (
     <TeamSettings
@@ -19,6 +24,7 @@ export default async function TeamPage() {
       currentRole={orgData?.currentRole || null}
       roles={roles}
       pendingInvitations={pendingInvitations}
+      technicianUserIds={technicianUserIds}
     />
-  );
+  )
 }

+ 54 - 0
src/app/(authenticated)/settings/team/team-settings.tsx

@@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label'
 import { AppCard } from '@/components/app-card'
 import { Badge } from '@/components/ui/badge'
 import { Checkbox } from '@/components/ui/checkbox'
+import { Switch } from '@/components/ui/switch'
 import {
   Select,
   SelectContent,
@@ -27,6 +28,7 @@ import { createRole } from '@/features/team/Actions/createRole'
 import { updateRole } from '@/features/team/Actions/updateRole'
 import { deleteRole } from '@/features/team/Actions/deleteRole'
 import { assignRole } from '@/features/team/Actions/assignRole'
+import { setMemberTechnician } from '@/features/team/Actions/setMemberTechnician'
 import { permissionGroups, PermissionAction } from '@/lib/permissions'
 
 /**
@@ -108,11 +110,14 @@ export function TeamSettings({
   organization,
   currentRole,
   roles = [],
+  technicianUserIds = [],
   pendingInvitations = [],
 }: {
   organization: Organization | null
   currentRole: string | null
   roles?: RoleData[]
+  /** User ids that already have an active technician record. */
+  technicianUserIds?: string[]
   pendingInvitations?: PendingInvitation[]
 }) {
   const router = useRouter()
@@ -129,6 +134,35 @@ export function TeamSettings({
   // Role form state
   const [showRoleForm, setShowRoleForm] = useState(false)
   const [editingRole, setEditingRole] = useState<RoleData | null>(null)
+  // Tracked locally so the switch answers the tap immediately. A revalidate
+  // round trip is a long time to sit on a toggle that has already moved.
+  const [technicians, setTechnicians] = useState<Set<string>>(() => new Set(technicianUserIds))
+  const [technicianBusy, setTechnicianBusy] = useState<string | null>(null)
+
+  const handleTechnicianToggle = async (userId: string, enabled: boolean) => {
+    setTechnicianBusy(userId)
+    setTechnicians((prev) => {
+      const next = new Set(prev)
+      if (enabled) next.add(userId)
+      else next.delete(userId)
+      return next
+    })
+    const result = await setMemberTechnician({ userId, enabled })
+    if (!result.success) {
+      // Put it back where it was; the server is the one that decides.
+      setTechnicians((prev) => {
+        const next = new Set(prev)
+        if (enabled) next.delete(userId)
+        else next.add(userId)
+        return next
+      })
+      toast.error(result.error || t('team.technicianFailed'))
+    } else {
+      router.refresh()
+    }
+    setTechnicianBusy(null)
+  }
+
   const [roleName, setRoleName] = useState('')
   const [roleIsAdmin, setRoleIsAdmin] = useState(false)
   const [selectedPermissions, setSelectedPermissions] = useState<Set<string>>(new Set())
@@ -407,6 +441,26 @@ export function TeamSettings({
                 <p className="truncate text-xs text-muted-foreground">{member.user.email}</p>
               </div>
               <div className="flex items-center gap-2">
+                {/* The other place this lives is the work board's technician
+                    dialog, which also carries colour, capacity and technicians
+                    with no login. This is the yes-or-no version, on the screen
+                    where someone adds the person in the first place. */}
+                {isAdmin && (
+                  <label className="flex cursor-pointer items-center gap-2 pr-1">
+                    <Switch
+                      checked={technicians.has(member.user.id)}
+                      disabled={technicianBusy === member.user.id}
+                      onCheckedChange={(v) => handleTechnicianToggle(member.user.id, v)}
+                      aria-label={t('team.technician')}
+                    />
+                    <span
+                      className="hidden text-muted-foreground text-xs sm:inline"
+                      title={t('team.technicianHint')}
+                    >
+                      {t('team.technician')}
+                    </span>
+                  </label>
+                )}
                 {isOwner && member.role !== 'owner' ? (
                   <Select
                     value={member.roleId || member.role}

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

@@ -133,6 +133,7 @@ export async function POST(request: NextRequest) {
                 attachments: true,
                 payments: true,
                 statusReports: true,
+                timeEntries: true,
               },
             },
           },
@@ -167,6 +168,7 @@ export async function POST(request: NextRequest) {
             laborItems: true,
             attachments: true,
             payments: true,
+            timeEntries: true,
           },
         })
         .then((result) => {

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

@@ -67,6 +67,8 @@ async function importServiceRecordTree(
     vehicleId: string | null
     customerId: string | null
     workDayStartTime: string
+    /** Technicians restored by this import. See the time entries below. */
+    technicianIds: ReadonlySet<string>
   }
 ) {
   // Derive startDateTime/endDateTime from backup or fall back to serviceDate + work day start
@@ -195,6 +197,28 @@ async function importServiceRecordTree(
     }
   )
 
+  // Clocked time. This is what a technician's hours were billed from, and in
+  // Germany it is a statutory record, so losing it on a restore is not the
+  // same as losing a cached total.
+  //
+  // Each entry needs its technician, and technicianId is not nullable. A
+  // restore that took vehicles but not technicians has nowhere to attach
+  // them, so those are dropped rather than failing the whole import: the
+  // alternative is a foreign key error that rolls back everything and tells
+  // the user nothing.
+  const timeEntries = (sr.timeEntries as Record<string, unknown>[] | undefined)?.filter((entry) =>
+    opts.technicianIds.has(entry.technicianId as string)
+  )
+  await restoreRows(
+    'time entries',
+    (rows) => tx.timeEntry.createMany({ data: rows as never }),
+    timeEntries,
+    {
+      organizationId: opts.organizationId,
+      serviceRecordId: sr.id as string,
+    }
+  )
+
   // Payments
   const payments = sr.payments as Record<string, unknown>[] | undefined
   if (payments?.length) {
@@ -438,6 +462,19 @@ export async function POST(request: NextRequest) {
         })
       }
 
+      // Which technicians this restore actually has, for the time entries
+      // nested inside each service record. Read back rather than taken from
+      // the file, so an older backup whose technicians were skipped does not
+      // claim rows that were never inserted.
+      const technicianIds = new Set(
+        (
+          await tx.technician.findMany({
+            where: { organizationId: ctx.organizationId },
+            select: { id: true },
+          })
+        ).map((t) => t.id)
+      )
+
       // 4b. Insert work bays. Service records and inspections point at them, so
       // they have to exist before either is restored.
       if (data.workBays?.length) {
@@ -634,6 +671,7 @@ export async function POST(request: NextRequest) {
                 vehicleId: v.id as string,
                 customerId: null,
                 workDayStartTime,
+                technicianIds,
               })
             }
           }
@@ -707,6 +745,7 @@ export async function POST(request: NextRequest) {
             vehicleId: null,
             customerId: (sr.customerId as string) || null,
             workDayStartTime,
+            technicianIds,
           })
         }
       }

+ 72 - 0
src/app/api/v1/tech/devices/route.ts

@@ -0,0 +1,72 @@
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { apiOk, withApiAuth } from '@/lib/with-api-auth'
+
+/**
+ * Registers this phone to receive push, or forgets it.
+ *
+ * Called on every launch, not just the first: Expo reissues tokens after a
+ * reinstall, an OS update, or a restore onto a new handset, and a workshop
+ * whose notifications silently stopped is worse off than one that never had
+ * them, because nobody goes looking for a thing they believe is working.
+ *
+ * Registration is an upsert keyed on the token. If the same phone is later
+ * signed into by a different technician, the row moves to them: the device is
+ * the thing being addressed, and the previous user must stop receiving another
+ * person's jobs on a handset they no longer hold.
+ */
+
+const registerSchema = z.object({
+  token: z.string().min(1).max(256),
+  platform: z.enum(['ios', 'android']),
+})
+
+export async function POST(request: Request) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { token, platform } = registerSchema.parse(await request.json())
+
+      await db.pushDevice.upsert({
+        where: { token },
+        create: {
+          token,
+          platform,
+          userId: ctx.userId,
+          organizationId: ctx.organizationId,
+        },
+        update: {
+          // Reassigns the device wholesale on a change of hands.
+          userId: ctx.userId,
+          organizationId: ctx.organizationId,
+          platform,
+          isActive: true,
+          lastSeenAt: new Date(),
+        },
+      })
+
+      return apiOk({ registered: true })
+    },
+    { requireTechnician: true, rateLimit: { limit: 30, windowMs: 60_000 } }
+  )
+}
+
+const forgetSchema = z.object({ token: z.string().min(1).max(256) })
+
+/**
+ * Signing out has to stop the notifications too.
+ *
+ * Scoped to the caller's own rows, so a token cannot be used to unregister
+ * somebody else's device by guessing it.
+ */
+export async function DELETE(request: Request) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { token } = forgetSchema.parse(await request.json())
+      await db.pushDevice.deleteMany({ where: { token, userId: ctx.userId } })
+      return apiOk({ forgotten: true })
+    },
+    { rateLimit: { limit: 30, windowMs: 60_000 } }
+  )
+}

+ 27 - 0
src/app/api/v1/tech/health/route.ts

@@ -0,0 +1,27 @@
+import { NextResponse } from 'next/server'
+import { rateLimit } from '@/lib/rate-limit'
+import { MIN_APP_VERSION } from '@/lib/tech-app-version'
+
+/**
+ * Confirms a URL is a Torqvoice server, for the app's first screen.
+ *
+ * Unauthenticated by necessity: the technician types a workshop address
+ * before they have any credentials for it. That makes this the one endpoint
+ * an anonymous caller can reach, so it says nothing about the workshop
+ * running here. No name, no organization count, no version of anything that
+ * would help someone fingerprint the host. Only "yes, this is the right kind
+ * of server, and it speaks v1".
+ */
+export async function GET(request: Request) {
+  const limited = rateLimit(request, { limit: 20, windowMs: 60_000 })
+  if (limited) return limited
+
+  return NextResponse.json({
+    data: {
+      service: 'torqvoice',
+      api: 'v1',
+      /** Bumped when the app must update before it can talk to this server. */
+      minAppVersion: MIN_APP_VERSION,
+    },
+  })
+}

+ 66 - 0
src/app/api/v1/tech/jobs/[id]/attachments/[attachmentId]/route.ts

@@ -0,0 +1,66 @@
+import { unlink } from 'node:fs/promises'
+import path from 'node:path'
+import { db } from '@/lib/db'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+
+/**
+ * Removes a photo from a job.
+ *
+ * A technician photographing a dark wheel arch takes three before one is
+ * usable, and without this the other two stay on the job forever and end up
+ * in front of the customer. Being able to take a photo and not being able to
+ * remove it is a worse position than not taking one.
+ *
+ * The row goes first and the file second. A row without its file renders as a
+ * broken image the technician can at least delete again; a file without its
+ * row is invisible and stays on disk forever.
+ */
+export async function DELETE(
+  request: Request,
+  { params }: { params: Promise<{ id: string; attachmentId: string }> }
+) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id, attachmentId } = await params
+
+      // Scoped through the job, so an id from another workshop finds nothing
+      // rather than deleting somebody else's evidence.
+      const attachment = await db.serviceAttachment.findFirst({
+        where: {
+          id: attachmentId,
+          serviceRecord: {
+            id,
+            organizationId: ctx.organizationId,
+            ...(ctx.isAdmin ? {} : { technicianId: { in: ctx.technicianIds } }),
+          },
+        },
+        select: { id: true, fileUrl: true },
+      })
+      if (!attachment) return apiError(404, 'not_found', 'That photo is not on this job.')
+
+      await db.serviceAttachment.delete({ where: { id: attachment.id } })
+
+      // Best effort. The photo is already gone as far as anyone can tell, and
+      // failing the request over a file that could not be unlinked would leave
+      // the technician retrying a deletion that already happened.
+      const filename = attachment.fileUrl.split('/').pop()
+      if (filename && !filename.includes('..') && !filename.includes('/')) {
+        await unlink(
+          path.join(process.cwd(), 'data', 'uploads', ctx.organizationId, 'services', filename)
+        ).catch(() => {
+          /* already gone, or never written */
+        })
+      }
+
+      return apiOk({ deleted: true })
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    }
+  )
+}

+ 145 - 0
src/app/api/v1/tech/jobs/[id]/attachments/route.ts

@@ -0,0 +1,145 @@
+import { mkdir, stat, writeFile } from 'node:fs/promises'
+import crypto from 'node:crypto'
+import path from 'node:path'
+import { db } from '@/lib/db'
+import { getFeatures, type PlanFeatures } from '@/lib/features'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+
+/**
+ * Attaches a photo or video shot in the bay to a job.
+ *
+ * One request, not the web's two. The browser uploads a file, gets a URL back
+ * and then posts that URL as an attachment, which is fine on a desk and wrong
+ * on a phone: a technician on workshop wifi who loses signal between the two
+ * calls leaves a file on disk that no job points at, and has no way to know.
+ * Here the row and the bytes commit together or neither does.
+ */
+
+/**
+ * Deliberately narrower than the web's list. This endpoint exists for a camera,
+ * so it accepts what a camera produces. PDFs, spreadsheets and text files have
+ * no business arriving from a phone in a bay, and every format accepted is a
+ * parser somewhere that has to be right.
+ */
+const ALLOWED = new Map<string, string>([
+  ['image/jpeg', 'jpg'],
+  ['image/png', 'png'],
+  ['image/webp', 'webp'],
+  ['image/heic', 'heic'],
+  ['video/mp4', 'mp4'],
+  ['video/quicktime', 'mov'],
+])
+
+/**
+ * Smaller than the web's 500MB. A phone on shop wifi uploading half a gigabyte
+ * is a request that will not finish, and a limit that cannot be met in the
+ * field is not a limit, it is a timeout with extra steps.
+ */
+const MAX_BYTES = 60 * 1024 * 1024
+
+const CATEGORY_LIMIT: Record<string, keyof PlanFeatures | undefined> = {
+  image: 'maxImagesPerService',
+  diagnostic: 'maxDiagnosticsPerService',
+  document: 'maxDocumentsPerService',
+}
+
+export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id } = await params
+
+      const job = await db.serviceRecord.findFirst({
+        where: {
+          id,
+          organizationId: ctx.organizationId,
+          ...(ctx.isAdmin ? {} : { technicianId: { in: ctx.technicianIds } }),
+        },
+        select: { id: true },
+      })
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+
+      const form = await request.formData()
+      const file = form.get('file')
+      if (!(file instanceof File)) {
+        return apiError(400, 'invalid_request', 'No file was attached.')
+      }
+
+      const ext = ALLOWED.get(file.type)
+      if (!ext) {
+        return apiError(400, 'invalid_request', 'That file type cannot be attached from the app.')
+      }
+      if (file.size === 0) {
+        return apiError(400, 'invalid_request', 'That file is empty.')
+      }
+      if (file.size > MAX_BYTES) {
+        return apiError(400, 'invalid_request', 'That file is too large. Keep it under 60 MB.')
+      }
+
+      const category = file.type.startsWith('video/') ? 'video' : 'image'
+      const description = (form.get('description') as string | null)?.slice(0, 500) || undefined
+
+      const limitKey = CATEGORY_LIMIT[category]
+      if (limitKey) {
+        const features = await getFeatures(ctx.organizationId)
+        const max = features[limitKey] as number
+        const used = await db.serviceAttachment.count({
+          where: { serviceRecordId: job.id, category },
+        })
+        if (used >= max) {
+          return apiError(
+            409,
+            'conflict',
+            `This job already has the maximum of ${max} ${category === 'video' ? 'videos' : 'photos'}.`
+          )
+        }
+      }
+
+      // The filename is generated, never taken from the client. A name that
+      // arrives over the wire is an attacker-controlled path, and the only
+      // safe thing to do with one is not use it.
+      const filename = `${crypto.randomUUID()}.${ext}`
+      const dir = path.join(process.cwd(), 'data', 'uploads', ctx.organizationId, 'services')
+      await mkdir(dir, { recursive: true })
+      const target = path.join(dir, filename)
+
+      await writeFile(target, new Uint8Array(await file.arrayBuffer()))
+      const written = await stat(target)
+
+      const attachment = await db.serviceAttachment.create({
+        data: {
+          // The original name is kept as a label only, and is never part of a
+          // path. Trimmed because a phone can produce a very long one.
+          fileName: (file.name || filename).slice(0, 200),
+          fileUrl: `/api/protected/files/${ctx.organizationId}/services/${filename}`,
+          fileType: file.type,
+          fileSize: written.size,
+          category,
+          description,
+          serviceRecordId: job.id,
+        },
+        select: {
+          id: true,
+          fileName: true,
+          fileUrl: true,
+          fileType: true,
+          fileSize: true,
+          category: true,
+          createdAt: true,
+        },
+      })
+
+      return apiOk({ attachment }, 201)
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+      // Uploads are slower and heavier than reads, and a runaway retry loop
+      // here fills a disk rather than just burning CPU.
+      rateLimit: { limit: 30, windowMs: 60_000 },
+    }
+  )
+}

+ 78 - 0
src/app/api/v1/tech/jobs/[id]/findings/route.ts

@@ -0,0 +1,78 @@
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+
+/**
+ * Records something the technician noticed that is not part of this job.
+ *
+ * A worn belt spotted while doing a clutch is the most perishable information
+ * in a workshop: it is known for about ten seconds, by one person, with their
+ * hands full. Anything that makes recording it slower than forgetting it means
+ * it gets forgotten, which is why this takes a sentence and nothing else.
+ *
+ * The finding hangs off the vehicle, not the job, because it outlives the job.
+ * `serviceRecordId` records where it was spotted so the history reads properly
+ * later.
+ */
+const bodySchema = z.object({
+  description: z.string().min(1).max(1000),
+  severity: z.enum(['monitor', 'needs_work', 'urgent']).default('needs_work'),
+  notes: z.string().max(2000).optional(),
+})
+
+export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id } = await params
+      const body = bodySchema.parse(await request.json())
+
+      const job = await db.serviceRecord.findFirst({
+        where: {
+          id,
+          organizationId: ctx.organizationId,
+          ...(ctx.isAdmin ? {} : { technicianId: { in: ctx.technicianIds } }),
+        },
+        select: { id: true, vehicleId: true },
+      })
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+
+      // A counter sale has no vehicle, and a finding with nothing to hang off
+      // would be an orphan nobody ever sees again.
+      if (!job.vehicleId) {
+        return apiError(
+          409,
+          'conflict',
+          'This job has no vehicle to record an observation against.'
+        )
+      }
+
+      const finding = await db.vehicleFinding.create({
+        data: {
+          description: body.description,
+          severity: body.severity,
+          notes: body.notes,
+          vehicleId: job.vehicleId,
+          serviceRecordId: job.id,
+        },
+        select: {
+          id: true,
+          description: true,
+          severity: true,
+          status: true,
+          notes: true,
+          createdAt: true,
+        },
+      })
+
+      return apiOk({ finding }, 201)
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    }
+  )
+}

+ 114 - 0
src/app/api/v1/tech/jobs/[id]/labor/route.ts

@@ -0,0 +1,114 @@
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { calculateTotals } from '@/lib/tax'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { roundMoney } from '@/features/inventory/Lib/partPricing'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+/**
+ * Adds a line of work to the job.
+ *
+ * The other half of what a technician has to record. Parts could already be
+ * booked from the bay; the labour that fitted them could not, so a job came
+ * back to the office with the components and no account of the work.
+ *
+ * The rate comes from the workshop's settings, never from the client. A phone
+ * naming its own hourly rate would put the shop's pricing on the far side of a
+ * network boundary, where a modified client could change it.
+ */
+const bodySchema = z.object({
+  /**
+   * Optional, because forcing a technician to describe work the job already
+   * names is a keyboard between them and clocking off. Falls back to the job's
+   * own title below, so the line still reads as something on an invoice.
+   */
+  description: z.string().max(500).optional(),
+  hours: z.number().positive().max(1000),
+})
+
+export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id } = await params
+      const { description, hours } = bodySchema.parse(await request.json())
+
+      const job = await db.serviceRecord.findFirst({
+        where: {
+          id,
+          organizationId: ctx.organizationId,
+          ...(ctx.isAdmin ? {} : { technicianId: { in: ctx.technicianIds } }),
+        },
+        select: {
+          id: true,
+          title: true,
+          taxRate: true,
+          taxInclusive: true,
+          discountType: true,
+          discountValue: true,
+        },
+      })
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+
+      const rateSetting = await db.appSetting.findFirst({
+        where: { organizationId: ctx.organizationId, key: SETTING_KEYS.DEFAULT_LABOR_RATE },
+        select: { value: true },
+      })
+      const rate = Number(rateSetting?.value ?? 0)
+      // A shop that has never set a rate gets a line at zero rather than a
+      // refusal. The work still happened, and the office can price it.
+      const safeRate = Number.isFinite(rate) && rate > 0 ? rate : 0
+
+      const line = await db.$transaction(async (tx) => {
+        const created = await tx.serviceLabor.create({
+          data: {
+            description: description?.trim() || job.title,
+            hours,
+            rate: safeRate,
+            total: roundMoney(safeRate * hours),
+            pricingType: 'hourly',
+            serviceRecordId: job.id,
+          },
+          select: { id: true, description: true, hours: true, rate: true, total: true },
+        })
+
+        // Same recalculation the parts path does, in the same transaction, so
+        // the job's totals can never disagree with its lines.
+        const [partsAgg, laborAgg] = await Promise.all([
+          tx.servicePart.aggregate({ where: { serviceRecordId: job.id }, _sum: { total: true } }),
+          tx.serviceLabor.aggregate({ where: { serviceRecordId: job.id }, _sum: { total: true } }),
+        ])
+
+        const subtotal = (partsAgg._sum.total || 0) + (laborAgg._sum.total || 0)
+        const discountAmount =
+          job.discountType === 'percentage'
+            ? subtotal * ((job.discountValue ?? 0) / 100)
+            : job.discountType === 'fixed'
+              ? Math.min(job.discountValue ?? 0, subtotal)
+              : 0
+        const { taxAmount, totalAmount } = calculateTotals({
+          subtotal,
+          discountAmount,
+          taxRate: job.taxRate,
+          taxInclusive: job.taxInclusive,
+        })
+
+        await tx.serviceRecord.update({
+          where: { id: job.id },
+          data: { subtotal, taxAmount, totalAmount },
+        })
+
+        return created
+      })
+
+      return apiOk({ labor: line }, 201)
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    }
+  )
+}

+ 83 - 0
src/app/api/v1/tech/jobs/[id]/notes/route.ts

@@ -0,0 +1,83 @@
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+
+/**
+ * Appends to the job's internal notes.
+ *
+ * Appends rather than replaces, deliberately. These notes are shared: the
+ * office writes in them too, and a phone that sends the whole field back would
+ * silently overwrite whatever was added while the technician had the screen
+ * open. Losing a colleague's note is worse than an untidy one.
+ *
+ * `diagnosticNotes` is the internal field, not `invoiceNotes`, because a
+ * technician writing "customer says the noise only happens when cold" is
+ * recording something for the shop. Whether any of it reaches the customer is
+ * the office's decision, made on the web.
+ */
+const bodySchema = z.object({
+  note: z.string().min(1).max(2000),
+})
+
+export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id } = await params
+      const { note } = bodySchema.parse(await request.json())
+
+      const job = await db.serviceRecord.findFirst({
+        where: {
+          id,
+          organizationId: ctx.organizationId,
+          ...(ctx.isAdmin ? {} : { technicianId: { in: ctx.technicianIds } }),
+        },
+        select: { id: true, diagnosticNotes: true },
+      })
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+
+      const technician = await db.technician.findFirst({
+        where: { id: { in: ctx.technicianIds } },
+        select: { name: true },
+      })
+
+      // Stamped, because a shared field with several authors and no
+      // attribution becomes unreadable within a week. The web editor stores
+      // HTML, so this matches rather than injecting bare newlines into it.
+      const stamp = new Date().toLocaleDateString('en-GB', {
+        day: 'numeric',
+        month: 'short',
+      })
+      const line = `<p><strong>${technician?.name ?? 'Technician'}, ${stamp}:</strong> ${escapeHtml(note.trim())}</p>`
+
+      const updated = await db.serviceRecord.update({
+        where: { id: job.id },
+        data: { diagnosticNotes: job.diagnosticNotes ? `${job.diagnosticNotes}${line}` : line },
+        select: { id: true, diagnosticNotes: true },
+      })
+
+      return apiOk({ diagnosticNotes: updated.diagnosticNotes }, 201)
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    }
+  )
+}
+
+/**
+ * The note is typed by a person and stored in a field the web renders as HTML,
+ * so it has to be escaped on the way in. Otherwise a technician writing
+ * "pads < 2mm" produces markup nobody can see and a bug nobody can explain.
+ */
+function escapeHtml(value: string): string {
+  return value
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;')
+}

+ 142 - 0
src/app/api/v1/tech/jobs/[id]/parts/route.ts

@@ -0,0 +1,142 @@
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { addPart, AddPartError } from '@/features/vehicles/Lib/addPart'
+import { onInventoryChanged } from '@/features/inventory/Lib/onInventoryChanged'
+import {
+  readPartsPricingSettings,
+  resolvePartPrice,
+  roundMoney,
+} from '@/features/inventory/Lib/partPricing'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+/**
+ * Books a part onto a job from the bay.
+ *
+ * The client sends what it scanned and how many, never a price. Letting a
+ * phone name the money would put the shop's margin on the far side of a
+ * network boundary, where a modified client could change it. Pricing is read
+ * from the stock record here.
+ *
+ * A free-text line (no inventoryPartId) is allowed for a part that is not in
+ * stock, because refusing would send the technician back to a desk. It books
+ * at zero and the office prices it later.
+ */
+const bodySchema = z.object({
+  inventoryPartId: z.string().optional(),
+  /** Only used when no stock part is named. */
+  name: z.string().min(1).max(200).optional(),
+  partNumber: z.string().max(100).optional(),
+  quantity: z.number().positive().max(10_000),
+})
+
+export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id } = await params
+      const body = bodySchema.parse(await request.json())
+
+      const job = await db.serviceRecord.findFirst({
+        where: {
+          id,
+          organizationId: ctx.organizationId,
+          ...(ctx.isAdmin ? {} : { technicianId: { in: ctx.technicianIds } }),
+        },
+        select: { id: true },
+      })
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+
+      let line: Parameters<typeof addPart>[0]['input']
+
+      if (body.inventoryPartId) {
+        const stock = await db.inventoryPart.findFirst({
+          where: {
+            id: body.inventoryPartId,
+            organizationId: ctx.organizationId,
+            isArchived: false,
+          },
+          select: {
+            id: true,
+            name: true,
+            partNumber: true,
+            unit: true,
+            unitCost: true,
+            sellPrice: true,
+          },
+        })
+        if (!stock) return apiError(404, 'not_found', 'That part is no longer in stock.')
+
+        // Same pricing rules the web uses, so a part booked from a phone and
+        // one booked from a desk cost the customer the same. The workshop may
+        // price from cost plus a house markup rather than the part's own sell
+        // price, and only the settings know which.
+        const settingRows = await db.appSetting.findMany({
+          where: {
+            organizationId: ctx.organizationId,
+            key: {
+              in: [
+                SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT,
+                SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY,
+              ],
+            },
+          },
+          select: { key: true, value: true },
+        })
+        const pricing = readPartsPricingSettings(
+          Object.fromEntries(settingRows.map((r) => [r.key, r.value ?? undefined])),
+          {
+            defaultMarkupPercent: SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT,
+            markupAppliesToInventory: SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY,
+          }
+        )
+        const { unitPrice } = resolvePartPrice(stock, pricing)
+
+        line = {
+          serviceRecordId: job.id,
+          inventoryPartId: stock.id,
+          name: stock.name,
+          partNumber: stock.partNumber,
+          unit: stock.unit,
+          quantity: body.quantity,
+          unitPrice,
+          unitCost: stock.unitCost,
+          total: roundMoney(unitPrice * body.quantity),
+        }
+      } else {
+        if (!body.name) {
+          return apiError(400, 'invalid_request', 'Name a part, or scan one from stock.')
+        }
+        line = {
+          serviceRecordId: job.id,
+          name: body.name,
+          partNumber: body.partNumber,
+          quantity: body.quantity,
+          unitPrice: 0,
+          unitCost: 0,
+          total: 0,
+        }
+      }
+
+      try {
+        const { part } = await addPart({
+          organizationId: ctx.organizationId,
+          userId: ctx.userId,
+          input: line,
+        })
+        if (line.inventoryPartId) await onInventoryChanged(ctx.organizationId)
+        return apiOk({ part }, 201)
+      } catch (err) {
+        if (err instanceof AddPartError) return apiError(404, 'not_found', err.message)
+        throw err
+      }
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    }
+  )
+}

+ 94 - 0
src/app/api/v1/tech/jobs/[id]/route.ts

@@ -0,0 +1,94 @@
+import { db } from '@/lib/db'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { getOpenEntry } from '@/features/time-tracking/Lib/timeEntries'
+
+/**
+ * One job, with everything the technician needs while standing at the car.
+ *
+ * Scoped to the organization, and then to the technician's own rows unless
+ * they are an admin. A technician who guesses another job's id gets a 404
+ * rather than a 403: whether a job exists in someone else's bay is not
+ * information this endpoint should confirm.
+ */
+export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id } = await params
+
+      const job = await db.serviceRecord.findFirst({
+        where: {
+          id,
+          organizationId: ctx.organizationId,
+          ...(ctx.isAdmin ? {} : { technicianId: { in: ctx.technicianIds } }),
+        },
+        select: {
+          id: true,
+          title: true,
+          description: true,
+          status: true,
+          diagnosticNotes: true,
+          mileage: true,
+          startDateTime: true,
+          endDateTime: true,
+          vehicle: {
+            select: {
+              id: true,
+              make: true,
+              model: true,
+              year: true,
+              licensePlate: true,
+              vin: true,
+              mileage: true,
+              customer: { select: { id: true, name: true, phone: true, email: true } },
+            },
+          },
+          customer: { select: { id: true, name: true, phone: true, email: true } },
+          partItems: {
+            select: { id: true, name: true, quantity: true, unit: true, partNumber: true },
+            orderBy: { id: 'asc' },
+          },
+          laborItems: {
+            select: { id: true, description: true, hours: true },
+            orderBy: { id: 'asc' },
+          },
+          attachments: {
+            select: {
+              id: true,
+              category: true,
+              fileUrl: true,
+              fileName: true,
+              fileType: true,
+              createdAt: true,
+            },
+            orderBy: { createdAt: 'desc' },
+            take: 30,
+          },
+          timeEntries: {
+            select: { id: true, startedAt: true, endedAt: true, durationMinutes: true, note: true },
+            orderBy: { startedAt: 'desc' },
+            take: 20,
+          },
+        },
+      })
+
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+
+      const openEntry = await getOpenEntry(ctx.organizationId, ctx.technicianIds)
+      const totalMinutes = job.timeEntries.reduce((sum, e) => sum + (e.durationMinutes ?? 0), 0)
+
+      return apiOk({
+        ...job,
+        customer: job.customer ?? job.vehicle?.customer ?? null,
+        isRunning: openEntry?.serviceRecordId === job.id,
+        runningSince: openEntry?.serviceRecordId === job.id ? openEntry.startedAt : null,
+        totalMinutes,
+      })
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SERVICES }],
+    }
+  )
+}

+ 185 - 0
src/app/api/v1/tech/jobs/[id]/status-report/route.ts

@@ -0,0 +1,185 @@
+import { randomBytes } from 'node:crypto'
+import { mkdir, stat, writeFile } from 'node:fs/promises'
+import crypto from 'node:crypto'
+import path from 'node:path'
+import { db } from '@/lib/db'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { sendStatusReport } from '@/features/status-reports/Actions/sendStatusReport'
+
+/**
+ * A short update for the customer, recorded standing at the car.
+ *
+ * This is the feature a workshop sells on: a sixty-second video of the actual
+ * worn part beats any amount of written explanation, and the only moment it
+ * can be filmed is while the technician is looking at it. So the video and the
+ * report are one request, for the same reason attachments are.
+ *
+ * Text-only is allowed and stays a draft, matching the web: a report with
+ * nothing to show is something the office finishes, not something a customer
+ * should receive as-is.
+ *
+ * Sending is opt-in. The technician has to ask for it explicitly, because a
+ * message to a customer cannot be recalled and the default for an
+ * irreversible outward-facing action should never be "yes". With the box
+ * unticked the report waits for the office, which is the common case.
+ *
+ * Channels are not the technician's decision either. Asking someone in a bay
+ * to choose between SMS, email and Telegram is a question they have no basis
+ * to answer; the report goes out on whatever the customer actually has on
+ * file.
+ */
+
+const ALLOWED_VIDEO = new Map<string, string>([
+  ['video/mp4', 'mp4'],
+  ['video/quicktime', 'mov'],
+])
+
+/** Roughly two minutes of phone video. Longer than that is not a status update. */
+const MAX_BYTES = 120 * 1024 * 1024
+
+/** Matches the web's default: a link that outlives the repair by a fortnight. */
+const DEFAULT_TTL_MS = 14 * 24 * 60 * 60 * 1000
+
+export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id } = await params
+
+      const job = await db.serviceRecord.findFirst({
+        where: {
+          id,
+          organizationId: ctx.organizationId,
+          ...(ctx.isAdmin ? {} : { technicianId: { in: ctx.technicianIds } }),
+        },
+        select: { id: true, technicianId: true },
+      })
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+
+      const form = await request.formData()
+      const title = (form.get('title') as string | null)?.slice(0, 200) || undefined
+      const message = (form.get('message') as string | null)?.slice(0, 4000) || undefined
+      // Named 'file' like every other upload, so the client has one shape.
+      const video = form.get('file')
+
+      let videoUrl: string | undefined
+      let videoFileName: string | undefined
+
+      if (video instanceof File && video.size > 0) {
+        const ext = ALLOWED_VIDEO.get(video.type)
+        if (!ext) {
+          return apiError(400, 'invalid_request', 'That video format cannot be uploaded.')
+        }
+        if (video.size > MAX_BYTES) {
+          return apiError(
+            400,
+            'invalid_request',
+            'That video is too long. Keep it under two minutes.'
+          )
+        }
+
+        // Generated name, never the client's. See the attachments route.
+        const filename = `${crypto.randomUUID()}.${ext}`
+        const dir = path.join(process.cwd(), 'data', 'uploads', ctx.organizationId, 'services')
+        await mkdir(dir, { recursive: true })
+        const target = path.join(dir, filename)
+        await writeFile(target, new Uint8Array(await video.arrayBuffer()))
+        await stat(target)
+
+        videoUrl = `/api/protected/files/${ctx.organizationId}/services/${filename}`
+        videoFileName = (video.name || filename).slice(0, 200)
+      }
+
+      if (!title && !message && !videoUrl) {
+        return apiError(400, 'invalid_request', 'Add a message or a video before sending.')
+      }
+
+      const wantsSend = form.get('send') === 'true'
+
+      const report = await db.statusReport.create({
+        data: {
+          // 32 bytes of CSPRNG output. This token is the only thing standing
+          // between a public URL and a customer's repair details.
+          publicToken: randomBytes(32).toString('hex'),
+          title,
+          message,
+          videoUrl,
+          videoFileName,
+          serviceRecordId: job.id,
+          organizationId: ctx.organizationId,
+          technicianId: ctx.technicianIds[0] ?? job.technicianId,
+          status: videoUrl ? 'published' : 'draft',
+          expiresAt: new Date(Date.now() + DEFAULT_TTL_MS),
+        },
+        select: {
+          id: true,
+          title: true,
+          message: true,
+          videoUrl: true,
+          status: true,
+          createdAt: true,
+        },
+      })
+
+      if (!wantsSend) {
+        return apiOk({ report, sent: null }, 201)
+      }
+
+      // Whatever the customer can actually be reached on. Requesting a channel
+      // with no address on file is counted as a failure by the sender, which
+      // would report a partial failure for something nobody asked for.
+      const customer = await db.serviceRecord
+        .findFirst({
+          where: { id: job.id },
+          select: {
+            customer: { select: { email: true, phone: true, telegramChatId: true } },
+            vehicle: {
+              select: { customer: { select: { email: true, phone: true, telegramChatId: true } } },
+            },
+          },
+        })
+        .then((r) => r?.customer ?? r?.vehicle?.customer ?? null)
+
+      const channels = {
+        email: Boolean(customer?.email),
+        sms: Boolean(customer?.phone),
+        telegram: Boolean(customer?.telegramChatId),
+      }
+
+      if (!channels.email && !channels.sms && !channels.telegram) {
+        // The report is saved either way. Losing the recording because there
+        // was nowhere to send it would be the worse outcome by far.
+        return apiOk(
+          {
+            report,
+            sent: { ok: false, reason: 'This customer has no phone, email or Telegram on file.' },
+          },
+          201
+        )
+      }
+
+      // `sendStatusReport` authenticates the same way this route does: withAuth
+      // resolves the session from request headers, and the bearer plugin reads
+      // the Authorization header the app already sent.
+      const result = await sendStatusReport({ statusReportId: report.id, channels })
+
+      return apiOk(
+        {
+          report,
+          sent: result.success
+            ? { ok: true, channels: result.data?.channels ?? [] }
+            : { ok: false, reason: result.error ?? 'Could not send it.' },
+        },
+        201
+      )
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+      rateLimit: { limit: 20, windowMs: 60_000 },
+    }
+  )
+}

+ 156 - 0
src/app/api/v1/tech/jobs/[id]/status/route.ts

@@ -0,0 +1,156 @@
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { notificationBus } from '@/lib/notification-bus'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { getOpenEntry, stopEntry } from '@/features/time-tracking/Lib/timeEntries'
+import { notify } from '@/lib/notify'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+/**
+ * Moves a job along.
+ *
+ * Without this the app can start work but never finish it: the job list shows
+ * pending, in-progress and waiting-parts, and a job the technician has
+ * completed sits on their phone forever because nothing can take it off.
+ *
+ * `completed` is deliberately reachable from here, and nothing beyond it is.
+ * Invoicing, pricing and everything downstream stay with the office; a
+ * technician saying "I have finished" is a statement about the work, not a
+ * decision to bill for it.
+ */
+const bodySchema = z.object({
+  status: z.enum(['pending', 'in-progress', 'waiting-parts', 'completed']),
+})
+
+export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id } = await params
+      const { status } = bodySchema.parse(await request.json())
+
+      const job = await db.serviceRecord.findFirst({
+        where: {
+          id,
+          organizationId: ctx.organizationId,
+          ...(ctx.isAdmin ? {} : { technicianId: { in: ctx.technicianIds } }),
+        },
+        select: {
+          id: true,
+          status: true,
+          title: true,
+          vehicleId: true,
+          vehicle: { select: { licensePlate: true } },
+        },
+      })
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+
+      // Finishing a job stops the clock on it. A technician who has moved on
+      // is not going to remember, and a clock left running past the end of the
+      // work is the exact error the whole feature exists to prevent.
+      let stoppedMinutes: number | null = null
+      if (status === 'completed' || status === 'waiting-parts') {
+        const open = await getOpenEntry(ctx.organizationId, ctx.technicianIds)
+        if (open?.serviceRecordId === job.id) {
+          const entry = await stopEntry({
+            organizationId: ctx.organizationId,
+            technicianIds: ctx.technicianIds,
+          })
+          stoppedMinutes = entry.durationMinutes
+        }
+      }
+
+      const technician = await db.technician.findFirst({
+        where: { id: { in: ctx.technicianIds } },
+        select: { name: true },
+      })
+
+      const updated = await db.serviceRecord.update({
+        where: { id: job.id },
+        data: { status },
+        select: { id: true, status: true },
+      })
+
+      // Puts it on the work board immediately, the same way the board's own
+      // changes reach the technician.
+      notificationBus.emit('workboard', {
+        type: 'job_updated',
+        organizationId: ctx.organizationId,
+        job: updated,
+      })
+
+      // Tells the desk, unless the shop has turned it off.
+      //
+      // The whole point of the app is that the office stops walking into the
+      // bay to ask, which only works if the office finds out. Not awaited: the
+      // status change succeeded the moment the row was written, and a
+      // notification failing must not undo it.
+      void notifyDesk({
+        organizationId: ctx.organizationId,
+        jobId: job.id,
+        vehicleId: job.vehicleId,
+        label: [job.vehicle?.licensePlate?.trim(), job.title].filter(Boolean).join(' · '),
+        technicianName: technician?.name ?? 'A technician',
+        status,
+      })
+
+      return apiOk({ job: updated, stoppedMinutes })
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    }
+  )
+}
+
+/** How each status reads to somebody at the desk. */
+const STATUS_WORDS: Record<string, string> = {
+  completed: 'finished',
+  'waiting-parts': 'is waiting for parts',
+  'in-progress': 'is back in progress',
+  pending: 'is back on the list',
+}
+
+async function notifyDesk(args: {
+  organizationId: string
+  jobId: string
+  vehicleId: string | null
+  label: string
+  technicianName: string
+  status: string
+}) {
+  try {
+    const setting = await db.appSetting.findFirst({
+      where: {
+        organizationId: args.organizationId,
+        key: SETTING_KEYS.TECHNICIAN_STATUS_ALERTS,
+      },
+      select: { value: true },
+    })
+
+    // Absent means on. A shop that has never opened the setting should still
+    // be told; opting out has to be a decision somebody made.
+    if (setting?.value === 'false') return
+
+    const words = STATUS_WORDS[args.status] ?? `moved to ${args.status}`
+    await notify({
+      organizationId: args.organizationId,
+      type: 'job.statusChanged',
+      title:
+        args.status === 'completed'
+          ? `${args.technicianName} finished a job`
+          : `${args.technicianName} updated a job`,
+      message: `${args.label} ${words}.`,
+      entityType: 'ServiceRecord',
+      entityId: args.jobId,
+      entityUrl: args.vehicleId
+        ? `/vehicles/${args.vehicleId}/service/${args.jobId}`
+        : `/sales/${args.jobId}`,
+    })
+  } catch (err) {
+    console.error('[status] could not notify the desk', err)
+  }
+}

+ 86 - 0
src/app/api/v1/tech/jobs/route.ts

@@ -0,0 +1,86 @@
+import { db } from '@/lib/db'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { getOpenEntry } from '@/features/time-tracking/Lib/timeEntries'
+
+/** Statuses that mean "this is on my plate right now". */
+const ACTIVE_STATUSES = ['in-progress', 'pending', 'waiting-parts']
+
+/**
+ * The technician's own job list, which is the app's home screen.
+ *
+ * Scoped to their technician rows, never to a client-supplied id: the app
+ * cannot ask for somebody else's work by changing a parameter. Counts are
+ * included so the list can show what a job already has without a request per
+ * row, which matters on a phone holding twenty of them.
+ */
+export async function GET(request: Request) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const records = await db.serviceRecord.findMany({
+        where: {
+          organizationId: ctx.organizationId,
+          status: { in: ACTIVE_STATUSES },
+          technicianId: { in: ctx.technicianIds },
+        },
+        select: {
+          id: true,
+          title: true,
+          status: true,
+          updatedAt: true,
+          startDateTime: true,
+          vehicle: {
+            select: {
+              id: true,
+              make: true,
+              model: true,
+              year: true,
+              licensePlate: true,
+              customer: { select: { id: true, name: true, phone: true, email: true } },
+            },
+          },
+          customer: { select: { id: true, name: true, phone: true, email: true } },
+          attachments: { select: { category: true } },
+          _count: { select: { partItems: true, laborItems: true } },
+        },
+        orderBy: { updatedAt: 'desc' },
+        take: 50,
+      })
+
+      const openEntry = await getOpenEntry(ctx.organizationId, ctx.technicianIds)
+
+      return apiOk({
+        openEntryJobId: openEntry?.serviceRecordId ?? null,
+        jobs: records.map((r) => ({
+          id: r.id,
+          title: r.title,
+          status: r.status,
+          updatedAt: r.updatedAt,
+          scheduledFor: r.startDateTime,
+          vehicle: r.vehicle
+            ? {
+                id: r.vehicle.id,
+                make: r.vehicle.make,
+                model: r.vehicle.model,
+                year: r.vehicle.year,
+                licensePlate: r.vehicle.licensePlate,
+              }
+            : null,
+          // A vehicle-linked job resolves its customer through the vehicle, so
+          // the app never has to know which of the two carried it.
+          customer: r.customer ?? r.vehicle?.customer ?? null,
+          imageCount: r.attachments.filter((a) => a.category === 'image').length,
+          videoCount: r.attachments.filter((a) => a.category === 'video').length,
+          partCount: r._count.partItems,
+          laborCount: r._count.laborItems,
+          isRunning: openEntry?.serviceRecordId === r.id,
+        })),
+      })
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SERVICES }],
+    }
+  )
+}

+ 64 - 0
src/app/api/v1/tech/me/route.ts

@@ -0,0 +1,64 @@
+import { db } from '@/lib/db'
+import { apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { getOpenEntry, loggedMinutes } from '@/features/time-tracking/Lib/timeEntries'
+import { MIN_APP_VERSION } from '@/lib/tech-app-version'
+
+/**
+ * Who the app is signed in as, which workshop it is looking at, and whether a
+ * clock is already running.
+ *
+ * Called on every launch, so it deliberately answers the three questions the
+ * first screen needs in one round trip rather than three. A technician
+ * opening the app in a bay with one bar of signal should reach a usable
+ * screen on a single request.
+ */
+export async function GET(request: Request) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const [user, organization, technicians] = await Promise.all([
+        db.user.findUnique({
+          where: { id: ctx.userId },
+          select: { id: true, name: true, email: true, image: true },
+        }),
+        db.organization.findUnique({
+          where: { id: ctx.organizationId },
+          select: { id: true, name: true },
+        }),
+        db.technician.findMany({
+          where: { organizationId: ctx.organizationId, userId: ctx.userId, isActive: true },
+          select: { id: true, name: true, color: true },
+        }),
+      ])
+
+      const openEntry = await getOpenEntry(ctx.organizationId, ctx.technicianIds)
+      // What the job had banked before this stretch, so the running bar counts
+      // from the same place the job screen does.
+      const banked = openEntry ? await loggedMinutes(openEntry.serviceRecordId) : 0
+
+      return apiOk({
+        // Repeated from /health because that one is only read at setup, and a
+        // minimum the app never re-checks is a minimum that cannot be raised.
+        minAppVersion: MIN_APP_VERSION,
+        user,
+        organization,
+        technicians,
+        isTechnician: technicians.length > 0,
+        isAdmin: ctx.isAdmin,
+        openEntry: openEntry
+          ? {
+              id: openEntry.id,
+              startedAt: openEntry.startedAt,
+              serviceRecordId: openEntry.serviceRecordId,
+              jobTitle: openEntry.serviceRecord.title,
+              loggedMinutes: banked,
+            }
+          : null,
+      })
+    },
+    // No permission requirement: this endpoint tells the app what it is
+    // allowed to do. Gating it on a permission would make an unprivileged
+    // account fail at launch with nothing to explain why.
+    { rateLimit: { limit: 60, windowMs: 60_000 } }
+  )
+}

+ 62 - 0
src/app/api/v1/tech/parts/lookup/route.ts

@@ -0,0 +1,62 @@
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { normalizeBarcode } from '@/features/inventory/Lib/barcode'
+
+const querySchema = z.object({ barcode: z.string().min(1).max(128) })
+
+/**
+ * Resolves a scanned barcode to a stock part.
+ *
+ * Scanned input goes through the same normalisation as stored barcodes, so an
+ * exact match is enough and the (organizationId, barcode) unique index makes
+ * it unambiguous.
+ *
+ * A miss is a 404 rather than an empty 200: the technician is standing at a
+ * shelf holding a box, and "we do not stock this" is a different answer from
+ * "here is nothing", which they would have to interpret.
+ */
+export async function GET(request: Request) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const url = new URL(request.url)
+      const { barcode } = querySchema.parse({ barcode: url.searchParams.get('barcode') })
+
+      const normalized = normalizeBarcode(barcode)
+      if (!normalized) return apiError(400, 'invalid_request', 'That barcode could not be read.')
+
+      const part = await db.inventoryPart.findFirst({
+        where: { organizationId: ctx.organizationId, barcode: normalized, isArchived: false },
+        select: {
+          id: true,
+          partNumber: true,
+          barcode: true,
+          name: true,
+          description: true,
+          unit: true,
+          unitCost: true,
+          sellPrice: true,
+          quantity: true,
+          category: true,
+        },
+      })
+
+      if (!part) {
+        return apiError(404, 'not_found', 'No part in stock matches that barcode.')
+      }
+
+      return apiOk({ part })
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.INVENTORY },
+      ],
+      // Scanning is bursty: a technician fitting a service kit scans six boxes
+      // in twenty seconds, and being throttled mid-job is worse than useless.
+      rateLimit: { limit: 120, windowMs: 60_000 },
+    }
+  )
+}

+ 41 - 0
src/app/api/v1/tech/time/entries/route.ts

@@ -0,0 +1,41 @@
+import { z } from 'zod'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { listEntries } from '@/features/time-tracking/Lib/timeEntries'
+
+const querySchema = z.object({
+  // The phone sends the range because it knows the technician's timezone and
+  // the server does not. A shift starting at 22:00 belongs to whichever day
+  // the person working it says it does.
+  from: z.iso.datetime(),
+  to: z.iso.datetime(),
+})
+
+/** What this technician clocked in a window, for the app's day summary. */
+export async function GET(request: Request) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const url = new URL(request.url)
+      const { from, to } = querySchema.parse({
+        from: url.searchParams.get('from'),
+        to: url.searchParams.get('to'),
+      })
+
+      const entries = await listEntries({
+        organizationId: ctx.organizationId,
+        technicianIds: ctx.technicianIds,
+        from: new Date(from),
+        to: new Date(to),
+      })
+
+      const totalMinutes = entries.reduce((sum, e) => sum + (e.durationMinutes ?? 0), 0)
+
+      return apiOk({ entries, totalMinutes })
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SERVICES }],
+    }
+  )
+}

+ 59 - 0
src/app/api/v1/tech/time/start/route.ts

@@ -0,0 +1,59 @@
+import { z } from 'zod'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { startEntry, TimeEntryError } from '@/features/time-tracking/Lib/timeEntries'
+
+const bodySchema = z.object({
+  serviceRecordId: z.string().min(1),
+})
+
+/**
+ * Start the clock on a job.
+ *
+ * Starting a second job closes the first rather than refusing, because that is
+ * what the technician meant: they moved. See `startEntry` for why.
+ */
+export async function POST(request: Request) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { serviceRecordId } = bodySchema.parse(await request.json())
+
+      try {
+        // The user may hold several technician rows; the clock is booked
+        // against the first, which is the one the board treats as primary.
+        const result = await startEntry({
+          organizationId: ctx.organizationId,
+          technicianId: ctx.technicianIds[0],
+          technicianIds: ctx.technicianIds,
+          serviceRecordId,
+          source: 'app',
+        })
+
+        return apiOk({
+          entry: {
+            id: result.entry.id,
+            startedAt: result.entry.startedAt,
+            serviceRecordId: result.entry.serviceRecordId,
+            jobTitle: result.entry.serviceRecord.title,
+          },
+          // The app shows "stopped X, started Y" so a mis-tap is visible
+          // immediately rather than discovered on the timesheet.
+          closed: result.closed,
+        })
+      } catch (err) {
+        if (err instanceof TimeEntryError) {
+          if (err.code === 'job_not_found') return apiError(404, 'not_found', err.message)
+          return apiError(409, 'conflict', err.message)
+        }
+        throw err
+      }
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    }
+  )
+}

+ 42 - 0
src/app/api/v1/tech/time/stop/route.ts

@@ -0,0 +1,42 @@
+import { z } from 'zod'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { stopEntry, TimeEntryError } from '@/features/time-tracking/Lib/timeEntries'
+
+const bodySchema = z.object({
+  note: z.string().max(500).optional(),
+})
+
+/** Stop whatever this technician has running. */
+export async function POST(request: Request) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      // A stop with no body is the common case: the button sends nothing.
+      const raw = await request.text()
+      const { note } = bodySchema.parse(raw ? JSON.parse(raw) : {})
+
+      try {
+        const entry = await stopEntry({
+          organizationId: ctx.organizationId,
+          technicianIds: ctx.technicianIds,
+          note,
+        })
+        return apiOk({ entry })
+      } catch (err) {
+        if (err instanceof TimeEntryError && err.code === 'not_running') {
+          // Not an error worth a red screen: the app and the server simply
+          // disagreed about a clock that is already stopped either way.
+          return apiError(409, 'conflict', err.message)
+        }
+        throw err
+      }
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    }
+  )
+}

+ 176 - 0
src/app/api/v1/tech/ws/route.ts

@@ -0,0 +1,176 @@
+import type { IncomingMessage } from 'node:http'
+import type WebSocket from 'ws'
+import { auth } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { notificationBus } from '@/lib/notification-bus'
+
+/**
+ * Live updates for the technician app.
+ *
+ * Deliberately separate from the work board's socket, which closes any
+ * connection that is not owner or admin. A technician is neither, and loosening
+ * that check would put every workshop-wide board event on their phone.
+ *
+ * This socket is a doorbell, not a data channel. It carries no job details at
+ * all: it says "something you can see has changed" and the app re-reads through
+ * the ordinary authenticated endpoint, which already filters to that
+ * technician's own work. Pushing the payload down the socket would mean
+ * reimplementing that filtering here, in a second place, where getting it wrong
+ * shows one technician another's jobs.
+ */
+
+// Required so the Next route validator recognises this as a route module.
+export function GET() {
+  return new Response('WebSocket endpoint', { status: 426 })
+}
+
+interface TechSocket extends WebSocket {
+  userId: string
+  organizationId: string
+  isAlive: boolean
+}
+
+const clients = new Set<TechSocket>()
+
+/** One event name for every change. See the doorbell note above. */
+function ring(organizationId: string, reason: string) {
+  const payload = JSON.stringify({ type: 'changed', reason })
+  for (const client of clients) {
+    if (client.organizationId === organizationId && client.readyState === 1) {
+      client.send(payload)
+    }
+  }
+}
+
+notificationBus.on('workboard', (event: { organizationId: string; type?: string }) => {
+  ring(event.organizationId, event.type ?? 'workboard')
+})
+
+notificationBus.on('notification', (event: { organizationId: string }) => {
+  ring(event.organizationId, 'notification')
+})
+
+export function UPGRADE(ws: WebSocket, _server: unknown, request: IncomingMessage) {
+  const client = ws as TechSocket
+  client.isAlive = true
+  ;(async () => {
+    try {
+      // The token rides in the WebSocket subprotocol rather than the query
+      // string. A URL is written to proxy and server logs in full, so a token
+      // there ends up on disk in half a dozen places nobody is guarding.
+      const offered = request.headers['sec-websocket-protocol']
+      const encoded = (Array.isArray(offered) ? offered.join(',') : (offered ?? ''))
+        .split(',')
+        .map((p) => p.trim())
+        .find((p) => p.startsWith('bearer.'))
+        ?.slice('bearer.'.length)
+
+      // Hex, because a subprotocol name may only use RFC 7230 token
+      // characters and a session token is base64: its '=' padding and any '/'
+      // are illegal there, and a browser refuses the connection outright
+      // rather than complaining about one character.
+      const token =
+        encoded && /^[0-9a-f]+$/i.test(encoded) && encoded.length % 2 === 0
+          ? Buffer.from(encoded, 'hex').toString('utf8')
+          : undefined
+
+      if (!token) {
+        ws.close(4001, 'No token')
+        return
+      }
+
+      // Resolved through Better Auth, exactly as the HTTP API does, so a
+      // revoked session cannot keep a socket alive that a request could not
+      // open. Looking the token up in the session table directly would miss
+      // that and quietly keep working.
+      const session = await auth.api.getSession({
+        headers: new Headers({ authorization: `Bearer ${token}` }),
+      })
+      if (!session?.user?.id) {
+        ws.close(4001, 'Invalid or expired session')
+        return
+      }
+
+      // Which workshop this socket is for.
+      //
+      // Offered as a second subprotocol by the app, because a WebSocket
+      // handshake carries no headers we control. It only selects: the
+      // membership lookup is what decides, and an id the user is not a member
+      // of falls through to their default rather than granting anything.
+      //
+      // Without this a technician who works for two workshops subscribed to
+      // whichever membership came back first, so they were told about changes
+      // in one shop while using the app against the other.
+      const wantedOrg = (Array.isArray(offered) ? offered.join(',') : (offered ?? ''))
+        .split(',')
+        .map((p) => p.trim())
+        .find((p) => p.startsWith('org.'))
+        ?.slice('org.'.length)
+
+      const membership =
+        (wantedOrg
+          ? await db.organizationMember.findFirst({
+              where: { userId: session.user.id, organizationId: wantedOrg },
+              select: { organizationId: true },
+            })
+          : null) ??
+        (await db.organizationMember.findFirst({
+          where: { userId: session.user.id },
+          select: { organizationId: true },
+        }))
+      if (!membership) {
+        ws.close(4001, 'No organization')
+        return
+      }
+
+      // Only actual technicians. Everyone else has nothing on this socket.
+      const isTechnician = await db.technician.findFirst({
+        where: {
+          organizationId: membership.organizationId,
+          userId: session.user.id,
+          isActive: true,
+        },
+        select: { id: true },
+      })
+      if (!isTechnician) {
+        ws.close(4003, 'Not a technician')
+        return
+      }
+
+      client.userId = session.user.id
+      client.organizationId = membership.organizationId
+      clients.add(client)
+
+      // A phone moves between wifi and mobile data, and a socket dropped in
+      // that handover otherwise looks alive from this end forever.
+      const ping = setInterval(() => {
+        if (!client.isAlive) {
+          clearInterval(ping)
+          ws.terminate()
+          return
+        }
+        client.isAlive = false
+        ws.ping()
+      }, 30_000)
+
+      ws.on('pong', () => {
+        client.isAlive = true
+      })
+
+      ws.on('close', () => {
+        clearInterval(ping)
+        clients.delete(client)
+      })
+
+      ws.on('error', () => {
+        clearInterval(ping)
+        clients.delete(client)
+      })
+
+      // Tells the app it is live, so it can stop its fallback polling.
+      ws.send(JSON.stringify({ type: 'ready' }))
+    } catch {
+      ws.close(4000, 'Could not open')
+    }
+  })()
+}

+ 18 - 18
src/features/notifications/Actions/notificationActions.ts

@@ -1,26 +1,26 @@
-"use server";
+'use server'
 
-import { db } from "@/lib/db";
-import { withAuth } from "@/lib/with-auth";
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
 
 export async function getNotifications() {
   return withAuth(async ({ organizationId, role }) => {
-    const isAdminOrOwner = role === "owner" || role === "admin" || role === "super_admin";
-    if (!isAdminOrOwner) return { notifications: [], unreadCount: 0 };
+    const isAdminOrOwner = role === 'owner' || role === 'admin' || role === 'super_admin'
+    if (!isAdminOrOwner) return { notifications: [], unreadCount: 0 }
 
     const [notifications, unreadCount] = await Promise.all([
       db.notification.findMany({
         where: { organizationId },
-        orderBy: { createdAt: "desc" },
+        orderBy: { createdAt: 'desc' },
         take: 50,
       }),
       db.notification.count({
         where: { organizationId, read: false },
       }),
-    ]);
+    ])
 
-    return { notifications, unreadCount };
-  });
+    return { notifications, unreadCount }
+  })
 }
 
 export async function markNotificationRead(id: string) {
@@ -28,9 +28,9 @@ export async function markNotificationRead(id: string) {
     await db.notification.updateMany({
       where: { id, organizationId },
       data: { read: true },
-    });
-    return { success: true };
-  });
+    })
+    return { success: true }
+  })
 }
 
 export async function markAllNotificationsRead() {
@@ -38,16 +38,16 @@ export async function markAllNotificationsRead() {
     await db.notification.updateMany({
       where: { organizationId, read: false },
       data: { read: true },
-    });
-    return { success: true };
-  });
+    })
+    return { success: true }
+  })
 }
 
 export async function deleteNotification(id: string) {
   return withAuth(async ({ organizationId }) => {
     await db.notification.deleteMany({
       where: { id, organizationId },
-    });
-    return { success: true };
-  });
+    })
+    return { success: true }
+  })
 }

+ 4 - 4
src/features/notifications/Components/NotificationInitializer.tsx

@@ -1,8 +1,8 @@
-"use client";
+'use client'
 
-import { useNotificationWebSocket } from "../hooks/useNotificationWebSocket";
+import { useNotificationWebSocket } from '../hooks/useNotificationWebSocket'
 
 export function NotificationInitializer() {
-  useNotificationWebSocket();
-  return null;
+  useNotificationWebSocket()
+  return null
 }

+ 77 - 63
src/features/notifications/Components/NotificationPanel.tsx

@@ -1,31 +1,30 @@
-"use client";
+'use client'
 
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { formatDistanceToNow } from "date-fns";
-import { Bell, FileText, ClipboardCheck, Receipt, X } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import { ScrollArea } from "@/components/ui/scroll-area";
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { formatDistanceToNow } from 'date-fns'
+import { Bell, FileText, ClipboardCheck, Receipt, X } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
+import { useNotificationStore } from '../store/notificationStore'
 import {
-  Sheet,
-  SheetContent,
-  SheetHeader,
-  SheetTitle,
-} from "@/components/ui/sheet";
-import { useNotificationStore } from "../store/notificationStore";
-import { markNotificationRead, markAllNotificationsRead, deleteNotification } from "../Actions/notificationActions";
+  markNotificationRead,
+  markAllNotificationsRead,
+  deleteNotification,
+} from '../Actions/notificationActions'
 
 const entityIcons: Record<string, typeof FileText> = {
   quote: FileText,
   inspection: ClipboardCheck,
   invoice: Receipt,
-};
+}
 
 export function NotificationBell() {
-  const { unreadCount, setPanelOpen } = useNotificationStore();
-  const t = useTranslations("notifications");
+  const { unreadCount, setPanelOpen } = useNotificationStore()
+  const t = useTranslations('notifications')
 
   return (
     <Button
@@ -33,69 +32,82 @@ export function NotificationBell() {
       size="icon"
       className="relative h-8 w-8"
       onClick={() => setPanelOpen(true)}
-      aria-label={t("openNotifications")}
+      aria-label={t('openNotifications')}
     >
       <Bell className="h-4 w-4" />
       {unreadCount > 0 && (
         <span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground">
-          {unreadCount > 99 ? "99+" : unreadCount}
+          {unreadCount > 99 ? '99+' : unreadCount}
         </span>
       )}
     </Button>
-  );
+  )
 }
 
 export function NotificationPanel() {
-  const t = useTranslations("notifications");
-  const router = useRouter();
-  const { notifications, unreadCount, isPanelOpen, setPanelOpen, markRead, markAllRead, removeNotification } =
-    useNotificationStore();
-  const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
+  const t = useTranslations('notifications')
+  const router = useRouter()
+  const {
+    notifications,
+    unreadCount,
+    isPanelOpen,
+    setPanelOpen,
+    markRead,
+    markAllRead,
+    removeNotification,
+  } = useNotificationStore()
+  const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
 
   const handleClickNotification = (id: string, entityUrl: string, read: boolean) => {
     if (pendingDeleteId) {
-      setPendingDeleteId(null);
-      return;
+      setPendingDeleteId(null)
+      return
     }
     if (!read) {
-      markRead(id);
-      markNotificationRead(id);
+      markRead(id)
+      markNotificationRead(id)
     }
-    setPanelOpen(false);
+    setPanelOpen(false)
     setTimeout(() => {
-      router.push(entityUrl);
-    }, 300);
-  };
+      router.push(entityUrl)
+    }, 300)
+  }
 
   const handleDeleteClick = (e: React.MouseEvent, id: string) => {
-    e.stopPropagation();
-    setPendingDeleteId(id);
-  };
+    e.stopPropagation()
+    setPendingDeleteId(id)
+  }
 
   const handleConfirmDelete = (e: React.MouseEvent, id: string) => {
-    e.stopPropagation();
-    removeNotification(id);
-    deleteNotification(id);
-    setPendingDeleteId(null);
-  };
+    e.stopPropagation()
+    removeNotification(id)
+    deleteNotification(id)
+    setPendingDeleteId(null)
+  }
 
   const handleCancelDelete = (e: React.MouseEvent) => {
-    e.stopPropagation();
-    setPendingDeleteId(null);
-  };
+    e.stopPropagation()
+    setPendingDeleteId(null)
+  }
 
   const handleMarkAllRead = async () => {
-    markAllRead();
-    markAllNotificationsRead();
-  };
+    markAllRead()
+    markAllNotificationsRead()
+  }
 
   return (
-    <Sheet open={isPanelOpen} onOpenChange={(open) => { setPanelOpen(open); if (!open) setPendingDeleteId(null); }}>
+    <Sheet
+      open={isPanelOpen}
+      onOpenChange={(open) => {
+        setPanelOpen(open)
+        if (!open) setPendingDeleteId(null)
+      }}
+    >
       <SheetContent className="w-[380px] p-0 sm:max-w-[380px]">
         <SheetHeader className="border-b px-4 py-3">
           <div className="flex items-center justify-between">
             <div className="flex items-center gap-2">
-              <SheetTitle>{t("panel.title")}</SheetTitle>
+              <SheetTitle>{t('panel.title')}</SheetTitle>
               {unreadCount > 0 && (
                 <Badge variant="secondary" className="text-xs">
                   {unreadCount}
@@ -104,7 +116,7 @@ export function NotificationPanel() {
             </div>
             {unreadCount > 0 && (
               <Button variant="ghost" size="sm" className="text-xs" onClick={handleMarkAllRead}>
-                {t("panel.markAllRead")}
+                {t('panel.markAllRead')}
               </Button>
             )}
           </div>
@@ -113,12 +125,12 @@ export function NotificationPanel() {
           {notifications.length === 0 ? (
             <div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
               <Bell className="mb-2 h-8 w-8 opacity-40" />
-              <p className="text-sm">{t("panel.empty")}</p>
+              <p className="text-sm">{t('panel.empty')}</p>
             </div>
           ) : (
             <div className="divide-y">
               {notifications.map((n) => {
-                const Icon = entityIcons[n.entityType] || Bell;
+                const Icon = entityIcons[n.entityType] || Bell
                 return (
                   <div
                     key={n.id}
@@ -126,7 +138,9 @@ export function NotificationPanel() {
                     tabIndex={0}
                     className="flex w-full cursor-pointer gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/50"
                     onClick={() => handleClickNotification(n.id, n.entityUrl, n.read)}
-                    onKeyDown={(e) => { if (e.key === "Enter") handleClickNotification(n.id, n.entityUrl, n.read); }}
+                    onKeyDown={(e) => {
+                      if (e.key === 'Enter') handleClickNotification(n.id, n.entityUrl, n.read)
+                    }}
                   >
                     <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
                       <Icon className="h-4 w-4 text-muted-foreground" />
@@ -135,9 +149,7 @@ export function NotificationPanel() {
                       <div className="flex items-start justify-between gap-2">
                         <p className="text-sm font-medium leading-tight">{n.title}</p>
                         <div className="flex shrink-0 items-center gap-1">
-                          {!n.read && (
-                            <span className="h-2 w-2 rounded-full bg-primary" />
-                          )}
+                          {!n.read && <span className="h-2 w-2 rounded-full bg-primary" />}
                           <button
                             type="button"
                             className="rounded p-0.5 text-muted-foreground/50 hover:bg-muted hover:text-foreground"
@@ -149,14 +161,16 @@ export function NotificationPanel() {
                       </div>
                       {pendingDeleteId === n.id ? (
                         <div className="mt-1 flex items-center gap-2">
-                          <span className="text-xs text-muted-foreground">{t("panel.confirmDelete")}</span>
+                          <span className="text-xs text-muted-foreground">
+                            {t('panel.confirmDelete')}
+                          </span>
                           <Button
                             variant="destructive"
                             size="sm"
                             className="h-6 px-2 text-xs"
                             onClick={(e) => handleConfirmDelete(e, n.id)}
                           >
-                            {t("panel.delete")}
+                            {t('panel.delete')}
                           </Button>
                           <Button
                             variant="ghost"
@@ -164,7 +178,7 @@ export function NotificationPanel() {
                             className="h-6 px-2 text-xs"
                             onClick={handleCancelDelete}
                           >
-                            {t("panel.cancel")}
+                            {t('panel.cancel')}
                           </Button>
                         </div>
                       ) : (
@@ -179,12 +193,12 @@ export function NotificationPanel() {
                       )}
                     </div>
                   </div>
-                );
+                )
               })}
             </div>
           )}
         </ScrollArea>
       </SheetContent>
     </Sheet>
-  );
+  )
 }

+ 115 - 0
src/features/notifications/Lib/pushToTechnician.ts

@@ -0,0 +1,115 @@
+import { db } from '@/lib/db'
+
+/**
+ * Sends a push to every device a technician is signed into.
+ *
+ * Fire-and-forget by design. A notification is a courtesy on top of work that
+ * already succeeded, so a failure here must never surface as a failure of the
+ * thing that triggered it: nobody should see "could not assign job" because
+ * Expo had a bad minute.
+ *
+ * Delivery runs through Expo's push service rather than APNs and FCM directly.
+ * That is the trade the app already made by being an Expo build, and doing it
+ * ourselves would mean holding Apple and Google credentials on the server for
+ * no gain the size of the work.
+ */
+
+const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send'
+
+/** Expo's documented cap per request. Larger batches are rejected wholesale. */
+const BATCH_SIZE = 100
+
+export interface PushMessage {
+  title: string
+  body: string
+  /** Routed by the app to open the right screen. */
+  data?: Record<string, string>
+}
+
+export async function pushToTechnician(args: {
+  organizationId: string
+  technicianId: string
+  message: PushMessage
+}) {
+  try {
+    const technician = await db.technician.findFirst({
+      where: { id: args.technicianId, organizationId: args.organizationId },
+      select: { userId: true },
+    })
+    if (!technician?.userId) return
+
+    const devices = await db.pushDevice.findMany({
+      where: {
+        userId: technician.userId,
+        organizationId: args.organizationId,
+        isActive: true,
+      },
+      select: { token: true },
+    })
+    if (devices.length === 0) return
+
+    await sendExpoPush(
+      devices.map((d) => d.token),
+      args.message
+    )
+  } catch (err) {
+    // Logged, never rethrown. See the note at the top.
+    console.error('[push] failed', err)
+  }
+}
+
+async function sendExpoPush(tokens: string[], message: PushMessage) {
+  for (let i = 0; i < tokens.length; i += BATCH_SIZE) {
+    const batch = tokens.slice(i, i + BATCH_SIZE)
+
+    const res = await fetch(EXPO_PUSH_URL, {
+      method: 'POST',
+      headers: {
+        Accept: 'application/json',
+        'Content-Type': 'application/json',
+        // Expo compresses aggressively and rejects oversized bodies otherwise.
+        'Accept-Encoding': 'gzip, deflate',
+      },
+      body: JSON.stringify(
+        batch.map((to) => ({
+          to,
+          sound: 'default',
+          title: message.title,
+          body: message.body,
+          data: message.data ?? {},
+          // A job notification is worth waking the screen for; it is the
+          // difference between seeing it now and seeing it after lunch.
+          priority: 'high',
+          channelId: 'jobs',
+        }))
+      ),
+    })
+
+    if (!res.ok) {
+      console.error('[push] expo rejected the batch', res.status)
+      continue
+    }
+
+    // Expo answers per message. A token it reports as dead will never work
+    // again, so it is retired rather than retried forever on every future send.
+    const body = (await res.json().catch(() => null)) as {
+      data?: { status: string; details?: { error?: string } }[]
+    } | null
+
+    const dead: string[] = []
+    body?.data?.forEach((result, index) => {
+      if (result.status === 'error' && result.details?.error === 'DeviceNotRegistered') {
+        const token = batch[index]
+        if (token) dead.push(token)
+      }
+    })
+
+    if (dead.length > 0) {
+      await db.pushDevice
+        .updateMany({ where: { token: { in: dead } }, data: { isActive: false } })
+        .catch(() => {
+          /* best effort; the next send will try again */
+        })
+    }
+  }
+}

+ 49 - 50
src/features/notifications/hooks/useNotificationWebSocket.ts

@@ -1,14 +1,14 @@
-"use client";
+'use client'
 
-import { useEffect, useRef } from "react";
-import { toast } from "sonner";
-import { useNotificationStore } from "../store/notificationStore";
-import { getNotifications, markNotificationRead } from "../Actions/notificationActions";
-import { getActiveSmsCustomerId } from "@/features/sms/activeSmsView";
+import { useEffect, useRef } from 'react'
+import { toast } from 'sonner'
+import { useNotificationStore } from '../store/notificationStore'
+import { getNotifications, markNotificationRead } from '../Actions/notificationActions'
+import { getActiveSmsCustomerId } from '@/features/sms/activeSmsView'
 
 export function useNotificationWebSocket() {
-  const wsRef = useRef<WebSocket | null>(null);
-  const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
+  const wsRef = useRef<WebSocket | null>(null)
+  const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
 
   useEffect(() => {
     // Liveness is a per-effect-run closure, not a shared ref: with a ref, a
@@ -16,92 +16,91 @@ export function useNotificationWebSocket() {
     // socket's async onclose observe the *new* run's "mounted" state and
     // schedule a reconnect — leaving two live sockets delivering every
     // notification twice.
-    let alive = true;
+    let alive = true
 
     // Fetch initial notifications
     getNotifications().then((result) => {
-      if (!alive) return;
+      if (!alive) return
       if (result.success && result.data) {
-        useNotificationStore.getState().setNotifications(
-          result.data.notifications,
-          result.data.unreadCount,
-        );
+        useNotificationStore
+          .getState()
+          .setNotifications(result.data.notifications, result.data.unreadCount)
       }
-    });
+    })
 
     function connect() {
-      if (!alive) return;
+      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;
+      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);
-      };
+        useNotificationStore.getState().setConnected(true)
+      }
 
       ws.onmessage = (event) => {
         try {
-          const msg = JSON.parse(event.data);
-          if (msg.type === "notification") {
-            const data = msg.data;
+          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();
+            const activeSmsCid = getActiveSmsCustomerId()
             if (
               activeSmsCid &&
-              data.type === "sms_inbound" &&
+              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);
+              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;
+              }))
+              markNotificationRead(data.id)
+              return
             }
 
-            useNotificationStore.getState().addNotification(data);
-            const isSms = data.type === "sms_inbound";
+            useNotificationStore.getState().addNotification(data)
+            const isSms = data.type === 'sms_inbound'
             toast(data.title, {
               description: data.message,
               ...(isSms && { duration: 5 * 60 * 1000 }),
               action: {
-                label: "View",
+                label: 'View',
                 onClick: () => {
-                  window.location.href = data.entityUrl;
+                  window.location.href = data.entityUrl
                 },
               },
-            });
+            })
           }
         } catch {
           // ignore malformed messages
         }
-      };
+      }
 
       ws.onclose = () => {
-        useNotificationStore.getState().setConnected(false);
-        wsRef.current = null;
+        useNotificationStore.getState().setConnected(false)
+        wsRef.current = null
         if (alive) {
-          reconnectTimer.current = setTimeout(connect, 3000);
+          reconnectTimer.current = setTimeout(connect, 3000)
         }
-      };
+      }
 
       ws.onerror = () => {
-        ws.close();
-      };
+        ws.close()
+      }
     }
 
-    connect();
+    connect()
 
     return () => {
-      alive = false;
-      clearTimeout(reconnectTimer.current);
-      wsRef.current?.close();
-    };
-  }, []); // no deps — mount once
+      alive = false
+      clearTimeout(reconnectTimer.current)
+      wsRef.current?.close()
+    }
+  }, []) // no deps — mount once
 }

+ 33 - 32
src/features/notifications/store/notificationStore.ts

@@ -1,31 +1,31 @@
-import { create } from "zustand";
+import { create } from 'zustand'
 
 export type Notification = {
-  id: string;
-  type: string;
-  title: string;
-  message: string;
-  entityType: string;
-  entityId: string;
-  entityUrl: string;
-  read: boolean;
-  organizationId: string;
-  createdAt: string | Date;
-};
+  id: string
+  type: string
+  title: string
+  message: string
+  entityType: string
+  entityId: string
+  entityUrl: string
+  read: boolean
+  organizationId: string
+  createdAt: string | Date
+}
 
 type NotificationState = {
-  notifications: Notification[];
-  unreadCount: number;
-  isConnected: boolean;
-  isPanelOpen: boolean;
-  setNotifications: (notifications: Notification[], unreadCount: number) => void;
-  addNotification: (notification: Notification) => void;
-  markRead: (id: string) => void;
-  markAllRead: () => void;
-  removeNotification: (id: string) => void;
-  setConnected: (connected: boolean) => void;
-  setPanelOpen: (open: boolean) => void;
-};
+  notifications: Notification[]
+  unreadCount: number
+  isConnected: boolean
+  isPanelOpen: boolean
+  setNotifications: (notifications: Notification[], unreadCount: number) => void
+  addNotification: (notification: Notification) => void
+  markRead: (id: string) => void
+  markAllRead: () => void
+  removeNotification: (id: string) => void
+  setConnected: (connected: boolean) => void
+  setPanelOpen: (open: boolean) => void
+}
 
 export const useNotificationStore = create<NotificationState>((set) => ({
   notifications: [],
@@ -38,19 +38,20 @@ export const useNotificationStore = create<NotificationState>((set) => ({
       // Dedupe by id: a reconnect race or a double-delivered broadcast must
       // never show (or count) the same notification twice.
       if (state.notifications.some((n) => n.id === notification.id)) {
-        return state;
+        return state
       }
       return {
         notifications: [notification, ...state.notifications].slice(0, 50),
         unreadCount: state.unreadCount + 1,
-      };
+      }
     }),
   markRead: (id) =>
     set((state) => ({
-      notifications: state.notifications.map((n) =>
-        n.id === id ? { ...n, read: true } : n,
+      notifications: state.notifications.map((n) => (n.id === id ? { ...n, read: true } : n)),
+      unreadCount: Math.max(
+        0,
+        state.unreadCount - (state.notifications.find((n) => n.id === id && !n.read) ? 1 : 0)
       ),
-      unreadCount: Math.max(0, state.unreadCount - (state.notifications.find((n) => n.id === id && !n.read) ? 1 : 0)),
     })),
   markAllRead: () =>
     set((state) => ({
@@ -59,12 +60,12 @@ export const useNotificationStore = create<NotificationState>((set) => ({
     })),
   removeNotification: (id) =>
     set((state) => {
-      const target = state.notifications.find((n) => n.id === id);
+      const target = state.notifications.find((n) => n.id === id)
       return {
         notifications: state.notifications.filter((n) => n.id !== id),
         unreadCount: Math.max(0, state.unreadCount - (target && !target.read ? 1 : 0)),
-      };
+      }
     }),
   setConnected: (isConnected) => set({ isConnected }),
   setPanelOpen: (isPanelOpen) => set({ isPanelOpen }),
-}));
+}))

+ 9 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -69,6 +69,15 @@ export const SETTING_KEYS = {
   /// Unit of measure pre-filled on newly created inventory parts ("pcs",
   /// "l", "qt"...). Empty means new parts start with no unit.
   INVENTORY_DEFAULT_UNIT: 'inventory.defaultUnit',
+  /**
+   * Whether the desk is told when a technician moves a job from the app.
+   *
+   * On by default: the point of the technician app is that the office stops
+   * having to walk into the bay and ask, and a notification nobody switched on
+   * does not achieve that. A shop that finds it noisy can turn it off.
+   */
+  TECHNICIAN_STATUS_ALERTS: 'workshop.technicianStatusAlerts.inApp',
+
   LOW_STOCK_ALERTS_ENABLED: 'inventory.lowStockAlerts.enabled',
   /// Org-wide fallback reorder point, applied to parts with no minQuantity of
   /// their own. 0 means only explicitly configured parts are watched.

+ 141 - 0
src/features/team/Actions/setMemberTechnician.ts

@@ -0,0 +1,141 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { notificationBus } from '@/lib/notification-bus'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+
+/**
+ * Marks a team member as a technician, or stops doing so.
+ *
+ * The work board's technician dialog is the full editor: colour, capacity, and
+ * technicians who have no login at all. This is the other half of the same
+ * idea, put where someone actually looks for it. A shop owner adding a
+ * mechanic to the team expects to say "this person works jobs" on the screen
+ * where they added them, not to discover that the setting lives on a
+ * scheduling board they may never have opened.
+ *
+ * It also gates the technician app: without a linked technician row, signing in
+ * there gets "not set up as a technician" and nothing else.
+ */
+
+const schema = z.object({
+  userId: z.string().min(1),
+  enabled: z.boolean(),
+})
+
+export async function setMemberTechnician(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const { userId, enabled } = schema.parse(input)
+
+      // Only for people already in this workshop. Without this check the
+      // action would happily mint a technician for any user id sent to it.
+      const member = await db.organizationMember.findFirst({
+        where: { userId, organizationId },
+        select: { user: { select: { id: true, name: true, email: true } } },
+      })
+      if (!member?.user) throw new Error('That person is not a member of this workshop.')
+
+      const existing = await db.technician.findFirst({
+        where: { userId, organizationId },
+        select: { id: true, isActive: true },
+      })
+
+      if (!enabled) {
+        // Deactivated, never deleted. The row is what past jobs, inspections
+        // and status reports point at, so removing it would rewrite history to
+        // say nobody did the work. Deactivating takes them off the board and
+        // out of the app while leaving every record intact, and keeps the row
+        // findable if they are switched back on later.
+        if (!existing) return { technicianId: null, isActive: false }
+
+        const technician = await db.technician.update({
+          where: { id: existing.id },
+          data: { isActive: false },
+        })
+        notificationBus.emit('workboard', {
+          type: 'technician_updated',
+          organizationId,
+          technician,
+        })
+        revalidatePath('/settings/team')
+        revalidatePath('/work-board')
+        return { technicianId: technician.id, isActive: false }
+      }
+
+      if (existing) {
+        const technician = await db.technician.update({
+          where: { id: existing.id },
+          data: { isActive: true },
+        })
+        notificationBus.emit('workboard', {
+          type: 'technician_updated',
+          organizationId,
+          technician,
+        })
+        revalidatePath('/settings/team')
+        revalidatePath('/work-board')
+        return { technicianId: technician.id, isActive: true }
+      }
+
+      const maxOrder = await db.technician.aggregate({
+        where: { organizationId },
+        _max: { sortOrder: true },
+      })
+
+      const technician = await db.technician.create({
+        data: {
+          // Named from the account, and the colour left at the default. Both
+          // are worth setting properly on the board; neither is worth asking
+          // about on a screen where the question is simply yes or no.
+          name: member.user.name || member.user.email,
+          userId,
+          sortOrder: (maxOrder._max.sortOrder ?? -1) + 1,
+          organizationId,
+        },
+      })
+
+      notificationBus.emit('workboard', {
+        type: 'technician_created',
+        organizationId,
+        technician,
+      })
+      revalidatePath('/settings/team')
+      revalidatePath('/work-board')
+      return { technicianId: technician.id, isActive: true }
+    },
+    {
+      // Managing who counts as a technician is a team decision, so it sits
+      // behind the team permission rather than the work board's.
+      requiredPermissions: [
+        { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: 'team.setMemberTechnician',
+        message: result.isActive
+          ? 'Marked a member as a technician'
+          : 'Removed a member as a technician',
+        metadata: { technicianId: result.technicianId, isActive: result.isActive },
+      }),
+    }
+  )
+}
+
+/** User ids in this workshop that already have an active technician row. */
+export async function getTechnicianUserIds() {
+  return withAuth(
+    async ({ organizationId }) => {
+      const rows = await db.technician.findMany({
+        where: { organizationId, isActive: true, userId: { not: null } },
+        select: { userId: true },
+      })
+      return rows.map((r) => r.userId as string)
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
+    }
+  )
+}

+ 199 - 0
src/features/time-tracking/Lib/timeEntries.ts

@@ -0,0 +1,199 @@
+import { db } from '@/lib/db'
+
+/**
+ * Clocking a technician on and off a job.
+ *
+ * Kept out of the route handlers because the web app needs exactly the same
+ * rules the moment it grows start/stop buttons, and two implementations of
+ * "what counts as an open entry" would drift within a release.
+ */
+
+/** A stop that lands before its start is a clock error, not a negative shift. */
+export class TimeEntryError extends Error {
+  constructor(
+    public code: 'already_running' | 'not_running' | 'job_not_found' | 'invalid_range',
+    message: string
+  ) {
+    super(message)
+    this.name = 'TimeEntryError'
+  }
+}
+
+export function durationMinutes(startedAt: Date, endedAt: Date): number {
+  // Rounded, not truncated: a 59-second job is a minute of someone's day, and
+  // truncating would make a shift of short jobs quietly bill as less than it
+  // took. Never below zero, so a clock skew cannot produce negative labour.
+  return Math.max(0, Math.round((endedAt.getTime() - startedAt.getTime()) / 60_000))
+}
+
+/**
+ * The technician's currently running entry, if any.
+ *
+ * Takes every technician row the user owns, because the person is the one
+ * holding the phone even when the shop has given them several board lanes.
+ */
+export async function getOpenEntry(organizationId: string, technicianIds: string[]) {
+  if (technicianIds.length === 0) return null
+  return db.timeEntry.findFirst({
+    where: { organizationId, technicianId: { in: technicianIds }, endedAt: null },
+    orderBy: { startedAt: 'desc' },
+    select: {
+      id: true,
+      startedAt: true,
+      technicianId: true,
+      serviceRecordId: true,
+      serviceRecord: { select: { id: true, title: true, status: true } },
+    },
+  })
+}
+
+/**
+ * Minutes already banked on a job, not counting a stretch still running.
+ *
+ * The clock has to continue from this rather than restart at zero: a
+ * technician who stops for a part and starts again has not un-worked the first
+ * twenty minutes, and a display that says otherwise reads as having lost them.
+ */
+export async function loggedMinutes(serviceRecordId: string): Promise<number> {
+  const result = await db.timeEntry.aggregate({
+    where: { serviceRecordId },
+    _sum: { durationMinutes: true },
+  })
+  return result._sum.durationMinutes ?? 0
+}
+
+/**
+ * Start the clock on a job.
+ *
+ * One open entry per person, enforced here rather than in the schema: a
+ * partial unique index on "endedAt is null" is not portable, and the rule is
+ * really a product rule. A technician who taps start on a second job has
+ * moved on from the first, so the open one is closed rather than refused.
+ * Refusing would leave them staring at an error in a bay with oily gloves.
+ */
+export async function startEntry(args: {
+  organizationId: string
+  technicianId: string
+  technicianIds: string[]
+  serviceRecordId: string
+  source?: string
+}) {
+  const { organizationId, technicianId, technicianIds, serviceRecordId } = args
+
+  const job = await db.serviceRecord.findFirst({
+    where: { id: serviceRecordId, organizationId },
+    select: { id: true },
+  })
+  if (!job) {
+    throw new TimeEntryError('job_not_found', 'That job does not exist in this workshop.')
+  }
+
+  const open = await getOpenEntry(organizationId, technicianIds)
+
+  // Tapping start on the job already running is a no-op, not a restart. The
+  // alternative loses the elapsed time to a double tap on a cold morning.
+  if (open?.serviceRecordId === serviceRecordId) {
+    return { entry: open, closed: null }
+  }
+
+  const now = new Date()
+
+  return db.$transaction(async (tx) => {
+    let closed = null
+    if (open) {
+      closed = await tx.timeEntry.update({
+        where: { id: open.id },
+        data: { endedAt: now, durationMinutes: durationMinutes(open.startedAt, now) },
+        select: { id: true, serviceRecordId: true, durationMinutes: true },
+      })
+    }
+
+    const entry = await tx.timeEntry.create({
+      data: {
+        organizationId,
+        technicianId,
+        serviceRecordId,
+        startedAt: now,
+        source: args.source ?? 'app',
+      },
+      select: {
+        id: true,
+        startedAt: true,
+        technicianId: true,
+        serviceRecordId: true,
+        serviceRecord: { select: { id: true, title: true, status: true } },
+      },
+    })
+
+    return { entry, closed }
+  })
+}
+
+/** Stop whatever is running. Idempotent: stopping nothing is not an error the app should have to handle twice. */
+export async function stopEntry(args: {
+  organizationId: string
+  technicianIds: string[]
+  note?: string
+}) {
+  const open = await getOpenEntry(args.organizationId, args.technicianIds)
+  if (!open) {
+    throw new TimeEntryError('not_running', 'No clock is running.')
+  }
+
+  const now = new Date()
+  return db.timeEntry.update({
+    where: { id: open.id },
+    data: {
+      endedAt: now,
+      durationMinutes: durationMinutes(open.startedAt, now),
+      note: args.note?.trim() || null,
+    },
+    select: {
+      id: true,
+      startedAt: true,
+      endedAt: true,
+      durationMinutes: true,
+      serviceRecordId: true,
+      note: true,
+    },
+  })
+}
+
+/**
+ * Everything the technician clocked between two instants.
+ *
+ * The caller passes the range rather than the server assuming "today",
+ * because the phone knows the technician's timezone and the server does not.
+ * A shift that starts at 22:00 belongs to the day the technician says it does.
+ */
+export async function listEntries(args: {
+  organizationId: string
+  technicianIds: string[]
+  from: Date
+  to: Date
+}) {
+  if (args.technicianIds.length === 0) return []
+  return db.timeEntry.findMany({
+    where: {
+      organizationId: args.organizationId,
+      technicianId: { in: args.technicianIds },
+      startedAt: { gte: args.from, lt: args.to },
+    },
+    orderBy: { startedAt: 'desc' },
+    select: {
+      id: true,
+      startedAt: true,
+      endedAt: true,
+      durationMinutes: true,
+      note: true,
+      source: true,
+      serviceRecord: {
+        select: {
+          id: true,
+          title: true,
+          vehicle: { select: { make: true, model: true, licensePlate: true } },
+        },
+      },
+    },
+  })
+}

+ 12 - 99
src/features/vehicles/Actions/addPartToServiceRecord.ts

@@ -1,111 +1,24 @@
 'use server'
 
-import { db } from '@/lib/db'
-import { withAuth } from '@/lib/with-auth'
-import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { revalidatePath } from 'next/cache'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
 import { onInventoryChanged } from '@/features/inventory/Lib/onInventoryChanged'
-import { calculateTotals } from '@/lib/tax'
-import { reconcileInventoryForParts } from '@/features/inventory/Lib/reconcileStock'
+import { addPart, type AddPartInput } from '../Lib/addPart'
 
-export async function addPartToServiceRecord(input: {
-  serviceRecordId: string
-  partNumber?: string
-  name: string
-  quantity: number
-  unit?: string | null
-  unitPrice: number
-  total: number
-  unitCost: number
-  inventoryPartId?: string
-}) {
+/**
+ * Web-side wrapper. The work itself lives in `../Lib/addPart` so the
+ * technician API adds parts the same way, stock movements included.
+ */
+export async function addPartToServiceRecord(input: AddPartInput) {
   return withAuth(
     async ({ userId, organizationId }) => {
-      const record = await db.serviceRecord.findFirst({
-        where: { id: input.serviceRecordId, organizationId },
-        select: {
-          id: true,
-          vehicleId: true,
-          subtotal: true,
-          taxRate: true,
-          taxInclusive: true,
-          discountType: true,
-          discountValue: true,
-          title: true,
-          invoiceNumber: true,
-        },
-      })
-      if (!record) throw new Error('Service record not found')
-
-      // Create the part, recalculate totals and deduct inventory stock in one
-      // transaction so the line and its stock movement commit together.
-      const part = await db.$transaction(async (tx) => {
-        const created = await tx.servicePart.create({
-          data: {
-            partNumber: input.partNumber || null,
-            name: input.name,
-            quantity: input.quantity,
-            unit: input.unit || null,
-            unitPrice: input.unitPrice,
-            total: input.total,
-            unitCost: input.unitCost,
-            inventoryPartId: input.inventoryPartId || null,
-            serviceRecordId: record.id,
-          },
-        })
-
-        // Recalculate totals
-        const [partsAgg, laborAgg] = await Promise.all([
-          tx.servicePart.aggregate({
-            where: { serviceRecordId: record.id },
-            _sum: { total: true },
-          }),
-          tx.serviceLabor.aggregate({
-            where: { serviceRecordId: record.id },
-            _sum: { total: true },
-          }),
-        ])
-
-        const subtotal = (partsAgg._sum.total || 0) + (laborAgg._sum.total || 0)
-        const discountAmount =
-          record.discountType === 'percentage'
-            ? subtotal * ((record.discountValue ?? 0) / 100)
-            : record.discountType === 'fixed'
-              ? Math.min(record.discountValue ?? 0, subtotal)
-              : 0
-        const { taxAmount, totalAmount } = calculateTotals({
-          subtotal,
-          discountAmount,
-          taxRate: record.taxRate,
-          taxInclusive: record.taxInclusive,
-        })
-
-        await tx.serviceRecord.update({
-          where: { id: record.id },
-          data: { subtotal, taxAmount, totalAmount },
-        })
-
-        // Deduct inventory stock for the newly added line (delta from empty).
-        await reconcileInventoryForParts(
-          tx,
-          organizationId,
-          [],
-          [{ inventoryPartId: input.inventoryPartId, quantity: input.quantity }],
-          {
-            reason: 'service_record',
-            userId,
-            serviceRecordId: record.id,
-            serviceRecordLabel: record.invoiceNumber || record.title,
-          }
-        )
-
-        return created
-      })
+      const { part, vehicleId } = await addPart({ organizationId, userId, input })
 
       revalidatePath(
-        record.vehicleId
-          ? `/vehicles/${record.vehicleId}/service/${record.id}`
-          : `/sales/${record.id}`
+        vehicleId
+          ? `/vehicles/${vehicleId}/service/${input.serviceRecordId}`
+          : `/sales/${input.serviceRecordId}`
       )
       if (input.inventoryPartId) await onInventoryChanged(organizationId)
 

+ 124 - 0
src/features/vehicles/Lib/addPart.ts

@@ -0,0 +1,124 @@
+import { db } from '@/lib/db'
+import { calculateTotals } from '@/lib/tax'
+import { reconcileInventoryForParts } from '@/features/inventory/Lib/reconcileStock'
+
+/**
+ * Adds a part line to a job, recalculates the job's money, and moves the stock.
+ *
+ * Lifted out of the server action so the technician API can do exactly the
+ * same thing. Two implementations of "add a part" would drift on the part that
+ * matters least visibly and most expensively: whether stock actually moved.
+ *
+ * Everything happens in one transaction. A line that exists without its stock
+ * movement is a part the shop thinks it still has on the shelf.
+ */
+
+export interface AddPartInput {
+  serviceRecordId: string
+  partNumber?: string | null
+  name: string
+  quantity: number
+  unit?: string | null
+  unitPrice: number
+  total: number
+  unitCost: number
+  inventoryPartId?: string | null
+}
+
+export class AddPartError extends Error {
+  constructor(
+    public code: 'job_not_found',
+    message: string
+  ) {
+    super(message)
+    this.name = 'AddPartError'
+  }
+}
+
+export async function addPart(args: {
+  organizationId: string
+  userId: string
+  input: AddPartInput
+}) {
+  const { organizationId, userId, input } = args
+
+  const record = await db.serviceRecord.findFirst({
+    where: { id: input.serviceRecordId, organizationId },
+    select: {
+      id: true,
+      vehicleId: true,
+      subtotal: true,
+      taxRate: true,
+      taxInclusive: true,
+      discountType: true,
+      discountValue: true,
+      title: true,
+      invoiceNumber: true,
+    },
+  })
+  if (!record) throw new AddPartError('job_not_found', 'Service record not found')
+
+  const part = await db.$transaction(async (tx) => {
+    const created = await tx.servicePart.create({
+      data: {
+        partNumber: input.partNumber || null,
+        name: input.name,
+        quantity: input.quantity,
+        unit: input.unit || null,
+        unitPrice: input.unitPrice,
+        total: input.total,
+        unitCost: input.unitCost,
+        inventoryPartId: input.inventoryPartId || null,
+        serviceRecordId: record.id,
+      },
+    })
+
+    const [partsAgg, laborAgg] = await Promise.all([
+      tx.servicePart.aggregate({
+        where: { serviceRecordId: record.id },
+        _sum: { total: true },
+      }),
+      tx.serviceLabor.aggregate({
+        where: { serviceRecordId: record.id },
+        _sum: { total: true },
+      }),
+    ])
+
+    const subtotal = (partsAgg._sum.total || 0) + (laborAgg._sum.total || 0)
+    const discountAmount =
+      record.discountType === 'percentage'
+        ? subtotal * ((record.discountValue ?? 0) / 100)
+        : record.discountType === 'fixed'
+          ? Math.min(record.discountValue ?? 0, subtotal)
+          : 0
+    const { taxAmount, totalAmount } = calculateTotals({
+      subtotal,
+      discountAmount,
+      taxRate: record.taxRate,
+      taxInclusive: record.taxInclusive,
+    })
+
+    await tx.serviceRecord.update({
+      where: { id: record.id },
+      data: { subtotal, taxAmount, totalAmount },
+    })
+
+    // Deduct stock for the newly added line (delta from empty).
+    await reconcileInventoryForParts(
+      tx,
+      organizationId,
+      [],
+      [{ inventoryPartId: input.inventoryPartId ?? undefined, quantity: input.quantity }],
+      {
+        reason: 'service_record',
+        userId,
+        serviceRecordId: record.id,
+        serviceRecordLabel: record.invoiceNumber || record.title,
+      }
+    )
+
+    return created
+  })
+
+  return { part, vehicleId: record.vehicleId }
+}

+ 18 - 0
src/features/workboard/Actions/boardActions/assignments.ts

@@ -5,6 +5,7 @@ import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { revalidatePath } from 'next/cache'
 import { notificationBus } from '@/lib/notification-bus'
+import { pushToTechnician } from '@/features/notifications/Lib/pushToTechnician'
 import {
   assignTechnicianSchema,
   moveJobSchema,
@@ -181,6 +182,23 @@ export async function assignTechnician(input: unknown) {
         job,
       })
 
+      // Tells the technician's phone. Not awaited: assigning a job succeeded
+      // the moment the row was written, and a push service having a bad minute
+      // must not turn that into an error on the board.
+      void pushToTechnician({
+        organizationId,
+        technicianId: data.technicianId,
+        message: {
+          title: 'New job assignment',
+          // Plate and job together, matching the running-clock notification.
+          // A plate says which car without saying what to do to it; a title
+          // says the opposite. On a lock screen the technician gets one look.
+          body: [job.vehicle?.licensePlate?.trim(), job.title].filter(Boolean).join(' · '),
+          // Read by the app to open the job rather than just the job list.
+          data: data.type === 'serviceRecord' ? { jobId: data.id } : {},
+        },
+      })
+
       revalidatePath('/work-board')
       return job
     },

+ 32 - 38
src/lib/audit.ts

@@ -1,6 +1,6 @@
-import { db } from "@/lib/db";
-import { createTranslator } from "next-intl";
-import enAudit from "../../messages/en/audit.json";
+import { db } from '@/lib/db'
+import { createTranslator } from 'next-intl'
+import enAudit from '../../messages/en/audit.json'
 
 /**
  * The readable half of an audit row, as ingredients rather than a sentence.
@@ -12,9 +12,9 @@ import enAudit from "../../messages/en/audit.json";
  */
 export type AuditDetails = {
   /** A key under `summary` in messages/<locale>/audit.json. */
-  key: string;
-  params?: Record<string, string | number>;
-};
+  key: string
+  params?: Record<string, string | number>
+}
 
 /**
  * Names an audit sentence and its values.
@@ -24,11 +24,8 @@ export type AuditDetails = {
  * parameter record, and the error that produces points at the call site
  * rather than at the cause. This gives both branches the same type.
  */
-export function auditDetails(
-  key: string,
-  params?: Record<string, string | number>,
-): AuditDetails {
-  return { key, params };
+export function auditDetails(key: string, params?: Record<string, string | number>): AuditDetails {
+  return { key, params }
 }
 
 /**
@@ -42,48 +39,45 @@ export function auditDetails(
 function renderEnglish(details: AuditDetails): string | null {
   try {
     const translate = createTranslator({
-      locale: "en",
+      locale: 'en',
       messages: { audit: enAudit },
-      namespace: "audit.summary",
+      namespace: 'audit.summary',
     } as Parameters<typeof createTranslator>[0]) as unknown as (
       key: string,
-      values?: Record<string, string | number>,
-    ) => string;
-    return translate(details.key, details.params);
+      values?: Record<string, string | number>
+    ) => string
+    return translate(details.key, details.params)
   } catch {
     // An unknown key must not lose the audit row. The key itself is a better
     // record than nothing, and the missing-key test below catches it in CI.
-    return details.key;
+    return details.key
   }
 }
 
 export type AuditEvent = {
-  action: string;
-  entity?: string;
-  entityId?: string;
+  action: string
+  entity?: string
+  entityId?: string
   /**
    * Pre-composed English. Only for events with nothing to translate; prefer
    * `details`, which reads in the viewer's own language.
    */
-  message?: string;
-  details?: AuditDetails;
+  message?: string
+  details?: AuditDetails
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  metadata?: Record<string, any> | null;
-  ip?: string | null;
-  userAgent?: string | null;
-};
+  metadata?: Record<string, any> | null
+  ip?: string | null
+  userAgent?: string | null
+}
 
-export async function logAudit(
-  ctx: { userId: string; organizationId: string },
-  event: AuditEvent,
-) {
-  const message = event.details ? renderEnglish(event.details) : (event.message ?? null);
+export async function logAudit(ctx: { userId: string; organizationId: string }, event: AuditEvent) {
+  const message = event.details ? renderEnglish(event.details) : (event.message ?? null)
   // Carried in metadata rather than its own column, so no existing row has to
   // be migrated and an older deployment reading the same database still finds
   // everything it expects.
   const metadata = event.details
     ? { ...(event.metadata ?? {}), details: event.details }
-    : event.metadata;
+    : event.metadata
 
   try {
     await db.auditLog.create({
@@ -98,17 +92,17 @@ export async function logAudit(
         ip: event.ip ?? null,
         userAgent: event.userAgent ?? null,
       },
-    });
+    })
   } catch (err) {
     // Don't block core flows due to logging failure
-    console.error("[audit] failed to write log:", err);
+    console.error('[audit] failed to write log:', err)
   }
 
   // Fan out to webhooks. Lazy-imported to avoid pulling Prisma webhook code
   // into modules that only need audit logging (and to break a potential
   // circular import if webhooks ever logs audits of its own).
   if (ctx.organizationId && event.action) {
-    import("@/features/webhooks/Lib/dispatcher")
+    import('@/features/webhooks/Lib/dispatcher')
       .then(({ dispatchWebhookEvent }) =>
         dispatchWebhookEvent({
           event: event.action,
@@ -118,10 +112,10 @@ export async function logAudit(
           message,
           data: metadata ?? null,
           userId: ctx.userId || null,
-        }),
+        })
       )
       .catch((err) => {
-        console.error("[webhooks] dispatch failed:", err);
-      });
+        console.error('[webhooks] dispatch failed:', err)
+      })
   }
 }

+ 7 - 7
src/lib/auth-client.ts

@@ -1,17 +1,17 @@
-import { passkeyClient } from "@better-auth/passkey/client";
-import { createAuthClient } from "better-auth/react";
-import { twoFactorClient } from "better-auth/plugins/two-factor";
+import { passkeyClient } from '@better-auth/passkey/client'
+import { createAuthClient } from 'better-auth/react'
+import { twoFactorClient } from 'better-auth/plugins/two-factor'
 
 export const authClient = createAuthClient({
-  basePath: "/api/public/auth",
+  basePath: '/api/public/auth',
   plugins: [
     twoFactorClient({
       onTwoFactorRedirect: () => {
-        window.location.href = "/auth/verify-2fa";
+        window.location.href = '/auth/verify-2fa'
       },
     }),
     passkeyClient(),
   ],
-});
+})
 
-export const { signIn, signUp, signOut, useSession } = authClient;
+export const { signIn, signUp, signOut, useSession } = authClient

+ 55 - 7
src/lib/auth.ts

@@ -2,6 +2,7 @@ import { passkey } from '@better-auth/passkey'
 import { betterAuth } from 'better-auth'
 import { prismaAdapter } from 'better-auth/adapters/prisma'
 import { nextCookies } from 'better-auth/next-js'
+import { bearer } from 'better-auth/plugins/bearer'
 import { twoFactor } from 'better-auth/plugins/two-factor'
 import { db } from './db'
 import { logAudit } from './audit'
@@ -10,10 +11,51 @@ import { isDemoMode } from './demo'
 const baseURL = process.env.NEXT_PUBLIC_APP_URL
 const isProduction = baseURL?.startsWith('https://')
 
+/**
+ * Origins allowed to sign in.
+ *
+ * Production trusts only the app's own URL. The technician app is native and
+ * sends no Origin header, so it needs nothing added here.
+ *
+ * Development also trusts the Expo dev server, which serves the technician app
+ * in a browser. Without this, signing in from Expo web is refused with
+ * "Invalid origin" and the app looks like it rejected the password, when what
+ * actually happened is a CSRF check doing its job.
+ *
+ * Wildcarded on the port rather than pinned, because Expo walks up from 8081
+ * whenever a port is busy and a pinned list goes stale the first time two dev
+ * servers overlap. Better Auth matches these as glob patterns.
+ */
+const EXPO_DEV_ORIGINS = [
+  'http://localhost:*',
+  'http://127.0.0.1:*',
+  // Expo also serves on the machine's LAN address, which is the same host the
+  // workshop is reached on during development. Derived rather than hardcoded
+  // so this keeps working on a different network.
+  ...devLanOrigin(),
+  ...(process.env.EXPO_DEV_ORIGIN ? [process.env.EXPO_DEV_ORIGIN] : []),
+]
+
+function devLanOrigin(): string[] {
+  if (!baseURL) return []
+  try {
+    const { hostname } = new URL(baseURL)
+    if (hostname === 'localhost' || hostname === '127.0.0.1') return []
+    return [`http://${hostname}:*`]
+  } catch {
+    return []
+  }
+}
+
+const trustedOrigins = [
+  ...(baseURL ? [baseURL] : []),
+  ...(process.env.NODE_ENV === 'production' ? [] : EXPO_DEV_ORIGINS),
+]
+
 export const auth = betterAuth({
   baseURL,
   basePath: '/api/public/auth',
-  trustedOrigins: baseURL ? [baseURL] : [],
+  trustedOrigins,
   database: prismaAdapter(db, {
     provider: 'postgresql',
   }),
@@ -133,7 +175,7 @@ export const auth = betterAuth({
       // Cloudflare (cf-connecting-ip); staging and self-hosted installs hit
       // nginx directly, which sets x-real-ip. Without this, Better Auth falls
       // back to one shared rate-limit bucket for the entire userbase.
-      ipAddressHeaders: ["cf-connecting-ip", "x-real-ip"],
+      ipAddressHeaders: ['cf-connecting-ip', 'x-real-ip'],
     },
   },
   databaseHooks: {
@@ -155,10 +197,12 @@ export const auth = betterAuth({
             {
               action: 'auth.login',
               message: 'User logged in',
-              ip: (session as Record<string, unknown>).ipAddress as string ?? null,
-              userAgent: (session as Record<string, unknown>).userAgent as string ?? null,
-            },
-          ).catch(() => { /* best-effort */ })
+              ip: ((session as Record<string, unknown>).ipAddress as string) ?? null,
+              userAgent: ((session as Record<string, unknown>).userAgent as string) ?? null,
+            }
+          ).catch(() => {
+            /* best-effort */
+          })
         },
       },
     },
@@ -200,12 +244,16 @@ export const auth = betterAuth({
               data: { termsAcceptedAt: new Date() },
             })
           }
-
         },
       },
     },
   },
   plugins: [
+    // Lets the technician app authenticate with `Authorization: Bearer <token>`
+    // instead of a cookie. Session lookup, expiry and revocation stay inside
+    // Better Auth rather than being reimplemented against the session table,
+    // so signing out on the web really does kill the phone's session too.
+    bearer(),
     twoFactor({ issuer: 'Torqvoice' }),
     passkey({
       rpID: baseURL ? new URL(baseURL).hostname : 'localhost',

+ 10 - 10
src/lib/backup-heartbeat.ts

@@ -1,10 +1,10 @@
-import { db } from "@/lib/db";
+import { db } from '@/lib/db'
 
-export const BACKUP_HEARTBEAT_KEY = "backup.heartbeat";
+export const BACKUP_HEARTBEAT_KEY = 'backup.heartbeat'
 
 export interface BackupHeartbeat {
-  at: string;
-  snapshotId: string | null;
+  at: string
+  snapshotId: string | null
 }
 
 /**
@@ -14,13 +14,13 @@ export interface BackupHeartbeat {
 export async function getBackupHeartbeat(): Promise<BackupHeartbeat | null> {
   const row = await db.systemSetting.findUnique({
     where: { key: BACKUP_HEARTBEAT_KEY },
-  });
-  if (!row) return null;
+  })
+  if (!row) return null
   try {
-    const parsed = JSON.parse(row.value);
-    if (typeof parsed?.at !== "string") return null;
-    return { at: parsed.at, snapshotId: parsed.snapshotId ?? null };
+    const parsed = JSON.parse(row.value)
+    if (typeof parsed?.at !== 'string') return null
+    return { at: parsed.at, snapshotId: parsed.snapshotId ?? null }
   } catch {
-    return null;
+    return null
   }
 }

+ 3 - 0
src/lib/backup/manifest.ts

@@ -78,6 +78,7 @@ export const BACKUP_ENTITIES: readonly BackupEntity[] = [
     clearOrder: 48,
   },
   { model: 'StatusReport', option: 'vehicles', nestedUnder: 'ServiceRecord', restore: 'replace' },
+  { model: 'TimeEntry', option: 'vehicles', nestedUnder: 'ServiceRecord', restore: 'replace' },
   {
     model: 'Reminder',
     key: 'orgReminders',
@@ -273,6 +274,8 @@ export const EXCLUDED_MODELS: Readonly<Record<string, string>> = {
   CustomerSmsCode: 'One-time code, valid for minutes.',
   DashboardWidget: 'Per-user dashboard layout, tied to user accounts a backup does not carry.',
   OrganizationMember: 'Membership of user accounts; people are restored by inviting them.',
+  PushDevice:
+    'Push token bound to one phone and one user account, and a backup carries neither. The app registers a new one on next launch.',
   Subscription: 'Billing state owned by Stripe, not by us.',
   TeamInvitation: 'Pending invitation, expires on its own.',
   WebhookDelivery: 'Delivery log for a webhook, rewritten every time one fires.',

+ 1 - 3
src/lib/broadcast.ts

@@ -25,9 +25,7 @@ const CUSTOMER_FACING = ['/portal', '/share', '/terms']
 
 export function isCustomerFacingPath(pathname: string | null | undefined): boolean {
   if (!pathname) return false
-  return CUSTOMER_FACING.some(
-    (root) => pathname === root || pathname.startsWith(`${root}/`)
-  )
+  return CUSTOMER_FACING.some((root) => pathname === root || pathname.startsWith(`${root}/`))
 }
 
 export type Broadcast = {

+ 27 - 13
src/lib/cached-session.ts

@@ -1,15 +1,29 @@
-import { cache } from "react";
-import { cookies, headers } from "next/headers";
-import { auth } from "./auth";
-import { db } from "./db";
+import { cache } from 'react'
+import { cookies, headers } from 'next/headers'
+import { auth } from './auth'
+import { db } from './db'
 
 export const getCachedSession = cache(async () => {
-  return auth.api.getSession({ headers: await headers() });
-});
+  return auth.api.getSession({ headers: await headers() })
+})
 
 export const getCachedMembership = cache(async (userId: string) => {
-  const cookieStore = await cookies();
-  const activeOrgCookie = cookieStore.get("active-org-id")?.value;
+  const cookieStore = await cookies()
+
+  // Which workshop the caller means.
+  //
+  // The web app says so with a cookie. The technician app cannot: it holds a
+  // bearer token and sends no cookies at all, so it names the workshop in a
+  // header instead. Reading only the cookie meant every part of the app
+  // outside `withApiAuth` silently resolved a multi-workshop user to whichever
+  // membership came back first — so a technician viewing a photo from their
+  // second workshop was refused it, and their live-update socket subscribed to
+  // the wrong one.
+  //
+  // Neither value grants anything. Both only select, and the membership lookup
+  // below is what decides.
+  const activeOrgHeader = (await headers()).get('x-org-id') ?? undefined
+  const activeOrgCookie = activeOrgHeader ?? cookieStore.get('active-org-id')?.value
 
   const select = {
     organizationId: true,
@@ -18,15 +32,15 @@ export const getCachedMembership = cache(async (userId: string) => {
     customRole: {
       select: { isAdmin: true, permissions: { select: { action: true, subject: true } } },
     },
-  } as const;
+  } as const
 
   if (activeOrgCookie) {
     const m = await db.organizationMember.findFirst({
       where: { userId, organizationId: activeOrgCookie },
       select,
-    });
-    if (m) return m;
+    })
+    if (m) return m
   }
 
-  return db.organizationMember.findFirst({ where: { userId }, select });
-});
+  return db.organizationMember.findFirst({ where: { userId }, select })
+})

+ 28 - 34
src/lib/compress-image.ts

@@ -5,53 +5,47 @@
  * Defaults tuned for invoice/history usage where full-resolution originals
  * are unnecessary — 1200px covers print-quality PDFs and on-screen viewing.
  */
-export function compressImage(
-  file: File,
-  maxWidth = 1200,
-  quality = 0.7,
-): Promise<File> {
+export function compressImage(file: File, maxWidth = 1200, quality = 0.7): Promise<File> {
   return new Promise((resolve) => {
-    if (!file.type.startsWith("image/") || file.size < 200 * 1024) {
-      resolve(file);
-      return;
+    if (!file.type.startsWith('image/') || file.size < 200 * 1024) {
+      resolve(file)
+      return
     }
 
-    const img = new window.Image();
+    const img = new window.Image()
     img.onload = () => {
-      let { width, height } = img;
+      let { width, height } = img
       if (width > maxWidth) {
-        height = Math.round(height * (maxWidth / width));
-        width = maxWidth;
+        height = Math.round(height * (maxWidth / width))
+        width = maxWidth
       }
 
-      const canvas = document.createElement("canvas");
-      canvas.width = width;
-      canvas.height = height;
-      const ctx = canvas.getContext("2d");
+      const canvas = document.createElement('canvas')
+      canvas.width = width
+      canvas.height = height
+      const ctx = canvas.getContext('2d')
       if (!ctx) {
-        resolve(file);
-        return;
+        resolve(file)
+        return
       }
 
-      ctx.drawImage(img, 0, 0, width, height);
+      ctx.drawImage(img, 0, 0, width, height)
       canvas.toBlob(
         (blob) => {
           if (!blob || blob.size >= file.size) {
-            resolve(file);
-            return;
+            resolve(file)
+            return
           }
-          const compressed = new File(
-            [blob],
-            file.name.replace(/\.\w+$/, ".jpg"),
-            { type: "image/jpeg" },
-          );
-          resolve(compressed);
+          const compressed = new File([blob], file.name.replace(/\.\w+$/, '.jpg'), {
+            type: 'image/jpeg',
+          })
+          resolve(compressed)
         },
-        "image/jpeg",
-        quality,
-      );
-    };
-    img.onerror = () => resolve(file);
-    img.src = URL.createObjectURL(file);
-  });
+        'image/jpeg',
+        quality
+      )
+    }
+    img.onerror = () => resolve(file)
+    img.src = URL.createObjectURL(file)
+  })
 }

+ 22 - 4
src/lib/cron/check-licenses.ts

@@ -37,7 +37,12 @@ export async function revalidateOrganizationLicense(organizationId: string, lice
     db.appSetting.upsert({
       where: { organizationId_key: { organizationId, key: 'license.valid' } },
       update: { value: String(valid) },
-      create: { userId: orgMember.userId, organizationId, key: 'license.valid', value: String(valid) },
+      create: {
+        userId: orgMember.userId,
+        organizationId,
+        key: 'license.valid',
+        value: String(valid),
+      },
     }),
     db.appSetting.upsert({
       where: { organizationId_key: { organizationId, key: 'license.checkedAt' } },
@@ -56,7 +61,12 @@ export async function revalidateOrganizationLicense(organizationId: string, lice
       db.appSetting.upsert({
         where: { organizationId_key: { organizationId, key: 'license.expiresAt' } },
         update: { value: expiresAt },
-        create: { userId: orgMember.userId, organizationId, key: 'license.expiresAt', value: expiresAt },
+        create: {
+          userId: orgMember.userId,
+          organizationId,
+          key: 'license.expiresAt',
+          value: expiresAt,
+        },
       })
     )
   }
@@ -93,7 +103,12 @@ export async function sendExpiryWarning(organizationId: string, daysLeft: number
   await db.appSetting.upsert({
     where: { organizationId_key: { organizationId, key: 'license.lastExpiryWarning' } },
     update: { value: today },
-    create: { userId: orgMember.userId, organizationId, key: 'license.lastExpiryWarning', value: today },
+    create: {
+      userId: orgMember.userId,
+      organizationId,
+      key: 'license.lastExpiryWarning',
+      value: today,
+    },
   })
 
   // In-app notification
@@ -148,7 +163,10 @@ export function checkLicenses() {
         try {
           await revalidateOrganizationLicense(setting.organizationId, setting.value)
         } catch (error) {
-          console.error(`[cron] Failed to revalidate license for org ${setting.organizationId}:`, error)
+          console.error(
+            `[cron] Failed to revalidate license for org ${setting.organizationId}:`,
+            error
+          )
         }
       }
     } catch (error) {

+ 35 - 6
src/lib/cron/check-subscriptions.ts

@@ -28,7 +28,16 @@ function resolveLicensePlan(internalStatus: string, planName: string): string {
 
 async function syncSubscription(
   stripe: Stripe,
-  sub: { id: string; organizationId: string; stripeSubscriptionId: string | null; status: string; currentPeriodStart: Date | null; currentPeriodEnd: Date | null; cancelAtPeriodEnd: boolean; plan: { name: string } },
+  sub: {
+    id: string
+    organizationId: string
+    stripeSubscriptionId: string | null
+    status: string
+    currentPeriodStart: Date | null
+    currentPeriodEnd: Date | null
+    cancelAtPeriodEnd: boolean
+    plan: { name: string }
+  }
 ) {
   const stripeSub = await stripe.subscriptions.retrieve(sub.stripeSubscriptionId!)
 
@@ -61,13 +70,25 @@ async function syncSubscription(
   await db.$transaction([
     db.subscription.update({
       where: { id: sub.id },
-      data: { status: newStatus, currentPeriodStart: periodStart, currentPeriodEnd: periodEnd, cancelAtPeriodEnd },
+      data: {
+        status: newStatus,
+        currentPeriodStart: periodStart,
+        currentPeriodEnd: periodEnd,
+        cancelAtPeriodEnd,
+      },
     }),
     ...(orgMember
       ? [
           db.appSetting.upsert({
-            where: { organizationId_key: { organizationId: sub.organizationId, key: 'license.plan' } },
-            create: { organizationId: sub.organizationId, key: 'license.plan', value: licensePlan, userId: orgMember.userId },
+            where: {
+              organizationId_key: { organizationId: sub.organizationId, key: 'license.plan' },
+            },
+            create: {
+              organizationId: sub.organizationId,
+              key: 'license.plan',
+              value: licensePlan,
+              userId: orgMember.userId,
+            },
             update: { value: licensePlan },
           }),
         ]
@@ -89,7 +110,12 @@ async function cancelOrphanedSubscription(subId: string, organizationId: string)
       ? [
           db.appSetting.upsert({
             where: { organizationId_key: { organizationId, key: 'license.plan' } },
-            create: { organizationId, key: 'license.plan', value: 'free', userId: orgMember.userId },
+            create: {
+              organizationId,
+              key: 'license.plan',
+              value: 'free',
+              userId: orgMember.userId,
+            },
             update: { value: 'free' },
           }),
         ]
@@ -109,7 +135,10 @@ export function checkSubscriptions() {
 
       const stripe = await getStripeClient()
       const subscriptions = await db.subscription.findMany({
-        where: { status: { in: ['active', 'past_due', 'trialing'] }, stripeSubscriptionId: { not: null } },
+        where: {
+          status: { in: ['active', 'past_due', 'trialing'] },
+          stripeSubscriptionId: { not: null },
+        },
         include: { plan: true },
       })
 

+ 3 - 1
src/lib/cron/cleanup-audit-logs.ts

@@ -21,7 +21,9 @@ export function cleanupAuditLogs() {
       })
 
       if (result.count > 0) {
-        console.warn(`[cron] Audit log cleanup: deleted ${result.count} logs older than ${retentionDays} days`)
+        console.warn(
+          `[cron] Audit log cleanup: deleted ${result.count} logs older than ${retentionDays} days`
+        )
       }
     } catch (error) {
       console.error('[cron] Audit log cleanup failed:', error)

+ 1 - 4
src/lib/cron/cleanup-portal-sessions.ts

@@ -13,10 +13,7 @@ export function cleanupPortalSessions() {
         }),
         db.customerMagicLink.deleteMany({
           where: {
-            OR: [
-              { expiresAt: { lt: now } },
-              { usedAt: { not: null } },
-            ],
+            OR: [{ expiresAt: { lt: now } }, { usedAt: { not: null } }],
           },
         }),
       ])

+ 14 - 4
src/lib/cron/recurring-invoices.ts

@@ -28,7 +28,10 @@ function calculateNextRunDate(current: Date, frequency: string): Date {
 
 async function generateInvoiceNumber(organizationId: string): Promise<string> {
   const settings = await db.appSetting.findMany({
-    where: { organizationId, key: { in: ['workshop.invoicePrefix', 'workshop.invoiceStartNumber'] } },
+    where: {
+      organizationId,
+      key: { in: ['workshop.invoicePrefix', 'workshop.invoiceStartNumber'] },
+    },
   })
 
   const settingsMap: Record<string, string> = {}
@@ -110,13 +113,20 @@ export function processRecurringInvoices() {
                 vehicleId: ri.vehicleId,
                 partItems: {
                   create: ri.templateParts.map((p) => ({
-                    name: p.name, partNumber: p.partNumber,
-                    quantity: p.quantity, unitPrice: p.unitPrice, total: lineTotal(p.quantity, p.unitPrice),
+                    name: p.name,
+                    partNumber: p.partNumber,
+                    quantity: p.quantity,
+                    unitPrice: p.unitPrice,
+                    total: lineTotal(p.quantity, p.unitPrice),
                   })),
                 },
                 laborItems: {
                   create: ri.templateLabor.map((l) => ({
-                    description: l.description, hours: l.hours, rate: l.rate, total: l.hours * l.rate, pricingType: l.pricingType || "hourly",
+                    description: l.description,
+                    hours: l.hours,
+                    rate: l.rate,
+                    total: l.hours * l.rate,
+                    pricingType: l.pricingType || 'hourly',
                   })),
                 },
               },

+ 71 - 73
src/lib/cron/reminder-alerts.ts

@@ -1,92 +1,90 @@
-import { CronJob } from "cron";
-import { db } from "@/lib/db";
-import { notify } from "@/lib/notify";
-import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
+import { CronJob } from 'cron'
+import { db } from '@/lib/db'
+import { notify } from '@/lib/notify'
+import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
 
-const LOG_PREFIX = "[reminder-alerts]";
+const LOG_PREFIX = '[reminder-alerts]'
 
 type DueReminder = {
-  id: string;
-  title: string;
-  description: string | null;
-  dueDate: Date | null;
-  notifyInApp: boolean;
-  notifyEmail: boolean;
-  organizationId: string | null;
-  customer: { id: string; name: string } | null;
+  id: string
+  title: string
+  description: string | null
+  dueDate: Date | null
+  notifyInApp: boolean
+  notifyEmail: boolean
+  organizationId: string | null
+  customer: { id: string; name: string } | null
   vehicle: {
-    id: string;
-    make: string;
-    model: string;
-    year: number;
-    licensePlate: string | null;
-    customer: { name: string } | null;
-  } | null;
-};
+    id: string
+    make: string
+    model: string
+    year: number
+    licensePlate: string | null
+    customer: { name: string } | null
+  } | null
+}
 
 /** What the reminder relates to: vehicle, customer, or the workshop itself. */
 function targetLabel(r: DueReminder) {
   if (r.vehicle) {
-    const base = `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}`;
-    return r.vehicle.licensePlate ? `${base} (${r.vehicle.licensePlate})` : base;
+    const base = `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}`
+    return r.vehicle.licensePlate ? `${base} (${r.vehicle.licensePlate})` : base
   }
-  return r.customer?.name ?? null;
+  return r.customer?.name ?? null
 }
 
 function targetUrl(r: DueReminder) {
-  if (r.vehicle) return `/vehicles/${r.vehicle.id}?tab=reminders`;
-  if (r.customer) return `/customers/${r.customer.id}`;
-  return "/reminders";
+  if (r.vehicle) return `/vehicles/${r.vehicle.id}?tab=reminders`
+  if (r.customer) return `/customers/${r.customer.id}`
+  return '/reminders'
 }
 
 /** Owners and admins receive the email. */
 async function getRecipients(organizationId: string) {
   const members = await db.organizationMember.findMany({
-    where: { organizationId, role: { in: ["owner", "admin"] } },
+    where: { organizationId, role: { in: ['owner', 'admin'] } },
     select: { user: { select: { email: true, name: true } } },
-  });
-  return members
-    .map((m) => m.user)
-    .filter((u): u is { email: string; name: string } => !!u?.email);
+  })
+  return members.map((m) => m.user).filter((u): u is { email: string; name: string } => !!u?.email)
 }
 
 async function sendReminderEmail(organizationId: string, reminders: DueReminder[]) {
-  const recipients = await getRecipients(organizationId);
-  if (recipients.length === 0) return;
+  const recipients = await getRecipients(organizationId)
+  if (recipients.length === 0) return
 
   // Resolving the sender reads the org's email provider settings — exactly
   // what is broken when email is misconfigured. Skip rather than invent an
   // address; the in-app notification (if chosen) has already gone out.
-  let fromAddress: string;
+  let fromAddress: string
   try {
-    fromAddress = await getOrgFromAddress(organizationId);
+    fromAddress = await getOrgFromAddress(organizationId)
   } catch (error) {
-    console.error(`${LOG_PREFIX} could not resolve sender; skipping email:`, error);
-    return;
+    console.error(`${LOG_PREFIX} could not resolve sender; skipping email:`, error)
+    return
   }
 
   const rows = reminders
     .map(
       (r) =>
         `<li style="margin-bottom:6px;"><strong>${r.title}</strong>${
-          targetLabel(r) ? ` — ${targetLabel(r)}` : ""
-        }${r.vehicle?.customer ? ` · ${r.vehicle.customer.name}` : ""}${
-          r.description ? `<br/><span style="color:#666;">${r.description}</span>` : ""
-        }</li>`,
+          targetLabel(r) ? ` — ${targetLabel(r)}` : ''
+        }${r.vehicle?.customer ? ` · ${r.vehicle.customer.name}` : ''}${
+          r.description ? `<br/><span style="color:#666;">${r.description}</span>` : ''
+        }</li>`
     )
-    .join("");
+    .join('')
 
   const subject =
     reminders.length === 1
       ? `Reminder due: ${reminders[0].title}`
-      : `${reminders.length} reminders due`;
+      : `${reminders.length} reminders due`
 
   const html = `
     <div style="font-family: sans-serif; max-width: 600px;">
       <h2 style="margin-bottom: 4px;">${subject}</h2>
       <ul style="padding-left: 18px;">${rows}</ul>
       <p style="color: #666; font-size: 13px;">Open Torqvoice to complete or reschedule the reminder.</p>
-    </div>`;
+    </div>`
 
   for (const recipient of recipients) {
     await sendOrgMail(organizationId, {
@@ -94,7 +92,7 @@ async function sendReminderEmail(organizationId: string, reminders: DueReminder[
       to: recipient.email,
       subject,
       html,
-    });
+    })
   }
 }
 
@@ -132,71 +130,71 @@ export async function processDueReminders(now = new Date()) {
       },
     },
     take: 500,
-  })) as DueReminder[];
+  })) as DueReminder[]
 
-  if (due.length === 0) return 0;
+  if (due.length === 0) return 0
 
   // Group by organization so email becomes one digest per org per run
-  const byOrg = new Map<string, DueReminder[]>();
+  const byOrg = new Map<string, DueReminder[]>()
   for (const r of due) {
-    const orgId = r.organizationId;
-    if (!orgId) continue;
-    const list = byOrg.get(orgId) ?? [];
-    list.push(r);
-    byOrg.set(orgId, list);
+    const orgId = r.organizationId
+    if (!orgId) continue
+    const list = byOrg.get(orgId) ?? []
+    list.push(r)
+    byOrg.set(orgId, list)
   }
 
-  let notified = 0;
+  let notified = 0
   for (const [organizationId, reminders] of byOrg) {
     // Bell: one notification per reminder, each linking to its vehicle
     for (const r of reminders) {
-      if (!r.notifyInApp) continue;
+      if (!r.notifyInApp) continue
       await notify({
         organizationId,
-        type: "reminder.due",
-        title: "Reminder Due",
+        type: 'reminder.due',
+        title: 'Reminder Due',
         message: targetLabel(r) ? `${r.title} — ${targetLabel(r)}` : r.title,
-        entityType: "Reminder",
+        entityType: 'Reminder',
         entityId: r.id,
         entityUrl: targetUrl(r),
-      });
+      })
     }
 
     // Email: one digest for the reminders that asked for it. Best-effort and
     // isolated — a broken mail setup must not stop notifiedAt from being set,
     // or the same reminders would be re-processed (and re-belled) every run.
-    const emailReminders = reminders.filter((r) => r.notifyEmail);
+    const emailReminders = reminders.filter((r) => r.notifyEmail)
     if (emailReminders.length > 0) {
       try {
-        await sendReminderEmail(organizationId, emailReminders);
+        await sendReminderEmail(organizationId, emailReminders)
       } catch (error) {
-        console.error(`${LOG_PREFIX} email failed for org ${organizationId}:`, error);
+        console.error(`${LOG_PREFIX} email failed for org ${organizationId}:`, error)
       }
     }
 
-    notified += reminders.length;
+    notified += reminders.length
   }
 
   await db.reminder.updateMany({
     where: { id: { in: due.map((r) => r.id) } },
     data: { notifiedAt: now },
-  });
+  })
 
-  return notified;
+  return notified
 }
 
 /** Hourly scan for reminders that have come due. */
 export function checkDueReminders() {
-  const job = new CronJob("25 * * * *", async () => {
+  const job = new CronJob('25 * * * *', async () => {
     try {
-      const notified = await processDueReminders();
+      const notified = await processDueReminders()
       if (notified > 0) {
-        console.warn(`${LOG_PREFIX} notified on ${notified} due reminder(s)`);
+        console.warn(`${LOG_PREFIX} notified on ${notified} due reminder(s)`)
       }
     } catch (error) {
-      console.error(`${LOG_PREFIX} scan failed:`, error);
+      console.error(`${LOG_PREFIX} scan failed:`, error)
     }
-  });
-  job.start();
-  console.warn(`${LOG_PREFIX} Due-reminder processor started (hourly)`);
+  })
+  job.start()
+  console.warn(`${LOG_PREFIX} Due-reminder processor started (hourly)`)
 }

File diff suppressed because it is too large
+ 584 - 271
src/lib/cron/report-schedules.ts


+ 32 - 32
src/lib/cron/scheduled-messages.ts

@@ -1,14 +1,14 @@
-import { CronJob } from "cron";
-import { db } from "@/lib/db";
+import { CronJob } from 'cron'
+import { db } from '@/lib/db'
 import {
   dispatchScheduledMessage,
   nextSendAt,
-} from "@/features/scheduled-messages/Lib/dispatchScheduledMessage";
+} from '@/features/scheduled-messages/Lib/dispatchScheduledMessage'
 
-const LOG_PREFIX = "[scheduled-messages]";
+const LOG_PREFIX = '[scheduled-messages]'
 
 /** Nothing older than this is sent on a late start; it is marked failed instead. */
-const MAX_LATENESS_MS = 24 * 60 * 60 * 1000;
+const MAX_LATENESS_MS = 24 * 60 * 60 * 1000
 
 /**
  * Send everything whose time has come.
@@ -19,7 +19,7 @@ const MAX_LATENESS_MS = 24 * 60 * 60 * 1000;
  */
 export async function processDueMessages(now = new Date()): Promise<number> {
   const due = await db.scheduledMessage.findMany({
-    where: { status: "scheduled", sendAt: { lte: now } },
+    where: { status: 'scheduled', sendAt: { lte: now } },
     select: {
       id: true,
       channel: true,
@@ -34,15 +34,15 @@ export async function processDueMessages(now = new Date()): Promise<number> {
       endDate: true,
       runCount: true,
     },
-    orderBy: { sendAt: "asc" },
+    orderBy: { sendAt: 'asc' },
     take: 200,
-  });
+  })
 
-  let sent = 0;
+  let sent = 0
 
   for (const message of due) {
-    const isStale = now.getTime() - message.sendAt.getTime() > MAX_LATENESS_MS;
-    const following = nextSendAt(message.sendAt, message.frequency, message.endDate);
+    const isStale = now.getTime() - message.sendAt.getTime() > MAX_LATENESS_MS
+    const following = nextSendAt(message.sendAt, message.frequency, message.endDate)
 
     if (isStale) {
       // A day late is no longer the message the workshop meant to send, so it
@@ -50,61 +50,61 @@ export async function processDueMessages(now = new Date()): Promise<number> {
       await db.scheduledMessage.update({
         where: { id: message.id },
         data: {
-          status: following ? "scheduled" : "failed",
+          status: following ? 'scheduled' : 'failed',
           sendAt: following ?? message.sendAt,
           lastRunAt: now,
-          errorMessage: "Missed its send window and was skipped",
+          errorMessage: 'Missed its send window and was skipped',
         },
-      });
-      console.warn(`${LOG_PREFIX} skipped stale message ${message.id}`);
-      continue;
+      })
+      console.warn(`${LOG_PREFIX} skipped stale message ${message.id}`)
+      continue
     }
 
     try {
-      await dispatchScheduledMessage(message);
+      await dispatchScheduledMessage(message)
       await db.scheduledMessage.update({
         where: { id: message.id },
         data: {
-          status: following ? "scheduled" : "sent",
+          status: following ? 'scheduled' : 'sent',
           sendAt: following ?? message.sendAt,
           sentAt: now,
           lastRunAt: now,
           runCount: message.runCount + 1,
           errorMessage: null,
         },
-      });
-      sent++;
+      })
+      sent++
     } catch (error) {
-      const errorMessage = error instanceof Error ? error.message : "Unknown error";
+      const errorMessage = error instanceof Error ? error.message : 'Unknown error'
       await db.scheduledMessage.update({
         where: { id: message.id },
         data: {
-          status: following ? "scheduled" : "failed",
+          status: following ? 'scheduled' : 'failed',
           sendAt: following ?? message.sendAt,
           lastRunAt: now,
           runCount: message.runCount + 1,
           errorMessage,
         },
-      });
-      console.error(`${LOG_PREFIX} send failed for ${message.id}:`, errorMessage);
+      })
+      console.error(`${LOG_PREFIX} send failed for ${message.id}:`, errorMessage)
     }
   }
 
-  return sent;
+  return sent
 }
 
 /** Minute-by-minute scan, so a message goes out on the minute it was set for. */
 export function processScheduledMessages() {
-  const job = new CronJob("* * * * *", async () => {
+  const job = new CronJob('* * * * *', async () => {
     try {
-      const sent = await processDueMessages();
+      const sent = await processDueMessages()
       if (sent > 0) {
-        console.warn(`${LOG_PREFIX} sent ${sent} scheduled message(s)`);
+        console.warn(`${LOG_PREFIX} sent ${sent} scheduled message(s)`)
       }
     } catch (error) {
-      console.error(`${LOG_PREFIX} scan failed:`, error);
+      console.error(`${LOG_PREFIX} scan failed:`, error)
     }
-  });
-  job.start();
-  console.warn(`${LOG_PREFIX} Scheduled-message processor started (every minute)`);
+  })
+  job.start()
+  console.warn(`${LOG_PREFIX} Scheduled-message processor started (every minute)`)
 }

+ 23 - 26
src/lib/cron/webhook-deliveries.ts

@@ -1,9 +1,6 @@
-import { CronJob } from "cron";
-import { db } from "@/lib/db";
-import {
-  processDueDeliveries,
-  recoverStuckDeliveries,
-} from "@/features/webhooks/Lib/deliver";
+import { CronJob } from 'cron'
+import { db } from '@/lib/db'
+import { processDueDeliveries, recoverStuckDeliveries } from '@/features/webhooks/Lib/deliver'
 
 /**
  * Webhook delivery retry cron — runs every minute. First sweeps any
@@ -11,22 +8,22 @@ import {
  * picks up due retries and re-attempts them with HMAC signing.
  */
 export function processWebhookDeliveries() {
-  const job = new CronJob("* * * * *", async () => {
+  const job = new CronJob('* * * * *', async () => {
     try {
-      const recovered = await recoverStuckDeliveries();
+      const recovered = await recoverStuckDeliveries()
       if (recovered > 0) {
-        console.warn(`[cron] Recovered ${recovered} stuck webhook deliveries`);
+        console.warn(`[cron] Recovered ${recovered} stuck webhook deliveries`)
       }
-      const count = await processDueDeliveries(100);
+      const count = await processDueDeliveries(100)
       if (count > 0) {
-        console.warn(`[cron] Webhook deliveries processed: ${count}`);
+        console.warn(`[cron] Webhook deliveries processed: ${count}`)
       }
     } catch (err) {
-      console.error("[cron] Webhook delivery processor failed:", err);
+      console.error('[cron] Webhook delivery processor failed:', err)
     }
-  });
-  job.start();
-  console.warn("[cron] Webhook delivery processor started (every minute)");
+  })
+  job.start()
+  console.warn('[cron] Webhook delivery processor started (every minute)')
 }
 
 /**
@@ -34,23 +31,23 @@ export function processWebhookDeliveries() {
  * rows older than WEBHOOK_DELIVERY_RETENTION_DAYS (default 30).
  */
 export function cleanupWebhookDeliveries() {
-  const job = new CronJob("30 3 * * *", async () => {
+  const job = new CronJob('30 3 * * *', async () => {
     try {
-      const parsed = parseInt(process.env.WEBHOOK_DELIVERY_RETENTION_DAYS || "30", 10);
-      const days = Number.isFinite(parsed) && parsed > 0 ? parsed : 30;
-      const cutoff = new Date();
-      cutoff.setDate(cutoff.getDate() - days);
+      const parsed = parseInt(process.env.WEBHOOK_DELIVERY_RETENTION_DAYS || '30', 10)
+      const days = Number.isFinite(parsed) && parsed > 0 ? parsed : 30
+      const cutoff = new Date()
+      cutoff.setDate(cutoff.getDate() - days)
       const result = await db.webhookDelivery.deleteMany({
         where: { createdAt: { lt: cutoff } },
-      });
+      })
       if (result.count > 0) {
         console.warn(
-          `[cron] Webhook delivery cleanup: deleted ${result.count} rows older than ${days} days`,
-        );
+          `[cron] Webhook delivery cleanup: deleted ${result.count} rows older than ${days} days`
+        )
       }
     } catch (err) {
-      console.error("[cron] Webhook delivery cleanup failed:", err);
+      console.error('[cron] Webhook delivery cleanup failed:', err)
     }
-  });
-  job.start();
+  })
+  job.start()
 }

+ 18 - 16
src/lib/customer-session.ts

@@ -1,35 +1,37 @@
-import { cookies } from "next/headers";
-import { db } from "./db";
+import { cookies } from 'next/headers'
+import { db } from './db'
 
-export const CUSTOMER_SESSION_COOKIE = "customer-session";
-export const CUSTOMER_SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
-export const MAGIC_LINK_DURATION = 15 * 60 * 1000; // 15 minutes
+export const CUSTOMER_SESSION_COOKIE = 'customer-session'
+export const CUSTOMER_SESSION_DURATION = 7 * 24 * 60 * 60 * 1000 // 7 days
+export const MAGIC_LINK_DURATION = 15 * 60 * 1000 // 15 minutes
 
 export type CustomerSessionData = {
-  customerId: string;
-  organizationId: string;
-};
+  customerId: string
+  organizationId: string
+}
 
 export async function getCustomerSession(): Promise<CustomerSessionData | null> {
-  const cookieStore = await cookies();
-  const token = cookieStore.get(CUSTOMER_SESSION_COOKIE)?.value;
+  const cookieStore = await cookies()
+  const token = cookieStore.get(CUSTOMER_SESSION_COOKIE)?.value
 
-  if (!token) return null;
+  if (!token) return null
 
   const session = await db.customerSession.findUnique({
     where: { token },
-  });
+  })
 
-  if (!session) return null;
+  if (!session) return null
 
   if (new Date() > session.expiresAt) {
     // Clean up expired session
-    await db.customerSession.delete({ where: { id: session.id } }).catch(() => { /* ignore */ });
-    return null;
+    await db.customerSession.delete({ where: { id: session.id } }).catch(() => {
+      /* ignore */
+    })
+    return null
   }
 
   return {
     customerId: session.customerId,
     organizationId: session.organizationId,
-  };
+  }
 }

+ 6 - 6
src/lib/date-sort.ts

@@ -1,4 +1,4 @@
-import { Prisma } from "@/generated/prisma/client";
+import { Prisma } from '@/generated/prisma/client'
 
 /**
  * SQL ORDER BY expression for the date a work order/invoice is presented
@@ -7,8 +7,8 @@ import { Prisma } from "@/generated/prisma/client";
  * `alias` must be a code-supplied table alias, never user input.
  */
 export function effectiveDateSql(alias: string): Prisma.Sql {
-  const a = Prisma.raw(`"${alias}"`);
-  return Prisma.sql`COALESCE(${a}."invoiceDate", ${a}."startDateTime", ${a}."serviceDate")`;
+  const a = Prisma.raw(`"${alias}"`)
+  return Prisma.sql`COALESCE(${a}."invoiceDate", ${a}."startDateTime", ${a}."serviceDate")`
 }
 
 /**
@@ -17,14 +17,14 @@ export function effectiveDateSql(alias: string): Prisma.Sql {
  * scheduled start (imported/legacy data) sort by serviceDate and group at
  * the old end: nulls first ascending, nulls last descending.
  */
-export function serviceDateOrderBy(dir: "asc" | "desc") {
+export function serviceDateOrderBy(dir: 'asc' | 'desc') {
   return [
     {
       startDateTime: {
         sort: dir,
-        nulls: dir === "asc" ? ("first" as const) : ("last" as const),
+        nulls: dir === 'asc' ? ('first' as const) : ('last' as const),
       },
     },
     { serviceDate: dir },
-  ];
+  ]
 }

+ 9 - 9
src/lib/db.ts

@@ -1,9 +1,9 @@
-import { PrismaClient } from "@/generated/prisma/client";
-import { PrismaPg } from "@prisma/adapter-pg";
+import { PrismaClient } from '@/generated/prisma/client'
+import { PrismaPg } from '@prisma/adapter-pg'
 
 const globalForPrisma = globalThis as unknown as {
-  prisma: PrismaClient | undefined;
-};
+  prisma: PrismaClient | undefined
+}
 
 function createPrismaClient() {
   const adapter = new PrismaPg({
@@ -11,13 +11,13 @@ function createPrismaClient() {
     max: 10,
     connectionTimeoutMillis: 5000,
     idleTimeoutMillis: 30000,
-  });
+  })
   return new PrismaClient({
     adapter,
-    log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
-  });
+    log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
+  })
 }
 
-export const db = globalForPrisma.prisma ?? createPrismaClient();
+export const db = globalForPrisma.prisma ?? createPrismaClient()
 
-globalForPrisma.prisma = db;
+globalForPrisma.prisma = db

+ 9 - 7
src/lib/demo.ts

@@ -10,11 +10,11 @@
  * the app. The reset cron (every 3 hours) reverts their changes.
  */
 
-export const isDemoMode = process.env.DEMO_MODE === "true";
+export const isDemoMode = process.env.DEMO_MODE === 'true'
 
 /** Credentials the sign-in page auto-fills. The seed script provisions this user. */
-export const DEMO_USER_EMAIL = process.env.DEMO_USER_EMAIL || "demo@torqvoice.com";
-export const DEMO_USER_PASSWORD = process.env.DEMO_USER_PASSWORD || "demo";
+export const DEMO_USER_EMAIL = process.env.DEMO_USER_EMAIL || 'demo@torqvoice.com'
+export const DEMO_USER_PASSWORD = process.env.DEMO_USER_PASSWORD || 'demo'
 
 /**
  * Throws inside a server action when demo mode is active. `withAuth`
@@ -23,7 +23,9 @@ export const DEMO_USER_PASSWORD = process.env.DEMO_USER_PASSWORD || "demo";
  */
 export function demoGuard(): void {
   if (isDemoMode) {
-    throw new Error("This action is disabled on the demo. Install Torqvoice on your own server to use it.");
+    throw new Error(
+      'This action is disabled on the demo. Install Torqvoice on your own server to use it.'
+    )
   }
 }
 
@@ -34,10 +36,10 @@ export function demoGuard(): void {
 const DEMO_BLOCKED_SETTING_KEY_PATTERNS: RegExp[] = [
   /^payment\.(stripe|vipps|paypal)\./,
   /^payment\.providersEnabled$/,
-];
+]
 
 export function isDemoBlockedSettingKey(key: string): boolean {
-  return DEMO_BLOCKED_SETTING_KEY_PATTERNS.some((p) => p.test(key));
+  return DEMO_BLOCKED_SETTING_KEY_PATTERNS.some((p) => p.test(key))
 }
 
 /**
@@ -46,6 +48,6 @@ export function isDemoBlockedSettingKey(key: string): boolean {
  */
 export function demoGuardSettingKey(key: string): void {
   if (isDemoMode && isDemoBlockedSettingKey(key)) {
-    throw new Error("This setting can't be changed on the demo.");
+    throw new Error("This setting can't be changed on the demo.")
   }
 }

+ 230 - 248
src/lib/email.ts

@@ -1,52 +1,43 @@
-import nodemailer from "nodemailer";
-import { Resend } from "resend";
-import { ServerClient as PostmarkClient } from "postmark";
-import Mailgun from "mailgun.js";
-import FormData from "form-data";
-import sgMail, { type MailDataRequired } from "@sendgrid/mail";
-import { SESClient, SendRawEmailCommand } from "@aws-sdk/client-ses";
-import { db } from "./db";
-import { SYSTEM_SETTING_KEYS } from "@/features/admin/Schema/systemSettingsSchema";
-import { ORG_EMAIL_KEYS } from "@/features/email/Schema/emailSettingsSchema";
-
-export type EmailProvider =
-  | "smtp"
-  | "resend"
-  | "postmark"
-  | "mailgun"
-  | "sendgrid"
-  | "ses";
+import nodemailer from 'nodemailer'
+import { Resend } from 'resend'
+import { ServerClient as PostmarkClient } from 'postmark'
+import Mailgun from 'mailgun.js'
+import FormData from 'form-data'
+import sgMail, { type MailDataRequired } from '@sendgrid/mail'
+import { SESClient, SendRawEmailCommand } from '@aws-sdk/client-ses'
+import { db } from './db'
+import { SYSTEM_SETTING_KEYS } from '@/features/admin/Schema/systemSettingsSchema'
+import { ORG_EMAIL_KEYS } from '@/features/email/Schema/emailSettingsSchema'
+
+export type EmailProvider = 'smtp' | 'resend' | 'postmark' | 'mailgun' | 'sendgrid' | 'ses'
 
 export interface SendMailOptions {
-  from: string;
-  to: string;
-  subject: string;
-  html: string;
+  from: string
+  to: string
+  subject: string
+  html: string
   attachments?: {
-    filename: string;
-    content: Buffer;
-  }[];
+    filename: string
+    content: Buffer
+  }[]
 }
 
 // ─── Settings map helper ────────────────────────────────────────────────────
 
-type SettingsMap = Map<string, string>;
+type SettingsMap = Map<string, string>
 
 async function getSystemSettings(keys: string[]): Promise<SettingsMap> {
   const rows = await db.systemSetting.findMany({
     where: { key: { in: keys } },
-  });
-  return new Map(rows.map((r) => [r.key, r.value]));
+  })
+  return new Map(rows.map((r) => [r.key, r.value]))
 }
 
-async function getOrgSettings(
-  organizationId: string,
-  keys: string[],
-): Promise<SettingsMap> {
+async function getOrgSettings(organizationId: string, keys: string[]): Promise<SettingsMap> {
   const rows = await db.appSetting.findMany({
     where: { organizationId, key: { in: keys } },
-  });
-  return new Map(rows.map((r) => [r.key, r.value]));
+  })
+  return new Map(rows.map((r) => [r.key, r.value]))
 }
 
 // ─── System-level helpers (read from SystemSetting + env) ───────────────────
@@ -54,22 +45,22 @@ async function getOrgSettings(
 async function getEmailProvider(): Promise<EmailProvider> {
   const setting = await db.systemSetting.findUnique({
     where: { key: SYSTEM_SETTING_KEYS.EMAIL_PROVIDER },
-  });
-  const value = setting?.value;
+  })
+  const value = setting?.value
   if (
-    value === "resend" ||
-    value === "postmark" ||
-    value === "mailgun" ||
-    value === "sendgrid" ||
-    value === "ses"
+    value === 'resend' ||
+    value === 'postmark' ||
+    value === 'mailgun' ||
+    value === 'sendgrid' ||
+    value === 'ses'
   ) {
-    return value;
+    return value
   }
-  return "smtp";
+  return 'smtp'
 }
 
 export async function getFromAddress(): Promise<string> {
-  const provider = await getEmailProvider();
+  const provider = await getEmailProvider()
 
   const keyMap: Record<EmailProvider, { email: string; name: string }> = {
     smtp: {
@@ -96,80 +87,75 @@ export async function getFromAddress(): Promise<string> {
       email: SYSTEM_SETTING_KEYS.SES_FROM_EMAIL,
       name: SYSTEM_SETTING_KEYS.SES_FROM_NAME,
     },
-  };
+  }
 
-  const keys = keyMap[provider];
+  const keys = keyMap[provider]
   const rows = await db.systemSetting.findMany({
     where: { key: { in: [keys.email, keys.name] } },
-  });
-  const map = new Map(rows.map((r) => [r.key, r.value]));
+  })
+  const map = new Map(rows.map((r) => [r.key, r.value]))
 
-  const fromEmail =
-    map.get(keys.email) || process.env.SMTP_FROM_EMAIL || "noreply@example.com";
-  const fromName = map.get(keys.name) || "Torqvoice";
+  const fromEmail = map.get(keys.email) || process.env.SMTP_FROM_EMAIL || 'noreply@example.com'
+  const fromName = map.get(keys.name) || 'Torqvoice'
 
-  return `${fromName} <${fromEmail}>`;
+  return `${fromName} <${fromEmail}>`
 }
 
 // ─── SMTP ──────────────────────────────────────────────────────────────────
 
 interface SmtpConfig {
-  host: string;
-  port: number;
-  user?: string;
-  pass?: string;
-  secure: boolean;
-  rejectUnauthorized: boolean;
-  requireTls: boolean;
+  host: string
+  port: number
+  user?: string
+  pass?: string
+  secure: boolean
+  rejectUnauthorized: boolean
+  requireTls: boolean
 }
 
 function buildSmtpConfig(
   settings: SettingsMap,
   keys: {
-    host: string;
-    port: string;
-    user: string;
-    pass: string;
-    secure: string;
-    rejectUnauthorized: string;
-    requireTls: string;
+    host: string
+    port: string
+    user: string
+    pass: string
+    secure: string
+    rejectUnauthorized: string
+    requireTls: string
   },
-  useEnvFallback: boolean,
+  useEnvFallback: boolean
 ): SmtpConfig {
-  const host = settings.get(keys.host) || (useEnvFallback ? process.env.SMTP_HOST : undefined);
+  const host = settings.get(keys.host) || (useEnvFallback ? process.env.SMTP_HOST : undefined)
   const port =
-    Number(settings.get(keys.port)) ||
-    (useEnvFallback ? Number(process.env.SMTP_PORT) : 0) ||
-    587;
-  const user = settings.get(keys.user) || (useEnvFallback ? process.env.SMTP_USER : undefined);
-  const pass = settings.get(keys.pass) || (useEnvFallback ? process.env.SMTP_PASS : undefined);
+    Number(settings.get(keys.port)) || (useEnvFallback ? Number(process.env.SMTP_PORT) : 0) || 587
+  const user = settings.get(keys.user) || (useEnvFallback ? process.env.SMTP_USER : undefined)
+  const pass = settings.get(keys.pass) || (useEnvFallback ? process.env.SMTP_PASS : undefined)
 
-  const secureSetting = settings.get(keys.secure);
+  const secureSetting = settings.get(keys.secure)
   const secure =
     secureSetting !== undefined
-      ? secureSetting === "true"
+      ? secureSetting === 'true'
       : useEnvFallback
-        ? process.env.SMTP_SECURE === "true"
-        : false;
+        ? process.env.SMTP_SECURE === 'true'
+        : false
 
-  const rejectSetting = settings.get(keys.rejectUnauthorized);
+  const rejectSetting = settings.get(keys.rejectUnauthorized)
   const rejectUnauthorized =
     rejectSetting !== undefined
-      ? rejectSetting !== "false"
+      ? rejectSetting !== 'false'
       : useEnvFallback
-        ? process.env.SMTP_REJECT_UNAUTHORIZED !== "false"
-        : true;
+        ? process.env.SMTP_REJECT_UNAUTHORIZED !== 'false'
+        : true
 
-  const requireTlsSetting = settings.get(keys.requireTls);
-  const requireTls = requireTlsSetting === "true";
+  const requireTlsSetting = settings.get(keys.requireTls)
+  const requireTls = requireTlsSetting === 'true'
 
   if (!host) {
-    throw new Error(
-      "SMTP is not configured. Configure SMTP in your email settings.",
-    );
+    throw new Error('SMTP is not configured. Configure SMTP in your email settings.')
   }
 
-  return { host, port, user, pass, secure, rejectUnauthorized, requireTls };
+  return { host, port, user, pass, secure, rejectUnauthorized, requireTls }
 }
 
 function createSmtpTransporter(config: SmtpConfig) {
@@ -187,25 +173,25 @@ function createSmtpTransporter(config: SmtpConfig) {
       rejectUnauthorized: config.rejectUnauthorized,
     },
     requireTLS: config.requireTls,
-  });
+  })
 }
 
 async function sendViaSmtpWithSettings(
   options: SendMailOptions,
   settings: SettingsMap,
   keys: {
-    host: string;
-    port: string;
-    user: string;
-    pass: string;
-    secure: string;
-    rejectUnauthorized: string;
-    requireTls: string;
+    host: string
+    port: string
+    user: string
+    pass: string
+    secure: string
+    rejectUnauthorized: string
+    requireTls: string
   },
-  useEnvFallback: boolean,
+  useEnvFallback: boolean
 ) {
-  const config = buildSmtpConfig(settings, keys, useEnvFallback);
-  const transporter = createSmtpTransporter(config);
+  const config = buildSmtpConfig(settings, keys, useEnvFallback)
+  const transporter = createSmtpTransporter(config)
   await transporter.sendMail({
     from: options.from,
     to: options.to,
@@ -215,7 +201,7 @@ async function sendViaSmtpWithSettings(
       filename: a.filename,
       content: a.content,
     })),
-  });
+  })
 }
 
 async function sendViaSmtp(options: SendMailOptions) {
@@ -227,17 +213,22 @@ async function sendViaSmtp(options: SendMailOptions) {
     SYSTEM_SETTING_KEYS.SMTP_SECURE,
     SYSTEM_SETTING_KEYS.SMTP_REJECT_UNAUTHORIZED,
     SYSTEM_SETTING_KEYS.SMTP_REQUIRE_TLS,
-  ];
-  const settings = await getSystemSettings(keys);
-  await sendViaSmtpWithSettings(options, settings, {
-    host: SYSTEM_SETTING_KEYS.SMTP_HOST,
-    port: SYSTEM_SETTING_KEYS.SMTP_PORT,
-    user: SYSTEM_SETTING_KEYS.SMTP_USER,
-    pass: SYSTEM_SETTING_KEYS.SMTP_PASS,
-    secure: SYSTEM_SETTING_KEYS.SMTP_SECURE,
-    rejectUnauthorized: SYSTEM_SETTING_KEYS.SMTP_REJECT_UNAUTHORIZED,
-    requireTls: SYSTEM_SETTING_KEYS.SMTP_REQUIRE_TLS,
-  }, true);
+  ]
+  const settings = await getSystemSettings(keys)
+  await sendViaSmtpWithSettings(
+    options,
+    settings,
+    {
+      host: SYSTEM_SETTING_KEYS.SMTP_HOST,
+      port: SYSTEM_SETTING_KEYS.SMTP_PORT,
+      user: SYSTEM_SETTING_KEYS.SMTP_USER,
+      pass: SYSTEM_SETTING_KEYS.SMTP_PASS,
+      secure: SYSTEM_SETTING_KEYS.SMTP_SECURE,
+      rejectUnauthorized: SYSTEM_SETTING_KEYS.SMTP_REJECT_UNAUTHORIZED,
+      requireTls: SYSTEM_SETTING_KEYS.SMTP_REQUIRE_TLS,
+    },
+    true
+  )
 }
 
 // ─── Resend ────────────────────────────────────────────────────────────────
@@ -245,14 +236,14 @@ async function sendViaSmtp(options: SendMailOptions) {
 async function sendViaResendWithSettings(
   options: SendMailOptions,
   settings: SettingsMap,
-  apiKeyField: string,
+  apiKeyField: string
 ) {
-  const apiKey = settings.get(apiKeyField);
+  const apiKey = settings.get(apiKeyField)
   if (!apiKey) {
-    throw new Error("Resend is not configured. Add your Resend API key.");
+    throw new Error('Resend is not configured. Add your Resend API key.')
   }
 
-  const resend = new Resend(apiKey);
+  const resend = new Resend(apiKey)
   await resend.emails.send({
     from: options.from,
     to: options.to,
@@ -262,12 +253,12 @@ async function sendViaResendWithSettings(
       filename: a.filename,
       content: a.content,
     })),
-  });
+  })
 }
 
 async function sendViaResend(options: SendMailOptions) {
-  const settings = await getSystemSettings([SYSTEM_SETTING_KEYS.RESEND_API_KEY]);
-  await sendViaResendWithSettings(options, settings, SYSTEM_SETTING_KEYS.RESEND_API_KEY);
+  const settings = await getSystemSettings([SYSTEM_SETTING_KEYS.RESEND_API_KEY])
+  await sendViaResendWithSettings(options, settings, SYSTEM_SETTING_KEYS.RESEND_API_KEY)
 }
 
 // ─── Postmark ──────────────────────────────────────────────────────────────
@@ -275,14 +266,14 @@ async function sendViaResend(options: SendMailOptions) {
 async function sendViaPostmarkWithSettings(
   options: SendMailOptions,
   settings: SettingsMap,
-  apiKeyField: string,
+  apiKeyField: string
 ) {
-  const apiKey = settings.get(apiKeyField);
+  const apiKey = settings.get(apiKeyField)
   if (!apiKey) {
-    throw new Error("Postmark is not configured. Add your Server Token.");
+    throw new Error('Postmark is not configured. Add your Server Token.')
   }
 
-  const client = new PostmarkClient(apiKey);
+  const client = new PostmarkClient(apiKey)
 
   if (options.attachments?.length) {
     await client.sendEmail({
@@ -292,24 +283,24 @@ async function sendViaPostmarkWithSettings(
       HtmlBody: options.html,
       Attachments: options.attachments.map((a) => ({
         Name: a.filename,
-        Content: a.content.toString("base64"),
-        ContentType: "application/octet-stream",
-        ContentID: "",
+        Content: a.content.toString('base64'),
+        ContentType: 'application/octet-stream',
+        ContentID: '',
       })),
-    });
+    })
   } else {
     await client.sendEmail({
       From: options.from,
       To: options.to,
       Subject: options.subject,
       HtmlBody: options.html,
-    });
+    })
   }
 }
 
 async function sendViaPostmark(options: SendMailOptions) {
-  const settings = await getSystemSettings([SYSTEM_SETTING_KEYS.POSTMARK_API_KEY]);
-  await sendViaPostmarkWithSettings(options, settings, SYSTEM_SETTING_KEYS.POSTMARK_API_KEY);
+  const settings = await getSystemSettings([SYSTEM_SETTING_KEYS.POSTMARK_API_KEY])
+  await sendViaPostmarkWithSettings(options, settings, SYSTEM_SETTING_KEYS.POSTMARK_API_KEY)
 }
 
 // ─── Mailgun ───────────────────────────────────────────────────────────────
@@ -317,22 +308,22 @@ async function sendViaPostmark(options: SendMailOptions) {
 async function sendViaMailgunWithSettings(
   options: SendMailOptions,
   settings: SettingsMap,
-  keys: { apiKey: string; domain: string; region: string },
+  keys: { apiKey: string; domain: string; region: string }
 ) {
-  const apiKey = settings.get(keys.apiKey);
-  const domain = settings.get(keys.domain);
-  const region = settings.get(keys.region) || "us";
+  const apiKey = settings.get(keys.apiKey)
+  const domain = settings.get(keys.domain)
+  const region = settings.get(keys.region) || 'us'
 
   if (!apiKey || !domain) {
-    throw new Error("Mailgun is not configured. Add your API key and domain.");
+    throw new Error('Mailgun is not configured. Add your API key and domain.')
   }
 
-  const mailgun = new Mailgun(FormData);
+  const mailgun = new Mailgun(FormData)
   const mg = mailgun.client({
-    username: "api",
+    username: 'api',
     key: apiKey,
-    url: region === "eu" ? "https://api.eu.mailgun.net" : undefined,
-  });
+    url: region === 'eu' ? 'https://api.eu.mailgun.net' : undefined,
+  })
 
   await mg.messages.create(domain, {
     from: options.from,
@@ -345,7 +336,7 @@ async function sendViaMailgunWithSettings(
         data: a.content,
       })),
     }),
-  });
+  })
 }
 
 async function sendViaMailgun(options: SendMailOptions) {
@@ -353,13 +344,13 @@ async function sendViaMailgun(options: SendMailOptions) {
     SYSTEM_SETTING_KEYS.MAILGUN_API_KEY,
     SYSTEM_SETTING_KEYS.MAILGUN_DOMAIN,
     SYSTEM_SETTING_KEYS.MAILGUN_REGION,
-  ];
-  const settings = await getSystemSettings(keys);
+  ]
+  const settings = await getSystemSettings(keys)
   await sendViaMailgunWithSettings(options, settings, {
     apiKey: SYSTEM_SETTING_KEYS.MAILGUN_API_KEY,
     domain: SYSTEM_SETTING_KEYS.MAILGUN_DOMAIN,
     region: SYSTEM_SETTING_KEYS.MAILGUN_REGION,
-  });
+  })
 }
 
 // ─── SendGrid ──────────────────────────────────────────────────────────────
@@ -367,37 +358,37 @@ async function sendViaMailgun(options: SendMailOptions) {
 async function sendViaSendGridWithSettings(
   options: SendMailOptions,
   settings: SettingsMap,
-  apiKeyField: string,
+  apiKeyField: string
 ) {
-  const apiKey = settings.get(apiKeyField);
+  const apiKey = settings.get(apiKeyField)
   if (!apiKey) {
-    throw new Error("SendGrid is not configured. Add your API key.");
+    throw new Error('SendGrid is not configured. Add your API key.')
   }
 
-  sgMail.setApiKey(apiKey);
+  sgMail.setApiKey(apiKey)
 
   const msg: MailDataRequired = {
     from: options.from,
     to: options.to,
     subject: options.subject,
     html: options.html,
-  };
+  }
 
   if (options.attachments?.length) {
     msg.attachments = options.attachments.map((a) => ({
       filename: a.filename,
-      content: a.content.toString("base64"),
-      type: "application/octet-stream",
-      disposition: "attachment" as const,
-    }));
+      content: a.content.toString('base64'),
+      type: 'application/octet-stream',
+      disposition: 'attachment' as const,
+    }))
   }
 
-  await sgMail.send(msg);
+  await sgMail.send(msg)
 }
 
 async function sendViaSendGrid(options: SendMailOptions) {
-  const settings = await getSystemSettings([SYSTEM_SETTING_KEYS.SENDGRID_API_KEY]);
-  await sendViaSendGridWithSettings(options, settings, SYSTEM_SETTING_KEYS.SENDGRID_API_KEY);
+  const settings = await getSystemSettings([SYSTEM_SETTING_KEYS.SENDGRID_API_KEY])
+  await sendViaSendGridWithSettings(options, settings, SYSTEM_SETTING_KEYS.SENDGRID_API_KEY)
 }
 
 // ─── Amazon SES ────────────────────────────────────────────────────────────
@@ -405,17 +396,17 @@ async function sendViaSendGrid(options: SendMailOptions) {
 async function sendViaSesWithSettings(
   options: SendMailOptions,
   settings: SettingsMap,
-  keys: { accessKeyId: string; secretAccessKey: string; region: string },
+  keys: { accessKeyId: string; secretAccessKey: string; region: string }
 ) {
-  const accessKeyId = settings.get(keys.accessKeyId);
-  const secretAccessKey = settings.get(keys.secretAccessKey);
-  const region = settings.get(keys.region) || "us-east-1";
+  const accessKeyId = settings.get(keys.accessKeyId)
+  const secretAccessKey = settings.get(keys.secretAccessKey)
+  const region = settings.get(keys.region) || 'us-east-1'
 
   if (!accessKeyId || !secretAccessKey) {
-    throw new Error("Amazon SES is not configured. Add your credentials.");
+    throw new Error('Amazon SES is not configured. Add your credentials.')
   }
 
-  const transporter = nodemailer.createTransport({ streamTransport: true });
+  const transporter = nodemailer.createTransport({ streamTransport: true })
   const info = await transporter.sendMail({
     from: options.from,
     to: options.to,
@@ -425,20 +416,18 @@ async function sendViaSesWithSettings(
       filename: a.filename,
       content: a.content,
     })),
-  });
+  })
 
   const rawMessage = Buffer.isBuffer(info.message)
     ? info.message
-    : await streamToBuffer(info.message);
+    : await streamToBuffer(info.message)
 
   const client = new SESClient({
     region,
     credentials: { accessKeyId, secretAccessKey },
-  });
+  })
 
-  await client.send(
-    new SendRawEmailCommand({ RawMessage: { Data: rawMessage } }),
-  );
+  await client.send(new SendRawEmailCommand({ RawMessage: { Data: rawMessage } }))
 }
 
 async function sendViaSes(options: SendMailOptions) {
@@ -446,24 +435,22 @@ async function sendViaSes(options: SendMailOptions) {
     SYSTEM_SETTING_KEYS.SES_ACCESS_KEY_ID,
     SYSTEM_SETTING_KEYS.SES_SECRET_ACCESS_KEY,
     SYSTEM_SETTING_KEYS.SES_REGION,
-  ];
-  const settings = await getSystemSettings(keys);
+  ]
+  const settings = await getSystemSettings(keys)
   await sendViaSesWithSettings(options, settings, {
     accessKeyId: SYSTEM_SETTING_KEYS.SES_ACCESS_KEY_ID,
     secretAccessKey: SYSTEM_SETTING_KEYS.SES_SECRET_ACCESS_KEY,
     region: SYSTEM_SETTING_KEYS.SES_REGION,
-  });
+  })
 }
 
-function streamToBuffer(
-  stream: NodeJS.ReadableStream,
-): Promise<Uint8Array> {
+function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Uint8Array> {
   return new Promise((resolve, reject) => {
-    const chunks: Buffer[] = [];
-    stream.on("data", (chunk: Buffer) => chunks.push(chunk));
-    stream.on("end", () => resolve(Buffer.concat(chunks)));
-    stream.on("error", reject);
-  });
+    const chunks: Buffer[] = []
+    stream.on('data', (chunk: Buffer) => chunks.push(chunk))
+    stream.on('end', () => resolve(Buffer.concat(chunks)))
+    stream.on('error', reject)
+  })
 }
 
 // ─── Provider dispatch (shared by both system and org) ──────────────────────
@@ -472,70 +459,73 @@ function sendWithProvider(
   provider: EmailProvider,
   options: SendMailOptions,
   settings: SettingsMap,
-  keySet: "system" | "org",
+  keySet: 'system' | 'org'
 ) {
-  if (keySet === "org") {
+  if (keySet === 'org') {
     switch (provider) {
-      case "smtp":
-        return sendViaSmtpWithSettings(options, settings, {
-          host: ORG_EMAIL_KEYS.EMAIL_SMTP_HOST,
-          port: ORG_EMAIL_KEYS.EMAIL_SMTP_PORT,
-          user: ORG_EMAIL_KEYS.EMAIL_SMTP_USER,
-          pass: ORG_EMAIL_KEYS.EMAIL_SMTP_PASS,
-          secure: ORG_EMAIL_KEYS.EMAIL_SMTP_SECURE,
-          rejectUnauthorized: ORG_EMAIL_KEYS.EMAIL_SMTP_REJECT_UNAUTHORIZED,
-          requireTls: ORG_EMAIL_KEYS.EMAIL_SMTP_REQUIRE_TLS,
-        }, false);
-      case "resend":
-        return sendViaResendWithSettings(options, settings, ORG_EMAIL_KEYS.EMAIL_RESEND_API_KEY);
-      case "postmark":
-        return sendViaPostmarkWithSettings(options, settings, ORG_EMAIL_KEYS.EMAIL_POSTMARK_API_KEY);
-      case "mailgun":
+      case 'smtp':
+        return sendViaSmtpWithSettings(
+          options,
+          settings,
+          {
+            host: ORG_EMAIL_KEYS.EMAIL_SMTP_HOST,
+            port: ORG_EMAIL_KEYS.EMAIL_SMTP_PORT,
+            user: ORG_EMAIL_KEYS.EMAIL_SMTP_USER,
+            pass: ORG_EMAIL_KEYS.EMAIL_SMTP_PASS,
+            secure: ORG_EMAIL_KEYS.EMAIL_SMTP_SECURE,
+            rejectUnauthorized: ORG_EMAIL_KEYS.EMAIL_SMTP_REJECT_UNAUTHORIZED,
+            requireTls: ORG_EMAIL_KEYS.EMAIL_SMTP_REQUIRE_TLS,
+          },
+          false
+        )
+      case 'resend':
+        return sendViaResendWithSettings(options, settings, ORG_EMAIL_KEYS.EMAIL_RESEND_API_KEY)
+      case 'postmark':
+        return sendViaPostmarkWithSettings(options, settings, ORG_EMAIL_KEYS.EMAIL_POSTMARK_API_KEY)
+      case 'mailgun':
         return sendViaMailgunWithSettings(options, settings, {
           apiKey: ORG_EMAIL_KEYS.EMAIL_MAILGUN_API_KEY,
           domain: ORG_EMAIL_KEYS.EMAIL_MAILGUN_DOMAIN,
           region: ORG_EMAIL_KEYS.EMAIL_MAILGUN_REGION,
-        });
-      case "sendgrid":
-        return sendViaSendGridWithSettings(options, settings, ORG_EMAIL_KEYS.EMAIL_SENDGRID_API_KEY);
-      case "ses":
+        })
+      case 'sendgrid':
+        return sendViaSendGridWithSettings(options, settings, ORG_EMAIL_KEYS.EMAIL_SENDGRID_API_KEY)
+      case 'ses':
         return sendViaSesWithSettings(options, settings, {
           accessKeyId: ORG_EMAIL_KEYS.EMAIL_SES_ACCESS_KEY_ID,
           secretAccessKey: ORG_EMAIL_KEYS.EMAIL_SES_SECRET_ACCESS_KEY,
           region: ORG_EMAIL_KEYS.EMAIL_SES_REGION,
-        });
+        })
     }
   }
 
   // system keySet
   switch (provider) {
-    case "resend":
-      return sendViaResend(options);
-    case "postmark":
-      return sendViaPostmark(options);
-    case "mailgun":
-      return sendViaMailgun(options);
-    case "sendgrid":
-      return sendViaSendGrid(options);
-    case "ses":
-      return sendViaSes(options);
+    case 'resend':
+      return sendViaResend(options)
+    case 'postmark':
+      return sendViaPostmark(options)
+    case 'mailgun':
+      return sendViaMailgun(options)
+    case 'sendgrid':
+      return sendViaSendGrid(options)
+    case 'ses':
+      return sendViaSes(options)
     default:
-      return sendViaSmtp(options);
+      return sendViaSmtp(options)
   }
 }
 
 // ─── System-level router (unchanged behavior) ──────────────────────────────
 
 export async function sendMail(options: SendMailOptions) {
-  const provider = await getEmailProvider();
-  await sendWithProvider(provider, options, new Map(), "system");
+  const provider = await getEmailProvider()
+  await sendWithProvider(provider, options, new Map(), 'system')
 }
 
 // ─── Org-level email ────────────────────────────────────────────────────────
 
-async function getOrgEmailProvider(
-  organizationId: string,
-): Promise<EmailProvider | null> {
+async function getOrgEmailProvider(organizationId: string): Promise<EmailProvider | null> {
   const setting = await db.appSetting.findUnique({
     where: {
       organizationId_key: {
@@ -543,28 +533,26 @@ async function getOrgEmailProvider(
         key: ORG_EMAIL_KEYS.EMAIL_PROVIDER,
       },
     },
-  });
-  const value = setting?.value;
+  })
+  const value = setting?.value
   if (
-    value === "smtp" ||
-    value === "resend" ||
-    value === "postmark" ||
-    value === "mailgun" ||
-    value === "sendgrid" ||
-    value === "ses"
+    value === 'smtp' ||
+    value === 'resend' ||
+    value === 'postmark' ||
+    value === 'mailgun' ||
+    value === 'sendgrid' ||
+    value === 'ses'
   ) {
-    return value;
+    return value
   }
-  return null;
+  return null
 }
 
-export async function getOrgFromAddress(
-  organizationId: string,
-): Promise<string> {
-  const provider = await getOrgEmailProvider(organizationId);
+export async function getOrgFromAddress(organizationId: string): Promise<string> {
+  const provider = await getOrgEmailProvider(organizationId)
 
   if (!provider) {
-    return getFromAddress();
+    return getFromAddress()
   }
 
   const keyMap: Record<EmailProvider, { email: string; name: string }> = {
@@ -592,35 +580,29 @@ export async function getOrgFromAddress(
       email: ORG_EMAIL_KEYS.EMAIL_SES_FROM_EMAIL,
       name: ORG_EMAIL_KEYS.EMAIL_SES_FROM_NAME,
     },
-  };
+  }
 
-  const keys = keyMap[provider];
-  const settings = await getOrgSettings(organizationId, [
-    keys.email,
-    keys.name,
-  ]);
+  const keys = keyMap[provider]
+  const settings = await getOrgSettings(organizationId, [keys.email, keys.name])
 
-  const fromEmail = settings.get(keys.email) || "noreply@example.com";
-  const fromName = settings.get(keys.name) || "Torqvoice";
+  const fromEmail = settings.get(keys.email) || 'noreply@example.com'
+  const fromName = settings.get(keys.name) || 'Torqvoice'
 
-  return `${fromName} <${fromEmail}>`;
+  return `${fromName} <${fromEmail}>`
 }
 
-export async function sendOrgMail(
-  organizationId: string,
-  options: SendMailOptions,
-) {
-  const provider = await getOrgEmailProvider(organizationId);
+export async function sendOrgMail(organizationId: string, options: SendMailOptions) {
+  const provider = await getOrgEmailProvider(organizationId)
 
   if (!provider) {
     // Fall back to global platform email
-    await sendMail(options);
-    return;
+    await sendMail(options)
+    return
   }
 
   // Load all org email settings
-  const allKeys = Object.values(ORG_EMAIL_KEYS);
-  const settings = await getOrgSettings(organizationId, allKeys);
+  const allKeys = Object.values(ORG_EMAIL_KEYS)
+  const settings = await getOrgSettings(organizationId, allKeys)
 
-  await sendWithProvider(provider, options, settings, "org");
+  await sendWithProvider(provider, options, settings, 'org')
 }

+ 3 - 4
src/lib/format.ts

@@ -133,10 +133,9 @@ export function formatCurrency(
         const override = CURRENCY_SYMBOL_OVERRIDES[currencyCode]
         // If the original symbol was glued to a digit/letter (e.g. "¥1,234.56"),
         // a multi-character override needs a space for readability.
-        result = result.replace(sym, override).replace(
-          new RegExp(`(${override.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(?=\\d)`),
-          '$1 '
-        )
+        result = result
+          .replace(sym, override)
+          .replace(new RegExp(`(${override.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(?=\\d)`), '$1 ')
       }
     }
     return result

+ 19 - 19
src/lib/get-auth-context.ts

@@ -1,22 +1,22 @@
-import { getCachedSession, getCachedMembership } from "./cached-session";
-import { db } from "./db";
-import type { AuthContext } from "./with-auth";
+import { getCachedSession, getCachedMembership } from './cached-session'
+import { db } from './db'
+import type { AuthContext } from './with-auth'
 
 type AuthContextResult =
-  | { status: "unauthenticated" }
-  | { status: "no-organization" }
-  | { status: "ok"; context: AuthContext };
+  | { status: 'unauthenticated' }
+  | { status: 'no-organization' }
+  | { status: 'ok'; context: AuthContext }
 
 export async function getAuthContext(): Promise<AuthContext | null> {
-  const result = await getAuthContextDetailed();
-  if (result.status !== "ok") return null;
-  return result.context;
+  const result = await getAuthContextDetailed()
+  if (result.status !== 'ok') return null
+  return result.context
 }
 
 export async function getAuthContextDetailed(): Promise<AuthContextResult> {
-  const session = await getCachedSession();
+  const session = await getCachedSession()
 
-  if (!session?.user?.id) return { status: "unauthenticated" };
+  if (!session?.user?.id) return { status: 'unauthenticated' }
 
   const [membership, user] = await Promise.all([
     getCachedMembership(session.user.id),
@@ -24,23 +24,23 @@ export async function getAuthContextDetailed(): Promise<AuthContextResult> {
       where: { id: session.user.id },
       select: { isSuperAdmin: true },
     }),
-  ]);
+  ])
 
-  const isSuperAdmin = user?.isSuperAdmin ?? false;
+  const isSuperAdmin = user?.isSuperAdmin ?? false
 
-  if (!membership?.organizationId) return { status: "no-organization" };
+  if (!membership?.organizationId) return { status: 'no-organization' }
 
-  const isOwnerOrAdmin = membership.role === "owner" || membership.role === "admin";
-  const roleIsAdmin = membership.customRole?.isAdmin === true;
+  const isOwnerOrAdmin = membership.role === 'owner' || membership.role === 'admin'
+  const roleIsAdmin = membership.customRole?.isAdmin === true
 
   return {
-    status: "ok",
+    status: 'ok',
     context: {
       userId: session.user.id,
       organizationId: membership.organizationId,
-      role: isSuperAdmin ? "super_admin" : (membership.role ?? "member"),
+      role: isSuperAdmin ? 'super_admin' : (membership.role ?? 'member'),
       isSuperAdmin,
       isAdmin: isSuperAdmin || isOwnerOrAdmin || roleIsAdmin,
     },
-  };
+  }
 }

+ 35 - 35
src/lib/get-layout-data.ts

@@ -1,32 +1,32 @@
-import { getCachedSession, getCachedMembership } from "./cached-session";
-import { db } from "./db";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { getCachedSession, getCachedMembership } from './cached-session'
+import { db } from './db'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 
 type AuthResult =
-  | { status: "unauthenticated" }
-  | { status: "no-organization" }
+  | { status: 'unauthenticated' }
+  | { status: 'no-organization' }
   | {
-      status: "ok";
-      userId: string;
-      organizationId: string;
-      role: string;
-      isSuperAdmin: boolean;
-      emailVerified: boolean;
-      lastSeenVersion: string | null;
-      companyLogo: string | undefined;
-      dateFormat: string | undefined;
-      timeFormat: string | undefined;
-      timezone: string | undefined;
-      weekStartDay: number;
-      serviceType: string | undefined;
-      currencyCode: string | undefined;
-      currencyFormat: string | undefined;
-      organizations: { id: string; name: string; role: string }[];
-    };
+      status: 'ok'
+      userId: string
+      organizationId: string
+      role: string
+      isSuperAdmin: boolean
+      emailVerified: boolean
+      lastSeenVersion: string | null
+      companyLogo: string | undefined
+      dateFormat: string | undefined
+      timeFormat: string | undefined
+      timezone: string | undefined
+      weekStartDay: number
+      serviceType: string | undefined
+      currencyCode: string | undefined
+      currencyFormat: string | undefined
+      organizations: { id: string; name: string; role: string }[]
+    }
 
 export async function getLayoutData(): Promise<AuthResult> {
-  const session = await getCachedSession();
-  if (!session?.user?.id) return { status: "unauthenticated" };
+  const session = await getCachedSession()
+  if (!session?.user?.id) return { status: 'unauthenticated' }
 
   const [membership, user] = await Promise.all([
     getCachedMembership(session.user.id),
@@ -34,14 +34,14 @@ export async function getLayoutData(): Promise<AuthResult> {
       where: { id: session.user.id },
       select: { isSuperAdmin: true, emailVerified: true, lastSeenVersion: true },
     }),
-  ]);
+  ])
 
   // User record deleted (e.g. admin removed the account) but session cookie still cached
-  if (!user) return { status: "unauthenticated" };
+  if (!user) return { status: 'unauthenticated' }
 
-  const isSuperAdmin = user.isSuperAdmin;
+  const isSuperAdmin = user.isSuperAdmin
 
-  if (!membership && !isSuperAdmin) return { status: "no-organization" };
+  if (!membership && !isSuperAdmin) return { status: 'no-organization' }
 
   const [orgSettings, memberships] = await Promise.all([
     membership
@@ -71,15 +71,15 @@ export async function getLayoutData(): Promise<AuthResult> {
         organization: { select: { id: true, name: true } },
       },
     }),
-  ]);
+  ])
 
-  const orgMap = new Map(orgSettings?.map((s) => [s.key, s.value]) ?? []);
+  const orgMap = new Map(orgSettings?.map((s) => [s.key, s.value]) ?? [])
 
   return {
-    status: "ok",
+    status: 'ok',
     userId: session.user.id,
-    organizationId: membership?.organizationId ?? "",
-    role: isSuperAdmin ? "super_admin" : (membership?.role ?? "member"),
+    organizationId: membership?.organizationId ?? '',
+    role: isSuperAdmin ? 'super_admin' : (membership?.role ?? 'member'),
     isSuperAdmin,
     emailVerified: user?.emailVerified ?? false,
     lastSeenVersion: user?.lastSeenVersion ?? null,
@@ -87,7 +87,7 @@ export async function getLayoutData(): Promise<AuthResult> {
     dateFormat: orgMap.get(SETTING_KEYS.DATE_FORMAT) || undefined,
     timeFormat: orgMap.get(SETTING_KEYS.TIME_FORMAT) || undefined,
     timezone: orgMap.get(SETTING_KEYS.TIMEZONE) || undefined,
-    weekStartDay: parseInt(orgMap.get(SETTING_KEYS.WORKBOARD_WEEK_START_DAY) || "1", 10),
+    weekStartDay: parseInt(orgMap.get(SETTING_KEYS.WORKBOARD_WEEK_START_DAY) || '1', 10),
     serviceType: orgMap.get(SETTING_KEYS.SERVICE_TYPE) || undefined,
     currencyCode: orgMap.get(SETTING_KEYS.CURRENCY_CODE) || undefined,
     currencyFormat: orgMap.get(SETTING_KEYS.CURRENCY_FORMAT) || undefined,
@@ -96,5 +96,5 @@ export async function getLayoutData(): Promise<AuthResult> {
       name: m.organization.name,
       role: m.role,
     })),
-  };
+  }
 }

+ 7 - 7
src/lib/interactive-row.ts

@@ -1,4 +1,4 @@
-import type { KeyboardEvent } from "react";
+import type { KeyboardEvent } from 'react'
 
 /**
  * Spread onto a clickable row — a `<TableRow>` or a list-row `<div>` — to make
@@ -19,15 +19,15 @@ import type { KeyboardEvent } from "react";
  */
 export function interactiveRow(activate: () => void) {
   return {
-    "data-row-interactive": true,
+    'data-row-interactive': true,
     tabIndex: 0,
     onClick: activate,
     onKeyDown: (e: KeyboardEvent<HTMLElement>) => {
-      if (e.target !== e.currentTarget) return;
-      if (e.key === "Enter" || e.key === " ") {
-        e.preventDefault();
-        activate();
+      if (e.target !== e.currentTarget) return
+      if (e.key === 'Enter' || e.key === ' ') {
+        e.preventDefault()
+        activate()
       }
     },
-  };
+  }
 }

+ 10 - 10
src/lib/invoice-utils.ts

@@ -3,7 +3,7 @@
  * Supported variables: {year} -> current 4-digit year
  */
 export function resolveInvoicePrefix(prefix: string): string {
-  return prefix.replace(/\{year\}/g, String(new Date().getFullYear()));
+  return prefix.replace(/\{year\}/g, String(new Date().getFullYear()))
 }
 
 /**
@@ -12,11 +12,11 @@ export function resolveInvoicePrefix(prefix: string): string {
  * reach the database (out-of-range timestamps break rendering later).
  */
 export function toSafeDate(value: string | undefined | null): Date | undefined {
-  if (!value) return undefined;
-  const d = new Date(value);
-  if (isNaN(d.getTime())) return undefined;
-  const year = d.getFullYear();
-  return year >= 1900 && year <= 2100 ? d : undefined;
+  if (!value) return undefined
+  const d = new Date(value)
+  if (isNaN(d.getTime())) return undefined
+  const year = d.getFullYear()
+  return year >= 1900 && year <= 2100 ? d : undefined
 }
 
 /**
@@ -25,9 +25,9 @@ export function toSafeDate(value: string | undefined | null): Date | undefined {
  * Matches what the invoice PDF and share views print.
  */
 export function effectiveInvoiceDate(record: {
-  invoiceDate?: Date | string | null;
-  startDateTime?: Date | string | null;
-  serviceDate: Date | string;
+  invoiceDate?: Date | string | null
+  startDateTime?: Date | string | null
+  serviceDate: Date | string
 }): Date {
-  return new Date(record.invoiceDate ?? record.startDateTime ?? record.serviceDate);
+  return new Date(record.invoiceDate ?? record.startDateTime ?? record.serviceDate)
 }

+ 5 - 6
src/lib/notification-bus.ts

@@ -1,11 +1,10 @@
-import { EventEmitter } from "node:events";
+import { EventEmitter } from 'node:events'
 
 const globalForBus = globalThis as unknown as {
-  notificationBus: EventEmitter | undefined;
-};
+  notificationBus: EventEmitter | undefined
+}
 
-export const notificationBus =
-  globalForBus.notificationBus ?? new EventEmitter();
+export const notificationBus = globalForBus.notificationBus ?? new EventEmitter()
 
 // Survive HMR in dev
-globalForBus.notificationBus = notificationBus;
+globalForBus.notificationBus = notificationBus

+ 13 - 13
src/lib/notify.ts

@@ -1,15 +1,15 @@
-import { db } from "@/lib/db";
-import { notificationBus } from "@/lib/notification-bus";
+import { db } from '@/lib/db'
+import { notificationBus } from '@/lib/notification-bus'
 
 type NotifyInput = {
-  organizationId: string;
-  type: string;
-  title: string;
-  message: string;
-  entityType: string;
-  entityId: string;
-  entityUrl: string;
-};
+  organizationId: string
+  type: string
+  title: string
+  message: string
+  entityType: string
+  entityId: string
+  entityUrl: string
+}
 
 export async function notify(input: NotifyInput) {
   try {
@@ -23,11 +23,11 @@ export async function notify(input: NotifyInput) {
         entityUrl: input.entityUrl,
         organizationId: input.organizationId,
       },
-    });
+    })
 
     // Emit to in-process bus — the WS route subscribes and broadcasts
-    notificationBus.emit("notification", notification);
+    notificationBus.emit('notification', notification)
   } catch (error) {
-    console.error("[notify] Failed:", error);
+    console.error('[notify] Failed:', error)
   }
 }

+ 23 - 23
src/lib/packages/format.ts

@@ -1,4 +1,4 @@
-import { z } from "zod";
+import { z } from 'zod'
 
 /**
  * The shape of anything a workshop can share out of Torqvoice.
@@ -13,9 +13,9 @@ import { z } from "zod";
  */
 
 /** Bumped only for a breaking change to the envelope, not to any content type. */
-export const PACKAGE_FORMAT_VERSION = 1;
+export const PACKAGE_FORMAT_VERSION = 1
 
-export const PACKAGE_FILE_EXTENSION = ".json";
+export const PACKAGE_FILE_EXTENSION = '.json'
 
 /**
  * Whether the contents are data or something that runs.
@@ -26,14 +26,14 @@ export const PACKAGE_FILE_EXTENSION = ".json";
  * would need signing and a sandbox, and should never be able to arrive by
  * omission in a file written for an older version.
  */
-export const packageKindSchema = z.enum(["bundle"]);
+export const packageKindSchema = z.enum(['bundle'])
 
 export const packageContentSchema = z.object({
   /** Registered content type, e.g. "inspection-template". */
   type: z.string().min(1).max(64),
   /** Validated by the installer for `type`, not here. */
   data: z.unknown(),
-});
+})
 
 export const packageManifestSchema = z.object({
   formatVersion: z.number().int().positive(),
@@ -47,10 +47,10 @@ export const packageManifestSchema = z.object({
   author: z.string().max(120).optional(),
   exportedAt: z.string().max(40).optional(),
   contents: z.array(packageContentSchema).min(1).max(50),
-});
+})
 
-export type PackageManifest = z.infer<typeof packageManifestSchema>;
-export type PackageContent = z.infer<typeof packageContentSchema>;
+export type PackageManifest = z.infer<typeof packageManifestSchema>
+export type PackageContent = z.infer<typeof packageContentSchema>
 
 export class PackageFormatError extends Error {}
 
@@ -61,36 +61,36 @@ export class PackageFormatError extends Error {}
  * says so plainly instead of failing as a list of unrecognised fields.
  */
 export function parsePackage(raw: unknown): PackageManifest {
-  if (raw === null || typeof raw !== "object") {
-    throw new PackageFormatError("This file is not a Torqvoice package.");
+  if (raw === null || typeof raw !== 'object') {
+    throw new PackageFormatError('This file is not a Torqvoice package.')
   }
 
-  const version = (raw as { formatVersion?: unknown }).formatVersion;
-  if (typeof version !== "number") {
-    throw new PackageFormatError("This file is not a Torqvoice package.");
+  const version = (raw as { formatVersion?: unknown }).formatVersion
+  if (typeof version !== 'number') {
+    throw new PackageFormatError('This file is not a Torqvoice package.')
   }
   if (version > PACKAGE_FORMAT_VERSION) {
     throw new PackageFormatError(
       `This package was made with a newer version of Torqvoice (format ${version}). Update before importing it.`
-    );
+    )
   }
 
-  const parsed = packageManifestSchema.safeParse(raw);
+  const parsed = packageManifestSchema.safeParse(raw)
   if (!parsed.success) {
-    throw new PackageFormatError("This package is missing information Torqvoice needs to read it.");
+    throw new PackageFormatError('This package is missing information Torqvoice needs to read it.')
   }
-  return parsed.data;
+  return parsed.data
 }
 
 /** A filename that survives a round trip through a downloads folder. */
-export function packageFileName(manifest: Pick<PackageManifest, "name">): string {
+export function packageFileName(manifest: Pick<PackageManifest, 'name'>): string {
   const slug =
     manifest.name
       .toLowerCase()
-      .normalize("NFKD")
-      .replace(/[^\w\s-]/g, "")
+      .normalize('NFKD')
+      .replace(/[^\w\s-]/g, '')
       .trim()
-      .replace(/\s+/g, "-")
-      .slice(0, 60) || "template";
-  return `torqvoice-${slug}${PACKAGE_FILE_EXTENSION}`;
+      .replace(/\s+/g, '-')
+      .slice(0, 60) || 'template'
+  return `torqvoice-${slug}${PACKAGE_FILE_EXTENSION}`
 }

+ 19 - 19
src/lib/packages/registry.ts

@@ -1,5 +1,5 @@
-import type { ZodType } from "zod";
-import { PackageFormatError, type PackageContent } from "./format";
+import type { ZodType } from 'zod'
+import { PackageFormatError, type PackageContent } from './format'
 
 /**
  * The extension point.
@@ -14,31 +14,31 @@ import { PackageFormatError, type PackageContent } from "./format";
  */
 export interface PackageInstaller<T> {
   /** Stable key written into the package, e.g. "inspection-template". */
-  type: string;
+  type: string
   /** Human name for the review screen. */
-  label: string;
+  label: string
   /** Nothing from a file is trusted until it has been through this. */
-  schema: ZodType<T>;
+  schema: ZodType<T>
   /** Lines shown before installing, e.g. "9 sections", "92 checks". */
-  describe(data: T): string[];
+  describe(data: T): string[]
 }
 
-const installers = new Map<string, PackageInstaller<unknown>>();
+const installers = new Map<string, PackageInstaller<unknown>>()
 
 export function registerInstaller<T>(installer: PackageInstaller<T>): void {
-  installers.set(installer.type, installer as PackageInstaller<unknown>);
+  installers.set(installer.type, installer as PackageInstaller<unknown>)
 }
 
 export function getInstaller(type: string): PackageInstaller<unknown> | undefined {
-  return installers.get(type);
+  return installers.get(type)
 }
 
 export interface ReviewedContent {
-  type: string;
-  label: string;
+  type: string
+  label: string
   /** Validated payload, safe to hand to the installer. */
-  data: unknown;
-  details: string[];
+  data: unknown
+  details: string[]
 }
 
 /**
@@ -50,21 +50,21 @@ export interface ReviewedContent {
  */
 export function reviewContents(contents: PackageContent[]): ReviewedContent[] {
   return contents.map((content) => {
-    const installer = getInstaller(content.type);
+    const installer = getInstaller(content.type)
     if (!installer) {
       throw new PackageFormatError(
         `This package contains "${content.type}", which this version of Torqvoice cannot install.`
-      );
+      )
     }
-    const parsed = installer.schema.safeParse(content.data);
+    const parsed = installer.schema.safeParse(content.data)
     if (!parsed.success) {
-      throw new PackageFormatError(`The ${installer.label} in this package is not valid.`);
+      throw new PackageFormatError(`The ${installer.label} in this package is not valid.`)
     }
     return {
       type: installer.type,
       label: installer.label,
       data: parsed.data,
       details: installer.describe(parsed.data),
-    };
-  });
+    }
+  })
 }

+ 31 - 33
src/lib/payment-providers/index.ts

@@ -1,63 +1,61 @@
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import type { PaymentProvider } from "./types";
-import { StripeProvider } from "./stripe";
-import { VippsProvider } from "./vipps";
-import { PayPalProvider } from "./paypal";
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import type { PaymentProvider } from './types'
+import { StripeProvider } from './stripe'
+import { VippsProvider } from './vipps'
+import { PayPalProvider } from './paypal'
 
-export type { PaymentProvider, CheckoutRequest, CheckoutResult, VerifyResult } from "./types";
+export type { PaymentProvider, CheckoutRequest, CheckoutResult, VerifyResult } from './types'
 
 export function getPaymentProvider(
   provider: string,
-  settings: Record<string, string>,
+  settings: Record<string, string>
 ): PaymentProvider {
   switch (provider) {
-    case "stripe": {
-      const secretKey = settings[SETTING_KEYS.PAYMENT_STRIPE_SECRET_KEY];
+    case 'stripe': {
+      const secretKey = settings[SETTING_KEYS.PAYMENT_STRIPE_SECRET_KEY]
       if (!secretKey) {
-        throw new Error("Stripe secret key not configured");
+        throw new Error('Stripe secret key not configured')
       }
-      return new StripeProvider(secretKey);
+      return new StripeProvider(secretKey)
     }
-    case "vipps": {
-      const clientId = settings[SETTING_KEYS.PAYMENT_VIPPS_CLIENT_ID];
-      const clientSecret = settings[SETTING_KEYS.PAYMENT_VIPPS_CLIENT_SECRET];
-      const subscriptionKey = settings[SETTING_KEYS.PAYMENT_VIPPS_SUBSCRIPTION_KEY];
-      const msn = settings[SETTING_KEYS.PAYMENT_VIPPS_MSN];
+    case 'vipps': {
+      const clientId = settings[SETTING_KEYS.PAYMENT_VIPPS_CLIENT_ID]
+      const clientSecret = settings[SETTING_KEYS.PAYMENT_VIPPS_CLIENT_SECRET]
+      const subscriptionKey = settings[SETTING_KEYS.PAYMENT_VIPPS_SUBSCRIPTION_KEY]
+      const msn = settings[SETTING_KEYS.PAYMENT_VIPPS_MSN]
       if (!clientId || !clientSecret || !subscriptionKey || !msn) {
-        throw new Error("Vipps credentials not fully configured");
+        throw new Error('Vipps credentials not fully configured')
       }
       return new VippsProvider({
         clientId,
         clientSecret,
         subscriptionKey,
         merchantSerialNumber: msn,
-        useTestMode: settings[SETTING_KEYS.PAYMENT_VIPPS_USE_TEST] === "true",
-      });
+        useTestMode: settings[SETTING_KEYS.PAYMENT_VIPPS_USE_TEST] === 'true',
+      })
     }
-    case "paypal": {
-      const clientId = settings[SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_ID];
-      const clientSecret = settings[SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_SECRET];
+    case 'paypal': {
+      const clientId = settings[SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_ID]
+      const clientSecret = settings[SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_SECRET]
       if (!clientId || !clientSecret) {
-        throw new Error("PayPal credentials not fully configured");
+        throw new Error('PayPal credentials not fully configured')
       }
       return new PayPalProvider({
         clientId,
         clientSecret,
-        useSandbox: settings[SETTING_KEYS.PAYMENT_PAYPAL_USE_SANDBOX] === "true",
-      });
+        useSandbox: settings[SETTING_KEYS.PAYMENT_PAYPAL_USE_SANDBOX] === 'true',
+      })
     }
     default:
-      throw new Error(`Unknown payment provider: ${provider}`);
+      throw new Error(`Unknown payment provider: ${provider}`)
   }
 }
 
-export function getEnabledProviders(
-  settings: Record<string, string>,
-): string[] {
-  const raw = settings[SETTING_KEYS.PAYMENT_PROVIDERS_ENABLED];
-  if (!raw) return [];
+export function getEnabledProviders(settings: Record<string, string>): string[] {
+  const raw = settings[SETTING_KEYS.PAYMENT_PROVIDERS_ENABLED]
+  if (!raw) return []
   return raw
-    .split(",")
+    .split(',')
     .map((s) => s.trim())
-    .filter(Boolean);
+    .filter(Boolean)
 }

+ 62 - 76
src/lib/payment-providers/paypal.ts

@@ -1,60 +1,55 @@
-import type {
-  PaymentProvider,
-  CheckoutRequest,
-  CheckoutResult,
-  VerifyResult,
-} from "./types";
+import type { PaymentProvider, CheckoutRequest, CheckoutResult, VerifyResult } from './types'
 
 interface PayPalConfig {
-  clientId: string;
-  clientSecret: string;
-  useSandbox: boolean;
+  clientId: string
+  clientSecret: string
+  useSandbox: boolean
 }
 
 export class PayPalProvider implements PaymentProvider {
-  private config: PayPalConfig;
-  private baseUrl: string;
+  private config: PayPalConfig
+  private baseUrl: string
 
   constructor(config: PayPalConfig) {
-    this.config = config;
+    this.config = config
     this.baseUrl = config.useSandbox
-      ? "https://api-m.sandbox.paypal.com"
-      : "https://api-m.paypal.com";
+      ? 'https://api-m.sandbox.paypal.com'
+      : 'https://api-m.paypal.com'
   }
 
   private async getAccessToken(): Promise<string> {
-    const credentials = Buffer.from(
-      `${this.config.clientId}:${this.config.clientSecret}`,
-    ).toString("base64");
+    const credentials = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString(
+      'base64'
+    )
 
     const res = await fetch(`${this.baseUrl}/v1/oauth2/token`, {
-      method: "POST",
+      method: 'POST',
       headers: {
         Authorization: `Basic ${credentials}`,
-        "Content-Type": "application/x-www-form-urlencoded",
+        'Content-Type': 'application/x-www-form-urlencoded',
       },
-      body: "grant_type=client_credentials",
-    });
+      body: 'grant_type=client_credentials',
+    })
 
     if (!res.ok) {
-      throw new Error(`PayPal auth failed: ${res.status}`);
+      throw new Error(`PayPal auth failed: ${res.status}`)
     }
 
-    const data = await res.json();
-    return data.access_token;
+    const data = await res.json()
+    return data.access_token
   }
 
   async createCheckout(req: CheckoutRequest): Promise<CheckoutResult> {
-    const accessToken = await this.getAccessToken();
+    const accessToken = await this.getAccessToken()
 
     const res = await fetch(`${this.baseUrl}/v2/checkout/orders`, {
-      method: "POST",
+      method: 'POST',
       headers: {
-        "Content-Type": "application/json",
+        'Content-Type': 'application/json',
         Authorization: `Bearer ${accessToken}`,
       },
       body: JSON.stringify({
-        intent: "CAPTURE",
+        intent: 'CAPTURE',
         purchase_units: [
           {
             amount: {
@@ -71,102 +66,93 @@ export class PayPalProvider implements PaymentProvider {
             experience_context: {
               return_url: `${req.successUrl}?paypal_order_id={order.id}`,
               cancel_url: req.cancelUrl,
-              user_action: "PAY_NOW",
-              brand_name: req.description.split(" - ")[0] || "Invoice Payment",
+              user_action: 'PAY_NOW',
+              brand_name: req.description.split(' - ')[0] || 'Invoice Payment',
             },
           },
         },
       }),
-    });
+    })
 
     if (!res.ok) {
-      const errorText = await res.text();
-      throw new Error(
-        `PayPal order creation failed: ${res.status} ${errorText}`,
-      );
+      const errorText = await res.text()
+      throw new Error(`PayPal order creation failed: ${res.status} ${errorText}`)
     }
 
-    const data = await res.json();
+    const data = await res.json()
 
     const approveLink = data.links?.find(
-      (l: { rel: string; href: string }) => l.rel === "payer-action",
-    );
+      (l: { rel: string; href: string }) => l.rel === 'payer-action'
+    )
 
     if (!approveLink) {
-      throw new Error("PayPal did not return a payer-action link");
+      throw new Error('PayPal did not return a payer-action link')
     }
 
     return {
       redirectUrl: approveLink.href,
       externalId: data.id,
-    };
+    }
   }
 
   async verifyPayment(orderId: string): Promise<VerifyResult | null> {
     try {
-      const accessToken = await this.getAccessToken();
-
-      const res = await fetch(
-        `${this.baseUrl}/v2/checkout/orders/${orderId}/capture`,
-        {
-          method: "POST",
-          headers: {
-            "Content-Type": "application/json",
-            Authorization: `Bearer ${accessToken}`,
-          },
+      const accessToken = await this.getAccessToken()
+
+      const res = await fetch(`${this.baseUrl}/v2/checkout/orders/${orderId}/capture`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          Authorization: `Bearer ${accessToken}`,
         },
-      );
+      })
 
       if (!res.ok) {
         // If already captured, try to get the order details instead
         if (res.status === 422) {
-          return await this.getOrderDetails(orderId, accessToken);
+          return await this.getOrderDetails(orderId, accessToken)
         }
-        return null;
+        return null
       }
 
-      const data = await res.json();
+      const data = await res.json()
 
-      if (data.status === "COMPLETED") {
+      if (data.status === 'COMPLETED') {
         const amount = Number.parseFloat(
-          data.purchase_units?.[0]?.payments?.captures?.[0]?.amount?.value ||
-            "0",
-        );
-        return { paid: true, amount };
+          data.purchase_units?.[0]?.payments?.captures?.[0]?.amount?.value || '0'
+        )
+        return { paid: true, amount }
       }
 
-      return { paid: false, amount: 0 };
+      return { paid: false, amount: 0 }
     } catch {
-      return null;
+      return null
     }
   }
 
   private async getOrderDetails(
     orderId: string,
-    accessToken: string,
+    accessToken: string
   ): Promise<VerifyResult | null> {
-    const res = await fetch(
-      `${this.baseUrl}/v2/checkout/orders/${orderId}`,
-      {
-        headers: {
-          Authorization: `Bearer ${accessToken}`,
-        },
+    const res = await fetch(`${this.baseUrl}/v2/checkout/orders/${orderId}`, {
+      headers: {
+        Authorization: `Bearer ${accessToken}`,
       },
-    );
+    })
 
-    if (!res.ok) return null;
+    if (!res.ok) return null
 
-    const data = await res.json();
+    const data = await res.json()
 
-    if (data.status === "COMPLETED") {
+    if (data.status === 'COMPLETED') {
       const amount = Number.parseFloat(
         data.purchase_units?.[0]?.payments?.captures?.[0]?.amount?.value ||
           data.purchase_units?.[0]?.amount?.value ||
-          "0",
-      );
-      return { paid: true, amount };
+          '0'
+      )
+      return { paid: true, amount }
     }
 
-    return { paid: false, amount: 0 };
+    return { paid: false, amount: 0 }
   }
 }

+ 13 - 18
src/lib/payment-providers/stripe.ts

@@ -1,22 +1,17 @@
-import Stripe from "stripe";
-import type {
-  PaymentProvider,
-  CheckoutRequest,
-  CheckoutResult,
-  VerifyResult,
-} from "./types";
+import Stripe from 'stripe'
+import type { PaymentProvider, CheckoutRequest, CheckoutResult, VerifyResult } from './types'
 
 export class StripeProvider implements PaymentProvider {
-  private stripe: Stripe;
+  private stripe: Stripe
 
   constructor(secretKey: string) {
-    this.stripe = new Stripe(secretKey);
+    this.stripe = new Stripe(secretKey)
   }
 
   async createCheckout(req: CheckoutRequest): Promise<CheckoutResult> {
     const session = await this.stripe.checkout.sessions.create({
-      mode: "payment",
-      payment_method_types: ["card"],
+      mode: 'payment',
+      payment_method_types: ['card'],
       line_items: [
         {
           price_data: {
@@ -37,27 +32,27 @@ export class StripeProvider implements PaymentProvider {
       },
       success_url: `${req.successUrl}?session_id={CHECKOUT_SESSION_ID}`,
       cancel_url: req.cancelUrl,
-    });
+    })
 
     if (!session.url) {
-      throw new Error("Failed to create Stripe checkout session");
+      throw new Error('Failed to create Stripe checkout session')
     }
 
     return {
       redirectUrl: session.url,
       externalId: session.id,
-    };
+    }
   }
 
   async verifyPayment(externalId: string): Promise<VerifyResult | null> {
     try {
-      const session = await this.stripe.checkout.sessions.retrieve(externalId);
+      const session = await this.stripe.checkout.sessions.retrieve(externalId)
       return {
-        paid: session.payment_status === "paid",
+        paid: session.payment_status === 'paid',
         amount: (session.amount_total ?? 0) / 100,
-      };
+      }
     } catch {
-      return null;
+      return null
     }
   }
 }

+ 14 - 14
src/lib/payment-providers/types.ts

@@ -1,25 +1,25 @@
 export interface CheckoutRequest {
-  amount: number;
-  currency: string;
-  invoiceNumber: string;
-  description: string;
-  successUrl: string;
-  cancelUrl: string;
-  serviceRecordId: string;
-  orgId: string;
+  amount: number
+  currency: string
+  invoiceNumber: string
+  description: string
+  successUrl: string
+  cancelUrl: string
+  serviceRecordId: string
+  orgId: string
 }
 
 export interface CheckoutResult {
-  redirectUrl: string;
-  externalId: string;
+  redirectUrl: string
+  externalId: string
 }
 
 export interface VerifyResult {
-  paid: boolean;
-  amount: number;
+  paid: boolean
+  amount: number
 }
 
 export interface PaymentProvider {
-  createCheckout(req: CheckoutRequest): Promise<CheckoutResult>;
-  verifyPayment(externalId: string): Promise<VerifyResult | null>;
+  createCheckout(req: CheckoutRequest): Promise<CheckoutResult>
+  verifyPayment(externalId: string): Promise<VerifyResult | null>
 }

+ 47 - 56
src/lib/payment-providers/vipps.ts

@@ -1,120 +1,111 @@
-import type {
-  PaymentProvider,
-  CheckoutRequest,
-  CheckoutResult,
-  VerifyResult,
-} from "./types";
+import type { PaymentProvider, CheckoutRequest, CheckoutResult, VerifyResult } from './types'
 
-const VIPPS_API_URL = "https://api.vipps.no";
-const VIPPS_TEST_API_URL = "https://apitest.vipps.no";
+const VIPPS_API_URL = 'https://api.vipps.no'
+const VIPPS_TEST_API_URL = 'https://apitest.vipps.no'
 
 interface VippsConfig {
-  clientId: string;
-  clientSecret: string;
-  subscriptionKey: string;
-  merchantSerialNumber: string;
-  useTestMode: boolean;
+  clientId: string
+  clientSecret: string
+  subscriptionKey: string
+  merchantSerialNumber: string
+  useTestMode: boolean
 }
 
 export class VippsProvider implements PaymentProvider {
-  private config: VippsConfig;
-  private baseUrl: string;
+  private config: VippsConfig
+  private baseUrl: string
 
   constructor(config: VippsConfig) {
-    this.config = config;
-    this.baseUrl = config.useTestMode ? VIPPS_TEST_API_URL : VIPPS_API_URL;
+    this.config = config
+    this.baseUrl = config.useTestMode ? VIPPS_TEST_API_URL : VIPPS_API_URL
   }
 
   private async getAccessToken(): Promise<string> {
     const res = await fetch(`${this.baseUrl}/accesstoken/get`, {
-      method: "POST",
+      method: 'POST',
       headers: {
-        "Content-Type": "application/json",
+        'Content-Type': 'application/json',
         client_id: this.config.clientId,
         client_secret: this.config.clientSecret,
-        "Ocp-Apim-Subscription-Key": this.config.subscriptionKey,
-        "Merchant-Serial-Number": this.config.merchantSerialNumber,
+        'Ocp-Apim-Subscription-Key': this.config.subscriptionKey,
+        'Merchant-Serial-Number': this.config.merchantSerialNumber,
       },
-    });
+    })
 
     if (!res.ok) {
-      throw new Error(`Vipps auth failed: ${res.status}`);
+      throw new Error(`Vipps auth failed: ${res.status}`)
     }
 
-    const data = await res.json();
-    return data.access_token;
+    const data = await res.json()
+    return data.access_token
   }
 
   async createCheckout(req: CheckoutRequest): Promise<CheckoutResult> {
-    const accessToken = await this.getAccessToken();
-    const reference = `inv-${req.serviceRecordId}-${Date.now()}`;
+    const accessToken = await this.getAccessToken()
+    const reference = `inv-${req.serviceRecordId}-${Date.now()}`
 
     const res = await fetch(`${this.baseUrl}/epayment/v1/payments`, {
-      method: "POST",
+      method: 'POST',
       headers: {
-        "Content-Type": "application/json",
+        'Content-Type': 'application/json',
         Authorization: `Bearer ${accessToken}`,
-        "Ocp-Apim-Subscription-Key": this.config.subscriptionKey,
-        "Merchant-Serial-Number": this.config.merchantSerialNumber,
-        "Idempotency-Key": reference,
+        'Ocp-Apim-Subscription-Key': this.config.subscriptionKey,
+        'Merchant-Serial-Number': this.config.merchantSerialNumber,
+        'Idempotency-Key': reference,
       },
       body: JSON.stringify({
         amount: {
           currency: req.currency,
           value: Math.round(req.amount * 100),
         },
-        paymentMethod: { type: "WALLET" },
+        paymentMethod: { type: 'WALLET' },
         reference,
         paymentDescription: `Invoice ${req.invoiceNumber}`,
-        userFlow: "WEB_REDIRECT",
+        userFlow: 'WEB_REDIRECT',
         returnUrl: `${req.successUrl}?reference=${reference}`,
         metadata: {
           serviceRecordId: req.serviceRecordId,
           orgId: req.orgId,
         },
       }),
-    });
+    })
 
     if (!res.ok) {
-      const errorText = await res.text();
-      throw new Error(`Vipps payment creation failed: ${res.status} ${errorText}`);
+      const errorText = await res.text()
+      throw new Error(`Vipps payment creation failed: ${res.status} ${errorText}`)
     }
 
-    const data = await res.json();
+    const data = await res.json()
 
     return {
       redirectUrl: data.redirectUrl,
       externalId: reference,
-    };
+    }
   }
 
   async verifyPayment(externalId: string): Promise<VerifyResult | null> {
     try {
-      const accessToken = await this.getAccessToken();
-
-      const res = await fetch(
-        `${this.baseUrl}/epayment/v1/payments/${externalId}`,
-        {
-          headers: {
-            Authorization: `Bearer ${accessToken}`,
-            "Ocp-Apim-Subscription-Key": this.config.subscriptionKey,
-            "Merchant-Serial-Number": this.config.merchantSerialNumber,
-          },
+      const accessToken = await this.getAccessToken()
+
+      const res = await fetch(`${this.baseUrl}/epayment/v1/payments/${externalId}`, {
+        headers: {
+          Authorization: `Bearer ${accessToken}`,
+          'Ocp-Apim-Subscription-Key': this.config.subscriptionKey,
+          'Merchant-Serial-Number': this.config.merchantSerialNumber,
         },
-      );
+      })
 
-      if (!res.ok) return null;
+      if (!res.ok) return null
 
-      const data = await res.json();
-      const paid =
-        data.state === "AUTHORIZED" || data.state === "CAPTURED";
+      const data = await res.json()
+      const paid = data.state === 'AUTHORIZED' || data.state === 'CAPTURED'
 
       return {
         paid,
         amount: (data.amount?.value ?? 0) / 100,
-      };
+      }
     } catch {
-      return null;
+      return null
     }
   }
 }

+ 99 - 107
src/lib/permissions.ts

@@ -1,198 +1,190 @@
 export enum PermissionAction {
-  CREATE = "create",
-  READ = "read",
-  UPDATE = "update",
-  DELETE = "delete",
-  MANAGE = "manage",
+  CREATE = 'create',
+  READ = 'read',
+  UPDATE = 'update',
+  DELETE = 'delete',
+  MANAGE = 'manage',
 }
 
 export enum PermissionSubject {
-  DASHBOARD = "dashboard",
-  VEHICLES = "vehicles",
-  CUSTOMERS = "customers",
-  WORK_ORDERS = "work_orders",
-  QUOTES = "quotes",
-  SERVICES = "services",
-  BILLING = "billing",
-  INVENTORY = "inventory",
-  LABOR_PRESETS = "labor_presets",
-  INSPECTIONS = "inspections",
-  TIRE_HOTEL = "tire_hotel",
-  REPORTS = "reports",
-  SETTINGS = "settings",
-  WORK_BOARD = "work_board",
-  AI_ASSISTANT = "ai_assistant",
+  DASHBOARD = 'dashboard',
+  VEHICLES = 'vehicles',
+  CUSTOMERS = 'customers',
+  WORK_ORDERS = 'work_orders',
+  QUOTES = 'quotes',
+  SERVICES = 'services',
+  BILLING = 'billing',
+  INVENTORY = 'inventory',
+  LABOR_PRESETS = 'labor_presets',
+  INSPECTIONS = 'inspections',
+  TIRE_HOTEL = 'tire_hotel',
+  REPORTS = 'reports',
+  SETTINGS = 'settings',
+  WORK_BOARD = 'work_board',
+  AI_ASSISTANT = 'ai_assistant',
 }
 
 export type PermissionInput = {
-  action: PermissionAction;
-  subject: PermissionSubject;
-};
+  action: PermissionAction
+  subject: PermissionSubject
+}
 
 export type PermissionGroup = {
-  name: string;
-  subject: PermissionSubject;
+  name: string
+  subject: PermissionSubject
   permissions: {
-    action: PermissionAction;
-    label: string;
-  }[];
-};
+    action: PermissionAction
+    label: string
+  }[]
+}
 
 export const permissionGroups: PermissionGroup[] = [
   {
-    name: "Dashboard",
+    name: 'Dashboard',
     subject: PermissionSubject.DASHBOARD,
-    permissions: [
-      { action: PermissionAction.READ, label: "View" },
-    ],
+    permissions: [{ action: PermissionAction.READ, label: 'View' }],
   },
   {
-    name: "Vehicles",
+    name: 'Vehicles',
     subject: PermissionSubject.VEHICLES,
     permissions: [
-      { action: PermissionAction.CREATE, label: "Create" },
-      { action: PermissionAction.READ, label: "View" },
-      { action: PermissionAction.UPDATE, label: "Edit" },
-      { action: PermissionAction.DELETE, label: "Delete" },
+      { action: PermissionAction.CREATE, label: 'Create' },
+      { action: PermissionAction.READ, label: 'View' },
+      { action: PermissionAction.UPDATE, label: 'Edit' },
+      { action: PermissionAction.DELETE, label: 'Delete' },
     ],
   },
   {
-    name: "Customers",
+    name: 'Customers',
     subject: PermissionSubject.CUSTOMERS,
     permissions: [
-      { action: PermissionAction.CREATE, label: "Create" },
-      { action: PermissionAction.READ, label: "View" },
-      { action: PermissionAction.UPDATE, label: "Edit" },
-      { action: PermissionAction.DELETE, label: "Delete" },
+      { action: PermissionAction.CREATE, label: 'Create' },
+      { action: PermissionAction.READ, label: 'View' },
+      { action: PermissionAction.UPDATE, label: 'Edit' },
+      { action: PermissionAction.DELETE, label: 'Delete' },
     ],
   },
   {
-    name: "Work Orders",
+    name: 'Work Orders',
     subject: PermissionSubject.WORK_ORDERS,
     permissions: [
-      { action: PermissionAction.CREATE, label: "Create" },
-      { action: PermissionAction.READ, label: "View" },
-      { action: PermissionAction.UPDATE, label: "Edit" },
-      { action: PermissionAction.DELETE, label: "Delete" },
+      { action: PermissionAction.CREATE, label: 'Create' },
+      { action: PermissionAction.READ, label: 'View' },
+      { action: PermissionAction.UPDATE, label: 'Edit' },
+      { action: PermissionAction.DELETE, label: 'Delete' },
     ],
   },
   {
-    name: "Quotes",
+    name: 'Quotes',
     subject: PermissionSubject.QUOTES,
     permissions: [
-      { action: PermissionAction.CREATE, label: "Create" },
-      { action: PermissionAction.READ, label: "View" },
-      { action: PermissionAction.UPDATE, label: "Edit" },
-      { action: PermissionAction.DELETE, label: "Delete" },
+      { action: PermissionAction.CREATE, label: 'Create' },
+      { action: PermissionAction.READ, label: 'View' },
+      { action: PermissionAction.UPDATE, label: 'Edit' },
+      { action: PermissionAction.DELETE, label: 'Delete' },
     ],
   },
   {
-    name: "Services",
+    name: 'Services',
     subject: PermissionSubject.SERVICES,
     permissions: [
-      { action: PermissionAction.CREATE, label: "Create" },
-      { action: PermissionAction.READ, label: "View" },
-      { action: PermissionAction.UPDATE, label: "Edit" },
-      { action: PermissionAction.DELETE, label: "Delete" },
+      { action: PermissionAction.CREATE, label: 'Create' },
+      { action: PermissionAction.READ, label: 'View' },
+      { action: PermissionAction.UPDATE, label: 'Edit' },
+      { action: PermissionAction.DELETE, label: 'Delete' },
     ],
   },
   {
-    name: "Billing",
+    name: 'Billing',
     subject: PermissionSubject.BILLING,
     permissions: [
-      { action: PermissionAction.CREATE, label: "Create" },
-      { action: PermissionAction.READ, label: "View" },
-      { action: PermissionAction.UPDATE, label: "Edit" },
-      { action: PermissionAction.DELETE, label: "Delete" },
+      { action: PermissionAction.CREATE, label: 'Create' },
+      { action: PermissionAction.READ, label: 'View' },
+      { action: PermissionAction.UPDATE, label: 'Edit' },
+      { action: PermissionAction.DELETE, label: 'Delete' },
     ],
   },
   {
-    name: "Inventory",
+    name: 'Inventory',
     subject: PermissionSubject.INVENTORY,
     permissions: [
-      { action: PermissionAction.CREATE, label: "Create" },
-      { action: PermissionAction.READ, label: "View" },
-      { action: PermissionAction.UPDATE, label: "Edit" },
-      { action: PermissionAction.DELETE, label: "Delete" },
+      { action: PermissionAction.CREATE, label: 'Create' },
+      { action: PermissionAction.READ, label: 'View' },
+      { action: PermissionAction.UPDATE, label: 'Edit' },
+      { action: PermissionAction.DELETE, label: 'Delete' },
     ],
   },
   {
-    name: "Labor Presets",
+    name: 'Labor Presets',
     subject: PermissionSubject.LABOR_PRESETS,
     permissions: [
-      { action: PermissionAction.CREATE, label: "Create" },
-      { action: PermissionAction.READ, label: "View" },
-      { action: PermissionAction.UPDATE, label: "Edit" },
-      { action: PermissionAction.DELETE, label: "Delete" },
+      { action: PermissionAction.CREATE, label: 'Create' },
+      { action: PermissionAction.READ, label: 'View' },
+      { action: PermissionAction.UPDATE, label: 'Edit' },
+      { action: PermissionAction.DELETE, label: 'Delete' },
     ],
   },
   {
-    name: "Inspections",
+    name: 'Inspections',
     subject: PermissionSubject.INSPECTIONS,
     permissions: [
-      { action: PermissionAction.CREATE, label: "Create" },
-      { action: PermissionAction.READ, label: "View" },
-      { action: PermissionAction.UPDATE, label: "Edit" },
-      { action: PermissionAction.DELETE, label: "Delete" },
+      { action: PermissionAction.CREATE, label: 'Create' },
+      { action: PermissionAction.READ, label: 'View' },
+      { action: PermissionAction.UPDATE, label: 'Edit' },
+      { action: PermissionAction.DELETE, label: 'Delete' },
     ],
   },
   {
-    name: "Tire Hotel",
+    name: 'Tire Hotel',
     subject: PermissionSubject.TIRE_HOTEL,
     permissions: [
-      { action: PermissionAction.CREATE, label: "Create" },
-      { action: PermissionAction.READ, label: "View" },
-      { action: PermissionAction.UPDATE, label: "Edit" },
-      { action: PermissionAction.DELETE, label: "Delete" },
-      { action: PermissionAction.MANAGE, label: "Manage storage layout" },
+      { action: PermissionAction.CREATE, label: 'Create' },
+      { action: PermissionAction.READ, label: 'View' },
+      { action: PermissionAction.UPDATE, label: 'Edit' },
+      { action: PermissionAction.DELETE, label: 'Delete' },
+      { action: PermissionAction.MANAGE, label: 'Manage storage layout' },
     ],
   },
   {
-    name: "Reports",
+    name: 'Reports',
     subject: PermissionSubject.REPORTS,
-    permissions: [
-      { action: PermissionAction.READ, label: "View" },
-    ],
+    permissions: [{ action: PermissionAction.READ, label: 'View' }],
   },
   {
-    name: "Work Board",
+    name: 'Work Board',
     subject: PermissionSubject.WORK_BOARD,
     permissions: [
-      { action: PermissionAction.CREATE, label: "Create" },
-      { action: PermissionAction.READ, label: "View" },
-      { action: PermissionAction.UPDATE, label: "Edit" },
-      { action: PermissionAction.DELETE, label: "Delete" },
+      { action: PermissionAction.CREATE, label: 'Create' },
+      { action: PermissionAction.READ, label: 'View' },
+      { action: PermissionAction.UPDATE, label: 'Edit' },
+      { action: PermissionAction.DELETE, label: 'Delete' },
     ],
   },
   {
-    name: "AI Assistant",
+    name: 'AI Assistant',
     subject: PermissionSubject.AI_ASSISTANT,
-    permissions: [
-      { action: PermissionAction.READ, label: "View" },
-    ],
+    permissions: [{ action: PermissionAction.READ, label: 'View' }],
   },
   {
-    name: "Settings",
+    name: 'Settings',
     subject: PermissionSubject.SETTINGS,
     permissions: [
-      { action: PermissionAction.READ, label: "View" },
-      { action: PermissionAction.UPDATE, label: "Edit" },
+      { action: PermissionAction.READ, label: 'View' },
+      { action: PermissionAction.UPDATE, label: 'Edit' },
     ],
   },
-];
+]
 
 export function hasPermission(
   userPermissions: { action: string; subject: string }[],
-  required: PermissionInput,
+  required: PermissionInput
 ): boolean {
-  return userPermissions.some(
-    (p) => p.action === required.action && p.subject === required.subject,
-  );
+  return userPermissions.some((p) => p.action === required.action && p.subject === required.subject)
 }
 
 export function hasAllPermissions(
   userPermissions: { action: string; subject: string }[],
-  required: PermissionInput[],
+  required: PermissionInput[]
 ): boolean {
-  return required.every((req) => hasPermission(userPermissions, req));
+  return required.every((req) => hasPermission(userPermissions, req))
 }

+ 9 - 11
src/lib/portal-slug.ts

@@ -1,26 +1,24 @@
-import { cache } from "react";
-import { db } from "@/lib/db";
+import { cache } from 'react'
+import { db } from '@/lib/db'
 
 /**
  * Resolve a portal URL parameter (slug or orgId) to the real organization.
  * Tries portalSlug first, falls back to id lookup.
  */
 export const resolvePortalOrg = cache(
-  async (
-    slugOrId: string,
-  ): Promise<{ id: string; name: string } | null> => {
+  async (slugOrId: string): Promise<{ id: string; name: string } | null> => {
     // Try slug first
     const bySlug = await db.organization.findUnique({
       where: { portalSlug: slugOrId },
       select: { id: true, name: true },
-    });
-    if (bySlug) return bySlug;
+    })
+    if (bySlug) return bySlug
 
     // Fall back to id
     const byId = await db.organization.findUnique({
       where: { id: slugOrId },
       select: { id: true, name: true },
-    });
-    return byId;
-  },
-);
+    })
+    return byId
+  }
+)

+ 4 - 7
src/lib/qr.ts

@@ -1,12 +1,9 @@
-import QRCode from "qrcode";
+import QRCode from 'qrcode'
 
-export async function generateQrDataUri(
-  text: string,
-  size: number = 200,
-): Promise<string> {
+export async function generateQrDataUri(text: string, size: number = 200): Promise<string> {
   return QRCode.toDataURL(text, {
     width: size,
     margin: 1,
-    color: { dark: "#000000", light: "#ffffff" },
-  });
+    color: { dark: '#000000', light: '#ffffff' },
+  })
 }

+ 5 - 7
src/lib/query-provider.tsx

@@ -1,7 +1,7 @@
-"use client";
+'use client'
 
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { useState } from "react";
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { useState } from 'react'
 
 export function QueryProvider({ children }: { children: React.ReactNode }) {
   const [queryClient] = useState(
@@ -13,9 +13,7 @@ export function QueryProvider({ children }: { children: React.ReactNode }) {
           },
         },
       })
-  );
+  )
 
-  return (
-    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
-  );
+  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
 }

+ 48 - 30
src/lib/rate-limit.ts

@@ -1,21 +1,25 @@
-import { NextResponse } from "next/server";
+import { createHash } from 'node:crypto'
+import { NextResponse } from 'next/server'
 
 interface RateLimitEntry {
-  count: number;
-  resetAt: number;
+  count: number
+  resetAt: number
 }
 
-const store = new Map<string, RateLimitEntry>();
+const store = new Map<string, RateLimitEntry>()
 
 // Clean up expired entries every 5 minutes
-setInterval(() => {
-  const now = Date.now();
-  for (const [key, entry] of store) {
-    if (now > entry.resetAt) {
-      store.delete(key);
+setInterval(
+  () => {
+    const now = Date.now()
+    for (const [key, entry] of store) {
+      if (now > entry.resetAt) {
+        store.delete(key)
+      }
     }
-  }
-}, 5 * 60 * 1000);
+  },
+  5 * 60 * 1000
+)
 
 /**
  * Simple in-memory rate limiter.
@@ -23,43 +27,57 @@ setInterval(() => {
  */
 export function rateLimit(
   request: Request,
-  { limit = 30, windowMs = 60_000 }: { limit?: number; windowMs?: number } = {},
+  { limit = 30, windowMs = 60_000 }: { limit?: number; windowMs?: number } = {}
 ): NextResponse | null {
   // cf-connecting-ip is set by Cloudflare and not client-forgeable on proxied
   // traffic; x-real-ip is set by nginx for direct/staging traffic. The first
   // entry of x-forwarded-for is client-controlled (proxies append, not
   // replace), so it is only a last resort.
-  const forwarded = request.headers.get("x-forwarded-for");
+  const forwarded = request.headers.get('x-forwarded-for')
   const ip =
-    request.headers.get("cf-connecting-ip") ||
-    request.headers.get("x-real-ip") ||
-    forwarded?.split(",")[0]?.trim() ||
-    "unknown";
-  const url = new URL(request.url);
-  const key = `${ip}:${url.pathname}`;
+    request.headers.get('cf-connecting-ip') ||
+    request.headers.get('x-real-ip') ||
+    forwarded?.split(',')[0]?.trim() ||
+    'unknown'
+  // Prefer the caller's own token over their IP.
+  //
+  // A workshop is one public address: eight technicians on the shop wifi share
+  // it, so an IP-keyed budget is divided between them and the busiest bay
+  // exhausts it for everyone. Hashed rather than stored, because this map is
+  // in memory and a session token has no business sitting in it.
+  //
+  // Falls back to the IP for unauthenticated callers, which is the only signal
+  // available before anyone has proved who they are.
+  const authHeader = request.headers.get('authorization')
+  const identity = authHeader?.toLowerCase().startsWith('bearer ')
+    ? `t:${createHash('sha256').update(authHeader.slice(7)).digest('hex').slice(0, 32)}`
+    : `ip:${ip}`
+
+  const url = new URL(request.url)
+  const key = `${identity}:${url.pathname}`
 
-  const now = Date.now();
-  const entry = store.get(key);
+  const now = Date.now()
+  const entry = store.get(key)
 
   if (!entry || now > entry.resetAt) {
-    store.set(key, { count: 1, resetAt: now + windowMs });
-    return null;
+    store.set(key, { count: 1, resetAt: now + windowMs })
+    return null
   }
 
-  entry.count++;
+  entry.count++
 
   if (entry.count > limit) {
-    const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
+    const retryAfter = Math.ceil((entry.resetAt - now) / 1000)
     return NextResponse.json(
-      { error: "Too many requests. Please try again later." },
+      { error: 'Too many requests. Please try again later.' },
       {
         status: 429,
         headers: {
-          "Retry-After": String(retryAfter),
+          'Retry-After': String(retryAfter),
         },
-      },
-    );
+      }
+    )
   }
 
-  return null;
+  return null
 }

+ 8 - 8
src/lib/resolve-upload-path.ts

@@ -1,4 +1,4 @@
-import path from "path";
+import path from 'path'
 
 /**
  * Resolves a file URL stored in the database to an absolute file path on disk.
@@ -9,18 +9,18 @@ import path from "path";
  *  - Legacy: /uploads/[category]/[filename] → public/uploads/[category]/[filename]
  */
 export function resolveUploadPath(fileUrl: string): string {
-  if (fileUrl.startsWith("/api/protected/files/")) {
+  if (fileUrl.startsWith('/api/protected/files/')) {
     // /api/protected/files/orgId/category/filename → data/uploads/orgId/category/filename
-    const relative = fileUrl.replace("/api/protected/files/", "");
-    return path.join(process.cwd(), "data", "uploads", relative);
+    const relative = fileUrl.replace('/api/protected/files/', '')
+    return path.join(process.cwd(), 'data', 'uploads', relative)
   }
 
-  if (fileUrl.startsWith("/api/files/")) {
+  if (fileUrl.startsWith('/api/files/')) {
     // /api/files/orgId/category/filename → data/uploads/orgId/category/filename
-    const relative = fileUrl.replace("/api/files/", "");
-    return path.join(process.cwd(), "data", "uploads", relative);
+    const relative = fileUrl.replace('/api/files/', '')
+    return path.join(process.cwd(), 'data', 'uploads', relative)
   }
 
   // Legacy: /uploads/category/filename → public/uploads/category/filename
-  return path.join(process.cwd(), "public", fileUrl);
+  return path.join(process.cwd(), 'public', fileUrl)
 }

+ 8 - 8
src/lib/safe-path.ts

@@ -1,4 +1,4 @@
-import path from "path";
+import path from 'path'
 
 /**
  * Safely resolve an archive- or user-supplied relative path against a trusted
@@ -15,17 +15,17 @@ export function resolveWithinDir(baseDir: string, relativePath: string): string
   // Callers pass archive-/backup-relative entry paths; an absolute input is
   // never legitimate and could escape via a drive/root, so reject it outright.
   if (path.isAbsolute(relativePath)) {
-    return null;
+    return null
   }
-  const base = path.resolve(baseDir);
-  const target = path.resolve(base, relativePath);
+  const base = path.resolve(baseDir)
+  const target = path.resolve(base, relativePath)
   // Use path.relative rather than a string prefix check so this stays correct
   // when base is the filesystem root (where `base + sep` would be `//`) and on
   // Windows. `target` is inside `base` iff the relative path neither climbs
   // out ("..") nor is itself absolute.
-  const rel = path.relative(base, target);
-  if (rel === "" || (rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel))) {
-    return target;
+  const rel = path.relative(base, target)
+  if (rel === '' || (rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel))) {
+    return target
   }
-  return null;
+  return null
 }

+ 20 - 20
src/lib/sanitize-html.ts

@@ -1,18 +1,18 @@
 const ALLOWED_TAGS = new Set([
-  "p",
-  "br",
-  "strong",
-  "b",
-  "em",
-  "i",
-  "u",
-  "h2",
-  "h3",
-  "ul",
-  "ol",
-  "li",
-  "blockquote",
-]);
+  'p',
+  'br',
+  'strong',
+  'b',
+  'em',
+  'i',
+  'u',
+  'h2',
+  'h3',
+  'ul',
+  'ol',
+  'li',
+  'blockquote',
+])
 
 /**
  * Strips all HTML tags and attributes except those produced by Tiptap.
@@ -21,12 +21,12 @@ const ALLOWED_TAGS = new Set([
  */
 export function sanitizeHtml(html: string): string {
   return html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/g, (match, tag: string) => {
-    const lower = tag.toLowerCase();
-    if (!ALLOWED_TAGS.has(lower)) return "";
+    const lower = tag.toLowerCase()
+    if (!ALLOWED_TAGS.has(lower)) return ''
     // Self-closing tags
-    if (lower === "br") return "<br>";
+    if (lower === 'br') return '<br>'
     // Strip all attributes, keep only the tag
-    if (match.startsWith("</")) return `</${lower}>`;
-    return `<${lower}>`;
-  });
+    if (match.startsWith('</')) return `</${lower}>`
+    return `<${lower}>`
+  })
 }

Some files were not shown because too many files changed in this diff