@voyantjs/products 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/booking-engine/handler.d.ts +203 -0
  2. package/dist/booking-engine/handler.d.ts.map +1 -0
  3. package/dist/booking-engine/handler.js +330 -0
  4. package/dist/booking-engine/index.d.ts +8 -0
  5. package/dist/booking-engine/index.d.ts.map +1 -0
  6. package/dist/booking-engine/index.js +7 -0
  7. package/dist/catalog-policy.d.ts +33 -0
  8. package/dist/catalog-policy.d.ts.map +1 -0
  9. package/dist/catalog-policy.js +421 -0
  10. package/dist/content-shape.d.ts +217 -0
  11. package/dist/content-shape.d.ts.map +1 -0
  12. package/dist/content-shape.js +159 -0
  13. package/dist/draft-shape.d.ts +43 -0
  14. package/dist/draft-shape.d.ts.map +1 -0
  15. package/dist/draft-shape.js +46 -0
  16. package/dist/events.d.ts +37 -0
  17. package/dist/events.d.ts.map +1 -0
  18. package/dist/events.js +32 -0
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -0
  22. package/dist/routes-content.d.ts +74 -0
  23. package/dist/routes-content.d.ts.map +1 -0
  24. package/dist/routes-content.js +117 -0
  25. package/dist/routes.d.ts +47 -26
  26. package/dist/routes.d.ts.map +1 -1
  27. package/dist/routes.js +88 -16
  28. package/dist/schema-core.d.ts +240 -1
  29. package/dist/schema-core.d.ts.map +1 -1
  30. package/dist/schema-core.js +49 -0
  31. package/dist/schema-itinerary.d.ts +18 -1
  32. package/dist/schema-itinerary.d.ts.map +1 -1
  33. package/dist/schema-itinerary.js +1 -0
  34. package/dist/schema-settings.d.ts +1 -1
  35. package/dist/schema-sourced-content.d.ts +262 -0
  36. package/dist/schema-sourced-content.d.ts.map +1 -0
  37. package/dist/schema-sourced-content.js +69 -0
  38. package/dist/schema-taxonomy.d.ts +17 -0
  39. package/dist/schema-taxonomy.d.ts.map +1 -1
  40. package/dist/schema-taxonomy.js +13 -0
  41. package/dist/schema.d.ts +1 -0
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +1 -0
  44. package/dist/service-catalog-plane.d.ts +129 -0
  45. package/dist/service-catalog-plane.d.ts.map +1 -0
  46. package/dist/service-catalog-plane.js +212 -0
  47. package/dist/service-content-owned.d.ts +68 -0
  48. package/dist/service-content-owned.d.ts.map +1 -0
  49. package/dist/service-content-owned.js +224 -0
  50. package/dist/service-content-synthesizer.d.ts +90 -0
  51. package/dist/service-content-synthesizer.d.ts.map +1 -0
  52. package/dist/service-content-synthesizer.js +171 -0
  53. package/dist/service-content.d.ts +106 -0
  54. package/dist/service-content.d.ts.map +1 -0
  55. package/dist/service-content.js +365 -0
  56. package/dist/service.d.ts +82 -28
  57. package/dist/service.d.ts.map +1 -1
  58. package/dist/service.js +4 -0
  59. package/dist/tasks/brochures.d.ts +2 -1
  60. package/dist/tasks/brochures.d.ts.map +1 -1
  61. package/dist/tasks/brochures.js +3 -0
  62. package/dist/validation-catalog.d.ts +4 -4
  63. package/dist/validation-config.d.ts +3 -3
  64. package/dist/validation-content.d.ts +34 -4
  65. package/dist/validation-content.d.ts.map +1 -1
  66. package/dist/validation-content.js +13 -0
  67. package/dist/validation-core.d.ts +53 -3
  68. package/dist/validation-core.d.ts.map +1 -1
  69. package/dist/validation-core.js +16 -0
  70. package/dist/validation-public.d.ts +9 -9
  71. package/dist/validation-shared.d.ts +4 -4
  72. package/package.json +12 -6
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Catalog-plane integration for the products service.
3
+ *
4
+ * Adds catalog-aware service methods alongside the existing `productsService`
5
+ * surface in `service.ts`. Routes opt in: the original `getProductById` /
6
+ * `listProducts` continue to return raw DB rows; the methods here return
7
+ * resolved CatalogEntry views with overlays + visibility filtering applied.
8
+ *
9
+ * Existing service code is untouched. Migration is per-route, gradual.
10
+ *
11
+ * Naming note: this file is `service-catalog-plane.ts` (not `service-catalog.ts`)
12
+ * because the existing `service-catalog.ts` handles the products module's own
13
+ * catalog management (categories, tags, types). The "catalog plane" is the
14
+ * cross-vertical projection / overlay / snapshot infrastructure from
15
+ * `@voyantjs/catalog`.
16
+ *
17
+ * See `docs/architecture/catalog-architecture.md` §9.1 for the integration
18
+ * pattern this file establishes (replicated for cruises, hospitality, etc.
19
+ * in their own service-catalog-plane.ts files).
20
+ */
21
+ import { type CaptureSnapshotInput, type DocumentBuilder, type DocumentEmitter, type IndexerDocument, type IndexerSlice, type PricingBasis, type Provenance, type ResolvedView, type ResolverScope, type Visibility } from "@voyantjs/catalog";
22
+ import type { AnyDrizzleDb } from "@voyantjs/db";
23
+ import { products } from "./schema-core.js";
24
+ /**
25
+ * Maps a product row to a field-keyed projection consumable by the catalog
26
+ * resolver. Field paths match the policy registry declarations in
27
+ * `catalog-policy.ts`.
28
+ *
29
+ * Provenance fields (`source.kind`, `source.ref`, `seller.operator_id`) are
30
+ * synthesized: today's products module models operator-owned inventory
31
+ * exclusively, so `source.kind = "owned"` and `source.ref = undefined`.
32
+ * When sourced products land (e.g. via Voyant Connect), this helper picks
33
+ * up the provenance from a parallel provenance row instead.
34
+ */
35
+ export declare function productRowToProjection(row: typeof products.$inferSelect, context: {
36
+ sellerOperatorId: string;
37
+ }): ReadonlyMap<string, unknown>;
38
+ /**
39
+ * Returns the Provenance tuple for a product row. Owned products synthesize
40
+ * a `source.kind: "owned"` provenance with `static` freshness; sourced
41
+ * products (Voyant Connect / GDS / direct API) carry their actual source
42
+ * connection identity. Phase 1 ships only the owned form.
43
+ */
44
+ export declare function productProvenance(_row: typeof products.$inferSelect, _context: {
45
+ sellerOperatorId: string;
46
+ }): Provenance;
47
+ /** Service-context the catalog-aware methods need. Templates wire this in. */
48
+ export interface ProductCatalogContext {
49
+ /** The deployment's operator/tenant identifier — synthesized into provenance. */
50
+ sellerOperatorId: string;
51
+ /** Variant scope for the request. */
52
+ scope: ResolverScope;
53
+ }
54
+ /**
55
+ * Catalog-aware product fetch. Returns the resolved view (source projection
56
+ * + active overlays + visibility filtering) instead of the raw DB row.
57
+ *
58
+ * The original `productsService.getProductById` continues to return raw
59
+ * rows — routes that haven't migrated to the catalog plane keep working.
60
+ *
61
+ * Returns `null` if no product with `id` exists.
62
+ */
63
+ export declare function getResolvedProductById(db: AnyDrizzleDb, id: string, context: ProductCatalogContext): Promise<ResolvedView | null>;
64
+ /**
65
+ * Catalog-aware product list. Returns resolved views per row.
66
+ *
67
+ * Caller fetches the rows (typically via the existing `productsService.listProducts`
68
+ * with whatever filtering / pagination / sort the route applies) and passes
69
+ * them in. This keeps query construction in the existing service layer and
70
+ * adds the catalog overlay step on top.
71
+ *
72
+ * For Phase B v1, this is a naive per-row resolver that issues one overlay
73
+ * fetch per product. Real list paths (storefront browse, admin search)
74
+ * should go through the search index instead — `IndexerService.search` is
75
+ * already wired for that purpose. Use this method for small admin-facing
76
+ * lists or detail-page composition where the index isn't on the read path.
77
+ *
78
+ * Production-grade batched overlay fetch is a TODO; the catalog plane
79
+ * supports it conceptually but `fetchOverlaysForEntity` is currently
80
+ * one-entity-at-a-time. A future `fetchOverlaysForEntities(db, [(module, id), ...])`
81
+ * lands with the indexer hot-path optimization.
82
+ */
83
+ export declare function listResolvedProducts(db: AnyDrizzleDb, rows: ReadonlyArray<typeof products.$inferSelect>, context: ProductCatalogContext): Promise<ResolvedView[]>;
84
+ /**
85
+ * Build a `CaptureSnapshotInput` for a product to feed into the catalog
86
+ * plane's `captureSnapshot` / `captureSnapshotGraph` helpers at booking
87
+ * commit time. Fetches the product, resolves its view (overlays applied,
88
+ * visibility filter for the supplied scope), and returns the snapshot
89
+ * input shape.
90
+ *
91
+ * Returns `null` if the product doesn't exist.
92
+ *
93
+ * Composition: a single-product booking calls this once and passes the
94
+ * result to `captureSnapshot`. A composite booking (e.g. a tour-package
95
+ * booking with referenced hospitality + excursions) calls this and the
96
+ * other verticals' equivalents, collects the inputs, and passes them all
97
+ * to `captureSnapshotGraph` in one transaction.
98
+ */
99
+ export declare function buildProductSnapshotInput(db: AnyDrizzleDb, productId: string, context: ProductCatalogContext & {
100
+ pricingBasis?: PricingBasis;
101
+ }): Promise<Omit<CaptureSnapshotInput, "bookingId"> | null>;
102
+ /**
103
+ * Construct a sync `DocumentEmitter` for products. The emitter takes a
104
+ * pre-fetched product row + a slice and returns the indexer document
105
+ * (filtered by visibility, with blob-only fields skipped).
106
+ *
107
+ * Bulk-reindex pipelines that already have rows in hand call this directly.
108
+ * Live reindex paths use `createProductDocumentBuilder` below, which fetches
109
+ * the row before emitting.
110
+ */
111
+ export declare function createProductDocumentEmitter(context: {
112
+ sellerOperatorId: string;
113
+ }): DocumentEmitter<typeof products.$inferSelect>;
114
+ /**
115
+ * Async `DocumentBuilder` for products — fetches the row by id, then emits.
116
+ * Plug this into `IndexerService.reindexEntity` for live reindex events.
117
+ *
118
+ * Returns `null` if the product no longer exists (e.g. it was deleted
119
+ * between the reindex enqueue and the worker picking it up). Callers can
120
+ * treat `null` as a delete signal.
121
+ */
122
+ export declare function createProductDocumentBuilder(db: AnyDrizzleDb, context: {
123
+ sellerOperatorId: string;
124
+ }): DocumentBuilder;
125
+ /**
126
+ * Re-exports for routes that only import from this file.
127
+ */
128
+ export type { CaptureSnapshotInput, DocumentBuilder, DocumentEmitter, IndexerDocument, IndexerSlice, PricingBasis, Provenance, ResolvedView, ResolverScope, Visibility, };
129
+ //# sourceMappingURL=service-catalog-plane.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-catalog-plane.d.ts","sourceRoot":"","sources":["../src/service-catalog-plane.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAGL,KAAK,oBAAoB,EAEzB,KAAK,eAAe,EACpB,KAAK,eAAe,EAEpB,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,KAAK,YAAY,EACjB,KAAK,aAAa,EAElB,KAAK,UAAU,EAChB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAIhD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AAc3C;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,OAAO,QAAQ,CAAC,YAAY,EACjC,OAAO,EAAE;IAAE,gBAAgB,EAAE,MAAM,CAAA;CAAE,GACpC,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,CAwC9B;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,OAAO,QAAQ,CAAC,YAAY,EAClC,QAAQ,EAAE;IAAE,gBAAgB,EAAE,MAAM,CAAA;CAAE,GACrC,UAAU,CAKZ;AAED,8EAA8E;AAC9E,MAAM,WAAW,qBAAqB;IACpC,iFAAiF;IACjF,gBAAgB,EAAE,MAAM,CAAA;IACxB,qCAAqC;IACrC,KAAK,EAAE,aAAa,CAAA;CACrB;AAED;;;;;;;;GAQG;AACH,wBAAsB,sBAAsB,CAC1C,EAAE,EAAE,YAAY,EAChB,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAS9B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,oBAAoB,CACxC,EAAE,EAAE,YAAY,EAChB,IAAI,EAAE,aAAa,CAAC,OAAO,QAAQ,CAAC,YAAY,CAAC,EACjD,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,YAAY,EAAE,CAAC,CAkBzB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,yBAAyB,CAC7C,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,qBAAqB,GAAG;IAAE,YAAY,CAAC,EAAE,YAAY,CAAA;CAAE,GAC/D,OAAO,CAAC,IAAI,CAAC,oBAAoB,EAAE,WAAW,CAAC,GAAG,IAAI,CAAC,CASzD;AAMD;;;;;;;;GAQG;AACH,wBAAgB,4BAA4B,CAAC,OAAO,EAAE;IACpD,gBAAgB,EAAE,MAAM,CAAA;CACzB,GAAG,eAAe,CAAC,OAAO,QAAQ,CAAC,YAAY,CAAC,CAWhD;AAED;;;;;;;GAOG;AACH,wBAAgB,4BAA4B,CAC1C,EAAE,EAAE,YAAY,EAChB,OAAO,EAAE;IAAE,gBAAgB,EAAE,MAAM,CAAA;CAAE,GACpC,eAAe,CAQjB;AAED;;GAEG;AACH,YAAY,EACV,oBAAoB,EACpB,eAAe,EACf,eAAe,EACf,eAAe,EACf,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,YAAY,EACZ,aAAa,EACb,UAAU,GACX,CAAA"}
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Catalog-plane integration for the products service.
3
+ *
4
+ * Adds catalog-aware service methods alongside the existing `productsService`
5
+ * surface in `service.ts`. Routes opt in: the original `getProductById` /
6
+ * `listProducts` continue to return raw DB rows; the methods here return
7
+ * resolved CatalogEntry views with overlays + visibility filtering applied.
8
+ *
9
+ * Existing service code is untouched. Migration is per-route, gradual.
10
+ *
11
+ * Naming note: this file is `service-catalog-plane.ts` (not `service-catalog.ts`)
12
+ * because the existing `service-catalog.ts` handles the products module's own
13
+ * catalog management (categories, tags, types). The "catalog plane" is the
14
+ * cross-vertical projection / overlay / snapshot infrastructure from
15
+ * `@voyantjs/catalog`.
16
+ *
17
+ * See `docs/architecture/catalog-architecture.md` §9.1 for the integration
18
+ * pattern this file establishes (replicated for cruises, hospitality, etc.
19
+ * in their own service-catalog-plane.ts files).
20
+ */
21
+ import { buildIndexerDocument, buildSnapshotInputFromView, createFieldPolicyRegistry, resolveEntityView, } from "@voyantjs/catalog";
22
+ import { eq } from "drizzle-orm";
23
+ import { productCatalogPolicy } from "./catalog-policy.js";
24
+ import { products } from "./schema-core.js";
25
+ /**
26
+ * Lazy-initialized registry. Built once per process; the field-policy file
27
+ * is static so this is safe to memoize.
28
+ */
29
+ let _registry;
30
+ function getProductsRegistry() {
31
+ if (!_registry) {
32
+ _registry = createFieldPolicyRegistry(productCatalogPolicy);
33
+ }
34
+ return _registry;
35
+ }
36
+ /**
37
+ * Maps a product row to a field-keyed projection consumable by the catalog
38
+ * resolver. Field paths match the policy registry declarations in
39
+ * `catalog-policy.ts`.
40
+ *
41
+ * Provenance fields (`source.kind`, `source.ref`, `seller.operator_id`) are
42
+ * synthesized: today's products module models operator-owned inventory
43
+ * exclusively, so `source.kind = "owned"` and `source.ref = undefined`.
44
+ * When sourced products land (e.g. via Voyant Connect), this helper picks
45
+ * up the provenance from a parallel provenance row instead.
46
+ */
47
+ export function productRowToProjection(row, context) {
48
+ const projection = new Map([
49
+ // Provenance — synthesized for owned products.
50
+ ["source.kind", "owned"],
51
+ ["seller.operator_id", context.sellerOperatorId],
52
+ // Identity
53
+ ["id", row.id],
54
+ ["createdAt", row.createdAt],
55
+ ["updatedAt", row.updatedAt],
56
+ // Merchandisable
57
+ ["name", row.name],
58
+ ["description", row.description],
59
+ ["tags[]", row.tags],
60
+ // Structural
61
+ ["status", row.status],
62
+ ["bookingMode", row.bookingMode],
63
+ ["capacityMode", row.capacityMode],
64
+ ["visibility", row.visibility],
65
+ ["activated", row.activated],
66
+ ["productTypeId", row.productTypeId],
67
+ ["facilityId", row.facilityId],
68
+ ["supplierId", row.supplierId],
69
+ ["pax", row.pax],
70
+ ["startDate", row.startDate],
71
+ ["endDate", row.endDate],
72
+ ["timezone", row.timezone],
73
+ ["reservationTimeoutMinutes", row.reservationTimeoutMinutes],
74
+ // Pricing (configured defaults — quote-time prices come from pricing module)
75
+ ["sellAmountCents", row.sellAmountCents],
76
+ ["sellCurrency", row.sellCurrency],
77
+ // Internal / staff-only
78
+ ["costAmountCents", row.costAmountCents],
79
+ ["marginPercent", row.marginPercent],
80
+ ]);
81
+ return projection;
82
+ }
83
+ /**
84
+ * Returns the Provenance tuple for a product row. Owned products synthesize
85
+ * a `source.kind: "owned"` provenance with `static` freshness; sourced
86
+ * products (Voyant Connect / GDS / direct API) carry their actual source
87
+ * connection identity. Phase 1 ships only the owned form.
88
+ */
89
+ export function productProvenance(_row, _context) {
90
+ return {
91
+ source_kind: "owned",
92
+ source_freshness: "static",
93
+ };
94
+ }
95
+ /**
96
+ * Catalog-aware product fetch. Returns the resolved view (source projection
97
+ * + active overlays + visibility filtering) instead of the raw DB row.
98
+ *
99
+ * The original `productsService.getProductById` continues to return raw
100
+ * rows — routes that haven't migrated to the catalog plane keep working.
101
+ *
102
+ * Returns `null` if no product with `id` exists.
103
+ */
104
+ export async function getResolvedProductById(db, id, context) {
105
+ const rows = await db.select().from(products).where(eq(products.id, id)).limit(1);
106
+ const row = rows[0];
107
+ if (!row)
108
+ return null;
109
+ const projection = productRowToProjection(row, {
110
+ sellerOperatorId: context.sellerOperatorId,
111
+ });
112
+ return resolveEntityView(db, getProductsRegistry(), "products", id, projection, context.scope);
113
+ }
114
+ /**
115
+ * Catalog-aware product list. Returns resolved views per row.
116
+ *
117
+ * Caller fetches the rows (typically via the existing `productsService.listProducts`
118
+ * with whatever filtering / pagination / sort the route applies) and passes
119
+ * them in. This keeps query construction in the existing service layer and
120
+ * adds the catalog overlay step on top.
121
+ *
122
+ * For Phase B v1, this is a naive per-row resolver that issues one overlay
123
+ * fetch per product. Real list paths (storefront browse, admin search)
124
+ * should go through the search index instead — `IndexerService.search` is
125
+ * already wired for that purpose. Use this method for small admin-facing
126
+ * lists or detail-page composition where the index isn't on the read path.
127
+ *
128
+ * Production-grade batched overlay fetch is a TODO; the catalog plane
129
+ * supports it conceptually but `fetchOverlaysForEntity` is currently
130
+ * one-entity-at-a-time. A future `fetchOverlaysForEntities(db, [(module, id), ...])`
131
+ * lands with the indexer hot-path optimization.
132
+ */
133
+ export async function listResolvedProducts(db, rows, context) {
134
+ const registry = getProductsRegistry();
135
+ const views = [];
136
+ for (const row of rows) {
137
+ const projection = productRowToProjection(row, {
138
+ sellerOperatorId: context.sellerOperatorId,
139
+ });
140
+ const view = await resolveEntityView(db, registry, "products", row.id, projection, context.scope);
141
+ views.push(view);
142
+ }
143
+ return views;
144
+ }
145
+ /**
146
+ * Build a `CaptureSnapshotInput` for a product to feed into the catalog
147
+ * plane's `captureSnapshot` / `captureSnapshotGraph` helpers at booking
148
+ * commit time. Fetches the product, resolves its view (overlays applied,
149
+ * visibility filter for the supplied scope), and returns the snapshot
150
+ * input shape.
151
+ *
152
+ * Returns `null` if the product doesn't exist.
153
+ *
154
+ * Composition: a single-product booking calls this once and passes the
155
+ * result to `captureSnapshot`. A composite booking (e.g. a tour-package
156
+ * booking with referenced hospitality + excursions) calls this and the
157
+ * other verticals' equivalents, collects the inputs, and passes them all
158
+ * to `captureSnapshotGraph` in one transaction.
159
+ */
160
+ export async function buildProductSnapshotInput(db, productId, context) {
161
+ const view = await getResolvedProductById(db, productId, context);
162
+ if (!view)
163
+ return null;
164
+ return buildSnapshotInputFromView(view, {
165
+ entityModule: "products",
166
+ entityId: productId,
167
+ sourceKind: "owned",
168
+ pricingBasis: context.pricingBasis,
169
+ });
170
+ }
171
+ // ─────────────────────────────────────────────────────────────────────────────
172
+ // Indexer document emission
173
+ // ─────────────────────────────────────────────────────────────────────────────
174
+ /**
175
+ * Construct a sync `DocumentEmitter` for products. The emitter takes a
176
+ * pre-fetched product row + a slice and returns the indexer document
177
+ * (filtered by visibility, with blob-only fields skipped).
178
+ *
179
+ * Bulk-reindex pipelines that already have rows in hand call this directly.
180
+ * Live reindex paths use `createProductDocumentBuilder` below, which fetches
181
+ * the row before emitting.
182
+ */
183
+ export function createProductDocumentEmitter(context) {
184
+ const registry = getProductsRegistry();
185
+ return {
186
+ vertical: "products",
187
+ emit(source, slice) {
188
+ const projection = productRowToProjection(source, {
189
+ sellerOperatorId: context.sellerOperatorId,
190
+ });
191
+ return buildIndexerDocument(registry, projection, slice, source.id);
192
+ },
193
+ };
194
+ }
195
+ /**
196
+ * Async `DocumentBuilder` for products — fetches the row by id, then emits.
197
+ * Plug this into `IndexerService.reindexEntity` for live reindex events.
198
+ *
199
+ * Returns `null` if the product no longer exists (e.g. it was deleted
200
+ * between the reindex enqueue and the worker picking it up). Callers can
201
+ * treat `null` as a delete signal.
202
+ */
203
+ export function createProductDocumentBuilder(db, context) {
204
+ const emitter = createProductDocumentEmitter(context);
205
+ return async (entityId, slice) => {
206
+ const rows = await db.select().from(products).where(eq(products.id, entityId)).limit(1);
207
+ const row = rows[0];
208
+ if (!row)
209
+ return null;
210
+ return emitter.emit(row, slice);
211
+ };
212
+ }
@@ -0,0 +1,68 @@
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 type { ContentLocaleMatchKind } from "@voyantjs/catalog";
34
+ import type { AnyDrizzleDb } from "@voyantjs/db";
35
+ import { type ProductContent } from "./content-shape.js";
36
+ export interface BuildOwnedProductContentOptions {
37
+ /**
38
+ * Ordered locale preference chain — most-preferred first. Same shape
39
+ * the catalog plane's `pickBestCachedLocale` consumes. Pass-through
40
+ * from the caller's `BookingDraft.scope.locale` (storefront /
41
+ * operator UI).
42
+ */
43
+ preferredLocales: ReadonlyArray<string>;
44
+ }
45
+ export interface BuildOwnedProductContentResult {
46
+ /** The owned product projected to ProductContent. */
47
+ content: ProductContent;
48
+ /**
49
+ * The locale we actually served — may differ from
50
+ * `preferredLocales[0]` when a fallback was used. Per-product;
51
+ * options that fell back to a different locale don't override this
52
+ * (storefront UI hints at the product level).
53
+ */
54
+ servedLocale: string;
55
+ /**
56
+ * How well the served locale matched the chain. Surfaces in the UI
57
+ * as a "served in English" hint when content was served in a
58
+ * non-preferred language.
59
+ */
60
+ matchKind: ContentLocaleMatchKind;
61
+ }
62
+ /**
63
+ * Read the owned product + related rows and project to `ProductContent`,
64
+ * resolving translations against the supplied locale-preference chain.
65
+ * Returns null when the product doesn't exist.
66
+ */
67
+ export declare function buildOwnedProductContent(db: AnyDrizzleDb, entityId: string, options: BuildOwnedProductContentOptions): Promise<BuildOwnedProductContentResult | null>;
68
+ //# sourceMappingURL=service-content-owned.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-content-owned.d.ts","sourceRoot":"","sources":["../src/service-content-owned.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAA;AAE/D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAGhD,OAAO,EACL,KAAK,cAAc,EAGpB,MAAM,oBAAoB,CAAA;AAW3B,MAAM,WAAW,+BAA+B;IAC9C;;;;;OAKG;IACH,gBAAgB,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;CACxC;AAED,MAAM,WAAW,8BAA8B;IAC7C,qDAAqD;IACrD,OAAO,EAAE,cAAc,CAAA;IACvB;;;;;OAKG;IACH,YAAY,EAAE,MAAM,CAAA;IACpB;;;;OAIG;IACH,SAAS,EAAE,sBAAsB,CAAA;CAClC;AAED;;;;GAIG;AACH,wBAAsB,wBAAwB,CAC5C,EAAE,EAAE,YAAY,EAChB,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,8BAA8B,GAAG,IAAI,CAAC,CAmIhD"}
@@ -0,0 +1,224 @@
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 "@voyantjs/catalog";
34
+ import { and, asc, eq, inArray } 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
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
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 content = productContentSchema.parse({
98
+ product: {
99
+ id: productRow.id,
100
+ name: localizedName,
101
+ status: productRow.status,
102
+ description: localizedDescription,
103
+ hero_image_url: cover?.url ?? null,
104
+ duration_days: estimateDurationDays(days, productRow),
105
+ start_date: dateToIso(productRow.startDate),
106
+ end_date: dateToIso(productRow.endDate),
107
+ sell_currency: productRow.sellCurrency,
108
+ supplier: productRow.supplierId ?? null,
109
+ tags: Array.isArray(productRow.tags) ? productRow.tags : [],
110
+ },
111
+ options: optionRows.map((opt) => {
112
+ const trnsForOption = optionTrns.filter((t) => t.optionId === opt.id);
113
+ const bestOptionTrn = pickBestOptionTranslation(trnsForOption, options.preferredLocales);
114
+ return {
115
+ id: opt.id,
116
+ name: bestOptionTrn?.candidate.name ?? opt.name,
117
+ description: bestOptionTrn?.candidate.shortDescription ??
118
+ bestOptionTrn?.candidate.description ??
119
+ opt.description ??
120
+ null,
121
+ units: [],
122
+ inclusions: [],
123
+ };
124
+ }),
125
+ days: days.map((d) => ({
126
+ // Days don't have a translation table today; source values flow
127
+ // through. When `product_day_translations` lands, slot in here
128
+ // with the same pickBestCachedLocale call.
129
+ day_number: d.dayNumber,
130
+ title: d.title ?? null,
131
+ description: d.description ?? null,
132
+ location: d.location ?? null,
133
+ services: [],
134
+ })),
135
+ media: mediaRows
136
+ .filter((m) => !m.isBrochure)
137
+ .map((m) => ({
138
+ url: m.url,
139
+ type: mediaType(m.mediaType),
140
+ caption: m.altText ?? null,
141
+ alt: m.altText ?? null,
142
+ })),
143
+ policies: [],
144
+ // Owned products have no scheduled-departure table in v1; sourced
145
+ // products carry departures via the upstream's getContent payload.
146
+ departures: [],
147
+ });
148
+ const validation = validateProductContent(content);
149
+ if (!validation.valid) {
150
+ throw new Error(`owned product ${entityId} projection failed validation: ${validation.reason}`);
151
+ }
152
+ return {
153
+ content,
154
+ servedLocale: productServedLocale,
155
+ matchKind: productMatchKind,
156
+ };
157
+ }
158
+ function pickBestProductTranslation(rows, preferred) {
159
+ if (rows.length === 0)
160
+ return null;
161
+ const candidates = rows.map((r) => ({
162
+ locale: r.languageTag,
163
+ name: r.name,
164
+ shortDescription: r.shortDescription,
165
+ description: r.description,
166
+ }));
167
+ return pickBestCachedLocale(candidates, preferred);
168
+ }
169
+ function pickBestOptionTranslation(rows, preferred) {
170
+ if (rows.length === 0)
171
+ return null;
172
+ const candidates = rows.map((r) => ({
173
+ locale: r.languageTag,
174
+ name: r.name,
175
+ shortDescription: r.shortDescription,
176
+ description: r.description,
177
+ }));
178
+ return pickBestCachedLocale(candidates, preferred);
179
+ }
180
+ /**
181
+ * When no translation rows exist for the product, the source row's
182
+ * `name` + `description` are surfaced. We don't know what locale they
183
+ * were authored in — operators tend to author in their default
184
+ * deployment locale (commonly en-GB / en-US for UK / US deployments).
185
+ * The caller's `defaultSourceLocale` would be a cleaner fix; for now
186
+ * we report `und` (BCP 47 "undetermined") so the storefront chip
187
+ * doesn't claim a specific locale we can't verify.
188
+ */
189
+ function sourceLocaleFor(_productRow) {
190
+ return "und";
191
+ }
192
+ // ─────────────────────────────────────────────────────────────────────────────
193
+ // Helpers
194
+ // ─────────────────────────────────────────────────────────────────────────────
195
+ function estimateDurationDays(days, productRow) {
196
+ if (days.length > 0) {
197
+ const max = Math.max(...days.map((d) => d.dayNumber));
198
+ return Number.isFinite(max) && max > 0 ? max : null;
199
+ }
200
+ if (productRow.startDate && productRow.endDate) {
201
+ const start = new Date(productRow.startDate);
202
+ const end = new Date(productRow.endDate);
203
+ if (!Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime())) {
204
+ const diffMs = end.getTime() - start.getTime();
205
+ const days = Math.round(diffMs / (24 * 60 * 60 * 1000));
206
+ return days > 0 ? days : null;
207
+ }
208
+ }
209
+ return null;
210
+ }
211
+ function dateToIso(value) {
212
+ if (!value)
213
+ return null;
214
+ if (typeof value === "string")
215
+ return value;
216
+ return value.toISOString().slice(0, 10);
217
+ }
218
+ function mediaType(value) {
219
+ if (value === "video")
220
+ return "video";
221
+ if (value === "document")
222
+ return "document";
223
+ return "image";
224
+ }