Skip to main content

Blog

The Blog module owns the storefront's editorial blog surface — Posts, Categories, and Tags — composed in the same drag-and-drop Page Builder that ships with the CMS module. Posts inherit the CMS Page's field shape (slug, status lifecycle, per-language content, SEO meta) and add blog-specific affordances: a parent Category tree, free-form Tags, ordered Related Posts, and ordered Related Products.

EntityIdentifierLifecycleEmbedded by
Postslug (per channel)draft → published → archivedURL <blog-prefix>/<slug> on the storefront
Categoryslug (per channel)enabled flag, tree-structuredURL <blog-prefix>/<slug> (siblings of Posts) and tiles
Tagcode (global)block-on-deleteURL <blog-prefix>/tag/<code> and per-Post chip strip

Entities and the reference graph​

blog_categories ── parent_id ──┐ self-FK tree (NO ACTION)
│ ▼
├─── blog_post_categories ───────► blog_posts
│ │
│ ┌────────┼─────────┐
▼ ▼ ▼ ▼
blog_post_categories blog_post_tags blog_post_related_posts (self-FK)
│ │
▼ │
blog_tags │
▼
blog_post_related_products → catalog.products

The schema lives entirely in 037_blog_init.ts (eleven new tables, all prefixed blog_*) and never touches existing tables.

URLs and routing​

The storefront mounts a single Next.js catch-all under storefront/app/(blog)/[[...slug]]/page.tsx. The matcher reads the channel-resolved blog.url_prefix from the Settings module and dispatches:

URL pattern (after channel resolution)Renders
/<prefix>Blog index — latest N + first-level Categories
/<prefix>/tag/<code>Tag view — paginated cards for the Tag
/<prefix>/<slug>Category page (if the slug matches a blog_categories.slug)
/<prefix>/<slug>Post page (if the slug matches a blog_posts.slug)

The dispatch resolution happens in one backend call — GET /api/v1/blog/by-slug?slug=… — which returns a discriminated union ({ kind: 'category' | 'post', … }). Two channels MAY use the same slug for unrelated entities (channel context disambiguates).

Slug uniqueness​

Slug uniqueness is enforced per (sales_channel, slug) across the union of blog_posts and blog_categories:

  • DB-level partial unique indexes on each scope row (*_sales_channels) catch any application bug that bypasses the service-level check.
  • The BlogSlugCollision.assertSlugAvailable helper acquires a Postgres pg_advisory_xact_lock per (channel, slug) so a concurrent save cannot squeeze a duplicate past the per-table indexes.
  • The literal tag is reserved as a slug (would collide with the <prefix>/tag/<code> URL pattern).

Lifecycle​

Posts​

draft ─── publish ────► published ─── unpublish ───► draft
│ │
│ archive │
▼ ▼
archived ◄── unarchive ── (admin)

published_at is set on the first transition to published and preserved across subsequent unpublish / archive transitions (so re- publishing a post does not reset its publication date). A Post renders on the storefront when:

status = 'published'
AND active = true
AND deleted_at IS NULL
AND requested-channel ∈ post.salesChannels
AND blog.enabled[channel] = true

Categories​

Categories carry a single enabled flag (no draft / publish). The seeded Default row is system-protected — admins can rename it, change its metadata, or detach it from channels, but it cannot be deleted.

Reference protection​

Five guards block destructive operations:

  1. Tag delete with referencing posts → 409 BLOG_TAG_IN_USE.
  2. Category delete with referencing posts → 409 BLOG_CATEGORY_IN_USE.
  3. Category delete with child rows → 409 BLOG_CATEGORY_HAS_CHILDREN.
  4. Seeded Default Category delete → 409 BLOG_CATEGORY_PROTECTED.
  5. Library Asset soft-delete while embedded in a Post body or Category description → 409 ASSET_REFERENCED (descriptors registered with the Assets Library reference registry).

The Post-as-Related-Post relationship uses a different contract: detach-on-delete. When a Post is soft-deleted, every parent Post's relatedPostIds shrinks atomically. The admin sees a confirmation dialog listing the affected parents (driven by the /posts/:id/inbound-references probe).

The Post-as-Related-Product relationship uses soft-delete-+-storefront-filter: a soft-deleted product is invisible on the next storefront read; the join row remains. A generic ProductReferenceRegistry may ship in a follow-up.

Settings​

CodeTypeDefaultNotes
blog.enabledbooleantrueDisables the namespace per channel.
blog.url_prefixstringblogSingle URL segment, ^[a-z0-9-]+$. Reserved Next.js segments refused.
blog.latest_countnumber5Latest posts on the index.
blog.posts_per_pagenumber12Page size on Category and Tag views.

A change to any blog.* setting drops the platform-wide settings cache at the write seam and then fires an EventBus event, which wipes the storefront cache (see Cache strategy below).

Admin roles​

Two roles seed at first boot through services/seed-roles.ts:

CodeDefault namePermissions
blog_managerBlog Managerblog.read, blog.write
content_managerContent Managerblog.read, blog.write, cms.read, cms.write

Both are system-protected: AdminRoleService.remove refuses delete with 409 ADMIN_ROLE_PROTECTED. The reconciler preserves admin-edited names across reboots and only refreshes the canonical permissions array if it has drifted.

Storefront API​

MethodPathReturns
GET/api/v1/blog/by-channelBlogIndexResponse — latest N + first-level Categories
GET/api/v1/blog/by-slug?slug=…Discriminated BlogBySlugResponse (category / post)
GET/api/v1/blog/tag-by-code?code=…BlogTagByCodeResponse — paginated posts for the Tag

All three:

  • read the resolved channel + language from request headers (x-sales-channel, x-blog-language / accept-language);
  • read blog.* settings from the channel-aware Settings service;
  • return 404 BLOG_DISABLED (or BLOG_POST_NOT_FOUND / BLOG_TAG_NOT_FOUND) when the resolved scope has no matching content;
  • ride the BlogCacheService Redis read-through.

Cache strategy​

Keys live under blog:v1:<channelCode>:<language>: with four shapes:

blog:v1:<channel>:<language>:index
blog:v1:<channel>:<language>:category:<slug>:p<page>
blog:v1:<channel>:<language>:post:<slug>
blog:v1:<channel>:<language>:tag:<code>:p<page>

TTL: 5 minutes (aligned with the CMS module's cache).

Invalidation:

EventWipe
Post / Category / Tag writeBlogCacheService.invalidateAll()
Soft-delete or settings writeBlogCacheService.invalidateAll() via EventBus

The granular invalidatePost(channelCode, slug) and friends are wired on the cache class for future surgical-invalidation work (coarse invalidation is correct at v1; rate of writes is low).

Cross-module dependencies​

ModuleWhat the blog reads
SettingsFour blog.* settings via settings.service.get
Sales ChannelsChannel resolution + language fallback
Assets LibraryAsset URL signing + reference registry
CMSPage Builder envelope (cmsContentEnvelopeSchema)
CatalogProduct card resolution for Related Products

The blog module never imports another module's internals — every cross-module read goes through a documented service port.