@voyant-travel/inventory 0.1.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 +201 -0
- package/dist/action-ledger-drift.d.ts +29 -0
- package/dist/action-ledger-drift.d.ts.map +1 -0
- package/dist/action-ledger-drift.js +338 -0
- package/dist/action-ledger.d.ts +104 -0
- package/dist/action-ledger.d.ts.map +1 -0
- package/dist/action-ledger.js +100 -0
- package/dist/authoring/builder.d.ts +37 -0
- package/dist/authoring/builder.d.ts.map +1 -0
- package/dist/authoring/builder.js +248 -0
- package/dist/authoring/clone-content.d.ts +38 -0
- package/dist/authoring/clone-content.d.ts.map +1 -0
- package/dist/authoring/clone-content.js +367 -0
- package/dist/authoring/clone-pricing.d.ts +9 -0
- package/dist/authoring/clone-pricing.d.ts.map +1 -0
- package/dist/authoring/clone-pricing.js +242 -0
- package/dist/authoring/clone.d.ts +45 -0
- package/dist/authoring/clone.d.ts.map +1 -0
- package/dist/authoring/clone.js +142 -0
- package/dist/authoring/errors.d.ts +21 -0
- package/dist/authoring/errors.d.ts.map +1 -0
- package/dist/authoring/errors.js +13 -0
- package/dist/authoring/extension.d.ts +248 -0
- package/dist/authoring/extension.d.ts.map +1 -0
- package/dist/authoring/extension.js +116 -0
- package/dist/authoring/index.d.ts +12 -0
- package/dist/authoring/index.d.ts.map +1 -0
- package/dist/authoring/index.js +11 -0
- package/dist/authoring/schema.d.ts +85 -0
- package/dist/authoring/schema.d.ts.map +1 -0
- package/dist/authoring/schema.js +16 -0
- package/dist/authoring/service.d.ts +28 -0
- package/dist/authoring/service.d.ts.map +1 -0
- package/dist/authoring/service.js +66 -0
- package/dist/authoring/spec.d.ts +524 -0
- package/dist/authoring/spec.d.ts.map +1 -0
- package/dist/authoring/spec.js +167 -0
- package/dist/authoring/validate.d.ts +17 -0
- package/dist/authoring/validate.d.ts.map +1 -0
- package/dist/authoring/validate.js +83 -0
- package/dist/authoring.d.ts +2 -0
- package/dist/authoring.d.ts.map +1 -0
- package/dist/authoring.js +1 -0
- package/dist/booking-engine/handler-support.d.ts +91 -0
- package/dist/booking-engine/handler-support.d.ts.map +1 -0
- package/dist/booking-engine/handler-support.js +355 -0
- package/dist/booking-engine/handler.d.ts +404 -0
- package/dist/booking-engine/handler.d.ts.map +1 -0
- package/dist/booking-engine/handler.js +398 -0
- package/dist/booking-engine/index.d.ts +8 -0
- package/dist/booking-engine/index.d.ts.map +1 -0
- package/dist/booking-engine/index.js +7 -0
- package/dist/booking-engine.d.ts +2 -0
- package/dist/booking-engine.d.ts.map +1 -0
- package/dist/booking-engine.js +1 -0
- package/dist/booking-extension.d.ts +278 -0
- package/dist/booking-extension.d.ts.map +1 -0
- package/dist/booking-extension.js +161 -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 +43 -0
- package/dist/catalog-policy-destinations.d.ts.map +1 -0
- package/dist/catalog-policy-destinations.js +165 -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/catalog-policy.d.ts +33 -0
- package/dist/catalog-policy.d.ts.map +1 -0
- package/dist/catalog-policy.js +733 -0
- package/dist/content-shape.d.ts +15 -0
- package/dist/content-shape.d.ts.map +1 -0
- package/dist/content-shape.js +28 -0
- package/dist/draft-shape.d.ts +43 -0
- package/dist/draft-shape.d.ts.map +1 -0
- package/dist/draft-shape.js +48 -0
- package/dist/events.d.ts +37 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +32 -0
- package/dist/extras/catalog-policy.d.ts +30 -0
- package/dist/extras/catalog-policy.d.ts.map +1 -0
- package/dist/extras/catalog-policy.js +319 -0
- package/dist/extras/content-shape.d.ts +5 -0
- package/dist/extras/content-shape.d.ts.map +1 -0
- package/dist/extras/content-shape.js +13 -0
- package/dist/extras/draft-shape.d.ts +34 -0
- package/dist/extras/draft-shape.d.ts.map +1 -0
- package/dist/extras/draft-shape.js +69 -0
- package/dist/extras/routes.d.ts +380 -0
- package/dist/extras/routes.d.ts.map +1 -0
- package/dist/extras/routes.js +59 -0
- package/dist/extras/schema-sourced-content.d.ts +254 -0
- package/dist/extras/schema-sourced-content.d.ts.map +1 -0
- package/dist/extras/schema-sourced-content.js +45 -0
- package/dist/extras/schema.d.ts +628 -0
- package/dist/extras/schema.d.ts.map +1 -0
- package/dist/extras/schema.js +87 -0
- package/dist/extras/service-catalog-plane.d.ts +77 -0
- package/dist/extras/service-catalog-plane.d.ts.map +1 -0
- package/dist/extras/service-catalog-plane.js +219 -0
- package/dist/extras/service-content-synthesizer.d.ts +41 -0
- package/dist/extras/service-content-synthesizer.d.ts.map +1 -0
- package/dist/extras/service-content-synthesizer.js +138 -0
- package/dist/extras/service-content.d.ts +48 -0
- package/dist/extras/service-content.d.ts.map +1 -0
- package/dist/extras/service-content.js +253 -0
- package/dist/extras/service.d.ts +185 -0
- package/dist/extras/service.d.ts.map +1 -0
- package/dist/extras/service.js +96 -0
- package/dist/extras/validation.d.ts +437 -0
- package/dist/extras/validation.d.ts.map +1 -0
- package/dist/extras/validation.js +149 -0
- package/dist/extras.d.ts +267 -0
- package/dist/extras.d.ts.map +1 -0
- package/dist/extras.js +19 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/interface.d.ts +5869 -0
- package/dist/interface.d.ts.map +1 -0
- package/dist/interface.js +54 -0
- package/dist/public-routes.d.ts +2 -0
- package/dist/public-routes.d.ts.map +1 -0
- package/dist/public-routes.js +1 -0
- package/dist/public-validation.d.ts +2 -0
- package/dist/public-validation.d.ts.map +1 -0
- package/dist/public-validation.js +1 -0
- package/dist/read-model.d.ts +25 -0
- package/dist/read-model.d.ts.map +1 -0
- package/dist/read-model.js +99 -0
- package/dist/route-env.d.ts +22 -0
- package/dist/route-env.d.ts.map +1 -0
- package/dist/route-env.js +1 -0
- package/dist/routes-associations.d.ts +164 -0
- package/dist/routes-associations.d.ts.map +1 -0
- package/dist/routes-associations.js +100 -0
- package/dist/routes-catalog.d.ts +436 -0
- package/dist/routes-catalog.d.ts.map +1 -0
- package/dist/routes-catalog.js +104 -0
- package/dist/routes-configuration.d.ts +773 -0
- package/dist/routes-configuration.d.ts.map +1 -0
- package/dist/routes-configuration.js +364 -0
- package/dist/routes-content.d.ts +74 -0
- package/dist/routes-content.d.ts.map +1 -0
- package/dist/routes-content.js +117 -0
- package/dist/routes-core.d.ts +331 -0
- package/dist/routes-core.d.ts.map +1 -0
- package/dist/routes-core.js +95 -0
- package/dist/routes-itinerary.d.ts +759 -0
- package/dist/routes-itinerary.d.ts.map +1 -0
- package/dist/routes-itinerary.js +387 -0
- package/dist/routes-maintenance.d.ts +32 -0
- package/dist/routes-maintenance.d.ts.map +1 -0
- package/dist/routes-maintenance.js +14 -0
- package/dist/routes-media.d.ts +634 -0
- package/dist/routes-media.d.ts.map +1 -0
- package/dist/routes-media.js +245 -0
- package/dist/routes-merchandising.d.ts +1120 -0
- package/dist/routes-merchandising.d.ts.map +1 -0
- package/dist/routes-merchandising.js +377 -0
- package/dist/routes-options.d.ts +363 -0
- package/dist/routes-options.d.ts.map +1 -0
- package/dist/routes-options.js +173 -0
- package/dist/routes-public.d.ts +776 -0
- package/dist/routes-public.d.ts.map +1 -0
- package/dist/routes-public.js +119 -0
- package/dist/routes-translations.d.ts +489 -0
- package/dist/routes-translations.d.ts.map +1 -0
- package/dist/routes-translations.js +258 -0
- package/dist/routes.d.ts +5097 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +64 -0
- package/dist/schema-core.d.ts +1238 -0
- package/dist/schema-core.d.ts.map +1 -0
- package/dist/schema-core.js +157 -0
- package/dist/schema-itinerary.d.ts +1169 -0
- package/dist/schema-itinerary.d.ts.map +1 -0
- package/dist/schema-itinerary.js +130 -0
- package/dist/schema-relations.d.ts +117 -0
- package/dist/schema-relations.d.ts.map +1 -0
- package/dist/schema-relations.js +192 -0
- package/dist/schema-settings.d.ts +1800 -0
- package/dist/schema-settings.d.ts.map +1 -0
- package/dist/schema-settings.js +220 -0
- package/dist/schema-shared.d.ts +15 -0
- package/dist/schema-shared.d.ts.map +1 -0
- package/dist/schema-shared.js +91 -0
- package/dist/schema-sourced-content.d.ts +262 -0
- package/dist/schema-sourced-content.d.ts.map +1 -0
- package/dist/schema-sourced-content.js +69 -0
- package/dist/schema-taxonomy.d.ts +1363 -0
- package/dist/schema-taxonomy.d.ts.map +1 -0
- package/dist/schema-taxonomy.js +203 -0
- package/dist/schema.d.ts +10 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +9 -0
- package/dist/service-aggregates.d.ts +29 -0
- package/dist/service-aggregates.d.ts.map +1 -0
- package/dist/service-aggregates.js +56 -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 +143 -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 +179 -0
- package/dist/service-catalog-plane.d.ts.map +1 -0
- package/dist/service-catalog-plane.js +431 -0
- package/dist/service-catalog.d.ts +251 -0
- package/dist/service-catalog.d.ts.map +1 -0
- package/dist/service-catalog.js +517 -0
- package/dist/service-configuration.d.ts +261 -0
- package/dist/service-configuration.d.ts.map +1 -0
- package/dist/service-configuration.js +343 -0
- package/dist/service-content-owned.d.ts +68 -0
- package/dist/service-content-owned.d.ts.map +1 -0
- package/dist/service-content-owned.js +329 -0
- package/dist/service-content-synthesizer.d.ts +90 -0
- package/dist/service-content-synthesizer.d.ts.map +1 -0
- package/dist/service-content-synthesizer.js +178 -0
- package/dist/service-content.d.ts +106 -0
- package/dist/service-content.d.ts.map +1 -0
- package/dist/service-content.js +388 -0
- package/dist/service-core.d.ts +194 -0
- package/dist/service-core.d.ts.map +1 -0
- package/dist/service-core.js +213 -0
- package/dist/service-delivery-formats.d.ts +58 -0
- package/dist/service-delivery-formats.d.ts.map +1 -0
- package/dist/service-delivery-formats.js +107 -0
- package/dist/service-destinations.d.ts +223 -0
- package/dist/service-destinations.d.ts.map +1 -0
- package/dist/service-destinations.js +310 -0
- package/dist/service-itinerary-history.d.ts +457 -0
- package/dist/service-itinerary-history.d.ts.map +1 -0
- package/dist/service-itinerary-history.js +135 -0
- package/dist/service-itinerary.d.ts +1149 -0
- package/dist/service-itinerary.d.ts.map +1 -0
- package/dist/service-itinerary.js +419 -0
- package/dist/service-media.d.ts +272 -0
- package/dist/service-media.d.ts.map +1 -0
- package/dist/service-media.js +320 -0
- package/dist/service-merchandising.d.ts +184 -0
- package/dist/service-merchandising.d.ts.map +1 -0
- package/dist/service-merchandising.js +181 -0
- package/dist/service-option-translations.d.ts +268 -0
- package/dist/service-option-translations.d.ts.map +1 -0
- package/dist/service-option-translations.js +300 -0
- package/dist/service-options.d.ts +181 -0
- package/dist/service-options.d.ts.map +1 -0
- package/dist/service-options.js +179 -0
- package/dist/service-product-destinations.d.ts +37 -0
- package/dist/service-product-destinations.d.ts.map +1 -0
- package/dist/service-product-destinations.js +94 -0
- package/dist/service-public.d.ts +664 -0
- package/dist/service-public.d.ts.map +1 -0
- package/dist/service-public.js +374 -0
- package/dist/service-taxonomy.d.ts +197 -0
- package/dist/service-taxonomy.d.ts.map +1 -0
- package/dist/service-taxonomy.js +221 -0
- package/dist/service.d.ts +3929 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +28 -0
- package/dist/tasks/brochure-printers.d.ts +31 -0
- package/dist/tasks/brochure-printers.d.ts.map +1 -0
- package/dist/tasks/brochure-printers.js +149 -0
- package/dist/tasks/brochure-templates.d.ts +36 -0
- package/dist/tasks/brochure-templates.d.ts.map +1 -0
- package/dist/tasks/brochure-templates.js +110 -0
- package/dist/tasks/brochures.d.ts +43 -0
- package/dist/tasks/brochures.d.ts.map +1 -0
- package/dist/tasks/brochures.js +72 -0
- package/dist/tasks/generate-pdf.d.ts +8 -0
- package/dist/tasks/generate-pdf.d.ts.map +1 -0
- package/dist/tasks/generate-pdf.js +106 -0
- package/dist/tasks/index.d.ts +5 -0
- package/dist/tasks/index.d.ts.map +1 -0
- package/dist/tasks/index.js +4 -0
- package/dist/tasks/pdf-text.d.ts +2 -0
- package/dist/tasks/pdf-text.d.ts.map +1 -0
- package/dist/tasks/pdf-text.js +40 -0
- package/dist/tasks.d.ts +2 -0
- package/dist/tasks.d.ts.map +1 -0
- package/dist/tasks.js +1 -0
- package/dist/validation-catalog.d.ts +2 -0
- package/dist/validation-catalog.d.ts.map +1 -0
- package/dist/validation-catalog.js +3 -0
- package/dist/validation-config.d.ts +2 -0
- package/dist/validation-config.d.ts.map +1 -0
- package/dist/validation-config.js +3 -0
- package/dist/validation-content.d.ts +2 -0
- package/dist/validation-content.d.ts.map +1 -0
- package/dist/validation-content.js +3 -0
- package/dist/validation-core.d.ts +2 -0
- package/dist/validation-core.d.ts.map +1 -0
- package/dist/validation-core.js +3 -0
- package/dist/validation-public.d.ts +2 -0
- package/dist/validation-public.d.ts.map +1 -0
- package/dist/validation-public.js +3 -0
- package/dist/validation-shared.d.ts +2 -0
- package/dist/validation-shared.d.ts.map +1 -0
- package/dist/validation-shared.js +3 -0
- package/dist/validation.d.ts +2 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +3 -0
- package/package.json +204 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owned-product content builder.
|
|
3
|
+
*
|
|
4
|
+
* Projects an owned product (the products module's own tables) into the
|
|
5
|
+
* vertical-shared `ProductContent` shape so the catalog content service
|
|
6
|
+
* can return rich detail for owned products via the same API that
|
|
7
|
+
* sourced products go through (`getProductContent`).
|
|
8
|
+
*
|
|
9
|
+
* Per sourced-content §3.3: `getProductContent` is the unified read
|
|
10
|
+
* surface for owned + sourced. v1 of service-content.ts only handled
|
|
11
|
+
* sourced (returning null for owned); this helper closes that gap.
|
|
12
|
+
*
|
|
13
|
+
* Locale resolution uses the catalog plane's standard
|
|
14
|
+
* `pickBestCachedLocale` against `product_translations` and
|
|
15
|
+
* `product_option_translations` rows — exact > language-match >
|
|
16
|
+
* fallback-chain > any. Matches the same scoring semantics the sourced
|
|
17
|
+
* cache reads use, so the same `BookingDraft.scope.locale` chain works
|
|
18
|
+
* for both owned and sourced products.
|
|
19
|
+
*
|
|
20
|
+
* The projection reads in parallel:
|
|
21
|
+
* - `products` row → product summary + tags + supplier
|
|
22
|
+
* - `product_translations` → localized name + description per locale
|
|
23
|
+
* - `product_itineraries` + `product_days` → itinerary days (no
|
|
24
|
+
* translation table today; falls back to source)
|
|
25
|
+
* - `product_options` + `product_option_translations` → options +
|
|
26
|
+
* localized labels
|
|
27
|
+
* - `product_media` → hero + gallery
|
|
28
|
+
*
|
|
29
|
+
* Day translations don't exist in the schema yet — when
|
|
30
|
+
* `product_day_translations` lands, this function picks them up the
|
|
31
|
+
* same way.
|
|
32
|
+
*/
|
|
33
|
+
import { pickBestCachedLocale } from "@voyant-travel/catalog";
|
|
34
|
+
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
|
35
|
+
import { productContentSchema, validateProductContent, } from "./content-shape.js";
|
|
36
|
+
import { productDays, productItineraries, productMedia, productOptions, productOptionTranslations, products, productTranslations, } from "./schema.js";
|
|
37
|
+
/**
|
|
38
|
+
* Read the owned product + related rows and project to `ProductContent`,
|
|
39
|
+
* resolving translations against the supplied locale-preference chain.
|
|
40
|
+
* Returns null when the product doesn't exist.
|
|
41
|
+
*/
|
|
42
|
+
export async function buildOwnedProductContent(db, entityId, options) {
|
|
43
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle's generic returning Pg infers the row shape but the AnyDrizzleDb wrapper widens it -- owner: inventory; existing suppression is intentional pending typed cleanup.
|
|
44
|
+
const productRow = (await db.select().from(products).where(eq(products.id, entityId)).limit(1))[0];
|
|
45
|
+
if (!productRow)
|
|
46
|
+
return null;
|
|
47
|
+
const [optionRows, mediaRows, itineraryRows, productTrns] = await Promise.all([
|
|
48
|
+
db
|
|
49
|
+
.select()
|
|
50
|
+
.from(productOptions)
|
|
51
|
+
.where(eq(productOptions.productId, entityId))
|
|
52
|
+
.orderBy(asc(productOptions.sortOrder)),
|
|
53
|
+
db
|
|
54
|
+
.select()
|
|
55
|
+
.from(productMedia)
|
|
56
|
+
.where(eq(productMedia.productId, entityId))
|
|
57
|
+
.orderBy(asc(productMedia.sortOrder), asc(productMedia.createdAt)),
|
|
58
|
+
db
|
|
59
|
+
.select()
|
|
60
|
+
.from(productItineraries)
|
|
61
|
+
.where(eq(productItineraries.productId, entityId))
|
|
62
|
+
.orderBy(asc(productItineraries.sortOrder)),
|
|
63
|
+
db.select().from(productTranslations).where(eq(productTranslations.productId, entityId)),
|
|
64
|
+
]);
|
|
65
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle row shape -- owner: inventory; existing suppression is intentional pending typed cleanup.
|
|
66
|
+
const defaultItinerary = itineraryRows.find((it) => it.isDefault) ?? itineraryRows[0];
|
|
67
|
+
const days = defaultItinerary
|
|
68
|
+
? await db
|
|
69
|
+
.select()
|
|
70
|
+
.from(productDays)
|
|
71
|
+
.where(and(eq(productDays.itineraryId, defaultItinerary.id)))
|
|
72
|
+
.orderBy(asc(productDays.dayNumber))
|
|
73
|
+
: [];
|
|
74
|
+
// Pull option translations in one round-trip for every option in this
|
|
75
|
+
// product. Fan-out per-option would be wasteful when products
|
|
76
|
+
// typically have a small number of options.
|
|
77
|
+
const optionIds = optionRows.map((o) => o.id);
|
|
78
|
+
const optionTrns = optionIds.length > 0
|
|
79
|
+
? await db
|
|
80
|
+
.select()
|
|
81
|
+
.from(productOptionTranslations)
|
|
82
|
+
.where(inArray(productOptionTranslations.optionId, optionIds))
|
|
83
|
+
: [];
|
|
84
|
+
// Pick the best product-level translation. Falls through to the
|
|
85
|
+
// source row's name/description when no translation row matches —
|
|
86
|
+
// pickBestCachedLocale returns the closest available, so the
|
|
87
|
+
// server-locale chip reflects what the user actually sees.
|
|
88
|
+
const bestProductTrn = pickBestProductTranslation(productTrns, options.preferredLocales);
|
|
89
|
+
const productServedLocale = bestProductTrn?.served_locale ?? sourceLocaleFor(productRow);
|
|
90
|
+
const productMatchKind = bestProductTrn?.match_kind ?? "any";
|
|
91
|
+
const cover = mediaRows.find((m) => m.isCover) ?? mediaRows[0] ?? undefined;
|
|
92
|
+
const localizedName = bestProductTrn?.candidate.name ?? productRow.name;
|
|
93
|
+
const localizedDescription = bestProductTrn?.candidate.shortDescription ??
|
|
94
|
+
bestProductTrn?.candidate.description ??
|
|
95
|
+
productRow.description ??
|
|
96
|
+
null;
|
|
97
|
+
const localizedInclusions = bestProductTrn?.candidate.inclusionsHtml ?? productRow.inclusionsHtml ?? null;
|
|
98
|
+
const localizedExclusions = bestProductTrn?.candidate.exclusionsHtml ?? productRow.exclusionsHtml ?? null;
|
|
99
|
+
const localizedTerms = bestProductTrn?.candidate.termsHtml ?? productRow.termsHtml ?? null;
|
|
100
|
+
const content = productContentSchema.parse({
|
|
101
|
+
product: {
|
|
102
|
+
id: productRow.id,
|
|
103
|
+
name: localizedName,
|
|
104
|
+
status: productRow.status,
|
|
105
|
+
description: localizedDescription,
|
|
106
|
+
inclusions_html: localizedInclusions,
|
|
107
|
+
exclusions_html: localizedExclusions,
|
|
108
|
+
terms_html: localizedTerms,
|
|
109
|
+
contract_template_id: productRow.contractTemplateId ?? null,
|
|
110
|
+
contractTemplateId: productRow.contractTemplateId ?? null,
|
|
111
|
+
hero_image_url: cover?.url ?? null,
|
|
112
|
+
duration_days: estimateDurationDays(days, productRow),
|
|
113
|
+
start_date: dateToIso(productRow.startDate),
|
|
114
|
+
end_date: dateToIso(productRow.endDate),
|
|
115
|
+
sell_currency: productRow.sellCurrency,
|
|
116
|
+
supplier: productRow.supplierId ?? null,
|
|
117
|
+
tags: Array.isArray(productRow.tags) ? productRow.tags : [],
|
|
118
|
+
},
|
|
119
|
+
options: optionRows.map((opt) => {
|
|
120
|
+
const trnsForOption = optionTrns.filter((t) => t.optionId === opt.id);
|
|
121
|
+
const bestOptionTrn = pickBestOptionTranslation(trnsForOption, options.preferredLocales);
|
|
122
|
+
return {
|
|
123
|
+
id: opt.id,
|
|
124
|
+
name: bestOptionTrn?.candidate.name ?? opt.name,
|
|
125
|
+
description: bestOptionTrn?.candidate.shortDescription ??
|
|
126
|
+
bestOptionTrn?.candidate.description ??
|
|
127
|
+
opt.description ??
|
|
128
|
+
null,
|
|
129
|
+
units: [],
|
|
130
|
+
inclusions: [],
|
|
131
|
+
};
|
|
132
|
+
}),
|
|
133
|
+
days: days.map((d) => ({
|
|
134
|
+
// Days don't have a translation table today; source values flow
|
|
135
|
+
// through. When `product_day_translations` lands, slot in here
|
|
136
|
+
// with the same pickBestCachedLocale call.
|
|
137
|
+
day_number: d.dayNumber,
|
|
138
|
+
title: d.title ?? null,
|
|
139
|
+
description: d.description ?? null,
|
|
140
|
+
location: d.location ?? null,
|
|
141
|
+
// Per-day hero — prefer the cover, fall back to the first sorted
|
|
142
|
+
// image attached to this day in `product_media`.
|
|
143
|
+
hero_image_url: pickDayHeroImage(mediaRows, d.id),
|
|
144
|
+
services: [],
|
|
145
|
+
})),
|
|
146
|
+
media: mediaRows
|
|
147
|
+
.filter((m) => !m.isBrochure)
|
|
148
|
+
.map((m) => ({
|
|
149
|
+
url: m.url,
|
|
150
|
+
type: mediaType(m.mediaType),
|
|
151
|
+
caption: m.altText ?? null,
|
|
152
|
+
alt: m.altText ?? null,
|
|
153
|
+
})),
|
|
154
|
+
policies: [],
|
|
155
|
+
// Owned products derive departures from `availability_slots`. Pull
|
|
156
|
+
// future-or-current slots only so the catalog sheet doesn't drown
|
|
157
|
+
// operators in expired departures; ordering is chronological so the
|
|
158
|
+
// UI can group consecutive months without sorting client-side.
|
|
159
|
+
departures: await readOwnedProductDepartures(db, entityId, productRow.sellCurrency),
|
|
160
|
+
});
|
|
161
|
+
const validation = validateProductContent(content);
|
|
162
|
+
if (!validation.valid) {
|
|
163
|
+
throw new Error(`owned product ${entityId} projection failed validation: ${validation.reason}`);
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
content,
|
|
167
|
+
servedLocale: productServedLocale,
|
|
168
|
+
matchKind: productMatchKind,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Pick the best image to surface on an itinerary day card. Filters
|
|
173
|
+
* media rows to entries with a matching `dayId`, ignores brochures and
|
|
174
|
+
* non-images, then prefers `isCover === true` before falling back to
|
|
175
|
+
* the first sorted entry. `media-rows` is the same list the parent
|
|
176
|
+
* projection already pulled; no extra round-trip.
|
|
177
|
+
*/
|
|
178
|
+
function pickDayHeroImage(mediaRows, dayId) {
|
|
179
|
+
const dayImages = mediaRows.filter((m) => m.dayId === dayId && m.mediaType === "image" && !m.isBrochure);
|
|
180
|
+
if (dayImages.length === 0)
|
|
181
|
+
return null;
|
|
182
|
+
const cover = dayImages.find((m) => m.isCover);
|
|
183
|
+
return (cover ?? dayImages[0])?.url ?? null;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Map future availability_slots → ProductDeparture[]. Raw SQL keeps the
|
|
187
|
+
* products module from depending on `@voyant-travel/operations` (cross-
|
|
188
|
+
* module schema coupling is avoided per the workspace's separation).
|
|
189
|
+
*/
|
|
190
|
+
async function readOwnedProductDepartures(db, productId, sellCurrency) {
|
|
191
|
+
try {
|
|
192
|
+
const result = await db.execute(sql `
|
|
193
|
+
SELECT
|
|
194
|
+
id,
|
|
195
|
+
starts_at,
|
|
196
|
+
ends_at,
|
|
197
|
+
status,
|
|
198
|
+
initial_pax,
|
|
199
|
+
remaining_pax
|
|
200
|
+
FROM availability_slots
|
|
201
|
+
WHERE product_id = ${productId}
|
|
202
|
+
AND starts_at >= NOW()
|
|
203
|
+
ORDER BY starts_at ASC
|
|
204
|
+
LIMIT 365
|
|
205
|
+
`);
|
|
206
|
+
const rows = Array.isArray(result) ? result : (result.rows ?? []);
|
|
207
|
+
return rows
|
|
208
|
+
.map((raw) => {
|
|
209
|
+
const row = raw;
|
|
210
|
+
const id = typeof row.id === "string" ? row.id : null;
|
|
211
|
+
const startsAt = isoOrNull(row.starts_at);
|
|
212
|
+
if (!id || !startsAt)
|
|
213
|
+
return null;
|
|
214
|
+
const status = typeof row.status === "string" ? row.status : null;
|
|
215
|
+
const capacity = numberOrNull(row.initial_pax);
|
|
216
|
+
const remaining = numberOrNull(row.remaining_pax);
|
|
217
|
+
return {
|
|
218
|
+
id,
|
|
219
|
+
starts_at: startsAt,
|
|
220
|
+
ends_at: isoOrNull(row.ends_at),
|
|
221
|
+
status,
|
|
222
|
+
capacity,
|
|
223
|
+
remaining,
|
|
224
|
+
// Lowest price hint is for display only; the live engine
|
|
225
|
+
// resolves the actual quote. Fall back to the product's base
|
|
226
|
+
// sell_amount via the content shape's parent currency.
|
|
227
|
+
lowest_price_cents: null,
|
|
228
|
+
currency: sellCurrency,
|
|
229
|
+
note: null,
|
|
230
|
+
};
|
|
231
|
+
})
|
|
232
|
+
.filter((row) => row !== null);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// availability_slots may be absent in trimmed test fixtures —
|
|
236
|
+
// empty list is a safe default (matches "on-request" behavior).
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function isoOrNull(value) {
|
|
241
|
+
if (value instanceof Date) {
|
|
242
|
+
const ms = value.getTime();
|
|
243
|
+
return Number.isFinite(ms) ? value.toISOString() : null;
|
|
244
|
+
}
|
|
245
|
+
if (typeof value === "string" && value.length > 0) {
|
|
246
|
+
const d = new Date(value);
|
|
247
|
+
return Number.isFinite(d.getTime()) ? d.toISOString() : null;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
function numberOrNull(value) {
|
|
252
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
253
|
+
return value;
|
|
254
|
+
if (typeof value === "string") {
|
|
255
|
+
const n = Number(value);
|
|
256
|
+
return Number.isFinite(n) ? n : null;
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
function pickBestProductTranslation(rows, preferred) {
|
|
261
|
+
if (rows.length === 0)
|
|
262
|
+
return null;
|
|
263
|
+
const candidates = rows.map((r) => ({
|
|
264
|
+
locale: r.languageTag,
|
|
265
|
+
name: r.name,
|
|
266
|
+
shortDescription: r.shortDescription,
|
|
267
|
+
description: r.description,
|
|
268
|
+
inclusionsHtml: r.inclusionsHtml,
|
|
269
|
+
exclusionsHtml: r.exclusionsHtml,
|
|
270
|
+
termsHtml: r.termsHtml,
|
|
271
|
+
}));
|
|
272
|
+
return pickBestCachedLocale(candidates, preferred);
|
|
273
|
+
}
|
|
274
|
+
function pickBestOptionTranslation(rows, preferred) {
|
|
275
|
+
if (rows.length === 0)
|
|
276
|
+
return null;
|
|
277
|
+
const candidates = rows.map((r) => ({
|
|
278
|
+
locale: r.languageTag,
|
|
279
|
+
name: r.name,
|
|
280
|
+
shortDescription: r.shortDescription,
|
|
281
|
+
description: r.description,
|
|
282
|
+
}));
|
|
283
|
+
return pickBestCachedLocale(candidates, preferred);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* When no translation rows exist for the product, the source row's
|
|
287
|
+
* `name` + `description` are surfaced. We don't know what locale they
|
|
288
|
+
* were authored in — operators tend to author in their default
|
|
289
|
+
* deployment locale (commonly en-GB / en-US for UK / US deployments).
|
|
290
|
+
* The caller's `defaultSourceLocale` would be a cleaner fix; for now
|
|
291
|
+
* we report `und` (BCP 47 "undetermined") so the storefront chip
|
|
292
|
+
* doesn't claim a specific locale we can't verify.
|
|
293
|
+
*/
|
|
294
|
+
function sourceLocaleFor(_productRow) {
|
|
295
|
+
return "und";
|
|
296
|
+
}
|
|
297
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
298
|
+
// Helpers
|
|
299
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
300
|
+
function estimateDurationDays(days, productRow) {
|
|
301
|
+
if (days.length > 0) {
|
|
302
|
+
const max = Math.max(...days.map((d) => d.dayNumber));
|
|
303
|
+
return Number.isFinite(max) && max > 0 ? max : null;
|
|
304
|
+
}
|
|
305
|
+
if (productRow.startDate && productRow.endDate) {
|
|
306
|
+
const start = new Date(productRow.startDate);
|
|
307
|
+
const end = new Date(productRow.endDate);
|
|
308
|
+
if (!Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime())) {
|
|
309
|
+
const diffMs = end.getTime() - start.getTime();
|
|
310
|
+
const days = Math.round(diffMs / (24 * 60 * 60 * 1000));
|
|
311
|
+
return days > 0 ? days : null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
function dateToIso(value) {
|
|
317
|
+
if (!value)
|
|
318
|
+
return null;
|
|
319
|
+
if (typeof value === "string")
|
|
320
|
+
return value;
|
|
321
|
+
return value.toISOString().slice(0, 10);
|
|
322
|
+
}
|
|
323
|
+
function mediaType(value) {
|
|
324
|
+
if (value === "video")
|
|
325
|
+
return "video";
|
|
326
|
+
if (value === "document")
|
|
327
|
+
return "document";
|
|
328
|
+
return "image";
|
|
329
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product content synthesizer — fallback for thin adapters that declare
|
|
3
|
+
* `supportsContentFetch: false`.
|
|
4
|
+
*
|
|
5
|
+
* Per sourced-content §3.6, the synthesizer's job is to produce the
|
|
6
|
+
* **most complete** content payload it legitimately can from what the
|
|
7
|
+
* catalog plane already knows: the durable sourced-entry projection,
|
|
8
|
+
* the editorial overlay store, and plane-level provenance metadata.
|
|
9
|
+
*
|
|
10
|
+
* Returns the same shape as a real `getContent` so consumers (detail
|
|
11
|
+
* pages, the booking journey, snapshot capture) cannot tell the two
|
|
12
|
+
* paths apart from the type signature. Fields the synthesizer cannot
|
|
13
|
+
* fill render as typed empty states — never as missing properties.
|
|
14
|
+
*
|
|
15
|
+
* What it does NOT do:
|
|
16
|
+
* - Mine prior snapshot rows (those carry customer-scoped PII and
|
|
17
|
+
* are point-in-time, not generic content).
|
|
18
|
+
* - Machine-translate (that's the adapter's `getContent` +
|
|
19
|
+
* `machine_translated: true` path).
|
|
20
|
+
* - Synthesize plausible-but-unverified fields ("hotels usually have
|
|
21
|
+
* a pool" is not a basis for an amenity).
|
|
22
|
+
* - Cache its own output (inputs change at projection / overlay
|
|
23
|
+
* write time; a cache layer would just complicate invalidation).
|
|
24
|
+
*/
|
|
25
|
+
import type { ProvenanceReadResult } from "@voyant-travel/catalog";
|
|
26
|
+
import type { AnyDrizzleDb } from "@voyant-travel/db";
|
|
27
|
+
import { type ProductContent } from "./content-shape.js";
|
|
28
|
+
export interface SynthesizeProductContentOptions {
|
|
29
|
+
/**
|
|
30
|
+
* The provenance read for the entity. Carries the durable projection
|
|
31
|
+
* captured at discover() time — the synthesizer's primary input.
|
|
32
|
+
*/
|
|
33
|
+
provenance: Extract<ProvenanceReadResult, {
|
|
34
|
+
kind: "sourced";
|
|
35
|
+
}>;
|
|
36
|
+
/**
|
|
37
|
+
* Optional locale-aware overlays. When present, layered on top via
|
|
38
|
+
* RFC 6901 JSON pointer paths. Caller passes overlays already
|
|
39
|
+
* filtered to the active scope (locale, audience, market) per the
|
|
40
|
+
* variant fallback chain.
|
|
41
|
+
*/
|
|
42
|
+
overlays?: ReadonlyArray<{
|
|
43
|
+
field_path: string;
|
|
44
|
+
value: unknown;
|
|
45
|
+
}>;
|
|
46
|
+
}
|
|
47
|
+
export interface SynthesizedProductContent {
|
|
48
|
+
/** The synthesized payload. Validated against the products/v1 schema. */
|
|
49
|
+
content: ProductContent;
|
|
50
|
+
/** Schema version stamped on the synthesized payload. */
|
|
51
|
+
content_schema_version: string;
|
|
52
|
+
/**
|
|
53
|
+
* The locale this synthesis represents. Synthesizer output isn't
|
|
54
|
+
* locale-pinned at the projection level — projections capture the
|
|
55
|
+
* adapter's canonical fields. Caller's locale is reported back so
|
|
56
|
+
* UI / SWR machinery can stamp it consistently.
|
|
57
|
+
*/
|
|
58
|
+
served_locale: string;
|
|
59
|
+
/**
|
|
60
|
+
* Whether the upstream adapter is rich (this synthesis is a fallback)
|
|
61
|
+
* vs. thin (this synthesis is the real path).
|
|
62
|
+
*/
|
|
63
|
+
source_kind: string;
|
|
64
|
+
/**
|
|
65
|
+
* Source provenance hints for UI badges ("served by …", "limited
|
|
66
|
+
* content available").
|
|
67
|
+
*/
|
|
68
|
+
source_provider?: string;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Synthesize a `ProductContent` payload from projection + overlay +
|
|
72
|
+
* plane metadata. Pure-ish: takes a provenance read result and an
|
|
73
|
+
* optional overlay list; returns a typed-empty-state-filled content
|
|
74
|
+
* blob.
|
|
75
|
+
*/
|
|
76
|
+
export declare function synthesizeProductContent(scope: {
|
|
77
|
+
locale: string;
|
|
78
|
+
}, options: SynthesizeProductContentOptions): SynthesizedProductContent;
|
|
79
|
+
/**
|
|
80
|
+
* Drizzle-bound convenience wrapper: fetches active overlays for the
|
|
81
|
+
* entity and runs the synthesizer. Verticals call this from their
|
|
82
|
+
* content service when no `getContent` adapter is registered (or when
|
|
83
|
+
* the synthesizer is the fallback for missing-cache + thin-adapter).
|
|
84
|
+
*/
|
|
85
|
+
export declare function synthesizeProductContentFromDb(db: AnyDrizzleDb, scope: {
|
|
86
|
+
locale: string;
|
|
87
|
+
}, provenance: Extract<ProvenanceReadResult, {
|
|
88
|
+
kind: "sourced";
|
|
89
|
+
}>): Promise<SynthesizedProductContent>;
|
|
90
|
+
//# sourceMappingURL=service-content-synthesizer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-content-synthesizer.d.ts","sourceRoot":"","sources":["../src/service-content-synthesizer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAA;AAElE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAErD,OAAO,EAEL,KAAK,cAAc,EAEpB,MAAM,oBAAoB,CAAA;AAE3B,MAAM,WAAW,+BAA+B;IAC9C;;;OAGG;IACH,UAAU,EAAE,OAAO,CAAC,oBAAoB,EAAE;QAAE,IAAI,EAAE,SAAS,CAAA;KAAE,CAAC,CAAA;IAC9D;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,aAAa,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAA;CACjE;AAED,MAAM,WAAW,yBAAyB;IACxC,yEAAyE;IACzE,OAAO,EAAE,cAAc,CAAA;IACvB,yDAAyD;IACzD,sBAAsB,EAAE,MAAM,CAAA;IAC9B;;;;;OAKG;IACH,aAAa,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAA;IACnB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,EACzB,OAAO,EAAE,+BAA+B,GACvC,yBAAyB,CAoC3B;AAED;;;;;GAKG;AACH,wBAAsB,8BAA8B,CAClD,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,EACzB,UAAU,EAAE,OAAO,CAAC,oBAAoB,EAAE;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC,GAC7D,OAAO,CAAC,yBAAyB,CAAC,CAMpC"}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product content synthesizer — fallback for thin adapters that declare
|
|
3
|
+
* `supportsContentFetch: false`.
|
|
4
|
+
*
|
|
5
|
+
* Per sourced-content §3.6, the synthesizer's job is to produce the
|
|
6
|
+
* **most complete** content payload it legitimately can from what the
|
|
7
|
+
* catalog plane already knows: the durable sourced-entry projection,
|
|
8
|
+
* the editorial overlay store, and plane-level provenance metadata.
|
|
9
|
+
*
|
|
10
|
+
* Returns the same shape as a real `getContent` so consumers (detail
|
|
11
|
+
* pages, the booking journey, snapshot capture) cannot tell the two
|
|
12
|
+
* paths apart from the type signature. Fields the synthesizer cannot
|
|
13
|
+
* fill render as typed empty states — never as missing properties.
|
|
14
|
+
*
|
|
15
|
+
* What it does NOT do:
|
|
16
|
+
* - Mine prior snapshot rows (those carry customer-scoped PII and
|
|
17
|
+
* are point-in-time, not generic content).
|
|
18
|
+
* - Machine-translate (that's the adapter's `getContent` +
|
|
19
|
+
* `machine_translated: true` path).
|
|
20
|
+
* - Synthesize plausible-but-unverified fields ("hotels usually have
|
|
21
|
+
* a pool" is not a basis for an amenity).
|
|
22
|
+
* - Cache its own output (inputs change at projection / overlay
|
|
23
|
+
* write time; a cache layer would just complicate invalidation).
|
|
24
|
+
*/
|
|
25
|
+
import { fetchOverlaysForEntity, mergeOverlaysIntoContent } from "@voyant-travel/catalog";
|
|
26
|
+
import { PRODUCTS_CONTENT_SCHEMA_VERSION, productContentSchema, } from "./content-shape.js";
|
|
27
|
+
/**
|
|
28
|
+
* Synthesize a `ProductContent` payload from projection + overlay +
|
|
29
|
+
* plane metadata. Pure-ish: takes a provenance read result and an
|
|
30
|
+
* optional overlay list; returns a typed-empty-state-filled content
|
|
31
|
+
* blob.
|
|
32
|
+
*/
|
|
33
|
+
export function synthesizeProductContent(scope, options) {
|
|
34
|
+
const projection = options.provenance.projection;
|
|
35
|
+
const product = pickProductSummary(projection, options.provenance);
|
|
36
|
+
const media = pickMedia(projection);
|
|
37
|
+
const policies = pickPolicies(projection);
|
|
38
|
+
const baseContent = {
|
|
39
|
+
product,
|
|
40
|
+
options: [],
|
|
41
|
+
days: [],
|
|
42
|
+
media,
|
|
43
|
+
policies,
|
|
44
|
+
departures: [],
|
|
45
|
+
};
|
|
46
|
+
let merged = baseContent;
|
|
47
|
+
if (options.overlays && options.overlays.length > 0) {
|
|
48
|
+
const result = mergeOverlaysIntoContent(baseContent, options.overlays, {
|
|
49
|
+
validate(p) {
|
|
50
|
+
const r = productContentSchema.safeParse(p);
|
|
51
|
+
return r.success
|
|
52
|
+
? { valid: true }
|
|
53
|
+
: { valid: false, reason: r.error.issues[0]?.message ?? "invalid" };
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
merged = productContentSchema.parse(result);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
content: merged,
|
|
60
|
+
content_schema_version: PRODUCTS_CONTENT_SCHEMA_VERSION,
|
|
61
|
+
served_locale: scope.locale,
|
|
62
|
+
source_kind: options.provenance.provenance.source_kind,
|
|
63
|
+
source_provider: options.provenance.provenance.source_provider,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Drizzle-bound convenience wrapper: fetches active overlays for the
|
|
68
|
+
* entity and runs the synthesizer. Verticals call this from their
|
|
69
|
+
* content service when no `getContent` adapter is registered (or when
|
|
70
|
+
* the synthesizer is the fallback for missing-cache + thin-adapter).
|
|
71
|
+
*/
|
|
72
|
+
export async function synthesizeProductContentFromDb(db, scope, provenance) {
|
|
73
|
+
const overlays = await fetchOverlaysForEntity(db, "products", entityIdFromProvenance(provenance));
|
|
74
|
+
return synthesizeProductContent(scope, {
|
|
75
|
+
provenance,
|
|
76
|
+
overlays: overlays.map((o) => ({ field_path: o.field_path, value: o.value })),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function entityIdFromProvenance(provenance) {
|
|
80
|
+
// The sourced-entry row's entry_id is the catalog-side TypeID; the
|
|
81
|
+
// products module-side id is on the projection row. Synthesizer
|
|
82
|
+
// callers thread the products-module entity_id through scope; we
|
|
83
|
+
// take it off the projection's `id` field when the adapter set it,
|
|
84
|
+
// falling back to the entry_id (which is wrong for read paths but
|
|
85
|
+
// safe for tests).
|
|
86
|
+
const fromProjection = provenance.projection.id;
|
|
87
|
+
if (typeof fromProjection === "string" && fromProjection.length > 0) {
|
|
88
|
+
return fromProjection;
|
|
89
|
+
}
|
|
90
|
+
return provenance.entry_id;
|
|
91
|
+
}
|
|
92
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
// Field pickers
|
|
94
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
95
|
+
function pickProductSummary(projection, provenance) {
|
|
96
|
+
return {
|
|
97
|
+
id: stringOr(projection.id, "") || provenance.entry_id,
|
|
98
|
+
name: stringOr(projection.name, "") || stringOr(projection.title, "") || "Unnamed product",
|
|
99
|
+
status: stringOr(projection.status, undefined),
|
|
100
|
+
description: stringOr(projection.description, null),
|
|
101
|
+
inclusions_html: stringOr(projection.inclusions_html, null) ?? stringOr(projection.inclusionsHtml, null),
|
|
102
|
+
exclusions_html: stringOr(projection.exclusions_html, null) ?? stringOr(projection.exclusionsHtml, null),
|
|
103
|
+
terms_html: stringOr(projection.terms_html, null) ?? stringOr(projection.termsHtml, null),
|
|
104
|
+
contract_template_id: stringOr(projection.contract_template_id, null) ??
|
|
105
|
+
stringOr(projection.contractTemplateId, null),
|
|
106
|
+
contractTemplateId: stringOr(projection.contractTemplateId, null) ??
|
|
107
|
+
stringOr(projection.contract_template_id, null),
|
|
108
|
+
highlights: stringArrayOr(projection.highlights, []),
|
|
109
|
+
hero_image_url: stringOr(projection.hero_image_url, null),
|
|
110
|
+
duration_days: numberOr(projection.duration_days, null),
|
|
111
|
+
start_date: stringOr(projection.start_date, null),
|
|
112
|
+
end_date: stringOr(projection.end_date, null),
|
|
113
|
+
sell_currency: stringOr(projection.sell_currency, null),
|
|
114
|
+
supplier: stringOr(projection.supplier, null) ??
|
|
115
|
+
stringOr(projection.supplier_name, null) ??
|
|
116
|
+
provenance.provenance.source_provider ??
|
|
117
|
+
null,
|
|
118
|
+
country: stringOr(projection.country, null),
|
|
119
|
+
departure_city: stringOr(projection.departure_city, null),
|
|
120
|
+
tags: stringArrayOr(projection.tags, []),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function pickMedia(projection) {
|
|
124
|
+
const heroUrl = stringOr(projection.hero_image_url, null);
|
|
125
|
+
const out = [];
|
|
126
|
+
if (heroUrl) {
|
|
127
|
+
out.push({ url: heroUrl, type: "image", caption: null, alt: null });
|
|
128
|
+
}
|
|
129
|
+
// Some adapters send a `media` array on the projection. Accept it
|
|
130
|
+
// when shaped reasonably; ignore otherwise rather than synthesize.
|
|
131
|
+
const additional = projection.media;
|
|
132
|
+
if (Array.isArray(additional)) {
|
|
133
|
+
for (const item of additional) {
|
|
134
|
+
if (item && typeof item === "object") {
|
|
135
|
+
const obj = item;
|
|
136
|
+
const url = stringOr(obj.url, null);
|
|
137
|
+
if (!url)
|
|
138
|
+
continue;
|
|
139
|
+
out.push({
|
|
140
|
+
url,
|
|
141
|
+
type: pickMediaType(obj.type),
|
|
142
|
+
caption: stringOr(obj.caption, null),
|
|
143
|
+
alt: stringOr(obj.alt, null),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
function pickMediaType(value) {
|
|
151
|
+
if (value === "video")
|
|
152
|
+
return "video";
|
|
153
|
+
if (value === "document")
|
|
154
|
+
return "document";
|
|
155
|
+
return "image";
|
|
156
|
+
}
|
|
157
|
+
function pickPolicies(projection) {
|
|
158
|
+
const policies = [];
|
|
159
|
+
const cancel = stringOr(projection.cancellation_policy, null);
|
|
160
|
+
if (cancel)
|
|
161
|
+
policies.push({ kind: "cancellation", body: cancel });
|
|
162
|
+
const payment = stringOr(projection.payment_terms, null);
|
|
163
|
+
if (payment)
|
|
164
|
+
policies.push({ kind: "payment", body: payment });
|
|
165
|
+
return policies;
|
|
166
|
+
}
|
|
167
|
+
function stringOr(value, fallback) {
|
|
168
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
169
|
+
}
|
|
170
|
+
function numberOr(value, fallback) {
|
|
171
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
172
|
+
}
|
|
173
|
+
function stringArrayOr(value, fallback) {
|
|
174
|
+
if (!Array.isArray(value))
|
|
175
|
+
return fallback;
|
|
176
|
+
const out = value.filter((v) => typeof v === "string");
|
|
177
|
+
return out.length > 0 ? out : fallback;
|
|
178
|
+
}
|