@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.
- package/LICENSE +21 -0
- package/README.md +61 -2
- package/dist/admin/api/comparisons-client.d.ts +24 -0
- package/dist/admin/api/comparisons-client.d.ts.map +1 -0
- package/dist/admin/api/comparisons-client.js +15 -0
- package/dist/admin/api/comparisons-client.js.map +1 -0
- package/dist/admin/index.d.ts +37 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +49 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/ComparisonDetailPage.d.ts +19 -0
- package/dist/admin/pages/ComparisonDetailPage.d.ts.map +1 -0
- package/dist/admin/pages/ComparisonDetailPage.js +62 -0
- package/dist/admin/pages/ComparisonDetailPage.js.map +1 -0
- package/dist/admin/pages/ComparisonsListPage.d.ts +10 -0
- package/dist/admin/pages/ComparisonsListPage.d.ts.map +1 -0
- package/dist/admin/pages/ComparisonsListPage.js +71 -0
- package/dist/admin/pages/ComparisonsListPage.js.map +1 -0
- package/dist/backend/entities/comparison-product.entity.d.ts +22 -0
- package/dist/backend/entities/comparison-product.entity.d.ts.map +1 -0
- package/dist/backend/entities/comparison-product.entity.js +54 -0
- package/dist/backend/entities/comparison-product.entity.js.map +1 -0
- package/dist/backend/entities/comparison.entity.d.ts +61 -0
- package/dist/backend/entities/comparison.entity.d.ts.map +1 -0
- package/dist/backend/entities/comparison.entity.js +115 -0
- package/dist/backend/entities/comparison.entity.js.map +1 -0
- package/dist/backend/index.d.ts +61 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +117 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/request-actor.d.ts +22 -0
- package/dist/backend/request-actor.d.ts.map +1 -0
- package/dist/backend/request-actor.js +24 -0
- package/dist/backend/request-actor.js.map +1 -0
- package/dist/backend/routes.admin.d.ts +22 -0
- package/dist/backend/routes.admin.d.ts.map +1 -0
- package/dist/backend/routes.admin.js +41 -0
- package/dist/backend/routes.admin.js.map +1 -0
- package/dist/backend/routes.public.d.ts +33 -0
- package/dist/backend/routes.public.d.ts.map +1 -0
- package/dist/backend/routes.public.js +215 -0
- package/dist/backend/routes.public.js.map +1 -0
- package/dist/backend/routes.share.d.ts +27 -0
- package/dist/backend/routes.share.d.ts.map +1 -0
- package/dist/backend/routes.share.js +49 -0
- package/dist/backend/routes.share.js.map +1 -0
- package/dist/backend/services/anonymous-token-cookie.d.ts +23 -0
- package/dist/backend/services/anonymous-token-cookie.d.ts.map +1 -0
- package/dist/backend/services/anonymous-token-cookie.js +33 -0
- package/dist/backend/services/anonymous-token-cookie.js.map +1 -0
- package/dist/backend/services/asset-byte-fetcher.d.ts +37 -0
- package/dist/backend/services/asset-byte-fetcher.d.ts.map +1 -0
- package/dist/backend/services/asset-byte-fetcher.js +70 -0
- package/dist/backend/services/asset-byte-fetcher.js.map +1 -0
- package/dist/backend/services/comparable-attribute-projection.d.ts +57 -0
- package/dist/backend/services/comparable-attribute-projection.d.ts.map +1 -0
- package/dist/backend/services/comparable-attribute-projection.js +136 -0
- package/dist/backend/services/comparable-attribute-projection.js.map +1 -0
- package/dist/backend/services/comparison-admin.service.d.ts +31 -0
- package/dist/backend/services/comparison-admin.service.d.ts.map +1 -0
- package/dist/backend/services/comparison-admin.service.js +184 -0
- package/dist/backend/services/comparison-admin.service.js.map +1 -0
- package/dist/backend/services/comparison-pdf-renderer.d.ts +57 -0
- package/dist/backend/services/comparison-pdf-renderer.d.ts.map +1 -0
- package/dist/backend/services/comparison-pdf-renderer.js +211 -0
- package/dist/backend/services/comparison-pdf-renderer.js.map +1 -0
- package/dist/backend/services/comparison-service.d.ts +339 -0
- package/dist/backend/services/comparison-service.d.ts.map +1 -0
- package/dist/backend/services/comparison-service.js +588 -0
- package/dist/backend/services/comparison-service.js.map +1 -0
- package/dist/backend/services/share-token-generator.d.ts +20 -0
- package/dist/backend/services/share-token-generator.d.ts.map +1 -0
- package/dist/backend/services/share-token-generator.js +23 -0
- package/dist/backend/services/share-token-generator.js.map +1 -0
- package/dist/manifest.d.ts +199 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +179 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260501T185834_comparisons_init.d.ts +32 -0
- package/dist/migrations/20260501T185834_comparisons_init.d.ts.map +1 -0
- package/dist/migrations/20260501T185834_comparisons_init.js +102 -0
- package/dist/migrations/20260501T185834_comparisons_init.js.map +1 -0
- package/dist/migrations/20260830T163143_comparisons_organization_attribution.d.ts +84 -0
- package/dist/migrations/20260830T163143_comparisons_organization_attribution.d.ts.map +1 -0
- package/dist/migrations/20260830T163143_comparisons_organization_attribution.js +147 -0
- package/dist/migrations/20260830T163143_comparisons_organization_attribution.js.map +1 -0
- package/dist/migrations/index.d.ts +28 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +31 -0
- package/dist/migrations/index.js.map +1 -0
- package/docs/comparisons.md +278 -0
- package/i18n/en.json +51 -0
- package/i18n/pl.json +51 -0
- package/package.json +102 -3
- 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":"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"}
|
|
@@ -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"}
|