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

Add an NHTSA connector with a vehicle safety panel (#325)

* Add an NHTSA connector: VIN decode, recalls, owner complaints and crash ratings on the vehicle page

* Collapse the safety panel until asked, name the connector NHTSA (USA), and translate job kinds in the activity list

* Open a complaint category from its bar and show the recall list as soon as the safety panel is expanded

* Check a vehicle's recalls the moment it is added, read recall dates day-first, and version cached safety reports
Bernt Christian Egeland 3 недель назад
Родитель
Сommit
87dca73a1b
48 измененных файлов с 3215 добавлено и 31 удалено
  1. 14 1
      messages/de/integrations.json
  2. 47 1
      messages/de/vehicles.json
  3. 14 1
      messages/en/integrations.json
  4. 47 1
      messages/en/vehicles.json
  5. 14 1
      messages/es/integrations.json
  6. 47 1
      messages/es/vehicles.json
  7. 14 1
      messages/fr/integrations.json
  8. 47 1
      messages/fr/vehicles.json
  9. 14 1
      messages/it/integrations.json
  10. 47 1
      messages/it/vehicles.json
  11. 14 1
      messages/lt/integrations.json
  12. 47 1
      messages/lt/vehicles.json
  13. 14 1
      messages/nb/integrations.json
  14. 47 1
      messages/nb/vehicles.json
  15. 14 1
      messages/nl/integrations.json
  16. 47 1
      messages/nl/vehicles.json
  17. 14 1
      messages/pl/integrations.json
  18. 47 1
      messages/pl/vehicles.json
  19. 14 1
      messages/pt-BR/integrations.json
  20. 47 1
      messages/pt-BR/vehicles.json
  21. 14 1
      messages/ru/integrations.json
  22. 47 1
      messages/ru/vehicles.json
  23. 14 1
      messages/tr/integrations.json
  24. 47 1
      messages/tr/vehicles.json
  25. 22 0
      prisma/migrations/20260905090000_vehicle_safety_reports/migration.sql
  26. 26 0
      prisma/schema/integrations.prisma
  27. 8 0
      public/images/integrations/nhtsa.svg
  28. 116 0
      src/__tests__/features/integrations/fixtures/nhtsa/complaints-accord-2003.json
  29. 0 0
      src/__tests__/features/integrations/fixtures/nhtsa/decode-1HGCM82633A004352.json
  30. 176 0
      src/__tests__/features/integrations/fixtures/nhtsa/models-honda-2003.json
  31. 1 0
      src/__tests__/features/integrations/fixtures/nhtsa/rating-4738.json
  32. 1 0
      src/__tests__/features/integrations/fixtures/nhtsa/ratings-accord-2003.json
  33. 53 0
      src/__tests__/features/integrations/fixtures/nhtsa/recalls-accord-2003.json
  34. 312 0
      src/__tests__/features/integrations/nhtsa.test.ts
  35. 4 1
      src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx
  36. 13 1
      src/app/(authenticated)/vehicles/[id]/page.tsx
  37. 15 0
      src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx
  38. 54 0
      src/features/integrations/Actions/vehicleSafetyActions.ts
  39. 85 0
      src/features/integrations/Lib/types.ts
  40. 33 0
      src/features/integrations/Lib/vehicle-safety-contract.ts
  41. 304 0
      src/features/integrations/Lib/vehicle-safety.ts
  42. 17 5
      src/features/vehicles/Components/PlateLookupButton.tsx
  43. 4 0
      src/features/vehicles/Components/VehicleForm.tsx
  44. 611 0
      src/features/vehicles/Components/VehicleSafetyPanel.tsx
  45. 27 0
      src/integrations/nhtsa/manifest.ts
  46. 388 0
      src/integrations/nhtsa/mapping.ts
  47. 211 0
      src/integrations/nhtsa/server.ts
  48. 2 0
      src/integrations/registry.ts

+ 14 - 1
messages/de/integrations.json

@@ -51,7 +51,8 @@
     },
     "vehicle": {
       "lookup": "Kennzeichen- und FIN-Abfrage",
-      "inspection": "HU-Fälligkeitsdaten"
+      "inspection": "HU-Fälligkeitsdaten",
+      "safety": "Rückrufe und Beschwerden von Haltern"
     },
     "ai": {
       "chat": "Texte und Zusammenfassungen"
@@ -514,6 +515,14 @@
         "pullPayments": "In QuickBooks erfasste Zahlungen zurückholen",
         "pullPaymentsHelp": "Alle 30 Minuten. Eine in QuickBooks gegen eine Rechnung aus Torqvoice erhaltene Zahlung wird hier auf der Rechnung erfasst."
       }
+    },
+    "nhtsa": {
+      "description": "Fülle ein Fahrzeug aus seiner FIN aus und sieh Rückrufe, Halterbeschwerden und Crashtest-Bewertungen für jedes Modelljahr, von der US-Sicherheitsbehörde. Kostenlos, ohne Schlüssel.",
+      "fields": {},
+      "settings": {
+        "refreshFleet": "Rückruf- und Beschwerdedaten für jedes Fahrzeug dieser Werkstatt aktuell halten",
+        "refreshFleetHelp": "Einmal täglich werden Modelle, die in der letzten Woche nicht aktualisiert wurden, erneut abgerufen, damit die Fahrzeugseite nie wartet und ein neuer Rückruf innerhalb einer Woche nach Einreichung erscheint. Nur Marke, Modell und Jahr werden gesendet."
+      }
     }
   },
   "jobKinds": {
@@ -532,6 +541,10 @@
       "payment": "Zahlung an die Buchhaltung",
       "customer": "Kunde an die Buchhaltung",
       "pull": "Zahlungen aus der Buchhaltung"
+    },
+    "safety": {
+      "refresh": "Aktualisierung der Rückrufdaten",
+      "vehicle": "Rückrufprüfung für ein neues Fahrzeug"
     }
   }
 }

+ 47 - 1
messages/de/vehicles.json

@@ -360,7 +360,8 @@
     "lookupSource": "Ausgefüllt aus {source}",
     "inspectionDue": "Nächste Hauptuntersuchung",
     "inspectionDueHint": "Datum eintragen, wenn kein Register es liefert. Erinnerungen und Dashboard nutzen es.",
-    "inspectionDueFromRegistry": "Aus dem Fahrzeugregister. Hier ändern, um es bis zum nächsten Abgleich zu überschreiben."
+    "inspectionDueFromRegistry": "Aus dem Fahrzeugregister. Hier ändern, um es bis zum nächsten Abgleich zu überschreiben.",
+    "lookupEnterPlateOrVin": "Gib zuerst ein Kennzeichen oder eine FIN ein"
   },
   "archiveDialog": {
     "title": "Fahrzeug archivieren",
@@ -520,5 +521,50 @@
     "emptyTitle": "Beliebiges Kennzeichen abfragen",
     "emptyBody": "Sehen Sie das Fahrzeug, wem es hier gehört und was {source} dazu führt. Noch nicht Ihres? In einem Schritt hinzufügen.",
     "registryWord": "das Register"
+  },
+  "safety": {
+    "title": "Sicherheit und bekannte Probleme",
+    "summaryRecalls": "{count, plural, one {# Rückruf} other {# Rückrufe}}",
+    "summaryComplaints": "{count, plural, one {# Halterbeschwerde} other {# Halterbeschwerden}}",
+    "expand": "Details anzeigen",
+    "collapse": "Details ausblenden",
+    "loading": "Sicherheitsbehörde wird gefragt...",
+    "from": "Von {source} für {model}, aktualisiert am {date}",
+    "stale": "{source} war nicht erreichbar; der Bericht vom {date} wird gezeigt",
+    "notKnown": "{source} hat keinen Eintrag zu {model}.",
+    "notKnownHint": "Prüfe Marke, Modell und Jahr oder trage die FIN ein, damit das Modell genau zugeordnet werden kann.",
+    "recalls": "Rückrufe",
+    "recallsNone": "Keine Rückrufaktionen",
+    "complaints": "Halterbeschwerden",
+    "complaintsNone": "Keine eingereicht",
+    "crashes": "{count, plural, one {# Unfall} other {# Unfälle}}",
+    "fires": "{count, plural, one {# Brand} other {# Brände}}",
+    "rating": "Crashtest-Bewertung",
+    "notRated": "Nicht bewertet",
+    "stars": "{count} von 5 Sternen",
+    "frontal": "Frontal",
+    "side": "Seite",
+    "rollover": "Überschlag",
+    "ownersReport": "Was Halter melden",
+    "ofComplaints": "von {total} eingereichten Beschwerden",
+    "crash": "Unfall",
+    "fire": "Brand",
+    "noExamples": "Die Beschwerdetexte sind in diesem Bericht noch nicht enthalten. Von der Quelle aktualisieren, um sie zu holen.",
+    "readAll": "Alle {count} auf {source} lesen",
+    "showRecalls": "{count, plural, one {# Rückruf anzeigen} other {# Rückrufe anzeigen}}",
+    "hideRecalls": "Rückrufe ausblenden",
+    "more": "Mehr",
+    "less": "Weniger",
+    "consequence": "Risiko",
+    "remedy": "Abhilfe",
+    "parkIt": "Nicht fahren",
+    "parkOutside": "Im Freien parken",
+    "addFinding": "Zu Beobachtungen hinzufügen",
+    "findingAdded": "In Beobachtungen",
+    "findingDescription": "Rückruf {campaign}: {component}",
+    "refresh": "Von der Quelle aktualisieren",
+    "refreshed": "Sicherheitsdaten aktualisiert",
+    "openSite": "Auf {source} öffnen",
+    "errorLoad": "Sicherheitsdaten konnten nicht geladen werden"
   }
 }

+ 14 - 1
messages/en/integrations.json

@@ -51,7 +51,8 @@
     },
     "vehicle": {
       "lookup": "Plate and VIN lookup",
-      "inspection": "Inspection due dates"
+      "inspection": "Inspection due dates",
+      "safety": "Recalls and owner complaints"
     },
     "ai": {
       "chat": "Writing and summaries"
@@ -514,6 +515,14 @@
         "pullPayments": "Bring back payments recorded in QuickBooks",
         "pullPaymentsHelp": "Every 30 minutes. A payment received in QuickBooks against an invoice from Torqvoice is recorded on the invoice here."
       }
+    },
+    "nhtsa": {
+      "description": "Fill in a vehicle from its VIN, and see recalls, owner complaints and crash ratings for every model year, from the US safety authority. Free, no key.",
+      "fields": {},
+      "settings": {
+        "refreshFleet": "Keep recall and complaint data fresh for every vehicle in this workshop",
+        "refreshFleetHelp": "Once a day, models not refreshed in the last week are fetched again, so the vehicle page never waits and a new recall shows within a week of being filed. Only make, model and year are sent."
+      }
     }
   },
   "jobKinds": {
@@ -532,6 +541,10 @@
       "payment": "Payment to ledger",
       "customer": "Customer to ledger",
       "pull": "Payments from ledger"
+    },
+    "safety": {
+      "refresh": "Recall data refresh",
+      "vehicle": "Recall check for a new vehicle"
     }
   }
 }

+ 47 - 1
messages/en/vehicles.json

@@ -360,7 +360,8 @@
     "lookupSource": "Filled from {source}",
     "inspectionDue": "Next periodic inspection",
     "inspectionDueHint": "Type the date if no registry supplies it. Reminders and the dashboard use it.",
-    "inspectionDueFromRegistry": "Filled from the vehicle registry. Change it here to override until the next sync."
+    "inspectionDueFromRegistry": "Filled from the vehicle registry. Change it here to override until the next sync.",
+    "lookupEnterPlateOrVin": "Type a plate or VIN first"
   },
   "archiveDialog": {
     "title": "Archive Vehicle",
@@ -520,5 +521,50 @@
     "emptyTitle": "Look up any plate",
     "emptyBody": "See the vehicle, who owns it here, and what {source} has on file. Not yours yet? Add it in one step.",
     "registryWord": "the registry"
+  },
+  "safety": {
+    "title": "Safety and known issues",
+    "summaryRecalls": "{count, plural, one {# recall} other {# recalls}}",
+    "summaryComplaints": "{count, plural, one {# owner complaint} other {# owner complaints}}",
+    "expand": "Show details",
+    "collapse": "Hide details",
+    "loading": "Asking the safety authority...",
+    "from": "From {source} for {model}, updated {date}",
+    "stale": "{source} could not be reached; showing the report from {date}",
+    "notKnown": "{source} has no record of a {model}.",
+    "notKnownHint": "Check the make, model and year, or add the VIN so the model can be matched exactly.",
+    "recalls": "Recalls",
+    "recallsNone": "No recall campaigns",
+    "complaints": "Owner complaints",
+    "complaintsNone": "None filed",
+    "crashes": "{count, plural, one {# crash} other {# crashes}}",
+    "fires": "{count, plural, one {# fire} other {# fires}}",
+    "rating": "Crash rating",
+    "notRated": "Not rated",
+    "stars": "{count} of 5 stars",
+    "frontal": "Frontal",
+    "side": "Side",
+    "rollover": "Rollover",
+    "ownersReport": "What owners report",
+    "ofComplaints": "of {total} complaints filed",
+    "crash": "Crash",
+    "fire": "Fire",
+    "noExamples": "Complaint texts are not in this report yet. Refresh from the source to fetch them.",
+    "readAll": "Read all {count} on {source}",
+    "showRecalls": "{count, plural, one {Show # recall} other {Show # recalls}}",
+    "hideRecalls": "Hide recalls",
+    "more": "More",
+    "less": "Less",
+    "consequence": "Risk",
+    "remedy": "Remedy",
+    "parkIt": "Do not drive",
+    "parkOutside": "Park outdoors",
+    "addFinding": "Add to observations",
+    "findingAdded": "In observations",
+    "findingDescription": "Recall {campaign}: {component}",
+    "refresh": "Refresh from the source",
+    "refreshed": "Safety data refreshed",
+    "openSite": "Open on {source}",
+    "errorLoad": "Could not load safety data"
   }
 }

+ 14 - 1
messages/es/integrations.json

@@ -51,7 +51,8 @@
     },
     "vehicle": {
       "lookup": "Consulta por matrícula y VIN",
-      "inspection": "Fechas de inspección"
+      "inspection": "Fechas de inspección",
+      "safety": "Llamadas a revisión y quejas de propietarios"
     },
     "ai": {
       "chat": "Textos y resúmenes"
@@ -514,6 +515,14 @@
         "pullPayments": "Traer los pagos registrados en QuickBooks",
         "pullPaymentsHelp": "Cada 30 minutos. Un pago recibido en QuickBooks contra una factura de Torqvoice se registra aquí en la factura."
       }
+    },
+    "nhtsa": {
+      "description": "Rellena un vehículo a partir de su VIN y consulta llamadas a revisión, quejas de propietarios y calificaciones de choque de cada año de modelo, de la autoridad de seguridad de EE. UU. Gratis, sin clave.",
+      "fields": {},
+      "settings": {
+        "refreshFleet": "Mantener al día los datos de llamadas a revisión y quejas de todos los vehículos de este taller",
+        "refreshFleetHelp": "Una vez al día se vuelven a consultar los modelos no actualizados en la última semana, para que la página del vehículo nunca espere y una nueva llamada a revisión aparezca en una semana. Solo se envían marca, modelo y año."
+      }
     }
   },
   "jobKinds": {
@@ -532,6 +541,10 @@
       "payment": "Pago a contabilidad",
       "customer": "Cliente a contabilidad",
       "pull": "Pagos desde contabilidad"
+    },
+    "safety": {
+      "refresh": "Actualización de datos de llamadas a revisión",
+      "vehicle": "Comprobación de llamadas a revisión para un vehículo nuevo"
     }
   }
 }

+ 47 - 1
messages/es/vehicles.json

@@ -360,7 +360,8 @@
     "lookupSource": "Rellenado desde {source}",
     "inspectionDue": "Próxima inspección periódica",
     "inspectionDueHint": "Escribe la fecha si ningún registro la aporta. Los recordatorios y el panel la usan.",
-    "inspectionDueFromRegistry": "Tomada del registro de vehículos. Cámbiala aquí para anularla hasta la próxima sincronización."
+    "inspectionDueFromRegistry": "Tomada del registro de vehículos. Cámbiala aquí para anularla hasta la próxima sincronización.",
+    "lookupEnterPlateOrVin": "Escribe primero una matrícula o un VIN"
   },
   "archiveDialog": {
     "title": "Archivar vehículo",
@@ -520,5 +521,50 @@
     "emptyTitle": "Busca cualquier matrícula",
     "emptyBody": "Ve el vehículo, quién es su dueño aquí y lo que {source} tiene registrado. ¿Aún no es tuyo? Añádelo en un paso.",
     "registryWord": "el registro"
+  },
+  "safety": {
+    "title": "Seguridad y problemas conocidos",
+    "summaryRecalls": "{count, plural, one {# llamada a revisión} other {# llamadas a revisión}}",
+    "summaryComplaints": "{count, plural, one {# queja de propietario} other {# quejas de propietarios}}",
+    "expand": "Ver detalles",
+    "collapse": "Ocultar detalles",
+    "loading": "Consultando a la autoridad de seguridad...",
+    "from": "De {source} para {model}, actualizado el {date}",
+    "stale": "No se pudo contactar con {source}; se muestra el informe del {date}",
+    "notKnown": "{source} no tiene registro de un {model}.",
+    "notKnownHint": "Comprueba marca, modelo y año, o añade el VIN para que el modelo se identifique con exactitud.",
+    "recalls": "Llamadas a revisión",
+    "recallsNone": "Sin campañas de revisión",
+    "complaints": "Quejas de propietarios",
+    "complaintsNone": "Ninguna presentada",
+    "crashes": "{count, plural, one {# choque} other {# choques}}",
+    "fires": "{count, plural, one {# incendio} other {# incendios}}",
+    "rating": "Calificación de choque",
+    "notRated": "Sin calificar",
+    "stars": "{count} de 5 estrellas",
+    "frontal": "Frontal",
+    "side": "Lateral",
+    "rollover": "Vuelco",
+    "ownersReport": "Lo que reportan los propietarios",
+    "ofComplaints": "de {total} quejas presentadas",
+    "crash": "Choque",
+    "fire": "Incendio",
+    "noExamples": "Los textos de las quejas aún no están en este informe. Actualiza desde la fuente para obtenerlos.",
+    "readAll": "Leer las {count} en {source}",
+    "showRecalls": "{count, plural, one {Mostrar # llamada a revisión} other {Mostrar # llamadas a revisión}}",
+    "hideRecalls": "Ocultar llamadas a revisión",
+    "more": "Más",
+    "less": "Menos",
+    "consequence": "Riesgo",
+    "remedy": "Solución",
+    "parkIt": "No conducir",
+    "parkOutside": "Aparcar al aire libre",
+    "addFinding": "Añadir a observaciones",
+    "findingAdded": "En observaciones",
+    "findingDescription": "Llamada a revisión {campaign}: {component}",
+    "refresh": "Actualizar desde la fuente",
+    "refreshed": "Datos de seguridad actualizados",
+    "openSite": "Abrir en {source}",
+    "errorLoad": "No se pudieron cargar los datos de seguridad"
   }
 }

+ 14 - 1
messages/fr/integrations.json

@@ -51,7 +51,8 @@
     },
     "vehicle": {
       "lookup": "Recherche par plaque et VIN",
-      "inspection": "Dates de contrôle technique"
+      "inspection": "Dates de contrôle technique",
+      "safety": "Rappels et plaintes de propriétaires"
     },
     "ai": {
       "chat": "Textes et résumés"
@@ -514,6 +515,14 @@
         "pullPayments": "Récupérer les paiements enregistrés dans QuickBooks",
         "pullPaymentsHelp": "Toutes les 30 minutes. Un paiement reçu dans QuickBooks sur une facture Torqvoice est enregistré ici sur la facture."
       }
+    },
+    "nhtsa": {
+      "description": "Remplissez un véhicule à partir de son VIN et consultez les rappels, les plaintes de propriétaires et les notes aux crash-tests pour chaque année-modèle, auprès de l'autorité de sécurité américaine. Gratuit, sans clé.",
+      "fields": {},
+      "settings": {
+        "refreshFleet": "Tenir à jour les données de rappels et de plaintes pour chaque véhicule de cet atelier",
+        "refreshFleetHelp": "Une fois par jour, les modèles non actualisés depuis une semaine sont récupérés à nouveau, pour que la page du véhicule n'attende jamais et qu'un nouveau rappel apparaisse dans la semaine. Seuls la marque, le modèle et l'année sont envoyés."
+      }
     }
   },
   "jobKinds": {
@@ -532,6 +541,10 @@
       "payment": "Paiement vers la comptabilité",
       "customer": "Client vers la comptabilité",
       "pull": "Paiements depuis la comptabilité"
+    },
+    "safety": {
+      "refresh": "Actualisation des données de rappel",
+      "vehicle": "Vérification des rappels pour un nouveau véhicule"
     }
   }
 }

+ 47 - 1
messages/fr/vehicles.json

@@ -360,7 +360,8 @@
     "lookupSource": "Rempli depuis {source}",
     "inspectionDue": "Prochain contrôle périodique",
     "inspectionDueHint": "Saisissez la date si aucun registre ne la fournit. Les rappels et le tableau de bord l'utilisent.",
-    "inspectionDueFromRegistry": "Issue du registre des véhicules. Modifiez-la ici pour la remplacer jusqu'à la prochaine synchronisation."
+    "inspectionDueFromRegistry": "Issue du registre des véhicules. Modifiez-la ici pour la remplacer jusqu'à la prochaine synchronisation.",
+    "lookupEnterPlateOrVin": "Saisissez d'abord une plaque ou un VIN"
   },
   "archiveDialog": {
     "title": "Archiver le véhicule",
@@ -520,5 +521,50 @@
     "emptyTitle": "Recherchez n'importe quelle plaque",
     "emptyBody": "Voyez le véhicule, à qui il appartient ici et ce que {source} a enregistré. Pas encore le vôtre ? Ajoutez-le en une étape.",
     "registryWord": "le registre"
+  },
+  "safety": {
+    "title": "Sécurité et problèmes connus",
+    "summaryRecalls": "{count, plural, one {# rappel} other {# rappels}}",
+    "summaryComplaints": "{count, plural, one {# plainte de propriétaire} other {# plaintes de propriétaires}}",
+    "expand": "Afficher les détails",
+    "collapse": "Masquer les détails",
+    "loading": "Interrogation de l'autorité de sécurité...",
+    "from": "De {source} pour {model}, mis à jour le {date}",
+    "stale": "{source} est injoignable ; rapport du {date} affiché",
+    "notKnown": "{source} n'a aucune fiche pour {model}.",
+    "notKnownHint": "Vérifiez la marque, le modèle et l'année, ou ajoutez le VIN pour identifier le modèle exactement.",
+    "recalls": "Rappels",
+    "recallsNone": "Aucune campagne de rappel",
+    "complaints": "Plaintes de propriétaires",
+    "complaintsNone": "Aucune déposée",
+    "crashes": "{count, plural, one {# accident} other {# accidents}}",
+    "fires": "{count, plural, one {# incendie} other {# incendies}}",
+    "rating": "Note aux crash-tests",
+    "notRated": "Non noté",
+    "stars": "{count} étoiles sur 5",
+    "frontal": "Frontal",
+    "side": "Latéral",
+    "rollover": "Retournement",
+    "ownersReport": "Ce que signalent les propriétaires",
+    "ofComplaints": "sur {total} plaintes déposées",
+    "crash": "Accident",
+    "fire": "Incendie",
+    "noExamples": "Les textes des plaintes ne sont pas encore dans ce rapport. Actualisez depuis la source pour les récupérer.",
+    "readAll": "Lire les {count} sur {source}",
+    "showRecalls": "{count, plural, one {Afficher # rappel} other {Afficher # rappels}}",
+    "hideRecalls": "Masquer les rappels",
+    "more": "Plus",
+    "less": "Moins",
+    "consequence": "Risque",
+    "remedy": "Remède",
+    "parkIt": "Ne pas conduire",
+    "parkOutside": "Stationner à l'extérieur",
+    "addFinding": "Ajouter aux observations",
+    "findingAdded": "Dans les observations",
+    "findingDescription": "Rappel {campaign} : {component}",
+    "refresh": "Actualiser depuis la source",
+    "refreshed": "Données de sécurité actualisées",
+    "openSite": "Ouvrir sur {source}",
+    "errorLoad": "Impossible de charger les données de sécurité"
   }
 }

+ 14 - 1
messages/it/integrations.json

@@ -51,7 +51,8 @@
     },
     "vehicle": {
       "lookup": "Ricerca per targa e VIN",
-      "inspection": "Scadenze revisione"
+      "inspection": "Scadenze revisione",
+      "safety": "Richiami e reclami dei proprietari"
     },
     "ai": {
       "chat": "Testi e riepiloghi"
@@ -514,6 +515,14 @@
         "pullPayments": "Riporta i pagamenti registrati in QuickBooks",
         "pullPaymentsHelp": "Ogni 30 minuti. Un pagamento ricevuto in QuickBooks su una fattura di Torqvoice viene registrato qui sulla fattura."
       }
+    },
+    "nhtsa": {
+      "description": "Compila un veicolo dal suo VIN e consulta richiami, reclami dei proprietari e valutazioni dei crash test per ogni anno modello, dall'autorità per la sicurezza statunitense. Gratuito, senza chiave.",
+      "fields": {},
+      "settings": {
+        "refreshFleet": "Tieni aggiornati i dati su richiami e reclami per ogni veicolo di questa officina",
+        "refreshFleetHelp": "Una volta al giorno i modelli non aggiornati nell'ultima settimana vengono richiesti di nuovo, così la pagina del veicolo non attende mai e un nuovo richiamo compare entro una settimana. Vengono inviati solo marca, modello e anno."
+      }
     }
   },
   "jobKinds": {
@@ -532,6 +541,10 @@
       "payment": "Pagamento in contabilità",
       "customer": "Cliente in contabilità",
       "pull": "Pagamenti dalla contabilità"
+    },
+    "safety": {
+      "refresh": "Aggiornamento dei dati sui richiami",
+      "vehicle": "Controllo richiami per un nuovo veicolo"
     }
   }
 }

+ 47 - 1
messages/it/vehicles.json

@@ -360,7 +360,8 @@
     "lookupSource": "Compilato da {source}",
     "inspectionDue": "Prossima revisione periodica",
     "inspectionDueHint": "Inserisci la data se nessun registro la fornisce. Promemoria e dashboard la usano.",
-    "inspectionDueFromRegistry": "Presa dal registro veicoli. Modificala qui per sovrascriverla fino alla prossima sincronizzazione."
+    "inspectionDueFromRegistry": "Presa dal registro veicoli. Modificala qui per sovrascriverla fino alla prossima sincronizzazione.",
+    "lookupEnterPlateOrVin": "Scrivi prima una targa o un VIN"
   },
   "archiveDialog": {
     "title": "Archivia Veicolo",
@@ -520,5 +521,50 @@
     "emptyTitle": "Cerca qualsiasi targa",
     "emptyBody": "Vedi il veicolo, chi ne è il proprietario qui e cosa ha registrato {source}. Non è ancora tuo? Aggiungilo in un passaggio.",
     "registryWord": "il registro"
+  },
+  "safety": {
+    "title": "Sicurezza e problemi noti",
+    "summaryRecalls": "{count, plural, one {# richiamo} other {# richiami}}",
+    "summaryComplaints": "{count, plural, one {# reclamo dei proprietari} other {# reclami dei proprietari}}",
+    "expand": "Mostra dettagli",
+    "collapse": "Nascondi dettagli",
+    "loading": "Interrogazione dell'autorità per la sicurezza...",
+    "from": "Da {source} per {model}, aggiornato il {date}",
+    "stale": "{source} non raggiungibile; mostrato il rapporto del {date}",
+    "notKnown": "{source} non ha alcun record di {model}.",
+    "notKnownHint": "Controlla marca, modello e anno, oppure aggiungi il VIN per identificare il modello con esattezza.",
+    "recalls": "Richiami",
+    "recallsNone": "Nessuna campagna di richiamo",
+    "complaints": "Reclami dei proprietari",
+    "complaintsNone": "Nessuno presentato",
+    "crashes": "{count, plural, one {# incidente} other {# incidenti}}",
+    "fires": "{count, plural, one {# incendio} other {# incendi}}",
+    "rating": "Valutazione crash test",
+    "notRated": "Non valutato",
+    "stars": "{count} stelle su 5",
+    "frontal": "Frontale",
+    "side": "Laterale",
+    "rollover": "Ribaltamento",
+    "ownersReport": "Cosa segnalano i proprietari",
+    "ofComplaints": "su {total} reclami presentati",
+    "crash": "Incidente",
+    "fire": "Incendio",
+    "noExamples": "I testi dei reclami non sono ancora in questo rapporto. Aggiorna dalla fonte per recuperarli.",
+    "readAll": "Leggi tutti i {count} su {source}",
+    "showRecalls": "{count, plural, one {Mostra # richiamo} other {Mostra # richiami}}",
+    "hideRecalls": "Nascondi richiami",
+    "more": "Altro",
+    "less": "Meno",
+    "consequence": "Rischio",
+    "remedy": "Rimedio",
+    "parkIt": "Non guidare",
+    "parkOutside": "Parcheggiare all'aperto",
+    "addFinding": "Aggiungi alle osservazioni",
+    "findingAdded": "Nelle osservazioni",
+    "findingDescription": "Richiamo {campaign}: {component}",
+    "refresh": "Aggiorna dalla fonte",
+    "refreshed": "Dati di sicurezza aggiornati",
+    "openSite": "Apri su {source}",
+    "errorLoad": "Impossibile caricare i dati di sicurezza"
   }
 }

+ 14 - 1
messages/lt/integrations.json

@@ -51,7 +51,8 @@
     },
     "vehicle": {
       "lookup": "Paieška pagal numerį ir VIN",
-      "inspection": "Apžiūros terminai"
+      "inspection": "Apžiūros terminai",
+      "safety": "Atšaukimai ir savininkų skundai"
     },
     "ai": {
       "chat": "Tekstai ir santraukos"
@@ -514,6 +515,14 @@
         "pullPayments": "Parsisiųsti QuickBooks užregistruotus mokėjimus",
         "pullPaymentsHelp": "Kas 30 minučių. QuickBooks gautas mokėjimas už Torqvoice sąskaitą užregistruojamas čia prie sąskaitos."
       }
+    },
+    "nhtsa": {
+      "description": "Užpildykite transporto priemonę pagal VIN ir matykite kiekvienų modelio metų atšaukimus, savininkų skundus ir smūgio bandymų įvertinimus iš JAV saugos institucijos. Nemokamai, be rakto.",
+      "fields": {},
+      "settings": {
+        "refreshFleet": "Nuolat atnaujinti atšaukimų ir skundų duomenis visoms šių dirbtuvių transporto priemonėms",
+        "refreshFleetHelp": "Kartą per dieną modeliai, neatnaujinti per pastarąją savaitę, užklausiami iš naujo, kad transporto priemonės puslapiui niekada nereikėtų laukti, o naujas atšaukimas pasirodytų per savaitę. Siunčiami tik markė, modelis ir metai."
+      }
     }
   },
   "jobKinds": {
@@ -532,6 +541,10 @@
       "payment": "Mokėjimas į apskaitą",
       "customer": "Klientas į apskaitą",
       "pull": "Mokėjimai iš apskaitos"
+    },
+    "safety": {
+      "refresh": "Atšaukimų duomenų atnaujinimas",
+      "vehicle": "Naujos transporto priemonės atšaukimų patikra"
     }
   }
 }

+ 47 - 1
messages/lt/vehicles.json

@@ -360,7 +360,8 @@
     "lookupSource": "Užpildyta iš {source}",
     "inspectionDue": "Kita periodinė apžiūra",
     "inspectionDueHint": "Įveskite datą, jei jos nepateikia registras. Ją naudoja priminimai ir skydelis.",
-    "inspectionDueFromRegistry": "Iš transporto priemonių registro. Pakeiskite čia, kad perrašytumėte iki kito sinchronizavimo."
+    "inspectionDueFromRegistry": "Iš transporto priemonių registro. Pakeiskite čia, kad perrašytumėte iki kito sinchronizavimo.",
+    "lookupEnterPlateOrVin": "Pirmiausia įveskite numerį arba VIN"
   },
   "archiveDialog": {
     "title": "Archyvuoti transporto priemonę",
@@ -520,5 +521,50 @@
     "emptyTitle": "Ieškokite bet kurio numerio",
     "emptyBody": "Pamatykite transporto priemonę, kam ji čia priklauso ir ką apie ją turi {source}. Dar ne jūsų? Pridėkite vienu žingsniu.",
     "registryWord": "registras"
+  },
+  "safety": {
+    "title": "Sauga ir žinomos problemos",
+    "summaryRecalls": "{count, plural, one {# atšaukimas} few {# atšaukimai} other {# atšaukimų}}",
+    "summaryComplaints": "{count, plural, one {# savininko skundas} few {# savininkų skundai} other {# savininkų skundų}}",
+    "expand": "Rodyti išsamiau",
+    "collapse": "Slėpti išsamiau",
+    "loading": "Klausiama saugos institucijos...",
+    "from": "Iš {source} apie {model}, atnaujinta {date}",
+    "stale": "{source} nepasiekiama; rodoma {date} ataskaita",
+    "notKnown": "{source} neturi įrašo apie {model}.",
+    "notKnownHint": "Patikrinkite markę, modelį ir metus arba pridėkite VIN, kad modelis būtų tiksliai atpažintas.",
+    "recalls": "Atšaukimai",
+    "recallsNone": "Atšaukimo kampanijų nėra",
+    "complaints": "Savininkų skundai",
+    "complaintsNone": "Nepateikta",
+    "crashes": "{count, plural, one {# avarija} few {# avarijos} other {# avarijų}}",
+    "fires": "{count, plural, one {# gaisras} few {# gaisrai} other {# gaisrų}}",
+    "rating": "Smūgio bandymų įvertinimas",
+    "notRated": "Neįvertinta",
+    "stars": "{count} iš 5 žvaigždučių",
+    "frontal": "Priekinis",
+    "side": "Šoninis",
+    "rollover": "Apsivertimas",
+    "ownersReport": "Ką praneša savininkai",
+    "ofComplaints": "iš {total} pateiktų skundų",
+    "crash": "Avarija",
+    "fire": "Gaisras",
+    "noExamples": "Skundų tekstų šioje ataskaitoje dar nėra. Atnaujinkite iš šaltinio, kad juos gautumėte.",
+    "readAll": "Skaityti visus {count} {source}",
+    "showRecalls": "{count, plural, one {Rodyti # atšaukimą} few {Rodyti # atšaukimus} other {Rodyti # atšaukimų}}",
+    "hideRecalls": "Slėpti atšaukimus",
+    "more": "Daugiau",
+    "less": "Mažiau",
+    "consequence": "Rizika",
+    "remedy": "Sprendimas",
+    "parkIt": "Nevažiuoti",
+    "parkOutside": "Statyti lauke",
+    "addFinding": "Pridėti prie pastebėjimų",
+    "findingAdded": "Pastebėjimuose",
+    "findingDescription": "Atšaukimas {campaign}: {component}",
+    "refresh": "Atnaujinti iš šaltinio",
+    "refreshed": "Saugos duomenys atnaujinti",
+    "openSite": "Atidaryti {source}",
+    "errorLoad": "Nepavyko įkelti saugos duomenų"
   }
 }

+ 14 - 1
messages/nb/integrations.json

@@ -51,7 +51,8 @@
     },
     "vehicle": {
       "lookup": "Oppslag på skilt og understellsnummer",
-      "inspection": "Frist for EU-kontroll"
+      "inspection": "Frist for EU-kontroll",
+      "safety": "Tilbakekallinger og eierklager"
     },
     "ai": {
       "chat": "Tekst og sammendrag"
@@ -514,6 +515,14 @@
         "pullPayments": "Hent tilbake betalinger registrert i QuickBooks",
         "pullPaymentsHelp": "Hvert 30. minutt. En betaling mottatt i QuickBooks mot en faktura fra Torqvoice registreres her på fakturaen."
       }
+    },
+    "nhtsa": {
+      "description": "Fyll ut et kjøretøy fra VIN, og se tilbakekallinger, eierklager og kollisjonsvurderinger for hver årsmodell, fra den amerikanske trafikksikkerhetsmyndigheten. Gratis, uten nøkkel.",
+      "fields": {},
+      "settings": {
+        "refreshFleet": "Hold tilbakekallings- og klagedata oppdatert for alle kjøretøy i dette verkstedet",
+        "refreshFleetHelp": "En gang om dagen hentes modeller som ikke er oppdatert siste uke på nytt, så kjøretøysiden aldri venter og en ny tilbakekalling vises innen en uke. Bare merke, modell og år sendes."
+      }
     }
   },
   "jobKinds": {
@@ -532,6 +541,10 @@
       "payment": "Betaling til regnskap",
       "customer": "Kunde til regnskap",
       "pull": "Betalinger fra regnskap"
+    },
+    "safety": {
+      "refresh": "Oppdatering av tilbakekallingsdata",
+      "vehicle": "Tilbakekallingssjekk for nytt kjøretøy"
     }
   }
 }

+ 47 - 1
messages/nb/vehicles.json

@@ -360,7 +360,8 @@
     "lookupSource": "Fylt ut fra {source}",
     "inspectionDue": "Neste EU-kontroll",
     "inspectionDueHint": "Skriv inn datoen hvis ingen register leverer den. Påminnelser og dashbordet bruker den.",
-    "inspectionDueFromRegistry": "Hentet fra kjøretøyregisteret. Endre her for å overstyre til neste synkronisering."
+    "inspectionDueFromRegistry": "Hentet fra kjøretøyregisteret. Endre her for å overstyre til neste synkronisering.",
+    "lookupEnterPlateOrVin": "Skriv inn et skilt eller VIN først"
   },
   "archiveDialog": {
     "title": "Arkiver kjøretøy",
@@ -520,5 +521,50 @@
     "emptyTitle": "Slå opp et hvilket som helst skilt",
     "emptyBody": "Se kjøretøyet, hvem som eier det her, og hva {source} har registrert. Ikke ditt enda? Legg det til i ett steg.",
     "registryWord": "registeret"
+  },
+  "safety": {
+    "title": "Sikkerhet og kjente problemer",
+    "summaryRecalls": "{count, plural, one {# tilbakekalling} other {# tilbakekallinger}}",
+    "summaryComplaints": "{count, plural, one {# eierklage} other {# eierklager}}",
+    "expand": "Vis detaljer",
+    "collapse": "Skjul detaljer",
+    "loading": "Spør sikkerhetsmyndigheten...",
+    "from": "Fra {source} for {model}, oppdatert {date}",
+    "stale": "{source} kunne ikke nås; viser rapporten fra {date}",
+    "notKnown": "{source} har ingen oppføring for {model}.",
+    "notKnownHint": "Sjekk merke, modell og år, eller legg inn VIN så modellen kan gjenkjennes nøyaktig.",
+    "recalls": "Tilbakekallinger",
+    "recallsNone": "Ingen tilbakekallinger",
+    "complaints": "Eierklager",
+    "complaintsNone": "Ingen registrert",
+    "crashes": "{count, plural, one {# ulykke} other {# ulykker}}",
+    "fires": "{count, plural, one {# brann} other {# branner}}",
+    "rating": "Kollisjonsvurdering",
+    "notRated": "Ikke vurdert",
+    "stars": "{count} av 5 stjerner",
+    "frontal": "Front",
+    "side": "Side",
+    "rollover": "Velt",
+    "ownersReport": "Hva eiere melder om",
+    "ofComplaints": "av {total} innsendte klager",
+    "crash": "Ulykke",
+    "fire": "Brann",
+    "noExamples": "Klageteksten er ikke med i denne rapporten ennå. Oppdater fra kilden for å hente den.",
+    "readAll": "Les alle {count} på {source}",
+    "showRecalls": "{count, plural, one {Vis # tilbakekalling} other {Vis # tilbakekallinger}}",
+    "hideRecalls": "Skjul tilbakekallinger",
+    "more": "Mer",
+    "less": "Mindre",
+    "consequence": "Risiko",
+    "remedy": "Utbedring",
+    "parkIt": "Ikke kjør",
+    "parkOutside": "Parker utendørs",
+    "addFinding": "Legg til i observasjoner",
+    "findingAdded": "I observasjoner",
+    "findingDescription": "Tilbakekalling {campaign}: {component}",
+    "refresh": "Oppdater fra kilden",
+    "refreshed": "Sikkerhetsdata oppdatert",
+    "openSite": "Åpne på {source}",
+    "errorLoad": "Kunne ikke laste sikkerhetsdata"
   }
 }

+ 14 - 1
messages/nl/integrations.json

@@ -51,7 +51,8 @@
     },
     "vehicle": {
       "lookup": "Kenteken- en VIN-opzoeking",
-      "inspection": "APK-vervaldatums"
+      "inspection": "APK-vervaldatums",
+      "safety": "Terugroepacties en klachten van eigenaren"
     },
     "ai": {
       "chat": "Teksten en samenvattingen"
@@ -514,6 +515,14 @@
         "pullPayments": "In QuickBooks geregistreerde betalingen terughalen",
         "pullPaymentsHelp": "Elke 30 minuten. Een betaling die in QuickBooks op een factuur van Torqvoice is ontvangen, wordt hier op de factuur geregistreerd."
       }
+    },
+    "nhtsa": {
+      "description": "Vul een voertuig in vanuit het VIN en bekijk terugroepacties, klachten van eigenaren en botsproefscores per modeljaar, van de Amerikaanse verkeersveiligheidsautoriteit. Gratis, zonder sleutel.",
+      "fields": {},
+      "settings": {
+        "refreshFleet": "Terugroep- en klachtgegevens actueel houden voor elk voertuig in deze werkplaats",
+        "refreshFleetHelp": "Eén keer per dag worden modellen die de afgelopen week niet zijn vernieuwd opnieuw opgehaald, zodat de voertuigpagina nooit wacht en een nieuwe terugroepactie binnen een week zichtbaar is. Alleen merk, model en bouwjaar worden verstuurd."
+      }
     }
   },
   "jobKinds": {
@@ -532,6 +541,10 @@
       "payment": "Betaling naar boekhouding",
       "customer": "Klant naar boekhouding",
       "pull": "Betalingen uit boekhouding"
+    },
+    "safety": {
+      "refresh": "Vernieuwing van terugroepgegevens",
+      "vehicle": "Terugroepcontrole voor een nieuw voertuig"
     }
   }
 }

+ 47 - 1
messages/nl/vehicles.json

@@ -360,7 +360,8 @@
     "lookupSource": "Ingevuld vanuit {source}",
     "inspectionDue": "Volgende periodieke keuring",
     "inspectionDueHint": "Vul de datum in als geen register die levert. Herinneringen en het dashboard gebruiken hem.",
-    "inspectionDueFromRegistry": "Uit het voertuigregister. Wijzig hier om te overschrijven tot de volgende synchronisatie."
+    "inspectionDueFromRegistry": "Uit het voertuigregister. Wijzig hier om te overschrijven tot de volgende synchronisatie.",
+    "lookupEnterPlateOrVin": "Typ eerst een kenteken of VIN"
   },
   "archiveDialog": {
     "title": "Voertuig archiveren",
@@ -520,5 +521,50 @@
     "emptyTitle": "Zoek elk kenteken op",
     "emptyBody": "Zie het voertuig, van wie het hier is en wat {source} erover heeft. Nog niet van jou? Voeg het in één stap toe.",
     "registryWord": "het register"
+  },
+  "safety": {
+    "title": "Veiligheid en bekende problemen",
+    "summaryRecalls": "{count, plural, one {# terugroepactie} other {# terugroepacties}}",
+    "summaryComplaints": "{count, plural, one {# klacht van eigenaren} other {# klachten van eigenaren}}",
+    "expand": "Details tonen",
+    "collapse": "Details verbergen",
+    "loading": "Veiligheidsautoriteit wordt geraadpleegd...",
+    "from": "Van {source} voor {model}, bijgewerkt op {date}",
+    "stale": "{source} was niet bereikbaar; rapport van {date} wordt getoond",
+    "notKnown": "{source} heeft geen gegevens over een {model}.",
+    "notKnownHint": "Controleer merk, model en bouwjaar, of voeg het VIN toe zodat het model exact wordt herkend.",
+    "recalls": "Terugroepacties",
+    "recallsNone": "Geen terugroepacties",
+    "complaints": "Klachten van eigenaren",
+    "complaintsNone": "Geen ingediend",
+    "crashes": "{count, plural, one {# ongeval} other {# ongevallen}}",
+    "fires": "{count, plural, one {# brand} other {# branden}}",
+    "rating": "Botsproefscore",
+    "notRated": "Niet beoordeeld",
+    "stars": "{count} van 5 sterren",
+    "frontal": "Frontaal",
+    "side": "Zijkant",
+    "rollover": "Kantelen",
+    "ownersReport": "Wat eigenaren melden",
+    "ofComplaints": "van {total} ingediende klachten",
+    "crash": "Ongeval",
+    "fire": "Brand",
+    "noExamples": "De klachtteksten zitten nog niet in dit rapport. Vernieuw vanaf de bron om ze op te halen.",
+    "readAll": "Lees alle {count} op {source}",
+    "showRecalls": "{count, plural, one {Toon # terugroepactie} other {Toon # terugroepacties}}",
+    "hideRecalls": "Terugroepacties verbergen",
+    "more": "Meer",
+    "less": "Minder",
+    "consequence": "Risico",
+    "remedy": "Oplossing",
+    "parkIt": "Niet rijden",
+    "parkOutside": "Buiten parkeren",
+    "addFinding": "Toevoegen aan observaties",
+    "findingAdded": "In observaties",
+    "findingDescription": "Terugroepactie {campaign}: {component}",
+    "refresh": "Vernieuwen vanaf de bron",
+    "refreshed": "Veiligheidsgegevens vernieuwd",
+    "openSite": "Openen op {source}",
+    "errorLoad": "Veiligheidsgegevens konden niet worden geladen"
   }
 }

+ 14 - 1
messages/pl/integrations.json

@@ -51,7 +51,8 @@
     },
     "vehicle": {
       "lookup": "Wyszukiwanie po tablicy i VIN",
-      "inspection": "Terminy przeglądów"
+      "inspection": "Terminy przeglądów",
+      "safety": "Akcje serwisowe i skargi właścicieli"
     },
     "ai": {
       "chat": "Teksty i podsumowania"
@@ -514,6 +515,14 @@
         "pullPayments": "Pobieraj płatności zarejestrowane w QuickBooks",
         "pullPaymentsHelp": "Co 30 minut. Płatność przyjęta w QuickBooks za fakturę z Torqvoice zostaje zarejestrowana tutaj na fakturze."
       }
+    },
+    "nhtsa": {
+      "description": "Uzupełnij pojazd na podstawie VIN i zobacz akcje serwisowe, skargi właścicieli i oceny z testów zderzeniowych dla każdego rocznika, z amerykańskiego urzędu bezpieczeństwa ruchu. Za darmo, bez klucza.",
+      "fields": {},
+      "settings": {
+        "refreshFleet": "Utrzymuj aktualne dane o akcjach serwisowych i skargach dla każdego pojazdu w tym warsztacie",
+        "refreshFleetHelp": "Raz dziennie modele nieodświeżone w ostatnim tygodniu są pobierane ponownie, aby strona pojazdu nigdy nie czekała, a nowa akcja serwisowa pojawiła się w ciągu tygodnia. Wysyłane są tylko marka, model i rocznik."
+      }
     }
   },
   "jobKinds": {
@@ -532,6 +541,10 @@
       "payment": "Płatność do księgowości",
       "customer": "Klient do księgowości",
       "pull": "Płatności z księgowości"
+    },
+    "safety": {
+      "refresh": "Odświeżanie danych o akcjach serwisowych",
+      "vehicle": "Sprawdzenie akcji serwisowych dla nowego pojazdu"
     }
   }
 }

+ 47 - 1
messages/pl/vehicles.json

@@ -360,7 +360,8 @@
     "lookupSource": "Uzupełniono z {source}",
     "inspectionDue": "Następny przegląd okresowy",
     "inspectionDueHint": "Wpisz datę, jeśli żaden rejestr jej nie dostarcza. Używają jej przypomnienia i pulpit.",
-    "inspectionDueFromRegistry": "Z rejestru pojazdów. Zmień tutaj, aby nadpisać do następnej synchronizacji."
+    "inspectionDueFromRegistry": "Z rejestru pojazdów. Zmień tutaj, aby nadpisać do następnej synchronizacji.",
+    "lookupEnterPlateOrVin": "Najpierw wpisz rejestrację lub VIN"
   },
   "archiveDialog": {
     "title": "Archiwizuj pojazd",
@@ -520,5 +521,50 @@
     "emptyTitle": "Sprawdź dowolną tablicę",
     "emptyBody": "Zobacz pojazd, kto jest tu jego właścicielem i co ma o nim {source}. Jeszcze nie Twój? Dodaj go jednym krokiem.",
     "registryWord": "rejestr"
+  },
+  "safety": {
+    "title": "Bezpieczeństwo i znane problemy",
+    "summaryRecalls": "{count, plural, one {# akcja serwisowa} few {# akcje serwisowe} other {# akcji serwisowych}}",
+    "summaryComplaints": "{count, plural, one {# skarga właściciela} few {# skargi właścicieli} other {# skarg właścicieli}}",
+    "expand": "Pokaż szczegóły",
+    "collapse": "Ukryj szczegóły",
+    "loading": "Zapytanie do urzędu bezpieczeństwa...",
+    "from": "Z {source} dla {model}, zaktualizowano {date}",
+    "stale": "Nie udało się połączyć z {source}; pokazano raport z {date}",
+    "notKnown": "{source} nie ma wpisu dla {model}.",
+    "notKnownHint": "Sprawdź markę, model i rocznik albo dodaj VIN, aby dokładnie dopasować model.",
+    "recalls": "Akcje serwisowe",
+    "recallsNone": "Brak akcji serwisowych",
+    "complaints": "Skargi właścicieli",
+    "complaintsNone": "Brak zgłoszeń",
+    "crashes": "{count, plural, one {# wypadek} few {# wypadki} other {# wypadków}}",
+    "fires": "{count, plural, one {# pożar} few {# pożary} other {# pożarów}}",
+    "rating": "Ocena zderzeniowa",
+    "notRated": "Brak oceny",
+    "stars": "{count} z 5 gwiazdek",
+    "frontal": "Czołowe",
+    "side": "Boczne",
+    "rollover": "Dachowanie",
+    "ownersReport": "Co zgłaszają właściciele",
+    "ofComplaints": "z {total} złożonych skarg",
+    "crash": "Wypadek",
+    "fire": "Pożar",
+    "noExamples": "Treści skarg nie ma jeszcze w tym raporcie. Odśwież ze źródła, aby je pobrać.",
+    "readAll": "Czytaj wszystkie {count} na {source}",
+    "showRecalls": "{count, plural, one {Pokaż # akcję serwisową} few {Pokaż # akcje serwisowe} other {Pokaż # akcji serwisowych}}",
+    "hideRecalls": "Ukryj akcje serwisowe",
+    "more": "Więcej",
+    "less": "Mniej",
+    "consequence": "Ryzyko",
+    "remedy": "Naprawa",
+    "parkIt": "Nie jeździć",
+    "parkOutside": "Parkować na zewnątrz",
+    "addFinding": "Dodaj do obserwacji",
+    "findingAdded": "W obserwacjach",
+    "findingDescription": "Akcja serwisowa {campaign}: {component}",
+    "refresh": "Odśwież ze źródła",
+    "refreshed": "Dane bezpieczeństwa odświeżone",
+    "openSite": "Otwórz na {source}",
+    "errorLoad": "Nie udało się wczytać danych bezpieczeństwa"
   }
 }

+ 14 - 1
messages/pt-BR/integrations.json

@@ -51,7 +51,8 @@
     },
     "vehicle": {
       "lookup": "Consulta por placa e VIN",
-      "inspection": "Datas de inspeção"
+      "inspection": "Datas de inspeção",
+      "safety": "Recalls e reclamações de proprietários"
     },
     "ai": {
       "chat": "Textos e resumos"
@@ -514,6 +515,14 @@
         "pullPayments": "Trazer de volta pagamentos registrados no QuickBooks",
         "pullPaymentsHelp": "A cada 30 minutos. Um pagamento recebido no QuickBooks para uma fatura do Torqvoice é registrado aqui na fatura."
       }
+    },
+    "nhtsa": {
+      "description": "Preencha um veículo a partir do VIN e veja recalls, reclamações de proprietários e notas de testes de colisão de cada ano-modelo, da autoridade de segurança dos EUA. Grátis, sem chave.",
+      "fields": {},
+      "settings": {
+        "refreshFleet": "Manter os dados de recalls e reclamações atualizados para todos os veículos desta oficina",
+        "refreshFleetHelp": "Uma vez por dia, os modelos não atualizados na última semana são buscados de novo, para que a página do veículo nunca espere e um novo recall apareça em até uma semana. Só marca, modelo e ano são enviados."
+      }
     }
   },
   "jobKinds": {
@@ -532,6 +541,10 @@
       "payment": "Pagamento para a contabilidade",
       "customer": "Cliente para a contabilidade",
       "pull": "Pagamentos da contabilidade"
+    },
+    "safety": {
+      "refresh": "Atualização dos dados de recall",
+      "vehicle": "Verificação de recall para um veículo novo"
     }
   }
 }

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

@@ -360,7 +360,8 @@
     "lookupSource": "Preenchido de {source}",
     "inspectionDue": "Próxima inspeção periódica",
     "inspectionDueHint": "Digite a data se nenhum registro a fornecer. Lembretes e o painel a usam.",
-    "inspectionDueFromRegistry": "Obtida do registro de veículos. Altere aqui para substituir até a próxima sincronização."
+    "inspectionDueFromRegistry": "Obtida do registro de veículos. Altere aqui para substituir até a próxima sincronização.",
+    "lookupEnterPlateOrVin": "Digite uma placa ou um VIN primeiro"
   },
   "archiveDialog": {
     "title": "Arquivar Veículo",
@@ -520,5 +521,50 @@
     "emptyTitle": "Consulte qualquer placa",
     "emptyBody": "Veja o veículo, de quem é aqui e o que {source} tem registrado. Ainda não é seu? Adicione em um passo.",
     "registryWord": "o registro"
+  },
+  "safety": {
+    "title": "Segurança e problemas conhecidos",
+    "summaryRecalls": "{count, plural, one {# recall} other {# recalls}}",
+    "summaryComplaints": "{count, plural, one {# reclamação de proprietário} other {# reclamações de proprietários}}",
+    "expand": "Mostrar detalhes",
+    "collapse": "Ocultar detalhes",
+    "loading": "Consultando a autoridade de segurança...",
+    "from": "De {source} para {model}, atualizado em {date}",
+    "stale": "{source} não pôde ser acessado; mostrando o relatório de {date}",
+    "notKnown": "{source} não tem registro de um {model}.",
+    "notKnownHint": "Verifique marca, modelo e ano, ou adicione o VIN para que o modelo seja identificado com exatidão.",
+    "recalls": "Recalls",
+    "recallsNone": "Nenhuma campanha de recall",
+    "complaints": "Reclamações de proprietários",
+    "complaintsNone": "Nenhuma registrada",
+    "crashes": "{count, plural, one {# colisão} other {# colisões}}",
+    "fires": "{count, plural, one {# incêndio} other {# incêndios}}",
+    "rating": "Nota de colisão",
+    "notRated": "Sem nota",
+    "stars": "{count} de 5 estrelas",
+    "frontal": "Frontal",
+    "side": "Lateral",
+    "rollover": "Capotamento",
+    "ownersReport": "O que os proprietários relatam",
+    "ofComplaints": "de {total} reclamações registradas",
+    "crash": "Colisão",
+    "fire": "Incêndio",
+    "noExamples": "Os textos das reclamações ainda não estão neste relatório. Atualize da fonte para buscá-los.",
+    "readAll": "Ler todas as {count} em {source}",
+    "showRecalls": "{count, plural, one {Mostrar # recall} other {Mostrar # recalls}}",
+    "hideRecalls": "Ocultar recalls",
+    "more": "Mais",
+    "less": "Menos",
+    "consequence": "Risco",
+    "remedy": "Solução",
+    "parkIt": "Não dirigir",
+    "parkOutside": "Estacionar ao ar livre",
+    "addFinding": "Adicionar às observações",
+    "findingAdded": "Nas observações",
+    "findingDescription": "Recall {campaign}: {component}",
+    "refresh": "Atualizar da fonte",
+    "refreshed": "Dados de segurança atualizados",
+    "openSite": "Abrir em {source}",
+    "errorLoad": "Não foi possível carregar os dados de segurança"
   }
 }

+ 14 - 1
messages/ru/integrations.json

@@ -51,7 +51,8 @@
     },
     "vehicle": {
       "lookup": "Поиск по номеру и VIN",
-      "inspection": "Сроки техосмотра"
+      "inspection": "Сроки техосмотра",
+      "safety": "Отзывные кампании и жалобы владельцев"
     },
     "ai": {
       "chat": "Тексты и сводки"
@@ -514,6 +515,14 @@
         "pullPayments": "Забирать платежи, записанные в QuickBooks",
         "pullPaymentsHelp": "Каждые 30 минут. Платёж, полученный в QuickBooks по счёту из Torqvoice, записывается здесь на счёт."
       }
+    },
+    "nhtsa": {
+      "description": "Заполните автомобиль по VIN и смотрите отзывные кампании, жалобы владельцев и рейтинги краш-тестов для каждого модельного года от американского ведомства безопасности. Бесплатно, без ключа.",
+      "fields": {},
+      "settings": {
+        "refreshFleet": "Поддерживать актуальные данные об отзывах и жалобах для каждого автомобиля этой мастерской",
+        "refreshFleetHelp": "Раз в день модели, не обновлявшиеся за последнюю неделю, запрашиваются заново, чтобы страница автомобиля никогда не ждала, а новая отзывная кампания появлялась в течение недели. Отправляются только марка, модель и год."
+      }
     }
   },
   "jobKinds": {
@@ -532,6 +541,10 @@
       "payment": "Платёж в бухгалтерию",
       "customer": "Клиент в бухгалтерию",
       "pull": "Платежи из бухгалтерии"
+    },
+    "safety": {
+      "refresh": "Обновление данных об отзывных кампаниях",
+      "vehicle": "Проверка отзывных кампаний для нового автомобиля"
     }
   }
 }

+ 47 - 1
messages/ru/vehicles.json

@@ -360,7 +360,8 @@
     "lookupSource": "Заполнено из {source}",
     "inspectionDue": "Следующий техосмотр",
     "inspectionDueHint": "Введите дату, если её не даёт реестр. Её используют напоминания и панель.",
-    "inspectionDueFromRegistry": "Из реестра транспортных средств. Измените здесь, чтобы переопределить до следующей синхронизации."
+    "inspectionDueFromRegistry": "Из реестра транспортных средств. Измените здесь, чтобы переопределить до следующей синхронизации.",
+    "lookupEnterPlateOrVin": "Сначала введите номер или VIN"
   },
   "archiveDialog": {
     "title": "Архивировать транспортное средство",
@@ -520,5 +521,50 @@
     "emptyTitle": "Найдите любой номер",
     "emptyBody": "Посмотрите автомобиль, кто его владелец здесь и что о нём есть в {source}. Ещё не ваш? Добавьте за один шаг.",
     "registryWord": "реестр"
+  },
+  "safety": {
+    "title": "Безопасность и известные проблемы",
+    "summaryRecalls": "{count, plural, one {# отзывная кампания} few {# отзывные кампании} other {# отзывных кампаний}}",
+    "summaryComplaints": "{count, plural, one {# жалоба владельца} few {# жалобы владельцев} other {# жалоб владельцев}}",
+    "expand": "Показать подробности",
+    "collapse": "Скрыть подробности",
+    "loading": "Запрос к ведомству безопасности...",
+    "from": "От {source} для {model}, обновлено {date}",
+    "stale": "{source} недоступен; показан отчёт от {date}",
+    "notKnown": "У {source} нет записи о {model}.",
+    "notKnownHint": "Проверьте марку, модель и год или добавьте VIN, чтобы модель определялась точно.",
+    "recalls": "Отзывные кампании",
+    "recallsNone": "Отзывных кампаний нет",
+    "complaints": "Жалобы владельцев",
+    "complaintsNone": "Не подано",
+    "crashes": "{count, plural, one {# авария} few {# аварии} other {# аварий}}",
+    "fires": "{count, plural, one {# возгорание} few {# возгорания} other {# возгораний}}",
+    "rating": "Рейтинг краш-тестов",
+    "notRated": "Не оценивался",
+    "stars": "{count} из 5 звёзд",
+    "frontal": "Фронтальный",
+    "side": "Боковой",
+    "rollover": "Опрокидывание",
+    "ownersReport": "На что жалуются владельцы",
+    "ofComplaints": "из {total} поданных жалоб",
+    "crash": "Авария",
+    "fire": "Возгорание",
+    "noExamples": "Тексты жалоб ещё не включены в этот отчёт. Обновите из источника, чтобы получить их.",
+    "readAll": "Читать все {count} на {source}",
+    "showRecalls": "{count, plural, one {Показать # отзывную кампанию} few {Показать # отзывные кампании} other {Показать # отзывных кампаний}}",
+    "hideRecalls": "Скрыть отзывные кампании",
+    "more": "Ещё",
+    "less": "Меньше",
+    "consequence": "Риск",
+    "remedy": "Устранение",
+    "parkIt": "Не ездить",
+    "parkOutside": "Парковать на улице",
+    "addFinding": "Добавить в наблюдения",
+    "findingAdded": "В наблюдениях",
+    "findingDescription": "Отзывная кампания {campaign}: {component}",
+    "refresh": "Обновить из источника",
+    "refreshed": "Данные безопасности обновлены",
+    "openSite": "Открыть на {source}",
+    "errorLoad": "Не удалось загрузить данные безопасности"
   }
 }

+ 14 - 1
messages/tr/integrations.json

@@ -51,7 +51,8 @@
     },
     "vehicle": {
       "lookup": "Plaka ve VIN sorgulama",
-      "inspection": "Muayene tarihleri"
+      "inspection": "Muayene tarihleri",
+      "safety": "Geri çağırmalar ve sahip şikâyetleri"
     },
     "ai": {
       "chat": "Metinler ve özetler"
@@ -514,6 +515,14 @@
         "pullPayments": "QuickBooks'ta kaydedilen ödemeleri geri al",
         "pullPaymentsHelp": "Her 30 dakikada bir. QuickBooks'ta bir Torqvoice faturası için alınan ödeme burada faturaya kaydedilir."
       }
+    },
+    "nhtsa": {
+      "description": "Bir aracı VIN'inden doldurun ve her model yılı için geri çağırmaları, sahip şikâyetlerini ve çarpışma testi puanlarını ABD güvenlik kurumundan görün. Ücretsiz, anahtarsız.",
+      "fields": {},
+      "settings": {
+        "refreshFleet": "Bu atölyedeki her araç için geri çağırma ve şikâyet verilerini güncel tut",
+        "refreshFleetHelp": "Günde bir kez, son bir haftada yenilenmemiş modeller yeniden çekilir; böylece araç sayfası hiç beklemez ve yeni bir geri çağırma bir hafta içinde görünür. Yalnızca marka, model ve yıl gönderilir."
+      }
     }
   },
   "jobKinds": {
@@ -532,6 +541,10 @@
       "payment": "Ödeme muhasebeye",
       "customer": "Müşteri muhasebeye",
       "pull": "Ödemeler muhasebeden"
+    },
+    "safety": {
+      "refresh": "Geri çağırma verisi yenileme",
+      "vehicle": "Yeni araç için geri çağırma kontrolü"
     }
   }
 }

+ 47 - 1
messages/tr/vehicles.json

@@ -360,7 +360,8 @@
     "lookupSource": "{source} kaynağından dolduruldu",
     "inspectionDue": "Sonraki periyodik muayene",
     "inspectionDueHint": "Bir sicil sağlamıyorsa tarihi yazın. Hatırlatmalar ve pano bunu kullanır.",
-    "inspectionDueFromRegistry": "Araç sicilinden alındı. Bir sonraki eşitlemeye kadar geçersiz kılmak için burada değiştirin."
+    "inspectionDueFromRegistry": "Araç sicilinden alındı. Bir sonraki eşitlemeye kadar geçersiz kılmak için burada değiştirin.",
+    "lookupEnterPlateOrVin": "Önce bir plaka veya VIN yazın"
   },
   "archiveDialog": {
     "title": "Aracı Arşivle",
@@ -520,5 +521,50 @@
     "emptyTitle": "Herhangi bir plakayı sorgulayın",
     "emptyBody": "Aracı, burada kime ait olduğunu ve {source} kaydında ne olduğunu görün. Henüz sizin değil mi? Tek adımda ekleyin.",
     "registryWord": "sicil"
+  },
+  "safety": {
+    "title": "Güvenlik ve bilinen sorunlar",
+    "summaryRecalls": "{count, plural, one {# geri çağırma} other {# geri çağırma}}",
+    "summaryComplaints": "{count, plural, one {# sahip şikâyeti} other {# sahip şikâyeti}}",
+    "expand": "Ayrıntıları göster",
+    "collapse": "Ayrıntıları gizle",
+    "loading": "Güvenlik kurumuna soruluyor...",
+    "from": "{model} için {source} kaynağından, {date} tarihinde güncellendi",
+    "stale": "{source} kaynağına ulaşılamadı; {date} tarihli rapor gösteriliyor",
+    "notKnown": "{source} kaynağında {model} için kayıt yok.",
+    "notKnownHint": "Marka, model ve yılı kontrol edin ya da modelin tam eşleşmesi için VIN ekleyin.",
+    "recalls": "Geri çağırmalar",
+    "recallsNone": "Geri çağırma kampanyası yok",
+    "complaints": "Sahip şikâyetleri",
+    "complaintsNone": "Şikâyet yok",
+    "crashes": "{count, plural, one {# kaza} other {# kaza}}",
+    "fires": "{count, plural, one {# yangın} other {# yangın}}",
+    "rating": "Çarpışma puanı",
+    "notRated": "Puanlanmamış",
+    "stars": "5 üzerinden {count} yıldız",
+    "frontal": "Önden",
+    "side": "Yandan",
+    "rollover": "Devrilme",
+    "ownersReport": "Sahiplerin bildirdikleri",
+    "ofComplaints": "{total} şikâyet içinde",
+    "crash": "Kaza",
+    "fire": "Yangın",
+    "noExamples": "Şikâyet metinleri henüz bu raporda yok. Getirmek için kaynaktan yenileyin.",
+    "readAll": "{count} şikâyetin tamamını {source} üzerinde oku",
+    "showRecalls": "{count, plural, one {# geri çağırmayı göster} other {# geri çağırmayı göster}}",
+    "hideRecalls": "Geri çağırmaları gizle",
+    "more": "Daha fazla",
+    "less": "Daha az",
+    "consequence": "Risk",
+    "remedy": "Çözüm",
+    "parkIt": "Sürmeyin",
+    "parkOutside": "Açık alana park edin",
+    "addFinding": "Gözlemlere ekle",
+    "findingAdded": "Gözlemlerde",
+    "findingDescription": "Geri çağırma {campaign}: {component}",
+    "refresh": "Kaynaktan yenile",
+    "refreshed": "Güvenlik verileri yenilendi",
+    "openSite": "{source} üzerinde aç",
+    "errorLoad": "Güvenlik verileri yüklenemedi"
   }
 }

+ 22 - 0
prisma/migrations/20260905090000_vehicle_safety_reports/migration.sql

@@ -0,0 +1,22 @@
+-- A cache of what a safety authority (NHTSA first) knows about one model
+-- year: recalls, owner complaints and crash ratings. Keyed by model, not by
+-- workshop, since the data is public and the same for every 2003 Accord.
+
+CREATE TABLE "vehicle_safety_reports" (
+    "id" TEXT NOT NULL,
+    "source" TEXT NOT NULL,
+    "make" TEXT NOT NULL,
+    "model" TEXT NOT NULL,
+    "year" INTEGER NOT NULL,
+    "found" BOOLEAN NOT NULL DEFAULT true,
+    "data" JSONB NOT NULL,
+    "recallCount" INTEGER NOT NULL DEFAULT 0,
+    "complaintCount" INTEGER NOT NULL DEFAULT 0,
+    "fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "vehicle_safety_reports_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "vehicle_safety_reports_source_make_model_year_key" ON "vehicle_safety_reports"("source", "make", "model", "year");
+CREATE INDEX "vehicle_safety_reports_fetchedAt_idx" ON "vehicle_safety_reports"("fetchedAt");

+ 26 - 0
prisma/schema/integrations.prisma

@@ -128,3 +128,29 @@ model ExternalCalendarEvent {
   @@index([organizationId, startAt])
   @@map("external_calendar_events")
 }
+
+/// What a safety authority knows about one model year: recalls, owner
+/// complaints and crash ratings, as the connector shaped them. Public data,
+/// keyed by model rather than by workshop or vehicle, so a 2003 Accord is
+/// fetched once for everyone. A cache: rebuilt by the connector, never edited.
+model VehicleSafetyReport {
+  id     String @id @default(cuid())
+  /// Connector id, such as "nhtsa".
+  source String
+  /// Make, model and year as the workshop wrote them, upper-cased and trimmed.
+  make   String
+  model  String
+  year   Int
+  /// False when the authority answered but had no such model.
+  found  Boolean @default(true)
+  data   Json
+  /// Counts lifted out of data so a list can show a badge without parsing it.
+  recallCount    Int      @default(0)
+  complaintCount Int      @default(0)
+  fetchedAt      DateTime @default(now())
+  createdAt      DateTime @default(now())
+
+  @@unique([source, make, model, year])
+  @@index([fetchedAt])
+  @@map("vehicle_safety_reports")
+}

+ 8 - 0
public/images/integrations/nhtsa.svg

@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="NHTSA">
+  <rect width="64" height="64" rx="12" fill="#1B3A6B"/>
+  <path d="M32 8 14 14v16c0 13.2 7.8 23.5 18 28 10.2-4.5 18-14.8 18-28V14L32 8z" fill="#FFFFFF"/>
+  <path d="M32 12.6 17.6 17.4V30c0 10.9 6.1 19.3 14.4 23.2C40.3 49.3 46.4 40.9 46.4 30V17.4L32 12.6z" fill="#1B3A6B"/>
+  <path d="M28.6 47 30.4 22h3.2L35.4 47z" fill="#FFFFFF" opacity="0.92"/>
+  <path d="M32 25v18" stroke="#1B3A6B" stroke-width="1.6" stroke-dasharray="3 2.4" stroke-linecap="round"/>
+  <path d="M22.5 23.5 32 19.5l9.5 4" fill="none" stroke="#E3262E" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 116 - 0
src/__tests__/features/integrations/fixtures/nhtsa/complaints-accord-2003.json

@@ -0,0 +1,116 @@
+{
+ "count": 5,
+ "message": "Results returned successfully",
+ "results": [
+  {
+   "odiNumber": 11746949,
+   "manufacturer": "Honda (American Honda Motor Co.)",
+   "crash": true,
+   "fire": false,
+   "numberOfInjuries": 1,
+   "numberOfDeaths": 0,
+   "dateOfIncident": "06/18/2026",
+   "dateComplaintFiled": "06/27/2026",
+   "vin": "JHMCM56323C",
+   "components": "AIR BAGS",
+   "summary": "The vehicle was rear-ended on I-5 in Seattle Washington.  The impact drove the subject car into the car in front of it. The front passenger airbag deployed (no ",
+   "products": [
+    {
+     "type": "Vehicle",
+     "productYear": "2003",
+     "productMake": "HONDA",
+     "productModel": "ACCORD",
+     "manufacturer": "Honda (American Honda Motor Co.)"
+    }
+   ]
+  },
+  {
+   "odiNumber": 11635240,
+   "manufacturer": "Honda (American Honda Motor Co.)",
+   "crash": true,
+   "fire": false,
+   "numberOfInjuries": 1,
+   "numberOfDeaths": 0,
+   "dateOfIncident": "12/12/2024",
+   "dateComplaintFiled": "01/10/2025",
+   "vin": "1HGCM81623A",
+   "components": "AIR BAGS",
+   "summary": "My 2003 Honda Accord Coupe was struck by an SUV/crossover, after the other driver ran a red light, and I tried, but was unable to avoid the accident.  My car wa",
+   "products": [
+    {
+     "type": "Vehicle",
+     "productYear": "2003",
+     "productMake": "HONDA",
+     "productModel": "ACCORD",
+     "manufacturer": "Honda (American Honda Motor Co.)"
+    }
+   ]
+  },
+  {
+   "odiNumber": 11746949,
+   "manufacturer": "Honda (American Honda Motor Co.)",
+   "crash": true,
+   "fire": false,
+   "numberOfInjuries": 1,
+   "numberOfDeaths": 0,
+   "dateOfIncident": "06/18/2026",
+   "dateComplaintFiled": "06/27/2026",
+   "vin": "JHMCM56323C",
+   "components": "AIR BAGS",
+   "summary": "The vehicle was rear-ended on I-5 in Seattle Washington.  The impact drove the subject car into the car in front of it. The front passenger airbag deployed (no ",
+   "products": [
+    {
+     "type": "Vehicle",
+     "productYear": "2003",
+     "productMake": "HONDA",
+     "productModel": "ACCORD",
+     "manufacturer": "Honda (American Honda Motor Co.)"
+    }
+   ]
+  },
+  {
+   "odiNumber": 11740104,
+   "manufacturer": "Honda (American Honda Motor Co.)",
+   "crash": false,
+   "fire": false,
+   "numberOfInjuries": 0,
+   "numberOfDeaths": 0,
+   "dateOfIncident": "05/22/2026",
+   "dateComplaintFiled": "05/26/2026",
+   "vin": "1HGCM66533A",
+   "components": "AIR BAGS",
+   "summary": "The contact owns a 2003 Honda Accord. The contact stated while attempting to start the vehicle, the contact noticed an unusual sound and the front passenger's s",
+   "products": [
+    {
+     "type": "Vehicle",
+     "productYear": "2003",
+     "productMake": "HONDA",
+     "productModel": "ACCORD",
+     "manufacturer": "Honda (American Honda Motor Co.)"
+    }
+   ]
+  },
+  {
+   "odiNumber": 11729508,
+   "manufacturer": "Honda (American Honda Motor Co.)",
+   "crash": false,
+   "fire": false,
+   "numberOfInjuries": 0,
+   "numberOfDeaths": 0,
+   "dateOfIncident": "04/02/2026",
+   "dateComplaintFiled": "04/06/2026",
+   "vin": "JHMCM56343C",
+   "components": "ELECTRICAL SYSTEM,AIR BAGS,UNKNOWN OR OTHER",
+   "summary": "I brought my vehicle to a Honda dealership for a safety recall repair (airbag/SRS related). After the repair, the SRS warning light appeared on my dashboard, in",
+   "products": [
+    {
+     "type": "Vehicle",
+     "productYear": "2003",
+     "productMake": "HONDA",
+     "productModel": "ACCORD",
+     "manufacturer": "Honda (American Honda Motor Co.)"
+    }
+   ]
+  }
+ ]
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/__tests__/features/integrations/fixtures/nhtsa/decode-1HGCM82633A004352.json


+ 176 - 0
src/__tests__/features/integrations/fixtures/nhtsa/models-honda-2003.json

@@ -0,0 +1,176 @@
+{
+ "count": 34,
+ "message": "Results returned successfully",
+ "results": [
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "CR-V"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "CIVIC"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "ODYSSEY"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "PILOT"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "ELEMENT"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "S2000"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "INSIGHT"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "ACCORD"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "CBR600RR"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "FSC600"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "SILVER WING"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "HONDA MOTORCYCLE"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "CB900F"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "VTX1800"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "V SERIES"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "GL1800"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "VTX1300"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "VT1100"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "VT750CD"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "GL1800A"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "RVT1000R"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "CBR1100XX"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "ST1300A"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "VFR800"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "VFR800A"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "GL1500C"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "NSS250"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "VFR800FI"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "ST1300"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "TRX650"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "CB250"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "CBR954RR"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "VT600C"
+  },
+  {
+   "modelYear": "2003",
+   "make": "HONDA",
+   "model": "CIVIC GX"
+  }
+ ]
+}

+ 1 - 0
src/__tests__/features/integrations/fixtures/nhtsa/rating-4738.json

@@ -0,0 +1 @@
+{"Count":1,"Message":"Results returned successfully","Results":[{"OverallRating":"Not Rated","OverallFrontCrashRating":"Not Rated","FrontCrashDriversideRating":"5","FrontCrashPassengersideRating":"5","FrontCrashPicture":"https://static.nhtsa.gov/crashTest/images/2003/03Accord4.jpg","FrontCrashVideo":"https://static.nhtsa.gov/crashTest/videos/2003/03Accord4.wmv","OverallSideCrashRating":"Not Rated","SideCrashDriversideRating":"Not Rated","SideCrashPassengersideRating":"Not Rated","combinedSideBarrierAndPoleRating-Front":"Not Rated","combinedSideBarrierAndPoleRating-Rear":"Not Rated","sideBarrierRating-Overall":"Not Rated","RolloverRating":"Not Rated","RolloverRating2":"4","RolloverPossibility":0.0,"RolloverPossibility2":0.0,"dynamicTipResult":" ","SidePoleCrashRating":"Not Rated","NHTSAElectronicStabilityControl":"No","NHTSAForwardCollisionWarning":"No","NHTSALaneDepartureWarning":"No","ComplaintsCount":2013,"RecallsCount":24,"InvestigationCount":5,"ModelYear":2003,"Make":"HONDA","Model":"ACCORD","VehicleDescription":"2003 Honda Accord 4-DR. w/SAB","VehicleId":4738}]}

+ 1 - 0
src/__tests__/features/integrations/fixtures/nhtsa/ratings-accord-2003.json

@@ -0,0 +1 @@
+{"Count":4,"Message":"Results returned successfully","Results":[{"VehicleDescription":"2003 Honda Accord 2-DR. w/SAB","VehicleId":4739},{"VehicleDescription":"2003 Honda Accord 4-DR. w/SAB","VehicleId":4738},{"VehicleDescription":"2003 Honda Accord 4-DR.","VehicleId":4737},{"VehicleDescription":"2003 Honda Accord 2-DR.","VehicleId":4736}]}

+ 53 - 0
src/__tests__/features/integrations/fixtures/nhtsa/recalls-accord-2003.json

@@ -0,0 +1,53 @@
+{
+ "Count": 3,
+ "Message": "Results returned successfully",
+ "results": [
+  {
+   "Manufacturer": "Honda (American Honda Motor Co.)",
+   "NHTSACampaignNumber": "19V182000",
+   "parkIt": false,
+   "parkOutSide": false,
+   "overTheAirUpdate": false,
+   "NHTSAActionNumber": "EA15001",
+   "ReportReceivedDate": "06/03/2019",
+   "Component": "AIR BAGS:FRONTAL:DRIVER SIDE:INFLATOR MODULE",
+   "Summary": "Honda (American Honda Motor Co.) is recalling specific 2003 Acura 3.2CL, 2013-2016 ILX, 2013-2014 ILX Hybrid, 2003-2006 MDX, 2007-2016 RDX, 2002-2003 3.2TL, 2004-2006, and 2009-2014 TL, 2010-2013 ZDX and 2001-2007 and 2009 Honda Accord, 2001-2005 Civic, 2003-2005 Civic Hybrid, 2001-2005 Civic GX NGV, 2002-2007 and 2010-2011 CR-V, 2003-2011 Element, 2007 Fit, 2002-2004 Odyssey, 2003-2008 Pilot, and 2006-2014 Ridgeline vehicles.  The affected vehicles received a replacement driver air bag inflator as part of a previous Takata inflator recall remedy or a replacement driver air bag module containing the same inflator type as a service part.  Due to a manufacturing error, in the event of a crash necessitating deployment of the driver frontal air bag, these inflators may explode.",
+   "Consequence": "An explosion of an inflator within the driver frontal air bag module may result in sharp metal fragments striking the driver, front seat passenger or other occupants resulting in serious injury or death.",
+   "Remedy": "Honda will notify owners, and dealers will replace the driver's air bag inflator with an alternate inflator, free of charge.  The recall began April 10, 2019.  Honda owners may contact customer service at 1-888-234-2138.  Honda's number for this recall is O41.  Acura owners may contact customer service at 1-888-234-2138.  Acura's number for this recall is U40.",
+   "Notes": "Owners may also contact the National Highway Traffic Safety Administration Vehicle Safety Hotline at 1-888-327-4236 (TTY 1-800-424-9153), or go to www.safercar.gov.",
+   "ModelYear": "2003",
+   "Make": "HONDA",
+   "Model": "ACCORD"
+  },
+  {
+   "Manufacturer": "Dorman Products, Inc.",
+   "NHTSACampaignNumber": "19E068000",
+   "ReportReceivedDate": "10/10/2019",
+   "Component": "VEHICLE SPEED CONTROL:ACCELERATOR PEDAL",
+   "Summary": "Dorman Products, Inc.  (Dorman) is recalling certain Accelerator Pedal Assemblies part numbers 699-114 and 825-5029-1, sold as replacement parts for 2003-2006 Acura MDX, 2004-2008 Acura TL and TSX, 2003-2007 Honda Accord, 2005-2006 Honda CR-V, 2007-2011 Honda Element, 2005-2008 Honda Pilot and 2006-2014 Honda Ridgeline vehicles.  The rotating portion of the accelerator pedal assembly may bind. As such, these vehicles fail to comply with the requirements of Federal Motor Vehicle Safety Standard (FMVSS) number 124, \"Accelerator Control Systems.\"",
+   "Consequence": "If the accelerator pedal binds, the engine may not quickly return to idle after the pedal is no longer pressed, increasing the risk of a crash.",
+   "Remedy": "Dorman will notify owners, and qualified service facilities will replace the affected accelerator pedal assemblies, free of charge.  The recall began December 9, 2019.  Owners may contact Dorman customer service at 1-800-523-2492, using option 5.  Dorman's number for this recall is AS1019XXXX.",
+   "Notes": "Owners may also contact the National Highway Traffic Safety Administration Vehicle Safety Hotline at 1-888-327-4236 (TTY 1-800-424-9153), or go to www.safercar.gov.",
+   "ModelYear": "2003",
+   "Make": "HONDA",
+   "Model": "ACCORD"
+  },
+  {
+   "Manufacturer": "Honda (American Honda Motor Co.)",
+   "NHTSACampaignNumber": "19V499000",
+   "parkIt": false,
+   "parkOutSide": false,
+   "overTheAirUpdate": false,
+   "NHTSAActionNumber": "EA15001",
+   "ReportReceivedDate": "27/06/2019",
+   "Component": "AIR BAGS:FRONTAL:DRIVER SIDE:INFLATOR MODULE",
+   "Summary": "Honda (American Honda Motor Co.) is recalling certain 2003 Acura 3.2CL, 2002-2003 3.2TL, 2003-2006 MDX, 2001-2007 Honda Accord, 2001-2005 Civic, 2003-2005 Civic Hybrid, 2001-2005 Civic GX NGV, 2002-2006 CR-V, 2003-2011 Element, 2002-2004 Odyssey, 2003-2008 Pilot and 2006 Ridgeline vehicles.  \r\n\r\nThese vehicles are equipped with driver frontal air bag inflators assembled as a recall remedy part or replacement service part, that may explode due to propellant degradation occurring after long-term exposure to high absolute humidity, temperature and temperature cycling.",
+   "Consequence": "An inflator explosion may result in sharp metal fragments striking the driver or other occupants resulting in serious injury or death.",
+   "Remedy": "Honda will notify owners, and dealers will replace the driver frontal air bag inflator, free of charge.  The recall began August 12, 2019.  Owners may contact Honda customer service at 1-888-234-2138.  Honda's numbers for this recall are Q5A and Y58.",
+   "Notes": "Owners may also contact the National Highway Traffic Safety Administration Vehicle Safety Hotline at 1-888-327-4236 (TTY 1-800-424-9153), or go to www.safercar.gov.",
+   "ModelYear": "2003",
+   "Make": "HONDA",
+   "Model": "ACCORD"
+  }
+ ]
+}

+ 312 - 0
src/__tests__/features/integrations/nhtsa.test.ts

@@ -0,0 +1,312 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it, vi } from 'vitest'
+import type { ConnectorContext } from '@/features/integrations/Lib/types'
+import { manifest } from '@/integrations/nhtsa/manifest'
+import {
+  type NhtsaComplaint,
+  type NhtsaRatingDetail,
+  type NhtsaRecall,
+  type VpicRow,
+  complaintComponents,
+  decodeFailed,
+  decodeToLookup,
+  fuelType,
+  humanizeComponent,
+  isoDate,
+  mapRating,
+  mapRecall,
+  matchModel,
+  pickVariant,
+  summarizeComplaints,
+} from '@/integrations/nhtsa/mapping'
+import { connector } from '@/integrations/nhtsa/server'
+
+/**
+ * Answers recorded from NHTSA's public services on 5 September 2026 for a
+ * 2003 Honda Accord: the vPIC decode of a VIN NHTSA uses in its own
+ * examples, three of its recalls, five of its complaints, the rated
+ * variants and one variant's ratings, and the models NHTSA files under
+ * Honda for that year. The component vocabulary and date formats the
+ * mapper reads were taken from those answers, not assumed.
+ */
+const FIXTURES = path.join(__dirname, 'fixtures/nhtsa')
+function fixture<T>(name: string): T {
+  return JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.json`), 'utf-8')) as T
+}
+const DECODE = fixture<{ Results: VpicRow[] }>('decode-1HGCM82633A004352').Results[0]
+const RECALLS = fixture<{ results: NhtsaRecall[] }>('recalls-accord-2003').results
+const COMPLAINTS = fixture<{ results: NhtsaComplaint[] }>('complaints-accord-2003').results
+const VARIANTS = fixture<{ Results: { VehicleDescription: string; VehicleId: number }[] }>(
+  'ratings-accord-2003'
+).Results
+const RATING = fixture<{ Results: NhtsaRatingDetail[] }>('rating-4738').Results[0]
+const MODELS = fixture<{ results: { model: string }[] }>('models-honda-2003').results.map(
+  (m) => m.model
+)
+
+describe('NHTSA VIN decode', () => {
+  it('fills the form from a clean decode', () => {
+    const result = decodeToLookup(DECODE, '1hgcm82633a004352')
+    expect(result).toEqual({
+      make: 'Honda',
+      model: 'Accord EX-V6',
+      year: 2003,
+      vin: '1HGCM82633A004352',
+      fuelType: 'gasoline',
+      transmission: 'automatic',
+      engineSize: '3.0',
+      engineCode: 'J30A4',
+      vehicleClass: 'Coupe',
+    })
+  })
+
+  it('treats a wrong check digit as decoded and invalid characters as not', () => {
+    expect(decodeFailed({ ...DECODE, ErrorCode: '1' })).toBe(false)
+    expect(decodeFailed({ ErrorCode: '1,5,14,400', Make: 'VOLKSWAGEN', Model: '' })).toBe(true)
+    expect(decodeFailed({ ErrorCode: '0', Make: '' })).toBe(true)
+    expect(decodeToLookup({ ErrorCode: '400', Make: 'X' }, 'X')).toBeNull()
+  })
+
+  it('names the fuel the way the form does', () => {
+    expect(fuelType({ FuelTypePrimary: 'Gasoline' })).toBe('gasoline')
+    expect(fuelType({ FuelTypePrimary: 'Diesel' })).toBe('diesel')
+    expect(
+      fuelType({
+        FuelTypePrimary: 'Gasoline',
+        FuelTypeSecondary: 'Electric',
+        ElectrificationLevel: 'Strong HEV (Hybrid Electric Vehicle)',
+      })
+    ).toBe('hybrid')
+    expect(
+      fuelType({
+        FuelTypePrimary: 'Electric',
+        ElectrificationLevel: 'BEV (Battery Electric Vehicle)',
+      })
+    ).toBe('electric')
+    expect(fuelType({ FuelTypePrimary: 'Compressed Natural Gas (CNG)' })).toBe('other')
+    expect(fuelType({ FuelTypePrimary: '' })).toBeUndefined()
+  })
+})
+
+describe('NHTSA recalls and complaints', () => {
+  it('maps a recall with its campaign, dates and driving advice', () => {
+    const recall = mapRecall(RECALLS[0])
+    expect(recall).toMatchObject({
+      campaign: '19V182000',
+      component: 'AIR BAGS:FRONTAL:DRIVER SIDE:INFLATOR MODULE',
+      // Written 06/03/2019 by the recall service, which is day first.
+      reported: '2019-03-06',
+      parkIt: false,
+      parkOutside: false,
+      manufacturer: 'Honda (American Honda Motor Co.)',
+    })
+    expect(recall?.remedy).toContain('free of charge')
+    expect(mapRecall({ Component: 'X' })).toBeNull()
+    expect(isoDate('27/06/2019', 'dmy')).toBe('2019-06-27')
+    expect(isoDate('06/27/2026', 'mdy')).toBe('2026-06-27')
+    expect(isoDate('27/06/2019', 'mdy')).toBeNull()
+    expect(isoDate('nope', 'dmy')).toBeNull()
+  })
+
+  it('splits the components of a complaint the way NHTSA joins them', () => {
+    expect(complaintComponents('SERVICE BRAKES, HYDRAULIC')).toEqual(['SERVICE BRAKES, HYDRAULIC'])
+    expect(complaintComponents('AIR BAGS,SEAT BELTS,ENGINE')).toEqual([
+      'AIR BAGS',
+      'SEAT BELTS',
+      'ENGINE',
+    ])
+    expect(complaintComponents('AIR BAGS,SERVICE BRAKES, HYDRAULIC')).toEqual([
+      'AIR BAGS',
+      'SERVICE BRAKES, HYDRAULIC',
+    ])
+    expect(complaintComponents(undefined)).toEqual([])
+  })
+
+  it('counts complaints per component with crashes and fires, newest first', () => {
+    const summary = summarizeComplaints(COMPLAINTS, 3, 2)
+    expect(summary.total).toBe(COMPLAINTS.length)
+    expect(summary.crashes).toBe(COMPLAINTS.filter((c) => c.crash).length)
+    expect(summary.fires).toBe(COMPLAINTS.filter((c) => c.fire).length)
+    expect(summary.byComponent.length).toBeLessThanOrEqual(3)
+    const shares = summary.byComponent.map((g) => g.share)
+    expect([...shares].sort((a, b) => b - a)).toEqual(shares)
+    expect(summary.byComponent[0].count).toBeGreaterThan(0)
+    expect(summary.latest).toHaveLength(2)
+    expect((summary.latest[0].date ?? '') >= (summary.latest[1].date ?? '')).toBe(true)
+  })
+
+  it('keeps the newest complaint texts per component so a bar can be opened', () => {
+    const summary = summarizeComplaints(COMPLAINTS, 8, 2)
+    const airBags = summary.byComponent.find((g) => g.component === 'AIR BAGS')
+    expect(airBags?.examples?.length).toBeGreaterThan(0)
+    expect(airBags?.examples?.length).toBeLessThanOrEqual(airBags?.count ?? 0)
+    const dates = airBags?.examples?.map((e) => e.date ?? '') ?? []
+    expect([...dates].sort().reverse()).toEqual(dates)
+    for (const e of airBags?.examples ?? []) {
+      expect(e.summary.length).toBeGreaterThan(0)
+      expect(e.summary.length).toBeLessThanOrEqual(400)
+      expect(typeof e.crash).toBe('boolean')
+    }
+  })
+
+  it('writes component paths for people', () => {
+    expect(humanizeComponent('AIR BAGS:FRONTAL:DRIVER SIDE:INFLATOR MODULE')).toBe(
+      'Air bags, frontal, driver side, inflator module'
+    )
+    expect(humanizeComponent('SERVICE BRAKES, HYDRAULIC:ANTILOCK')).toBe(
+      'Service brakes, hydraulic, antilock'
+    )
+    expect(humanizeComponent('ELECTRICAL SYSTEM')).toBe('Electrical system')
+  })
+})
+
+describe('NHTSA ratings and model matching', () => {
+  it('reads stars and leaves an unrated field null', () => {
+    expect(mapRating(RATING)).toEqual({
+      overall: null,
+      frontal: 5,
+      side: null,
+      rollover: 4,
+      description: '2003 Honda Accord 4-DR. w/SAB',
+    })
+    expect(mapRating({ OverallRating: 'Not Rated', VehicleDescription: 'x' })).toBeNull()
+  })
+
+  it('picks the rated variant that matches the body', () => {
+    expect(pickVariant(VARIANTS, { doors: '2' })?.VehicleDescription).toBe(
+      '2003 Honda Accord 2-DR. w/SAB'
+    )
+    expect(pickVariant(VARIANTS, { doors: '4' })?.VehicleDescription).toBe(
+      '2003 Honda Accord 4-DR. w/SAB'
+    )
+    expect(pickVariant(VARIANTS, {})?.VehicleId).toBe(VARIANTS[0].VehicleId)
+    expect(pickVariant([], { doors: '2' })).toBeNull()
+  })
+
+  it("finds the workshop's model name in NHTSA's list", () => {
+    expect(matchModel('Accord', MODELS)).toBe('ACCORD')
+    expect(matchModel('civic gx', MODELS)).toBe('CIVIC GX')
+    expect(matchModel('Accord EX-V6', MODELS)).toBe('ACCORD')
+    expect(matchModel('CR V', MODELS)).toBe('CR-V')
+    expect(matchModel('Corolla', MODELS)).toBeNull()
+    expect(matchModel('', MODELS)).toBeNull()
+  })
+})
+
+function context(routes: Record<string, unknown>) {
+  const urls: string[] = []
+  const json = vi.fn(async (url: string) => {
+    urls.push(url)
+    const hit = Object.entries(routes).find(([needle]) => url.includes(needle))
+    if (!hit) throw new Error(`unexpected ${url}`)
+    return hit[1]
+  })
+  const ctx = {
+    connection: {
+      id: 'c1',
+      organizationId: 'org',
+      connectorId: 'nhtsa',
+      settings: {},
+      state: {},
+      externalAccountId: null,
+    },
+    credentials: {},
+    http: { fetch: vi.fn(), json },
+    links: { get: vi.fn(), set: vi.fn(), remove: vi.fn(), remoteIds: vi.fn(), byRemoteId: vi.fn() },
+    log: vi.fn(async () => undefined),
+    saveState: vi.fn(),
+    timezone: 'America/New_York',
+    appUrl: 'https://shop.example.com',
+  } as unknown as ConnectorContext
+  return { ctx, urls }
+}
+
+describe('NHTSA connector', () => {
+  it('needs no key and joins the safety refresh like every safety source', () => {
+    expect(manifest.auth).toEqual({ type: 'api-key', fields: [] })
+    expect(manifest.capabilities).toContain('vehicle.lookup')
+    expect(manifest.capabilities).toContain('vehicle.safety')
+    expect(manifest.schedules?.[0].job).toBe('safety.refresh')
+    expect(connector.jobs['safety.refresh']).toBeTypeOf('function')
+    // A vehicle just added gets its report before anyone opens the page.
+    expect(manifest.subscriptions?.map((s) => `${s.event}>${s.job}`)).toEqual([
+      'vehicle.create>safety.vehicle',
+      'vehicle.update>safety.vehicle',
+    ])
+    expect(connector.jobs['safety.vehicle']).toBeTypeOf('function')
+  })
+
+  it('decodes a VIN and refuses a plate', async () => {
+    const { ctx, urls } = context({ DecodeVinValues: { Results: [DECODE] } })
+    const result = await connector.lookupVehicle?.(ctx, { vin: '1HGCM82633A004352' })
+    expect(result?.make).toBe('Honda')
+    expect(urls[0]).toBe(
+      'https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues/1HGCM82633A004352?format=json'
+    )
+    await expect(connector.lookupVehicle?.(ctx, { plate: 'ABC 123' })).rejects.toThrow(/VIN/)
+    await expect(connector.lookupVehicle?.(ctx, { vin: 'SHORT' })).rejects.toThrow(/17/)
+    expect(await connector.test(ctx)).toEqual({ ok: true })
+  })
+
+  it('builds the report from the VIN, the model list, recalls, complaints and ratings', async () => {
+    const { ctx, urls } = context({
+      DecodeVinValues: { Results: [DECODE] },
+      '/products/vehicle/models': { results: MODELS.map((model) => ({ model })) },
+      '/recalls/recallsByVehicle': { results: [...RECALLS, RECALLS[0]] },
+      '/complaints/complaintsByVehicle': { results: COMPLAINTS },
+      '/SafetyRatings/VehicleId/': { Results: [RATING] },
+      '/SafetyRatings/modelyear/': { Results: VARIANTS },
+    })
+    const report = await connector.vehicleSafety?.(ctx, {
+      make: 'Honda',
+      model: 'Accord',
+      year: 2003,
+      vin: '1HGCM82633A004352',
+    })
+    expect(report?.matched).toEqual({ make: 'HONDA', model: 'ACCORD', year: 2003 })
+    // Duplicated campaign counted once, newest first by the day-first recall dates.
+    expect(report?.recalls.map((r) => r.campaign)).toEqual(['19E068000', '19V499000', '19V182000'])
+    expect(report?.complaints.total).toBe(COMPLAINTS.length)
+    expect(report?.rating?.frontal).toBe(5)
+    expect(report?.url).toBe('https://www.nhtsa.gov/vehicle/2003/HONDA/ACCORD')
+    const recallsUrl = new URL(urls.find((u) => u.includes('recallsByVehicle')) as string)
+    expect(Object.fromEntries(recallsUrl.searchParams)).toEqual({
+      make: 'HONDA',
+      model: 'ACCORD',
+      modelYear: '2003',
+    })
+    // The coupe's VIN steers the rating to the 2-door variant.
+    expect(urls.some((u) => u.endsWith('/SafetyRatings/VehicleId/4739'))).toBe(true)
+  })
+
+  it('says so when NHTSA has no such model instead of showing zeros', async () => {
+    const { ctx, urls } = context({
+      '/products/vehicle/models': { results: MODELS.map((model) => ({ model })) },
+    })
+    const report = await connector.vehicleSafety?.(ctx, {
+      make: 'Honda',
+      model: 'Jazz',
+      year: 2003,
+    })
+    expect(report?.matched).toBeNull()
+    expect(report?.recalls).toEqual([])
+    expect(urls.some((u) => u.includes('recallsByVehicle'))).toBe(false)
+  })
+
+  it('keeps the recalls when the ratings service fails', async () => {
+    const { ctx } = context({
+      '/products/vehicle/models': { results: [{ model: 'ACCORD' }] },
+      '/recalls/recallsByVehicle': { results: RECALLS },
+      '/complaints/complaintsByVehicle': { results: [] },
+    })
+    const report = await connector.vehicleSafety?.(ctx, {
+      make: 'Honda',
+      model: 'Accord',
+      year: 2003,
+    })
+    expect(report?.recalls).toHaveLength(3)
+    expect(report?.rating).toBeNull()
+    expect(report?.complaints.total).toBe(0)
+  })
+})

+ 4 - 1
src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx

@@ -919,6 +919,9 @@ function ActivityCard({
   busy: string | null
 }) {
   const t = useTranslations('integrations.activity')
+  // Job kinds are shared by every connector, so their labels live at the
+  // namespace root rather than under the activity card.
+  const tk = useTranslations('integrations')
   const format = useFormatter()
   const when = (iso: string) =>
     format.dateTime(new Date(iso), { dateStyle: 'short', timeStyle: 'short' })
@@ -933,7 +936,7 @@ function ActivityCard({
             >
               <div className="min-w-0">
                 <span className="font-medium">
-                  {t.has(`jobKinds.${j.kind}`) ? t(`jobKinds.${j.kind}`) : j.kind}
+                  {tk.has(`jobKinds.${j.kind}`) ? tk(`jobKinds.${j.kind}`) : j.kind}
                 </span>
                 <span className="ml-2 text-xs text-muted-foreground">{when(j.createdAt)}</span>
                 {j.error && <p className="truncate text-xs text-destructive">{j.error}</p>}

+ 13 - 1
src/app/(authenticated)/vehicles/[id]/page.tsx

@@ -11,6 +11,7 @@ import { getTemplates } from '@/features/inspections/Actions/templateActions'
 import { getVehicleQuotes } from '@/features/quotes/Actions/quoteActions'
 import { getVehicleFindings } from '@/features/vehicles/Actions/findingActions'
 import { getFeatures } from '@/lib/features'
+import { findSafetyConnection } from '@/features/integrations/Lib/vehicle-safety'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
 import { getTireSetsForVehicle } from '@/features/tire-hotel/Actions/tireJobActions'
@@ -158,8 +159,9 @@ export default async function VehicleDetailPage({
   const authContext = await getAuthContext()
   const orgId = authContext?.organizationId
   let aiEnabled = false
+  let safetyAvailable = false
   if (orgId) {
-    const [features, aiSettings] = await Promise.all([
+    const [features, aiSettings, safetyConnection] = await Promise.all([
       getFeatures(orgId),
       db.appSetting.findMany({
         where: {
@@ -168,7 +170,16 @@ export default async function VehicleDetailPage({
         },
         select: { key: true, value: true },
       }),
+      // An integration must never take the vehicle page down with it: a
+      // failed lookup means no panel, nothing more.
+      findSafetyConnection(orgId).catch(() => null),
     ])
+    // Recall and complaint data needs a safety authority connected and a
+    // model to ask about; the panel is left out entirely otherwise.
+    safetyAvailable =
+      features?.integrations === true &&
+      safetyConnection !== null &&
+      Boolean(result.data.make && result.data.model && result.data.year)
     const aiMap = Object.fromEntries(aiSettings.map((s) => [s.key, s.value]))
     aiEnabled =
       features?.ai === true &&
@@ -198,6 +209,7 @@ export default async function VehicleDetailPage({
           }
           quotes={quotesResult.success && quotesResult.data ? quotesResult.data : []}
           aiEnabled={aiEnabled}
+          safetyAvailable={safetyAvailable}
           paginatedFindings={paginatedFindings}
           tireSets={tireSetsResult.success && tireSetsResult.data ? tireSetsResult.data : []}
         />

+ 15 - 0
src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx

@@ -25,6 +25,7 @@ import { VehicleForm } from '@/features/vehicles/Components/VehicleForm'
 import { NoteForm } from '@/features/vehicles/Components/NoteForm'
 import { ReminderForm } from '@/features/vehicles/Components/ReminderForm'
 import { FindingForm } from '@/features/vehicles/Components/FindingForm'
+import { VehicleSafetyPanel } from '@/features/vehicles/Components/VehicleSafetyPanel'
 import { ServiceRecordsTable } from './service-records-table'
 import { NotesTable } from './notes-table'
 import { FindingsTable } from './findings-table'
@@ -290,6 +291,7 @@ export function VehicleDetailClient({
   inspectionTemplates,
   quotes = [],
   aiEnabled = false,
+  safetyAvailable = false,
   paginatedFindings,
   tireSets = [],
 }: {
@@ -325,6 +327,8 @@ export function VehicleDetailClient({
   inspectionTemplates?: { id: string; name: string; isDefault: boolean }[]
   quotes?: QuoteRecord[]
   aiEnabled?: boolean
+  /** A safety authority such as NHTSA is connected and this vehicle has a model to ask about. */
+  safetyAvailable?: boolean
 }) {
   const formatCurrency = useFormatCurrency()
   const serviceType = useServiceType()
@@ -1215,6 +1219,17 @@ export function VehicleDetailClient({
         </Card>
       )}
 
+      {/* Recalls, owner complaints and crash rating for this model year. */}
+      {safetyAvailable && !isMarine && (
+        <VehicleSafetyPanel
+          vehicleId={vehicle.id}
+          make={vehicle.make}
+          model={vehicle.model}
+          year={vehicle.year}
+          existingFindings={paginatedFindings.records.map((f) => f.description)}
+        />
+      )}
+
       {/* Tabs */}
       <Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-4">
         <TabsList className="grid w-full grid-cols-6">

+ 54 - 0
src/features/integrations/Actions/vehicleSafetyActions.ts

@@ -0,0 +1,54 @@
+'use server'
+
+import { withAuth } from '@/lib/with-auth'
+import { getFeatures } from '@/lib/features'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import type { VehicleSafetyReport } from '../Lib/types'
+import { findSafetyConnection, vehicleSafetyReport } from '../Lib/vehicle-safety'
+
+const READ_VEHICLES = [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }]
+
+export interface VehicleSafetyView {
+  report: VehicleSafetyReport
+  /** Vendor's product name for the attribution line. */
+  source: string
+  fetchedAt: string
+  /** The authority could not be reached; this is the last report it gave. */
+  stale: boolean
+}
+
+/** Whether a vehicle page should show the safety panel at all. */
+export async function isVehicleSafetyAvailable() {
+  return withAuth(
+    async ({ organizationId }) => {
+      const features = await getFeatures(organizationId)
+      if (!features.integrations) return false
+      return (await findSafetyConnection(organizationId)) !== null
+    },
+    { requiredPermissions: READ_VEHICLES }
+  )
+}
+
+/**
+ * Recalls, complaints and ratings for one vehicle: from the weekly cache
+ * when it is fresh, from the authority otherwise. Null when the vehicle has
+ * no make, model and year to ask about, or no safety source is connected.
+ */
+export async function getVehicleSafety(vehicleId: string, options: { refresh?: boolean } = {}) {
+  return withAuth(
+    async ({ organizationId }): Promise<VehicleSafetyView | null> => {
+      const features = await getFeatures(organizationId)
+      if (!features.integrations) return null
+      const cached = await vehicleSafetyReport(organizationId, vehicleId, options)
+      if (!cached) return null
+      const { getManifest } = await import('@/integrations/registry')
+      return {
+        report: cached.report,
+        source: getManifest(cached.report.source)?.name ?? cached.report.source,
+        fetchedAt: cached.fetchedAt.toISOString(),
+        stale: cached.stale,
+      }
+    },
+    { requiredPermissions: READ_VEHICLES }
+  )
+}

+ 85 - 0
src/features/integrations/Lib/types.ts

@@ -239,6 +239,85 @@ export interface VehicleLookupResult {
   registered?: boolean
 }
 
+/** What a safety data source is asked about: the model, and the VIN when the vehicle has one. */
+export interface VehicleSafetyQuery {
+  make: string
+  model: string
+  year: number
+  vin?: string
+}
+
+export interface SafetyRecall {
+  /** The authority's campaign number, such as 19V182000. */
+  campaign: string
+  /** Component path as the authority files it, such as "AIR BAGS:FRONTAL:DRIVER SIDE". */
+  component: string
+  summary: string
+  consequence: string
+  remedy: string
+  /** ISO date the recall was reported. */
+  reported: string | null
+  /** The authority says not to drive the vehicle until it is fixed. */
+  parkIt: boolean
+  /** The authority says to park it outdoors, away from buildings: a fire risk. */
+  parkOutside: boolean
+  manufacturer: string
+}
+
+export interface SafetyComplaintExample {
+  date: string | null
+  summary: string
+  crash: boolean
+  fire: boolean
+}
+
+export interface SafetyComplaintGroup {
+  component: string
+  count: number
+  /** Share of all complaints, 0 to 1. */
+  share: number
+  /** The most recent few complaints naming this component, newest first, for reading on the spot. */
+  examples?: SafetyComplaintExample[]
+}
+
+export interface SafetyRating {
+  /** Stars 1 to 5, null when not rated. */
+  overall: number | null
+  frontal: number | null
+  side: number | null
+  rollover: number | null
+  /** The variant the rating is for, such as "2003 Honda Accord 4-DR. w/SAB". */
+  description: string
+}
+
+/**
+ * What an authority knows about one model year: open recall campaigns, what
+ * owners have complained about, and crash-test ratings. Public data, so the
+ * same report serves every vehicle of that model in every workshop.
+ */
+export interface VehicleSafetyReport {
+  /** Connector id. */
+  source: string
+  /** SAFETY_REPORT_VERSION at the time the connector built it. */
+  version?: number
+  /** The model as the authority names it, or null when it has no record of it. */
+  matched: { make: string; model: string; year: number } | null
+  recalls: SafetyRecall[]
+  complaints: {
+    total: number
+    crashes: number
+    fires: number
+    injuries: number
+    deaths: number
+    byComponent: SafetyComplaintGroup[]
+    /** The most recent few, newest first. */
+    latest: { date: string | null; component: string; summary: string }[]
+  }
+  rating: SafetyRating | null
+  /** Where a person can read the same on the authority's site. */
+  url: string
+}
+
 export interface ConnectorServer {
   manifest: ConnectorManifest
   /** After connecting: who the account is, shown on the connection page. */
@@ -262,6 +341,12 @@ export interface ConnectorServer {
     ctx: ConnectorContext,
     query: VehicleLookupQuery
   ): Promise<VehicleLookupResult | null>
+  /**
+   * Recalls, complaints and ratings for one model year, for connectors with
+   * the 'vehicle.safety' capability. Returns a report with matched null when
+   * the authority has no record of the model.
+   */
+  vehicleSafety?(ctx: ConnectorContext, query: VehicleSafetyQuery): Promise<VehicleSafetyReport>
   jobs: Record<string, JobHandler>
   /** Inbound webhook handling, where the vendor pushes. */
   webhook?: {

+ 33 - 0
src/features/integrations/Lib/vehicle-safety-contract.ts

@@ -0,0 +1,33 @@
+/**
+ * What a safety connector puts in its manifest to take part in the recall
+ * and complaint sync: the capability, the opt-in setting and the daily tick.
+ * Kept apart from the sync itself so a manifest, which the registry loads
+ * eagerly, never pulls in the database or the registry.
+ */
+
+export const SAFETY_CAPABILITY = 'vehicle.safety'
+export const SAFETY_JOB = 'safety.refresh'
+/** One vehicle, right after it is added or its model changes, so its page is ready when opened. */
+export const SAFETY_VEHICLE_JOB = 'safety.vehicle'
+export const SAFETY_SETTING = 'refreshFleet'
+/**
+ * Bumped when the report shape or a mapping rule changes; a cached report
+ * from an older version is refreshed on the next read rather than served.
+ */
+export const SAFETY_REPORT_VERSION = 2
+
+export const SAFETY_MANIFEST = {
+  capability: SAFETY_CAPABILITY,
+  setting: {
+    key: SAFETY_SETTING,
+    type: 'boolean' as const,
+    label: SAFETY_SETTING,
+    help: `${SAFETY_SETTING}Help`,
+    default: true,
+  },
+  schedule: { job: SAFETY_JOB, everyMinutes: 24 * 60 },
+  subscriptions: [
+    { event: 'vehicle.create', job: SAFETY_VEHICLE_JOB },
+    { event: 'vehicle.update', job: SAFETY_VEHICLE_JOB },
+  ],
+}

+ 304 - 0
src/features/integrations/Lib/vehicle-safety.ts

@@ -0,0 +1,304 @@
+import { db } from '@/lib/db'
+import { getManifest } from '@/integrations/registry'
+import { loadConnection } from './connections'
+import type { ConnectorContext, JobHandler, JobOutcome, VehicleSafetyReport } from './types'
+
+/**
+ * Recalls, owner complaints and crash ratings for the vehicles a workshop
+ * looks after, from whichever safety authority it has connected.
+ *
+ * The data is about a model year, not a car: every 2003 Accord in every
+ * workshop shares one report. So reports are cached by model, refreshed
+ * weekly, and a vehicle page reads the cache rather than the authority.
+ * Connectors declare the capability and answer `vehicleSafety`; nothing in
+ * a connector touches the cache.
+ */
+
+import {
+  SAFETY_CAPABILITY,
+  SAFETY_JOB,
+  SAFETY_MANIFEST,
+  SAFETY_REPORT_VERSION,
+  SAFETY_SETTING,
+  SAFETY_VEHICLE_JOB,
+} from './vehicle-safety-contract'
+
+export {
+  SAFETY_CAPABILITY,
+  SAFETY_JOB,
+  SAFETY_MANIFEST,
+  SAFETY_REPORT_VERSION,
+  SAFETY_SETTING,
+  SAFETY_VEHICLE_JOB,
+}
+
+/** A report older than this is fetched again when a page asks for it. */
+export const REPORT_TTL_DAYS = 7
+/** How many models the weekly pass refreshes per run before rescheduling. */
+const FLEET_BATCH = 40
+
+/** Keys as the cache stores them: what the workshop typed, upper-cased and squeezed. */
+export function modelKey(input: { make: string; model: string; year: number }) {
+  const squeeze = (s: string) => s.trim().replace(/\s+/g, ' ').toUpperCase()
+  return { make: squeeze(input.make), model: squeeze(input.model), year: input.year }
+}
+
+export async function findSafetyConnection(
+  organizationId: string
+): Promise<{ id: string; connectorId: string } | null> {
+  const rows = await db.integrationConnection.findMany({
+    where: { organizationId, status: 'active' },
+    select: { id: true, connectorId: true },
+  })
+  return (
+    rows.find((r) => getManifest(r.connectorId)?.capabilities.includes(SAFETY_CAPABILITY)) ?? null
+  )
+}
+
+export interface CachedSafetyReport {
+  report: VehicleSafetyReport
+  found: boolean
+  fetchedAt: Date
+  stale: boolean
+}
+
+function isStale(fetchedAt: Date, now = new Date()): boolean {
+  return now.getTime() - fetchedAt.getTime() > REPORT_TTL_DAYS * 86_400_000
+}
+
+async function readCache(
+  source: string,
+  key: ReturnType<typeof modelKey>
+): Promise<CachedSafetyReport | null> {
+  const row = await db.vehicleSafetyReport.findUnique({
+    where: { source_make_model_year: { source, ...key } },
+  })
+  if (!row) return null
+  const report = row.data as unknown as VehicleSafetyReport
+  return {
+    report,
+    found: row.found,
+    fetchedAt: row.fetchedAt,
+    stale: isStale(row.fetchedAt) || report.version !== SAFETY_REPORT_VERSION,
+  }
+}
+
+async function writeCache(
+  source: string,
+  key: ReturnType<typeof modelKey>,
+  report: VehicleSafetyReport
+): Promise<CachedSafetyReport> {
+  const data = JSON.parse(JSON.stringify(report)) as object
+  const shared = {
+    found: report.matched !== null,
+    data,
+    recallCount: report.recalls.length,
+    complaintCount: report.complaints.total,
+    fetchedAt: new Date(),
+  }
+  const row = await db.vehicleSafetyReport.upsert({
+    where: { source_make_model_year: { source, ...key } },
+    create: { source, ...key, ...shared },
+    update: shared,
+  })
+  return { report, found: row.found, fetchedAt: row.fetchedAt, stale: false }
+}
+
+/**
+ * Ask the connector and remember the answer. A failure with a report already
+ * cached returns the old one marked stale, so a page keeps showing what it
+ * had while the authority is down.
+ */
+async function fetchAndCache(
+  connectionId: string,
+  key: ReturnType<typeof modelKey>,
+  query: { make: string; model: string; year: number; vin?: string }
+): Promise<CachedSafetyReport> {
+  const { ctx, server } = await loadConnection(connectionId)
+  if (!server.vehicleSafety) throw new Error('This integration has no safety data')
+  const source = server.manifest.id
+  try {
+    const report = await server.vehicleSafety(ctx, query)
+    return await writeCache(source, key, report)
+  } catch (err) {
+    const cached = await readCache(source, key)
+    if (cached) {
+      await ctx.log(
+        'warn',
+        `Safety refresh failed, kept the report from ${cached.fetchedAt.toISOString().slice(0, 10)}: ${err instanceof Error ? err.message : String(err)}`
+      )
+      return { ...cached, stale: true }
+    }
+    throw err
+  }
+}
+
+/**
+ * The report for one of the workshop's vehicles: from the cache when fresh,
+ * from the authority otherwise. Null when no safety connection is active.
+ */
+export async function vehicleSafetyReport(
+  organizationId: string,
+  vehicleId: string,
+  options: { refresh?: boolean } = {}
+): Promise<CachedSafetyReport | null> {
+  const connection = await findSafetyConnection(organizationId)
+  if (!connection) return null
+  const vehicle = await db.vehicle.findFirst({
+    where: { id: vehicleId, organizationId },
+    select: { make: true, model: true, year: true, vin: true },
+  })
+  if (!vehicle || !vehicle.make.trim() || !vehicle.model.trim() || !vehicle.year) return null
+  const key = modelKey(vehicle)
+  if (!options.refresh) {
+    const cached = await readCache(connection.connectorId, key)
+    if (cached && !cached.stale) return cached
+  }
+  return fetchAndCache(connection.id, key, {
+    make: vehicle.make,
+    model: vehicle.model,
+    year: vehicle.year,
+    vin: vehicle.vin ?? undefined,
+  })
+}
+
+/**
+ * The weekly pass: every model the workshop has a live vehicle of gets a
+ * fresh report, so the vehicle page never waits on the authority and a new
+ * recall shows up within a week of being filed. Batched, and rescheduled
+ * until the whole fleet is covered.
+ */
+export async function refreshFleetReports(ctx: ConnectorContext): Promise<JobOutcome> {
+  if (ctx.connection.settings[SAFETY_SETTING] === false) return { summary: 'fleet refresh off' }
+  const server = (await loadConnection(ctx.connection.id)).server
+  if (!server.vehicleSafety) return { summary: 'no safety data' }
+  const source = server.manifest.id
+
+  const vehicles = await db.vehicle.findMany({
+    where: { organizationId: ctx.connection.organizationId, isArchived: false },
+    select: { make: true, model: true, year: true, vin: true },
+  })
+  const byKey = new Map<string, { make: string; model: string; year: number; vin?: string }>()
+  for (const v of vehicles) {
+    if (!v.make.trim() || !v.model.trim() || !v.year) continue
+    const key = modelKey(v)
+    const id = `${key.make}|${key.model}|${key.year}`
+    const entry = byKey.get(id)
+    if (!entry)
+      byKey.set(id, { make: v.make, model: v.model, year: v.year, vin: v.vin ?? undefined })
+    else if (!entry.vin && v.vin) entry.vin = v.vin
+  }
+  const cutoff = new Date(Date.now() - REPORT_TTL_DAYS * 86_400_000)
+  const fresh = await db.vehicleSafetyReport.findMany({
+    where: { source, fetchedAt: { gte: cutoff } },
+    select: { make: true, model: true, year: true, data: true },
+  })
+  const freshIds = new Set(
+    fresh
+      .filter((r) => (r.data as { version?: number }).version === SAFETY_REPORT_VERSION)
+      .map((r) => `${r.make}|${r.model}|${r.year}`)
+  )
+  const due = [...byKey.entries()].filter(([id]) => !freshIds.has(id))
+
+  let refreshed = 0
+  let recalls = 0
+  for (const [, query] of due.slice(0, FLEET_BATCH)) {
+    const key = modelKey(query)
+    try {
+      const report = await server.vehicleSafety(ctx, query)
+      await writeCache(source, key, report)
+      refreshed++
+      recalls += report.recalls.length
+    } catch (err) {
+      await ctx.log(
+        'warn',
+        `Could not refresh ${query.year} ${query.make} ${query.model}: ${err instanceof Error ? err.message : String(err)}`
+      )
+    }
+  }
+  const remaining = Math.max(0, due.length - FLEET_BATCH)
+  await ctx.saveState({
+    fleetModels: byKey.size,
+    fleetLastPassAt:
+      remaining === 0 ? new Date().toISOString() : ctx.connection.state.fleetLastPassAt,
+  })
+  return {
+    summary: `${refreshed} of ${byKey.size} models refreshed, ${recalls} recalls seen${remaining ? `, ${remaining} to go` : ''}`,
+    ...(remaining > 0 && { rescheduleInSeconds: 120 }),
+  }
+}
+
+/**
+ * A vehicle was just added, or its make, model or year changed: fetch its
+ * model's report now, so the front desk sees recalls the moment the page
+ * opens rather than a few seconds later. A model already fresh in the cache
+ * costs nothing.
+ */
+export async function warmVehicleReport(
+  ctx: ConnectorContext,
+  payload: Record<string, unknown>
+): Promise<JobOutcome> {
+  const vehicleId = typeof payload.entityId === 'string' ? payload.entityId : null
+  if (!vehicleId) return { summary: 'no vehicle id' }
+  const connection = await db.integrationConnection.findUnique({
+    where: { id: ctx.connection.id },
+    select: { connectorId: true },
+  })
+  if (!connection) return { summary: 'connection gone' }
+  const vehicle = await db.vehicle.findFirst({
+    where: { id: vehicleId, organizationId: ctx.connection.organizationId },
+    select: { make: true, model: true, year: true, vin: true },
+  })
+  if (!vehicle || !vehicle.make.trim() || !vehicle.model.trim() || !vehicle.year)
+    return { summary: 'vehicle has no model to ask about' }
+  const key = modelKey(vehicle)
+  const cached = await readCache(connection.connectorId, key)
+  if (cached && !cached.stale)
+    return { summary: `${key.year} ${key.make} ${key.model} already fresh` }
+  const result = await fetchAndCache(ctx.connection.id, key, {
+    make: vehicle.make,
+    model: vehicle.model,
+    year: vehicle.year,
+    vin: vehicle.vin ?? undefined,
+  })
+  const recalls = result.report.recalls.length
+  return {
+    summary: result.report.matched
+      ? `${key.year} ${key.make} ${key.model}: ${recalls} recalls, ${result.report.complaints.total} complaints`
+      : `${key.year} ${key.make} ${key.model}: not known to the source`,
+  }
+}
+
+/** The job map entries for a safety connector's server module. */
+export function safetyJobs(): Record<string, JobHandler> {
+  return { [SAFETY_JOB]: refreshFleetReports, [SAFETY_VEHICLE_JOB]: warmVehicleReport }
+}
+
+/**
+ * Open recalls per vehicle for a list, read from the cache only; a model
+ * never fetched simply has no badge. One query for the page, not one per row.
+ */
+export async function recallCountsFor(
+  organizationId: string,
+  vehicles: { id: string; make: string; model: string; year: number }[]
+): Promise<Map<string, number>> {
+  const out = new Map<string, number>()
+  if (vehicles.length === 0) return out
+  const connection = await findSafetyConnection(organizationId)
+  if (!connection) return out
+  const keys = vehicles.map((v) => modelKey(v))
+  const rows = await db.vehicleSafetyReport.findMany({
+    where: {
+      source: connection.connectorId,
+      OR: keys.map((k) => ({ make: k.make, model: k.model, year: k.year })),
+    },
+    select: { make: true, model: true, year: true, recallCount: true },
+  })
+  const counts = new Map(rows.map((r) => [`${r.make}|${r.model}|${r.year}`, r.recallCount]))
+  for (const v of vehicles) {
+    const k = modelKey(v)
+    const n = counts.get(`${k.make}|${k.model}|${k.year}`)
+    if (n) out.set(v.id, n)
+  }
+  return out
+}

+ 17 - 5
src/features/vehicles/Components/PlateLookupButton.tsx

@@ -15,6 +15,8 @@ import {
 interface PlateLookupButtonProps {
   /** The plate as typed right now; read when the button is pressed. */
   getPlate: () => string
+  /** The VIN as typed right now, for registries that answer to a VIN rather than a plate. */
+  getVin?: () => string
   /** Called with what the registry knows, for the form to apply. */
   onFound: (data: VehicleLookup) => void
   /** Set when editing, so the answer is also recorded on the vehicle. */
@@ -28,7 +30,12 @@ interface PlateLookupButtonProps {
  * scanner: three dialogs render this form and none should have to know which
  * registries exist.
  */
-export function PlateLookupButton({ getPlate, onFound, vehicleId }: PlateLookupButtonProps) {
+export function PlateLookupButton({
+  getPlate,
+  getVin,
+  onFound,
+  vehicleId,
+}: PlateLookupButtonProps) {
   const t = useTranslations('vehicles.form')
   const [busy, setBusy] = useState(false)
   /** null while the availability check is still in flight. */
@@ -46,14 +53,19 @@ export function PlateLookupButton({ getPlate, onFound, vehicleId }: PlateLookupB
 
   const handleClick = useCallback(async () => {
     const plate = getPlate().trim()
-    if (!plate) {
-      toast.error(t('lookupEnterPlate'))
+    const vin = getVin?.().trim() ?? ''
+    if (!plate && !vin) {
+      toast.error(getVin ? t('lookupEnterPlateOrVin') : t('lookupEnterPlate'))
       return
     }
     setBusy(true)
     const toastId = toast.loading(t('lookingUp'))
     try {
-      const result = await lookupVehicle({ plate, vehicleId })
+      const result = await lookupVehicle({
+        plate: plate || undefined,
+        vin: vin || undefined,
+        vehicleId,
+      })
       if (!result.success) {
         toast.error(result.error || t('lookupFailed'), { id: toastId })
         return
@@ -69,7 +81,7 @@ export function PlateLookupButton({ getPlate, onFound, vehicleId }: PlateLookupB
     } finally {
       setBusy(false)
     }
-  }, [getPlate, onFound, vehicleId, t])
+  }, [getPlate, getVin, onFound, vehicleId, t])
 
   return (
     <Tooltip>

+ 4 - 0
src/features/vehicles/Components/VehicleForm.tsx

@@ -623,6 +623,10 @@ export function VehicleForm({
                             ) as HTMLInputElement | null
                           )?.value ?? ''
                         }
+                        getVin={() =>
+                          (formRef.current?.elements.namedItem('vin') as HTMLInputElement | null)
+                            ?.value ?? ''
+                        }
                         onFound={applyLookup}
                         vehicleId={vehicle?.id}
                       />

+ 611 - 0
src/features/vehicles/Components/VehicleSafetyPanel.tsx

@@ -0,0 +1,611 @@
+'use client'
+
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import {
+  AlertOctagon,
+  Check,
+  ChevronDown,
+  ExternalLink,
+  Flame,
+  Loader2,
+  Plus,
+  RefreshCw,
+  ShieldAlert,
+  ShieldCheck,
+  Star,
+} from 'lucide-react'
+import { AppCard } from '@/components/app-card'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import {
+  getVehicleSafety,
+  type VehicleSafetyView,
+} from '@/features/integrations/Actions/vehicleSafetyActions'
+import { createFinding } from '@/features/vehicles/Actions/findingActions'
+import type { SafetyRecall } from '@/features/integrations/Lib/types'
+import { humanizeComponent } from '@/integrations/nhtsa/mapping'
+import { useFormatDate } from '@/lib/use-format-date'
+import { cn } from '@/lib/utils'
+
+interface VehicleSafetyPanelProps {
+  vehicleId: string
+  make: string
+  model: string
+  year: number
+  /** Observations already on the vehicle, so a recall added once shows as added. */
+  existingFindings?: string[]
+}
+
+/**
+ * What the safety authority knows about this model year, on the vehicle
+ * page: open recalls, what owners complain about most, and the crash
+ * rating. Reads the weekly cache, so it is instant for a model the workshop
+ * has seen before and a few seconds the first time.
+ *
+ * Built to be acted on rather than read: a recall becomes an observation on
+ * the vehicle with one click, so it reaches the job list and the customer.
+ */
+export function VehicleSafetyPanel({
+  vehicleId,
+  make,
+  model,
+  year,
+  existingFindings = [],
+}: VehicleSafetyPanelProps) {
+  const t = useTranslations('vehicles.safety')
+  const router = useRouter()
+  const { formatDate } = useFormatDate()
+  const [view, setView] = useState<VehicleSafetyView | null | undefined>(undefined)
+  const [failed, setFailed] = useState(false)
+  const [refreshing, setRefreshing] = useState(false)
+  const [expanded, setExpanded] = useState(false)
+  const [showRecalls, setShowRecalls] = useState(true)
+  const [showAllComponents, setShowAllComponents] = useState(false)
+  const [openComponent, setOpenComponent] = useState<string | null>(null)
+  const [added, setAdded] = useState<Set<string>>(() => new Set())
+  const [adding, setAdding] = useState<string | null>(null)
+
+  const load = useCallback(
+    async (refresh: boolean) => {
+      if (refresh) setRefreshing(true)
+      try {
+        const res = await getVehicleSafety(vehicleId, { refresh })
+        if (!res.success) {
+          setFailed(true)
+          if (refresh) toast.error(res.error || t('errorLoad'))
+          return
+        }
+        setFailed(false)
+        setView(res.data ?? null)
+        if (refresh) toast.success(t('refreshed'))
+      } catch {
+        setFailed(true)
+        if (refresh) toast.error(t('errorLoad'))
+      } finally {
+        setRefreshing(false)
+      }
+    },
+    [vehicleId, t]
+  )
+
+  useEffect(() => {
+    load(false)
+  }, [load])
+
+  const report = view?.report ?? null
+  const recalls = report?.recalls ?? []
+  const openRecalls = recalls.length
+  const urgent = recalls.some((r) => r.parkIt || r.parkOutside)
+  const modelLabel = report?.matched
+    ? `${report.matched.year} ${titleCase(report.matched.make)} ${titleCase(report.matched.model)}`
+    : `${year} ${make} ${model}`
+
+  const alreadyAdded = useMemo(() => {
+    const set = new Set(added)
+    for (const r of recalls) {
+      if (existingFindings.some((f) => f.includes(r.campaign))) set.add(r.campaign)
+    }
+    return set
+  }, [added, recalls, existingFindings])
+
+  const addFinding = async (recall: SafetyRecall) => {
+    setAdding(recall.campaign)
+    try {
+      const res = await createFinding({
+        vehicleId,
+        description: t('findingDescription', {
+          campaign: recall.campaign,
+          component: humanizeComponent(recall.component),
+        }),
+        severity: recall.parkIt || recall.parkOutside ? 'urgent' : 'needs_work',
+        notes: [recall.summary, recall.remedy ? `${t('remedy')}: ${recall.remedy}` : null]
+          .filter(Boolean)
+          .join('\n\n'),
+      })
+      if (!res.success) {
+        toast.error(res.error || t('errorLoad'))
+        return
+      }
+      setAdded((s) => new Set(s).add(recall.campaign))
+      toast.success(t('findingAdded'))
+      router.refresh()
+    } finally {
+      setAdding(null)
+    }
+  }
+
+  // The card disappears rather than apologising when the source cannot
+  // answer at all: a vehicle without make, model and year has nothing to
+  // ask about, and a failed first load is not worth a panel of its own.
+  if (view === null || (failed && view === undefined)) return null
+
+  const Icon = openRecalls > 0 ? ShieldAlert : ShieldCheck
+  const iconTone = openRecalls > 0 ? (urgent ? 'text-red-600' : 'text-amber-600') : undefined
+
+  return (
+    <AppCard
+      icon={Icon}
+      title={<span className={cn(iconTone)}>{t('title')}</span>}
+      badge={openRecalls || undefined}
+      description={
+        !report
+          ? t('loading')
+          : !expanded && report.matched
+            ? [
+                t('summaryRecalls', { count: openRecalls }),
+                t('summaryComplaints', { count: report.complaints.total }),
+                report.rating?.overall
+                  ? t('stars', { count: report.rating.overall })
+                  : t('notRated'),
+              ].join(' · ')
+            : view?.stale
+              ? t('stale', { date: formatDate(new Date(view.fetchedAt)) })
+              : t('from', {
+                  source: view?.source ?? '',
+                  model: modelLabel,
+                  date: formatDate(new Date(view?.fetchedAt ?? Date.now())),
+                })
+      }
+      action={
+        <div className="flex items-center gap-1">
+          {report && expanded && (
+            <Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs" asChild>
+              <a href={report.url} target="_blank" rel="noopener noreferrer">
+                <ExternalLink className="h-3.5 w-3.5" />
+                {t('openSite', { source: view?.source ?? '' })}
+              </a>
+            </Button>
+          )}
+          {expanded && (
+            <Tooltip>
+              <TooltipTrigger asChild>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  className="h-7 w-7 p-0"
+                  onClick={() => load(true)}
+                  disabled={refreshing || view === undefined}
+                  aria-label={t('refresh')}
+                >
+                  {refreshing ? (
+                    <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                  ) : (
+                    <RefreshCw className="h-3.5 w-3.5" />
+                  )}
+                </Button>
+              </TooltipTrigger>
+              <TooltipContent>{t('refresh')}</TooltipContent>
+            </Tooltip>
+          )}
+          <Button
+            variant="ghost"
+            size="sm"
+            className="h-7 gap-1 text-xs"
+            onClick={() => setExpanded((e) => !e)}
+            aria-expanded={expanded}
+            disabled={view === undefined}
+          >
+            {expanded ? t('collapse') : t('expand')}
+            <ChevronDown
+              className={cn('h-3.5 w-3.5 transition-transform', expanded && 'rotate-180')}
+            />
+          </Button>
+        </div>
+      }
+      contentClassName="p-0"
+    >
+      {!expanded ? null : view === undefined ? (
+        <div className="grid gap-3 p-4 sm:grid-cols-3">
+          <Skeleton className="h-16 rounded-lg" />
+          <Skeleton className="h-16 rounded-lg" />
+          <Skeleton className="h-16 rounded-lg" />
+        </div>
+      ) : !report?.matched ? (
+        <div className="px-4 py-5 text-sm text-muted-foreground">
+          <p>{t('notKnown', { source: view?.source ?? '', model: modelLabel })}</p>
+          <p className="mt-1 text-xs">{t('notKnownHint')}</p>
+        </div>
+      ) : (
+        <div className="divide-y">
+          {/* Three numbers at a glance. */}
+          <div className="grid gap-px bg-border sm:grid-cols-3">
+            <Stat
+              label={t('recalls')}
+              value={openRecalls}
+              tone={openRecalls > 0 ? (urgent ? 'danger' : 'warn') : 'good'}
+              hint={openRecalls === 0 ? t('recallsNone') : urgent ? t('parkIt') : undefined}
+            />
+            <Stat
+              label={t('complaints')}
+              value={report.complaints.total}
+              tone={report.complaints.total === 0 ? 'good' : 'neutral'}
+              hint={
+                report.complaints.total === 0
+                  ? t('complaintsNone')
+                  : [
+                      report.complaints.crashes > 0
+                        ? t('crashes', { count: report.complaints.crashes })
+                        : null,
+                      report.complaints.fires > 0
+                        ? t('fires', { count: report.complaints.fires })
+                        : null,
+                    ]
+                      .filter(Boolean)
+                      .join(' · ') || undefined
+              }
+            />
+            <div className="bg-card px-4 py-3">
+              <p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
+                {t('rating')}
+              </p>
+              {report.rating?.overall ? (
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <div
+                      className="mt-1 flex items-center gap-1"
+                      aria-label={t('stars', { count: report.rating.overall })}
+                    >
+                      {[1, 2, 3, 4, 5].map((n) => (
+                        <Star
+                          key={n}
+                          className={cn(
+                            'h-4 w-4',
+                            n <= (report.rating?.overall ?? 0)
+                              ? 'fill-amber-400 text-amber-400'
+                              : 'text-muted-foreground/30'
+                          )}
+                        />
+                      ))}
+                    </div>
+                  </TooltipTrigger>
+                  <TooltipContent className="max-w-xs text-xs">
+                    <p>{report.rating.description}</p>
+                    <p className="mt-1 text-muted-foreground">
+                      {[
+                        report.rating.frontal ? `${t('frontal')} ${report.rating.frontal}` : null,
+                        report.rating.side ? `${t('side')} ${report.rating.side}` : null,
+                        report.rating.rollover
+                          ? `${t('rollover')} ${report.rating.rollover}`
+                          : null,
+                      ]
+                        .filter(Boolean)
+                        .join(' · ')}
+                    </p>
+                  </TooltipContent>
+                </Tooltip>
+              ) : (
+                <p className="mt-1 text-sm text-muted-foreground">{t('notRated')}</p>
+              )}
+              {report.rating?.overall && (
+                <p className="mt-0.5 truncate text-xs text-muted-foreground">
+                  {report.rating.description}
+                </p>
+              )}
+            </div>
+          </div>
+
+          {/* What owners report: the workshop's diagnostic prior. Each bar
+              opens the newest complaints in that category, so the number
+              can be read as what people actually wrote. */}
+          {report.complaints.byComponent.length > 0 && (
+            <div className="px-4 py-3">
+              <div className="mb-2 flex items-baseline justify-between gap-2">
+                <h4 className="text-sm font-medium">{t('ownersReport')}</h4>
+                <span className="text-xs text-muted-foreground">
+                  {t('ofComplaints', { total: report.complaints.total })}
+                </span>
+              </div>
+              <ul className="space-y-1">
+                {(showAllComponents
+                  ? report.complaints.byComponent
+                  : report.complaints.byComponent.slice(0, 4)
+                ).map((g) => {
+                  const top = report.complaints.byComponent[0]?.share || 1
+                  const open = openComponent === g.component
+                  return (
+                    <li key={g.component}>
+                      <button
+                        type="button"
+                        className={cn(
+                          'grid w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 rounded-md px-1.5 py-1 text-left text-sm hover:bg-muted/50',
+                          open && 'bg-muted/50'
+                        )}
+                        onClick={() => setOpenComponent(open ? null : g.component)}
+                        aria-expanded={open}
+                      >
+                        <div className="min-w-0">
+                          <div className="flex items-baseline justify-between gap-2">
+                            <span className="truncate">{humanizeComponent(g.component)}</span>
+                            <span className="shrink-0 tabular-nums text-xs text-muted-foreground">
+                              {Math.round(g.share * 100)}%
+                            </span>
+                          </div>
+                          <div className="mt-1 h-1.5 overflow-hidden rounded-full bg-muted">
+                            <div
+                              className="h-full rounded-full bg-primary/70"
+                              style={{ width: `${Math.max(4, (g.share / top) * 100)}%` }}
+                            />
+                          </div>
+                        </div>
+                        <span className="w-10 text-right tabular-nums text-xs text-muted-foreground">
+                          {g.count}
+                        </span>
+                        <ChevronDown
+                          className={cn(
+                            'h-3.5 w-3.5 text-muted-foreground transition-transform',
+                            open && 'rotate-180'
+                          )}
+                        />
+                      </button>
+                      {open && (
+                        <div className="mb-2 ml-1.5 mt-1 border-l-2 border-primary/30 pl-3">
+                          {g.examples && g.examples.length > 0 ? (
+                            <ul className="space-y-2">
+                              {g.examples.map((e, i) => (
+                                <li key={`${e.date ?? ''}-${i}`} className="text-sm">
+                                  <p className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
+                                    {safeDate(e.date, formatDate) && (
+                                      <span>{safeDate(e.date, formatDate)}</span>
+                                    )}
+                                    {e.crash && (
+                                      <Badge
+                                        variant="outline"
+                                        className="border-red-500/40 text-[10px] text-red-600"
+                                      >
+                                        {t('crash')}
+                                      </Badge>
+                                    )}
+                                    {e.fire && (
+                                      <Badge
+                                        variant="outline"
+                                        className="border-red-500/40 text-[10px] text-red-600"
+                                      >
+                                        {t('fire')}
+                                      </Badge>
+                                    )}
+                                  </p>
+                                  <p className="mt-0.5 text-muted-foreground">{e.summary}</p>
+                                </li>
+                              ))}
+                            </ul>
+                          ) : (
+                            <p className="text-xs text-muted-foreground">{t('noExamples')}</p>
+                          )}
+                          <a
+                            href={`${report.url}#complaints`}
+                            target="_blank"
+                            rel="noopener noreferrer"
+                            className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
+                          >
+                            <ExternalLink className="h-3 w-3" />
+                            {t('readAll', { count: g.count, source: view?.source ?? '' })}
+                          </a>
+                        </div>
+                      )}
+                    </li>
+                  )
+                })}
+              </ul>
+              {report.complaints.byComponent.length > 4 && (
+                <button
+                  type="button"
+                  className="mt-2 text-xs text-muted-foreground hover:text-foreground"
+                  onClick={() => setShowAllComponents((s) => !s)}
+                >
+                  {showAllComponents ? t('less') : t('more')}
+                </button>
+              )}
+            </div>
+          )}
+
+          {/* Recalls, each one click from the job list. */}
+          {openRecalls > 0 && (
+            <div>
+              <button
+                type="button"
+                className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium hover:bg-muted/40"
+                onClick={() => setShowRecalls((s) => !s)}
+                aria-expanded={showRecalls}
+              >
+                <span className="flex items-center gap-2">
+                  <AlertOctagon
+                    className={cn('h-4 w-4', urgent ? 'text-red-600' : 'text-amber-600')}
+                  />
+                  {showRecalls ? t('hideRecalls') : t('showRecalls', { count: openRecalls })}
+                </span>
+                <ChevronDown
+                  className={cn(
+                    'h-4 w-4 text-muted-foreground transition-transform',
+                    showRecalls && 'rotate-180'
+                  )}
+                />
+              </button>
+              {showRecalls && (
+                <ul className="divide-y border-t">
+                  {recalls.map((r) => (
+                    <RecallRow
+                      key={r.campaign}
+                      recall={r}
+                      added={alreadyAdded.has(r.campaign)}
+                      adding={adding === r.campaign}
+                      onAdd={() => addFinding(r)}
+                      formatDate={formatDate}
+                      t={t}
+                    />
+                  ))}
+                </ul>
+              )}
+            </div>
+          )}
+        </div>
+      )}
+    </AppCard>
+  )
+}
+
+function Stat({
+  label,
+  value,
+  tone,
+  hint,
+}: {
+  label: string
+  value: number
+  tone: 'good' | 'warn' | 'danger' | 'neutral'
+  hint?: string
+}) {
+  const color =
+    tone === 'danger'
+      ? 'text-red-600'
+      : tone === 'warn'
+        ? 'text-amber-600'
+        : tone === 'good'
+          ? 'text-emerald-600'
+          : ''
+  return (
+    <div className="bg-card px-4 py-3">
+      <p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
+        {label}
+      </p>
+      <p className={cn('mt-0.5 text-2xl font-semibold tabular-nums leading-tight', color)}>
+        {value.toLocaleString()}
+      </p>
+      {hint && <p className="mt-0.5 truncate text-xs text-muted-foreground">{hint}</p>}
+    </div>
+  )
+}
+
+function RecallRow({
+  recall,
+  added,
+  adding,
+  onAdd,
+  formatDate,
+  t,
+}: {
+  recall: SafetyRecall
+  added: boolean
+  adding: boolean
+  onAdd: () => void
+  formatDate: (d: Date) => string
+  t: ReturnType<typeof useTranslations<'vehicles.safety'>>
+}) {
+  const [open, setOpen] = useState(false)
+  const flagged = recall.parkIt || recall.parkOutside
+  return (
+    <li className="px-4 py-3">
+      <div className="flex items-start justify-between gap-3">
+        <div className="min-w-0">
+          <div className="flex flex-wrap items-center gap-1.5">
+            <p className="text-sm font-medium">{humanizeComponent(recall.component)}</p>
+            {recall.parkIt && (
+              <Badge variant="destructive" className="gap-1 text-[10px]">
+                <AlertOctagon className="h-3 w-3" />
+                {t('parkIt')}
+              </Badge>
+            )}
+            {recall.parkOutside && (
+              <Badge variant="destructive" className="gap-1 text-[10px]">
+                <Flame className="h-3 w-3" />
+                {t('parkOutside')}
+              </Badge>
+            )}
+          </div>
+          <p className="mt-0.5 text-xs text-muted-foreground">
+            {[safeDate(recall.reported, formatDate), recall.campaign, recall.manufacturer]
+              .filter(Boolean)
+              .join(' · ')}
+          </p>
+        </div>
+        <Button
+          variant={added ? 'ghost' : 'outline'}
+          size="sm"
+          className="h-7 shrink-0 gap-1.5 text-xs"
+          onClick={onAdd}
+          disabled={added || adding}
+        >
+          {adding ? (
+            <Loader2 className="h-3.5 w-3.5 animate-spin" />
+          ) : added ? (
+            <Check className="h-3.5 w-3.5 text-emerald-600" />
+          ) : (
+            <Plus className="h-3.5 w-3.5" />
+          )}
+          {added ? t('findingAdded') : t('addFinding')}
+        </Button>
+      </div>
+      <p className={cn('mt-2 text-sm text-muted-foreground', !open && 'line-clamp-2')}>
+        {recall.summary}
+      </p>
+      {open && (
+        <div className="mt-2 space-y-2 text-sm">
+          {recall.consequence && (
+            <p>
+              <span className={cn('font-medium', flagged && 'text-red-600')}>
+                {t('consequence')}:{' '}
+              </span>
+              <span className="text-muted-foreground">{recall.consequence}</span>
+            </p>
+          )}
+          {recall.remedy && (
+            <p>
+              <span className="font-medium">{t('remedy')}: </span>
+              <span className="text-muted-foreground">{recall.remedy}</span>
+            </p>
+          )}
+        </div>
+      )}
+      <button
+        type="button"
+        className="mt-1 text-xs text-muted-foreground hover:text-foreground"
+        onClick={() => setOpen((o) => !o)}
+      >
+        {open ? t('less') : t('more')}
+      </button>
+    </li>
+  )
+}
+
+/** A stored date, formatted, or nothing when it cannot be read. */
+function safeDate(value: string | null, formatDate: (d: Date) => string): string | null {
+  if (!value) return null
+  const time = Date.parse(value)
+  return Number.isNaN(time) ? null : formatDate(new Date(time))
+}
+
+function titleCase(value: string): string {
+  return value
+    .toLowerCase()
+    .split(/(\s+|-)/)
+    .map((w) =>
+      w.length <= 3 && /^[a-z0-9]+$/.test(w)
+        ? w.toUpperCase()
+        : w.charAt(0).toUpperCase() + w.slice(1)
+    )
+    .join('')
+}

+ 27 - 0
src/integrations/nhtsa/manifest.ts

@@ -0,0 +1,27 @@
+import { SAFETY_MANIFEST } from '@/features/integrations/Lib/vehicle-safety-contract'
+import type { ConnectorManifest } from '@/features/integrations/Lib/types'
+
+/**
+ * NHTSA, the US National Highway Traffic Safety Administration, and the
+ * data it publishes free and without a key: the vPIC VIN decoder, every
+ * recall campaign, every owner complaint filed, and the NCAP crash ratings.
+ *
+ * Two things for a workshop. A VIN fills the vehicle form, for any make sold
+ * in the US and most sold elsewhere, since the decoder knows the world's
+ * manufacturer codes. And a vehicle page shows what is known about that
+ * model year: open recalls, what owners complain about most, and how it
+ * scored in crash tests. No plate lookup: NHTSA holds no registrations.
+ */
+export const manifest: ConnectorManifest = {
+  id: 'nhtsa',
+  name: 'NHTSA (USA)',
+  category: 'registry',
+  countries: ['US'],
+  logo: '/images/integrations/nhtsa.svg',
+  docs: '/docs/integrations/nhtsa',
+  auth: { type: 'api-key', fields: [] },
+  capabilities: ['vehicle.lookup', SAFETY_MANIFEST.capability],
+  settings: [SAFETY_MANIFEST.setting],
+  subscriptions: SAFETY_MANIFEST.subscriptions,
+  schedules: [SAFETY_MANIFEST.schedule],
+}

+ 388 - 0
src/integrations/nhtsa/mapping.ts

@@ -0,0 +1,388 @@
+/**
+ * NHTSA's answers in the app's vocabulary.
+ *
+ * Pure functions over the JSON the three services return, so the shapes can
+ * be tested against recorded answers. The decoder writes empty strings for
+ * what it does not know; the recall and complaint services write dates as
+ * MM/DD/YYYY and file components as upper-case paths.
+ */
+
+import { makeCase } from '@/features/integrations/Lib/make-case'
+import type {
+  SafetyComplaintExample,
+  SafetyComplaintGroup,
+  SafetyRating,
+  SafetyRecall,
+  VehicleLookupResult,
+} from '@/features/integrations/Lib/types'
+
+export const VPIC_URL = 'https://vpic.nhtsa.dot.gov/api/vehicles'
+export const API_URL = 'https://api.nhtsa.gov'
+export const SITE_URL = 'https://www.nhtsa.gov'
+
+/** Decoded VIN, one flat row of strings; only the fields the app reads. */
+export interface VpicRow {
+  ErrorCode?: string
+  ErrorText?: string
+  Make?: string
+  Model?: string
+  ModelYear?: string
+  Trim?: string
+  BodyClass?: string
+  Doors?: string
+  FuelTypePrimary?: string
+  FuelTypeSecondary?: string
+  ElectrificationLevel?: string
+  TransmissionStyle?: string
+  DisplacementL?: string
+  EngineModel?: string
+  EngineCylinders?: string
+  VehicleType?: string
+  PlantCountry?: string
+}
+
+export interface NhtsaRecall {
+  NHTSACampaignNumber?: string
+  Manufacturer?: string
+  Component?: string
+  Summary?: string
+  Consequence?: string
+  Remedy?: string
+  Notes?: string
+  ReportReceivedDate?: string
+  parkIt?: boolean
+  parkOutSide?: boolean
+  Make?: string
+  Model?: string
+  ModelYear?: string
+}
+
+export interface NhtsaComplaint {
+  odiNumber?: number
+  components?: string
+  summary?: string
+  crash?: boolean
+  fire?: boolean
+  numberOfInjuries?: number
+  numberOfDeaths?: number
+  dateOfIncident?: string
+  dateComplaintFiled?: string
+}
+
+export interface NhtsaRatingVariant {
+  VehicleDescription?: string
+  VehicleId?: number
+}
+
+export interface NhtsaRatingDetail {
+  OverallRating?: string
+  OverallFrontCrashRating?: string
+  FrontCrashDriversideRating?: string
+  FrontCrashPassengersideRating?: string
+  OverallSideCrashRating?: string
+  SideCrashDriversideRating?: string
+  SideCrashPassengersideRating?: string
+  RolloverRating?: string
+  /** The rating under the newer rollover test, filled when RolloverRating is not. */
+  RolloverRating2?: string
+  VehicleDescription?: string
+  VehiclePicture?: string
+}
+
+function text(value: unknown): string | undefined {
+  if (typeof value !== 'string') return undefined
+  const t = value.trim()
+  return t && t !== 'Not Applicable' ? t : undefined
+}
+
+/**
+ * Decoder error codes that mean the VIN itself is bad, as opposed to codes
+ * that only say a few positions are unknown. 0 is clean; 1 a wrong check
+ * digit, which is still decoded; 400 invalid characters; and 6, 7, 8 and
+ * 11 are VINs the decoder cannot place at all.
+ */
+const FATAL_DECODE_CODES = new Set(['6', '7', '8', '11', '400'])
+
+export function decodeFailed(row: VpicRow): boolean {
+  const codes = (row.ErrorCode ?? '0').split(',').map((c) => c.trim())
+  if (codes.some((c) => FATAL_DECODE_CODES.has(c))) return true
+  return !text(row.Make)
+}
+
+/** The form's fuel word from the decoder's primary and secondary fuels. */
+export function fuelType(row: VpicRow): string | undefined {
+  const primary = text(row.FuelTypePrimary)?.toLowerCase()
+  const secondary = text(row.FuelTypeSecondary)?.toLowerCase()
+  const level = text(row.ElectrificationLevel)?.toLowerCase() ?? ''
+  if (!primary) return undefined
+  const electric = (s: string | undefined) => Boolean(s && /electric/.test(s))
+  if (electric(primary) && !secondary)
+    return level.includes('bev') || !level ? 'electric' : 'hybrid'
+  if (electric(secondary) || /hev|hybrid|phev/.test(level)) return 'hybrid'
+  if (/diesel/.test(primary)) return 'diesel'
+  if (/gasoline|ethanol|flexible|e85|cng|lpg|propane|natural gas/.test(primary)) {
+    return /cng|lpg|propane|natural gas/.test(primary) ? 'other' : 'gasoline'
+  }
+  if (/hydrogen|fuel cell/.test(primary)) return 'other'
+  return 'other'
+}
+
+export function transmission(row: VpicRow): string | undefined {
+  const style = text(row.TransmissionStyle)?.toLowerCase()
+  if (!style) return undefined
+  if (/cvt|continuously/.test(style)) return 'cvt'
+  if (/manual/.test(style)) return 'manual'
+  if (/automatic|automated|dual clutch|dct|amt/.test(style)) return 'automatic'
+  return undefined
+}
+
+/** Litres to one decimal, as the form shows engine size: "3.0". */
+export function engineSize(row: VpicRow): string | undefined {
+  const litres = Number(text(row.DisplacementL))
+  if (!Number.isFinite(litres) || litres <= 0) return undefined
+  return (Math.round(litres * 10) / 10).toFixed(1)
+}
+
+export function decodeToLookup(row: VpicRow, vin: string): VehicleLookupResult | null {
+  if (decodeFailed(row)) return null
+  const year = Number(text(row.ModelYear))
+  const model = [text(row.Model), text(row.Trim)].filter(Boolean).join(' ')
+  return {
+    make: text(row.Make) ? makeCase(text(row.Make) as string) : undefined,
+    model: model || undefined,
+    year: Number.isFinite(year) && year > 1900 ? year : undefined,
+    vin: vin.toUpperCase(),
+    fuelType: fuelType(row),
+    transmission: transmission(row),
+    engineSize: engineSize(row),
+    engineCode: text(row.EngineModel),
+    vehicleClass: text(row.BodyClass) ?? text(row.VehicleType),
+  }
+}
+
+/**
+ * NHTSA's two services disagree on dates: recalls are written day first
+ * (27/06/2019) and complaints month first (06/27/2026), checked against
+ * live answers on 5 September 2026. Each caller says which it is reading.
+ */
+export function isoDate(value: string | undefined, order: 'dmy' | 'mdy'): string | null {
+  const m = value ? /^(\d{1,2})\/(\d{1,2})\/(\d{4})/.exec(value.trim()) : null
+  if (!m) return null
+  const [day, month] = order === 'dmy' ? [m[1], m[2]] : [m[2], m[1]]
+  if (Number(month) < 1 || Number(month) > 12 || Number(day) < 1 || Number(day) > 31) return null
+  return `${m[3]}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`
+}
+
+/**
+ * "AIR BAGS:FRONTAL:DRIVER SIDE:INFLATOR MODULE" as a person would say it:
+ * "Air bags, frontal, driver side, inflator module". Initials and the
+ * authority's slash pairs are kept as written.
+ */
+export function humanizeComponent(value: string): string {
+  const sentence = value
+    .split(':')
+    .map((p) => p.trim().toLowerCase())
+    .filter(Boolean)
+    .join(', ')
+  return sentence
+    .replace(/^[a-z]/, (c) => c.toUpperCase())
+    .replace(/\b(abs|esc|ecu|led|hid|srs|tpms|ac|cv|lpg|cng|awd|4wd|fwd|rwd)\b/g, (s) =>
+      s.toUpperCase()
+    )
+}
+
+export function mapRecall(r: NhtsaRecall): SafetyRecall | null {
+  const campaign = text(r.NHTSACampaignNumber)
+  if (!campaign) return null
+  return {
+    campaign,
+    component: text(r.Component) ?? 'UNKNOWN',
+    summary: text(r.Summary) ?? '',
+    consequence: text(r.Consequence) ?? '',
+    remedy: text(r.Remedy) ?? '',
+    reported: isoDate(r.ReportReceivedDate, 'dmy'),
+    parkIt: r.parkIt === true,
+    parkOutside: r.parkOutSide === true,
+    manufacturer: text(r.Manufacturer) ?? '',
+  }
+}
+
+/**
+ * A complaint names one or more components. One component may itself carry a
+ * comma and a space ("SERVICE BRAKES, HYDRAULIC"); several are joined with a
+ * bare comma ("AIR BAGS,SEAT BELTS"). Split only on the bare one.
+ */
+export function complaintComponents(value: string | undefined): string[] {
+  if (!value) return []
+  return value
+    .split(/,(?=\S)/)
+    .map((c) => c.trim())
+    .filter(Boolean)
+}
+
+export interface ComplaintSummary {
+  total: number
+  crashes: number
+  fires: number
+  injuries: number
+  deaths: number
+  byComponent: SafetyComplaintGroup[]
+  latest: { date: string | null; component: string; summary: string }[]
+}
+
+/** How many complaint texts each component keeps for reading in the panel. */
+export const EXAMPLES_PER_COMPONENT = 5
+const EXAMPLE_MAX_CHARS = 400
+
+export function summarizeComplaints(rows: NhtsaComplaint[], top = 8, latest = 5): ComplaintSummary {
+  const counts = new Map<string, number>()
+  const examples = new Map<string, SafetyComplaintExample[]>()
+  let crashes = 0
+  let fires = 0
+  let injuries = 0
+  let deaths = 0
+  const dated = rows.map((c) => ({
+    row: c,
+    date: isoDate(c.dateComplaintFiled, 'mdy') ?? isoDate(c.dateOfIncident, 'mdy'),
+    components: complaintComponents(c.components),
+  }))
+  // Newest first, so the first few per component are the ones worth reading.
+  dated.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''))
+  for (const { row: c, date, components } of dated) {
+    if (c.crash) crashes++
+    if (c.fire) fires++
+    injuries += Number(c.numberOfInjuries) || 0
+    deaths += Number(c.numberOfDeaths) || 0
+    for (const component of components) {
+      counts.set(component, (counts.get(component) ?? 0) + 1)
+      const list = examples.get(component) ?? []
+      const summary = text(c.summary)
+      if (list.length < EXAMPLES_PER_COMPONENT && summary) {
+        list.push({
+          date,
+          summary: summary.slice(0, EXAMPLE_MAX_CHARS),
+          crash: c.crash === true,
+          fire: c.fire === true,
+        })
+        examples.set(component, list)
+      }
+    }
+  }
+  const total = rows.length
+  const byComponent = [...counts.entries()]
+    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+    .slice(0, top)
+    .map(([component, count]) => ({
+      component,
+      count,
+      share: total > 0 ? Math.round((count / total) * 1000) / 1000 : 0,
+      examples: examples.get(component) ?? [],
+    }))
+  const newest = dated.slice(0, latest).map(({ row: c, date, components }) => ({
+    date,
+    component: components.join(', ') || 'UNKNOWN',
+    summary: (text(c.summary) ?? '').slice(0, EXAMPLE_MAX_CHARS),
+  }))
+  return { total, crashes, fires, injuries, deaths, byComponent, latest: newest }
+}
+
+function stars(value: string | undefined): number | null {
+  const n = Number(text(value))
+  return Number.isFinite(n) && n >= 1 && n <= 5 ? n : null
+}
+
+/** The overall figure when NHTSA gives one, otherwise the lower of the two seats. */
+function combined(
+  overall: string | undefined,
+  driver: string | undefined,
+  passenger: string | undefined
+) {
+  const o = stars(overall)
+  if (o !== null) return o
+  const sides = [stars(driver), stars(passenger)].filter((n): n is number => n !== null)
+  return sides.length ? Math.min(...sides) : null
+}
+
+export function mapRating(detail: NhtsaRatingDetail): SafetyRating | null {
+  const rating: SafetyRating = {
+    overall: stars(detail.OverallRating),
+    frontal: combined(
+      detail.OverallFrontCrashRating,
+      detail.FrontCrashDriversideRating,
+      detail.FrontCrashPassengersideRating
+    ),
+    side: combined(
+      detail.OverallSideCrashRating,
+      detail.SideCrashDriversideRating,
+      detail.SideCrashPassengersideRating
+    ),
+    rollover: stars(detail.RolloverRating) ?? stars(detail.RolloverRating2),
+    description: text(detail.VehicleDescription) ?? '',
+  }
+  if (
+    rating.overall === null &&
+    rating.frontal === null &&
+    rating.side === null &&
+    rating.rollover === null
+  )
+    return null
+  return rating
+}
+
+/**
+ * Which rated variant is this car: a coupe wants the 2-door row, a wagon
+ * the wagon row. Falls back to the first variant when nothing distinguishes.
+ */
+export function pickVariant(
+  variants: NhtsaRatingVariant[],
+  hints: { doors?: string; bodyClass?: string }
+): NhtsaRatingVariant | null {
+  if (variants.length === 0) return null
+  const doors = Number(hints.doors)
+  const body = (hints.bodyClass ?? '').toLowerCase()
+  const score = (v: NhtsaRatingVariant) => {
+    const d = (v.VehicleDescription ?? '').toLowerCase()
+    let s = 0
+    if (doors === 2 && /2-dr|2 dr|coupe/.test(d)) s += 2
+    if (doors >= 4 && /4-dr|4 dr|sedan/.test(d)) s += 2
+    if (body.includes('wagon') && /wagon/.test(d)) s += 2
+    if (body.includes('convertible') && /conv/.test(d)) s += 2
+    if (/w\/sab|side air/.test(d)) s += 1
+    return s
+  }
+  return [...variants].sort((a, b) => score(b) - score(a))[0]
+}
+
+/**
+ * The model name NHTSA files the recalls under, from the list it publishes
+ * for the make and year. Exact first, then the workshop's word as a prefix
+ * or contained ("Civic" for "CIVIC GX", "3 Series" for "328I" fails and is
+ * left for the person). Null means no near match.
+ */
+export function matchModel(wanted: string, available: string[]): string | null {
+  const norm = (s: string) =>
+    s
+      .toUpperCase()
+      .replace(/[^A-Z0-9]+/g, ' ')
+      .trim()
+  const target = norm(wanted)
+  if (!target) return null
+  const list = available.map((m) => ({ raw: m, norm: norm(m) }))
+  const exact = list.find((m) => m.norm === target)
+  if (exact) return exact.raw
+  const first = target.split(' ')[0]
+  const prefixed = list.filter((m) => m.norm.startsWith(target) || target.startsWith(m.norm))
+  if (prefixed.length > 0) {
+    prefixed.sort(
+      (a, b) => Math.abs(a.norm.length - target.length) - Math.abs(b.norm.length - target.length)
+    )
+    return prefixed[0].raw
+  }
+  const byFirstWord = list.filter((m) => m.norm.split(' ')[0] === first && first.length >= 3)
+  if (byFirstWord.length === 1) return byFirstWord[0].raw
+  return null
+}
+
+export function siteUrl(make: string, model: string, year: number): string {
+  return `${SITE_URL}/vehicle/${year}/${encodeURIComponent(make.toUpperCase())}/${encodeURIComponent(model.toUpperCase())}`
+}

+ 211 - 0
src/integrations/nhtsa/server.ts

@@ -0,0 +1,211 @@
+import {
+  type ConnectorContext,
+  ConnectorHttpError,
+  type ConnectorServer,
+  type SafetyRating,
+  type VehicleLookupQuery,
+  type VehicleLookupResult,
+  type VehicleSafetyQuery,
+  type VehicleSafetyReport,
+} from '@/features/integrations/Lib/types'
+import { safetyJobs } from '@/features/integrations/Lib/vehicle-safety'
+import { SAFETY_REPORT_VERSION } from '@/features/integrations/Lib/vehicle-safety-contract'
+import { manifest } from './manifest'
+import {
+  API_URL,
+  type NhtsaComplaint,
+  type NhtsaRatingDetail,
+  type NhtsaRatingVariant,
+  type NhtsaRecall,
+  VPIC_URL,
+  type VpicRow,
+  decodeToLookup,
+  mapRating,
+  mapRecall,
+  matchModel,
+  pickVariant,
+  siteUrl,
+  summarizeComplaints,
+} from './mapping'
+
+/**
+ * Three NHTSA services, all public, all JSON, none needing a key:
+ *
+ * - vPIC decodes a VIN into make, model, year, engine and body.
+ * - api.nhtsa.gov lists recalls and owner complaints by make, model and
+ *   model year, and NCAP ratings per rated variant.
+ * - api.nhtsa.gov also publishes the model names it files under, which is
+ *   how "Civic" typed in a form finds "CIVIC" in the recall database.
+ *
+ * The services answer 200 with an empty list for a model they do not know,
+ * so "not found" is decided from the model list, not from an error.
+ */
+
+const VPIC_ERROR_TEXT = 'NHTSA could not decode that VIN'
+
+async function get<T>(ctx: ConnectorContext, url: string): Promise<T> {
+  try {
+    return await ctx.http.json<T>(url, { headers: { Accept: 'application/json' } })
+  } catch (err) {
+    if (err instanceof ConnectorHttpError) throw new Error(`NHTSA answered ${err.status}`)
+    throw err
+  }
+}
+
+async function decodeVin(ctx: ConnectorContext, vin: string): Promise<VpicRow | null> {
+  const res = await get<{ Results?: VpicRow[] }>(
+    ctx,
+    `${VPIC_URL}/DecodeVinValues/${encodeURIComponent(vin)}?format=json`
+  )
+  return res.Results?.[0] ?? null
+}
+
+async function lookupVehicle(
+  ctx: ConnectorContext,
+  query: VehicleLookupQuery
+): Promise<VehicleLookupResult | null> {
+  const vin = query.vin?.trim().toUpperCase()
+  if (!vin) throw new Error('NHTSA looks up by VIN, not by plate; type the VIN and try again')
+  if (vin.length !== 17) throw new Error('A VIN has 17 characters')
+  const row = await decodeVin(ctx, vin)
+  if (!row) throw new Error(VPIC_ERROR_TEXT)
+  return decodeToLookup(row, vin)
+}
+
+async function models(ctx: ConnectorContext, make: string, year: number): Promise<string[]> {
+  const url = new URL(`${API_URL}/products/vehicle/models`)
+  url.searchParams.set('modelYear', String(year))
+  url.searchParams.set('make', make)
+  url.searchParams.set('issueType', 'c')
+  const res = await get<{ results?: { model?: string }[] }>(ctx, url.toString())
+  const names = new Set<string>()
+  for (const r of res.results ?? []) if (r.model) names.add(r.model)
+  return [...names]
+}
+
+async function recalls(ctx: ConnectorContext, make: string, model: string, year: number) {
+  const url = new URL(`${API_URL}/recalls/recallsByVehicle`)
+  url.searchParams.set('make', make)
+  url.searchParams.set('model', model)
+  url.searchParams.set('modelYear', String(year))
+  const res = await get<{ results?: NhtsaRecall[] }>(ctx, url.toString())
+  return res.results ?? []
+}
+
+async function complaints(ctx: ConnectorContext, make: string, model: string, year: number) {
+  const url = new URL(`${API_URL}/complaints/complaintsByVehicle`)
+  url.searchParams.set('make', make)
+  url.searchParams.set('model', model)
+  url.searchParams.set('modelYear', String(year))
+  const res = await get<{ results?: NhtsaComplaint[] }>(ctx, url.toString())
+  return res.results ?? []
+}
+
+async function rating(
+  ctx: ConnectorContext,
+  make: string,
+  model: string,
+  year: number,
+  hints: { doors?: string; bodyClass?: string }
+): Promise<SafetyRating | null> {
+  const list = await get<{ Results?: NhtsaRatingVariant[] }>(
+    ctx,
+    `${API_URL}/SafetyRatings/modelyear/${year}/make/${encodeURIComponent(make)}/model/${encodeURIComponent(model)}`
+  )
+  const variant = pickVariant(list.Results ?? [], hints)
+  if (!variant?.VehicleId) return null
+  const detail = await get<{ Results?: NhtsaRatingDetail[] }>(
+    ctx,
+    `${API_URL}/SafetyRatings/VehicleId/${variant.VehicleId}`
+  )
+  const row = detail.Results?.[0]
+  return row ? mapRating(row) : null
+}
+
+/**
+ * The report for one model year. The VIN, when there is one, settles the
+ * make and model in NHTSA's own words and tells the ratings which variant
+ * this car is; without it the workshop's spelling is matched against the
+ * models NHTSA lists for that make and year.
+ */
+async function vehicleSafety(
+  ctx: ConnectorContext,
+  query: VehicleSafetyQuery
+): Promise<VehicleSafetyReport> {
+  let make = query.make.trim()
+  let model = query.model.trim()
+  const year = query.year
+  const hints: { doors?: string; bodyClass?: string } = {}
+
+  const vin = query.vin?.trim().toUpperCase()
+  if (vin && vin.length === 17) {
+    try {
+      const row = await decodeVin(ctx, vin)
+      if (row?.Make && row.Model) {
+        make = row.Make
+        model = row.Model
+        hints.doors = row.Doors
+        hints.bodyClass = row.BodyClass
+      }
+    } catch {
+      // The decoder being down is no reason to skip the recalls.
+    }
+  }
+
+  const known = await models(ctx, make, year)
+  const matched = matchModel(model, known)
+  if (!matched) {
+    return {
+      source: manifest.id,
+      version: SAFETY_REPORT_VERSION,
+      matched: null,
+      recalls: [],
+      complaints: {
+        total: 0,
+        crashes: 0,
+        fires: 0,
+        injuries: 0,
+        deaths: 0,
+        byComponent: [],
+        latest: [],
+      },
+      rating: null,
+      url: siteUrl(make, model, year),
+    }
+  }
+
+  const [recallRows, complaintRows, stars] = await Promise.all([
+    recalls(ctx, make, matched, year),
+    complaints(ctx, make, matched, year),
+    rating(ctx, make, matched, year, hints).catch(() => null),
+  ])
+  const seen = new Set<string>()
+  const recallList = recallRows
+    .map(mapRecall)
+    .filter((r): r is NonNullable<typeof r> => r !== null)
+    .filter((r) => (seen.has(r.campaign) ? false : (seen.add(r.campaign), true)))
+    .sort((a, b) => (b.reported ?? '').localeCompare(a.reported ?? ''))
+
+  return {
+    source: manifest.id,
+    version: SAFETY_REPORT_VERSION,
+    matched: { make: make.toUpperCase(), model: matched, year },
+    recalls: recallList,
+    complaints: summarizeComplaints(complaintRows),
+    rating: stars,
+    url: siteUrl(make, matched, year),
+  }
+}
+
+export const connector: ConnectorServer = {
+  manifest,
+  async test(ctx) {
+    // A decode of a VIN NHTSA itself uses in its examples: proves the
+    // service is up without touching anything a workshop owns.
+    const row = await decodeVin(ctx, '1HGCM82633A004352')
+    return row?.Make ? { ok: true } : { ok: false, message: VPIC_ERROR_TEXT }
+  },
+  lookupVehicle,
+  vehicleSafety,
+  jobs: safetyJobs(),
+}

+ 2 - 0
src/integrations/registry.ts

@@ -13,6 +13,7 @@ import { manifest as anthropic } from './anthropic/manifest'
 import { manifest as googleCalendar } from './google-calendar/manifest'
 import { manifest as mailgun } from './mailgun/manifest'
 import { manifest as microsoft365 } from './microsoft-365/manifest'
+import { manifest as nhtsa } from './nhtsa/manifest'
 import { manifest as openai } from './openai/manifest'
 import { manifest as openapiAutomotive } from './openapi-automotive/manifest'
 import { manifest as paypal } from './paypal/manifest'
@@ -49,6 +50,7 @@ const ENTRIES: readonly RegistryEntry[] = [
   { manifest: openapiAutomotive, load: () => import('./openapi-automotive/server') },
   { manifest: rdw, load: () => import('./rdw/server') },
   { manifest: regcheck, load: () => import('./regcheck/server') },
+  { manifest: nhtsa, load: () => import('./nhtsa/server') },
   { manifest: twilioSms, load: () => import('./twilio-sms/server') },
   { manifest: vonageSms, load: () => import('./vonage-sms/server') },
   { manifest: telnyxSms, load: () => import('./telnyx-sms/server') },

Некоторые файлы не были показаны из-за большого количества измененных файлов