@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.
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/dist/admin/api/blog-client.d.ts +70 -0
- package/dist/admin/api/blog-client.d.ts.map +1 -0
- package/dist/admin/api/blog-client.js +137 -0
- package/dist/admin/api/blog-client.js.map +1 -0
- package/dist/admin/components/CategoryTreeNode.d.ts +25 -0
- package/dist/admin/components/CategoryTreeNode.d.ts.map +1 -0
- package/dist/admin/components/CategoryTreeNode.js +14 -0
- package/dist/admin/components/CategoryTreeNode.js.map +1 -0
- package/dist/admin/components/PostStatusBadge.d.ts +7 -0
- package/dist/admin/components/PostStatusBadge.d.ts.map +1 -0
- package/dist/admin/components/PostStatusBadge.js +16 -0
- package/dist/admin/components/PostStatusBadge.js.map +1 -0
- package/dist/admin/components/RelatedPostsPicker.d.ts +11 -0
- package/dist/admin/components/RelatedPostsPicker.d.ts.map +1 -0
- package/dist/admin/components/RelatedPostsPicker.js +77 -0
- package/dist/admin/components/RelatedPostsPicker.js.map +1 -0
- package/dist/admin/components/RelatedProductsPicker.d.ts +9 -0
- package/dist/admin/components/RelatedProductsPicker.d.ts.map +1 -0
- package/dist/admin/components/RelatedProductsPicker.js +66 -0
- package/dist/admin/components/RelatedProductsPicker.js.map +1 -0
- package/dist/admin/components/TagPicker.d.ts +9 -0
- package/dist/admin/components/TagPicker.d.ts.map +1 -0
- package/dist/admin/components/TagPicker.js +66 -0
- package/dist/admin/components/TagPicker.js.map +1 -0
- package/dist/admin/index.d.ts +33 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +114 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/BlogCategoryEditor.d.ts +9 -0
- package/dist/admin/pages/BlogCategoryEditor.d.ts.map +1 -0
- package/dist/admin/pages/BlogCategoryEditor.js +196 -0
- package/dist/admin/pages/BlogCategoryEditor.js.map +1 -0
- package/dist/admin/pages/BlogCategoryTreePage.d.ts +9 -0
- package/dist/admin/pages/BlogCategoryTreePage.d.ts.map +1 -0
- package/dist/admin/pages/BlogCategoryTreePage.js +122 -0
- package/dist/admin/pages/BlogCategoryTreePage.js.map +1 -0
- package/dist/admin/pages/BlogPostEditor.d.ts +9 -0
- package/dist/admin/pages/BlogPostEditor.d.ts.map +1 -0
- package/dist/admin/pages/BlogPostEditor.js +314 -0
- package/dist/admin/pages/BlogPostEditor.js.map +1 -0
- package/dist/admin/pages/BlogPostListPage.d.ts +9 -0
- package/dist/admin/pages/BlogPostListPage.d.ts.map +1 -0
- package/dist/admin/pages/BlogPostListPage.js +75 -0
- package/dist/admin/pages/BlogPostListPage.js.map +1 -0
- package/dist/admin/pages/BlogTagListPage.d.ts +9 -0
- package/dist/admin/pages/BlogTagListPage.d.ts.map +1 -0
- package/dist/admin/pages/BlogTagListPage.js +145 -0
- package/dist/admin/pages/BlogTagListPage.js.map +1 -0
- package/dist/backend/entities/blog-category-language.entity.d.ts +11 -0
- package/dist/backend/entities/blog-category-language.entity.d.ts.map +1 -0
- package/dist/backend/entities/blog-category-language.entity.js +35 -0
- package/dist/backend/entities/blog-category-language.entity.js.map +1 -0
- package/dist/backend/entities/blog-category-sales-channel.entity.d.ts +17 -0
- package/dist/backend/entities/blog-category-sales-channel.entity.d.ts.map +1 -0
- package/dist/backend/entities/blog-category-sales-channel.entity.js +48 -0
- package/dist/backend/entities/blog-category-sales-channel.entity.js.map +1 -0
- package/dist/backend/entities/blog-category.entity.d.ts +32 -0
- package/dist/backend/entities/blog-category.entity.d.ts.map +1 -0
- package/dist/backend/entities/blog-category.entity.js +113 -0
- package/dist/backend/entities/blog-category.entity.js.map +1 -0
- package/dist/backend/entities/blog-post-category.entity.d.ts +10 -0
- package/dist/backend/entities/blog-post-category.entity.d.ts.map +1 -0
- package/dist/backend/entities/blog-post-category.entity.js +34 -0
- package/dist/backend/entities/blog-post-category.entity.js.map +1 -0
- package/dist/backend/entities/blog-post-language.entity.d.ts +9 -0
- package/dist/backend/entities/blog-post-language.entity.d.ts.map +1 -0
- package/dist/backend/entities/blog-post-language.entity.js +33 -0
- package/dist/backend/entities/blog-post-language.entity.js.map +1 -0
- package/dist/backend/entities/blog-post-related-post.entity.d.ts +15 -0
- package/dist/backend/entities/blog-post-related-post.entity.d.ts.map +1 -0
- package/dist/backend/entities/blog-post-related-post.entity.js +42 -0
- package/dist/backend/entities/blog-post-related-post.entity.js.map +1 -0
- package/dist/backend/entities/blog-post-related-product.entity.d.ts +14 -0
- package/dist/backend/entities/blog-post-related-product.entity.d.ts.map +1 -0
- package/dist/backend/entities/blog-post-related-product.entity.js +41 -0
- package/dist/backend/entities/blog-post-related-product.entity.js.map +1 -0
- package/dist/backend/entities/blog-post-sales-channel.entity.d.ts +16 -0
- package/dist/backend/entities/blog-post-sales-channel.entity.d.ts.map +1 -0
- package/dist/backend/entities/blog-post-sales-channel.entity.js +47 -0
- package/dist/backend/entities/blog-post-sales-channel.entity.js.map +1 -0
- package/dist/backend/entities/blog-post-tag.entity.d.ts +14 -0
- package/dist/backend/entities/blog-post-tag.entity.d.ts.map +1 -0
- package/dist/backend/entities/blog-post-tag.entity.js +41 -0
- package/dist/backend/entities/blog-post-tag.entity.js.map +1 -0
- package/dist/backend/entities/blog-post.entity.d.ts +31 -0
- package/dist/backend/entities/blog-post.entity.d.ts.map +1 -0
- package/dist/backend/entities/blog-post.entity.js +106 -0
- package/dist/backend/entities/blog-post.entity.js.map +1 -0
- package/dist/backend/entities/blog-tag.entity.d.ts +18 -0
- package/dist/backend/entities/blog-tag.entity.d.ts.map +1 -0
- package/dist/backend/entities/blog-tag.entity.js +66 -0
- package/dist/backend/entities/blog-tag.entity.js.map +1 -0
- package/dist/backend/index.d.ts +100 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +182 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/routes.admin.d.ts +12 -0
- package/dist/backend/routes.admin.d.ts.map +1 -0
- package/dist/backend/routes.admin.js +148 -0
- package/dist/backend/routes.admin.js.map +1 -0
- package/dist/backend/routes.storefront.d.ts +6 -0
- package/dist/backend/routes.storefront.d.ts.map +1 -0
- package/dist/backend/routes.storefront.js +72 -0
- package/dist/backend/routes.storefront.js.map +1 -0
- package/dist/backend/services/blog-asset-references.d.ts +4 -0
- package/dist/backend/services/blog-asset-references.d.ts.map +1 -0
- package/dist/backend/services/blog-asset-references.js +79 -0
- package/dist/backend/services/blog-asset-references.js.map +1 -0
- package/dist/backend/services/blog-cache.d.ts +55 -0
- package/dist/backend/services/blog-cache.d.ts.map +1 -0
- package/dist/backend/services/blog-cache.js +111 -0
- package/dist/backend/services/blog-cache.js.map +1 -0
- package/dist/backend/services/blog-category-service.d.ts +53 -0
- package/dist/backend/services/blog-category-service.d.ts.map +1 -0
- package/dist/backend/services/blog-category-service.js +446 -0
- package/dist/backend/services/blog-category-service.js.map +1 -0
- package/dist/backend/services/blog-language-reference.d.ts +18 -0
- package/dist/backend/services/blog-language-reference.d.ts.map +1 -0
- package/dist/backend/services/blog-language-reference.js +43 -0
- package/dist/backend/services/blog-language-reference.js.map +1 -0
- package/dist/backend/services/blog-post-service.d.ts +79 -0
- package/dist/backend/services/blog-post-service.d.ts.map +1 -0
- package/dist/backend/services/blog-post-service.js +539 -0
- package/dist/backend/services/blog-post-service.js.map +1 -0
- package/dist/backend/services/blog-settings-resolver.d.ts +36 -0
- package/dist/backend/services/blog-settings-resolver.d.ts.map +1 -0
- package/dist/backend/services/blog-settings-resolver.js +68 -0
- package/dist/backend/services/blog-settings-resolver.js.map +1 -0
- package/dist/backend/services/blog-slug-collision.d.ts +35 -0
- package/dist/backend/services/blog-slug-collision.d.ts.map +1 -0
- package/dist/backend/services/blog-slug-collision.js +84 -0
- package/dist/backend/services/blog-slug-collision.js.map +1 -0
- package/dist/backend/services/blog-storefront-resolver.d.ts +107 -0
- package/dist/backend/services/blog-storefront-resolver.d.ts.map +1 -0
- package/dist/backend/services/blog-storefront-resolver.js +492 -0
- package/dist/backend/services/blog-storefront-resolver.js.map +1 -0
- package/dist/backend/services/blog-tag-service.d.ts +45 -0
- package/dist/backend/services/blog-tag-service.d.ts.map +1 -0
- package/dist/backend/services/blog-tag-service.js +198 -0
- package/dist/backend/services/blog-tag-service.js.map +1 -0
- package/dist/backend/services/seed-default-category.d.ts +25 -0
- package/dist/backend/services/seed-default-category.d.ts.map +1 -0
- package/dist/backend/services/seed-default-category.js +48 -0
- package/dist/backend/services/seed-default-category.js.map +1 -0
- package/dist/backend/services/seed-roles.d.ts +41 -0
- package/dist/backend/services/seed-roles.d.ts.map +1 -0
- package/dist/backend/services/seed-roles.js +98 -0
- package/dist/backend/services/seed-roles.js.map +1 -0
- package/dist/manifest.d.ts +226 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +236 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260506T081055_blog_init.d.ts +33 -0
- package/dist/migrations/20260506T081055_blog_init.d.ts.map +1 -0
- package/dist/migrations/20260506T081055_blog_init.js +260 -0
- package/dist/migrations/20260506T081055_blog_init.js.map +1 -0
- package/dist/migrations/20260903T101744_blog_namespace_block_names.d.ts +6 -0
- package/dist/migrations/20260903T101744_blog_namespace_block_names.d.ts.map +1 -0
- package/dist/migrations/20260903T101744_blog_namespace_block_names.js +58 -0
- package/dist/migrations/20260903T101744_blog_namespace_block_names.js.map +1 -0
- package/dist/migrations/index.d.ts +34 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +37 -0
- package/dist/migrations/index.js.map +1 -0
- package/docs/blog/index.md +199 -0
- package/i18n/en.json +166 -0
- package/i18n/pl.json +166 -0
- package/package.json +108 -0
- package/tailwind.css +14 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ERROR_CODES, BLOG_RESERVED_URL_PREFIXES } from '@endora-commerce/contracts';
|
|
3
|
+
import { HttpError } from '@endora-commerce/platform/http';
|
|
4
|
+
import { BLOG_DEFAULT_ENABLED, BLOG_DEFAULT_LATEST_COUNT, BLOG_DEFAULT_POSTS_PER_PAGE, BLOG_DEFAULT_URL_PREFIX, BLOG_SETTING_CODES, } from '../../manifest.js';
|
|
5
|
+
/**
|
|
6
|
+
* BlogSettingsResolver — feature 016 / R7.
|
|
7
|
+
*
|
|
8
|
+
* Wraps `settings.service.getValue('blog.*', salesChannelId)` with:
|
|
9
|
+
* - default fallbacks (when no row exists yet);
|
|
10
|
+
* - type coercion (the Settings module's value-type registry stores
|
|
11
|
+
* primitives as `unknown`; the resolver narrows them);
|
|
12
|
+
* - range clamps for `latestCount` and `postsPerPage` (any stored value
|
|
13
|
+
* ≤ 0 falls back to the documented default with a logged warning —
|
|
14
|
+
* the caller is the Settings admin form, which has already validated
|
|
15
|
+
* the value, so this branch is a defence-in-depth safeguard);
|
|
16
|
+
* - a pure `assertValidPrefix(value)` validator used as the setter
|
|
17
|
+
* callback for `blog.url_prefix` (per the spec FR-024 + the
|
|
18
|
+
* reference-scan contract § R-5).
|
|
19
|
+
*/
|
|
20
|
+
const URL_PREFIX_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
21
|
+
export class BlogSettingsResolver {
|
|
22
|
+
settings;
|
|
23
|
+
constructor(settings) {
|
|
24
|
+
this.settings = settings;
|
|
25
|
+
}
|
|
26
|
+
async getResolved(salesChannelId) {
|
|
27
|
+
const enabled = await this.readWithFallback(BLOG_SETTING_CODES.ENABLED, salesChannelId, z.boolean(), BLOG_DEFAULT_ENABLED);
|
|
28
|
+
const urlPrefixRaw = await this.readWithFallback(BLOG_SETTING_CODES.URL_PREFIX, salesChannelId, z.string(), BLOG_DEFAULT_URL_PREFIX);
|
|
29
|
+
const latestCount = await this.readWithFallback(BLOG_SETTING_CODES.LATEST_COUNT, salesChannelId, z.number(), BLOG_DEFAULT_LATEST_COUNT);
|
|
30
|
+
const postsPerPage = await this.readWithFallback(BLOG_SETTING_CODES.POSTS_PER_PAGE, salesChannelId, z.number(), BLOG_DEFAULT_POSTS_PER_PAGE);
|
|
31
|
+
return {
|
|
32
|
+
enabled,
|
|
33
|
+
urlPrefix: URL_PREFIX_REGEX.test(urlPrefixRaw) ? urlPrefixRaw : BLOG_DEFAULT_URL_PREFIX,
|
|
34
|
+
latestCount: clampPositiveInt(latestCount, BLOG_DEFAULT_LATEST_COUNT),
|
|
35
|
+
postsPerPage: clampPositiveInt(postsPerPage, BLOG_DEFAULT_POSTS_PER_PAGE),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async readWithFallback(code, salesChannelId, schema, fallback) {
|
|
39
|
+
try {
|
|
40
|
+
return await this.settings.get(code, salesChannelId, schema);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return fallback;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Pure validator wired into the Settings module's setter callback for
|
|
48
|
+
* the `blog.url_prefix` key. Refuses an invalid shape, a reserved
|
|
49
|
+
* Next.js segment, or a value already owned by another storefront route
|
|
50
|
+
* (the cross-module owner check is delegated; v1 only enforces the
|
|
51
|
+
* shape + reserved-segments rule).
|
|
52
|
+
*/
|
|
53
|
+
static assertValidPrefix(value) {
|
|
54
|
+
if (typeof value !== 'string' || !URL_PREFIX_REGEX.test(value)) {
|
|
55
|
+
throw new HttpError(400, ERROR_CODES.BLOG_URL_PREFIX_INVALID, 'Blog URL prefix must match ^[a-z0-9-]+$ (1–64 characters).');
|
|
56
|
+
}
|
|
57
|
+
if (BLOG_RESERVED_URL_PREFIXES.has(value)) {
|
|
58
|
+
throw new HttpError(409, ERROR_CODES.BLOG_URL_PREFIX_RESERVED, `URL prefix "${value}" is reserved by the storefront and cannot be assigned to the blog.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function clampPositiveInt(raw, fallback) {
|
|
63
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw))
|
|
64
|
+
return fallback;
|
|
65
|
+
const n = Math.floor(raw);
|
|
66
|
+
return n >= 1 ? n : fallback;
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=blog-settings-resolver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blog-settings-resolver.js","sourceRoot":"","sources":["../../../src/backend/services/blog-settings-resolver.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-table slug-uniqueness probe — feature 016 / R3.
|
|
4
|
+
*
|
|
5
|
+
* Postgres cannot enforce UNIQUE across two tables; the per-table partial
|
|
6
|
+
* unique indexes are defensive. The cross-table check (Post slug colliding
|
|
7
|
+
* with a Category slug in the same Sales Channel — they share the
|
|
8
|
+
* `<prefix>/<slug>` URL space) lives here.
|
|
9
|
+
*
|
|
10
|
+
* Each probe runs inside the caller's transaction and acquires a Postgres
|
|
11
|
+
* advisory lock keyed on (channel, slug) so a concurrent save cannot
|
|
12
|
+
* sneak between probe and write. The lock is released automatically when
|
|
13
|
+
* the transaction commits or rolls back.
|
|
14
|
+
*
|
|
15
|
+
* Callers MUST be inside an `em.transactional(...)` block.
|
|
16
|
+
*/
|
|
17
|
+
export interface SlugCollisionCheckInput {
|
|
18
|
+
/** Slug being asserted. */
|
|
19
|
+
slug: string;
|
|
20
|
+
/** Sales-channel ids the entity is being attached to. */
|
|
21
|
+
salesChannelIds: string[];
|
|
22
|
+
/** Which side is calling — drives the row-id exclusion below. */
|
|
23
|
+
kind: 'post' | 'category';
|
|
24
|
+
/** When updating an existing row, exclude it from the probe. */
|
|
25
|
+
excludeId?: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Probe `blog_post_sales_channels` ∪ `blog_category_sales_channels` for
|
|
29
|
+
* any non-soft-deleted row in any of the supplied channels with the same
|
|
30
|
+
* slug. Throws BLOG_SLUG_TAKEN with `details.colliding[]` populated.
|
|
31
|
+
*
|
|
32
|
+
* Returns silently when no collision exists.
|
|
33
|
+
*/
|
|
34
|
+
export declare function assertSlugAvailable(em: EntityManager, input: SlugCollisionCheckInput): Promise<void>;
|
|
35
|
+
//# sourceMappingURL=blog-slug-collision.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blog-slug-collision.d.ts","sourceRoot":"","sources":["../../../src/backend/services/blog-slug-collision.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAK3D;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,uBAAuB;IACtC,2BAA2B;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,iEAAiE;IACjE,IAAI,EAAE,MAAM,GAAG,UAAU,CAAC;IAC1B,gEAAgE;IAChE,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAChC;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,aAAa,EACjB,KAAK,EAAE,uBAAuB,GAC7B,OAAO,CAAC,IAAI,CAAC,CAyEf"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ERROR_CODES } from '@endora-commerce/contracts';
|
|
2
|
+
import { HttpError } from '@endora-commerce/platform/http';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
/**
|
|
5
|
+
* Probe `blog_post_sales_channels` ∪ `blog_category_sales_channels` for
|
|
6
|
+
* any non-soft-deleted row in any of the supplied channels with the same
|
|
7
|
+
* slug. Throws BLOG_SLUG_TAKEN with `details.colliding[]` populated.
|
|
8
|
+
*
|
|
9
|
+
* Returns silently when no collision exists.
|
|
10
|
+
*/
|
|
11
|
+
export async function assertSlugAvailable(em, input) {
|
|
12
|
+
if (input.salesChannelIds.length === 0)
|
|
13
|
+
return;
|
|
14
|
+
// 1. Take per-(channel, slug) advisory locks so a concurrent transaction
|
|
15
|
+
// cannot squeeze a colliding write between this probe and the upcoming
|
|
16
|
+
// INSERT. The lock is released when the transaction completes.
|
|
17
|
+
for (const channelId of input.salesChannelIds) {
|
|
18
|
+
const lockKey = advisoryLockKeyFor(channelId, input.slug);
|
|
19
|
+
await em.execute('select pg_advisory_xact_lock(?)', [lockKey]);
|
|
20
|
+
}
|
|
21
|
+
const placeholders = input.salesChannelIds.map(() => '?').join(', ');
|
|
22
|
+
const params = [input.slug, ...input.salesChannelIds];
|
|
23
|
+
// 2. Check the post side. Exclude the calling row if updating.
|
|
24
|
+
let postSql = `select p.id::text as id, psc.sales_channel_id::text as channel_id
|
|
25
|
+
from blog_post_sales_channels psc
|
|
26
|
+
join blog_posts p on p.id = psc.blog_post_id
|
|
27
|
+
where psc.slug = ?
|
|
28
|
+
and psc.sales_channel_id in (${placeholders})
|
|
29
|
+
and psc.deleted_at is null`;
|
|
30
|
+
if (input.kind === 'post' && input.excludeId) {
|
|
31
|
+
postSql += ' and p.id <> ?';
|
|
32
|
+
params.push(input.excludeId);
|
|
33
|
+
}
|
|
34
|
+
postSql += ' limit 5';
|
|
35
|
+
const postRows = (await em.execute(postSql, params));
|
|
36
|
+
// 3. Check the category side.
|
|
37
|
+
const catParams = [input.slug, ...input.salesChannelIds];
|
|
38
|
+
let categorySql = `select c.id::text as id, csc.sales_channel_id::text as channel_id
|
|
39
|
+
from blog_category_sales_channels csc
|
|
40
|
+
join blog_categories c on c.id = csc.blog_category_id
|
|
41
|
+
where csc.slug = ?
|
|
42
|
+
and csc.sales_channel_id in (${placeholders})
|
|
43
|
+
and csc.deleted_at is null`;
|
|
44
|
+
if (input.kind === 'category' && input.excludeId) {
|
|
45
|
+
categorySql += ' and c.id <> ?';
|
|
46
|
+
catParams.push(input.excludeId);
|
|
47
|
+
}
|
|
48
|
+
categorySql += ' limit 5';
|
|
49
|
+
const categoryRows = (await em.execute(categorySql, catParams));
|
|
50
|
+
if (postRows.length === 0 && categoryRows.length === 0)
|
|
51
|
+
return;
|
|
52
|
+
// Surface each colliding owner as a separate detail entry so the admin
|
|
53
|
+
// UI can render a friendly list. `path: 'slug'` keeps the field
|
|
54
|
+
// attribution consistent with other slug errors; `issue` carries the
|
|
55
|
+
// colliding kind + id + channel.
|
|
56
|
+
const details = [
|
|
57
|
+
...postRows.map((r) => ({
|
|
58
|
+
path: 'slug',
|
|
59
|
+
issue: `taken by post id=${r.id} in sales-channel id=${r.channel_id}`,
|
|
60
|
+
})),
|
|
61
|
+
...categoryRows.map((r) => ({
|
|
62
|
+
path: 'slug',
|
|
63
|
+
issue: `taken by category id=${r.id} in sales-channel id=${r.channel_id}`,
|
|
64
|
+
})),
|
|
65
|
+
];
|
|
66
|
+
throw new HttpError(409, ERROR_CODES.BLOG_SLUG_TAKEN, `Slug "${input.slug}" is already taken in at least one of the assigned Sales Channels.`, details);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Hash `(channelId, slug)` into a `bigint` namespace acceptable to
|
|
70
|
+
* `pg_advisory_xact_lock(bigint)`. We use SHA-256 then take the first
|
|
71
|
+
* 8 bytes interpreted as a signed bigint (Postgres `bigint` range).
|
|
72
|
+
*/
|
|
73
|
+
function advisoryLockKeyFor(channelId, slug) {
|
|
74
|
+
const digest = createHash('sha256').update(`blog-slug:${channelId}:${slug}`).digest();
|
|
75
|
+
// Take the first 8 bytes as a 64-bit big-endian integer; force the
|
|
76
|
+
// sign bit so the value fits in Postgres's signed bigint range. The
|
|
77
|
+
// exact mapping doesn't matter — we just need a stable bigint.
|
|
78
|
+
let hi = digest.readUInt32BE(0);
|
|
79
|
+
// Clear the top bit so the resulting bigint is positive.
|
|
80
|
+
hi = hi & 0x7fffffff;
|
|
81
|
+
const lo = digest.readUInt32BE(4);
|
|
82
|
+
return ((BigInt(hi) << 32n) | BigInt(lo)).toString();
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=blog-slug-collision.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blog-slug-collision.js","sourceRoot":"","sources":["../../../src/backend/services/blog-slug-collision.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
2
|
+
import type { BlogBySlugResponse, BlogIndexResponse, BlogProductCard, BlogTagByCodeResponse } from '@endora-commerce/contracts';
|
|
3
|
+
import { BlogCacheService } from './blog-cache.js';
|
|
4
|
+
import type { BlogSettingsResolver } from './blog-settings-resolver.js';
|
|
5
|
+
/**
|
|
6
|
+
* Cross-module surfaces the storefront resolver delegates to. Each is a
|
|
7
|
+
* narrow port (Constitution I) — the resolver does not import the
|
|
8
|
+
* upstream module's internals.
|
|
9
|
+
*/
|
|
10
|
+
export interface BlogStorefrontDeps {
|
|
11
|
+
/**
|
|
12
|
+
* Resolves a Library Asset id to a CDN-ready URL. Returns null when
|
|
13
|
+
* the asset is missing, soft-deleted, or otherwise unavailable.
|
|
14
|
+
*/
|
|
15
|
+
resolveAssetUrl?: (assetId: string) => Promise<string | null>;
|
|
16
|
+
/**
|
|
17
|
+
* Resolves a Catalog Product id to a card payload (or null when the
|
|
18
|
+
* product is soft-deleted / out-of-channel — handled at the upstream
|
|
19
|
+
* port, not here). Optional: when omitted, related-products lists
|
|
20
|
+
* always render as empty (R6 storefront-filter contract).
|
|
21
|
+
*/
|
|
22
|
+
resolveProductCard?: (productId: string, salesChannelId: string, language: string) => Promise<BlogProductCard | null>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The already-resolved sales channel handed down from the route layer
|
|
26
|
+
* (feature 053). Mirrors the shape of the sales-channel resolver's
|
|
27
|
+
* `CachedChannel`; the resolver has already refused inactive/unknown
|
|
28
|
+
* channels upstream, so no re-query or active check is needed here.
|
|
29
|
+
*/
|
|
30
|
+
export type ResolvedChannel = {
|
|
31
|
+
id: string;
|
|
32
|
+
code: string;
|
|
33
|
+
defaultLanguage: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* BlogStorefrontResolver — feature 016 / T051.
|
|
37
|
+
*
|
|
38
|
+
* Three read endpoints behind `/api/v1/blog/**`:
|
|
39
|
+
*
|
|
40
|
+
* getIndex → BlogIndexResponse (latest N + first-level categories)
|
|
41
|
+
* getBySlug → BlogBySlugResponse discriminated by `kind`
|
|
42
|
+
* getTagByCode → BlogTagByCodeResponse paginated
|
|
43
|
+
*
|
|
44
|
+
* Every method:
|
|
45
|
+
* - reads the channel-resolved blog settings via BlogSettingsResolver;
|
|
46
|
+
* - returns null (the route maps to 404) when blog.enabled = false;
|
|
47
|
+
* - applies the platform-wide language-fallback rule (feature 005)
|
|
48
|
+
* via the `pickLanguage` helper;
|
|
49
|
+
* - wraps results in BlogCacheService when Redis is wired.
|
|
50
|
+
*/
|
|
51
|
+
export declare class BlogStorefrontResolver {
|
|
52
|
+
private readonly emFactory;
|
|
53
|
+
private readonly settingsResolver;
|
|
54
|
+
private readonly deps;
|
|
55
|
+
private readonly cache?;
|
|
56
|
+
constructor(emFactory: () => EntityManager, settingsResolver: BlogSettingsResolver, deps?: BlogStorefrontDeps, cache?: BlogCacheService | undefined);
|
|
57
|
+
getIndex(input: {
|
|
58
|
+
resolvedChannel: ResolvedChannel;
|
|
59
|
+
language?: string | undefined;
|
|
60
|
+
}): Promise<BlogIndexResponse | null>;
|
|
61
|
+
getBySlug(slug: string, input: {
|
|
62
|
+
resolvedChannel: ResolvedChannel;
|
|
63
|
+
language?: string | undefined;
|
|
64
|
+
page?: number;
|
|
65
|
+
}): Promise<BlogBySlugResponse | null>;
|
|
66
|
+
getTagByCode(code: string, input: {
|
|
67
|
+
resolvedChannel: ResolvedChannel;
|
|
68
|
+
language?: string | undefined;
|
|
69
|
+
page?: number;
|
|
70
|
+
}): Promise<BlogTagByCodeResponse | null>;
|
|
71
|
+
/**
|
|
72
|
+
* Language-fallback rule (FR-019 / R12 / per feature 005): pick the
|
|
73
|
+
* requested language; fall back to the channel's default language; if
|
|
74
|
+
* neither exists, return null. The caller decides whether `null`
|
|
75
|
+
* means "skip this field" or "404 the page" depending on context.
|
|
76
|
+
*/
|
|
77
|
+
private pickLanguage;
|
|
78
|
+
/**
|
|
79
|
+
* Resolve a human-readable name for a published entity. Applies the
|
|
80
|
+
* standard language fallback (requested → channel default) and, when that
|
|
81
|
+
* is exhausted, falls back to the first language actually authored on the
|
|
82
|
+
* entity rather than giving up.
|
|
83
|
+
*
|
|
84
|
+
* This keeps a *published* post/category viewable even when its content
|
|
85
|
+
* was authored only in a language other than the channel's default — the
|
|
86
|
+
* alternative (returning null) made the post-detail resolver 404 while the
|
|
87
|
+
* index/category resolvers still listed the post (via their `?? slug`
|
|
88
|
+
* fallback), an inconsistency that surfaced as "the post is in the list but
|
|
89
|
+
* its page 404s". A published entity is always reachable; only its display
|
|
90
|
+
* language degrades.
|
|
91
|
+
*/
|
|
92
|
+
private pickName;
|
|
93
|
+
private toPostCard;
|
|
94
|
+
private toCategoryTile;
|
|
95
|
+
private toResolvedPost;
|
|
96
|
+
private maybeAssetUrl;
|
|
97
|
+
private normaliseDescription;
|
|
98
|
+
private normaliseContent;
|
|
99
|
+
/**
|
|
100
|
+
* Best-effort excerpt extraction — picks the first text-bearing node
|
|
101
|
+
* in the resolved language's tree. Falls back to the channel default
|
|
102
|
+
* language. Returns null when nothing usable is found.
|
|
103
|
+
*/
|
|
104
|
+
private firstParagraph;
|
|
105
|
+
private toIso;
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=blog-storefront-resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blog-storefront-resolver.d.ts","sourceRoot":"","sources":["../../../src/backend/services/blog-storefront-resolver.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EACV,kBAAkB,EAElB,iBAAiB,EAGjB,eAAe,EAIf,qBAAqB,EACtB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AAExE;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC9D;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,CACnB,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,EACtB,QAAQ,EAAE,MAAM,KACb,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAAC;CACtC;AAQD;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,MAAM,CAAA;CAAE,CAAC;AAoCpF;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,sBAAsB;IAE/B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC;gBAHN,SAAS,EAAE,MAAM,aAAa,EAC9B,gBAAgB,EAAE,oBAAoB,EACtC,IAAI,GAAE,kBAAuB,EAC7B,KAAK,CAAC,EAAE,gBAAgB,YAAA;IAGrC,QAAQ,CAAC,KAAK,EAAE;QACpB,eAAe,EAAE,eAAe,CAAC;QACjC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;KAC/B,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;IAyD/B,SAAS,CACb,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE;QAAE,eAAe,EAAE,eAAe,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GACxF,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IAuJ/B,YAAY,CAChB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE;QAAE,eAAe,EAAE,eAAe,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GACxF,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC;IAiGxC;;;;;OAKG;IACH,OAAO,CAAC,YAAY;IAWpB;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,QAAQ;YAcF,UAAU;YAmCV,cAAc;YAad,cAAc;YA2Gd,aAAa;IAM3B,OAAO,CAAC,oBAAoB;IAU5B,OAAO,CAAC,gBAAgB;IAUxB;;;;OAIG;IACH,OAAO,CAAC,cAAc;IAWtB,OAAO,CAAC,KAAK;CAId"}
|