Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
ca7a1ea3b7

+ 11 - 0
init-db.sh

@@ -28,5 +28,16 @@ if ! npx prisma migrate deploy 2>/dev/null; then
 fi
 echo "Migrations applied successfully!"
 
+# Photo embedding is the one thing here that depends on a native module, and
+# sharp's prebuilt binaries need an x86-64-v2 CPU. Some virtualised hosts do not
+# advertise one — Proxmox's default kvm64 processor model, for instance, reports
+# a baseline x86-64 even on modern hardware. Report it at boot rather than
+# leaving it to be found when a certificate comes out without its photos.
+# Never fatal: everything else in the app works without it.
+if ! sharp_error=$(node -e "require('sharp')" 2>&1); then
+  echo "WARNING: sharp could not be loaded. Inspection certificates will render without photos."
+  echo "$sharp_error" | sed 's/^/  /'
+fi
+
 echo "Starting application..."
 exec $cmd

+ 111 - 0
src/__tests__/features/inspections/legacy-inspection-pdf.test.tsx

@@ -0,0 +1,111 @@
+/**
+ * @vitest-environment node
+ *
+ * Upgrade safety for the certificate PDF of inspections recorded before the
+ * overhaul.
+ *
+ * `legacy-inspection-compat.test.tsx` covers the shared web report; this covers
+ * the other renderer of the same rows, which is the one a workshop reaches for
+ * when it needs a copy of a report it issued months ago. The fixture is built
+ * from the v1.2.38 columns and nothing else, so a new field the PDF starts
+ * depending on shows up here as a failure rather than as a 500 on a customer's
+ * download.
+ */
+import { describe, it, expect } from "vitest";
+import { renderToBuffer } from "@react-pdf/renderer";
+import React from "react";
+import "@/features/vehicles/Components/invoice-pdf/fonts";
+import { InspectionPDF } from "@/features/inspections/Components/InspectionPDF";
+
+/** Exactly the v1.2.38 `InspectionItem` columns: nothing added since. */
+const legacyItem = (over: Record<string, unknown> = {}) => ({
+  id: "legacy-item",
+  name: "Brake Pads",
+  section: "Brakes",
+  sortOrder: 1,
+  condition: "fail",
+  notes: "Worn down to 2mm",
+  imageUrls: [] as string[],
+  ...over,
+});
+
+const LEGACY_INSPECTION = {
+  id: "insp-legacy",
+  status: "completed",
+  mileage: 82000,
+  notes: null,
+  createdAt: new Date("2025-11-02"),
+  completedAt: new Date("2025-11-02"),
+  // Written by the 20260816090000 backfill; every other new column is null.
+  severityScale: "basic",
+  country: null,
+  vehicleCategory: null,
+  nextTestDue: null,
+  certificateNumber: null,
+  inspectorName: null,
+  testLocation: 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 }),
+    legacyItem({ id: "l-none", name: "Exhaust", section: "Engine", condition: "not_inspected", notes: null }),
+  ],
+};
+
+const WORKSHOP = {
+  name: "Quality Auto",
+  address: "5 Shop Lane",
+  phone: "555-2222",
+  email: "qa@example.com",
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const renderPdf = (data: any, extra: Record<string, unknown> = {}) =>
+  renderToBuffer(
+    React.createElement(InspectionPDF, {
+      data,
+      workshop: WORKSHOP,
+      ...extra,
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    }) as any
+  );
+
+describe("certificate PDF for inspections recorded before the overhaul", () => {
+  it("renders a legacy inspection to a PDF", async () => {
+    const buffer = await renderPdf(LEGACY_INSPECTION);
+    expect(buffer.subarray(0, 4).toString()).toBe("%PDF");
+  }, 30000);
+
+  it("renders when the scale snapshot predates the backfill", async () => {
+    const buffer = await renderPdf({ ...LEGACY_INSPECTION, severityScale: null });
+    expect(buffer.subarray(0, 4).toString()).toBe("%PDF");
+  }, 30000);
+
+  it("renders an inspection that was never completed", async () => {
+    const buffer = await renderPdf({
+      ...LEGACY_INSPECTION,
+      status: "in_progress",
+      completedAt: null,
+      items: LEGACY_INSPECTION.items.map((i) => ({ ...i, condition: "not_inspected" })),
+    });
+    expect(buffer.subarray(0, 4).toString()).toBe("%PDF");
+  }, 30000);
+
+  it("renders when the customer was never linked", async () => {
+    const buffer = await renderPdf({
+      ...LEGACY_INSPECTION,
+      vehicle: { ...LEGACY_INSPECTION.vehicle, customer: null, vin: null, licensePlate: null },
+    });
+    expect(buffer.subarray(0, 4).toString()).toBe("%PDF");
+  }, 30000);
+});

+ 10 - 1
src/app/api/protected/inspections/[id]/pdf/route.ts

@@ -107,7 +107,16 @@ export async function GET(
       headerStyle: settingsMap["invoice.headerStyle"] || "standard",
     };
 
-    const { photos, omitted: photosOmitted } = await loadInspectionPhotos(inspection.items);
+    // Photos are an enhancement; the certificate is the document. Anything that
+    // goes wrong embedding them is logged and dropped rather than allowed to
+    // fail a download of a report the workshop has already issued.
+    let photos: Awaited<ReturnType<typeof loadInspectionPhotos>>["photos"] = {};
+    let photosOmitted = 0;
+    try {
+      ({ photos, omitted: photosOmitted } = await loadInspectionPhotos(inspection.items));
+    } catch (error) {
+      console.error("[Inspection PDF] Photo embedding failed, rendering without photos:", error);
+    }
 
     const element = React.createElement(InspectionPDF, {
       data: inspection,

+ 9 - 1
src/app/api/public/share/inspection/[orgId]/[token]/pdf/route.ts

@@ -125,7 +125,15 @@ export async function GET(
       ? `${appUrl}/portal/${portalSlug || orgId}`
       : undefined;
 
-    const { photos, omitted: photosOmitted } = await loadInspectionPhotos(inspection.items);
+    // Photos are an enhancement; the certificate is the document. See the
+    // protected route for why this is not allowed to fail the download.
+    let photos: Awaited<ReturnType<typeof loadInspectionPhotos>>["photos"] = {};
+    let photosOmitted = 0;
+    try {
+      ({ photos, omitted: photosOmitted } = await loadInspectionPhotos(inspection.items));
+    } catch (error) {
+      console.error("[Public Inspection PDF] Photo embedding failed, rendering without photos:", error);
+    }
 
     const element = React.createElement(InspectionPDF, {
       data: inspection,

+ 44 - 5
src/features/inspections/Lib/inspectionPhotos.ts

@@ -1,7 +1,6 @@
 import "server-only";
 
 import { readFile } from "fs/promises";
-import sharp from "sharp";
 import { resolveUploadPath } from "@/lib/resolve-upload-path";
 import { isDefect } from "./conditions";
 
@@ -28,6 +27,38 @@ export interface InspectionPhoto {
   dataUri: string;
 }
 
+/**
+ * sharp is a native module, so it is the one dependency here that can fail on
+ * the machine rather than on the data — a musl/glibc mismatch or a partially
+ * copied node_modules after a deploy. Imported lazily and behind a catch so
+ * that failure costs the photos rather than the certificate: a report a
+ * workshop issued years ago must still download on an install where the image
+ * pipeline is broken. A static import would throw while the route module is
+ * being evaluated, before any handler's try/catch exists, which the client
+ * sees as an empty 500 with nothing in it to explain itself.
+ */
+type SharpFactory = (typeof import("sharp"))["default"];
+let cachedSharp: SharpFactory | null | undefined;
+
+async function getSharp(): Promise<SharpFactory | null> {
+  if (cachedSharp !== undefined) return cachedSharp;
+  try {
+    const loaded = (await import("sharp")).default;
+    // libvips defaults to one thread per core and holds a cache per thread,
+    // which on a small box is a lot of memory for something that runs a few
+    // times a day.
+    loaded.concurrency(1);
+    cachedSharp = loaded;
+  } catch (error) {
+    console.error(
+      "[Inspection photos] sharp is unavailable; certificates will render without photos:",
+      error
+    );
+    cachedSharp = null;
+  }
+  return cachedSharp;
+}
+
 interface PhotoSourceItem {
   id: string;
   condition: string;
@@ -36,9 +67,11 @@ interface PhotoSourceItem {
 }
 
 async function loadPhoto(url: string): Promise<InspectionPhoto | null> {
+  const sharp = await getSharp();
+  if (!sharp) return null;
   try {
     const buffer = await readFile(resolveUploadPath(url));
-    const resized = await sharp(buffer)
+    const resized = await sharp(buffer, { sequentialRead: true })
       .rotate() // honour the EXIF orientation before it is stripped
       .resize({ width: MAX_WIDTH, withoutEnlargement: true })
       .jpeg({ quality: JPEG_QUALITY })
@@ -86,9 +119,15 @@ export async function loadInspectionPhotos(
     omitted += wanted.length - affordable.length;
     if (affordable.length === 0) continue;
 
-    const loaded = (await Promise.all(affordable.map(loadPhoto))).filter(
-      (photo): photo is InspectionPhoto => photo !== null
-    );
+    // One at a time: decoding is where the memory goes, and a phone photo
+    // expands to tens of megabytes uncompressed. A certificate is worth a
+    // second of extra wall clock; it is not worth an out-of-memory kill on a
+    // box that is also serving the rest of the workshop.
+    const loaded: InspectionPhoto[] = [];
+    for (const url of affordable) {
+      const photo = await loadPhoto(url);
+      if (photo) loaded.push(photo);
+    }
     if (loaded.length > 0) {
       photos[item.id] = loaded;
       budget -= loaded.length;