CataSync feeds 3/6: engine named feeds, field repeater, presets, XML/CSV builder
Imported from GitHub issue El-SaMa/oma#246 by @El-SaMa.
Part of the Product feeds epic. Needs "Shop catalog snapshot" live.
What
Any number of named feeds per shop, each fully customizable (fields repeater, filters, format). Example set on niletech: hinta.fi, Hintaseuranta, Hintaopas (Prisjakt), Google Merchant. Feeds can be created from a preset, duplicated, edited, paused and deleted.
Data model
Migration ops/catasync/engine/migrations/016_feeds.sql:
CREATE TABLE IF NOT EXISTS feeds (
id CHAR(36) PRIMARY KEY,
site_id CHAR(36) NOT NULL,
name VARCHAR(80) NOT NULL,
destination ENUM('url','google') NOT NULL DEFAULT 'url',
format ENUM('xml','csv') NOT NULL DEFAULT 'xml',
preset VARCHAR(40) NULL,
token_hash CHAR(64) NOT NULL, -- sha256 of the public token; the token itself is shown once
enabled TINYINT(1) NOT NULL DEFAULT 1,
settings JSON NOT NULL,
fields JSON NOT NULL, -- ordered repeater rows
filters JSON NOT NULL,
built_at DATETIME NULL, item_count INT NULL, build_ms INT NULL, last_error VARCHAR(300) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY (site_id, name)
);
settingsXML:{ rootTag, itemTag, namespaces: {prefix: uri}, wrapper: "rss" | "plain" }. CSV:{ delimiter: "," | ";" | "\t", header: true }. Both:backorderDays(default 24),idPrefix(default empty),includeVariations(default true),shipping: { "wave-products": 9.90, "default": 6.21, "freeFrom": 100, "freeExcludeClasses": ["wave-products"] }(editable per feed).fieldsrow:{ name, kind: "source" | "text" | "template", source?, value?, transform?: "none" | "strip_html" | "truncate" | "upper" | "lower" | "price_number" | "price_with_currency" | "yes_no" | "date_iso", maxLength?, required: boolean, cdata?: boolean }. Max 60 rows.namemay carry a namespace prefix (g:price) for XML; for CSV it is the column header. Whensourceisgallerythe row repeats once per image (forg:additional_image_link).filters:{ stock: ["instock","onbackorder"], requireImage: true, requireDescription: true, minPrice?, maxPrice?, categoriesInclude?: string[], categoriesExclude?: string[], shippingClassesExclude?: string[], catasyncOnly: false, readyOnly: false }.
Sources available to rows
- From
shop_items.data: every connector field (id, sku, ean, mpn, name, short_description, description, permalink, image, gallery, regular_price, sale_price, price, currency, stock_status, stock_quantity, weight, shipping_class, categories, brand, energy_class, lead_text, item_group_id) pluscategory_path_last. - Computed:
availability(Google wordsin_stock/out_of_stock/backorder),availability_fi(Varastossa/Tilaustuote/Loppu)availability_date: today +backorderDaysfor backorder (ISO 8601), else empty. Computed at build time, never stored.delivery_days: instock1-3(no class, f9-products, also-products),5-10(wave-products); backorder14-24shipping_price: fromsettings.shippingby shipping class; 0 when price >=freeFromand the class is not infreeExcludeClassescondition(new),supplier(CataSync offer supplier orown),id_with_prefix
- Templates use
{source}placeholders, e.g.{brand} {name}.
Builder
- New module
ops/catasync/engine/src/feedsout.mjs(not the supplierfeeds.mjs):buildFeed(feedId) -> { contentType, body, count, skipped: { reason: n } }. Streams rows fromshop_itemsfor the site, applies filters, rows and transforms. XML: proper escaping (or CDATA when the row says so), UTF-8, invalid control chars removed. Items missing arequiredfield are skipped and counted. - Cache the built body on disk at
/var/lib/catasync/feeds/<feedId>.xml|csv(create the dir ininstall.sh, owned by the service user). Rebuild after each catalog sync that changed the site's data, and on feed edit. Serve the cached file. - Presets in
ops/catasync/engine/src/feedpresets.mjs, all editable after creation:google_xml: RSS 2.0 withxmlns:g="http://base.google.com/ns/1.0", rowsg:id, title, description, link, g:image_link, g:additional_image_link (gallery), g:availability, g:availability_date, g:price, g:sale_price, g:brand, g:gtin, g:mpn, g:condition, g:item_group_id, g:product_type, g:shipping_weight, g:shipping (country FI + price); filters require image and description.hinta_fi,hintaseuranta,hintaopas_prisjakt: start from the Google XML layout plusdelivery_daysandshipping_pricerows. These sites generally accept Google-format feeds, but check each site's own merchant feed spec before go-live (merchant pages / ask their support) and adjust the preset; note the result in the handbook.csv_basic:id;name;price;url;image;ean;brand;availability;delivery_days;shipping_price, delimiter;.empty: no rows.
- Routes in
server.mjs:GET /v1/sites/:id/feedsPOST /v1/sites/:id/feeds(create from preset or a body; returns the token once)POST /v1/feeds/:id(update),POST /v1/feeds/:id/duplicate(new name, new token once),POST /v1/feeds/:id/rotate-token(token once),DELETE /v1/feeds/:id,POST /v1/feeds/:id/rebuildGET /v1/feeds/:id/preview?limit=5(rendered sample + skipped counts)GET /v1/feeds/by-token/:token(sha256 lookup, enabled feeds only, returns the cached file and content type; 404 otherwise)
- Error codes:
feed_name_taken,feed_fields_invalid,feed_too_many_fields,feed_not_found,feed_disabled,feed_preset_unknown. Add them toKNOWN_ERRORSinservices/core-api/src/catasync/engine.tsin issue 4. - Tests (pure functions): XML escaping, CDATA, gallery repeat, templates, transforms, filters, required skip, CSV quoting, availability date.
Acceptance
- Engine tests pass (
node --test test/*.test.mjs); engine rolled. - On maxfort: one feed from each preset; preview rows correct; XML passes
xmllint --noout; CSV opens in a spreadsheet. - Handbook section "Product feeds" (model, presets, sources list, filters, tokens).
Binding rules (read before coding)
- Read
CLAUDE.mdand every.cursor/rules/*.mdcwithalwaysApply: truefirst. They win over your defaults. - No OpenAI / AI calls anywhere in this feature. CataSync AI switch (
ai_enabled) stays off. Never turn it on. - Smallest correct change. Match the surrounding code style (engine = plain ESM
.mjs, mysql2 pool fromsrc/db.mjs; core-api = TypeScript). - Every staff action goes through the admin MCP (
admin-mcp-first.mdc): executor inservices/core-api/src/agent-tools/tools-catasync.ts, confirm handler inservices/core-api/src/platform/confirm.tsfor writes, registry entry, zod schema inservices/mcp-server/src/tools.ts, test intools-catasync.test.ts. The UI calls the same tool (viaapps/web/lib/catasync/admin-actions.ts), never a separate path. - UI: vertical side nav only (never a second tab row), forms in modals, shadow cards, full width (
portal-chrome.mdc). - Every string:
next-intlkey with native en, fi, sv (Finnish must read native, not translated). No em/en dashes in prose. Runnode scripts/i18n-validate.mjs. - Handbook: update
apps/web/content/admin-docs/products/catasync.md(new section) for every behaviour you add. - Secrets (tokens, Google tokens, feed tokens) never in logs, commits, handbook or chat.
- Commits: author ELSAMA only, no Co-authored-by / AI attribution. Push to
mainright after each commit (rebase onorigin/mainfirst). - Deploy: engine with
bash scripts/catasync-engine-roll.sh figaro; OMA only via the trainbash scripts/deploy-quick.sh(never setOMA_DEPLOY_SERVICES, never build on the VM). Check the live SHA contains your commit. - WordPress plugin changes: bump version, pass
scripts/wp-plugin-gate-remote.sh ops/catasync/plugins/lafu-catasync-connectorwith 0 errors 0 warnings + security check (wp-plugin-dod.mdc). Install on maxfort.fi first (test shop), only then niletech. - Never change niletech prices, statuses, shipping rows or the Code Snippets / table-rate plugins as part of this work.
- Clean up test data and temp files when done.