Logo — three separate faults, all of which had to be fixed for the letterhead logo to print at all: 1. receipt.html hardcoded '/wfdash-logo.png' for the sale slip, so it printed the app's bundled mark (the solid black W block) and never looked at the letterhead at all. 2. The paths that DID use the letterhead (printTest, cashup) passed PocketBase's fully-qualified cross-origin URL into imageToRaster(), which draws to a <canvas> — getImageData() taints and throws on a cross-origin image, so the .catch(() => null) silently dropped the logo. pos-letterhead-service now returns a same-origin /api/v1/files/... URL (per AGENTS.md's file-URL rule). 3. letterhead_settings wasn't whitelisted in that file proxy, so the same-origin URL would have 403'd. Verified against the live WESTSCOTT letterhead: its SVG rasterizes to 384x137 at ~15% ink coverage (line art), no tainted canvas. Also fixes the company name never printing: every builder read `letterhead.name`, but the POS letterhead record exposes `display_name` — so the name line silently rendered empty on every slip, on screen and on paper. Delivery note now matches the sale slip, per request: gains the logo and business_info blocks and the same 2x-size title, instead of being a stripped-down variant. This also reconciles the transport divergence flagged earlier (Bluetooth baked business info into its header; the network builder omitted it entirely) — it is now one reorderable business_info block on both. resolveBlocks() now slots a newly-shipped block in at its intended position rather than appending it to the end. Without this, any letterhead with an already-saved layout would have printed the new logo/business_info blocks BELOW the signature line.
120 lines
3.9 KiB
TypeScript
120 lines
3.9 KiB
TypeScript
import { getAdminPocketBase } from "./pocketbase-admin";
|
|
import { type Letterhead } from "../letterhead";
|
|
|
|
/**
|
|
* Server-side read model for the POS letterhead picker.
|
|
*
|
|
* The till needs a list of available letterheads so the operator can
|
|
* pick which one this device runs under (CNL, ISHERWOOD, Westcott
|
|
* Group, etc.), plus a "fetch by id" path so the till can grab the
|
|
* full record (address, phone, logo URL, VAT rate) at boot.
|
|
*
|
|
* The `letterhead_settings` PB collection is shared with the main
|
|
* WFDASH app (which uses it for the PDF flow). We don't change
|
|
* anything there — just expose what already exists.
|
|
*/
|
|
|
|
export type PosLetterheadListItem = {
|
|
id: string;
|
|
display_name: string;
|
|
company: string;
|
|
vat_percent: number;
|
|
accent_color: string;
|
|
has_logo: boolean;
|
|
is_active: boolean;
|
|
};
|
|
|
|
export type PosLetterheadFull = PosLetterheadListItem & {
|
|
address: string;
|
|
phone: string;
|
|
email: string;
|
|
vat_number: string;
|
|
registration_number: string;
|
|
footer_text: string;
|
|
logo_url: string | null;
|
|
collection_id: string;
|
|
};
|
|
|
|
function toListItem(r: Letterhead): PosLetterheadListItem {
|
|
return {
|
|
id: r.id,
|
|
display_name: r.display_name,
|
|
company: r.company,
|
|
vat_percent: Number(r.vat_percent ?? 0),
|
|
accent_color: r.accent_color || "",
|
|
has_logo: !!r.logo,
|
|
is_active: !!r.is_active,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The till's logo URL must be **same-origin**, not the raw PocketBase
|
|
* URL that `letterheadLogoUrl()` builds.
|
|
*
|
|
* The thermal-print path rasterizes the logo through a `<canvas>`
|
|
* (see `imageToRaster` in public/pos/epos-print.js) and canvas
|
|
* `getImageData()` throws a SecurityError on a cross-origin image —
|
|
* so a raw `{PB_BASE}/api/files/...` URL silently yields no logo on
|
|
* every printed slip. Routing through this app's own file proxy keeps
|
|
* it same-origin so the canvas stays untainted.
|
|
*
|
|
* See AGENTS.md — "File URLs go through `/api/v1/files/`, never
|
|
* `/api/files/`". `letterhead_settings` is whitelisted in that proxy.
|
|
*/
|
|
function posLetterheadLogoUrl(r: Letterhead): string | null {
|
|
if (!r.logo) return null;
|
|
return `/api/v1/files/letterhead_settings/${r.id}/${r.logo}`;
|
|
}
|
|
|
|
function toFull(r: Letterhead): PosLetterheadFull {
|
|
return {
|
|
...toListItem(r),
|
|
address: r.address || "",
|
|
phone: r.phone || "",
|
|
email: r.email || "",
|
|
vat_number: r.vat_number || "",
|
|
registration_number: r.registration_number || "",
|
|
footer_text: r.footer_text || "",
|
|
logo_url: posLetterheadLogoUrl(r),
|
|
collection_id: r.collectionId,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* List every letterhead that's marked active. The till uses this to
|
|
* populate its picker UI. Inactive rows are skipped because they
|
|
* shouldn't be selectable from the device.
|
|
*
|
|
* We don't sort here — `display_name` is already human-ordered in
|
|
* the PB admin (CNL, ISHERWOOD, WESTSCOTT, ...) so the natural
|
|
* insertion order is the order the operator set up.
|
|
*/
|
|
export async function listActiveLetterheads(): Promise<PosLetterheadListItem[]> {
|
|
const pb = await getAdminPocketBase();
|
|
const rows = await pb.collection("letterhead_settings").getFullList<Letterhead>({
|
|
filter: "is_active = true",
|
|
});
|
|
return rows.map(toListItem);
|
|
}
|
|
|
|
/**
|
|
* Fetch a single letterhead by id, with the full set of fields the
|
|
* till needs at sale time (address, phone, logo URL, VAT rate, etc).
|
|
*
|
|
* Throws if the id doesn't exist — that's a config bug (the till
|
|
* cached a stale id), not a user error, so the route will surface
|
|
* a 500 and the till should re-list and re-pick.
|
|
*/
|
|
export async function getPosLetterheadById(id: string): Promise<PosLetterheadFull> {
|
|
if (!id) {
|
|
throw new Error("letterhead id is required");
|
|
}
|
|
const pb = await getAdminPocketBase();
|
|
let row: Letterhead;
|
|
try {
|
|
row = await pb.collection("letterhead_settings").getOne<Letterhead>(id);
|
|
} catch (e) {
|
|
throw new Error(`Letterhead ${id} not found: ${(e as Error).message}`);
|
|
}
|
|
return toFull(row);
|
|
}
|