@voyant-travel/cruises 0.118.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.
Files changed (210) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +50 -0
  3. package/dist/adapters/connect-compat.d.ts +20 -0
  4. package/dist/adapters/connect-compat.d.ts.map +1 -0
  5. package/dist/adapters/connect-compat.js +71 -0
  6. package/dist/adapters/contract-fixture.d.ts +32 -0
  7. package/dist/adapters/contract-fixture.d.ts.map +1 -0
  8. package/dist/adapters/contract-fixture.js +152 -0
  9. package/dist/adapters/index.d.ts +331 -0
  10. package/dist/adapters/index.d.ts.map +1 -0
  11. package/dist/adapters/index.js +16 -0
  12. package/dist/adapters/memoize.d.ts +28 -0
  13. package/dist/adapters/memoize.d.ts.map +1 -0
  14. package/dist/adapters/memoize.js +131 -0
  15. package/dist/adapters/mock.d.ts +44 -0
  16. package/dist/adapters/mock.d.ts.map +1 -0
  17. package/dist/adapters/mock.js +192 -0
  18. package/dist/adapters/registry.d.ts +26 -0
  19. package/dist/adapters/registry.d.ts.map +1 -0
  20. package/dist/adapters/registry.js +42 -0
  21. package/dist/adapters/source-adapter-shim.d.ts +80 -0
  22. package/dist/adapters/source-adapter-shim.d.ts.map +1 -0
  23. package/dist/adapters/source-adapter-shim.js +390 -0
  24. package/dist/booking-engine/handler.d.ts +108 -0
  25. package/dist/booking-engine/handler.d.ts.map +1 -0
  26. package/dist/booking-engine/handler.js +225 -0
  27. package/dist/booking-engine/index.d.ts +9 -0
  28. package/dist/booking-engine/index.d.ts.map +1 -0
  29. package/dist/booking-engine/index.js +8 -0
  30. package/dist/booking-extension.d.ts +1179 -0
  31. package/dist/booking-extension.d.ts.map +1 -0
  32. package/dist/booking-extension.js +342 -0
  33. package/dist/cabin-features.d.ts +8 -0
  34. package/dist/cabin-features.d.ts.map +1 -0
  35. package/dist/cabin-features.js +7 -0
  36. package/dist/catalog-policy-cabins.d.ts +18 -0
  37. package/dist/catalog-policy-cabins.d.ts.map +1 -0
  38. package/dist/catalog-policy-cabins.js +96 -0
  39. package/dist/catalog-policy-core.d.ts +3 -0
  40. package/dist/catalog-policy-core.d.ts.map +1 -0
  41. package/dist/catalog-policy-core.js +247 -0
  42. package/dist/catalog-policy-structure.d.ts +3 -0
  43. package/dist/catalog-policy-structure.d.ts.map +1 -0
  44. package/dist/catalog-policy-structure.js +387 -0
  45. package/dist/catalog-policy.d.ts +15 -0
  46. package/dist/catalog-policy.d.ts.map +1 -0
  47. package/dist/catalog-policy.js +19 -0
  48. package/dist/content-shape.d.ts +5 -0
  49. package/dist/content-shape.d.ts.map +1 -0
  50. package/dist/content-shape.js +13 -0
  51. package/dist/draft-shape.d.ts +59 -0
  52. package/dist/draft-shape.d.ts.map +1 -0
  53. package/dist/draft-shape.js +98 -0
  54. package/dist/events.d.ts +21 -0
  55. package/dist/events.d.ts.map +1 -0
  56. package/dist/events.js +21 -0
  57. package/dist/index.d.ts +43 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +66 -0
  60. package/dist/lib/key.d.ts +41 -0
  61. package/dist/lib/key.d.ts.map +1 -0
  62. package/dist/lib/key.js +100 -0
  63. package/dist/routes-booking-payloads.d.ts +133 -0
  64. package/dist/routes-booking-payloads.d.ts.map +1 -0
  65. package/dist/routes-booking-payloads.js +142 -0
  66. package/dist/routes-content.d.ts +53 -0
  67. package/dist/routes-content.d.ts.map +1 -0
  68. package/dist/routes-content.js +158 -0
  69. package/dist/routes-core.d.ts +4 -0
  70. package/dist/routes-core.d.ts.map +1 -0
  71. package/dist/routes-core.js +68 -0
  72. package/dist/routes-detail.d.ts +4 -0
  73. package/dist/routes-detail.d.ts.map +1 -0
  74. package/dist/routes-detail.js +261 -0
  75. package/dist/routes-env.d.ts +13 -0
  76. package/dist/routes-env.d.ts.map +1 -0
  77. package/dist/routes-env.js +1 -0
  78. package/dist/routes-keying.d.ts +28 -0
  79. package/dist/routes-keying.d.ts.map +1 -0
  80. package/dist/routes-keying.js +70 -0
  81. package/dist/routes-public.d.ts +911 -0
  82. package/dist/routes-public.d.ts.map +1 -0
  83. package/dist/routes-public.js +252 -0
  84. package/dist/routes-sailings-prices.d.ts +4 -0
  85. package/dist/routes-sailings-prices.d.ts.map +1 -0
  86. package/dist/routes-sailings-prices.js +278 -0
  87. package/dist/routes-search-index.d.ts +4 -0
  88. package/dist/routes-search-index.d.ts.map +1 -0
  89. package/dist/routes-search-index.js +25 -0
  90. package/dist/routes-ships.d.ts +4 -0
  91. package/dist/routes-ships.d.ts.map +1 -0
  92. package/dist/routes-ships.js +147 -0
  93. package/dist/routes-voyage-groups.d.ts +4 -0
  94. package/dist/routes-voyage-groups.d.ts.map +1 -0
  95. package/dist/routes-voyage-groups.js +85 -0
  96. package/dist/routes.d.ts +5 -0
  97. package/dist/routes.d.ts.map +1 -0
  98. package/dist/routes.js +14 -0
  99. package/dist/schema-cabins.d.ts +1098 -0
  100. package/dist/schema-cabins.d.ts.map +1 -0
  101. package/dist/schema-cabins.js +105 -0
  102. package/dist/schema-content.d.ts +577 -0
  103. package/dist/schema-content.d.ts.map +1 -0
  104. package/dist/schema-content.js +63 -0
  105. package/dist/schema-core.d.ts +1790 -0
  106. package/dist/schema-core.d.ts.map +1 -0
  107. package/dist/schema-core.js +171 -0
  108. package/dist/schema-itinerary.d.ts +556 -0
  109. package/dist/schema-itinerary.d.ts.map +1 -0
  110. package/dist/schema-itinerary.js +50 -0
  111. package/dist/schema-pricing.d.ts +633 -0
  112. package/dist/schema-pricing.d.ts.map +1 -0
  113. package/dist/schema-pricing.js +73 -0
  114. package/dist/schema-search.d.ts +611 -0
  115. package/dist/schema-search.d.ts.map +1 -0
  116. package/dist/schema-search.js +64 -0
  117. package/dist/schema-shared.d.ts +23 -0
  118. package/dist/schema-shared.d.ts.map +1 -0
  119. package/dist/schema-shared.js +107 -0
  120. package/dist/schema-sourced-content.d.ts +247 -0
  121. package/dist/schema-sourced-content.d.ts.map +1 -0
  122. package/dist/schema-sourced-content.js +38 -0
  123. package/dist/schema.d.ts +10 -0
  124. package/dist/schema.d.ts.map +1 -0
  125. package/dist/schema.js +9 -0
  126. package/dist/service-booking-helpers.d.ts +12 -0
  127. package/dist/service-booking-helpers.d.ts.map +1 -0
  128. package/dist/service-booking-helpers.js +94 -0
  129. package/dist/service-booking-types.d.ts +101 -0
  130. package/dist/service-booking-types.d.ts.map +1 -0
  131. package/dist/service-booking-types.js +1 -0
  132. package/dist/service-bookings.d.ts +46 -0
  133. package/dist/service-bookings.d.ts.map +1 -0
  134. package/dist/service-bookings.js +420 -0
  135. package/dist/service-catalog-plane-cabins.d.ts +24 -0
  136. package/dist/service-catalog-plane-cabins.d.ts.map +1 -0
  137. package/dist/service-catalog-plane-cabins.js +90 -0
  138. package/dist/service-catalog-plane.d.ts +74 -0
  139. package/dist/service-catalog-plane.d.ts.map +1 -0
  140. package/dist/service-catalog-plane.js +194 -0
  141. package/dist/service-content-synthesizer.d.ts +42 -0
  142. package/dist/service-content-synthesizer.d.ts.map +1 -0
  143. package/dist/service-content-synthesizer.js +144 -0
  144. package/dist/service-content.d.ts +74 -0
  145. package/dist/service-content.d.ts.map +1 -0
  146. package/dist/service-content.js +315 -0
  147. package/dist/service-core.d.ts +134 -0
  148. package/dist/service-core.d.ts.map +1 -0
  149. package/dist/service-core.js +257 -0
  150. package/dist/service-detach.d.ts +18 -0
  151. package/dist/service-detach.d.ts.map +1 -0
  152. package/dist/service-detach.js +199 -0
  153. package/dist/service-enrichment.d.ts +11 -0
  154. package/dist/service-enrichment.d.ts.map +1 -0
  155. package/dist/service-enrichment.js +47 -0
  156. package/dist/service-external-refresh.d.ts +39 -0
  157. package/dist/service-external-refresh.d.ts.map +1 -0
  158. package/dist/service-external-refresh.js +47 -0
  159. package/dist/service-itinerary.d.ts +22 -0
  160. package/dist/service-itinerary.d.ts.map +1 -0
  161. package/dist/service-itinerary.js +34 -0
  162. package/dist/service-prices.d.ts +46 -0
  163. package/dist/service-prices.d.ts.map +1 -0
  164. package/dist/service-prices.js +89 -0
  165. package/dist/service-pricing.d.ts +97 -0
  166. package/dist/service-pricing.d.ts.map +1 -0
  167. package/dist/service-pricing.js +198 -0
  168. package/dist/service-sailings.d.ts +48 -0
  169. package/dist/service-sailings.d.ts.map +1 -0
  170. package/dist/service-sailings.js +145 -0
  171. package/dist/service-search-types.d.ts +54 -0
  172. package/dist/service-search-types.d.ts.map +1 -0
  173. package/dist/service-search-types.js +1 -0
  174. package/dist/service-search.d.ts +65 -0
  175. package/dist/service-search.d.ts.map +1 -0
  176. package/dist/service-search.js +467 -0
  177. package/dist/service-shared.d.ts +22 -0
  178. package/dist/service-shared.d.ts.map +1 -0
  179. package/dist/service-shared.js +22 -0
  180. package/dist/service-ships.d.ts +47 -0
  181. package/dist/service-ships.d.ts.map +1 -0
  182. package/dist/service-ships.js +156 -0
  183. package/dist/service.d.ts +255 -0
  184. package/dist/service.d.ts.map +1 -0
  185. package/dist/service.js +12 -0
  186. package/dist/validation-cabins.d.ts +267 -0
  187. package/dist/validation-cabins.d.ts.map +1 -0
  188. package/dist/validation-cabins.js +77 -0
  189. package/dist/validation-content.d.ts +123 -0
  190. package/dist/validation-content.d.ts.map +1 -0
  191. package/dist/validation-content.js +40 -0
  192. package/dist/validation-core.d.ts +393 -0
  193. package/dist/validation-core.d.ts.map +1 -0
  194. package/dist/validation-core.js +162 -0
  195. package/dist/validation-itinerary.d.ts +123 -0
  196. package/dist/validation-itinerary.d.ts.map +1 -0
  197. package/dist/validation-itinerary.js +47 -0
  198. package/dist/validation-pricing.d.ts +137 -0
  199. package/dist/validation-pricing.d.ts.map +1 -0
  200. package/dist/validation-pricing.js +49 -0
  201. package/dist/validation-search.d.ts +118 -0
  202. package/dist/validation-search.d.ts.map +1 -0
  203. package/dist/validation-search.js +60 -0
  204. package/dist/validation-shared.d.ts +123 -0
  205. package/dist/validation-shared.d.ts.map +1 -0
  206. package/dist/validation-shared.js +103 -0
  207. package/dist/validation.d.ts +8 -0
  208. package/dist/validation.d.ts.map +1 -0
  209. package/dist/validation.js +7 -0
  210. package/package.json +146 -0
@@ -0,0 +1,467 @@
1
+ /**
2
+ * Search-index service for mixed local/external cruise browse rows.
3
+ * `cruise_search_index` is optional; storefront deployments populate it
4
+ * from local projection hooks and adapter `searchProjection()` streams.
5
+ */
6
+ import { and, asc, eq, gte, ilike, lte, notInArray, or, sql } from "drizzle-orm";
7
+ import { listCruiseAdapters } from "./adapters/registry.js";
8
+ import { cruiseShips } from "./schema-cabins.js";
9
+ import { cruiseSailings, cruises } from "./schema-core.js";
10
+ import { cruisePrices } from "./schema-pricing.js";
11
+ import { cruiseSearchIndex, } from "./schema-search.js";
12
+ export const cruisesSearchService = {
13
+ // ---------- queries ----------
14
+ async query(db, query) {
15
+ const conditions = [];
16
+ if (query.cruiseType)
17
+ conditions.push(eq(cruiseSearchIndex.cruiseType, query.cruiseType));
18
+ if (query.source)
19
+ conditions.push(eq(cruiseSearchIndex.source, query.source));
20
+ if (query.region) {
21
+ // agent-quality: raw-sql reviewed -- owner: cruises; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
22
+ conditions.push(sql `${cruiseSearchIndex.regions} @> ${JSON.stringify([query.region])}::jsonb`);
23
+ }
24
+ if (query.regionId) {
25
+ conditions.push(
26
+ // agent-quality: raw-sql reviewed -- owner: cruises; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
27
+ sql `${cruiseSearchIndex.regionIds} @> ${JSON.stringify([query.regionId])}::jsonb`);
28
+ }
29
+ if (query.waterwayId) {
30
+ conditions.push(
31
+ // agent-quality: raw-sql reviewed -- owner: cruises; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
32
+ sql `${cruiseSearchIndex.waterwayIds} @> ${JSON.stringify([query.waterwayId])}::jsonb`);
33
+ }
34
+ if (query.portId) {
35
+ // agent-quality: raw-sql reviewed -- owner: cruises; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
36
+ conditions.push(sql `${cruiseSearchIndex.portIds} @> ${JSON.stringify([query.portId])}::jsonb`);
37
+ }
38
+ if (query.countryIso) {
39
+ conditions.push(
40
+ // agent-quality: raw-sql reviewed -- owner: cruises; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
41
+ sql `${cruiseSearchIndex.countryIso} @> ${JSON.stringify([query.countryIso])}::jsonb`);
42
+ }
43
+ if (query.theme) {
44
+ // agent-quality: raw-sql reviewed -- owner: cruises; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
45
+ conditions.push(sql `${cruiseSearchIndex.themes} @> ${JSON.stringify([query.theme])}::jsonb`);
46
+ }
47
+ if (query.dateFrom)
48
+ conditions.push(gte(cruiseSearchIndex.earliestDeparture, query.dateFrom));
49
+ if (query.dateTo)
50
+ conditions.push(lte(cruiseSearchIndex.latestDeparture, query.dateTo));
51
+ if (query.priceMaxCents !== undefined) {
52
+ // agent-quality: raw-sql reviewed -- owner: cruises; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
53
+ conditions.push(sql `${cruiseSearchIndex.lowestPriceCents} <= ${query.priceMaxCents}`);
54
+ }
55
+ if (query.embarkPortCanonicalPlaceId) {
56
+ conditions.push(eq(cruiseSearchIndex.embarkPortCanonicalPlaceId, query.embarkPortCanonicalPlaceId));
57
+ }
58
+ if (query.disembarkPortCanonicalPlaceId) {
59
+ conditions.push(eq(cruiseSearchIndex.disembarkPortCanonicalPlaceId, query.disembarkPortCanonicalPlaceId));
60
+ }
61
+ if (query.portCanonicalPlaceId) {
62
+ const portClause = or(eq(cruiseSearchIndex.embarkPortCanonicalPlaceId, query.portCanonicalPlaceId), eq(cruiseSearchIndex.disembarkPortCanonicalPlaceId, query.portCanonicalPlaceId));
63
+ if (portClause)
64
+ conditions.push(portClause);
65
+ }
66
+ if (query.search) {
67
+ const term = `%${query.search}%`;
68
+ const searchClause = or(ilike(cruiseSearchIndex.name, term), ilike(cruiseSearchIndex.lineName, term), ilike(cruiseSearchIndex.shipName, term));
69
+ if (searchClause)
70
+ conditions.push(searchClause);
71
+ }
72
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
73
+ const [rows, totalRows] = await Promise.all([
74
+ db
75
+ .select()
76
+ .from(cruiseSearchIndex)
77
+ .where(where)
78
+ .orderBy(asc(cruiseSearchIndex.earliestDeparture),
79
+ // agent-quality: raw-sql reviewed -- owner: cruises; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
80
+ asc(sql `${cruiseSearchIndex.lowestPriceCents} NULLS LAST`), asc(cruiseSearchIndex.name))
81
+ .limit(query.limit)
82
+ .offset(query.offset),
83
+ db.select({ value: sql `count(*)::int` }).from(cruiseSearchIndex).where(where),
84
+ ]);
85
+ return {
86
+ data: rows,
87
+ total: totalRows[0]?.value ?? 0,
88
+ limit: query.limit,
89
+ offset: query.offset,
90
+ };
91
+ },
92
+ async getBySlug(db, slug) {
93
+ const [row] = await db
94
+ .select()
95
+ .from(cruiseSearchIndex)
96
+ .where(eq(cruiseSearchIndex.slug, slug))
97
+ .limit(1);
98
+ return row ?? null;
99
+ },
100
+ // ---------- writes ----------
101
+ async upsertEntry(db, entry) {
102
+ const payload = {
103
+ source: entry.source,
104
+ sourceProvider: entry.sourceProvider ?? null,
105
+ sourceRef: entry.sourceRef ?? null,
106
+ localCruiseId: entry.localCruiseId ?? null,
107
+ slug: entry.slug,
108
+ name: entry.name,
109
+ cruiseType: entry.cruiseType,
110
+ lineName: entry.lineName,
111
+ shipName: entry.shipName,
112
+ nights: entry.nights,
113
+ embarkPortName: entry.embarkPortName ?? null,
114
+ embarkPortCanonicalPlaceId: entry.embarkPortCanonicalPlaceId ?? null,
115
+ disembarkPortName: entry.disembarkPortName ?? null,
116
+ disembarkPortCanonicalPlaceId: entry.disembarkPortCanonicalPlaceId ?? null,
117
+ regionIds: entry.regionIds ?? [],
118
+ waterwayIds: entry.waterwayIds ?? [],
119
+ portIds: entry.portIds ?? [],
120
+ countryIso: entry.countryIso ?? [],
121
+ regions: entry.regions ?? [],
122
+ waterways: entry.waterways ?? [],
123
+ ports: entry.ports ?? [],
124
+ countries: entry.countries ?? [],
125
+ themes: entry.themes ?? [],
126
+ earliestDeparture: entry.earliestDeparture ?? null,
127
+ latestDeparture: entry.latestDeparture ?? null,
128
+ departureCount: entry.departureCount ?? null,
129
+ lowestPriceCents: entry.lowestPriceCents ?? null,
130
+ lowestPriceCurrency: entry.lowestPriceCurrency ?? null,
131
+ salesStatus: entry.salesStatus ?? null,
132
+ heroImageUrl: entry.heroImageUrl ?? null,
133
+ refreshedAt: new Date(),
134
+ };
135
+ const existing = await findExisting(db, entry);
136
+ if (existing) {
137
+ const [row] = await db
138
+ .update(cruiseSearchIndex)
139
+ .set({ ...payload, updatedAt: new Date() })
140
+ .where(eq(cruiseSearchIndex.id, existing.id))
141
+ .returning();
142
+ if (!row)
143
+ throw new Error("Failed to update search index entry");
144
+ return row;
145
+ }
146
+ const [row] = await db.insert(cruiseSearchIndex).values(payload).returning();
147
+ if (!row)
148
+ throw new Error("Failed to insert search index entry");
149
+ return row;
150
+ },
151
+ async bulkUpsert(db, entries) {
152
+ let upserted = 0;
153
+ // Run in a transaction so a partial run can roll back. Adapters typically
154
+ // call this in chunks; the chunk size is the adapter's choice.
155
+ await db.transaction(async (tx) => {
156
+ for (const entry of entries) {
157
+ await this.upsertEntry(tx, entry);
158
+ upserted++;
159
+ }
160
+ });
161
+ return { upserted };
162
+ },
163
+ async removeEntry(db, id) {
164
+ const result = await db
165
+ .delete(cruiseSearchIndex)
166
+ .where(eq(cruiseSearchIndex.id, id))
167
+ .returning({ id: cruiseSearchIndex.id });
168
+ return result.length > 0;
169
+ },
170
+ async removeBySource(db, sourceProvider) {
171
+ const result = await db
172
+ .delete(cruiseSearchIndex)
173
+ .where(and(eq(cruiseSearchIndex.source, "external"), eq(cruiseSearchIndex.sourceProvider, sourceProvider)))
174
+ .returning({ id: cruiseSearchIndex.id });
175
+ return { removed: result.length };
176
+ },
177
+ async removeExternalByIdsExcept(db, sourceProvider, keepIds, sourceConnectionId) {
178
+ const conditions = [
179
+ eq(cruiseSearchIndex.source, "external"),
180
+ eq(cruiseSearchIndex.sourceProvider, sourceProvider),
181
+ sourceConnectionId == null
182
+ ? // agent-quality: raw-sql reviewed -- owner: cruises; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
183
+ sql `coalesce(${cruiseSearchIndex.sourceRef}->>'connectionId', '') = ''`
184
+ : // agent-quality: raw-sql reviewed -- owner: cruises; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
185
+ sql `${cruiseSearchIndex.sourceRef}->>'connectionId' = ${sourceConnectionId}`,
186
+ ];
187
+ if (keepIds.length > 0) {
188
+ conditions.push(notInArray(cruiseSearchIndex.id, [...keepIds]));
189
+ }
190
+ const result = await db
191
+ .delete(cruiseSearchIndex)
192
+ .where(and(...conditions))
193
+ .returning({ id: cruiseSearchIndex.id });
194
+ return { removed: result.length };
195
+ },
196
+ async listExternalConnectionIds(db, sourceProvider) {
197
+ const connectionId = sql `nullif(${cruiseSearchIndex.sourceRef}->>'connectionId', '')`;
198
+ const rows = await db
199
+ .select({ connectionId })
200
+ .from(cruiseSearchIndex)
201
+ .where(and(eq(cruiseSearchIndex.source, "external"), eq(cruiseSearchIndex.sourceProvider, sourceProvider)))
202
+ .groupBy(connectionId);
203
+ return rows.map((row) => row.connectionId);
204
+ },
205
+ // ---------- projection from local cruises ----------
206
+ /**
207
+ * Re-project a single local cruise into the search index. Called from the
208
+ * cruisesService mutation hooks so the index stays fresh without a separate
209
+ * scheduled job. Computes the lowest available price across the cruise's
210
+ * sailings and the earliest/latest departure dates.
211
+ *
212
+ * If the cruise's status is 'archived' the entry is removed instead — archived
213
+ * cruises shouldn't appear on the storefront.
214
+ */
215
+ async projectLocalCruise(db, cruiseId) {
216
+ const [cruise] = await db.select().from(cruises).where(eq(cruises.id, cruiseId)).limit(1);
217
+ if (!cruise) {
218
+ // Cruise was deleted — drop any matching index row.
219
+ await db.delete(cruiseSearchIndex).where(eq(cruiseSearchIndex.localCruiseId, cruiseId));
220
+ return null;
221
+ }
222
+ if (cruise.status === "archived") {
223
+ await db.delete(cruiseSearchIndex).where(eq(cruiseSearchIndex.localCruiseId, cruiseId));
224
+ return null;
225
+ }
226
+ const entry = await buildLocalEntry(db, cruise);
227
+ if (!entry)
228
+ return null;
229
+ return this.upsertEntry(db, entry);
230
+ },
231
+ /**
232
+ * Drop and rebuild every local cruise entry. Useful after schema changes
233
+ * or operator-triggered "rebuild storefront index" actions.
234
+ */
235
+ async rebuildLocal(db) {
236
+ // Remove all local entries first so deleted cruises don't linger.
237
+ await db.delete(cruiseSearchIndex).where(eq(cruiseSearchIndex.source, "local"));
238
+ // agent-quality: raw-sql reviewed -- owner: cruises; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
239
+ const allCruises = await db.select().from(cruises).where(sql `${cruises.status} <> 'archived'`);
240
+ let upserted = 0;
241
+ for (const cruise of allCruises) {
242
+ const entry = await buildLocalEntry(db, cruise);
243
+ if (!entry)
244
+ continue;
245
+ await this.upsertEntry(db, entry);
246
+ upserted++;
247
+ }
248
+ return { upserted };
249
+ },
250
+ /**
251
+ * Drain `searchProjection()` from a single adapter and bulk-upsert. Useful
252
+ * for ad-hoc "refresh from upstream" actions; production deployments
253
+ * typically have the adapter push deltas continuously instead.
254
+ */
255
+ async rebuildExternalForAdapter(db, adapter) {
256
+ const result = await this.refreshExternalForAdapter(db, adapter);
257
+ return { upserted: result.upserted };
258
+ },
259
+ /**
260
+ * Drain `searchProjection()` from a single adapter and reconcile the local
261
+ * external search-index rows for that provider. Existing rows stay intact
262
+ * until the adapter stream completes; only then are missing rows removed.
263
+ */
264
+ async refreshExternalForAdapter(db, adapter) {
265
+ let upserted = 0;
266
+ const keptIdsByConnection = new Map();
267
+ const pruneConnectionIds = new Set(await this.listExternalConnectionIds(db, adapter.name));
268
+ for await (const entry of adapter.searchProjection()) {
269
+ const row = await this.upsertEntry(db, {
270
+ source: "external",
271
+ sourceProvider: adapter.name,
272
+ sourceRef: entry.sourceRef,
273
+ slug: entry.slug,
274
+ name: entry.name,
275
+ cruiseType: entry.cruiseType,
276
+ lineName: entry.lineName,
277
+ shipName: entry.shipName,
278
+ nights: entry.nights,
279
+ embarkPortName: entry.embarkPortName ?? null,
280
+ embarkPortCanonicalPlaceId: entry.embarkPortCanonicalPlaceId ?? null,
281
+ disembarkPortName: entry.disembarkPortName ?? null,
282
+ disembarkPortCanonicalPlaceId: entry.disembarkPortCanonicalPlaceId ?? null,
283
+ regionIds: entry.regionIds ?? [],
284
+ waterwayIds: entry.waterwayIds ?? [],
285
+ portIds: entry.portIds ?? [],
286
+ countryIso: entry.countryIso ?? [],
287
+ regions: entry.regions ?? [],
288
+ waterways: entry.waterways ?? [],
289
+ ports: entry.ports ?? [],
290
+ countries: entry.countries ?? [],
291
+ themes: entry.themes ?? [],
292
+ earliestDeparture: entry.earliestDeparture ?? null,
293
+ latestDeparture: entry.latestDeparture ?? null,
294
+ departureCount: entry.departureCount ?? null,
295
+ lowestPriceCents: entry.lowestPriceCents ?? null,
296
+ lowestPriceCurrency: entry.lowestPriceCurrency ?? null,
297
+ salesStatus: entry.salesStatus ?? null,
298
+ heroImageUrl: entry.heroImageUrl ?? null,
299
+ });
300
+ const connectionId = sourceRefConnectionId(entry.sourceRef);
301
+ const keptIds = keptIdsByConnection.get(connectionId) ?? [];
302
+ keptIds.push(row.id);
303
+ keptIdsByConnection.set(connectionId, keptIds);
304
+ pruneConnectionIds.add(connectionId);
305
+ upserted++;
306
+ }
307
+ let removed = 0;
308
+ for (const connectionId of pruneConnectionIds) {
309
+ const keptIds = keptIdsByConnection.get(connectionId) ?? [];
310
+ const result = await this.removeExternalByIdsExcept(db, adapter.name, keptIds, connectionId);
311
+ removed += result.removed;
312
+ }
313
+ return { upserted, removed };
314
+ },
315
+ /**
316
+ * Full rebuild — local cruises + every registered adapter.
317
+ * Per-adapter errors are collected so one bad adapter doesn't block the rest.
318
+ */
319
+ async rebuildAll(db) {
320
+ const localResult = await this.rebuildLocal(db);
321
+ const externalErrors = [];
322
+ let externalUpserted = 0;
323
+ let externalRemoved = 0;
324
+ for (const adapter of listCruiseAdapters()) {
325
+ try {
326
+ const result = await this.refreshExternalForAdapter(db, adapter);
327
+ externalUpserted += result.upserted;
328
+ externalRemoved += result.removed;
329
+ }
330
+ catch (err) {
331
+ externalErrors.push({ adapter: adapter.name, error: err.message });
332
+ }
333
+ }
334
+ return {
335
+ localUpserted: localResult.upserted,
336
+ externalUpserted,
337
+ externalRemoved,
338
+ externalErrors,
339
+ };
340
+ },
341
+ };
342
+ // ---------- helpers ----------
343
+ async function findExisting(db, entry) {
344
+ if (entry.source === "local" && entry.localCruiseId) {
345
+ const [row] = await db
346
+ .select()
347
+ .from(cruiseSearchIndex)
348
+ .where(eq(cruiseSearchIndex.localCruiseId, entry.localCruiseId))
349
+ .limit(1);
350
+ if (row)
351
+ return row;
352
+ }
353
+ if (entry.source === "external" && entry.sourceProvider && entry.sourceRef) {
354
+ const [row] = await db
355
+ .select()
356
+ .from(cruiseSearchIndex)
357
+ .where(and(eq(cruiseSearchIndex.source, "external"), eq(cruiseSearchIndex.sourceProvider, entry.sourceProvider),
358
+ // agent-quality: raw-sql reviewed -- owner: cruises; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
359
+ sql `${cruiseSearchIndex.sourceRef} = ${sourceRefIdentityJson(entry.sourceRef)}::jsonb`))
360
+ .limit(1);
361
+ if (row)
362
+ return row;
363
+ }
364
+ // Fallback: match by slug (slug is unique across the index).
365
+ const [bySlug] = await db
366
+ .select()
367
+ .from(cruiseSearchIndex)
368
+ .where(eq(cruiseSearchIndex.slug, entry.slug))
369
+ .limit(1);
370
+ return bySlug ?? null;
371
+ }
372
+ export function sourceRefIdentityJson(sourceRef) {
373
+ return JSON.stringify(sortValue(sourceRef));
374
+ }
375
+ function sourceRefConnectionId(sourceRef) {
376
+ return typeof sourceRef.connectionId === "string" ? sourceRef.connectionId : null;
377
+ }
378
+ function moneyStringToCents(value) {
379
+ if (!value)
380
+ return null;
381
+ const major = Number.parseFloat(value);
382
+ if (!Number.isFinite(major))
383
+ return null;
384
+ return Math.round(major * 100);
385
+ }
386
+ function sortValue(value) {
387
+ if (Array.isArray(value))
388
+ return value.map(sortValue);
389
+ if (!value || typeof value !== "object")
390
+ return value;
391
+ const out = {};
392
+ for (const key of Object.keys(value).sort()) {
393
+ out[key] = sortValue(value[key]);
394
+ }
395
+ return out;
396
+ }
397
+ async function buildLocalEntry(db, cruise) {
398
+ // Resolve ship name. Falls back to "—" when no default ship is set; storefront
399
+ // can hide rows without a ship if it cares, but most local cruises have one.
400
+ let shipName = "—";
401
+ if (cruise.defaultShipId) {
402
+ const [ship] = await db
403
+ .select({ name: cruiseShips.name })
404
+ .from(cruiseShips)
405
+ .where(eq(cruiseShips.id, cruise.defaultShipId))
406
+ .limit(1);
407
+ if (ship)
408
+ shipName = ship.name;
409
+ }
410
+ // Aggregate over sailings + prices in two parallel queries.
411
+ const [dateAgg] = await db
412
+ .select({
413
+ earliest: sql `MIN(${cruiseSailings.departureDate})`,
414
+ latest: sql `MAX(${cruiseSailings.departureDate})`,
415
+ count: sql `COUNT(*)::int`,
416
+ })
417
+ .from(cruiseSailings)
418
+ .where(eq(cruiseSailings.cruiseId, cruise.id));
419
+ const [priceAgg] = await db
420
+ .select({
421
+ lowestCents: sql `MIN(ROUND(${cruisePrices.pricePerPerson}::numeric * 100))::int`,
422
+ currency: sql `(ARRAY_AGG(${cruisePrices.currency} ORDER BY ${cruisePrices.pricePerPerson}::numeric ASC))[1]`,
423
+ })
424
+ .from(cruisePrices)
425
+ .innerJoin(cruiseSailings, eq(cruisePrices.sailingId, cruiseSailings.id))
426
+ .where(
427
+ // agent-quality: raw-sql reviewed -- owner: cruises; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
428
+ and(eq(cruiseSailings.cruiseId, cruise.id), sql `${cruisePrices.availability} <> 'sold_out'`));
429
+ // Sales status is a coarse roll-up: if any sailing is open, the cruise is open.
430
+ const [salesAgg] = await db
431
+ .select({
432
+ hasOpen: sql `bool_or(${cruiseSailings.salesStatus} = 'open')`,
433
+ })
434
+ .from(cruiseSailings)
435
+ .where(eq(cruiseSailings.cruiseId, cruise.id));
436
+ const salesStatus = salesAgg?.hasOpen ? "open" : "closed";
437
+ return {
438
+ source: "local",
439
+ sourceProvider: null,
440
+ sourceRef: null,
441
+ localCruiseId: cruise.id,
442
+ slug: cruise.slug,
443
+ name: cruise.name,
444
+ cruiseType: cruise.cruiseType,
445
+ lineName: cruise.lineSupplierId ?? "—",
446
+ shipName,
447
+ nights: cruise.nights,
448
+ embarkPortCanonicalPlaceId: cruise.embarkPortCanonicalPlaceId ?? null,
449
+ disembarkPortCanonicalPlaceId: cruise.disembarkPortCanonicalPlaceId ?? null,
450
+ regionIds: cruise.regionIds ?? [],
451
+ waterwayIds: cruise.waterwayIds ?? [],
452
+ portIds: cruise.portIds ?? [],
453
+ countryIso: cruise.countryIso ?? [],
454
+ regions: cruise.regions ?? [],
455
+ waterways: cruise.waterways ?? [],
456
+ ports: cruise.ports ?? [],
457
+ countries: cruise.countries ?? [],
458
+ themes: cruise.themes ?? [],
459
+ earliestDeparture: dateAgg?.earliest ?? null,
460
+ latestDeparture: dateAgg?.latest ?? null,
461
+ departureCount: dateAgg?.count ?? null,
462
+ lowestPriceCents: priceAgg?.lowestCents ?? moneyStringToCents(cruise.lowestPriceCached ?? null) ?? null,
463
+ lowestPriceCurrency: priceAgg?.currency ?? cruise.lowestPriceCurrencyCached ?? null,
464
+ salesStatus,
465
+ heroImageUrl: cruise.heroImageUrl ?? null,
466
+ };
467
+ }
@@ -0,0 +1,22 @@
1
+ import type { EventBus } from "@voyant-travel/core";
2
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
+ export declare const setUpdated: {
4
+ updatedAt: Date;
5
+ };
6
+ export declare function paginate(query: {
7
+ limit: number;
8
+ offset: number;
9
+ }): {
10
+ limit: number;
11
+ offset: number;
12
+ };
13
+ export interface CruiseMutationRuntime {
14
+ eventBus?: EventBus;
15
+ }
16
+ /**
17
+ * Re-project a cruise into cruise_search_index after a mutation. Errors are
18
+ * swallowed and logged; the search index is best-effort and never blocks the
19
+ * underlying mutation.
20
+ */
21
+ export declare function reprojectIfPossible(db: PostgresJsDatabase, cruiseId: string | null): Promise<void>;
22
+ //# sourceMappingURL=service-shared.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-shared.d.ts","sourceRoot":"","sources":["../src/service-shared.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AACnD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAEjE,eAAO,MAAM,UAAU;;CAA4B,CAAA;AAEnD,wBAAgB,QAAQ,CAAC,KAAK,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE;;;EAEhE;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,EAAE,QAAQ,CAAA;CACpB;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,kBAAkB,EACtB,QAAQ,EAAE,MAAM,GAAG,IAAI,GACtB,OAAO,CAAC,IAAI,CAAC,CAUf"}
@@ -0,0 +1,22 @@
1
+ export const setUpdated = { updatedAt: new Date() };
2
+ export function paginate(query) {
3
+ return { limit: query.limit, offset: query.offset };
4
+ }
5
+ /**
6
+ * Re-project a cruise into cruise_search_index after a mutation. Errors are
7
+ * swallowed and logged; the search index is best-effort and never blocks the
8
+ * underlying mutation.
9
+ */
10
+ export async function reprojectIfPossible(db, cruiseId) {
11
+ if (!cruiseId)
12
+ return;
13
+ try {
14
+ const { cruisesSearchService } = await import("./service-search.js");
15
+ await cruisesSearchService.projectLocalCruise(db, cruiseId);
16
+ }
17
+ catch (err) {
18
+ // Don't crash the caller. Operators can run the search-index rebuild route to repair drift.
19
+ // eslint-disable-next-line no-console -- owner: cruises; existing suppression is intentional pending typed cleanup.
20
+ console.warn(`[cruises] search-index projection failed for ${cruiseId}:`, err);
21
+ }
22
+ }
@@ -0,0 +1,47 @@
1
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
+ import type { CruiseCabin, CruiseCabinCategory, CruiseDeck, CruiseShip } from "./schema-cabins.js";
3
+ import type { InsertCabin, InsertCabinCategory, InsertDeck, InsertShip, ShipListQuery, UpdateCabin, UpdateCabinCategory, UpdateDeck, UpdateShip } from "./validation-cabins.js";
4
+ export declare const cruiseShipService: {
5
+ listShips(db: PostgresJsDatabase, query: ShipListQuery): Promise<{
6
+ data: {
7
+ id: string;
8
+ lineSupplierId: string | null;
9
+ name: string;
10
+ slug: string;
11
+ shipType: "ocean" | "river" | "expedition" | "coastal" | "yacht" | "sailing";
12
+ capacityGuests: number | null;
13
+ capacityCrew: number | null;
14
+ cabinCount: number | null;
15
+ deckCount: number | null;
16
+ lengthMeters: string | null;
17
+ cruisingSpeedKnots: string | null;
18
+ yearBuilt: number | null;
19
+ yearRefurbished: number | null;
20
+ imo: string | null;
21
+ description: string | null;
22
+ deckPlanUrl: string | null;
23
+ gallery: string[] | null;
24
+ amenities: Record<string, unknown> | null;
25
+ externalRefs: Record<string, string> | null;
26
+ isActive: boolean;
27
+ createdAt: Date;
28
+ updatedAt: Date;
29
+ }[];
30
+ total: number;
31
+ limit: number;
32
+ offset: number;
33
+ }>;
34
+ getShipById(db: PostgresJsDatabase, id: string): Promise<CruiseShip | null>;
35
+ createShip(db: PostgresJsDatabase, data: InsertShip): Promise<CruiseShip>;
36
+ updateShip(db: PostgresJsDatabase, id: string, data: UpdateShip): Promise<CruiseShip | null>;
37
+ listShipDecks(db: PostgresJsDatabase, shipId: string): Promise<CruiseDeck[]>;
38
+ upsertDeck(db: PostgresJsDatabase, data: InsertDeck): Promise<CruiseDeck>;
39
+ updateDeck(db: PostgresJsDatabase, id: string, data: UpdateDeck): Promise<CruiseDeck | null>;
40
+ listShipCabinCategories(db: PostgresJsDatabase, shipId: string): Promise<CruiseCabinCategory[]>;
41
+ upsertCabinCategory(db: PostgresJsDatabase, data: InsertCabinCategory): Promise<CruiseCabinCategory>;
42
+ updateCabinCategory(db: PostgresJsDatabase, id: string, data: UpdateCabinCategory): Promise<CruiseCabinCategory | null>;
43
+ listCabinsByCategory(db: PostgresJsDatabase, categoryId: string): Promise<CruiseCabin[]>;
44
+ upsertCabin(db: PostgresJsDatabase, data: InsertCabin): Promise<CruiseCabin>;
45
+ updateCabin(db: PostgresJsDatabase, id: string, data: UpdateCabin): Promise<CruiseCabin | null>;
46
+ };
47
+ //# sourceMappingURL=service-ships.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-ships.d.ts","sourceRoot":"","sources":["../src/service-ships.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAEjE,OAAO,KAAK,EAAE,WAAW,EAAE,mBAAmB,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAGlG,OAAO,KAAK,EACV,WAAW,EACX,mBAAmB,EACnB,UAAU,EACV,UAAU,EACV,aAAa,EACb,WAAW,EACX,mBAAmB,EACnB,UAAU,EACV,UAAU,EACX,MAAM,wBAAwB,CAAA;AAE/B,eAAO,MAAM,iBAAiB;kBACR,kBAAkB,SAAS,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;oBAuBtC,kBAAkB,MAAM,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;mBAK5D,kBAAkB,QAAQ,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;mBAOzE,kBAAkB,MAClB,MAAM,QACJ,UAAU,GACf,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;sBASL,kBAAkB,UAAU,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC;mBAQ7D,kBAAkB,QAAQ,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;mBAqBzE,kBAAkB,MAClB,MAAM,QACJ,UAAU,GACf,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;gCAUvB,kBAAkB,UACd,MAAM,GACb,OAAO,CAAC,mBAAmB,EAAE,CAAC;4BAS3B,kBAAkB,QAChB,mBAAmB,GACxB,OAAO,CAAC,mBAAmB,CAAC;4BA0BzB,kBAAkB,MAClB,MAAM,QACJ,mBAAmB,GACxB,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC;6BASP,kBAAkB,cAAc,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;oBAQxE,kBAAkB,QAAQ,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;oBA0B5E,kBAAkB,MAClB,MAAM,QACJ,WAAW,GAChB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;CAU/B,CAAA"}