Feat: PDF generators - #20
Conversation
Adds the @open-mercato/pdf-generators community module — built-in order invoice and sales offer PDF templates, a preview/download UI, and an extension API for registering custom templates from any module. Also includes the scaffold-pdf-templates and code-commenting AI skills, the SPEC-004 spec, and a changeset entry.
|
@pkarw @dominikpalatynski Could you look into it? |
|
I used in the Quote Document a raw SQL because I wasn't able to resolve the "SalesQuote" entity. |
|
All official modules should be created with good quality and in a consistent way. |
Split monolithic docs/README.md into separate files: - README.md (package root, level 2 — shortened docs with screenshots) - docs/installation.md - docs/usage.md - docs/api.md - docs/contributing.md
|
🤖 |
pkarw
left a comment
There was a problem hiding this comment.
Code Review: PDF generators (@open-mercato/pdf-generators)
Thank you for this large contribution. The architecture is clean and the extension story is well thought through — a single templateRegistry with internal/external split, BaseDocumentService per resource kind, and a generator plugin for external registration is a sound pattern.
However, a number of release-blocking issues need to be resolved before this can merge. Most are concentrated in the document services (security + correctness), the package manifest, and the CI gate.
Severity summary
| Severity | Count | Headline |
|---|---|---|
| Critical | 5 | Tenant-isolation breach in fetchData, broken filename API contract, CI red on yarn install --immutable, spec claim contradicts implementation |
| High | 7 | Encryption-helper bypass, missing tests, package.json dependency tier mismatch, missing peerDeps, spec-ID collision, missing changeset |
| Medium | 7 | i18n sort + Polish fallbacks, downloaded filename, any leakage, example doc contradicts skill guidance |
| Low | 1 | Inline comments on self-explanatory code |
Critical
C1. Tenant-isolation breach in QuotesDocumentService.fetchData (packages/pdf-generators/src/modules/pdf_generators/services/quotes-document-service.ts:83-97)
Raw SQL fetches a quote by id with no organization_id / tenant_id filter:
const [quote] = await conn.execute(
`SELECT ... FROM sales_quotes WHERE id = ? LIMIT 1`,
[id]
)
// also: SELECT ... FROM sales_quote_lines WHERE quote_id = ?A user with the pdf_generators.view feature can submit any quote UUID (e.g. exfiltrated from a side-channel) to POST /api/pdf-generators/preview or /generate and receive a PDF generated from that quote's data — including cross-tenant quotes. requireFeatures: ['pdf_generators.view'] only checks authentication and permission, not record ownership.
Fix: derive the request organizationId / tenantId from the auth context (the OM core route layer exposes this via the request-scoped DI container), and add AND organization_id = ? AND tenant_id = ? to both queries. Same applies to the line-items query.
C2. Tenant-isolation breach in OrdersDocumentService.fetchData (services/orders-document-service.ts:81, 102)
const order = await em.findOne(SalesOrder, { id }, { populate: ['lines'] })
// ...
const address = await em.findOne(CustomerAddress, { entity: order.customerEntityId, isPrimary: true })Same problem: em.findOne with only { id } does not enforce tenant scoping unless the EM has global filters configured for these entities — which is not guaranteed and must not be assumed by an external module. Add explicit organizationId / tenantId to the where clause.
C3. Spec claim contradicts implementation (.ai/specs/SPEC-004-2026-05-06-pdf-generators.md:472-477)
The spec says:
Tenant & Data Isolation
- No database entities — no tenant isolation risk in this phase.
- Templates are code-defined — no cross-tenant data leakage.
The implementation queries sales_quotes, sales_quote_lines, sales_orders (via SalesOrder entity), CustomerEntity, and CustomerAddress. The spec's "no tenant isolation risk" claim is false and the risk surface was not assessed. Update the spec to reflect what is actually fetched server-side and document the required tenant-scoping approach.
C4. BaseDocumentService.getEntries() filename wrapping is broken — overrides never run (services/base-document-service.ts:92)
filename: (data: Record<string, unknown>) => this.filename({ data }),TemplateEntry.filename is declared as (input: { data: Record<string, unknown> }) => string (lib/interfaces.ts:19) and the registry calls it that way: entry.filename({ data }) (lib/template-registry.ts:92). So when the bound arrow function runs, its parameter data receives the wrapper object { data: thePdfData }, then re-wraps it: this.filename({ data: { data: thePdfData } }).
Inside the subclass override:
override filename({ data }: { data: Record<string, unknown> }): string {
const num = (data.document as any)?.number // data is { data: ... }, not the data
return num ? `offer-${num}.pdf` : 'offer.pdf'
}The destructured data is the wrapper, so data.document is undefined. Both OrdersDocumentService.filename and QuotesDocumentService.filename always return the fallback (offer.pdf / invoice.pdf), never the variant that includes the document number. This silently regressed the documented behavior.
Fix: change the binding in getEntries() to filename: (input: { data: Record<string, unknown> }) => this.filename(input) (and apply the same pattern check to fetchData / fromRecord). Add a unit test that calls entry.filename({ data: { document: { number: '42' } } }) and asserts the number ends up in the filename.
C5. CI fails on yarn install --immutable — stale yarn.lock
The CI test job fails at the install step (https://github.com/open-mercato/official-modules/actions/runs/26021384604):
YN0028: - "@open-mercato/shared": 0.4.9-develop.1013.aa3a9dea92
YN0028: - "@open-mercato/ui": 0.4.9-develop.1013.aa3a9dea92
YN0028: + "@open-mercato/shared": ^0.6.2
YN0028: + "@open-mercato/ui": ^0.6.2
YN0028: The lockfile would have been modified by this install, which is explicitly forbidden.
packages/pdf-generators/package.json was updated to declare ^0.6.2 peer deps but yarn.lock was not regenerated. Run yarn install locally and commit the updated lockfile. The full CI gate cannot pass until this is fixed — typecheck, i18n-check, and tests never even run.
High
H1. Encryption helper bypass (services/orders-document-service.ts:78, 81, 102; services/quotes-document-service.ts:120, 138)
Per repo conventions (AGENTS.md in open-mercato/open-mercato):
Use
findWithDecryption/findOneWithDecryptioninstead ofem.find/em.findOne
The services call em.findOne(SalesOrder, ...), em.findOne(CustomerEntity, ...), em.findOne(CustomerAddress, ...) directly. If any selected column is encrypted (customer addresses commonly are), these calls return ciphertext or — depending on the entity's encryption configuration — fail at runtime. Switch to findOneWithDecryption from @open-mercato/shared/lib/encryption/find (or the equivalent helper available to community modules).
H2. No tests for the new package (~5,000 LOC, 78 files, zero *.test.ts(x) / *.spec.ts(x))
The reference community package carrier-inpost ships with seven unit tests plus integration tests. The CI workflow runs yarn test, which currently passes only because of passWithNoTests: true in jest.config.cjs. Critical behaviors to cover at minimum:
templateRegistryregistration + load semantics (internal/external split,findTemplatethrowingUnknown template)BaseDocumentService.getEntries()produces a correctly boundfilename(this would have caught C4)QuotesDocumentService.toTemplateDatafor the snapshot string-vs-object branchesformatDateanddownloadBlobutilities
H3. SPEC-004 ID collision
.ai/specs/ already contains SPEC-004-2026-03-25-carrier-dhl-parcel.md. The new spec is SPEC-004-2026-05-06-pdf-generators.md. Two specs share the same ID. Renumber the new spec to the next free ID (e.g. SPEC-005) or follow whatever convention the repo settles on.
H4. Missing changeset entry
No .changeset/*.md file was added for @open-mercato/pdf-generators. carrier-inpost has two changesets for its initial release; this PR adds a new publishable package without one, so the release pipeline cannot version/publish it.
H5. Wrong dependency tier — @open-mercato/ui in dependencies (packages/pdf-generators/package.json:60-62)
"dependencies": {
"@open-mercato/ui": "0.6.2-develop.3330.1.8ad2b3b84c",
"@react-pdf/renderer": "4.3.0"
},Reference packages put @open-mercato/ui only in peerDependencies + devDependencies (e.g. test-package/package.json:55-72). Hoisting @open-mercato/ui as a runtime dependency of an installed module risks pulling in a second React tree and duplicate context providers in the host app. Move it to peerDependencies (with ^0.6.2) plus devDependencies (with a concrete version), exactly like @open-mercato/shared.
H6. Hard-pinned develop snapshot in dependencies (same lines as H5)
0.6.2-develop.3330.1.8ad2b3b84c is a develop canary version, not a published stable. Even if H5 is resolved, never pin a develop snapshot as a runtime dependency of a published module — it will break the moment that canary expires from npm.
H7. Missing peerDependencies — react, react-dom, react-is, @types/react
The CI lockfile validation also reports:
@open-mercato/pdf-generators doesn't provide @types/react (...), requested by @open-mercato/ui
@open-mercato/pdf-generators doesn't provide react (...), requested by @open-mercato/ui
@open-mercato/pdf-generators doesn't provide react-dom (...), requested by @open-mercato/ui
@open-mercato/pdf-generators doesn't provide react-is (...), requested by @open-mercato/ui
test-package/package.json declares react + react-dom in peerDependencies and react, react-dom, @types/react in devDependencies. Mirror that. The package also imports lucide-react, @tanstack/react-table, next/server, and @radix-ui/react-dialog (transitively via @open-mercato/ui); audit those too.
Medium
M1. i18n keys not alphabetically sorted (packages/pdf-generators/src/modules/pdf_generators/i18n/{en,pl,es,de}.json)
The CI step yarn i18n:check-sync:packages enforces flat dot-notation + alphabetically sorted keys (scripts/i18n-check-sync.ts:14-17). The new locale files are not sorted — title comes before description, internal before external, etc. Run yarn i18n:check-sync --fix from the repo root, or sort manually.
M2. Polish strings hardcoded as English fallback (multiple files)
Convention: the second argument to t('key', fallback) is the default shown when no translation resolves; it should be the English string. Found Polish fallbacks in:
components/PreviewPanel.tsx:62, 70—'Nie udało się wygenerować dokumentu.'components/PreviewPanel.tsx:105—'Podgląd dokumentu'components/PreviewPanel.tsx:127-128—'Generowanie...','Pobierz PDF'components/Loader.tsx:12—'Ładowanie danych...'components/TemplatesList.tsx:50—'Dostępne szablony PDF'
English-only deployments — or any failure to load the locale chunk — surface Polish UI for every English speaker. Replace each fallback with the English string already present in i18n/en.json.
M3. Downloaded filename ignores server-provided Content-Disposition (components/PreviewPanel.tsx:97, utils/downloadBlob.ts)
downloadBlob(url, template.id) // -> "sales-offer.pdf", "order-invoice.pdf"renderPdf already sets Content-Disposition: attachment; filename="..." server-side. The client overrides it with a.download = ${filename}.pdf``, so the actual document number never appears in the saved file. Once C4 is fixed and the server returns offer-1234.pdf, the client must propagate that — either parse the header (`response.headers.get('Content-Disposition')`) and pass it to `downloadBlob`, or compute the filename client-side from the same data.
M4. any leakage and unsafe casts (services/orders-document-service.ts:78, 81, 84, 102; services/quotes-document-service.ts:80, 81, 120, 138, 179, 180)
The services repeatedly as any ORM results and templates:
const em = container.resolve('em') as any
const customer = ... as any
const num = (data.document as any)?.numberPer repo conventions: derive types via zod + z.infer and narrow with runtime checks. At minimum, replace the (data.document as any) access with a typed shape — that exact cast is what masked C4 from tsc.
M5. Spec/skill/example inconsistency about widgets
.ai/skills/scaffold-pdf-templates/SKILL.md(top): "No widget needed. The PDF tab for sales orders and quotes is rendered automatically by the core sales module."packages/pdf-generators/examples/README.mdstep 6: "Register the widget slot inwidgets/injection-table.ts"packages/pdf-generators/examples/widgets/injection-table.tsregisters'sales.document.detail.order:tabs', the same slot already registered by the package's ownwidgets/injection-table.ts:11.
If an external module copies the example, the slot will receive two competing widgets. Either the skill is right (drop the widget files from examples/) or the example is right (update the skill text). Pick one.
M6. Widget contract is essentially { id }-only — drop the record indirection
Widget passes record={{ id: record.id }} (widgets/injection/quote_pdf_tab/widget.client.tsx:22), and every service's fetchData does const { id } = data as { id: string }. The spec's design rationale ("Widget passes raw context.record — no client-side mapping") is not used by anything that ships in this PR. Either pass the actual context.record and exercise the contract, or simplify both the API and the spec to take only { template_id, id, resource_kind, resource_id }. The current shape is contract-by-aspiration.
M7. data/cache.db previously committed, now deleted + gitignored
The PR removes apps/sandbox/data/cache.db (a previously-committed binary) and adds data/cache.db to apps/sandbox/.gitignore. The cleanup is correct, but the rationale is not in the commit message. Worth a one-line note in the PR description so future archaeologists see why the binary disappeared.
Low
L1. Inline comments on self-explanatory code
config/registry.ts:1-14 and several spots in the services have comments like // registers built-in templates as side effect and // SalesQuote is not in DI — use raw SQL. The second one is a real "why" and should stay. The first restates templateRegistry.registerInternal([...]) — remove.
Out-of-scope notes (no action requested)
.ai/skills/code-commenting/SKILL.mdand.ai/skills/scaffold-pdf-templates/SKILL.mdare useful additions. They are not on the critical path.- The PR description mentions SPEC-005 in commit
ca484c50but the file is SPEC-004; minor inconsistency.
Suggested fix order
- Regenerate
yarn.lock(unblocks CI). [C5] - Fix
BaseDocumentService.getEntries()filename binding + add a unit test that would have caught it. [C4, H2 starter] - Move
@open-mercato/uito peers/dev and add missing react peerDeps. [H5, H6, H7] - Tenant scope every server-side query in
fetchData. [C1, C2, C3] - Swap
em.findOne→findOneWithDecryption. [H1] - Rename the spec to a free SPEC ID; add a changeset entry. [H3, H4]
- Sort i18n keys and replace Polish fallbacks with English. [M1, M2]
- Pick one widget story (auto-rendered by core OR per-module slot), update the skill or the example to match. [M5]
- Address remaining Medium / Low.
Happy to re-review once these land. Pinging back so the next push can pick up from latest head.
|
Thanks @kriss145 — review found release-blocking items (CI red on stale |
|
🤖 |
|
Thanks! Will check. |
- Fix BaseDocumentService.getEntries() filename binding — was double-wrapping the data object, causing filename overrides to always return the fallback - Replace Polish i18n fallback strings with English in PreviewPanel, Loader, TemplatesList (M2) - Sort all i18n locale keys alphabetically to pass i18n:check-sync (M1) - Remove @open-mercato/ui from dependencies (was a pinned develop canary); it remains in peerDependencies and devDependencies (H5, H6) - Add react-dom, @types/react, react-is to peerDependencies and react-dom, @types/react to devDependencies (H7) - Regenerate yarn.lock to fix CI --immutable install failure (C5) - Remove redundant inline comment in config/registry.ts (L1)
…H3, H4) SPEC-004 was already taken by carrier-dhl-parcel; renumbered to SPEC-005. Added minor changeset for the initial release of @open-mercato/pdf-generators.
|
@pkarw Regarding the changeset. I checked earlier, for example in the README, using AI, but it didn’t find any information about it. That’s why it’s missing. |
C4 — Fix: broken filename binding in
|
Direct em.findOne calls on encrypted entities (CustomerEntity, CustomerAddress, SalesOrder) return ciphertext instead of decrypted values. Switched to findOneWithDecryption from @open-mercato/shared/lib/encryption/find.
PreviewPanel was ignoring Content-Disposition from the generate endpoint and always saving the file as <template-id>.pdf. Now reads the filename from the response header via getFilenameFromResponse, falling back to template.id if the header is absent. Extracted getFilenameFromResponse into its own utility file and kept downloadBlob focused on triggering the browser download.
M6 — By design: widget passes
|
Both document services were fetching records by id only, allowing any authenticated user to retrieve data from another tenant. - getAuthFromRequest called in both route handlers; auth passed through renderPdf → templateRegistry.load → fetchData via ctx.auth - QuotesDocumentService: SQL WHERE extended with tenant_id and organization_id guards derived from auth context - OrdersDocumentService: ORM where clause extended with tenantId and organizationId from auth context - AuthContext propagated through interfaces, template-registry, render-pdf, and base-document-service signatures
…chData Missing tenantId or orgId means we cannot scope the query safely. Return raw data instead of fetching — callers without auth context (tests, CLI) get a no-op rather than a crash or an unscoped query. SQL query simplified to strict equality now that nulls are guarded.
C1 & C2 — Fix: tenant isolation in
|
C3 — Fix: spec updated to reflect actual tenant isolation risk and mitigationThe spec's "Tenant & Data Isolation" section previously stated "No database entities — no tenant isolation risk in this phase" and "Templates are code-defined — no cross-tenant data leakage." Both claims were false once the built-in document services were added. Updated
|
… reality (C3) Previous text claimed no tenant isolation risk existed; the implementation queries sales_quotes, sales_quote_lines, sales_orders, CustomerEntity, and CustomerAddress. Documents the mitigation already in place (auth context propagation + tenant_id/organization_id guards) and adds an explicit contract for external DocumentService implementors.
…ly (M5) The example injection-table was registering sales.document.detail.order:tabs — the same slot already owned by the built-in pdf-generators widget. Copying the example verbatim would produce duplicate PDF tabs on the order detail page. Changed the example slot to a generic my-module.custom.resource:tabs placeholder and added comments explaining the widgets/ directory is only needed for custom slots (non-orders/quotes resources). Updated examples/README.md step 6 to make this explicit.
M5 — Fix: example widgets clarified for custom slots onlyThe The confusion came from the example registering Fix:
|
…l interfaces (M4 partial) Define minimal local interfaces (SalesOrderRow, SalesOrderLineRow, CustomerAddressRow, QuoteRow, QuoteLineRow, CustomerEntityRow, PersonProfileRow, CompanyProfileRow, CustomerAddressRow) matching only the fields used in fetchData. Cast DI-resolved constructors once at the resolution boundary (as unknown as EntityCtor<T>) and pass an explicit generic to findOneWithDecryption — the return type is now T | null instead of any throughout both services. Also replaces (em as any).getConnection() with em.getConnection() cast to a typed SqlConnection interface, and removes (line: any) from the order lines map. Remaining as any in filename() and toTemplateData() (data.document and snapshot access) are addressed separately.
…sely - add @open-mercato/checkout + @open-mercato/webhooks to config/platform-channel-policy.json so platform:sync covers them (były pomijane i trzymały stary core/shared@0.6.0 w drzewie) - bump sandbox @mikro-orm/* pins ^7.0.13 → ^7.1.4 - platform:sync --channel latest → all @open-mercato/* packages on 0.6.4 - result: single @mikro-orm/postgresql@7.1.4 + kysely@0.29.2 (no nested duplicates), pdf-generators and carrier-inpost typecheck clean, yarn install --immutable green
CI fix — platform version alignment (
|
…of raw SQL SalesQuote and SalesQuoteLine are now registered in DI, so the quotes document service can load them through findOneWithDecryption like the orders service, instead of the raw SQL workaround added when the entities were unavailable. Snapshots come straight from the entity; the customer primary-address fallback is preserved.
… selected When no organization is active (e.g. the all-organizations view), the preview/generate routes have no orgId to scope the document fetch and previously rendered an empty PDF or a misleading generic error. The routes now return a coded 'organization_required' 409, and the preview panel maps it to a specific, translated hint via a shared resolveErrorMessage util. The download path no longer tries to save an error JSON as a PDF.
|
Org-scoping for PDF documents Two related fixes:
Commits: |
Summary
Adds
@open-mercato/pdf-generators— a new community module for generating and previewing PDF documents inside Open Mercato. It ships two production-ready built-in templates and exposes a public extension API so any other module can register its own templates with zero changes to this package.What's included
Package —
packages/pdf-generators/GET /api/pdf-generators/templates,POST /api/pdf-generators/preview,POST /api/pdf-generators/generateTemplatesList+PreviewPanelcomponents; PDF tab injected automatically into Orders and Quotes detail pages by the core sales module — no widget registration needed in consuming modulesBaseDocumentServiceabstract class + convention file (pdf-generators.ts) auto-discovered bymercato generate registry; any module can register its own templates without touching this packagepdf_generators.view,pdf_generators.managefeatures; automatically granted to default roles onyarn generateDocs —
packages/pdf-generators/docs/README.mdFull documentation covering:
yarn mercato module add @open-mercato/pdf-generators)yarn generate)BaseDocumentService,formatDate,OpenMercatoLogo, all exported types)Examples —
packages/pdf-generators/examples/A fully working invoice example that mirrors exactly what a consuming module should produce:
pdf-generators.ts— convention file (auto-discovery entry point)invoice/services/example-invoice-document-service.ts— concreteDocumentServiceimplementationinvoice/templates/example-invoice/— React-PDF template component + typed data shapewidgets/— reference widget layout (illustrative only — the core sales module owns the PDF tab)AI Skills
.ai/skills/scaffold-pdf-templates/— Claude Code skill;/scaffold-pdf-templatesscaffolds a full custom template (DocumentService + template component + types + convention file) in one step.ai/skills/code-commenting/— Claude Code skill for JSDoc conventions across all community modulesSpec
.ai/specs/SPEC-005-2026-05-06-pdf-generators.md— full design specSandbox —
apps/sandbox/package.json—"@open-mercato/pdf-generators": "workspace:*"addedsrc/modules.ts— module enabled with{ id: 'pdf_generators', from: '@open-mercato/pdf-generators' }data/cache.db— removed from tracking and added to.gitignore; it is a runtime SQLite cache generated by the sandbox dev server, not a project artifactRoot README
yarn install-skills+ skill slash commands)Installation (for end users)
Add the package:
Register in
src/modules.ts:Regenerate:
A PDF tab appears automatically on Order and Quote detail pages.
Test plan
yarn install && yarn generatein sandbox completes without errorsGET /api/pdf-generators/templatesreturns both internal templatespdf-generators.tsin another module appears afteryarn generate/scaffold-pdf-templatesskill scaffolds all required files correctlyWhat's next
Planned phases described in SPEC-005:
Phase 5 — Generation history
PdfGeneratedDocumentrecord (resource kind/id/label, template used, generated by, timestamp)GET /api/pdf-generators/documentsendpoint — paginated history filterable by resourcepdf-generatorsbackend admin pagePhase 6 — Attachment storage
attachmentsmodule (private partition)attachment_idon the history record — subsequent downloads serve the stored file instead of re-rendering