@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,147 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ /**
3
+ * D-187 — `comparisons` gains its organisation, and a row that names a customer
4
+ * account carries one.
5
+ *
6
+ * Feature 087 Group B, class 1 of 4. `Comparison` is `@CustomerScoped`, and
7
+ * until this migration the `allowed-set` arm of `customerFilterCond` had no
8
+ * column to grant on: a sales representative assigned to the buyer's own
9
+ * organisation was shown **none** of their comparisons and told so, through the
10
+ * `ORGANIZATION_ATTRIBUTION_PENDING` notice the refusing arm records. The
11
+ * column is what turns that into an answer.
12
+ *
13
+ * ## The column and the read arrive together, on purpose
14
+ *
15
+ * `customerOrganizationColumn` asks the ORM's own metadata whether the filtered
16
+ * entity carries `organizationId`, **per query**, so the entity property in
17
+ * this same merge request is what switches the grant on — there is no third
18
+ * artefact, no flag and no staging. At the same instant the notice retires
19
+ * itself: it is keyed on the column's *absence*.
20
+ *
21
+ * That pairing is why this migration is not "column and backfill". From the
22
+ * moment the grant is live, a row inserted without an organisation is invisible
23
+ * to the representative who serves that organisation, on a screen that has just
24
+ * stopped explaining itself — and MikroORM applies no filter to `INSERT`
25
+ * (`r1-spike.md` §4, measured), so the filter cannot refuse it. The `CHECK`
26
+ * below is the only refusal an `INSERT` has.
27
+ *
28
+ * ## An implication, not an equivalence
29
+ *
30
+ * `customer_account_id is null or organization_id is not null` is FR-010 and
31
+ * FR-011 together and nothing wider: an **owned** row has an organisation, an
32
+ * **ownerless** row need not. Half of this table is ownerless by construction —
33
+ * `comparisons_owner_xor_chk` makes every row that is not owned an anonymous
34
+ * one — and who an anonymous comparison belongs to is R-6's open question. The
35
+ * equivalence would answer it in the schema, which D-187 declines to do.
36
+ *
37
+ * ## No foreign key
38
+ *
39
+ * Matching `Cart`, which carries none. D-187 withdraws `r1-spike.md` §8's
40
+ * `on delete set null` recommendation as contradicting FR-011: it produces a
41
+ * row that names an account and no organisation, which is precisely the state
42
+ * this migration makes unreachable, and under the constraint below it would
43
+ * abort an unrelated organisation delete with a message about a table the
44
+ * operator was not touching. If one is ever taken here it must be `restrict`,
45
+ * and it is not this migration's decision.
46
+ *
47
+ * ## Derive, then count, then refuse — never delete
48
+ *
49
+ * The derivation is the owning account's own organisation, and on **this**
50
+ * table it is total by proof rather than by hope.
51
+ * `comparisons_customer_account_fk` is `on delete cascade`, so a row naming an
52
+ * account that is gone cannot exist; `comparisons_owner_xor_chk` makes every
53
+ * other row anonymous; and D-178 made `customer_accounts.organization_id`
54
+ * `NOT NULL`. *Derivable ⟺ has an account* is a theorem here, and the refusal
55
+ * branch is dead code the day it is written.
56
+ *
57
+ * It is written anyway, and that is the point of writing it: a proof that stops
58
+ * being true stops **silently**. Drop the foreign key, relax the XOR, or
59
+ * re-open D-178, and the count below is the only thing that would notice. It
60
+ * raises with the count and up to twenty ids and deletes nothing (D-184), in
61
+ * the shape `20260825T141659_customer_accounts_organization_required.ts`
62
+ * established and `20260830T112911_carts_organization_attribution_check.ts`
63
+ * repeated.
64
+ *
65
+ * ## Nothing to declare in the manifest
66
+ *
67
+ * `comparisons` already declares `organizations`, whose own
68
+ * `20260424T205317_organizations_init.ts` creates `customer_accounts` — far
69
+ * below `BASELINE_THROUGH`, so the table this reads exists whatever this
70
+ * module's position in the dependency order. No foreign key is added, so
71
+ * `fk-dependency-drift` has nothing to say either, and a migration naming
72
+ * another module's table is outside `check:module-boundary`'s population by
73
+ * that check's own rule.
74
+ *
75
+ * The standing guard is
76
+ * `backend/test/integration/tenancy/customer-scoped-organization-completeness.test.ts`,
77
+ * which derives its population from the ORM's metadata rather than from a list
78
+ * — so this class is covered by gaining the column, with no edit to that file.
79
+ */
80
+ export class Migration20260830T163143ComparisonsOrganizationAttribution extends Migration {
81
+ async up() {
82
+ // 1. The column. Nullable, because an anonymous comparison legitimately has
83
+ // none (FR-011) and this table is half anonymous by construction.
84
+ this.addSql(`alter table "comparisons" add column "organization_id" uuid null;`);
85
+ // 2. Derive the organisation from the account that owns the comparison.
86
+ // Total on this table: the foreign key cascades, so an account named
87
+ // here exists, and its own organisation is NOT NULL since D-178.
88
+ this.addSql(`
89
+ update "comparisons" c
90
+ set "organization_id" = ca."organization_id"
91
+ from "customer_accounts" ca
92
+ where ca."id" = c."customer_account_id"
93
+ and c."organization_id" is null;
94
+ `);
95
+ // 3. Count what is left and refuse. Provably empty today — see the block
96
+ // comment. It runs because the proof rests on three facts none of which
97
+ // this migration owns, and a proof that stops being true stops without
98
+ // saying so.
99
+ this.addSql(`
100
+ do $$
101
+ declare
102
+ remaining bigint;
103
+ sample text;
104
+ begin
105
+ select count(*) into remaining
106
+ from "comparisons"
107
+ where "customer_account_id" is not null
108
+ and "organization_id" is null;
109
+ if remaining > 0 then
110
+ select string_agg(id::text, ', ') into sample from (
111
+ select "id" from "comparisons"
112
+ where "customer_account_id" is not null
113
+ and "organization_id" is null
114
+ order by "id" limit 20
115
+ ) s;
116
+ raise exception
117
+ 'D-187: % comparisons row(s) still name a customer account with organization_id IS NULL after derivation. First ids: %. Every customer account has an organization (D-178) and this table cascades on account delete, so these rows contradict the schema. Do not delete them - find out how they got here.',
118
+ remaining, sample;
119
+ end if;
120
+ end $$;
121
+ `);
122
+ // 4. The constraint.
123
+ this.addSql(`
124
+ alter table "comparisons"
125
+ add constraint "comparisons_organization_attribution_chk"
126
+ check ("customer_account_id" is null or "organization_id" is not null);
127
+ `);
128
+ // 5. The index the entity property declares. Partial, like the two owner
129
+ // indexes the init migration writes: the column is null on every
130
+ // anonymous row, and the reader is an organisation-scoped list.
131
+ this.addSql(`
132
+ create index "idx_comparisons_organization"
133
+ on "comparisons" ("organization_id")
134
+ where "organization_id" is not null;
135
+ `);
136
+ }
137
+ async down() {
138
+ // The constraint, the index and the column. The organisations step 2
139
+ // derived go with the column they were written into — there is nothing to
140
+ // preserve, because each of them was already implied by the account its row
141
+ // names.
142
+ this.addSql(`alter table "comparisons" drop constraint if exists "comparisons_organization_attribution_chk";`);
143
+ this.addSql(`drop index if exists "idx_comparisons_organization";`);
144
+ this.addSql(`alter table "comparisons" drop column if exists "organization_id";`);
145
+ }
146
+ }
147
+ //# sourceMappingURL=20260830T163143_comparisons_organization_attribution.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260830T163143_comparisons_organization_attribution.js","sourceRoot":"","sources":["../../src/migrations/20260830T163143_comparisons_organization_attribution.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6EG;AACH,MAAM,OAAO,0DAA2D,SAAQ,SAAS;IAC9E,KAAK,CAAC,EAAE;QACf,4EAA4E;QAC5E,qEAAqE;QACrE,IAAI,CAAC,MAAM,CAAC,mEAAmE,CAAC,CAAC;QAEjF,wEAAwE;QACxE,wEAAwE;QACxE,oEAAoE;QACpE,IAAI,CAAC,MAAM,CAAC;;;;;;KAMX,CAAC,CAAC;QAEH,yEAAyE;QACzE,2EAA2E;QAC3E,0EAA0E;QAC1E,gBAAgB;QAChB,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;KAsBX,CAAC,CAAC;QAEH,qBAAqB;QACrB,IAAI,CAAC,MAAM,CAAC;;;;KAIX,CAAC,CAAC;QAEH,yEAAyE;QACzE,oEAAoE;QACpE,mEAAmE;QACnE,IAAI,CAAC,MAAM,CAAC;;;;KAIX,CAAC,CAAC;IACL,CAAC;IAEQ,KAAK,CAAC,IAAI;QACjB,qEAAqE;QACrE,0EAA0E;QAC1E,4EAA4E;QAC5E,SAAS;QACT,IAAI,CAAC,MAAM,CACT,iGAAiG,CAClG,CAAC;QACF,IAAI,CAAC,MAAM,CAAC,sDAAsD,CAAC,CAAC;QACpE,IAAI,CAAC,MAAM,CAAC,oEAAoE,CAAC,CAAC;IACpF,CAAC;CACF"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The `./migrations` subpath — every migration class this module owns, as one
3
+ * ordered `migrations` array.
4
+ *
5
+ * The array is what the platform reads when this module is **installed**:
6
+ * `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
7
+ * the package outright when it is absent (D-168).
8
+ *
9
+ * Listed in ascending timestamp, which is the order of this module's own
10
+ * migrations and of nothing else (feature 081): a manifest `dependencies` array
11
+ * is the only thing ordering this block against another module's.
12
+ *
13
+ * The **named** exports stay beside the array, and the asymmetry with
14
+ * `./backend` — which publishes an array and no named class (D-168) — is
15
+ * deliberate. `db/migrations-registry.generated.ts` imports each class by name
16
+ * from this specifier, and a migration class name is contract in a way an entity
17
+ * class name is not: `mikro_orm_migrations` persists it, so it is a string every
18
+ * already-migrated database holds.
19
+ *
20
+ * A class that is in neither the array nor the barrel is a migration that does
21
+ * not run: `migration:pending` reports nothing pending and the first symptom is
22
+ * a query against a table nobody created.
23
+ */
24
+ import { Migration20260501T185834ComparisonsInit } from './20260501T185834_comparisons_init.js';
25
+ import { Migration20260830T163143ComparisonsOrganizationAttribution } from './20260830T163143_comparisons_organization_attribution.js';
26
+ export declare const migrations: (typeof Migration20260501T185834ComparisonsInit)[];
27
+ export { Migration20260501T185834ComparisonsInit, Migration20260830T163143ComparisonsOrganizationAttribution, };
28
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,uCAAuC,EAAE,MAAM,uCAAuC,CAAC;AAChG,OAAO,EAAE,0DAA0D,EAAE,MAAM,2DAA2D,CAAC;AAEvI,eAAO,MAAM,UAAU,oDAGtB,CAAC;AAEF,OAAO,EACL,uCAAuC,EACvC,0DAA0D,GAC3D,CAAC"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The `./migrations` subpath — every migration class this module owns, as one
3
+ * ordered `migrations` array.
4
+ *
5
+ * The array is what the platform reads when this module is **installed**:
6
+ * `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
7
+ * the package outright when it is absent (D-168).
8
+ *
9
+ * Listed in ascending timestamp, which is the order of this module's own
10
+ * migrations and of nothing else (feature 081): a manifest `dependencies` array
11
+ * is the only thing ordering this block against another module's.
12
+ *
13
+ * The **named** exports stay beside the array, and the asymmetry with
14
+ * `./backend` — which publishes an array and no named class (D-168) — is
15
+ * deliberate. `db/migrations-registry.generated.ts` imports each class by name
16
+ * from this specifier, and a migration class name is contract in a way an entity
17
+ * class name is not: `mikro_orm_migrations` persists it, so it is a string every
18
+ * already-migrated database holds.
19
+ *
20
+ * A class that is in neither the array nor the barrel is a migration that does
21
+ * not run: `migration:pending` reports nothing pending and the first symptom is
22
+ * a query against a table nobody created.
23
+ */
24
+ import { Migration20260501T185834ComparisonsInit } from './20260501T185834_comparisons_init.js';
25
+ import { Migration20260830T163143ComparisonsOrganizationAttribution } from './20260830T163143_comparisons_organization_attribution.js';
26
+ export const migrations = [
27
+ Migration20260501T185834ComparisonsInit,
28
+ Migration20260830T163143ComparisonsOrganizationAttribution,
29
+ ];
30
+ export { Migration20260501T185834ComparisonsInit, Migration20260830T163143ComparisonsOrganizationAttribution, };
31
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,uCAAuC,EAAE,MAAM,uCAAuC,CAAC;AAChG,OAAO,EAAE,0DAA0D,EAAE,MAAM,2DAA2D,CAAC;AAEvI,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,uCAAuC;IACvC,0DAA0D;CAC3D,CAAC;AAEF,OAAO,EACL,uCAAuC,EACvC,0DAA0D,GAC3D,CAAC"}
@@ -0,0 +1,278 @@
1
+ ---
2
+ title: Compare Products
3
+ description: 'Compare Products: customer-curated set with display modes, share link, and PDF export'
4
+ ---
5
+
6
+ # Compare Products
7
+
8
+ Customer-facing product-comparison module. Owns a first-class
9
+ `Comparison` resource (a customer-curated set of products with a chosen
10
+ display mode and a stable shareable link), an admin observability
11
+ surface, and the Node-side PDF export. The module deliberately holds no
12
+ catalog data; it consumes products and the `is_comparable` attribute
13
+ flag through the `CatalogQueryService` port.
14
+
15
+ This page is written for two audiences:
16
+
17
+ - **Buyers and platform administrators** who want to see *what* the
18
+ feature does (sections "What a buyer does", "What an administrator
19
+ sees", "What an administrator configures").
20
+ - **Developers** who need to extend or operate the module (every
21
+ section after "Public surface").
22
+
23
+ ## What a buyer does
24
+
25
+ ### 1. Pick products to compare
26
+
27
+ On any catalog list page or product detail page, click **Compare** on
28
+ the product card. The button toggles — click it again to remove the
29
+ product from the comparison set. A header pill (**Compare (N)**) shows
30
+ how many products you have queued.
31
+
32
+ The first time you mark a product, the storefront opens a session for
33
+ your comparison. You don't need to be signed in.
34
+
35
+ ### 2. Open the comparison page
36
+
37
+ Click the header pill, or navigate to `/compare`. You'll see a
38
+ side-by-side table:
39
+
40
+ - The header row always shows each product's **name**, **price** in
41
+ your sales-channel currency, and **base image**.
42
+ - The body shows every comparable attribute the catalog administrator
43
+ has flagged for comparison (weight, material, dimensions, etc. —
44
+ varies per category).
45
+
46
+ ### 3. Switch the view
47
+
48
+ The toolbar above the table has three buttons:
49
+
50
+ - **All attributes** — shows everything; rows that are identical
51
+ across products are highlighted with a calmer styling, rows that
52
+ differ stand out.
53
+ - **Common attributes only** — shows only the rows where every product
54
+ agrees, so you can confirm the baseline.
55
+ - **Differences only** — shows only the rows where at least one
56
+ product disagrees, so the decision-relevant signal stands out.
57
+
58
+ Switching is instantaneous — no reload.
59
+
60
+ ### 4. Share with a colleague
61
+
62
+ Click **Copy share link**. The storefront writes a URL of the form
63
+ `https://your-store/compare/share/<token>` to your clipboard. Anyone
64
+ who opens that link sees the same comparison — without needing an
65
+ account, and without being able to change anything. They can switch
66
+ modes themselves (locally), but they cannot remove products, delete the
67
+ comparison, or use *Add to cart*.
68
+
69
+ The link works as long as the comparison exists. If you delete the
70
+ comparison, the link stops working for everyone.
71
+
72
+ **What the recipient sees is decided by who the recipient is**, not by
73
+ who sent the link. A share link grants access to the comparison, never
74
+ to anything in it:
75
+
76
+ - **Prices** — a signed-in recipient sees the prices agreed with *their*
77
+ organization, and a recipient who is not signed in sees the store's
78
+ standard prices. Your negotiated prices are never disclosed by a link
79
+ you send, and the page says whose prices it is showing.
80
+ - **Products** — if your comparison holds a product that is restricted
81
+ to your organization, a recipient outside it does not see that column.
82
+ The page tells them that something is not available to them rather
83
+ than quietly showing a shorter table. A recipient whose own
84
+ organization is allowed to see the product sees it normally.
85
+
86
+ ### 5. Add a chosen product to the cart
87
+
88
+ Once you've decided, click **Add to cart** on the chosen product's
89
+ column. The cart picks up the product subject to the same rules as if
90
+ you'd added it from the product page (channel availability, stock).
91
+ The comparison itself stays intact.
92
+
93
+ ### 6. Export to PDF
94
+
95
+ Click **Export to PDF**. The storefront downloads a PDF named
96
+ `comparison-<token>.pdf` that mirrors what's on screen — same
97
+ products, same display mode, same column order. The PDF is generated
98
+ fresh each time; switching the display mode and re-exporting produces
99
+ a new file with the new mode.
100
+
101
+ PDFs of three or more products land in landscape orientation; one or
102
+ two products land in portrait.
103
+
104
+ ### 7. Tidy up
105
+
106
+ When you're done, click **Delete comparison**. The set is cleared and
107
+ any shared links you sent stop resolving.
108
+
109
+ ## Limits and edge cases
110
+
111
+ - **Maximum products per comparison** — defaults to **4**. Your
112
+ platform operator can raise or lower this per sales channel via
113
+ Settings (`compare.max_products`). The eleventh add — or whichever
114
+ one exceeds the configured cap — is refused with a message; the
115
+ existing comparison stays unchanged.
116
+ - **Removing a product** — drops a column. The remaining columns
117
+ recompute which rows count as common vs. different.
118
+ - **One product in the set** — the page renders, but suggests adding
119
+ at least one more product to draw a meaningful comparison.
120
+ - **Empty set** — the page invites you to add products from the
121
+ catalog.
122
+ - **Product disappears from the catalog** — if a product is unpublished
123
+ while it is in your comparison, the column stays but is marked
124
+ unavailable; *Add to cart* is disabled for that column.
125
+ - **Shared link in a different sales channel** — recipients see prices
126
+ in their own channel currency, and any product that isn't sold in
127
+ their channel still appears but is marked unavailable.
128
+ - **Shared link opened by a buyer from another organization** — the
129
+ columns are re-priced for that buyer's own agreements, and any product
130
+ they are not entitled to see is left out with a note saying so.
131
+ - **Multi-value attributes** — two products are treated as agreeing on
132
+ a multi-value attribute (e.g. a list of certifications) only when
133
+ their full sets of values match.
134
+ - **Missing values** — a product without a value for a row renders an
135
+ `—`, and the row counts as a difference.
136
+
137
+ ## What an administrator sees
138
+
139
+ Open **Comparisons** in the admin sidebar. The list shows every
140
+ comparison every customer has built, with the customer's email (or
141
+ *Anonymous* for unsigned-in builders), the sales channel, the active
142
+ display mode, the product count, and the creation time. Filter by
143
+ channel, owner kind, or time range; sort is newest-first by default.
144
+
145
+ Click a row to open the detail view. You'll see every product and
146
+ attribute row the customer put in the comparison — including products
147
+ restricted to organizations other than theirs — projected through that
148
+ customer's sales channel. **The prices here are the channel's standard
149
+ prices, not the customer's negotiated ones**: the comparison prices for
150
+ whoever is looking at it, and an administrator has no buying
151
+ organization to price against. The screen says so, so a figure quoted
152
+ back to a customer is never mistaken for the figure they were shown.
153
+ There are no edit, delete, or share buttons on the admin view by design
154
+ (it is read-only audit, not a tool to alter customer state).
155
+
156
+ If a customer deletes their comparison on the storefront, the row
157
+ disappears from the admin list on the next refresh.
158
+
159
+ ## What an administrator configures
160
+
161
+ | Where | What |
162
+ | --- | --- |
163
+ | **Catalog → Attributes**, the `Comparable` checkbox | Picks which attributes appear as rows on the comparison page. Independent of `Searchable` / `Filterable`. |
164
+ | **Settings → Compare**, the `compare.max_products` setting | The per-channel cap on how many products a single comparison can hold. |
165
+
166
+ ## Why this exists
167
+
168
+ In B2B procurement, a buyer rarely chooses alone — engineers, finance
169
+ folk, and managers all weigh in. Building a comparison once, sharing
170
+ the link, and exporting a PDF for archival is what the feature is for.
171
+ The admin view exists so the platform team can investigate support
172
+ tickets that quote a shared link.
173
+
174
+ ---
175
+
176
+ ## Public surface
177
+
178
+ | Verb + Path | Audience | Purpose |
179
+ | --- | --- | --- |
180
+ | `GET /api/v1/comparisons/me` | storefront (anonymous or customer) | Read the caller's Comparison; `204 No Content` when none |
181
+ | `POST /api/v1/comparisons/me/products` | storefront | Add a product (creates the Comparison + `compare_token` cookie on first call); refuses with `409 COMPARISON_FULL` over the channel-resolved cap |
182
+ | `DELETE /api/v1/comparisons/me/products/:productId` | storefront | Remove a product; `404 PRODUCT_NOT_IN_COMPARISON` if absent |
183
+ | `PATCH /api/v1/comparisons/me` | storefront | Update the persisted display mode (`all` / `common` / `differences`) |
184
+ | `DELETE /api/v1/comparisons/me` | storefront | Hard-delete the Comparison; the share token stops resolving for everyone |
185
+ | `GET /api/v1/comparisons/me/pdf` | storefront (owner only) | Owner PDF export; PDF mirrors the active mode and embeds product base images; `409 COMPARISON_EMPTY` for a zero-product comparison |
186
+ | `GET /api/v1/comparisons/share/:token` | public, no auth | Recipient view; same shape as the owner read minus `maxProducts`, plus `meta.viewerIsOwner`; priced and filtered for the *recipient* (`data.pricedFor`, `data.hiddenProductCount`); `404 COMPARISON_NOT_FOUND` for a deleted/never-existed token |
187
+ | `GET /api/v1/admin/comparisons` | admin (`comparisons:read`) | List every Comparison with filters (channel, owner kind, time range) and cursor pagination |
188
+ | `GET /api/v1/admin/comparisons/:id` | admin (`comparisons:read`) | Read-only detail; rendered through the comparison's recorded sales channel so the view matches what the customer reported |
189
+
190
+ There is **no** comparisons-side cart proxy. The storefront's
191
+ *Add to cart* button on the comparison page calls the existing
192
+ `POST /api/v1/cart/items` directly.
193
+
194
+ ## Settings
195
+
196
+ One knob under the `compare` group, registered by
197
+ `packages/modules/comparisons/src/manifest.ts`:
198
+
199
+ | Code | Type | Default | Purpose |
200
+ | --- | --- | --- | --- |
201
+ | `compare.max_products` | `number` | `4` | Upper bound on a single Comparison; the store-front refuses to add the (max+1)-th product per channel. Sane range `1..16`. |
202
+
203
+ The bound is read at request time inside
204
+ `ComparisonService.addProduct(...)`. Lowering the cap mid-session does
205
+ **not** retroactively trim existing comparisons; the next add is the
206
+ first request that picks up the new value.
207
+
208
+ ## Catalog flag
209
+
210
+ The module relies on a new `is_comparable` boolean column on
211
+ `product_attributes`. Catalog owns the column (migration `028` lives
212
+ under `catalog/migrations/`); the comparisons module reads it through
213
+ `CatalogQueryService.comparableAttributeKeys()`.
214
+
215
+ The Catalog admin UI's `<AttributesManager>` exposes a `Comparable`
216
+ checkbox alongside `Searchable` and `Filterable`; toggling it has no
217
+ side effect (no event emission, no reindex) — the next comparison-page
218
+ render picks up the change directly from Postgres.
219
+
220
+ ## Storage
221
+
222
+ Two tables, both owned by the comparisons module
223
+ (`027_comparisons_init.ts`):
224
+
225
+ - `comparisons` — primary key, 22-char base64url `share_token`
226
+ (`UNIQUE`), exclusive owner column (`customer_account_id` *or*
227
+ `anonymous_token`; DB CHECK enforces XOR), `sales_channel_id` (FK
228
+ with `ON DELETE RESTRICT`), `display_mode`, `created_at`,
229
+ `updated_at`. Partial indexes on each owner column; a separate index
230
+ on `(sales_channel_id, created_at desc)` supports the admin overview
231
+ channel filter.
232
+ - `comparison_products` — composite PK on
233
+ `(comparison_id, product_id)`, `position` (smallint), `added_at`.
234
+ Both FKs cascade. Reverse index on `product_id` for the admin list's
235
+ product-count aggregate.
236
+
237
+ No soft-delete. A deleted comparison must resolve to a
238
+ clear "no longer exists" state for shared-link recipients — a missing
239
+ row + `404 COMPARISON_NOT_FOUND` already satisfies that without a
240
+ soft-delete bit.
241
+
242
+ ## Anonymous → authenticated identity
243
+
244
+ Anonymous customers carry the `compare_token` cookie (HttpOnly,
245
+ SameSite=Lax, Path=/, Max-Age = 1 year). At sign-in the existing
246
+ `onLogin` hook in `organizationsModule` extracts the cookie and calls
247
+ `ComparisonService.adoptAnonymousComparison(...)`:
248
+
249
+ - Customer with no Comparison → the anonymous one is reassigned
250
+ (`customer_account_id` set, `anonymous_token` cleared).
251
+ - Customer with an existing Comparison → the anonymous one is hard-
252
+ deleted; the customer's curated set wins.
253
+
254
+ ## PDF export
255
+
256
+ Generated server-side with `pdfmake` — the module's only new runtime
257
+ dependency. Document definition is built declaratively from `ComparisonOwnerView`; product
258
+ base images are pre-fetched by `AssetByteFetcher` (per-request cache,
259
+ 1×1 transparent PNG fallback) and embedded as data URIs. Page
260
+ orientation is landscape when product count ≥ 3, portrait otherwise.
261
+
262
+ A strict deny-all `setUrlAccessPolicy` pins the contract that pdfmake
263
+ never opens its own network sockets — every image reaches the document
264
+ through the AssetByteFetcher.
265
+
266
+ ## Module isolation
267
+
268
+ | Direction | What we depend on | How |
269
+ | --- | --- | --- |
270
+ | Reads | Catalog products and `is_comparable` flag | `CatalogQueryService.comparableAttributeKeys()` + entity reads via the EM |
271
+ | Reads | `compare.max_products` | `SettingsService.get(...)` — same shape Search uses |
272
+ | Reads | Sales channel context (currency, public flag) | `getResolvedChannel()` — the channel the resolver middleware put on the request scope |
273
+ | Reads | Customer email for the admin list | `CustomerAccount` entity — read-only join |
274
+ | Writes | None outside its own two tables | — |
275
+
276
+ The comparisons module has zero compile-time dependencies on the
277
+ `carts` module. Removing the comparisons module leaves catalog,
278
+ settings, sales_channels, and carts working — no dangling references.
package/i18n/en.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "errors.COMPARISON_FULL": "Comparison Full.",
3
+ "errors.COMPARISON_NOT_FOUND": "Comparison Not Found.",
4
+ "errors.COMPARISON_EMPTY": "Comparison Empty.",
5
+ "errors.PDF_GENERATION_FAILED": "Pdf Generation Failed.",
6
+ "error.load": "Failed to load.",
7
+ "common.loading": "Loading…",
8
+ "list.page.title": "Comparisons",
9
+ "list.page.description": "Read-only audit of every comparison generated on the storefront.",
10
+ "list.filter.salesChannelId": "Sales channel id",
11
+ "list.filter.uuidOptional": "uuid (optional)",
12
+ "list.filter.owner": "Owner",
13
+ "list.filter.createdAfter": "Created after",
14
+ "list.filter.createdBefore": "Created before",
15
+ "list.empty": "No comparisons match the filters.",
16
+ "list.column.owner": "Owner",
17
+ "list.column.salesChannel": "Sales channel",
18
+ "list.column.mode": "Mode",
19
+ "list.column.products": "Products",
20
+ "list.column.created": "Created",
21
+ "list.column.open": "Open",
22
+ "list.loadMore": "Load more",
23
+ "detail.page.title": "Comparison detail",
24
+ "detail.page.description": "Read-only view — what the customer is currently seeing.",
25
+ "detail.pricesNote": "Prices here are the sales channel’s standard prices, not the customer’s negotiated ones — the customer sees their own. Products restricted to other organizations are listed here in full.",
26
+ "detail.backToList": "Back to list",
27
+ "detail.owner": "Owner",
28
+ "detail.salesChannel": "Sales channel",
29
+ "detail.displayMode": "Display mode",
30
+ "detail.shareToken": "Share token",
31
+ "detail.created": "Created",
32
+ "detail.updated": "updated",
33
+ "detail.attribute": "Attribute",
34
+ "detail.noComparableAttributes": "No comparable attributes defined for this catalog.",
35
+ "owner.any": "Any",
36
+ "owner.customer": "Customer",
37
+ "owner.anonymous": "Anonymous",
38
+ "owner.noEmail": "(no email)",
39
+ "owner.noEmailOnFile": "(no email on file)",
40
+ "owner.anonymousWithToken": "Anonymous ({token})",
41
+ "displayMode.full": "Full",
42
+ "displayMode.compact": "Compact",
43
+ "displayMode.differences_only": "Differences only",
44
+ "displayMode.differencesOnly": "Differences only",
45
+ "displayMode.all": "All attributes",
46
+ "displayMode.common": "Common attributes",
47
+ "displayMode.differences": "Differences",
48
+ "nav.comparisons.label": "Comparisons",
49
+ "actions.openComparisons.label": "Comparisons",
50
+ "actions.openComparisons.description": "Compare-feature audit"
51
+ }
package/i18n/pl.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "errors.COMPARISON_FULL": "Błąd: comparison full.",
3
+ "errors.COMPARISON_NOT_FOUND": "Błąd: comparison not found.",
4
+ "errors.COMPARISON_EMPTY": "Błąd: comparison empty.",
5
+ "errors.PDF_GENERATION_FAILED": "Błąd: pdf generation failed.",
6
+ "error.load": "Nie udało się wczytać.",
7
+ "common.loading": "Wczytywanie…",
8
+ "list.page.title": "Porównania",
9
+ "list.page.description": "Tylko-do-odczytu audyt wszystkich porównań wygenerowanych w sklepie.",
10
+ "list.filter.salesChannelId": "Id kanału sprzedaży",
11
+ "list.filter.uuidOptional": "uuid (opcjonalnie)",
12
+ "list.filter.owner": "Właściciel",
13
+ "list.filter.createdAfter": "Utworzone po",
14
+ "list.filter.createdBefore": "Utworzone przed",
15
+ "list.empty": "Żadne porównanie nie pasuje do filtrów.",
16
+ "list.column.owner": "Właściciel",
17
+ "list.column.salesChannel": "Kanał sprzedaży",
18
+ "list.column.mode": "Tryb",
19
+ "list.column.products": "Produkty",
20
+ "list.column.created": "Utworzono",
21
+ "list.column.open": "Otwórz",
22
+ "list.loadMore": "Wczytaj więcej",
23
+ "detail.page.title": "Szczegóły porównania",
24
+ "detail.page.description": "Widok tylko do odczytu — to, co aktualnie widzi klient.",
25
+ "detail.pricesNote": "Ceny w tym widoku to ceny standardowe kanału sprzedaży, a nie ceny wynegocjowane przez klienta — klient widzi swoje. Produkty ograniczone do innych organizacji są tutaj pokazane w całości.",
26
+ "detail.backToList": "Wróć do listy",
27
+ "detail.owner": "Właściciel",
28
+ "detail.salesChannel": "Kanał sprzedaży",
29
+ "detail.displayMode": "Tryb wyświetlania",
30
+ "detail.shareToken": "Token udostępniania",
31
+ "detail.created": "Utworzono",
32
+ "detail.updated": "zaktualizowano",
33
+ "detail.attribute": "Atrybut",
34
+ "detail.noComparableAttributes": "Brak porównywalnych atrybutów dla tego katalogu.",
35
+ "owner.any": "Dowolny",
36
+ "owner.customer": "Klient",
37
+ "owner.anonymous": "Anonimowy",
38
+ "owner.noEmail": "(brak e-maila)",
39
+ "owner.noEmailOnFile": "(brak e-maila w kartotece)",
40
+ "owner.anonymousWithToken": "Anonimowy ({token})",
41
+ "displayMode.full": "Pełny",
42
+ "displayMode.compact": "Kompaktowy",
43
+ "displayMode.differences_only": "Tylko różnice",
44
+ "displayMode.differencesOnly": "Tylko różnice",
45
+ "displayMode.all": "Wszystkie atrybuty",
46
+ "displayMode.common": "Wspólne atrybuty",
47
+ "displayMode.differences": "Różnice",
48
+ "nav.comparisons.label": "Porównania",
49
+ "actions.openComparisons.label": "Porównania",
50
+ "actions.openComparisons.description": "Audyt porównań"
51
+ }