@endora-commerce/mod-comparisons 0.0.0-stage → 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +61 -2
- package/dist/admin/api/comparisons-client.d.ts +24 -0
- package/dist/admin/api/comparisons-client.d.ts.map +1 -0
- package/dist/admin/api/comparisons-client.js +15 -0
- package/dist/admin/api/comparisons-client.js.map +1 -0
- package/dist/admin/index.d.ts +37 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +49 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/ComparisonDetailPage.d.ts +19 -0
- package/dist/admin/pages/ComparisonDetailPage.d.ts.map +1 -0
- package/dist/admin/pages/ComparisonDetailPage.js +62 -0
- package/dist/admin/pages/ComparisonDetailPage.js.map +1 -0
- package/dist/admin/pages/ComparisonsListPage.d.ts +10 -0
- package/dist/admin/pages/ComparisonsListPage.d.ts.map +1 -0
- package/dist/admin/pages/ComparisonsListPage.js +71 -0
- package/dist/admin/pages/ComparisonsListPage.js.map +1 -0
- package/dist/backend/entities/comparison-product.entity.d.ts +22 -0
- package/dist/backend/entities/comparison-product.entity.d.ts.map +1 -0
- package/dist/backend/entities/comparison-product.entity.js +54 -0
- package/dist/backend/entities/comparison-product.entity.js.map +1 -0
- package/dist/backend/entities/comparison.entity.d.ts +61 -0
- package/dist/backend/entities/comparison.entity.d.ts.map +1 -0
- package/dist/backend/entities/comparison.entity.js +115 -0
- package/dist/backend/entities/comparison.entity.js.map +1 -0
- package/dist/backend/index.d.ts +61 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +117 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/request-actor.d.ts +22 -0
- package/dist/backend/request-actor.d.ts.map +1 -0
- package/dist/backend/request-actor.js +24 -0
- package/dist/backend/request-actor.js.map +1 -0
- package/dist/backend/routes.admin.d.ts +22 -0
- package/dist/backend/routes.admin.d.ts.map +1 -0
- package/dist/backend/routes.admin.js +41 -0
- package/dist/backend/routes.admin.js.map +1 -0
- package/dist/backend/routes.public.d.ts +33 -0
- package/dist/backend/routes.public.d.ts.map +1 -0
- package/dist/backend/routes.public.js +215 -0
- package/dist/backend/routes.public.js.map +1 -0
- package/dist/backend/routes.share.d.ts +27 -0
- package/dist/backend/routes.share.d.ts.map +1 -0
- package/dist/backend/routes.share.js +49 -0
- package/dist/backend/routes.share.js.map +1 -0
- package/dist/backend/services/anonymous-token-cookie.d.ts +23 -0
- package/dist/backend/services/anonymous-token-cookie.d.ts.map +1 -0
- package/dist/backend/services/anonymous-token-cookie.js +33 -0
- package/dist/backend/services/anonymous-token-cookie.js.map +1 -0
- package/dist/backend/services/asset-byte-fetcher.d.ts +37 -0
- package/dist/backend/services/asset-byte-fetcher.d.ts.map +1 -0
- package/dist/backend/services/asset-byte-fetcher.js +70 -0
- package/dist/backend/services/asset-byte-fetcher.js.map +1 -0
- package/dist/backend/services/comparable-attribute-projection.d.ts +57 -0
- package/dist/backend/services/comparable-attribute-projection.d.ts.map +1 -0
- package/dist/backend/services/comparable-attribute-projection.js +136 -0
- package/dist/backend/services/comparable-attribute-projection.js.map +1 -0
- package/dist/backend/services/comparison-admin.service.d.ts +31 -0
- package/dist/backend/services/comparison-admin.service.d.ts.map +1 -0
- package/dist/backend/services/comparison-admin.service.js +184 -0
- package/dist/backend/services/comparison-admin.service.js.map +1 -0
- package/dist/backend/services/comparison-pdf-renderer.d.ts +57 -0
- package/dist/backend/services/comparison-pdf-renderer.d.ts.map +1 -0
- package/dist/backend/services/comparison-pdf-renderer.js +211 -0
- package/dist/backend/services/comparison-pdf-renderer.js.map +1 -0
- package/dist/backend/services/comparison-service.d.ts +339 -0
- package/dist/backend/services/comparison-service.d.ts.map +1 -0
- package/dist/backend/services/comparison-service.js +588 -0
- package/dist/backend/services/comparison-service.js.map +1 -0
- package/dist/backend/services/share-token-generator.d.ts +20 -0
- package/dist/backend/services/share-token-generator.d.ts.map +1 -0
- package/dist/backend/services/share-token-generator.js +23 -0
- package/dist/backend/services/share-token-generator.js.map +1 -0
- package/dist/manifest.d.ts +199 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +179 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260501T185834_comparisons_init.d.ts +32 -0
- package/dist/migrations/20260501T185834_comparisons_init.d.ts.map +1 -0
- package/dist/migrations/20260501T185834_comparisons_init.js +102 -0
- package/dist/migrations/20260501T185834_comparisons_init.js.map +1 -0
- package/dist/migrations/20260830T163143_comparisons_organization_attribution.d.ts +84 -0
- package/dist/migrations/20260830T163143_comparisons_organization_attribution.d.ts.map +1 -0
- package/dist/migrations/20260830T163143_comparisons_organization_attribution.js +147 -0
- package/dist/migrations/20260830T163143_comparisons_organization_attribution.js.map +1 -0
- package/dist/migrations/index.d.ts +28 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +31 -0
- package/dist/migrations/index.js.map +1 -0
- package/docs/comparisons.md +278 -0
- package/i18n/en.json +51 -0
- package/i18n/pl.json +51 -0
- package/package.json +102 -3
- package/tailwind.css +14 -0
|
@@ -0,0 +1,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":"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"}
|
|
@@ -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
|