@voyant-travel/trips 0.110.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 (63) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +38 -0
  3. package/dist/catalog-component-adapter.d.ts +16 -0
  4. package/dist/catalog-component-adapter.d.ts.map +1 -0
  5. package/dist/catalog-component-adapter.js +34 -0
  6. package/dist/cruise-extension.d.ts +48 -0
  7. package/dist/cruise-extension.d.ts.map +1 -0
  8. package/dist/cruise-extension.js +66 -0
  9. package/dist/index.d.ts +24 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +31 -0
  12. package/dist/mcp-contract.d.ts +79 -0
  13. package/dist/mcp-contract.d.ts.map +1 -0
  14. package/dist/mcp-contract.js +21 -0
  15. package/dist/mcp-registry.d.ts +48 -0
  16. package/dist/mcp-registry.d.ts.map +1 -0
  17. package/dist/mcp-registry.js +88 -0
  18. package/dist/mcp-tools.d.ts +157 -0
  19. package/dist/mcp-tools.d.ts.map +1 -0
  20. package/dist/mcp-tools.js +109 -0
  21. package/dist/routes.d.ts +2068 -0
  22. package/dist/routes.d.ts.map +1 -0
  23. package/dist/routes.js +280 -0
  24. package/dist/schema.d.ts +1897 -0
  25. package/dist/schema.d.ts.map +1 -0
  26. package/dist/schema.js +255 -0
  27. package/dist/service-cancellation.d.ts +6 -0
  28. package/dist/service-cancellation.d.ts.map +1 -0
  29. package/dist/service-cancellation.js +251 -0
  30. package/dist/service-checkout.d.ts +6 -0
  31. package/dist/service-checkout.d.ts.map +1 -0
  32. package/dist/service-checkout.js +328 -0
  33. package/dist/service-helpers.d.ts +17 -0
  34. package/dist/service-helpers.d.ts.map +1 -0
  35. package/dist/service-helpers.js +161 -0
  36. package/dist/service-internals.d.ts +11 -0
  37. package/dist/service-internals.d.ts.map +1 -0
  38. package/dist/service-internals.js +72 -0
  39. package/dist/service-pricing.d.ts +8 -0
  40. package/dist/service-pricing.d.ts.map +1 -0
  41. package/dist/service-pricing.js +142 -0
  42. package/dist/service-reservation.d.ts +5 -0
  43. package/dist/service-reservation.d.ts.map +1 -0
  44. package/dist/service-reservation.js +493 -0
  45. package/dist/service-snapshots.d.ts +8 -0
  46. package/dist/service-snapshots.d.ts.map +1 -0
  47. package/dist/service-snapshots.js +115 -0
  48. package/dist/service-trips.d.ts +14 -0
  49. package/dist/service-trips.d.ts.map +1 -0
  50. package/dist/service-trips.js +378 -0
  51. package/dist/service-types.d.ts +285 -0
  52. package/dist/service-types.d.ts.map +1 -0
  53. package/dist/service-types.js +6 -0
  54. package/dist/service.d.ts +38 -0
  55. package/dist/service.d.ts.map +1 -0
  56. package/dist/service.js +40 -0
  57. package/dist/traveler-party-validation.d.ts +3 -0
  58. package/dist/traveler-party-validation.d.ts.map +1 -0
  59. package/dist/traveler-party-validation.js +68 -0
  60. package/dist/validation.d.ts +449 -0
  61. package/dist/validation.d.ts.map +1 -0
  62. package/dist/validation.js +261 -0
  63. package/package.json +83 -0
@@ -0,0 +1,115 @@
1
+ import { desc, eq } from "drizzle-orm";
2
+ import { tripSnapshots } from "./schema.js";
3
+ import { aggregateComponentPricing } from "./service-helpers.js";
4
+ import { getTrip } from "./service-trips.js";
5
+ import { TripsInvariantError } from "./service-types.js";
6
+ export async function freezeTripSnapshot(db, input) {
7
+ const trip = await getTrip(db, input.envelopeId);
8
+ if (!trip) {
9
+ throw new TripsInvariantError(`Trip envelope ${input.envelopeId} was not found`);
10
+ }
11
+ const components = trip.components.filter((component) => component.status !== "removed");
12
+ const missingPricing = components.filter((component) => !component.pricingSnapshot);
13
+ if (missingPricing.length > 0) {
14
+ throw new TripsInvariantError(`Cannot freeze trip ${input.envelopeId}; components missing pricing snapshots: ${missingPricing
15
+ .map((component) => component.id)
16
+ .join(", ")}`);
17
+ }
18
+ const frozenAt = new Date();
19
+ const proposal = buildTripSnapshotProposal(trip.envelope, components, frozenAt);
20
+ const values = {
21
+ envelopeId: trip.envelope.id,
22
+ sourceEnvelopeUpdatedAt: trip.envelope.updatedAt ?? frozenAt,
23
+ titleSnapshot: trip.envelope.title,
24
+ descriptionSnapshot: trip.envelope.description,
25
+ travelerPartySnapshot: snapshotJsonRecord(trip.envelope.travelerParty),
26
+ constraintsSnapshot: snapshotJsonRecord(trip.envelope.constraints),
27
+ currency: proposal.currency,
28
+ subtotalAmountCents: proposal.subtotalAmountCents,
29
+ taxAmountCents: proposal.taxAmountCents,
30
+ totalAmountCents: proposal.totalAmountCents,
31
+ componentCount: proposal.componentCount,
32
+ pricedComponentCount: proposal.pricedComponentCount,
33
+ frozenEnvelope: snapshotJsonRecord(trip.envelope),
34
+ frozenComponents: components.map(snapshotJsonRecord),
35
+ proposal,
36
+ createdBy: input.createdBy ?? null,
37
+ };
38
+ const [snapshot] = (await db.insert(tripSnapshots).values(values).returning());
39
+ if (!snapshot)
40
+ throw new Error("freezeTripSnapshot: insert returned no snapshot");
41
+ return snapshot;
42
+ }
43
+ export async function getTripSnapshotById(db, snapshotId) {
44
+ const [snapshot] = (await db
45
+ .select()
46
+ .from(tripSnapshots)
47
+ .where(eq(tripSnapshots.id, snapshotId))
48
+ .limit(1));
49
+ return snapshot ?? null;
50
+ }
51
+ export async function listTripSnapshots(db, envelopeId) {
52
+ return (await db
53
+ .select()
54
+ .from(tripSnapshots)
55
+ .where(eq(tripSnapshots.envelopeId, envelopeId))
56
+ .orderBy(desc(tripSnapshots.createdAt)));
57
+ }
58
+ export function buildTripSnapshotProposal(envelope, components, frozenAt) {
59
+ const aggregate = aggregateComponentPricing(components, envelope.aggregateCurrency ?? undefined);
60
+ return {
61
+ envelopeId: envelope.id,
62
+ title: envelope.title,
63
+ description: envelope.description,
64
+ currency: aggregate.currency,
65
+ subtotalAmountCents: aggregate.subtotalAmountCents,
66
+ taxAmountCents: aggregate.taxAmountCents,
67
+ totalAmountCents: aggregate.totalAmountCents,
68
+ componentCount: aggregate.componentCount,
69
+ pricedComponentCount: aggregate.pricedComponentCount,
70
+ warnings: aggregate.warnings ?? [],
71
+ frozenAt: frozenAt.toISOString(),
72
+ lines: components.map(componentToProposalLine),
73
+ };
74
+ }
75
+ function componentToProposalLine(component) {
76
+ const pricing = component.pricingSnapshot;
77
+ if (!pricing) {
78
+ throw new TripsInvariantError(`Cannot build proposal line for unpriced component ${component.id}`);
79
+ }
80
+ return {
81
+ componentId: component.id,
82
+ sequence: component.sequence,
83
+ kind: component.kind,
84
+ status: component.status,
85
+ title: component.title,
86
+ description: componentDisplayName(component),
87
+ entityModule: component.entityModule,
88
+ entityId: component.entityId,
89
+ sourceKind: component.sourceKind,
90
+ currency: pricing.currency,
91
+ subtotalAmountCents: pricing.subtotalAmountCents,
92
+ taxAmountCents: pricing.taxAmountCents,
93
+ totalAmountCents: pricing.totalAmountCents,
94
+ priceExpiresAt: pricing.priceExpiresAt ?? component.priceExpiresAt?.toISOString() ?? null,
95
+ warnings: [...new Set([...(pricing.warnings ?? []), ...(component.warningCodes ?? [])])],
96
+ };
97
+ }
98
+ function componentDisplayName(component) {
99
+ const metadata = component.metadata;
100
+ const manualServiceName = metadata.manualService?.name;
101
+ if (typeof component.title === "string" && component.title.trim())
102
+ return component.title;
103
+ if (typeof manualServiceName === "string" && manualServiceName.trim()) {
104
+ return manualServiceName.trim();
105
+ }
106
+ if (typeof component.description === "string" && component.description.trim()) {
107
+ return component.description;
108
+ }
109
+ if (component.entityModule && component.entityId)
110
+ return `${component.entityModule}:${component.entityId}`;
111
+ return component.kind.replaceAll("_", " ");
112
+ }
113
+ function snapshotJsonRecord(value) {
114
+ return JSON.parse(JSON.stringify(value));
115
+ }
@@ -0,0 +1,14 @@
1
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
2
+ import type { TripComponent, TripEnvelope } from "./schema.js";
3
+ import { type Trip, type TripListResult } from "./service-types.js";
4
+ import type { CreateTripComponentInput, CreateTripEnvelopeInput, ListTripsQuery, ReorderTripComponentsInput, UpdateTripComponentInput, UpdateTripComponentRefsInput, UpdateTripEnvelopeInput } from "./validation.js";
5
+ export declare function createTrip(db: AnyDrizzleDb, input: CreateTripEnvelopeInput): Promise<Trip>;
6
+ export declare function getTrip(db: AnyDrizzleDb, envelopeId: string): Promise<Trip | null>;
7
+ export declare function listTrips(db: AnyDrizzleDb, input?: ListTripsQuery): Promise<TripListResult>;
8
+ export declare function updateTrip(db: AnyDrizzleDb, envelopeId: string, input: UpdateTripEnvelopeInput): Promise<TripEnvelope | null>;
9
+ export declare function addComponent(db: AnyDrizzleDb, input: CreateTripComponentInput): Promise<TripComponent>;
10
+ export declare function updateComponent(db: AnyDrizzleDb, componentId: string, input: UpdateTripComponentInput): Promise<TripComponent | null>;
11
+ export declare function updateComponentRefs(db: AnyDrizzleDb, componentId: string, input: UpdateTripComponentRefsInput): Promise<TripComponent | null>;
12
+ export declare function removeComponent(db: AnyDrizzleDb, componentId: string): Promise<TripComponent | null>;
13
+ export declare function reorderComponents(db: AnyDrizzleDb, input: ReorderTripComponentsInput): Promise<TripComponent[]>;
14
+ //# sourceMappingURL=service-trips.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-trips.d.ts","sourceRoot":"","sources":["../src/service-trips.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAGrD,OAAO,KAAK,EAAqC,aAAa,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAOjG,OAAO,EAAE,KAAK,IAAI,EAAE,KAAK,cAAc,EAAuB,MAAM,oBAAoB,CAAA;AAExF,OAAO,KAAK,EACV,wBAAwB,EACxB,uBAAuB,EACvB,cAAc,EACd,0BAA0B,EAC1B,wBAAwB,EACxB,4BAA4B,EAC5B,uBAAuB,EACxB,MAAM,iBAAiB,CAAA;AAExB,wBAAsB,UAAU,CAAC,EAAE,EAAE,YAAY,EAAE,KAAK,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBhG;AAED,wBAAsB,OAAO,CAAC,EAAE,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC,CAexF;AAED,wBAAsB,SAAS,CAC7B,EAAE,EAAE,YAAY,EAChB,KAAK,GAAE,cAKN,GACA,OAAO,CAAC,cAAc,CAAC,CAsIzB;AAqBD,wBAAsB,UAAU,CAC9B,EAAE,EAAE,YAAY,EAChB,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,uBAAuB,GAC7B,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAsB9B;AAED,wBAAsB,YAAY,CAChC,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,aAAa,CAAC,CAkCxB;AAED,wBAAsB,eAAe,CACnC,EAAE,EAAE,YAAY,EAChB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CA+B/B;AAED,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,YAAY,EAChB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,4BAA4B,GAClC,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CA8B/B;AAED,wBAAsB,eAAe,CACnC,EAAE,EAAE,YAAY,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAE/B;AAED,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,0BAA0B,GAChC,OAAO,CAAC,aAAa,EAAE,CAAC,CAkC1B"}
@@ -0,0 +1,378 @@
1
+ import { asc, eq, inArray } from "drizzle-orm";
2
+ import { tripComponents, tripEnvelopes } from "./schema.js";
3
+ import { assertTripComponentCanBeUpdated, assertTripComponentCanReceiveRefs, } from "./service-helpers.js";
4
+ import { createComponentEvent, statusToEventType } from "./service-internals.js";
5
+ import { TripsInvariantError } from "./service-types.js";
6
+ import { assertTripTravelerPartyComplete } from "./traveler-party-validation.js";
7
+ export async function createTrip(db, input) {
8
+ assertTripTravelerPartyComplete(input.travelerParty, "Trip creation");
9
+ const values = {
10
+ title: input.title,
11
+ description: input.description,
12
+ travelerParty: input.travelerParty,
13
+ constraints: input.constraints,
14
+ createdBy: input.createdBy,
15
+ updatedBy: input.createdBy,
16
+ };
17
+ const [envelope] = (await db.insert(tripEnvelopes).values(values).returning());
18
+ if (!envelope)
19
+ throw new Error("createTrip: insert returned no envelope");
20
+ return { envelope, components: [] };
21
+ }
22
+ export async function getTrip(db, envelopeId) {
23
+ const [envelope] = (await db
24
+ .select()
25
+ .from(tripEnvelopes)
26
+ .where(eq(tripEnvelopes.id, envelopeId))
27
+ .limit(1));
28
+ if (!envelope)
29
+ return null;
30
+ const components = (await db
31
+ .select()
32
+ .from(tripComponents)
33
+ .where(eq(tripComponents.envelopeId, envelopeId))
34
+ .orderBy(asc(tripComponents.sequence), asc(tripComponents.createdAt)));
35
+ return { envelope, components };
36
+ }
37
+ export async function listTrips(db, input = {
38
+ limit: 50,
39
+ offset: 0,
40
+ sortBy: "updatedAt",
41
+ sortDir: "desc",
42
+ }) {
43
+ const allEnvelopes = (await db.select().from(tripEnvelopes));
44
+ const needsComponents = input.productId !== undefined ||
45
+ input.accommodationId !== undefined ||
46
+ input.cruiseId !== undefined ||
47
+ input.hasFlight === true;
48
+ // When a component-based filter is active we have to know each envelope's
49
+ // components *before* paging, so fetch them up front. Otherwise we keep the
50
+ // cheap path that only loads components for the visible page.
51
+ const allComponents = needsComponents
52
+ ? (await db
53
+ .select()
54
+ .from(tripComponents)
55
+ .orderBy(asc(tripComponents.sequence), asc(tripComponents.createdAt)))
56
+ : [];
57
+ const allComponentsByEnvelope = new Map();
58
+ if (needsComponents) {
59
+ for (const component of allComponents) {
60
+ if (component.status === "removed")
61
+ continue;
62
+ const bucket = allComponentsByEnvelope.get(component.envelopeId) ?? [];
63
+ bucket.push(component);
64
+ allComponentsByEnvelope.set(component.envelopeId, bucket);
65
+ }
66
+ }
67
+ const normalizedSearch = input.search?.toLowerCase();
68
+ const createdFromMs = parseDateMs(input.createdFrom);
69
+ const createdToMs = parseEndDateMs(input.createdTo);
70
+ const filtered = allEnvelopes.filter((envelope) => {
71
+ if (input.status && envelope.status !== input.status)
72
+ return false;
73
+ if (normalizedSearch) {
74
+ const matches = [envelope.id, envelope.title, envelope.description]
75
+ .filter((value) => typeof value === "string")
76
+ .some((value) => value.toLowerCase().includes(normalizedSearch));
77
+ if (!matches)
78
+ return false;
79
+ }
80
+ if (input.totalMinCents !== undefined) {
81
+ const total = envelope.aggregateTotalAmountCents ?? null;
82
+ if (total === null || total < input.totalMinCents)
83
+ return false;
84
+ }
85
+ if (input.totalMaxCents !== undefined) {
86
+ const total = envelope.aggregateTotalAmountCents ?? null;
87
+ if (total === null || total > input.totalMaxCents)
88
+ return false;
89
+ }
90
+ if (createdFromMs !== null) {
91
+ const ts = envelope.createdAt?.getTime() ?? null;
92
+ if (ts === null || ts < createdFromMs)
93
+ return false;
94
+ }
95
+ if (createdToMs !== null) {
96
+ const ts = envelope.createdAt?.getTime() ?? null;
97
+ if (ts === null || ts > createdToMs)
98
+ return false;
99
+ }
100
+ if (needsComponents) {
101
+ const envelopeComponents = allComponentsByEnvelope.get(envelope.id) ?? [];
102
+ if (input.productId &&
103
+ !envelopeComponents.some((component) => component.entityModule === "products" && component.entityId === input.productId)) {
104
+ return false;
105
+ }
106
+ if (input.accommodationId &&
107
+ !envelopeComponents.some((component) => component.entityModule === "accommodations" &&
108
+ component.entityId === input.accommodationId)) {
109
+ return false;
110
+ }
111
+ if (input.cruiseId &&
112
+ !envelopeComponents.some((component) => component.entityModule === "cruises" && component.entityId === input.cruiseId)) {
113
+ return false;
114
+ }
115
+ if (input.hasFlight === true &&
116
+ !envelopeComponents.some((component) => component.kind === "flight_placeholder" || component.kind === "flight_order")) {
117
+ return false;
118
+ }
119
+ }
120
+ return true;
121
+ });
122
+ const sortDirection = input.sortDir === "asc" ? 1 : -1;
123
+ filtered.sort((a, b) => {
124
+ const compare = compareEnvelopes(a, b, input.sortBy);
125
+ if (compare !== 0)
126
+ return compare * sortDirection;
127
+ const updatedDelta = (b.updatedAt?.getTime() ?? 0) - (a.updatedAt?.getTime() ?? 0);
128
+ if (updatedDelta !== 0)
129
+ return updatedDelta;
130
+ return (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0);
131
+ });
132
+ const page = filtered.slice(input.offset, input.offset + input.limit);
133
+ const envelopeIds = page.map((envelope) => envelope.id);
134
+ const components = needsComponents
135
+ ? envelopeIds.flatMap((id) => allComponentsByEnvelope.get(id) ?? [])
136
+ : envelopeIds.length > 0
137
+ ? (await db
138
+ .select()
139
+ .from(tripComponents)
140
+ .where(inArray(tripComponents.envelopeId, envelopeIds))
141
+ .orderBy(asc(tripComponents.sequence), asc(tripComponents.createdAt)))
142
+ : [];
143
+ const componentsByEnvelope = new Map();
144
+ for (const component of components) {
145
+ const bucket = componentsByEnvelope.get(component.envelopeId) ?? [];
146
+ bucket.push(component);
147
+ componentsByEnvelope.set(component.envelopeId, bucket);
148
+ }
149
+ return {
150
+ data: page.map((envelope) => ({
151
+ envelope,
152
+ components: componentsByEnvelope.get(envelope.id) ?? [],
153
+ })),
154
+ total: filtered.length,
155
+ limit: input.limit,
156
+ offset: input.offset,
157
+ };
158
+ }
159
+ function parseDateMs(value) {
160
+ if (!value)
161
+ return null;
162
+ const ts = new Date(value).getTime();
163
+ return Number.isFinite(ts) ? ts : null;
164
+ }
165
+ function parseEndDateMs(value) {
166
+ if (!value)
167
+ return null;
168
+ // Date-only `YYYY-MM-DD` strings should match the whole day. Use the end
169
+ // of that day in UTC so callers comparing `createdAt <= createdTo` get the
170
+ // intuitive inclusive behaviour.
171
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
172
+ const ts = new Date(`${value}T23:59:59.999Z`).getTime();
173
+ return Number.isFinite(ts) ? ts : null;
174
+ }
175
+ const ts = new Date(value).getTime();
176
+ return Number.isFinite(ts) ? ts : null;
177
+ }
178
+ export async function updateTrip(db, envelopeId, input) {
179
+ if (input.travelerParty !== undefined) {
180
+ assertTripTravelerPartyComplete(input.travelerParty, "Trip update");
181
+ }
182
+ const updates = {
183
+ updatedAt: new Date(),
184
+ };
185
+ if (input.title !== undefined)
186
+ updates.title = input.title;
187
+ if (input.description !== undefined)
188
+ updates.description = input.description;
189
+ if (input.travelerParty !== undefined)
190
+ updates.travelerParty = input.travelerParty;
191
+ if (input.constraints !== undefined)
192
+ updates.constraints = input.constraints;
193
+ if (input.status !== undefined)
194
+ updates.status = input.status;
195
+ if (input.updatedBy !== undefined)
196
+ updates.updatedBy = input.updatedBy;
197
+ const [row] = (await db
198
+ .update(tripEnvelopes)
199
+ .set(updates)
200
+ .where(eq(tripEnvelopes.id, envelopeId))
201
+ .returning());
202
+ return row ?? null;
203
+ }
204
+ export async function addComponent(db, input) {
205
+ const values = {
206
+ envelopeId: input.envelopeId,
207
+ sequence: input.sequence,
208
+ kind: input.kind,
209
+ description: input.description,
210
+ entityModule: input.catalogRef?.entityModule,
211
+ entityId: input.catalogRef?.entityId,
212
+ sourceKind: input.catalogRef?.sourceKind,
213
+ sourceConnectionId: input.catalogRef?.sourceConnectionId,
214
+ sourceRef: input.catalogRef?.sourceRef,
215
+ componentCurrency: input.estimatedPricing?.currency,
216
+ componentSubtotalAmountCents: input.estimatedPricing?.subtotalAmountCents,
217
+ componentTaxAmountCents: input.estimatedPricing?.taxAmountCents,
218
+ componentTotalAmountCents: input.estimatedPricing?.totalAmountCents,
219
+ pricingSnapshot: input.estimatedPricing,
220
+ metadata: input.metadata,
221
+ };
222
+ const [component] = (await db
223
+ .insert(tripComponents)
224
+ .values(values)
225
+ .returning());
226
+ if (!component)
227
+ throw new Error("addComponent: insert returned no component");
228
+ await createComponentEvent(db, {
229
+ envelopeId: component.envelopeId,
230
+ componentId: component.id,
231
+ eventType: "created",
232
+ toStatus: component.status,
233
+ payload: {},
234
+ });
235
+ return component;
236
+ }
237
+ export async function updateComponent(db, componentId, input) {
238
+ const existing = await getTripComponentOrThrow(db, componentId);
239
+ assertTripComponentCanBeUpdated(existing, input);
240
+ const updates = {
241
+ updatedAt: new Date(),
242
+ ...toCatalogRefPatch(input.catalogRef),
243
+ };
244
+ if (input.sequence !== undefined)
245
+ updates.sequence = input.sequence;
246
+ if (input.status !== undefined)
247
+ updates.status = input.status;
248
+ if (input.description !== undefined)
249
+ updates.description = input.description;
250
+ if (input.metadata !== undefined)
251
+ updates.metadata = input.metadata;
252
+ if (input.warningCodes !== undefined)
253
+ updates.warningCodes = input.warningCodes;
254
+ const [component] = (await db
255
+ .update(tripComponents)
256
+ .set(updates)
257
+ .where(eq(tripComponents.id, componentId))
258
+ .returning());
259
+ if (!component)
260
+ return null;
261
+ await createComponentEvent(db, {
262
+ envelopeId: component.envelopeId,
263
+ componentId: component.id,
264
+ eventType: input.status ? statusToEventType(input.status) : "updated",
265
+ fromStatus: existing.status,
266
+ toStatus: component.status,
267
+ payload: {},
268
+ });
269
+ return component;
270
+ }
271
+ export async function updateComponentRefs(db, componentId, input) {
272
+ const existing = await getTripComponentOrThrow(db, componentId);
273
+ assertTripComponentCanReceiveRefs(existing, input);
274
+ const updates = {
275
+ updatedAt: new Date(),
276
+ };
277
+ if (input.bookingDraftId !== undefined)
278
+ updates.bookingDraftId = input.bookingDraftId;
279
+ if (input.catalogQuoteId !== undefined)
280
+ updates.catalogQuoteId = input.catalogQuoteId;
281
+ if (input.committedRef?.bookingId !== undefined)
282
+ updates.bookingId = input.committedRef.bookingId;
283
+ if (input.committedRef?.bookingGroupId !== undefined) {
284
+ updates.bookingGroupId = input.committedRef.bookingGroupId;
285
+ }
286
+ if (input.committedRef?.orderId !== undefined)
287
+ updates.orderId = input.committedRef.orderId;
288
+ if (input.committedRef?.paymentSessionId !== undefined) {
289
+ updates.paymentSessionId = input.committedRef.paymentSessionId;
290
+ }
291
+ if (input.committedRef?.providerRef !== undefined) {
292
+ updates.providerRef = input.committedRef.providerRef;
293
+ }
294
+ if (input.committedRef?.supplierRef !== undefined) {
295
+ updates.supplierRef = input.committedRef.supplierRef;
296
+ }
297
+ const [component] = (await db
298
+ .update(tripComponents)
299
+ .set(updates)
300
+ .where(eq(tripComponents.id, componentId))
301
+ .returning());
302
+ return component ?? null;
303
+ }
304
+ export async function removeComponent(db, componentId) {
305
+ return updateComponent(db, componentId, { status: "removed" });
306
+ }
307
+ export async function reorderComponents(db, input) {
308
+ const rows = (await db
309
+ .select()
310
+ .from(tripComponents)
311
+ .where(inArray(tripComponents.id, input.componentIds)));
312
+ const found = new Set(rows.map((row) => row.id));
313
+ const missing = input.componentIds.filter((id) => !found.has(id));
314
+ if (missing.length > 0) {
315
+ throw new TripsInvariantError(`Cannot reorder missing trip components: ${missing.join(", ")}`);
316
+ }
317
+ for (const row of rows) {
318
+ if (row.envelopeId !== input.envelopeId) {
319
+ throw new TripsInvariantError(`Trip component ${row.id} does not belong to envelope ${input.envelopeId}`);
320
+ }
321
+ if (row.status === "removed") {
322
+ throw new TripsInvariantError(`Trip component ${row.id} is removed`);
323
+ }
324
+ }
325
+ const updated = [];
326
+ for (const [sequence, componentId] of input.componentIds.entries()) {
327
+ const [row] = (await db
328
+ .update(tripComponents)
329
+ .set({ sequence, updatedAt: new Date() })
330
+ .where(eq(tripComponents.id, componentId))
331
+ .returning());
332
+ if (row)
333
+ updated.push(row);
334
+ }
335
+ return updated.sort((a, b) => a.sequence - b.sequence);
336
+ }
337
+ function compareEnvelopes(a, b, field) {
338
+ switch (field) {
339
+ case "createdAt":
340
+ return (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0);
341
+ case "status":
342
+ return a.status.localeCompare(b.status);
343
+ case "total":
344
+ return (a.aggregateTotalAmountCents ?? 0) - (b.aggregateTotalAmountCents ?? 0);
345
+ default:
346
+ return (a.updatedAt?.getTime() ?? 0) - (b.updatedAt?.getTime() ?? 0);
347
+ }
348
+ }
349
+ function toCatalogRefPatch(input) {
350
+ if (input === undefined)
351
+ return {};
352
+ if (input === null) {
353
+ return {
354
+ entityModule: null,
355
+ entityId: null,
356
+ sourceKind: null,
357
+ sourceConnectionId: null,
358
+ sourceRef: null,
359
+ };
360
+ }
361
+ return {
362
+ entityModule: input.entityModule,
363
+ entityId: input.entityId,
364
+ sourceKind: input.sourceKind,
365
+ sourceConnectionId: input.sourceConnectionId ?? null,
366
+ sourceRef: input.sourceRef ?? null,
367
+ };
368
+ }
369
+ async function getTripComponentOrThrow(db, id) {
370
+ const [component] = (await db
371
+ .select()
372
+ .from(tripComponents)
373
+ .where(eq(tripComponents.id, id))
374
+ .limit(1));
375
+ if (!component)
376
+ throw new TripsInvariantError(`Trip component ${id} was not found`);
377
+ return component;
378
+ }