Просмотр исходного кода

Add per-reminder due notifications (bell and email) (#229)

* Add per-reminder due notifications (bell and email)

* Reminders can relate to a vehicle, a customer, or the workshop
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
111fd2a30b
44 измененных файлов с 923 добавлено и 84 удалено
  1. 6 1
      messages/de/reminders.json
  2. 4 1
      messages/de/vehicles.json
  3. 6 1
      messages/en/reminders.json
  4. 4 1
      messages/en/vehicles.json
  5. 6 1
      messages/es/reminders.json
  6. 4 1
      messages/es/vehicles.json
  7. 6 1
      messages/fr/reminders.json
  8. 4 1
      messages/fr/vehicles.json
  9. 6 1
      messages/it/reminders.json
  10. 4 1
      messages/it/vehicles.json
  11. 6 1
      messages/lt/reminders.json
  12. 4 1
      messages/lt/vehicles.json
  13. 6 1
      messages/nb/reminders.json
  14. 4 1
      messages/nb/vehicles.json
  15. 6 1
      messages/nl/reminders.json
  16. 4 1
      messages/nl/vehicles.json
  17. 6 1
      messages/pl/reminders.json
  18. 4 1
      messages/pl/vehicles.json
  19. 6 1
      messages/pt-BR/reminders.json
  20. 4 1
      messages/pt-BR/vehicles.json
  21. 6 1
      messages/ru/reminders.json
  22. 4 1
      messages/ru/vehicles.json
  23. 6 1
      messages/tr/reminders.json
  24. 4 1
      messages/tr/vehicles.json
  25. 16 0
      prisma/migrations/20260812210000_reminder_notifications/migration.sql
  26. 27 0
      prisma/migrations/20260813160000_reminder_org_scope/migration.sql
  27. 19 2
      prisma/schema.prisma
  28. 262 0
      src/__tests__/features/reminder-alerts.test.ts
  29. 6 4
      src/app/(authenticated)/dashboard-client.tsx
  30. 1 0
      src/app/(authenticated)/reminders/page.tsx
  31. 16 1
      src/app/api/protected/backup/export/route.ts
  32. 27 1
      src/app/api/protected/backup/import/route.ts
  33. 1 0
      src/cronTasks.ts
  34. 4 3
      src/features/calendar/Actions/calendarActions.ts
  35. 1 1
      src/features/calendar/Components/CalendarEventList.tsx
  36. 3 1
      src/features/search/Actions/searchActions.ts
  37. 7 5
      src/features/search/Components/SearchCommand.tsx
  38. 2 1
      src/features/vehicles/Actions/dashboardActions.ts
  39. 65 23
      src/features/vehicles/Actions/reminderActions.ts
  40. 31 0
      src/features/vehicles/Components/ReminderForm.tsx
  41. 105 17
      src/features/vehicles/Components/RemindersPageClient.tsx
  42. 6 1
      src/features/vehicles/Schema/reminderSchema.ts
  43. 2 0
      src/instrumentation.ts
  44. 202 0
      src/lib/cron/reminder-alerts.ts

+ 6 - 1
messages/de/reminders.json

@@ -8,5 +8,10 @@
   "searchVehicle": "Fahrzeuge suchen...",
   "noVehicle": "Keine Fahrzeuge gefunden.",
   "pickDate": "Datum auswählen",
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "relatesTo": "Bezieht sich auf (optional)",
+  "selectCustomer": "Kunde (optional)",
+  "noneOption": "Keiner",
+  "workshopHint": "Kein Kunde oder Fahrzeug gewählt: dies ist eine allgemeine Werkstatt-Erinnerung.",
+  "workshopReminder": "Werkstatt"
 }

+ 4 - 1
messages/de/vehicles.json

@@ -134,7 +134,10 @@
     "dueMileagePlaceholder": "z.B. 50000",
     "reminderUpdated": "Erinnerung aktualisiert",
     "reminderCreated": "Erinnerung erstellt",
-    "saveError": "Fehler beim {action} der Erinnerung"
+    "saveError": "Fehler beim {action} der Erinnerung",
+    "notifyLabel": "Bei Fälligkeit benachrichtigen",
+    "notifyInApp": "In-App-Benachrichtigung",
+    "notifyEmail": "E-Mail"
   },
   "services": {
     "searchPlaceholder": "Einträge durchsuchen...",

+ 6 - 1
messages/en/reminders.json

@@ -8,5 +8,10 @@
   "searchVehicle": "Search vehicles...",
   "noVehicle": "No vehicles found.",
   "pickDate": "Pick a date",
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "relatesTo": "Relates to (optional)",
+  "selectCustomer": "Customer (optional)",
+  "noneOption": "None",
+  "workshopHint": "No customer or vehicle selected: this is a general workshop reminder.",
+  "workshopReminder": "Workshop"
 }

+ 4 - 1
messages/en/vehicles.json

@@ -134,7 +134,10 @@
     "dueMileagePlaceholder": "e.g. 50000",
     "reminderUpdated": "Reminder updated",
     "reminderCreated": "Reminder created",
-    "saveError": "Failed to {action} reminder"
+    "saveError": "Failed to {action} reminder",
+    "notifyLabel": "Notify me when due",
+    "notifyInApp": "In-app notification",
+    "notifyEmail": "Email"
   },
   "services": {
     "searchPlaceholder": "Search records...",

+ 6 - 1
messages/es/reminders.json

@@ -8,5 +8,10 @@
   "searchVehicle": "Buscar vehículos...",
   "noVehicle": "No se encontraron vehículos.",
   "pickDate": "Seleccionar fecha",
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "relatesTo": "Se refiere a (opcional)",
+  "selectCustomer": "Cliente (opcional)",
+  "noneOption": "Ninguno",
+  "workshopHint": "Sin cliente ni vehículo: este es un recordatorio general del taller.",
+  "workshopReminder": "Taller"
 }

+ 4 - 1
messages/es/vehicles.json

@@ -134,7 +134,10 @@
     "dueMileagePlaceholder": "ej. 50000",
     "reminderUpdated": "Recordatorio actualizado",
     "reminderCreated": "Recordatorio creado",
-    "saveError": "Error al {action} recordatorio"
+    "saveError": "Error al {action} recordatorio",
+    "notifyLabel": "Notificarme al vencer",
+    "notifyInApp": "Notificación en la app",
+    "notifyEmail": "Correo electrónico"
   },
   "services": {
     "searchPlaceholder": "Buscar registros...",

+ 6 - 1
messages/fr/reminders.json

@@ -8,5 +8,10 @@
   "searchVehicle": "Rechercher des véhicules...",
   "noVehicle": "Aucun véhicule trouvé.",
   "pickDate": "Choisir une date",
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "relatesTo": "Concerne (optionnel)",
+  "selectCustomer": "Client (optionnel)",
+  "noneOption": "Aucun",
+  "workshopHint": "Aucun client ni véhicule sélectionné : rappel général pour l'atelier.",
+  "workshopReminder": "Atelier"
 }

+ 4 - 1
messages/fr/vehicles.json

@@ -134,7 +134,10 @@
     "dueMileagePlaceholder": "ex. 50000",
     "reminderUpdated": "Rappel mis à jour",
     "reminderCreated": "Rappel créé",
-    "saveError": "Échec de {action} du rappel"
+    "saveError": "Échec de {action} du rappel",
+    "notifyLabel": "Me notifier à l'échéance",
+    "notifyInApp": "Notification dans l'app",
+    "notifyEmail": "E-mail"
   },
   "services": {
     "searchPlaceholder": "Rechercher des dossiers...",

+ 6 - 1
messages/it/reminders.json

@@ -8,5 +8,10 @@
   "searchVehicle": "Cerca veicoli...",
   "noVehicle": "Nessun veicolo trovato.",
   "pickDate": "Seleziona una data",
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "relatesTo": "Riguarda (opzionale)",
+  "selectCustomer": "Cliente (opzionale)",
+  "noneOption": "Nessuno",
+  "workshopHint": "Nessun cliente o veicolo selezionato: promemoria generale dell'officina.",
+  "workshopReminder": "Officina"
 }

+ 4 - 1
messages/it/vehicles.json

@@ -134,7 +134,10 @@
     "dueMileagePlaceholder": "es. 50000",
     "reminderUpdated": "Promemoria aggiornato",
     "reminderCreated": "Promemoria creato",
-    "saveError": "Impossibile {action} il promemoria"
+    "saveError": "Impossibile {action} il promemoria",
+    "notifyLabel": "Avvisami alla scadenza",
+    "notifyInApp": "Notifica in app",
+    "notifyEmail": "E-mail"
   },
   "services": {
     "searchPlaceholder": "Cerca interventi...",

+ 6 - 1
messages/lt/reminders.json

@@ -8,5 +8,10 @@
   "searchVehicle": "Ieškoti transporto priemonių...",
   "noVehicle": "Transporto priemonių nerasta.",
   "pickDate": "Pasirinkite datą",
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "relatesTo": "Susijęs su (neprivaloma)",
+  "selectCustomer": "Klientas (neprivaloma)",
+  "noneOption": "Nėra",
+  "workshopHint": "Nepasirinktas klientas ar transporto priemonė: tai bendras dirbtuvių priminimas.",
+  "workshopReminder": "Dirbtuvės"
 }

+ 4 - 1
messages/lt/vehicles.json

@@ -134,7 +134,10 @@
     "dueMileagePlaceholder": "pvz., 50000",
     "reminderUpdated": "Priminimas atnaujintas",
     "reminderCreated": "Priminimas sukurtas",
-    "saveError": "Nepavyko {action} priminimo"
+    "saveError": "Nepavyko {action} priminimo",
+    "notifyLabel": "Pranešti suėjus terminui",
+    "notifyInApp": "Pranešimas programoje",
+    "notifyEmail": "El. paštas"
   },
   "services": {
     "searchPlaceholder": "Ieškoti įrašų...",

+ 6 - 1
messages/nb/reminders.json

@@ -8,5 +8,10 @@
   "searchVehicle": "Søk kjøretøy...",
   "noVehicle": "Ingen kjøretøy funnet.",
   "pickDate": "Velg en dato",
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "relatesTo": "Gjelder (valgfritt)",
+  "selectCustomer": "Kunde (valgfritt)",
+  "noneOption": "Ingen",
+  "workshopHint": "Ingen kunde eller kjøretøy valgt: dette er en generell verkstedpåminnelse.",
+  "workshopReminder": "Verksted"
 }

+ 4 - 1
messages/nb/vehicles.json

@@ -134,7 +134,10 @@
     "dueMileagePlaceholder": "f.eks. 50000",
     "reminderUpdated": "Paminnelse oppdatert",
     "reminderCreated": "Paminnelse opprettet",
-    "saveError": "Kunne ikke {action} paminnelse"
+    "saveError": "Kunne ikke {action} paminnelse",
+    "notifyLabel": "Varsle meg ved forfall",
+    "notifyInApp": "Varsel i appen",
+    "notifyEmail": "E-post"
   },
   "services": {
     "searchPlaceholder": "Sok i oppforinger...",

+ 6 - 1
messages/nl/reminders.json

@@ -8,5 +8,10 @@
   "searchVehicle": "Voertuigen zoeken...",
   "noVehicle": "Geen voertuigen gevonden.",
   "pickDate": "Datum kiezen",
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "relatesTo": "Betreft (optioneel)",
+  "selectCustomer": "Klant (optioneel)",
+  "noneOption": "Geen",
+  "workshopHint": "Geen klant of voertuig gekozen: dit is een algemene werkplaatsherinnering.",
+  "workshopReminder": "Werkplaats"
 }

+ 4 - 1
messages/nl/vehicles.json

@@ -134,7 +134,10 @@
     "dueMileagePlaceholder": "bijv. 50000",
     "reminderUpdated": "Herinnering bijgewerkt",
     "reminderCreated": "Herinnering aangemaakt",
-    "saveError": "{action} van herinnering mislukt"
+    "saveError": "{action} van herinnering mislukt",
+    "notifyLabel": "Waarschuw mij bij vervaldatum",
+    "notifyInApp": "In-app melding",
+    "notifyEmail": "E-mail"
   },
   "services": {
     "searchPlaceholder": "Zoek in records...",

+ 6 - 1
messages/pl/reminders.json

@@ -8,5 +8,10 @@
   "searchVehicle": "Szukaj pojazdów...",
   "noVehicle": "Nie znaleziono pojazdów.",
   "pickDate": "Wybierz datę",
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "relatesTo": "Dotyczy (opcjonalnie)",
+  "selectCustomer": "Klient (opcjonalnie)",
+  "noneOption": "Brak",
+  "workshopHint": "Nie wybrano klienta ani pojazdu: to ogólne przypomnienie warsztatu.",
+  "workshopReminder": "Warsztat"
 }

+ 4 - 1
messages/pl/vehicles.json

@@ -134,7 +134,10 @@
     "dueMileagePlaceholder": "np. 50000",
     "reminderUpdated": "Przypomnienie zaktualizowane",
     "reminderCreated": "Przypomnienie utworzone",
-    "saveError": "Nie udało się {action} przypomnienia"
+    "saveError": "Nie udało się {action} przypomnienia",
+    "notifyLabel": "Powiadom mnie w terminie",
+    "notifyInApp": "Powiadomienie w aplikacji",
+    "notifyEmail": "E-mail"
   },
   "services": {
     "searchPlaceholder": "Szukaj rekordów...",

+ 6 - 1
messages/pt-BR/reminders.json

@@ -8,5 +8,10 @@
   "searchVehicle": "Buscar veículos...",
   "noVehicle": "Nenhum veículo encontrado.",
   "pickDate": "Escolher uma data",
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "relatesTo": "Refere-se a (opcional)",
+  "selectCustomer": "Cliente (opcional)",
+  "noneOption": "Nenhum",
+  "workshopHint": "Nenhum cliente ou veículo selecionado: este é um lembrete geral da oficina.",
+  "workshopReminder": "Oficina"
 }

+ 4 - 1
messages/pt-BR/vehicles.json

@@ -134,7 +134,10 @@
     "dueMileagePlaceholder": "ex. 50000",
     "reminderUpdated": "Lembrete atualizado",
     "reminderCreated": "Lembrete criado",
-    "saveError": "Falha ao {action} lembrete"
+    "saveError": "Falha ao {action} lembrete",
+    "notifyLabel": "Notificar no vencimento",
+    "notifyInApp": "Notificação no app",
+    "notifyEmail": "E-mail"
   },
   "services": {
     "searchPlaceholder": "Buscar registros...",

+ 6 - 1
messages/ru/reminders.json

@@ -8,5 +8,10 @@
   "searchVehicle": "Поиск транспортных средств...",
   "noVehicle": "Транспортные средства не найдены.",
   "pickDate": "Выберите дату",
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "relatesTo": "Относится к (необязательно)",
+  "selectCustomer": "Клиент (необязательно)",
+  "noneOption": "Нет",
+  "workshopHint": "Клиент или автомобиль не выбраны: это общее напоминание для мастерской.",
+  "workshopReminder": "Мастерская"
 }

+ 4 - 1
messages/ru/vehicles.json

@@ -134,7 +134,10 @@
     "dueMileagePlaceholder": "напр. 50000",
     "reminderUpdated": "Напоминание обновлено",
     "reminderCreated": "Напоминание создано",
-    "saveError": "Не удалось {action} напоминание"
+    "saveError": "Не удалось {action} напоминание",
+    "notifyLabel": "Уведомить при наступлении срока",
+    "notifyInApp": "Уведомление в приложении",
+    "notifyEmail": "Эл. почта"
   },
   "services": {
     "searchPlaceholder": "Поиск записей...",

+ 6 - 1
messages/tr/reminders.json

@@ -8,5 +8,10 @@
   "searchVehicle": "Araç ara...",
   "noVehicle": "Araç bulunamadı.",
   "pickDate": "Tarih seçin",
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "relatesTo": "İlgili (isteğe bağlı)",
+  "selectCustomer": "Müşteri (isteğe bağlı)",
+  "noneOption": "Yok",
+  "workshopHint": "Müşteri veya araç seçilmedi: bu genel bir atölye hatırlatıcısıdır.",
+  "workshopReminder": "Atölye"
 }

+ 4 - 1
messages/tr/vehicles.json

@@ -134,7 +134,10 @@
     "dueMileagePlaceholder": "örn. 50000",
     "reminderUpdated": "Hatırlatıcı güncellendi",
     "reminderCreated": "Hatırlatıcı oluşturuldu",
-    "saveError": "Hatırlatıcı {action} başarısız oldu"
+    "saveError": "Hatırlatıcı {action} başarısız oldu",
+    "notifyLabel": "Vadesi geldiğinde bildir",
+    "notifyInApp": "Uygulama içi bildirim",
+    "notifyEmail": "E-posta"
   },
   "services": {
     "searchPlaceholder": "Kayıtlarda ara...",

+ 16 - 0
prisma/migrations/20260812210000_reminder_notifications/migration.sql

@@ -0,0 +1,16 @@
+-- Per-reminder notification channels (in-app bell / email) and a marker for
+-- when the due-reminder cron has fired. Idempotent and safe to re-run.
+
+ALTER TABLE "reminders" ADD COLUMN IF NOT EXISTS "notifyInApp" BOOLEAN NOT NULL DEFAULT true;
+ALTER TABLE "reminders" ADD COLUMN IF NOT EXISTS "notifyEmail" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "reminders" ADD COLUMN IF NOT EXISTS "notifiedAt" TIMESTAMP(3);
+
+-- Reminders that are ALREADY overdue at deploy time are marked as notified so
+-- the first cron run does not flood every workshop's bell with notifications
+-- for reminders they have long been seeing as overdue in the UI. Only
+-- reminders that become due after this deploy will notify.
+UPDATE "reminders"
+SET "notifiedAt" = NOW()
+WHERE "dueDate" IS NOT NULL
+  AND "dueDate" <= NOW()
+  AND "notifiedAt" IS NULL;

+ 27 - 0
prisma/migrations/20260813160000_reminder_org_scope/migration.sql

@@ -0,0 +1,27 @@
+-- Reminders gain their own organization scope, an optional customer, and an
+-- optional vehicle so a reminder can relate to a vehicle, a customer, or just
+-- the workshop. Idempotent and backfilling; safe to re-run.
+
+ALTER TABLE "reminders" ADD COLUMN IF NOT EXISTS "organizationId" TEXT;
+ALTER TABLE "reminders" ADD COLUMN IF NOT EXISTS "customerId" TEXT;
+
+-- Backfill organization from the owning vehicle (every existing reminder has one)
+UPDATE "reminders" r
+SET "organizationId" = v."organizationId"
+FROM "vehicles" v
+WHERE r."vehicleId" = v."id" AND r."organizationId" IS NULL;
+
+ALTER TABLE "reminders" ALTER COLUMN "vehicleId" DROP NOT NULL;
+
+DO $$ BEGIN
+  ALTER TABLE "reminders" ADD CONSTRAINT "reminders_organizationId_fkey"
+    FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+
+DO $$ BEGIN
+  ALTER TABLE "reminders" ADD CONSTRAINT "reminders_customerId_fkey"
+    FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+
+CREATE INDEX IF NOT EXISTS "reminders_customerId_idx" ON "reminders"("customerId");
+CREATE INDEX IF NOT EXISTS "reminders_organizationId_idx" ON "reminders"("organizationId");

+ 19 - 2
prisma/schema.prisma

@@ -307,13 +307,28 @@ model Reminder {
   dueDate     DateTime?
   dueMileage  Int?
   isCompleted Boolean   @default(false)
+  /// Per-reminder notification channels, chosen when creating the reminder
+  notifyInApp Boolean   @default(true)
+  notifyEmail Boolean   @default(false)
+  /// Set by the due-reminder cron once notified; cleared when dueDate changes
+  notifiedAt  DateTime?
   createdAt   DateTime  @default(now())
   updatedAt   DateTime  @updatedAt
 
-  vehicleId String
-  vehicle   Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
+  /// A reminder relates to a vehicle, a customer, or just the workshop
+  /// (both null). organizationId is the canonical scope for all three.
+  vehicleId String?
+  vehicle   Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
+
+  customerId String?
+  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: Cascade)
+
+  organizationId String?
+  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
 
   @@index([vehicleId])
+  @@index([customerId])
+  @@index([organizationId])
   @@map("reminders")
 }
 
@@ -373,6 +388,7 @@ model Customer {
   serviceRequests  ServiceRequest[]
 
   serviceRecords ServiceRecord[]
+  reminders      Reminder[]
 
   @@unique([organizationId, customerNumber])
   @@index([organizationId])
@@ -769,6 +785,7 @@ model Organization {
   roles                  Role[]
   vehicles               Vehicle[]
   serviceRecords         ServiceRecord[]
+  reminders              Reminder[]
   customers              Customer[]
   quotes                 Quote[]
   inventoryParts         InventoryPart[]

+ 262 - 0
src/__tests__/features/reminder-alerts.test.ts

@@ -0,0 +1,262 @@
+/**
+ * Due-reminder notifications: per-reminder channel choice (bell / email),
+ * notify-once semantics, and re-arming when a reminder is rescheduled.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/cached-session", () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}));
+vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
+vi.mock("@/lib/notify", () => ({ notify: vi.fn() }));
+vi.mock("@/lib/email", () => ({
+  sendOrgMail: vi.fn(),
+  getOrgFromAddress: vi.fn().mockResolvedValue("Shop <shop@example.com>"),
+}));
+vi.mock("@/lib/db", () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    vehicle: { findFirst: vi.fn() },
+    customer: { findFirst: vi.fn() },
+    reminder: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
+    organizationMember: { findMany: vi.fn() },
+    organization: { findUnique: vi.fn() },
+  },
+}));
+
+import { db } from "@/lib/db";
+import { notify } from "@/lib/notify";
+import { sendOrgMail } from "@/lib/email";
+import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
+import { processDueReminders } from "@/lib/cron/reminder-alerts";
+import { createReminder, updateReminder } from "@/features/vehicles/Actions/reminderActions";
+
+const ORG_A = "org-a";
+
+const dueReminder = (overrides: Record<string, unknown> = {}) => ({
+  id: "rem-1",
+  title: "EU-kontroll",
+  description: null,
+  dueDate: new Date("2026-08-13T07:00:00Z"),
+  notifyInApp: true,
+  notifyEmail: false,
+  organizationId: ORG_A,
+  customer: null,
+  vehicle: {
+    id: "veh-1",
+    make: "Volvo",
+    model: "V70",
+    year: 2019,
+    licensePlate: "AB12345",
+    customer: { name: "Kari" },
+  },
+  ...overrides,
+});
+
+beforeEach(() => {
+  vi.resetAllMocks();
+  vi.mocked(db.reminder.updateMany).mockResolvedValue({ count: 1 } as never);
+});
+
+describe("processDueReminders", () => {
+  it("only picks up uncompleted, unnotified reminders with a channel enabled", async () => {
+    vi.mocked(db.reminder.findMany).mockResolvedValue([]);
+    await processDueReminders(new Date("2026-08-13T12:00:00Z"));
+
+    expect(vi.mocked(db.reminder.findMany)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: expect.objectContaining({
+          isCompleted: false,
+          notifiedAt: null,
+          OR: [{ notifyInApp: true }, { notifyEmail: true }],
+        }),
+      })
+    );
+  });
+
+  it("sends a bell notification and marks the reminder notified", async () => {
+    vi.mocked(db.reminder.findMany).mockResolvedValue([dueReminder()] as never);
+
+    const count = await processDueReminders(new Date("2026-08-13T12:00:00Z"));
+
+    expect(count).toBe(1);
+    expect(vi.mocked(notify)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        organizationId: ORG_A,
+        type: "reminder.due",
+        entityId: "rem-1",
+        entityUrl: "/vehicles/veh-1?tab=reminders",
+      })
+    );
+    expect(vi.mocked(sendOrgMail)).not.toHaveBeenCalled();
+    expect(vi.mocked(db.reminder.updateMany)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: { id: { in: ["rem-1"] } },
+        data: { notifiedAt: expect.any(Date) },
+      })
+    );
+  });
+
+  it("emails owners/admins when the reminder chose email, without a bell", async () => {
+    vi.mocked(db.reminder.findMany).mockResolvedValue([
+      dueReminder({ notifyInApp: false, notifyEmail: true }),
+    ] as never);
+    vi.mocked(db.organizationMember.findMany).mockResolvedValue([
+      { user: { email: "owner@shop.no", name: "Owner" } },
+    ] as never);
+
+    await processDueReminders(new Date("2026-08-13T12:00:00Z"));
+
+    expect(vi.mocked(notify)).not.toHaveBeenCalled();
+    expect(vi.mocked(sendOrgMail)).toHaveBeenCalledWith(
+      ORG_A,
+      expect.objectContaining({ to: "owner@shop.no", subject: expect.stringContaining("EU-kontroll") })
+    );
+  });
+
+  it("still marks reminders notified when email sending fails", async () => {
+    vi.mocked(db.reminder.findMany).mockResolvedValue([
+      dueReminder({ notifyEmail: true }),
+    ] as never);
+    vi.mocked(db.organizationMember.findMany).mockResolvedValue([
+      { user: { email: "owner@shop.no", name: "Owner" } },
+    ] as never);
+    vi.mocked(sendOrgMail).mockRejectedValue(new Error("smtp down"));
+
+    await processDueReminders(new Date("2026-08-13T12:00:00Z"));
+
+    expect(vi.mocked(db.reminder.updateMany)).toHaveBeenCalled();
+  });
+});
+
+describe("updateReminder — re-arm on reschedule", () => {
+  function setupOrgAOwner() {
+    vi.mocked(getCachedSession).mockResolvedValue({ user: { id: "user-a", email: "a@x.no" } } as never);
+    vi.mocked(getCachedMembership).mockResolvedValue({
+      organizationId: ORG_A, role: "owner", roleId: null, customRole: null,
+    } as never);
+    vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as never);
+  }
+
+  it("clears notifiedAt when the due date changes", async () => {
+    setupOrgAOwner();
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ id: "veh-1", customerId: null } as never);
+    vi.mocked(db.reminder.findFirst).mockResolvedValue({
+      id: "rem-1", vehicleId: "veh-1", dueDate: new Date("2026-08-01T00:00:00Z"),
+    } as never);
+    vi.mocked(db.reminder.update).mockResolvedValue({ id: "rem-1" } as never);
+
+    await updateReminder({ id: "rem-1", vehicleId: "veh-1", title: "x", dueDate: "2026-09-01" });
+
+    expect(vi.mocked(db.reminder.update)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ notifiedAt: null }),
+      })
+    );
+  });
+
+  it("keeps notifiedAt when the due date is unchanged", async () => {
+    setupOrgAOwner();
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ id: "veh-1", customerId: null } as never);
+    const same = new Date("2026-09-01T00:00:00.000Z");
+    vi.mocked(db.reminder.findFirst).mockResolvedValue({
+      id: "rem-1", vehicleId: "veh-1", dueDate: same,
+    } as never);
+    vi.mocked(db.reminder.update).mockResolvedValue({ id: "rem-1" } as never);
+
+    await updateReminder({ id: "rem-1", vehicleId: "veh-1", title: "x", dueDate: "2026-09-01" });
+
+    const call = vi.mocked(db.reminder.update).mock.calls[0][0] as { data: { notifiedAt?: unknown } };
+    expect(call.data.notifiedAt).toBeUndefined();
+  });
+});
+
+describe("reminder targets — vehicle, customer, or workshop", () => {
+  function setupOrgAOwner() {
+    vi.mocked(getCachedSession).mockResolvedValue({ user: { id: "user-a", email: "a@x.no" } } as never);
+    vi.mocked(getCachedMembership).mockResolvedValue({
+      organizationId: ORG_A, role: "owner", roleId: null, customRole: null,
+    } as never);
+    vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as never);
+  }
+
+  it("workshop reminder: no vehicle, no customer, org-stamped", async () => {
+    setupOrgAOwner();
+    vi.mocked(db.reminder.create).mockResolvedValue({ id: "rem-w", title: "Petter has birthday", vehicleId: null } as never);
+
+    const result = await createReminder({ title: "Petter has birthday", dueDate: "2026-09-01" });
+
+    expect(result.success).toBe(true);
+    expect(vi.mocked(db.reminder.create)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({
+          organizationId: ORG_A,
+          vehicleId: null,
+          customerId: null,
+        }),
+      })
+    );
+  });
+
+  it("customer reminder: verifies the customer belongs to the org", async () => {
+    setupOrgAOwner();
+    vi.mocked(db.customer.findFirst).mockResolvedValue(null);
+
+    const result = await createReminder({ title: "Follow up", customerId: "cust-other-org" });
+
+    expect(result.success).toBe(false);
+    expect(result.error).toBe("Customer not found");
+  });
+
+  it("vehicle reminder: takes the vehicle's customer automatically", async () => {
+    setupOrgAOwner();
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ id: "veh-1", customerId: "cust-a" } as never);
+    vi.mocked(db.reminder.create).mockResolvedValue({ id: "rem-v", title: "EU", vehicleId: "veh-1" } as never);
+
+    const result = await createReminder({ title: "EU", vehicleId: "veh-1" });
+
+    expect(result.success).toBe(true);
+    expect(vi.mocked(db.reminder.create)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({
+          organizationId: ORG_A,
+          vehicleId: "veh-1",
+          customerId: "cust-a",
+        }),
+      })
+    );
+  });
+
+  it("cron notifies a workshop reminder with a plain title and /reminders link", async () => {
+    vi.mocked(db.reminder.updateMany).mockResolvedValue({ count: 1 } as never);
+    vi.mocked(db.reminder.findMany).mockResolvedValue([
+      dueReminder({ vehicle: null, customer: null, title: "Petter has birthday" }),
+    ] as never);
+
+    await processDueReminders(new Date("2026-08-13T12:00:00Z"));
+
+    expect(vi.mocked(notify)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        message: "Petter has birthday",
+        entityUrl: "/reminders",
+      })
+    );
+  });
+
+  it("cron links a customer reminder to the customer page", async () => {
+    vi.mocked(db.reminder.updateMany).mockResolvedValue({ count: 1 } as never);
+    vi.mocked(db.reminder.findMany).mockResolvedValue([
+      dueReminder({ vehicle: null, customer: { id: "cust-a", name: "Petter" } }),
+    ] as never);
+
+    await processDueReminders(new Date("2026-08-13T12:00:00Z"));
+
+    expect(vi.mocked(notify)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        message: "EU-kontroll — Petter",
+        entityUrl: "/customers/cust-a",
+      })
+    );
+  });
+});

+ 6 - 4
src/app/(authenticated)/dashboard-client.tsx

@@ -107,13 +107,14 @@ interface ReminderItem {
   description: string | null;
   dueDate: Date | null;
   dueMileage: number | null;
+  customer: { id: string; name: string } | null;
   vehicle: {
     id: string;
     make: string;
     model: string;
     year: number;
     licensePlate: string | null;
-  };
+  } | null;
 }
 
 interface VehicleDueForService {
@@ -730,7 +731,7 @@ export function DashboardClient({
                     <div
                       key={r.id}
                       className="flex items-center justify-between px-5 py-3 cursor-pointer hover:bg-muted/50 transition-colors"
-                      onClick={() => router.push(`/vehicles/${r.vehicle.id}?tab=reminders`)}
+                      onClick={() => router.push(r.vehicle ? `/vehicles/${r.vehicle.id}?tab=reminders` : "/reminders")}
                     >
                       <div className="flex items-center gap-3 min-w-0">
                         <div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
@@ -747,8 +748,9 @@ export function DashboardClient({
                         <div className="min-w-0">
                           <p className="font-medium text-sm truncate">{r.title}</p>
                           <p className="text-xs text-muted-foreground truncate">
-                            {r.vehicle.year} {r.vehicle.make} {r.vehicle.model}
-                            {r.vehicle.licensePlate && ` · ${r.vehicle.licensePlate}`}
+                            {r.vehicle
+                              ? `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}${r.vehicle.licensePlate ? ` · ${r.vehicle.licensePlate}` : ""}`
+                              : r.customer?.name ?? ""}
                           </p>
                         </div>
                       </div>

+ 1 - 0
src/app/(authenticated)/reminders/page.tsx

@@ -35,6 +35,7 @@ export default async function RemindersPage() {
         year: v.year,
         licensePlate: v.licensePlate,
         customerName: v.customer?.name ?? null,
+        customerId: v.customer?.id ?? null,
       }))
     : [];
 

+ 16 - 1
src/app/api/protected/backup/export/route.ts

@@ -126,6 +126,17 @@ export async function POST(request: NextRequest) {
     );
   }
 
+  if (options.vehicles) {
+    // Customer/workshop reminders have no vehicle to nest under.
+    queries.push(
+      db.reminder
+        .findMany({ where: { organizationId: ctx.organizationId, vehicleId: null } })
+        .then((result) => {
+          data.orgReminders = result;
+        })
+    );
+  }
+
   if (options.vehicles) {
     // Counter sales: service records without a vehicle, linked directly to a
     // customer. They are not nested under any vehicle, so export them
@@ -298,7 +309,7 @@ export async function GET() {
   }
 
   const [
-    settings, customers, customFieldDefinitions, inventoryParts, vehicles, counterSales, quotes,
+    settings, customers, customFieldDefinitions, inventoryParts, vehicles, orgReminders, counterSales, quotes,
     technicians, inspectionTemplates, inspections, auditLogs, smsMessages, notifications,
   ] = await Promise.all([
       db.appSetting.findMany({ where: { organizationId: ctx.organizationId } }),
@@ -324,6 +335,9 @@ export async function GET() {
           },
         },
       }),
+      db.reminder.findMany({
+        where: { organizationId: ctx.organizationId, vehicleId: null },
+      }),
       db.serviceRecord.findMany({
         where: { organizationId: ctx.organizationId, vehicleId: null },
         include: {
@@ -363,6 +377,7 @@ export async function GET() {
       customFieldDefinitions,
       inventoryParts,
       vehicles,
+      orgReminders,
       counterSales,
       quotes,
       technicians,

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

@@ -552,9 +552,13 @@ export async function POST(request: NextRequest) {
                 dueDate: toSafeDate(r.dueDate as string) ?? null,
                 dueMileage: (r.dueMileage as number) || null,
                 isCompleted: (r.isCompleted as boolean) || false,
+                notifyInApp: (r.notifyInApp as boolean) ?? true,
+                notifyEmail: (r.notifyEmail as boolean) ?? false,
                 createdAt: toSafeDate(r.createdAt as string),
                 updatedAt: toSafeDate(r.updatedAt as string),
                 vehicleId: v.id as string,
+                customerId: (r.customerId as string) || null,
+                organizationId: ctx.organizationId,
               })),
             });
           }
@@ -576,7 +580,29 @@ export async function POST(request: NextRequest) {
         }
       }
 
-      // 6b. Counter sales (service records without a vehicle, linked directly
+      // 6b. Customer/workshop reminders (no vehicle). Older backups don't
+      // have this key.
+      if (data.orgReminders?.length) {
+        await tx.reminder.createMany({
+          data: (data.orgReminders as Record<string, unknown>[]).map((r) => ({
+            id: r.id as string,
+            title: r.title as string,
+            description: (r.description as string) || null,
+            dueDate: toSafeDate(r.dueDate as string) ?? null,
+            dueMileage: (r.dueMileage as number) || null,
+            isCompleted: (r.isCompleted as boolean) || false,
+            notifyInApp: (r.notifyInApp as boolean) ?? true,
+            notifyEmail: (r.notifyEmail as boolean) ?? false,
+            createdAt: toSafeDate(r.createdAt as string),
+            updatedAt: toSafeDate(r.updatedAt as string),
+            vehicleId: null,
+            customerId: (r.customerId as string) || null,
+            organizationId: ctx.organizationId,
+          })),
+        });
+      }
+
+      // 6c. Counter sales (service records without a vehicle, linked directly
       // to a customer). Older backups don't have this key.
       if (data.counterSales?.length) {
         for (const sr of data.counterSales as Record<string, unknown>[]) {

+ 1 - 0
src/cronTasks.ts

@@ -6,3 +6,4 @@ export { cleanupAuditLogs } from './lib/cron/cleanup-audit-logs'
 export { processReportSchedules } from './lib/cron/report-schedules'
 export { processWebhookDeliveries, cleanupWebhookDeliveries } from './lib/cron/webhook-deliveries'
 export { checkLowStock } from './lib/cron/low-stock-alerts'
+export { checkDueReminders } from './lib/cron/reminder-alerts'

+ 4 - 3
src/features/calendar/Actions/calendarActions.ts

@@ -69,7 +69,7 @@ export async function getCalendarEvents(params: {
       }),
       db.reminder.findMany({
         where: {
-          vehicle: { organizationId },
+          organizationId,
           dueDate: { gte: start, lte: end },
         },
         select: {
@@ -78,6 +78,7 @@ export async function getCalendarEvents(params: {
           dueDate: true,
           isCompleted: true,
           vehicleId: true,
+          customer: { select: { name: true } },
           vehicle: {
             select: {
               make: true,
@@ -141,8 +142,8 @@ export async function getCalendarEvents(params: {
           type: "reminder" as const,
           status: r.isCompleted ? "completed" : new Date(r.dueDate!) < new Date() ? "overdue" : "upcoming",
           vehicleId: r.vehicleId,
-          vehicleLabel: `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}`,
-          customerName: r.vehicle.customer?.name ?? null,
+          vehicleLabel: r.vehicle ? `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}` : "",
+          customerName: (r.customer ?? r.vehicle?.customer)?.name ?? null,
           invoiceNumber: null,
           amount: null,
         })),

+ 1 - 1
src/features/calendar/Components/CalendarEventList.tsx

@@ -59,7 +59,7 @@ function getTypeBadge(type: CalendarEvent["type"], t: (key: string) => string) {
 
 function getEventLink(event: CalendarEvent) {
   if (event.type === "quote") return `/quotes/${event.id}`;
-  if (!event.vehicleId) return `/sales/${event.id}`;
+  if (!event.vehicleId) return event.type === "reminder" ? "/reminders" : `/sales/${event.id}`;
   return `/vehicles/${event.vehicleId}`;
 }
 

+ 3 - 1
src/features/search/Actions/searchActions.ts

@@ -199,7 +199,7 @@ export async function globalSearch(query: string) {
       }),
       db.reminder.findMany({
         where: {
-          vehicle: { organizationId },
+          organizationId,
           AND: words.map((word) => ({
             OR: [
               { title: { contains: word, mode } },
@@ -207,6 +207,7 @@ export async function globalSearch(query: string) {
               { vehicle: { make: { contains: word, mode } } },
               { vehicle: { model: { contains: word, mode } } },
               { vehicle: { licensePlate: { contains: word, mode } } },
+              { customer: { name: { contains: word, mode } } },
             ],
           })),
         },
@@ -215,6 +216,7 @@ export async function globalSearch(query: string) {
           title: true,
           dueDate: true,
           isCompleted: true,
+          customer: { select: { id: true, name: true } },
           vehicle: {
             select: {
               id: true,

+ 7 - 5
src/features/search/Components/SearchCommand.tsx

@@ -64,13 +64,14 @@ interface SearchResult {
     title: string;
     dueDate: Date | null;
     isCompleted: boolean;
+    customer: { id: string; name: string } | null;
     vehicle: {
       id: string;
       make: string;
       model: string;
       year: number;
       licensePlate: string | null;
-    };
+    } | null;
   }[];
   inspections: {
     id: string;
@@ -373,15 +374,16 @@ export function SearchCommand() {
                 {results.reminders.map((r) => (
                   <CommandItem
                     key={r.id}
-                    value={`${r.title} ${r.vehicle.make} ${r.vehicle.model} ${r.vehicle.licensePlate || ""}`}
-                    onSelect={() => handleSelect(`/vehicles/${r.vehicle.id}?tab=reminders`)}
+                    value={`${r.title} ${r.vehicle ? `${r.vehicle.make} ${r.vehicle.model} ${r.vehicle.licensePlate || ""}` : r.customer?.name || ""}`}
+                    onSelect={() => handleSelect(r.vehicle ? `/vehicles/${r.vehicle.id}?tab=reminders` : "/reminders")}
                   >
                     <Bell className="mr-2 h-4 w-4 text-muted-foreground" />
                     <div className="flex flex-col">
                       <span>{r.title}{r.isCompleted && <span className="ml-1.5 text-muted-foreground line-through">(done)</span>}</span>
                       <span className="text-xs text-muted-foreground">
-                        {`${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}`}
-                        {r.vehicle.licensePlate && ` · ${r.vehicle.licensePlate}`}
+                        {r.vehicle
+                          ? `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}${r.vehicle.licensePlate ? ` · ${r.vehicle.licensePlate}` : ""}`
+                          : r.customer?.name || ""}
                       </span>
                     </div>
                   </CommandItem>

+ 2 - 1
src/features/vehicles/Actions/dashboardActions.ts

@@ -111,7 +111,7 @@ export async function getUpcomingReminders() {
     return db.reminder.findMany({
       where: {
         isCompleted: false,
-        vehicle: { organizationId },
+        organizationId,
       },
       include: {
         vehicle: {
@@ -123,6 +123,7 @@ export async function getUpcomingReminders() {
             licensePlate: true,
           },
         },
+        customer: { select: { id: true, name: true } },
       },
       orderBy: { dueDate: "asc" },
       take: 10,

+ 65 - 23
src/features/vehicles/Actions/reminderActions.ts

@@ -7,12 +7,16 @@ import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { createReminderSchema, updateReminderSchema } from "../Schema/reminderSchema";
 import { revalidatePath } from "next/cache";
 
+function revalidateReminderPaths(vehicleId: string | null) {
+  if (vehicleId) revalidatePath(`/vehicles/${vehicleId}`);
+  revalidatePath("/");
+  revalidatePath("/reminders");
+}
+
 export async function getAllReminders() {
   return withAuth(async ({ organizationId }) => {
     return db.reminder.findMany({
-      where: {
-        vehicle: { organizationId },
-      },
+      where: { organizationId },
       include: {
         vehicle: {
           select: {
@@ -24,6 +28,9 @@ export async function getAllReminders() {
             mileage: true,
           },
         },
+        customer: {
+          select: { id: true, name: true },
+        },
       },
       orderBy: [{ isCompleted: "asc" }, { dueDate: "asc" }],
     });
@@ -33,20 +40,36 @@ export async function getAllReminders() {
 export async function createReminder(input: unknown) {
   return withAuth(async ({ organizationId }) => {
     const data = createReminderSchema.parse(input);
-    const vehicle = await db.vehicle.findFirst({
-      where: { id: data.vehicleId, organizationId },
-    });
-    if (!vehicle) throw new Error("Vehicle not found");
+
+    // A reminder relates to a vehicle, a customer, or just the workshop.
+    // Whatever it relates to must belong to this organization.
+    let customerId = data.customerId ?? null;
+    if (data.vehicleId) {
+      const vehicle = await db.vehicle.findFirst({
+        where: { id: data.vehicleId, organizationId },
+        select: { id: true, customerId: true },
+      });
+      if (!vehicle) throw new Error("Vehicle not found");
+      // The vehicle wins: keep its customer link consistent with the vehicle
+      customerId = vehicle.customerId;
+    } else if (customerId) {
+      const customer = await db.customer.findFirst({
+        where: { id: customerId, organizationId },
+        select: { id: true },
+      });
+      if (!customer) throw new Error("Customer not found");
+    }
 
     const reminder = await db.reminder.create({
       data: {
         ...data,
+        organizationId,
+        vehicleId: data.vehicleId ?? null,
+        customerId,
         dueDate: toSafeDate(data.dueDate) ?? null,
       },
     });
-    revalidatePath(`/vehicles/${data.vehicleId}`);
-    revalidatePath("/");
-    revalidatePath("/reminders");
+    revalidateReminderPaths(data.vehicleId ?? null);
     return reminder;
   }, {
     requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES }],
@@ -64,22 +87,45 @@ export async function updateReminder(input: unknown) {
   return withAuth(async ({ organizationId }) => {
     const { id, ...data } = updateReminderSchema.parse(input);
     const reminder = await db.reminder.findFirst({
-      where: { id, vehicle: { organizationId } },
+      where: { id, organizationId },
     });
     if (!reminder) throw new Error("Reminder not found");
 
+    // Ownership checks when the target changes
+    let customerId = data.customerId;
+    if (data.vehicleId) {
+      const vehicle = await db.vehicle.findFirst({
+        where: { id: data.vehicleId, organizationId },
+        select: { id: true, customerId: true },
+      });
+      if (!vehicle) throw new Error("Vehicle not found");
+      customerId = vehicle.customerId;
+    } else if (data.customerId) {
+      const customer = await db.customer.findFirst({
+        where: { id: data.customerId, organizationId },
+        select: { id: true },
+      });
+      if (!customer) throw new Error("Customer not found");
+    }
+
+    const newDueDate = data.dueDate !== undefined ? (toSafeDate(data.dueDate) ?? null) : undefined;
     const updated = await db.reminder.update({
       where: { id },
       data: {
         ...data,
+        vehicleId: data.vehicleId !== undefined ? (data.vehicleId ?? null) : undefined,
+        customerId: customerId !== undefined ? (customerId ?? null) : undefined,
         description: data.description !== undefined ? (data.description || null) : undefined,
-        dueDate: data.dueDate !== undefined ? (toSafeDate(data.dueDate) ?? null) : undefined,
+        dueDate: newDueDate,
         dueMileage: data.dueMileage !== undefined ? (data.dueMileage ?? null) : undefined,
+        // A rescheduled reminder should notify again when the new date comes due
+        notifiedAt:
+          newDueDate !== undefined && newDueDate?.getTime() !== reminder.dueDate?.getTime()
+            ? null
+            : undefined,
       },
     });
-    revalidatePath(`/vehicles/${reminder.vehicleId}`);
-    revalidatePath("/");
-    revalidatePath("/reminders");
+    revalidateReminderPaths(reminder.vehicleId);
     return updated;
   }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES }] });
 }
@@ -87,7 +133,7 @@ export async function updateReminder(input: unknown) {
 export async function toggleReminder(reminderId: string) {
   return withAuth(async ({ organizationId }) => {
     const reminder = await db.reminder.findFirst({
-      where: { id: reminderId, vehicle: { organizationId } },
+      where: { id: reminderId, organizationId },
     });
     if (!reminder) throw new Error("Reminder not found");
 
@@ -95,23 +141,19 @@ export async function toggleReminder(reminderId: string) {
       where: { id: reminderId },
       data: { isCompleted: !reminder.isCompleted },
     });
-    revalidatePath(`/vehicles/${reminder.vehicleId}`);
-    revalidatePath("/");
-    revalidatePath("/reminders");
+    revalidateReminderPaths(reminder.vehicleId);
   }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES }] });
 }
 
 export async function deleteReminder(reminderId: string) {
   return withAuth(async ({ organizationId }) => {
     const reminder = await db.reminder.findFirst({
-      where: { id: reminderId, vehicle: { organizationId } },
+      where: { id: reminderId, organizationId },
     });
     if (!reminder) throw new Error("Reminder not found");
 
     await db.reminder.delete({ where: { id: reminderId } });
-    revalidatePath(`/vehicles/${reminder.vehicleId}`);
-    revalidatePath("/");
-    revalidatePath("/reminders");
+    revalidateReminderPaths(reminder.vehicleId);
     return { reminderId };
   }, {
     requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES }],

+ 31 - 0
src/features/vehicles/Components/ReminderForm.tsx

@@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input";
 import { DateInput } from "@/components/ui/date-input";
 import { Label } from "@/components/ui/label";
 import { Textarea } from "@/components/ui/textarea";
+import { Checkbox } from "@/components/ui/checkbox";
 import {
   Dialog,
   DialogContent,
@@ -25,6 +26,8 @@ interface ReminderData {
   description: string | null;
   dueDate: Date | null;
   dueMileage: number | null;
+  notifyInApp?: boolean;
+  notifyEmail?: boolean;
 }
 
 interface ReminderFormProps {
@@ -44,6 +47,8 @@ export function ReminderForm({ vehicleId, open, onOpenChange, reminder }: Remind
   const [description, setDescription] = useState("");
   const [dueDate, setDueDate] = useState("");
   const [dueMileage, setDueMileage] = useState("");
+  const [notifyInApp, setNotifyInApp] = useState(true);
+  const [notifyEmail, setNotifyEmail] = useState(false);
 
   const isEdit = !!reminder;
 
@@ -53,11 +58,15 @@ export function ReminderForm({ vehicleId, open, onOpenChange, reminder }: Remind
       setDescription(reminder.description || "");
       setDueDate(reminder.dueDate ? new Date(reminder.dueDate).toISOString().split("T")[0] : "");
       setDueMileage(reminder.dueMileage ? String(reminder.dueMileage) : "");
+      setNotifyInApp(reminder.notifyInApp ?? true);
+      setNotifyEmail(reminder.notifyEmail ?? false);
     } else if (open) {
       setTitle("");
       setDescription("");
       setDueDate("");
       setDueMileage("");
+      setNotifyInApp(true);
+      setNotifyEmail(false);
     }
   }, [open, reminder]);
 
@@ -71,6 +80,8 @@ export function ReminderForm({ vehicleId, open, onOpenChange, reminder }: Remind
       description: description || undefined,
       dueDate: dueDate || undefined,
       dueMileage: dueMileage ? Number(dueMileage) : undefined,
+      notifyInApp,
+      notifyEmail,
     };
 
     const result = isEdit
@@ -139,6 +150,26 @@ export function ReminderForm({ vehicleId, open, onOpenChange, reminder }: Remind
             </div>
           </div>
 
+          <div className="space-y-2">
+            <Label>{t("notifyLabel")}</Label>
+            <div className="flex gap-6">
+              <label className="flex items-center gap-2 text-sm font-normal">
+                <Checkbox
+                  checked={notifyInApp}
+                  onCheckedChange={(v) => setNotifyInApp(v === true)}
+                />
+                {t("notifyInApp")}
+              </label>
+              <label className="flex items-center gap-2 text-sm font-normal">
+                <Checkbox
+                  checked={notifyEmail}
+                  onCheckedChange={(v) => setNotifyEmail(v === true)}
+                />
+                {t("notifyEmail")}
+              </label>
+            </div>
+          </div>
+
           <div className="flex justify-end gap-3 pt-2">
             <Button
               type="button"

+ 105 - 17
src/features/vehicles/Components/RemindersPageClient.tsx

@@ -11,6 +11,8 @@ import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
 import { Textarea } from "@/components/ui/textarea";
+import { Checkbox } from "@/components/ui/checkbox";
+import { CustomerCombobox } from "@/features/quotes/Components/CustomerCombobox";
 import { Calendar } from "@/components/ui/calendar";
 import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
 import {
@@ -64,7 +66,10 @@ interface Reminder {
   dueDate: Date | null;
   dueMileage: number | null;
   isCompleted: boolean;
+  notifyInApp: boolean;
+  notifyEmail: boolean;
   createdAt: Date;
+  customer: { id: string; name: string } | null;
   vehicle: {
     id: string;
     make: string;
@@ -72,7 +77,7 @@ interface Reminder {
     year: number;
     licensePlate: string | null;
     mileage: number;
-  };
+  } | null;
 }
 
 interface VehicleOption {
@@ -82,6 +87,7 @@ interface VehicleOption {
   year: number;
   licensePlate: string | null;
   customerName: string | null;
+  customerId: string | null;
 }
 
 interface RemindersPageClientProps {
@@ -123,6 +129,10 @@ export function RemindersPageClient({ reminders, vehicles, unitSystem }: Reminde
   const [formDescription, setFormDescription] = useState("");
   const [formDueDate, setFormDueDate] = useState<Date | undefined>();
   const [formDueMileage, setFormDueMileage] = useState("");
+  const [formNotifyInApp, setFormNotifyInApp] = useState(true);
+  const [formNotifyEmail, setFormNotifyEmail] = useState(false);
+  const [formCustomerId, setFormCustomerId] = useState("");
+  const [formCustomer, setFormCustomer] = useState<{ id: string; name: string; company: string | null } | null>(null);
   const [formLoading, setFormLoading] = useState(false);
   const [vehicleOpen, setVehicleOpen] = useState(false);
   const [calendarOpen, setCalendarOpen] = useState(false);
@@ -157,30 +167,40 @@ export function RemindersPageClient({ reminders, vehicles, unitSystem }: Reminde
     setFormDescription("");
     setFormDueDate(undefined);
     setFormDueMileage("");
+    setFormNotifyInApp(true);
+    setFormNotifyEmail(false);
+    setFormCustomerId("");
+    setFormCustomer(null);
     setShowForm(true);
   };
 
   const openEditForm = (r: Reminder) => {
     setEditingReminder(r);
-    setFormVehicleId(r.vehicle.id);
+    setFormVehicleId(r.vehicle?.id ?? "");
     setFormTitle(r.title);
     setFormDescription(r.description || "");
     setFormDueDate(r.dueDate ? new Date(r.dueDate) : undefined);
     setFormDueMileage(r.dueMileage ? String(r.dueMileage) : "");
+    setFormNotifyInApp(r.notifyInApp ?? true);
+    setFormNotifyEmail(r.notifyEmail ?? false);
+    setFormCustomerId(r.customer?.id ?? "");
+    setFormCustomer(r.customer ? { ...r.customer, company: null } : null);
     setShowForm(true);
   };
 
   const handleFormSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
-    if (!formVehicleId) return;
     setFormLoading(true);
 
     const payload = {
-      vehicleId: formVehicleId,
+      vehicleId: formVehicleId || null,
+      customerId: formCustomerId || null,
       title: formTitle,
       description: formDescription || undefined,
       dueDate: formDueDate ? formDueDate.toISOString().split("T")[0] : undefined,
       dueMileage: formDueMileage ? Number(formDueMileage) : undefined,
+      notifyInApp: formNotifyInApp,
+      notifyEmail: formNotifyEmail,
     };
 
     const result = editingReminder
@@ -287,15 +307,28 @@ export function RemindersPageClient({ reminders, vehicles, unitSystem }: Reminde
                           {tv("dueSoon")}
                         </Badge>
                       )}
-                      <span className="text-xs text-muted-foreground">
-                        <span
-                          className="hover:underline cursor-pointer"
-                          onClick={() => router.push(`/vehicles/${r.vehicle.id}?tab=reminders`)}
-                        >
-                          {r.vehicle.year} {r.vehicle.make} {r.vehicle.model}
-                          {r.vehicle.licensePlate && ` · ${r.vehicle.licensePlate}`}
+                      {r.vehicle ? (
+                        <span className="text-xs text-muted-foreground">
+                          <span
+                            className="hover:underline cursor-pointer"
+                            onClick={() => router.push(`/vehicles/${r.vehicle?.id}?tab=reminders`)}
+                          >
+                            {r.vehicle.year} {r.vehicle.make} {r.vehicle.model}
+                            {r.vehicle.licensePlate && ` · ${r.vehicle.licensePlate}`}
+                          </span>
                         </span>
-                      </span>
+                      ) : r.customer ? (
+                        <span className="text-xs text-muted-foreground">
+                          <span
+                            className="hover:underline cursor-pointer"
+                            onClick={() => router.push(`/customers/${r.customer?.id}`)}
+                          >
+                            {r.customer.name}
+                          </span>
+                        </span>
+                      ) : (
+                        <span className="text-xs text-muted-foreground">{t("workshopReminder")}</span>
+                      )}
                     </div>
                     <div className="shrink-0 flex items-center gap-2 text-xs text-muted-foreground">
                       {r.dueDate && (
@@ -340,9 +373,23 @@ export function RemindersPageClient({ reminders, vehicles, unitSystem }: Reminde
             <DialogTitle>{editingReminder ? tv("editTitle") : tv("addTitle")}</DialogTitle>
           </DialogHeader>
           <form onSubmit={handleFormSubmit} className="space-y-4">
-            {/* Vehicle selector */}
+            {/* Target: workshop (nothing), customer, or vehicle */}
             <div className="space-y-2">
-              <Label>{t("vehicle")}</Label>
+              <Label>{t("relatesTo")}</Label>
+              <CustomerCombobox
+                value={formCustomerId}
+                initialCustomer={formCustomer}
+                placeholder={t("selectCustomer")}
+                noneLabel={t("noneOption")}
+                onChange={(id) => {
+                  setFormCustomerId(id);
+                  // A vehicle belonging to another customer no longer fits
+                  const current = vehicles.find((v) => v.id === formVehicleId);
+                  if (id && current && current.customerId !== id) {
+                    setFormVehicleId("");
+                  }
+                }}
+              />
               <Popover open={vehicleOpen} onOpenChange={setVehicleOpen}>
                 <PopoverTrigger asChild>
                   <Button
@@ -350,7 +397,6 @@ export function RemindersPageClient({ reminders, vehicles, unitSystem }: Reminde
                     role="combobox"
                     aria-expanded={vehicleOpen}
                     className="w-full justify-between font-normal"
-                    disabled={!!editingReminder}
                   >
                     {selectedVehicle
                       ? `${selectedVehicle.year} ${selectedVehicle.make} ${selectedVehicle.model}${selectedVehicle.licensePlate ? ` · ${selectedVehicle.licensePlate}` : ""}`
@@ -364,12 +410,31 @@ export function RemindersPageClient({ reminders, vehicles, unitSystem }: Reminde
                     <CommandList>
                       <CommandEmpty>{t("noVehicle")}</CommandEmpty>
                       <CommandGroup>
-                        {vehicles.map((v) => (
+                        <CommandItem
+                          value="__none__"
+                          onSelect={() => {
+                            setFormVehicleId("");
+                            setVehicleOpen(false);
+                          }}
+                        >
+                          <Check
+                            className={cn("mr-2 h-4 w-4", !formVehicleId ? "opacity-100" : "opacity-0")}
+                          />
+                          {t("noneOption")}
+                        </CommandItem>
+                        {vehicles
+                          .filter((v) => !formCustomerId || v.customerId === formCustomerId)
+                          .map((v) => (
                           <CommandItem
                             key={v.id}
                             value={`${v.year} ${v.make} ${v.model} ${v.licensePlate || ""} ${v.customerName || ""}`}
                             onSelect={() => {
                               setFormVehicleId(v.id);
+                              // The vehicle's customer follows automatically
+                              if (v.customerId) {
+                                setFormCustomerId(v.customerId);
+                                setFormCustomer({ id: v.customerId, name: v.customerName || "", company: null });
+                              }
                               setVehicleOpen(false);
                             }}
                           >
@@ -395,6 +460,9 @@ export function RemindersPageClient({ reminders, vehicles, unitSystem }: Reminde
                   </Command>
                 </PopoverContent>
               </Popover>
+              {!formVehicleId && !formCustomerId && (
+                <p className="text-xs text-muted-foreground">{t("workshopHint")}</p>
+              )}
             </div>
 
             {/* Title */}
@@ -463,11 +531,31 @@ export function RemindersPageClient({ reminders, vehicles, unitSystem }: Reminde
               </div>
             </div>
 
+            <div className="space-y-2">
+              <Label>{tv("notifyLabel")}</Label>
+              <div className="flex gap-6">
+                <label className="flex items-center gap-2 text-sm font-normal">
+                  <Checkbox
+                    checked={formNotifyInApp}
+                    onCheckedChange={(v) => setFormNotifyInApp(v === true)}
+                  />
+                  {tv("notifyInApp")}
+                </label>
+                <label className="flex items-center gap-2 text-sm font-normal">
+                  <Checkbox
+                    checked={formNotifyEmail}
+                    onCheckedChange={(v) => setFormNotifyEmail(v === true)}
+                  />
+                  {tv("notifyEmail")}
+                </label>
+              </div>
+            </div>
+
             <div className="flex justify-end gap-3 pt-2">
               <Button type="button" variant="outline" onClick={() => setShowForm(false)}>
                 {tc("cancel")}
               </Button>
-              <Button type="submit" disabled={formLoading || !formVehicleId}>
+              <Button type="submit" disabled={formLoading}>
                 {formLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
                 {editingReminder ? tc("saveChanges") : tv("addTitle")}
               </Button>

+ 6 - 1
src/features/vehicles/Schema/reminderSchema.ts

@@ -1,11 +1,16 @@
 import { z } from "zod";
 
 export const createReminderSchema = z.object({
-  vehicleId: z.string(),
+  // A reminder relates to a vehicle, a customer, or just the workshop (both null)
+  vehicleId: z.string().nullable().optional(),
+  customerId: z.string().nullable().optional(),
   title: z.string().min(1, "Title is required"),
   description: z.string().optional(),
   dueDate: z.string().optional(),
   dueMileage: z.coerce.number().optional(),
+  // How the workshop wants to be notified when the reminder comes due
+  notifyInApp: z.boolean().optional(),
+  notifyEmail: z.boolean().optional(),
 });
 
 export type CreateReminderInput = z.infer<typeof createReminderSchema>;

+ 2 - 0
src/instrumentation.ts

@@ -10,6 +10,7 @@ export async function register() {
       processWebhookDeliveries,
       cleanupWebhookDeliveries,
       checkLowStock,
+      checkDueReminders,
     } = await import('./cronTasks')
     checkLicenses()
     checkSubscriptions()
@@ -20,5 +21,6 @@ export async function register() {
     processWebhookDeliveries()
     cleanupWebhookDeliveries()
     checkLowStock()
+    checkDueReminders()
   }
 }

+ 202 - 0
src/lib/cron/reminder-alerts.ts

@@ -0,0 +1,202 @@
+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]";
+
+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;
+  vehicle: {
+    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;
+  }
+  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";
+}
+
+/** Owners and admins receive the email. */
+async function getRecipients(organizationId: string) {
+  const members = await db.organizationMember.findMany({
+    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);
+}
+
+async function sendReminderEmail(organizationId: string, reminders: DueReminder[]) {
+  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;
+  try {
+    fromAddress = await getOrgFromAddress(organizationId);
+  } catch (error) {
+    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>`,
+    )
+    .join("");
+
+  const subject =
+    reminders.length === 1
+      ? `Reminder due: ${reminders[0].title}`
+      : `${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>`;
+
+  for (const recipient of recipients) {
+    await sendOrgMail(organizationId, {
+      from: fromAddress,
+      to: recipient.email,
+      subject,
+      html,
+    });
+  }
+}
+
+/**
+ * Notifies about reminders whose due date has arrived, on the channels chosen
+ * when the reminder was created (bell and/or email). Each reminder notifies
+ * exactly once; rescheduling (changing dueDate) re-arms it.
+ */
+export async function processDueReminders(now = new Date()) {
+  const due = (await db.reminder.findMany({
+    where: {
+      isCompleted: false,
+      notifiedAt: null,
+      dueDate: { not: null, lte: now },
+      OR: [{ notifyInApp: true }, { notifyEmail: true }],
+    },
+    select: {
+      id: true,
+      title: true,
+      description: true,
+      dueDate: true,
+      notifyInApp: true,
+      notifyEmail: true,
+      organizationId: true,
+      customer: { select: { id: true, name: true } },
+      vehicle: {
+        select: {
+          id: true,
+          make: true,
+          model: true,
+          year: true,
+          licensePlate: true,
+          customer: { select: { name: true } },
+        },
+      },
+    },
+    take: 500,
+  })) as DueReminder[];
+
+  if (due.length === 0) return 0;
+
+  // Group by organization so email becomes one digest per org per run
+  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);
+  }
+
+  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;
+      await notify({
+        organizationId,
+        type: "reminder.due",
+        title: "Reminder Due",
+        message: targetLabel(r) ? `${r.title} — ${targetLabel(r)}` : r.title,
+        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);
+    if (emailReminders.length > 0) {
+      try {
+        await sendReminderEmail(organizationId, emailReminders);
+      } catch (error) {
+        console.error(`${LOG_PREFIX} email failed for org ${organizationId}:`, error);
+      }
+    }
+
+    notified += reminders.length;
+  }
+
+  await db.reminder.updateMany({
+    where: { id: { in: due.map((r) => r.id) } },
+    data: { notifiedAt: now },
+  });
+
+  return notified;
+}
+
+/** Hourly scan for reminders that have come due. */
+export function checkDueReminders() {
+  const job = new CronJob("25 * * * *", async () => {
+    try {
+      const notified = await processDueReminders();
+      if (notified > 0) {
+        console.warn(`${LOG_PREFIX} notified on ${notified} due reminder(s)`);
+      }
+    } catch (error) {
+      console.error(`${LOG_PREFIX} scan failed:`, error);
+    }
+  });
+  job.start();
+  console.warn(`${LOG_PREFIX} Due-reminder processor started (hourly)`);
+}