Publications: modelling, linking, retrieval

How installation manuals, spec sheets, catalogs and warranty documents are
stored, associated with products, and served — on the website, in the CRM, and
to marketplaces.

The operational summary lives in the
publications skill; this is the
long-form reference behind it.

A publication is an Item

There is no Publication model. A publication is a row in items whose
pc_path_slugs descends from goods.publications:

Item.where('pc_path_slugs <@ ?', LtreePaths::PC_PUBLICATIONS_INSTALLATION)

pc_path_slugs is a PostgreSQL ltree column. ILIKE against it raises
PG::UndefinedFunction: operator does not exist: ltree ~~* unknown — use <@.

Relevant columns:

Column Purpose
pc_path_slugs ltree category path — what kind of document this is
publication_locales array of locales the document covers
publication_base_name groups language variants that are separate records
all_pl_paths_slugs product lines the document applies to
literature_id FK to the Upload (STI Literature) holding the file

Four ways a product reaches a publication

PublicationItem — the specific_publications association — is only one of
them. Reading it alone is the single most common mistake here.

  1. Product line. Via primary_product_line.slug_ltree, or every entry in
    product_lines when all_product_lines: true. This is how most products
    reach their manual.
  2. Item-specific. The PublicationItem join.
  3. Kit components. A kit recurses into components whose
    ItemRelation#include_in_spec is true and inherits their manuals.
  4. Product category. product_category_id narrows the product-line pull.

Measured impact

On active Amazon US/CA listings:

Method US (548 items) CA (537 items)
PublicationItem only 36 (6.6%) 37 (6.9%)
product-line heuristic ~116 (28%) ~116 (28%)
all_my_publications 486 (88.7%) 476 (88.6%)

Anything that reimplements the lookup will be wrong by roughly this margin.

The canonical call

item.all_my_publications(
  categories: :all,
  locale: :'en-CA',
  publication_category_paths: [LtreePaths::PC_PUBLICATIONS_INSTALLATION]
)

Item::PublicationRetriever, which backs the www product page
(app/views/www/products/_section_documents.html.erb). Every other consumer —
marketplace feed, export, report — must use it too, so the site and the channel
cannot disagree.

Returns all_publications (flat, sorted, unique) and publications_grouped
(keyed by product_category). The base query is always
.publications.with_publication_attached.active, so a publication without a
file never appears.

Known limitations

These are real and currently unhandled. Anything depending on them must
compensate at the call site, or fix the retriever.

Only the first category path is used

res = res.by_product_category_path(paths.first) if paths.any?

Passing several publication_category_paths silently ignores all but the first.
Call once per path and merge.

Locale has no fallback, and failure is silent

LOCALE_TO_STORE_ID = { en: 1, 'en-US': 1, 'en-CA': 2, 'fr-CA': 2 }.freeze
def self.store_id_for_locale(locale = nil) = LOCALE_TO_STORE_ID[locale&.to_sym]

Any locale outside that hash — :fr, :de, :it, every EU locale — yields
nil. A nil store_id means the in_store filter is skipped, so the call
returns publications for all stores rather than none. It looks like a
successful, generous result. Verify the store resolved before trusting output.

Do not add locale fallback

An earlier draft of this document proposed fr-CA → fr and en-CA → en
fallback. That is wrong and has been removed.

A Canadian French installation manual documents a 120 V product under
Canadian electrical code. A France French manual documents 230 V under
CE. Neither substitutes for the other, and en-CA vs en-US differs the
same way (CSA vs UL). Compliance documentation is the worst possible place to
infer a near-enough match: the failure mode is a detail page showing safety
instructions for the wrong electrical system.

Tag locales explicitly and completely at the source. publication_locales
is an array so one document can declare every market it is genuinely valid for
TOWEL-WARMER-MANUAL-SIERRA-EUROPE-A lists all eight EU locales because it
covers all eight. If a document applies to a market, add that locale to the
array. Never derive it at read time.

Ordering is priority, not specificity

publications.sort_by { |p| [p.product_category_priority, (p.name.presence || p.name_en).downcase] }

It is neither item-specific-first nor most-specific-line-first. Where exactly
one document must win — a marketplace attribute allowing a single value per
selector — that ordering has to be imposed. It belongs in the retriever as a
parameter, not as a re-sort at each call site
, so every consumer agrees.

One PDF can be multilingual

publication_locales is an array. TOWEL-WARMER-MANUAL-SIERRA-EUROPE-A carries
["en","fr","es","nl","de","it","pl","sv"] — one document serving eight
languages. Do not assume one file per language; publication_base_name groups
the variants that genuinely are separate records.

Serving the file

item.literature is an Upload (STI Literature) with attachment_uid /
attachment_name in object storage.

URL Reachable from Notes
/:locale/publications/:sku.pdf?download=true USA/CA storefront only Www::PublicationsController 404s any publication not published to the US or CA store — every EUROPE manual 404s. On-domain proxy (not a redirect), Cloudflare edge-cached; download=true sets Content-Disposition: attachment.
https://api.…/downloads/:token/:file_name?download=true anywhere Token is the whole authorization — no locale or store gate. 302s to a presigned object-storage URL rather than proxying on-domain.

The two rows behave differently on the wire, and it matters for anything that
fetches the URL programmatically. The storefront route proxies the bytes from
warmlyyours.com; the api route answers 302 with a presigned Wasabi location
carrying X-Amz-Expires=86400. That inner URL is re-minted on every request, so
the token URL itself stays durable — but only for a consumer that follows the
redirect each time. One that caches the resolved S3 address gets a link that
dies in 24 hours, and one that refuses cross-domain redirects gets nothing.
Verified 2026-08-10 on TOWEL-WARMER-MANUAL-13-BAR-RIVIERA-EUROPE-A: final
response is 200, Content-Disposition: attachment, application/pdf.

Do not submit a presigned object-storage URL anywhere durable:
Upload#presigned_url clamps lifetime under 7 days.

Token lifetime is a trap

download_tokens.expires_at is NOT NULL, and DownloadToken#set_expiration
defaults it to 30 days. No token is permanent — the expires_at IS NULL branch
in DownloadsController#token_download is unreachable. A URL given to a
marketplace must be minted with an explicit far-future expires_at, otherwise
the link dies after a month and the listing silently regresses.

Marketplace use: Amazon compliance_media

Amazon's GPSR attribute takes content_type, content_language and
source_location, keyed on
marketplace_id + content_type + content_language. Available on US, CA and all
EU marketplaces (24 content_type values; installation manual maps to
application_guide).

Established by live testing (Aug 2026):

  • A seller-hosted URL is accepted and stored — the document need not live in
    Amazon's storage.
  • source_location must be a direct download. A link that opens the PDF in
    a browser leaves the attribute suppressed, and the submission still returns
    ACCEPTED, so the failure is invisible from the response.
  • A wrong-language entry does not by itself cause suppression; a URL that 404s
    does.

Derive the document from all_my_publications at feed time. A hardcoded link
table rots as product lines change, and diverges from what the website shows.