@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,215 @@
1
+ import { ERROR_CODES, comparisonAddProductInputSchema, comparisonSetDisplayModeInputSchema, } from '@endora-commerce/contracts';
2
+ import { HttpError } from '@endora-commerce/platform/http';
3
+ import { productAudienceOf } from '@endora-commerce/platform/http';
4
+ import { getResolvedChannel } from '@endora-commerce/platform/kernel';
5
+ import { ComparisonFullError, ComparisonNotFoundError, ProductNotFoundError, ProductNotInComparisonError, } from './services/comparison-service.js';
6
+ import { COMPARE_TOKEN_COOKIE, readAnonymousToken, writeAnonymousToken, } from './services/anonymous-token-cookie.js';
7
+ import { customerActor } from './request-actor.js';
8
+ export async function registerComparisonsPublicRoutes(app, deps) {
9
+ const { comparisonService, tokens, pdfRenderer } = deps;
10
+ // -----------------------------------------------------------------
11
+ // GET /me — read
12
+ // -----------------------------------------------------------------
13
+ app.get('/api/v1/comparisons/me', async (request, reply) => {
14
+ const owner = readOwner(request);
15
+ if (!owner) {
16
+ reply.code(204);
17
+ return null;
18
+ }
19
+ const comparison = await comparisonService.getForOwner(owner);
20
+ if (!comparison) {
21
+ reply.code(204);
22
+ return null;
23
+ }
24
+ const channel = getResolvedChannel(request);
25
+ reply.header('cache-control', 'no-store');
26
+ return {
27
+ data: await comparisonService.buildOwnerView(comparison, channel.id, { kind: 'buyer', audience: productAudienceOf(request) }),
28
+ };
29
+ });
30
+ // -----------------------------------------------------------------
31
+ // POST /me/products — add (creates on first call)
32
+ // -----------------------------------------------------------------
33
+ app.post('/api/v1/comparisons/me/products', async (request, reply) => {
34
+ const body = parse(comparisonAddProductInputSchema, request.body);
35
+ const owner = ensureOwner(request, reply, tokens);
36
+ const channel = getResolvedChannel(request);
37
+ let comparison;
38
+ try {
39
+ comparison = await comparisonService.addProduct(owner, channel.id, body.productId, productAudienceOf(request));
40
+ }
41
+ catch (err) {
42
+ throw translate(err);
43
+ }
44
+ reply.header('cache-control', 'no-store');
45
+ return {
46
+ data: await comparisonService.buildOwnerView(comparison, channel.id, { kind: 'buyer', audience: productAudienceOf(request) }),
47
+ };
48
+ });
49
+ // -----------------------------------------------------------------
50
+ // DELETE /me/products/:productId — remove
51
+ // -----------------------------------------------------------------
52
+ app.delete('/api/v1/comparisons/me/products/:productId', async (request, reply) => {
53
+ const owner = readOwner(request);
54
+ if (!owner)
55
+ throw notFoundComparison();
56
+ let comparison;
57
+ try {
58
+ comparison = await comparisonService.removeProduct(owner, request.params.productId);
59
+ }
60
+ catch (err) {
61
+ throw translate(err);
62
+ }
63
+ const channel = getResolvedChannel(request);
64
+ reply.header('cache-control', 'no-store');
65
+ return {
66
+ data: await comparisonService.buildOwnerView(comparison, channel.id, { kind: 'buyer', audience: productAudienceOf(request) }),
67
+ };
68
+ });
69
+ // -----------------------------------------------------------------
70
+ // PATCH /me — set display mode
71
+ // -----------------------------------------------------------------
72
+ app.patch('/api/v1/comparisons/me', async (request, reply) => {
73
+ const body = parse(comparisonSetDisplayModeInputSchema, request.body);
74
+ const owner = readOwner(request);
75
+ if (!owner)
76
+ throw notFoundComparison();
77
+ let comparison;
78
+ try {
79
+ comparison = await comparisonService.setDisplayMode(owner, body.displayMode);
80
+ }
81
+ catch (err) {
82
+ throw translate(err);
83
+ }
84
+ const channel = getResolvedChannel(request);
85
+ reply.header('cache-control', 'no-store');
86
+ return {
87
+ data: await comparisonService.buildOwnerView(comparison, channel.id, { kind: 'buyer', audience: productAudienceOf(request) }),
88
+ };
89
+ });
90
+ // -----------------------------------------------------------------
91
+ // GET /me/pdf — owner-only export (US4)
92
+ // -----------------------------------------------------------------
93
+ if (pdfRenderer) {
94
+ app.get('/api/v1/comparisons/me/pdf', { config: { streamingResponse: true } }, async (request, reply) => {
95
+ const owner = readOwner(request);
96
+ if (!owner)
97
+ throw notFoundComparison();
98
+ const comparison = await comparisonService.getForOwner(owner);
99
+ if (!comparison)
100
+ throw notFoundComparison();
101
+ const channel = getResolvedChannel(request);
102
+ const view = await comparisonService.buildOwnerView(comparison, channel.id, { kind: 'buyer', audience: productAudienceOf(request) });
103
+ if (view.products.length === 0) {
104
+ throw new HttpError(409, ERROR_CODES.COMPARISON_EMPTY, 'Cannot export an empty comparison.');
105
+ }
106
+ let bytes;
107
+ try {
108
+ bytes = await pdfRenderer.render(view);
109
+ }
110
+ catch {
111
+ throw new HttpError(503, ERROR_CODES.PDF_GENERATION_FAILED, 'PDF generation failed.');
112
+ }
113
+ const shortToken = comparison.shareToken.slice(0, 8);
114
+ reply.header('content-type', 'application/pdf');
115
+ reply.header('content-disposition', `attachment; filename="comparison-${shortToken}.pdf"`);
116
+ reply.header('cache-control', 'no-store');
117
+ return reply.send(bytes);
118
+ });
119
+ }
120
+ // -----------------------------------------------------------------
121
+ // DELETE /me — hard-delete
122
+ // -----------------------------------------------------------------
123
+ app.delete('/api/v1/comparisons/me', async (request, reply) => {
124
+ const owner = readOwner(request);
125
+ if (!owner)
126
+ throw notFoundComparison();
127
+ try {
128
+ await comparisonService.deleteForOwner(owner);
129
+ }
130
+ catch (err) {
131
+ throw translate(err);
132
+ }
133
+ reply.code(204);
134
+ reply.header('cache-control', 'no-store');
135
+ return null;
136
+ });
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ // Owner resolution
140
+ // ---------------------------------------------------------------------------
141
+ /**
142
+ * Read the caller's owner identity for a *read* path. Returns null when
143
+ * the caller is anonymous and has no `compare_token` cookie. Read paths
144
+ * never mint a fresh cookie — only writes do.
145
+ */
146
+ function readOwner(request) {
147
+ const customer = customerActor(request);
148
+ if (customer !== null) {
149
+ return {
150
+ kind: 'customer',
151
+ customerAccountId: customer.customerAccountId,
152
+ };
153
+ }
154
+ const token = readAnonymousToken(request);
155
+ if (token)
156
+ return { kind: 'anonymous', anonymousToken: token };
157
+ return null;
158
+ }
159
+ /**
160
+ * Same as readOwner, but for *write* paths: when no identity is present,
161
+ * mint a fresh anonymous token and set the `compare_token` cookie on the
162
+ * response. Returns the owner for the just-minted identity so the
163
+ * service can attach it to the new Comparison row.
164
+ */
165
+ function ensureOwner(request, reply, tokens) {
166
+ const existing = readOwner(request);
167
+ if (existing)
168
+ return existing;
169
+ const token = tokens.generate();
170
+ writeAnonymousToken(reply, token);
171
+ return { kind: 'anonymous', anonymousToken: token };
172
+ }
173
+ // ---------------------------------------------------------------------------
174
+ // Error envelope translation
175
+ // ---------------------------------------------------------------------------
176
+ function translate(err) {
177
+ if (err instanceof HttpError)
178
+ return err;
179
+ if (err instanceof ComparisonFullError) {
180
+ return new HttpError(409, ERROR_CODES.COMPARISON_FULL, err.message);
181
+ }
182
+ if (err instanceof ComparisonNotFoundError) {
183
+ return notFoundComparison();
184
+ }
185
+ if (err instanceof ProductNotFoundError) {
186
+ return new HttpError(404, ERROR_CODES.PRODUCT_NOT_FOUND, err.message);
187
+ }
188
+ if (err instanceof ProductNotInComparisonError) {
189
+ return new HttpError(404, ERROR_CODES.PRODUCT_NOT_IN_COMPARISON, err.message);
190
+ }
191
+ if (err instanceof Error) {
192
+ return new HttpError(500, ERROR_CODES.INTERNAL, err.message);
193
+ }
194
+ return new HttpError(500, ERROR_CODES.INTERNAL, 'Unexpected error.');
195
+ }
196
+ function notFoundComparison() {
197
+ return new HttpError(404, ERROR_CODES.COMPARISON_NOT_FOUND, 'Comparison not found.');
198
+ }
199
+ // ---------------------------------------------------------------------------
200
+ // Tiny zod-parse helper that turns Zod errors into 422 with the project's
201
+ // error envelope.
202
+ // ---------------------------------------------------------------------------
203
+ function parse(schema, input) {
204
+ const parsed = schema.safeParse(input);
205
+ if (!parsed.success) {
206
+ throw new HttpError(422, ERROR_CODES.VALIDATION_FAILED, 'Validation failed.', parsed.error.issues.map((i) => ({
207
+ path: i.path.map(String).join('.'),
208
+ issue: i.message,
209
+ })));
210
+ }
211
+ return parsed.data;
212
+ }
213
+ // Re-export the cookie name so test-server / docs can reference it.
214
+ export { COMPARE_TOKEN_COOKIE };
215
+ //# sourceMappingURL=routes.public.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.public.js","sourceRoot":"","sources":["../../src/backend/routes.public.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,27 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { ComparisonService } from './services/comparison-service.js';
3
+ /**
4
+ * Public share-token endpoint — feature 007 / US2 / T040.
5
+ *
6
+ * GET /api/v1/comparisons/share/:token
7
+ *
8
+ * Resolves the share token in O(1) via the UNIQUE index, projects the
9
+ * comparison through the **recipient's** sales-channel context (so
10
+ * prices and availability reflect the recipient's channel, not the
11
+ * creator's — per data-model.md §3.2), and returns a typed response
12
+ * with `meta.viewerIsOwner` so the storefront knows whether to render
13
+ * owner-only affordances. No mutation surface; no auth.
14
+ *
15
+ * The recipient's **identity** decides the prices for the same reason their
16
+ * channel does, and one step further: a signed-in recipient from another
17
+ * organisation is quoted their own negotiated figures, an anonymous one the
18
+ * channel's. The token names the products; it never carries the sender's
19
+ * pricing identity, so a link cannot disclose what the sender pays. The view
20
+ * says which of the two it is in `data.pricedFor`, because a reader who cannot
21
+ * tell whose prices these are cannot act on either.
22
+ */
23
+ export interface ComparisonsShareDeps {
24
+ comparisonService: ComparisonService;
25
+ }
26
+ export declare function registerComparisonsShareRoutes(app: FastifyInstance, deps: ComparisonsShareDeps): Promise<void>;
27
+ //# sourceMappingURL=routes.share.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.share.d.ts","sourceRoot":"","sources":["../../src/backend/routes.share.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,SAAS,CAAC;AAK/D,OAAO,KAAK,EAAmB,iBAAiB,EAAE,MAAM,kCAAkC,CAAC;AAI3F;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,MAAM,WAAW,oBAAoB;IACnC,iBAAiB,EAAE,iBAAiB,CAAC;CACtC;AAED,wBAAsB,8BAA8B,CAClD,GAAG,EAAE,eAAe,EACpB,IAAI,EAAE,oBAAoB,GACzB,OAAO,CAAC,IAAI,CAAC,CAgCf"}
@@ -0,0 +1,49 @@
1
+ import { ERROR_CODES } from '@endora-commerce/contracts';
2
+ import { HttpError } from '@endora-commerce/platform/http';
3
+ import { productAudienceOf } from '@endora-commerce/platform/http';
4
+ import { getResolvedChannel } from '@endora-commerce/platform/kernel';
5
+ import { readAnonymousToken } from './services/anonymous-token-cookie.js';
6
+ import { customerActor } from './request-actor.js';
7
+ export async function registerComparisonsShareRoutes(app, deps) {
8
+ const { comparisonService } = deps;
9
+ app.get('/api/v1/comparisons/share/:token', async (request, reply) => {
10
+ const token = request.params.token;
11
+ if (!token || token.length < 8 || token.length > 32) {
12
+ throw notFound();
13
+ }
14
+ const comparison = await comparisonService.findByShareToken(token);
15
+ if (!comparison)
16
+ throw notFound();
17
+ const channel = getResolvedChannel(request);
18
+ const view = await comparisonService.buildOwnerView(comparison, channel.id, { kind: 'buyer', audience: productAudienceOf(request) });
19
+ const viewerIsOwner = comparisonService.isOwnedBy(comparison, viewerIdentity(request));
20
+ reply.header('cache-control', 'no-store');
21
+ // Strip `maxProducts` from the wire shape — the cap is irrelevant
22
+ // to the recipient (they cannot mutate the comparison).
23
+ const { maxProducts: _stripped, ...sharedView } = view;
24
+ return {
25
+ data: sharedView,
26
+ meta: { viewerIsOwner },
27
+ };
28
+ });
29
+ }
30
+ function notFound() {
31
+ return new HttpError(404, ERROR_CODES.COMPARISON_NOT_FOUND, 'Comparison not found.');
32
+ }
33
+ /**
34
+ * Best-effort owner detection on a no-auth endpoint. Returns the owner
35
+ * identity if one of the two paths resolves (authenticated customer or
36
+ * `compare_token` cookie); otherwise null.
37
+ */
38
+ function viewerIdentity(request) {
39
+ const customer = customerActor(request);
40
+ if (customer !== null) {
41
+ return {
42
+ kind: 'customer',
43
+ customerAccountId: customer.customerAccountId,
44
+ };
45
+ }
46
+ const token = readAnonymousToken(request);
47
+ return token ? { kind: 'anonymous', anonymousToken: token } : null;
48
+ }
49
+ //# sourceMappingURL=routes.share.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.share.js","sourceRoot":"","sources":["../../src/backend/routes.share.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AACzD,OAAO,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAC;AAC3D,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAC;AAEtE,OAAO,EAAE,kBAAkB,EAAE,MAAM,sCAAsC,CAAC;AAC1E,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AA2BnD,MAAM,CAAC,KAAK,UAAU,8BAA8B,CAClD,GAAoB,EACpB,IAA0B;IAE1B,MAAM,EAAE,iBAAiB,EAAE,GAAG,IAAI,CAAC;IAEnC,GAAG,CAAC,GAAG,CACL,kCAAkC,EAClC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE;QACvB,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC;QACnC,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,GAAG,EAAE,EAAE,CAAC;YACpD,MAAM,QAAQ,EAAE,CAAC;QACnB,CAAC;QACD,MAAM,UAAU,GAAG,MAAM,iBAAiB,CAAC,gBAAgB,CAAC,KAAK,CAAC,CAAC;QACnE,IAAI,CAAC,UAAU;YAAE,MAAM,QAAQ,EAAE,CAAC;QAClC,MAAM,OAAO,GAAG,kBAAkB,CAAC,OAAO,CAAC,CAAC;QAC5C,MAAM,IAAI,GAAG,MAAM,iBAAiB,CAAC,cAAc,CACjD,UAAU,EACV,OAAO,CAAC,EAAE,EACV,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,iBAAiB,CAAC,OAAO,CAAC,EAAE,CACxD,CAAC;QACF,MAAM,aAAa,GAAG,iBAAiB,CAAC,SAAS,CAC/C,UAAU,EACV,cAAc,CAAC,OAAO,CAAC,CACxB,CAAC;QACF,KAAK,CAAC,MAAM,CAAC,eAAe,EAAE,UAAU,CAAC,CAAC;QAC1C,kEAAkE;QAClE,wDAAwD;QACxD,MAAM,EAAE,WAAW,EAAE,SAAS,EAAE,GAAG,UAAU,EAAE,GAAG,IAAI,CAAC;QACvD,OAAO;YACL,IAAI,EAAE,UAAU;YAChB,IAAI,EAAE,EAAE,aAAa,EAAE;SACxB,CAAC;IACJ,CAAC,CACF,CAAC;AACJ,CAAC;AAED,SAAS,QAAQ;IACf,OAAO,IAAI,SAAS,CAClB,GAAG,EACH,WAAW,CAAC,oBAAoB,EAChC,uBAAuB,CACxB,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,SAAS,cAAc,CAAC,OAAuB;IAC7C,MAAM,QAAQ,GAAG,aAAa,CAAC,OAAO,CAAC,CAAC;IACxC,IAAI,QAAQ,KAAK,IAAI,EAAE,CAAC;QACtB,OAAO;YACL,IAAI,EAAE,UAAU;YAChB,iBAAiB,EAAE,QAAQ,CAAC,iBAAiB;SAC9C,CAAC;IACJ,CAAC;IACD,MAAM,KAAK,GAAG,kBAAkB,CAAC,OAAO,CAAC,CAAC;IAC1C,OAAO,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,cAAc,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACrE,CAAC"}
@@ -0,0 +1,23 @@
1
+ import type { FastifyReply, FastifyRequest } from 'fastify';
2
+ /**
3
+ * Cookie helpers for the comparisons module's anonymous owner identity
4
+ * (research.md R-2). The cookie holds a 22-char base64url token that
5
+ * doubles as the row's `comparisons.anonymous_token` column. It is *not*
6
+ * the share token — leaking the cookie should not leak shareable URLs.
7
+ *
8
+ * Flags:
9
+ * - `HttpOnly` → JavaScript on the storefront cannot read the
10
+ * token, mitigating XSS exfiltration.
11
+ * - `SameSite=Lax` → cookie is sent on top-level navigation but
12
+ * not on cross-site POSTs, mitigating CSRF.
13
+ * - `Path=/` → applies platform-wide so server-rendered
14
+ * pages and the API see the same cookie.
15
+ * - `Max-Age = 1 year` → long-lived; spec FR-005 ties anonymous
16
+ * ownership to "their browser session" but in
17
+ * practice the cookie is what carries that
18
+ * identity across visits.
19
+ */
20
+ export declare const COMPARE_TOKEN_COOKIE = "compare_token";
21
+ export declare function readAnonymousToken(request: FastifyRequest): string | null;
22
+ export declare function writeAnonymousToken(reply: FastifyReply, token: string): void;
23
+ //# sourceMappingURL=anonymous-token-cookie.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"anonymous-token-cookie.d.ts","sourceRoot":"","sources":["../../../src/backend/services/anonymous-token-cookie.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE5D;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,oBAAoB,kBAAkB,CAAC;AAIpD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,cAAc,GAAG,MAAM,GAAG,IAAI,CAGzE;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAO5E"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Cookie helpers for the comparisons module's anonymous owner identity
3
+ * (research.md R-2). The cookie holds a 22-char base64url token that
4
+ * doubles as the row's `comparisons.anonymous_token` column. It is *not*
5
+ * the share token — leaking the cookie should not leak shareable URLs.
6
+ *
7
+ * Flags:
8
+ * - `HttpOnly` → JavaScript on the storefront cannot read the
9
+ * token, mitigating XSS exfiltration.
10
+ * - `SameSite=Lax` → cookie is sent on top-level navigation but
11
+ * not on cross-site POSTs, mitigating CSRF.
12
+ * - `Path=/` → applies platform-wide so server-rendered
13
+ * pages and the API see the same cookie.
14
+ * - `Max-Age = 1 year` → long-lived; spec FR-005 ties anonymous
15
+ * ownership to "their browser session" but in
16
+ * practice the cookie is what carries that
17
+ * identity across visits.
18
+ */
19
+ export const COMPARE_TOKEN_COOKIE = 'compare_token';
20
+ const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;
21
+ export function readAnonymousToken(request) {
22
+ const cookies = request.cookies;
23
+ return cookies?.[COMPARE_TOKEN_COOKIE] ?? null;
24
+ }
25
+ export function writeAnonymousToken(reply, token) {
26
+ reply.setCookie(COMPARE_TOKEN_COOKIE, token, {
27
+ path: '/',
28
+ sameSite: 'lax',
29
+ httpOnly: true,
30
+ maxAge: ONE_YEAR_SECONDS,
31
+ });
32
+ }
33
+ //# sourceMappingURL=anonymous-token-cookie.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"anonymous-token-cookie.js","sourceRoot":"","sources":["../../../src/backend/services/anonymous-token-cookie.ts"],"names":[],"mappings":"AAWA;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,eAAe,CAAC;AAEpD,MAAM,gBAAgB,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,GAAG,CAAC;AAE5C,MAAM,UAAU,kBAAkB,CAAC,OAAuB;IACxD,MAAM,OAAO,GAAI,OAA4D,CAAC,OAAO,CAAC;IACtF,OAAO,OAAO,EAAE,CAAC,oBAAoB,CAAC,IAAI,IAAI,CAAC;AACjD,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,KAAmB,EAAE,KAAa;IACpE,KAAK,CAAC,SAAS,CAAC,oBAAoB,EAAE,KAAK,EAAE;QAC3C,IAAI,EAAE,GAAG;QACT,QAAQ,EAAE,KAAK;QACf,QAAQ,EAAE,IAAI;QACd,MAAM,EAAE,gBAAgB;KACzB,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Per-request asset-byte fetcher used by the PDF renderer
3
+ * (research.md R-8 / feature 007 / T054).
4
+ *
5
+ * pdfmake accepts inline base64 image data — but the bytes have to come
6
+ * from somewhere. The renderer asks this fetcher for each base-image
7
+ * URL it found on the comparison's products. We:
8
+ *
9
+ * - fetch the bytes (HTTP for remote URLs, file-system read for
10
+ * `file://` URLs and any path-shaped string the assets module
11
+ * emits for local storage),
12
+ * - cache the result in a per-request `Map<key, Buffer>` so the same
13
+ * image is never fetched twice within one PDF render,
14
+ * - fall back to {@link IMAGE_FALLBACK_BYTES} (a 1×1 transparent PNG)
15
+ * on any failure — fetch error, non-2xx, network timeout — so the
16
+ * PDF still renders rather than 503'ing the whole export
17
+ * (spec FR-018).
18
+ *
19
+ * Construct one fetcher per request; do not reuse across renders.
20
+ */
21
+ /** 1×1 transparent PNG. Used when an image is unavailable. */
22
+ export declare const IMAGE_FALLBACK_BYTES: Buffer;
23
+ export declare class AssetByteFetcher {
24
+ #private;
25
+ private readonly cache;
26
+ /** Per-fetch timeout — short; we'd rather fall back than block the PDF response. */
27
+ private readonly timeoutMs;
28
+ constructor(opts?: {
29
+ timeoutMs?: number;
30
+ });
31
+ /**
32
+ * Fetch the bytes at `url`. Returns {@link IMAGE_FALLBACK_BYTES} on any
33
+ * failure. Cached per-instance.
34
+ */
35
+ fetch(url: string): Promise<Buffer>;
36
+ }
37
+ //# sourceMappingURL=asset-byte-fetcher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asset-byte-fetcher.d.ts","sourceRoot":"","sources":["../../../src/backend/services/asset-byte-fetcher.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,8DAA8D;AAC9D,eAAO,MAAM,oBAAoB,EAAE,MAIlC,CAAC;AAEF,qBAAa,gBAAgB;;IAC3B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA6B;IACnD,oFAAoF;IACpF,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;gBAEvB,IAAI,GAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAO;IAI7C;;;OAGG;IACG,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CA8B1C"}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Per-request asset-byte fetcher used by the PDF renderer
3
+ * (research.md R-8 / feature 007 / T054).
4
+ *
5
+ * pdfmake accepts inline base64 image data — but the bytes have to come
6
+ * from somewhere. The renderer asks this fetcher for each base-image
7
+ * URL it found on the comparison's products. We:
8
+ *
9
+ * - fetch the bytes (HTTP for remote URLs, file-system read for
10
+ * `file://` URLs and any path-shaped string the assets module
11
+ * emits for local storage),
12
+ * - cache the result in a per-request `Map<key, Buffer>` so the same
13
+ * image is never fetched twice within one PDF render,
14
+ * - fall back to {@link IMAGE_FALLBACK_BYTES} (a 1×1 transparent PNG)
15
+ * on any failure — fetch error, non-2xx, network timeout — so the
16
+ * PDF still renders rather than 503'ing the whole export
17
+ * (spec FR-018).
18
+ *
19
+ * Construct one fetcher per request; do not reuse across renders.
20
+ */
21
+ import { readFile } from 'node:fs/promises';
22
+ /** 1×1 transparent PNG. Used when an image is unavailable. */
23
+ export const IMAGE_FALLBACK_BYTES = Buffer.from(
24
+ // 67-byte minimal transparent PNG.
25
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=', 'base64');
26
+ export class AssetByteFetcher {
27
+ cache = new Map();
28
+ /** Per-fetch timeout — short; we'd rather fall back than block the PDF response. */
29
+ timeoutMs;
30
+ constructor(opts = {}) {
31
+ this.timeoutMs = opts.timeoutMs ?? 3_000;
32
+ }
33
+ /**
34
+ * Fetch the bytes at `url`. Returns {@link IMAGE_FALLBACK_BYTES} on any
35
+ * failure. Cached per-instance.
36
+ */
37
+ async fetch(url) {
38
+ const cached = this.cache.get(url);
39
+ if (cached)
40
+ return cached;
41
+ const bytes = await this.#read(url).catch(() => IMAGE_FALLBACK_BYTES);
42
+ this.cache.set(url, bytes);
43
+ return bytes;
44
+ }
45
+ async #read(url) {
46
+ if (url.startsWith('file://')) {
47
+ return readFile(new URL(url));
48
+ }
49
+ if (url.startsWith('/') || url.startsWith('./')) {
50
+ // Bare filesystem path the assets module sometimes emits.
51
+ return readFile(url);
52
+ }
53
+ if (url.startsWith('http://') || url.startsWith('https://')) {
54
+ const controller = new AbortController();
55
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
56
+ try {
57
+ const res = await fetch(url, { signal: controller.signal });
58
+ if (!res.ok)
59
+ throw new Error(`HTTP ${res.status}`);
60
+ const arr = new Uint8Array(await res.arrayBuffer());
61
+ return Buffer.from(arr);
62
+ }
63
+ finally {
64
+ clearTimeout(timer);
65
+ }
66
+ }
67
+ throw new Error(`unsupported URL scheme: ${url}`);
68
+ }
69
+ }
70
+ //# sourceMappingURL=asset-byte-fetcher.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asset-byte-fetcher.js","sourceRoot":"","sources":["../../../src/backend/services/asset-byte-fetcher.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE5C,8DAA8D;AAC9D,MAAM,CAAC,MAAM,oBAAoB,GAAW,MAAM,CAAC,IAAI;AACrD,mCAAmC;AACnC,8FAA8F,EAC9F,QAAQ,CACT,CAAC;AAEF,MAAM,OAAO,gBAAgB;IACV,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAC;IACnD,oFAAoF;IACnE,SAAS,CAAS;IAEnC,YAAY,OAA+B,EAAE;QAC3C,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,KAAK,CAAC;IAC3C,CAAC;IAED;;;OAGG;IACH,KAAK,CAAC,KAAK,CAAC,GAAW;QACrB,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACnC,IAAI,MAAM;YAAE,OAAO,MAAM,CAAC;QAC1B,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,oBAAoB,CAAC,CAAC;QACtE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QAC3B,OAAO,KAAK,CAAC;IACf,CAAC;IAED,KAAK,CAAC,KAAK,CAAC,GAAW;QACrB,IAAI,GAAG,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;YAC9B,OAAO,QAAQ,CAAC,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;QAChC,CAAC;QACD,IAAI,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;YAChD,0DAA0D;YAC1D,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAC;QACvB,CAAC;QACD,IAAI,GAAG,CAAC,UAAU,CAAC,SAAS,CAAC,IAAI,GAAG,CAAC,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC;YAC5D,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;YACzC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;YACnE,IAAI,CAAC;gBACH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;gBAC5D,IAAI,CAAC,GAAG,CAAC,EAAE;oBAAE,MAAM,IAAI,KAAK,CAAC,QAAQ,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;gBACnD,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,WAAW,EAAE,CAAC,CAAC;gBACpD,OAAO,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAC1B,CAAC;oBAAS,CAAC;gBACT,YAAY,CAAC,KAAK,CAAC,CAAC;YACtB,CAAC;QACH,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,2BAA2B,GAAG,EAAE,CAAC,CAAC;IACpD,CAAC;CACF"}
@@ -0,0 +1,57 @@
1
+ import type { AttributeValueType, ComparisonAttributeRow, ComparisonRowClass } from '@endora-commerce/contracts';
2
+ /**
3
+ * The slice of the catalog's composed `CatalogAttributeView` this projection
4
+ * needs (feature 061 — comparisons no longer imports catalog entities; the
5
+ * definitions arrive through the injected `CatalogAttributeReadService`).
6
+ */
7
+ export interface ComparableAttributeDefinition {
8
+ key: string;
9
+ label: Record<string, string>;
10
+ valueType: AttributeValueType;
11
+ }
12
+ /**
13
+ * Pure projection from "products + comparable-attribute definitions" to
14
+ * the body rows of the comparison page (and PDF). Implements research.md
15
+ * R-6 algorithm.
16
+ *
17
+ * Inputs:
18
+ * - `products` — the column-ordered list of products in the comparison.
19
+ * Each product carries its `attributeValues` JSONB record (catalog
20
+ * stores values keyed by attribute key).
21
+ * - `attributeDefinitions` — every catalog attribute flagged
22
+ * `is_comparable=true`, in display order. Order is preserved in the
23
+ * output so the client / PDF both render rows top-to-bottom in the
24
+ * same sequence.
25
+ *
26
+ * Output: one `ComparisonAttributeRow` per definition, with one entry
27
+ * per product (in column order) and the row's classification (`common`
28
+ * vs `different`). Pre-formatted strings ride on the wire so the
29
+ * storefront table doesn't have to re-format multi-value attributes.
30
+ *
31
+ * Equality semantics (R-6):
32
+ * - `multiselect` — set equality (full overlap), order-insensitive.
33
+ * - `enum` / `string` — `===` after `String(v).trim()`.
34
+ * - `number` / `price` — `===` after `Number(v)` (no tolerance).
35
+ * - `boolean` — `===`.
36
+ * - `date` — `===` after ISO-8601 normalisation (parse → toISOString).
37
+ * - missing value on either side — classified as `different`; cell is `null`.
38
+ *
39
+ * Pure: no DB, no HTTP, no I/O. Unit-testable in isolation.
40
+ */
41
+ export declare class ComparableAttributeProjection {
42
+ projectRows(products: Array<{
43
+ attributeValues: Record<string, unknown>;
44
+ }>, attributeDefinitions: ComparableAttributeDefinition[]): ComparisonAttributeRow[];
45
+ }
46
+ /**
47
+ * Pre-format a raw `attributeValues[key]` for display. Returns null when
48
+ * the value is missing — the storefront / PDF render `—` for null cells.
49
+ */
50
+ export declare function formatForDisplay(valueType: AttributeValueType, v: unknown): string | null;
51
+ /**
52
+ * Returns 'common' iff every product carries an equivalent value for the
53
+ * attribute (per R-6 equality semantics) AND no product is missing. Any
54
+ * other case is 'different'.
55
+ */
56
+ export declare function classifyRow(valueType: AttributeValueType, rawValues: unknown[]): ComparisonRowClass;
57
+ //# sourceMappingURL=comparable-attribute-projection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"comparable-attribute-projection.d.ts","sourceRoot":"","sources":["../../../src/backend/services/comparable-attribute-projection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,sBAAsB,EACtB,kBAAkB,EACnB,MAAM,4BAA4B,CAAC;AAEpC;;;;GAIG;AACH,MAAM,WAAW,6BAA6B;IAC5C,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,SAAS,EAAE,kBAAkB,CAAC;CAC/B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qBAAa,6BAA6B;IACxC,WAAW,CACT,QAAQ,EAAE,KAAK,CAAC;QAAE,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,EAC7D,oBAAoB,EAAE,6BAA6B,EAAE,GACpD,sBAAsB,EAAE;CAgB5B;AAcD;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,kBAAkB,EAC7B,CAAC,EAAE,OAAO,GACT,MAAM,GAAG,IAAI,CAoBf;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CACzB,SAAS,EAAE,kBAAkB,EAC7B,SAAS,EAAE,OAAO,EAAE,GACnB,kBAAkB,CAYpB"}