@voyant-travel/catalog 0.117.2
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/README.md +190 -0
- package/dist/adapter/booking-forwarding.d.ts +2 -0
- package/dist/adapter/booking-forwarding.d.ts.map +1 -0
- package/dist/adapter/booking-forwarding.js +1 -0
- package/dist/adapter/channel-push-contracts.d.ts +2 -0
- package/dist/adapter/channel-push-contracts.d.ts.map +1 -0
- package/dist/adapter/channel-push-contracts.js +1 -0
- package/dist/adapter/contract.d.ts +2 -0
- package/dist/adapter/contract.d.ts.map +1 -0
- package/dist/adapter/contract.js +1 -0
- package/dist/adapter/contract.test.d.ts +2 -0
- package/dist/adapter/contract.test.d.ts.map +1 -0
- package/dist/adapter/contract.test.js +390 -0
- package/dist/adapter/provider-contracts.d.ts +2 -0
- package/dist/adapter/provider-contracts.d.ts.map +1 -0
- package/dist/adapter/provider-contracts.js +1 -0
- package/dist/adapter/provider-contracts.test.d.ts +2 -0
- package/dist/adapter/provider-contracts.test.d.ts.map +1 -0
- package/dist/adapter/provider-contracts.test.js +206 -0
- package/dist/adapter/schemas.d.ts +2 -0
- package/dist/adapter/schemas.d.ts.map +1 -0
- package/dist/adapter/schemas.js +1 -0
- package/dist/adapter/schemas.test.d.ts +2 -0
- package/dist/adapter/schemas.test.d.ts.map +1 -0
- package/dist/adapter/schemas.test.js +344 -0
- package/dist/booking-engine/book.d.ts +124 -0
- package/dist/booking-engine/book.d.ts.map +1 -0
- package/dist/booking-engine/book.js +311 -0
- package/dist/booking-engine/cancel.d.ts +40 -0
- package/dist/booking-engine/cancel.d.ts.map +1 -0
- package/dist/booking-engine/cancel.js +56 -0
- package/dist/booking-engine/checkout-finalize.d.ts +146 -0
- package/dist/booking-engine/checkout-finalize.d.ts.map +1 -0
- package/dist/booking-engine/checkout-finalize.js +132 -0
- package/dist/booking-engine/contracts.d.ts +9 -0
- package/dist/booking-engine/contracts.d.ts.map +1 -0
- package/dist/booking-engine/contracts.js +8 -0
- package/dist/booking-engine/contracts.test.d.ts +2 -0
- package/dist/booking-engine/contracts.test.d.ts.map +1 -0
- package/dist/booking-engine/contracts.test.js +116 -0
- package/dist/booking-engine/draft-shape.d.ts +10 -0
- package/dist/booking-engine/draft-shape.d.ts.map +1 -0
- package/dist/booking-engine/draft-shape.js +9 -0
- package/dist/booking-engine/draft-shape.test.d.ts +2 -0
- package/dist/booking-engine/draft-shape.test.d.ts.map +1 -0
- package/dist/booking-engine/draft-shape.test.js +74 -0
- package/dist/booking-engine/drafts-schema.d.ts +302 -0
- package/dist/booking-engine/drafts-schema.d.ts.map +1 -0
- package/dist/booking-engine/drafts-schema.js +53 -0
- package/dist/booking-engine/drafts-service.d.ts +41 -0
- package/dist/booking-engine/drafts-service.d.ts.map +1 -0
- package/dist/booking-engine/drafts-service.js +108 -0
- package/dist/booking-engine/errors.d.ts +81 -0
- package/dist/booking-engine/errors.d.ts.map +1 -0
- package/dist/booking-engine/errors.js +113 -0
- package/dist/booking-engine/index.d.ts +36 -0
- package/dist/booking-engine/index.d.ts.map +1 -0
- package/dist/booking-engine/index.js +34 -0
- package/dist/booking-engine/orders.d.ts +41 -0
- package/dist/booking-engine/orders.d.ts.map +1 -0
- package/dist/booking-engine/orders.js +49 -0
- package/dist/booking-engine/owned-handler.d.ts +166 -0
- package/dist/booking-engine/owned-handler.d.ts.map +1 -0
- package/dist/booking-engine/owned-handler.js +50 -0
- package/dist/booking-engine/owned-handler.test.d.ts +2 -0
- package/dist/booking-engine/owned-handler.test.d.ts.map +1 -0
- package/dist/booking-engine/owned-handler.test.js +63 -0
- package/dist/booking-engine/promotions-contract.d.ts +8 -0
- package/dist/booking-engine/promotions-contract.d.ts.map +1 -0
- package/dist/booking-engine/promotions-contract.js +7 -0
- package/dist/booking-engine/quote-enricher.test.d.ts +12 -0
- package/dist/booking-engine/quote-enricher.test.d.ts.map +1 -0
- package/dist/booking-engine/quote-enricher.test.js +138 -0
- package/dist/booking-engine/quote.d.ts +163 -0
- package/dist/booking-engine/quote.d.ts.map +1 -0
- package/dist/booking-engine/quote.js +259 -0
- package/dist/booking-engine/registry.d.ts +85 -0
- package/dist/booking-engine/registry.d.ts.map +1 -0
- package/dist/booking-engine/registry.js +118 -0
- package/dist/booking-engine/registry.test.d.ts +2 -0
- package/dist/booking-engine/registry.test.d.ts.map +1 -0
- package/dist/booking-engine/registry.test.js +132 -0
- package/dist/booking-engine/routes-contracts.d.ts +169 -0
- package/dist/booking-engine/routes-contracts.d.ts.map +1 -0
- package/dist/booking-engine/routes-contracts.js +63 -0
- package/dist/booking-engine/routes.d.ts +7 -0
- package/dist/booking-engine/routes.d.ts.map +1 -0
- package/dist/booking-engine/routes.js +443 -0
- package/dist/booking-engine/routes.test.d.ts +2 -0
- package/dist/booking-engine/routes.test.d.ts.map +1 -0
- package/dist/booking-engine/routes.test.js +304 -0
- package/dist/booking-engine/schema.d.ts +455 -0
- package/dist/booking-engine/schema.d.ts.map +1 -0
- package/dist/booking-engine/schema.js +75 -0
- package/dist/booking-engine/snapshot-content.d.ts +120 -0
- package/dist/booking-engine/snapshot-content.d.ts.map +1 -0
- package/dist/booking-engine/snapshot-content.js +110 -0
- package/dist/booking-engine/snapshot-content.test.d.ts +2 -0
- package/dist/booking-engine/snapshot-content.test.d.ts.map +1 -0
- package/dist/booking-engine/snapshot-content.test.js +213 -0
- package/dist/booking-engine/sync.d.ts +136 -0
- package/dist/booking-engine/sync.d.ts.map +1 -0
- package/dist/booking-engine/sync.js +177 -0
- package/dist/booking-engine/sync.test.d.ts +2 -0
- package/dist/booking-engine/sync.test.d.ts.map +1 -0
- package/dist/booking-engine/sync.test.js +377 -0
- package/dist/contract.d.ts +2 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +1 -0
- package/dist/contract.test.d.ts +2 -0
- package/dist/contract.test.d.ts.map +1 -0
- package/dist/contract.test.js +107 -0
- package/dist/drift/events.d.ts +2 -0
- package/dist/drift/events.d.ts.map +1 -0
- package/dist/drift/events.js +1 -0
- package/dist/drift/events.test.d.ts +2 -0
- package/dist/drift/events.test.d.ts.map +1 -0
- package/dist/drift/events.test.js +100 -0
- package/dist/embeddings/contract.d.ts +85 -0
- package/dist/embeddings/contract.d.ts.map +1 -0
- package/dist/embeddings/contract.js +42 -0
- package/dist/embeddings/contract.test.d.ts +2 -0
- package/dist/embeddings/contract.test.d.ts.map +1 -0
- package/dist/embeddings/contract.test.js +30 -0
- package/dist/embeddings/gemini.d.ts +110 -0
- package/dist/embeddings/gemini.d.ts.map +1 -0
- package/dist/embeddings/gemini.js +118 -0
- package/dist/embeddings/gemini.test.d.ts +2 -0
- package/dist/embeddings/gemini.test.d.ts.map +1 -0
- package/dist/embeddings/gemini.test.js +132 -0
- package/dist/embeddings/model-registry.d.ts +62 -0
- package/dist/embeddings/model-registry.d.ts.map +1 -0
- package/dist/embeddings/model-registry.js +78 -0
- package/dist/embeddings/model-registry.test.d.ts +2 -0
- package/dist/embeddings/model-registry.test.d.ts.map +1 -0
- package/dist/embeddings/model-registry.test.js +81 -0
- package/dist/embeddings/openai.d.ts +81 -0
- package/dist/embeddings/openai.d.ts.map +1 -0
- package/dist/embeddings/openai.js +123 -0
- package/dist/embeddings/openai.test.d.ts +2 -0
- package/dist/embeddings/openai.test.d.ts.map +1 -0
- package/dist/embeddings/openai.test.js +164 -0
- package/dist/events/taxonomy.d.ts +158 -0
- package/dist/events/taxonomy.d.ts.map +1 -0
- package/dist/events/taxonomy.js +99 -0
- package/dist/events/taxonomy.test.d.ts +2 -0
- package/dist/events/taxonomy.test.d.ts.map +1 -0
- package/dist/events/taxonomy.test.js +48 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/indexer/contract.d.ts +203 -0
- package/dist/indexer/contract.d.ts.map +1 -0
- package/dist/indexer/contract.js +16 -0
- package/dist/indexer/typesense-search-query.d.ts +31 -0
- package/dist/indexer/typesense-search-query.d.ts.map +1 -0
- package/dist/indexer/typesense-search-query.js +185 -0
- package/dist/indexer/typesense.d.ts +105 -0
- package/dist/indexer/typesense.d.ts.map +1 -0
- package/dist/indexer/typesense.js +394 -0
- package/dist/indexer/typesense.test.d.ts +2 -0
- package/dist/indexer/typesense.test.d.ts.map +1 -0
- package/dist/indexer/typesense.test.js +253 -0
- package/dist/overlay/resolver.d.ts +101 -0
- package/dist/overlay/resolver.d.ts.map +1 -0
- package/dist/overlay/resolver.js +167 -0
- package/dist/overlay/resolver.test.d.ts +2 -0
- package/dist/overlay/resolver.test.d.ts.map +1 -0
- package/dist/overlay/resolver.test.js +179 -0
- package/dist/overlay/schema.d.ts +266 -0
- package/dist/overlay/schema.d.ts.map +1 -0
- package/dist/overlay/schema.js +71 -0
- package/dist/provenance.d.ts +2 -0
- package/dist/provenance.d.ts.map +1 -0
- package/dist/provenance.js +1 -0
- package/dist/schema-sourced-entries.d.ts +344 -0
- package/dist/schema-sourced-entries.d.ts.map +1 -0
- package/dist/schema-sourced-entries.js +75 -0
- package/dist/schema.d.ts +21 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +20 -0
- package/dist/search/federate.d.ts +58 -0
- package/dist/search/federate.d.ts.map +1 -0
- package/dist/search/federate.js +103 -0
- package/dist/search/federate.test.d.ts +2 -0
- package/dist/search/federate.test.d.ts.map +1 -0
- package/dist/search/federate.test.js +146 -0
- package/dist/search/rerank.d.ts +77 -0
- package/dist/search/rerank.d.ts.map +1 -0
- package/dist/search/rerank.js +68 -0
- package/dist/search/rerank.test.d.ts +2 -0
- package/dist/search/rerank.test.d.ts.map +1 -0
- package/dist/search/rerank.test.js +60 -0
- package/dist/search/routes.d.ts +144 -0
- package/dist/search/routes.d.ts.map +1 -0
- package/dist/search/routes.js +288 -0
- package/dist/search/routes.test.d.ts +2 -0
- package/dist/search/routes.test.d.ts.map +1 -0
- package/dist/search/routes.test.js +322 -0
- package/dist/search/semantic.d.ts +63 -0
- package/dist/search/semantic.d.ts.map +1 -0
- package/dist/search/semantic.js +75 -0
- package/dist/search/semantic.test.d.ts +2 -0
- package/dist/search/semantic.test.d.ts.map +1 -0
- package/dist/search/semantic.test.js +143 -0
- package/dist/services/build-indexer-document.test.d.ts +2 -0
- package/dist/services/build-indexer-document.test.d.ts.map +1 -0
- package/dist/services/build-indexer-document.test.js +102 -0
- package/dist/services/content-service.d.ts +125 -0
- package/dist/services/content-service.d.ts.map +1 -0
- package/dist/services/content-service.js +139 -0
- package/dist/services/content-service.test.d.ts +2 -0
- package/dist/services/content-service.test.d.ts.map +1 -0
- package/dist/services/content-service.test.js +322 -0
- package/dist/services/indexer-service.d.ts +109 -0
- package/dist/services/indexer-service.d.ts.map +1 -0
- package/dist/services/indexer-service.js +123 -0
- package/dist/services/indexer-service.test.d.ts +2 -0
- package/dist/services/indexer-service.test.d.ts.map +1 -0
- package/dist/services/indexer-service.test.js +176 -0
- package/dist/services/overlay-service.d.ts +108 -0
- package/dist/services/overlay-service.d.ts.map +1 -0
- package/dist/services/overlay-service.js +211 -0
- package/dist/services/overlay-service.test.d.ts +2 -0
- package/dist/services/overlay-service.test.d.ts.map +1 -0
- package/dist/services/overlay-service.test.js +79 -0
- package/dist/services/snapshot-builder.test.d.ts +2 -0
- package/dist/services/snapshot-builder.test.d.ts.map +1 -0
- package/dist/services/snapshot-builder.test.js +93 -0
- package/dist/services/snapshot-service.d.ts +78 -0
- package/dist/services/snapshot-service.d.ts.map +1 -0
- package/dist/services/snapshot-service.js +165 -0
- package/dist/services/sourced-entry-service.d.ts +142 -0
- package/dist/services/sourced-entry-service.d.ts.map +1 -0
- package/dist/services/sourced-entry-service.js +203 -0
- package/dist/services/sourced-entry-service.test.d.ts +10 -0
- package/dist/services/sourced-entry-service.test.d.ts.map +1 -0
- package/dist/services/sourced-entry-service.test.js +66 -0
- package/dist/snapshot/schema.d.ts +362 -0
- package/dist/snapshot/schema.d.ts.map +1 -0
- package/dist/snapshot/schema.js +102 -0
- package/package.json +210 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focused tests for the `contentEnricher` hook on `quoteEntity`. The
|
|
3
|
+
* full quote path needs a real DB — these tests stub the drizzle
|
|
4
|
+
* insert chain just enough to verify the hook's call contract:
|
|
5
|
+
* - Called with the right input when wired and live-resolve succeeds.
|
|
6
|
+
* - Result attached to the quote response as `shape`.
|
|
7
|
+
* - Errors swallowed + reported via `onEnricherError` instead of
|
|
8
|
+
* failing the quote.
|
|
9
|
+
* - Skipped when the entity is not available (failed live-resolve).
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { DEFAULT_PAX_BANDS, defaultBookingFields, defaultDraftShapeFlags, defaultTravelerFields, } from "./draft-shape.js";
|
|
13
|
+
import { quoteEntity } from "./quote.js";
|
|
14
|
+
import { createSourceAdapterRegistry } from "./registry.js";
|
|
15
|
+
function makeStubDb() {
|
|
16
|
+
// Mirrors enough of the drizzle chain for `.insert(...).values(...).returning()`.
|
|
17
|
+
return {
|
|
18
|
+
insert() {
|
|
19
|
+
return {
|
|
20
|
+
values(_v) {
|
|
21
|
+
return {
|
|
22
|
+
async returning() {
|
|
23
|
+
return [{ id: "cquo_x" }];
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function makeAdapter(liveResolve) {
|
|
32
|
+
return {
|
|
33
|
+
kind: "stub",
|
|
34
|
+
capabilities: {
|
|
35
|
+
verticals: ["products"],
|
|
36
|
+
supportsLiveResolution: true,
|
|
37
|
+
supportsDriftDetection: false,
|
|
38
|
+
supportsBookingForwarding: false,
|
|
39
|
+
postBookOperations: [],
|
|
40
|
+
},
|
|
41
|
+
connect: async () => undefined,
|
|
42
|
+
pause: async () => undefined,
|
|
43
|
+
disconnect: async () => undefined,
|
|
44
|
+
getState: async () => "active",
|
|
45
|
+
discover: async () => ({ projections: [], next_cursor: undefined }),
|
|
46
|
+
liveResolve,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const baseRequest = {
|
|
50
|
+
entityModule: "products",
|
|
51
|
+
entityId: "prod_abc",
|
|
52
|
+
sourceKind: "stub",
|
|
53
|
+
scope: { locale: "en-GB", audience: "customer", market: "GB", currency: "GBP" },
|
|
54
|
+
adapterContext: { connection_id: "conn_1" },
|
|
55
|
+
};
|
|
56
|
+
const okLiveResolve = async (_ctx, _req) => ({
|
|
57
|
+
values: { prod_abc: { priceCents: 10000, currency: "GBP" } },
|
|
58
|
+
});
|
|
59
|
+
const failLiveResolve = async (_ctx, _req) => ({
|
|
60
|
+
values: {},
|
|
61
|
+
failed: { prod_abc: "not_found" },
|
|
62
|
+
});
|
|
63
|
+
const sampleShape = {
|
|
64
|
+
...defaultDraftShapeFlags(),
|
|
65
|
+
paxBands: DEFAULT_PAX_BANDS,
|
|
66
|
+
paxBandsAllowedTotal: { min: 1, max: 8 },
|
|
67
|
+
travelerFields: defaultTravelerFields(),
|
|
68
|
+
bookingFields: defaultBookingFields(),
|
|
69
|
+
paymentIntents: ["hold", "card"],
|
|
70
|
+
};
|
|
71
|
+
describe("quoteEntity — contentEnricher hook", () => {
|
|
72
|
+
it("attaches the enricher's BookingDraftShape to the quote result", async () => {
|
|
73
|
+
const enricher = vi.fn(async () => sampleShape);
|
|
74
|
+
const registry = createSourceAdapterRegistry();
|
|
75
|
+
registry.register(makeAdapter(okLiveResolve));
|
|
76
|
+
const result = await quoteEntity(makeStubDb(), { registry, contentEnricher: enricher }, baseRequest);
|
|
77
|
+
expect(result.available).toBe(true);
|
|
78
|
+
expect(result.shape).toEqual(sampleShape);
|
|
79
|
+
expect(enricher).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
it("threads entity identity + scope into the enricher input", async () => {
|
|
82
|
+
let captured = null;
|
|
83
|
+
const enricher = async (input) => {
|
|
84
|
+
captured = input;
|
|
85
|
+
return sampleShape;
|
|
86
|
+
};
|
|
87
|
+
const registry = createSourceAdapterRegistry();
|
|
88
|
+
registry.register(makeAdapter(okLiveResolve));
|
|
89
|
+
await quoteEntity(makeStubDb(), { registry, contentEnricher: enricher }, baseRequest);
|
|
90
|
+
expect(captured).not.toBeNull();
|
|
91
|
+
expect(captured.entityModule).toBe("products");
|
|
92
|
+
expect(captured.entityId).toBe("prod_abc");
|
|
93
|
+
expect(captured.sourceKind).toBe("stub");
|
|
94
|
+
expect(captured.scope.locale).toBe("en-GB");
|
|
95
|
+
expect(captured.scope.market).toBe("GB");
|
|
96
|
+
expect(captured.scope.currency).toBe("GBP");
|
|
97
|
+
});
|
|
98
|
+
it("omits shape when no enricher is wired (today's default)", async () => {
|
|
99
|
+
const registry = createSourceAdapterRegistry();
|
|
100
|
+
registry.register(makeAdapter(okLiveResolve));
|
|
101
|
+
const result = await quoteEntity(makeStubDb(), { registry }, baseRequest);
|
|
102
|
+
expect(result.shape).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
it("skips the enricher when live-resolve fails (entity not available)", async () => {
|
|
105
|
+
const enricher = vi.fn(async () => sampleShape);
|
|
106
|
+
const registry = createSourceAdapterRegistry();
|
|
107
|
+
registry.register(makeAdapter(failLiveResolve));
|
|
108
|
+
const result = await quoteEntity(makeStubDb(), { registry, contentEnricher: enricher }, baseRequest);
|
|
109
|
+
expect(result.available).toBe(false);
|
|
110
|
+
expect(result.shape).toBeUndefined();
|
|
111
|
+
expect(enricher).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
it("swallows enricher errors and reports via onEnricherError — quote still succeeds", async () => {
|
|
114
|
+
const enricher = async () => {
|
|
115
|
+
throw new Error("content-service unreachable");
|
|
116
|
+
};
|
|
117
|
+
const errors = [];
|
|
118
|
+
const registry = createSourceAdapterRegistry();
|
|
119
|
+
registry.register(makeAdapter(okLiveResolve));
|
|
120
|
+
const result = await quoteEntity(makeStubDb(), { registry, contentEnricher: enricher, onEnricherError: (e) => errors.push(e) }, baseRequest);
|
|
121
|
+
// Quote succeeded — the enricher error didn't block.
|
|
122
|
+
expect(result.available).toBe(true);
|
|
123
|
+
expect(result.shape).toBeUndefined();
|
|
124
|
+
// Diagnostic surfaced.
|
|
125
|
+
expect(errors).toHaveLength(1);
|
|
126
|
+
expect(errors[0]?.entityModule).toBe("products");
|
|
127
|
+
expect(errors[0]?.entityId).toBe("prod_abc");
|
|
128
|
+
expect(errors[0]?.reason).toContain("content-service unreachable");
|
|
129
|
+
});
|
|
130
|
+
it("returns shape: undefined when the enricher returns null (entity has no content)", async () => {
|
|
131
|
+
const enricher = async () => null;
|
|
132
|
+
const registry = createSourceAdapterRegistry();
|
|
133
|
+
registry.register(makeAdapter(okLiveResolve));
|
|
134
|
+
const result = await quoteEntity(makeStubDb(), { registry, contentEnricher: enricher }, baseRequest);
|
|
135
|
+
expect(result.available).toBe(true);
|
|
136
|
+
expect(result.shape).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `quoteEntity` — the first step in the booking engine lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Asks the registered adapter "is this row still bookable, and at what
|
|
5
|
+
* price right now?", persists the answer in `catalog_quotes` with an
|
|
6
|
+
* expiry, and returns a stable `quoteId` the subsequent `bookEntity`
|
|
7
|
+
* call can validate against.
|
|
8
|
+
*
|
|
9
|
+
* Quotes are short-lived (default TTL: 10 minutes) and not de-duped.
|
|
10
|
+
* Re-quoting the same row produces a new quote row so the audit trail
|
|
11
|
+
* shows every quote attempt.
|
|
12
|
+
*/
|
|
13
|
+
import type { AnyDrizzleDb } from "@voyant-travel/db";
|
|
14
|
+
import type { SourceAdapterContext } from "../adapter/contract.js";
|
|
15
|
+
import type { PricingBasis } from "../snapshot/schema.js";
|
|
16
|
+
import type { BookingDraftShape } from "./draft-shape.js";
|
|
17
|
+
import type { OwnedBookingHandlerRegistry } from "./owned-handler.js";
|
|
18
|
+
import type { PromotionEvaluationInput, PromotionEvaluationOutput } from "./promotions-contract.js";
|
|
19
|
+
import type { SourceAdapterRegistry } from "./registry.js";
|
|
20
|
+
/** Default time-to-live for a quote. */
|
|
21
|
+
export declare const DEFAULT_QUOTE_TTL_MS: number;
|
|
22
|
+
export interface QuoteScope {
|
|
23
|
+
locale: string;
|
|
24
|
+
audience: string;
|
|
25
|
+
market: string;
|
|
26
|
+
currency?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface QuoteEntityRequest {
|
|
29
|
+
/** The catalog row to quote. */
|
|
30
|
+
entityModule: string;
|
|
31
|
+
entityId: string;
|
|
32
|
+
/** Source pointer, read from the row's provenance. */
|
|
33
|
+
sourceKind: string;
|
|
34
|
+
sourceProvider?: string;
|
|
35
|
+
sourceConnectionId?: string;
|
|
36
|
+
sourceRef?: string;
|
|
37
|
+
/** Variant scope for the quote. */
|
|
38
|
+
scope: QuoteScope;
|
|
39
|
+
/** Vertical-specific parameters echoed to the adapter (date range, pax, etc.). */
|
|
40
|
+
parameters?: Record<string, unknown>;
|
|
41
|
+
/** Override the TTL (rare — defaults to `DEFAULT_QUOTE_TTL_MS`). */
|
|
42
|
+
ttlMs?: number;
|
|
43
|
+
/** Adapter context (connection_id, credentials, correlation_id). */
|
|
44
|
+
adapterContext: SourceAdapterContext;
|
|
45
|
+
}
|
|
46
|
+
export interface QuoteEntityResult {
|
|
47
|
+
quoteId: string;
|
|
48
|
+
quotedAt: Date;
|
|
49
|
+
expiresAt: Date;
|
|
50
|
+
available: boolean;
|
|
51
|
+
invalidReason?: string;
|
|
52
|
+
pricing?: PricingBasis;
|
|
53
|
+
upstreamPayload?: Record<string, unknown>;
|
|
54
|
+
/**
|
|
55
|
+
* The journey wizard descriptor — populated when
|
|
56
|
+
* `deps.contentEnricher` is wired and the entity is sourced. Tells
|
|
57
|
+
* the wizard which steps + sub-steps to render. Per
|
|
58
|
+
* `docs/architecture/booking-journey-architecture.md` §3, this is
|
|
59
|
+
* returned alongside the quote so the journey can render the
|
|
60
|
+
* correct shape without a follow-up call.
|
|
61
|
+
*
|
|
62
|
+
* Undefined when no enricher is wired (today's behavior — the
|
|
63
|
+
* journey hardcodes a minimal shape until templates wire content).
|
|
64
|
+
*/
|
|
65
|
+
shape?: BookingDraftShape;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Input the content enricher receives — quote + scope + parameters.
|
|
69
|
+
* The enricher reads cached content for the entity and projects a
|
|
70
|
+
* `BookingDraftShape` that drives the wizard. Verticals compose their
|
|
71
|
+
* `build*DraftShape` builders into one enricher routed by
|
|
72
|
+
* `entity_module`.
|
|
73
|
+
*/
|
|
74
|
+
export interface QuoteContentEnrichmentInput {
|
|
75
|
+
db: AnyDrizzleDb;
|
|
76
|
+
entityModule: string;
|
|
77
|
+
entityId: string;
|
|
78
|
+
sourceKind: string;
|
|
79
|
+
sourceConnectionId?: string;
|
|
80
|
+
sourceRef?: string;
|
|
81
|
+
scope: QuoteScope;
|
|
82
|
+
parameters?: Record<string, unknown>;
|
|
83
|
+
adapterContext: SourceAdapterContext;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Hook called by `quoteEntity` after the live-resolve step succeeds.
|
|
87
|
+
* Receives entity identity + scope; returns a `BookingDraftShape` (or
|
|
88
|
+
* null when content is unavailable / the entity is owned and the
|
|
89
|
+
* enricher chooses not to surface a shape).
|
|
90
|
+
*
|
|
91
|
+
* Templates compose this from per-vertical content services, e.g.:
|
|
92
|
+
*
|
|
93
|
+
* const enricher: QuoteContentEnricher = async (input) => {
|
|
94
|
+
* const content = await readContentByModule(input)
|
|
95
|
+
* return content ? buildDraftShape(input.entityModule, content, input.scope) : null
|
|
96
|
+
* }
|
|
97
|
+
*/
|
|
98
|
+
export type QuoteContentEnricher = (input: QuoteContentEnrichmentInput) => Promise<BookingDraftShape | null>;
|
|
99
|
+
export interface QuoteEntityDeps {
|
|
100
|
+
registry: SourceAdapterRegistry;
|
|
101
|
+
/**
|
|
102
|
+
* Owned-arm dispatch — when set and the request's source kind is
|
|
103
|
+
* `"owned"`, the engine routes to a handler keyed by
|
|
104
|
+
* `entityModule` instead of the SourceAdapterRegistry. Per
|
|
105
|
+
* booking-journey-architecture §6.
|
|
106
|
+
*
|
|
107
|
+
* Templates that ship owned products MUST wire this; templates
|
|
108
|
+
* that only proxy sourced rows can leave it undefined and the
|
|
109
|
+
* engine falls through to the legacy adapter path.
|
|
110
|
+
*/
|
|
111
|
+
ownedHandlers?: OwnedBookingHandlerRegistry;
|
|
112
|
+
/**
|
|
113
|
+
* Optional content-aware enricher. When wired, called after the
|
|
114
|
+
* adapter's `liveResolve` step succeeds; the returned
|
|
115
|
+
* `BookingDraftShape` is attached to the quote result so the
|
|
116
|
+
* journey wizard can render the correct shape without a follow-up
|
|
117
|
+
* call.
|
|
118
|
+
*
|
|
119
|
+
* When not wired (today's default), the quote response omits
|
|
120
|
+
* `shape` and the journey falls back to its hardcoded minimal
|
|
121
|
+
* descriptor.
|
|
122
|
+
*
|
|
123
|
+
* Errors from the enricher are caught and logged via
|
|
124
|
+
* `onEnricherError` (defaults to silent) — they MUST NOT fail the
|
|
125
|
+
* quote because the wizard can render the minimal shape on its
|
|
126
|
+
* own.
|
|
127
|
+
*/
|
|
128
|
+
contentEnricher?: QuoteContentEnricher;
|
|
129
|
+
/** Optional sink for enricher errors. */
|
|
130
|
+
onEnricherError?: (event: {
|
|
131
|
+
entityModule: string;
|
|
132
|
+
entityId: string;
|
|
133
|
+
reason: string;
|
|
134
|
+
}) => void;
|
|
135
|
+
/**
|
|
136
|
+
* Optional promotion-evaluator hook. When wired, called after the
|
|
137
|
+
* adapter's `liveResolve` succeeds (only for `entity_module ==
|
|
138
|
+
* "products"` in v1). Discounts apply to `pricing.base_amount`
|
|
139
|
+
* pre-tax; the operator starter's `applyOperatorTaxToQuoteResult`
|
|
140
|
+
* step downstream recomputes taxes against the new base.
|
|
141
|
+
*
|
|
142
|
+
* When the customer-supplied code fails validation, the engine
|
|
143
|
+
* surfaces the result as a `code_*` `invalidReason` on the quote
|
|
144
|
+
* (`code_not_found`, `code_expired`, `code_not_yet_valid`,
|
|
145
|
+
* `code_not_applicable`). Auto offers do NOT apply when a bad code
|
|
146
|
+
* is supplied — the quote is short-circuited to unavailable so the
|
|
147
|
+
* customer gets clear feedback.
|
|
148
|
+
*
|
|
149
|
+
* Per `docs/architecture/promotions-architecture.md` §3.6 + §7.1.
|
|
150
|
+
*/
|
|
151
|
+
evaluatePromotions?: (input: PromotionEvaluationInput) => Promise<PromotionEvaluationOutput>;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Quote the row. Calls `adapter.liveResolve` (sourced) or interprets
|
|
155
|
+
* `available = true` from a stub (owned, when no adapter is registered
|
|
156
|
+
* for the `"owned"` kind in this MVP cut).
|
|
157
|
+
*
|
|
158
|
+
* Throws `NoAdapterRegisteredError` if the registry has no entry for
|
|
159
|
+
* `sourceKind`. Persists the quote either way — a `failed` lookup is
|
|
160
|
+
* still recorded so subsequent diagnostics can see the attempt.
|
|
161
|
+
*/
|
|
162
|
+
export declare function quoteEntity(db: AnyDrizzleDb, deps: QuoteEntityDeps, request: QuoteEntityRequest): Promise<QuoteEntityResult>;
|
|
163
|
+
//# sourceMappingURL=quote.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"quote.d.ts","sourceRoot":"","sources":["../../src/booking-engine/quote.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `quoteEntity` — the first step in the booking engine lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Asks the registered adapter "is this row still bookable, and at what
|
|
5
|
+
* price right now?", persists the answer in `catalog_quotes` with an
|
|
6
|
+
* expiry, and returns a stable `quoteId` the subsequent `bookEntity`
|
|
7
|
+
* call can validate against.
|
|
8
|
+
*
|
|
9
|
+
* Quotes are short-lived (default TTL: 10 minutes) and not de-duped.
|
|
10
|
+
* Re-quoting the same row produces a new quote row so the audit trail
|
|
11
|
+
* shows every quote attempt.
|
|
12
|
+
*/
|
|
13
|
+
import { newId } from "@voyant-travel/db/lib/typeid";
|
|
14
|
+
import { OWNED_SOURCE_KIND } from "./owned-handler.js";
|
|
15
|
+
import { catalogQuotesTable } from "./schema.js";
|
|
16
|
+
/** Default time-to-live for a quote. */
|
|
17
|
+
export const DEFAULT_QUOTE_TTL_MS = 10 * 60 * 1000;
|
|
18
|
+
/**
|
|
19
|
+
* Quote the row. Calls `adapter.liveResolve` (sourced) or interprets
|
|
20
|
+
* `available = true` from a stub (owned, when no adapter is registered
|
|
21
|
+
* for the `"owned"` kind in this MVP cut).
|
|
22
|
+
*
|
|
23
|
+
* Throws `NoAdapterRegisteredError` if the registry has no entry for
|
|
24
|
+
* `sourceKind`. Persists the quote either way — a `failed` lookup is
|
|
25
|
+
* still recorded so subsequent diagnostics can see the attempt.
|
|
26
|
+
*/
|
|
27
|
+
export async function quoteEntity(db, deps, request) {
|
|
28
|
+
const ttlMs = request.ttlMs ?? DEFAULT_QUOTE_TTL_MS;
|
|
29
|
+
const quotedAt = new Date();
|
|
30
|
+
const expiresAt = new Date(quotedAt.getTime() + ttlMs);
|
|
31
|
+
// Two dispatch arms:
|
|
32
|
+
// - Owned: handler registry keyed by entity_module. Returns a
|
|
33
|
+
// ComputeQuoteResult directly — pricing, shape, availability.
|
|
34
|
+
// - Sourced: SourceAdapterRegistry keyed by connection_id (with
|
|
35
|
+
// a kind-only fallback for legacy single-connection-per-kind).
|
|
36
|
+
let available;
|
|
37
|
+
let failedReason;
|
|
38
|
+
let pricing;
|
|
39
|
+
let upstreamPayload;
|
|
40
|
+
let ownedShape;
|
|
41
|
+
if (request.sourceKind === OWNED_SOURCE_KIND && deps.ownedHandlers) {
|
|
42
|
+
const handler = deps.ownedHandlers.resolveOrThrow(request.entityModule);
|
|
43
|
+
const result = await handler.computeQuote({ db, adapterContext: request.adapterContext }, {
|
|
44
|
+
entityModule: request.entityModule,
|
|
45
|
+
entityId: request.entityId,
|
|
46
|
+
scope: request.scope,
|
|
47
|
+
parameters: request.parameters,
|
|
48
|
+
draft: request.parameters?.draft,
|
|
49
|
+
});
|
|
50
|
+
available = result.available;
|
|
51
|
+
failedReason = result.invalidReason;
|
|
52
|
+
pricing = result.pricing;
|
|
53
|
+
upstreamPayload = result.upstreamPayload;
|
|
54
|
+
ownedShape = result.shape;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const adapter = request.sourceConnectionId
|
|
58
|
+
? (deps.registry.resolveByConnection(request.sourceConnectionId) ??
|
|
59
|
+
deps.registry.resolveOrThrow(request.sourceKind))
|
|
60
|
+
: deps.registry.resolveOrThrow(request.sourceKind);
|
|
61
|
+
let liveResolve = { values: {} };
|
|
62
|
+
if (adapter.liveResolve) {
|
|
63
|
+
liveResolve = await adapter.liveResolve(request.adapterContext, {
|
|
64
|
+
ids: [request.entityId],
|
|
65
|
+
scope: {
|
|
66
|
+
locale: request.scope.locale,
|
|
67
|
+
audience: request.scope.audience,
|
|
68
|
+
market: request.scope.market,
|
|
69
|
+
currency: request.scope.currency,
|
|
70
|
+
},
|
|
71
|
+
parameters: request.parameters,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
failedReason = liveResolve.failed?.[request.entityId];
|
|
75
|
+
const liveValues = liveResolve.values[request.entityId];
|
|
76
|
+
available = !failedReason && liveValues !== undefined;
|
|
77
|
+
pricing = available ? liveValuesToPricing(liveValues, request.scope.currency) : undefined;
|
|
78
|
+
upstreamPayload = liveValues;
|
|
79
|
+
}
|
|
80
|
+
// Promotion evaluation — runs only for the products vertical in v1
|
|
81
|
+
// (other verticals would need their own bridge to the evaluator).
|
|
82
|
+
// Discounts apply to `pricing.base_amount` pre-tax; operator starter
|
|
83
|
+
// tax recompute downstream picks up the new base.
|
|
84
|
+
let appliedOffers;
|
|
85
|
+
if (deps.evaluatePromotions && available && pricing && request.entityModule === "products") {
|
|
86
|
+
const params = request.parameters;
|
|
87
|
+
const promotionCode = readString(params?.promotionCode);
|
|
88
|
+
// Read `paxCount` first — `engineParametersFromDraft` (in this file's
|
|
89
|
+
// sibling `routes.ts`) writes the summed traveler count under that key
|
|
90
|
+
// when a draft drives the quote, and the products owned-handler reads
|
|
91
|
+
// `paxCount` too. Fall back to `pax` for callers that build parameters
|
|
92
|
+
// directly without going through the draft pipeline.
|
|
93
|
+
const pax = readNumber(params?.paxCount) ?? readNumber(params?.pax);
|
|
94
|
+
const offerEval = await deps.evaluatePromotions({
|
|
95
|
+
productId: request.entityId,
|
|
96
|
+
slice: { audience: narrowAudience(request.scope.audience), market: request.scope.market },
|
|
97
|
+
pax,
|
|
98
|
+
date: quotedAt,
|
|
99
|
+
code: promotionCode,
|
|
100
|
+
basePriceCents: Math.round(pricing.base_amount),
|
|
101
|
+
baseCurrency: pricing.currency,
|
|
102
|
+
});
|
|
103
|
+
const codeStatus = offerEval.codeStatus;
|
|
104
|
+
if (codeStatus && codeStatus.kind !== "code_valid") {
|
|
105
|
+
// Customer-supplied code failed validation. Surface as a quote-
|
|
106
|
+
// level invalidReason and short-circuit; auto offers don't apply
|
|
107
|
+
// either, so the customer gets unambiguous feedback.
|
|
108
|
+
available = false;
|
|
109
|
+
failedReason = codeStatusToReason(codeStatus);
|
|
110
|
+
}
|
|
111
|
+
else if (offerEval.applied.length > 0) {
|
|
112
|
+
// Subtract the discount from base_amount in cents. The operator
|
|
113
|
+
// template's `applyOperatorTaxToQuoteResult` step downstream
|
|
114
|
+
// sees the new base and recomputes taxes accordingly.
|
|
115
|
+
pricing.base_amount = Math.round(pricing.base_amount) - offerEval.total.discountAppliedCents;
|
|
116
|
+
pricing.appliedOffers = offerEval.applied;
|
|
117
|
+
appliedOffers = offerEval.applied;
|
|
118
|
+
// Invalidate any taxes / breakdown that the source (sourced
|
|
119
|
+
// adapter or owned handler) computed against the un-discounted
|
|
120
|
+
// base — they're stale now. Setting `taxes = 0` and clearing
|
|
121
|
+
// `breakdown` is the explicit signal the operator-side transform
|
|
122
|
+
// (`applyOperatorTaxToQuoteResult`) reads to recompute. Without
|
|
123
|
+
// this, the API serializer would echo a breakdown total that
|
|
124
|
+
// doesn't match `appliedOffers`.
|
|
125
|
+
pricing.taxes = 0;
|
|
126
|
+
pricing.breakdown = undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const quoteId = newId("catalog_quotes");
|
|
130
|
+
const inserted = (await db
|
|
131
|
+
.insert(catalogQuotesTable)
|
|
132
|
+
.values({
|
|
133
|
+
id: quoteId,
|
|
134
|
+
entity_module: request.entityModule,
|
|
135
|
+
entity_id: request.entityId,
|
|
136
|
+
source_kind: request.sourceKind,
|
|
137
|
+
source_provider: request.sourceProvider,
|
|
138
|
+
source_connection_id: request.sourceConnectionId,
|
|
139
|
+
source_ref: request.sourceRef,
|
|
140
|
+
available,
|
|
141
|
+
invalid_reason: failedReason,
|
|
142
|
+
locale: request.scope.locale,
|
|
143
|
+
audience: request.scope.audience,
|
|
144
|
+
market: request.scope.market,
|
|
145
|
+
currency: request.scope.currency,
|
|
146
|
+
pricing_base_amount: pricing?.base_amount != null ? String(pricing.base_amount) : undefined,
|
|
147
|
+
pricing_taxes: pricing?.taxes != null ? String(pricing.taxes) : undefined,
|
|
148
|
+
pricing_fees: pricing?.fees != null ? String(pricing.fees) : undefined,
|
|
149
|
+
pricing_surcharges: pricing?.surcharges != null ? String(pricing.surcharges) : undefined,
|
|
150
|
+
pricing_currency: pricing?.currency,
|
|
151
|
+
pricing_breakdown: pricing?.breakdown,
|
|
152
|
+
pricing_applied_offers: appliedOffers,
|
|
153
|
+
upstream_payload: upstreamPayload ?? null,
|
|
154
|
+
created_at: quotedAt,
|
|
155
|
+
expires_at: expiresAt,
|
|
156
|
+
})
|
|
157
|
+
.returning());
|
|
158
|
+
if (!inserted[0])
|
|
159
|
+
throw new Error("quoteEntity: insert returned no rows");
|
|
160
|
+
// Optional content enrichment — per booking-journey-architecture
|
|
161
|
+
// §3, the quote response carries a BookingDraftShape descriptor
|
|
162
|
+
// when the engine has the content in front of it. When the hook
|
|
163
|
+
// throws, we swallow the error (the wizard's minimal-shape fallback
|
|
164
|
+
// covers it) but surface via onEnricherError for diagnostics.
|
|
165
|
+
//
|
|
166
|
+
// Owned handlers are authoritative for their own products — when
|
|
167
|
+
// they returned a shape, the enricher is not consulted.
|
|
168
|
+
let shape = ownedShape;
|
|
169
|
+
if (!shape && deps.contentEnricher && available) {
|
|
170
|
+
try {
|
|
171
|
+
const enriched = await deps.contentEnricher({
|
|
172
|
+
db,
|
|
173
|
+
entityModule: request.entityModule,
|
|
174
|
+
entityId: request.entityId,
|
|
175
|
+
sourceKind: request.sourceKind,
|
|
176
|
+
sourceConnectionId: request.sourceConnectionId,
|
|
177
|
+
sourceRef: request.sourceRef,
|
|
178
|
+
scope: request.scope,
|
|
179
|
+
parameters: request.parameters,
|
|
180
|
+
adapterContext: request.adapterContext,
|
|
181
|
+
});
|
|
182
|
+
shape = enriched ?? undefined;
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
deps.onEnricherError?.({
|
|
186
|
+
entityModule: request.entityModule,
|
|
187
|
+
entityId: request.entityId,
|
|
188
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
quoteId,
|
|
194
|
+
quotedAt,
|
|
195
|
+
expiresAt,
|
|
196
|
+
available,
|
|
197
|
+
invalidReason: failedReason,
|
|
198
|
+
pricing,
|
|
199
|
+
upstreamPayload,
|
|
200
|
+
shape,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Convert an adapter's per-entity `liveResolve` value into the catalog
|
|
205
|
+
* plane's `PricingBasis` shape. The adapter's payload is opaque, so this
|
|
206
|
+
* helper looks for the conventional fields (`priceCents`, `currency`,
|
|
207
|
+
* `taxesCents`, `feesCents`, `surchargesCents`) and falls back to a
|
|
208
|
+
* single-line "all in base_amount" basis when only a price is provided.
|
|
209
|
+
*/
|
|
210
|
+
function liveValuesToPricing(values, fallbackCurrency) {
|
|
211
|
+
if (!values)
|
|
212
|
+
return undefined;
|
|
213
|
+
const priceCents = readNumber(values.priceCents) ?? readNumber(values.price);
|
|
214
|
+
if (priceCents == null)
|
|
215
|
+
return undefined;
|
|
216
|
+
const currency = readString(values.currency) ?? fallbackCurrency;
|
|
217
|
+
if (!currency)
|
|
218
|
+
return undefined;
|
|
219
|
+
return {
|
|
220
|
+
base_amount: priceCents,
|
|
221
|
+
taxes: readNumber(values.taxesCents) ?? 0,
|
|
222
|
+
fees: readNumber(values.feesCents) ?? 0,
|
|
223
|
+
surcharges: readNumber(values.surchargesCents) ?? 0,
|
|
224
|
+
currency,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function readNumber(v) {
|
|
228
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
229
|
+
return v;
|
|
230
|
+
if (typeof v === "string") {
|
|
231
|
+
const n = Number.parseFloat(v);
|
|
232
|
+
return Number.isFinite(n) ? n : undefined;
|
|
233
|
+
}
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
function readString(v) {
|
|
237
|
+
return typeof v === "string" ? v : undefined;
|
|
238
|
+
}
|
|
239
|
+
const KNOWN_AUDIENCES = new Set(["staff", "customer", "partner", "supplier"]);
|
|
240
|
+
/**
|
|
241
|
+
* Narrow the QuoteScope.audience (typed `string` for legacy reasons)
|
|
242
|
+
* to the evaluator's `Visibility` enum. Unknown audiences fall back to
|
|
243
|
+
* `"customer"` — the most permissive storefront default; exotic audience
|
|
244
|
+
* tokens (e.g., `"staff-admin"`) get no special treatment beyond visibility
|
|
245
|
+
* rules the offer's scope already encodes.
|
|
246
|
+
*/
|
|
247
|
+
function narrowAudience(audience) {
|
|
248
|
+
if (audience === "staff-admin")
|
|
249
|
+
return "staff";
|
|
250
|
+
return KNOWN_AUDIENCES.has(audience) ? audience : "customer";
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Map a non-valid `CodeStatus` to a quote `invalidReason` string. The
|
|
254
|
+
* `code_valid` case is filtered upstream so this only sees the failure
|
|
255
|
+
* variants.
|
|
256
|
+
*/
|
|
257
|
+
function codeStatusToReason(codeStatus) {
|
|
258
|
+
return codeStatus.kind;
|
|
259
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SourceAdapterRegistry` — process-local map keyed by `connection_id` that
|
|
3
|
+
* the booking engine and channel-push pipeline consult when dispatching a
|
|
4
|
+
* quote / book / cancel / push call.
|
|
5
|
+
*
|
|
6
|
+
* Templates wire the registry at process start, registering one adapter
|
|
7
|
+
* instance per upstream connection. Two connections of the same kind
|
|
8
|
+
* (e.g. TUI dev + TUI prod, or two different Voyant Connect peers) get
|
|
9
|
+
* two registry entries with two distinct `connection_id`s — different
|
|
10
|
+
* credentials, different rate buckets, different `channel_id` mappings.
|
|
11
|
+
*
|
|
12
|
+
* `kind` remains a useful secondary index — "list all adapters of this
|
|
13
|
+
* kind" supports rotate-to-next-available policies and admin debug
|
|
14
|
+
* surfaces. But routing dispatches by `connection_id` because that's
|
|
15
|
+
* what the data carries (provenance.source_connection_id on sourced
|
|
16
|
+
* rows, channels.id on outbound mappings).
|
|
17
|
+
*
|
|
18
|
+
* Per channel-push-architecture §3.1 and catalog-booking-engine §4.
|
|
19
|
+
*/
|
|
20
|
+
import type { SourceAdapter } from "../adapter/contract.js";
|
|
21
|
+
/**
|
|
22
|
+
* One registry entry. `connectionId` is the typed-id key (the row in
|
|
23
|
+
* whichever table holds the connection record — `channels` for outbound,
|
|
24
|
+
* the catalog plane's connection store for inbound). For adapters with
|
|
25
|
+
* no upstream connection record (e.g. the demo adapter at boot), pass a
|
|
26
|
+
* stable synthetic id like `"default:<kind>"`.
|
|
27
|
+
*/
|
|
28
|
+
export interface RegisteredAdapter {
|
|
29
|
+
connectionId: string;
|
|
30
|
+
adapter: SourceAdapter;
|
|
31
|
+
}
|
|
32
|
+
export interface SourceAdapterRegistry {
|
|
33
|
+
/**
|
|
34
|
+
* Register an adapter under a connection id. The connection id is the
|
|
35
|
+
* primary key — re-registering the same connection id replaces the
|
|
36
|
+
* previous adapter (used at hot-reload time). Production registrations
|
|
37
|
+
* happen once at process start, one entry per upstream connection.
|
|
38
|
+
*/
|
|
39
|
+
register(connectionId: string, adapter: SourceAdapter): void;
|
|
40
|
+
/**
|
|
41
|
+
* Backward-compat overload — register an adapter without an explicit
|
|
42
|
+
* connection id. Stored under the synthetic id `"default:<kind>"`.
|
|
43
|
+
* Use this for single-deployment adapters where there's no separate
|
|
44
|
+
* connection record (e.g. demo adapters, single-tenant integrations).
|
|
45
|
+
* New code paths that route per-connection should prefer the explicit
|
|
46
|
+
* `register(connectionId, adapter)` form.
|
|
47
|
+
*/
|
|
48
|
+
register(adapter: SourceAdapter): void;
|
|
49
|
+
/**
|
|
50
|
+
* Resolve by connection id. Hot path for the booking engine (sourced
|
|
51
|
+
* bookings) and the channel-push pipeline (outbound dispatches).
|
|
52
|
+
* Returns `undefined` when no adapter is registered.
|
|
53
|
+
*/
|
|
54
|
+
resolveByConnection(connectionId: string): SourceAdapter | undefined;
|
|
55
|
+
/** Like `resolveByConnection` but throws `NoAdapterRegisteredError` on miss. */
|
|
56
|
+
resolveByConnectionOrThrow(connectionId: string): SourceAdapter;
|
|
57
|
+
/**
|
|
58
|
+
* Resolve by source kind. Returns the FIRST adapter registered for this
|
|
59
|
+
* kind. Useful for legacy dispatch paths that don't yet thread a
|
|
60
|
+
* connection id, and for the common single-connection-per-kind case.
|
|
61
|
+
* New code that supports multiple connections per kind should use
|
|
62
|
+
* `byKind` to pick deliberately.
|
|
63
|
+
*/
|
|
64
|
+
resolveOrThrow(sourceKind: string): SourceAdapter;
|
|
65
|
+
/**
|
|
66
|
+
* Returns every adapter registered for this kind, paired with its
|
|
67
|
+
* connection id. Order is registration order. Use for "rotate to next
|
|
68
|
+
* available connection" policies and admin debug surfaces.
|
|
69
|
+
*/
|
|
70
|
+
byKind(sourceKind: string): ReadonlyArray<RegisteredAdapter>;
|
|
71
|
+
/** Returns the registered connection ids. */
|
|
72
|
+
connections(): ReadonlyArray<string>;
|
|
73
|
+
/** Returns the registered source kinds. */
|
|
74
|
+
kinds(): ReadonlyArray<string>;
|
|
75
|
+
/** True iff a connection id is registered. */
|
|
76
|
+
has(connectionId: string): boolean;
|
|
77
|
+
/** True iff at least one adapter of this kind is registered. */
|
|
78
|
+
hasKind(sourceKind: string): boolean;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Construct a fresh registry. Templates create one at process start and
|
|
82
|
+
* pass it to the booking-engine route handlers + channel-push wiring.
|
|
83
|
+
*/
|
|
84
|
+
export declare function createSourceAdapterRegistry(): SourceAdapterRegistry;
|
|
85
|
+
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/booking-engine/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAI3D;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAA;IACpB,OAAO,EAAE,aAAa,CAAA;CACvB;AAED,MAAM,WAAW,qBAAqB;IACpC;;;;;OAKG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,IAAI,CAAA;IAE5D;;;;;;;OAOG;IACH,QAAQ,CAAC,OAAO,EAAE,aAAa,GAAG,IAAI,CAAA;IAEtC;;;;OAIG;IACH,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CAAA;IAEpE,gFAAgF;IAChF,0BAA0B,CAAC,YAAY,EAAE,MAAM,GAAG,aAAa,CAAA;IAE/D;;;;;;OAMG;IACH,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,aAAa,CAAA;IAEjD;;;;OAIG;IACH,MAAM,CAAC,UAAU,EAAE,MAAM,GAAG,aAAa,CAAC,iBAAiB,CAAC,CAAA;IAE5D,6CAA6C;IAC7C,WAAW,IAAI,aAAa,CAAC,MAAM,CAAC,CAAA;IAEpC,2CAA2C;IAC3C,KAAK,IAAI,aAAa,CAAC,MAAM,CAAC,CAAA;IAE9B,8CAA8C;IAC9C,GAAG,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAA;IAElC,gEAAgE;IAChE,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAA;CACrC;AAED;;;GAGG;AACH,wBAAgB,2BAA2B,IAAI,qBAAqB,CA6FnE"}
|