@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,136 @@
1
+ /**
2
+ * Pure projection from "products + comparable-attribute definitions" to
3
+ * the body rows of the comparison page (and PDF). Implements research.md
4
+ * R-6 algorithm.
5
+ *
6
+ * Inputs:
7
+ * - `products` — the column-ordered list of products in the comparison.
8
+ * Each product carries its `attributeValues` JSONB record (catalog
9
+ * stores values keyed by attribute key).
10
+ * - `attributeDefinitions` — every catalog attribute flagged
11
+ * `is_comparable=true`, in display order. Order is preserved in the
12
+ * output so the client / PDF both render rows top-to-bottom in the
13
+ * same sequence.
14
+ *
15
+ * Output: one `ComparisonAttributeRow` per definition, with one entry
16
+ * per product (in column order) and the row's classification (`common`
17
+ * vs `different`). Pre-formatted strings ride on the wire so the
18
+ * storefront table doesn't have to re-format multi-value attributes.
19
+ *
20
+ * Equality semantics (R-6):
21
+ * - `multiselect` — set equality (full overlap), order-insensitive.
22
+ * - `enum` / `string` — `===` after `String(v).trim()`.
23
+ * - `number` / `price` — `===` after `Number(v)` (no tolerance).
24
+ * - `boolean` — `===`.
25
+ * - `date` — `===` after ISO-8601 normalisation (parse → toISOString).
26
+ * - missing value on either side — classified as `different`; cell is `null`.
27
+ *
28
+ * Pure: no DB, no HTTP, no I/O. Unit-testable in isolation.
29
+ */
30
+ export class ComparableAttributeProjection {
31
+ projectRows(products, attributeDefinitions) {
32
+ return attributeDefinitions.map((def) => {
33
+ const rawValues = products.map((p) => p.attributeValues?.[def.key]);
34
+ const formatted = rawValues.map((v) => formatForDisplay(def.valueType, v));
35
+ const rowClass = classifyRow(def.valueType, rawValues);
36
+ return {
37
+ key: def.key,
38
+ label: def.label,
39
+ valueType: def.valueType,
40
+ values: formatted,
41
+ rowClass,
42
+ };
43
+ });
44
+ }
45
+ }
46
+ // ---------------------------------------------------------------------------
47
+ // Internal helpers (file-private — exported for testing only).
48
+ // ---------------------------------------------------------------------------
49
+ /** Returns true when v should be treated as "no value". */
50
+ function isMissing(v) {
51
+ if (v === null || v === undefined)
52
+ return true;
53
+ if (Array.isArray(v) && v.length === 0)
54
+ return true;
55
+ if (typeof v === 'string' && v.trim() === '')
56
+ return true;
57
+ return false;
58
+ }
59
+ /**
60
+ * Pre-format a raw `attributeValues[key]` for display. Returns null when
61
+ * the value is missing — the storefront / PDF render `—` for null cells.
62
+ */
63
+ export function formatForDisplay(valueType, v) {
64
+ if (isMissing(v))
65
+ return null;
66
+ switch (valueType) {
67
+ case 'multiselect':
68
+ // Sorted for stable visual presentation across re-renders.
69
+ return Array.isArray(v) ? [...v].map(String).sort().join(', ') : String(v);
70
+ case 'date':
71
+ // Best-effort ISO normalisation; if the catalog value is already a
72
+ // valid date string, this is idempotent.
73
+ return normaliseDate(v);
74
+ case 'boolean':
75
+ return v ? 'true' : 'false';
76
+ case 'number':
77
+ case 'price':
78
+ return String(Number(v));
79
+ case 'enum':
80
+ case 'string':
81
+ default:
82
+ return String(v).trim();
83
+ }
84
+ }
85
+ /**
86
+ * Returns 'common' iff every product carries an equivalent value for the
87
+ * attribute (per R-6 equality semantics) AND no product is missing. Any
88
+ * other case is 'different'.
89
+ */
90
+ export function classifyRow(valueType, rawValues) {
91
+ if (rawValues.length < 2) {
92
+ // 0 or 1 product: trivially "common" — no disagreement possible.
93
+ return 'common';
94
+ }
95
+ if (rawValues.some(isMissing))
96
+ return 'different';
97
+ const [head, ...rest] = rawValues;
98
+ for (const v of rest) {
99
+ if (!equalsForType(valueType, head, v))
100
+ return 'different';
101
+ }
102
+ return 'common';
103
+ }
104
+ function equalsForType(valueType, a, b) {
105
+ switch (valueType) {
106
+ case 'multiselect': {
107
+ const sa = Array.isArray(a) ? new Set(a.map(String)) : new Set([String(a)]);
108
+ const sb = Array.isArray(b) ? new Set(b.map(String)) : new Set([String(b)]);
109
+ if (sa.size !== sb.size)
110
+ return false;
111
+ for (const v of sa) {
112
+ if (!sb.has(v))
113
+ return false;
114
+ }
115
+ return true;
116
+ }
117
+ case 'number':
118
+ case 'price':
119
+ return Number(a) === Number(b);
120
+ case 'boolean':
121
+ return Boolean(a) === Boolean(b);
122
+ case 'date':
123
+ return normaliseDate(a) === normaliseDate(b);
124
+ case 'enum':
125
+ case 'string':
126
+ default:
127
+ return String(a).trim() === String(b).trim();
128
+ }
129
+ }
130
+ function normaliseDate(v) {
131
+ if (v instanceof Date)
132
+ return v.toISOString();
133
+ const parsed = new Date(String(v));
134
+ return Number.isNaN(parsed.getTime()) ? String(v) : parsed.toISOString();
135
+ }
136
+ //# sourceMappingURL=comparable-attribute-projection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"comparable-attribute-projection.js","sourceRoot":"","sources":["../../../src/backend/services/comparable-attribute-projection.ts"],"names":[],"mappings":"AAiBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,OAAO,6BAA6B;IACxC,WAAW,CACT,QAA6D,EAC7D,oBAAqD;QAErD,OAAO,oBAAoB,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE;YACtC,MAAM,SAAS,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,eAAe,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;YACpE,MAAM,SAAS,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CACpC,gBAAgB,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC,CACnC,CAAC;YACF,MAAM,QAAQ,GAAuB,WAAW,CAAC,GAAG,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;YAC3E,OAAO;gBACL,GAAG,EAAE,GAAG,CAAC,GAAG;gBACZ,KAAK,EAAE,GAAG,CAAC,KAAK;gBAChB,SAAS,EAAE,GAAG,CAAC,SAAS;gBACxB,MAAM,EAAE,SAAS;gBACjB,QAAQ;aACT,CAAC;QACJ,CAAC,CAAC,CAAC;IACL,CAAC;CACF;AAED,8EAA8E;AAC9E,+DAA+D;AAC/D,8EAA8E;AAE9E,2DAA2D;AAC3D,SAAS,SAAS,CAAC,CAAU;IAC3B,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IAC/C,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACpD,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC;IAC1D,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAC9B,SAA6B,EAC7B,CAAU;IAEV,IAAI,SAAS,CAAC,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IAC9B,QAAQ,SAAS,EAAE,CAAC;QAClB,KAAK,aAAa;YAChB,2DAA2D;YAC3D,OAAO,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QAC7E,KAAK,MAAM;YACT,mEAAmE;YACnE,yCAAyC;YACzC,OAAO,aAAa,CAAC,CAAC,CAAC,CAAC;QAC1B,KAAK,SAAS;YACZ,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;QAC9B,KAAK,QAAQ,CAAC;QACd,KAAK,OAAO;YACV,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;QAC3B,KAAK,MAAM,CAAC;QACZ,KAAK,QAAQ,CAAC;QACd;YACE,OAAO,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC5B,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,WAAW,CACzB,SAA6B,EAC7B,SAAoB;IAEpB,IAAI,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACzB,iEAAiE;QACjE,OAAO,QAAQ,CAAC;IAClB,CAAC;IACD,IAAI,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC;QAAE,OAAO,WAAW,CAAC;IAElD,MAAM,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,GAAG,SAAS,CAAC;IAClC,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,CAAC,aAAa,CAAC,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;YAAE,OAAO,WAAW,CAAC;IAC7D,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,SAAS,aAAa,CACpB,SAA6B,EAC7B,CAAU,EACV,CAAU;IAEV,QAAQ,SAAS,EAAE,CAAC;QAClB,KAAK,aAAa,CAAC,CAAC,CAAC;YACnB,MAAM,EAAE,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YAC5E,MAAM,EAAE,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YAC5E,IAAI,EAAE,CAAC,IAAI,KAAK,EAAE,CAAC,IAAI;gBAAE,OAAO,KAAK,CAAC;YACtC,KAAK,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC;gBACnB,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;oBAAE,OAAO,KAAK,CAAC;YAC/B,CAAC;YACD,OAAO,IAAI,CAAC;QACd,CAAC;QACD,KAAK,QAAQ,CAAC;QACd,KAAK,OAAO;YACV,OAAO,MAAM,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,CAAC,CAAC,CAAC;QACjC,KAAK,SAAS;YACZ,OAAO,OAAO,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;QACnC,KAAK,MAAM;YACT,OAAO,aAAa,CAAC,CAAC,CAAC,KAAK,aAAa,CAAC,CAAC,CAAC,CAAC;QAC/C,KAAK,MAAM,CAAC;QACZ,KAAK,QAAQ,CAAC;QACd;YACE,OAAO,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACjD,CAAC;AACH,CAAC;AAED,SAAS,aAAa,CAAC,CAAU;IAC/B,IAAI,CAAC,YAAY,IAAI;QAAE,OAAO,CAAC,CAAC,WAAW,EAAE,CAAC;IAC9C,MAAM,MAAM,GAAG,IAAI,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;IACnC,OAAO,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC;AAC3E,CAAC"}
@@ -0,0 +1,31 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import type { ComparisonAdminDetail, ComparisonAdminListItem, ComparisonAdminListQuery, CustomerAccountReadPort } from '@endora-commerce/contracts';
3
+ import { type ComparisonService } from './comparison-service.js';
4
+ /**
5
+ * Read-only admin service for the comparisons module — feature 007 /
6
+ * US5 / T063. Reuses the storefront `ComparisonService.buildOwnerView`
7
+ * for the per-comparison projection, but joins owner/channel metadata
8
+ * the storefront does not need.
9
+ *
10
+ * No mutation methods (spec FR-022). When operational tooling demands
11
+ * a force-delete in the future, that is a separate spec.
12
+ *
13
+ * The owner column shows a customer's e-mail address, which is
14
+ * `customer_accounts`' row and not this module's. Feature 075 (Phase C)
15
+ * replaced the `em.find(CustomerAccount, …)` that used to fetch it with that
16
+ * module's read port, so an admin screen cannot report identities out of a
17
+ * module the operator has switched off.
18
+ */
19
+ export declare class ComparisonAdminService {
20
+ private readonly emFactory;
21
+ private readonly comparisonService;
22
+ private readonly customerAccounts;
23
+ constructor(emFactory: () => EntityManager, comparisonService: ComparisonService, customerAccounts: CustomerAccountReadPort);
24
+ list(filters: ComparisonAdminListQuery): Promise<{
25
+ data: ComparisonAdminListItem[];
26
+ nextCursor: string | null;
27
+ limit: number;
28
+ }>;
29
+ detail(id: string): Promise<ComparisonAdminDetail>;
30
+ }
31
+ //# sourceMappingURL=comparison-admin.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"comparison-admin.service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/comparison-admin.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EACV,qBAAqB,EACrB,uBAAuB,EACvB,wBAAwB,EACxB,uBAAuB,EAExB,MAAM,4BAA4B,CAAC;AAGpC,OAAO,EAEL,KAAK,iBAAiB,EACvB,MAAM,yBAAyB,CAAC;AAEjC;;;;;;;;;;;;;;GAcG;AACH,qBAAa,sBAAsB;IAE/B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,iBAAiB;IAClC,OAAO,CAAC,QAAQ,CAAC,gBAAgB;gBAFhB,SAAS,EAAE,MAAM,aAAa,EAC9B,iBAAiB,EAAE,iBAAiB,EACpC,gBAAgB,EAAE,uBAAuB;IAOtD,IAAI,CACR,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC;QAAE,IAAI,EAAE,uBAAuB,EAAE,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IA2GnF,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC;CAgDzD"}
@@ -0,0 +1,184 @@
1
+ import { SalesChannel } from '@endora-commerce/platform/kernel';
2
+ import { Comparison } from '../entities/comparison.entity.js';
3
+ import { ComparisonNotFoundError, } from './comparison-service.js';
4
+ /**
5
+ * Read-only admin service for the comparisons module — feature 007 /
6
+ * US5 / T063. Reuses the storefront `ComparisonService.buildOwnerView`
7
+ * for the per-comparison projection, but joins owner/channel metadata
8
+ * the storefront does not need.
9
+ *
10
+ * No mutation methods (spec FR-022). When operational tooling demands
11
+ * a force-delete in the future, that is a separate spec.
12
+ *
13
+ * The owner column shows a customer's e-mail address, which is
14
+ * `customer_accounts`' row and not this module's. Feature 075 (Phase C)
15
+ * replaced the `em.find(CustomerAccount, …)` that used to fetch it with that
16
+ * module's read port, so an admin screen cannot report identities out of a
17
+ * module the operator has switched off.
18
+ */
19
+ export class ComparisonAdminService {
20
+ emFactory;
21
+ comparisonService;
22
+ customerAccounts;
23
+ constructor(emFactory, comparisonService, customerAccounts) {
24
+ this.emFactory = emFactory;
25
+ this.comparisonService = comparisonService;
26
+ this.customerAccounts = customerAccounts;
27
+ }
28
+ // ------------------------------------------------------------------
29
+ // List
30
+ // ------------------------------------------------------------------
31
+ async list(filters) {
32
+ const em = this.emFactory();
33
+ const limit = Math.max(1, Math.min(100, filters.limit ?? 25));
34
+ const where = {};
35
+ if (filters.salesChannelId)
36
+ where['salesChannelId'] = filters.salesChannelId;
37
+ if (filters.customerAccountId)
38
+ where['customerAccountId'] = filters.customerAccountId;
39
+ if (filters.ownerType === 'customer') {
40
+ where['customerAccountId'] = { $ne: null };
41
+ }
42
+ else if (filters.ownerType === 'anonymous') {
43
+ where['anonymousToken'] = { $ne: null };
44
+ }
45
+ if (filters.createdAfter) {
46
+ where['createdAt'] = { $gte: new Date(filters.createdAfter) };
47
+ }
48
+ if (filters.createdBefore) {
49
+ where['createdAt'] = {
50
+ ...where['createdAt'],
51
+ $lt: new Date(filters.createdBefore),
52
+ };
53
+ }
54
+ // Cursor is a base64-encoded createdAt timestamp; rows BEFORE the
55
+ // cursor are returned in createdAt-desc order.
56
+ if (filters.cursor) {
57
+ try {
58
+ const cursorDate = new Date(Buffer.from(filters.cursor, 'base64url').toString('utf8'));
59
+ if (!Number.isNaN(cursorDate.getTime())) {
60
+ where['createdAt'] = {
61
+ ...where['createdAt'],
62
+ $lt: cursorDate,
63
+ };
64
+ }
65
+ }
66
+ catch {
67
+ // Malformed cursor — silently start from the top, same as omitting it.
68
+ }
69
+ }
70
+ // Fetch limit+1 to detect whether more pages remain.
71
+ const rows = await em.find(Comparison, where, {
72
+ orderBy: { createdAt: 'desc' },
73
+ limit: limit + 1,
74
+ });
75
+ const hasMore = rows.length > limit;
76
+ const page = rows.slice(0, limit);
77
+ // Resolve sales-channel + customer-email metadata for the page.
78
+ const channelIds = unique(page.map((c) => c.salesChannelId));
79
+ const customerIds = unique(page.map((c) => c.customerAccountId).filter((id) => Boolean(id)));
80
+ const [channels, customers] = await Promise.all([
81
+ channelIds.length > 0
82
+ ? em.find(SalesChannel, { id: { $in: channelIds } })
83
+ : Promise.resolve([]),
84
+ customerIds.length > 0
85
+ ? this.customerAccounts.findByIds(customerIds)
86
+ : Promise.resolve([]),
87
+ ]);
88
+ const channelById = new Map(channels.map((c) => [c.id, c]));
89
+ const customerById = new Map(customers.map((c) => [c.id, c]));
90
+ // Bridge product counts for the page in one query.
91
+ const counts = await em.getConnection().execute(page.length > 0
92
+ ? `select comparison_id, count(*)::int as n
93
+ from comparison_products
94
+ where comparison_id in (${page.map(() => '?').join(',')})
95
+ group by comparison_id`
96
+ : `select null as comparison_id, 0 as n where false`, page.map((c) => c.id));
97
+ const countById = new Map(counts.map((r) => [r.comparison_id, Number(r.n)]));
98
+ const data = page.map((c) => ({
99
+ id: c.id,
100
+ shareToken: c.shareToken,
101
+ owner: ownerMetadata(c, customerById),
102
+ salesChannel: {
103
+ id: c.salesChannelId,
104
+ code: channelById.get(c.salesChannelId)?.code ?? '(unknown)',
105
+ },
106
+ displayMode: c.displayMode,
107
+ productCount: countById.get(c.id) ?? 0,
108
+ createdAt: c.createdAt.toISOString(),
109
+ updatedAt: c.updatedAt.toISOString(),
110
+ }));
111
+ const nextCursor = hasMore
112
+ ? Buffer.from(page[page.length - 1].createdAt.toISOString(), 'utf8').toString('base64url')
113
+ : null;
114
+ return { data, nextCursor, limit };
115
+ }
116
+ // ------------------------------------------------------------------
117
+ // Detail
118
+ // ------------------------------------------------------------------
119
+ async detail(id) {
120
+ const em = this.emFactory();
121
+ const comparison = await em.findOne(Comparison, { id });
122
+ if (!comparison)
123
+ throw new ComparisonNotFoundError();
124
+ // The detail view renders prices in the comparison's recorded
125
+ // channel (the creator's), not the admin's preferred channel.
126
+ //
127
+ // And in that channel's **public** prices, not the owner's negotiated
128
+ // ones: the viewer decides the figures, the viewer here is an
129
+ // administrator, and an administrator has no buying organisation to
130
+ // resolve against. Handing this screen the owner's price lists would be a
131
+ // disclosure decision of its own rather than a consequence of this one, so
132
+ // the screen says which prices it is showing instead of assuming
133
+ // (`detail.pricesNote`).
134
+ //
135
+ // The `administrator` viewer is also what keeps every *row* on this screen.
136
+ // A storefront reader is filtered by `isProductVisibleTo`, and an admin
137
+ // given any audience would be filtered too — dropping exactly the
138
+ // organisation-restricted products this audit view exists to show.
139
+ const view = await this.comparisonService.buildOwnerView(comparison, comparison.salesChannelId, { kind: 'administrator' });
140
+ const channel = await em.findOne(SalesChannel, { id: comparison.salesChannelId });
141
+ const customer = comparison.customerAccountId
142
+ ? await this.customerAccounts.findById(comparison.customerAccountId)
143
+ : null;
144
+ const customerById = new Map(customer ? [[customer.id, customer]] : []);
145
+ return {
146
+ id: view.id,
147
+ shareToken: view.shareToken,
148
+ owner: ownerMetadata(comparison, customerById),
149
+ salesChannel: {
150
+ id: comparison.salesChannelId,
151
+ code: channel?.code ?? '(unknown)',
152
+ },
153
+ displayMode: view.displayMode,
154
+ products: view.products,
155
+ comparableAttributes: view.comparableAttributes,
156
+ createdAt: view.createdAt,
157
+ updatedAt: view.updatedAt,
158
+ };
159
+ }
160
+ }
161
+ // ---------------------------------------------------------------------------
162
+ // helpers
163
+ // ---------------------------------------------------------------------------
164
+ function unique(arr) {
165
+ return Array.from(new Set(arr));
166
+ }
167
+ function ownerMetadata(c, customerById) {
168
+ if (c.customerAccountId) {
169
+ const customer = customerById.get(c.customerAccountId);
170
+ return {
171
+ kind: 'customer',
172
+ customerAccountId: c.customerAccountId,
173
+ email: customer?.email ?? null,
174
+ anonymousToken: null,
175
+ };
176
+ }
177
+ return {
178
+ kind: 'anonymous',
179
+ customerAccountId: null,
180
+ email: null,
181
+ anonymousToken: c.anonymousToken ?? null,
182
+ };
183
+ }
184
+ //# sourceMappingURL=comparison-admin.service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"comparison-admin.service.js","sourceRoot":"","sources":["../../../src/backend/services/comparison-admin.service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,57 @@
1
+ import type { TDocumentDefinitions } from 'pdfmake/interfaces.js';
2
+ import type { ComparisonOwnerView, ComparisonAttributeRow, ComparisonDisplayMode } from '@endora-commerce/contracts';
3
+ import { AssetByteFetcher } from './asset-byte-fetcher.js';
4
+ /**
5
+ * `ComparisonPdfRenderer` — feature 007 / US4 / T055.
6
+ *
7
+ * Builds a `pdfmake` document definition from a `ComparisonOwnerView`
8
+ * and streams the resulting PDF into an in-memory Buffer that the
9
+ * route handler ships back to the customer.
10
+ *
11
+ * Layout invariants (per `contracts/public-comparisons-pdf.md` and
12
+ * spec FR-017–FR-020):
13
+ *
14
+ * - Page 1 header: comparison title + active display-mode caption.
15
+ * - Always-on row (table header): one cell per product, each with
16
+ * base image (≤ 150×150), name, and price.
17
+ * - Body rows: rows filtered by the active display mode, with the
18
+ * `rowClass` styling propagated as a left-border colour.
19
+ * - Page orientation: landscape when product count ≥ 3, portrait
20
+ * otherwise (covers spec edge case "PDF on very wide
21
+ * comparisons"). The `pickOrientation` export is unit-tested in
22
+ * isolation.
23
+ * - Image fallback: an unavailable base image is replaced with a
24
+ * 1×1 transparent placeholder bytes constant rather than failing
25
+ * the export.
26
+ *
27
+ * The class wraps pdfmake behind a single seam so swapping the engine
28
+ * later (constitution amendment, etc.) touches one file.
29
+ */
30
+ export declare class ComparisonPdfRenderer {
31
+ /**
32
+ * pdfmake's singleton is process-global, so we just register fonts on
33
+ * it once the first time a renderer is constructed and reuse the
34
+ * registered set on every subsequent render.
35
+ */
36
+ private static fontsRegistered;
37
+ constructor();
38
+ /**
39
+ * Render the supplied view to a PDF Buffer. Uses a fresh
40
+ * {@link AssetByteFetcher} per call so per-request caches do not
41
+ * leak across exports.
42
+ */
43
+ render(view: ComparisonOwnerView, opts?: {
44
+ fetcher?: AssetByteFetcher;
45
+ }): Promise<Buffer>;
46
+ /**
47
+ * Public for tests: returns the assembled document definition object
48
+ * (no PDF stream produced). Tests assert the right column count, row
49
+ * count, and orientation without binding to pdfmake's byte output.
50
+ */
51
+ buildDocumentDefinition(view: ComparisonOwnerView, fetcher: AssetByteFetcher): Promise<TDocumentDefinitions>;
52
+ private bufferiseDocument;
53
+ }
54
+ /** Spec edge case "PDF on very wide comparisons": ≥3 products → landscape. */
55
+ export declare function pickOrientation(productCount: number): 'landscape' | 'portrait';
56
+ export declare function filterRowsByMode(rows: ComparisonAttributeRow[], mode: ComparisonDisplayMode): ComparisonAttributeRow[];
57
+ //# sourceMappingURL=comparison-pdf-renderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"comparison-pdf-renderer.d.ts","sourceRoot":"","sources":["../../../src/backend/services/comparison-pdf-renderer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,oBAAoB,EAMrB,MAAM,uBAAuB,CAAC;AAkB/B,OAAO,KAAK,EACV,mBAAmB,EACnB,sBAAsB,EACtB,qBAAqB,EAEtB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAE3D;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,qBAAa,qBAAqB;IAChC;;;;OAIG;IACH,OAAO,CAAC,MAAM,CAAC,eAAe,CAAS;;IAevC;;;;OAIG;IACG,MAAM,CACV,IAAI,EAAE,mBAAmB,EACzB,IAAI,GAAE;QAAE,OAAO,CAAC,EAAE,gBAAgB,CAAA;KAAO,GACxC,OAAO,CAAC,MAAM,CAAC;IAMlB;;;;OAIG;IACG,uBAAuB,CAC3B,IAAI,EAAE,mBAAmB,EACzB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,oBAAoB,CAAC;YA6DlB,iBAAiB;CAGhC;AAYD,8EAA8E;AAC9E,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,WAAW,GAAG,UAAU,CAE9E;AAED,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,sBAAsB,EAAE,EAC9B,IAAI,EAAE,qBAAqB,GAC1B,sBAAsB,EAAE,CAI1B"}
@@ -0,0 +1,211 @@
1
+ import path from 'node:path';
2
+ import { createRequire } from 'node:module';
3
+ const requireFromHere = createRequire(import.meta.url);
4
+ const pdfMake = requireFromHere('pdfmake');
5
+ import { AssetByteFetcher } from './asset-byte-fetcher.js';
6
+ /**
7
+ * `ComparisonPdfRenderer` — feature 007 / US4 / T055.
8
+ *
9
+ * Builds a `pdfmake` document definition from a `ComparisonOwnerView`
10
+ * and streams the resulting PDF into an in-memory Buffer that the
11
+ * route handler ships back to the customer.
12
+ *
13
+ * Layout invariants (per `contracts/public-comparisons-pdf.md` and
14
+ * spec FR-017–FR-020):
15
+ *
16
+ * - Page 1 header: comparison title + active display-mode caption.
17
+ * - Always-on row (table header): one cell per product, each with
18
+ * base image (≤ 150×150), name, and price.
19
+ * - Body rows: rows filtered by the active display mode, with the
20
+ * `rowClass` styling propagated as a left-border colour.
21
+ * - Page orientation: landscape when product count ≥ 3, portrait
22
+ * otherwise (covers spec edge case "PDF on very wide
23
+ * comparisons"). The `pickOrientation` export is unit-tested in
24
+ * isolation.
25
+ * - Image fallback: an unavailable base image is replaced with a
26
+ * 1×1 transparent placeholder bytes constant rather than failing
27
+ * the export.
28
+ *
29
+ * The class wraps pdfmake behind a single seam so swapping the engine
30
+ * later (constitution amendment, etc.) touches one file.
31
+ */
32
+ export class ComparisonPdfRenderer {
33
+ /**
34
+ * pdfmake's singleton is process-global, so we just register fonts on
35
+ * it once the first time a renderer is constructed and reuse the
36
+ * registered set on every subsequent render.
37
+ */
38
+ static fontsRegistered = false;
39
+ constructor() {
40
+ if (!ComparisonPdfRenderer.fontsRegistered) {
41
+ pdfMake.setFonts(buildFontDictionary());
42
+ // Refuse every URL pdfmake might want to fetch directly. Image
43
+ // bytes are pre-fetched by `AssetByteFetcher` and embedded as
44
+ // data URIs, so there is never a legitimate need for pdfmake to
45
+ // open a network socket — a strict deny-all also silences the
46
+ // package's "No URL access policy defined" startup warning.
47
+ pdfMake.setUrlAccessPolicy(() => false);
48
+ ComparisonPdfRenderer.fontsRegistered = true;
49
+ }
50
+ }
51
+ /**
52
+ * Render the supplied view to a PDF Buffer. Uses a fresh
53
+ * {@link AssetByteFetcher} per call so per-request caches do not
54
+ * leak across exports.
55
+ */
56
+ async render(view, opts = {}) {
57
+ const fetcher = opts.fetcher ?? new AssetByteFetcher();
58
+ const def = await this.buildDocumentDefinition(view, fetcher);
59
+ return this.bufferiseDocument(def);
60
+ }
61
+ /**
62
+ * Public for tests: returns the assembled document definition object
63
+ * (no PDF stream produced). Tests assert the right column count, row
64
+ * count, and orientation without binding to pdfmake's byte output.
65
+ */
66
+ async buildDocumentDefinition(view, fetcher) {
67
+ const visibleRows = filterRowsByMode(view.comparableAttributes, view.displayMode);
68
+ const productImages = await Promise.all(view.products.map(async (p) => {
69
+ if (!p.primaryAssetUrl)
70
+ return null;
71
+ const bytes = await fetcher.fetch(p.primaryAssetUrl);
72
+ return imageDataUri(bytes);
73
+ }));
74
+ const headerRow = buildHeaderRow(view.products, productImages);
75
+ const bodyRows = visibleRows.map((row) => buildBodyRow(row));
76
+ const tableBody = [headerRow, ...bodyRows];
77
+ const columnWidths = ['*', ...view.products.map(() => '*')];
78
+ const orientation = pickOrientation(view.products.length);
79
+ const info = {
80
+ title: 'Product comparison',
81
+ creator: 'B2B Platform',
82
+ producer: 'pdfmake',
83
+ };
84
+ return {
85
+ info,
86
+ pageSize: 'A4',
87
+ pageOrientation: orientation,
88
+ pageMargins: [32, 40, 32, 40],
89
+ defaultStyle: { fontSize: 10, font: 'Roboto' },
90
+ content: [
91
+ {
92
+ text: 'Product comparison',
93
+ style: 'h1',
94
+ },
95
+ {
96
+ text: `Mode: ${MODE_LABELS[view.displayMode]}`,
97
+ style: 'caption',
98
+ margin: [0, 0, 0, 12],
99
+ },
100
+ {
101
+ table: {
102
+ headerRows: 1,
103
+ widths: columnWidths,
104
+ body: tableBody,
105
+ },
106
+ layout: COMPARE_TABLE_LAYOUT,
107
+ },
108
+ ],
109
+ styles: {
110
+ h1: { fontSize: 16, bold: true, margin: [0, 0, 0, 4] },
111
+ caption: { fontSize: 10, italics: true, color: '#555' },
112
+ productName: { fontSize: 11, bold: true },
113
+ productPrice: { fontSize: 10, color: '#333' },
114
+ attrLabel: { fontSize: 10, bold: true },
115
+ attrValue: { fontSize: 10 },
116
+ common: { color: '#155724' },
117
+ different: { color: '#721c24' },
118
+ },
119
+ };
120
+ }
121
+ async bufferiseDocument(def) {
122
+ return pdfMake.createPdf(def).getBuffer();
123
+ }
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // Pure helpers — exported for unit testing.
127
+ // ---------------------------------------------------------------------------
128
+ const MODE_LABELS = {
129
+ all: 'All attributes',
130
+ common: 'Common attributes only',
131
+ differences: 'Differences only',
132
+ };
133
+ /** Spec edge case "PDF on very wide comparisons": ≥3 products → landscape. */
134
+ export function pickOrientation(productCount) {
135
+ return productCount >= 3 ? 'landscape' : 'portrait';
136
+ }
137
+ export function filterRowsByMode(rows, mode) {
138
+ if (mode === 'all')
139
+ return rows;
140
+ if (mode === 'common')
141
+ return rows.filter((r) => r.rowClass === 'common');
142
+ return rows.filter((r) => r.rowClass === 'different');
143
+ }
144
+ function buildHeaderRow(products, images) {
145
+ const head = { text: '', style: 'attrLabel' };
146
+ const cells = products.map((p, idx) => {
147
+ const stack = [];
148
+ const dataUri = images[idx];
149
+ if (dataUri) {
150
+ stack.push({ image: dataUri, fit: [120, 120], alignment: 'center' });
151
+ }
152
+ stack.push({ text: localised(p.name), style: 'productName', alignment: 'center' });
153
+ stack.push({
154
+ text: p.price ? `${p.price.amount} ${p.price.currency}` : '—',
155
+ style: 'productPrice',
156
+ alignment: 'center',
157
+ });
158
+ return { stack };
159
+ });
160
+ return [head, ...cells];
161
+ }
162
+ function buildBodyRow(row) {
163
+ const labelCell = {
164
+ text: localised(row.label),
165
+ style: ['attrLabel', row.rowClass],
166
+ };
167
+ const cells = row.values.map((v) => ({
168
+ text: v ?? '—',
169
+ style: ['attrValue', row.rowClass],
170
+ }));
171
+ return [labelCell, ...cells];
172
+ }
173
+ function localised(value) {
174
+ return value['en-US'] ?? value['en'] ?? Object.values(value)[0] ?? '';
175
+ }
176
+ function imageDataUri(bytes) {
177
+ // pdfmake recognises PNG and JPEG bytes by header. We pass it as a data
178
+ // URI so it is treated as inline image regardless of source URL.
179
+ return `data:image/png;base64,${bytes.toString('base64')}`;
180
+ }
181
+ /**
182
+ * Resolves the bundled Roboto font set that ships with `pdfmake` so we
183
+ * do not depend on a separate font-asset pipeline for the renderer.
184
+ */
185
+ function buildFontDictionary() {
186
+ // pdfmake/.../fonts/Roboto/* — the path is stable across the package's
187
+ // releases. Use `requireFromHere` (createRequire-wrapped) because the
188
+ // backend runs as an ES module and bare `require` is not defined in
189
+ // that scope.
190
+ const fontsDir = requireFromHere.resolve('pdfmake/package.json').replace(/package\.json$/, `fonts${path.sep}Roboto${path.sep}`);
191
+ return {
192
+ Roboto: {
193
+ normal: `${fontsDir}Roboto-Regular.ttf`,
194
+ bold: `${fontsDir}Roboto-Medium.ttf`,
195
+ italics: `${fontsDir}Roboto-Italic.ttf`,
196
+ bolditalics: `${fontsDir}Roboto-MediumItalic.ttf`,
197
+ },
198
+ };
199
+ }
200
+ /** Subtle row-separator borders + soft fill on common rows. */
201
+ const COMPARE_TABLE_LAYOUT = {
202
+ hLineWidth: (i) => (i === 0 || i === 1 ? 1 : 0.5),
203
+ vLineWidth: () => 0.5,
204
+ hLineColor: () => '#ccc',
205
+ vLineColor: () => '#ccc',
206
+ paddingTop: () => 6,
207
+ paddingBottom: () => 6,
208
+ paddingLeft: () => 8,
209
+ paddingRight: () => 8,
210
+ };
211
+ //# sourceMappingURL=comparison-pdf-renderer.js.map