@porulle/core 0.54.0 → 0.55.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/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/modules/catalog/brand-service.d.ts +2 -2
- package/dist/modules/catalog/brand-service.d.ts.map +1 -1
- package/dist/modules/catalog/brand-service.js +3 -3
- package/dist/modules/catalog/category-service.d.ts +2 -2
- package/dist/modules/catalog/category-service.d.ts.map +1 -1
- package/dist/modules/catalog/category-service.js +3 -3
- package/dist/modules/catalog/entity-links.d.ts +63 -0
- package/dist/modules/catalog/entity-links.d.ts.map +1 -0
- package/dist/modules/catalog/entity-links.js +69 -0
- package/dist/modules/catalog/import-service.d.ts.map +1 -1
- package/dist/modules/catalog/import-service.js +13 -17
- package/dist/modules/catalog/repository/index.d.ts +4 -2
- package/dist/modules/catalog/repository/index.d.ts.map +1 -1
- package/dist/modules/catalog/repository/index.js +10 -4
- package/dist/modules/catalog/service.d.ts +6 -0
- package/dist/modules/catalog/service.d.ts.map +1 -1
- package/dist/modules/catalog/service.js +35 -12
- package/dist/modules/media/service.d.ts.map +1 -1
- package/dist/modules/media/service.js +3 -0
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/modules/catalog/brand-service.ts +5 -5
- package/src/modules/catalog/category-service.ts +5 -5
- package/src/modules/catalog/entity-links.ts +114 -0
- package/src/modules/catalog/import-service.ts +12 -17
- package/src/modules/catalog/repository/index.ts +12 -6
- package/src/modules/catalog/service.ts +43 -8
- package/src/modules/media/service.ts +4 -0
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -204,6 +204,9 @@ export class CatalogServiceImpl {
|
|
|
204
204
|
requestId: ctx.requestId,
|
|
205
205
|
}, ctx);
|
|
206
206
|
}
|
|
207
|
+
notifyEntityChanged(entityId, changedFieldPaths, actor, ctx) {
|
|
208
|
+
return this.entities.notifyEntityUpdated(entityId, changedFieldPaths, actor, ctx);
|
|
209
|
+
}
|
|
207
210
|
async recordEntityRevision(entityId, actor, reason = "update", ctx) {
|
|
208
211
|
try {
|
|
209
212
|
return await this.withMutation(actor, ctx, async (txCtx) => {
|
|
@@ -567,17 +570,27 @@ export class CatalogServiceImpl {
|
|
|
567
570
|
addToCategory(entityId, categoryId, actor, ctx) {
|
|
568
571
|
return this.withMutationResult(actor, ctx, async (txCtx) => {
|
|
569
572
|
const result = await this.categories.addToCategory(entityId, categoryId, actor, txCtx);
|
|
570
|
-
if (result.ok)
|
|
571
|
-
|
|
572
|
-
|
|
573
|
+
if (!result.ok)
|
|
574
|
+
return result;
|
|
575
|
+
// A link that actually changed versions the entity (an indexed facet moved); re-linking what
|
|
576
|
+
// is already linked moves nothing.
|
|
577
|
+
if (result.value)
|
|
578
|
+
await this.entities.notifyEntityUpdated(entityId, ["categories"], actor, txCtx);
|
|
579
|
+
await this.captureRevision(entityId, actor, "update", txCtx);
|
|
580
|
+
return Ok(undefined);
|
|
573
581
|
});
|
|
574
582
|
}
|
|
575
583
|
removeFromCategory(entityId, categoryId, actor, ctx) {
|
|
576
584
|
return this.withMutationResult(actor, ctx, async (txCtx) => {
|
|
577
585
|
const result = await this.categories.removeFromCategory(entityId, categoryId, actor, txCtx);
|
|
578
|
-
if (result.ok)
|
|
579
|
-
|
|
580
|
-
|
|
586
|
+
if (!result.ok)
|
|
587
|
+
return result;
|
|
588
|
+
// A link that actually changed versions the entity (an indexed facet moved); re-linking what
|
|
589
|
+
// is already linked moves nothing.
|
|
590
|
+
if (result.value)
|
|
591
|
+
await this.entities.notifyEntityUpdated(entityId, ["categories"], actor, txCtx);
|
|
592
|
+
await this.captureRevision(entityId, actor, "update", txCtx);
|
|
593
|
+
return Ok(undefined);
|
|
581
594
|
});
|
|
582
595
|
}
|
|
583
596
|
listBrands(ctx) {
|
|
@@ -595,17 +608,27 @@ export class CatalogServiceImpl {
|
|
|
595
608
|
addToBrand(entityId, brandId, actor, ctx) {
|
|
596
609
|
return this.withMutationResult(actor, ctx, async (txCtx) => {
|
|
597
610
|
const result = await this.brands.addToBrand(entityId, brandId, actor, txCtx);
|
|
598
|
-
if (result.ok)
|
|
599
|
-
|
|
600
|
-
|
|
611
|
+
if (!result.ok)
|
|
612
|
+
return result;
|
|
613
|
+
// A link that actually changed versions the entity (an indexed facet moved); re-linking what
|
|
614
|
+
// is already linked moves nothing.
|
|
615
|
+
if (result.value)
|
|
616
|
+
await this.entities.notifyEntityUpdated(entityId, ["brand"], actor, txCtx);
|
|
617
|
+
await this.captureRevision(entityId, actor, "update", txCtx);
|
|
618
|
+
return Ok(undefined);
|
|
601
619
|
});
|
|
602
620
|
}
|
|
603
621
|
removeFromBrand(entityId, brandId, actor, ctx) {
|
|
604
622
|
return this.withMutationResult(actor, ctx, async (txCtx) => {
|
|
605
623
|
const result = await this.brands.removeFromBrand(entityId, brandId, actor, txCtx);
|
|
606
|
-
if (result.ok)
|
|
607
|
-
|
|
608
|
-
|
|
624
|
+
if (!result.ok)
|
|
625
|
+
return result;
|
|
626
|
+
// A link that actually changed versions the entity (an indexed facet moved); re-linking what
|
|
627
|
+
// is already linked moves nothing.
|
|
628
|
+
if (result.value)
|
|
629
|
+
await this.entities.notifyEntityUpdated(entityId, ["brand"], actor, txCtx);
|
|
630
|
+
await this.captureRevision(entityId, actor, "update", txCtx);
|
|
631
|
+
return Ok(undefined);
|
|
609
632
|
});
|
|
610
633
|
}
|
|
611
634
|
createOptionType(input, actor, ctx) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../../src/modules/media/service.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../../src/modules/media/service.ts"],"names":[],"mappings":"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"}
|
|
@@ -181,6 +181,9 @@ export class MediaService {
|
|
|
181
181
|
throw new Error("Catalog service is required for media revision capture.");
|
|
182
182
|
}
|
|
183
183
|
const catalogService = catalog;
|
|
184
|
+
// A new attachment versions the entity: the hero drives the image embedding, so a consumer
|
|
185
|
+
// that versions on `updated_at` must see it.
|
|
186
|
+
await catalogService.notifyEntityChanged(input.entityId, [`media.${input.role}`], actor ?? txCtx.actor, txCtx);
|
|
184
187
|
const revision = await catalogService.recordEntityRevision(input.entityId, actor ?? txCtx.actor, "update", txCtx);
|
|
185
188
|
if (!revision.ok)
|
|
186
189
|
throw revision.error;
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -227,6 +227,8 @@ export type {
|
|
|
227
227
|
ImportProductsReport,
|
|
228
228
|
ImportRowFailureCode,
|
|
229
229
|
} from "./modules/catalog/import-service.js";
|
|
230
|
+
export { writeEntityLinks, linkFieldPaths } from "./modules/catalog/entity-links.js";
|
|
231
|
+
export type { EntityLinkRows, EntityMediaRole, WrittenEntityLinks } from "./modules/catalog/entity-links.js";
|
|
230
232
|
export type {
|
|
231
233
|
TxContext,
|
|
232
234
|
WithTransactionOptions,
|
|
@@ -70,7 +70,7 @@ export class BrandService {
|
|
|
70
70
|
return Ok(undefined);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
async addToBrand(entityId: string, brandId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<
|
|
73
|
+
async addToBrand(entityId: string, brandId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<boolean>> {
|
|
74
74
|
try { assertPermission(actor, "catalog:update"); } catch (error) { return Err(toCommerceError(error)); }
|
|
75
75
|
const entity = await this.deps.repository.findEntityById(entityId, ctx);
|
|
76
76
|
if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
|
|
@@ -82,11 +82,11 @@ export class BrandService {
|
|
|
82
82
|
if (!brand) {
|
|
83
83
|
brand = await this.repo.createBrand({ organizationId: addBrandOrgId, slug: brandId, displayName: brandId, metadata: {} }, ctx);
|
|
84
84
|
}
|
|
85
|
-
await this.repo.addEntityToBrand(entityId, brand.id, 0, ctx);
|
|
86
|
-
return Ok(
|
|
85
|
+
const added = await this.repo.addEntityToBrand(entityId, brand.id, 0, ctx);
|
|
86
|
+
return Ok(added);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
async removeFromBrand(entityId: string, brandId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<
|
|
89
|
+
async removeFromBrand(entityId: string, brandId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<boolean>> {
|
|
90
90
|
try { assertPermission(actor, "catalog:update"); } catch (error) { return Err(toCommerceError(error)); }
|
|
91
91
|
const entity = await this.deps.repository.findEntityById(entityId, ctx);
|
|
92
92
|
if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
|
|
@@ -96,6 +96,6 @@ export class BrandService {
|
|
|
96
96
|
if (!brand) brand = await this.repo.findBrandBySlug(resolveOrgIdForCommerce(actor ?? ctx?.actor ?? null, this.deps.config), brandId, ctx);
|
|
97
97
|
const removed = await this.repo.removeEntityFromBrand(entityId, brand?.id ?? brandId, ctx);
|
|
98
98
|
if (!removed) return Err(new CommerceNotFoundError("Brand assignment not found."));
|
|
99
|
-
return Ok(
|
|
99
|
+
return Ok(true);
|
|
100
100
|
}
|
|
101
101
|
}
|
|
@@ -93,7 +93,7 @@ export class CategoryService {
|
|
|
93
93
|
return Ok(undefined);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
async addToCategory(entityId: string, categoryId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<
|
|
96
|
+
async addToCategory(entityId: string, categoryId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<boolean>> {
|
|
97
97
|
try { assertPermission(actor, "catalog:update"); } catch (error) { return Err(toCommerceError(error)); }
|
|
98
98
|
const entity = await this.deps.repository.findEntityById(entityId, ctx);
|
|
99
99
|
if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
|
|
@@ -105,11 +105,11 @@ export class CategoryService {
|
|
|
105
105
|
if (!category) {
|
|
106
106
|
category = await this.repo.createCategory({ organizationId: addCatOrgId, slug: categoryId, sortOrder: 0, metadata: {} }, ctx);
|
|
107
107
|
}
|
|
108
|
-
await this.repo.addEntityToCategory(entityId, category.id, 0, ctx);
|
|
109
|
-
return Ok(
|
|
108
|
+
const added = await this.repo.addEntityToCategory(entityId, category.id, 0, ctx);
|
|
109
|
+
return Ok(added);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
async removeFromCategory(entityId: string, categoryId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<
|
|
112
|
+
async removeFromCategory(entityId: string, categoryId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<boolean>> {
|
|
113
113
|
try { assertPermission(actor, "catalog:update"); } catch (error) { return Err(toCommerceError(error)); }
|
|
114
114
|
const entity = await this.deps.repository.findEntityById(entityId, ctx);
|
|
115
115
|
if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
|
|
@@ -119,6 +119,6 @@ export class CategoryService {
|
|
|
119
119
|
if (!category) category = await this.repo.findCategoryBySlug(resolveOrgIdForCommerce(actor ?? ctx?.actor ?? null, this.deps.config), categoryId, ctx);
|
|
120
120
|
const removed = await this.repo.removeEntityFromCategory(entityId, category?.id ?? categoryId, ctx);
|
|
121
121
|
if (!removed) return Err(new CommerceNotFoundError("Category assignment not found."));
|
|
122
|
-
return Ok(
|
|
122
|
+
return Ok(true);
|
|
123
123
|
}
|
|
124
124
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { and, eq, isNull, ne, or, sql, type SQL } from "drizzle-orm";
|
|
2
|
+
import type { PluginDb } from "../../kernel/database/plugin-types.js";
|
|
3
|
+
import { mediaAssets, entityMedia } from "../media/schema.js";
|
|
4
|
+
import { brands, categories, entityBrands, entityCategories, entityTags, sellableEntities, tags } from "./schema.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE writer of an entity's link rows — categories, brand, tags, media — for the bulk paths: the
|
|
8
|
+
* page import (`importProducts`) and the channel converge, both of which write links directly
|
|
9
|
+
* rather than one service call per link.
|
|
10
|
+
*
|
|
11
|
+
* - One statement per link class, however many entities the rows span.
|
|
12
|
+
* - Org scope is enforced IN the statement: a row whose entity, or whose category / brand / tag /
|
|
13
|
+
* media asset, belongs to another organization matches nothing and is not written. Callers
|
|
14
|
+
* resolve ids within their org already; this makes a slip a no-write instead of a cross-org link.
|
|
15
|
+
* - Idempotent: a link that already exists is not rewritten and is not returned. What comes back
|
|
16
|
+
* is exactly what changed, which is what a caller versions the entity from (`linkFieldPaths`).
|
|
17
|
+
*/
|
|
18
|
+
export type EntityMediaRole = (typeof entityMedia.$inferInsert)["role"];
|
|
19
|
+
|
|
20
|
+
export interface EntityLinkRows {
|
|
21
|
+
categories?: ReadonlyArray<{ entityId: string; categoryId: string; sortOrder: number }>;
|
|
22
|
+
brands?: ReadonlyArray<{ entityId: string; brandId: string; sortOrder: number }>;
|
|
23
|
+
tags?: ReadonlyArray<{ entityId: string; tagId: string }>;
|
|
24
|
+
/** New media links. */
|
|
25
|
+
media?: ReadonlyArray<{ entityId: string; variantId: string | null; mediaAssetId: string; role: EntityMediaRole; sortOrder: number }>;
|
|
26
|
+
/** Existing media links whose role or sort order should become these; unchanged ones write nothing. */
|
|
27
|
+
mediaPlacements?: ReadonlyArray<{ entityId: string; variantId: string | null; mediaAssetId: string; role: EntityMediaRole; sortOrder: number }>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface WrittenEntityLinks {
|
|
31
|
+
categories: Array<typeof entityCategories.$inferSelect>;
|
|
32
|
+
brands: Array<typeof entityBrands.$inferSelect>;
|
|
33
|
+
tags: Array<typeof entityTags.$inferSelect>;
|
|
34
|
+
media: Array<typeof entityMedia.$inferSelect>;
|
|
35
|
+
placed: Array<typeof entityMedia.$inferSelect>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const valuesOf = (rows: ReadonlyArray<readonly SQL[]>): SQL =>
|
|
39
|
+
sql.join(rows.map((row) => sql`(${sql.join([...row], sql`, `)})`), sql`, `);
|
|
40
|
+
|
|
41
|
+
const entityInOrg = (orgId: string): SQL =>
|
|
42
|
+
sql`join ${sellableEntities} on ${sellableEntities.id} = v.entity_id and ${sellableEntities.organizationId} = ${orgId}`;
|
|
43
|
+
|
|
44
|
+
export async function writeEntityLinks(
|
|
45
|
+
db: Pick<PluginDb, "insert" | "update">,
|
|
46
|
+
orgId: string,
|
|
47
|
+
rows: EntityLinkRows,
|
|
48
|
+
): Promise<WrittenEntityLinks> {
|
|
49
|
+
const written: WrittenEntityLinks = { categories: [], brands: [], tags: [], media: [], placed: [] };
|
|
50
|
+
if (rows.categories?.length) {
|
|
51
|
+
written.categories = await db.insert(entityCategories).select(sql`
|
|
52
|
+
select v.entity_id, v.category_id, v.sort_order
|
|
53
|
+
from (values ${valuesOf(rows.categories.map((row) => [sql`${row.entityId}::uuid`, sql`${row.categoryId}::uuid`, sql`${row.sortOrder}::integer`]))})
|
|
54
|
+
as v(entity_id, category_id, sort_order)
|
|
55
|
+
join ${categories} on ${categories.id} = v.category_id and ${categories.organizationId} = ${orgId}
|
|
56
|
+
${entityInOrg(orgId)}`).onConflictDoNothing().returning();
|
|
57
|
+
}
|
|
58
|
+
if (rows.brands?.length) {
|
|
59
|
+
written.brands = await db.insert(entityBrands).select(sql`
|
|
60
|
+
select v.entity_id, v.brand_id, v.sort_order
|
|
61
|
+
from (values ${valuesOf(rows.brands.map((row) => [sql`${row.entityId}::uuid`, sql`${row.brandId}::uuid`, sql`${row.sortOrder}::integer`]))})
|
|
62
|
+
as v(entity_id, brand_id, sort_order)
|
|
63
|
+
join ${brands} on ${brands.id} = v.brand_id and ${brands.organizationId} = ${orgId}
|
|
64
|
+
${entityInOrg(orgId)}`).onConflictDoNothing().returning();
|
|
65
|
+
}
|
|
66
|
+
if (rows.tags?.length) {
|
|
67
|
+
written.tags = await db.insert(entityTags).select(sql`
|
|
68
|
+
select v.entity_id, v.tag_id
|
|
69
|
+
from (values ${valuesOf(rows.tags.map((row) => [sql`${row.entityId}::uuid`, sql`${row.tagId}::uuid`]))})
|
|
70
|
+
as v(entity_id, tag_id)
|
|
71
|
+
join ${tags} on ${tags.id} = v.tag_id and ${tags.organizationId} = ${orgId}
|
|
72
|
+
${entityInOrg(orgId)}`).onConflictDoNothing().returning();
|
|
73
|
+
}
|
|
74
|
+
if (rows.media?.length) {
|
|
75
|
+
written.media = await db.insert(entityMedia).select(sql`
|
|
76
|
+
select v.entity_id, v.variant_id, v.media_asset_id, v.role, v.sort_order, now()
|
|
77
|
+
from (values ${valuesOf(rows.media.map((row) => [
|
|
78
|
+
sql`${row.entityId}::uuid`, sql`${row.variantId}::uuid`, sql`${row.mediaAssetId}::uuid`, sql`${row.role}::text`, sql`${row.sortOrder}::integer`,
|
|
79
|
+
]))}) as v(entity_id, variant_id, media_asset_id, role, sort_order)
|
|
80
|
+
join ${mediaAssets} on ${mediaAssets.id} = v.media_asset_id and ${mediaAssets.organizationId} = ${orgId}
|
|
81
|
+
${entityInOrg(orgId)}`).onConflictDoNothing().returning();
|
|
82
|
+
}
|
|
83
|
+
// ponytail: one UPDATE per re-placed link; a role/sort change upstream is rare, batch it if a
|
|
84
|
+
// store ever reshuffles whole galleries on every sync.
|
|
85
|
+
for (const row of rows.mediaPlacements ?? []) {
|
|
86
|
+
written.placed.push(...await db.update(entityMedia).set({ role: row.role, sortOrder: row.sortOrder }).where(and(
|
|
87
|
+
eq(entityMedia.entityId, row.entityId),
|
|
88
|
+
eq(entityMedia.mediaAssetId, row.mediaAssetId),
|
|
89
|
+
row.variantId === null ? isNull(entityMedia.variantId) : eq(entityMedia.variantId, row.variantId),
|
|
90
|
+
or(ne(entityMedia.role, row.role), ne(entityMedia.sortOrder, row.sortOrder)),
|
|
91
|
+
sql`exists (select 1 from ${sellableEntities} where ${sellableEntities.id} = ${entityMedia.entityId} and ${sellableEntities.organizationId} = ${orgId})`,
|
|
92
|
+
)).returning());
|
|
93
|
+
}
|
|
94
|
+
return written;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The field paths each entity's written links changed, sorted — the names the catalog services
|
|
99
|
+
* version links under (`categories`, `brand`, `tags`, `media.<role>`), so a consumer sees the same
|
|
100
|
+
* paths whichever path wrote the link.
|
|
101
|
+
*/
|
|
102
|
+
export function linkFieldPaths(written: WrittenEntityLinks): Map<string, string[]> {
|
|
103
|
+
const byEntity = new Map<string, Set<string>>();
|
|
104
|
+
const add = (entityId: string, path: string) => {
|
|
105
|
+
const paths = byEntity.get(entityId) ?? new Set<string>();
|
|
106
|
+
paths.add(path);
|
|
107
|
+
byEntity.set(entityId, paths);
|
|
108
|
+
};
|
|
109
|
+
for (const row of written.categories) add(row.entityId, "categories");
|
|
110
|
+
for (const row of written.brands) add(row.entityId, "brand");
|
|
111
|
+
for (const row of written.tags) add(row.entityId, "tags");
|
|
112
|
+
for (const row of [...written.media, ...written.placed]) add(row.entityId, `media.${row.role}`);
|
|
113
|
+
return new Map([...byEntity].map(([entityId, paths]) => [entityId, [...paths].sort()]));
|
|
114
|
+
}
|
|
@@ -9,13 +9,11 @@ import { createTxContext, isWriteContextTransactional, resolveWriteContextHookCo
|
|
|
9
9
|
import { runAfterHooks } from "../../kernel/hooks/executor.js";
|
|
10
10
|
import type { AfterHook } from "../../kernel/hooks/types.js";
|
|
11
11
|
import { isValidFieldPath, type FieldPath } from "./ownership.js";
|
|
12
|
+
import { writeEntityLinks } from "./entity-links.js";
|
|
12
13
|
import {
|
|
13
14
|
brands,
|
|
14
15
|
categories,
|
|
15
16
|
catalogFieldOwnership,
|
|
16
|
-
entityBrands,
|
|
17
|
-
entityCategories,
|
|
18
|
-
entityTags,
|
|
19
17
|
optionTypes,
|
|
20
18
|
optionValues,
|
|
21
19
|
sellableAttributes,
|
|
@@ -137,7 +135,7 @@ export interface ImportProductsReport extends Record<string, unknown> {
|
|
|
137
135
|
rows: ImportProductRowResult[];
|
|
138
136
|
}
|
|
139
137
|
|
|
140
|
-
type Writer = Pick<PluginDb, "insert" | "select">;
|
|
138
|
+
type Writer = Pick<PluginDb, "insert" | "select" | "update">;
|
|
141
139
|
|
|
142
140
|
/** The drizzle handle a transaction context carries (typed `unknown` on the context). */
|
|
143
141
|
const writerOf = (txCtx: TxContext): PluginDb => txCtx.tx as PluginDb;
|
|
@@ -391,7 +389,6 @@ async function writeItem(
|
|
|
391
389
|
if (priceRows.length > 0) await sp.insert(prices).values(priceRows);
|
|
392
390
|
}
|
|
393
391
|
|
|
394
|
-
const categoryLinks: Row[] = [];
|
|
395
392
|
const categoryInputs = [...new Set(item.categories ?? [])].flatMap((slug, index) => {
|
|
396
393
|
const category = taxonomy.categories.get(slug);
|
|
397
394
|
if (!category) throw new Error(`Category "${slug}" was not resolved for the page.`);
|
|
@@ -401,20 +398,18 @@ async function writeItem(
|
|
|
401
398
|
}
|
|
402
399
|
return [{ entityId, categoryId: category.id, sortOrder: index }];
|
|
403
400
|
});
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
if (item.brand) {
|
|
407
|
-
const brandId = taxonomy.brands.get(item.brand);
|
|
408
|
-
if (brandId === undefined) throw new Error(`Brand "${item.brand}" was not resolved for the page.`);
|
|
409
|
-
brandLinks.push(...asRows(await sp.insert(entityBrands).values([{ entityId, brandId, sortOrder: 0 }]).returning()));
|
|
410
|
-
}
|
|
411
|
-
const tagLinks: Row[] = [];
|
|
401
|
+
const brandId = item.brand ? taxonomy.brands.get(item.brand) : undefined;
|
|
402
|
+
if (item.brand && brandId === undefined) throw new Error(`Brand "${item.brand}" was not resolved for the page.`);
|
|
412
403
|
const tagInputs = [...new Set(item.tags ?? [])].map((slug) => {
|
|
413
404
|
const tagId = taxonomy.tags.get(slug);
|
|
414
405
|
if (tagId === undefined) throw new Error(`Tag "${slug}" was not resolved for the page.`);
|
|
415
406
|
return { entityId, tagId };
|
|
416
407
|
});
|
|
417
|
-
|
|
408
|
+
const links = await writeEntityLinks(sp, orgId, {
|
|
409
|
+
categories: categoryInputs,
|
|
410
|
+
brands: brandId === undefined ? [] : [{ entityId, brandId, sortOrder: 0 }],
|
|
411
|
+
tags: tagInputs,
|
|
412
|
+
});
|
|
418
413
|
|
|
419
414
|
const ownedPaths = [...new Set(item.ownedFieldPaths ?? [])] as FieldPath[];
|
|
420
415
|
if (ownedPaths.length > 0) {
|
|
@@ -434,9 +429,9 @@ async function writeItem(
|
|
|
434
429
|
attributes: asRows(attributeRows).sort((a, b) => String(a.locale).localeCompare(String(b.locale)) || String(a.id).localeCompare(String(b.id))),
|
|
435
430
|
customFields: [],
|
|
436
431
|
media: [],
|
|
437
|
-
categories:
|
|
438
|
-
brands:
|
|
439
|
-
tags:
|
|
432
|
+
categories: asRows(links.categories).sort(byKey("categoryId")),
|
|
433
|
+
brands: asRows(links.brands).sort(byKey("brandId")),
|
|
434
|
+
tags: asRows(links.tags).sort(byKey("tagId")),
|
|
440
435
|
};
|
|
441
436
|
return { entityId, variantIds, warnings, snapshot };
|
|
442
437
|
}
|
|
@@ -923,17 +923,20 @@ export class CatalogRepository {
|
|
|
923
923
|
return rows.map((r) => r.entityId);
|
|
924
924
|
}
|
|
925
925
|
|
|
926
|
+
/** True when the link was inserted; false when it already existed. */
|
|
926
927
|
async addEntityToCategory(
|
|
927
928
|
entityId: string,
|
|
928
929
|
categoryId: string,
|
|
929
930
|
sortOrder = 0,
|
|
930
931
|
ctx?: TxContext,
|
|
931
|
-
): Promise<
|
|
932
|
+
): Promise<boolean> {
|
|
932
933
|
const db = this.getDb(ctx);
|
|
933
|
-
await db
|
|
934
|
+
const inserted = await db
|
|
934
935
|
.insert(entityCategories)
|
|
935
936
|
.values({ entityId, categoryId, sortOrder })
|
|
936
|
-
.onConflictDoNothing()
|
|
937
|
+
.onConflictDoNothing()
|
|
938
|
+
.returning({ entityId: entityCategories.entityId });
|
|
939
|
+
return inserted.length > 0;
|
|
937
940
|
}
|
|
938
941
|
|
|
939
942
|
async removeEntityFromCategory(
|
|
@@ -1079,17 +1082,20 @@ export class CatalogRepository {
|
|
|
1079
1082
|
await db.delete(entityTags).where(eq(entityTags.entityId, entityId));
|
|
1080
1083
|
}
|
|
1081
1084
|
|
|
1085
|
+
/** True when the link was inserted; false when it already existed. */
|
|
1082
1086
|
async addEntityToBrand(
|
|
1083
1087
|
entityId: string,
|
|
1084
1088
|
brandId: string,
|
|
1085
1089
|
sortOrder = 0,
|
|
1086
1090
|
ctx?: TxContext,
|
|
1087
|
-
): Promise<
|
|
1091
|
+
): Promise<boolean> {
|
|
1088
1092
|
const db = this.getDb(ctx);
|
|
1089
|
-
await db
|
|
1093
|
+
const inserted = await db
|
|
1090
1094
|
.insert(entityBrands)
|
|
1091
1095
|
.values({ entityId, brandId, sortOrder })
|
|
1092
|
-
.onConflictDoNothing()
|
|
1096
|
+
.onConflictDoNothing()
|
|
1097
|
+
.returning({ entityId: entityBrands.entityId });
|
|
1098
|
+
return inserted.length > 0;
|
|
1093
1099
|
}
|
|
1094
1100
|
|
|
1095
1101
|
async removeEntityFromBrand(
|
|
@@ -379,6 +379,16 @@ export interface CatalogService {
|
|
|
379
379
|
actor: Actor | null,
|
|
380
380
|
ctx?: TxContext,
|
|
381
381
|
): Promise<Result<{ created: Variant[]; skipped: number }>>;
|
|
382
|
+
/**
|
|
383
|
+
* Move the entity's `updated_at` and fire `catalog.afterUpdate` for a change to rows RELATED to
|
|
384
|
+
* it (links, media) — the versioning contract consumers rely on to re-read a product.
|
|
385
|
+
*/
|
|
386
|
+
notifyEntityChanged(
|
|
387
|
+
entityId: string,
|
|
388
|
+
changedFieldPaths: readonly string[],
|
|
389
|
+
actor: Actor | null,
|
|
390
|
+
ctx?: TxContext,
|
|
391
|
+
): Promise<void>;
|
|
382
392
|
recordEntityRevision(
|
|
383
393
|
entityId: string,
|
|
384
394
|
actor: Actor | null,
|
|
@@ -678,6 +688,15 @@ export class CatalogServiceImpl implements CatalogService {
|
|
|
678
688
|
}, ctx);
|
|
679
689
|
}
|
|
680
690
|
|
|
691
|
+
notifyEntityChanged(
|
|
692
|
+
entityId: string,
|
|
693
|
+
changedFieldPaths: readonly string[],
|
|
694
|
+
actor: Actor | null,
|
|
695
|
+
ctx?: TxContext,
|
|
696
|
+
): Promise<void> {
|
|
697
|
+
return this.entities.notifyEntityUpdated(entityId, changedFieldPaths, actor, ctx);
|
|
698
|
+
}
|
|
699
|
+
|
|
681
700
|
async recordEntityRevision(
|
|
682
701
|
entityId: string,
|
|
683
702
|
actor: Actor | null,
|
|
@@ -1134,16 +1153,24 @@ export class CatalogServiceImpl implements CatalogService {
|
|
|
1134
1153
|
addToCategory(entityId: string, categoryId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
|
|
1135
1154
|
return this.withMutationResult(actor, ctx, async (txCtx) => {
|
|
1136
1155
|
const result = await this.categories.addToCategory(entityId, categoryId, actor, txCtx);
|
|
1137
|
-
if (result.ok)
|
|
1138
|
-
|
|
1156
|
+
if (!result.ok) return result;
|
|
1157
|
+
// A link that actually changed versions the entity (an indexed facet moved); re-linking what
|
|
1158
|
+
// is already linked moves nothing.
|
|
1159
|
+
if (result.value) await this.entities.notifyEntityUpdated(entityId, ["categories"], actor, txCtx);
|
|
1160
|
+
await this.captureRevision(entityId, actor, "update", txCtx);
|
|
1161
|
+
return Ok(undefined);
|
|
1139
1162
|
});
|
|
1140
1163
|
}
|
|
1141
1164
|
|
|
1142
1165
|
removeFromCategory(entityId: string, categoryId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
|
|
1143
1166
|
return this.withMutationResult(actor, ctx, async (txCtx) => {
|
|
1144
1167
|
const result = await this.categories.removeFromCategory(entityId, categoryId, actor, txCtx);
|
|
1145
|
-
if (result.ok)
|
|
1146
|
-
|
|
1168
|
+
if (!result.ok) return result;
|
|
1169
|
+
// A link that actually changed versions the entity (an indexed facet moved); re-linking what
|
|
1170
|
+
// is already linked moves nothing.
|
|
1171
|
+
if (result.value) await this.entities.notifyEntityUpdated(entityId, ["categories"], actor, txCtx);
|
|
1172
|
+
await this.captureRevision(entityId, actor, "update", txCtx);
|
|
1173
|
+
return Ok(undefined);
|
|
1147
1174
|
});
|
|
1148
1175
|
}
|
|
1149
1176
|
|
|
@@ -1166,16 +1193,24 @@ export class CatalogServiceImpl implements CatalogService {
|
|
|
1166
1193
|
addToBrand(entityId: string, brandId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
|
|
1167
1194
|
return this.withMutationResult(actor, ctx, async (txCtx) => {
|
|
1168
1195
|
const result = await this.brands.addToBrand(entityId, brandId, actor, txCtx);
|
|
1169
|
-
if (result.ok)
|
|
1170
|
-
|
|
1196
|
+
if (!result.ok) return result;
|
|
1197
|
+
// A link that actually changed versions the entity (an indexed facet moved); re-linking what
|
|
1198
|
+
// is already linked moves nothing.
|
|
1199
|
+
if (result.value) await this.entities.notifyEntityUpdated(entityId, ["brand"], actor, txCtx);
|
|
1200
|
+
await this.captureRevision(entityId, actor, "update", txCtx);
|
|
1201
|
+
return Ok(undefined);
|
|
1171
1202
|
});
|
|
1172
1203
|
}
|
|
1173
1204
|
|
|
1174
1205
|
removeFromBrand(entityId: string, brandId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
|
|
1175
1206
|
return this.withMutationResult(actor, ctx, async (txCtx) => {
|
|
1176
1207
|
const result = await this.brands.removeFromBrand(entityId, brandId, actor, txCtx);
|
|
1177
|
-
if (result.ok)
|
|
1178
|
-
|
|
1208
|
+
if (!result.ok) return result;
|
|
1209
|
+
// A link that actually changed versions the entity (an indexed facet moved); re-linking what
|
|
1210
|
+
// is already linked moves nothing.
|
|
1211
|
+
if (result.value) await this.entities.notifyEntityUpdated(entityId, ["brand"], actor, txCtx);
|
|
1212
|
+
await this.captureRevision(entityId, actor, "update", txCtx);
|
|
1213
|
+
return Ok(undefined);
|
|
1179
1214
|
});
|
|
1180
1215
|
}
|
|
1181
1216
|
|
|
@@ -287,6 +287,7 @@ export class MediaService {
|
|
|
287
287
|
throw new Error("Catalog service is required for media revision capture.");
|
|
288
288
|
}
|
|
289
289
|
const catalogService = catalog as {
|
|
290
|
+
notifyEntityChanged: (entityId: string, changedFieldPaths: readonly string[], actor: Actor | null, ctx?: TxContext) => Promise<void>;
|
|
290
291
|
recordEntityRevision: (
|
|
291
292
|
entityId: string,
|
|
292
293
|
actor: Actor | null,
|
|
@@ -294,6 +295,9 @@ export class MediaService {
|
|
|
294
295
|
ctx?: TxContext,
|
|
295
296
|
) => Promise<Result<unknown>>;
|
|
296
297
|
};
|
|
298
|
+
// A new attachment versions the entity: the hero drives the image embedding, so a consumer
|
|
299
|
+
// that versions on `updated_at` must see it.
|
|
300
|
+
await catalogService.notifyEntityChanged(input.entityId, [`media.${input.role}`], actor ?? txCtx.actor, txCtx);
|
|
297
301
|
const revision = await catalogService.recordEntityRevision(input.entityId, actor ?? txCtx.actor, "update", txCtx);
|
|
298
302
|
if (!revision.ok) throw revision.error;
|
|
299
303
|
return Ok(undefined);
|