Explorar el Código

Give templates a stable identity and let them travel as files (#237)

Two steps toward sharing checklists, both useful on their own.

Templates now carry provenance: a namespaced package id, the version installed
and where it came from. The library recognised its own checklists by name,
which cannot survive a rename, leaves nowhere to record origin and cannot tell
an update from a duplicate. The migration backfills the built-in library by
name — the last time names are used for identity.

Export and import go through a package format that is deliberately not "an
exported inspection template": a manifest plus a list of typed contents, so
labour presets or a bundle of several arrive later as new content types rather
than a second file format. Nothing in src/lib/packages knows what a template
is; that lives in an installer registered against a content type, which is the
whole extension point.

Export is field by field rather than a spread of the row, because ids,
timestamps and the default flag mean nothing — or the wrong thing — elsewhere.
The workshop's own defect wording is off by default and the dialog says why:
it is free text typed at a bench and can name a customer or a colleague.

Import validates everything before writing anything, inside one transaction, so
a malformed file cannot leave a workshop with a checklist that looks complete
and is not. Payload sizes are bounded, an imported checklist never becomes the
default, and it never replaces one already installed. The preview warns that
limits in a shared checklist are whatever its author set.
Bernt Christian Egeland hace 1 mes
padre
commit
2bbd3f9e79

+ 32 - 0
prisma/migrations/20260815200000_inspection_template_provenance/migration.sql

@@ -0,0 +1,32 @@
+-- Stable, namespaced identity for templates that came from a package.
+--
+-- The library previously recognised its own checklists by name, which cannot
+-- survive a rename and leaves nowhere to record where a template came from or
+-- which version of it is installed. Idempotent; safe to re-run.
+
+ALTER TABLE "inspection_templates" ADD COLUMN IF NOT EXISTS "packageId" TEXT;
+ALTER TABLE "inspection_templates" ADD COLUMN IF NOT EXISTS "packageVersion" TEXT;
+ALTER TABLE "inspection_templates" ADD COLUMN IF NOT EXISTS "packageSource" TEXT;
+
+CREATE INDEX IF NOT EXISTS "inspection_templates_organizationId_packageId_idx"
+  ON "inspection_templates"("organizationId", "packageId");
+
+-- Backfill the built-in library by name, which is how it was matched until now.
+-- This is the last time names are used for identity: from here the id is
+-- authoritative, so a workshop renaming its copy keeps its provenance.
+UPDATE "inspection_templates" SET "packageId" = 'torqvoice/' || v.slug,
+                                  "packageVersion" = '1.0.0',
+                                  "packageSource" = 'builtin'
+FROM (VALUES
+  ('EU periodic technical inspection',            'eu-roadworthiness'),
+  ('EU inspection — motorcycles (L category)',    'eu-roadworthiness-motorcycle'),
+  ('Norway — EU-kontroll',                        'no-eu-kontroll'),
+  ('Germany — Hauptuntersuchung',                 'de-hauptuntersuchung'),
+  ('Netherlands — APK',                           'nl-apk'),
+  ('Standard multi-point inspection',             'standard-multipoint'),
+  ('Pre-purchase inspection',                     'pre-purchase'),
+  ('Electric and hybrid vehicle check',           'ev-hybrid'),
+  ('Marine vessel inspection',                    'marine')
+) AS v(name, slug)
+WHERE "inspection_templates"."name" = v.name
+  AND "inspection_templates"."packageId" IS NULL;

+ 10 - 0
prisma/schema.prisma

@@ -1002,6 +1002,15 @@ model InspectionTemplate {
   standard      String? @default("custom")
   standard      String? @default("custom")
   severityScale String  @default("eu")
   severityScale String  @default("eu")
 
 
+  /// Provenance, for templates that came from a package rather than being
+  /// written here. `packageId` is namespaced and stable across renames
+  /// ("torqvoice/eu-roadworthiness"), which is what lets the library tell an
+  /// update from a duplicate. Null on a template the workshop wrote itself.
+  packageId      String?
+  packageVersion String?
+  /// Where it came from: "builtin", "file" or "catalogue".
+  packageSource  String?
+
   organizationId String
   organizationId String
   organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
   organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
 
 
@@ -1009,6 +1018,7 @@ model InspectionTemplate {
   inspections Inspection[]
   inspections Inspection[]
 
 
   @@index([organizationId])
   @@index([organizationId])
+  @@index([organizationId, packageId])
   @@map("inspection_templates")
   @@map("inspection_templates")
 }
 }
 
 

+ 157 - 0
src/__tests__/features/inspections/template-packages.test.ts

@@ -0,0 +1,157 @@
+import { describe, it, expect } from "vitest";
+import {
+  PACKAGE_FORMAT_VERSION,
+  PackageFormatError,
+  packageFileName,
+  parsePackage,
+} from "@/lib/packages/format";
+import { getInstaller, reviewContents } from "@/lib/packages/registry";
+import {
+  INSPECTION_TEMPLATE_TYPE,
+  countCustomWording,
+  describeTemplate,
+  packagedTemplateSchema,
+  withoutCustomWording,
+  type PackagedTemplate,
+} from "@/features/inspections/Lib/inspectionTemplatePackage";
+
+const TEMPLATE: PackagedTemplate = packagedTemplateSchema.parse({
+  name: "Norway — EU-kontroll",
+  description: "Annex I with the Norwegian periodic control in mind.",
+  country: "NO",
+  standard: "eu-2014-45",
+  severityScale: "eu",
+  sections: [
+    {
+      name: "Braking equipment",
+      code: "1",
+      items: [
+        {
+          name: "Brake linings and pads",
+          code: "1.1.13",
+          inputType: "measurement",
+          unit: "mm",
+          minValue: 3,
+          defaultSeverity: "fail",
+          defectSuggestions: ["Pads at 2mm — call Kari before ordering"],
+        },
+        { name: "Brake fluid", code: "1.8" },
+      ],
+    },
+  ],
+});
+
+const packageFor = (data: PackagedTemplate) => ({
+  formatVersion: PACKAGE_FORMAT_VERSION,
+  kind: "bundle",
+  id: "torqvoice/no-eu-kontroll",
+  version: "1.0.0",
+  name: data.name,
+  contents: [{ type: INSPECTION_TEMPLATE_TYPE, data }],
+});
+
+describe("package format", () => {
+  it("accepts a package it wrote itself", () => {
+    const parsed = parsePackage(packageFor(TEMPLATE));
+    expect(parsed.id).toBe("torqvoice/no-eu-kontroll");
+    expect(parsed.contents).toHaveLength(1);
+  });
+
+  it("says plainly when a package is from a newer Torqvoice", () => {
+    // Rather than failing as a list of unrecognised fields.
+    expect(() =>
+      parsePackage({ ...packageFor(TEMPLATE), formatVersion: PACKAGE_FORMAT_VERSION + 1 })
+    ).toThrow(/newer version/i);
+  });
+
+  it("rejects anything that is not a package", () => {
+    for (const junk of [null, 42, "{}", {}, { formatVersion: "1" }]) {
+      expect(() => parsePackage(junk)).toThrow(PackageFormatError);
+    }
+  });
+
+  it("rejects a package whose envelope is incomplete", () => {
+    const { version: _dropped, ...withoutVersion } = packageFor(TEMPLATE);
+    expect(() => parsePackage(withoutVersion)).toThrow(PackageFormatError);
+  });
+
+  it("builds a filename that survives a downloads folder", () => {
+    expect(packageFileName({ name: "Norway — EU-kontroll" })).toBe("torqvoice-norway-eu-kontroll.json");
+    expect(packageFileName({ name: "///" })).toBe("torqvoice-template.json");
+  });
+});
+
+describe("package registry", () => {
+  it("has the inspection template type registered", () => {
+    expect(getInstaller(INSPECTION_TEMPLATE_TYPE)?.label).toBe("inspection template");
+  });
+
+  it("refuses a content type this version cannot install", () => {
+    // All-or-nothing: a half-recognised package must install nothing.
+    expect(() => reviewContents([{ type: "labour-preset", data: {} }])).toThrow(
+      /cannot install/i
+    );
+  });
+
+  it("refuses a payload that does not match its schema", () => {
+    expect(() =>
+      reviewContents([{ type: INSPECTION_TEMPLATE_TYPE, data: { name: "x", sections: [] } }])
+    ).toThrow(/not valid/i);
+  });
+
+  it("returns a validated payload with a summary to show first", () => {
+    const [reviewed] = reviewContents([{ type: INSPECTION_TEMPLATE_TYPE, data: TEMPLATE }]);
+    expect(reviewed.details).toContain("2 checks");
+    expect(reviewed.details).toContain("1 section");
+  });
+});
+
+describe("inspection template payload", () => {
+  it("survives a round trip through JSON unchanged", () => {
+    const round = packagedTemplateSchema.parse(JSON.parse(JSON.stringify(TEMPLATE)));
+    expect(round).toEqual(TEMPLATE);
+  });
+
+  it("keeps the measurement limits, which are the point of sharing one", () => {
+    const round = packagedTemplateSchema.parse(JSON.parse(JSON.stringify(TEMPLATE)));
+    const pads = round.sections[0].items[0];
+    expect(pads.unit).toBe("mm");
+    expect(pads.minValue).toBe(3);
+    expect(pads.defaultSeverity).toBe("fail");
+  });
+
+  it("strips the workshop's own wording when asked", () => {
+    expect(countCustomWording(TEMPLATE)).toBe(1);
+    const stripped = withoutCustomWording(TEMPLATE);
+    expect(countCustomWording(stripped)).toBe(0);
+    // Everything else is untouched — only the free text goes.
+    expect(stripped.sections[0].items[0].minValue).toBe(3);
+    expect(stripped.sections[0].items[0].name).toBe("Brake linings and pads");
+  });
+
+  it("does not mutate the payload it was given", () => {
+    withoutCustomWording(TEMPLATE);
+    expect(countCustomWording(TEMPLATE)).toBe(1);
+  });
+
+  it("counts custom wording so the export screen can warn about it", () => {
+    const details = describeTemplate(TEMPLATE);
+    expect(details).toContain("1 custom defect phrase");
+    expect(details).toContain("Country: NO");
+    expect(details).toContain("1 measured against a limit");
+  });
+
+  it("bounds what a file can ask to be written", () => {
+    // A hostile or corrupt file must not turn into an unbounded insert.
+    const huge = {
+      ...TEMPLATE,
+      sections: Array.from({ length: 61 }, () => TEMPLATE.sections[0]),
+    };
+    expect(packagedTemplateSchema.safeParse(huge).success).toBe(false);
+  });
+
+  it("refuses a template with no checks in a section", () => {
+    const empty = { ...TEMPLATE, sections: [{ name: "Empty", items: [] }] };
+    expect(packagedTemplateSchema.safeParse(empty).success).toBe(false);
+  });
+});

+ 197 - 0
src/features/inspections/Actions/packageActions.ts

@@ -0,0 +1,197 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { revalidatePath } from "next/cache";
+import {
+  PACKAGE_FORMAT_VERSION,
+  PackageFormatError,
+  parsePackage,
+  type PackageManifest,
+} from "@/lib/packages/format";
+import { reviewContents } from "@/lib/packages/registry";
+import {
+  INSPECTION_TEMPLATE_TYPE,
+  countCustomWording,
+  packagedTemplateSchema,
+  withoutCustomWording,
+  type PackagedTemplate,
+} from "../Lib/inspectionTemplatePackage";
+
+/**
+ * Reads a template out of the database in package shape.
+ *
+ * Deliberately field-by-field rather than spreading the row: ids, timestamps,
+ * the organization and the default flag are all local facts that mean nothing
+ * — or the wrong thing — on another instance.
+ */
+export async function exportTemplatePackage(
+  id: string,
+  options: { includeCustomWording?: boolean; author?: string } = {}
+) {
+  return withAuth(async ({ organizationId }) => {
+    const template = await db.inspectionTemplate.findFirst({
+      where: { id, organizationId },
+      include: {
+        sections: {
+          include: { items: { orderBy: { sortOrder: "asc" } } },
+          orderBy: { sortOrder: "asc" },
+        },
+      },
+    });
+    if (!template) throw new Error("Template not found");
+
+    const payload: PackagedTemplate = packagedTemplateSchema.parse({
+      name: template.name,
+      description: template.description,
+      country: template.country,
+      standard: template.standard,
+      severityScale: template.severityScale === "basic" ? "basic" : "eu",
+      sections: template.sections.map((section) => ({
+        name: section.name,
+        description: section.description,
+        code: section.code,
+        items: section.items.map((item) => ({
+          name: item.name,
+          description: item.description,
+          code: item.code,
+          inputType: item.inputType,
+          unit: item.unit,
+          minValue: item.minValue,
+          maxValue: item.maxValue,
+          choices: item.choices,
+          required: item.required,
+          photoRequired: item.photoRequired,
+          defaultSeverity: item.defaultSeverity,
+          defectSuggestions: item.defectSuggestions,
+        })),
+      })),
+    });
+
+    const data = options.includeCustomWording ? payload : withoutCustomWording(payload);
+
+    const manifest: PackageManifest = {
+      formatVersion: PACKAGE_FORMAT_VERSION,
+      kind: "bundle",
+      // A template written here has no package identity of its own yet, so the
+      // export gets one derived from the row. Keeping the original id for a
+      // template that came from the library means an import elsewhere can tell
+      // it is the same checklist rather than a lookalike.
+      id: template.packageId ?? `local/${template.id}`,
+      version: template.packageVersion ?? "1.0.0",
+      name: template.name,
+      description: template.description ?? undefined,
+      author: options.author?.trim() || undefined,
+      exportedAt: new Date().toISOString(),
+      contents: [{ type: INSPECTION_TEMPLATE_TYPE, data }],
+    };
+
+    return {
+      manifest,
+      customWordingCount: countCustomWording(payload),
+      includedCustomWording: !!options.includeCustomWording,
+    };
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.INSPECTIONS }] });
+}
+
+/**
+ * Installs a package.
+ *
+ * Everything is validated before a single row is written, and the write is one
+ * transaction, so a file that turns out to be malformed halfway through cannot
+ * leave a workshop with a checklist that looks complete and is not.
+ */
+export async function importTemplatePackage(raw: unknown) {
+  return withAuth(async ({ organizationId }) => {
+    let manifest: PackageManifest;
+    let reviewed: ReturnType<typeof reviewContents>;
+    try {
+      manifest = parsePackage(raw);
+      reviewed = reviewContents(manifest.contents);
+    } catch (error) {
+      if (error instanceof PackageFormatError) throw new Error(error.message);
+      throw error;
+    }
+
+    const templates = reviewed.filter((c) => c.type === INSPECTION_TEMPLATE_TYPE);
+    if (templates.length === 0) {
+      throw new Error("This package does not contain an inspection template.");
+    }
+
+    // An imported checklist is never made the default, and never silently
+    // replaces one already installed: it arrives beside whatever is there and
+    // the workshop decides. Overwriting a checklist someone runs tests from is
+    // not a decision an import should take.
+    const existingNames = new Set(
+      (
+        await db.inspectionTemplate.findMany({
+          where: { organizationId },
+          select: { name: true },
+        })
+      ).map((t) => t.name.trim().toLowerCase())
+    );
+
+    const created = await db.$transaction(
+      templates.map((content) => {
+        const data = content.data as PackagedTemplate;
+        const name = existingNames.has(data.name.trim().toLowerCase())
+          ? `${data.name} (imported)`
+          : data.name;
+
+        return db.inspectionTemplate.create({
+          data: {
+            name,
+            description: data.description ?? null,
+            isDefault: false,
+            country: data.country ?? null,
+            standard: data.standard ?? "custom",
+            severityScale: data.severityScale,
+            packageId: manifest.id,
+            packageVersion: manifest.version,
+            packageSource: "file",
+            organizationId,
+            sections: {
+              create: data.sections.map((section, sIdx) => ({
+                name: section.name,
+                description: section.description ?? null,
+                code: section.code ?? null,
+                sortOrder: sIdx,
+                items: {
+                  create: section.items.map((item, iIdx) => ({
+                    name: item.name,
+                    description: item.description ?? null,
+                    code: item.code ?? null,
+                    sortOrder: iIdx,
+                    inputType: item.inputType,
+                    unit: item.unit ?? null,
+                    minValue: item.minValue ?? null,
+                    maxValue: item.maxValue ?? null,
+                    choices: item.choices,
+                    required: item.required,
+                    photoRequired: item.photoRequired,
+                    defaultSeverity: item.defaultSeverity ?? null,
+                    defectSuggestions: item.defectSuggestions,
+                  })),
+                },
+              })),
+            },
+          },
+          select: { id: true, name: true },
+        });
+      })
+    );
+
+    revalidatePath("/settings/templates");
+    return { templates: created };
+  }, {
+    requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.INSPECTIONS }],
+    audit: ({ result }) => ({
+      action: "inspectionTemplate.import",
+      entity: "InspectionTemplate",
+      entityId: result.templates[0]?.id,
+      message: `Imported ${result.templates.length} inspection template(s) from a package`,
+      metadata: { count: result.templates.length },
+    }),
+  });
+}

+ 26 - 9
src/features/inspections/Actions/templateActions.ts

@@ -6,7 +6,12 @@ import { createTemplateSchema, updateTemplateSchema } from "../Schema/templateSc
 import { revalidatePath } from "next/cache";
 import { revalidatePath } from "next/cache";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import type { TemplateSectionInput } from "../Schema/templateSchema";
 import type { TemplateSectionInput } from "../Schema/templateSchema";
-import { TEMPLATE_PRESETS, type TemplatePreset } from "../Lib/templatePresets";
+import {
+  PRESET_VERSION,
+  TEMPLATE_PRESETS,
+  presetPackageId,
+  type TemplatePreset,
+} from "../Lib/templatePresets";
 
 
 /**
 /**
  * Sections and their checks are always rewritten wholesale rather than diffed,
  * Sections and their checks are always rewritten wholesale rather than diffed,
@@ -318,6 +323,9 @@ function presetToCreate(preset: TemplatePreset, organizationId: string, isDefaul
     country: preset.country,
     country: preset.country,
     standard: preset.standard,
     standard: preset.standard,
     severityScale: preset.severityScale,
     severityScale: preset.severityScale,
+    packageId: presetPackageId(preset),
+    packageVersion: PRESET_VERSION,
+    packageSource: "builtin",
     organizationId,
     organizationId,
     sections: {
     sections: {
       create: preset.sections.map((section, sIdx) => ({
       create: preset.sections.map((section, sIdx) => ({
@@ -390,12 +398,17 @@ async function syncPresetLibrary(organizationId: string, userId: string) {
 
 
   const existing = await db.inspectionTemplate.findMany({
   const existing = await db.inspectionTemplate.findMany({
     where: { organizationId },
     where: { organizationId },
-    select: { name: true, isDefault: true },
+    select: { name: true, isDefault: true, packageId: true },
   });
   });
-  const taken = new Set(existing.map((t) => t.name.trim().toLowerCase()));
-  // A workshop that already built its own copy keeps it; the preset is marked
-  // handled so it is never added alongside.
-  const toCreate = pending.filter((p) => !taken.has(p.name.trim().toLowerCase()));
+  // Identity is the package id, so a workshop that renamed its copy still has
+  // it recognised. Names are only consulted for templates predating provenance
+  // and for one a workshop wrote itself under the same name.
+  const installedIds = new Set(existing.map((t) => t.packageId).filter(Boolean));
+  const takenNames = new Set(existing.map((t) => t.name.trim().toLowerCase()));
+  const toCreate = pending.filter(
+    (p) =>
+      !installedIds.has(presetPackageId(p)) && !takenNames.has(p.name.trim().toLowerCase())
+  );
 
 
   if (toCreate.length > 0) {
   if (toCreate.length > 0) {
     // A shop opening the app for the first time should land on the general
     // A shop opening the app for the first time should land on the general
@@ -438,10 +451,14 @@ export async function restoreMissingPresets() {
   return withAuth(async ({ organizationId, userId }) => {
   return withAuth(async ({ organizationId, userId }) => {
     const existing = await db.inspectionTemplate.findMany({
     const existing = await db.inspectionTemplate.findMany({
       where: { organizationId },
       where: { organizationId },
-      select: { name: true, isDefault: true },
+      select: { name: true, isDefault: true, packageId: true },
     });
     });
-    const taken = new Set(existing.map((t) => t.name.trim().toLowerCase()));
-    const missing = LIBRARY_PRESETS.filter((p) => !taken.has(p.name.trim().toLowerCase()));
+    const installedIds = new Set(existing.map((t) => t.packageId).filter(Boolean));
+    const takenNames = new Set(existing.map((t) => t.name.trim().toLowerCase()));
+    const missing = LIBRARY_PRESETS.filter(
+      (p) =>
+        !installedIds.has(presetPackageId(p)) && !takenNames.has(p.name.trim().toLowerCase())
+    );
     if (missing.length === 0) return { added: 0 };
     if (missing.length === 0) return { added: 0 };
 
 
     const hasDefault = existing.some((t) => t.isDefault);
     const hasDefault = existing.some((t) => t.isDefault);

+ 23 - 0
src/features/inspections/Components/TemplateListClient.tsx

@@ -30,11 +30,13 @@ import {
   Loader2,
   Loader2,
   MoreVertical,
   MoreVertical,
   Pencil,
   Pencil,
+  Download,
   LibraryBig,
   LibraryBig,
   Plus,
   Plus,
   Ruler,
   Ruler,
   ShieldCheck,
   ShieldCheck,
   Trash2,
   Trash2,
+  Upload,
 } from "lucide-react";
 } from "lucide-react";
 import { toast } from "sonner";
 import { toast } from "sonner";
 import {
 import {
@@ -44,6 +46,7 @@ import {
 } from "../Actions/templateActions";
 } from "../Actions/templateActions";
 import { TemplateForm, type TemplateFormData } from "./TemplateForm";
 import { TemplateForm, type TemplateFormData } from "./TemplateForm";
 import { TemplatePresetPicker } from "./TemplatePresetPicker";
 import { TemplatePresetPicker } from "./TemplatePresetPicker";
+import { TemplateExportDialog, TemplateImportDialog } from "./TemplatePackageDialogs";
 import { TEMPLATE_COUNTRIES, TEMPLATE_PRESETS } from "../Lib/templatePresets";
 import { TEMPLATE_COUNTRIES, TEMPLATE_PRESETS } from "../Lib/templatePresets";
 
 
 interface TemplateSection {
 interface TemplateSection {
@@ -90,12 +93,14 @@ function TemplateCard({
   template,
   template,
   onEdit,
   onEdit,
   onDuplicate,
   onDuplicate,
+  onExport,
   onDelete,
   onDelete,
   isDuplicating,
   isDuplicating,
 }: {
 }: {
   template: Template;
   template: Template;
   onEdit: () => void;
   onEdit: () => void;
   onDuplicate: () => void;
   onDuplicate: () => void;
+  onExport: () => void;
   onDelete: () => void;
   onDelete: () => void;
   isDuplicating: boolean;
   isDuplicating: boolean;
 }) {
 }) {
@@ -149,6 +154,10 @@ function TemplateCard({
               <Copy className="mr-2 h-4 w-4" aria-hidden="true" />
               <Copy className="mr-2 h-4 w-4" aria-hidden="true" />
               Duplicate
               Duplicate
             </DropdownMenuItem>
             </DropdownMenuItem>
+            <DropdownMenuItem onClick={onExport}>
+              <Download className="mr-2 h-4 w-4" aria-hidden="true" />
+              Export…
+            </DropdownMenuItem>
             <DropdownMenuSeparator />
             <DropdownMenuSeparator />
             <DropdownMenuItem className="text-destructive" onClick={onDelete}>
             <DropdownMenuItem className="text-destructive" onClick={onDelete}>
               <Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
               <Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
@@ -202,6 +211,8 @@ export function TemplateListClient({ templates }: { templates: Template[] }) {
   const router = useRouter();
   const router = useRouter();
   const [showForm, setShowForm] = useState(false);
   const [showForm, setShowForm] = useState(false);
   const [showPresets, setShowPresets] = useState(false);
   const [showPresets, setShowPresets] = useState(false);
+  const [showImport, setShowImport] = useState(false);
+  const [exportTarget, setExportTarget] = useState<{ id: string; name: string } | null>(null);
   const [editingTemplate, setEditingTemplate] = useState<TemplateFormData | undefined>();
   const [editingTemplate, setEditingTemplate] = useState<TemplateFormData | undefined>();
   const [deleteTarget, setDeleteTarget] = useState<Template | null>(null);
   const [deleteTarget, setDeleteTarget] = useState<Template | null>(null);
   const [duplicatingId, setDuplicatingId] = useState<string | null>(null);
   const [duplicatingId, setDuplicatingId] = useState<string | null>(null);
@@ -277,6 +288,10 @@ export function TemplateListClient({ templates }: { templates: Template[] }) {
           </p>
           </p>
         </div>
         </div>
         <div className="flex shrink-0 gap-2">
         <div className="flex shrink-0 gap-2">
+          <Button variant="outline" size="sm" onClick={() => setShowImport(true)}>
+            <Upload className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
+            Import
+          </Button>
           <Button variant="outline" size="sm" onClick={() => setShowPresets(true)}>
           <Button variant="outline" size="sm" onClick={() => setShowPresets(true)}>
             <ShieldCheck className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
             <ShieldCheck className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
             Browse checklists
             Browse checklists
@@ -342,6 +357,7 @@ export function TemplateListClient({ templates }: { templates: Template[] }) {
               isDuplicating={duplicatingId === t.id}
               isDuplicating={duplicatingId === t.id}
               onEdit={() => handleEdit(t)}
               onEdit={() => handleEdit(t)}
               onDuplicate={() => handleDuplicate(t.id)}
               onDuplicate={() => handleDuplicate(t.id)}
+              onExport={() => setExportTarget({ id: t.id, name: t.name })}
               onDelete={() => setDeleteTarget(t)}
               onDelete={() => setDeleteTarget(t)}
             />
             />
           ))}
           ))}
@@ -362,6 +378,13 @@ export function TemplateListClient({ templates }: { templates: Template[] }) {
         template={editingTemplate}
         template={editingTemplate}
       />
       />
 
 
+      <TemplateExportDialog
+        template={exportTarget}
+        onOpenChange={(open) => !open && setExportTarget(null)}
+      />
+
+      <TemplateImportDialog open={showImport} onOpenChange={setShowImport} />
+
       <TemplatePresetPicker
       <TemplatePresetPicker
         open={showPresets}
         open={showPresets}
         onOpenChange={setShowPresets}
         onOpenChange={setShowPresets}

+ 298 - 0
src/features/inspections/Components/TemplatePackageDialogs.tsx

@@ -0,0 +1,298 @@
+"use client";
+
+import { useEffect, useRef, useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { Download, FileJson, Loader2, TriangleAlert, Upload } from "lucide-react";
+import { toast } from "sonner";
+import { exportTemplatePackage, importTemplatePackage } from "../Actions/packageActions";
+import { packageFileName, parsePackage, PackageFormatError } from "@/lib/packages/format";
+import { reviewContents, type ReviewedContent } from "@/lib/packages/registry";
+// Registers the inspection-template content type with the package registry.
+import "../Lib/inspectionTemplatePackage";
+
+function DetailList({ details }: { details: string[] }) {
+  return (
+    <ul className="text-muted-foreground grid gap-1 text-sm sm:grid-cols-2">
+      {details.map((detail) => (
+        <li key={detail} className="flex items-baseline gap-1.5">
+          <span aria-hidden="true" className="bg-muted-foreground/50 h-1 w-1 shrink-0 rounded-full" />
+          {detail}
+        </li>
+      ))}
+    </ul>
+  );
+}
+
+/* -------------------------------------------------------------------------- */
+/* Export                                                                     */
+/* -------------------------------------------------------------------------- */
+
+export function TemplateExportDialog({
+  template,
+  onOpenChange,
+}: {
+  template: { id: string; name: string } | null;
+  onOpenChange: (open: boolean) => void;
+}) {
+  const [includeWording, setIncludeWording] = useState(false);
+  const [author, setAuthor] = useState("");
+  const [isPending, startTransition] = useTransition();
+
+  useEffect(() => {
+    if (template) {
+      setIncludeWording(false);
+      setAuthor("");
+    }
+  }, [template]);
+
+  const handleExport = () => {
+    if (!template) return;
+    startTransition(async () => {
+      const result = await exportTemplatePackage(template.id, {
+        includeCustomWording: includeWording,
+        author,
+      });
+      if (!result.success || !result.data) {
+        toast.error(result.error || "Could not export this template");
+        return;
+      }
+
+      const blob = new Blob([JSON.stringify(result.data.manifest, null, 2)], {
+        type: "application/json",
+      });
+      const url = URL.createObjectURL(blob);
+      const link = document.createElement("a");
+      link.href = url;
+      link.download = packageFileName(result.data.manifest);
+      link.click();
+      URL.revokeObjectURL(url);
+
+      toast.success("Template exported");
+      onOpenChange(false);
+    });
+  };
+
+  return (
+    <Dialog open={!!template} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-lg">
+        <DialogHeader>
+          <DialogTitle>Export &ldquo;{template?.name}&rdquo;</DialogTitle>
+          <DialogDescription>
+            Downloads the whole checklist as a file — sections, checks, units and limits. Anyone
+            running Torqvoice can import it.
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="space-y-4">
+          <div className="flex items-start gap-3 rounded-lg border p-3">
+            <Switch
+              id="include-wording"
+              checked={includeWording}
+              onCheckedChange={setIncludeWording}
+              className="mt-0.5"
+            />
+            <div>
+              <Label htmlFor="include-wording">Include your own defect wording</Label>
+              <p className="text-muted-foreground mt-0.5 text-xs">
+                The phrases your workshop added to checks. Useful to whoever receives this, but
+                they are free text typed at a bench and can name a customer, a colleague or a
+                local arrangement. Off unless you have read them.
+              </p>
+            </div>
+          </div>
+
+          <div className="space-y-1.5">
+            <Label htmlFor="package-author">Attribute it to (optional)</Label>
+            <Input
+              id="package-author"
+              value={author}
+              onChange={(e) => setAuthor(e.target.value)}
+              placeholder="e.g. Egeland Auto"
+              maxLength={120}
+            />
+            <p className="text-muted-foreground text-xs">
+              Written into the file so whoever installs it can see where it came from. Nothing is
+              taken from your account.
+            </p>
+          </div>
+        </div>
+
+        <DialogFooter>
+          <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+            Cancel
+          </Button>
+          <Button type="button" onClick={handleExport} disabled={isPending}>
+            {isPending ? (
+              <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
+            ) : (
+              <Download className="mr-2 h-4 w-4" aria-hidden="true" />
+            )}
+            Download
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+/* -------------------------------------------------------------------------- */
+/* Import                                                                     */
+/* -------------------------------------------------------------------------- */
+
+interface Preview {
+  raw: unknown;
+  name: string;
+  author?: string;
+  contents: ReviewedContent[];
+}
+
+export function TemplateImportDialog({
+  open,
+  onOpenChange,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+}) {
+  const router = useRouter();
+  const fileInputRef = useRef<HTMLInputElement>(null);
+  const [preview, setPreview] = useState<Preview | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  const [isPending, startTransition] = useTransition();
+
+  useEffect(() => {
+    if (!open) {
+      setPreview(null);
+      setError(null);
+    }
+  }, [open]);
+
+  // Parsed and validated in the browser so the file can be shown before it is
+  // sent anywhere. The server re-validates: this is for the reader, not a check.
+  const handleFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
+    const file = event.target.files?.[0];
+    if (!file) return;
+    setError(null);
+    setPreview(null);
+    try {
+      const raw = JSON.parse(await file.text());
+      const manifest = parsePackage(raw);
+      setPreview({
+        raw,
+        name: manifest.name,
+        author: manifest.author,
+        contents: reviewContents(manifest.contents),
+      });
+    } catch (err) {
+      setError(
+        err instanceof PackageFormatError
+          ? err.message
+          : "That file could not be read. A Torqvoice package is a .json file exported from a template."
+      );
+    } finally {
+      if (fileInputRef.current) fileInputRef.current.value = "";
+    }
+  };
+
+  const handleImport = () => {
+    if (!preview) return;
+    startTransition(async () => {
+      const result = await importTemplatePackage(preview.raw);
+      if (result.success && result.data) {
+        toast.success(
+          `Imported ${result.data.templates.length} template${
+            result.data.templates.length === 1 ? "" : "s"
+          }`
+        );
+        onOpenChange(false);
+        router.refresh();
+      } else {
+        toast.error(result.error || "Could not import this package");
+      }
+    });
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-lg">
+        <DialogHeader>
+          <DialogTitle>Import a checklist</DialogTitle>
+          <DialogDescription>
+            Adds a checklist from a Torqvoice package file. It arrives as a normal template you
+            can edit, and never replaces one you already have.
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="space-y-4">
+          <input
+            ref={fileInputRef}
+            type="file"
+            accept="application/json,.json"
+            className="sr-only"
+            tabIndex={-1}
+            onChange={handleFile}
+          />
+          <Button
+            type="button"
+            variant="outline"
+            className="h-auto w-full flex-col gap-1 border-dashed py-6"
+            onClick={() => fileInputRef.current?.click()}
+          >
+            <Upload className="h-5 w-5" aria-hidden="true" />
+            <span>{preview ? "Choose a different file" : "Choose a package file"}</span>
+            <span className="text-muted-foreground text-xs font-normal">.json</span>
+          </Button>
+
+          {error && (
+            <p className="text-destructive flex items-start gap-2 text-sm" role="alert">
+              <TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
+              {error}
+            </p>
+          )}
+
+          {preview && (
+            <div className="space-y-3 rounded-lg border p-4">
+              <div className="flex items-start gap-2">
+                <FileJson className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
+                <div className="min-w-0">
+                  <p className="font-medium">{preview.name}</p>
+                  {preview.author && (
+                    <p className="text-muted-foreground text-xs">From {preview.author}</p>
+                  )}
+                </div>
+              </div>
+              {preview.contents.map((content) => (
+                <DetailList key={content.type} details={content.details} />
+              ))}
+              <p className="text-muted-foreground border-t pt-3 text-xs">
+                Limits in a shared checklist are whatever its author set. Check them against your
+                national rules before issuing anything from it.
+              </p>
+            </div>
+          )}
+        </div>
+
+        <DialogFooter>
+          <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+            Cancel
+          </Button>
+          <Button type="button" onClick={handleImport} disabled={!preview || isPending}>
+            {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />}
+            Import
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 95 - 0
src/features/inspections/Lib/inspectionTemplatePackage.ts

@@ -0,0 +1,95 @@
+import { z } from "zod";
+import { registerInstaller } from "@/lib/packages/registry";
+
+/**
+ * The inspection-template content type.
+ *
+ * This is the payload shape, the review summary and the sanitising rule — the
+ * database work lives in the server action, so this file stays importable from
+ * the client for previewing a file before anything is sent.
+ */
+
+export const INSPECTION_TEMPLATE_TYPE = "inspection-template";
+
+export const packagedItemSchema = z.object({
+  name: z.string().min(1).max(300),
+  description: z.string().max(2000).nullish(),
+  code: z.string().max(40).nullish(),
+  inputType: z.enum(["condition", "measurement", "text", "choice"]).default("condition"),
+  unit: z.string().max(20).nullish(),
+  minValue: z.number().nullish(),
+  maxValue: z.number().nullish(),
+  choices: z.array(z.string().max(200)).max(50).default([]),
+  required: z.boolean().default(false),
+  photoRequired: z.boolean().default(false),
+  defaultSeverity: z.enum(["attention", "fail", "dangerous"]).nullish(),
+  defectSuggestions: z.array(z.string().max(500)).max(50).default([]),
+});
+
+export const packagedSectionSchema = z.object({
+  name: z.string().min(1).max(200),
+  description: z.string().max(2000).nullish(),
+  code: z.string().max(40).nullish(),
+  // Bounded so a malformed or hostile file cannot ask for an unbounded write.
+  items: z.array(packagedItemSchema).min(1).max(500),
+});
+
+export const packagedTemplateSchema = z.object({
+  name: z.string().min(1).max(200),
+  description: z.string().max(2000).nullish(),
+  country: z.string().length(2).nullish(),
+  standard: z.string().max(64).nullish(),
+  severityScale: z.enum(["eu", "basic"]).default("eu"),
+  sections: z.array(packagedSectionSchema).min(1).max(60),
+});
+
+export type PackagedTemplate = z.infer<typeof packagedTemplateSchema>;
+
+export function describeTemplate(data: PackagedTemplate): string[] {
+  const items = data.sections.flatMap((s) => s.items);
+  const measurements = items.filter((i) => i.inputType === "measurement").length;
+  const wording = items.reduce((n, i) => n + i.defectSuggestions.length, 0);
+
+  const lines = [
+    `${data.sections.length} section${data.sections.length === 1 ? "" : "s"}`,
+    `${items.length} check${items.length === 1 ? "" : "s"}`,
+    data.severityScale === "eu" ? "EU defect scale" : "Pass / attention / fail",
+  ];
+  if (measurements > 0) lines.push(`${measurements} measured against a limit`);
+  if (data.country) lines.push(`Country: ${data.country}`);
+  if (wording > 0) lines.push(`${wording} custom defect phrase${wording === 1 ? "" : "s"}`);
+  return lines;
+}
+
+/**
+ * Removes the workshop's own defect wording.
+ *
+ * That field is free text someone typed at a bench. It is the most useful part
+ * of a refined checklist and the most likely to name a customer, a colleague or
+ * a local arrangement, so sharing it is a decision the exporter makes rather
+ * than a default they discover afterwards.
+ */
+export function withoutCustomWording(data: PackagedTemplate): PackagedTemplate {
+  return {
+    ...data,
+    sections: data.sections.map((section) => ({
+      ...section,
+      items: section.items.map((item) => ({ ...item, defectSuggestions: [] })),
+    })),
+  };
+}
+
+export function countCustomWording(data: PackagedTemplate): number {
+  return data.sections.reduce(
+    (total, section) =>
+      total + section.items.reduce((n, item) => n + item.defectSuggestions.length, 0),
+    0
+  );
+}
+
+registerInstaller({
+  type: INSPECTION_TEMPLATE_TYPE,
+  label: "inspection template",
+  schema: packagedTemplateSchema,
+  describe: describeTemplate,
+});

+ 15 - 0
src/features/inspections/Lib/templatePresets.ts

@@ -729,6 +729,21 @@ export function getPreset(id: string): TemplatePreset | undefined {
   return TEMPLATE_PRESETS.find((p) => p.id === id)
   return TEMPLATE_PRESETS.find((p) => p.id === id)
 }
 }
 
 
+/**
+ * The namespaced, stable identity a preset carries once installed.
+ *
+ * Recorded on the template so the library recognises its own checklists after a
+ * workshop renames them, and so an update can be told from a duplicate.
+ */
+export const PRESET_NAMESPACE = 'torqvoice'
+
+export function presetPackageId(preset: TemplatePreset | string): string {
+  return `${PRESET_NAMESPACE}/${typeof preset === 'string' ? preset : preset.id}`
+}
+
+/** Bumped when a preset's contents change enough to offer as an update. */
+export const PRESET_VERSION = '1.0.0'
+
 export function countPresetItems(preset: TemplatePreset): number {
 export function countPresetItems(preset: TemplatePreset): number {
   return preset.sections.reduce((sum, s) => sum + s.items.length, 0)
   return preset.sections.reduce((sum, s) => sum + s.items.length, 0)
 }
 }

+ 96 - 0
src/lib/packages/format.ts

@@ -0,0 +1,96 @@
+import { z } from "zod";
+
+/**
+ * The shape of anything a workshop can share out of Torqvoice.
+ *
+ * Deliberately not "an exported inspection template". A package is a manifest
+ * plus a list of typed contents, so labour presets, custom fields or a bundle
+ * of several arrive later as new content types rather than a second file
+ * format with its own parser and its own bugs.
+ *
+ * Nothing here knows what an inspection template is — that lives in an
+ * installer, registered against a content type. See `./registry`.
+ */
+
+/** Bumped only for a breaking change to the envelope, not to any content type. */
+export const PACKAGE_FORMAT_VERSION = 1;
+
+export const PACKAGE_FILE_EXTENSION = ".json";
+
+/**
+ * Whether the contents are data or something that runs.
+ *
+ * Only `bundle` exists today and everything in it is declarative, which is
+ * what makes a schema a sufficient check on the way in. The field is here from
+ * the start so that answer is stated rather than implied — an executable kind
+ * would need signing and a sandbox, and should never be able to arrive by
+ * omission in a file written for an older version.
+ */
+export const packageKindSchema = z.enum(["bundle"]);
+
+export const packageContentSchema = z.object({
+  /** Registered content type, e.g. "inspection-template". */
+  type: z.string().min(1).max(64),
+  /** Validated by the installer for `type`, not here. */
+  data: z.unknown(),
+});
+
+export const packageManifestSchema = z.object({
+  formatVersion: z.number().int().positive(),
+  kind: packageKindSchema,
+  /** Namespaced and stable across renames, e.g. "torqvoice/eu-roadworthiness". */
+  id: z.string().min(1).max(200),
+  version: z.string().min(1).max(32),
+  name: z.string().min(1).max(200),
+  description: z.string().max(2000).optional(),
+  /** Free text the exporter chose to attribute it to; never derived from the account. */
+  author: z.string().max(120).optional(),
+  exportedAt: z.string().max(40).optional(),
+  contents: z.array(packageContentSchema).min(1).max(50),
+});
+
+export type PackageManifest = z.infer<typeof packageManifestSchema>;
+export type PackageContent = z.infer<typeof packageContentSchema>;
+
+export class PackageFormatError extends Error {}
+
+/**
+ * Parses untrusted JSON into a package.
+ *
+ * The version is checked before the shape, so a file from a newer Torqvoice
+ * says so plainly instead of failing as a list of unrecognised fields.
+ */
+export function parsePackage(raw: unknown): PackageManifest {
+  if (raw === null || typeof raw !== "object") {
+    throw new PackageFormatError("This file is not a Torqvoice package.");
+  }
+
+  const version = (raw as { formatVersion?: unknown }).formatVersion;
+  if (typeof version !== "number") {
+    throw new PackageFormatError("This file is not a Torqvoice package.");
+  }
+  if (version > PACKAGE_FORMAT_VERSION) {
+    throw new PackageFormatError(
+      `This package was made with a newer version of Torqvoice (format ${version}). Update before importing it.`
+    );
+  }
+
+  const parsed = packageManifestSchema.safeParse(raw);
+  if (!parsed.success) {
+    throw new PackageFormatError("This package is missing information Torqvoice needs to read it.");
+  }
+  return parsed.data;
+}
+
+/** A filename that survives a round trip through a downloads folder. */
+export function packageFileName(manifest: Pick<PackageManifest, "name">): string {
+  const slug =
+    manifest.name
+      .toLowerCase()
+      .normalize("NFKD")
+      .replace(/[^\w\s-]/g, "")
+      .trim()
+      .replace(/\s+/g, "-")
+      .slice(0, 60) || "template";
+  return `torqvoice-${slug}${PACKAGE_FILE_EXTENSION}`;
+}

+ 70 - 0
src/lib/packages/registry.ts

@@ -0,0 +1,70 @@
+import type { ZodType } from "zod";
+import { PackageFormatError, type PackageContent } from "./format";
+
+/**
+ * The extension point.
+ *
+ * Everything else about packages — the envelope, the file, the dialogs — is
+ * written once. Supporting a new kind of shareable thing means writing one of
+ * these and registering it.
+ *
+ * `export` lives here rather than being generic because what is safe to share
+ * differs sharply by type: a labour preset references this instance's stock, a
+ * webhook holds a live secret. A serialise-the-row exporter would leak.
+ */
+export interface PackageInstaller<T> {
+  /** Stable key written into the package, e.g. "inspection-template". */
+  type: string;
+  /** Human name for the review screen. */
+  label: string;
+  /** Nothing from a file is trusted until it has been through this. */
+  schema: ZodType<T>;
+  /** Lines shown before installing, e.g. "9 sections", "92 checks". */
+  describe(data: T): string[];
+}
+
+const installers = new Map<string, PackageInstaller<unknown>>();
+
+export function registerInstaller<T>(installer: PackageInstaller<T>): void {
+  installers.set(installer.type, installer as PackageInstaller<unknown>);
+}
+
+export function getInstaller(type: string): PackageInstaller<unknown> | undefined {
+  return installers.get(type);
+}
+
+export interface ReviewedContent {
+  type: string;
+  label: string;
+  /** Validated payload, safe to hand to the installer. */
+  data: unknown;
+  details: string[];
+}
+
+/**
+ * Validates every item in a package against its registered installer.
+ *
+ * All-or-nothing: a package that is half-recognised installs nothing, so an
+ * import can never leave a workshop with a partial checklist it believes is
+ * complete.
+ */
+export function reviewContents(contents: PackageContent[]): ReviewedContent[] {
+  return contents.map((content) => {
+    const installer = getInstaller(content.type);
+    if (!installer) {
+      throw new PackageFormatError(
+        `This package contains "${content.type}", which this version of Torqvoice cannot install.`
+      );
+    }
+    const parsed = installer.schema.safeParse(content.data);
+    if (!parsed.success) {
+      throw new PackageFormatError(`The ${installer.label} in this package is not valid.`);
+    }
+    return {
+      type: installer.type,
+      label: installer.label,
+      data: parsed.data,
+      details: installer.describe(parsed.data),
+    };
+  });
+}