big boi refactor to customer inof from passenger info
This commit is contained in:
@@ -1,4 +1,12 @@
|
||||
import type { CustomerInfo } from "$lib/domains/passengerinfo/data/entities";
|
||||
import type { PaymentDetailsPayload } from "$lib/domains/paymentinfo/data/entities";
|
||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
|
||||
import type { Database } from "@pkg/db";
|
||||
import { and, eq } from "@pkg/db";
|
||||
import { checkoutFlowSession } from "@pkg/db/schema";
|
||||
import { getError, Logger } from "@pkg/logger";
|
||||
import { ERROR_CODES, type Result } from "@pkg/result";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
flowInfoModel,
|
||||
SessionOutcome,
|
||||
@@ -7,14 +15,6 @@ import {
|
||||
type PaymentFlowStepPayload,
|
||||
type PrePaymentFlowStepPayload,
|
||||
} from "./entities";
|
||||
import { getError, Logger } from "@pkg/logger";
|
||||
import { nanoid } from "nanoid";
|
||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
|
||||
import type { PassengerPII } from "$lib/domains/passengerinfo/data/entities";
|
||||
import type { PaymentDetailsPayload } from "$lib/domains/paymentinfo/data/entities";
|
||||
import type { Database } from "@pkg/db";
|
||||
import { and, eq } from "@pkg/db";
|
||||
import { checkoutFlowSession } from "@pkg/db/schema";
|
||||
|
||||
export class CheckoutFlowRepository {
|
||||
constructor(private db: Database) {}
|
||||
@@ -202,7 +202,7 @@ export class CheckoutFlowRepository {
|
||||
|
||||
async syncPersonalInfo(
|
||||
flowId: string,
|
||||
personalInfo: PassengerPII,
|
||||
personalInfo: CustomerInfo,
|
||||
): Promise<Result<boolean>> {
|
||||
try {
|
||||
const existingSession = await this.db
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import {
|
||||
customerInfoModel,
|
||||
type CustomerInfo,
|
||||
} from "$lib/domains/passengerinfo/data/entities";
|
||||
import {
|
||||
paymentDetailsPayloadModel,
|
||||
type PaymentDetailsPayload,
|
||||
} from "$lib/domains/paymentinfo/data/entities";
|
||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
|
||||
import { createTRPCRouter, publicProcedure } from "$lib/trpc/t";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
feCreateCheckoutFlowPayloadModel,
|
||||
@@ -8,18 +18,6 @@ import {
|
||||
type PrePaymentFlowStepPayload,
|
||||
} from "../data/entities";
|
||||
import { getCKUseCases } from "./usecases";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
passengerPIIModel,
|
||||
type PassengerInfo,
|
||||
type PassengerPII,
|
||||
} from "$lib/domains/passengerinfo/data/entities";
|
||||
import {
|
||||
paymentDetailsPayloadModel,
|
||||
type PaymentDetails,
|
||||
type PaymentDetailsPayload,
|
||||
} from "$lib/domains/paymentinfo/data/entities";
|
||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
|
||||
|
||||
function getIPFromHeaders(headers: Headers): string {
|
||||
const ip = headers.get("x-forwarded-for");
|
||||
@@ -74,7 +72,7 @@ export const ckflowRouter = createTRPCRouter({
|
||||
.mutation(async ({ input }) => {
|
||||
return getCKUseCases().syncPersonalInfo(
|
||||
input.flowId,
|
||||
input.personalInfo as PassengerPII,
|
||||
input.personalInfo as CustomerInfo,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -104,7 +102,7 @@ export const ckflowRouter = createTRPCRouter({
|
||||
flowId: z.string(),
|
||||
payload: z.object({
|
||||
initialUrl: z.string(),
|
||||
personalInfo: passengerPIIModel.optional(),
|
||||
personalInfo: customerInfoModel.optional(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@@ -120,7 +118,7 @@ export const ckflowRouter = createTRPCRouter({
|
||||
z.object({
|
||||
flowId: z.string(),
|
||||
payload: z.object({
|
||||
personalInfo: passengerPIIModel.optional(),
|
||||
personalInfo: customerInfoModel.optional(),
|
||||
paymentInfo: paymentDetailsPayloadModel.optional(),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getRedisInstance } from "$lib/server/redis";
|
||||
import type { CustomerInfo } from "$lib/domains/passengerinfo/data/entities";
|
||||
import type { PaymentDetailsPayload } from "$lib/domains/paymentinfo/data/entities";
|
||||
import { db } from "@pkg/db";
|
||||
import { isTimestampMoreThan1MinAgo } from "@pkg/logic/core/date.utils";
|
||||
import type {
|
||||
CreateCheckoutFlowPayload,
|
||||
@@ -7,9 +9,6 @@ import type {
|
||||
PrePaymentFlowStepPayload,
|
||||
} from "../data/entities";
|
||||
import { CheckoutFlowRepository } from "../data/repository";
|
||||
import type { PassengerPII } from "$lib/domains/passengerinfo/data/entities";
|
||||
import type { PaymentDetailsPayload } from "$lib/domains/paymentinfo/data/entities";
|
||||
import { db } from "@pkg/db";
|
||||
|
||||
export class CheckoutFlowUseCases {
|
||||
constructor(private repo: CheckoutFlowRepository) {}
|
||||
@@ -55,7 +54,7 @@ export class CheckoutFlowUseCases {
|
||||
return this.repo.executePaymentStep(flowId, payload);
|
||||
}
|
||||
|
||||
async syncPersonalInfo(flowId: string, personalInfo: PassengerPII) {
|
||||
async syncPersonalInfo(flowId: string, personalInfo: CustomerInfo) {
|
||||
return this.repo.syncPersonalInfo(flowId, personalInfo);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { get } from "svelte/store";
|
||||
import { page } from "$app/state";
|
||||
import {
|
||||
CKActionType,
|
||||
SessionOutcome,
|
||||
@@ -6,25 +6,25 @@ import {
|
||||
type PendingAction,
|
||||
type PendingActions,
|
||||
} from "$lib/domains/ckflow/data/entities";
|
||||
import { trpcApiStore } from "$lib/stores/api";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { flightTicketStore } from "$lib/domains/ticket/data/store";
|
||||
import {
|
||||
customerInfoModel,
|
||||
type CustomerInfo,
|
||||
} from "$lib/domains/passengerinfo/data/entities";
|
||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
||||
import { paymentInfoVM } from "$lib/domains/ticket/view/checkout/payment-info-section/payment.info.vm.svelte";
|
||||
import { ticketCheckoutVM } from "$lib/domains/ticket/view/checkout/flight-checkout.vm.svelte";
|
||||
import type { PaymentDetailsPayload } from "$lib/domains/paymentinfo/data/entities";
|
||||
import { PaymentMethod } from "$lib/domains/paymentinfo/data/entities";
|
||||
import {
|
||||
CheckoutStep,
|
||||
type FlightPriceDetails,
|
||||
} from "$lib/domains/ticket/data/entities";
|
||||
import { PaymentMethod } from "$lib/domains/paymentinfo/data/entities";
|
||||
import { page } from "$app/state";
|
||||
import { ClientLogger } from "@pkg/logger/client";
|
||||
import { flightTicketStore } from "$lib/domains/ticket/data/store";
|
||||
import { ticketCheckoutVM } from "$lib/domains/ticket/view/checkout/flight-checkout.vm.svelte";
|
||||
import { billingDetailsVM } from "$lib/domains/ticket/view/checkout/payment-info-section/billing.details.vm.svelte";
|
||||
import {
|
||||
passengerPIIModel,
|
||||
type PassengerPII,
|
||||
} from "$lib/domains/passengerinfo/data/entities";
|
||||
import type { PaymentDetailsPayload } from "$lib/domains/paymentinfo/data/entities";
|
||||
import { paymentInfoVM } from "$lib/domains/ticket/view/checkout/payment-info-section/payment.info.vm.svelte";
|
||||
import { trpcApiStore } from "$lib/stores/api";
|
||||
import { ClientLogger } from "@pkg/logger/client";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
class ActionRunner {
|
||||
async run(actions: PendingActions) {
|
||||
@@ -248,7 +248,7 @@ export class CKFlowViewModel {
|
||||
this.setupDone = true;
|
||||
}
|
||||
|
||||
debouncePersonalInfoSync(personalInfo: PassengerPII) {
|
||||
debouncePersonalInfoSync(personalInfo: CustomerInfo) {
|
||||
this.clearPersonalInfoDebounce();
|
||||
this.personalInfoDebounceTimer = setTimeout(() => {
|
||||
this.syncPersonalInfo(personalInfo);
|
||||
@@ -276,12 +276,12 @@ export class CKFlowViewModel {
|
||||
);
|
||||
}
|
||||
|
||||
isPersonalInfoValid(personalInfo: PassengerPII): boolean {
|
||||
const parsed = passengerPIIModel.safeParse(personalInfo);
|
||||
isPersonalInfoValid(personalInfo: CustomerInfo): boolean {
|
||||
const parsed = customerInfoModel.safeParse(personalInfo);
|
||||
return !parsed.error && !!parsed.data;
|
||||
}
|
||||
|
||||
async syncPersonalInfo(personalInfo: PassengerPII) {
|
||||
async syncPersonalInfo(personalInfo: CustomerInfo) {
|
||||
if (!this.flowId || !this.setupDone) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { EmailAccountPayload } from "@pkg/logic/domains/account/data/entities";
|
||||
import { get } from "svelte/store";
|
||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
||||
import {
|
||||
createOrderPayloadModel,
|
||||
OrderCreationStep,
|
||||
} from "$lib/domains/order/data/entities";
|
||||
import { trpcApiStore } from "$lib/stores/api";
|
||||
import type { FlightTicket } from "$lib/domains/ticket/data/entities";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
||||
import type { FlightTicket } from "$lib/domains/ticket/data/entities";
|
||||
import { trpcApiStore } from "$lib/stores/api";
|
||||
import type { EmailAccountPayload } from "@pkg/logic/domains/account/data/entities";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export class CreateOrderViewModel {
|
||||
orderStep = $state(OrderCreationStep.ACCOUNT_SELECTION);
|
||||
@@ -40,8 +40,8 @@ export class CreateOrderViewModel {
|
||||
if (this.orderStep === OrderCreationStep.ACCOUNT_SELECTION) {
|
||||
this.orderStep = OrderCreationStep.TICKET_SELECTION;
|
||||
} else if (this.orderStep === OrderCreationStep.TICKET_SELECTION) {
|
||||
this.orderStep = OrderCreationStep.PASSENGER_INFO;
|
||||
} else if (this.orderStep === OrderCreationStep.PASSENGER_INFO) {
|
||||
this.orderStep = OrderCreationStep.CUSTOMER_INFO;
|
||||
} else if (this.orderStep === OrderCreationStep.CUSTOMER_INFO) {
|
||||
this.orderStep = OrderCreationStep.SUMMARY;
|
||||
} else {
|
||||
this.orderStep = OrderCreationStep.ACCOUNT_SELECTION;
|
||||
@@ -50,8 +50,8 @@ export class CreateOrderViewModel {
|
||||
|
||||
setPrevStep() {
|
||||
if (this.orderStep === OrderCreationStep.SUMMARY) {
|
||||
this.orderStep = OrderCreationStep.PASSENGER_INFO;
|
||||
} else if (this.orderStep === OrderCreationStep.PASSENGER_INFO) {
|
||||
this.orderStep = OrderCreationStep.CUSTOMER_INFO;
|
||||
} else if (this.orderStep === OrderCreationStep.CUSTOMER_INFO) {
|
||||
this.orderStep = OrderCreationStep.TICKET_SELECTION;
|
||||
} else {
|
||||
this.orderStep = OrderCreationStep.ACCOUNT_SELECTION;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { eq, inArray, type Database } from "@pkg/db";
|
||||
import { order, passengerInfo, passengerPII } from "@pkg/db/schema";
|
||||
import { passengerInfo, passengerPII } from "@pkg/db/schema";
|
||||
import { getError, Logger } from "@pkg/logger";
|
||||
import { ERROR_CODES, type Result } from "@pkg/result";
|
||||
import {
|
||||
passengerInfoModel,
|
||||
type CustomerInfo,
|
||||
type PassengerInfo,
|
||||
type PassengerPII,
|
||||
} from "./entities";
|
||||
import { getError, Logger } from "@pkg/logger";
|
||||
|
||||
export class PassengerInfoRepository {
|
||||
private db: Database;
|
||||
@@ -15,7 +15,7 @@ export class PassengerInfoRepository {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async createPassengerPii(payload: PassengerPII): Promise<Result<number>> {
|
||||
async createPassengerPii(payload: CustomerInfo): Promise<Result<number>> {
|
||||
try {
|
||||
const out = await this.db
|
||||
.insert(passengerPII)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script lang="ts">
|
||||
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import Input from "$lib/components/ui/input/input.svelte";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { COUNTRIES_SELECT } from "$lib/core/countries";
|
||||
import type { SelectOption } from "$lib/core/data.types";
|
||||
import { capitalize } from "$lib/core/string.utils";
|
||||
import type { CustomerInfo } from "$lib/domains/ticket/data/entities/create.entities";
|
||||
import { Gender } from "$lib/domains/ticket/data/entities/index";
|
||||
import type { PassengerPII } from "$lib/domains/ticket/data/entities/create.entities";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import { passengerInfoVM } from "./passenger.info.vm.svelte";
|
||||
import { PHONE_COUNTRY_CODES } from "@pkg/logic/core/data/phonecc";
|
||||
import { passengerInfoVM } from "./passenger.info.vm.svelte";
|
||||
|
||||
let { info = $bindable(), idx }: { info: PassengerPII; idx: number } =
|
||||
let { info = $bindable(), idx }: { info: CustomerInfo; idx: number } =
|
||||
$props();
|
||||
|
||||
const genderOpts = [
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
customerInfoModel,
|
||||
type BagSelectionInfo,
|
||||
type CustomerInfo,
|
||||
type PassengerInfo,
|
||||
type SeatSelectionInfo,
|
||||
} from "$lib/domains/ticket/data/entities/create.entities";
|
||||
import {
|
||||
Gender,
|
||||
PassengerType,
|
||||
@@ -5,19 +12,12 @@ import {
|
||||
type FlightPriceDetails,
|
||||
type PassengerCount,
|
||||
} from "$lib/domains/ticket/data/entities/index";
|
||||
import {
|
||||
passengerPIIModel,
|
||||
type BagSelectionInfo,
|
||||
type PassengerInfo,
|
||||
type PassengerPII,
|
||||
type SeatSelectionInfo,
|
||||
} from "$lib/domains/ticket/data/entities/create.entities";
|
||||
import { z } from "zod";
|
||||
|
||||
export class PassengerInfoViewModel {
|
||||
passengerInfos = $state<PassengerInfo[]>([]);
|
||||
|
||||
piiErrors = $state<Array<Partial<Record<keyof PassengerPII, string>>>>([]);
|
||||
piiErrors = $state<Array<Partial<Record<keyof CustomerInfo, string>>>>([]);
|
||||
|
||||
reset() {
|
||||
this.passengerInfos = [];
|
||||
@@ -47,7 +47,7 @@ export class PassengerInfoViewModel {
|
||||
// zipCode: "123098",
|
||||
// address: "address",
|
||||
// address2: "",
|
||||
// } as PassengerPII;
|
||||
// } as CustomerInfo;
|
||||
|
||||
const _defaultPiiObj = {
|
||||
firstName: "",
|
||||
@@ -67,7 +67,7 @@ export class PassengerInfoViewModel {
|
||||
zipCode: "",
|
||||
address: "",
|
||||
address2: "",
|
||||
} as PassengerPII;
|
||||
} as CustomerInfo;
|
||||
|
||||
const _defaultPriceObj = {
|
||||
currency: "",
|
||||
@@ -137,20 +137,20 @@ export class PassengerInfoViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
validatePII(info: PassengerPII, idx: number) {
|
||||
validatePII(info: CustomerInfo, idx: number) {
|
||||
try {
|
||||
const result = passengerPIIModel.parse(info);
|
||||
const result = customerInfoModel.parse(info);
|
||||
this.piiErrors[idx] = {};
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
this.piiErrors[idx] = error.errors.reduce(
|
||||
(acc, curr) => {
|
||||
const path = curr.path[0] as keyof PassengerPII;
|
||||
const path = curr.path[0] as keyof CustomerInfo;
|
||||
acc[path] = curr.message;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<keyof PassengerPII, string>,
|
||||
{} as Record<keyof CustomerInfo, string>,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1,618 +0,0 @@
|
||||
import { ERROR_CODES, type Result } from "@pkg/result";
|
||||
import type { BagsInfo, FlightTicket, TicketSearchDTO } from "./entities";
|
||||
import { betterFetch } from "@better-fetch/fetch";
|
||||
import { getError, Logger } from "@pkg/logger";
|
||||
import { CabinClass, TicketType, type FlightPriceDetails } from "./entities";
|
||||
import type Redis from "ioredis";
|
||||
|
||||
interface AmadeusAPIRequest {
|
||||
currencyCode: string;
|
||||
originDestinations: Array<{
|
||||
id: string;
|
||||
originLocationCode: string;
|
||||
destinationLocationCode: string;
|
||||
departureDateTimeRange: {
|
||||
date: string;
|
||||
time?: string;
|
||||
};
|
||||
arrivalDateTimeRange?: {
|
||||
date: string;
|
||||
time?: string;
|
||||
};
|
||||
}>;
|
||||
travelers: Array<{
|
||||
id: string;
|
||||
travelerType: string;
|
||||
}>;
|
||||
sources: string[];
|
||||
searchCriteria: {
|
||||
maxFlightOffers: number;
|
||||
flightFilters?: {
|
||||
cabinRestrictions?: Array<{
|
||||
cabin: string;
|
||||
coverage: string;
|
||||
originDestinationIds: string[];
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
type: string;
|
||||
username: string;
|
||||
application_name: string;
|
||||
client_id: string;
|
||||
token_type: string;
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
state: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export class AmadeusTicketsAPIDataSource {
|
||||
apiBaseUrl: string;
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
redis: Redis;
|
||||
|
||||
// Redis key for storing the access token
|
||||
private readonly TOKEN_CACHE_KEY = "amadeus:access_token";
|
||||
private readonly TOKEN_EXPIRY_KEY = "amadeus:token_expiry";
|
||||
|
||||
// Add a buffer time to refresh token before it expires (5 minutes in seconds)
|
||||
private readonly TOKEN_EXPIRY_BUFFER = 300;
|
||||
|
||||
constructor(
|
||||
apiBaseUrl: string,
|
||||
apiKey: string,
|
||||
apiSecret: string,
|
||||
redis: Redis,
|
||||
) {
|
||||
this.apiBaseUrl = apiBaseUrl;
|
||||
this.apiKey = apiKey;
|
||||
this.apiSecret = apiSecret;
|
||||
this.redis = redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid access token, either from cache or by requesting a new one
|
||||
*/
|
||||
private async getAccessToken(): Promise<Result<string>> {
|
||||
try {
|
||||
// Try to get token from cache
|
||||
const cachedToken = await this.redis.get(this.TOKEN_CACHE_KEY);
|
||||
const tokenExpiry = await this.redis.get(this.TOKEN_EXPIRY_KEY);
|
||||
|
||||
// Check if we have a valid token that's not about to expire
|
||||
if (cachedToken && tokenExpiry) {
|
||||
const expiryTime = parseInt(tokenExpiry, 10);
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
|
||||
// If token is still valid and not about to expire, use it
|
||||
if (expiryTime > currentTime + this.TOKEN_EXPIRY_BUFFER) {
|
||||
Logger.debug("Using cached Amadeus API token");
|
||||
return { data: cachedToken };
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
"Amadeus API token is expired or about to expire, requesting a new one",
|
||||
);
|
||||
}
|
||||
|
||||
// Request a new token
|
||||
Logger.info("Requesting new Amadeus API access token");
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append("grant_type", "client_credentials");
|
||||
params.append("client_id", this.apiKey);
|
||||
params.append("client_secret", this.apiSecret);
|
||||
|
||||
const { data, error } = await betterFetch<TokenResponse>(
|
||||
`${this.apiBaseUrl}/v1/security/oauth2/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !data) {
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.AUTH_ERROR,
|
||||
message: "Failed to authenticate with Amadeus API",
|
||||
detail: "Could not obtain access token",
|
||||
userHint: "Please try again later",
|
||||
error: error,
|
||||
actionable: false,
|
||||
},
|
||||
error,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Cache the token
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const expiryTime = currentTime + data.expires_in;
|
||||
|
||||
await this.redis.set(this.TOKEN_CACHE_KEY, data.access_token);
|
||||
await this.redis.set(this.TOKEN_EXPIRY_KEY, expiryTime.toString());
|
||||
await this.redis.expire(this.TOKEN_CACHE_KEY, data.expires_in);
|
||||
await this.redis.expire(this.TOKEN_EXPIRY_KEY, data.expires_in);
|
||||
|
||||
Logger.info(
|
||||
`Successfully obtained and cached Amadeus API token (expires in ${data.expires_in} seconds)`,
|
||||
);
|
||||
|
||||
return { data: data.access_token };
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.AUTH_ERROR,
|
||||
message: "Failed to authenticate with Amadeus API",
|
||||
detail: "An unexpected error occurred during authentication",
|
||||
userHint: "Please try again later",
|
||||
error: err,
|
||||
actionable: false,
|
||||
},
|
||||
err,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async searchForTickets(
|
||||
payload: TicketSearchDTO,
|
||||
): Promise<Result<FlightTicket[]>> {
|
||||
Logger.info(
|
||||
`Base api url: ${this.apiBaseUrl}, api key: ${this.apiKey}, api secret: ${this.apiSecret}`,
|
||||
);
|
||||
try {
|
||||
const tokenResult = await this.getAccessToken();
|
||||
|
||||
if (tokenResult.error) {
|
||||
return { error: tokenResult.error };
|
||||
}
|
||||
|
||||
const accessToken = tokenResult.data;
|
||||
const request = this.buildAmadeusRequest(payload);
|
||||
|
||||
const from = request.originDestinations[0].originLocationCode;
|
||||
const to = request.originDestinations[0].destinationLocationCode;
|
||||
Logger.info(`Searching Amadeus for flights from ${from} to ${to}`);
|
||||
|
||||
const { data, error } = await betterFetch<any>(
|
||||
`${this.apiBaseUrl}/v2/shopping/flight-offers`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.amadeus+json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"X-HTTP-Method-Override": "GET",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.NETWORK_ERROR,
|
||||
message:
|
||||
"Failed to search for tickets via Amadeus API",
|
||||
detail: "Network error when connecting to Amadeus API",
|
||||
userHint: "Please try again later",
|
||||
error: error,
|
||||
actionable: false,
|
||||
},
|
||||
JSON.stringify(error, null, 4),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (data.errors) {
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.API_ERROR,
|
||||
message: "Amadeus API returned an error",
|
||||
detail: data.errors
|
||||
.map((e: any) => `${e.title}: ${e.detail || ""}`)
|
||||
.join(", "),
|
||||
userHint: "Please try with different search criteria",
|
||||
error: data.errors,
|
||||
actionable: false,
|
||||
},
|
||||
JSON.stringify(data.errors, null, 4),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we have data to process
|
||||
if (
|
||||
!data.data ||
|
||||
!Array.isArray(data.data) ||
|
||||
data.data.length === 0
|
||||
) {
|
||||
Logger.info("Amadeus API returned no flight offers");
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
// Transform API response to our FlightTicket format
|
||||
const tickets = this.transformAmadeusResponseToFlightTickets(
|
||||
data,
|
||||
payload,
|
||||
);
|
||||
|
||||
Logger.info(`Amadeus API returned ${tickets.length} tickets`);
|
||||
|
||||
return { data: tickets };
|
||||
} catch (err) {
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "Failed to search for tickets via Amadeus API",
|
||||
detail: "An unexpected error occurred while processing the Amadeus API response",
|
||||
userHint: "Please try again later",
|
||||
error: err,
|
||||
actionable: false,
|
||||
},
|
||||
JSON.stringify(err, null, 4),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
private buildAmadeusRequest(payload: TicketSearchDTO): AmadeusAPIRequest {
|
||||
const { ticketSearchPayload } = payload;
|
||||
const { adults, children } = ticketSearchPayload.passengerCounts;
|
||||
|
||||
// Create travelers array
|
||||
const travelers = [];
|
||||
for (let i = 0; i < adults; i++) {
|
||||
travelers.push({
|
||||
id: (i + 1).toString(),
|
||||
travelerType: "ADULT",
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < (children || 0); i++) {
|
||||
travelers.push({
|
||||
id: (adults + i + 1).toString(),
|
||||
travelerType: "CHILD",
|
||||
});
|
||||
}
|
||||
|
||||
// Map cabin class to Amadeus format
|
||||
const cabinMap: Record<string, string> = {
|
||||
[CabinClass.Economy]: "ECONOMY",
|
||||
[CabinClass.PremiumEconomy]: "PREMIUM_ECONOMY",
|
||||
[CabinClass.Business]: "BUSINESS",
|
||||
[CabinClass.FirstClass]: "FIRST",
|
||||
};
|
||||
|
||||
// Build the request
|
||||
const originDestinations = [];
|
||||
|
||||
// Format dates to ensure ISO 8601 format (YYYY-MM-DD)
|
||||
const departureDate = this.formatDateForAmadeus(
|
||||
ticketSearchPayload.departureDate,
|
||||
);
|
||||
const returnDate = ticketSearchPayload.returnDate
|
||||
? this.formatDateForAmadeus(ticketSearchPayload.returnDate)
|
||||
: null;
|
||||
|
||||
// Add outbound journey
|
||||
originDestinations.push({
|
||||
id: "1",
|
||||
originLocationCode: ticketSearchPayload.departure,
|
||||
destinationLocationCode: ticketSearchPayload.arrival,
|
||||
departureDateTimeRange: {
|
||||
date: departureDate,
|
||||
},
|
||||
});
|
||||
|
||||
// Add return journey if it's a round trip
|
||||
if (ticketSearchPayload.ticketType === TicketType.Return && returnDate) {
|
||||
originDestinations.push({
|
||||
id: "2",
|
||||
originLocationCode: ticketSearchPayload.arrival,
|
||||
destinationLocationCode: ticketSearchPayload.departure,
|
||||
departureDateTimeRange: {
|
||||
date: returnDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
currencyCode: "USD", // Use USD as default currency
|
||||
originDestinations,
|
||||
travelers,
|
||||
sources: ["GDS"],
|
||||
searchCriteria: {
|
||||
maxFlightOffers: 50,
|
||||
flightFilters: {
|
||||
cabinRestrictions: [
|
||||
{
|
||||
cabin:
|
||||
cabinMap[ticketSearchPayload.cabinClass] ||
|
||||
"ECONOMY",
|
||||
coverage: "MOST_SEGMENTS",
|
||||
originDestinationIds: originDestinations.map(
|
||||
(od) => od.id,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to ensure date is in ISO 8601 format (YYYY-MM-DD)
|
||||
* @param dateString Date string to format
|
||||
* @returns Formatted date string in YYYY-MM-DD format
|
||||
*/
|
||||
private formatDateForAmadeus(dateString: string): string {
|
||||
try {
|
||||
// Check if already in correct format
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
// Try to parse the date and format it
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) {
|
||||
Logger.error(`Invalid date format provided: ${dateString}`);
|
||||
throw new Error(`Invalid date: ${dateString}`);
|
||||
}
|
||||
|
||||
// Format as YYYY-MM-DD
|
||||
return date.toISOString().split("T")[0];
|
||||
} catch (err) {
|
||||
Logger.error(`Error formatting date: ${dateString}`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private transformAmadeusResponseToFlightTickets(
|
||||
response: any,
|
||||
originalPayload: TicketSearchDTO,
|
||||
): FlightTicket[] {
|
||||
if (
|
||||
!response.data ||
|
||||
!Array.isArray(response.data) ||
|
||||
response.data.length === 0
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { ticketSearchPayload } = originalPayload;
|
||||
const dictionaries = response.dictionaries || {};
|
||||
|
||||
return response.data.map((offer: any, index: number) => {
|
||||
// Extract basic info from the offer
|
||||
const id = index + 1;
|
||||
const ticketId = offer.id;
|
||||
const departure = ticketSearchPayload.departure;
|
||||
const arrival = ticketSearchPayload.arrival;
|
||||
const departureDate = ticketSearchPayload.departureDate;
|
||||
const returnDate = ticketSearchPayload.returnDate || "";
|
||||
const flightType = ticketSearchPayload.ticketType;
|
||||
const cabinClass = ticketSearchPayload.cabinClass;
|
||||
const passengerCounts = ticketSearchPayload.passengerCounts;
|
||||
|
||||
// Create price details
|
||||
const priceDetails: FlightPriceDetails = {
|
||||
currency: offer.price.currency,
|
||||
basePrice: parseFloat(offer.price.total),
|
||||
displayPrice: parseFloat(offer.price.total),
|
||||
discountAmount: 0,
|
||||
};
|
||||
|
||||
// Create baggageInfo - using defaults as Amadeus doesn't provide complete bag info
|
||||
const bagsInfo: BagsInfo = {
|
||||
includedPersonalBags: 1,
|
||||
includedHandBags: 1,
|
||||
includedCheckedBags: this.getIncludedBagsFromOffer(offer),
|
||||
hasHandBagsSupport: true,
|
||||
hasCheckedBagsSupport: true,
|
||||
details: {
|
||||
personalBags: {
|
||||
price: 0,
|
||||
weight: 7,
|
||||
unit: "KG",
|
||||
dimensions: { length: 40, width: 30, height: 20 },
|
||||
},
|
||||
handBags: {
|
||||
price: 20,
|
||||
weight: 12,
|
||||
unit: "KG",
|
||||
dimensions: { length: 55, width: 40, height: 20 },
|
||||
},
|
||||
checkedBags: {
|
||||
price: 40,
|
||||
weight: 23,
|
||||
unit: "KG",
|
||||
dimensions: { length: 90, width: 75, height: 43 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Create flight itineraries
|
||||
const flightIteneraries = {
|
||||
outbound: this.buildSegmentDetails(
|
||||
offer.itineraries[0],
|
||||
dictionaries,
|
||||
0,
|
||||
),
|
||||
inbound:
|
||||
offer.itineraries.length > 1
|
||||
? this.buildSegmentDetails(
|
||||
offer.itineraries[1],
|
||||
dictionaries,
|
||||
1,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
|
||||
// Dates - always include departure date
|
||||
const dates = [departureDate];
|
||||
if (returnDate) {
|
||||
dates.push(returnDate);
|
||||
}
|
||||
|
||||
// Create the flight ticket
|
||||
const ticket: FlightTicket = {
|
||||
id,
|
||||
ticketId,
|
||||
departure,
|
||||
arrival,
|
||||
departureDate,
|
||||
returnDate,
|
||||
dates,
|
||||
flightType,
|
||||
flightIteneraries,
|
||||
priceDetails,
|
||||
refOIds: [],
|
||||
refundable: offer.pricingOptions?.refundableFare || false,
|
||||
passengerCounts,
|
||||
cabinClass,
|
||||
bagsInfo,
|
||||
lastAvailable: {
|
||||
availableSeats: offer.numberOfBookableSeats || 10,
|
||||
},
|
||||
shareId: `AMADEUS-${ticketId}`,
|
||||
checkoutUrl: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return ticket;
|
||||
});
|
||||
}
|
||||
|
||||
private getIncludedBagsFromOffer(offer: any): number {
|
||||
// Try to extract checked bag info from first traveler and first segment
|
||||
try {
|
||||
const firstTraveler = offer.travelerPricings?.[0];
|
||||
const firstSegment = firstTraveler?.fareDetailsBySegment?.[0];
|
||||
|
||||
if (firstSegment?.includedCheckedBags?.quantity) {
|
||||
return firstSegment.includedCheckedBags.quantity;
|
||||
}
|
||||
|
||||
// If weight is specified without quantity, assume 1 bag
|
||||
if (firstSegment?.includedCheckedBags?.weight) {
|
||||
return 1;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore error and use default
|
||||
}
|
||||
|
||||
return 0; // Default: no included checked bags
|
||||
}
|
||||
|
||||
private buildSegmentDetails(
|
||||
itinerary: any,
|
||||
dictionaries: any,
|
||||
directionIndex: number,
|
||||
): any[] {
|
||||
return itinerary.segments.map((segment: any, index: number) => {
|
||||
const depStation = this.enhanceLocationInfo(
|
||||
segment.departure.iataCode,
|
||||
dictionaries,
|
||||
);
|
||||
const arrStation = this.enhanceLocationInfo(
|
||||
segment.arrival.iataCode,
|
||||
dictionaries,
|
||||
);
|
||||
const airlineInfo = this.getAirlineInfo(
|
||||
segment.carrierCode,
|
||||
dictionaries,
|
||||
);
|
||||
|
||||
return {
|
||||
flightId: `${directionIndex}-${index}`,
|
||||
flightNumber: `${segment.carrierCode}${segment.number}`,
|
||||
airline: airlineInfo,
|
||||
departure: {
|
||||
station: depStation,
|
||||
localTime: segment.departure.at,
|
||||
utcTime: segment.departure.at, // Note: Amadeus doesn't provide UTC time
|
||||
},
|
||||
destination: {
|
||||
station: arrStation,
|
||||
localTime: segment.arrival.at,
|
||||
utcTime: segment.arrival.at, // Note: Amadeus doesn't provide UTC time
|
||||
},
|
||||
durationSeconds: this.parseDuration(segment.duration),
|
||||
seatInfo: {
|
||||
availableSeats: 10, // Default value as Amadeus doesn't provide per-segment seat info
|
||||
seatClass: this.mapAmadeusCabinToInternal(
|
||||
segment.travelerPricings?.[0]?.fareDetailsBySegment?.[0]
|
||||
?.cabin || "ECONOMY",
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private enhanceLocationInfo(iataCode: string, dictionaries: any) {
|
||||
const locationInfo = dictionaries?.locations?.[iataCode] || {};
|
||||
|
||||
return {
|
||||
id: 0, // Dummy id as we don't have one from Amadeus
|
||||
type: "AIRPORT",
|
||||
code: iataCode,
|
||||
name: iataCode, // We only have the code
|
||||
city: locationInfo.cityCode || iataCode,
|
||||
country: locationInfo.countryCode || "Unknown",
|
||||
};
|
||||
}
|
||||
|
||||
private getAirlineInfo(carrierCode: string, dictionaries: any) {
|
||||
const airlineName = dictionaries?.carriers?.[carrierCode] || carrierCode;
|
||||
|
||||
return {
|
||||
code: carrierCode,
|
||||
name: airlineName,
|
||||
imageUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
private parseDuration(durationStr: string): number {
|
||||
try {
|
||||
// Example format: PT5H30M (5 hours 30 minutes)
|
||||
const hourMatch = durationStr.match(/(\d+)H/);
|
||||
const minuteMatch = durationStr.match(/(\d+)M/);
|
||||
|
||||
const hours = hourMatch ? parseInt(hourMatch[1]) : 0;
|
||||
const minutes = minuteMatch ? parseInt(minuteMatch[1]) : 0;
|
||||
|
||||
return hours * 3600 + minutes * 60;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private mapAmadeusCabinToInternal(amadeusClass: string): string | null {
|
||||
const cabinMap: Record<
|
||||
string,
|
||||
(typeof CabinClass)[keyof typeof CabinClass]
|
||||
> = {
|
||||
ECONOMY: CabinClass.Economy,
|
||||
PREMIUM_ECONOMY: CabinClass.PremiumEconomy,
|
||||
BUSINESS: CabinClass.Business,
|
||||
FIRST: CabinClass.FirstClass,
|
||||
};
|
||||
|
||||
return cabinMap[amadeusClass] || CabinClass.Economy;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
<script lang="ts">
|
||||
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import Input from "$lib/components/ui/input/input.svelte";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { COUNTRIES_SELECT } from "$lib/core/countries";
|
||||
import { capitalize } from "$lib/core/string.utils";
|
||||
import type { PassengerPII } from "$lib/domains/ticket/data/entities/create.entities";
|
||||
import type { CustomerInfo } from "$lib/domains/ticket/data/entities/create.entities";
|
||||
import { billingDetailsVM } from "./billing.details.vm.svelte";
|
||||
|
||||
let { info = $bindable() }: { info: PassengerPII } = $props();
|
||||
let { info = $bindable() }: { info: CustomerInfo } = $props();
|
||||
|
||||
function onSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Gender } from "$lib/domains/ticket/data/entities/index";
|
||||
import {
|
||||
passengerPIIModel,
|
||||
type PassengerPII,
|
||||
customerInfoModel,
|
||||
type CustomerInfo,
|
||||
} from "$lib/domains/ticket/data/entities/create.entities";
|
||||
import { Gender } from "$lib/domains/ticket/data/entities/index";
|
||||
import { z } from "zod";
|
||||
|
||||
export class BillingDetailsViewModel {
|
||||
// @ts-ignore
|
||||
billingDetails = $state<PassengerPII>(undefined);
|
||||
billingDetails = $state<CustomerInfo>(undefined);
|
||||
|
||||
piiErrors = $state<Partial<Record<keyof PassengerPII, string>>>({});
|
||||
piiErrors = $state<Partial<Record<keyof CustomerInfo, string>>>({});
|
||||
|
||||
constructor() {
|
||||
this.reset();
|
||||
@@ -34,28 +34,28 @@ export class BillingDetailsViewModel {
|
||||
zipCode: "",
|
||||
address: "",
|
||||
address2: "",
|
||||
} as PassengerPII;
|
||||
} as CustomerInfo;
|
||||
this.piiErrors = {};
|
||||
}
|
||||
|
||||
setPII(info: PassengerPII) {
|
||||
setPII(info: CustomerInfo) {
|
||||
this.billingDetails = info;
|
||||
}
|
||||
|
||||
validatePII(info: PassengerPII) {
|
||||
validatePII(info: CustomerInfo) {
|
||||
try {
|
||||
const result = passengerPIIModel.parse(info);
|
||||
const result = customerInfoModel.parse(info);
|
||||
this.piiErrors = {};
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
this.piiErrors = error.errors.reduce(
|
||||
(acc, curr) => {
|
||||
const path = curr.path[0] as keyof PassengerPII;
|
||||
const path = curr.path[0] as keyof CustomerInfo;
|
||||
acc[path] = curr.message;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<keyof PassengerPII, string>,
|
||||
{} as Record<keyof CustomerInfo, string>,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user