@voyantjs/accommodations 0.55.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/README.md +11 -0
- package/dist/booking-engine/handler.d.ts +103 -0
- package/dist/booking-engine/handler.d.ts.map +1 -0
- package/dist/booking-engine/handler.js +254 -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 +23 -0
- package/dist/catalog-policy.d.ts.map +1 -0
- package/dist/catalog-policy.js +422 -0
- package/dist/content-shape.d.ts +185 -0
- package/dist/content-shape.d.ts.map +1 -0
- package/dist/content-shape.js +122 -0
- package/dist/draft-shape.d.ts +35 -0
- package/dist/draft-shape.d.ts.map +1 -0
- package/dist/draft-shape.js +84 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/routes-content.d.ts +31 -0
- package/dist/routes-content.d.ts.map +1 -0
- package/dist/routes-content.js +87 -0
- package/dist/schema-bookings.d.ts +582 -0
- package/dist/schema-bookings.d.ts.map +1 -0
- package/dist/schema-bookings.js +65 -0
- package/dist/schema-inventory.d.ts +1361 -0
- package/dist/schema-inventory.d.ts.map +1 -0
- package/dist/schema-inventory.js +132 -0
- package/dist/schema-shared.d.ts +5 -0
- package/dist/schema-shared.d.ts.map +1 -0
- package/dist/schema-shared.js +24 -0
- package/dist/schema-sourced-content.d.ts +254 -0
- package/dist/schema-sourced-content.d.ts.map +1 -0
- package/dist/schema-sourced-content.js +48 -0
- package/dist/schema.d.ts +5 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +4 -0
- package/dist/service-catalog-plane.d.ts +55 -0
- package/dist/service-catalog-plane.d.ts.map +1 -0
- package/dist/service-catalog-plane.js +202 -0
- package/dist/service-content-synthesizer.d.ts +43 -0
- package/dist/service-content-synthesizer.d.ts.map +1 -0
- package/dist/service-content-synthesizer.js +149 -0
- package/dist/service-content.d.ts +54 -0
- package/dist/service-content.d.ts.map +1 -0
- package/dist/service-content.js +480 -0
- package/package.json +113 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accommodation content service — `getAccommodationContent` with locale-
|
|
3
|
+
* resolved cache reads, SWR refresh, and synthesizer fallback.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors `service-content.ts` in the products and cruises packages
|
|
6
|
+
* but accommodation-shaped. The accommodation content aggregate (§3.2 /
|
|
7
|
+
* §3.6) is `{ hotel, room_types[], rate_plans[], meal_plans[],
|
|
8
|
+
* amenities[], policies[] }` — one payload returned by a single
|
|
9
|
+
* getContent. Pricing stays out (volatile-live, flows through
|
|
10
|
+
* `liveResolve`); rate plans here are the structural plan definitions,
|
|
11
|
+
* not their per-night rates.
|
|
12
|
+
*/
|
|
13
|
+
import { createInvalidateOnDrift, fetchOverlaysForEntity, isStale, pickBestCachedLocale, readSourcedEntry, withContentRefreshLock, } from "@voyantjs/catalog";
|
|
14
|
+
import { facilities, facilityAddressProjections, facilityFeatures, properties, } from "@voyantjs/facilities/schema";
|
|
15
|
+
import { and, asc, eq, inArray } from "drizzle-orm";
|
|
16
|
+
import { ACCOMMODATION_CONTENT_SCHEMA_VERSION, accommodationContentSchema, mergeOverlaysIntoAccommodationContent, validateAccommodationContent, } from "./content-shape.js";
|
|
17
|
+
import { mealPlans, ratePlanRoomTypes, ratePlans, roomTypeBedConfigs, roomTypes, } from "./schema-inventory.js";
|
|
18
|
+
import { ACCOMMODATION_CONTENT_MARKET_ANY, accommodationSourcedContentTable, } from "./schema-sourced-content.js";
|
|
19
|
+
import { synthesizeAccommodationContent, } from "./service-content-synthesizer.js";
|
|
20
|
+
/**
|
|
21
|
+
* Accommodation cache TTL is 4h by default — significantly shorter than
|
|
22
|
+
* products / cruises because room availability and rate plans churn
|
|
23
|
+
* faster on bedbanks. Per §3.4: products 24h, hotels 4h.
|
|
24
|
+
*/
|
|
25
|
+
const ACCOMMODATION_DEFAULT_TTL_MS = 4 * 60 * 60 * 1000;
|
|
26
|
+
export async function getAccommodationContent(db, entityId, scope, options) {
|
|
27
|
+
const sourcedEntry = await readSourcedEntry(db, "accommodations", entityId);
|
|
28
|
+
if (!sourcedEntry) {
|
|
29
|
+
const owned = await buildOwnedAccommodationContent(db, entityId, {
|
|
30
|
+
preferredLocales: scope.preferredLocales,
|
|
31
|
+
});
|
|
32
|
+
if (!owned)
|
|
33
|
+
return null;
|
|
34
|
+
const overlays = await fetchOverlaysForEntity(db, "accommodations", entityId);
|
|
35
|
+
const merged = mergeOverlaysIntoAccommodationContent(owned.content, overlays.map((o) => ({ field_path: o.field_path, value: o.value })), {
|
|
36
|
+
onOverlayError: options.onOverlayError
|
|
37
|
+
? (e) => options.onOverlayError({
|
|
38
|
+
field_path: e.overlay.field_path,
|
|
39
|
+
reason: e.reason,
|
|
40
|
+
})
|
|
41
|
+
: undefined,
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
content: merged,
|
|
45
|
+
resolution: {
|
|
46
|
+
candidate: { locale: owned.servedLocale, payload: merged },
|
|
47
|
+
served_locale: owned.servedLocale,
|
|
48
|
+
match_kind: owned.matchKind,
|
|
49
|
+
},
|
|
50
|
+
source: "owned",
|
|
51
|
+
served_stale: false,
|
|
52
|
+
synthesized: false,
|
|
53
|
+
machine_translated: false,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const provenance = {
|
|
57
|
+
kind: "sourced",
|
|
58
|
+
provenance: {
|
|
59
|
+
source_kind: sourcedEntry.source_kind,
|
|
60
|
+
source_provider: sourcedEntry.source_provider ?? undefined,
|
|
61
|
+
source_connection_id: sourcedEntry.source_connection_id ?? undefined,
|
|
62
|
+
source_ref: sourcedEntry.source_ref ?? undefined,
|
|
63
|
+
source_freshness: sourcedEntry.source_freshness,
|
|
64
|
+
last_sourced_at: sourcedEntry.last_sourced_at ?? undefined,
|
|
65
|
+
},
|
|
66
|
+
entry_id: sourcedEntry.id,
|
|
67
|
+
status: sourcedEntry.status,
|
|
68
|
+
projection: sourcedEntry.projection,
|
|
69
|
+
projection_etag: sourcedEntry.projection_etag,
|
|
70
|
+
projection_seen_at: sourcedEntry.projection_seen_at,
|
|
71
|
+
first_seen_at: sourcedEntry.first_seen_at,
|
|
72
|
+
last_seen_at: sourcedEntry.last_seen_at,
|
|
73
|
+
};
|
|
74
|
+
const adapter = sourcedEntry.source_connection_id
|
|
75
|
+
? (options.registry.resolveByConnection(sourcedEntry.source_connection_id) ??
|
|
76
|
+
options.registry.byKind(sourcedEntry.source_kind)[0]?.adapter)
|
|
77
|
+
: options.registry.byKind(sourcedEntry.source_kind)[0]?.adapter;
|
|
78
|
+
const adapterCtx = options.buildAdapterContext?.(adapter) ?? {
|
|
79
|
+
connection_id: sourcedEntry.source_connection_id ?? sourcedEntry.source_kind,
|
|
80
|
+
};
|
|
81
|
+
const market = scope.market ?? ACCOMMODATION_CONTENT_MARKET_ANY;
|
|
82
|
+
const acceptMT = scope.acceptMachineTranslated ?? true;
|
|
83
|
+
const cachedRows = await fetchCacheCandidates(db, entityId, market);
|
|
84
|
+
const eligibleRows = acceptMT ? cachedRows : cachedRows.filter((r) => !r.machine_translated);
|
|
85
|
+
const best = pickBestCachedLocale(eligibleRows.map((row) => ({ ...row, locale: row.locale })), scope.preferredLocales);
|
|
86
|
+
if (best && !isStale(best.candidate)) {
|
|
87
|
+
return finalizeFromCache(db, entityId, best, false, options);
|
|
88
|
+
}
|
|
89
|
+
if (best && isStale(best.candidate)) {
|
|
90
|
+
if (adapter?.getContent) {
|
|
91
|
+
void scheduleRefresh(db, adapter, adapterCtx, {
|
|
92
|
+
entity_module: "accommodations",
|
|
93
|
+
entity_id: entityId,
|
|
94
|
+
locale: scope.preferredLocales[0] ?? best.candidate.locale,
|
|
95
|
+
market,
|
|
96
|
+
currency: scope.currency,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return finalizeFromCache(db, entityId, best, true, options);
|
|
100
|
+
}
|
|
101
|
+
if (!adapter?.getContent) {
|
|
102
|
+
const overlays = await fetchOverlaysForEntity(db, "accommodations", entityId);
|
|
103
|
+
const synthesized = synthesizeAccommodationContent({ locale: scope.preferredLocales[0] ?? "en-GB" }, {
|
|
104
|
+
provenance,
|
|
105
|
+
overlays: overlays.map((o) => ({ field_path: o.field_path, value: o.value })),
|
|
106
|
+
});
|
|
107
|
+
return wrapSynthesized(synthesized, scope, false);
|
|
108
|
+
}
|
|
109
|
+
const fresh = await fetchFreshContent(db, adapter, adapterCtx, {
|
|
110
|
+
entity_module: "accommodations",
|
|
111
|
+
entity_id: entityId,
|
|
112
|
+
locale: scope.preferredLocales[0] ?? "en-GB",
|
|
113
|
+
market,
|
|
114
|
+
currency: scope.currency,
|
|
115
|
+
});
|
|
116
|
+
if (!fresh) {
|
|
117
|
+
const overlays = await fetchOverlaysForEntity(db, "accommodations", entityId);
|
|
118
|
+
const synthesized = synthesizeAccommodationContent({ locale: scope.preferredLocales[0] ?? "en-GB" }, {
|
|
119
|
+
provenance,
|
|
120
|
+
overlays: overlays.map((o) => ({ field_path: o.field_path, value: o.value })),
|
|
121
|
+
});
|
|
122
|
+
return wrapSynthesized(synthesized, scope, false);
|
|
123
|
+
}
|
|
124
|
+
return finalizeFresh(db, entityId, fresh, scope, options);
|
|
125
|
+
}
|
|
126
|
+
export async function buildOwnedAccommodationContent(db, entityId, options) {
|
|
127
|
+
// biome-ignore lint/suspicious/noExplicitAny: AnyDrizzleDb widens drizzle's row inference.
|
|
128
|
+
const roomRow = (await db.select().from(roomTypes).where(eq(roomTypes.id, entityId)).limit(1))[0];
|
|
129
|
+
if (!roomRow)
|
|
130
|
+
return null;
|
|
131
|
+
// biome-ignore lint/suspicious/noExplicitAny: AnyDrizzleDb widens drizzle's row inference.
|
|
132
|
+
const propertyRow = (await db.select().from(properties).where(eq(properties.id, roomRow.propertyId)).limit(1))[0];
|
|
133
|
+
if (!propertyRow)
|
|
134
|
+
return null;
|
|
135
|
+
const ownedRows = await Promise.all([
|
|
136
|
+
db.select().from(facilities).where(eq(facilities.id, propertyRow.facilityId)).limit(1),
|
|
137
|
+
db
|
|
138
|
+
.select()
|
|
139
|
+
.from(facilityAddressProjections)
|
|
140
|
+
.where(eq(facilityAddressProjections.facilityId, propertyRow.facilityId))
|
|
141
|
+
.limit(1),
|
|
142
|
+
db
|
|
143
|
+
.select()
|
|
144
|
+
.from(facilityFeatures)
|
|
145
|
+
.where(eq(facilityFeatures.facilityId, propertyRow.facilityId))
|
|
146
|
+
.orderBy(asc(facilityFeatures.sortOrder), asc(facilityFeatures.name)),
|
|
147
|
+
db
|
|
148
|
+
.select()
|
|
149
|
+
.from(roomTypes)
|
|
150
|
+
.where(and(eq(roomTypes.propertyId, propertyRow.id), eq(roomTypes.active, true)))
|
|
151
|
+
.orderBy(asc(roomTypes.sortOrder), asc(roomTypes.name)),
|
|
152
|
+
db
|
|
153
|
+
.select()
|
|
154
|
+
.from(mealPlans)
|
|
155
|
+
.where(and(eq(mealPlans.propertyId, propertyRow.id), eq(mealPlans.active, true)))
|
|
156
|
+
.orderBy(asc(mealPlans.sortOrder), asc(mealPlans.name)),
|
|
157
|
+
db
|
|
158
|
+
.select()
|
|
159
|
+
.from(ratePlans)
|
|
160
|
+
.where(and(eq(ratePlans.propertyId, propertyRow.id), eq(ratePlans.active, true)))
|
|
161
|
+
.orderBy(asc(ratePlans.sortOrder), asc(ratePlans.name)),
|
|
162
|
+
]);
|
|
163
|
+
const facilityRows = ownedRows[0];
|
|
164
|
+
const addressRows = ownedRows[1];
|
|
165
|
+
const featureRows = ownedRows[2];
|
|
166
|
+
const roomRows = ownedRows[3];
|
|
167
|
+
const mealPlanRows = ownedRows[4];
|
|
168
|
+
const ratePlanRows = ownedRows[5];
|
|
169
|
+
const roomIds = roomRows.map((row) => row.id);
|
|
170
|
+
const ratePlanIds = ratePlanRows.map((row) => row.id);
|
|
171
|
+
const [bedRows, ratePlanRoomRows] = await Promise.all([
|
|
172
|
+
roomIds.length > 0
|
|
173
|
+
? db
|
|
174
|
+
.select()
|
|
175
|
+
.from(roomTypeBedConfigs)
|
|
176
|
+
.where(inArray(roomTypeBedConfigs.roomTypeId, roomIds))
|
|
177
|
+
.orderBy(asc(roomTypeBedConfigs.isPrimary), asc(roomTypeBedConfigs.createdAt))
|
|
178
|
+
: [],
|
|
179
|
+
ratePlanIds.length > 0
|
|
180
|
+
? db
|
|
181
|
+
.select()
|
|
182
|
+
.from(ratePlanRoomTypes)
|
|
183
|
+
.where(and(inArray(ratePlanRoomTypes.ratePlanId, ratePlanIds), eq(ratePlanRoomTypes.active, true)))
|
|
184
|
+
.orderBy(asc(ratePlanRoomTypes.sortOrder), asc(ratePlanRoomTypes.createdAt))
|
|
185
|
+
: [],
|
|
186
|
+
]);
|
|
187
|
+
const facilityRow = facilityRows[0] ?? null;
|
|
188
|
+
const addressRow = addressRows[0] ?? null;
|
|
189
|
+
const content = accommodationContentSchema.parse({
|
|
190
|
+
hotel: {
|
|
191
|
+
id: propertyRow.id,
|
|
192
|
+
name: facilityRow?.name ?? roomRow.name,
|
|
193
|
+
description: facilityRow?.description ?? roomRow.description ?? null,
|
|
194
|
+
star_rating: normalizeStarRating(propertyRow.rating, propertyRow.ratingScale),
|
|
195
|
+
hero_image_url: firstStringFromMetadata(roomRow.metadata, "images"),
|
|
196
|
+
highlights: featureRows
|
|
197
|
+
.filter((feature) => feature.highlighted)
|
|
198
|
+
.map((feature) => feature.name),
|
|
199
|
+
brand: propertyRow.brandName ?? propertyRow.groupName ?? null,
|
|
200
|
+
country: addressRow?.country ?? null,
|
|
201
|
+
city: addressRow?.city ?? null,
|
|
202
|
+
address: addressRow?.fullText ?? addressRow?.address ?? addressRow?.line1 ?? null,
|
|
203
|
+
postal_code: addressRow?.postalCode ?? null,
|
|
204
|
+
latitude: addressRow?.latitude ?? null,
|
|
205
|
+
longitude: addressRow?.longitude ?? null,
|
|
206
|
+
check_in_time: propertyRow.checkInTime ?? null,
|
|
207
|
+
check_out_time: propertyRow.checkOutTime ?? null,
|
|
208
|
+
},
|
|
209
|
+
room_types: roomRows.map((room) => ownedRoomTypeToContent(room, bedRows)),
|
|
210
|
+
rate_plans: ratePlanRows.map((plan) => ownedRatePlanToContent(plan, ratePlanRoomRows)),
|
|
211
|
+
meal_plans: mealPlanRows.map((plan) => ({
|
|
212
|
+
id: plan.id,
|
|
213
|
+
code: plan.code,
|
|
214
|
+
name: plan.name,
|
|
215
|
+
description: plan.description ?? null,
|
|
216
|
+
basis: mealPlanBasis(plan),
|
|
217
|
+
inclusions: mealPlanInclusions(plan),
|
|
218
|
+
})),
|
|
219
|
+
amenities: featureRows
|
|
220
|
+
.filter((feature) => feature.category === "amenity")
|
|
221
|
+
.map((feature) => ({
|
|
222
|
+
id: feature.code ?? feature.id,
|
|
223
|
+
category: feature.category,
|
|
224
|
+
name: feature.name,
|
|
225
|
+
description: feature.description ?? feature.valueText ?? null,
|
|
226
|
+
is_free: undefined,
|
|
227
|
+
})),
|
|
228
|
+
policies: propertyRow.policyNotes
|
|
229
|
+
? [{ kind: "supplier_notes", body: propertyRow.policyNotes }]
|
|
230
|
+
: [],
|
|
231
|
+
});
|
|
232
|
+
const validation = validateAccommodationContent(content);
|
|
233
|
+
if (!validation.valid) {
|
|
234
|
+
throw new Error(`owned accommodation ${entityId} projection failed validation: ${validation.reason}`);
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
content,
|
|
238
|
+
servedLocale: options.preferredLocales[0] ?? "en-GB",
|
|
239
|
+
matchKind: options.preferredLocales.length > 0 ? "exact" : "any",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
async function fetchCacheCandidates(db, entityId, market) {
|
|
243
|
+
return db
|
|
244
|
+
.select()
|
|
245
|
+
.from(accommodationSourcedContentTable)
|
|
246
|
+
.where(and(eq(accommodationSourcedContentTable.entity_id, entityId), eq(accommodationSourcedContentTable.market, market), eq(accommodationSourcedContentTable.content_schema_version, ACCOMMODATION_CONTENT_SCHEMA_VERSION)));
|
|
247
|
+
}
|
|
248
|
+
async function fetchFreshContent(db, adapter, ctx, request) {
|
|
249
|
+
const result = await withContentRefreshLock(db, {
|
|
250
|
+
entityModule: request.entity_module,
|
|
251
|
+
entityId: request.entity_id,
|
|
252
|
+
locale: request.locale,
|
|
253
|
+
market: request.market,
|
|
254
|
+
}, async () => {
|
|
255
|
+
const got = await adapter.getContent(ctx, request);
|
|
256
|
+
const validation = validateAccommodationContent(got.content);
|
|
257
|
+
if (!validation.valid) {
|
|
258
|
+
throw new Error(`accommodation getContent for ${request.entity_id} failed validation: ${validation.reason}`);
|
|
259
|
+
}
|
|
260
|
+
await writeCacheRow(db, request, got);
|
|
261
|
+
return got;
|
|
262
|
+
});
|
|
263
|
+
return result ?? null;
|
|
264
|
+
}
|
|
265
|
+
function scheduleRefresh(db, adapter, ctx, request) {
|
|
266
|
+
void withContentRefreshLock(db, {
|
|
267
|
+
entityModule: request.entity_module,
|
|
268
|
+
entityId: request.entity_id,
|
|
269
|
+
locale: request.locale,
|
|
270
|
+
market: request.market,
|
|
271
|
+
}, async () => {
|
|
272
|
+
const got = await adapter.getContent(ctx, request);
|
|
273
|
+
const validation = validateAccommodationContent(got.content);
|
|
274
|
+
if (!validation.valid)
|
|
275
|
+
return;
|
|
276
|
+
await writeCacheRow(db, request, got);
|
|
277
|
+
}).catch(() => {
|
|
278
|
+
// intentional swallow — see §3.4 SWR refresh contract
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
async function writeCacheRow(db, request, result) {
|
|
282
|
+
const market = request.market ?? ACCOMMODATION_CONTENT_MARKET_ANY;
|
|
283
|
+
const now = new Date();
|
|
284
|
+
// Coerce JSON-string dates to Date — see products writeCacheRow.
|
|
285
|
+
const sourceUpdatedAt = toDateOrNull(result.source_updated_at);
|
|
286
|
+
const freshUntil = toDateOrNull(result.fresh_until) ?? new Date(now.getTime() + ACCOMMODATION_DEFAULT_TTL_MS);
|
|
287
|
+
await db
|
|
288
|
+
.insert(accommodationSourcedContentTable)
|
|
289
|
+
.values({
|
|
290
|
+
entity_id: request.entity_id,
|
|
291
|
+
locale: request.locale,
|
|
292
|
+
market,
|
|
293
|
+
payload: result.content,
|
|
294
|
+
content_schema_version: result.content_schema_version,
|
|
295
|
+
returned_locale: result.returned_locale,
|
|
296
|
+
machine_translated: result.machine_translated ?? false,
|
|
297
|
+
source_updated_at: sourceUpdatedAt,
|
|
298
|
+
fetched_at: now,
|
|
299
|
+
fresh_until: freshUntil,
|
|
300
|
+
etag: result.etag ?? null,
|
|
301
|
+
fetch_status: "ok",
|
|
302
|
+
fetch_error: null,
|
|
303
|
+
})
|
|
304
|
+
.onConflictDoUpdate({
|
|
305
|
+
target: [
|
|
306
|
+
accommodationSourcedContentTable.entity_id,
|
|
307
|
+
accommodationSourcedContentTable.locale,
|
|
308
|
+
accommodationSourcedContentTable.market,
|
|
309
|
+
],
|
|
310
|
+
set: {
|
|
311
|
+
payload: result.content,
|
|
312
|
+
content_schema_version: result.content_schema_version,
|
|
313
|
+
returned_locale: result.returned_locale,
|
|
314
|
+
machine_translated: result.machine_translated ?? false,
|
|
315
|
+
source_updated_at: sourceUpdatedAt,
|
|
316
|
+
fetched_at: now,
|
|
317
|
+
fresh_until: freshUntil,
|
|
318
|
+
etag: result.etag ?? null,
|
|
319
|
+
fetch_status: "ok",
|
|
320
|
+
fetch_error: null,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
async function finalizeFromCache(db, entityId, best, servedStale, options) {
|
|
325
|
+
const validation = validateAccommodationContent(best.candidate.payload);
|
|
326
|
+
if (!validation.valid) {
|
|
327
|
+
throw new Error(`accommodation cache row for ${entityId} (${best.candidate.locale}) failed validation: ${validation.reason}`);
|
|
328
|
+
}
|
|
329
|
+
const overlays = await fetchOverlaysForEntity(db, "accommodations", entityId);
|
|
330
|
+
const merged = mergeOverlaysIntoAccommodationContent(validation.content, overlays.map((o) => ({ field_path: o.field_path, value: o.value })), {
|
|
331
|
+
onOverlayError: options.onOverlayError
|
|
332
|
+
? (e) => options.onOverlayError({
|
|
333
|
+
field_path: e.overlay.field_path,
|
|
334
|
+
reason: e.reason,
|
|
335
|
+
})
|
|
336
|
+
: undefined,
|
|
337
|
+
});
|
|
338
|
+
return {
|
|
339
|
+
content: merged,
|
|
340
|
+
resolution: {
|
|
341
|
+
candidate: { locale: best.candidate.locale, payload: merged },
|
|
342
|
+
served_locale: best.candidate.returned_locale,
|
|
343
|
+
match_kind: best.match_kind,
|
|
344
|
+
},
|
|
345
|
+
source: "sourced-cache",
|
|
346
|
+
served_stale: servedStale,
|
|
347
|
+
synthesized: false,
|
|
348
|
+
machine_translated: best.candidate.machine_translated,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
async function finalizeFresh(db, entityId, fresh, scope, options) {
|
|
352
|
+
const cachedContent = accommodationContentSchema.parse(fresh.content);
|
|
353
|
+
const overlays = await fetchOverlaysForEntity(db, "accommodations", entityId);
|
|
354
|
+
const merged = mergeOverlaysIntoAccommodationContent(cachedContent, overlays.map((o) => ({ field_path: o.field_path, value: o.value })), {
|
|
355
|
+
onOverlayError: options.onOverlayError
|
|
356
|
+
? (e) => options.onOverlayError({
|
|
357
|
+
field_path: e.overlay.field_path,
|
|
358
|
+
reason: e.reason,
|
|
359
|
+
})
|
|
360
|
+
: undefined,
|
|
361
|
+
});
|
|
362
|
+
return {
|
|
363
|
+
content: merged,
|
|
364
|
+
resolution: {
|
|
365
|
+
candidate: { locale: scope.preferredLocales[0] ?? fresh.returned_locale, payload: merged },
|
|
366
|
+
served_locale: fresh.returned_locale,
|
|
367
|
+
match_kind: scope.preferredLocales[0] === fresh.returned_locale ? "exact" : "language_match",
|
|
368
|
+
},
|
|
369
|
+
source: "sourced-fresh",
|
|
370
|
+
served_stale: false,
|
|
371
|
+
synthesized: false,
|
|
372
|
+
machine_translated: fresh.machine_translated ?? false,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
function wrapSynthesized(synthesized, scope, servedStale) {
|
|
376
|
+
return {
|
|
377
|
+
content: synthesized.content,
|
|
378
|
+
resolution: {
|
|
379
|
+
candidate: { locale: synthesized.served_locale, payload: synthesized.content },
|
|
380
|
+
served_locale: synthesized.served_locale,
|
|
381
|
+
match_kind: scope.preferredLocales[0] === synthesized.served_locale ? "exact" : "any",
|
|
382
|
+
},
|
|
383
|
+
source: "synthesized",
|
|
384
|
+
served_stale: servedStale,
|
|
385
|
+
synthesized: true,
|
|
386
|
+
machine_translated: false,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
function ownedRoomTypeToContent(room, bedRows) {
|
|
390
|
+
const roomBeds = bedRows.filter((bed) => bed.roomTypeId === room.id);
|
|
391
|
+
return {
|
|
392
|
+
id: room.id,
|
|
393
|
+
code: room.code ?? null,
|
|
394
|
+
name: room.name,
|
|
395
|
+
description: room.description ?? null,
|
|
396
|
+
room_class: room.roomClass ?? null,
|
|
397
|
+
view: stringFromMetadata(room.metadata, "view"),
|
|
398
|
+
bedrooms: room.bedroomCount ?? null,
|
|
399
|
+
beds: roomBeds.map((bed) => bed.quantity > 1 ? `${bed.quantity} ${bed.bedType}` : bed.bedType),
|
|
400
|
+
size_sqm: room.areaUnit === "sqm" ? room.areaValue : null,
|
|
401
|
+
max_adults: room.maxAdults ?? null,
|
|
402
|
+
max_children: room.maxChildren ?? null,
|
|
403
|
+
max_occupancy: room.maxOccupancy ?? room.standardOccupancy ?? null,
|
|
404
|
+
amenities: stringArrayFromMetadata(room.metadata, "amenities"),
|
|
405
|
+
images: stringArrayFromMetadata(room.metadata, "images"),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function ownedRatePlanToContent(plan, ratePlanRoomRows) {
|
|
409
|
+
return {
|
|
410
|
+
id: plan.id,
|
|
411
|
+
code: plan.code,
|
|
412
|
+
name: plan.name,
|
|
413
|
+
description: plan.description ?? null,
|
|
414
|
+
charge_frequency: contentChargeFrequency(plan.chargeFrequency),
|
|
415
|
+
applies_to_room_type_ids: ratePlanRoomRows
|
|
416
|
+
.filter((row) => row.ratePlanId === plan.id)
|
|
417
|
+
.map((row) => row.roomTypeId),
|
|
418
|
+
cancellation_policy: plan.cancellationPolicyId ?? null,
|
|
419
|
+
inclusions: [],
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
function contentChargeFrequency(value) {
|
|
423
|
+
return value === "per_stay" || value === "per_person_per_stay" ? "per_stay" : "per_night";
|
|
424
|
+
}
|
|
425
|
+
function mealPlanBasis(plan) {
|
|
426
|
+
if (plan.includesBreakfast && plan.includesLunch && plan.includesDinner)
|
|
427
|
+
return "full_board";
|
|
428
|
+
if (plan.includesBreakfast && plan.includesDinner)
|
|
429
|
+
return "half_board";
|
|
430
|
+
if (plan.includesBreakfast)
|
|
431
|
+
return "bed_breakfast";
|
|
432
|
+
return "room_only";
|
|
433
|
+
}
|
|
434
|
+
function mealPlanInclusions(plan) {
|
|
435
|
+
const inclusions = [];
|
|
436
|
+
if (plan.includesBreakfast)
|
|
437
|
+
inclusions.push("Breakfast");
|
|
438
|
+
if (plan.includesLunch)
|
|
439
|
+
inclusions.push("Lunch");
|
|
440
|
+
if (plan.includesDinner)
|
|
441
|
+
inclusions.push("Dinner");
|
|
442
|
+
if (plan.includesDrinks)
|
|
443
|
+
inclusions.push("Drinks");
|
|
444
|
+
return inclusions;
|
|
445
|
+
}
|
|
446
|
+
function normalizeStarRating(value, scale) {
|
|
447
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
448
|
+
return null;
|
|
449
|
+
if (typeof scale === "number" && Number.isFinite(scale) && scale > 0 && scale !== 5) {
|
|
450
|
+
return Math.max(0, Math.min(5, (value / scale) * 5));
|
|
451
|
+
}
|
|
452
|
+
return Math.max(0, Math.min(5, value));
|
|
453
|
+
}
|
|
454
|
+
function firstStringFromMetadata(metadata, key) {
|
|
455
|
+
return stringArrayFromMetadata(metadata, key)[0] ?? null;
|
|
456
|
+
}
|
|
457
|
+
function stringArrayFromMetadata(metadata, key) {
|
|
458
|
+
if (!metadata || typeof metadata !== "object")
|
|
459
|
+
return [];
|
|
460
|
+
const value = metadata[key];
|
|
461
|
+
if (!Array.isArray(value))
|
|
462
|
+
return [];
|
|
463
|
+
return value.filter((item) => typeof item === "string" && item.length > 0);
|
|
464
|
+
}
|
|
465
|
+
function stringFromMetadata(metadata, key) {
|
|
466
|
+
if (!metadata || typeof metadata !== "object")
|
|
467
|
+
return null;
|
|
468
|
+
const value = metadata[key];
|
|
469
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
470
|
+
}
|
|
471
|
+
/** Drift event consumer for the accommodation content cache. Per §3.4.1. */
|
|
472
|
+
export const invalidateAccommodationContentOnDrift = createInvalidateOnDrift(accommodationSourcedContentTable, { entityModule: "accommodations" });
|
|
473
|
+
function toDateOrNull(value) {
|
|
474
|
+
if (!value)
|
|
475
|
+
return null;
|
|
476
|
+
if (value instanceof Date)
|
|
477
|
+
return value;
|
|
478
|
+
const parsed = new Date(value);
|
|
479
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
480
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voyantjs/accommodations",
|
|
3
|
+
"version": "0.55.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./schema": "./src/schema.ts",
|
|
9
|
+
"./content-shape": "./src/content-shape.ts",
|
|
10
|
+
"./service-content": "./src/service-content.ts",
|
|
11
|
+
"./service-content-synthesizer": "./src/service-content-synthesizer.ts",
|
|
12
|
+
"./catalog-policy": "./src/catalog-policy.ts",
|
|
13
|
+
"./service-catalog-plane": "./src/service-catalog-plane.ts",
|
|
14
|
+
"./draft-shape": "./src/draft-shape.ts",
|
|
15
|
+
"./booking-engine": "./src/booking-engine/index.ts",
|
|
16
|
+
"./routes-content": "./src/routes-content.ts"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"lint": "biome check src/",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"build": "tsc -p tsconfig.json",
|
|
23
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
24
|
+
"prepack": "pnpm run build"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@voyantjs/bookings": "workspace:*",
|
|
28
|
+
"@voyantjs/catalog": "workspace:*",
|
|
29
|
+
"@voyantjs/db": "workspace:*",
|
|
30
|
+
"@voyantjs/facilities": "workspace:*",
|
|
31
|
+
"drizzle-orm": "^0.45.2",
|
|
32
|
+
"hono": "^4.12.10",
|
|
33
|
+
"zod": "^4.3.6"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@voyantjs/voyant-typescript-config": "workspace:*",
|
|
37
|
+
"typescript": "^6.0.2",
|
|
38
|
+
"vitest": "^4.1.2"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"exports": {
|
|
46
|
+
".": {
|
|
47
|
+
"types": "./dist/index.d.ts",
|
|
48
|
+
"import": "./dist/index.js",
|
|
49
|
+
"default": "./dist/index.js"
|
|
50
|
+
},
|
|
51
|
+
"./schema": {
|
|
52
|
+
"types": "./dist/schema.d.ts",
|
|
53
|
+
"import": "./dist/schema.js",
|
|
54
|
+
"default": "./dist/schema.js"
|
|
55
|
+
},
|
|
56
|
+
"./content-shape": {
|
|
57
|
+
"types": "./dist/content-shape.d.ts",
|
|
58
|
+
"import": "./dist/content-shape.js",
|
|
59
|
+
"default": "./dist/content-shape.js"
|
|
60
|
+
},
|
|
61
|
+
"./service-content": {
|
|
62
|
+
"types": "./dist/service-content.d.ts",
|
|
63
|
+
"import": "./dist/service-content.js",
|
|
64
|
+
"default": "./dist/service-content.js"
|
|
65
|
+
},
|
|
66
|
+
"./service-content-synthesizer": {
|
|
67
|
+
"types": "./dist/service-content-synthesizer.d.ts",
|
|
68
|
+
"import": "./dist/service-content-synthesizer.js",
|
|
69
|
+
"default": "./dist/service-content-synthesizer.js"
|
|
70
|
+
},
|
|
71
|
+
"./catalog-policy": {
|
|
72
|
+
"types": "./dist/catalog-policy.d.ts",
|
|
73
|
+
"import": "./dist/catalog-policy.js",
|
|
74
|
+
"default": "./dist/catalog-policy.js"
|
|
75
|
+
},
|
|
76
|
+
"./service-catalog-plane": {
|
|
77
|
+
"types": "./dist/service-catalog-plane.d.ts",
|
|
78
|
+
"import": "./dist/service-catalog-plane.js",
|
|
79
|
+
"default": "./dist/service-catalog-plane.js"
|
|
80
|
+
},
|
|
81
|
+
"./draft-shape": {
|
|
82
|
+
"types": "./dist/draft-shape.d.ts",
|
|
83
|
+
"import": "./dist/draft-shape.js",
|
|
84
|
+
"default": "./dist/draft-shape.js"
|
|
85
|
+
},
|
|
86
|
+
"./booking-engine": {
|
|
87
|
+
"types": "./dist/booking-engine/index.d.ts",
|
|
88
|
+
"import": "./dist/booking-engine/index.js",
|
|
89
|
+
"default": "./dist/booking-engine/index.js"
|
|
90
|
+
},
|
|
91
|
+
"./routes-content": {
|
|
92
|
+
"types": "./dist/routes-content.d.ts",
|
|
93
|
+
"import": "./dist/routes-content.js",
|
|
94
|
+
"default": "./dist/routes-content.js"
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"main": "./dist/index.js",
|
|
98
|
+
"types": "./dist/index.d.ts"
|
|
99
|
+
},
|
|
100
|
+
"repository": {
|
|
101
|
+
"type": "git",
|
|
102
|
+
"url": "https://github.com/voyantjs/voyant.git",
|
|
103
|
+
"directory": "packages/accommodations"
|
|
104
|
+
},
|
|
105
|
+
"voyant": {
|
|
106
|
+
"schema": "./schema",
|
|
107
|
+
"requiresSchemas": [
|
|
108
|
+
"@voyantjs/db",
|
|
109
|
+
"@voyantjs/facilities",
|
|
110
|
+
"@voyantjs/bookings"
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
}
|