@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.
- package/LICENSE +201 -0
- package/README.md +38 -0
- package/dist/catalog-component-adapter.d.ts +16 -0
- package/dist/catalog-component-adapter.d.ts.map +1 -0
- package/dist/catalog-component-adapter.js +34 -0
- package/dist/cruise-extension.d.ts +48 -0
- package/dist/cruise-extension.d.ts.map +1 -0
- package/dist/cruise-extension.js +66 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/mcp-contract.d.ts +79 -0
- package/dist/mcp-contract.d.ts.map +1 -0
- package/dist/mcp-contract.js +21 -0
- package/dist/mcp-registry.d.ts +48 -0
- package/dist/mcp-registry.d.ts.map +1 -0
- package/dist/mcp-registry.js +88 -0
- package/dist/mcp-tools.d.ts +157 -0
- package/dist/mcp-tools.d.ts.map +1 -0
- package/dist/mcp-tools.js +109 -0
- package/dist/routes.d.ts +2068 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +280 -0
- package/dist/schema.d.ts +1897 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +255 -0
- package/dist/service-cancellation.d.ts +6 -0
- package/dist/service-cancellation.d.ts.map +1 -0
- package/dist/service-cancellation.js +251 -0
- package/dist/service-checkout.d.ts +6 -0
- package/dist/service-checkout.d.ts.map +1 -0
- package/dist/service-checkout.js +328 -0
- package/dist/service-helpers.d.ts +17 -0
- package/dist/service-helpers.d.ts.map +1 -0
- package/dist/service-helpers.js +161 -0
- package/dist/service-internals.d.ts +11 -0
- package/dist/service-internals.d.ts.map +1 -0
- package/dist/service-internals.js +72 -0
- package/dist/service-pricing.d.ts +8 -0
- package/dist/service-pricing.d.ts.map +1 -0
- package/dist/service-pricing.js +142 -0
- package/dist/service-reservation.d.ts +5 -0
- package/dist/service-reservation.d.ts.map +1 -0
- package/dist/service-reservation.js +493 -0
- package/dist/service-snapshots.d.ts +8 -0
- package/dist/service-snapshots.d.ts.map +1 -0
- package/dist/service-snapshots.js +115 -0
- package/dist/service-trips.d.ts +14 -0
- package/dist/service-trips.d.ts.map +1 -0
- package/dist/service-trips.js +378 -0
- package/dist/service-types.d.ts +285 -0
- package/dist/service-types.d.ts.map +1 -0
- package/dist/service-types.js +6 -0
- package/dist/service.d.ts +38 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +40 -0
- package/dist/traveler-party-validation.d.ts +3 -0
- package/dist/traveler-party-validation.d.ts.map +1 -0
- package/dist/traveler-party-validation.js +68 -0
- package/dist/validation.d.ts +449 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +261 -0
- 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
|
+
}
|