@designcrowd/library.brand-page 6.1.16 → 6.1.17

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 (24) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-CmNhDMIf.js → CreatedOnBrandCrowd-de-de-CK6etjY1.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-DgplxxWa.js → CreatedOnBrandCrowd-en-us-D2_dNMC_.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-DFdhFOa1.js → CreatedOnBrandCrowd-es-es-DOfe1n1i.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-DjLCWBLB.js → CreatedOnBrandCrowd-fr-ca-8DzyX3jw.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-DjLCWBLB.js → CreatedOnBrandCrowd-fr-fr-8DzyX3jw.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-CI2XD0OS.js → CreatedOnBrandCrowd-pt-br-Cv554e0a.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-CI2XD0OS.js → CreatedOnBrandCrowd-pt-pt-Cv554e0a.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-CkLkBztx.js → CreatedOnDcom-de-de-DvfcEbrX.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-frnxBJJv.js → CreatedOnDcom-en-us-1CwdKDdv.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-CrqgiERj.js → CreatedOnDcom-es-es-DcYnDykc.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-CPoEcusj.js → CreatedOnDcom-fr-ca-BsFnW3Gm.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-BqrRH3dV.js → CreatedOnDcom-fr-fr-CK2r77t1.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-CpbVi3Sw.js → CreatedOnDcom-pt-br-F9xrm0CV.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-s0XZ3c7y.js → CreatedOnDcom-pt-pt-B1Cf7iLu.js} +1 -1
  15. package/lib/src/index.d.ts +1 -0
  16. package/lib/src/index.mjs +45 -44
  17. package/lib/src/{lib-O_eJgJHP.js → lib-YpnLrt3k.js} +14352 -14321
  18. package/lib/src/lib.js +1 -0
  19. package/lib/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.d.ts +11 -0
  20. package/lib/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.js +80 -0
  21. package/package.json +1 -1
  22. package/src/lib.ts +1 -0
  23. package/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.test.ts +399 -0
  24. package/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.ts +99 -0
package/lib/src/lib.js CHANGED
@@ -48,6 +48,7 @@ export { tiptapJsonToHtml } from './metadata-builder/helpers/rte.helpers';
48
48
  // stores
49
49
  export { useBrandPageMakerStore, useBrandPageMakerUserStore, useUiStore, useBrandPageContainerStore, useEventManagerStore, useLocalisedRouteStore, useVideoStore, useRouteStore, } from './brand-page-maker/stores';
50
50
  export { useShopConfigStore } from './shared/ecommerce-settings/store/shop-settings-store';
51
+ export { refreshDuplicatedMetadata } from './shared/ecommerce-settings/util/refreshDuplicatedMetadata';
51
52
  // for jest, need to set active pinia in library.brand-page as well
52
53
  import { createPinia, setActivePinia } from 'pinia';
53
54
  import { useCartStore } from './shared/ecommerce-settings/store/cart-settings-store';
@@ -0,0 +1,11 @@
1
+ import type { BrandPageModel } from '@brand-page/models';
2
+ /**
3
+ * Refresh a (migrated, current-version) BrandPageModel so it is safe to persist as a *duplicate*.
4
+ * Two things travel with a verbatim copy that must not: client-minted identifiers that the
5
+ * brand-page-api keys a global row on, and the source owner's embedded payment connection. This
6
+ * rotates the former to fresh values and removes the latter, in place — the new owner reconnects
7
+ * a payment provider explicitly. Runs after migration, so it only ever sees the current shape.
8
+ *
9
+ * See docs/architecture/capabilities/shop-duplication.md.
10
+ */
11
+ export declare function refreshDuplicatedMetadata(model: BrandPageModel): BrandPageModel;
@@ -0,0 +1,80 @@
1
+ import { v4 as uuid } from 'uuid';
2
+ import { isPayment, isShopContent, isSupportMe } from '@brand-page/utils';
3
+ import { generateSku } from './skuUtils';
4
+ /**
5
+ * Refresh a (migrated, current-version) BrandPageModel so it is safe to persist as a *duplicate*.
6
+ * Two things travel with a verbatim copy that must not: client-minted identifiers that the
7
+ * brand-page-api keys a global row on, and the source owner's embedded payment connection. This
8
+ * rotates the former to fresh values and removes the latter, in place — the new owner reconnects
9
+ * a payment provider explicitly. Runs after migration, so it only ever sees the current shape.
10
+ *
11
+ * See docs/architecture/capabilities/shop-duplication.md.
12
+ */
13
+ export function refreshDuplicatedMetadata(model) {
14
+ // Site-level payment connection: the authoritative one at checkout — `initialiseShopStore` prefers
15
+ // it over any content-level config — so it leaks the source owner's account even after the
16
+ // content-level connections below are cleared. Disconnect it too; the new owner reconnects.
17
+ model.paymentConfig = undefined;
18
+ model.pages.forEach((page) => page.contents.forEach((content) => {
19
+ if (isShopContent(content)) {
20
+ // Shop products collide on the global product / sku_key tables. The shop's payment-config
21
+ // link is written with throwaway ids (not the content id), so only the product ids rotate.
22
+ content.products.forEach(rotateProductIds);
23
+ disconnectPayment(content.shopPaymentConfig);
24
+ }
25
+ else if (isPayment(content)) {
26
+ resetPaymentBlock(content, content.requestPaymentConfig);
27
+ }
28
+ else if (isSupportMe(content)) {
29
+ resetPaymentBlock(content, content.supportMeConfig);
30
+ }
31
+ }));
32
+ return model;
33
+ }
34
+ function rotateProductIds(product) {
35
+ product.id = uuid();
36
+ // The option id is the only value cross-referenced inside a product — SKUs point at product
37
+ // options by id and each skuKey embeds it — so it is remapped through one old→new map and
38
+ // reused everywhere it reappears. Every other id is standalone and just gets a fresh uuid.
39
+ const optionIdMap = new Map();
40
+ product.variant.forEach((variant) => {
41
+ variant.id = uuid();
42
+ variant.options.forEach((option) => {
43
+ const newId = uuid();
44
+ optionIdMap.set(option.id, newId);
45
+ option.id = newId;
46
+ });
47
+ });
48
+ product.skus.forEach((sku) => {
49
+ sku.id = uuid();
50
+ sku.variant.forEach((skuVariant) => {
51
+ skuVariant.id = uuid();
52
+ skuVariant.option.id = optionIdMap.get(skuVariant.option.id) ?? uuid();
53
+ });
54
+ // Regenerate from the now-remapped option so the key embeds the new option id and a fresh
55
+ // random suffix — this is what keeps sku_key globally unique on the copy's first upsert.
56
+ sku.skuKey = generateSku(product.name, sku.variant[0]?.option);
57
+ });
58
+ }
59
+ /**
60
+ * request-payment / support-me content. The api keys `content_block_payment_config` on the content
61
+ * id (PK) and `brand_page_product` on the payment-option id (PK), both global. A verbatim copy
62
+ * would, on its next payment upsert, repoint the source's config link and adopt its product rows —
63
+ * so rotate both to make the copy independent, then drop the connection.
64
+ */
65
+ function resetPaymentBlock(content, config) {
66
+ content.id = uuid();
67
+ config.paymentOptions.forEach((option) => {
68
+ option.id = uuid();
69
+ });
70
+ disconnectPayment(config);
71
+ }
72
+ /**
73
+ * Drop the embedded payment connection (the source owner's payment/Stripe account). The section
74
+ * keeps its options and amounts but reverts to "not connected"; the new owner reconnects.
75
+ */
76
+ function disconnectPayment(config) {
77
+ if (config) {
78
+ config.paymentConfig = undefined;
79
+ }
80
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@designcrowd/library.brand-page",
3
3
  "description": "A collection of Maker/Designer applications",
4
- "version": "6.1.16",
4
+ "version": "6.1.17",
5
5
  "author": "Design.com Eng",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",
package/src/lib.ts CHANGED
@@ -126,6 +126,7 @@ export {
126
126
  useRouteStore,
127
127
  } from './brand-page-maker/stores';
128
128
  export { useShopConfigStore } from './shared/ecommerce-settings/store/shop-settings-store';
129
+ export { refreshDuplicatedMetadata } from './shared/ecommerce-settings/util/refreshDuplicatedMetadata';
129
130
 
130
131
  // for jest, need to set active pinia in library.brand-page as well
131
132
  import { createPinia, setActivePinia } from 'pinia';
@@ -0,0 +1,399 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { refreshDuplicatedMetadata } from './refreshDuplicatedMetadata';
3
+ import type { BrandPageModel } from '@brand-page/models';
4
+ import type { ProductModel } from '@brand-page/models/shop.model';
5
+
6
+ const variantProduct = (): ProductModel => ({
7
+ id: 'p1',
8
+ name: 'Denim Jacket',
9
+ description: '',
10
+ imageUrl: '',
11
+ defaultPrice: 10,
12
+ hasVariants: true,
13
+ enableNotes: false,
14
+ variant: [
15
+ {
16
+ id: 'v1',
17
+ name: 'Colour',
18
+ options: [
19
+ { id: 'o1', name: 'Black', price: 10 },
20
+ { id: 'o2', name: 'White', price: 12 },
21
+ ],
22
+ },
23
+ ],
24
+ skus: [
25
+ {
26
+ id: 's1',
27
+ price: 10,
28
+ skuKey: 'denim-jacket-o1-Black-abc123',
29
+ variant: [
30
+ {
31
+ id: 'v1',
32
+ name: 'Colour',
33
+ option: { id: 'o1', name: 'Black', price: 10 },
34
+ },
35
+ ],
36
+ },
37
+ {
38
+ id: 's2',
39
+ price: 12,
40
+ skuKey: 'denim-jacket-o2-White-def456',
41
+ variant: [
42
+ {
43
+ id: 'v1',
44
+ name: 'Colour',
45
+ option: { id: 'o2', name: 'White', price: 12 },
46
+ },
47
+ ],
48
+ },
49
+ ],
50
+ });
51
+
52
+ const noVariantProduct = (): ProductModel => ({
53
+ id: 'p2',
54
+ name: 'Sticker',
55
+ description: '',
56
+ imageUrl: '',
57
+ defaultPrice: 5,
58
+ hasVariants: false,
59
+ enableNotes: false,
60
+ variant: [],
61
+ skus: [{ id: 's3', price: 5, skuKey: 'sticker-nv-zzz999', variant: [] }],
62
+ });
63
+
64
+ const multiVariantProduct = (): ProductModel => ({
65
+ id: 'mp1',
66
+ name: 'Tee',
67
+ description: '',
68
+ imageUrl: '',
69
+ defaultPrice: 20,
70
+ hasVariants: true,
71
+ enableNotes: false,
72
+ variant: [
73
+ {
74
+ id: 'cv',
75
+ name: 'Colour',
76
+ options: [
77
+ { id: 'c-black', name: 'Black', price: 20 },
78
+ { id: 'c-white', name: 'White', price: 20 },
79
+ ],
80
+ },
81
+ {
82
+ id: 'sv',
83
+ name: 'Size',
84
+ options: [
85
+ { id: 's-s', name: 'S', price: 20 },
86
+ { id: 's-m', name: 'M', price: 22 },
87
+ ],
88
+ },
89
+ ],
90
+ skus: [
91
+ {
92
+ id: 'k1',
93
+ price: 20,
94
+ skuKey: 'tee-c-black-Black-a',
95
+ variant: [
96
+ {
97
+ id: 'cv',
98
+ name: 'Colour',
99
+ option: { id: 'c-black', name: 'Black', price: 20 },
100
+ },
101
+ ],
102
+ },
103
+ {
104
+ id: 'k2',
105
+ price: 20,
106
+ skuKey: 'tee-c-white-White-b',
107
+ variant: [
108
+ {
109
+ id: 'cv',
110
+ name: 'Colour',
111
+ option: { id: 'c-white', name: 'White', price: 20 },
112
+ },
113
+ ],
114
+ },
115
+ {
116
+ id: 'k3',
117
+ price: 20,
118
+ skuKey: 'tee-s-s-S-c',
119
+ variant: [
120
+ { id: 'sv', name: 'Size', option: { id: 's-s', name: 'S', price: 20 } },
121
+ ],
122
+ },
123
+ {
124
+ id: 'k4',
125
+ price: 22,
126
+ skuKey: 'tee-s-m-M-d',
127
+ variant: [
128
+ { id: 'sv', name: 'Size', option: { id: 's-m', name: 'M', price: 22 } },
129
+ ],
130
+ },
131
+ ],
132
+ });
133
+
134
+ const paymentConfigStub = () => ({
135
+ id: 'cfg-1',
136
+ name: "Seller's Stripe",
137
+ status: 'active',
138
+ defaultCurrency: 'USD',
139
+ defaultCurrencySymbol: '$',
140
+ paymentProviderName: 'stripe',
141
+ paymentProviderImageUrl: '',
142
+ dashboardUrl: 'https://dashboard.stripe.com/x',
143
+ });
144
+
145
+ const requestPaymentContent = () => ({
146
+ type: 'request-payment',
147
+ id: 'rp1',
148
+ requestPaymentConfig: {
149
+ paymentConfig: paymentConfigStub(),
150
+ paymentOptions: [
151
+ { id: 'po1', amount: '10', name: 'Small' },
152
+ { id: 'po2', amount: '20', name: 'Large' },
153
+ ],
154
+ },
155
+ });
156
+
157
+ const supportMeContent = () => ({
158
+ type: 'support-me',
159
+ id: 'sm1',
160
+ supportMeConfig: {
161
+ paymentConfig: paymentConfigStub(),
162
+ paymentOptions: [{ id: 'so1', amount: '5', name: 'Coffee' }],
163
+ },
164
+ });
165
+
166
+ const shopWithConfig = (products: ProductModel[]) => ({
167
+ type: 'shop-section',
168
+ id: 'shop1',
169
+ products,
170
+ shopPaymentConfig: {
171
+ paymentConfig: paymentConfigStub(),
172
+ paymentOptions: [],
173
+ },
174
+ });
175
+
176
+ const modelWith = (contents: unknown[]): BrandPageModel =>
177
+ ({ pages: [{ contents }] }) as unknown as BrandPageModel;
178
+
179
+ const shopModel = (products: ProductModel[]): BrandPageModel =>
180
+ modelWith([{ type: 'shop-section', products }]);
181
+
182
+ const collectIds = (products: ProductModel[]): string[] =>
183
+ products.flatMap((p) => [
184
+ p.id,
185
+ ...p.variant.flatMap((v) => [v.id, ...v.options.map((o) => o.id)]),
186
+ ...p.skus.flatMap((s) => [
187
+ s.id,
188
+ ...s.variant.flatMap((sv) => [sv.id, sv.option.id]),
189
+ ]),
190
+ ]);
191
+
192
+ describe('refreshDuplicatedMetadata — shop id rotation', () => {
193
+ it('R1 — rotates every product/sku/variant/option id (no input id survives)', () => {
194
+ const products = [variantProduct(), noVariantProduct()];
195
+ const before = new Set(collectIds(products));
196
+ const beforeKeys = new Set(
197
+ products.flatMap((p) => p.skus.map((s) => s.skuKey)),
198
+ );
199
+
200
+ const out = shopModel(products);
201
+
202
+ refreshDuplicatedMetadata(out);
203
+ const after = collectIds(products);
204
+
205
+ expect(after.every((id) => !before.has(id))).toBe(true);
206
+ products
207
+ .flatMap((p) => p.skus.map((s) => s.skuKey))
208
+ .forEach((key) => expect(beforeKeys.has(key)).toBe(false));
209
+ });
210
+
211
+ it('R2 — preserves the SKU→option linkage (each sku option id maps to a product option id)', () => {
212
+ const products = [variantProduct()];
213
+
214
+ refreshDuplicatedMetadata(shopModel(products));
215
+
216
+ products.forEach((p) => {
217
+ const productOptionIds = new Set(
218
+ p.variant.flatMap((v) => v.options.map((o) => o.id)),
219
+ );
220
+
221
+ p.skus.forEach((s) =>
222
+ s.variant.forEach((sv) =>
223
+ expect(productOptionIds.has(sv.option.id)).toBe(true),
224
+ ),
225
+ );
226
+ });
227
+ });
228
+
229
+ it('R3 — regenerates each skuKey to a fresh value that embeds the new option id', () => {
230
+ const product = variantProduct();
231
+
232
+ refreshDuplicatedMetadata(shopModel([product]));
233
+
234
+ product.skus.forEach((s) => {
235
+ const newOptionId = s.variant[0].option.id;
236
+
237
+ expect(s.skuKey).toContain(newOptionId);
238
+ expect(s.skuKey.startsWith('denim-jacket-')).toBe(true);
239
+ });
240
+ // The two SKUs still get distinct keys.
241
+ expect(product.skus[0].skuKey).not.toBe(product.skus[1].skuKey);
242
+ });
243
+
244
+ it('R4 — no-variant product keeps the "nv" skuKey shape with a fresh id', () => {
245
+ const product = noVariantProduct();
246
+
247
+ refreshDuplicatedMetadata(shopModel([product]));
248
+
249
+ const sku = product.skus[0];
250
+
251
+ expect(sku.id).not.toBe('s3');
252
+ expect(sku.skuKey).toBe(`sticker-nv-${sku.skuKey.split('-').pop()}`);
253
+ expect(sku.skuKey).not.toBe('sticker-nv-zzz999');
254
+ });
255
+
256
+ it('R5 — no-op when there is no shop content (and does not throw)', () => {
257
+ const model = modelWith([{ type: 'text', text: 'hello' }]);
258
+
259
+ expect(() => refreshDuplicatedMetadata(model)).not.toThrow();
260
+
261
+ // An empty shop section is also a clean no-op.
262
+ const emptyShop = shopModel([]);
263
+
264
+ expect(() => refreshDuplicatedMetadata(emptyShop)).not.toThrow();
265
+ });
266
+
267
+ it('R6 — multi-variant product: every SKU still links to a product option after rotation', () => {
268
+ const product = multiVariantProduct();
269
+
270
+ refreshDuplicatedMetadata(shopModel([product]));
271
+
272
+ const productOptionIds = new Set(
273
+ product.variant.flatMap((v) => v.options.map((o) => o.id)),
274
+ );
275
+
276
+ expect(productOptionIds.size).toBe(4);
277
+ product.skus.forEach((s) =>
278
+ s.variant.forEach((sv) =>
279
+ expect(productOptionIds.has(sv.option.id)).toBe(true),
280
+ ),
281
+ );
282
+ });
283
+
284
+ it('R7 — rotates shop content across multiple sections and pages with no id collisions', () => {
285
+ const p1 = variantProduct();
286
+ const p2 = variantProduct();
287
+
288
+ const model = {
289
+ pages: [
290
+ {
291
+ contents: [
292
+ { type: 'shop-section', products: [p1] },
293
+ { type: 'text', text: 'x' },
294
+ ],
295
+ },
296
+ { contents: [{ type: 'shop-section', products: [p2] }] },
297
+ ],
298
+ } as unknown as BrandPageModel;
299
+
300
+ refreshDuplicatedMetadata(model);
301
+
302
+ // Two products from identical fixtures must share no id across sections/pages. (Within a
303
+ // product, a SKU's option id equals its product option id by design — the R2 linkage — so
304
+ // the check is cross-product disjointness, not global uniqueness.)
305
+ const ids1 = new Set(collectIds([p1]));
306
+ const p2Ids = collectIds([p2]);
307
+
308
+ expect(p2Ids.some((id) => ids1.has(id))).toBe(false);
309
+ });
310
+ });
311
+
312
+ describe('refreshDuplicatedMetadata — payment connection reset', () => {
313
+ it('P1 — request-payment: rotates the content id and every option id, and drops the connection', () => {
314
+ const content = requestPaymentContent();
315
+
316
+ refreshDuplicatedMetadata(modelWith([content]));
317
+
318
+ // Content id (content_block_payment_config PK) and option ids (brand_page_product PK) are the
319
+ // global keys a reconnect would otherwise clash the source on — both rotated.
320
+ expect(content.id).not.toBe('rp1');
321
+ expect(
322
+ content.requestPaymentConfig.paymentOptions.map((o) => o.id),
323
+ ).not.toContain('po1');
324
+ expect(
325
+ content.requestPaymentConfig.paymentOptions.map((o) => o.id),
326
+ ).not.toContain('po2');
327
+ // Amounts/labels are kept — only the ids change and the provider is disconnected.
328
+ expect(
329
+ content.requestPaymentConfig.paymentOptions.map((o) => o.amount),
330
+ ).toEqual(['10', '20']);
331
+ expect(content.requestPaymentConfig.paymentConfig).toBeUndefined();
332
+ });
333
+
334
+ it('P2 — support-me: rotates the content id and option ids, and drops the connection', () => {
335
+ const content = supportMeContent();
336
+
337
+ refreshDuplicatedMetadata(modelWith([content]));
338
+
339
+ expect(content.id).not.toBe('sm1');
340
+ expect(content.supportMeConfig.paymentOptions[0].id).not.toBe('so1');
341
+ expect(content.supportMeConfig.paymentOptions[0].amount).toBe('5');
342
+ expect(content.supportMeConfig.paymentConfig).toBeUndefined();
343
+ });
344
+
345
+ it('P3 — shop: drops the checkout connection but keeps (rotated) products and the content id', () => {
346
+ const product = variantProduct();
347
+ const content = shopWithConfig([product]);
348
+
349
+ refreshDuplicatedMetadata(modelWith([content]));
350
+
351
+ // The seller's Stripe connection is removed…
352
+ expect(content.shopPaymentConfig.paymentConfig).toBeUndefined();
353
+ // …but products are still rotated (shop collides on product ids, not the content id)…
354
+ expect(product.id).not.toBe('p1');
355
+ // …and the shop content id is deliberately left alone (its config link uses throwaway ids).
356
+ expect(content.id).toBe('shop1');
357
+ });
358
+
359
+ it('P4 — mixed page: each payment block is reset, non-payment content is untouched', () => {
360
+ const rp = requestPaymentContent();
361
+ const sm = supportMeContent();
362
+ const shop = shopWithConfig([variantProduct()]);
363
+ const text = { type: 'text', id: 'txt1', text: 'hello' };
364
+
365
+ expect(() =>
366
+ refreshDuplicatedMetadata(modelWith([rp, sm, shop, text])),
367
+ ).not.toThrow();
368
+
369
+ expect(rp.requestPaymentConfig.paymentConfig).toBeUndefined();
370
+ expect(sm.supportMeConfig.paymentConfig).toBeUndefined();
371
+ expect(shop.shopPaymentConfig.paymentConfig).toBeUndefined();
372
+ expect(text).toEqual({ type: 'text', id: 'txt1', text: 'hello' });
373
+ });
374
+
375
+ it('P5 — already-disconnected payment block: still rotates ids and stays disconnected', () => {
376
+ const content = requestPaymentContent();
377
+
378
+ content.requestPaymentConfig.paymentConfig = undefined as never;
379
+
380
+ expect(() => refreshDuplicatedMetadata(modelWith([content]))).not.toThrow();
381
+
382
+ expect(content.id).not.toBe('rp1');
383
+ expect(content.requestPaymentConfig.paymentOptions[0].id).not.toBe('po1');
384
+ expect(content.requestPaymentConfig.paymentConfig).toBeUndefined();
385
+ });
386
+
387
+ it('P6 — clears the site-level paymentConfig (the authoritative checkout connection)', () => {
388
+ // initialiseShopStore prefers model.paymentConfig over any content-level config, so leaving it
389
+ // would still route the copy's checkout to the source owner even with the shop section cleared.
390
+ const model = {
391
+ paymentConfig: paymentConfigStub(),
392
+ pages: [{ contents: [shopWithConfig([variantProduct()])] }],
393
+ } as unknown as BrandPageModel;
394
+
395
+ refreshDuplicatedMetadata(model);
396
+
397
+ expect(model.paymentConfig).toBeUndefined();
398
+ });
399
+ });
@@ -0,0 +1,99 @@
1
+ import { v4 as uuid } from 'uuid';
2
+ import type { BrandPageModel, Content } from '@brand-page/models';
3
+ import type { ProductModel } from '@brand-page/models/shop.model';
4
+ import type { PaymentConfig } from '@brand-page/models/payment.model';
5
+ import { isPayment, isShopContent, isSupportMe } from '@brand-page/utils';
6
+ import { generateSku } from './skuUtils';
7
+
8
+ /**
9
+ * Refresh a (migrated, current-version) BrandPageModel so it is safe to persist as a *duplicate*.
10
+ * Two things travel with a verbatim copy that must not: client-minted identifiers that the
11
+ * brand-page-api keys a global row on, and the source owner's embedded payment connection. This
12
+ * rotates the former to fresh values and removes the latter, in place — the new owner reconnects
13
+ * a payment provider explicitly. Runs after migration, so it only ever sees the current shape.
14
+ *
15
+ * See docs/architecture/capabilities/shop-duplication.md.
16
+ */
17
+ export function refreshDuplicatedMetadata(
18
+ model: BrandPageModel,
19
+ ): BrandPageModel {
20
+ // Site-level payment connection: the authoritative one at checkout — `initialiseShopStore` prefers
21
+ // it over any content-level config — so it leaks the source owner's account even after the
22
+ // content-level connections below are cleared. Disconnect it too; the new owner reconnects.
23
+ model.paymentConfig = undefined;
24
+
25
+ model.pages.forEach((page) =>
26
+ page.contents.forEach((content) => {
27
+ if (isShopContent(content)) {
28
+ // Shop products collide on the global product / sku_key tables. The shop's payment-config
29
+ // link is written with throwaway ids (not the content id), so only the product ids rotate.
30
+ content.products.forEach(rotateProductIds);
31
+ disconnectPayment(content.shopPaymentConfig);
32
+ } else if (isPayment(content)) {
33
+ resetPaymentBlock(content, content.requestPaymentConfig);
34
+ } else if (isSupportMe(content)) {
35
+ resetPaymentBlock(content, content.supportMeConfig);
36
+ }
37
+ }),
38
+ );
39
+
40
+ return model;
41
+ }
42
+
43
+ function rotateProductIds(product: ProductModel): void {
44
+ product.id = uuid();
45
+
46
+ // The option id is the only value cross-referenced inside a product — SKUs point at product
47
+ // options by id and each skuKey embeds it — so it is remapped through one old→new map and
48
+ // reused everywhere it reappears. Every other id is standalone and just gets a fresh uuid.
49
+ const optionIdMap = new Map<string, string>();
50
+
51
+ product.variant.forEach((variant) => {
52
+ variant.id = uuid();
53
+ variant.options.forEach((option) => {
54
+ const newId = uuid();
55
+
56
+ optionIdMap.set(option.id, newId);
57
+ option.id = newId;
58
+ });
59
+ });
60
+
61
+ product.skus.forEach((sku) => {
62
+ sku.id = uuid();
63
+
64
+ sku.variant.forEach((skuVariant) => {
65
+ skuVariant.id = uuid();
66
+ skuVariant.option.id = optionIdMap.get(skuVariant.option.id) ?? uuid();
67
+ });
68
+
69
+ // Regenerate from the now-remapped option so the key embeds the new option id and a fresh
70
+ // random suffix — this is what keeps sku_key globally unique on the copy's first upsert.
71
+ sku.skuKey = generateSku(product.name, sku.variant[0]?.option);
72
+ });
73
+ }
74
+
75
+ /**
76
+ * request-payment / support-me content. The api keys `content_block_payment_config` on the content
77
+ * id (PK) and `brand_page_product` on the payment-option id (PK), both global. A verbatim copy
78
+ * would, on its next payment upsert, repoint the source's config link and adopt its product rows —
79
+ * so rotate both to make the copy independent, then drop the connection.
80
+ */
81
+ function resetPaymentBlock(content: Content, config: PaymentConfig): void {
82
+ content.id = uuid();
83
+
84
+ config.paymentOptions.forEach((option) => {
85
+ option.id = uuid();
86
+ });
87
+
88
+ disconnectPayment(config);
89
+ }
90
+
91
+ /**
92
+ * Drop the embedded payment connection (the source owner's payment/Stripe account). The section
93
+ * keeps its options and amounts but reverts to "not connected"; the new owner reconnects.
94
+ */
95
+ function disconnectPayment(config: PaymentConfig | undefined): void {
96
+ if (config) {
97
+ config.paymentConfig = undefined;
98
+ }
99
+ }