@voyantjs/products 0.28.3 → 0.29.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/dist/booking-engine/handler.d.ts +8 -0
- package/dist/booking-engine/handler.d.ts.map +1 -1
- package/dist/booking-engine/handler.js +38 -0
- package/dist/catalog-policy-departures.d.ts +52 -0
- package/dist/catalog-policy-departures.d.ts.map +1 -0
- package/dist/catalog-policy-departures.js +169 -0
- package/dist/catalog-policy-destinations.d.ts +41 -0
- package/dist/catalog-policy-destinations.d.ts.map +1 -0
- package/dist/catalog-policy-destinations.js +121 -0
- package/dist/catalog-policy-pricing.d.ts +55 -0
- package/dist/catalog-policy-pricing.d.ts.map +1 -0
- package/dist/catalog-policy-pricing.js +109 -0
- package/dist/catalog-policy-promotions.d.ts +52 -0
- package/dist/catalog-policy-promotions.d.ts.map +1 -0
- package/dist/catalog-policy-promotions.js +270 -0
- package/dist/catalog-policy-taxonomy.d.ts +51 -0
- package/dist/catalog-policy-taxonomy.d.ts.map +1 -0
- package/dist/catalog-policy-taxonomy.js +191 -0
- package/dist/events.d.ts +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/routes.d.ts +254 -19
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +73 -5
- package/dist/schema-taxonomy.d.ts +287 -0
- package/dist/schema-taxonomy.d.ts.map +1 -1
- package/dist/schema-taxonomy.js +47 -0
- package/dist/service-catalog-plane-destinations.d.ts +30 -0
- package/dist/service-catalog-plane-destinations.d.ts.map +1 -0
- package/dist/service-catalog-plane-destinations.js +119 -0
- package/dist/service-catalog-plane-taxonomy.d.ts +73 -0
- package/dist/service-catalog-plane-taxonomy.d.ts.map +1 -0
- package/dist/service-catalog-plane-taxonomy.js +242 -0
- package/dist/service-catalog-plane.d.ts +46 -1
- package/dist/service-catalog-plane.d.ts.map +1 -1
- package/dist/service-catalog-plane.js +42 -6
- package/dist/service.d.ts +98 -19
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +142 -1
- package/dist/tasks/brochures.d.ts +1 -1
- package/dist/validation-content.d.ts +38 -0
- package/dist/validation-content.d.ts.map +1 -1
- package/dist/validation-content.js +29 -0
- package/package.json +77 -7
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog plane field policy for product → promotional-offer denormalization.
|
|
3
|
+
*
|
|
4
|
+
* Promotions are projected as **annotations only** — this policy does NOT
|
|
5
|
+
* touch the existing `priceFromAmountCents` field (that's emitted by the
|
|
6
|
+
* pricing extension and the two extensions can't read each other's output;
|
|
7
|
+
* see `docs/architecture/promotions-architecture.md` §3.7). Instead, we
|
|
8
|
+
* emit `bestOffer*` + `originalPriceFromAmountCents` and storefront
|
|
9
|
+
* consumers compute the effective price client-side:
|
|
10
|
+
*
|
|
11
|
+
* effective = bestOfferDiscountKind === "percentage"
|
|
12
|
+
* ? round(price × (1 - bestOfferDiscountPercent/100))
|
|
13
|
+
* : price - bestOfferDiscountAmountCents
|
|
14
|
+
*
|
|
15
|
+
* Catalog filter / sort behavior continues to use `priceFromAmountCents`
|
|
16
|
+
* (the list price). A customer searching `< $200` won't find a
|
|
17
|
+
* `$250 → $180` discounted product via the filter — acknowledged v1
|
|
18
|
+
* limitation, see §15.1 of the architecture doc.
|
|
19
|
+
*
|
|
20
|
+
* Conditional offers are surfaced separately so storefront cards can
|
|
21
|
+
* render hints like "From 4 pax: extra 5% off" — the catalog plane
|
|
22
|
+
* doesn't know pax at index time, so any offer with a `minPax` condition
|
|
23
|
+
* lands here instead of in `bestOffer*`.
|
|
24
|
+
*
|
|
25
|
+
* Wire this policy into the products registry by composing with
|
|
26
|
+
* `productCatalogPolicy`:
|
|
27
|
+
*
|
|
28
|
+
* const registry = createFieldPolicyRegistry([
|
|
29
|
+
* ...productCatalogPolicy,
|
|
30
|
+
* ...productPromotionsCatalogPolicy,
|
|
31
|
+
* ])
|
|
32
|
+
*
|
|
33
|
+
* and wire `createProductPromotionsProjectionExtension` (from
|
|
34
|
+
* `@voyantjs/promotions/service-catalog-plane-promotions`) into
|
|
35
|
+
* `createProductDocumentBuilder` so the values land in the doc.
|
|
36
|
+
*
|
|
37
|
+
* Out of scope here:
|
|
38
|
+
* - Localized offer names. Offers are operator-managed in one language
|
|
39
|
+
* for v1 (`localized: false`); a `promotional_offer_translations`
|
|
40
|
+
* table mirrors `destinations_translations` if/when needed.
|
|
41
|
+
* - Discount-aware filter / sort. Requires the §15.1 ordered-extensions
|
|
42
|
+
* contract change; deferred.
|
|
43
|
+
*/
|
|
44
|
+
import { type FieldPolicyInput } from "@voyantjs/catalog/contract";
|
|
45
|
+
declare const PRODUCT_PROMOTIONS_FIELD_POLICY: FieldPolicyInput[];
|
|
46
|
+
/**
|
|
47
|
+
* Resolved promotions policy. Compose with `productCatalogPolicy` when
|
|
48
|
+
* building the products registry.
|
|
49
|
+
*/
|
|
50
|
+
export declare const productPromotionsCatalogPolicy: import("@voyantjs/catalog").FieldPolicy[];
|
|
51
|
+
export { PRODUCT_PROMOTIONS_FIELD_POLICY };
|
|
52
|
+
//# sourceMappingURL=catalog-policy-promotions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalog-policy-promotions.d.ts","sourceRoot":"","sources":["../src/catalog-policy-promotions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,4BAA4B,CAAA;AAErF,QAAA,MAAM,+BAA+B,EAAE,gBAAgB,EA4NtD,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,8BAA8B,2CAAqD,CAAA;AAEhG,OAAO,EAAE,+BAA+B,EAAE,CAAA"}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog plane field policy for product → promotional-offer denormalization.
|
|
3
|
+
*
|
|
4
|
+
* Promotions are projected as **annotations only** — this policy does NOT
|
|
5
|
+
* touch the existing `priceFromAmountCents` field (that's emitted by the
|
|
6
|
+
* pricing extension and the two extensions can't read each other's output;
|
|
7
|
+
* see `docs/architecture/promotions-architecture.md` §3.7). Instead, we
|
|
8
|
+
* emit `bestOffer*` + `originalPriceFromAmountCents` and storefront
|
|
9
|
+
* consumers compute the effective price client-side:
|
|
10
|
+
*
|
|
11
|
+
* effective = bestOfferDiscountKind === "percentage"
|
|
12
|
+
* ? round(price × (1 - bestOfferDiscountPercent/100))
|
|
13
|
+
* : price - bestOfferDiscountAmountCents
|
|
14
|
+
*
|
|
15
|
+
* Catalog filter / sort behavior continues to use `priceFromAmountCents`
|
|
16
|
+
* (the list price). A customer searching `< $200` won't find a
|
|
17
|
+
* `$250 → $180` discounted product via the filter — acknowledged v1
|
|
18
|
+
* limitation, see §15.1 of the architecture doc.
|
|
19
|
+
*
|
|
20
|
+
* Conditional offers are surfaced separately so storefront cards can
|
|
21
|
+
* render hints like "From 4 pax: extra 5% off" — the catalog plane
|
|
22
|
+
* doesn't know pax at index time, so any offer with a `minPax` condition
|
|
23
|
+
* lands here instead of in `bestOffer*`.
|
|
24
|
+
*
|
|
25
|
+
* Wire this policy into the products registry by composing with
|
|
26
|
+
* `productCatalogPolicy`:
|
|
27
|
+
*
|
|
28
|
+
* const registry = createFieldPolicyRegistry([
|
|
29
|
+
* ...productCatalogPolicy,
|
|
30
|
+
* ...productPromotionsCatalogPolicy,
|
|
31
|
+
* ])
|
|
32
|
+
*
|
|
33
|
+
* and wire `createProductPromotionsProjectionExtension` (from
|
|
34
|
+
* `@voyantjs/promotions/service-catalog-plane-promotions`) into
|
|
35
|
+
* `createProductDocumentBuilder` so the values land in the doc.
|
|
36
|
+
*
|
|
37
|
+
* Out of scope here:
|
|
38
|
+
* - Localized offer names. Offers are operator-managed in one language
|
|
39
|
+
* for v1 (`localized: false`); a `promotional_offer_translations`
|
|
40
|
+
* table mirrors `destinations_translations` if/when needed.
|
|
41
|
+
* - Discount-aware filter / sort. Requires the §15.1 ordered-extensions
|
|
42
|
+
* contract change; deferred.
|
|
43
|
+
*/
|
|
44
|
+
import { defineFieldPolicy } from "@voyantjs/catalog/contract";
|
|
45
|
+
const PRODUCT_PROMOTIONS_FIELD_POLICY = [
|
|
46
|
+
// ── Best-offer existence flag ────────────────────────────────────────────
|
|
47
|
+
// Storefront filters use this for "show only products on sale" and to
|
|
48
|
+
// suppress an empty badge.
|
|
49
|
+
{
|
|
50
|
+
path: "hasOffer",
|
|
51
|
+
class: "structural",
|
|
52
|
+
merge: "source-only",
|
|
53
|
+
drift: "low",
|
|
54
|
+
reindex: "facet-affecting",
|
|
55
|
+
snapshot: "on-book",
|
|
56
|
+
query: "indexed-column",
|
|
57
|
+
localized: false,
|
|
58
|
+
visibility: ["staff", "customer", "partner"],
|
|
59
|
+
editRole: "none",
|
|
60
|
+
overrideFriction: "none",
|
|
61
|
+
sourceFreshness: "sync",
|
|
62
|
+
},
|
|
63
|
+
// ── Best-offer ID ────────────────────────────────────────────────────────
|
|
64
|
+
// Used for deep-linking from a search card to the offer-detail page.
|
|
65
|
+
{
|
|
66
|
+
path: "bestOfferId",
|
|
67
|
+
class: "structural",
|
|
68
|
+
merge: "source-only",
|
|
69
|
+
drift: "low",
|
|
70
|
+
reindex: "facet-affecting",
|
|
71
|
+
snapshot: "on-book",
|
|
72
|
+
query: "indexed-column",
|
|
73
|
+
localized: false,
|
|
74
|
+
visibility: ["staff", "customer", "partner"],
|
|
75
|
+
editRole: "none",
|
|
76
|
+
overrideFriction: "none",
|
|
77
|
+
sourceFreshness: "sync",
|
|
78
|
+
},
|
|
79
|
+
// ── Best-offer name (display) ────────────────────────────────────────────
|
|
80
|
+
// Single-locale in v1 — see file-header notes.
|
|
81
|
+
{
|
|
82
|
+
path: "bestOfferName",
|
|
83
|
+
class: "structural",
|
|
84
|
+
merge: "source-only",
|
|
85
|
+
drift: "low",
|
|
86
|
+
reindex: "facet-affecting",
|
|
87
|
+
snapshot: "on-book",
|
|
88
|
+
query: "indexed-column",
|
|
89
|
+
localized: false,
|
|
90
|
+
visibility: ["staff", "customer", "partner"],
|
|
91
|
+
editRole: "none",
|
|
92
|
+
overrideFriction: "none",
|
|
93
|
+
sourceFreshness: "sync",
|
|
94
|
+
},
|
|
95
|
+
// ── Best-offer discount kind ─────────────────────────────────────────────
|
|
96
|
+
// `"percentage"` | `"fixed_amount"` | `null`. Drives storefront's badge
|
|
97
|
+
// rendering (% vs $).
|
|
98
|
+
{
|
|
99
|
+
path: "bestOfferDiscountKind",
|
|
100
|
+
class: "structural",
|
|
101
|
+
merge: "source-only",
|
|
102
|
+
drift: "low",
|
|
103
|
+
reindex: "facet-affecting",
|
|
104
|
+
snapshot: "on-book",
|
|
105
|
+
query: "indexed-column",
|
|
106
|
+
localized: false,
|
|
107
|
+
visibility: ["staff", "customer", "partner"],
|
|
108
|
+
editRole: "none",
|
|
109
|
+
overrideFriction: "none",
|
|
110
|
+
sourceFreshness: "sync",
|
|
111
|
+
},
|
|
112
|
+
// ── Best-offer percentage (when kind = percentage) ───────────────────────
|
|
113
|
+
{
|
|
114
|
+
path: "bestOfferDiscountPercent",
|
|
115
|
+
class: "structural",
|
|
116
|
+
merge: "source-only",
|
|
117
|
+
drift: "low",
|
|
118
|
+
reindex: "facet-affecting",
|
|
119
|
+
snapshot: "on-book",
|
|
120
|
+
query: "indexed-column",
|
|
121
|
+
localized: false,
|
|
122
|
+
visibility: ["staff", "customer", "partner"],
|
|
123
|
+
editRole: "none",
|
|
124
|
+
overrideFriction: "none",
|
|
125
|
+
sourceFreshness: "sync",
|
|
126
|
+
},
|
|
127
|
+
// ── Best-offer cents off (when kind = fixed_amount) ──────────────────────
|
|
128
|
+
{
|
|
129
|
+
path: "bestOfferDiscountAmountCents",
|
|
130
|
+
class: "structural",
|
|
131
|
+
merge: "source-only",
|
|
132
|
+
drift: "low",
|
|
133
|
+
reindex: "facet-affecting",
|
|
134
|
+
snapshot: "on-book",
|
|
135
|
+
query: "indexed-column",
|
|
136
|
+
localized: false,
|
|
137
|
+
visibility: ["staff", "customer", "partner"],
|
|
138
|
+
editRole: "none",
|
|
139
|
+
overrideFriction: "none",
|
|
140
|
+
sourceFreshness: "sync",
|
|
141
|
+
},
|
|
142
|
+
// ── Original (un-discounted) price ───────────────────────────────────────
|
|
143
|
+
// Populated only when an offer applies (so consumers know what to
|
|
144
|
+
// strike through). When `null`, no offer applies and consumers should
|
|
145
|
+
// render `priceFromAmountCents` plain.
|
|
146
|
+
{
|
|
147
|
+
path: "originalPriceFromAmountCents",
|
|
148
|
+
class: "structural",
|
|
149
|
+
merge: "source-only",
|
|
150
|
+
drift: "low",
|
|
151
|
+
reindex: "facet-affecting",
|
|
152
|
+
snapshot: "on-book",
|
|
153
|
+
query: "indexed-column",
|
|
154
|
+
localized: false,
|
|
155
|
+
visibility: ["staff", "customer", "partner"],
|
|
156
|
+
editRole: "none",
|
|
157
|
+
overrideFriction: "none",
|
|
158
|
+
sourceFreshness: "sync",
|
|
159
|
+
},
|
|
160
|
+
// ── Conditional-offer existence flag ─────────────────────────────────────
|
|
161
|
+
// Surfaced when an offer has a `minPax` condition the catalog plane
|
|
162
|
+
// can't satisfy at index time. Storefront uses this for the "From N
|
|
163
|
+
// pax: extra X% off" hint.
|
|
164
|
+
{
|
|
165
|
+
path: "hasConditionalOffer",
|
|
166
|
+
class: "structural",
|
|
167
|
+
merge: "source-only",
|
|
168
|
+
drift: "low",
|
|
169
|
+
reindex: "facet-affecting",
|
|
170
|
+
snapshot: "on-book",
|
|
171
|
+
query: "indexed-column",
|
|
172
|
+
localized: false,
|
|
173
|
+
visibility: ["staff", "customer", "partner"],
|
|
174
|
+
editRole: "none",
|
|
175
|
+
overrideFriction: "none",
|
|
176
|
+
sourceFreshness: "sync",
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
path: "conditionalOfferId",
|
|
180
|
+
class: "structural",
|
|
181
|
+
merge: "source-only",
|
|
182
|
+
drift: "low",
|
|
183
|
+
reindex: "facet-affecting",
|
|
184
|
+
snapshot: "on-book",
|
|
185
|
+
query: "indexed-column",
|
|
186
|
+
localized: false,
|
|
187
|
+
visibility: ["staff", "customer", "partner"],
|
|
188
|
+
editRole: "none",
|
|
189
|
+
overrideFriction: "none",
|
|
190
|
+
sourceFreshness: "sync",
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
path: "conditionalOfferName",
|
|
194
|
+
class: "structural",
|
|
195
|
+
merge: "source-only",
|
|
196
|
+
drift: "low",
|
|
197
|
+
reindex: "facet-affecting",
|
|
198
|
+
snapshot: "on-book",
|
|
199
|
+
query: "indexed-column",
|
|
200
|
+
localized: false,
|
|
201
|
+
visibility: ["staff", "customer", "partner"],
|
|
202
|
+
editRole: "none",
|
|
203
|
+
overrideFriction: "none",
|
|
204
|
+
sourceFreshness: "sync",
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
path: "conditionalOfferDiscountKind",
|
|
208
|
+
class: "structural",
|
|
209
|
+
merge: "source-only",
|
|
210
|
+
drift: "low",
|
|
211
|
+
reindex: "facet-affecting",
|
|
212
|
+
snapshot: "on-book",
|
|
213
|
+
query: "indexed-column",
|
|
214
|
+
localized: false,
|
|
215
|
+
visibility: ["staff", "customer", "partner"],
|
|
216
|
+
editRole: "none",
|
|
217
|
+
overrideFriction: "none",
|
|
218
|
+
sourceFreshness: "sync",
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
path: "conditionalOfferDiscountPercent",
|
|
222
|
+
class: "structural",
|
|
223
|
+
merge: "source-only",
|
|
224
|
+
drift: "low",
|
|
225
|
+
reindex: "facet-affecting",
|
|
226
|
+
snapshot: "on-book",
|
|
227
|
+
query: "indexed-column",
|
|
228
|
+
localized: false,
|
|
229
|
+
visibility: ["staff", "customer", "partner"],
|
|
230
|
+
editRole: "none",
|
|
231
|
+
overrideFriction: "none",
|
|
232
|
+
sourceFreshness: "sync",
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
path: "conditionalOfferDiscountAmountCents",
|
|
236
|
+
class: "structural",
|
|
237
|
+
merge: "source-only",
|
|
238
|
+
drift: "low",
|
|
239
|
+
reindex: "facet-affecting",
|
|
240
|
+
snapshot: "on-book",
|
|
241
|
+
query: "indexed-column",
|
|
242
|
+
localized: false,
|
|
243
|
+
visibility: ["staff", "customer", "partner"],
|
|
244
|
+
editRole: "none",
|
|
245
|
+
overrideFriction: "none",
|
|
246
|
+
sourceFreshness: "sync",
|
|
247
|
+
},
|
|
248
|
+
// ── Conditional-offer minPax threshold ───────────────────────────────────
|
|
249
|
+
// Drives the "From N pax" message text.
|
|
250
|
+
{
|
|
251
|
+
path: "conditionalOfferMinPax",
|
|
252
|
+
class: "structural",
|
|
253
|
+
merge: "source-only",
|
|
254
|
+
drift: "low",
|
|
255
|
+
reindex: "facet-affecting",
|
|
256
|
+
snapshot: "on-book",
|
|
257
|
+
query: "indexed-column",
|
|
258
|
+
localized: false,
|
|
259
|
+
visibility: ["staff", "customer", "partner"],
|
|
260
|
+
editRole: "none",
|
|
261
|
+
overrideFriction: "none",
|
|
262
|
+
sourceFreshness: "sync",
|
|
263
|
+
},
|
|
264
|
+
];
|
|
265
|
+
/**
|
|
266
|
+
* Resolved promotions policy. Compose with `productCatalogPolicy` when
|
|
267
|
+
* building the products registry.
|
|
268
|
+
*/
|
|
269
|
+
export const productPromotionsCatalogPolicy = defineFieldPolicy(PRODUCT_PROMOTIONS_FIELD_POLICY);
|
|
270
|
+
export { PRODUCT_PROMOTIONS_FIELD_POLICY };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog plane field policy for product → taxonomy denormalization
|
|
3
|
+
* (categories + tags).
|
|
4
|
+
*
|
|
5
|
+
* These fields don't live on the `products` table — they're joined from
|
|
6
|
+
* `product_category_products` → `product_categories` (with parent walk for
|
|
7
|
+
* hierarchy denormalization) and `product_tag_products` → `product_tags` at
|
|
8
|
+
* index time and projected onto the product search document. See
|
|
9
|
+
* `docs/architecture/catalog-architecture.md` §5.4 — the search index is the
|
|
10
|
+
* canonical place for cross-entity denormalization.
|
|
11
|
+
*
|
|
12
|
+
* Storefront product cards filter and display by taxonomy:
|
|
13
|
+
* - "Adventure tours" (filter: `categories[]` includes "Adventure")
|
|
14
|
+
* - A product tagged "Hiking" (parent "Adventure") MUST also surface under
|
|
15
|
+
* the "Adventure" filter — Typesense can't recurse, so the projection
|
|
16
|
+
* denormalizes the full ancestor chain into `categories[]` /
|
|
17
|
+
* `categoryIds[]`.
|
|
18
|
+
* - "Family-friendly" (filter: `tags[]` includes "Family-friendly")
|
|
19
|
+
*
|
|
20
|
+
* Wire this policy into the products registry by composing with
|
|
21
|
+
* `productCatalogPolicy`:
|
|
22
|
+
*
|
|
23
|
+
* const registry = createFieldPolicyRegistry([
|
|
24
|
+
* ...productCatalogPolicy,
|
|
25
|
+
* ...productTaxonomyCatalogPolicy,
|
|
26
|
+
* ])
|
|
27
|
+
*
|
|
28
|
+
* and wire `createProductTaxonomyProjectionExtension` into
|
|
29
|
+
* `createProductDocumentBuilder` so the values land in the doc.
|
|
30
|
+
*
|
|
31
|
+
* Localization (#502): `product_category_translations` and
|
|
32
|
+
* `product_tag_translations` tables back the locale-aware label fields.
|
|
33
|
+
* Slice-locale rows win; if no row exists for the slice, the projection
|
|
34
|
+
* falls back to the canonical `productCategories.name` /
|
|
35
|
+
* `productTags.name`. Slug stays single-locale on `productCategories.slug`
|
|
36
|
+
* (per #502 non-goals — operators want stable URLs that don't shift when
|
|
37
|
+
* translations are edited).
|
|
38
|
+
*
|
|
39
|
+
* Out of scope here:
|
|
40
|
+
* - Per-locale slugs. One canonical slug per category.
|
|
41
|
+
* - Tag hierarchy. Tags are flat by design.
|
|
42
|
+
*/
|
|
43
|
+
import { type FieldPolicyInput } from "@voyantjs/catalog/contract";
|
|
44
|
+
declare const PRODUCT_TAXONOMY_FIELD_POLICY: FieldPolicyInput[];
|
|
45
|
+
/**
|
|
46
|
+
* Resolved taxonomy policy. Compose with `productCatalogPolicy` when
|
|
47
|
+
* building the products registry.
|
|
48
|
+
*/
|
|
49
|
+
export declare const productTaxonomyCatalogPolicy: import("@voyantjs/catalog").FieldPolicy[];
|
|
50
|
+
export { PRODUCT_TAXONOMY_FIELD_POLICY };
|
|
51
|
+
//# sourceMappingURL=catalog-policy-taxonomy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalog-policy-taxonomy.d.ts","sourceRoot":"","sources":["../src/catalog-policy-taxonomy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,4BAA4B,CAAA;AAErF,QAAA,MAAM,6BAA6B,EAAE,gBAAgB,EAiJpD,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,4BAA4B,2CAAmD,CAAA;AAE5F,OAAO,EAAE,6BAA6B,EAAE,CAAA"}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog plane field policy for product → taxonomy denormalization
|
|
3
|
+
* (categories + tags).
|
|
4
|
+
*
|
|
5
|
+
* These fields don't live on the `products` table — they're joined from
|
|
6
|
+
* `product_category_products` → `product_categories` (with parent walk for
|
|
7
|
+
* hierarchy denormalization) and `product_tag_products` → `product_tags` at
|
|
8
|
+
* index time and projected onto the product search document. See
|
|
9
|
+
* `docs/architecture/catalog-architecture.md` §5.4 — the search index is the
|
|
10
|
+
* canonical place for cross-entity denormalization.
|
|
11
|
+
*
|
|
12
|
+
* Storefront product cards filter and display by taxonomy:
|
|
13
|
+
* - "Adventure tours" (filter: `categories[]` includes "Adventure")
|
|
14
|
+
* - A product tagged "Hiking" (parent "Adventure") MUST also surface under
|
|
15
|
+
* the "Adventure" filter — Typesense can't recurse, so the projection
|
|
16
|
+
* denormalizes the full ancestor chain into `categories[]` /
|
|
17
|
+
* `categoryIds[]`.
|
|
18
|
+
* - "Family-friendly" (filter: `tags[]` includes "Family-friendly")
|
|
19
|
+
*
|
|
20
|
+
* Wire this policy into the products registry by composing with
|
|
21
|
+
* `productCatalogPolicy`:
|
|
22
|
+
*
|
|
23
|
+
* const registry = createFieldPolicyRegistry([
|
|
24
|
+
* ...productCatalogPolicy,
|
|
25
|
+
* ...productTaxonomyCatalogPolicy,
|
|
26
|
+
* ])
|
|
27
|
+
*
|
|
28
|
+
* and wire `createProductTaxonomyProjectionExtension` into
|
|
29
|
+
* `createProductDocumentBuilder` so the values land in the doc.
|
|
30
|
+
*
|
|
31
|
+
* Localization (#502): `product_category_translations` and
|
|
32
|
+
* `product_tag_translations` tables back the locale-aware label fields.
|
|
33
|
+
* Slice-locale rows win; if no row exists for the slice, the projection
|
|
34
|
+
* falls back to the canonical `productCategories.name` /
|
|
35
|
+
* `productTags.name`. Slug stays single-locale on `productCategories.slug`
|
|
36
|
+
* (per #502 non-goals — operators want stable URLs that don't shift when
|
|
37
|
+
* translations are edited).
|
|
38
|
+
*
|
|
39
|
+
* Out of scope here:
|
|
40
|
+
* - Per-locale slugs. One canonical slug per category.
|
|
41
|
+
* - Tag hierarchy. Tags are flat by design.
|
|
42
|
+
*/
|
|
43
|
+
import { defineFieldPolicy } from "@voyantjs/catalog/contract";
|
|
44
|
+
const PRODUCT_TAXONOMY_FIELD_POLICY = [
|
|
45
|
+
// ── Category labels (denormalized with ancestors, locale-aware) ──────────
|
|
46
|
+
// One entry per category in the linked-category-plus-ancestors set, deduped.
|
|
47
|
+
// A product linked to "Hiking" (parent: "Adventure") emits both
|
|
48
|
+
// ["Hiking", "Adventure"] so any-level filter matches a single equality.
|
|
49
|
+
// Locale-keyed since #502 — falls back to canonical name when no
|
|
50
|
+
// translation exists for the slice's locale.
|
|
51
|
+
{
|
|
52
|
+
path: "categories[]",
|
|
53
|
+
class: "structural",
|
|
54
|
+
merge: "source-only",
|
|
55
|
+
drift: "low",
|
|
56
|
+
reindex: "entry-locale",
|
|
57
|
+
snapshot: "on-book",
|
|
58
|
+
query: "indexed-column",
|
|
59
|
+
localized: true,
|
|
60
|
+
visibility: ["staff", "customer", "partner"],
|
|
61
|
+
editRole: "none",
|
|
62
|
+
overrideFriction: "none",
|
|
63
|
+
sourceFreshness: "sync",
|
|
64
|
+
},
|
|
65
|
+
// ── Category IDs (denormalized with ancestors) ───────────────────────────
|
|
66
|
+
// Same denormalization as `categories[]` but for ID-based filters
|
|
67
|
+
// (landing pages pinned to a category id rather than a label).
|
|
68
|
+
{
|
|
69
|
+
path: "categoryIds[]",
|
|
70
|
+
class: "structural",
|
|
71
|
+
merge: "source-only",
|
|
72
|
+
drift: "low",
|
|
73
|
+
reindex: "facet-affecting",
|
|
74
|
+
snapshot: "on-book",
|
|
75
|
+
query: "indexed-column",
|
|
76
|
+
localized: false,
|
|
77
|
+
visibility: ["staff", "customer", "partner"],
|
|
78
|
+
editRole: "none",
|
|
79
|
+
overrideFriction: "none",
|
|
80
|
+
sourceFreshness: "sync",
|
|
81
|
+
},
|
|
82
|
+
// ── Category slugs (locale-stable, denormalized with ancestors) ──────────
|
|
83
|
+
// Storefront category-page links want stable URLs that don't shift when
|
|
84
|
+
// labels are edited. `product_categories.slug` exists today.
|
|
85
|
+
{
|
|
86
|
+
path: "categorySlugs[]",
|
|
87
|
+
class: "structural",
|
|
88
|
+
merge: "source-only",
|
|
89
|
+
drift: "low",
|
|
90
|
+
reindex: "facet-affecting",
|
|
91
|
+
snapshot: "on-book",
|
|
92
|
+
query: "indexed-column",
|
|
93
|
+
localized: false,
|
|
94
|
+
visibility: ["staff", "customer", "partner"],
|
|
95
|
+
editRole: "none",
|
|
96
|
+
overrideFriction: "none",
|
|
97
|
+
sourceFreshness: "sync",
|
|
98
|
+
},
|
|
99
|
+
// ── Primary category (single, for badge display) ─────────────────────────
|
|
100
|
+
// Picked as the directly-linked category with the lowest sortOrder on
|
|
101
|
+
// `product_category_products` (the operator-controlled per-product
|
|
102
|
+
// ordering); ties broken by category name. Ancestors are NOT considered
|
|
103
|
+
// for primary — operators pin a leaf via the link table. Null when the
|
|
104
|
+
// product has no category links.
|
|
105
|
+
{
|
|
106
|
+
path: "primaryCategoryId",
|
|
107
|
+
class: "structural",
|
|
108
|
+
merge: "source-only",
|
|
109
|
+
drift: "low",
|
|
110
|
+
reindex: "facet-affecting",
|
|
111
|
+
snapshot: "on-book",
|
|
112
|
+
query: "indexed-column",
|
|
113
|
+
localized: false,
|
|
114
|
+
visibility: ["staff", "customer", "partner"],
|
|
115
|
+
editRole: "none",
|
|
116
|
+
overrideFriction: "none",
|
|
117
|
+
sourceFreshness: "sync",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
// Locale-aware since #502 — the primary's name follows the same
|
|
121
|
+
// translation lookup as `categories[]`.
|
|
122
|
+
path: "primaryCategoryName",
|
|
123
|
+
class: "structural",
|
|
124
|
+
merge: "source-only",
|
|
125
|
+
drift: "low",
|
|
126
|
+
reindex: "entry-locale",
|
|
127
|
+
snapshot: "on-book",
|
|
128
|
+
query: "indexed-column",
|
|
129
|
+
localized: true,
|
|
130
|
+
visibility: ["staff", "customer", "partner"],
|
|
131
|
+
editRole: "none",
|
|
132
|
+
overrideFriction: "none",
|
|
133
|
+
sourceFreshness: "sync",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
path: "primaryCategorySlug",
|
|
137
|
+
class: "structural",
|
|
138
|
+
merge: "source-only",
|
|
139
|
+
drift: "low",
|
|
140
|
+
reindex: "facet-affecting",
|
|
141
|
+
snapshot: "on-book",
|
|
142
|
+
query: "indexed-column",
|
|
143
|
+
localized: false,
|
|
144
|
+
visibility: ["staff", "customer", "partner"],
|
|
145
|
+
editRole: "none",
|
|
146
|
+
overrideFriction: "none",
|
|
147
|
+
sourceFreshness: "sync",
|
|
148
|
+
},
|
|
149
|
+
// ── Tag labels ───────────────────────────────────────────────────────────
|
|
150
|
+
// `products.tags` (a free-form `text[]` column) already projects to
|
|
151
|
+
// `tags[]` from the base product policy. The structured `product_tags`
|
|
152
|
+
// table is a separate, normalized taxonomy — its labels land here under
|
|
153
|
+
// `tagLabels[]` to avoid colliding with the legacy column. One entry per
|
|
154
|
+
// linked tag.
|
|
155
|
+
{
|
|
156
|
+
// Locale-aware since #502 — falls back to canonical `productTags.name`
|
|
157
|
+
// when no `product_tag_translations` row exists for the slice's locale.
|
|
158
|
+
path: "tagLabels[]",
|
|
159
|
+
class: "structural",
|
|
160
|
+
merge: "source-only",
|
|
161
|
+
drift: "low",
|
|
162
|
+
reindex: "entry-locale",
|
|
163
|
+
snapshot: "on-book",
|
|
164
|
+
query: "indexed-column",
|
|
165
|
+
localized: true,
|
|
166
|
+
visibility: ["staff", "customer", "partner"],
|
|
167
|
+
editRole: "none",
|
|
168
|
+
overrideFriction: "none",
|
|
169
|
+
sourceFreshness: "sync",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
path: "tagIds[]",
|
|
173
|
+
class: "structural",
|
|
174
|
+
merge: "source-only",
|
|
175
|
+
drift: "low",
|
|
176
|
+
reindex: "facet-affecting",
|
|
177
|
+
snapshot: "on-book",
|
|
178
|
+
query: "indexed-column",
|
|
179
|
+
localized: false,
|
|
180
|
+
visibility: ["staff", "customer", "partner"],
|
|
181
|
+
editRole: "none",
|
|
182
|
+
overrideFriction: "none",
|
|
183
|
+
sourceFreshness: "sync",
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
/**
|
|
187
|
+
* Resolved taxonomy policy. Compose with `productCatalogPolicy` when
|
|
188
|
+
* building the products registry.
|
|
189
|
+
*/
|
|
190
|
+
export const productTaxonomyCatalogPolicy = defineFieldPolicy(PRODUCT_TAXONOMY_FIELD_POLICY);
|
|
191
|
+
export { PRODUCT_TAXONOMY_FIELD_POLICY };
|
package/dist/events.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface ProductContentChangedEvent {
|
|
|
19
19
|
* push hashes the full current content at push time so this field is
|
|
20
20
|
* not load-bearing for correctness.
|
|
21
21
|
*/
|
|
22
|
-
axis?: "product" | "option" | "day" | "media" | "feature" | "faq" | "location" | "destination" | "translation";
|
|
22
|
+
axis?: "product" | "option" | "day" | "media" | "feature" | "faq" | "location" | "destination" | "category" | "tag" | "translation";
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* Helper for route handlers / services to fire `product.content.changed`
|
package/dist/events.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAE9C,+BAA+B;AAC/B,eAAO,MAAM,6BAA6B,EAAG,yBAAkC,CAAA;AAE/E,MAAM,WAAW,0BAA0B;IACzC,wCAAwC;IACxC,EAAE,EAAE,MAAM,CAAA;IACV;;;;OAIG;IACH,IAAI,CAAC,EACD,SAAS,GACT,QAAQ,GACR,KAAK,GACL,OAAO,GACP,SAAS,GACT,KAAK,GACL,UAAU,GACV,aAAa,GACb,aAAa,CAAA;CAClB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,QAAQ,GAAG,SAAS,EAC9B,OAAO,EAAE,0BAA0B,GAClC,OAAO,CAAC,IAAI,CAAC,CAMf"}
|
|
1
|
+
{"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAE9C,+BAA+B;AAC/B,eAAO,MAAM,6BAA6B,EAAG,yBAAkC,CAAA;AAE/E,MAAM,WAAW,0BAA0B;IACzC,wCAAwC;IACxC,EAAE,EAAE,MAAM,CAAA;IACV;;;;OAIG;IACH,IAAI,CAAC,EACD,SAAS,GACT,QAAQ,GACR,KAAK,GACL,OAAO,GACP,SAAS,GACT,KAAK,GACL,UAAU,GACV,aAAa,GACb,UAAU,GACV,KAAK,GACL,aAAa,CAAA;CAClB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,QAAQ,GAAG,SAAS,EAC9B,OAAO,EAAE,0BAA0B,GAClC,OAAO,CAAC,IAAI,CAAC,CAMf"}
|