PJL Sales Ops

PJL Proposal Engine

Replaced Word mail-merge with a Pipedrive-driven proposal system that freezes exactly what the client signed.

13.6 → 2.3 MBproposal PDF size
1 clickfrom deal to sent proposal
2npm dependencies
~18klines of TypeScript
Design proposal cover rendered from sample data: hero photo, drawing reference and client address pulled from the deal
Design proposal cover rendered from sample data: hero photo, drawing reference and client address pulled from the deal

Phillip Johnson Landscapes produced every design proposal and construction quote by hand: re-keying client details, scope and fees from the CRM into a Word template, exporting a PDF, emailing it, and chasing acceptance by phone with no record of which version the client saw. Now a deal in Pipedrive becomes a branded 8 to 12 page A4 document at the click of a button, emailed with a public accept or decline link, with the signed version frozen in R2 and filed to the NAS automatically.

The before state

The source-of-truth templates are still in the repo as the design spec: a Word design proposal with merge fields highlighted in yellow, and four construction quote templates. Each proposal meant re-keying the client's name, address, drawing number, scope and fees from the CRM, exporting a PDF, emailing it, and then chasing acceptance with no record of what the client actually received. Once a first web version shipped, pages re-rendered live on every view, so a wording change silently altered documents already in clients' hands.

What it is now

A single Cloudflare Worker written in TypeScript with Hono. Server-side JSX renders the document; Browser Rendering turns it into a PDF; KV holds state; R2 holds heroes, survey plans and frozen PDFs; Email Sending delivers it; Cloudflare Access gates the internal pages while a path-scoped bypass policy lets clients open share links without a login. The whole thing runs on two npm dependencies.

Pipedrive deal ──► loaders ──► RenderInputs ──► HTML (JSX) ──► PDF (Browser Rendering)
                                   │                              │
                                   ▼                              ▼
                            KV snapshot                    R2 frozen bytes
                                   │
                                   ▼
              signed share link ──► client accept / decline ──► NAS filing + deposit intent

Three generations in seven weeks

Generation 1 had hardcoded templates and one design family.

Generation 2 made everything user-editable. Scope components, payment-terms sets, planting packages and per-deal text overrides all moved from code into KV-backed admin pages. The code library became merely a seed.

Generation 3 made proposal types data. Each type carries its engine, its Pipedrive deal-type labels, an ordered section manifest and an ordered component list. One admin page assembles any document, design and construction converged onto a single manifest-driven engine, and a prose description can be drafted into a new type by Workers AI. The rule written into the project guide says it all: never widen a variant union, use string keys everywhere, so user-created types need no code.

Then came the reversal that shows the most judgement. An earlier decision that type reordering was global and retroactive was deliberately undone. Six phases of versioning work made a proposal freeze at send, because a document a client signs against must not move. Flexibility was traded back for integrity where integrity mattered more.

Engineering notes

Versioning without compare-and-set. KV has no atomic increment, so a sequential counter would let two near-simultaneous sends clobber each other. Versions are keyed by the share nonce instead, which is random, already minted and already embedded in the token, making each snapshot write-once by construction. The snapshot stores the post-loader render inputs rather than raw KV values, because replaying today's loaders over old raw state under a future code version would not reproduce what was actually sent.

Durable acceptance evidence. Acceptance is written to both the mutable share record and the decided nonce's own snapshot, which nothing ever overwrites. A stale accept cannot resurrect an old nonce over a just-minted send. The ordering of freeze is resolve, render, upload, snapshot, then share record, so a failure part-way leaves orphan bytes but never a live token pointing at nothing.

The JPEG pass-through discovery. Chromium's PDF backend only keeps a JPEG's original bytes when the image is drawn whole; a cropped background-size: cover gets re-embedded as raw bitmap. One 4K back cover produced a 10 MB page and a 12-page proposal hit 13.6 MB, over Adobe Sign's limit. The fix runs inside Chromium before printing: every large element with a bitmap background has its hero re-cut to the element's exact aspect ratio on a canvas and re-applied so it draws whole. Result: about 2.3 MB.

The Safari soft-mask bug. Chromium rasterises a blurred text shadow into a transparency group with a luminosity mask. Chrome and poppler composite it correctly; CoreGraphics, which is what Safari and Preview use and what most clients open the attachment in, paints the group's bounding box as a flat grey rectangle. The fix is hard zero-blur offsets. The valuable part is the documented reproduction: rasterise with sips to go through CoreGraphics, because pdftoppm uses poppler and will never show it.

Rendering behind an Access gate. Headless Chromium cannot fetch the Worker's own URLs because it would get the login page. The renderer inlines every asset as a data URI and uses setContent instead.

A linter for a footgun tsc cannot see. Inline client scripts live in template literals, so a stray \n written as a real newline breaks the whole script at runtime while the type checker sees only a string. A 48-line script fetches each admin page, extracts every script block and parses it with the Node VM module.

Email size cap. Heroes are stored at three resolutions. The emailed PDF uses the 1280px variant, the download link the 4K one, and if an attachment would ever exceed the 5 MiB cap the send falls back to a link. Sends never hard-fail.

Small details that matter to the business. GST rounding matches how the human-authored estimates were written. Dates render in Melbourne time, fixed before anything was frozen. Heroes and survey plans retire rather than delete because frozen snapshots re-resolve their paths. Archiving a deal revokes its share rather than deleting it, preserving the signed name and decision time.

What is deliberately not done

The Xero deposit-invoice automation is built and records intents but is not yet connected. Construction quote types are send-gated off pending the owner's sign-off. Both are documented as decisions, not gaps.