Skip to content

Feat: PDF generators - #20

Open
kriss145 wants to merge 26 commits into
open-mercato:developfrom
kriss145:feat/pdf-generators
Open

Feat: PDF generators#20
kriss145 wants to merge 26 commits into
open-mercato:developfrom
kriss145:feat/pdf-generators

Conversation

@kriss145

@kriss145 kriss145 commented May 17, 2026

Copy link
Copy Markdown
Contributor

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/

  • 3 REST endpointsGET /api/pdf-generators/templates, POST /api/pdf-generators/preview, POST /api/pdf-generators/generate
  • 2 built-in templates — Order Invoice and Sales Offer (with cover page + quote page)
  • Preview/download UITemplatesList + PreviewPanel components; PDF tab injected automatically into Orders and Quotes detail pages by the core sales module — no widget registration needed in consuming modules
  • Extension APIBaseDocumentService abstract class + convention file (pdf-generators.ts) auto-discovered by mercato generate registry; any module can register its own templates without touching this package
  • Shared design tokens — Inter font (Regular, Medium, SemiBold bundled), color palette, spacing, borders — reusable in custom templates
  • i18n — EN, DE, ES, PL
  • ACLpdf_generators.view, pdf_generators.manage features; automatically granted to default roles on yarn generate

Docs — packages/pdf-generators/docs/README.md

Full documentation covering:

  • Installation (yarn mercato module add @open-mercato/pdf-generators)
  • Step-by-step guide to registering custom templates from another module (4 steps: DocumentService → template component → convention file → yarn generate)
  • Full REST API reference (both endpoints, request/response shapes, preview vs generate decision guide)
  • Full TypeScript API reference (BaseDocumentService, formatDate, OpenMercatoLogo, all exported types)
  • Package structure overview for contributors

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 — concrete DocumentService implementation
  • invoice/templates/example-invoice/ — React-PDF template component + typed data shape
  • widgets/ — 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-templates scaffolds 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 modules

Spec

  • .ai/specs/SPEC-005-2026-05-06-pdf-generators.md — full design spec

Sandbox — apps/sandbox/

  • package.json"@open-mercato/pdf-generators": "workspace:*" added
  • src/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 artifact

Root README

  • Module listed in the Module List table
  • AI Skills setup section added (documents yarn install-skills + skill slash commands)

Installation (for end users)

Add the package:

yarn mercato module add @open-mercato/pdf-generators

Register in src/modules.ts:

{ id: 'pdf_generators', from: '@open-mercato/pdf-generators' }

Regenerate:

yarn generate

A PDF tab appears automatically on Order and Quote detail pages.

Test plan

  • yarn install && yarn generate in sandbox completes without errors
  • PDF tab appears on Order detail page
  • PDF tab appears on Quote detail page
  • Template list loads correctly (built-in order invoice + sales offer visible)
  • Clicking a template card opens the preview dialog with a rendered PDF
  • Download button produces a valid, correctly named PDF file
  • GET /api/pdf-generators/templates returns both internal templates
  • External template registered via pdf-generators.ts in another module appears after yarn generate
  • /scaffold-pdf-templates skill scaffolds all required files correctly
  • i18n works in EN, DE, ES, PL

What's next

Planned phases described in SPEC-005:

Phase 5 — Generation history

  • Persist every generated document as a PdfGeneratedDocument record (resource kind/id/label, template used, generated by, timestamp)
  • New GET /api/pdf-generators/documents endpoint — paginated history filterable by resource
  • History table added to the pdf-generators backend admin page

Phase 6 — Attachment storage

  • After a successful render, upload the PDF buffer to the core attachments module (private partition)
  • Store attachment_id on the history record — subsequent downloads serve the stored file instead of re-rendering
  • Download button in the widget prefers the stored attachment URL, falls back to on-demand render

kriss145 added 4 commits May 17, 2026 19:29
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.
@kriss145

Copy link
Copy Markdown
Contributor Author

@pkarw @dominikpalatynski Could you look into it?

@kriss145

Copy link
Copy Markdown
Contributor Author

I used in the Quote Document a raw SQL because I wasn't able to resolve the "SalesQuote" entity.
That's why I crated a new PR here: open-mercato/open-mercato#1953

@kriss145

kriss145 commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

All official modules should be created with good quality and in a consistent way.
That's why I added also a new skill: .ai/skills/code-commenting/

@pkarw pkarw self-assigned this May 18, 2026
@pkarw

pkarw commented May 18, 2026

Copy link
Copy Markdown
Contributor

🤖 auto-review-pr started by @pkarw at 2026-05-18T10:30:56Z. Other auto-skills will skip this PR until the lock is released.

@pkarw pkarw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/findOneWithDecryption instead of em.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:

  • templateRegistry registration + load semantics (internal/external split, findTemplate throwing Unknown template)
  • BaseDocumentService.getEntries() produces a correctly bound filename (this would have caught C4)
  • QuotesDocumentService.toTemplateData for the snapshot string-vs-object branches
  • formatDate and downloadBlob utilities

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)?.number

Per 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.md step 6: "Register the widget slot in widgets/injection-table.ts"
  • packages/pdf-generators/examples/widgets/injection-table.ts registers 'sales.document.detail.order:tabs', the same slot already registered by the package's own widgets/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.md and .ai/skills/scaffold-pdf-templates/SKILL.md are useful additions. They are not on the critical path.
  • The PR description mentions SPEC-005 in commit ca484c50 but the file is SPEC-004; minor inconsistency.

Suggested fix order

  1. Regenerate yarn.lock (unblocks CI). [C5]
  2. Fix BaseDocumentService.getEntries() filename binding + add a unit test that would have caught it. [C4, H2 starter]
  3. Move @open-mercato/ui to peers/dev and add missing react peerDeps. [H5, H6, H7]
  4. Tenant scope every server-side query in fetchData. [C1, C2, C3]
  5. Swap em.findOnefindOneWithDecryption. [H1]
  6. Rename the spec to a free SPEC ID; add a changeset entry. [H3, H4]
  7. Sort i18n keys and replace Polish fallbacks with English. [M1, M2]
  8. Pick one widget story (auto-rendered by core OR per-module slot), update the skill or the example to match. [M5]
  9. Address remaining Medium / Low.

Happy to re-review once these land. Pinging back so the next push can pick up from latest head.

@pkarw pkarw assigned kriss145 and unassigned pkarw May 18, 2026
@pkarw

pkarw commented May 18, 2026

Copy link
Copy Markdown
Contributor

Thanks @kriss145 — review found release-blocking items (CI red on stale yarn.lock, tenant-isolation in the fetchData hooks, and a quiet filename bug that masks the document number). I'm handing this PR back to you for the next pass. Detailed punch list and suggested fix order are in the review above. When the updates are pushed, re-request review and the automation can pick it up from the latest head.

@pkarw

pkarw commented May 18, 2026

Copy link
Copy Markdown
Contributor

🤖 auto-review-pr completed: CHANGES REQUESTED (5 Critical, 7 High, 7 Medium, 1 Low). Lock released. Autofix not run — the critical findings (tenant isolation in fetchData, encryption-helper bypass) require domain knowledge of OM core's request-scoped tenant/auth context and are better fixed by the original author on the source branch than carried forward in a replacement PR.

@kriss145

Copy link
Copy Markdown
Contributor Author

Thanks! Will check.

kriss145 added 2 commits May 18, 2026 20:35
- 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.
@kriss145

kriss145 commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

@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.

@kriss145

kriss145 commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

C4 — Fix: broken filename binding in BaseDocumentService.getEntries()

base-document-service.ts line 92 — the lambda passed as filename to TemplateEntry was double-wrapping the data object ({ data: { data: ... } }), causing overrides in QuotesDocumentService and OrdersDocumentService to always return the fallback (offer.pdf / invoice.pdf) instead of the variant containing the document number. Changed from (data) => this.filename({ data }) to (input) => this.filename(input).

M2 — Fix: Polish strings used as i18n fallbacks

7 places across 3 files (PreviewPanel.tsx, Loader.tsx, TemplatesList.tsx) were passing Polish strings as the second argument to t('key', fallback). English equivalents were already present in en.json — replaced all fallbacks with the corresponding English strings.

M1 — Fix: unsorted i18n keys

All 4 locale files (en, pl, de, es) had keys in non-alphabetical order. Sorted to pass the CI yarn i18n:check-sync:packages step.

H5/H6 — Fix: @open-mercato/ui in wrong package.json section

@open-mercato/ui was listed under dependencies with a pinned develop canary version (0.6.2-develop.3330...). Removed from dependencies — the package remains in peerDependencies: "^0.6.2" and devDependencies with a concrete version.

H7 — Fix: missing peerDependencies

Added react-dom, @types/react, and react-is to peerDependencies, and react-dom and @types/react to devDependencies.

C5 — Fix: stale yarn.lock

Regenerated yarn.lock after package.json changes. CI step yarn install --immutable no longer reports YN0028.

H3 — Fix: spec ID collision

Renamed SPEC-004-2026-05-06-pdf-generators.md to SPEC-005-2026-05-06-pdf-generators.md — SPEC-004 was already taken by carrier-dhl-parcel.

H4 — Added changeset entry

Created .changeset/pdf-generators-initial.md with a minor bump for @open-mercato/pdf-generators — without it the release pipeline could not version or publish the package.

L1 — Cleanup: redundant comment in config/registry.ts

Shortened the comment on the REGISTRY export — the previous version restated what was already clear from the name and type.

M7 — Note: apps/sandbox/data/cache.db removed from tracking

data/cache.db was previously committed, then removed and added to .gitignore in an earlier commit. The rationale was missing from the commit message — added a one-line explanation to the PR description clarifying it is a runtime SQLite cache generated by the sandbox dev server, not a project artifact.

H1 — Fix: em.findOne replaced with findOneWithDecryption

Both QuotesDocumentService and OrdersDocumentService were calling em.findOne directly on encrypted entities (CustomerEntity, CustomerAddress, SalesOrder), returning ciphertext instead of decrypted values. Replaced all 4 calls with findOneWithDecryption from @open-mercato/shared/lib/encryption/find.

M3 — Fix: downloaded filename now reflects the document number

PreviewPanel was ignoring the Content-Disposition header returned by the /generate endpoint and always saving the file as <template-id>.pdf (e.g. sales-offer.pdf). Now reads the filename from the response header, so the saved file includes the document number (e.g. offer-1234.pdf).

Extracted getFilenameFromResponse into its own utility (utils/getFilenameFromResponse.ts) so it can be reused by any future download flows. downloadBlob remains focused solely on triggering the browser download.

kriss145 added 2 commits May 18, 2026 22:04
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.
@kriss145

Copy link
Copy Markdown
Contributor Author

M6 — By design: widget passes { id } intentionally

The data field in BaseDocumentService.fetchData is typed as unknown by design. The shape of data is a contract between the widget and its DocumentService, not between the widget and the API.

Built-in services (QuotesDocumentService, OrdersDocumentService) only need { id } because they fetch everything else from the database. But an external module implementing its own DocumentService may need to pass additional context — e.g. { id, organizationId } to assist with tenant scoping, or { id, type: 'draft' } if its fetchData requires extra query parameters.

Fixing the contract to { template_id, id, resource_kind, resource_id } would remove that flexibility and force external modules to work around the limitation. The current shape is intentional.

kriss145 added 4 commits May 18, 2026 22:38
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.
@kriss145

Copy link
Copy Markdown
Contributor Author

C1 & C2 — Fix: tenant isolation in fetchData queries

Both document services were fetching records by id only, with no tenant or organization scoping — a user with pdf_generators.view could retrieve data from any tenant by submitting an arbitrary UUID.

Fix:

  • getAuthFromRequest is now called in both route handlers (/generate, /preview) and auth is propagated through renderPdftemplateRegistry.loadfetchData via ctx.auth
  • QuotesDocumentService: SQL WHERE extended with AND tenant_id = ? AND organization_id = ?
  • OrdersDocumentService: ORM where clause extended with tenantId and organizationId from auth context
  • Both services throw explicitly if auth.tenantId or auth.orgId is missing — no silent fallback to unscoped data
  • AuthContext type from @open-mercato/shared/lib/auth/server propagated through all affected interfaces and signatures

@kriss145

Copy link
Copy Markdown
Contributor Author

C3 — Fix: spec updated to reflect actual tenant isolation risk and mitigation

The 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 .ai/specs/SPEC-005-2026-05-06-pdf-generators.md:

  • Documents what is actually fetched server-side: sales_quotes, sales_quote_lines, sales_orders, CustomerEntity, CustomerAddress
  • Describes the mitigation already in place (C1/C2 fixes): getAuthFromRequest in both route handlers, AuthContext propagated through renderPdf → templateRegistry.load → fetchData, every query filtered by tenant_id + organization_id, explicit throw when either value is missing
  • Adds an explicit contract for external DocumentService implementors: ctx.auth is available in fetchData and must be used for tenant scoping — ignoring it is a security defect

kriss145 added 2 commits June 10, 2026 18:39
… 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.
@kriss145

Copy link
Copy Markdown
Contributor Author

M5 — Fix: example widgets clarified for custom slots only

The examples/widgets/ directory is intentional — it shows how to embed TemplatesList in a custom slot when a module wants to surface the PDF tab in a context other than sales orders or quotes (e.g. a shipment detail page, or any resource the host module defines).

The confusion came from the example registering sales.document.detail.order:tabs — the same slot the built-in pdf-generators widget already owns. Copied verbatim, that would produce two PDF tabs on the order detail page.

Fix:

  • Changed the example slot in examples/widgets/injection-table.ts to a generic my-module.custom.resource:tabs placeholder
  • Added a comment explaining the pattern is for custom/non-standard slots only, and that sales.document.detail.order:tabs / sales.document.detail.quote:tabs are already handled by the built-in widgets
  • Updated step 6 in examples/README.md to mark widgets as optional and warn about the duplicate-tab risk

kriss145 added 2 commits June 10, 2026 18:59
…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
@kriss145

Copy link
Copy Markdown
Contributor Author

CI fix — platform version alignment (7926549)

Pushed 7926549 to address the CI typecheck failure that surfaced once the lockfile install was fixed (fallout of C5). Root cause was not in pdf-generators code — it was framework-version drift across the workspace producing duplicate @mikro-orm/postgresql / kysely copies, which broke the dev-snapshot source typecheck with #private is missing errors.

What changed

  • config/platform-channel-policy.json — added @open-mercato/checkout and @open-mercato/webhooks to platformPackages (they were excluded, so platform:sync left them at 0.6.0 and they kept dragging old core/shared@0.6.0 into the tree).
  • apps/sandbox/package.json — bumped @mikro-orm/* pins ^7.0.13 → ^7.1.4.
  • Ran yarn platform:sync --channel latest → all @open-mercato/* packages now on stable 0.6.4 (sandbox + every workspace package's devDeps).

Result

  • Single @mikro-orm/postgresql@7.1.4 + kysely@0.29.2 at root — no nested duplicates.
  • @open-mercato/pdf-generators typecheck: 0 errors
  • @open-mercato/carrier-inpost typecheck: 0 errors ✅ (was the main CI blocker)
  • yarn install --immutable: green ✅

Notes

  • We deliberately pinned to stable 0.6.4 (latest channel) rather than the develop snapshot for a lighter dev profile; CI's platform:sync --check only runs for main-based PRs, so this is fine on a develop-targeted PR.
  • Still open from the review and unrelated to versions: H2 (no unit tests in the package) and M6 ({ id }-only widget contract).

kriss145 added 2 commits June 11, 2026 18:42
…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.
@kriss145

Copy link
Copy Markdown
Contributor Author

Org-scoping for PDF documents

Two related fixes:

  • refactor: quotes now load via the SalesQuote DI entity (findOneWithDecryption) instead of the raw SQL workaround — the entities are registered in DI now, so it matches the orders service.
  • feat: when no organization is active (e.g. the all-organizations view), the preview/generate routes have no orgId to scope the fetch. Instead of an empty PDF or a misleading generic error, they now return a coded organization_required 409 and the preview panel shows a specific, translated hint ("Select an organization to generate this document."). The download path no longer tries to save an error JSON as a PDF.

Commits: b436b9e, 8422928.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants