@endora-commerce/mod-blog 0.100.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/dist/admin/api/blog-client.d.ts +70 -0
  4. package/dist/admin/api/blog-client.d.ts.map +1 -0
  5. package/dist/admin/api/blog-client.js +137 -0
  6. package/dist/admin/api/blog-client.js.map +1 -0
  7. package/dist/admin/components/CategoryTreeNode.d.ts +25 -0
  8. package/dist/admin/components/CategoryTreeNode.d.ts.map +1 -0
  9. package/dist/admin/components/CategoryTreeNode.js +14 -0
  10. package/dist/admin/components/CategoryTreeNode.js.map +1 -0
  11. package/dist/admin/components/PostStatusBadge.d.ts +7 -0
  12. package/dist/admin/components/PostStatusBadge.d.ts.map +1 -0
  13. package/dist/admin/components/PostStatusBadge.js +16 -0
  14. package/dist/admin/components/PostStatusBadge.js.map +1 -0
  15. package/dist/admin/components/RelatedPostsPicker.d.ts +11 -0
  16. package/dist/admin/components/RelatedPostsPicker.d.ts.map +1 -0
  17. package/dist/admin/components/RelatedPostsPicker.js +77 -0
  18. package/dist/admin/components/RelatedPostsPicker.js.map +1 -0
  19. package/dist/admin/components/RelatedProductsPicker.d.ts +9 -0
  20. package/dist/admin/components/RelatedProductsPicker.d.ts.map +1 -0
  21. package/dist/admin/components/RelatedProductsPicker.js +66 -0
  22. package/dist/admin/components/RelatedProductsPicker.js.map +1 -0
  23. package/dist/admin/components/TagPicker.d.ts +9 -0
  24. package/dist/admin/components/TagPicker.d.ts.map +1 -0
  25. package/dist/admin/components/TagPicker.js +66 -0
  26. package/dist/admin/components/TagPicker.js.map +1 -0
  27. package/dist/admin/index.d.ts +33 -0
  28. package/dist/admin/index.d.ts.map +1 -0
  29. package/dist/admin/index.js +114 -0
  30. package/dist/admin/index.js.map +1 -0
  31. package/dist/admin/pages/BlogCategoryEditor.d.ts +9 -0
  32. package/dist/admin/pages/BlogCategoryEditor.d.ts.map +1 -0
  33. package/dist/admin/pages/BlogCategoryEditor.js +196 -0
  34. package/dist/admin/pages/BlogCategoryEditor.js.map +1 -0
  35. package/dist/admin/pages/BlogCategoryTreePage.d.ts +9 -0
  36. package/dist/admin/pages/BlogCategoryTreePage.d.ts.map +1 -0
  37. package/dist/admin/pages/BlogCategoryTreePage.js +122 -0
  38. package/dist/admin/pages/BlogCategoryTreePage.js.map +1 -0
  39. package/dist/admin/pages/BlogPostEditor.d.ts +9 -0
  40. package/dist/admin/pages/BlogPostEditor.d.ts.map +1 -0
  41. package/dist/admin/pages/BlogPostEditor.js +314 -0
  42. package/dist/admin/pages/BlogPostEditor.js.map +1 -0
  43. package/dist/admin/pages/BlogPostListPage.d.ts +9 -0
  44. package/dist/admin/pages/BlogPostListPage.d.ts.map +1 -0
  45. package/dist/admin/pages/BlogPostListPage.js +75 -0
  46. package/dist/admin/pages/BlogPostListPage.js.map +1 -0
  47. package/dist/admin/pages/BlogTagListPage.d.ts +9 -0
  48. package/dist/admin/pages/BlogTagListPage.d.ts.map +1 -0
  49. package/dist/admin/pages/BlogTagListPage.js +145 -0
  50. package/dist/admin/pages/BlogTagListPage.js.map +1 -0
  51. package/dist/backend/entities/blog-category-language.entity.d.ts +11 -0
  52. package/dist/backend/entities/blog-category-language.entity.d.ts.map +1 -0
  53. package/dist/backend/entities/blog-category-language.entity.js +35 -0
  54. package/dist/backend/entities/blog-category-language.entity.js.map +1 -0
  55. package/dist/backend/entities/blog-category-sales-channel.entity.d.ts +17 -0
  56. package/dist/backend/entities/blog-category-sales-channel.entity.d.ts.map +1 -0
  57. package/dist/backend/entities/blog-category-sales-channel.entity.js +48 -0
  58. package/dist/backend/entities/blog-category-sales-channel.entity.js.map +1 -0
  59. package/dist/backend/entities/blog-category.entity.d.ts +32 -0
  60. package/dist/backend/entities/blog-category.entity.d.ts.map +1 -0
  61. package/dist/backend/entities/blog-category.entity.js +113 -0
  62. package/dist/backend/entities/blog-category.entity.js.map +1 -0
  63. package/dist/backend/entities/blog-post-category.entity.d.ts +10 -0
  64. package/dist/backend/entities/blog-post-category.entity.d.ts.map +1 -0
  65. package/dist/backend/entities/blog-post-category.entity.js +34 -0
  66. package/dist/backend/entities/blog-post-category.entity.js.map +1 -0
  67. package/dist/backend/entities/blog-post-language.entity.d.ts +9 -0
  68. package/dist/backend/entities/blog-post-language.entity.d.ts.map +1 -0
  69. package/dist/backend/entities/blog-post-language.entity.js +33 -0
  70. package/dist/backend/entities/blog-post-language.entity.js.map +1 -0
  71. package/dist/backend/entities/blog-post-related-post.entity.d.ts +15 -0
  72. package/dist/backend/entities/blog-post-related-post.entity.d.ts.map +1 -0
  73. package/dist/backend/entities/blog-post-related-post.entity.js +42 -0
  74. package/dist/backend/entities/blog-post-related-post.entity.js.map +1 -0
  75. package/dist/backend/entities/blog-post-related-product.entity.d.ts +14 -0
  76. package/dist/backend/entities/blog-post-related-product.entity.d.ts.map +1 -0
  77. package/dist/backend/entities/blog-post-related-product.entity.js +41 -0
  78. package/dist/backend/entities/blog-post-related-product.entity.js.map +1 -0
  79. package/dist/backend/entities/blog-post-sales-channel.entity.d.ts +16 -0
  80. package/dist/backend/entities/blog-post-sales-channel.entity.d.ts.map +1 -0
  81. package/dist/backend/entities/blog-post-sales-channel.entity.js +47 -0
  82. package/dist/backend/entities/blog-post-sales-channel.entity.js.map +1 -0
  83. package/dist/backend/entities/blog-post-tag.entity.d.ts +14 -0
  84. package/dist/backend/entities/blog-post-tag.entity.d.ts.map +1 -0
  85. package/dist/backend/entities/blog-post-tag.entity.js +41 -0
  86. package/dist/backend/entities/blog-post-tag.entity.js.map +1 -0
  87. package/dist/backend/entities/blog-post.entity.d.ts +31 -0
  88. package/dist/backend/entities/blog-post.entity.d.ts.map +1 -0
  89. package/dist/backend/entities/blog-post.entity.js +106 -0
  90. package/dist/backend/entities/blog-post.entity.js.map +1 -0
  91. package/dist/backend/entities/blog-tag.entity.d.ts +18 -0
  92. package/dist/backend/entities/blog-tag.entity.d.ts.map +1 -0
  93. package/dist/backend/entities/blog-tag.entity.js +66 -0
  94. package/dist/backend/entities/blog-tag.entity.js.map +1 -0
  95. package/dist/backend/index.d.ts +100 -0
  96. package/dist/backend/index.d.ts.map +1 -0
  97. package/dist/backend/index.js +182 -0
  98. package/dist/backend/index.js.map +1 -0
  99. package/dist/backend/routes.admin.d.ts +12 -0
  100. package/dist/backend/routes.admin.d.ts.map +1 -0
  101. package/dist/backend/routes.admin.js +148 -0
  102. package/dist/backend/routes.admin.js.map +1 -0
  103. package/dist/backend/routes.storefront.d.ts +6 -0
  104. package/dist/backend/routes.storefront.d.ts.map +1 -0
  105. package/dist/backend/routes.storefront.js +72 -0
  106. package/dist/backend/routes.storefront.js.map +1 -0
  107. package/dist/backend/services/blog-asset-references.d.ts +4 -0
  108. package/dist/backend/services/blog-asset-references.d.ts.map +1 -0
  109. package/dist/backend/services/blog-asset-references.js +79 -0
  110. package/dist/backend/services/blog-asset-references.js.map +1 -0
  111. package/dist/backend/services/blog-cache.d.ts +55 -0
  112. package/dist/backend/services/blog-cache.d.ts.map +1 -0
  113. package/dist/backend/services/blog-cache.js +111 -0
  114. package/dist/backend/services/blog-cache.js.map +1 -0
  115. package/dist/backend/services/blog-category-service.d.ts +53 -0
  116. package/dist/backend/services/blog-category-service.d.ts.map +1 -0
  117. package/dist/backend/services/blog-category-service.js +446 -0
  118. package/dist/backend/services/blog-category-service.js.map +1 -0
  119. package/dist/backend/services/blog-language-reference.d.ts +18 -0
  120. package/dist/backend/services/blog-language-reference.d.ts.map +1 -0
  121. package/dist/backend/services/blog-language-reference.js +43 -0
  122. package/dist/backend/services/blog-language-reference.js.map +1 -0
  123. package/dist/backend/services/blog-post-service.d.ts +79 -0
  124. package/dist/backend/services/blog-post-service.d.ts.map +1 -0
  125. package/dist/backend/services/blog-post-service.js +539 -0
  126. package/dist/backend/services/blog-post-service.js.map +1 -0
  127. package/dist/backend/services/blog-settings-resolver.d.ts +36 -0
  128. package/dist/backend/services/blog-settings-resolver.d.ts.map +1 -0
  129. package/dist/backend/services/blog-settings-resolver.js +68 -0
  130. package/dist/backend/services/blog-settings-resolver.js.map +1 -0
  131. package/dist/backend/services/blog-slug-collision.d.ts +35 -0
  132. package/dist/backend/services/blog-slug-collision.d.ts.map +1 -0
  133. package/dist/backend/services/blog-slug-collision.js +84 -0
  134. package/dist/backend/services/blog-slug-collision.js.map +1 -0
  135. package/dist/backend/services/blog-storefront-resolver.d.ts +107 -0
  136. package/dist/backend/services/blog-storefront-resolver.d.ts.map +1 -0
  137. package/dist/backend/services/blog-storefront-resolver.js +492 -0
  138. package/dist/backend/services/blog-storefront-resolver.js.map +1 -0
  139. package/dist/backend/services/blog-tag-service.d.ts +45 -0
  140. package/dist/backend/services/blog-tag-service.d.ts.map +1 -0
  141. package/dist/backend/services/blog-tag-service.js +198 -0
  142. package/dist/backend/services/blog-tag-service.js.map +1 -0
  143. package/dist/backend/services/seed-default-category.d.ts +25 -0
  144. package/dist/backend/services/seed-default-category.d.ts.map +1 -0
  145. package/dist/backend/services/seed-default-category.js +48 -0
  146. package/dist/backend/services/seed-default-category.js.map +1 -0
  147. package/dist/backend/services/seed-roles.d.ts +41 -0
  148. package/dist/backend/services/seed-roles.d.ts.map +1 -0
  149. package/dist/backend/services/seed-roles.js +98 -0
  150. package/dist/backend/services/seed-roles.js.map +1 -0
  151. package/dist/manifest.d.ts +226 -0
  152. package/dist/manifest.d.ts.map +1 -0
  153. package/dist/manifest.js +236 -0
  154. package/dist/manifest.js.map +1 -0
  155. package/dist/migrations/20260506T081055_blog_init.d.ts +33 -0
  156. package/dist/migrations/20260506T081055_blog_init.d.ts.map +1 -0
  157. package/dist/migrations/20260506T081055_blog_init.js +260 -0
  158. package/dist/migrations/20260506T081055_blog_init.js.map +1 -0
  159. package/dist/migrations/20260903T101744_blog_namespace_block_names.d.ts +6 -0
  160. package/dist/migrations/20260903T101744_blog_namespace_block_names.d.ts.map +1 -0
  161. package/dist/migrations/20260903T101744_blog_namespace_block_names.js +58 -0
  162. package/dist/migrations/20260903T101744_blog_namespace_block_names.js.map +1 -0
  163. package/dist/migrations/index.d.ts +34 -0
  164. package/dist/migrations/index.d.ts.map +1 -0
  165. package/dist/migrations/index.js +37 -0
  166. package/dist/migrations/index.js.map +1 -0
  167. package/docs/blog/index.md +199 -0
  168. package/i18n/en.json +166 -0
  169. package/i18n/pl.json +166 -0
  170. package/package.json +108 -0
  171. package/tailwind.css +14 -0
@@ -0,0 +1,260 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ /**
3
+ * Blog module — feature 016 / data-model.md.
4
+ *
5
+ * Schema changes (one atomic migration):
6
+ * - new tables: blog_categories + blog_category_sales_channels +
7
+ * blog_category_languages + blog_posts + blog_post_sales_channels +
8
+ * blog_post_languages + blog_post_categories + blog_post_tags +
9
+ * blog_post_related_posts + blog_post_related_products + blog_tags.
10
+ * - GIN index on blog_posts.content (jsonb_path_ops) so the asset-reference
11
+ * scan stays fast (R13).
12
+ * - btree index on blog_posts(status, published_at DESC) drives the
13
+ * storefront's "newest first" listing.
14
+ * - btree index on blog_categories(parent_id, position) drives the
15
+ * per-tree fetch.
16
+ * - PARTIAL unique indexes on blog_post_sales_channels(sales_channel_id,
17
+ * slug) WHERE deleted_at IS NULL and the analogous one on
18
+ * blog_category_sales_channels — defensive guards for the cross-table
19
+ * uniqueness contract documented in research.md § R3 (the cross-table
20
+ * check itself runs at the service layer with a pg_advisory_xact_lock).
21
+ * - PARTIAL unique on blog_tags(code) WHERE deleted_at IS NULL — global
22
+ * tag-code uniqueness (R14).
23
+ *
24
+ * No seed rows; the seeded `Default` Category and the two seeded admin
25
+ * roles are inserted by runtime reconcilers in `services/seed-default-
26
+ * category.ts` and `services/seed-roles.ts` so admin edits across deploys
27
+ * stay safe (R8 + R11).
28
+ */
29
+ export class Migration20260506T081055BlogInit extends Migration {
30
+ async up() {
31
+ // ────────────────────────────────────────────────────────────────────
32
+ // 1) blog_categories — adjacency-list tree
33
+ // ────────────────────────────────────────────────────────────────────
34
+ this.addSql(`
35
+ create table "blog_categories" (
36
+ "id" uuid not null,
37
+ "parent_id" uuid null,
38
+ "position" int not null default 0,
39
+ "name" jsonb not null default '{}'::jsonb,
40
+ "slug" varchar(160) not null,
41
+ "enabled" boolean not null default true,
42
+ "description" jsonb null,
43
+ "main_image_asset_id" uuid null,
44
+ "meta_title" jsonb null,
45
+ "meta_description" jsonb null,
46
+ "meta_keywords" jsonb null,
47
+ "is_system" boolean not null default false,
48
+ "version" int not null default 1,
49
+ "created_at" timestamptz not null,
50
+ "updated_at" timestamptz not null,
51
+ "deleted_at" timestamptz null,
52
+ constraint "blog_categories_pkey" primary key ("id"),
53
+ constraint "blog_categories_parent_fk" foreign key ("parent_id")
54
+ references "blog_categories" ("id") on delete no action,
55
+ constraint "blog_categories_main_image_fk" foreign key ("main_image_asset_id")
56
+ references "assets" ("id") on delete no action
57
+ );
58
+ `);
59
+ this.addSql('create index "idx_blog_categories_parent_position" on "blog_categories" ("parent_id", "position");');
60
+ this.addSql('create index "idx_blog_categories_main_image_asset" on "blog_categories" ("main_image_asset_id");');
61
+ this.addSql('create index "idx_blog_categories_is_system" on "blog_categories" ("is_system");');
62
+ this.addSql('create index "idx_blog_categories_deleted_at" on "blog_categories" ("deleted_at");');
63
+ // ────────────────────────────────────────────────────────────────────
64
+ // 2) blog_category_sales_channels (M2M + denormalised slug + deleted_at)
65
+ // ────────────────────────────────────────────────────────────────────
66
+ this.addSql(`
67
+ create table "blog_category_sales_channels" (
68
+ "blog_category_id" uuid not null,
69
+ "sales_channel_id" uuid not null,
70
+ "slug" varchar(160) not null,
71
+ "deleted_at" timestamptz null,
72
+ constraint "blog_category_sales_channels_pkey"
73
+ primary key ("blog_category_id", "sales_channel_id"),
74
+ constraint "blog_category_sales_channels_category_fk" foreign key ("blog_category_id")
75
+ references "blog_categories" ("id") on delete cascade,
76
+ constraint "blog_category_sales_channels_channel_fk" foreign key ("sales_channel_id")
77
+ references "sales_channels" ("id") on delete cascade
78
+ );
79
+ `);
80
+ this.addSql(`create unique index "idx_blog_categories_slug_per_channel_uniq"
81
+ on "blog_category_sales_channels" ("sales_channel_id", "slug")
82
+ where "deleted_at" is null;`);
83
+ // ────────────────────────────────────────────────────────────────────
84
+ // 3) blog_category_languages (M2M)
85
+ // ────────────────────────────────────────────────────────────────────
86
+ this.addSql(`
87
+ create table "blog_category_languages" (
88
+ "blog_category_id" uuid not null,
89
+ "language" varchar(8) not null,
90
+ constraint "blog_category_languages_pkey"
91
+ primary key ("blog_category_id", "language"),
92
+ constraint "blog_category_languages_category_fk" foreign key ("blog_category_id")
93
+ references "blog_categories" ("id") on delete cascade
94
+ );
95
+ `);
96
+ // ────────────────────────────────────────────────────────────────────
97
+ // 4) blog_posts
98
+ // ────────────────────────────────────────────────────────────────────
99
+ this.addSql(`
100
+ create table "blog_posts" (
101
+ "id" uuid not null,
102
+ "name" jsonb not null default '{}'::jsonb,
103
+ "slug" varchar(160) not null,
104
+ "active" boolean not null default true,
105
+ "status" varchar(16) not null default 'draft',
106
+ "published_at" timestamptz null,
107
+ "description" text null,
108
+ "meta_title" jsonb null,
109
+ "meta_description" jsonb null,
110
+ "meta_keywords" jsonb null,
111
+ "content" jsonb not null default '{}'::jsonb,
112
+ "version" int not null default 1,
113
+ "created_at" timestamptz not null,
114
+ "updated_at" timestamptz not null,
115
+ "deleted_at" timestamptz null,
116
+ constraint "blog_posts_pkey" primary key ("id")
117
+ );
118
+ `);
119
+ this.addSql(`create index "idx_blog_posts_status_published_at"
120
+ on "blog_posts" ("status", "published_at" desc);`);
121
+ this.addSql('create index "idx_blog_posts_deleted_at" on "blog_posts" ("deleted_at");');
122
+ this.addSql(`create index "idx_blog_posts_content_refs"
123
+ on "blog_posts" using gin ("content" jsonb_path_ops);`);
124
+ // ────────────────────────────────────────────────────────────────────
125
+ // 5) blog_post_sales_channels (M2M + denormalised slug + deleted_at)
126
+ // ────────────────────────────────────────────────────────────────────
127
+ this.addSql(`
128
+ create table "blog_post_sales_channels" (
129
+ "blog_post_id" uuid not null,
130
+ "sales_channel_id" uuid not null,
131
+ "slug" varchar(160) not null,
132
+ "deleted_at" timestamptz null,
133
+ constraint "blog_post_sales_channels_pkey"
134
+ primary key ("blog_post_id", "sales_channel_id"),
135
+ constraint "blog_post_sales_channels_post_fk" foreign key ("blog_post_id")
136
+ references "blog_posts" ("id") on delete cascade,
137
+ constraint "blog_post_sales_channels_channel_fk" foreign key ("sales_channel_id")
138
+ references "sales_channels" ("id") on delete cascade
139
+ );
140
+ `);
141
+ this.addSql(`create unique index "idx_blog_posts_slug_per_channel_uniq"
142
+ on "blog_post_sales_channels" ("sales_channel_id", "slug")
143
+ where "deleted_at" is null;`);
144
+ // ────────────────────────────────────────────────────────────────────
145
+ // 6) blog_post_languages (M2M)
146
+ // ────────────────────────────────────────────────────────────────────
147
+ this.addSql(`
148
+ create table "blog_post_languages" (
149
+ "blog_post_id" uuid not null,
150
+ "language" varchar(8) not null,
151
+ constraint "blog_post_languages_pkey"
152
+ primary key ("blog_post_id", "language"),
153
+ constraint "blog_post_languages_post_fk" foreign key ("blog_post_id")
154
+ references "blog_posts" ("id") on delete cascade
155
+ );
156
+ `);
157
+ // ────────────────────────────────────────────────────────────────────
158
+ // 7) blog_post_categories (M2M)
159
+ // ────────────────────────────────────────────────────────────────────
160
+ this.addSql(`
161
+ create table "blog_post_categories" (
162
+ "blog_post_id" uuid not null,
163
+ "blog_category_id" uuid not null,
164
+ constraint "blog_post_categories_pkey"
165
+ primary key ("blog_post_id", "blog_category_id"),
166
+ constraint "blog_post_categories_post_fk" foreign key ("blog_post_id")
167
+ references "blog_posts" ("id") on delete cascade,
168
+ constraint "blog_post_categories_category_fk" foreign key ("blog_category_id")
169
+ references "blog_categories" ("id") on delete no action
170
+ );
171
+ `);
172
+ this.addSql(`create index "idx_blog_post_categories_category_id"
173
+ on "blog_post_categories" ("blog_category_id");`);
174
+ // ────────────────────────────────────────────────────────────────────
175
+ // 8) blog_tags
176
+ // ────────────────────────────────────────────────────────────────────
177
+ this.addSql(`
178
+ create table "blog_tags" (
179
+ "id" uuid not null,
180
+ "name" jsonb not null default '{}'::jsonb,
181
+ "description" jsonb null,
182
+ "code" varchar(64) not null,
183
+ "version" int not null default 1,
184
+ "created_at" timestamptz not null,
185
+ "updated_at" timestamptz not null,
186
+ "deleted_at" timestamptz null,
187
+ constraint "blog_tags_pkey" primary key ("id")
188
+ );
189
+ `);
190
+ this.addSql(`create unique index "idx_blog_tags_code_uniq"
191
+ on "blog_tags" ("code")
192
+ where "deleted_at" is null;`);
193
+ // ────────────────────────────────────────────────────────────────────
194
+ // 9) blog_post_tags (M2M, ordered)
195
+ // ────────────────────────────────────────────────────────────────────
196
+ this.addSql(`
197
+ create table "blog_post_tags" (
198
+ "blog_post_id" uuid not null,
199
+ "blog_tag_id" uuid not null,
200
+ "position" int not null default 0,
201
+ constraint "blog_post_tags_pkey"
202
+ primary key ("blog_post_id", "blog_tag_id"),
203
+ constraint "blog_post_tags_post_fk" foreign key ("blog_post_id")
204
+ references "blog_posts" ("id") on delete cascade,
205
+ constraint "blog_post_tags_tag_fk" foreign key ("blog_tag_id")
206
+ references "blog_tags" ("id") on delete no action
207
+ );
208
+ `);
209
+ this.addSql(`create index "idx_blog_post_tags_tag_id"
210
+ on "blog_post_tags" ("blog_tag_id");`);
211
+ // ────────────────────────────────────────────────────────────────────
212
+ // 10) blog_post_related_posts (self-join, ordered)
213
+ // ────────────────────────────────────────────────────────────────────
214
+ this.addSql(`
215
+ create table "blog_post_related_posts" (
216
+ "parent_post_id" uuid not null,
217
+ "related_post_id" uuid not null,
218
+ "position" int not null default 0,
219
+ constraint "blog_post_related_posts_pkey"
220
+ primary key ("parent_post_id", "related_post_id"),
221
+ constraint "blog_post_related_posts_parent_fk" foreign key ("parent_post_id")
222
+ references "blog_posts" ("id") on delete cascade,
223
+ constraint "blog_post_related_posts_related_fk" foreign key ("related_post_id")
224
+ references "blog_posts" ("id") on delete no action
225
+ );
226
+ `);
227
+ this.addSql(`create index "idx_blog_post_related_posts_related"
228
+ on "blog_post_related_posts" ("related_post_id");`);
229
+ // ────────────────────────────────────────────────────────────────────
230
+ // 11) blog_post_related_products (M2M to products, ordered)
231
+ // ────────────────────────────────────────────────────────────────────
232
+ this.addSql(`
233
+ create table "blog_post_related_products" (
234
+ "blog_post_id" uuid not null,
235
+ "product_id" uuid not null,
236
+ "position" int not null default 0,
237
+ constraint "blog_post_related_products_pkey"
238
+ primary key ("blog_post_id", "product_id"),
239
+ constraint "blog_post_related_products_post_fk" foreign key ("blog_post_id")
240
+ references "blog_posts" ("id") on delete cascade,
241
+ constraint "blog_post_related_products_product_fk" foreign key ("product_id")
242
+ references "products" ("id") on delete no action
243
+ );
244
+ `);
245
+ }
246
+ async down() {
247
+ this.addSql('drop table if exists "blog_post_related_products" cascade;');
248
+ this.addSql('drop table if exists "blog_post_related_posts" cascade;');
249
+ this.addSql('drop table if exists "blog_post_tags" cascade;');
250
+ this.addSql('drop table if exists "blog_tags" cascade;');
251
+ this.addSql('drop table if exists "blog_post_categories" cascade;');
252
+ this.addSql('drop table if exists "blog_post_languages" cascade;');
253
+ this.addSql('drop table if exists "blog_post_sales_channels" cascade;');
254
+ this.addSql('drop table if exists "blog_posts" cascade;');
255
+ this.addSql('drop table if exists "blog_category_languages" cascade;');
256
+ this.addSql('drop table if exists "blog_category_sales_channels" cascade;');
257
+ this.addSql('drop table if exists "blog_categories" cascade;');
258
+ }
259
+ }
260
+ //# sourceMappingURL=20260506T081055_blog_init.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260506T081055_blog_init.js","sourceRoot":"","sources":["../../src/migrations/20260506T081055_blog_init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,OAAO,gCAAiC,SAAQ,SAAS;IACpD,KAAK,CAAC,EAAE;QACf,uEAAuE;QACvE,2CAA2C;QAC3C,uEAAuE;QACvE,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;KAwBX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CACT,oGAAoG,CACrG,CAAC;QACF,IAAI,CAAC,MAAM,CACT,mGAAmG,CACpG,CAAC;QACF,IAAI,CAAC,MAAM,CACT,kFAAkF,CACnF,CAAC;QACF,IAAI,CAAC,MAAM,CACT,oFAAoF,CACrF,CAAC;QAEF,uEAAuE;QACvE,yEAAyE;QACzE,uEAAuE;QACvE,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;KAaX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CACT;;qCAE+B,CAChC,CAAC;QAEF,uEAAuE;QACvE,mCAAmC;QACnC,uEAAuE;QACvE,IAAI,CAAC,MAAM,CAAC;;;;;;;;;KASX,CAAC,CAAC;QAEH,uEAAuE;QACvE,gBAAgB;QAChB,uEAAuE;QACvE,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;KAmBX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CACT;0DACoD,CACrD,CAAC;QACF,IAAI,CAAC,MAAM,CACT,0EAA0E,CAC3E,CAAC;QACF,IAAI,CAAC,MAAM,CACT;+DACyD,CAC1D,CAAC;QAEF,uEAAuE;QACvE,qEAAqE;QACrE,uEAAuE;QACvE,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;KAaX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CACT;;qCAE+B,CAChC,CAAC;QAEF,uEAAuE;QACvE,+BAA+B;QAC/B,uEAAuE;QACvE,IAAI,CAAC,MAAM,CAAC;;;;;;;;;KASX,CAAC,CAAC;QAEH,uEAAuE;QACvE,gCAAgC;QAChC,uEAAuE;QACvE,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;KAWX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CACT;yDACmD,CACpD,CAAC;QAEF,uEAAuE;QACvE,eAAe;QACf,uEAAuE;QACvE,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;KAYX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CACT;;qCAE+B,CAChC,CAAC;QAEF,uEAAuE;QACvE,mCAAmC;QACnC,uEAAuE;QACvE,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;KAYX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CACT;8CACwC,CACzC,CAAC;QAEF,uEAAuE;QACvE,mDAAmD;QACnD,uEAAuE;QACvE,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;KAYX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CACT;2DACqD,CACtD,CAAC;QAEF,uEAAuE;QACvE,4DAA4D;QAC5D,uEAAuE;QACvE,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;KAYX,CAAC,CAAC;IACL,CAAC;IAEQ,KAAK,CAAC,IAAI;QACjB,IAAI,CAAC,MAAM,CAAC,4DAA4D,CAAC,CAAC;QAC1E,IAAI,CAAC,MAAM,CAAC,yDAAyD,CAAC,CAAC;QACvE,IAAI,CAAC,MAAM,CAAC,gDAAgD,CAAC,CAAC;QAC9D,IAAI,CAAC,MAAM,CAAC,2CAA2C,CAAC,CAAC;QACzD,IAAI,CAAC,MAAM,CAAC,sDAAsD,CAAC,CAAC;QACpE,IAAI,CAAC,MAAM,CAAC,qDAAqD,CAAC,CAAC;QACnE,IAAI,CAAC,MAAM,CAAC,0DAA0D,CAAC,CAAC;QACxE,IAAI,CAAC,MAAM,CAAC,4CAA4C,CAAC,CAAC;QAC1D,IAAI,CAAC,MAAM,CAAC,yDAAyD,CAAC,CAAC;QACvE,IAAI,CAAC,MAAM,CAAC,8DAA8D,CAAC,CAAC;QAC5E,IAAI,CAAC,MAAM,CAAC,iDAAiD,CAAC,CAAC;IACjE,CAAC;CACF"}
@@ -0,0 +1,6 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ export declare class Migration20260903T101744BlogNamespaceBlockNames extends Migration {
3
+ up(): Promise<void>;
4
+ down(): Promise<void>;
5
+ }
6
+ //# sourceMappingURL=20260903T101744_blog_namespace_block_names.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260903T101744_blog_namespace_block_names.d.ts","sourceRoot":"","sources":["../../src/migrations/20260903T101744_blog_namespace_block_names.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAmDlD,qBAAa,+CAAgD,SAAQ,SAAS;IAC7D,EAAE,IAAI,OAAO,CAAC,IAAI,CAAC;IAOnB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAMrC"}
@@ -0,0 +1,58 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ import { FROZEN_BLOCK_RENAMES, FROZEN_BLOCK_RENAMES_INVERSE, applyRenameFunctionSql, createRenameFunctionSql, dropRenameFunctionSql, } from '@endora-commerce/page-builder-core/migration';
3
+ /**
4
+ * Namespace the Page Builder block names stored in `blog`'s 2 `jsonb` columns
5
+ * — feature 096, T405 (`contracts/block-name-migration.md`).
6
+ *
7
+ * **The rewrite is structural, never textual.** Twelve of the 74 renamed names
8
+ * are ordinary English words that occur throughout shop content — `Row`,
9
+ * `Text`, `Image`, `Map`, `Button` among them — so a text substitution over the
10
+ * column would corrupt a `RawHtml` block's markup and every `alt` attribute in
11
+ * the shop. `pg_temp.rename_block_names_blog` descends the document and replaces the value of a
12
+ * `type` property in a **node** position and nothing else; the walk is
13
+ * generated from `FROZEN_BLOCK_RENAMES` by
14
+ * `@endora-commerce/page-builder-core/migration`, so five migrations share one
15
+ * definition of what a node is.
16
+ *
17
+ * **This migration belongs to `blog` because `blog` owns these tables**,
18
+ * not because it owns the new names (`contracts/block-name-migration.md` §2).
19
+ * Some of the names it writes belong to other modules; that creates no
20
+ * obligation on them and **no new manifest `dependencies` edge**, because a
21
+ * block name is a string value inside a JSONB document — not a foreign key and
22
+ * not a table identifier.
23
+ *
24
+ * **It cannot fail on its input.** A name the map does not hold is left
25
+ * byte-identical: an already-namespaced one and an unrecognised one alike,
26
+ * because the map's domain is bare and its codomain is dotted, so the two sets
27
+ * are disjoint. That is also what makes a second run rewrite nothing — FR-013
28
+ * is a property of the map rather than an outcome a branch has to remember to
29
+ * produce, and no dot test appears in this SQL.
30
+ *
31
+ * `down()` applies the inverse over the identical walk and is **exact for the
32
+ * frozen set**. It is partial by construction: a name with no pre-migration
33
+ * form — a block authored after the upgrade, a third party's `acme.Banner` —
34
+ * has nothing to return to and is left alone. That is correct, and it is why
35
+ * the operator pre-flight (§6) is a backup rather than a `down()`.
36
+ */
37
+ /**
38
+ * Named per module: the five rename migrations may run on one pooled session,
39
+ * and a second `create function` over the same name fails. Each drops its own
40
+ * when it is done, and a rollback removes it with everything else — `pg_temp`
41
+ * DDL is transactional.
42
+ */
43
+ const FN = 'rename_block_names_blog';
44
+ export class Migration20260903T101744BlogNamespaceBlockNames extends Migration {
45
+ async up() {
46
+ this.addSql(createRenameFunctionSql(FN, FROZEN_BLOCK_RENAMES));
47
+ this.addSql(applyRenameFunctionSql(FN, 'blog_posts', 'content'));
48
+ this.addSql(applyRenameFunctionSql(FN, 'blog_categories', 'description'));
49
+ this.addSql(dropRenameFunctionSql(FN));
50
+ }
51
+ async down() {
52
+ this.addSql(createRenameFunctionSql(FN, FROZEN_BLOCK_RENAMES_INVERSE));
53
+ this.addSql(applyRenameFunctionSql(FN, 'blog_posts', 'content'));
54
+ this.addSql(applyRenameFunctionSql(FN, 'blog_categories', 'description'));
55
+ this.addSql(dropRenameFunctionSql(FN));
56
+ }
57
+ }
58
+ //# sourceMappingURL=20260903T101744_blog_namespace_block_names.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260903T101744_blog_namespace_block_names.js","sourceRoot":"","sources":["../../src/migrations/20260903T101744_blog_namespace_block_names.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAClD,OAAO,EACL,oBAAoB,EACpB,4BAA4B,EAC5B,sBAAsB,EACtB,uBAAuB,EACvB,qBAAqB,GACtB,MAAM,8CAA8C,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH;;;;;GAKG;AACH,MAAM,EAAE,GAAG,yBAAyB,CAAC;AAErC,MAAM,OAAO,+CAAgD,SAAQ,SAAS;IACnE,KAAK,CAAC,EAAE;QACf,IAAI,CAAC,MAAM,CAAC,uBAAuB,CAAC,EAAE,EAAE,oBAAoB,CAAC,CAAC,CAAC;QAC/D,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,EAAE,EAAE,YAAY,EAAE,SAAS,CAAC,CAAC,CAAC;QACjE,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,EAAE,EAAE,iBAAiB,EAAE,aAAa,CAAC,CAAC,CAAC;QAC1E,IAAI,CAAC,MAAM,CAAC,qBAAqB,CAAC,EAAE,CAAC,CAAC,CAAC;IACzC,CAAC;IAEQ,KAAK,CAAC,IAAI;QACjB,IAAI,CAAC,MAAM,CAAC,uBAAuB,CAAC,EAAE,EAAE,4BAA4B,CAAC,CAAC,CAAC;QACvE,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,EAAE,EAAE,YAAY,EAAE,SAAS,CAAC,CAAC,CAAC;QACjE,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,EAAE,EAAE,iBAAiB,EAAE,aAAa,CAAC,CAAC,CAAC;QAC1E,IAAI,CAAC,MAAM,CAAC,qBAAqB,CAAC,EAAE,CAAC,CAAC,CAAC;IACzC,CAAC;CACF"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The `./migrations` subpath — every migration class this module owns, as one
3
+ * ordered `migrations` array.
4
+ *
5
+ * The array is what the platform reads when this module is **installed**:
6
+ * `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
7
+ * the package outright when it is absent — *"the `./migrations` export of
8
+ * @endora-commerce/mod-blog exports no 'migrations' array"*. Until D-168's
9
+ * merge request this file published only `export *`, so an installed `blog`
10
+ * did not merely lose its schema, it stopped the platform from booting; the
11
+ * defect was invisible because a workspace member is not an installed package
12
+ * and the committed host registry names the class directly. That is the same
13
+ * silence D-168 found on `./backend`, one subpath along.
14
+ *
15
+ * The **named** export stays, and the asymmetry with `./backend` is deliberate.
16
+ * `db/migrations-registry.generated.ts` imports each class by name from this
17
+ * specifier and hands it to `migration('blog', …)`, and a migration class name
18
+ * is contract in a way an entity class name is not: `mikro_orm_migrations`
19
+ * persists it, so it is a string every already-migrated database holds. It also
20
+ * carries none of the hazard D-168 removes — no module has a reason to name
21
+ * another module's migration, and doing so buys nothing an entity import buys.
22
+ *
23
+ * A class that is in neither the array nor the barrel is a migration that does
24
+ * not run: `migration:pending` reports nothing pending and the first symptom is
25
+ * a query against a table nobody created. The composer refuses a migration file
26
+ * no declared subpath covers for exactly that reason, but it cannot see whether
27
+ * the barrel behind the subpath actually carries the class — that is this
28
+ * file's job.
29
+ */
30
+ import { Migration20260506T081055BlogInit } from './20260506T081055_blog_init.js';
31
+ import { Migration20260903T101744BlogNamespaceBlockNames } from './20260903T101744_blog_namespace_block_names.js';
32
+ export declare const migrations: (typeof Migration20260506T081055BlogInit)[];
33
+ export { Migration20260506T081055BlogInit, Migration20260903T101744BlogNamespaceBlockNames, };
34
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAE,gCAAgC,EAAE,MAAM,gCAAgC,CAAC;AAClF,OAAO,EAAE,+CAA+C,EAAE,MAAM,iDAAiD,CAAC;AAElH,eAAO,MAAM,UAAU,6CAGtB,CAAC;AAEF,OAAO,EACL,gCAAgC,EAChC,+CAA+C,GAChD,CAAC"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The `./migrations` subpath — every migration class this module owns, as one
3
+ * ordered `migrations` array.
4
+ *
5
+ * The array is what the platform reads when this module is **installed**:
6
+ * `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
7
+ * the package outright when it is absent — *"the `./migrations` export of
8
+ * @endora-commerce/mod-blog exports no 'migrations' array"*. Until D-168's
9
+ * merge request this file published only `export *`, so an installed `blog`
10
+ * did not merely lose its schema, it stopped the platform from booting; the
11
+ * defect was invisible because a workspace member is not an installed package
12
+ * and the committed host registry names the class directly. That is the same
13
+ * silence D-168 found on `./backend`, one subpath along.
14
+ *
15
+ * The **named** export stays, and the asymmetry with `./backend` is deliberate.
16
+ * `db/migrations-registry.generated.ts` imports each class by name from this
17
+ * specifier and hands it to `migration('blog', …)`, and a migration class name
18
+ * is contract in a way an entity class name is not: `mikro_orm_migrations`
19
+ * persists it, so it is a string every already-migrated database holds. It also
20
+ * carries none of the hazard D-168 removes — no module has a reason to name
21
+ * another module's migration, and doing so buys nothing an entity import buys.
22
+ *
23
+ * A class that is in neither the array nor the barrel is a migration that does
24
+ * not run: `migration:pending` reports nothing pending and the first symptom is
25
+ * a query against a table nobody created. The composer refuses a migration file
26
+ * no declared subpath covers for exactly that reason, but it cannot see whether
27
+ * the barrel behind the subpath actually carries the class — that is this
28
+ * file's job.
29
+ */
30
+ import { Migration20260506T081055BlogInit } from './20260506T081055_blog_init.js';
31
+ import { Migration20260903T101744BlogNamespaceBlockNames } from './20260903T101744_blog_namespace_block_names.js';
32
+ export const migrations = [
33
+ Migration20260506T081055BlogInit,
34
+ Migration20260903T101744BlogNamespaceBlockNames,
35
+ ];
36
+ export { Migration20260506T081055BlogInit, Migration20260903T101744BlogNamespaceBlockNames, };
37
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAE,gCAAgC,EAAE,MAAM,gCAAgC,CAAC;AAClF,OAAO,EAAE,+CAA+C,EAAE,MAAM,iDAAiD,CAAC;AAElH,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,gCAAgC;IAChC,+CAA+C;CAChD,CAAC;AAEF,OAAO,EACL,gCAAgC,EAChC,+CAA+C,GAChD,CAAC"}
@@ -0,0 +1,199 @@
1
+ ---
2
+ title: Blog
3
+ sidebar_position: 16
4
+ description: Editorial Posts with Page Builder bodies, taxonomy (Categories + Tags), and storefront feeds
5
+ ---
6
+
7
+ # Blog
8
+
9
+ The Blog module owns the storefront's editorial blog surface — Posts,
10
+ Categories, and Tags — composed in the same drag-and-drop **Page
11
+ Builder** that ships with the [CMS module](../cms/index.md). Posts
12
+ inherit the CMS Page's field shape (slug, status lifecycle, per-language
13
+ content, SEO meta) and add blog-specific affordances: a parent Category
14
+ tree, free-form Tags, ordered Related Posts, and ordered Related
15
+ Products.
16
+
17
+ | Entity | Identifier | Lifecycle | Embedded by |
18
+ | -------------- | ---------------------- | ------------------------------------ | -------------------------------------------------------- |
19
+ | **Post** | `slug` (per channel) | `draft → published → archived` | URL `<blog-prefix>/<slug>` on the storefront |
20
+ | **Category** | `slug` (per channel) | `enabled` flag, tree-structured | URL `<blog-prefix>/<slug>` (siblings of Posts) and tiles |
21
+ | **Tag** | `code` (global) | block-on-delete | URL `<blog-prefix>/tag/<code>` and per-Post chip strip |
22
+
23
+ ## Entities and the reference graph
24
+
25
+ ```
26
+ blog_categories ── parent_id ──┐ self-FK tree (NO ACTION)
27
+ │ ▼
28
+ ├─── blog_post_categories ───────► blog_posts
29
+ │ │
30
+ │ ┌────────┼─────────┐
31
+ ▼ ▼ ▼ ▼
32
+ blog_post_categories blog_post_tags blog_post_related_posts (self-FK)
33
+ │ │
34
+ ▼ │
35
+ blog_tags │
36
+ ▼
37
+ blog_post_related_products → catalog.products
38
+ ```
39
+
40
+ The schema lives entirely in `037_blog_init.ts` (eleven new tables, all
41
+ prefixed `blog_*`) and never touches existing tables.
42
+
43
+ ## URLs and routing
44
+
45
+ The storefront mounts a single Next.js catch-all under
46
+ `storefront/app/(blog)/[[...slug]]/page.tsx`. The matcher reads the
47
+ channel-resolved `blog.url_prefix` from the [Settings module](../settings/index.md)
48
+ and dispatches:
49
+
50
+ | URL pattern (after channel resolution) | Renders |
51
+ | -------------------------------------- | ------------------------------------ |
52
+ | `/<prefix>` | Blog index — latest N + first-level Categories |
53
+ | `/<prefix>/tag/<code>` | Tag view — paginated cards for the Tag |
54
+ | `/<prefix>/<slug>` | Category page (if the slug matches a `blog_categories.slug`) |
55
+ | `/<prefix>/<slug>` | Post page (if the slug matches a `blog_posts.slug`) |
56
+
57
+ 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).
58
+
59
+ ### Slug uniqueness
60
+
61
+ Slug uniqueness is enforced **per `(sales_channel, slug)` across the
62
+ union of `blog_posts` and `blog_categories`**:
63
+
64
+ - DB-level partial unique indexes on each scope row (`*_sales_channels`)
65
+ catch any application bug that bypasses the service-level check.
66
+ - The `BlogSlugCollision.assertSlugAvailable` helper acquires a
67
+ Postgres `pg_advisory_xact_lock` per `(channel, slug)` so a concurrent
68
+ save cannot squeeze a duplicate past the per-table indexes.
69
+ - The literal `tag` is reserved as a slug (would collide with the
70
+ `<prefix>/tag/<code>` URL pattern).
71
+
72
+ ## Lifecycle
73
+
74
+ ### Posts
75
+
76
+ ```
77
+ draft ─── publish ────► published ─── unpublish ───► draft
78
+ │ │
79
+ │ archive │
80
+ ▼ ▼
81
+ archived ◄── unarchive ── (admin)
82
+ ```
83
+
84
+ `published_at` is set on the first transition to `published` and
85
+ preserved across subsequent unpublish / archive transitions (so re-
86
+ publishing a post does not reset its publication date). A Post renders
87
+ on the storefront when:
88
+
89
+ ```
90
+ status = 'published'
91
+ AND active = true
92
+ AND deleted_at IS NULL
93
+ AND requested-channel ∈ post.salesChannels
94
+ AND blog.enabled[channel] = true
95
+ ```
96
+
97
+ ### Categories
98
+
99
+ Categories carry a single `enabled` flag (no draft / publish). The seeded
100
+ `Default` row is system-protected — admins can rename it, change its
101
+ metadata, or detach it from channels, but it cannot be deleted.
102
+
103
+ ## Reference protection
104
+
105
+ Five guards block destructive operations:
106
+
107
+ 1. **Tag delete with referencing posts** → 409 `BLOG_TAG_IN_USE`.
108
+ 2. **Category delete with referencing posts** → 409 `BLOG_CATEGORY_IN_USE`.
109
+ 3. **Category delete with child rows** → 409 `BLOG_CATEGORY_HAS_CHILDREN`.
110
+ 4. **Seeded `Default` Category delete** → 409 `BLOG_CATEGORY_PROTECTED`.
111
+ 5. **Library Asset soft-delete while embedded in a Post body or Category description** →
112
+ 409 `ASSET_REFERENCED` (descriptors registered with the
113
+ [Assets Library reference registry](../assets-library/index.md#asset-reference-registry)).
114
+
115
+ The Post-as-Related-Post relationship uses a different contract:
116
+ **detach-on-delete**. When a Post is soft-deleted, every parent Post's
117
+ `relatedPostIds` shrinks atomically. The admin sees a confirmation
118
+ dialog listing the affected parents (driven by the
119
+ `/posts/:id/inbound-references` probe).
120
+
121
+ 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.
122
+
123
+ ## Settings
124
+
125
+ | Code | Type | Default | Notes |
126
+ | --------------------- | ------- | ------: | ---------------------------------------------------- |
127
+ | `blog.enabled` | boolean | `true` | Disables the namespace per channel. |
128
+ | `blog.url_prefix` | string | `blog` | Single URL segment, `^[a-z0-9-]+$`. Reserved Next.js segments refused. |
129
+ | `blog.latest_count` | number | `5` | Latest posts on the index. |
130
+ | `blog.posts_per_page` | number | `12` | Page size on Category and Tag views. |
131
+
132
+ A change to any `blog.*` setting drops the platform-wide settings cache at the
133
+ write seam and then fires an `EventBus` event, which wipes the storefront cache
134
+ (see Cache strategy below).
135
+
136
+ ## Admin roles
137
+
138
+ Two roles seed at first boot through `services/seed-roles.ts`:
139
+
140
+ | Code | Default name | Permissions |
141
+ | ----------------- | ----------------- | ---------------------------------------------------- |
142
+ | `blog_manager` | Blog Manager | `blog.read`, `blog.write` |
143
+ | `content_manager` | Content Manager | `blog.read`, `blog.write`, `cms.read`, `cms.write` |
144
+
145
+ Both are **system-protected**: `AdminRoleService.remove` refuses delete
146
+ with 409 `ADMIN_ROLE_PROTECTED`. The reconciler preserves admin-edited
147
+ names across reboots and only refreshes the canonical permissions
148
+ array if it has drifted.
149
+
150
+ ## Storefront API
151
+
152
+ | Method | Path | Returns |
153
+ | ------ | ------------------------------------- | -------------------------------------------------------- |
154
+ | GET | `/api/v1/blog/by-channel` | `BlogIndexResponse` — latest N + first-level Categories |
155
+ | GET | `/api/v1/blog/by-slug?slug=…` | Discriminated `BlogBySlugResponse` (category / post) |
156
+ | GET | `/api/v1/blog/tag-by-code?code=…` | `BlogTagByCodeResponse` — paginated posts for the Tag |
157
+
158
+ All three:
159
+
160
+ - read the resolved channel + language from request headers (`x-sales-channel`, `x-blog-language` / `accept-language`);
161
+ - read `blog.*` settings from the channel-aware Settings service;
162
+ - return `404 BLOG_DISABLED` (or `BLOG_POST_NOT_FOUND` / `BLOG_TAG_NOT_FOUND`) when the resolved scope has no matching content;
163
+ - ride the [`BlogCacheService`](#cache-strategy) Redis read-through.
164
+
165
+ ## Cache strategy
166
+
167
+ Keys live under `blog:v1:<channelCode>:<language>:` with four shapes:
168
+
169
+ ```
170
+ blog:v1:<channel>:<language>:index
171
+ blog:v1:<channel>:<language>:category:<slug>:p<page>
172
+ blog:v1:<channel>:<language>:post:<slug>
173
+ blog:v1:<channel>:<language>:tag:<code>:p<page>
174
+ ```
175
+
176
+ TTL: 5 minutes (aligned with the CMS module's cache).
177
+
178
+ Invalidation:
179
+
180
+ | Event | Wipe |
181
+ | -------------------------------------- | ----------------------------------------------- |
182
+ | Post / Category / Tag write | `BlogCacheService.invalidateAll()` |
183
+ | Soft-delete or settings write | `BlogCacheService.invalidateAll()` via EventBus |
184
+
185
+ The granular `invalidatePost(channelCode, slug)` and friends are wired
186
+ on the cache class for future surgical-invalidation work (coarse
187
+ invalidation is correct at v1; rate of writes is low).
188
+
189
+ ## Cross-module dependencies
190
+
191
+ | Module | What the blog reads |
192
+ | -------------------------------------------- | ----------------------------------------------------------- |
193
+ | [Settings](../settings/index.md) | Four `blog.*` settings via `settings.service.get` |
194
+ | [Sales Channels](../sales_channels/index.md) | Channel resolution + language fallback |
195
+ | [Assets Library](../assets-library/index.md) | Asset URL signing + reference registry |
196
+ | [CMS](../cms/index.md) | Page Builder envelope (`cmsContentEnvelopeSchema`) |
197
+ | [Catalog](../catalog.md) | Product card resolution for Related Products |
198
+
199
+ The blog module never imports another module's internals — every cross-module read goes through a documented service port.