4
0
Эх сурвалжийг харах

Stop a restore deleting what the backup does not contain (#264)

* Describe a backup in one place, and fail the build when it falls behind

The export route and the import route each carried their own idea of what a
backup contains, and they drifted. Ten organisation-scoped models shipped
without ever reaching a backup, WhatsApp and Telegram among them while SMS was
carried; the import accepted an upload folder the export never wrote; and
several tables were restored without being cleared, so a second restore would
collide with the rows already there.

The manifest names every organisation-scoped model, the key it takes in the
file, the option that includes it, where it nests, and how a restore treats
it. Roles merge rather than replace, because clearing them would strip every
member of the role they hold and members are not in a backup.

A test compares the manifest against the schema and against the folders the
app actually writes uploads to, so a new model or a new attachment folder
fails the build until someone classifies it. Both were checked by adding a
model and removing a folder, not only by watching them pass.

* Carry every feature into a backup, and clear only what a backup can put back

Import wiped the organisation unconditionally while export had been selective
for some time, so restoring a customers-only file deleted every vehicle,
quote and inspection in the workshop. A restore now clears exactly the tables
the file carries, worked out from the manifest, and leaves the rest alone.

The wipe also cascaded into tables no backup contained. Status reports went
with their service record, stock movements with their part, portal service
requests with their customer, and vehicle findings, recurring invoices and
quote attachments with their vehicle or quote. All of them are exported now,
along with WhatsApp and Telegram messages, labour presets, webhooks, report
schedules, roles and inventory images.

Counter sales, tire sets and scheduled messages were restored but never
cleared, so a second restore collided with the rows already there. They have
a clearing order now.

Stock movements and findings are written once every service record exists,
since both point at one, and a movement takes the importing user rather than
whoever moved the stock on another instance. Roles fill gaps instead of
replacing, so members keep the role they hold, matching on name as well as id
because an organisation already has its defaults.

Files follow the same rule: a restore clears the folders the archive carries
rather than everything, which used to take the portal background and the tire
photos with it. Quote, tire hotel and portal uploads are carried now.

The older GET export was a second implementation that had fallen behind and
handed out an incomplete backup; it runs the same export as everything else.
A failed restore names the part of the file it could not read back.

* Check the routes against the manifest, not only the schema

The manifest could promise a key that neither route touched, which is the
same gap in a new place. Three checks read the two routes: every top-level key
is written on the way out, read on the way back in, and clearable before it is
written back. Verified by adding an entity to the manifest alone and watching
two of them fail.
Bernt Christian Egeland 2 сар өмнө
parent
commit
9695b1d6e9

+ 5 - 3
messages/de/settings.json

@@ -921,8 +921,8 @@
     "optInspectionsDesc": "Inspektionsvorlagen, Inspektionen und Ergebnisse",
     "optAuditLogs": "Prüfprotokolle",
     "optAuditLogsDesc": "Aktivitäts- und Änderungsverlauf",
-    "optSmsMessages": "SMS-Nachrichten",
-    "optSmsMessagesDesc": "Gesendete und empfangene SMS-Nachrichten",
+    "optSmsMessages": "Nachrichten",
+    "optSmsMessagesDesc": "Gesendete und empfangene SMS-, WhatsApp- und Telegram-Nachrichten",
     "optScheduledMessages": "Geplante Nachrichten",
     "optScheduledMessagesDesc": "Wartende und wiederkehrende Nachrichten, die noch gesendet werden",
     "optNotifications": "Benachrichtigungen",
@@ -984,7 +984,9 @@
       "supportAction": "kontaktieren Sie unser Support-Team."
     },
     "optTireHotel": "Reifenhotel",
-    "optTireHotelDesc": "Eingelagerte Reifensätze, Regale, Vorbereitung und Lagervereinbarungen"
+    "optTireHotelDesc": "Eingelagerte Reifensätze, Regale, Vorbereitung und Lagervereinbarungen",
+    "optWorkshopConfig": "Werkstatt-Einrichtung",
+    "optWorkshopConfigDesc": "Arbeitsvorlagen, Rollen, Webhooks und geplante Berichte"
   },
   "license": {
     "title": "White-Label-Lizenz",

+ 5 - 3
messages/en/settings.json

@@ -921,8 +921,8 @@
     "optInspectionsDesc": "Inspection templates, inspections, and results",
     "optAuditLogs": "Audit Logs",
     "optAuditLogsDesc": "Activity and change history",
-    "optSmsMessages": "SMS Messages",
-    "optSmsMessagesDesc": "Sent and received SMS messages",
+    "optSmsMessages": "Messages",
+    "optSmsMessagesDesc": "Sent and received SMS, WhatsApp and Telegram messages",
     "optScheduledMessages": "Scheduled Messages",
     "optScheduledMessagesDesc": "Queued and repeating messages waiting to go out",
     "optNotifications": "Notifications",
@@ -984,7 +984,9 @@
       "supportAction": "contact our support team."
     },
     "optTireHotel": "Tire hotel",
-    "optTireHotelDesc": "Stored tire sets, shelves, prep work and storage agreements"
+    "optTireHotelDesc": "Stored tire sets, shelves, prep work and storage agreements",
+    "optWorkshopConfig": "Workshop setup",
+    "optWorkshopConfigDesc": "Labour presets, roles, webhooks and scheduled reports"
   },
   "license": {
     "title": "White-Label License",

+ 5 - 3
messages/es/settings.json

@@ -921,8 +921,8 @@
     "optInspectionsDesc": "Plantillas de inspección, inspecciones y resultados",
     "optAuditLogs": "Registros de auditoría",
     "optAuditLogsDesc": "Historial de actividad y cambios",
-    "optSmsMessages": "Mensajes SMS",
-    "optSmsMessagesDesc": "Mensajes SMS enviados y recibidos",
+    "optSmsMessages": "Mensajes",
+    "optSmsMessagesDesc": "Mensajes SMS, WhatsApp y Telegram enviados y recibidos",
     "optScheduledMessages": "Mensajes programados",
     "optScheduledMessagesDesc": "Mensajes en cola y recurrentes pendientes de envío",
     "optNotifications": "Notificaciones",
@@ -984,7 +984,9 @@
       "supportAction": "contacte con nuestro equipo de soporte."
     },
     "optTireHotel": "Hotel de neumáticos",
-    "optTireHotelDesc": "Juegos guardados, estantes, preparación y contratos de almacenaje"
+    "optTireHotelDesc": "Juegos guardados, estantes, preparación y contratos de almacenaje",
+    "optWorkshopConfig": "Configuración del taller",
+    "optWorkshopConfigDesc": "Plantillas de mano de obra, roles, webhooks e informes programados"
   },
   "license": {
     "title": "Licencia marca blanca",

+ 5 - 3
messages/fr/settings.json

@@ -921,8 +921,8 @@
     "optInspectionsDesc": "Modèles d'inspection, inspections et résultats",
     "optAuditLogs": "Journaux d'audit",
     "optAuditLogsDesc": "Historique des activités et des modifications",
-    "optSmsMessages": "Messages SMS",
-    "optSmsMessagesDesc": "Messages SMS envoyés et reçus",
+    "optSmsMessages": "Messages",
+    "optSmsMessagesDesc": "Messages SMS, WhatsApp et Telegram envoyés et reçus",
     "optScheduledMessages": "Messages programmés",
     "optScheduledMessagesDesc": "Messages en attente et récurrents qui doivent encore partir",
     "optNotifications": "Notifications",
@@ -984,7 +984,9 @@
       "supportAction": "contactez notre équipe d'assistance."
     },
     "optTireHotel": "Hôtel à pneus",
-    "optTireHotelDesc": "Jeux stockés, étagères, préparation et contrats de stockage"
+    "optTireHotelDesc": "Jeux stockés, étagères, préparation et contrats de stockage",
+    "optWorkshopConfig": "Configuration de l'atelier",
+    "optWorkshopConfigDesc": "Modèles de main-d'œuvre, rôles, webhooks et rapports programmés"
   },
   "license": {
     "title": "Licence marque blanche",

+ 5 - 3
messages/it/settings.json

@@ -921,8 +921,8 @@
     "optInspectionsDesc": "Modelli di ispezione, ispezioni e risultati",
     "optAuditLogs": "Registri di audit",
     "optAuditLogsDesc": "Cronologia delle attività e delle modifiche",
-    "optSmsMessages": "Messaggi SMS",
-    "optSmsMessagesDesc": "Messaggi SMS inviati e ricevuti",
+    "optSmsMessages": "Messaggi",
+    "optSmsMessagesDesc": "Messaggi SMS, WhatsApp e Telegram inviati e ricevuti",
     "optScheduledMessages": "Messaggi pianificati",
     "optScheduledMessagesDesc": "Messaggi in coda e ricorrenti in attesa di essere inviati",
     "optNotifications": "Notifiche",
@@ -984,7 +984,9 @@
       "supportAction": "contatta il nostro team di supporto."
     },
     "optTireHotel": "Hotel pneumatici",
-    "optTireHotelDesc": "Treni in deposito, scaffali, preparazione e contratti di deposito"
+    "optTireHotelDesc": "Treni in deposito, scaffali, preparazione e contratti di deposito",
+    "optWorkshopConfig": "Configurazione officina",
+    "optWorkshopConfigDesc": "Preset di manodopera, ruoli, webhook e report programmati"
   },
   "license": {
     "title": "Licenza White-Label",

+ 5 - 3
messages/lt/settings.json

@@ -921,8 +921,8 @@
     "optInspectionsDesc": "Apžiūrų šablonai, apžiūros ir rezultatai",
     "optAuditLogs": "Audito žurnalai",
     "optAuditLogsDesc": "Veiklos ir pakeitimų istorija",
-    "optSmsMessages": "SMS žinutės",
-    "optSmsMessagesDesc": "Išsiųstos ir gautos SMS žinutės",
+    "optSmsMessages": "Žinutės",
+    "optSmsMessagesDesc": "Išsiųstos ir gautos SMS, WhatsApp ir Telegram žinutės",
     "optScheduledMessages": "Suplanuotos žinutės",
     "optScheduledMessagesDesc": "Eilėje esančios ir pasikartojančios žinutės, laukiančios išsiuntimo",
     "optNotifications": "Pranešimai",
@@ -984,7 +984,9 @@
       "supportAction": "susisiekite su mūsų pagalbos komanda."
     },
     "optTireHotel": "Padangų viešbutis",
-    "optTireHotelDesc": "Saugomi komplektai, lentynos, paruošimas ir saugojimo sutartys"
+    "optTireHotelDesc": "Saugomi komplektai, lentynos, paruošimas ir saugojimo sutartys",
+    "optWorkshopConfig": "Dirbtuvių nustatymai",
+    "optWorkshopConfigDesc": "Darbo šablonai, rolės, webhook'ai ir suplanuotos ataskaitos"
   },
   "license": {
     "title": "\"White-Label\" licencija",

+ 5 - 3
messages/nb/settings.json

@@ -921,8 +921,8 @@
     "optInspectionsDesc": "Inspeksjonsmaler, inspeksjoner og resultater",
     "optAuditLogs": "Aktivitetslogg",
     "optAuditLogsDesc": "Aktivitets- og endringshistorikk",
-    "optSmsMessages": "SMS-meldinger",
-    "optSmsMessagesDesc": "Sendte og mottatte SMS-meldinger",
+    "optSmsMessages": "Meldinger",
+    "optSmsMessagesDesc": "Sendte og mottatte SMS-, WhatsApp- og Telegram-meldinger",
     "optScheduledMessages": "Planlagte meldinger",
     "optScheduledMessagesDesc": "Meldinger i kø og gjentakende meldinger som venter på å bli sendt",
     "optNotifications": "Varsler",
@@ -984,7 +984,9 @@
       "supportAction": "kontakt supportteamet vårt."
     },
     "optTireHotel": "Dekkhotell",
-    "optTireHotelDesc": "Lagrede dekksett, hyller, klargjøring og lagringsavtaler"
+    "optTireHotelDesc": "Lagrede dekksett, hyller, klargjøring og lagringsavtaler",
+    "optWorkshopConfig": "Verkstedoppsett",
+    "optWorkshopConfigDesc": "Arbeidsmaler, roller, webhooks og planlagte rapporter"
   },
   "license": {
     "title": "White-label-lisens",

+ 5 - 3
messages/nl/settings.json

@@ -921,8 +921,8 @@
     "optInspectionsDesc": "Inspectiesjablonen, inspecties en resultaten",
     "optAuditLogs": "Auditlogboeken",
     "optAuditLogsDesc": "Activiteiten- en wijzigingsgeschiedenis",
-    "optSmsMessages": "SMS-berichten",
-    "optSmsMessagesDesc": "Verzonden en ontvangen SMS-berichten",
+    "optSmsMessages": "Berichten",
+    "optSmsMessagesDesc": "Verzonden en ontvangen sms-, WhatsApp- en Telegram-berichten",
     "optScheduledMessages": "Geplande berichten",
     "optScheduledMessagesDesc": "Berichten in de wachtrij en herhalende berichten die nog verstuurd worden",
     "optNotifications": "Meldingen",
@@ -984,7 +984,9 @@
       "supportAction": "neem contact op met ons supportteam."
     },
     "optTireHotel": "Bandenhotel",
-    "optTireHotelDesc": "Opgeslagen bandensets, stellingen, voorbereiding en opslagovereenkomsten"
+    "optTireHotelDesc": "Opgeslagen bandensets, stellingen, voorbereiding en opslagovereenkomsten",
+    "optWorkshopConfig": "Werkplaatsinrichting",
+    "optWorkshopConfigDesc": "Arbeidsjablonen, rollen, webhooks en geplande rapporten"
   },
   "license": {
     "title": "White-label licentie",

+ 5 - 3
messages/pl/settings.json

@@ -921,8 +921,8 @@
     "optInspectionsDesc": "Szablony inspekcji, inspekcje i wyniki",
     "optAuditLogs": "Dzienniki audytu",
     "optAuditLogsDesc": "Historia aktywności i zmian",
-    "optSmsMessages": "Wiadomości SMS",
-    "optSmsMessagesDesc": "Wysłane i odebrane wiadomości SMS",
+    "optSmsMessages": "Wiadomości",
+    "optSmsMessagesDesc": "Wysłane i odebrane wiadomości SMS, WhatsApp i Telegram",
     "optScheduledMessages": "Zaplanowane wiadomości",
     "optScheduledMessagesDesc": "Wiadomości w kolejce i cykliczne, czekające na wysyłkę",
     "optNotifications": "Powiadomienia",
@@ -984,7 +984,9 @@
       "supportAction": "skontaktuj się z naszym zespołem wsparcia."
     },
     "optTireHotel": "Hotel opon",
-    "optTireHotelDesc": "Przechowywane komplety, półki, przygotowanie i umowy przechowywania"
+    "optTireHotelDesc": "Przechowywane komplety, półki, przygotowanie i umowy przechowywania",
+    "optWorkshopConfig": "Konfiguracja warsztatu",
+    "optWorkshopConfigDesc": "Szablony robocizny, role, webhooki i zaplanowane raporty"
   },
   "license": {
     "title": "Licencja white-label",

+ 5 - 3
messages/pt-BR/settings.json

@@ -921,8 +921,8 @@
     "optInspectionsDesc": "Modelos de inspeção, inspeções e resultados",
     "optAuditLogs": "Registros de auditoria",
     "optAuditLogsDesc": "Histórico de atividades e alterações",
-    "optSmsMessages": "Mensagens SMS",
-    "optSmsMessagesDesc": "Mensagens SMS enviadas e recebidas",
+    "optSmsMessages": "Mensagens",
+    "optSmsMessagesDesc": "Mensagens SMS, WhatsApp e Telegram enviadas e recebidas",
     "optScheduledMessages": "Mensagens agendadas",
     "optScheduledMessagesDesc": "Mensagens na fila e recorrentes aguardando envio",
     "optNotifications": "Notificações",
@@ -984,7 +984,9 @@
       "supportAction": "fale com nossa equipe de suporte."
     },
     "optTireHotel": "Hotel de pneus",
-    "optTireHotelDesc": "Jogos armazenados, prateleiras, preparação e contratos de armazenagem"
+    "optTireHotelDesc": "Jogos armazenados, prateleiras, preparação e contratos de armazenagem",
+    "optWorkshopConfig": "Configuração da oficina",
+    "optWorkshopConfigDesc": "Modelos de mão de obra, funções, webhooks e relatórios agendados"
   },
   "license": {
     "title": "Licença marca branca",

+ 5 - 3
messages/ru/settings.json

@@ -921,8 +921,8 @@
     "optInspectionsDesc": "Шаблоны осмотров, осмотры и результаты",
     "optAuditLogs": "Журналы аудита",
     "optAuditLogsDesc": "История активности и изменений",
-    "optSmsMessages": "SMS-сообщения",
-    "optSmsMessagesDesc": "Отправленные и полученные SMS-сообщения",
+    "optSmsMessages": "Сообщения",
+    "optSmsMessagesDesc": "Отправленные и полученные SMS, WhatsApp и Telegram",
     "optScheduledMessages": "Запланированные сообщения",
     "optScheduledMessagesDesc": "Сообщения в очереди и повторяющиеся, ожидающие отправки",
     "optNotifications": "Уведомления",
@@ -984,7 +984,9 @@
       "supportAction": "свяжитесь с нашей службой поддержки."
     },
     "optTireHotel": "Шинный отель",
-    "optTireHotelDesc": "Комплекты на хранении, полки, подготовка и договоры хранения"
+    "optTireHotelDesc": "Комплекты на хранении, полки, подготовка и договоры хранения",
+    "optWorkshopConfig": "Настройки мастерской",
+    "optWorkshopConfigDesc": "Шаблоны работ, роли, вебхуки и запланированные отчёты"
   },
   "license": {
     "title": "White-Label лицензия",

+ 5 - 3
messages/tr/settings.json

@@ -921,8 +921,8 @@
     "optInspectionsDesc": "Denetim şablonları, denetimler ve sonuçlar",
     "optAuditLogs": "Denetim Günlükleri",
     "optAuditLogsDesc": "Etkinlik ve değişiklik geçmişi",
-    "optSmsMessages": "SMS Mesajları",
-    "optSmsMessagesDesc": "Gönderilen ve alınan SMS mesajları",
+    "optSmsMessages": "Mesajlar",
+    "optSmsMessagesDesc": "Gönderilen ve alınan SMS, WhatsApp ve Telegram mesajları",
     "optScheduledMessages": "Planlanmış mesajlar",
     "optScheduledMessagesDesc": "Sırada bekleyen ve tekrar eden, henüz gönderilmemiş mesajlar",
     "optNotifications": "Bildirimler",
@@ -984,7 +984,9 @@
       "supportAction": "destek ekibimizle iletişime geçin."
     },
     "optTireHotel": "Lastik oteli",
-    "optTireHotelDesc": "Depodaki takımlar, raflar, hazırlık ve depolama sözleşmeleri"
+    "optTireHotelDesc": "Depodaki takımlar, raflar, hazırlık ve depolama sözleşmeleri",
+    "optWorkshopConfig": "Atölye kurulumu",
+    "optWorkshopConfigDesc": "İşçilik şablonları, roller, webhook'lar ve zamanlanmış raporlar"
   },
   "license": {
     "title": "Beyaz Etiket Lisansı",

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

@@ -0,0 +1,223 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import {
+  BACKUP_ENTITIES,
+  clearPlanFor,
+  EXCLUDED_MODELS,
+  topLevelEntities,
+  UPLOAD_CATEGORIES,
+} from '@/lib/backup/manifest'
+
+/**
+ * Ten features shipped before anyone noticed they were missing from backups,
+ * because nothing compared the schema against what a backup carries. This
+ * does, without needing a database.
+ */
+
+const SCHEMA = fs.readFileSync(path.join('prisma', 'schema.prisma'), 'utf-8')
+
+/**
+ * model -> the models it is cascade-deleted by.
+ *
+ * Rows scoped through a parent rather than by organizationId are invisible to
+ * the organizationId check above, yet a restore deletes them all the same:
+ * vehicle findings and recurring invoices were both lost that way.
+ */
+function cascadeParents(): Map<string, Set<string>> {
+  const edges = new Map<string, Set<string>>()
+  const pattern = /^model (\w+) \{([\s\S]*?)^\}/gm
+  let match = pattern.exec(SCHEMA)
+  while (match) {
+    for (const line of match[2].split('\n')) {
+      const relation = /^\s*\w+\s+(\w+)\??\s+@relation\(.*onDelete:\s*Cascade/.exec(line)
+      if (relation) {
+        const parents = edges.get(match[1]) ?? new Set<string>()
+        parents.add(relation[1])
+        edges.set(match[1], parents)
+      }
+    }
+    match = pattern.exec(SCHEMA)
+  }
+  return edges
+}
+
+/** Models a restore would delete, by following cascades out from what it clears. */
+function deletedByRestore(): string[] {
+  const edges = cascadeParents()
+  const reached = new Set<string>()
+  const frontier = BACKUP_ENTITIES.filter((e) => e.restore === 'replace').map((e) => e.model)
+
+  while (frontier.length) {
+    const parent = frontier.pop() as string
+    for (const [child, parents] of edges) {
+      if (parents.has(parent) && !reached.has(child)) {
+        reached.add(child)
+        frontier.push(child)
+      }
+    }
+  }
+  return [...reached]
+}
+
+/** Every model in the schema. */
+function allModels(): string[] {
+  return [...SCHEMA.matchAll(/^model (\w+) \{/gm)].map((m) => m[1])
+}
+
+/** Models with an organizationId, which is what makes a row a workshop's own. */
+function tenantScopedModels(): string[] {
+  const models: string[] = []
+  const pattern = /^model (\w+) \{([\s\S]*?)^\}/gm
+  let match = pattern.exec(SCHEMA)
+  while (match) {
+    if (/^\s*organizationId\s/m.test(match[2])) models.push(match[1])
+    match = pattern.exec(SCHEMA)
+  }
+  return models
+}
+
+describe('backup manifest', () => {
+  it('accounts for every organisation-scoped model', () => {
+    const known = new Set([...BACKUP_ENTITIES.map((e) => e.model), ...Object.keys(EXCLUDED_MODELS)])
+    const unclassified = tenantScopedModels().filter((model) => !known.has(model))
+
+    expect(
+      unclassified,
+      `Add these to BACKUP_ENTITIES so they are backed up, or to EXCLUDED_MODELS with a reason: ${unclassified.join(', ')}`
+    ).toEqual([])
+  })
+
+  it('carries everything a restore deletes', () => {
+    // Clearing a table takes its children with it. Anything reachable that way
+    // has to be in the backup, or a restore is a one-way loss.
+    const known = new Set([...BACKUP_ENTITIES.map((e) => e.model), ...Object.keys(EXCLUDED_MODELS)])
+    const lost = deletedByRestore().filter((model) => !known.has(model))
+
+    expect(
+      lost,
+      `A restore deletes these, and no backup puts them back: ${lost.join(', ')}`
+    ).toEqual([])
+  })
+
+  it('only excludes models that still exist', () => {
+    // Not only the organisation-scoped ones: an exclusion may name a model
+    // reached through a cascade, such as a delivery log.
+    const inSchema = new Set(allModels())
+    const stale = Object.keys(EXCLUDED_MODELS).filter((model) => !inSchema.has(model))
+    expect(stale, `No longer in the schema: ${stale.join(', ')}`).toEqual([])
+  })
+
+  it('gives every exclusion a reason', () => {
+    const empty = Object.entries(EXCLUDED_MODELS)
+      .filter(([, reason]) => reason.trim().length < 10)
+      .map(([model]) => model)
+    expect(empty).toEqual([])
+  })
+
+  it('never reuses a backup key', () => {
+    const keys = topLevelEntities().map((e) => e.key)
+    expect(keys.length).toBe(new Set(keys).size)
+  })
+
+  it('nests a model under a parent that is itself in the backup', () => {
+    const models = new Set(BACKUP_ENTITIES.map((e) => e.model))
+    const orphans = BACKUP_ENTITIES.filter((e) => e.nestedUnder && !models.has(e.nestedUnder))
+    expect(orphans.map((e) => e.model)).toEqual([])
+  })
+
+  it('gives every replaceable top-level entity a clearing order', () => {
+    // A table restored but never cleared collides with the rows already there,
+    // which is a failed import on the second restore rather than a lost row.
+    const missing = topLevelEntities()
+      .filter((e) => e.restore === 'replace' && e.clearOrder === undefined)
+      .map((e) => e.model)
+    expect(missing).toEqual([])
+  })
+
+  it('carries every upload folder the app writes to', () => {
+    const written = new Set<string>()
+    const walk = (dir: string) => {
+      for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+        const full = path.join(dir, entry.name)
+        if (entry.isDirectory()) walk(full)
+        else if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) {
+          const source = fs.readFileSync(full, 'utf-8')
+          const pattern = /['"]uploads['"],\s*[^,)]+,\s*['"]([a-z-]+)['"]/g
+          let hit = pattern.exec(source)
+          while (hit) {
+            written.add(hit[1])
+            hit = pattern.exec(source)
+          }
+        }
+      }
+    }
+    walk('src')
+
+    const missing = [...written].filter((category) => !UPLOAD_CATEGORIES.includes(category))
+    expect(
+      missing,
+      `The app writes uploads to these folders, but no backup carries them: ${missing.join(', ')}`
+    ).toEqual([])
+  })
+})
+
+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('writes every top-level key on the way out', () => {
+    const missing = topLevelEntities()
+      .filter((entity) => !exportRoute.includes(`data.${entity.key} =`))
+      .map((entity) => entity.key)
+
+    expect(
+      missing,
+      `The manifest promises these keys, the export writes none: ${missing.join(', ')}`
+    ).toEqual([])
+  })
+
+  it('reads every top-level key on the way back in', () => {
+    const missing = topLevelEntities()
+      .filter((entity) => !importRoute.includes(`data.${entity.key}`))
+      .map((entity) => entity.key)
+
+    expect(
+      missing,
+      `Exported but never restored, so a restore would drop them: ${missing.join(', ')}`
+    ).toEqual([])
+  })
+
+  it('can clear every table it restores', () => {
+    // A table written back without being cleared collides with the rows
+    // already there, which is a failed import on the second restore.
+    const missing = topLevelEntities()
+      .filter((entity) => entity.restore === 'replace')
+      .filter((entity) => !importRoute.includes(`${entity.model}: () =>`))
+      .map((entity) => entity.model)
+
+    expect(missing, `Restored but never cleared: ${missing.join(', ')}`).toEqual([])
+  })
+})
+
+describe('clearPlanFor', () => {
+  it('clears nothing the backup does not carry', () => {
+    // Restoring a customers-only export used to delete every vehicle.
+    expect(clearPlanFor(['customers'])).toEqual(['Customer'])
+  })
+
+  it('clears children before the rows they point at', () => {
+    const plan = clearPlanFor(['customers', 'vehicles', 'quotes', 'notifications'])
+    expect(plan.indexOf('Notification')).toBeLessThan(plan.indexOf('Quote'))
+    expect(plan.indexOf('Quote')).toBeLessThan(plan.indexOf('Vehicle'))
+    expect(plan.indexOf('Vehicle')).toBeLessThan(plan.indexOf('Customer'))
+  })
+
+  it('never clears a merged table', () => {
+    expect(clearPlanFor(['roles'])).toEqual([])
+  })
+
+  it('ignores keys it does not know', () => {
+    expect(clearPlanFor(['somethingElse'])).toEqual([])
+  })
+})

+ 46 - 0
src/__tests__/lib/backup-rows.test.ts

@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+import { columnsOf } from '@/lib/backup/rows'
+
+describe('columnsOf', () => {
+  it('keeps the id, so everything pointing at the row still finds it', () => {
+    expect(columnsOf({ id: 'abc', name: 'Brake pad' })).toEqual({ id: 'abc', name: 'Brake pad' })
+  })
+
+  it('drops nested rows, which belong to their own table', () => {
+    const row = { id: 'q1', total: 10, partItems: [{ id: 'p1' }], attachments: [] }
+    expect(columnsOf(row)).toEqual({ id: 'q1', total: 10 })
+  })
+
+  it('turns timestamps back into dates', () => {
+    const columns = columnsOf({ createdAt: '2026-08-23T10:11:12.000Z' })
+    expect(columns.createdAt).toBeInstanceOf(Date)
+    expect((columns.createdAt as Date).toISOString()).toBe('2026-08-23T10:11:12.000Z')
+  })
+
+  it('leaves text that merely looks date-ish alone', () => {
+    const columns = columnsOf({ note: '2026-08-23', plate: 'AB-12-345' })
+    expect(columns.note).toBe('2026-08-23')
+    expect(columns.plate).toBe('AB-12-345')
+  })
+
+  it('keeps nulls, which are a value rather than a gap', () => {
+    expect(columnsOf({ customerId: null })).toEqual({ customerId: null })
+  })
+
+  it('takes the importing organisation over the one in the file', () => {
+    const columns = columnsOf(
+      { id: 'm1', organizationId: 'from-another-instance' },
+      {
+        organizationId: 'mine',
+      }
+    )
+    expect(columns.organizationId).toBe('mine')
+  })
+
+  it('replaces a user who has no account here', () => {
+    // Stock movements name whoever moved the stock, and that person may not
+    // exist on the instance the backup is restored to.
+    const columns = columnsOf({ userId: 'someone-else' }, { userId: 'me' })
+    expect(columns.userId).toBe('me')
+  })
+})

+ 3 - 0
src/app/(authenticated)/settings/data/data-settings.tsx

@@ -69,6 +69,7 @@ interface ExportOptions {
   scheduledMessages: boolean
   notifications: boolean
   tireHotel: boolean
+  workshopConfig: boolean
   files: boolean
 }
 
@@ -90,6 +91,7 @@ const OPTION_META: { key: keyof ExportOptions; labelKey: string; descKey: string
   { key: 'notifications', labelKey: 'optNotifications', descKey: 'optNotificationsDesc' },
   { key: 'customFields', labelKey: 'optCustomFields', descKey: 'optCustomFieldsDesc' },
   { key: 'tireHotel', labelKey: 'optTireHotel', descKey: 'optTireHotelDesc' },
+  { key: 'workshopConfig', labelKey: 'optWorkshopConfig', descKey: 'optWorkshopConfigDesc' },
   { key: 'files', labelKey: 'optFiles', descKey: 'optFilesDesc' },
 ]
 
@@ -107,6 +109,7 @@ const ALL_TRUE: ExportOptions = {
   scheduledMessages: true,
   notifications: true,
   tireHotel: true,
+  workshopConfig: true,
   files: true,
 }
 

+ 82 - 112
src/app/api/protected/backup/export/route.ts

@@ -3,6 +3,7 @@ import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
 import JSZip from 'jszip'
 import { isDemoMode } from '@/lib/demo'
+import { UPLOAD_CATEGORIES } from '@/lib/backup/manifest'
 import { readdir, readFile, stat } from 'fs/promises'
 import path from 'path'
 
@@ -23,6 +24,11 @@ interface ExportOptions {
   scheduledMessages: boolean
   notifications: boolean
   tireHotel: boolean
+  /**
+   * Labour presets, roles, webhooks, report schedules and dashboard layout:
+   * the settings a workshop builds up that are not key/value AppSettings.
+   */
+  workshopConfig: boolean
 }
 
 const DEFAULT_OPTIONS: ExportOptions = {
@@ -40,6 +46,7 @@ const DEFAULT_OPTIONS: ExportOptions = {
   scheduledMessages: true,
   notifications: true,
   tireHotel: true,
+  workshopConfig: true,
 }
 
 export async function POST(request: NextRequest) {
@@ -99,7 +106,10 @@ export async function POST(request: NextRequest) {
   if (options.inventory) {
     queries.push(
       db.inventoryPart
-        .findMany({ where: { organizationId: ctx.organizationId } })
+        .findMany({
+          where: { organizationId: ctx.organizationId },
+          include: { movements: true, gallery: true },
+        })
         .then((result) => {
           data.inventoryParts = result
         })
@@ -115,12 +125,14 @@ export async function POST(request: NextRequest) {
             notes: true,
             fuelLogs: true,
             reminders: true,
+            serviceRequests: true,
             serviceRecords: {
               include: {
                 partItems: true,
                 laborItems: true,
                 attachments: true,
                 payments: true,
+                statusReports: true,
               },
             },
           },
@@ -171,6 +183,7 @@ export async function POST(request: NextRequest) {
           include: {
             partItems: true,
             laborItems: true,
+            attachments: true,
           },
         })
         .then((result) => {
@@ -248,6 +261,43 @@ export async function POST(request: NextRequest) {
     )
   }
 
+  if (options.workshopConfig) {
+    // Everything a workshop configures that is not a key/value setting: none
+    // of it was in a backup before, so a restore rebuilt an empty shop.
+    queries.push(
+      db.laborPreset
+        .findMany({
+          where: { organizationId: ctx.organizationId },
+          include: { items: true, parts: true },
+        })
+        .then((result) => {
+          data.laborPresets = result
+        })
+    )
+    queries.push(
+      db.webhook.findMany({ where: { organizationId: ctx.organizationId } }).then((result) => {
+        data.webhooks = result
+      })
+    )
+    queries.push(
+      db.reportSchedule
+        .findMany({ where: { organizationId: ctx.organizationId } })
+        .then((result) => {
+          data.reportSchedules = result
+        })
+    )
+    queries.push(
+      db.role
+        .findMany({
+          where: { organizationId: ctx.organizationId },
+          include: { permissions: true },
+        })
+        .then((result) => {
+          data.roles = result
+        })
+    )
+  }
+
   if (options.auditLogs) {
     queries.push(
       db.auditLog.findMany({ where: { organizationId: ctx.organizationId } }).then((result) => {
@@ -262,6 +312,20 @@ export async function POST(request: NextRequest) {
         data.smsMessages = result
       })
     )
+    queries.push(
+      db.whatsappMessage
+        .findMany({ where: { organizationId: ctx.organizationId } })
+        .then((result) => {
+          data.whatsappMessages = result
+        })
+    )
+    queries.push(
+      db.telegramMessage
+        .findMany({ where: { organizationId: ctx.organizationId } })
+        .then((result) => {
+          data.telegramMessages = result
+        })
+    )
   }
 
   if (options.scheduledMessages) {
@@ -299,7 +363,7 @@ export async function POST(request: NextRequest) {
   if (options.files) {
     const uploadsDir = path.join(process.cwd(), 'data', 'uploads', ctx.organizationId)
 
-    const categories = ['logos', 'vehicles', 'inventory', 'services']
+    const categories = UPLOAD_CATEGORIES
 
     for (const category of categories) {
       const categoryDir = path.join(uploadsDir, category)
@@ -337,114 +401,20 @@ export async function POST(request: NextRequest) {
   })
 }
 
-// Keep GET for backward compatibility — exports all data as zip with defaults
-export async function GET() {
-  const ctx = await getAuthContext()
-
-  if (!ctx) {
-    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
-  }
-
-  const [
-    settings,
-    customers,
-    customFieldDefinitions,
-    inventoryParts,
-    vehicles,
-    orgReminders,
-    counterSales,
-    quotes,
-    technicians,
-    inspectionTemplates,
-    inspections,
-    auditLogs,
-    smsMessages,
-    scheduledMessages,
-    notifications,
-  ] = await Promise.all([
-    db.appSetting.findMany({ where: { organizationId: ctx.organizationId } }),
-    db.customer.findMany({ where: { organizationId: ctx.organizationId } }),
-    db.customFieldDefinition.findMany({
-      where: { organizationId: ctx.organizationId },
-      include: { values: true },
-    }),
-    db.inventoryPart.findMany({ where: { organizationId: ctx.organizationId } }),
-    db.vehicle.findMany({
-      where: { organizationId: ctx.organizationId },
-      include: {
-        notes: true,
-        fuelLogs: true,
-        reminders: true,
-        serviceRecords: {
-          include: {
-            partItems: true,
-            laborItems: true,
-            attachments: true,
-            payments: true,
-          },
-        },
-      },
-    }),
-    db.reminder.findMany({
-      where: { organizationId: ctx.organizationId, vehicleId: null },
-    }),
-    db.serviceRecord.findMany({
-      where: { organizationId: ctx.organizationId, vehicleId: null },
-      include: {
-        partItems: true,
-        laborItems: true,
-        attachments: true,
-        payments: true,
-      },
-    }),
-    db.quote.findMany({
-      where: { organizationId: ctx.organizationId },
-      include: {
-        partItems: true,
-        laborItems: true,
-      },
-    }),
-    db.technician.findMany({ where: { organizationId: ctx.organizationId } }),
-    db.inspectionTemplate.findMany({
-      where: { organizationId: ctx.organizationId },
-      include: { sections: { include: { items: true } } },
-    }),
-    db.inspection.findMany({
-      where: { organizationId: ctx.organizationId },
-      include: { items: true, quoteRequests: true },
-    }),
-    db.auditLog.findMany({ where: { organizationId: ctx.organizationId } }),
-    db.smsMessage.findMany({ where: { organizationId: ctx.organizationId } }),
-    db.scheduledMessage.findMany({ where: { organizationId: ctx.organizationId } }),
-    db.notification.findMany({ where: { organizationId: ctx.organizationId } }),
-  ])
-
-  const backup = {
-    version: 1,
-    exportedAt: new Date().toISOString(),
-    data: {
-      settings,
-      customers,
-      customFieldDefinitions,
-      inventoryParts,
-      vehicles,
-      orgReminders,
-      counterSales,
-      quotes,
-      technicians,
-      inspectionTemplates,
-      inspections,
-      auditLogs,
-      smsMessages,
-      scheduledMessages,
-      notifications,
-    },
-  }
-
-  return new NextResponse(JSON.stringify(backup, null, 2), {
-    headers: {
-      'Content-Type': 'application/json',
-      'Content-Disposition': `attachment; filename="torqvoice-backup-${new Date().toISOString().slice(0, 10)}.json"`,
-    },
-  })
+/**
+ * The older, no-options export.
+ *
+ * It used to be a second implementation of the same thing, and it fell behind:
+ * it predated the tire hotel and never learned about anything added since, so
+ * whoever called it received a quietly incomplete backup. It now runs the same
+ * export as everything else, with every option on.
+ */
+export async function GET(request: NextRequest) {
+  return POST(
+    new NextRequest(request.url, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ include: DEFAULT_OPTIONS }),
+    })
+  )
 }

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

@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
 import { isDemoMode } from '@/lib/demo'
+import { clearPlanFor, UPLOAD_CATEGORIES } from '@/lib/backup/manifest'
+import { columnsOf } from '@/lib/backup/rows'
 import { toSafeDate } from '@/lib/invoice-utils'
 import { Prisma } from '@/generated/prisma/client'
 import JSZip from 'jszip'
@@ -175,6 +177,17 @@ async function importServiceRecordTree(
     })
   }
 
+  // Status reports. They cascade from the service record, so a restore
+  // without them loses every report the workshop sent.
+  await restoreRows(
+    'status reports',
+    (rows) => tx.statusReport.createMany({ data: rows as never }),
+    sr.statusReports,
+    {
+      organizationId: opts.organizationId,
+    }
+  )
+
   // Payments
   const payments = sr.payments as Record<string, unknown>[] | undefined
   if (payments?.length) {
@@ -193,22 +206,53 @@ async function importServiceRecordTree(
   }
 }
 
-async function restoreFiles(zip: JSZip, organizationId: string) {
-  const uploadsDir = path.join(process.cwd(), 'data', 'uploads', organizationId)
+/**
+ * Writes rows back with the ids they had.
+ *
+ * Keeping ids is what lets everything else in the file keep pointing at them,
+ * and it is why a table that is restored must also be cleared first.
+ */
+async function restoreRows(
+  what: string,
+  create: (rows: Record<string, unknown>[]) => Promise<unknown>,
+  rows: unknown,
+  override: Record<string, unknown> = {}
+) {
+  const list = rows as Record<string, unknown>[] | undefined
+  if (!list?.length) return
 
-  // Clear existing uploads for this org
   try {
-    await rm(uploadsDir, { recursive: true, force: true })
-  } catch {
-    // Directory may not exist
+    await create(list.map((row) => columnsOf(row, override)))
+  } catch (error) {
+    // A restore rolls back as a whole, so the only thing left to salvage is
+    // knowing which part of the file could not be read back.
+    const reason = error instanceof Error ? error.message : String(error)
+    throw new Error(`Could not restore ${what}: ${reason}`)
   }
+}
 
-  const allowedCategories = ['logos', 'vehicles', 'inventory', 'services', 'quotes']
+async function restoreFiles(zip: JSZip, organizationId: string) {
+  const uploadsDir = path.join(process.cwd(), 'data', 'uploads', organizationId)
 
   const fileEntries = Object.keys(zip.files).filter(
     (name) => !zip.files[name].dir && (name.startsWith('files/') || name.startsWith('uploads/'))
   )
 
+  // Clear only the folders this backup can refill. Wiping the lot took the
+  // portal background and the tire photos with it, and no backup carried
+  // either of them back.
+  const carried = new Set(
+    fileEntries.map((name) => name.split('/')[1]).filter((category) => Boolean(category))
+  )
+  for (const category of carried) {
+    if (!UPLOAD_CATEGORIES.includes(category)) continue
+    try {
+      await rm(path.join(uploadsDir, category), { recursive: true, force: true })
+    } catch {
+      // Folder may not exist yet.
+    }
+  }
+
   for (const filePath of fileEntries) {
     // Supports both formats:
     //   files/{category}/{filename}  (v2 backup)
@@ -219,7 +263,7 @@ async function restoreFiles(zip: JSZip, organizationId: string) {
     const category = parts[1]
     const filename = parts[2]
 
-    if (!allowedCategories.includes(category)) continue
+    if (!UPLOAD_CATEGORIES.includes(category)) continue
 
     // Prevent directory traversal
     if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) continue
@@ -285,43 +329,47 @@ export async function POST(request: NextRequest) {
 
   try {
     await db.$transaction(async (tx) => {
-      // 1. Delete all existing organization data (cascade handles children)
-      await tx.notification.deleteMany({
-        where: { organizationId: ctx.organizationId },
-      })
-      await tx.smsMessage.deleteMany({
-        where: { organizationId: ctx.organizationId },
-      })
-      await tx.auditLog.deleteMany({
-        where: { organizationId: ctx.organizationId },
-      })
-      await tx.inspection.deleteMany({
-        where: { organizationId: ctx.organizationId },
-      })
-      await tx.inspectionTemplate.deleteMany({
-        where: { organizationId: ctx.organizationId },
-      })
-      await tx.quote.deleteMany({
-        where: { organizationId: ctx.organizationId },
-      })
-      await tx.vehicle.deleteMany({
-        where: { organizationId: ctx.organizationId },
-      })
-      await tx.customFieldDefinition.deleteMany({
-        where: { organizationId: ctx.organizationId },
-      })
-      await tx.inventoryPart.deleteMany({
-        where: { organizationId: ctx.organizationId },
-      })
-      await tx.technician.deleteMany({
-        where: { organizationId: ctx.organizationId },
-      })
-      await tx.customer.deleteMany({
-        where: { organizationId: ctx.organizationId },
-      })
-      await tx.appSetting.deleteMany({
-        where: { organizationId: ctx.organizationId },
-      })
+      const organizationId = ctx.organizationId
+
+      // 1. Clear what this backup can put back, and nothing else.
+      //
+      // A selective export omits the keys it was not asked for. Clearing a
+      // table the file says nothing about deletes records it cannot restore,
+      // which is how importing a customers-only export used to wipe every
+      // vehicle in the workshop.
+      const clearable: Record<string, () => Promise<unknown>> = {
+        Notification: () => tx.notification.deleteMany({ where: { organizationId } }),
+        SmsMessage: () => tx.smsMessage.deleteMany({ where: { organizationId } }),
+        WhatsappMessage: () => tx.whatsappMessage.deleteMany({ where: { organizationId } }),
+        TelegramMessage: () => tx.telegramMessage.deleteMany({ where: { organizationId } }),
+        ScheduledMessage: () => tx.scheduledMessage.deleteMany({ where: { organizationId } }),
+        AuditLog: () => tx.auditLog.deleteMany({ where: { organizationId } }),
+        Inspection: () => tx.inspection.deleteMany({ where: { organizationId } }),
+        InspectionTemplate: () => tx.inspectionTemplate.deleteMany({ where: { organizationId } }),
+        Quote: () => tx.quote.deleteMany({ where: { organizationId } }),
+        TireSet: () => tx.tireSet.deleteMany({ where: { organizationId } }),
+        TireWarehouse: () => tx.tireWarehouse.deleteMany({ where: { organizationId } }),
+        // Counter sales hang off a customer rather than a vehicle, so nothing
+        // else deletes them and a second restore used to collide with them.
+        ServiceRecord: () =>
+          tx.serviceRecord.deleteMany({ where: { organizationId, vehicleId: null } }),
+        Reminder: () => tx.reminder.deleteMany({ where: { organizationId, vehicleId: null } }),
+        Vehicle: () => tx.vehicle.deleteMany({ where: { organizationId } }),
+        CustomFieldDefinition: () =>
+          tx.customFieldDefinition.deleteMany({ where: { organizationId } }),
+        InventoryPart: () => tx.inventoryPart.deleteMany({ where: { organizationId } }),
+        Technician: () => tx.technician.deleteMany({ where: { organizationId } }),
+        Customer: () => tx.customer.deleteMany({ where: { organizationId } }),
+        LaborPreset: () => tx.laborPreset.deleteMany({ where: { organizationId } }),
+        Webhook: () => tx.webhook.deleteMany({ where: { organizationId } }),
+        ReportSchedule: () => tx.reportSchedule.deleteMany({ where: { organizationId } }),
+        AppSetting: () => tx.appSetting.deleteMany({ where: { organizationId } }),
+      }
+
+      for (const model of clearPlanFor(Object.keys(data))) {
+        const clear = clearable[model]
+        if (clear) await clear()
+      }
 
       // 2. Insert settings
       if (data.settings?.length) {
@@ -446,6 +494,14 @@ export async function POST(request: NextRequest) {
             })
           ),
         })
+
+        for (const part of data.inventoryParts as Record<string, unknown>[]) {
+          await restoreRows(
+            'inventory images',
+            (rows) => tx.storedImage.createMany({ data: rows as never }),
+            part.gallery
+          )
+        }
       }
 
       // Resolve work day start time from imported settings for backfilling service record times
@@ -553,6 +609,42 @@ export async function POST(request: NextRequest) {
               })
             }
           }
+
+          // What else a vehicle owns. All of it is deleted with the vehicle,
+          // so leaving any of it out makes a restore a one-way loss.
+          await restoreRows(
+            'service requests',
+            (rows) => tx.serviceRequest.createMany({ data: rows as never }),
+            v.serviceRequests,
+            { organizationId }
+          )
+          await restoreRows(
+            'AI drafted messages',
+            (rows) => tx.aiGeneratedMessage.createMany({ data: rows as never }),
+            v.aiMessages
+          )
+
+          const recurring = v.recurringInvoices as Record<string, unknown>[] | undefined
+          if (recurring?.length) {
+            await restoreRows(
+              'recurring invoices',
+              (rows) => tx.recurringInvoice.createMany({ data: rows as never }),
+              recurring,
+              { organizationId }
+            )
+            for (const invoice of recurring) {
+              await restoreRows(
+                'recurring invoice parts',
+                (rows) => tx.recurringPart.createMany({ data: rows as never }),
+                invoice.templateParts
+              )
+              await restoreRows(
+                'recurring invoice labour',
+                (rows) => tx.recurringLabor.createMany({ data: rows as never }),
+                invoice.templateLabor
+              )
+            }
+          }
         }
       }
 
@@ -591,6 +683,33 @@ export async function POST(request: NextRequest) {
         }
       }
 
+      // 6d. Rows that point at a service record, once every service record
+      // exists. A finding can be resolved in a counter sale, and a stock
+      // movement can belong to any job, so neither can be written while its
+      // service record is still to come.
+      if (data.inventoryParts?.length) {
+        for (const part of data.inventoryParts as Record<string, unknown>[]) {
+          // The importing user owns the history: the person who moved the
+          // stock has no account on this instance.
+          await restoreRows(
+            'stock movements',
+            (rows) => tx.stockMovement.createMany({ data: rows as never }),
+            part.movements,
+            { organizationId, userId: ctx.userId }
+          )
+        }
+      }
+      if (data.vehicles?.length) {
+        for (const vehicle of data.vehicles as Record<string, unknown>[]) {
+          await restoreRows(
+            'vehicle findings',
+            (rows) => tx.vehicleFinding.createMany({ data: rows as never }),
+            vehicle.findings,
+            { organizationId }
+          )
+        }
+      }
+
       // 7. Insert quotes with nested data
       if (data.quotes?.length) {
         for (const q of data.quotes as Record<string, unknown>[]) {
@@ -651,6 +770,14 @@ export async function POST(request: NextRequest) {
               })),
             })
           }
+
+          // Quote attachments: the drawings and photos a quote was argued
+          // with. Their files live in the uploads/quotes folder.
+          await restoreRows(
+            'quote attachments',
+            (rows) => tx.quoteAttachment.createMany({ data: rows as never }),
+            q.attachments
+          )
         }
       }
 
@@ -799,6 +926,84 @@ export async function POST(request: NextRequest) {
         })
       }
 
+      // The other two channels, which were never in a backup at all while SMS
+      // was. Customers are already in place, so their links survive.
+      await restoreRows(
+        'WhatsApp messages',
+        (rows) => tx.whatsappMessage.createMany({ data: rows as never }),
+        data.whatsappMessages,
+        { organizationId }
+      )
+      await restoreRows(
+        'Telegram messages',
+        (rows) => tx.telegramMessage.createMany({ data: rows as never }),
+        data.telegramMessages,
+        { organizationId }
+      )
+
+      // Workshop configuration: labour presets, webhooks, report schedules.
+      const laborPresets = data.laborPresets as Record<string, unknown>[] | undefined
+      if (laborPresets?.length) {
+        await restoreRows(
+          'labour presets',
+          (rows) => tx.laborPreset.createMany({ data: rows as never }),
+          laborPresets,
+          {
+            organizationId,
+            userId: ctx.userId,
+          }
+        )
+        for (const preset of laborPresets) {
+          await restoreRows(
+            'labour preset items',
+            (rows) => tx.laborPresetItem.createMany({ data: rows as never }),
+            preset.items
+          )
+          await restoreRows(
+            'labour preset parts',
+            (rows) => tx.laborPresetPart.createMany({ data: rows as never }),
+            preset.parts
+          )
+        }
+      }
+      await restoreRows(
+        'webhooks',
+        (rows) => tx.webhook.createMany({ data: rows as never }),
+        data.webhooks,
+        {
+          organizationId,
+        }
+      )
+      await restoreRows(
+        'report schedules',
+        (rows) => tx.reportSchedule.createMany({ data: rows as never }),
+        data.reportSchedules,
+        { organizationId }
+      )
+
+      // Roles fill gaps rather than replace: members still point at the roles
+      // they hold, and members are not in a backup.
+      const roles = data.roles as Record<string, unknown>[] | undefined
+      if (roles?.length) {
+        for (const role of roles) {
+          // A role is unique by id and again by name, and an organisation
+          // already has its default roles by the time anyone restores a file.
+          const existing = await tx.role.findFirst({
+            where: {
+              organizationId,
+              OR: [{ id: role.id as string }, { name: role.name as string }],
+            },
+          })
+          if (existing) continue
+          await tx.role.create({ data: columnsOf(role, { organizationId }) as never })
+          await restoreRows(
+            'role permissions',
+            (rows) => tx.permission.createMany({ data: rows as never }),
+            role.permissions
+          )
+        }
+      }
+
       // 13. Insert notifications
       if (data.notifications?.length) {
         await tx.notification.createMany({

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

@@ -0,0 +1,310 @@
+/**
+ * What a backup contains, in one place.
+ *
+ * The export route and the import route used to keep separate lists of
+ * entities, and they drifted: the import accepted an upload category the
+ * export never wrote, several tables were restored but never cleared, and ten
+ * features shipped without ever reaching a backup. Both halves now read this
+ * file, and a test compares it against the Prisma schema, so a new
+ * organisation-scoped model fails the build until someone classifies it.
+ *
+ * Keys are part of the file format. Old backups carry the key they were
+ * written with, so a key may be added but never renamed.
+ */
+
+export type BackupOption =
+  | 'settings'
+  | 'customers'
+  | 'vehicles'
+  | 'quotes'
+  | 'inventory'
+  | 'customFields'
+  | 'technicians'
+  | 'inspections'
+  | 'auditLogs'
+  | 'smsMessages'
+  | 'scheduledMessages'
+  | 'notifications'
+  | 'tireHotel'
+  | 'workshopConfig'
+
+export interface BackupEntity {
+  /** Prisma model name, spelled as in schema.prisma. */
+  model: string
+  /** Key under `data` in the backup file. Absent when the rows nest under a parent. */
+  key?: string
+  /** The export checkbox that includes it. */
+  option: BackupOption
+  /** Parent model, for rows carried inside another entity's tree. */
+  nestedUnder?: string
+  /**
+   * How a restore treats existing rows.
+   *
+   * `replace` clears the organisation's rows first, which is what a restore
+   * means for workshop records. `merge` leaves them and fills in what is
+   * missing, for tables other records point at: clearing roles would strip
+   * every member of the role they hold, since members are not in a backup.
+   */
+  restore: 'replace' | 'merge'
+  /** Clearing order, lowest first. Children before the rows they point at. */
+  clearOrder?: number
+}
+
+export const BACKUP_ENTITIES: readonly BackupEntity[] = [
+  { model: 'AppSetting', key: 'settings', option: 'settings', restore: 'replace', clearOrder: 90 },
+  { model: 'Customer', key: 'customers', option: 'customers', restore: 'replace', clearOrder: 80 },
+  {
+    model: 'CustomFieldDefinition',
+    key: 'customFieldDefinitions',
+    option: 'customFields',
+    restore: 'replace',
+    clearOrder: 60,
+  },
+  {
+    model: 'InventoryPart',
+    key: 'inventoryParts',
+    option: 'inventory',
+    restore: 'replace',
+    clearOrder: 62,
+  },
+  { model: 'StockMovement', option: 'inventory', nestedUnder: 'InventoryPart', restore: 'replace' },
+  { model: 'Vehicle', key: 'vehicles', option: 'vehicles', restore: 'replace', clearOrder: 50 },
+  { model: 'ServiceRequest', option: 'vehicles', nestedUnder: 'Vehicle', restore: 'replace' },
+  {
+    model: 'ServiceRecord',
+    key: 'counterSales',
+    option: 'vehicles',
+    restore: 'replace',
+    clearOrder: 48,
+  },
+  { model: 'StatusReport', option: 'vehicles', nestedUnder: 'ServiceRecord', restore: 'replace' },
+  {
+    model: 'Reminder',
+    key: 'orgReminders',
+    option: 'vehicles',
+    restore: 'replace',
+    clearOrder: 52,
+  },
+  { model: 'Quote', key: 'quotes', option: 'quotes', restore: 'replace', clearOrder: 40 },
+  {
+    model: 'Technician',
+    key: 'technicians',
+    option: 'technicians',
+    restore: 'replace',
+    clearOrder: 70,
+  },
+  {
+    model: 'InspectionTemplate',
+    key: 'inspectionTemplates',
+    option: 'inspections',
+    restore: 'replace',
+    clearOrder: 32,
+  },
+  {
+    model: 'Inspection',
+    key: 'inspections',
+    option: 'inspections',
+    restore: 'replace',
+    clearOrder: 30,
+  },
+  {
+    model: 'InspectionQuoteRequest',
+    option: 'inspections',
+    nestedUnder: 'Inspection',
+    restore: 'replace',
+  },
+  { model: 'AuditLog', key: 'auditLogs', option: 'auditLogs', restore: 'replace', clearOrder: 20 },
+  {
+    model: 'SmsMessage',
+    key: 'smsMessages',
+    option: 'smsMessages',
+    restore: 'replace',
+    clearOrder: 10,
+  },
+  {
+    model: 'WhatsappMessage',
+    key: 'whatsappMessages',
+    option: 'smsMessages',
+    restore: 'replace',
+    clearOrder: 11,
+  },
+  {
+    model: 'TelegramMessage',
+    key: 'telegramMessages',
+    option: 'smsMessages',
+    restore: 'replace',
+    clearOrder: 12,
+  },
+  {
+    model: 'ScheduledMessage',
+    key: 'scheduledMessages',
+    option: 'scheduledMessages',
+    restore: 'replace',
+    clearOrder: 13,
+  },
+  {
+    model: 'Notification',
+    key: 'notifications',
+    option: 'notifications',
+    restore: 'replace',
+    clearOrder: 5,
+  },
+  {
+    model: 'TireWarehouse',
+    key: 'tireWarehouses',
+    option: 'tireHotel',
+    restore: 'replace',
+    clearOrder: 46,
+  },
+  { model: 'TireLocation', option: 'tireHotel', nestedUnder: 'TireWarehouse', restore: 'replace' },
+  { model: 'TireSet', key: 'tireSets', option: 'tireHotel', restore: 'replace', clearOrder: 44 },
+  { model: 'TireMovement', option: 'tireHotel', nestedUnder: 'TireSet', restore: 'replace' },
+  { model: 'TireTreatment', option: 'tireHotel', nestedUnder: 'TireSet', restore: 'replace' },
+  { model: 'TireSetAttachment', option: 'tireHotel', nestedUnder: 'TireSet', restore: 'replace' },
+  {
+    model: 'LaborPreset',
+    key: 'laborPresets',
+    option: 'workshopConfig',
+    restore: 'replace',
+    clearOrder: 85,
+  },
+  {
+    model: 'Webhook',
+    key: 'webhooks',
+    option: 'workshopConfig',
+    restore: 'replace',
+    clearOrder: 86,
+  },
+  {
+    model: 'ReportSchedule',
+    key: 'reportSchedules',
+    option: 'workshopConfig',
+    restore: 'replace',
+    clearOrder: 87,
+  },
+  // Members keep pointing at their role, so a restore fills gaps rather than
+  // clearing the table.
+  { model: 'Role', key: 'roles', option: 'workshopConfig', restore: 'merge' },
+  { model: 'Permission', option: 'workshopConfig', nestedUnder: 'Role', restore: 'merge' },
+  {
+    model: 'LaborPresetItem',
+    option: 'workshopConfig',
+    nestedUnder: 'LaborPreset',
+    restore: 'replace',
+  },
+  {
+    model: 'LaborPresetPart',
+    option: 'workshopConfig',
+    nestedUnder: 'LaborPreset',
+    restore: 'replace',
+  },
+
+  // Everything below hangs off a row above and is deleted with it, so a
+  // restore that leaves any of these out destroys them.
+  { model: 'Note', option: 'vehicles', nestedUnder: 'Vehicle', restore: 'replace' },
+  { model: 'FuelLog', option: 'vehicles', nestedUnder: 'Vehicle', restore: 'replace' },
+  { model: 'VehicleFinding', option: 'vehicles', nestedUnder: 'Vehicle', restore: 'replace' },
+  { model: 'RecurringInvoice', option: 'vehicles', nestedUnder: 'Vehicle', restore: 'replace' },
+  {
+    model: 'RecurringPart',
+    option: 'vehicles',
+    nestedUnder: 'RecurringInvoice',
+    restore: 'replace',
+  },
+  {
+    model: 'RecurringLabor',
+    option: 'vehicles',
+    nestedUnder: 'RecurringInvoice',
+    restore: 'replace',
+  },
+  { model: 'AiGeneratedMessage', option: 'vehicles', nestedUnder: 'Vehicle', restore: 'replace' },
+  { model: 'ServicePart', option: 'vehicles', nestedUnder: 'ServiceRecord', restore: 'replace' },
+  { model: 'ServiceLabor', option: 'vehicles', nestedUnder: 'ServiceRecord', restore: 'replace' },
+  {
+    model: 'ServiceAttachment',
+    option: 'vehicles',
+    nestedUnder: 'ServiceRecord',
+    restore: 'replace',
+  },
+  { model: 'Payment', option: 'vehicles', nestedUnder: 'ServiceRecord', restore: 'replace' },
+  { model: 'QuotePart', option: 'quotes', nestedUnder: 'Quote', restore: 'replace' },
+  { model: 'QuoteLabor', option: 'quotes', nestedUnder: 'Quote', restore: 'replace' },
+  { model: 'QuoteAttachment', option: 'quotes', nestedUnder: 'Quote', restore: 'replace' },
+  {
+    model: 'CustomFieldValue',
+    option: 'customFields',
+    nestedUnder: 'CustomFieldDefinition',
+    restore: 'replace',
+  },
+  { model: 'StoredImage', option: 'inventory', nestedUnder: 'InventoryPart', restore: 'replace' },
+  { model: 'InspectionItem', option: 'inspections', nestedUnder: 'Inspection', restore: 'replace' },
+  {
+    model: 'InspectionTemplateSection',
+    option: 'inspections',
+    nestedUnder: 'InspectionTemplate',
+    restore: 'replace',
+  },
+  {
+    model: 'InspectionTemplateItem',
+    option: 'inspections',
+    nestedUnder: 'InspectionTemplateSection',
+    restore: 'replace',
+  },
+  { model: 'TireMeasurement', option: 'tireHotel', nestedUnder: 'TireSet', restore: 'replace' },
+]
+
+/**
+ * Organisation-scoped models a backup deliberately leaves out, and why.
+ *
+ * A reason is required: it is the difference between a decision and an
+ * oversight, and the next person reading this needs to tell them apart.
+ */
+export const EXCLUDED_MODELS: Readonly<Record<string, string>> = {
+  AiChat: 'Assistant conversation history, not a workshop record.',
+  CustomerMagicLink: 'Single-use portal login link, expires within the hour.',
+  CustomerSession: 'Portal session, recreated when the customer signs in.',
+  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.',
+  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.',
+}
+
+/**
+ * Upload folders a backup carries. The app writes one folder per kind of
+ * attachment, and a folder missing here is silently left out of every backup
+ * and deleted by every restore.
+ */
+export const UPLOAD_CATEGORIES: readonly string[] = [
+  'logos',
+  'vehicles',
+  'inventory',
+  'services',
+  'quotes',
+  'tire-hotel',
+  'portal',
+]
+
+/** Entities written at the top level of the backup, with their key. */
+export function topLevelEntities(): (BackupEntity & { key: string })[] {
+  return BACKUP_ENTITIES.filter((entity): entity is BackupEntity & { key: string } =>
+    Boolean(entity.key)
+  )
+}
+
+/**
+ * Which tables a restore may clear, given the keys a backup actually carries.
+ *
+ * A selective export omits the keys it was not asked for. Clearing a table the
+ * backup says nothing about would delete records the file cannot put back,
+ * which is how restoring "customers only" used to wipe every vehicle.
+ */
+export function clearPlanFor(presentKeys: Iterable<string>): string[] {
+  const keys = new Set(presentKeys)
+  return topLevelEntities()
+    .filter((entity) => entity.restore === 'replace' && entity.clearOrder !== undefined)
+    .filter((entity) => keys.has(entity.key))
+    .sort((a, b) => (a.clearOrder ?? 0) - (b.clearOrder ?? 0))
+    .map((entity) => entity.model)
+}

+ 27 - 0
src/lib/backup/rows.ts

@@ -0,0 +1,27 @@
+/**
+ * Turning a backup row back into something Prisma will accept.
+ *
+ * Rows in a backup are Prisma output, so their field names already match the
+ * table they came from. Two things do not survive the trip through JSON: the
+ * nested arrays an `include` added, which belong to their own tables, and
+ * dates, which arrive as strings.
+ */
+
+/** A full ISO timestamp, which is what JSON.stringify makes of a Date. */
+const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/
+
+export function columnsOf(
+  row: Record<string, unknown>,
+  override: Record<string, unknown> = {}
+): Record<string, unknown> {
+  const columns: Record<string, unknown> = {}
+
+  for (const [field, value] of Object.entries(row)) {
+    if (Array.isArray(value) || value === undefined) continue
+    columns[field] = typeof value === 'string' && ISO_DATE.test(value) ? new Date(value) : value
+  }
+
+  // The organisation and the user importing are this instance's, never the
+  // ones the file was written on.
+  return { ...columns, ...override }
+}