Sfoglia il codice sorgente

Pin each inspection to the scale it was graded on (#247)

* bumped packages

* next

* Pin each inspection to the scale it was graded on

The defect scale lived only on the template and was read live, so a workshop
that edited an old template and switched it to the EU scale would silently
relabel every inspection ever made from it: a Fail recorded last year would
start reading Major defect on a certificate already handed to a customer.

The scale and country are now snapshotted onto the inspection when it is
created, and every read prefers the snapshot. Inspections that predate this
fall back to the template, which the earlier migration pinned to its old
three-step scale, so history stays as it was recorded either way.

Additive and backfilled; nothing is rewritten.

* test
Bernt Christian Egeland 1 mese fa
parent
commit
383de1afc3

+ 18 - 0
prisma/migrations/20260816090000_inspection_scale_snapshot/migration.sql

@@ -0,0 +1,18 @@
+-- Pins each inspection to the defect scale it was actually graded on.
+--
+-- The scale lived only on the template and was read live, so a workshop that
+-- edited an old template and switched it to the EU scale would silently
+-- relabel every inspection ever made from it: a "Fail" recorded in 2024 would
+-- start reading "Major defect" on a certificate that had already been issued.
+--
+-- Additive and idempotent; safe to re-run.
+
+ALTER TABLE "inspections" ADD COLUMN IF NOT EXISTS "severityScale" TEXT;
+ALTER TABLE "inspections" ADD COLUMN IF NOT EXISTS "country" TEXT;
+
+-- Backfill from the template as it stands today, which for anything predating
+-- the EU scale is the plain three-step scale the earlier migration pinned.
+UPDATE "inspections" i
+SET "severityScale" = t."severityScale", "country" = t."country"
+FROM "inspection_templates" t
+WHERE i."templateId" = t."id" AND i."severityScale" IS NULL;

+ 8 - 0
prisma/schema.prisma

@@ -1100,6 +1100,14 @@ model Inspection {
   // Roadworthiness certificate fields required by Directive 2014/45/EU Annex IV.
   // `inspectorName` and `certificateNumber` are snapshots taken at completion so
   // the issued certificate stays stable even if staff or settings change later.
+  /// Snapshot of how this inspection was graded, taken when it was created.
+  /// Read in preference to the template's current setting: a workshop that
+  /// later switches a template to the EU scale must not relabel a certificate
+  /// it already issued. Null on inspections that predate the snapshot; those
+  /// fall back to the template, which the migration pinned to its old scale.
+  severityScale String?
+  country       String?
+
   vehicleCategory   String?
   nextTestDue       DateTime?
   certificateNumber String?

+ 173 - 0
src/__tests__/features/inspections/legacy-inspection-compat.test.tsx

@@ -0,0 +1,173 @@
+/**
+ * Upgrade safety for inspections recorded before the overhaul.
+ *
+ * The fixtures here are deliberately built from the v1.2.38 schema and nothing
+ * else: every column that tag's `InspectionItem` and `Inspection` models
+ * actually had, and not one field added since. Anything the new code needs
+ * beyond that is either absent or null, exactly as it will be on a workshop's
+ * database the moment they deploy.
+ *
+ * The point is to catch the failure mode that a fixture written today cannot:
+ * new rendering quietly depending on a column that older rows have no value
+ * for. Copying the current fixture and nulling a few fields would drift as the
+ * model grows; pinning to the old shape does not.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+
+vi.mock("lucide-react", async (importOriginal) => {
+  const actual = await importOriginal<Record<string, unknown>>();
+  const Icon = () => <span data-testid="icon" />;
+  return new Proxy(
+    { ...actual },
+    {
+      get: (target, prop: string) =>
+        prop === "__esModule" ? true : prop in target ? Icon : undefined,
+    }
+  );
+});
+
+vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+
+vi.mock("@/components/ui/button", () => ({
+  Button: ({
+    children,
+    onClick,
+    disabled,
+    ...props
+  }: React.HTMLAttributes<HTMLButtonElement> & { disabled?: boolean; variant?: string }) => (
+    <button onClick={onClick} disabled={disabled} {...props}>
+      {children}
+    </button>
+  ),
+}));
+
+vi.mock("@/features/inspections/Components/QuoteRequestDialog", () => ({
+  QuoteRequestDialog: () => null,
+}));
+
+import { InspectionView } from "@/app/(public)/share/inspection/[orgId]/[token]/inspection-view";
+import { deriveTestResult, conditionGrade } from "@/features/inspections/Lib/conditions";
+
+/**
+ * An item with exactly the v1.2.38 columns: id, name, section, sortOrder,
+ * condition, notes, imageUrls. No code, sectionCode, inputType, defaultSeverity,
+ * measuredValue, unit, required or photoRequired.
+ */
+const legacyItem = (over: Partial<Record<string, unknown>> = {}) => ({
+  id: "legacy-item",
+  name: "Brake Pads",
+  section: "Brakes",
+  sortOrder: 1,
+  condition: "fail",
+  notes: "Worn down to 2mm",
+  imageUrls: [] as string[],
+  ...over,
+});
+
+/**
+ * An inspection as the backfill leaves it: the old columns, plus the scale
+ * snapshot pinned to `basic` because every pre-overhaul template was flipped
+ * there by 20260815120000. Every other new column is null.
+ */
+const LEGACY_INSPECTION = {
+  id: "insp-legacy",
+  status: "completed",
+  mileage: 82000,
+  notes: null,
+  completedAt: new Date("2025-11-02"),
+  createdAt: new Date("2025-11-02"),
+  severityScale: "basic",
+  country: null,
+  vehicle: {
+    make: "Ford",
+    model: "F-150",
+    year: 2021,
+    vin: "FORD123",
+    licensePlate: "TR-001",
+    mileage: 82000,
+    customer: { name: "Dave Owner", email: "dave@example.com", phone: "555-3333" },
+  },
+  template: { name: "Full Inspection", severityScale: "basic", country: null },
+  items: [
+    legacyItem({ id: "l-pass", name: "Oil Level", section: "Engine", condition: "pass", notes: null }),
+    legacyItem(),
+    legacyItem({ id: "l-att", name: "Tire Tread", section: "Tires", condition: "attention", notes: null }),
+  ],
+};
+
+const PROPS = {
+  inspection: LEGACY_INSPECTION,
+  workshop: { name: "Quality Auto", address: "5 Shop Lane", phone: "555-2222", email: "qa@example.com" },
+  logoUrl: "",
+  primaryColor: "#d97706",
+  showTorqvoiceBranding: false,
+  publicToken: "pub-tok-legacy",
+  orgId: "org-1",
+  hasExistingQuoteRequest: false,
+};
+
+beforeEach(() => {
+  vi.resetAllMocks();
+  vi.stubGlobal("fetch", vi.fn());
+  vi.stubGlobal("open", vi.fn());
+});
+
+describe("inspections recorded before the overhaul", () => {
+  it("renders a legacy report without throwing", () => {
+    render(<InspectionView {...PROPS} />);
+    expect(screen.getByText("Quality Auto")).toBeInTheDocument();
+    expect(screen.getByText("2021 Ford F-150")).toBeInTheDocument();
+  });
+
+  it("keeps the wording the certificate was issued with", () => {
+    render(<InspectionView {...PROPS} />);
+    // The plain scale the report was created under, not the EU relabelling.
+    expect(screen.getAllByText("Attention").length).toBeGreaterThan(0);
+    expect(screen.queryByText("Minor defect")).not.toBeInTheDocument();
+    expect(screen.queryByText("Major defect")).not.toBeInTheDocument();
+  });
+
+  it("still shows the recorded defect and its note", () => {
+    render(<InspectionView {...PROPS} />);
+    expect(screen.getAllByText("Brake Pads").length).toBeGreaterThan(0);
+    expect(screen.getAllByText("Worn down to 2mm").length).toBeGreaterThan(0);
+  });
+
+  it("groups by the section string old rows already had", () => {
+    render(<InspectionView {...PROPS} />);
+    for (const section of ["Engine", "Brakes", "Tires"]) {
+      expect(screen.getAllByText(section).length).toBeGreaterThan(0);
+    }
+  });
+
+  it("falls back to the template when the snapshot predates the backfill", () => {
+    // Belt and braces for a row created between deploy and migration.
+    const props = {
+      ...PROPS,
+      inspection: { ...LEGACY_INSPECTION, severityScale: null },
+    };
+    render(<InspectionView {...props} />);
+    expect(screen.getAllByText("Attention").length).toBeGreaterThan(0);
+  });
+
+  it("derives a result from the old four-value vocabulary", () => {
+    // v1.2.38 could only ever write these four.
+    const items = [
+      { condition: "pass" as const },
+      { condition: "attention" as const },
+      { condition: "fail" as const },
+      { condition: "not_inspected" as const },
+    ];
+    const result = deriveTestResult(items, { requireAllInspected: false });
+    expect(result).toBeTruthy();
+  });
+
+  it("never numbers a grade on a legacy inspection", () => {
+    // Numbering is national and opt-in; a pre-overhaul row has no country, so
+    // it must not acquire "2 — ..." prefixes it was never issued with.
+    for (const condition of ["pass", "attention", "fail", "not_inspected"] as const) {
+      expect(conditionGrade(condition, "basic", null)).toBeNull();
+    }
+  });
+});

+ 5 - 2
src/app/(public)/share/inspection/[orgId]/[token]/inspection-view.tsx

@@ -60,6 +60,8 @@ interface InspectionRecord {
   notes: string | null;
   completedAt: Date | null;
   createdAt: Date;
+  severityScale?: string | null;
+  country?: string | null;
   vehicleCategory?: string | null;
   nextTestDue?: Date | null;
   certificateNumber?: string | null;
@@ -124,12 +126,13 @@ export function InspectionView({
   const tz = timezone || "America/New_York";
   const formatDate = (d: Date | string) => fmtDate(new Date(d), fmt, tz);
 
-  const scale: SeverityScale = inspection.template.severityScale === "basic" ? "basic" : "eu";
+  const storedScale = inspection.severityScale ?? inspection.template.severityScale;
+  const scale: SeverityScale = storedScale === "basic" ? "basic" : "eu";
   const conditionText = (condition: Condition) =>
     scale === "basic" ? t(`basic.${condition}`) : t(`eu.${condition}`);
   // Several member states record defects by grade number rather than by name,
   // so the number leads and the wording follows.
-  const country = inspection.template.country ?? null;
+  const country = inspection.country ?? inspection.template.country ?? null;
   const gradedText = (condition: Condition) =>
     gradedConditionLabel(condition, scale, country, conditionText(condition));
 

+ 4 - 0
src/features/inspections/Actions/inspectionActions.ts

@@ -184,6 +184,10 @@ export async function createInspection(input: unknown) {
           templateId: data.templateId,
           mileage: data.mileage,
           technicianId: technician?.id ?? null,
+          // Snapshotted so editing the template later cannot relabel a
+          // certificate that has already been issued from this inspection.
+          severityScale: template.severityScale,
+          country: template.country,
           organizationId,
         },
       });

+ 4 - 2
src/features/inspections/Components/InspectionPDF.tsx

@@ -44,6 +44,8 @@ interface InspectionData {
   notes: string | null
   createdAt: Date
   completedAt: Date | null
+  severityScale?: string | null
+  country?: string | null
   vehicleCategory?: string | null
   nextTestDue?: Date | null
   certificateNumber?: string | null
@@ -145,7 +147,7 @@ export function InspectionPDF({
   const shopName = workshop?.name || 'Torqvoice'
 
   const label = (key: string) => labels[key] || FALLBACK[key] || key
-  const isBasic = data.template.severityScale === 'basic'
+  const isBasic = (data.severityScale ?? data.template.severityScale) === 'basic'
   const conditionText = (condition: Condition) => {
     const suffix = condition.charAt(0).toUpperCase() + condition.slice(1)
     return label(`${isBasic ? 'basic' : 'eu'}${suffix}`)
@@ -156,7 +158,7 @@ export function InspectionPDF({
     gradedConditionLabel(
       condition,
       isBasic ? 'basic' : 'eu',
-      data.template.country,
+      data.country ?? data.template.country,
       conditionText(condition)
     )
 

+ 7 - 2
src/features/inspections/Components/InspectionPageClient.tsx

@@ -77,6 +77,8 @@ export interface InspectionData {
   completedAt: Date | null;
   createdAt: Date;
   organizationId: string;
+  severityScale: string | null;
+  country: string | null;
   vehicleCategory: string | null;
   nextTestDue: Date | null;
   certificateNumber: string | null;
@@ -224,8 +226,11 @@ export function InspectionPageClient({
   );
 
   const isCompleted = inspection.status === "completed";
-  const scale: SeverityScale = inspection.template.severityScale === "basic" ? "basic" : "eu";
-  const country = inspection.template.country ?? null;
+  // The snapshot wins; the template is the fallback for inspections that
+  // predate it, and the migration pinned those templates to their old scale.
+  const storedScale = inspection.severityScale ?? inspection.template.severityScale;
+  const scale: SeverityScale = storedScale === "basic" ? "basic" : "eu";
+  const country = inspection.country ?? inspection.template.country ?? null;
   const { label: gradeLabel, graded, result: resultLabel, resultDetail } = useConditionLabels(scale, country);
   const mileageLabel = serviceType === "marine" ? t("engineHours") : t("odometer");