@endora-commerce/mod-comparisons 0.0.0-stage → 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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -2
  3. package/dist/admin/api/comparisons-client.d.ts +24 -0
  4. package/dist/admin/api/comparisons-client.d.ts.map +1 -0
  5. package/dist/admin/api/comparisons-client.js +15 -0
  6. package/dist/admin/api/comparisons-client.js.map +1 -0
  7. package/dist/admin/index.d.ts +37 -0
  8. package/dist/admin/index.d.ts.map +1 -0
  9. package/dist/admin/index.js +49 -0
  10. package/dist/admin/index.js.map +1 -0
  11. package/dist/admin/pages/ComparisonDetailPage.d.ts +19 -0
  12. package/dist/admin/pages/ComparisonDetailPage.d.ts.map +1 -0
  13. package/dist/admin/pages/ComparisonDetailPage.js +62 -0
  14. package/dist/admin/pages/ComparisonDetailPage.js.map +1 -0
  15. package/dist/admin/pages/ComparisonsListPage.d.ts +10 -0
  16. package/dist/admin/pages/ComparisonsListPage.d.ts.map +1 -0
  17. package/dist/admin/pages/ComparisonsListPage.js +71 -0
  18. package/dist/admin/pages/ComparisonsListPage.js.map +1 -0
  19. package/dist/backend/entities/comparison-product.entity.d.ts +22 -0
  20. package/dist/backend/entities/comparison-product.entity.d.ts.map +1 -0
  21. package/dist/backend/entities/comparison-product.entity.js +54 -0
  22. package/dist/backend/entities/comparison-product.entity.js.map +1 -0
  23. package/dist/backend/entities/comparison.entity.d.ts +61 -0
  24. package/dist/backend/entities/comparison.entity.d.ts.map +1 -0
  25. package/dist/backend/entities/comparison.entity.js +115 -0
  26. package/dist/backend/entities/comparison.entity.js.map +1 -0
  27. package/dist/backend/index.d.ts +61 -0
  28. package/dist/backend/index.d.ts.map +1 -0
  29. package/dist/backend/index.js +117 -0
  30. package/dist/backend/index.js.map +1 -0
  31. package/dist/backend/request-actor.d.ts +22 -0
  32. package/dist/backend/request-actor.d.ts.map +1 -0
  33. package/dist/backend/request-actor.js +24 -0
  34. package/dist/backend/request-actor.js.map +1 -0
  35. package/dist/backend/routes.admin.d.ts +22 -0
  36. package/dist/backend/routes.admin.d.ts.map +1 -0
  37. package/dist/backend/routes.admin.js +41 -0
  38. package/dist/backend/routes.admin.js.map +1 -0
  39. package/dist/backend/routes.public.d.ts +33 -0
  40. package/dist/backend/routes.public.d.ts.map +1 -0
  41. package/dist/backend/routes.public.js +215 -0
  42. package/dist/backend/routes.public.js.map +1 -0
  43. package/dist/backend/routes.share.d.ts +27 -0
  44. package/dist/backend/routes.share.d.ts.map +1 -0
  45. package/dist/backend/routes.share.js +49 -0
  46. package/dist/backend/routes.share.js.map +1 -0
  47. package/dist/backend/services/anonymous-token-cookie.d.ts +23 -0
  48. package/dist/backend/services/anonymous-token-cookie.d.ts.map +1 -0
  49. package/dist/backend/services/anonymous-token-cookie.js +33 -0
  50. package/dist/backend/services/anonymous-token-cookie.js.map +1 -0
  51. package/dist/backend/services/asset-byte-fetcher.d.ts +37 -0
  52. package/dist/backend/services/asset-byte-fetcher.d.ts.map +1 -0
  53. package/dist/backend/services/asset-byte-fetcher.js +70 -0
  54. package/dist/backend/services/asset-byte-fetcher.js.map +1 -0
  55. package/dist/backend/services/comparable-attribute-projection.d.ts +57 -0
  56. package/dist/backend/services/comparable-attribute-projection.d.ts.map +1 -0
  57. package/dist/backend/services/comparable-attribute-projection.js +136 -0
  58. package/dist/backend/services/comparable-attribute-projection.js.map +1 -0
  59. package/dist/backend/services/comparison-admin.service.d.ts +31 -0
  60. package/dist/backend/services/comparison-admin.service.d.ts.map +1 -0
  61. package/dist/backend/services/comparison-admin.service.js +184 -0
  62. package/dist/backend/services/comparison-admin.service.js.map +1 -0
  63. package/dist/backend/services/comparison-pdf-renderer.d.ts +57 -0
  64. package/dist/backend/services/comparison-pdf-renderer.d.ts.map +1 -0
  65. package/dist/backend/services/comparison-pdf-renderer.js +211 -0
  66. package/dist/backend/services/comparison-pdf-renderer.js.map +1 -0
  67. package/dist/backend/services/comparison-service.d.ts +339 -0
  68. package/dist/backend/services/comparison-service.d.ts.map +1 -0
  69. package/dist/backend/services/comparison-service.js +588 -0
  70. package/dist/backend/services/comparison-service.js.map +1 -0
  71. package/dist/backend/services/share-token-generator.d.ts +20 -0
  72. package/dist/backend/services/share-token-generator.d.ts.map +1 -0
  73. package/dist/backend/services/share-token-generator.js +23 -0
  74. package/dist/backend/services/share-token-generator.js.map +1 -0
  75. package/dist/manifest.d.ts +199 -0
  76. package/dist/manifest.d.ts.map +1 -0
  77. package/dist/manifest.js +179 -0
  78. package/dist/manifest.js.map +1 -0
  79. package/dist/migrations/20260501T185834_comparisons_init.d.ts +32 -0
  80. package/dist/migrations/20260501T185834_comparisons_init.d.ts.map +1 -0
  81. package/dist/migrations/20260501T185834_comparisons_init.js +102 -0
  82. package/dist/migrations/20260501T185834_comparisons_init.js.map +1 -0
  83. package/dist/migrations/20260830T163143_comparisons_organization_attribution.d.ts +84 -0
  84. package/dist/migrations/20260830T163143_comparisons_organization_attribution.d.ts.map +1 -0
  85. package/dist/migrations/20260830T163143_comparisons_organization_attribution.js +147 -0
  86. package/dist/migrations/20260830T163143_comparisons_organization_attribution.js.map +1 -0
  87. package/dist/migrations/index.d.ts +28 -0
  88. package/dist/migrations/index.d.ts.map +1 -0
  89. package/dist/migrations/index.js +31 -0
  90. package/dist/migrations/index.js.map +1 -0
  91. package/docs/comparisons.md +278 -0
  92. package/i18n/en.json +51 -0
  93. package/i18n/pl.json +51 -0
  94. package/package.json +102 -3
  95. package/tailwind.css +14 -0
@@ -0,0 +1,588 @@
1
+ import { z } from 'zod';
2
+ import { listingPriceMoney, ANONYMOUS_PRODUCT_AUDIENCE, isProductVisibleTo, } from '@endora-commerce/contracts';
3
+ import { withSystemScope } from '@endora-commerce/platform/tenancy';
4
+ import { SalesChannel } from '@endora-commerce/platform/kernel';
5
+ import { Comparison } from '../entities/comparison.entity.js';
6
+ import { ComparisonProduct } from '../entities/comparison-product.entity.js';
7
+ import { COMPARE_SETTING_CODES, DEFAULT_COMPARE_MAX_PRODUCTS, } from '../../manifest.js';
8
+ /**
9
+ * The most restrictive viewer there is — the public. The right default for a
10
+ * caller that has not been taught to resolve its reader, for the same reason
11
+ * {@link ANONYMOUS_PRODUCT_AUDIENCE} is: everything it may see, every other
12
+ * viewer may see too.
13
+ */
14
+ export const ANONYMOUS_COMPARISON_VIEWER = {
15
+ kind: 'buyer',
16
+ audience: ANONYMOUS_PRODUCT_AUDIENCE,
17
+ };
18
+ export class ComparisonService {
19
+ emFactory;
20
+ projection;
21
+ tokens;
22
+ catalogProducts;
23
+ channelMembership;
24
+ catalogGallery;
25
+ settingsService;
26
+ catalogAttributes;
27
+ listingPrices;
28
+ organizationDetails;
29
+ customerAccounts;
30
+ constructor(emFactory, projection, tokens,
31
+ /**
32
+ * `catalog`'s product read model. Not optional: a comparison is a list of
33
+ * products, so a service that cannot read one has nothing to answer with —
34
+ * where the settings and pricing arguments below have a defined fallback,
35
+ * this has none.
36
+ */
37
+ catalogProducts,
38
+ /**
39
+ * The sanctioned bridge accessor (Constitution XII), for the assortment
40
+ * gate `addProduct` owes (issue #259). Required for the same reason
41
+ * `catalogProducts` is: an optional one would spell "this rig wired no
42
+ * membership service" and "this channel publishes the product" as the same
43
+ * answer.
44
+ */
45
+ channelMembership,
46
+ /**
47
+ * Feature 075 / D-87 — `catalog`'s gallery read model, for the base image
48
+ * of each column. Required for the same reason `catalogProducts` is: the
49
+ * alternative to a wired port is not "a comparison without pictures", it is
50
+ * a service that cannot tell an operator who set no base image from a rig
51
+ * that wired no owner.
52
+ *
53
+ * `baseImageUrls`, not `list`: the read is a batch, it must tolerate an id
54
+ * whose product has since been removed, and the value a column renders is a
55
+ * **url**, which `catalog` resolves through the asset port it holds. Those
56
+ * three are exactly why the single join this replaced could not be swapped
57
+ * for the per-product port that already existed.
58
+ */
59
+ catalogGallery,
60
+ /**
61
+ * Optional settings service. When undefined, `compare.max_products`
62
+ * defaults to {@link DEFAULT_COMPARE_MAX_PRODUCTS} on every call —
63
+ * useful for foundation tests that pre-date Settings wiring.
64
+ */
65
+ settingsService,
66
+ /**
67
+ * Feature 061 — the catalog's composed attribute read model (Principle I:
68
+ * replaces the former direct `ProductAttribute` entity find).
69
+ */
70
+ catalogAttributes,
71
+ /**
72
+ * Issue #132 — the pricing engine, through the `pricingService` port. A
73
+ * comparison exists so a buyer can put prices side by side, so the figures
74
+ * in its columns have to be the ones a price list stands behind.
75
+ */
76
+ listingPrices,
77
+ /**
78
+ * `organizations`' read model, for the one field the pricing engine needs
79
+ * beyond the viewer's organisation id: the customer group it belongs to,
80
+ * which a group-targeted price list is selected by. Without it a buyer's
81
+ * comparison column and their own cart line would resolve against
82
+ * different lists and quote different figures for one product.
83
+ */
84
+ organizationDetails,
85
+ /**
86
+ * Feature 087 Group B / D-187 — `customer_accounts`' read model, for the
87
+ * one field the owner stamp needs: the organisation the owning account
88
+ * belongs to.
89
+ *
90
+ * The organisation is derived from the **row**, never from the ambient
91
+ * request context, and that is what makes one implementation serve both
92
+ * write paths: `addProduct` runs on a storefront request and
93
+ * `adoptAnonymousComparison` runs inside another module's login hook, where
94
+ * there is no comparison caller to read a context from.
95
+ *
96
+ * Optional in the constructor and required at the two call sites, in the
97
+ * shape {@link organizationDetails} already takes here. A rig that wired no
98
+ * port cannot stamp, and the honest answer to that is a refusal at the
99
+ * write rather than a row `comparisons_organization_attribution_chk` will
100
+ * reject with a message about a constraint the caller never heard of.
101
+ */
102
+ customerAccounts) {
103
+ this.emFactory = emFactory;
104
+ this.projection = projection;
105
+ this.tokens = tokens;
106
+ this.catalogProducts = catalogProducts;
107
+ this.channelMembership = channelMembership;
108
+ this.catalogGallery = catalogGallery;
109
+ this.settingsService = settingsService;
110
+ this.catalogAttributes = catalogAttributes;
111
+ this.listingPrices = listingPrices;
112
+ this.organizationDetails = organizationDetails;
113
+ this.customerAccounts = customerAccounts;
114
+ }
115
+ #requireListingPrices() {
116
+ if (!this.listingPrices) {
117
+ throw new Error('ComparisonService: the pricing port is not wired — a comparison cannot be priced.');
118
+ }
119
+ return this.listingPrices;
120
+ }
121
+ #requireOrganizationDetails() {
122
+ if (!this.organizationDetails) {
123
+ throw new Error('ComparisonService: the organization read port is not wired — a signed-in buyer cannot be priced.');
124
+ }
125
+ return this.organizationDetails;
126
+ }
127
+ /**
128
+ * The organisation that owns a comparison belonging to this account — feature
129
+ * 087 Group B, D-187.
130
+ *
131
+ * Total, and refuses rather than returning `null`: `customer_accounts.organization_id`
132
+ * is `NOT NULL` (D-178), so an account that resolves always has one, and an
133
+ * account that does not resolve is a caller naming a row that is not there.
134
+ * Either way there is no organisation to stamp, and a comparison written
135
+ * without one is a row hidden from the representative who serves its buyer —
136
+ * which is the whole defect this column exists to close. So the write stops
137
+ * here, where the message can name the account, instead of at the constraint.
138
+ */
139
+ async #organizationOf(customerAccountId) {
140
+ if (!this.customerAccounts) {
141
+ throw new Error('ComparisonService: the customer-account read port is not wired — an owned comparison cannot be attributed to an organisation.');
142
+ }
143
+ const account = await this.customerAccounts.findById(customerAccountId);
144
+ if (!account) {
145
+ throw new Error(`ComparisonService: customer account ${customerAccountId} does not resolve, so the comparison it would own has no organisation to carry.`);
146
+ }
147
+ return account.organizationId;
148
+ }
149
+ /**
150
+ * The owner identity a row can be written from — the caller's, plus the
151
+ * organisation a customer owner's account belongs to (D-187).
152
+ */
153
+ async #resolveOwner(owner) {
154
+ if (owner.kind === 'anonymous')
155
+ return owner;
156
+ return {
157
+ kind: 'customer',
158
+ customerAccountId: owner.customerAccountId,
159
+ organizationId: await this.#organizationOf(owner.customerAccountId),
160
+ };
161
+ }
162
+ /**
163
+ * The buying organisation the pricing engine should resolve against, for the
164
+ * viewer in front of this comparison — or `null` when there is none, which is
165
+ * the anonymous reader, the signed-in buyer whose account carries no
166
+ * Organization, and the buyer whose organisation has since been removed.
167
+ *
168
+ * The last of those three deserves saying: it answers with the channel price
169
+ * rather than throwing, and the view says `pricedFor: 'channel'`, so a figure
170
+ * is never labelled as the reader's own when it is not.
171
+ */
172
+ async #viewerOrganization(viewer) {
173
+ if (viewer.organizationId === null)
174
+ return null;
175
+ const record = await this.#requireOrganizationDetails().findById(viewer.organizationId);
176
+ if (!record)
177
+ return null;
178
+ return { id: record.id, customerGroupId: record.customerGroupId };
179
+ }
180
+ // ------------------------------------------------------------------
181
+ // Reads
182
+ // ------------------------------------------------------------------
183
+ async getForOwner(owner) {
184
+ const em = this.emFactory();
185
+ return em.findOne(Comparison, ownerWhere(owner));
186
+ }
187
+ /**
188
+ * Resolve a share token to its comparison, across customers.
189
+ *
190
+ * `Comparison` is `@CustomerScoped`, so the ambient filter confines this read
191
+ * to the caller's own rows — which is right for every other method here and
192
+ * is the one thing a share link has to cross. An **anonymous** recipient
193
+ * already crossed it by accident: the composition roots run anonymous
194
+ * requests under `systemTenantContext('actor:anonymous')`, so the filter is a
195
+ * no-op for them, while a *signed-in* recipient got a 404 on a link that
196
+ * worked when they were logged out. That is the shape a guard takes when a
197
+ * deliberate cross-tenant grant is left implicit.
198
+ *
199
+ * So it is explicit, through the one sanctioned crossing (feature 050,
200
+ * FR-005): the token is the authorization, exactly as it is on the public
201
+ * product-feed endpoint, and the widening covers this single lookup by a
202
+ * unique index and nothing after it. **What the recipient then sees is not
203
+ * widened at all** — `buildOwnerView` filters the products by their own
204
+ * audience and prices them for their own organisation.
205
+ */
206
+ async findByShareToken(token) {
207
+ const em = this.emFactory();
208
+ return withSystemScope('comparisons: share-token lookup — a share link is a cross-customer grant, and the token is the authorization', async () => em.findOne(Comparison, { shareToken: token }));
209
+ }
210
+ /**
211
+ * Anonymous → authenticated merge (research.md R-2 / spec FR-005).
212
+ * Called from the post-login hook with the cookie-bound
213
+ * `anonymousToken` the caller was carrying. Behaviour:
214
+ *
215
+ * - customer has no Comparison → reassign the anonymous one
216
+ * (set customer_account_id, clear anonymous_token).
217
+ * - customer already has a Comparison → discard the anonymous one
218
+ * (the customer's existing curated set wins, per R-2 — merging
219
+ * two sets is more surprising than honouring the persistent
220
+ * identity).
221
+ *
222
+ * No-op when neither side resolves.
223
+ *
224
+ * **This is the one place in the platform where a comparison becomes owned**
225
+ * — `Cart` has no counterpart, because its anonymous merge *completes* the
226
+ * anonymous cart rather than re-owning it. So it is the write feature 087's
227
+ * invariant would have been forgotten at: the row arrives here with a null
228
+ * `organization_id` that was correct while it was anonymous, and the
229
+ * organisation is set in the same unit of work as the account, from the same
230
+ * account. Nothing here is conditional on the column being null — an
231
+ * anonymous row's organisation is null by construction, and if it ever is
232
+ * not, the account it is being handed to is the truth.
233
+ */
234
+ async adoptAnonymousComparison(customerAccountId, anonymousToken) {
235
+ // command-coverage-ignore: transient customer working state (like carts) —
236
+ // self-service convenience data, not an audited domain-state mutation.
237
+ const em = this.emFactory();
238
+ const anon = await em.findOne(Comparison, { anonymousToken });
239
+ if (!anon)
240
+ return;
241
+ const existing = await em.findOne(Comparison, { customerAccountId });
242
+ if (existing) {
243
+ await em.removeAndFlush(anon);
244
+ return;
245
+ }
246
+ // D-187 — resolved **before** the row is touched, not between the account
247
+ // and the flush. The entity is managed, so a throw after the assignment
248
+ // would leave an owned, unattributed comparison in the unit of work for
249
+ // whatever flushes this request next.
250
+ const organizationId = await this.#organizationOf(customerAccountId);
251
+ anon.customerAccountId = customerAccountId;
252
+ anon.organizationId = organizationId;
253
+ anon.anonymousToken = null;
254
+ anon.updatedAt = new Date();
255
+ await em.flush();
256
+ }
257
+ /**
258
+ * True when the supplied owner identity matches the Comparison's
259
+ * stored owner. Used by the share-token endpoint to populate
260
+ * `meta.viewerIsOwner`.
261
+ */
262
+ isOwnedBy(comparison, owner) {
263
+ if (!owner)
264
+ return false;
265
+ if (owner.kind === 'customer') {
266
+ return comparison.customerAccountId === owner.customerAccountId;
267
+ }
268
+ return comparison.anonymousToken === owner.anonymousToken;
269
+ }
270
+ // ------------------------------------------------------------------
271
+ // Writes
272
+ // ------------------------------------------------------------------
273
+ async addProduct(owner, salesChannelId, productId,
274
+ /**
275
+ * Who is comparing (issue #227). Defaulted to the anonymous audience so a
276
+ * composition that has not been taught to resolve its caller refuses a
277
+ * restricted product rather than adding it — the fail-closed end. The
278
+ * public route passes the real one.
279
+ */
280
+ audience = ANONYMOUS_PRODUCT_AUDIENCE) {
281
+ // command-coverage-ignore: transient customer working state (like carts) —
282
+ // self-service convenience data, not an audited domain-state mutation.
283
+ const em = this.emFactory();
284
+ const product = await this.catalogProducts.findById(productId);
285
+ // Issue #227 — a comparison row discloses the product's name, SKU, price
286
+ // and every comparable attribute value, so a product this shopper may not
287
+ // see may not enter their comparison. Same answer as a product that does
288
+ // not exist: `ProductNotFoundError` is what the route turns into a 404.
289
+ if (!product || !isProductVisibleTo(product, audience)) {
290
+ throw new ProductNotFoundError(productId);
291
+ }
292
+ // Issue #259 — the channel axis, which the predicate above states in
293
+ // writing that it is not. `salesChannelId` is the channel the caller
294
+ // resolved for this request and stamps on the row below, so the comparison
295
+ // a buyer builds on one channel can only ever hold products that channel
296
+ // publishes. Same `ProductNotFoundError` as the line above: "sold on
297
+ // another channel" must be indistinguishable from "restricted" and from
298
+ // "does not exist", or the three together enumerate the assortment.
299
+ const publishedHere = await this.channelMembership.filterEntityIdsInChannel(salesChannelId, 'product', [product.id]);
300
+ if (publishedHere.length === 0) {
301
+ throw new ProductNotFoundError(productId);
302
+ }
303
+ let comparison = await em.findOne(Comparison, ownerWhere(owner));
304
+ if (!comparison) {
305
+ // D-187 — the only place a comparison is inserted, and the organisation
306
+ // goes in with the account. `ownerColumns` takes a *resolved* owner, so
307
+ // the customer branch has no shape in which the organisation could be
308
+ // omitted; the anonymous branch has none to carry (FR-011).
309
+ comparison = em.create(Comparison, {
310
+ shareToken: this.tokens.generate(),
311
+ ...ownerColumns(await this.#resolveOwner(owner)),
312
+ salesChannelId,
313
+ displayMode: 'all',
314
+ });
315
+ await em.persistAndFlush(comparison);
316
+ }
317
+ const existingRows = await em.find(ComparisonProduct, {
318
+ comparisonId: comparison.id,
319
+ });
320
+ // Idempotent on duplicate add — caller's earlier click is honoured.
321
+ if (existingRows.some((r) => r.productId === productId)) {
322
+ return comparison;
323
+ }
324
+ const max = await this.resolveMaxProducts(salesChannelId);
325
+ if (existingRows.length >= max) {
326
+ throw new ComparisonFullError(max);
327
+ }
328
+ const next = em.create(ComparisonProduct, {
329
+ comparisonId: comparison.id,
330
+ productId,
331
+ position: existingRows.length,
332
+ });
333
+ comparison.updatedAt = new Date();
334
+ await em.persistAndFlush(next);
335
+ return comparison;
336
+ }
337
+ async removeProduct(owner, productId) {
338
+ // command-coverage-ignore: transient customer working state (like carts) —
339
+ // self-service convenience data, not an audited domain-state mutation.
340
+ const em = this.emFactory();
341
+ const comparison = await em.findOne(Comparison, ownerWhere(owner));
342
+ if (!comparison)
343
+ throw new ComparisonNotFoundError();
344
+ const row = await em.findOne(ComparisonProduct, {
345
+ comparisonId: comparison.id,
346
+ productId,
347
+ });
348
+ if (!row)
349
+ throw new ProductNotInComparisonError(productId);
350
+ await em.removeAndFlush(row);
351
+ comparison.updatedAt = new Date();
352
+ await em.flush();
353
+ return comparison;
354
+ }
355
+ async setDisplayMode(owner, mode) {
356
+ // command-coverage-ignore: transient customer working state (like carts) —
357
+ // self-service convenience data, not an audited domain-state mutation.
358
+ const em = this.emFactory();
359
+ const comparison = await em.findOne(Comparison, ownerWhere(owner));
360
+ if (!comparison)
361
+ throw new ComparisonNotFoundError();
362
+ comparison.displayMode = mode;
363
+ comparison.updatedAt = new Date();
364
+ await em.flush();
365
+ return comparison;
366
+ }
367
+ async deleteForOwner(owner) {
368
+ // command-coverage-ignore: transient customer working state (like carts) —
369
+ // self-service convenience data, not an audited domain-state mutation.
370
+ const em = this.emFactory();
371
+ const comparison = await em.findOne(Comparison, ownerWhere(owner));
372
+ if (!comparison)
373
+ throw new ComparisonNotFoundError();
374
+ // FK on comparison_products cascades; we can simply remove the row.
375
+ await em.removeAndFlush(comparison);
376
+ }
377
+ // ------------------------------------------------------------------
378
+ // Read-side projection (consumed by GET /me + US2 share + US5 admin)
379
+ // ------------------------------------------------------------------
380
+ /**
381
+ * Build the wire-format `ComparisonOwnerView` for the given comparison
382
+ * in the viewer's sales-channel context. Reusable across the three
383
+ * consumers (owner / recipient / admin); the recipient + admin views
384
+ * pass `viewerIsOwner=false` and consume only the slice they need.
385
+ *
386
+ * **The viewer's identity decides what the viewer sees. Always.** A share
387
+ * token grants access to the *comparison*, never to anything in it, so it
388
+ * settles two questions the same way:
389
+ *
390
+ * - **the prices** — the owner sees their organisation's figures, a
391
+ * signed-in recipient from another organisation sees **theirs**, and an
392
+ * anonymous recipient sees the channel's. That is what makes a shared
393
+ * link trustworthy: the numbers are true for whoever is looking, and a
394
+ * recipient who acted on the sender's negotiated price would be acting on
395
+ * a price nobody will sell them at.
396
+ * - **the products** — a row restricted to the sender's organisation is not
397
+ * disclosed to a recipient outside it, while a recipient who *is* on the
398
+ * allow-list sees it. The owner was entitled to add it (`addProduct`
399
+ * enforces that, issue #227); a second reader has an entitlement of their
400
+ * own, and a product may be restricted after it was added.
401
+ *
402
+ * The two run in that order — filter, then price — because a price resolved
403
+ * for a row the reader may not see is work done to produce a figure that must
404
+ * not be sent.
405
+ */
406
+ async buildOwnerView(comparison, viewerSalesChannelId,
407
+ /**
408
+ * Who is looking. Defaulted to the anonymous buyer so a caller that has not
409
+ * been taught to resolve its reader shows the public set at the channel
410
+ * price — the answer every reader may see — rather than one buyer's.
411
+ */
412
+ viewer = ANONYMOUS_COMPARISON_VIEWER) {
413
+ const em = this.emFactory();
414
+ const bridgeRows = await em.find(ComparisonProduct, { comparisonId: comparison.id }, { orderBy: { position: 'asc', addedAt: 'asc' } });
415
+ const productIds = bridgeRows.map((r) => r.productId);
416
+ const stored = productIds.length > 0 ? await this.catalogProducts.findByIds(productIds) : [];
417
+ // Issue #227's predicate, over this reader. A product the reader may not
418
+ // see leaves the view entirely — it is not a `(removed product)` column,
419
+ // which would disclose that the sender holds something.
420
+ const products = viewer.kind === 'administrator'
421
+ ? stored
422
+ : stored.filter((product) => isProductVisibleTo(product, viewer.audience));
423
+ const hiddenProductCount = stored.length - products.length;
424
+ const productById = new Map(products.map((p) => [p.id, p]));
425
+ const channel = await em.findOne(SalesChannel, { id: viewerSalesChannelId });
426
+ const currency = channel?.defaultCurrency ?? 'PLN';
427
+ const isPublic = channel?.isPublic ?? true;
428
+ const viewerOrganization = viewer.kind === 'buyer' ? await this.#viewerOrganization(viewer.audience) : null;
429
+ // The pricing engine's answer per product (issue #132), resolved for the
430
+ // viewer. A channel that withholds prices is not asked for them, so the map
431
+ // stays empty and every column renders `null`.
432
+ const resolvedPrices = isPublic && products.length > 0
433
+ ? await this.#requireListingPrices().resolveListingPrices({
434
+ products,
435
+ context: {
436
+ salesChannel: { id: viewerSalesChannelId, defaultCurrency: currency },
437
+ organization: viewerOrganization,
438
+ },
439
+ })
440
+ : new Map();
441
+ const pricedFor = viewerOrganization !== null ? 'organization' : 'channel';
442
+ // Base-image lookup per product (label='base_image' only — spec FR-006
443
+ // names *base image* explicitly; no fallback to other labels, which is why
444
+ // `catalog`'s own `resolvePrimaryAssetUrls` — thumbnail → base_image →
445
+ // first — is not what this asks for).
446
+ //
447
+ // Feature 075 / D-87: this was one statement joining `gallery_item_labels`
448
+ // and `gallery_items` to `assets_library`'s `assets`, three cross-module
449
+ // reaches in a single join and invisible to every import check in the tree.
450
+ // The owner answers it now, from its own two tables plus the asset port it
451
+ // already holds.
452
+ const visibleProductIds = products.map((p) => p.id);
453
+ const baseImageByProduct = visibleProductIds.length > 0
454
+ ? await this.catalogGallery.baseImageUrls(visibleProductIds)
455
+ : new Map();
456
+ // Comparable attribute definitions (catalog adapter port — Constitution I).
457
+ // `listByFlag` orders by key ASC, matching the pre-061 entity query.
458
+ const attributeDefs = this.catalogAttributes
459
+ ? await this.catalogAttributes.listByFlag('isComparable')
460
+ : [];
461
+ // A row this reader may not see is dropped; a row whose product is *gone*
462
+ // still renders as `(removed product)`, which is the owner's own history
463
+ // and says nothing about anybody's entitlement.
464
+ const withheldIds = new Set(stored.filter((p) => !productById.has(p.id)).map((p) => p.id));
465
+ const visibleRows = bridgeRows.filter((row) => !withheldIds.has(row.productId));
466
+ const productSummaries = visibleRows.map((row) => {
467
+ const p = productById.get(row.productId);
468
+ if (!p) {
469
+ return {
470
+ id: row.productId,
471
+ sku: '',
472
+ name: { en: '(removed product)' },
473
+ slug: '',
474
+ type: 'simple',
475
+ primaryAssetUrl: null,
476
+ price: null,
477
+ available: false,
478
+ addedAt: row.addedAt.toISOString(),
479
+ };
480
+ }
481
+ const resolved = resolvedPrices.get(p.id);
482
+ const price = resolved === undefined ? null : listingPriceMoney(resolved);
483
+ return {
484
+ id: p.id,
485
+ sku: p.sku,
486
+ name: p.name,
487
+ slug: p.slug,
488
+ type: p.type,
489
+ primaryAssetUrl: baseImageByProduct.get(p.id) ?? null,
490
+ price,
491
+ available: p.deletedAt == null && p.archivedAt == null && p.status === 'active',
492
+ addedAt: row.addedAt.toISOString(),
493
+ };
494
+ });
495
+ const comparableAttributes = this.projection.projectRows(productSummaries.map((s) => {
496
+ const p = productById.get(s.id);
497
+ return { attributeValues: p?.attributeValues ?? {} };
498
+ }), attributeDefs);
499
+ const max = await this.resolveMaxProducts(viewerSalesChannelId);
500
+ return {
501
+ id: comparison.id,
502
+ shareToken: comparison.shareToken,
503
+ displayMode: comparison.displayMode,
504
+ maxProducts: max,
505
+ products: productSummaries,
506
+ comparableAttributes,
507
+ pricedFor,
508
+ hiddenProductCount,
509
+ createdAt: comparison.createdAt.toISOString(),
510
+ updatedAt: comparison.updatedAt.toISOString(),
511
+ };
512
+ }
513
+ // ------------------------------------------------------------------
514
+ // Helpers
515
+ // ------------------------------------------------------------------
516
+ /**
517
+ * Read `compare.max_products` from Settings; fall back to the manifest
518
+ * default on any error (Settings hiccup must not break the storefront).
519
+ */
520
+ async resolveMaxProducts(salesChannelId) {
521
+ if (!this.settingsService)
522
+ return DEFAULT_COMPARE_MAX_PRODUCTS;
523
+ try {
524
+ const value = await this.settingsService.get(COMPARE_SETTING_CODES.MAX_PRODUCTS, salesChannelId, z.number().int().positive());
525
+ // Defensive bounds — operator could set anything via CLI; the
526
+ // service still must not allow zero or negative caps.
527
+ return Math.max(1, Math.floor(value));
528
+ }
529
+ catch {
530
+ return DEFAULT_COMPARE_MAX_PRODUCTS;
531
+ }
532
+ }
533
+ }
534
+ function ownerWhere(owner) {
535
+ return owner.kind === 'customer'
536
+ ? { customerAccountId: owner.customerAccountId }
537
+ : { anonymousToken: owner.anonymousToken };
538
+ }
539
+ function ownerColumns(owner) {
540
+ return owner.kind === 'customer'
541
+ ? {
542
+ customerAccountId: owner.customerAccountId,
543
+ organizationId: owner.organizationId,
544
+ anonymousToken: null,
545
+ }
546
+ : {
547
+ customerAccountId: null,
548
+ // FR-011 — an anonymous comparison belongs to no organisation, and the
549
+ // constraint says nothing about it. Who it *should* belong to is R-6's
550
+ // open question and is not answered here.
551
+ organizationId: null,
552
+ anonymousToken: owner.anonymousToken,
553
+ };
554
+ }
555
+ // ---------------------------------------------------------------------------
556
+ // Typed exceptions — translated to HTTP status codes by routes.public.ts.
557
+ // ---------------------------------------------------------------------------
558
+ export class ComparisonFullError extends Error {
559
+ max;
560
+ constructor(max) {
561
+ super(`Comparison already holds the maximum of ${max} products.`);
562
+ this.max = max;
563
+ this.name = 'ComparisonFullError';
564
+ }
565
+ }
566
+ export class ComparisonNotFoundError extends Error {
567
+ constructor() {
568
+ super('Comparison not found.');
569
+ this.name = 'ComparisonNotFoundError';
570
+ }
571
+ }
572
+ export class ProductNotInComparisonError extends Error {
573
+ productId;
574
+ constructor(productId) {
575
+ super(`Product ${productId} is not in the comparison.`);
576
+ this.productId = productId;
577
+ this.name = 'ProductNotInComparisonError';
578
+ }
579
+ }
580
+ export class ProductNotFoundError extends Error {
581
+ productId;
582
+ constructor(productId) {
583
+ super(`Product ${productId} not found.`);
584
+ this.productId = productId;
585
+ this.name = 'ProductNotFoundError';
586
+ }
587
+ }
588
+ //# sourceMappingURL=comparison-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"comparison-service.js","sourceRoot":"","sources":["../../../src/backend/services/comparison-service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EACL,iBAAiB,EAIjB,0BAA0B,EAC1B,kBAAkB,GAUnB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AACpE,OAAO,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAGhE,OAAO,EAAE,UAAU,EAAE,MAAM,kCAAkC,CAAC;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,0CAA0C,CAAC;AAG7E,OAAO,EACL,qBAAqB,EACrB,4BAA4B,GAC7B,MAAM,mBAAmB,CAAC;AAkD3B;;;;;GAKG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAqB;IAC3D,IAAI,EAAE,OAAO;IACb,QAAQ,EAAE,0BAA0B;CACrC,CAAC;AAEF,MAAM,OAAO,iBAAiB;IAET;IACA;IACA;IAOA;IAQA;IAcA;IAMA;IAKA;IAMA;IAQA;IAkBA;IA3EnB,YACmB,SAA8B,EAC9B,UAAyC,EACzC,MAA2B;IAC5C;;;;;OAKG;IACc,eAAuC;IACxD;;;;;;OAMG;IACc,iBAA6C;IAC9D;;;;;;;;;;;;OAYG;IACc,cAAkC;IACnD;;;;OAIG;IACc,eAAkC;IACnD;;;OAGG;IACc,iBAA4C;IAC7D;;;;OAIG;IACc,aAAgC;IACjD;;;;;;OAMG;IACc,mBAA6C;IAC9D;;;;;;;;;;;;;;;;OAgBG;IACc,gBAA0C;QA1E1C,cAAS,GAAT,SAAS,CAAqB;QAC9B,eAAU,GAAV,UAAU,CAA+B;QACzC,WAAM,GAAN,MAAM,CAAqB;QAO3B,oBAAe,GAAf,eAAe,CAAwB;QAQvC,sBAAiB,GAAjB,iBAAiB,CAA4B;QAc7C,mBAAc,GAAd,cAAc,CAAoB;QAMlC,oBAAe,GAAf,eAAe,CAAmB;QAKlC,sBAAiB,GAAjB,iBAAiB,CAA2B;QAM5C,kBAAa,GAAb,aAAa,CAAmB;QAQhC,wBAAmB,GAAnB,mBAAmB,CAA0B;QAkB7C,qBAAgB,GAAhB,gBAAgB,CAA0B;IAC1D,CAAC;IAEJ,qBAAqB;QACnB,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,CAAC;YACxB,MAAM,IAAI,KAAK,CACb,mFAAmF,CACpF,CAAC;QACJ,CAAC;QACD,OAAO,IAAI,CAAC,aAAa,CAAC;IAC5B,CAAC;IAED,2BAA2B;QACzB,IAAI,CAAC,IAAI,CAAC,mBAAmB,EAAE,CAAC;YAC9B,MAAM,IAAI,KAAK,CACb,kGAAkG,CACnG,CAAC;QACJ,CAAC;QACD,OAAO,IAAI,CAAC,mBAAmB,CAAC;IAClC,CAAC;IAED;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,eAAe,CAAC,iBAAyB;QAC7C,IAAI,CAAC,IAAI,CAAC,gBAAgB,EAAE,CAAC;YAC3B,MAAM,IAAI,KAAK,CACb,+HAA+H,CAChI,CAAC;QACJ,CAAC;QACD,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,gBAAgB,CAAC,QAAQ,CAAC,iBAAiB,CAAC,CAAC;QACxE,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,MAAM,IAAI,KAAK,CACb,uCAAuC,iBAAiB,iFAAiF,CAC1I,CAAC;QACJ,CAAC;QACD,OAAO,OAAO,CAAC,cAAc,CAAC;IAChC,CAAC;IAED;;;OAGG;IACH,KAAK,CAAC,aAAa,CAAC,KAAsB;QACxC,IAAI,KAAK,CAAC,IAAI,KAAK,WAAW;YAAE,OAAO,KAAK,CAAC;QAC7C,OAAO;YACL,IAAI,EAAE,UAAU;YAChB,iBAAiB,EAAE,KAAK,CAAC,iBAAiB;YAC1C,cAAc,EAAE,MAAM,IAAI,CAAC,eAAe,CAAC,KAAK,CAAC,iBAAiB,CAAC;SACpE,CAAC;IACJ,CAAC;IAED;;;;;;;;;OASG;IACH,KAAK,CAAC,mBAAmB,CAAC,MAAuB;QAC/C,IAAI,MAAM,CAAC,cAAc,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;QAChD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,2BAA2B,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;QACxF,IAAI,CAAC,MAAM;YAAE,OAAO,IAAI,CAAC;QACzB,OAAO,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,eAAe,EAAE,MAAM,CAAC,eAAe,EAAE,CAAC;IACpE,CAAC;IAED,qEAAqE;IACrE,QAAQ;IACR,qEAAqE;IAErE,KAAK,CAAC,WAAW,CAAC,KAAsB;QACtC,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAC5B,OAAO,EAAE,CAAC,OAAO,CAAC,UAAU,EAAE,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC;IACnD,CAAC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,KAAK,CAAC,gBAAgB,CAAC,KAAa;QAClC,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAC5B,OAAO,eAAe,CACpB,8GAA8G,EAC9G,KAAK,IAAI,EAAE,CAAC,EAAE,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC,CAC1D,CAAC;IACJ,CAAC;IAED;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,KAAK,CAAC,wBAAwB,CAC5B,iBAAyB,EACzB,cAAsB;QAEtB,2EAA2E;QAC3E,uEAAuE;QACvE,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAC5B,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,cAAc,EAAE,CAAC,CAAC;QAC9D,IAAI,CAAC,IAAI;YAAE,OAAO;QAElB,MAAM,QAAQ,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,iBAAiB,EAAE,CAAC,CAAC;QACrE,IAAI,QAAQ,EAAE,CAAC;YACb,MAAM,EAAE,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC;YAC9B,OAAO;QACT,CAAC;QAED,0EAA0E;QAC1E,wEAAwE;QACxE,wEAAwE;QACxE,sCAAsC;QACtC,MAAM,cAAc,GAAG,MAAM,IAAI,CAAC,eAAe,CAAC,iBAAiB,CAAC,CAAC;QAErE,IAAI,CAAC,iBAAiB,GAAG,iBAAiB,CAAC;QAC3C,IAAI,CAAC,cAAc,GAAG,cAAc,CAAC;QACrC,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC;QAC3B,IAAI,CAAC,SAAS,GAAG,IAAI,IAAI,EAAE,CAAC;QAC5B,MAAM,EAAE,CAAC,KAAK,EAAE,CAAC;IACnB,CAAC;IAED;;;;OAIG;IACH,SAAS,CAAC,UAAsB,EAAE,KAA6B;QAC7D,IAAI,CAAC,KAAK;YAAE,OAAO,KAAK,CAAC;QACzB,IAAI,KAAK,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;YAC9B,OAAO,UAAU,CAAC,iBAAiB,KAAK,KAAK,CAAC,iBAAiB,CAAC;QAClE,CAAC;QACD,OAAO,UAAU,CAAC,cAAc,KAAK,KAAK,CAAC,cAAc,CAAC;IAC5D,CAAC;IAED,qEAAqE;IACrE,SAAS;IACT,qEAAqE;IAErE,KAAK,CAAC,UAAU,CACd,KAAsB,EACtB,cAAsB,EACtB,SAAiB;IACjB;;;;;OAKG;IACH,WAA4B,0BAA0B;QAEtD,2EAA2E;QAC3E,uEAAuE;QACvE,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAE5B,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,eAAe,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;QAC/D,yEAAyE;QACzE,0EAA0E;QAC1E,yEAAyE;QACzE,wEAAwE;QACxE,IAAI,CAAC,OAAO,IAAI,CAAC,kBAAkB,CAAC,OAAO,EAAE,QAAQ,CAAC,EAAE,CAAC;YACvD,MAAM,IAAI,oBAAoB,CAAC,SAAS,CAAC,CAAC;QAC5C,CAAC;QACD,qEAAqE;QACrE,qEAAqE;QACrE,2EAA2E;QAC3E,yEAAyE;QACzE,qEAAqE;QACrE,wEAAwE;QACxE,oEAAoE;QACpE,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,iBAAiB,CAAC,wBAAwB,CACzE,cAAc,EACd,SAAS,EACT,CAAC,OAAO,CAAC,EAAE,CAAC,CACb,CAAC;QACF,IAAI,aAAa,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC/B,MAAM,IAAI,oBAAoB,CAAC,SAAS,CAAC,CAAC;QAC5C,CAAC;QAED,IAAI,UAAU,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,UAAU,EAAE,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC;QACjE,IAAI,CAAC,UAAU,EAAE,CAAC;YAChB,wEAAwE;YACxE,wEAAwE;YACxE,sEAAsE;YACtE,4DAA4D;YAC5D,UAAU,GAAG,EAAE,CAAC,MAAM,CAAC,UAAU,EAAE;gBACjC,UAAU,EAAE,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE;gBAClC,GAAG,YAAY,CAAC,MAAM,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;gBAChD,cAAc;gBACd,WAAW,EAAE,KAA8B;aAC5C,CAAC,CAAC;YACH,MAAM,EAAE,CAAC,eAAe,CAAC,UAAU,CAAC,CAAC;QACvC,CAAC;QAED,MAAM,YAAY,GAAG,MAAM,EAAE,CAAC,IAAI,CAAC,iBAAiB,EAAE;YACpD,YAAY,EAAE,UAAU,CAAC,EAAE;SAC5B,CAAC,CAAC;QACH,oEAAoE;QACpE,IAAI,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,SAAS,CAAC,EAAE,CAAC;YACxD,OAAO,UAAU,CAAC;QACpB,CAAC;QAED,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,kBAAkB,CAAC,cAAc,CAAC,CAAC;QAC1D,IAAI,YAAY,CAAC,MAAM,IAAI,GAAG,EAAE,CAAC;YAC/B,MAAM,IAAI,mBAAmB,CAAC,GAAG,CAAC,CAAC;QACrC,CAAC;QAED,MAAM,IAAI,GAAG,EAAE,CAAC,MAAM,CAAC,iBAAiB,EAAE;YACxC,YAAY,EAAE,UAAU,CAAC,EAAE;YAC3B,SAAS;YACT,QAAQ,EAAE,YAAY,CAAC,MAAM;SAC9B,CAAC,CAAC;QACH,UAAU,CAAC,SAAS,GAAG,IAAI,IAAI,EAAE,CAAC;QAClC,MAAM,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;QAC/B,OAAO,UAAU,CAAC;IACpB,CAAC;IAED,KAAK,CAAC,aAAa,CAAC,KAAsB,EAAE,SAAiB;QAC3D,2EAA2E;QAC3E,uEAAuE;QACvE,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAC5B,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,UAAU,EAAE,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC;QACnE,IAAI,CAAC,UAAU;YAAE,MAAM,IAAI,uBAAuB,EAAE,CAAC;QACrD,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,iBAAiB,EAAE;YAC9C,YAAY,EAAE,UAAU,CAAC,EAAE;YAC3B,SAAS;SACV,CAAC,CAAC;QACH,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,2BAA2B,CAAC,SAAS,CAAC,CAAC;QAC3D,MAAM,EAAE,CAAC,cAAc,CAAC,GAAG,CAAC,CAAC;QAC7B,UAAU,CAAC,SAAS,GAAG,IAAI,IAAI,EAAE,CAAC;QAClC,MAAM,EAAE,CAAC,KAAK,EAAE,CAAC;QACjB,OAAO,UAAU,CAAC;IACpB,CAAC;IAED,KAAK,CAAC,cAAc,CAClB,KAAsB,EACtB,IAA2B;QAE3B,2EAA2E;QAC3E,uEAAuE;QACvE,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAC5B,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,UAAU,EAAE,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC;QACnE,IAAI,CAAC,UAAU;YAAE,MAAM,IAAI,uBAAuB,EAAE,CAAC;QACrD,UAAU,CAAC,WAAW,GAAG,IAAI,CAAC;QAC9B,UAAU,CAAC,SAAS,GAAG,IAAI,IAAI,EAAE,CAAC;QAClC,MAAM,EAAE,CAAC,KAAK,EAAE,CAAC;QACjB,OAAO,UAAU,CAAC;IACpB,CAAC;IAED,KAAK,CAAC,cAAc,CAAC,KAAsB;QACzC,2EAA2E;QAC3E,uEAAuE;QACvE,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAC5B,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,UAAU,EAAE,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC;QACnE,IAAI,CAAC,UAAU;YAAE,MAAM,IAAI,uBAAuB,EAAE,CAAC;QACrD,oEAAoE;QACpE,MAAM,EAAE,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC;IACtC,CAAC;IAED,qEAAqE;IACrE,qEAAqE;IACrE,qEAAqE;IAErE;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,KAAK,CAAC,cAAc,CAClB,UAAsB,EACtB,oBAA4B;IAC5B;;;;OAIG;IACH,SAA2B,2BAA2B;QAEtD,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAE5B,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,IAAI,CAC9B,iBAAiB,EACjB,EAAE,YAAY,EAAE,UAAU,CAAC,EAAE,EAAE,EAC/B,EAAE,OAAO,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,CACjD,CAAC;QAEF,MAAM,UAAU,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;QACtD,MAAM,MAAM,GACV,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,eAAe,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAChF,yEAAyE;QACzE,yEAAyE;QACzE,wDAAwD;QACxD,MAAM,QAAQ,GACZ,MAAM,CAAC,IAAI,KAAK,eAAe;YAC7B,CAAC,CAAC,MAAM;YACR,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,kBAAkB,CAAC,OAAO,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;QAC/E,MAAM,kBAAkB,GAAG,MAAM,CAAC,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC;QAC3D,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAE5D,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,EAAE,EAAE,oBAAoB,EAAE,CAAC,CAAC;QAC7E,MAAM,QAAQ,GAAG,OAAO,EAAE,eAAe,IAAI,KAAK,CAAC;QACnD,MAAM,QAAQ,GAAG,OAAO,EAAE,QAAQ,IAAI,IAAI,CAAC;QAE3C,MAAM,kBAAkB,GACtB,MAAM,CAAC,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,mBAAmB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QAEnF,yEAAyE;QACzE,4EAA4E;QAC5E,+CAA+C;QAC/C,MAAM,cAAc,GAClB,QAAQ,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC;YAC7B,CAAC,CAAC,MAAM,IAAI,CAAC,qBAAqB,EAAE,CAAC,oBAAoB,CAAC;gBACtD,QAAQ;gBACR,OAAO,EAAE;oBACP,YAAY,EAAE,EAAE,EAAE,EAAE,oBAAoB,EAAE,eAAe,EAAE,QAAQ,EAAE;oBACrE,YAAY,EAAE,kBAAkB;iBACjC;aACF,CAAC;YACJ,CAAC,CAAC,IAAI,GAAG,EAAwB,CAAC;QACtC,MAAM,SAAS,GACb,kBAAkB,KAAK,IAAI,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,SAAS,CAAC;QAE3D,uEAAuE;QACvE,2EAA2E;QAC3E,uEAAuE;QACvE,sCAAsC;QACtC,EAAE;QACF,2EAA2E;QAC3E,yEAAyE;QACzE,4EAA4E;QAC5E,2EAA2E;QAC3E,iBAAiB;QACjB,MAAM,iBAAiB,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QACpD,MAAM,kBAAkB,GACtB,iBAAiB,CAAC,MAAM,GAAG,CAAC;YAC1B,CAAC,CAAC,MAAM,IAAI,CAAC,cAAc,CAAC,aAAa,CAAC,iBAAiB,CAAC;YAC5D,CAAC,CAAC,IAAI,GAAG,EAAyB,CAAC;QAEvC,4EAA4E;QAC5E,qEAAqE;QACrE,MAAM,aAAa,GAAG,IAAI,CAAC,iBAAiB;YAC1C,CAAC,CAAC,MAAM,IAAI,CAAC,iBAAiB,CAAC,UAAU,CAAC,cAAc,CAAC;YACzD,CAAC,CAAC,EAAE,CAAC;QAEP,0EAA0E;QAC1E,yEAAyE;QACzE,gDAAgD;QAChD,MAAM,WAAW,GAAG,IAAI,GAAG,CACzB,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAC9D,CAAC;QACF,MAAM,WAAW,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC;QAChF,MAAM,gBAAgB,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE;YAC/C,MAAM,CAAC,GAAG,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;YACzC,IAAI,CAAC,CAAC,EAAE,CAAC;gBACP,OAAO;oBACL,EAAE,EAAE,GAAG,CAAC,SAAS;oBACjB,GAAG,EAAE,EAAE;oBACP,IAAI,EAAE,EAAE,EAAE,EAAE,mBAAmB,EAAE;oBACjC,IAAI,EAAE,EAAE;oBACR,IAAI,EAAE,QAAiB;oBACvB,eAAe,EAAE,IAAI;oBACrB,KAAK,EAAE,IAAI;oBACX,SAAS,EAAE,KAAK;oBAChB,OAAO,EAAE,GAAG,CAAC,OAAO,CAAC,WAAW,EAAE;iBACnC,CAAC;YACJ,CAAC;YACD,MAAM,QAAQ,GAAG,cAAc,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YAC1C,MAAM,KAAK,GAAG,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,iBAAiB,CAAC,QAAQ,CAAC,CAAC;YAC1E,OAAO;gBACL,EAAE,EAAE,CAAC,CAAC,EAAE;gBACR,GAAG,EAAE,CAAC,CAAC,GAAG;gBACV,IAAI,EAAE,CAAC,CAAC,IAAI;gBACZ,IAAI,EAAE,CAAC,CAAC,IAAI;gBACZ,IAAI,EAAE,CAAC,CAAC,IAAI;gBACZ,eAAe,EAAE,kBAAkB,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,IAAI;gBACrD,KAAK;gBACL,SAAS,EACP,CAAC,CAAC,SAAS,IAAI,IAAI,IAAI,CAAC,CAAC,UAAU,IAAI,IAAI,IAAI,CAAC,CAAC,MAAM,KAAK,QAAQ;gBACtE,OAAO,EAAE,GAAG,CAAC,OAAO,CAAC,WAAW,EAAE;aACnC,CAAC;QACJ,CAAC,CAAC,CAAC;QAEH,MAAM,oBAAoB,GAAG,IAAI,CAAC,UAAU,CAAC,WAAW,CACtD,gBAAgB,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;YACzB,MAAM,CAAC,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YAChC,OAAO,EAAE,eAAe,EAAE,CAAC,EAAE,eAAe,IAAI,EAAE,EAAE,CAAC;QACvD,CAAC,CAAC,EACF,aAAa,CACd,CAAC;QAEF,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,kBAAkB,CAAC,oBAAoB,CAAC,CAAC;QAEhE,OAAO;YACL,EAAE,EAAE,UAAU,CAAC,EAAE;YACjB,UAAU,EAAE,UAAU,CAAC,UAAU;YACjC,WAAW,EAAE,UAAU,CAAC,WAAW;YACnC,WAAW,EAAE,GAAG;YAChB,QAAQ,EAAE,gBAAgB;YAC1B,oBAAoB;YACpB,SAAS;YACT,kBAAkB;YAClB,SAAS,EAAE,UAAU,CAAC,SAAS,CAAC,WAAW,EAAE;YAC7C,SAAS,EAAE,UAAU,CAAC,SAAS,CAAC,WAAW,EAAE;SAC9C,CAAC;IACJ,CAAC;IAED,qEAAqE;IACrE,UAAU;IACV,qEAAqE;IAErE;;;OAGG;IACK,KAAK,CAAC,kBAAkB,CAAC,cAAsB;QACrD,IAAI,CAAC,IAAI,CAAC,eAAe;YAAE,OAAO,4BAA4B,CAAC;QAC/D,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,eAAe,CAAC,GAAG,CAC1C,qBAAqB,CAAC,YAAY,EAClC,cAAc,EACd,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAC5B,CAAC;YACF,8DAA8D;YAC9D,sDAAsD;YACtD,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;QACxC,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,4BAA4B,CAAC;QACtC,CAAC;IACH,CAAC;CAEF;AA4BD,SAAS,UAAU,CAAC,KAAsB;IACxC,OAAO,KAAK,CAAC,IAAI,KAAK,UAAU;QAC9B,CAAC,CAAC,EAAE,iBAAiB,EAAE,KAAK,CAAC,iBAAiB,EAAE;QAChD,CAAC,CAAC,EAAE,cAAc,EAAE,KAAK,CAAC,cAAc,EAAE,CAAC;AAC/C,CAAC;AAED,SAAS,YAAY,CAAC,KAA8B;IAKlD,OAAO,KAAK,CAAC,IAAI,KAAK,UAAU;QAC9B,CAAC,CAAC;YACE,iBAAiB,EAAE,KAAK,CAAC,iBAAiB;YAC1C,cAAc,EAAE,KAAK,CAAC,cAAc;YACpC,cAAc,EAAE,IAAI;SACrB;QACH,CAAC,CAAC;YACE,iBAAiB,EAAE,IAAI;YACvB,uEAAuE;YACvE,uEAAuE;YACvE,0CAA0C;YAC1C,cAAc,EAAE,IAAI;YACpB,cAAc,EAAE,KAAK,CAAC,cAAc;SACrC,CAAC;AACR,CAAC;AAED,8EAA8E;AAC9E,0EAA0E;AAC1E,8EAA8E;AAE9E,MAAM,OAAO,mBAAoB,SAAQ,KAAK;IAChB;IAA5B,YAA4B,GAAW;QACrC,KAAK,CAAC,2CAA2C,GAAG,YAAY,CAAC,CAAC;QADxC,QAAG,GAAH,GAAG,CAAQ;QAErC,IAAI,CAAC,IAAI,GAAG,qBAAqB,CAAC;IACpC,CAAC;CACF;AAED,MAAM,OAAO,uBAAwB,SAAQ,KAAK;IAChD;QACE,KAAK,CAAC,uBAAuB,CAAC,CAAC;QAC/B,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAC;IACxC,CAAC;CACF;AAED,MAAM,OAAO,2BAA4B,SAAQ,KAAK;IACxB;IAA5B,YAA4B,SAAiB;QAC3C,KAAK,CAAC,WAAW,SAAS,4BAA4B,CAAC,CAAC;QAD9B,cAAS,GAAT,SAAS,CAAQ;QAE3C,IAAI,CAAC,IAAI,GAAG,6BAA6B,CAAC;IAC5C,CAAC;CACF;AAED,MAAM,OAAO,oBAAqB,SAAQ,KAAK;IACjB;IAA5B,YAA4B,SAAiB;QAC3C,KAAK,CAAC,WAAW,SAAS,aAAa,CAAC,CAAC;QADf,cAAS,GAAT,SAAS,CAAQ;QAE3C,IAAI,CAAC,IAAI,GAAG,sBAAsB,CAAC;IACrC,CAAC;CACF"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Generates 22-char URL-safe base64 tokens (16 random bytes → base64url).
3
+ *
4
+ * Used for two distinct purposes inside the comparisons module:
5
+ * - `Comparison.shareToken` — the public, sharable identifier consumed
6
+ * by `GET /api/v1/comparisons/share/{token}` (US2).
7
+ * - `Comparison.anonymousToken` — the cookie-bound owner identity for
8
+ * anonymous customers (R-2).
9
+ *
10
+ * 128 bits of randomness from Node's stdlib (`crypto.randomBytes`) — no
11
+ * external dependency. The keyspace is large enough that enumeration is
12
+ * not a realistic threat (spec NFR-004).
13
+ *
14
+ * Constructor takes no DB; the class exists so the service layer can mock
15
+ * deterministic tokens in tests via dependency injection.
16
+ */
17
+ export declare class ShareTokenGenerator {
18
+ generate(): string;
19
+ }
20
+ //# sourceMappingURL=share-token-generator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"share-token-generator.d.ts","sourceRoot":"","sources":["../../../src/backend/services/share-token-generator.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,mBAAmB;IAC9B,QAAQ,IAAI,MAAM;CAGnB"}
@@ -0,0 +1,23 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ /**
3
+ * Generates 22-char URL-safe base64 tokens (16 random bytes → base64url).
4
+ *
5
+ * Used for two distinct purposes inside the comparisons module:
6
+ * - `Comparison.shareToken` — the public, sharable identifier consumed
7
+ * by `GET /api/v1/comparisons/share/{token}` (US2).
8
+ * - `Comparison.anonymousToken` — the cookie-bound owner identity for
9
+ * anonymous customers (R-2).
10
+ *
11
+ * 128 bits of randomness from Node's stdlib (`crypto.randomBytes`) — no
12
+ * external dependency. The keyspace is large enough that enumeration is
13
+ * not a realistic threat (spec NFR-004).
14
+ *
15
+ * Constructor takes no DB; the class exists so the service layer can mock
16
+ * deterministic tokens in tests via dependency injection.
17
+ */
18
+ export class ShareTokenGenerator {
19
+ generate() {
20
+ return randomBytes(16).toString('base64url');
21
+ }
22
+ }
23
+ //# sourceMappingURL=share-token-generator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"share-token-generator.js","sourceRoot":"","sources":["../../../src/backend/services/share-token-generator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE1C;;;;;;;;;;;;;;;GAeG;AACH,MAAM,OAAO,mBAAmB;IAC9B,QAAQ;QACN,OAAO,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;IAC/C,CAAC;CACF"}