WFDASH-WEBAPP/src/lib/file-fields.ts
Dave 4e4e7328b3 fix(delivery-note): signed copy invisible, and a bigger QR in the corner
TWO bugs, one of which was hiding the other.

1. "No signed copy on file" for a note that had just been signed,
   scanned and uploaded. The upload was never the problem — the READ
   path was. `delivery_notes.signed_copy` is a PB file field with
   `maxSelect: 1`: we WRITE it as an array (`signed_copy: [file]`),
   but PocketBase RETURNS A BARE STRING when exactly one file is
   attached. Verified on the live record after a real upload:

       typeof        : string
       Array.isArray : false
       value         : "image_ar52u2hjfq.jpg"
       value[0]      : "i"        <-- indexing yields a CHARACTER

   Every read site assumed `string[]`, and not one of them threw:
     - signed-copy GET: `Array.isArray(x) ? x[0] : null` returned null
       for notes that demonstrably had a copy — the reported error
     - delivery-note detail tooltip: `signed_copy[0]` rendered the
       first LETTER of the filename
     - delivery-notes-service: treated every signed copy as absent, so
       the quote went out with the UNSIGNED pdf attached
     - deliveries-service: reported an empty filename
     - deliveries page: `.join(", ")` on a string — this one THREW,
       on a page people open daily

   Adds `src/lib/file-fields.ts` (firstFileName / fileNames / hasFile)
   and routes all of them through it. The type was the root of the
   wrong assumptions, so it is now `string | string[]` with a comment
   pointing at the helper.

2. QR 28pt -> 52pt, moved to the top-right corner. Measured by
   rasterising the real PDF at 200 dpi and decoding: it now reads at
   1.24 px/module, where the old size needed ~2.5 — roughly double the
   distance and angle tolerance.

   It could not simply go at top:40: the logo is an absolute 160x100pt
   block in the same corner, so the QR landed on top of it. That is
   not only ugly, it breaks the QR's quiet zone — logo ink inside the
   light margin makes the code refuse to decode. The logo now steps
   left (right: 112 -> 106, width 160 -> 132) to give the QR the true
   corner, and the company name is width-bounded ONLY when a logo is
   present. The Westscott letterhead has no matching .svg in
   public/letterhead/, so in practice the name still gets the full
   width and stays on one line.

Nine tests pin the file-field shapes, including one that asserts the
old `.join(", ")` call really does throw on a string, so the string
case cannot be quietly dropped again.
2026-10-04 17:00:30 +02:00

62 lines
2.3 KiB
TypeScript

/**
* Helpers for PocketBase FILE fields, whose read shape depends on how
* many files are attached.
*
* ## The trap
*
* A `file` field with `maxSelect: 1` is written as an array — the PB
* SDK and the API both want `signed_copy: [file]` — but PocketBase
* RETURNS a bare STRING when exactly one file is attached, and an
* array only when there are several. Verified against the live
* `delivery_notes.signed_copy` field (maxSelect: 1, one file on
* DN26100403):
*
* typeof : string
* Array.isArray : false
* value : "image_ar52u2hjfq.jpg"
* value[0] : "i" <-- indexing silently yields a CHARACTER
*
* So `Array.isArray(x) ? x[0] : null` returns null for a note that very
* much does have a signed copy, and `x[0]` in a template returns the
* first letter of the filename instead of the filename. Both fail
* quietly: no type error, no exception, just a wrong answer. One of
* them (`deliveries/page.tsx`, which called `.join(", ")` on the value)
* threw outright instead.
*
* ## Use these
*
* Anywhere a PB file field is READ. `firstFileName` for single-file
* fields, `fileNames` when you want the whole list for display.
*/
/** A PB file field's read value: a bare string, or an array of them. */
export type PbFileValue = string | string[] | null | undefined;
/**
* The first filename on a PB file field, or null when empty.
* Handles every shape PB can return: string, array, null, undefined.
*/
export function firstFileName(value: PbFileValue): string | null {
if (!value) return null;
if (typeof value === "string") return value.length > 0 ? value : null;
if (Array.isArray(value)) {
for (const entry of value) {
if (typeof entry === "string" && entry.length > 0) return entry;
}
}
return null;
}
/** Every filename on a PB file field, normalised to an array. */
export function fileNames(value: PbFileValue): string[] {
if (!value) return [];
if (typeof value === "string") return value.length > 0 ? [value] : [];
return Array.isArray(value)
? value.filter((e): e is string => typeof e === "string" && e.length > 0)
: [];
}
/** Is anything attached? Truthy check that also works on a bare string. */
export function hasFile(value: PbFileValue): boolean {
return fileNames(value).length > 0;
}