@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.
- package/dist/booking-engine/handler.d.ts +203 -0
- package/dist/booking-engine/handler.d.ts.map +1 -0
- package/dist/booking-engine/handler.js +330 -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/catalog-policy.d.ts +33 -0
- package/dist/catalog-policy.d.ts.map +1 -0
- package/dist/catalog-policy.js +421 -0
- package/dist/content-shape.d.ts +217 -0
- package/dist/content-shape.d.ts.map +1 -0
- package/dist/content-shape.js +159 -0
- package/dist/draft-shape.d.ts +43 -0
- package/dist/draft-shape.d.ts.map +1 -0
- package/dist/draft-shape.js +46 -0
- package/dist/events.d.ts +37 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +32 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -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.d.ts +47 -26
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +88 -16
- package/dist/schema-core.d.ts +240 -1
- package/dist/schema-core.d.ts.map +1 -1
- package/dist/schema-core.js +49 -0
- package/dist/schema-itinerary.d.ts +18 -1
- package/dist/schema-itinerary.d.ts.map +1 -1
- package/dist/schema-itinerary.js +1 -0
- package/dist/schema-settings.d.ts +1 -1
- 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 +17 -0
- package/dist/schema-taxonomy.d.ts.map +1 -1
- package/dist/schema-taxonomy.js +13 -0
- package/dist/schema.d.ts +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1 -0
- package/dist/service-catalog-plane.d.ts +129 -0
- package/dist/service-catalog-plane.d.ts.map +1 -0
- package/dist/service-catalog-plane.js +212 -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 +224 -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 +171 -0
- package/dist/service-content.d.ts +106 -0
- package/dist/service-content.d.ts.map +1 -0
- package/dist/service-content.js +365 -0
- package/dist/service.d.ts +82 -28
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +4 -0
- package/dist/tasks/brochures.d.ts +2 -1
- package/dist/tasks/brochures.d.ts.map +1 -1
- package/dist/tasks/brochures.js +3 -0
- package/dist/validation-catalog.d.ts +4 -4
- package/dist/validation-config.d.ts +3 -3
- package/dist/validation-content.d.ts +34 -4
- package/dist/validation-content.d.ts.map +1 -1
- package/dist/validation-content.js +13 -0
- package/dist/validation-core.d.ts +53 -3
- package/dist/validation-core.d.ts.map +1 -1
- package/dist/validation-core.js +16 -0
- package/dist/validation-public.d.ts +9 -9
- package/dist/validation-shared.d.ts +4 -4
- 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
|
+
}
|