Completes the Upstream Inventory milestone that started in 0.12.0 by closing the last two functional gaps: buyer-initiated order placement (with a real PDF order document and email-to-supplier notification) and a one-click "Mark received and add to stock" transactional flow on inbound batches. Plus the long-overdue DataGrid migration of every upstream-feature table so the customer portal feels consistent end to end, and native ZPL/PDF label printing for SUPPLIER tenants so the InDesign-to-PDF workaround goes away.
Added
- Buyer-initiated order placement. The previously placeholder "Place order" tab on the buyer-side Suppliers detail page is now functional. Editable qty and unit-price columns next to each subscribed SKU, submit creates one
UpstreamOrder row per line under a shared batchId. Bulk-place limited to 500 lines per request. Validates qty positivity per line before any rows hit the database. Buyer sees a success banner with the batchId + a link to download the consolidated PDF order document. - PDF order document. Server-side
pdfkit-rendered A4 purchase-order layout with header (reference + placed date), buyer + supplier party blocks (name, address, phone, web, tax ID), zebra-striped line-items table (SKU, description with brand, qty, unit price, line total), and a running total when prices are present. Generated on-demand from the database; both buyer GET /api/app/upstream/contracts/:id/orders/batch/:batchId/pdf and a supplier-side mirror endpoint serve the same document so either party can re-download from their own portal. Document states it's a record of the order placed, not a contract or invoice. - Email notification to supplier on new order. All supplier-tenant portal users (owner / admin / member roles) get a Resend-sent email with the buyer name, line count, batch reference, and a CTA button linking directly to the customer detail in their PowersportOS portal. Plain-text fallback included. Failures are logged and the order itself still saves; the email is best-effort.
- Dashboard "Pending orders" card + attention list. The SUPPLIER dashboard gains a sixth stat card showing the count of PENDING upstream orders across all customers (orange when > 0, grey otherwise). When any pending orders exist, a new attention table at the top of the dashboard surfaces the most recent ones with clickable buyer name, part number, qty, placed date, and batch reference. Supplier no longer has to poll the per-customer Orders tab to know an order arrived.
- Mark received & add to stock. New POST
/api/app/upstream/inventory/inbound/:id/receive flips an inbound batch's status to RECEIVED and increments the parent SupplierPart.qty by the batch's qty in a single Prisma transaction. Inbound batches dialog gets a green "Receive" button on each ORDERED / IN_PRODUCTION / IN_TRANSIT row, replacing the previous two-step manual flow that easily drifted out of sync. Confirms first via dialog since the qty bump is hard to undo without manual reversal. - SUPPLIER label printing. The existing label-print pipeline now resolves
SupplierPart rows for SUPPLIER tenants, so the standard /labels bulk-print page and a per-row print action on Inventory both produce ZPL or PDF labels the same way they do for My Catalog / My Parts / My Vehicles. When the SupplierPart is linked to a central Part, all the rich fields (brand, EAN, dimensions, OEM cross-refs, country of origin) come from the linked catalog row; loose rows render with just partNumber + name. Ends the previous workaround of building labels manually in InDesign and emailing the PDF to the supplier. - Labels in SUPPLIER sidebar. New
/labels entry between Customers and Settings for SUPPLIER tenants so the bulk-print page is reachable from navigation, matching the placement on other tenant types.
Changed
- All upstream-feature tables migrated to MUI X DataGrid. Inventory, customer-detail Stock / Shipments / Orders, supplier-detail At-supplier / In transit / On order, and the dashboard attention lists now use the same
@mui/x-data-grid + GridToolbar setup as My Catalog / My Parts / My Vehicles. Column sorting on header click, quick search, density toggle, column visibility, CSV export, and pagination come for free from the toolbar. Stock-tab inbound batches that previously inline-expanded now open in a modal, consistent with the portal's existing detail-in-a-dialog pattern. - Inventory cell editing via DataGrid. Qty, name, master box, and UoM edit through DataGrid's
processRowUpdate flow: double-click or Enter to start, Tab / blur to commit, Escape to cancel. Validation runs before the PATCH; failures revert the cell and surface in the page-level error banner. - Incoming-to-supplier column styling. Bold + warning-orange (#F59E0B) for the qty number so the planning-attention value reads at a glance instead of blending into the row.
Upstream Inventory milestone. PowersportOS now models the relationship between a buyer-tenant and the supplier upstream of them as a first-class object: contracts, shared-pool inventory, customer- specific subscriptions, inbound pipeline tracking, and labels.
The trigger was a real operational use case: Lionparts EU buys from a Chinese supplier (RuiBo) who in turn orders from factories. Before this release, none of those flows were visible inside PowersportOS, so stock-planning lived in spreadsheets and labels lived in InDesign. After this release, both buyer and supplier see exactly the same data from their own perspectives, with the supplier doing the data entry once and every subscribed buyer seeing the result immediately.
Added
SUPPLIER tenant type. New value on the TenantType enum alongside MANUFACTURER, DISTRIBUTOR, RETAIL, etc. Supplier tenants run a stripped-down portal (Dashboard, Inventory, Customers, Labels, Settings, Help) since they don't sell to end customers directly. Authentication, billing, and the rest of the platform behave identically to other tenant types.- Upstream contracts. Admin-managed
UpstreamContract rows pair a buyer-tenant with an upstream-tenant (typically a SUPPLIER). The contract is the trust boundary: data only flows where a contract exists, both sides see what the contract grants. Currency, active flag, and free-text notes per pair. - Shared-pool supplier inventory. A single
SupplierPart row per (supplier-tenant, SKU) carries the physical warehouse stock + box size + UoM + product name. Per-buyer UpstreamStockSubscription rows give buyers visibility on specific SKUs from the shared pool. Updating qty once in /inventory propagates instantly to every subscribed buyer. The supplier no longer maintains separate stock numbers per customer. - Inbound pipeline. New
SupplierInbound rows track batches the supplier expects to receive into their own warehouse (sub-supplier orders, factory production runs, in-transit deliveries). Statuses cover the lifecycle: ORDERED, IN_PRODUCTION, IN_TRANSIT, RECEIVED, CANCELLED. Buyers see the pipeline alongside current stock so they can plan against incoming, not just on-hand. Subscribed buyers see the same batches the supplier sees, in read-only mode. - Per-buyer Stock + Shipments + Orders. Each contract gets its own stock subscription list, outbound shipment log, and order log. Suppliers manage shipments + orders directly; buyers see them in real time from their Suppliers detail page. Status lifecycles for shipments (
IN_TRANSIT, DELIVERED, CANCELLED) and orders (PENDING, CONFIRMED, IN_PRODUCTION, FULFILLED, CANCELLED) drive the UI. - Central-catalog linking on supplier inventory. A new optional FK from
SupplierPart to central Part lets the supplier link their inventory to a real catalog entry. When linked, the supplier gets all the rich catalog data for free (brand, name, EAN, dimensions, OEM cross-refs, country of origin). When not linked, the row stays as a loose SKU with manually-entered name only, which is fine for one-offs and parts not yet in any catalog. - Resolver-driven linking. When the supplier adds a SKU in
/inventory, a live /resolve lookup searches the central Part table directly, then OemCrossReference, then the supplier's own TenantSkuAlias rows. Multi-match shows all candidates with the matched-via tag (direct / OEM / alias) and lets the supplier pick. Buyer-side subscribe-flow runs the same resolver against the buyer's tenant context, so two buyers using different local aliases for the same central Part land in the same SupplierPart. - Orphan-state tracking.
partNumberAtLinkTime snapshots the canonical SKU at link time. If a central Part is later deleted, the SupplierPart row stays intact, the link drops to null, and a warning chip in the UI exposes a one-click relink flow with the original SKU pre-filled. - Supplier Dashboard. A new landing page for SUPPLIER tenants with five headline counts (customers, subscribed parts, inventory parts, out-of-stock, overdue inbound) and two attention lists (out-of-stock SKUs with subscriber + incoming-pipeline context; overdue inbound batches with days-overdue + status). Capped at 10 rows per list with true totals on the headline tiles so the supplier sees "5 of 12 shown" rather than a misleading partial count.
- Bulk subscribe picker. The supplier-side Subscribe-part flow now shows the buyer's full catalog plus all central Parts plus the supplier's own loose inventory in one paginated picker with filter toggles (All / Unsubscribed / In buyer's catalog / Not in buyer's catalog / Loose). Multi-select + "Subscribe N selected" replaces the one-at-a-time typing of the previous version. Source chips (Own / Not in buyer's catalog / Loose) tell the supplier exactly what they're working with on each row.
- SUPPLIER label printing. The existing label-print pipeline now resolves SupplierPart rows for SUPPLIER tenants, so the standard
/labels bulk-print page and the per-row print action on Inventory both work the same way they do on My Catalog / My Parts / My Vehicles. When a SupplierPart is linked to a central Part, labels render with full brand + dimensions + EAN + OEM cross-refs + country-of-origin data; loose rows render with just partNumber and name. Ends the previous workaround of doing labels in InDesign and emailing PDFs to the supplier.
Changed
- Customer portal feature tables. Inventory, customer-detail Stock / Shipments / Orders, supplier-detail At-supplier / In transit / On order, and the dashboard attention lists all use MUI X DataGrid now, matching the rest of the portal (My Catalog, My Parts, My Vehicles). Column sorting on header click, quick search, density toggle, column visibility, CSV export, and pagination come for free from the GridToolbar. Stock-tab inbound batches that previously inline-expanded now open in a modal, consistent with the portal's existing detail-in-a-dialog pattern.
- Inventory cell editing. The
/inventory page edits qty / master box / UoM / name as DataGrid editable cells. Double-click or Enter to start editing, Tab / blur to commit, Escape to cancel. Save errors revert the cell and surface in an alert banner. - Auto-link on subscribe. When a buyer subscribes to a part through the picker, the auto-created SupplierPart in the supplier's inventory comes pre-linked to the matching central Part if one exists. The supplier doesn't have to manually link every new SKU.
Analytics & Data Network milestone, Phase 0. PowersportOS now runs its own self-hosted analytics infrastructure with a first admin-side surface inside the platform itself. This release is the architectural bottom layer of what the public roadmap describes as the major Analytics & Data Network milestone: a cookieless, GDPR-compliant analytics layer for the public marketing site today, with per-tenant analytics in the customer portal and cross-tenant network aggregates planned for successive releases.
Alongside Phase 0, the customer-portal "What's new" modal caught up to feature parity with the public /changelog page (italic and [link](url) markup now render in both), the marketing site gained two substantial new public surfaces (/why as a long-form pitch narrative and a revised /for-distributors hero anchored on the federated-commerce tagline), and the public roadmap formally announces the Analytics & Data Network milestone as IN DEVELOPMENT / MAJOR.
Added
- Self-hosted Umami analytics at
analytics.powersportos.com, deployed in Coolify on the same Hetzner host as the rest of PowersportOS. Tracks only the public marketing site (powersportos.com), not the customer portal or admin surfaces. Cookieless, no visitor IP storage, no consent banner required. Tracking script lives in apps/web/src/layouts/Layout.astro and loads on every public marketing-site page. Privacy Policy 1.3 documents the new instance in section 2.4 (technical data) with a 12-month visit-data retention and in section 7.3 (cookies and similar) clarifying that no analytics runs on portal or admin. - Admin Site analytics page at
/analytics on admin.powersportos.com. Four endpoints under /api/admin/analytics (summary, pageviews, metrics, active) proxy the upstream Umami API through requireAuth, so the Umami service credentials never round-trip client-side. 60-second in-memory response cache softens multi-tab opens. Page shows visitor / visits / pageview / bounce-rate / average- duration cards with a period toggle (24h / 7d / 30d / 90d), a lightweight inline SVG pageviews chart, plus top-pages, top-referrers, top-countries, and top-browsers tables. A live-now visitor indicator sits in the period bar. Falls back to a tasteful "Analytics not configured" banner if the UMAMI_URL / UMAMI_USERNAME / UMAMI_PASSWORD / UMAMI_WEBSITE_ID env vars are not set. - Public
/why pitch page as a seven-section long-form narrative laying out the platform vision in sequence: the dropship-vs-stock-holding data asymmetry, the structural position PowersportOS occupies in the value chain, the federated-commerce framing ("Chain, but not in chains"), the operating layer already running, the four S&OP-grade data points the Analytics & Data Network will expose, the compounding loop, and the early-access ask. Stays inside the existing navy + orange palette; the extra visual punch comes from giant section numerals at the head of each section, atmospheric radial gradients, and inline-coded UI motifs (chain diagram, parallels grid, position table, data-point cards, compounding-loop SVG). - Analytics & Data Network spotlight on
/roadmap marked MAJOR / IN DEVELOPMENT. Documents the five data tiers in order of feasibility from already-possible (catalog coverage, YMM search trends) through next-realistic (sales velocity via the Phase D Custom App credential plus read_orders scope) to the Tier 6 combined visit + order funnel as a join layer on top. Names the four data points an S&OP team uses (sell-through velocity, inventory aging, search-to-sale ratio, regional demand patterns), the non-negotiable guard-rails (per-tenant opt-in, k-anonymity techniques, no per-tenant attribution in cross-tenant views, end-customer privacy intact), and the status of what is and is not built yet.
Changed
/for-distributors hero replaced with a custom inlined hero centred on the federated-commerce tagline "Chain, but not in chains." Billboard-grade typography, a payoff line that resolves the wordplay, then the previous operational pitch ("Push your range to every reseller's Shopify, in hours.") demoted to subhead. Page meta description updated to lead with the federated-commerce framing.- Portal "What's new" modal renders the same markup as the public
/changelog page. The shared-ui formatInline helper caught up to feature parity with the public page's renderInline: bold and ` code were already supported; this release adds italic and [link](url) rendering. Modal and public page now produce equivalent output from the same upstream /api/version` response. Comment in both files flags the parity requirement so the two helpers stay in sync. - Privacy Policy 1.3 (effective 8 May 2026, updated 26 May 2026). Section 2.4 (technical data) describes the self-hosted Umami instance and 12-month retention. Section 7.3 (cookies and similar) makes the marketing-site / portal- admin distinction explicit: cookieless analytics on the public site, no analytics tracking anywhere in the portal or admin.
Phase E lands: Channel Communications, a multi-kind message bus that lets manufacturer, distributor, and data-provider tenants publish once and route content (blog posts, product releases, operational alerts, safety recalls) through the brand-subscription graph to opted-in retailer / reseller / hybrid / standard tenants. One engine, one schema, an adapter pattern that ships a Shopify Admin blog target in v1 and leaves the door open for WordPress / intranet CMS / generic webhook without touching the core.
Beyond the feature itself this release is also where PowersportOS crosses the line from "platform that hosts our customers' data" to "platform that intermediates third-party Content between customers". That comes with DSA Article 6 hosting-service classification, two new module-specific terms documents that owners and admins must click-through accept, a voluntary transparency report at /legal/transparency, and a recall-acknowledgement workflow with a 48-hour SLA captured in the audit trail.
Added
- Multi-kind content message bus. Four post kinds at the schema level (
BLOG_POST, PRODUCT_RELEASE, OPERATIONAL_ALERT, RECALL). Post, PostCategory, TenantPostSubscription, TenantPostPublication, RecallAcknowledgement, and TermsAcceptance tables, plus Tenant.shopifyAdminCredentials encrypted-bytes column. All schema additions land as one additive migration (20260524000000_phase_e_content_distribution), no destructive changes. - Author surface at
/content/publisher for MANUFACTURER / DISTRIBUTOR / DATA_PROVIDER tenants. Drawer-based compose with title, kind, summary, HTML body (server-sanitised via the same allow-list parser as Part descriptions), canonical URL (required at publish, set as a Shopify metafield on each delivery), hero image URL, free-form categories, edit-scope picker (LOCKED / MINOR / FULL, forced LOCKED for RECALL). Inline SKU markup [[sku:NNN]] resolves to subscriber-specific part titles at delivery time. - Subscriber surface at
/content/feeds (Sources + Targets tabs) and /content/inbox (Pending / Scheduled / Published / Declined-or-Failed tabs) for RETAIL / RESELLER / HYBRID / STANDARD tenants. Per-subscription mode (QUEUE vs AUTO_PUBLISH), category filter, schedule offset, rate cap, author-name override. Targets tab proxies the Shopify Admin blog list so adding a publish target is a one-click pick from the actual blogs on the merchant's store. - Publish fanout + queue worker. When an author publishes a post the API creates one
TenantPostPublication per matching subscription with the right initial status (SCHEDULED for AUTO_PUBLISH, PENDING for QUEUE), applying the per-subscription offset and a small jitter. A 30s-polling worker (startContentPublicationQueue) picks scheduled publications up, resolves [[sku:NNN]] markup and {store_name} / {store_url} / {publish_date} placeholders, invokes the adapter, and writes back delivery status with externalId + externalUrl. - Adapter pattern from day one.
ContentPublishAdapter interface (validate / publish / update / unpublish) lives in lib/contentAdapters/. The Shopify Admin blog adapter (articleCreate, articleUpdate, articleDelete, plus blog query and canonical-URL metafield) ships in v1; future targets plug in via the same interface. All GraphQL mutations validated against Shopify Admin 2024-10 schema. - Force-delivered safety recalls.
RECALL posts bypass every subscriber category filter, rate cap, mute state, and queue mode; force-schedule for immediate delivery without jitter; forced LOCKED edit scope regardless of the author's selection; trigger a parallel email to each subscriber's technical / billing contact via Resend; surface as a persistent red RecallBanner across the subscriber portal with poll every 60s until acknowledged; cannot be Declined by the subscriber. - Recall acknowledgement workflow. Owner or admin role only.
POST /api/app/content/recall/:postId/ack records userId, role at the moment of acknowledgement, IP address, user agent, and timestamp; idempotent via the (postId, subscriberId) unique constraint. The acting user is responsible for actual product- safety action (stop sales, customer notification, regulator reporting) under applicable law in their jurisdiction. 48-hour SLA per the Channel Subscriber Terms. - Click-through Channel Communications terms.
CONTENT_AUTHOR and CONTENT_SUBSCRIBER additional terms, versioned per kind (currently 1.0.0), accepted per tenant at the current version with TermsAcceptance rows capturing userId, IP, and user agent. ContentTermsGate component blocks the Publisher / Feeds pages until acceptance lands; requireContentTermsAccepted middleware double-gates the two write endpoints (/posts/:id/publish and /subscriptions POST) at the API level with a structured CONTENT_TERMS_REQUIRED error code. - Encrypted-at-rest Shopify credentials. AES-256-GCM
encryptJsonBlob / decryptJsonBlob using the existing APP_ENCRYPTION_KEY; new Tenant.shopifyAdminCredentials bytes column replaces the JSON-in-settings path. One-shot migrate-shopify-credentials script ports existing connections; read helpers fall back to legacy JSON until migration runs. - Public legal documents at
/legal/channel-author, /legal/channel-subscriber, and /legal/transparency. The two module-specific terms cover author representations, DSA Article 6 hosting classification, liability allocation, recall obligations, notice + counter-notice procedure, edit-scope policy, publish-target credential handling. The transparency report follows the spirit of DSA Article 15 with sections for notices received, authority orders, provider-initiated moderation (none), use of automated means (none), complaint- handling, Article 11 + 12 single points of contact, and methodology. - Eight portal help articles in a new "Content distribution" section: overview, composing a post (author), edit scopes, publishing a safety recall (author), subscribing to an author (subscriber), publish targets (subscriber), the Content Inbox (subscriber), terms acceptance and legal docs.
Changed
- Public roadmap. Content distribution moved from "Coming up" to "Recently shipped" with this release's bullets. Tenant role- based access added as the third "Coming up" card. v0.7.0 drops off the shipped grid to keep three. Footer meta-legal row gains
/legal/transparency. /terms section 17 (Entire agreement) and /dpa footer cross-link the two new module-specific terms documents plus the transparency report. - Public footer. New LinkedIn link with inline icon next to the existing Sign in / System status / mailto cluster, pointing at the PowersportOS showcase page.
E.0 round: Data Provider tier + delegated brand-managed catalog. PowersportOS now has two new tenant types (DATA_PROVIDER and DISTRIBUTOR) and a brand-permission model that lets data-curating tenants write directly to the central catalog scoped per brand, without admin handholding. CSV upload and direct JSON API are both wired up; per-provider adapters for XML, REST, FTP and proprietary feeds are built per relationship as customers come on board.
The shape of the day-to-day operator experience: a brand owner gets a gratis Data provider tenant with brand permissions on the brands they own; the portal sidebar strips down to Dashboard, Brands, Parts, Import, Settings, Help; they upload CSV or push via API; the data lands in central catalog scoped to their brands and flows to every retailer subscribing to those brands. Free upgrade to Manufacturer or Distributor when the relationship matures.
This release also extends the central catalog data model: a new ProductDocument table for attaching manuals, fitting instructions, service manuals, exploded views, spec sheets, warranties, and certificates per Part (R&G Racing-style "separate fitting instructions file" is now a first-class kind, not an overloaded manual URL). Lifecycle hardening on managed-parts blocks destructive deletes once retailers have activated a part; the status enum plus replacedById pointer is the right tool when an SKU is going away.
Added
DATA_PROVIDER added to TenantType. Gratis tier. Tenant curates central catalog data for the brands they own; no storefront features, no content publishing, no dealer or YMM-widget surfaces. Sidebar in the portal is stripped to Dashboard / Brands / Parts / Import / Settings / Help.DISTRIBUTOR added to TenantType. Channel partner sitting between manufacturer and reseller. Gets the full operational portal plus the Managed brands and Managed parts surfaces.TenantBrandPermission table with role enum (READ / WRITE / OWNER). Grants per (tenant, brand) pair. Granted by admin from the tenant detail page (new Brand permissions tab). Every grant and every subsequent write is captured by the platform audit log.requireBrandManagedTenant middleware gates the managed-* surface to tenants whose type is in (DATA_PROVIDER, MANUFACTURER, DISTRIBUTOR). Other types get 403 before any per-brand check runs.- Per-brand permission check inline in route handlers via
assertBrandPermission(tenantId, brand, minRole). Throws BrandPermissionError that handlers convert to 403 with the brand and required role in the response body. GET / POST / PUT / DELETE /api/app/managed-brands for listing the tenant's brands, reading single-brand metadata, and updating Brand fields (logoUrl, description, countryOfOrigin, website, contact). OWNER role required for updates; name and slug stay admin-only.GET / POST / PUT / DELETE /api/app/managed-parts for CRUD on central Part rows scoped to permitted brands. POST asserts WRITE on body.brand; PUT asserts WRITE on the current brand plus the new brand if body changes it. DELETE blocks with 409 if the part has any retailer activations and points the caller at the lifecycle path.POST /api/app/managed-parts/import for CSV upload. Reduced column set: part_number, brand, name, category_path, description, dimensions, EAN, country_of_origin, hs_code, status, image_url_1..3, manual_url. Brand-permission gate per row. Rows with out-of-scope brands skip with a warning instead of erroring the batch.GET /api/app/managed-parts/import/template returns the CSV template with one example row.ProductDocument table with kind enum: MANUAL, FITTING_INSTRUCTIONS, SERVICE_MANUAL, EXPLODED_VIEW, SPEC_SHEET, WARRANTY, CERTIFICATE, OTHER. Same pattern as ProductMedia for images: rows per (part or tenantPart), bucket-mirrored URL, original-source URL, file metadata, optional language code and label override, ordering plus primary flag.Part.manualUrl stays as a cache for the primary MANUAL document so existing theme + datasheet rendering keeps working.- ProductDocumentsSection shared-ui component renders the documents list grouped by kind with color-coded chips, file metadata, and download links. Wired into admin Part detail (new Documents tab) and portal catalog detail (section after fitments).
PartStatus and replacedById now accepted in managed-parts POST / PUT. Successor Part must live under a brand the tenant has at least READ on (anti-griefing). Self- reference rejected with 400./managed/brands and /managed/parts for tenants with brand permissions. Sidebar item shown as "Brands" / "Parts" for DATA_PROVIDER (their primary surface) and as "Managed brands" / "Managed parts" for MANUFACTURER and DISTRIBUTOR alongside the existing My Catalog and My Parts.- Settings page gains an Account upgrade card visible only for DATA_PROVIDER tenants. Submit button posts to /api/contact with
inquiryType=upgrade-request. - Brand permissions tab on tenant detail. Lists granted permissions with inline role dropdown for quick changes, delete button per row, grant dialog with brand autocomplete (seeded from existing brand-subscriptions endpoint) and a role picker with helper text per role.
- Tenant type picker in the form gets two new options: DISTRIBUTOR (success / green chip) and Data provider (free, default chip).
- About account types dialog in the customers list gets two new TypeSection blocks describing the new tiers and their commercial framing.
- New
/docs/data-ingest public docs page covering the ingest architecture: CSV upload + direct JSON API as the two paths live today; per-provider adapters (XML / REST / FTP / proprietary) as the per-relationship build pattern, scoped honestly. Brand-permission model, lifecycle as alternative to delete, what gets normalised (ISO country codes, HS code validation, HTML sanitisation, year-range expansion, asset mirroring) vs what stays verbatim. /docs/tenant-types extended from five to seven types with DATA_PROVIDER and DISTRIBUTOR descriptions plus the bi- directional flip path between them and paid tiers./docs/two-layer-data-model gains a delegated-central-writes subsection describing the brand-permission model./docs/csv-import-format adds a third import-surface row for managed-parts CSV./docs/pim-light editing-surfaces list extended from three to four with the managed-brands / managed-parts entry./data-providers landing page rewritten with a "How it works in practice" section that walks through the actual operator experience step by step (admin grants permissions, tenant logs in to stripped-down portal, picks an ingest path, platform normalises, lifecycle handles sunset SKUs, upgrade flips to paid tier). Hero copy and meta description tightened to flag "live today" rather than "apply for partnership".- Touch-ups across
/for-distributors, /for-manufacturers, /pricing, /platform to reflect the new live capabilities. - Five new portal help articles in a dedicated Brand-managed catalog section: Overview, Roles, CSV import, Lifecycle, Account upgrade.
- Two new admin help articles under Customers (tenants): Brand permissions and Data provider tenants.
- Account upgrade request flow. Tenant-side "Request account upgrade" button on Settings (DATA_PROVIDER only) opens a dialog with desired-tier dropdown and free-text message field. Submits to /api/contact with
inquiryType=upgrade-request. Backend wires it through the existing contact pipeline; new email template upgradeRequestEmail formats the inbound for staff.
Changed
requireBrandPermission middleware introduced a new pre-route check; the managed-* routes use it stacked on top of requireTenantUser. Per-brand checks remain inline so the brand argument can be pulled from body / param / query per endpoint.ImportJobSource enum gains CSV_MANAGED value so the new managed-parts importer writes ImportJob rows distinguishable from CSV_ADMIN (admin central imports) and CSV_TENANT (tenant-side TenantPart imports).- Tenant type form helper text updated to cover all seven types with explanatory text per option in the form picker.
- Roadmap on the public site moves the E.0 entry to Recently shipped under v0.9.0. The Manufactured-content distribution and other future items stay in Coming.
Shopify product push, end to end. PowersportOS catalog now writes into a customer's Shopify store with one click or as a bulk batch: single SKUs, variant groups becoming multi-variant Shopify products with per-variant images, a per-tenant push profile controlling which fields overwrite vs leave alone on re-push, a Shopify-state modal that reads back what's live, and a loop guard on the inbound stock-feed so webhook echoes from the merchant's inventory worker don't pollute the sync logs. The credential model is a customer- managed Custom App in the merchant's own Shopify admin, exchanged via OAuth client_credentials. Strategically, the legacy Shopify App track was scrapped during this release; theme + tenant API key for inbound and customer-managed Custom Apps for outbound is now the only integration story.
Phase D rests on a PIM-Light expansion shipped in the same window: variant grouping at the schema layer, HS code, SEO title and description, PIM pricing (cost + compare-at), multi-image CSV import via image_url_1..9 columns, server-side WebP optimisation on CSV-fetched images, and a country-of-origin migration to ISO 3166-1 alpha-2 so the country field can be pushed cleanly to Shopify. Plus opt-in TOTP two-factor on admin accounts (portal mirror in a follow-up), multi-tenant access that lets one user hold membership in N tenants and switch in-session without re-auth, an auto-generated product datasheet PDF, a full rewrite of the Terms / Privacy / DPA for pre-launch legal hygiene, and a public roadmap page so prospects can register interest in features on the queue.
Added
- One-click push from any catalog or own-part detail page. A Shopify card in the sidebar runs the full create-or-update cycle: first click creates the product as Draft; subsequent clicks update the same product via the mapping table. Pushes go via Shopify Admin GraphQL
productSet mutation (schema 2024-10), which replaces the legacy productCreate + productVariantsBulkUpdate dance with a single atomic call. - Bulk push on My Catalog and My Parts. Select N rows via the grid checkboxes, click Push to Shopify, watch live progress (succeeded / failed / remaining counters update in real time, failure list per SKU at the bottom). One job per tenant runs at a time; closing the dialog mid-batch keeps the job running server-side and reopening re-attaches. Cancellable. In-memory job state retained 1 hour after completion.
- Variant-group push. Parts grouped via
PartVariantGroup (central) or TenantPartVariantGroup (tenant-owned) push as one Shopify product with N variants, each carrying its own SKU, price, stock, weight, EAN, HS code, country of origin, and image. Per-variant images ride along on productSet.variants[i].file in the same call rather than a separate productCreateMedia. The push card surfaces a blue banner before the click ("This SKU is part of a variant group, push affects all N variants as one Shopify product") so the operator never triggers a multi-variant push by accident. - Per-tenant push profile. Per-field policy for create vs update lives at
Settings - Shopify push profile. Content fields (title, description, vendor, productType, tags, status, SEO, image) default to leave-alone on update so manual Shopify edits aren't wiped on every re-push. Operational fields (price, compareAtPrice, costPrice, weight, EAN, HS code, country of origin, stock) default to overwrite on update because PowersportOS is the authoritative source. Tenant can flip any field independently. - Push result severity split. Yellow warnings for things that need attention (Shopify userErrors, missing default location, non-ISO country code, image overwrite that wiped manual Shopify media). Green info notes for "ran as configured" outcomes (which fields the profile kept untouched). Operator can distinguish "needs my attention" from "expected behaviour" at a glance.
- Shopify state modal. A View button on the push card opens a side-by-side modal showing what's currently in Shopify for that product: title, vendor, status, tags, SEO, plus a variants table when the product has more than one variant. Useful for verifying that the right number of variants landed and that per-variant overrides made it through.
- Stock-feed loop guard. Outbound pushes that include stock record
lastPushHash = "stock:<n>" on the per-part mapping. The POST /api/integrations/stock-feed endpoint checks the hash on each inbound row: matching value within a 5-minute window is treated as a webhook round-trip from the merchant's own inventory worker and skipped, counted separately as echoSkipped in the response. Outside the window or different value, the inbound write proceeds normally. - Customer-managed Custom App credentials. Merchant creates a Custom App in their own Shopify admin (Settings - Apps and sales channels - Develop apps), grants the scopes we publish (
write_products + read_products + read_inventory, plus write_inventory for stock and read_files for images), and provides the Client ID + Client Secret in our portal Settings. We exchange those for short-lived OAuth tokens via client_credentials on each call. Works on every Shopify plan tier. Replaces the legacy long-lived shpat_ token model that Shopify deprecated 2026-01-01. - Variant grouping at the schema layer. New
PartVariantGroup and TenantPartVariantGroup tables with name + option1Name + optional option2Name / option3Name. Parts get variantGroupId + option1Value + variantPosition columns. Group becomes one Shopify product on push; members order by variantPosition (position 0 supplies master title + description + vendor). Admin and portal both get a Variant Grouping tab on the part-detail page with full CRUD. - HS code on Part and TenantPart. New
hsCode column with API-side format validation (6, 8, or 10 digits). Maps to Shopify inventoryItem.harmonizedSystemCode on push. Editable from admin part-create + edit, and from portal My Parts. - SEO + tags + PIM pricing override on TenantCatalogItem and TenantPart. New fields:
seoTitle, seoDescription, tags[], compareAtPrice, costPrice. Edited from portal My Catalog (override layer) and My Parts (own). Tags drive Shopify product tags; SEO drives product.seo; pricing splits cost (for inventoryItem.cost) from price (for variant.price) from compare-at (for variant.compareAtPrice). - Multi-image CSV import. CSV importer (admin + tenant side) now reads
image_url_1 through image_url_9, fetching each image, uploading to our bucket, and populating the ProductMedia gallery with primary-flag and ordering. Idempotent on re-import via the new ProductMedia.originalUrl dedup column. Downloadable CSV templates updated; docs page updated. - Country of origin as ISO 3166-1 alpha-2. Free-text country values migrated to ISO codes via a one-shot normalisation script. Country dropdown on part-detail pages with full ISO list (260 entries) sourced from packages/shared-ui. Required so the country field can be pushed cleanly to Shopify, which only accepts ISO codes on
inventoryItem.countryCodeOfOrigin. - Server-side WebP image optimisation. CSV importer resizes manufacturer images at fetch time (1600px cap, q=82, WebP output) so the bucket stays under control even when the source URL serves uncompressed photography. Browser uploads still go straight to bucket via presigned PUT for v1; client-side optimisation is on the backlog.
- Opt-in TOTP two-factor on admin accounts. Better Auth twoFactor plugin enabled platform-wide; Admin Settings page gains a 2FA section with QR-code enrolment + 8 backup codes. Sign-in flow handles the TOTP challenge after password. Portal-side 2FA ships in a follow-up; activating it on a portal account today would currently lock the user out because the portal login page doesn't yet handle the challenge step. Tracked in the backlog.
- Multi-tenant access for one user. Schema previously allowed many
TenantUser rows per User but the UI ignored it. Now a user with membership in N tenants picks a default at the select-tenant screen and can switch in- session from the portal header without re-authenticating. Admin gains an Add existing user action on the tenant Users tab so support can attach an existing User to a second tenant without creating a duplicate account. - Sentry PII scrubbing. User email + IP are stripped from Sentry events before send; query params + cookies removed at the same layer. Privacy improvement for the shared error-monitoring service that has access to production traffic samples.
- Auto-generated product datasheet PDF. A Datasheet button on every part-detail page generates a printable one-page PDF (logo, primary image, fitments, OEM refs, dimensions, EAN, country) from the same structured data Phase D uses. Useful for service-bay reference, shop walls, and trade-show handouts.
- Public roadmap page at
/roadmap listing what's coming and what shipped recently, with a register-interest form prospects can use to flag features they care about. Submissions route through /api/contact with inquiryType=roadmap. - TenantPart fitment parity with central parts. Position and notes fields editable per fitment from My Parts, matching the central Part fitments UI.
- Five new portal help articles under Shopify product push: overview, push profile, bulk push, variant groups, stock-feed loop guard. Plus a topic-tree section that groups them under Storefront integration.
- New
/docs/shopify-product-push.astro public docs page covering the credential model, field mapping, push profile, variant groups, bulk push, loop guard, and the deliberate non-features (no auto-push, no multi-location stock distribution, no fitment metafields, no reverse sync).
Changed
- Strategy: PowersportOS Shopify App scrapped 2026-05-19. Rationale and replacement patterns documented in CLAUDE.md. Net effect on the platform: theme + tenant API key for inbound storefront widgets (unchanged),
/api/integrations/* for inbound data flows (unchanged), customer-managed Custom App for outbound writes (the Phase D credential model). Code residue removed in this release: apps/api/src/routes/ shopifyApp.ts, apps/api/src/lib/shopify.ts, admin tenant- detail Shopify tab, install-link generator endpoints, tenant.settings.shopify JSON field cleanup. - TOTP 2FA wording on
/docs/security-posture updated from "available on request" to "opt-in self-service" with a follow-up note about portal-side mirror. - PIM-Light docs page ties Shopify push directly to the structured-data layer with field-to-field mapping (was framed as "Phase D enabler", now framed as the live consumer of PIM-Light data).
- Landing-site copy flipped from "planned product push" to present tense across
bundled-stack, welcome, api-overview, csv-import-format, pim-light, for-distributors, platform, and pricing pages. The Phase D roadmap card moved from Coming to Recently Shipped as v0.8.0. - Hono
Env typing tech-debt cleared. API now passes TSC with strict mode and zero errors after typing the context variables (c.get("user") etc) at the router level. Previously had 92 errors as a baseline.
Operator-experience drop. Lots of UI surfaces got real attention: a dashboard rebuild that finally answers "what should I do today", a read-only central-part detail page tying together everything that lives on a Part, multi-timezone header clocks for tenants operating across offices, a per-tenant dealer map inside the portal (no more bouncing to the storefront for visual coverage checks), optional dealer logos that surface on both storefront maps and the new in-portal map, plus a sidebar reorganisation that moves Settings/Help to the bottom alongside Send feedback and Logout. Also a GDPR-compliance pass (form privacy notices + self-hosted fonts) and a Mapbox layout-fight epic settled with position-fixed.
Added
- New
/catalog/:partNumber page. Read-only detail view for any part the tenant has access to — central activation OR a parent-tenant TenantPart activated via Multi-Store. Mirrors the structure of /own-parts/:id but with central data (description, multi-image gallery, dimensions + EAN + origin, OEM cross-refs, vehicle fitments grouped by make → model, manual PDF link) rendered as read-only. A sidebar carries the editable tenant override layer — price, stock, bin, active — saving via the existing /catalog/:itemId or /group/parent-catalog/:parentPartId PUT endpoints. Bookmarkable URL. - New
GET /api/app/catalog/:partNumber endpoint that merges Part / TenantPart + fitments + OEM refs + media + categoryRel + replacedBy + the matching activation row. Falls through central → parent-tenant resolution; 404 when neither matches. - Vehicle Lookup row actions reworked. Per-row actions now route to source-appropriate destinations: portal-open icon navigates to the new
/catalog/:partNumber; storefront-open icon opens a search-deeplink against the tenant's Shopify shop (when installed). Tooltip on the portal-open action is now accurate (was previously mislabeled). Icons distinguishable: ReadMore for portal, Storefront for external store — previously both were open-arrow style and indistinguishable. - My Catalog part-number cells become React Router links to the new detail page, so operators can drill into a part directly from the grid.
/api/app/lookup/parts returns PortalPart[] with source + partId + tenantPartId discriminators so the UI can route rows to the right detail page. Storefront /api/t/parts shape unchanged via a projection step in the shared YMM lib./api/app/me sanitises Shopify settings before returning. Shop domain + scopes + installedAt stay exposed (storefront deeplink purposes); access token stripped — security cleanup for a pre-existing leak.- Fitments accordion-per-make on the detail page so a part fitting 558 Can-Am vehicles doesn't dominate the page. Default expand state depends on total count: small lists open, large lists start collapsed; operator picks the make they care about.
- Welcome header. Personal greeting + tenant identity line (name + type + RETAIL location count OR Multi-Store parent / child relationship). Replaces the bare "Dashboard" heading.
- Actionable warnings block. Conditional — disappears entirely when there's nothing to surface. Currently covers stock-feed staleness (never pushed OR > 7 days old), active products at zero stock, dealers/locations missing geocodes, DEPRECATED products still active on storefront, Multi-Store children's unactivated parent-catalog items. Each warning is a clickable tap-target with a destination action.
- System health strip. Muted-typography row at the bottom: backup schedule, stock-feed last-pushed relative time, Shopify connection state with shop domain. Reads as "system meta" not content.
- New
GET /api/app/dashboard composite endpoint returning identity + warnings + health in one round-trip. /api/app/stats unchanged for any existing consumer. - Up to three live clocks in the portal header. Each clock has a free-text label (≤30 chars) and an IANA timezone (Europe/ Stockholm, America/New_York, Asia/Shanghai, etc.). Auto-complete over
Intl.supportedValuesOf("timeZone") in the settings UI. - Per-tenant display preferences. 12h vs 24h time format, Monospace (default JetBrains Mono) vs Doto (dot-matrix display font added to the portal's font stack). Doto added to the portal's Google Fonts import; OFL-licensed, free to bundle.
- Updates on minute-boundary with synchronised flips (interval re-aligns to next
:00 instead of drifting based on mount time). Hidden on mobile to preserve header real-estate. No visual footprint when no clocks are configured. - Backend
PUT /api/app/settings extended with clocks field. Validates each entry server-side (label non-empty + ≤30 chars, timezone in IANA set, max 3 clocks). Passing null or empty array removes the clocks key entirely. - Schema: new nullable
logoUrl + originalLogoUrl columns on Dealer (provenance pair, same pattern as Part.imageUrl). Migration 20260518010000_dealer_logo. - API: PUT/POST
/api/app/dealers accept logoUrl. CSV importer gains a logo_url column that fetches external URLs and caches them in our bucket via the same fetchAssetForCsvRow flow part-image imports use. - Storefront
/api/t/dealers exposes logoUrl so the bundled Shopify theme can render dealer logos in popup-cards / list- cards. Deliberately NOT as the marker icon — keeps the map visually consistent regardless of which dealers have logos. - Presign endpoint extended with
kind="dealer-logo" for in-portal uploads. Storage key prefix: tenants/<tenantId>/ dealer-logos/<dealerId>/<hash>-<filename>. KeyHint + KeyParams union types extended in lib/storage.ts. - Portal: new FileUpload section in the dealer-edit dialog (visible only after the dealer row exists), thumbnail column at the left of the dealers DataGrid showing a 28×28 contain-fit image for dealers with logos.
- New
/dealers/map route rendering the same data as the storefront dealer-map but inside the operator portal. Split- view layout mirroring the bundled Shopify theme's reseller- map: filterable list on the left, Mapbox map with markers on the right. Click a list item to fly to its pin; click a marker to pop up the address card (with logo when set). - Uses the tenant's own Mapbox token from Settings. No token configured → setup prompt linking to Settings.
- Lazy-loaded via
React.lazy + Suspense so mapbox-gl's ~1.5 MB chunk only loads when an operator visits the map view. Main bundle stays at ~1.6 MB; map chunk is a one-time fetch on first map view. - Mapbox initial-render workaround baked in:
map.on("load") + requestAnimationFrame + two short timeouts + ResizeObserver all call map.resize() to catch the well-known "container 0x0 at init time" bug. - Position-fixed layout anchored to viewport edges (right of sidebar, below AppBar). Bypasses Refine's parent layout uncertainty entirely — went through three increasingly clever CSS attempts before landing on the deterministic fix.
- "Map view" button added to the Dealers page action bar.
- New
PartAliases component providing contextual add/list/ delete editor for a single part's aliases. Wired into both /catalog/:partNumber (new section in left column below OEM cross-references) and /own-parts/:id (new "Aliases" tab). Uses the existing ?partId= / ?tenantPartId= filter the alias API already supported. - Dedicated
/aliases page unchanged — still the right surface for bulk operations and cross-part audit.
Changed
- Sidebar reorganised into grouped clusters with subtle dividers between. Settings + Help move to the bottom block alongside Send feedback + Logout — system / config items no longer in the same scan-region as daily-work items (Linear / Stripe / GitHub / Slack pattern). Group order: Dashboard → Vehicle lookup → Catalog cluster (My Catalog, My Parts, My Vehicles, SKU aliases) → Operations (Labels) → Network (Group, Dealers, Stock Sharing) → Data tools (CSV import).
- HeaderClocks label colour bumped from
#64748b to #94a3b8 + fontSize 9.5 → 10 — previous colour was nearly invisible on the dark navy header background.
Operator-side feature drop. Three big themes: a full label-printing pipeline (ZPL + PDF, distributor templates, bulk + one-click), an internal YMM Lookup that brings storefront vehicle search inside the portal, and a substantial landing-site catch-up so prospects can see what's actually shipped. Plus a per-tenant bin/warehouse location field threaded through schema, API, UI, and label data, and a help-coverage audit pass that closed eleven gaps across admin / portal / public docs.
Added
- Schema. New
AliasSource enum on TenantSkuAlias capturing where each alias originated (PARTS_EUROPE, TFK, IMPORTGARDEN, NORDMARK_POWERSPORTS, POS_INTERNAL, OEM_CROSS_REF, LEGACY_SUPPLIER, CUSTOM, OTHER). New LabelPrintJob audit table records every print run with template, destination source, format, label counts, and full per-row breakdown in a JSON jobData field. Migrations idempotent on re-apply. - Generator infrastructure.
apps/api/src/lib/labels module with templates registry, ZPL rendering, Labelary integration for PDF + PNG preview, alias resolution, and three-pass part resolution (central catalog → own TenantParts → alias). Five starter templates ship: 25×25 EAN sticker, 50×25 warehouse SKU, 70×40 extended product, 76×25 Parts Europe (PE-aware default source), 76×25 generic (same physical size, MPN default). - Portal API. Eight endpoints under
/api/app/labels/*: templates (list), preview-png (single-label preview), preflight (resolve + report matches/fallbacks/unresolved), bulk/zpl + bulk/pdf (concatenated output), settings GET + PUT (default template + source override per tenant), jobs + jobs/:id (print history). - Portal /labels page. Four-tab surface: Bulk print, Print history, Templates, Settings. Bulk-print flow accepts paste input OR file upload (CSV / TSV / XLSX / XLS), auto-detects SKU + qty columns by header name (English + Swedish), opens a column-picker dialog for ambiguous multi-column files. Sheet.js is dynamic-imported so CSV uploads never pull the ~430 KB parser into the page bundle. Preview tab renders every template via Labelary at 150ms stagger to stay under free-tier rate limits; failures get a retry button. Settings tab persists tenant-level defaults.
- One-click print.
PrintLabelButton component on every row in My Catalog and My Parts (also wired into the new Internal YMM Lookup results). Click → qty input → ZPL or PDF download. Uses the tenant's default template + source override from settings. - Explicit MPN choice. UI source dropdown lists "Manufacturer part number (MPN)" as the first option, with each
AliasSource listed as "{Source} alias" below. No more "use template default" indirection that previously confused operators about what would actually print. - Help articles. Three portal articles (overview, print bridges, templates), one admin reference article, public
docs/label-printing rewritten with full SKU-source mechanics and the four print-bridge options (PrintNode, Zebra Browser Print, WebUSB, manual download). - Shared YMM resolution library.
apps/api/src/lib/ymm extracts the storefront YMM resolution into a single source of truth. Both /api/t/ (X-API-Key auth, storefront) and the new /api/app/lookup/ (session auth, portal) call into the same helpers (listYears, listMakes, listModels, listVehicles, listPartsForVehicle, findPartByNumber). public.ts handlers shrink to thin wrappers. Multi-Store children get parent's TenantParts merged into results automatically via the same resolution layer. - Portal
/lookup page. Four cascading dropdowns (Year → Make → Model → Submodel) with auto-select on single-variant matches. Result table shows part number, OEM ref, brand, name, category, price, stock. Three actions per row: print label (one-click), open manual (when manualUrl set), open part (filters My Catalog to the SKU). Sidebar entry sits above My Catalog as the first content destination after Dashboard. - Help article. New portal help article "Vehicle lookup (Year / Make / Model)" with workflow examples (customer call, receive-goods, coverage check, fitment verification). Public
/docs/internal-ymm-lookup concept doc covering the architecture and the Multi-Store interaction. - Schema. New
binLocation TEXT column on TenantCatalogItem, TenantPart, and TenantParentItem. Free-text, nullable. Each Multi-Store child carries its own bin label independent of the parent — child A's RACK-3 is unrelated to child B's RACK-3 because each store has its own physical layout. - API. All three activation surfaces accept
binLocation on their PUT endpoints. CSV importer reads a new bin_location column and includes it in the TenantPart upsert. - UI. Inline-editable Bin column on My Catalog and My Parts (click cell → type → Enter). Bin field in the My Parts detail form under Inventory & price. Bin field + column on the Group catalog with explanatory helper text about per-child independence.
- Label data.
LabelData.binLocation and LabelData.printedAt (UTC YYYY-MM-DD stamp generated at render time) threaded through partResolution → buildLabelData → templates. Every template file gets an inline variable-reference comment listing every available data.* field for future template authors. - Six new public docs pages.
/docs/internal-ymm-lookup, /docs/pim-light (the structured-product-data bundle pitched as one offering), /docs/asset-storage, /docs/audit-log, /docs/shopify-app, /docs/dealer-map. /docs/label-printing updated with CSV/Excel import, lookup-to-print workflow, bin location, and the printedAt field. docsTree.ts wired with all new entries so /docs index + prev/next traversal pick them up. /platform module cards. Eight new cards added for shipped work that wasn't yet visible to prospects: Label printing, Internal YMM Lookup, SKU aliases, PIM-Light, Bin location (folded into PIM-Light), Audit log, Asset storage, CSV+Excel import expansion. Each card deep-links into its canonical docs page (one source of truth, no duplicated copy).- Header megamenu. Replaces the former three-link Platform dropdown with a four-column megamenu (Storefront / Catalog & PIM / Operations / Developers) plus a supplementary footer row carrying the former destinations (all-modules overview, full docs index, data quality, data-provider partnership). Mobile drawer mirrors the structure with four "Platform · X" sub-sections plus a Resources catch-all. Drops to 2 columns on viewports under 1100px.
- Pricing touch-up. Retailer-tier feature list expanded with the new features (Label printing, Internal YMM Lookup, SKU aliases, PIM-Light, Asset storage, Shopify App) each with a deep-link to its docs page. Multi-Location and Manufacturer inherit via "Everything in Retailer, plus" — no duplicate rewrite needed.
- Closed eleven documentation gaps across admin / portal / public docs after a coverage pass against features that had shipped but weren't yet documented. New articles for SKU aliases, OEM cross-references, the Brands page, dimensions+EAN+origin, HTML descriptions, multi-image gallery, lifecycle status, bulk operations, status announcements, Mapbox setup, and Shopify App configuration.
LearnMoreLink primitive added to @powersportos/shared-ui; sixteen surfaces across admin and portal now link to their corresponding help article via the same component for visual consistency.
Changed
/api/t/parts/:partNumber response shape unchanged despite the resolution layer refactor. findPartByNumber returns the same fields the previous inline handler did (partNumber, oemPartNumber, brand, name, category, description, imageUrl, manualUrl, weightKg, price, stock) so the bundled Shopify theme contract is preserved.- CHANGELOG.md format unchanged. Keep a Changelog 1.1.0 + SemVer continue as the conventions. v0.6.0 is a feature drop (added behaviour, no breaking changes to public APIs).
Three big themes: Multi-Store (parent / child tenants v1), operational resilience around server restarts and kernel patches, and storefront API formalisation as a stable consumer contract. Plus three doc bugs fixed (cache numbers and the stock-feed request body shape) that would have actively misled an integrator following the portal help.
Added
- Schema.
Tenant.parentTenantId self-referential FK (onDelete: Restrict so parents can't be deleted while children point at them) plus a new TenantParentItem activation/override table mirroring TenantCatalogItem's role for the parent/child layer. Cascade-delete on both sides so removing the child tenant or the parent's source TenantPart cleans up activations. - Storefront API resolution.
/api/t/parts, /api/t/parts/:partNumber, /api/t/years, /api/t/makes, /api/t/models, /api/t/vehicles now merge results from the parent's TenantParts (those the child has activated) in addition to the central catalog. A new vehicleHasAccessiblePart helper consolidates the OR clause so all four YMM endpoints stay in sync. /parts/:partNumber gains a three-pass resolution: central catalog → SKU alias → parent activation. v1 scope intentionally excludes parent's own TenantVehicles — covers the 95% case (custom kits fitting OEM bikes) and keeps the YMM widget's Make/Model object shape stable. - Admin Group tab. New tab on tenant detail page with controls to set / clear
parentTenantId (Autocomplete picker filtered to eligible candidates) and a list of children with per-child activation counts. Server-side invariants enforced: single-level hierarchy (parent OR child, not both), one parent per child, can't be your own parent, parent must be active. - Portal Group page. Single
/group route that branches on whether the calling tenant is a child (shows parent catalog DataGrid with activate / pause / edit / remove per row, plus an edit dialog with parent's price/stock visible as reference for the override) or a parent (shows attached child stores with their activation counts). Sidebar entry only renders when the tenant is in a Multi-Store relationship — no upgrade gates inside the portal. - Portal API. Six new endpoints under
/api/app/group/* — parent-catalog list, activate, update, delete, plus children for the parent side. /api/app/me extended with _count.childTenants so the sidebar can decide whether to surface Group without an extra request. - Admin API. Three new endpoints under
/api/tenants/:id — GET/PUT /parent, GET /children. All v1 invariants enforced here. - Help docs. Admin help gets a "Multi-Store (parent / child tenants)" article under Customers. Portal help gets a new "Group (Multi-Store)" section with three articles (overview, child-side activation, parent-side overview). The existing portal "Central catalog vs your own data" article picks up a one-paragraph note framing Group as a third data source. Public docs get
/docs/multi-store under Core concepts. - Reboot-without-downtime gate. New
wait-for-db.ts script (apps/api/src/scripts/wait-for-db.ts) loops SELECT 1 against Postgres with 500ms → 5s backoff (2-minute total timeout), exits 0 on success, exits 1 on timeout. Wired in front of migrate:deploy in the Coolify start chain so the API container no longer dies the instant Postgres is briefly unreachable on cold-boot. - DNS-hostname convention.
DATABASE_URL now points at the Postgres container's stable Docker name (tdwll7rpqn5f3576o3ypavrf) instead of a raw internal IP — Docker's DNS resolves it to the current IP automatically, so container restarts no longer require a manual env-var update. Verified that container name survives a Coolify redeploy (container ID changes, name doesn't). - Ubuntu Pro Livepatch. Attached the host to the free Ubuntu Pro personal subscription tier (1 of 5 machines used) and enabled Livepatch + esm-infra + esm-apps. Kernel CVEs apply in-memory without reboot; security maintenance window extended to ~10 years.
- Cache-Control headers on six previously uncached endpoints:
/years, /makes, /models, /vehicles cache 10 min (catalog shape changes slowly); /parts?vehicleId= and /parts/:partNumber cache 1 min (price + stock change more often). Brings the storefront surface into line with the existing /dealers, /categories, /config cache behaviour. - **Public
/docs/api/* reference.** Four new articles — storefront-ymm, storefront-product, storefront-dealers, integrations — covering request/response shapes, query parameters, error semantics, and recommended client-side behaviour per endpoint. /docs/api-overview rewritten as a landing page linking into the four detail articles instead of trying to cover everything inline. - Shopify theme integration contract documented in
CLAUDE.md — list of load-bearing invariants the bundled PowersportOS Shopify theme depends on (empty results return [] not 404; required param missing returns 400; CORS open; cache headers per endpoint). Full consumer spec kept in _context/SHOPIFY_THEME_API_INTEGRATION.md (gitignored) for theme-repo coordination. - New top-level "Security posture" section in
CLAUDE.md catalogues what's defending the platform today — transport + perimeter, auth, app-layer hardening, storage, host — plus recent work log and planned/next-up register. Doubles as the single-page reference when a customer's auditor asks what we have in place.
Changed
- Web header top utility bar (Status / Sign in / version chip) switched from a 1.5% white overlay to the same
rgba(8, 15, 26, 0.92) + 8px backdrop blur the main nav uses. Content was bleeding through the bar on scroll, reading visually flummy.
Major iteration on the core catalog data model, full PIM-style part-edit surfaces in both admin and portal, comprehensive help system, an append-only audit log, a layered security audit with seven hardening fixes, and Astro 4 → 6 on the landing site. The platform crosses from "early-customer-friendly" into "first-paying-customer-ready" with this release.
Added
- Position enum on Fitment (LH / RH / FRONT / REAR + four-corner variants). On
Fitment, TenantPartFitment, and TenantVehicleFitment — covers parts that come in mirrored or stacked variants (brake levers, mirrors, shocks, footpegs). Inline-editable on the admin Part-detail Fitments tab. - Fitment notes — free-text caveats per fitment row ("fits with electric start", "before VIN 12345", "requires bracket kit X") that surface on the storefront product page so customers see the qualifier before buying.
- Part dimensions —
lengthCm, widthCm, heightCm Decimal columns on Part and TenantPart. Required for shipping calculations and the planned Shopify product-push. - EAN / barcode —
ean String? on Part and TenantPart, free-text (industry codes vary too much to validate). - Country of origin on Part —
countryOfOrigin String? on Part and TenantPart, distinct from Brand.countryOfOrigin (a Swedish brand may manufacture a part in Taiwan). - OEM cross-references — new
OemCrossReference table with XOR (partId / tenantPartId) like ProductMedia. Replaces the single oemPartNumber string. Each row carries oemPartNumber, optional oemBrand, and order. Existing single-OEM values backfilled into order=0 rows on migration. Part.oemPartNumber stays as a denormalised cache of the primary row so the Shopify-theme contract stays uniform. - PartCategory tree — hierarchical category taxonomy seeded with 12 top-level + ~70 sub-level powersport categories (Engine, Body & Plastics, Suspension, Drivetrain, Electrical, Brakes, Wheels & Tires, Controls & Cockpit, Maintenance & Fluids, Accessories, Apparel & Gear, Tools).
Part.categoryId + TenantPart.categoryId reference the tree; legacy category strings retained as fallback during transition. - Multi-image product gallery —
ProductMedia table with XOR ownership, primary-row tracking, alt-text, ordered display. Backend CRUD on /api/admin/media + /api/app/media. Gallery editor with upload, reorder, set-primary, alt-text, copy-URL controls. Legacy Part.imageUrl / TenantPart.imageUrl stays as the cached primary for fast theme reads. - Markdown descriptions —
Part.description / TenantPart.description now interpreted as Markdown by the rendering surfaces (admin + portal). No schema change. Edit / Preview toggle in both apps via react-markdown. Shopify theme Markdown rendering tracked in theme-repo backlog. - Bucket versioning — Hetzner Object Storage versioning enabled on
powersportos-assets via one-shot probe script. Forward-protection only for objects uploaded after 2026-05-12. - Replaced thin single-column form with a header card (88px thumbnail + breadcrumb-style category + status chips) and four tabs: General, Fitments, Media, Cross-refs.
- General tab is now two columns: Identification + Description (with Markdown edit/preview toggle) on the left; Logistics (weight, L×W×H, EAN, country) + Manual upload on the right.
- Category field is a tree-aware dropdown sourced from
/api/admin/categories with depth-indented options; legacy free-text categories surfaced as helper text for one-time reclassification. - Fitments tab: inline position dropdown + free-text notes column, saves on change/blur via the new
PUT /api/fitments/:id. - Cross-refs tab: list of OEM rows with primary chip on row 0, reorder arrows, edit + delete + add controls. Reorders bubble up to refresh
Part.oemPartNumber transactionally. /own-parts/:id is now a routed page mirroring the admin layout (header card + tabs: General + Media + Cross-refs). Replaces the old narrow edit modal that didn't have room for the new fields.- My Parts list page keeps a slim quick-add modal (part #, brand, name) → "Create & continue" creates the row and routes to the full edit page.
- Inventory & price section on the General tab —
TenantPart.price and .stock get their own section alongside Logistics. - Full CRUD on the
PartCategory tree from /categories in admin. Expand/collapse, add subcategory under any node, edit name + slug + parent (with cycle-prevention), reorder siblings, delete (blocked if children exist). - Add-top-level button creates a new root. Slug auto-generated from name with collision-suffixing; user can override.
- Both admin (
/help) and customer portal (/help) replaced the long accordion-stack with sidebar-tree + breadcrumb + right-rail "In this article" TOC. Each topic gets its own routed URL so articles are linkable and prev/next traversal works. @powersportos/shared-ui HelpLayout component with session-persisted section collapse state, find-by-title filter, mobile-drawer sidebar, prev/next footer. HelpArticle auto-scrapes h2/h3 headings to build the TOC. Primitives: InlineCode, CodeBlock (labelled), Callout (info/tip/warning/danger/success), FieldTable, Steps, TopicCards.- Admin: 23 articles across Overview, Central catalog (with sub- pages for dimensions/markdown/OEM-refs/multi-image, position+notes), Customers (onboarding/users/activation/Shopify/ stock network), Operations (dashboard/announcements/public API/ adding admin/status & monitoring).
- Portal: 27 articles across Getting started, Parts (with 6 sub-pages on the Part detail surface), Vehicles, Stock & pricing, Dealers / Locations, Shopify integration, Account.
- Each article got substantive content — what / why / how plus examples — not just stubs.
- In-sidebar Send feedback button opens a modal with inquiry type (bug / feature / question / other), optional subject, and message textarea. Posts to authenticated
POST /api/app/feedback which resolves user + tenant from the session and emails the support inbox with full context (page, app version, user agent). - New
portalFeedbackEmail() template prefixes subject with [Portal] + inquiry-type label so the inbox triages at a glance. - Append-only
AuditLog table capturing every POST/PUT/PATCH/DELETE on /api/admin, /api/app, /api/integrations, and /api/shopify-app/webhooks. Reads (GET) skipped to keep volume manageable; /api/auth/ and /health excluded. - New
AuditActorType enum (ADMIN, TENANT_USER, INTEGRATION, SHOPIFY_APP, UNKNOWN — last one covers rejected-before-auth attempts which are the most forensically interesting). - Middleware (
apps/api/src/middleware/auditLog.ts) runs at the top of the request chain, captures actor from the four auth surfaces, fires the insert fire-and-forget so logging failures never block user requests. - Admin viewer at
/audit-log with filters (tenant, actor, actor type, method, path contains, date range) and pagination. Tenant + user filter dropdowns populated by a server-side groupBy on the most frequent values. - Per-tenant Activity tab on tenant detail page renders a slimmer view of the same data scoped to that tenant, with a "Full audit log" deep-link that pre-filters the global page.
- Global audit-log page reads filter state from URL params so links from the per-tenant tab pre-filter correctly; filter changes mirror back into the URL via replaceState.
- PowersportOS Partners page (
/partners) — sister page to Studio, aimed at Shopify agencies and freelance e-commerce builders who want to bring PowersportOS to their own clients. V1 program is co-marketing + clean service separation (no kickbacks, no wholesale); revenue sharing exists as a private conversation once a partner brings sustained volume. Form posts to /api/contact with inquiryType=partner. - Footer Services column gets a PowersportOS Partners entry next to Studio.
/data-quality and /data-providers rewritten to reflect the structured fields shipped this release (categories, multi-image gallery, multi-OEM with cross-brand attribution, dimensions, EAN, country, fitment position + notes, markdown descriptions).- Replaces Refine's default
AuthPage with a custom screen styled to match powersportos.com — dark navy backdrop, blue grid pattern, corner crosshairs, ISO-style stamp at bottom-right, TASA Explorer heading with orange accent, JetBrains Mono module bar + field labels. Auth flow unchanged (Better Auth through Refine's authProvider). - New
/import route in portal sidebar between Stock Sharing and Settings. Tabs for Parts and Vehicles imports. Per tab: comprehensive column docs (required + optional, year-range syntax, category-path explanation), example CSV rows in code blocks, download-template + upload-CSV controls, result display with success summary + per-row error list. - The existing CSV buttons on My Parts and My Vehicles stay discoverable in context; the dedicated page is the primary path for new tenants and larger batches.
Changed
- Admin auth surface for central catalog reads — added
requireAuth at router level on /api/makes, /api/models, /api/vehicles, /api/parts, /api/fitments. The unscoped central catalog reads were previously scrapeable without an API key. Tenant-scoped reads on /api/t/* already required X-API-Key and continue to filter to the tenant's activated subset. - Hono 4.12.16 → 4.12.18 — patches three advisories, most relevant being the Vary-header cache-leak on the Cache middleware.
- Astro 4.16.18 → 6.3.1 on apps/web — clears 12 advisories patched in the 5.x/6.x lines (reflected XSS, X-Forwarded-Host reflection, middleware bypass, define:vars XSS, etc.). Build output unchanged: same 18 static pages, same sitemap.
- Kysely pinned ≥0.28.17 via
pnpm.overrides — patches JSON-path traversal CVE in the transitive dep through Better Auth's kysely-adapter. - Better Auth fail-loud — throws at import time when
BETTER_AUTH_SECRET, BETTER_AUTH_URL, or ALLOWED_ORIGINS is missing in production. Dev still has the localhost fallback for the loop-without-.env case. - Allowed-domain check anchored on dot boundary —
tenantAuth middleware previously allowed evilstore.com when allowedDomain=store.com via naive endsWith. Now requires exact match or true subdomain (.endsWith("." + allowed)). Affects custom-domain tenants only (myshopify.com tenants were effectively safe because Shopify owns the entire namespace). - FileUpload + ProductMediaGallery lifted into
@powersportos/shared-ui. Identical components in admin + app collapsed into one shared implementation. App passes its apiFetch (with 401-redirect) via a new ApiFetchProvider context; admin uses the default fetcher. - CSV importer columns — added
oem_part_numbers (semicolon- separated, replaces singular oem_part_number), oem_brands (positional), category_path (slash-separated tree walk), length_cm, width_cm, height_cm, ean, country_of_origin, position, fitment_notes. Old oem_part_number and category columns still accepted as fallbacks. - Markdown hint on Part description fields moved from placeholder (vanishes on type) to a persistent caption below the textarea (always visible).
- All dialogs got an × close button in the top-right of the title bar via a new shared
DialogTitleWithClose component. 20 DialogTitle instances across 14 files migrated.
Added
/pricing — four subscription tracks (Retailer €400/mo, Multi-Location €500 + €200/location, Manufacturer €1500/mo, Distributor custom from €50k/yr), free Data Provider tier, onboarding fees (Basic €1500 / Full Store Build €4500), explicit "what we don't do" block, billing terms (monthly, excl. VAT, 3-month minimum, 14-day grace then suspension), and grandfathering promise for existing subscriptions./data-providers — standalone brand-partnership page covering the full Data Provider exchange (free distribution + portal access + data-quality requirements + ownership responsibility + application process). Brands maintain their own data via portal — full ownership comes with full responsibility.- In-page anchor nav on
/pricing linking to Tracks, Data Provider, Onboarding, What we don't do, Billing, Intro prices. MaskedSecret component (Stripe/Vercel/Cloudflare pattern: first 8 + ellipsis + last 6, reveal toggle, copy always uses real value).- Applied to Settings → API Key and Stock Sharing → curl example (the latter caused the screenshot incident that motivated this).
Changed
- Header navigation: "Data" link converted to dropdown with Data quality and Data Provider partnership as children. Dropdown JS refactored to handle multiple independent dropdowns.
- Multi-Location track now explicitly owns the dealer map; Retailer track adds automatic manual-link injection on product pages.
- CHANGELOG entry for 0.3.3 was already published; this entry covers the work that landed between then and now.
Third milestone — extends PowersportOS from a B2B catalog platform into a multi-segment network with email-driven self-service, tenant typing (retail / manufacturer / reseller / hybrid), the first version of the reseller stock network, the Shopify App Phase A, and a fully reframed public-facing landing page with legal pages and live status monitoring.
Added
Tenant.type enum — STANDARD | MANUFACTURER | RESELLER | HYBRID | RETAIL. Drives sidebar gating and segment-specific features without forking the codebase.- RETAIL tenant type (v1) —
DealerStock table for per-store stock, POST /api/integrations/location-stock accepts [{ dealerId, partNumber, stock }] from the retailer's POS/ERP. GET /api/t/dealers/stock two-path logic (manufacturer-reseller + retail-multi-location) returns the same shape so the Shopify widget contract stays uniform. Sidebar shows "Locations" instead of "Dealers" for retail. Admin "About account types" dialog updated. - Phase 1 —
TenantStockShare opt-in consent table with EXACT | LEVEL precision, POST /api/integrations/stock-feed for bulk stock ingest with X-API-Key auth, customer-portal Stock Sharing page for resellers to manage incoming requests. - Phase 2 — manufacturer side.
Dealer.linkedTenantId ties a manufacturer's dealer entry to the actual reseller tenant. GET /api/t/dealers/stock?partNumber&lat&lng&limit answers the "find this part at a reseller near me" query — Haversine-sorted, opt-in-filtered, precision-respecting. seed-reseller-network script — spins up six fake RESELLER tenants across the Nordics + Germany with deterministic stock for end-to-end manufacturer-side demos.- Manual OAuth flow at
/api/shopify-app/install and /api/shopify-app/callback. Verifies HMAC, exchanges code for token, registers webhooks, persists install state to tenant.settings.shopify. POST /webhooks/inventory receiver for inventory_levels/update. Verifies HMAC, reads SKU + total available across locations, routes through shared applyStockFeed() helper.POST /webhooks/app-uninstalled clears install state for clean reinstall.- Admin tenant-detail "Shopify" tab with install-link generator, install-status panel, and clear-installation action.
- Bumped Shopify Admin API version to
2026-04. - Centralized
apps/api/src/lib/email.ts with branded HTML templates. - Better Auth
sendResetPassword callback wired to Resend. - Self-service forgot-password and reset-password pages in both admin and customer portal.
- Welcome email on tenant-user creation with first-time login URL and temp password.
POST /api/contact endpoint backing the landing-page beta + demo forms (with honeypot + basic shape validation).- Custom
AppSider matching the dark-navy admin styling. - Platform-wide announcement banner — admin-managed, sessionStorage dismissal, polled every 60s.
- Version chip in the sider footer with click-to-open changelog dialog reading from
/api/version. - Stock Sharing page for
RESELLER and HYBRID tenants. - Forgot / Reset password pages.
- Help page updated with dealer-map and Mapbox documentation, plus a link to
status.powersportos.com. - Tenant name now shown in the customer-portal header.
- "About account types" help dialog on the Customers page.
- Reusable
add-admin script for ad-hoc admin creation. - Tenant Shopify tab — install-link generator and install state panel.
- Auto-slug from name on tenant create with actionable error responses.
- Full reframe around the bundled-package product model — backend + Shopify Theme + Shopify App, sold together.
- Hero rewritten around "What fits. Where it's in stock."
- Solution section split into four role-specific cards (dealer / webshop, manufacturer, retail chain, hybrid).
- Platform section with a 9-module grid (some marked "Coming").
- Integration-partners section.
- "Why it's different" differentiators.
- "For brands & data providers" recruitment section with prefill-CTA that jumps to the demo form pre-selected to "Manufacturer / Importer".
- Beta access form gated by tabs alongside a longer Request-a-Demo form.
- Data & privacy section.
- Header navigation: Platform · For brands · Status · Sign in.
- Privacy Policy at
/privacy.html and Terms of Service at /terms.html, both v1.0, GDPR-aligned. - Footer links to Status, Privacy, and Terms.
- Product-positioning section in
CLAUDE.md explicit about the "best-in-class niche tool, network effects as accelerator" thesis (vertical SaaS, not marketplace). - Bundled-stack assumption section — design assuming customers run backend + theme + Shopify app, not loose-API integration.
_context/PITCH_DECK_BRIEF.md with full slide-by-slide content._context/RESELLER_STOCK_WIDGET.md handoff doc for the Shopify theme repo.
Changed
- Auth provider gating — admin and customer portal now distinguish "confirmed-not-admin" (403, sign out), "no-session" (401, redirect), and "transient" (5xx / network, leave session intact). Stops spurious sign-outs on Coolify-recreate or brief network blips.
- Tenant-form — passes
id explicitly so pre-population works at /tenants/:id. - Admin endpoints —
requireAuth middleware now also rejects User rows that have a TenantUser link (tenant users can't access admin). - Better Auth password-reset endpoint adopted to v1.6's rename (
/api/auth/request-password-reset).
Second milestone — moves PowersportOS from "POC catalog" to a usable B2B platform with a self-serve customer portal, multi-tenant operations, and Shopify-side integrations beyond the YMM widget.
Added
- Dealer map — per-tenant
Dealer table, public GET /api/t/dealers and GET /api/t/config for Shopify store-locator embeds, customer portal CRUD with country dropdown (ISO 3166), CSV import with server-side Nominatim geocoding, manual lat/lng with one-click re-geocode, and active toggle. - Per-tenant integration config — new
Tenant.settings JSON column. First use: Mapbox public token, fetched by Shopify themes via /api/t/config instead of being pasted into theme settings. - Admin password reset — admins can generate a new temporary password for any tenant user from the customers → users page. Hashed via Better Auth's
hashPassword and overwrites the credential account; old password is invalidated immediately. Bridges the gap until self-service forgot-password is wired to an email provider. - Local context dump —
_context/ folder gitignored for sharing handoff files (screenshots, dumps, references) with the assistant.
Changed
- Notification provider — both admin and customer portal now use a custom notistack-backed provider instead of
@refinedev/mui's useNotificationProvider. The Refine MUI bundle ships its own copy of notistack, and the dual-context React tree caused useSnackbar() to return undefined and crash both apps on mount. - Customer portal toast colors — switched to direct
notistack.SnackbarProvider import so the Components prop works for custom-styled toasts. - Tenant edit form — values now read from
queryResult directly so pre-population works on first paint.