tripit 0.1.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/src/tripit.ts ADDED
@@ -0,0 +1,1295 @@
1
+ import fetch from "node-fetch";
2
+ import { authenticate } from "./auth";
3
+ import {
4
+ ACTIVITY_FIELD_ORDER,
5
+ ADDRESS_FIELD_ORDER,
6
+ AIR_FIELD_ORDER,
7
+ AIR_SEGMENT_FIELD_ORDER,
8
+ IMAGE_FIELD_ORDER,
9
+ LODGING_FIELD_ORDER,
10
+ TRANSPORT_FIELD_ORDER,
11
+ TRANSPORT_SEGMENT_FIELD_ORDER,
12
+ TRIP_UPDATE_FIELD_ORDER,
13
+ } from "./constants";
14
+ import type {
15
+ ActivityResponse,
16
+ AirResponse,
17
+ AirSegment,
18
+ DeleteResponse,
19
+ LodgingResponse,
20
+ OneOrMany,
21
+ TransportResponse,
22
+ TransportSegment,
23
+ TripGetResponse,
24
+ TripImage,
25
+ TripItConfig,
26
+ TripListResponse,
27
+ TripMutationResponse,
28
+ } from "./types";
29
+ import {
30
+ clean,
31
+ normalizeArray,
32
+ normalizeTime,
33
+ orderObjectByKeys,
34
+ toBoolean,
35
+ } from "./utils";
36
+
37
+ export class TripIt {
38
+ private config: TripItConfig;
39
+ private accessToken: string | null = null;
40
+
41
+ constructor(config: TripItConfig) {
42
+ this.config = config;
43
+ }
44
+
45
+ async authenticate(): Promise<string> {
46
+ this.accessToken = await authenticate(this.config);
47
+ return this.accessToken;
48
+ }
49
+
50
+ getAccessToken(): string {
51
+ if (!this.accessToken) {
52
+ throw new Error("Not authenticated. Call authenticate() first.");
53
+ }
54
+ return this.accessToken;
55
+ }
56
+
57
+ // === API Helper ===
58
+
59
+ private endpoint(version: "v1" | "v2", path: string): string {
60
+ return `https://api.tripit.com/${version}/${path}/format/json`;
61
+ }
62
+
63
+ private identifierEndpoint(
64
+ action: string,
65
+ resource: string,
66
+ id: string,
67
+ ): string {
68
+ const isUuid = id.includes("-");
69
+ return isUuid
70
+ ? this.endpoint("v2", `${action}/${resource}/uuid/${id}`)
71
+ : this.endpoint("v1", `${action}/${resource}/id/${id}`);
72
+ }
73
+
74
+ private async apiGet<TResponse>(path: string): Promise<TResponse> {
75
+ const token = this.getAccessToken();
76
+ const res = await fetch(path, {
77
+ headers: { Authorization: `Bearer ${token}` },
78
+ });
79
+ const text = await res.text();
80
+ if (!res.ok) throw new Error(`API error (${res.status}): ${text}`);
81
+ return JSON.parse(text) as TResponse;
82
+ }
83
+
84
+ private async apiPost<TResponse>(
85
+ path: string,
86
+ payload: Record<string, unknown>,
87
+ ): Promise<TResponse> {
88
+ const token = this.getAccessToken();
89
+ const res = await fetch(path, {
90
+ method: "POST",
91
+ headers: {
92
+ Authorization: `Bearer ${token}`,
93
+ "Content-Type": "application/x-www-form-urlencoded",
94
+ },
95
+ body: new URLSearchParams({ json: JSON.stringify(payload) }).toString(),
96
+ });
97
+ const text = await res.text();
98
+ if (!res.ok) throw new Error(`API error (${res.status}): ${text}`);
99
+ return JSON.parse(text) as TResponse;
100
+ }
101
+
102
+ // === Trips ===
103
+
104
+ async listTrips(
105
+ pageSize = 100,
106
+ pageNum = 1,
107
+ past = false,
108
+ ): Promise<TripListResponse> {
109
+ const past_ = past ? "&past=true" : "";
110
+ const url = `https://api.tripit.com/v2/list/trip?format=json&page_size=${pageSize}&page_num=${pageNum}${past_}`;
111
+ return this.apiGet<TripListResponse>(url);
112
+ }
113
+
114
+ async getTrip(id: string): Promise<TripGetResponse> {
115
+ const url = this.identifierEndpoint("get", "trip", id).replace(
116
+ "/format/json",
117
+ "/include_objects/true/format/json",
118
+ );
119
+ const data = await this.apiGet<TripGetResponse & { Profile?: unknown }>(
120
+ url,
121
+ );
122
+ if (data.Profile) delete data.Profile;
123
+ return data;
124
+ }
125
+
126
+ async createTrip(params: {
127
+ displayName: string;
128
+ startDate: string;
129
+ endDate: string;
130
+ primaryLocation?: string;
131
+ }): Promise<TripMutationResponse> {
132
+ return this.apiPost<TripMutationResponse>(
133
+ this.endpoint("v2", "create/trip"),
134
+ {
135
+ Trip: clean({
136
+ display_name: params.displayName,
137
+ start_date: params.startDate,
138
+ end_date: params.endDate,
139
+ primary_location: params.primaryLocation,
140
+ }),
141
+ },
142
+ );
143
+ }
144
+
145
+ async updateTrip(params: {
146
+ id?: string;
147
+ uuid?: string;
148
+ displayName?: string;
149
+ startDate?: string;
150
+ endDate?: string;
151
+ primaryLocation?: string;
152
+ description?: string;
153
+ isPrivate?: boolean;
154
+ isExpensible?: boolean;
155
+ tripPurpose?: string;
156
+ }): Promise<TripMutationResponse> {
157
+ const identifier = params.uuid || params.id;
158
+ if (!identifier) {
159
+ throw new Error("Either uuid or id parameter is required");
160
+ }
161
+
162
+ const existingTripResponse = await this.apiGet<TripMutationResponse>(
163
+ this.identifierEndpoint("get", "trip", identifier),
164
+ );
165
+ const existingTrip = existingTripResponse.Trip;
166
+ if (!existingTrip?.uuid) {
167
+ throw new Error(`Trip with identifier ${identifier} not found`);
168
+ }
169
+
170
+ const tripData: Record<string, unknown> = {
171
+ primary_location: params.primaryLocation ?? existingTrip.primary_location,
172
+ is_private:
173
+ params.isPrivate !== undefined
174
+ ? params.isPrivate
175
+ : toBoolean(existingTrip.is_private),
176
+ start_date: params.startDate ?? existingTrip.start_date,
177
+ display_name: params.displayName ?? existingTrip.display_name,
178
+ is_expensible:
179
+ params.isExpensible !== undefined
180
+ ? params.isExpensible
181
+ : toBoolean(existingTrip.is_expensible),
182
+ end_date: params.endDate ?? existingTrip.end_date,
183
+ };
184
+
185
+ const purposeTypeCode =
186
+ params.tripPurpose ?? existingTrip.TripPurposes?.purpose_type_code;
187
+ if (purposeTypeCode) {
188
+ tripData.TripPurposes = { purpose_type_code: purposeTypeCode };
189
+ }
190
+
191
+ const description = params.description ?? existingTrip.description;
192
+ if (description) {
193
+ tripData.description = description;
194
+ }
195
+
196
+ const orderedTripData = orderObjectByKeys(
197
+ clean(tripData),
198
+ TRIP_UPDATE_FIELD_ORDER,
199
+ );
200
+
201
+ return this.apiPost(
202
+ this.endpoint("v2", `replace/trip/uuid/${existingTrip.uuid}`),
203
+ {
204
+ Trip: orderedTripData,
205
+ },
206
+ );
207
+ }
208
+
209
+ async deleteTrip(id: string): Promise<DeleteResponse> {
210
+ return this.apiGet<DeleteResponse>(
211
+ this.identifierEndpoint("delete", "trip", id),
212
+ );
213
+ }
214
+
215
+ private async buildImageAttachment(params: {
216
+ filePath: string;
217
+ caption?: string;
218
+ mimeType?: string;
219
+ }): Promise<TripImage> {
220
+ const file = Bun.file(params.filePath);
221
+ const exists = await file.exists();
222
+ if (!exists) {
223
+ throw new Error(`File not found: ${params.filePath}`);
224
+ }
225
+
226
+ const buffer = await file.arrayBuffer();
227
+ const base64Content = Buffer.from(buffer).toString("base64");
228
+ const mimeType = params.mimeType || file.type || "application/octet-stream";
229
+ const caption =
230
+ params.caption || params.filePath.split("/").pop() || "document";
231
+
232
+ return orderObjectByKeys(
233
+ {
234
+ caption,
235
+ ImageData: {
236
+ content: base64Content,
237
+ mime_type: mimeType,
238
+ },
239
+ },
240
+ IMAGE_FIELD_ORDER,
241
+ ) as unknown as TripImage;
242
+ }
243
+
244
+ private mergeImages(
245
+ existing: OneOrMany<TripImage> | undefined,
246
+ newImage: TripImage,
247
+ ): OneOrMany<TripImage> {
248
+ const existingImages = normalizeArray(existing) as TripImage[];
249
+ return existingImages.length > 0 ? [...existingImages, newImage] : newImage;
250
+ }
251
+
252
+ private setSegmentUuidIfNeeded(
253
+ images: OneOrMany<TripImage>,
254
+ segmentUuid: string | undefined,
255
+ ): OneOrMany<TripImage> {
256
+ if (!segmentUuid) return images;
257
+ const mapImage = (img: TripImage): TripImage => {
258
+ if (!img.ImageData || img.segment_uuid) return img;
259
+ return orderObjectByKeys(
260
+ {
261
+ ...img,
262
+ segment_uuid: segmentUuid,
263
+ },
264
+ IMAGE_FIELD_ORDER,
265
+ ) as unknown as TripImage;
266
+ };
267
+
268
+ return Array.isArray(images) ? images.map(mapImage) : mapImage(images);
269
+ }
270
+
271
+ async detectObjectType(
272
+ id: string,
273
+ ): Promise<"lodging" | "activity" | "air" | "transport"> {
274
+ const types = ["lodging", "activity", "air", "transport"] as const;
275
+ const getters: Record<string, (id: string) => Promise<unknown>> = {
276
+ lodging: (id) => this.getHotel(id),
277
+ activity: (id) => this.getActivity(id),
278
+ air: (id) => this.getFlight(id),
279
+ transport: (id) => this.getTransport(id),
280
+ };
281
+ for (const type of types) {
282
+ try {
283
+ await getters[type]!(id);
284
+ return type;
285
+ } catch {}
286
+ }
287
+ throw new Error(
288
+ `Could not find object with identifier ${id} as any supported type`,
289
+ );
290
+ }
291
+
292
+ async attachDocument(params: {
293
+ objectType?: "lodging" | "activity" | "air" | "transport";
294
+ objectId: string;
295
+ filePath: string;
296
+ caption?: string;
297
+ mimeType?: string;
298
+ }): Promise<
299
+ LodgingResponse | ActivityResponse | AirResponse | TransportResponse
300
+ > {
301
+ const objectType =
302
+ params.objectType || (await this.detectObjectType(params.objectId));
303
+
304
+ const newImage = await this.buildImageAttachment({
305
+ filePath: params.filePath,
306
+ caption: params.caption,
307
+ mimeType: params.mimeType,
308
+ });
309
+
310
+ if (objectType === "lodging") {
311
+ const existingHotelResponse = await this.getHotel(params.objectId);
312
+ const existingHotel = existingHotelResponse.LodgingObject;
313
+ if (!existingHotel?.uuid) {
314
+ throw new Error(`Hotel with identifier ${params.objectId} not found`);
315
+ }
316
+
317
+ return this.updateHotel({
318
+ uuid: existingHotel.uuid,
319
+ Image: this.mergeImages(existingHotel.Image, newImage),
320
+ });
321
+ }
322
+
323
+ if (objectType === "activity") {
324
+ const existingActivityResponse = await this.getActivity(params.objectId);
325
+ const existingActivity = existingActivityResponse.ActivityObject;
326
+ if (!existingActivity?.uuid) {
327
+ throw new Error(
328
+ `Activity with identifier ${params.objectId} not found`,
329
+ );
330
+ }
331
+
332
+ return this.updateActivity({
333
+ uuid: existingActivity.uuid,
334
+ Image: this.mergeImages(existingActivity.Image, newImage),
335
+ });
336
+ }
337
+
338
+ if (objectType === "air") {
339
+ const existingFlightResponse = await this.getFlight(params.objectId);
340
+ const existingFlight = existingFlightResponse.AirObject;
341
+ if (!existingFlight?.uuid) {
342
+ throw new Error(`Flight with identifier ${params.objectId} not found`);
343
+ }
344
+
345
+ const segment = (
346
+ normalizeArray(existingFlight.Segment) as AirSegment[]
347
+ )[0];
348
+ const imageField = this.setSegmentUuidIfNeeded(
349
+ this.mergeImages(existingFlight.Image, newImage),
350
+ segment?.uuid,
351
+ );
352
+
353
+ return this.updateFlight({
354
+ uuid: existingFlight.uuid,
355
+ Image: imageField,
356
+ });
357
+ }
358
+
359
+ const existingTransportResponse = await this.getTransport(params.objectId);
360
+ const existingTransport = existingTransportResponse.TransportObject;
361
+ if (!existingTransport?.uuid) {
362
+ throw new Error(`Transport with identifier ${params.objectId} not found`);
363
+ }
364
+
365
+ const segment = (
366
+ normalizeArray(existingTransport.Segment) as TransportSegment[]
367
+ )[0];
368
+ const imageField = this.setSegmentUuidIfNeeded(
369
+ this.mergeImages(existingTransport.Image, newImage),
370
+ segment?.uuid,
371
+ );
372
+
373
+ return this.updateTransport({
374
+ uuid: existingTransport.uuid,
375
+ Image: imageField,
376
+ });
377
+ }
378
+
379
+ async removeDocument(params: {
380
+ objectType?: "lodging" | "activity" | "air" | "transport";
381
+ objectId: string;
382
+ imageUuid?: string;
383
+ imageUrl?: string;
384
+ caption?: string;
385
+ index?: number;
386
+ removeAll?: boolean;
387
+ }): Promise<
388
+ LodgingResponse | ActivityResponse | AirResponse | TransportResponse
389
+ > {
390
+ const objectType =
391
+ params.objectType || (await this.detectObjectType(params.objectId));
392
+
393
+ const selectors = [
394
+ Boolean(params.imageUuid),
395
+ Boolean(params.imageUrl),
396
+ Boolean(params.caption),
397
+ params.index !== undefined,
398
+ Boolean(params.removeAll),
399
+ ].filter(Boolean).length;
400
+ if (selectors !== 1) {
401
+ throw new Error(
402
+ "Provide exactly one selector: imageUuid, imageUrl, caption, index, or removeAll",
403
+ );
404
+ }
405
+
406
+ const selectRemainingImages = (
407
+ currentImages: OneOrMany<TripImage> | undefined,
408
+ ): TripImage[] => {
409
+ const images = normalizeArray(currentImages) as TripImage[];
410
+ if (images.length === 0) {
411
+ throw new Error(
412
+ `No documents found on ${objectType} ${params.objectId}`,
413
+ );
414
+ }
415
+
416
+ if (params.removeAll) {
417
+ return [];
418
+ }
419
+
420
+ if (params.imageUuid) {
421
+ const remaining = images.filter(
422
+ (image) => image.uuid !== params.imageUuid,
423
+ );
424
+ if (remaining.length === images.length) {
425
+ throw new Error(
426
+ `No document found with UUID ${params.imageUuid} on ${objectType} ${params.objectId}`,
427
+ );
428
+ }
429
+ return remaining;
430
+ }
431
+
432
+ if (params.imageUrl) {
433
+ const remaining = images.filter(
434
+ (image) => image.url !== params.imageUrl,
435
+ );
436
+ if (remaining.length === images.length) {
437
+ throw new Error(
438
+ `No document found with URL ${params.imageUrl} on ${objectType} ${params.objectId}`,
439
+ );
440
+ }
441
+ return remaining;
442
+ }
443
+
444
+ if (params.caption) {
445
+ const removeIndex = images.findIndex(
446
+ (image) => image.caption === params.caption,
447
+ );
448
+ if (removeIndex < 0) {
449
+ throw new Error(
450
+ `No document found with caption '${params.caption}' on ${objectType} ${params.objectId}`,
451
+ );
452
+ }
453
+ return images.filter((_, index) => index !== removeIndex);
454
+ }
455
+
456
+ const index = (params.index ?? 1) - 1;
457
+ if (index < 0 || index >= images.length) {
458
+ throw new Error(
459
+ `Document index out of range. Expected 1-${images.length}, got ${params.index}`,
460
+ );
461
+ }
462
+ return images.filter((_, i) => i !== index);
463
+ };
464
+
465
+ const toImageField = (
466
+ remainingImages: TripImage[],
467
+ ): OneOrMany<TripImage> | undefined => {
468
+ if (remainingImages.length === 0) return undefined;
469
+ return remainingImages.length === 1
470
+ ? remainingImages[0]
471
+ : remainingImages;
472
+ };
473
+
474
+ if (objectType === "lodging") {
475
+ const existingHotelResponse = await this.getHotel(params.objectId);
476
+ const existingHotel = existingHotelResponse.LodgingObject;
477
+ if (!existingHotel?.uuid) {
478
+ throw new Error(`Hotel with identifier ${params.objectId} not found`);
479
+ }
480
+ const remainingImages = selectRemainingImages(existingHotel.Image);
481
+ const imageField = toImageField(remainingImages);
482
+ return this.updateHotel(
483
+ imageField
484
+ ? { uuid: existingHotel.uuid, Image: imageField }
485
+ : { uuid: existingHotel.uuid },
486
+ );
487
+ }
488
+
489
+ if (objectType === "activity") {
490
+ const existingActivityResponse = await this.getActivity(params.objectId);
491
+ const existingActivity = existingActivityResponse.ActivityObject;
492
+ if (!existingActivity?.uuid) {
493
+ throw new Error(
494
+ `Activity with identifier ${params.objectId} not found`,
495
+ );
496
+ }
497
+ const remainingImages = selectRemainingImages(existingActivity.Image);
498
+ const imageField = toImageField(remainingImages);
499
+ return this.updateActivity(
500
+ imageField
501
+ ? { uuid: existingActivity.uuid, Image: imageField }
502
+ : { uuid: existingActivity.uuid },
503
+ );
504
+ }
505
+
506
+ if (objectType === "air") {
507
+ const existingFlightResponse = await this.getFlight(params.objectId);
508
+ const existingFlight = existingFlightResponse.AirObject;
509
+ if (!existingFlight?.uuid) {
510
+ throw new Error(`Flight with identifier ${params.objectId} not found`);
511
+ }
512
+ const remainingImages = selectRemainingImages(existingFlight.Image);
513
+ const imageField = toImageField(remainingImages);
514
+ return this.updateFlight(
515
+ imageField
516
+ ? { uuid: existingFlight.uuid, Image: imageField }
517
+ : { uuid: existingFlight.uuid },
518
+ );
519
+ }
520
+
521
+ const existingTransportResponse = await this.getTransport(params.objectId);
522
+ const existingTransport = existingTransportResponse.TransportObject;
523
+ if (!existingTransport?.uuid) {
524
+ throw new Error(`Transport with identifier ${params.objectId} not found`);
525
+ }
526
+ const remainingImages = selectRemainingImages(existingTransport.Image);
527
+ const imageField = toImageField(remainingImages);
528
+ return this.updateTransport(
529
+ imageField
530
+ ? { uuid: existingTransport.uuid, Image: imageField }
531
+ : { uuid: existingTransport.uuid },
532
+ );
533
+ }
534
+
535
+ // === Hotels (Lodging) ===
536
+
537
+ async getHotel(id: string): Promise<LodgingResponse> {
538
+ return this.apiGet<LodgingResponse>(
539
+ this.identifierEndpoint("get", "lodging", id),
540
+ );
541
+ }
542
+
543
+ async deleteHotel(id: string): Promise<DeleteResponse> {
544
+ return this.apiGet<DeleteResponse>(
545
+ this.identifierEndpoint("delete", "lodging", id),
546
+ );
547
+ }
548
+
549
+ async createHotel(params: {
550
+ tripId: string;
551
+ hotelName: string;
552
+ checkInDate: string;
553
+ checkInTime: string;
554
+ checkOutDate: string;
555
+ checkOutTime: string;
556
+ timezone: string;
557
+ street: string;
558
+ city: string;
559
+ country: string;
560
+ state?: string;
561
+ zip?: string;
562
+ supplierConfNum?: string;
563
+ bookingRate?: string;
564
+ notes?: string;
565
+ totalCost?: string;
566
+ }): Promise<LodgingResponse> {
567
+ const tripKey = params.tripId.includes("-") ? "trip_uuid" : "trip_id";
568
+ return this.apiPost<LodgingResponse>(
569
+ this.endpoint("v2", "create/lodging"),
570
+ {
571
+ LodgingObject: clean({
572
+ [tripKey]: params.tripId,
573
+ supplier_name: params.hotelName,
574
+ supplier_conf_num: params.supplierConfNum,
575
+ booking_rate: params.bookingRate,
576
+ notes: params.notes,
577
+ total_cost: params.totalCost,
578
+ StartDateTime: {
579
+ date: params.checkInDate,
580
+ time: normalizeTime(params.checkInTime),
581
+ timezone: params.timezone,
582
+ },
583
+ EndDateTime: {
584
+ date: params.checkOutDate,
585
+ time: normalizeTime(params.checkOutTime),
586
+ timezone: params.timezone,
587
+ },
588
+ Address: clean({
589
+ address: params.street,
590
+ city: params.city,
591
+ state: params.state,
592
+ zip: params.zip,
593
+ country: params.country,
594
+ }),
595
+ }),
596
+ },
597
+ );
598
+ }
599
+
600
+ async updateHotel(params: {
601
+ id?: string;
602
+ uuid?: string;
603
+ tripId?: string;
604
+ hotelName?: string;
605
+ checkInDate?: string;
606
+ checkInTime?: string;
607
+ checkOutDate?: string;
608
+ checkOutTime?: string;
609
+ timezone?: string;
610
+ street?: string;
611
+ city?: string;
612
+ state?: string;
613
+ zip?: string;
614
+ country?: string;
615
+ supplierConfNum?: string;
616
+ bookingRate?: string;
617
+ notes?: string;
618
+ totalCost?: string;
619
+ Image?: OneOrMany<TripImage>;
620
+ }): Promise<LodgingResponse> {
621
+ const identifier = params.uuid || params.id;
622
+ if (!identifier) {
623
+ throw new Error("Either uuid or id parameter is required");
624
+ }
625
+
626
+ const existingHotelResponse = await this.getHotel(identifier);
627
+ const existingHotel = existingHotelResponse.LodgingObject;
628
+ if (!existingHotel?.uuid) {
629
+ throw new Error(`Hotel with identifier ${identifier} not found`);
630
+ }
631
+
632
+ const tripId =
633
+ params.tripId || existingHotel.trip_uuid || existingHotel.trip_id;
634
+ const tripKey = tripId
635
+ ? tripId.includes("-")
636
+ ? "trip_uuid"
637
+ : "trip_id"
638
+ : undefined;
639
+
640
+ const startTimezone =
641
+ params.timezone ||
642
+ existingHotel.StartDateTime?.timezone ||
643
+ existingHotel.EndDateTime?.timezone;
644
+ const endTimezone =
645
+ params.timezone ||
646
+ existingHotel.EndDateTime?.timezone ||
647
+ existingHotel.StartDateTime?.timezone;
648
+
649
+ const lodgingObject: Record<string, unknown> = {
650
+ uuid: existingHotel.uuid,
651
+ is_client_traveler: toBoolean(existingHotel.is_client_traveler),
652
+ display_name: existingHotel.display_name,
653
+ supplier_name:
654
+ params.hotelName ??
655
+ existingHotel.supplier_name ??
656
+ existingHotel.display_name,
657
+ supplier_conf_num:
658
+ params.supplierConfNum ?? existingHotel.supplier_conf_num,
659
+ booking_rate: params.bookingRate ?? existingHotel.booking_rate,
660
+ is_purchased: toBoolean(existingHotel.is_purchased),
661
+ notes: params.notes ?? existingHotel.notes,
662
+ total_cost: params.totalCost ?? existingHotel.total_cost,
663
+ StartDateTime: {
664
+ date: params.checkInDate ?? existingHotel.StartDateTime?.date,
665
+ time:
666
+ normalizeTime(params.checkInTime || "") ??
667
+ existingHotel.StartDateTime?.time,
668
+ timezone: startTimezone,
669
+ },
670
+ EndDateTime: {
671
+ date: params.checkOutDate ?? existingHotel.EndDateTime?.date,
672
+ time:
673
+ normalizeTime(params.checkOutTime || "") ??
674
+ existingHotel.EndDateTime?.time,
675
+ timezone: endTimezone,
676
+ },
677
+ Address: orderObjectByKeys(
678
+ clean({
679
+ address: params.street ?? existingHotel.Address?.address,
680
+ city: params.city ?? existingHotel.Address?.city,
681
+ state: params.state ?? existingHotel.Address?.state,
682
+ zip: params.zip ?? existingHotel.Address?.zip,
683
+ country: params.country ?? existingHotel.Address?.country,
684
+ }),
685
+ ADDRESS_FIELD_ORDER,
686
+ ),
687
+ };
688
+
689
+ if (tripKey) {
690
+ lodgingObject[tripKey] = tripId;
691
+ }
692
+
693
+ if (params.Image) {
694
+ if (Array.isArray(params.Image)) {
695
+ lodgingObject.Image = params.Image.map((image) => {
696
+ if (!image.ImageData) return image;
697
+ return orderObjectByKeys(image, IMAGE_FIELD_ORDER);
698
+ });
699
+ } else {
700
+ lodgingObject.Image = params.Image.ImageData
701
+ ? orderObjectByKeys(params.Image, IMAGE_FIELD_ORDER)
702
+ : params.Image;
703
+ }
704
+ }
705
+
706
+ return this.apiPost(
707
+ this.endpoint("v2", `replace/lodging/uuid/${existingHotel.uuid}`),
708
+ {
709
+ LodgingObject: orderObjectByKeys(
710
+ clean(lodgingObject),
711
+ LODGING_FIELD_ORDER,
712
+ ),
713
+ },
714
+ );
715
+ }
716
+
717
+ // === Flights (Air) ===
718
+
719
+ async getFlight(id: string): Promise<AirResponse> {
720
+ return this.apiGet<AirResponse>(this.identifierEndpoint("get", "air", id));
721
+ }
722
+
723
+ async deleteFlight(id: string): Promise<DeleteResponse> {
724
+ return this.apiGet<DeleteResponse>(
725
+ this.identifierEndpoint("delete", "air", id),
726
+ );
727
+ }
728
+
729
+ async createFlight(params: {
730
+ tripId: string;
731
+ displayName: string;
732
+ supplierName: string;
733
+ segments: Array<{
734
+ startDate: string;
735
+ startTime: string;
736
+ startTimezone: string;
737
+ endDate: string;
738
+ endTime: string;
739
+ endTimezone: string;
740
+ startCityName: string;
741
+ startCountryCode: string;
742
+ endCityName: string;
743
+ endCountryCode: string;
744
+ marketingAirline: string;
745
+ marketingFlightNumber: string;
746
+ aircraft?: string;
747
+ serviceClass?: string;
748
+ }>;
749
+ supplierConfNum?: string;
750
+ notes?: string;
751
+ totalCost?: string;
752
+ }): Promise<AirResponse> {
753
+ const segments = params.segments.map((s) =>
754
+ clean({
755
+ StartDateTime: {
756
+ date: s.startDate,
757
+ time: normalizeTime(s.startTime),
758
+ timezone: s.startTimezone,
759
+ },
760
+ EndDateTime: {
761
+ date: s.endDate,
762
+ time: normalizeTime(s.endTime),
763
+ timezone: s.endTimezone,
764
+ },
765
+ start_city_name: s.startCityName,
766
+ start_country_code: s.startCountryCode,
767
+ end_city_name: s.endCityName,
768
+ end_country_code: s.endCountryCode,
769
+ marketing_airline: s.marketingAirline,
770
+ marketing_flight_number: s.marketingFlightNumber,
771
+ aircraft: s.aircraft,
772
+ service_class: s.serviceClass,
773
+ }),
774
+ );
775
+ return this.apiPost<AirResponse>(this.endpoint("v2", "create/air"), {
776
+ AirObject: clean({
777
+ trip_uuid: params.tripId,
778
+ display_name: params.displayName,
779
+ supplier_name: params.supplierName,
780
+ supplier_conf_num: params.supplierConfNum,
781
+ notes: params.notes,
782
+ total_cost: params.totalCost,
783
+ Segment: segments,
784
+ }),
785
+ });
786
+ }
787
+
788
+ async updateFlight(params: {
789
+ id?: string;
790
+ uuid?: string;
791
+ tripId?: string;
792
+ displayName?: string;
793
+ supplierName?: string;
794
+ supplierConfNum?: string;
795
+ notes?: string;
796
+ totalCost?: string;
797
+ Image?: OneOrMany<TripImage>;
798
+ segment?: {
799
+ startDate?: string;
800
+ startTime?: string;
801
+ startTimezone?: string;
802
+ endDate?: string;
803
+ endTime?: string;
804
+ endTimezone?: string;
805
+ startCityName?: string;
806
+ startCountryCode?: string;
807
+ endCityName?: string;
808
+ endCountryCode?: string;
809
+ marketingAirline?: string;
810
+ marketingFlightNumber?: string;
811
+ aircraft?: string;
812
+ serviceClass?: string;
813
+ };
814
+ }): Promise<AirResponse> {
815
+ const identifier = params.uuid || params.id;
816
+ if (!identifier) {
817
+ throw new Error("Either uuid or id parameter is required");
818
+ }
819
+
820
+ const existingFlightResponse = await this.getFlight(identifier);
821
+ const existingFlight = existingFlightResponse.AirObject;
822
+ if (!existingFlight?.uuid) {
823
+ throw new Error(`Flight with identifier ${identifier} not found`);
824
+ }
825
+
826
+ const existingSegment = (
827
+ normalizeArray(existingFlight.Segment) as AirSegment[]
828
+ )[0];
829
+ if (!existingSegment) {
830
+ throw new Error(`Flight with identifier ${identifier} has no segment`);
831
+ }
832
+
833
+ const tripId =
834
+ params.tripId || existingFlight.trip_uuid || existingFlight.trip_id;
835
+ const tripKey = tripId
836
+ ? tripId.includes("-")
837
+ ? "trip_uuid"
838
+ : "trip_id"
839
+ : undefined;
840
+
841
+ const segment = {
842
+ uuid: existingSegment.uuid,
843
+ StartDateTime: {
844
+ date: params.segment?.startDate ?? existingSegment.StartDateTime?.date,
845
+ time:
846
+ normalizeTime(params.segment?.startTime || "") ??
847
+ existingSegment.StartDateTime?.time,
848
+ timezone:
849
+ params.segment?.startTimezone ??
850
+ existingSegment.StartDateTime?.timezone,
851
+ },
852
+ EndDateTime: {
853
+ date: params.segment?.endDate ?? existingSegment.EndDateTime?.date,
854
+ time:
855
+ normalizeTime(params.segment?.endTime || "") ??
856
+ existingSegment.EndDateTime?.time,
857
+ timezone:
858
+ params.segment?.endTimezone ?? existingSegment.EndDateTime?.timezone,
859
+ },
860
+ start_city_name:
861
+ params.segment?.startCityName ?? existingSegment.start_city_name,
862
+ start_country_code:
863
+ params.segment?.startCountryCode ?? existingSegment.start_country_code,
864
+ end_city_name:
865
+ params.segment?.endCityName ?? existingSegment.end_city_name,
866
+ end_country_code:
867
+ params.segment?.endCountryCode ?? existingSegment.end_country_code,
868
+ marketing_airline:
869
+ params.segment?.marketingAirline ??
870
+ existingSegment.marketing_airline_code ??
871
+ existingSegment.marketing_airline,
872
+ marketing_flight_number:
873
+ params.segment?.marketingFlightNumber ??
874
+ existingSegment.marketing_flight_number,
875
+ aircraft: params.segment?.aircraft ?? existingSegment.aircraft,
876
+ service_class:
877
+ params.segment?.serviceClass ?? existingSegment.service_class,
878
+ };
879
+
880
+ const airObject: Record<string, unknown> = {
881
+ uuid: existingFlight.uuid,
882
+ is_client_traveler: toBoolean(existingFlight.is_client_traveler),
883
+ display_name: params.displayName ?? existingFlight.display_name,
884
+ supplier_name: params.supplierName ?? existingFlight.supplier_name,
885
+ supplier_conf_num:
886
+ params.supplierConfNum ?? existingFlight.supplier_conf_num,
887
+ is_purchased: toBoolean(existingFlight.is_purchased),
888
+ notes: params.notes ?? existingFlight.notes,
889
+ total_cost: params.totalCost ?? existingFlight.total_cost,
890
+ Segment: [orderObjectByKeys(clean(segment), AIR_SEGMENT_FIELD_ORDER)],
891
+ };
892
+
893
+ if (tripKey) {
894
+ airObject[tripKey] = tripId;
895
+ }
896
+
897
+ if (params.Image) {
898
+ if (Array.isArray(params.Image)) {
899
+ airObject.Image = params.Image.map((image) => {
900
+ if (!image.ImageData) return image;
901
+ return orderObjectByKeys(
902
+ {
903
+ ...image,
904
+ segment_uuid: image.segment_uuid ?? existingSegment.uuid,
905
+ },
906
+ IMAGE_FIELD_ORDER,
907
+ );
908
+ });
909
+ } else {
910
+ const image = params.Image;
911
+ airObject.Image = image.ImageData
912
+ ? orderObjectByKeys(
913
+ {
914
+ ...image,
915
+ segment_uuid: image.segment_uuid ?? existingSegment.uuid,
916
+ },
917
+ IMAGE_FIELD_ORDER,
918
+ )
919
+ : image;
920
+ }
921
+ }
922
+
923
+ return this.apiPost(
924
+ this.endpoint("v2", `replace/air/uuid/${existingFlight.uuid}`),
925
+ {
926
+ AirObject: orderObjectByKeys(clean(airObject), AIR_FIELD_ORDER),
927
+ },
928
+ );
929
+ }
930
+
931
+ // === Transport ===
932
+
933
+ async getTransport(id: string): Promise<TransportResponse> {
934
+ return this.apiGet<TransportResponse>(
935
+ this.identifierEndpoint("get", "transport", id),
936
+ );
937
+ }
938
+
939
+ async deleteTransport(id: string): Promise<DeleteResponse> {
940
+ return this.apiGet<DeleteResponse>(
941
+ this.identifierEndpoint("delete", "transport", id),
942
+ );
943
+ }
944
+
945
+ async createTransport(params: {
946
+ tripId: string;
947
+ startDate: string;
948
+ startTime: string;
949
+ endDate: string;
950
+ endTime: string;
951
+ timezone: string;
952
+ startAddress: string;
953
+ endAddress: string;
954
+ startLocationName?: string;
955
+ endLocationName?: string;
956
+ vehicleDescription?: string;
957
+ carrierName?: string;
958
+ confirmationNum?: string;
959
+ displayName?: string;
960
+ }): Promise<TransportResponse> {
961
+ const tripKey = params.tripId.includes("-") ? "trip_uuid" : "trip_id";
962
+ const name =
963
+ params.displayName ||
964
+ [
965
+ params.startLocationName || params.startAddress,
966
+ params.endLocationName || params.endAddress,
967
+ ]
968
+ .filter(Boolean)
969
+ .join(" → ") ||
970
+ "Transport";
971
+ return this.apiPost<TransportResponse>(
972
+ this.endpoint("v2", "create/transport"),
973
+ {
974
+ TransportObject: clean({
975
+ [tripKey]: params.tripId,
976
+ display_name: name,
977
+ Segment: [
978
+ clean({
979
+ StartLocationAddress: { address: params.startAddress },
980
+ StartDateTime: {
981
+ date: params.startDate,
982
+ time: normalizeTime(params.startTime),
983
+ timezone: params.timezone,
984
+ },
985
+ EndLocationAddress: { address: params.endAddress },
986
+ EndDateTime: {
987
+ date: params.endDate,
988
+ time: normalizeTime(params.endTime),
989
+ timezone: params.timezone,
990
+ },
991
+ vehicle_description: params.vehicleDescription,
992
+ start_location_name: params.startLocationName,
993
+ end_location_name: params.endLocationName,
994
+ confirmation_num: params.confirmationNum,
995
+ carrier_name: params.carrierName,
996
+ }),
997
+ ],
998
+ }),
999
+ },
1000
+ );
1001
+ }
1002
+
1003
+ async updateTransport(params: {
1004
+ id?: string;
1005
+ uuid?: string;
1006
+ tripId?: string;
1007
+ startDate?: string;
1008
+ startTime?: string;
1009
+ endDate?: string;
1010
+ endTime?: string;
1011
+ timezone?: string;
1012
+ startAddress?: string;
1013
+ endAddress?: string;
1014
+ startLocationName?: string;
1015
+ endLocationName?: string;
1016
+ vehicleDescription?: string;
1017
+ carrierName?: string;
1018
+ confirmationNum?: string;
1019
+ displayName?: string;
1020
+ Image?: OneOrMany<TripImage>;
1021
+ }): Promise<TransportResponse> {
1022
+ const identifier = params.uuid || params.id;
1023
+ if (!identifier) {
1024
+ throw new Error("Either uuid or id parameter is required");
1025
+ }
1026
+
1027
+ const existingTransportResponse = await this.getTransport(identifier);
1028
+ const existingTransport = existingTransportResponse.TransportObject;
1029
+ if (!existingTransport?.uuid) {
1030
+ throw new Error(`Transport with identifier ${identifier} not found`);
1031
+ }
1032
+
1033
+ const existingSegment = (
1034
+ normalizeArray(existingTransport.Segment) as TransportSegment[]
1035
+ )[0];
1036
+ if (!existingSegment) {
1037
+ throw new Error(`Transport with identifier ${identifier} has no segment`);
1038
+ }
1039
+
1040
+ const tripId =
1041
+ params.tripId || existingTransport.trip_uuid || existingTransport.trip_id;
1042
+ const tripKey = tripId
1043
+ ? tripId.includes("-")
1044
+ ? "trip_uuid"
1045
+ : "trip_id"
1046
+ : undefined;
1047
+
1048
+ const segment = {
1049
+ uuid: existingSegment.uuid,
1050
+ StartLocationAddress: {
1051
+ address:
1052
+ params.startAddress ?? existingSegment.StartLocationAddress?.address,
1053
+ },
1054
+ StartDateTime: {
1055
+ date: params.startDate ?? existingSegment.StartDateTime?.date,
1056
+ time:
1057
+ normalizeTime(params.startTime || "") ??
1058
+ existingSegment.StartDateTime?.time,
1059
+ timezone: params.timezone ?? existingSegment.StartDateTime?.timezone,
1060
+ },
1061
+ EndLocationAddress: {
1062
+ address:
1063
+ params.endAddress ?? existingSegment.EndLocationAddress?.address,
1064
+ },
1065
+ EndDateTime: {
1066
+ date: params.endDate ?? existingSegment.EndDateTime?.date,
1067
+ time:
1068
+ normalizeTime(params.endTime || "") ??
1069
+ existingSegment.EndDateTime?.time,
1070
+ timezone: params.timezone ?? existingSegment.EndDateTime?.timezone,
1071
+ },
1072
+ vehicle_description:
1073
+ params.vehicleDescription ?? existingSegment.vehicle_description,
1074
+ start_location_name:
1075
+ params.startLocationName ?? existingSegment.start_location_name,
1076
+ end_location_name:
1077
+ params.endLocationName ?? existingSegment.end_location_name,
1078
+ confirmation_num:
1079
+ params.confirmationNum ?? existingSegment.confirmation_num,
1080
+ carrier_name: params.carrierName ?? existingSegment.carrier_name,
1081
+ };
1082
+
1083
+ const transportObject: Record<string, unknown> = {
1084
+ uuid: existingTransport.uuid,
1085
+ is_client_traveler: toBoolean(existingTransport.is_client_traveler),
1086
+ display_name: params.displayName ?? existingTransport.display_name,
1087
+ is_purchased: toBoolean(existingTransport.is_purchased),
1088
+ is_tripit_booking: toBoolean(existingTransport.is_tripit_booking),
1089
+ has_possible_cancellation: toBoolean(
1090
+ existingTransport.has_possible_cancellation,
1091
+ ),
1092
+ Segment: [
1093
+ orderObjectByKeys(clean(segment), TRANSPORT_SEGMENT_FIELD_ORDER),
1094
+ ],
1095
+ };
1096
+
1097
+ if (tripKey) {
1098
+ transportObject[tripKey] = tripId;
1099
+ }
1100
+
1101
+ if (params.Image) {
1102
+ if (Array.isArray(params.Image)) {
1103
+ transportObject.Image = params.Image.map((image) => {
1104
+ if (!image.ImageData) return image;
1105
+ return orderObjectByKeys(
1106
+ {
1107
+ ...image,
1108
+ segment_uuid: image.segment_uuid ?? existingSegment.uuid,
1109
+ },
1110
+ IMAGE_FIELD_ORDER,
1111
+ );
1112
+ });
1113
+ } else {
1114
+ const image = params.Image;
1115
+ transportObject.Image = image.ImageData
1116
+ ? orderObjectByKeys(
1117
+ {
1118
+ ...image,
1119
+ segment_uuid: image.segment_uuid ?? existingSegment.uuid,
1120
+ },
1121
+ IMAGE_FIELD_ORDER,
1122
+ )
1123
+ : image;
1124
+ }
1125
+ }
1126
+
1127
+ return this.apiPost(
1128
+ this.endpoint("v2", `replace/transport/uuid/${existingTransport.uuid}`),
1129
+ {
1130
+ TransportObject: orderObjectByKeys(
1131
+ clean(transportObject),
1132
+ TRANSPORT_FIELD_ORDER,
1133
+ ),
1134
+ },
1135
+ );
1136
+ }
1137
+
1138
+ // === Activities ===
1139
+
1140
+ async getActivity(id: string): Promise<ActivityResponse> {
1141
+ return this.apiGet<ActivityResponse>(
1142
+ this.identifierEndpoint("get", "activity", id),
1143
+ );
1144
+ }
1145
+
1146
+ async deleteActivity(id: string): Promise<DeleteResponse> {
1147
+ return this.apiGet<DeleteResponse>(
1148
+ this.identifierEndpoint("delete", "activity", id),
1149
+ );
1150
+ }
1151
+
1152
+ async createActivity(params: {
1153
+ tripId: string;
1154
+ displayName: string;
1155
+ startDate: string;
1156
+ startTime: string;
1157
+ endDate: string;
1158
+ endTime: string;
1159
+ timezone: string;
1160
+ address: string;
1161
+ locationName: string;
1162
+ city?: string;
1163
+ state?: string;
1164
+ zip?: string;
1165
+ country?: string;
1166
+ }): Promise<ActivityResponse> {
1167
+ const tripKey = params.tripId.includes("-") ? "trip_uuid" : "trip_id";
1168
+ return this.apiPost<ActivityResponse>(
1169
+ this.endpoint("v2", "create/activity"),
1170
+ {
1171
+ ActivityObject: clean({
1172
+ [tripKey]: params.tripId,
1173
+ display_name: params.displayName,
1174
+ StartDateTime: {
1175
+ date: params.startDate,
1176
+ time: normalizeTime(params.startTime),
1177
+ timezone: params.timezone,
1178
+ },
1179
+ EndDateTime: {
1180
+ date: params.endDate,
1181
+ time: normalizeTime(params.endTime),
1182
+ timezone: params.timezone,
1183
+ },
1184
+ Address: clean({
1185
+ address: params.address,
1186
+ city: params.city,
1187
+ state: params.state,
1188
+ zip: params.zip,
1189
+ country: params.country,
1190
+ }),
1191
+ location_name: params.locationName,
1192
+ }),
1193
+ },
1194
+ );
1195
+ }
1196
+
1197
+ async updateActivity(params: {
1198
+ id?: string;
1199
+ uuid?: string;
1200
+ tripId?: string;
1201
+ displayName?: string;
1202
+ startDate?: string;
1203
+ startTime?: string;
1204
+ endDate?: string;
1205
+ endTime?: string;
1206
+ timezone?: string;
1207
+ address?: string;
1208
+ locationName?: string;
1209
+ city?: string;
1210
+ state?: string;
1211
+ zip?: string;
1212
+ country?: string;
1213
+ notes?: string;
1214
+ Image?: OneOrMany<TripImage>;
1215
+ }): Promise<ActivityResponse> {
1216
+ const identifier = params.uuid || params.id;
1217
+ if (!identifier) {
1218
+ throw new Error("Either uuid or id parameter is required");
1219
+ }
1220
+
1221
+ const existingActivityResponse = await this.getActivity(identifier);
1222
+ const existingActivity = existingActivityResponse.ActivityObject;
1223
+ if (!existingActivity?.uuid) {
1224
+ throw new Error(`Activity with identifier ${identifier} not found`);
1225
+ }
1226
+
1227
+ const tripId =
1228
+ params.tripId || existingActivity.trip_uuid || existingActivity.trip_id;
1229
+ const tripKey = tripId
1230
+ ? tripId.includes("-")
1231
+ ? "trip_uuid"
1232
+ : "trip_id"
1233
+ : undefined;
1234
+
1235
+ const activityObject: Record<string, unknown> = {
1236
+ uuid: existingActivity.uuid,
1237
+ is_client_traveler: toBoolean(existingActivity.is_client_traveler),
1238
+ display_name: params.displayName ?? existingActivity.display_name,
1239
+ is_purchased: toBoolean(existingActivity.is_purchased),
1240
+ notes: params.notes ?? existingActivity.notes,
1241
+ StartDateTime: {
1242
+ date: params.startDate ?? existingActivity.StartDateTime?.date,
1243
+ time:
1244
+ normalizeTime(params.startTime || "") ??
1245
+ existingActivity.StartDateTime?.time,
1246
+ timezone: params.timezone ?? existingActivity.StartDateTime?.timezone,
1247
+ },
1248
+ EndDateTime: {
1249
+ date: params.endDate ?? existingActivity.EndDateTime?.date,
1250
+ time:
1251
+ normalizeTime(params.endTime || "") ??
1252
+ existingActivity.EndDateTime?.time,
1253
+ timezone: params.timezone ?? existingActivity.EndDateTime?.timezone,
1254
+ },
1255
+ Address: orderObjectByKeys(
1256
+ clean({
1257
+ address: params.address ?? existingActivity.Address?.address,
1258
+ city: params.city ?? existingActivity.Address?.city,
1259
+ state: params.state ?? existingActivity.Address?.state,
1260
+ zip: params.zip ?? existingActivity.Address?.zip,
1261
+ country: params.country ?? existingActivity.Address?.country,
1262
+ }),
1263
+ ADDRESS_FIELD_ORDER,
1264
+ ),
1265
+ location_name: params.locationName ?? existingActivity.location_name,
1266
+ };
1267
+
1268
+ if (tripKey) {
1269
+ activityObject[tripKey] = tripId;
1270
+ }
1271
+
1272
+ if (params.Image) {
1273
+ if (Array.isArray(params.Image)) {
1274
+ activityObject.Image = params.Image.map((image) => {
1275
+ if (!image.ImageData) return image;
1276
+ return orderObjectByKeys(image, IMAGE_FIELD_ORDER);
1277
+ });
1278
+ } else {
1279
+ activityObject.Image = params.Image.ImageData
1280
+ ? orderObjectByKeys(params.Image, IMAGE_FIELD_ORDER)
1281
+ : params.Image;
1282
+ }
1283
+ }
1284
+
1285
+ return this.apiPost(
1286
+ this.endpoint("v2", `replace/activity/uuid/${existingActivity.uuid}`),
1287
+ {
1288
+ ActivityObject: orderObjectByKeys(
1289
+ clean(activityObject),
1290
+ ACTIVITY_FIELD_ORDER,
1291
+ ),
1292
+ },
1293
+ );
1294
+ }
1295
+ }