and so it begins

This commit is contained in:
user
2025-10-20 18:59:38 +03:00
parent f5b99afc8f
commit e239b3bbf6
53 changed files with 4813 additions and 2887 deletions

View File

@@ -1 +0,0 @@
export * from "@pkg/logic/domains/airport/data/entities";

View File

@@ -1,222 +0,0 @@
import { asc, eq, sql, type AirportsDatabase } from "@pkg/airports-db";
import { airportModel, type Airport } from "./entities";
import { ERROR_CODES, type Result } from "@pkg/result";
import { getError, Logger } from "@pkg/logger";
import { airport } from "@pkg/airports-db/schema";
export class AirportsRepository {
private db: AirportsDatabase;
constructor(db: AirportsDatabase) {
this.db = db;
}
async searchAirports(query: string): Promise<Result<Airport[]>> {
if (query.length < 2) {
return { data: [] };
}
try {
const cleanQuery = query.trim();
let allResults: any[] = [];
// Strategy 1: Try fuzzy search with trigrams
try {
// Similarity threshold - adjust this value between 0.1 and 0.6
// Lower values give more results but less precise
const similarityThreshold = 0.3;
// Execute trigram similarity search
const trigRawResults = await this.db.execute(sql`
SELECT * FROM airport
WHERE
similarity(LOWER(name), LOWER(${cleanQuery})) > ${similarityThreshold} OR
similarity(LOWER(iata_code), LOWER(${cleanQuery})) > ${similarityThreshold} OR
similarity(LOWER(municipality), LOWER(${cleanQuery})) > ${similarityThreshold} OR
similarity(LOWER(country), LOWER(${cleanQuery})) > ${similarityThreshold}
ORDER BY
GREATEST(
similarity(LOWER(name), LOWER(${cleanQuery})),
similarity(LOWER(iata_code), LOWER(${cleanQuery})),
similarity(LOWER(municipality), LOWER(${cleanQuery})),
similarity(LOWER(country), LOWER(${cleanQuery}))
) DESC,
iata_code ASC
LIMIT 15
`);
const trigResults = trigRawResults.map((row) => ({
id: row.id,
type: row.type,
name: row.name,
gpsCode: row.gps_code,
ident: row.ident,
latitudeDeg: row.latitude_deg,
longitudeDeg: row.longitude_deg,
elevationFt: row.elevation_ft,
continent: row.continent,
country: row.country,
isoCountry: row.iso_country,
isoRegion: row.iso_region,
municipality: row.municipality,
scheduledService: row.scheduled_service,
iataCode: row.iata_code,
localCode: row.local_code,
createdAt: row.created_at,
updatedAt: row.updated_at,
searchVector: row.search_vector,
}));
allResults.push(...trigResults);
} catch (e) {
// In case trigram extension isn't available or other issues
Logger.warn(
"Trigram search failed, possibly extension not enabled",
e,
);
}
Logger.info(
`Result count after trigram search: ${allResults.length}`,
);
// Strategy 2: Try with plainto_tsquery if trigram search returned few results
if (allResults.length < 5) {
const plainTextResults = await this.db.query.airport.findMany({
where: sql`${airport.searchVector} @@ plainto_tsquery('english', ${cleanQuery})`,
limit: 15,
orderBy: [
sql`ts_rank(${airport.searchVector}, plainto_tsquery('english', ${cleanQuery})) DESC`,
asc(airport.iataCode),
],
});
// Add new results that aren't already in allResults
const existingIds = new Set(allResults.map((r) => r.id));
for (const result of plainTextResults) {
if (!existingIds.has(result.id)) {
allResults.push(result);
existingIds.add(result.id);
}
}
Logger.info(
`Result count after plainto_tsquery search: ${allResults.length}`,
);
}
// Strategy 3: Full-text search with prefix matching as fallback
if (allResults.length < 5) {
const prefixQuery = cleanQuery
.toLowerCase()
.split(/\s+/)
.filter(Boolean)
.map((term) => term.replace(/[&|!:*'"\(\)]/g, "") + ":*")
.join(" & ");
if (prefixQuery) {
const textSearchResults =
await this.db.query.airport.findMany({
where: sql`${airport.searchVector} @@ to_tsquery('english', ${prefixQuery})`,
limit: 15,
orderBy: [
sql`ts_rank(${airport.searchVector}, to_tsquery('english', ${prefixQuery})) DESC`,
asc(airport.iataCode),
],
});
// Add new results that aren't already in allResults
const existingIds = new Set(allResults.map((r) => r.id));
for (const result of textSearchResults) {
if (!existingIds.has(result.id)) {
allResults.push(result);
existingIds.add(result.id);
}
}
}
Logger.info(
`Result count after prefix search: ${allResults.length}`,
);
}
// Limit to 15 results
allResults = allResults.slice(0, 15);
// Process and validate results
const res = [] as Airport[];
for (const each of allResults) {
const parsed = airportModel.safeParse(each);
if (parsed.success) {
res.push(parsed.data);
} else {
Logger.warn(
`An error occurred while parsing airport in airport search`,
);
Logger.error(parsed.error.errors);
}
}
return { data: res };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "An error occurred while searching for airports",
detail: "A database error occurred while performing the search",
userHint:
"Please try again later, or contact us for more assistance",
},
err,
),
};
}
}
async getAirportByCode(code: string): Promise<Result<Airport>> {
try {
const qRes = await this.db.query.airport.findFirst({
where: eq(airport.iataCode, code),
});
if (!qRes) {
return {
error: getError({
code: ERROR_CODES.DATABASE_ERROR,
message: "Airport not found",
detail: "Airport not found in the database",
userHint: "Please check the airport code and try again",
}),
};
}
const parsed = airportModel.safeParse(qRes);
if (!parsed.success) {
Logger.info(parsed.error.errors);
return {
error: getError({
code: ERROR_CODES.DATABASE_ERROR,
message: "Error parsing airport data",
detail: "The airport data could not be processed correctly",
userHint:
"Please try again later, or contact us for more assistance",
}),
};
}
return { data: parsed.data };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Error retrieving airport",
detail: "An error occurred while fetching the airport data",
userHint:
"Please try again later, or contact us for more assistance",
},
err,
),
};
}
}
}

View File

@@ -1,210 +0,0 @@
<script lang="ts">
import ChevronsUpDown from "@lucide/svelte/icons/chevrons-up-down";
import { tick } from "svelte";
import * as Popover from "$lib/components/ui/popover/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { airportVM } from "./airport.vm.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import PlaneTakeOffIcon from "~icons/mingcute/flight-takeoff-line";
import PlaneLandIcon from "~icons/mingcute/flight-land-line";
import SearchIcon from "~icons/solar/magnifer-linear";
import type { Airport } from "../data/entities";
import { TRANSITION_COLORS } from "$lib/core/constants";
import { browser } from "$app/environment";
import MobileAirportSearchModal from "./mobile-airport-search-modal.svelte";
import { onDestroy, onMount } from "svelte";
let {
currentValue = $bindable(),
onChange,
width = "w-full lg:w-[clamp(10rem,28vw,34rem)]",
placeholder = "",
searchPlaceholder = "",
isMobile,
fieldType = undefined,
}: {
currentValue?: Airport | null;
onChange?: (value: string) => void;
width?: string;
placeholder?: string;
searchPlaceholder?: string;
isMobile?: boolean;
fieldType?: "departure" | "arrival";
} = $props();
let open = $state(false);
let triggerRef = $state<HTMLButtonElement>(null!);
let showMobileModal = $state(false);
function checkIfMobile() {
if (browser) {
isMobile = window.innerWidth < 768;
}
}
// Set up resize listener
function setupResizeListener() {
if (browser) {
window.addEventListener("resize", checkIfMobile);
checkIfMobile(); // Initial check
return () => {
window.removeEventListener("resize", checkIfMobile);
};
}
}
function handleTriggerClick() {
airportVM.resetSearch();
if (isMobile) {
open = false;
// Show mobile modal with a slight delay to ensure popover is fully closed
setTimeout(() => {
showMobileModal = true;
// Dispatch field type event after modal is shown
setTimeout(() => {
window.dispatchEvent(
new CustomEvent("airportFieldSelected", {
detail: {
fieldType:
fieldType ||
(searchPlaceholder.includes("rival")
? "arrival"
: "departure"),
},
}),
);
}, 50);
}, 10);
}
}
function onSelect(newValue: string) {
closeAndFocusTrigger();
onChange?.(newValue);
}
function closeAndFocusTrigger() {
open = false;
tick().then(() => triggerRef.focus());
}
let cleanup: any | undefined = $state(undefined);
onMount(() => {
cleanup = setupResizeListener();
});
onDestroy(() => {
cleanup?.();
});
</script>
<MobileAirportSearchModal
bind:open={showMobileModal}
departureAirport={airportVM.departure}
arrivalAirport={airportVM.arrival}
{fieldType}
on:close={() => (showMobileModal = false)}
on:departureSelected={(e) => {
airportVM.setDepartureAirport(e.detail.iataCode);
showMobileModal = false;
}}
on:arrivalSelected={(e) => {
airportVM.setArrivalAirport(e.detail.iataCode);
showMobileModal = false;
}}
on:swap={() => {
airportVM.swapDepartureAndArrival();
}}
/>
<Popover.Root bind:open>
<Popover.Trigger bind:ref={triggerRef} onclick={() => handleTriggerClick()}>
{#snippet child({ props })}
<Button
variant="white"
class={cn(
"w-full items-center justify-between px-4 text-xs md:text-sm",
)}
{...props}
role="combobox"
aria-expanded={open}
>
<div class="flex w-full items-center gap-2">
<Icon
icon={searchPlaceholder.includes("rrival")
? PlaneLandIcon
: PlaneTakeOffIcon}
cls={cn("w-auto h-5")}
/>
<span
class="max-w-32 overflow-hidden overflow-ellipsis whitespace-nowrap text-left sm:max-w-64 md:max-w-32"
>
{currentValue
? `${currentValue.iataCode} (${currentValue.name})`
: placeholder}
</span>
</div>
<ChevronsUpDown class="h-4 w-auto opacity-20" />
</Button>
{/snippet}
</Popover.Trigger>
{#if !isMobile && open}
<Popover.Content class={cn(width, "p-0")}>
<div class="flex items-center rounded-t-md border-b bg-white">
<Icon icon={SearchIcon} cls="w-auto h-4 ml-2" />
<input
placeholder={searchPlaceholder}
oninput={(e) => {
airportVM.setAirportSearchQuery(e.currentTarget.value);
}}
class="ring-none w-full rounded-t-md border-none p-3 px-4 outline-none focus:border-none focus:outline-none focus:ring-0"
/>
</div>
<section
class="flex max-h-[20rem] w-full flex-col gap-2 overflow-y-auto p-2"
>
{#if airportVM.isLoading}
<div class="flex flex-col gap-2 p-2">
{#each Array(3) as _}
<div class="flex animate-pulse flex-col gap-1 p-2">
<div class="h-5 w-3/4 rounded bg-gray-200"></div>
<div class="h-3 w-1/2 rounded bg-gray-100"></div>
</div>
{/each}
</div>
{:else if airportVM.airportsQueried.length === 0}
<div class="grid place-items-center p-8">
<span>No results found</span>
</div>
{:else}
{#each airportVM.airportsQueried as opt}
<button
class={cn(
"flex w-full flex-col items-start gap-0 rounded-md bg-white p-2 text-start hover:bg-gray-100 active:bg-gray-200",
TRANSITION_COLORS,
)}
value={opt.iataCode}
onclick={() => onSelect(opt.iataCode)}
>
<span class="text-base font-medium tracking-wide">
{opt.iataCode}, {opt.name}, {opt.country}
</span>
<span
class="break-words text-xs font-medium text-gray-500"
>
{opt.municipality}
</span>
</button>
{/each}
{/if}
</section>
</Popover.Content>
{/if}
</Popover.Root>

View File

@@ -1,143 +0,0 @@
import { type Airport } from "$lib/domains/airport/data/entities";
import { ticketSearchStore } from "$lib/domains/ticket/data/store";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
export class AirportViewModel {
airportSearchQuery = $state("");
airportsQueried = $state<Airport[]>([]);
private airportSearchTimeout: NodeJS.Timer | undefined;
isLoading = $state(false);
departure = $state<Airport | null>(null);
arrival = $state<Airport | null>(null);
async setAirportSearchQuery(query: string) {
this.airportSearchQuery = query;
if (this.airportSearchTimeout) {
clearTimeout(this.airportSearchTimeout);
}
this.isLoading = true;
this.airportSearchTimeout = setTimeout(() => {
this.queryAirports();
}, 300);
}
resetSearch() {
this.airportSearchQuery = "";
this.airportsQueried = [];
this.isLoading = false;
}
async queryAirports() {
const api = get(trpcApiStore);
if (!api) {
this.isLoading = false;
return;
}
try {
const response = await api.ticket.searchAirports.query({
query: this.airportSearchQuery,
});
if (response.error) {
return toast.error(response.error.message, {
description: response.error.userHint,
});
}
if (response.data) {
this.airportsQueried = response.data.map((each) => {
return {
...each,
createdAt: new Date(each.createdAt),
updatedAt: new Date(each.updatedAt),
};
});
}
} finally {
this.isLoading = false;
}
}
async searchForAirportByCode(code: string) {
const api = get(trpcApiStore);
if (!api) {
return;
}
const res = await api.ticket.getAirportByCode.query({ code });
if (res.error) {
return toast.error(res.error.message, {
description: res.error.userHint,
});
}
if (!res.data) {
return toast.error("Airport not found", {
description:
"Please try searching manually or try again again later",
});
}
this.airportsQueried = [
{
...res.data,
createdAt: new Date(res.data.createdAt),
updatedAt: new Date(res.data.updatedAt),
},
];
this.setArrivalAirport(res.data.iataCode);
}
setDepartureAirport(code: string) {
const found = this.airportsQueried.find((f) => f.iataCode === code);
if (found) {
this.departure = found;
} else {
this.departure = null;
}
const meta = {} as Record<string, any>;
if (found && found.isoCountry) {
meta.isoCountry = found.isoCountry;
}
console.log("setting the meta to", meta);
ticketSearchStore.update((prev) => {
return { ...prev, departure: found?.iataCode ?? "", meta };
});
}
setArrivalAirport(code: string) {
const found = this.airportsQueried.find((f) => f.iataCode === code);
if (found) {
this.arrival = found;
} else {
this.arrival = null;
}
ticketSearchStore.update((prev) => {
return { ...prev, arrival: found?.iataCode ?? "" };
});
}
swapDepartureAndArrival() {
const temp = this.departure;
this.departure = this.arrival;
this.arrival = temp;
ticketSearchStore.update((prev) => {
return {
...prev,
departure: this.departure?.iataCode ?? "",
arrival: this.arrival?.iataCode ?? "",
};
});
}
}
export const airportVM = new AirportViewModel();

View File

@@ -1,259 +0,0 @@
<script lang="ts">
import { createEventDispatcher, onMount } from "svelte";
import SearchIcon from "~icons/solar/magnifer-linear";
import PlaneTakeOffIcon from "~icons/mingcute/flight-takeoff-line";
import PlaneLandIcon from "~icons/mingcute/flight-land-line";
import SwapIcon from "~icons/ant-design/swap-outlined";
import Button from "$lib/components/ui/button/button.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import { cn } from "$lib/utils";
import { airportVM } from "./airport.vm.svelte";
import { TRANSITION_COLORS } from "$lib/core/constants";
import type { Airport } from "../data/entities";
import * as Dialog from "$lib/components/ui/dialog";
import { browser } from "$app/environment";
let {
open = $bindable(false),
departureAirport,
arrivalAirport,
fieldType = "departure",
}: {
open?: boolean;
departureAirport: Airport | null;
arrivalAirport: Airport | null;
fieldType?: "departure" | "arrival";
} = $props();
const dispatch = createEventDispatcher();
let activeField: "departure" | "arrival" | null = $state(null);
let searchQuery = $state("");
let searchInputRef: HTMLInputElement | undefined = $state(undefined);
// Format airport display text
function formatAirportText(airport: Airport | null) {
return airport ? `${airport.iataCode} (${airport.name})` : "";
}
// Handle close
function handleClose() {
open = false;
activeField = null;
dispatch("close");
}
// Handle airport selection
function selectAirport(airport: Airport) {
if (activeField === "departure") {
dispatch("departureSelected", airport);
} else if (activeField === "arrival") {
dispatch("arrivalSelected", airport);
}
// Close after selection
handleClose();
}
// Handle field selection and focus the input
function setActiveField(field: "departure" | "arrival") {
activeField = field;
airportVM.resetSearch();
searchQuery = "";
// Focus the input with a slight delay to ensure rendering is complete
setTimeout(() => {
if (searchInputRef) {
searchInputRef.focus();
}
}, 50);
}
// Update search when query changes
$effect(() => {
if (searchQuery) {
airportVM.setAirportSearchQuery(searchQuery);
}
});
// Reset and auto-select field when dialog opens
$effect(() => {
if (open) {
activeField = fieldType;
}
});
// Handle swap function
function swapAirports() {
dispatch("swap");
}
onMount(() => {
if (browser) {
const handleFieldSelection = (event: CustomEvent) => {
if (open) {
const selectedField = event.detail.fieldType;
setActiveField(selectedField as "departure" | "arrival");
}
};
window.addEventListener(
"airportFieldSelected",
handleFieldSelection as EventListener,
);
return () => {
window.removeEventListener(
"airportFieldSelected",
handleFieldSelection as EventListener,
);
};
}
});
</script>
<Dialog.Root bind:open onOpenChange={(isOpen) => !isOpen && handleClose()}>
<Dialog.Content class="z-[100] flex h-[85vh] w-screen flex-col gap-0 p-0">
<div class="flex items-center justify-between border-b p-4">
<h2 class="text-lg font-medium">Airport Selection</h2>
<Dialog.Close />
</div>
<!-- Main content area -->
<div class="flex flex-1 flex-col overflow-hidden">
<!-- Header with direct input integration -->
<div class="flex flex-col gap-4 border-b p-4">
<div class="flex items-center justify-between">
<div class="mb-1 text-sm font-medium text-gray-500">
{activeField === "departure" ? "From" : "To"}
</div>
<!-- Swap button -->
<Button
size="icon"
variant="ghost"
class="h-8 w-8"
onclick={swapAirports}
>
<Icon icon={SwapIcon} cls="w-5 h-5" />
</Button>
</div>
<!-- The active input field -->
<div
class="relative flex w-full items-center rounded-lg border bg-white"
>
<Icon
icon={activeField === "departure"
? PlaneTakeOffIcon
: PlaneLandIcon}
cls="absolute left-3 h-5 w-5 text-gray-400"
/>
<input
bind:this={searchInputRef}
bind:value={searchQuery}
placeholder={activeField === "departure"
? departureAirport
? formatAirportText(departureAirport)
: "Search for departure airport"
: arrivalAirport
? formatAirportText(arrivalAirport)
: "Search for arrival airport"}
class="w-full border-none bg-transparent py-3 pl-10 pr-3 outline-none focus:ring-0"
autocomplete="off"
/>
</div>
<!-- Toggle buttons -->
<div class="flex w-full gap-2">
<Button
variant={activeField === "departure"
? "default"
: "outline"}
class="flex-1"
onclick={() => setActiveField("departure")}
>
<Icon icon={PlaneTakeOffIcon} cls="mr-2 h-4 w-4" />
Departure
</Button>
<Button
variant={activeField === "arrival"
? "default"
: "outline"}
class="flex-1"
onclick={() => setActiveField("arrival")}
>
<Icon icon={PlaneLandIcon} cls="mr-2 h-4 w-4" />
Arrival
</Button>
</div>
</div>
<!-- Search results area -->
<div class="flex-1 overflow-y-auto p-0">
{#if airportVM.isLoading}
<div class="flex flex-col p-2">
{#each Array(5) as _}
<div
class="flex animate-pulse flex-col gap-1 border-b p-4"
>
<div class="flex items-center gap-2">
<div
class="h-5 w-5 rounded-full bg-gray-200"
></div>
<div
class="h-5 w-16 rounded bg-gray-200"
></div>
</div>
<div
class="ml-7 mt-1 h-4 w-3/4 rounded bg-gray-100"
></div>
<div
class="ml-7 mt-1 h-3 w-1/2 rounded bg-gray-100"
></div>
</div>
{/each}
</div>
{:else if airportVM.airportsQueried.length === 0}
<div class="grid h-32 place-items-center">
<span class="text-gray-500">
{searchQuery
? "No airports found"
: "Start typing to search airports"}
</span>
</div>
{:else}
<div class="flex flex-col">
{#each airportVM.airportsQueried as airport}
<button
class={cn(
"flex w-full flex-col items-start gap-1 border-b p-4 text-start hover:bg-gray-50 active:bg-gray-100",
TRANSITION_COLORS,
)}
onclick={() => selectAirport(airport)}
>
<div class="flex w-full items-center gap-2">
<Icon
icon={activeField === "departure"
? PlaneTakeOffIcon
: PlaneLandIcon}
cls="h-5 w-5 text-gray-400"
/>
<span class="text-base font-medium">
{airport.iataCode}
</span>
</div>
<span class="pl-7 text-sm">
{airport.name}, {airport.country}
</span>
<span class="pl-7 text-xs text-gray-500">
{airport.municipality}
</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,4 +1,12 @@
import { round } from "$lib/core/num.utils";
import {
getCKUseCases,
type CheckoutFlowUseCases,
} from "$lib/domains/ckflow/domain/usecases";
import { and, arrayContains, eq, or, type Database } from "@pkg/db";
import { flightTicketInfo, order } from "@pkg/db/schema";
import { getError, Logger } from "@pkg/logger";
import { ERROR_CODES, type Result } from "@pkg/result";
import {
flightPriceDetailsModel,
flightTicketModel,
@@ -7,32 +15,17 @@ import {
type FlightTicket,
type TicketSearchDTO,
} from "./entities";
import { type Result, ERROR_CODES } from "@pkg/result";
import { getError, Logger } from "@pkg/logger";
import type { ScrapedTicketsDataSource } from "./scrape.data.source";
import { flightTicketInfo, order } from "@pkg/db/schema";
import { round } from "$lib/core/num.utils";
import {
getCKUseCases,
type CheckoutFlowUseCases,
} from "$lib/domains/ckflow/domain/usecases";
import type { AmadeusTicketsAPIDataSource } from "./amadeusapi.data.source";
export class TicketRepository {
private db: Database;
private ckUseCases: CheckoutFlowUseCases;
private scraper: ScrapedTicketsDataSource;
private amadeusApi: AmadeusTicketsAPIDataSource;
constructor(
db: Database,
scraper: ScrapedTicketsDataSource,
amadeusApi: AmadeusTicketsAPIDataSource,
) {
constructor(db: Database, scraper: ScrapedTicketsDataSource) {
this.db = db;
this.scraper = scraper;
this.ckUseCases = getCKUseCases();
this.amadeusApi = amadeusApi;
}
async getCheckoutUrlByRefOId(refOId: number): Promise<Result<string>> {
@@ -96,10 +89,7 @@ export class TicketRepository {
try {
let out = await this.scraper.searchForTickets(payload);
if (out.error || (out.data && out.data.length < 1)) {
out = await this.amadeusApi.searchForTickets(payload);
if (out.error) {
return out;
}
return out;
}
return out;
} catch (err) {

View File

@@ -1,202 +0,0 @@
import {
type FlightTicket,
type FlightPriceDetails,
type LimitedFlightTicket,
type PassengerCount,
TicketType,
CabinClass,
} from "../data/entities";
import type { LimitedOrderWithTicketInfoModel } from "$lib/domains/order/data/entities";
import { OrderStatus } from "$lib/domains/order/data/entities";
export class TestDataGenerator {
private static idCounter = 1;
static getNextId(): number {
return this.idCounter++;
}
static resetIds(): void {
this.idCounter = 1;
}
static createPriceDetails(
basePrice: number,
discountAmount = 0,
): FlightPriceDetails {
return {
currency: "USD",
basePrice,
discountAmount,
displayPrice: basePrice - discountAmount,
};
}
static createPassengerCount(
adults: number = 1,
children: number = 0,
): PassengerCount {
return { adults, children };
}
static createTicket(config: LimitedFlightTicket): FlightTicket {
const id = config.id ?? this.getNextId();
const now = new Date().toISOString();
return {
id,
ticketId: `T${id}`,
departure: config.departure,
arrival: config.arrival,
departureDate: config.departureDate,
returnDate: config.returnDate,
dates: [config.departureDate],
flightType: config.flightType,
flightIteneraries: {
outbound: [
{
flightId: `F${id}`,
flightNumber: `FL${id}`,
airline: {
code: "TEST",
name: "Test Airlines",
imageUrl: null,
},
departure: {
station: {
id: 1,
type: "AIRPORT",
code: config.departure,
name: "Test Airport",
city: "Test City",
country: "Test Country",
},
localTime: config.departureDate,
utcTime: config.departureDate,
},
destination: {
station: {
id: 2,
type: "AIRPORT",
code: config.arrival,
name: "Test Airport 2",
city: "Test City 2",
country: "Test Country",
},
localTime: config.departureDate,
utcTime: config.departureDate,
},
durationSeconds: 10800,
seatInfo: {
availableSeats: 100,
seatClass: config.cabinClass,
},
},
],
inbound: [],
},
priceDetails: config.priceDetails,
refOIds: [],
refundable: true,
passengerCounts: config.passengerCounts,
cabinClass: config.cabinClass,
bagsInfo: {
includedPersonalBags: 1,
includedHandBags: 1,
includedCheckedBags: 0,
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 },
},
},
},
lastAvailable: { availableSeats: 10 },
shareId: `SH${id}`,
checkoutUrl: `http://test.com/checkout/${id}`,
createdAt: now,
updatedAt: now,
};
}
static createOrder(config: {
basePrice: number;
displayPrice?: number;
discountAmount?: number;
pricePerPassenger?: number;
departureDate?: string;
passengerCount?: PassengerCount;
status?: OrderStatus;
}): LimitedOrderWithTicketInfoModel {
const id = this.getNextId();
const displayPrice = config.displayPrice ?? config.basePrice;
const pricePerPassenger = config.pricePerPassenger ?? displayPrice;
const passengerCount =
config.passengerCount ?? this.createPassengerCount();
return {
id,
basePrice: config.basePrice,
displayPrice,
discountAmount: config.discountAmount ?? 0,
pricePerPassenger,
fullfilledPrice: 0,
status: config.status ?? OrderStatus.PENDING_FULLFILLMENT,
flightTicketInfo: {
id,
departure: "NYC",
arrival: "LAX",
departureDate: config.departureDate ?? "2024-02-20",
returnDate: "",
flightType: TicketType.OneWay,
passengerCounts: passengerCount,
},
};
}
static createTestScenario(config: {
tickets: Array<LimitedFlightTicket>;
orders: Array<{
basePrice: number;
displayPrice?: number;
pricePerPassenger?: number;
departureDate?: string;
passengerCount?: PassengerCount;
}>;
}) {
const tickets = [] as FlightTicket[];
for (const t of config.tickets) {
const limitedTicket: LimitedFlightTicket = {
id: this.getNextId(),
departure: t.departure ?? "NYC",
arrival: t.arrival ?? "LAX",
departureDate: t.departureDate ?? new Date().toISOString(),
returnDate: t.returnDate ?? "",
dates: t.dates ?? [t.departureDate ?? new Date().toISOString()],
flightType: t.flightType,
priceDetails: t.priceDetails,
passengerCounts: t.passengerCounts ?? this.createPassengerCount(),
cabinClass: t.cabinClass ?? CabinClass.Economy,
};
tickets.push(this.createTicket(limitedTicket));
}
const orders = config.orders.map((o) => this.createOrder(o));
return { tickets, orders };
}
}

View File

@@ -1,38 +1,25 @@
import { AirportsRepository } from "$lib/domains/airport/data/repository";
import { env } from "$env/dynamic/private";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
import { db } from "@pkg/db";
import { getError, Logger } from "@pkg/logger";
import { DiscountType, type CouponModel } from "@pkg/logic/domains/coupon/data";
import { ERROR_CODES, type Result } from "@pkg/result";
import type {
FlightPriceDetails,
FlightTicket,
TicketSearchPayload,
} from "../data/entities";
import { TicketRepository } from "../data/repository";
import { getError, Logger } from "@pkg/logger";
import { env } from "$env/dynamic/private";
import { ScrapedTicketsDataSource } from "../data/scrape.data.source";
import { db } from "@pkg/db";
import { airportsDb } from "@pkg/airports-db";
import { ERROR_CODES, type Result } from "@pkg/result";
import { DiscountType, type CouponModel } from "@pkg/logic/domains/coupon/data";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
import { AmadeusTicketsAPIDataSource } from "../data/amadeusapi.data.source";
import { getRedisInstance } from "$lib/server/redis";
export class TicketController {
private repo: TicketRepository;
private airportsRepo: AirportsRepository;
private TICKET_SCRAPERS = ["kiwi"];
constructor(repo: TicketRepository, airportsRepo: AirportsRepository) {
constructor(repo: TicketRepository) {
this.repo = repo;
this.airportsRepo = airportsRepo;
}
async searchAirports(query: string) {
return this.airportsRepo.searchAirports(query);
}
async getAirportByCode(query: string) {
return this.airportsRepo.getAirportByCode(query);
}
async searchForTickets(
payload: TicketSearchPayload,
coupon: CouponModel | undefined,
@@ -166,14 +153,5 @@ export function getTC() {
env.TICKET_SCRAPER_API_URL,
env.API_KEY,
);
const amadeusApi = new AmadeusTicketsAPIDataSource(
env.AMADEUS_API_BASE_URL,
env.AMADEUS_API_KEY,
env.AMADEUS_API_SECRET,
getRedisInstance(),
);
return new TicketController(
new TicketRepository(db, ds, amadeusApi),
new AirportsRepository(airportsDb),
);
return new TicketController(new TicketRepository(db, ds));
}

View File

@@ -1,248 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import { TicketWithOrderSkiddaddler } from "./ticket.n.order.skiddaddler";
import { TestDataGenerator as TDG } from "../data/test.data.generator";
import { getJustDateString } from "@pkg/logic/core/date.utils";
import { CabinClass, TicketType } from "../data/entities";
function getTodaysDate() {
return new Date(getJustDateString(new Date()));
}
describe("TicketWithOrderSkiddaddler", () => {
beforeEach(() => {
TDG.resetIds();
});
describe("Direct Matching", () => {
const today = getTodaysDate();
it("should match ticket with exact price order", async () => {
const { tickets, orders } = TDG.createTestScenario({
tickets: [
{
id: 1,
departure: "NYC",
arrival: "LAX",
departureDate: today.toISOString(),
returnDate: "",
dates: [today.toISOString()],
flightType: TicketType.OneWay,
priceDetails: TDG.createPriceDetails(100),
passengerCounts: TDG.createPassengerCount(1, 0),
cabinClass: CabinClass.Economy,
},
],
orders: [
{
basePrice: 100,
displayPrice: 100,
departureDate: today.toISOString(),
},
],
});
const skiddaddler = new TicketWithOrderSkiddaddler(tickets, orders);
await skiddaddler.magic();
expect(tickets[0].refOIds).toContain(orders[0].id);
expect(tickets[0].priceDetails.discountAmount).toBe(0);
});
it("should handle multiple tickets with slight diffs", async () => {
const { tickets, orders } = TDG.createTestScenario({
tickets: [
{
id: 1,
departure: "NYC",
arrival: "LAX",
departureDate: today.toISOString(),
returnDate: "",
dates: [today.toISOString()],
flightType: TicketType.OneWay,
priceDetails: TDG.createPriceDetails(100),
passengerCounts: TDG.createPassengerCount(1, 0),
cabinClass: CabinClass.Economy,
},
{
id: 2,
departure: "NYC",
arrival: "LAX",
departureDate: today.toISOString(),
returnDate: "",
dates: [today.toISOString()],
flightType: TicketType.OneWay,
priceDetails: TDG.createPriceDetails(200),
passengerCounts: TDG.createPassengerCount(1, 0),
cabinClass: CabinClass.Economy,
},
],
orders: [
{
basePrice: 95,
displayPrice: 95,
departureDate: today.toISOString(),
},
{
basePrice: 190,
displayPrice: 190,
departureDate: today.toISOString(),
},
],
});
const skiddaddler = new TicketWithOrderSkiddaddler(tickets, orders);
await skiddaddler.magic();
expect(tickets[0].refOIds).toContain(orders[0].id);
expect(tickets[1].refOIds).toContain(orders[1].id);
expect(tickets[0].priceDetails.discountAmount).toBe(5);
expect(tickets[1].priceDetails.discountAmount).toBe(10);
});
it("should not pair up ticket with order with much greater price", async () => {
const { tickets, orders } = TDG.createTestScenario({
tickets: [
{
id: 1,
departure: "NYC",
arrival: "LAX",
departureDate: today.toISOString(),
returnDate: "",
dates: [today.toISOString()],
flightType: TicketType.OneWay,
priceDetails: TDG.createPriceDetails(100),
passengerCounts: TDG.createPassengerCount(1, 0),
cabinClass: CabinClass.Economy,
},
],
orders: [
{
basePrice: 1000,
displayPrice: 1000,
departureDate: today.toISOString(),
},
],
});
const skiddaddler = new TicketWithOrderSkiddaddler(tickets, orders);
await skiddaddler.magic();
expect(tickets[0].refOIds?.length).toBe(0);
});
it("should 'elevate' the order's rank due to it being the only option for ticket", async () => {
const { tickets, orders } = TDG.createTestScenario({
tickets: [
{
id: 1,
departure: "NYC",
arrival: "LAX",
departureDate: today.toISOString(),
returnDate: "",
dates: [today.toISOString()],
flightType: TicketType.OneWay,
priceDetails: TDG.createPriceDetails(400),
passengerCounts: TDG.createPassengerCount(1, 0),
cabinClass: CabinClass.Economy,
},
],
orders: [
{
basePrice: 200,
displayPrice: 200,
departureDate: today.toISOString(),
},
],
});
new TicketWithOrderSkiddaddler(tickets, orders).magic();
expect(tickets[0].refOIds).toContain(orders[0].id);
expect(tickets[0].priceDetails.displayPrice).toEqual(200);
});
});
describe("Partial Order Matching", () => {
const today = getTodaysDate();
it("should pick direct order via priceperpassenger field", async () => {
const { tickets, orders } = TDG.createTestScenario({
tickets: [
{
id: 1,
departure: "NYC",
arrival: "LAX",
departureDate: today.toISOString(),
returnDate: "",
dates: [today.toISOString()],
flightType: TicketType.OneWay,
priceDetails: TDG.createPriceDetails(100),
passengerCounts: TDG.createPassengerCount(1, 0),
cabinClass: CabinClass.Economy,
},
],
orders: [
{
basePrice: 270,
displayPrice: 270,
pricePerPassenger: 90,
departureDate: today.toISOString(),
passengerCount: TDG.createPassengerCount(3, 0),
},
],
});
new TicketWithOrderSkiddaddler(tickets, orders).magic();
expect(tickets[0].refOIds).toContain(orders[0].id);
expect(tickets[0].priceDetails.displayPrice).toBe(90);
});
it("should join multiple smaller order with the ticket", async () => {
const { tickets, orders } = TDG.createTestScenario({
tickets: [
{
id: 1,
departure: "NYC",
arrival: "LAX",
departureDate: today.toISOString(),
returnDate: "",
dates: [today.toISOString()],
flightType: TicketType.OneWay,
priceDetails: TDG.createPriceDetails(400),
passengerCounts: TDG.createPassengerCount(1, 0),
cabinClass: CabinClass.Economy,
},
],
orders: [
{
basePrice: 200,
displayPrice: 200,
pricePerPassenger: 100,
departureDate: today.toISOString(),
passengerCount: TDG.createPassengerCount(2, 0),
},
{
basePrice: 500,
displayPrice: 500,
pricePerPassenger: 50,
departureDate: today.toISOString(),
passengerCount: TDG.createPassengerCount(10, 0),
},
{
basePrice: 750,
displayPrice: 750,
pricePerPassenger: 150,
departureDate: today.toISOString(),
passengerCount: TDG.createPassengerCount(5, 0),
},
{
basePrice: 240,
displayPrice: 240,
pricePerPassenger: 80,
departureDate: today.toISOString(),
passengerCount: TDG.createPassengerCount(3, 0),
},
],
});
new TicketWithOrderSkiddaddler(tickets, orders).magic();
expect(tickets[0].refOIds?.length).toEqual(3);
expect(tickets[0].priceDetails.displayPrice).toBe(400);
});
});
});

View File

@@ -1,25 +1,24 @@
import { goto, replaceState } from "$app/navigation";
import { page } from "$app/state";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import {
type FlightPriceDetails,
type FlightTicket,
ticketSearchPayloadModel,
} from "$lib/domains/ticket/data/entities/index";
import { trpcApiStore } from "$lib/stores/api";
import type { Result } from "@pkg/result";
import { nanoid } from "nanoid";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { airportVM } from "$lib/domains/airport/view/airport.vm.svelte";
import { goto, replaceState } from "$app/navigation";
import { page } from "$app/state";
import { flightTicketStore, ticketSearchStore } from "../data/store";
import { ticketCheckoutVM } from "./checkout/flight-checkout.vm.svelte";
import { paymentInfoVM } from "./checkout/payment-info-section/payment.info.vm.svelte";
import {
MaxStops,
SortOption,
ticketFiltersStore,
} from "./ticket-filters.vm.svelte";
import type { Result } from "@pkg/result";
import { flightTicketStore, ticketSearchStore } from "../data/store";
import { nanoid } from "nanoid";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { ticketCheckoutVM } from "./checkout/flight-checkout.vm.svelte";
import { paymentInfoVM } from "./checkout/payment-info-section/payment.info.vm.svelte";
export class FlightTicketViewModel {
searching = $state(false);
@@ -355,12 +354,6 @@ export class FlightTicketViewModel {
out.append("cabinClass", info.cabinClass);
out.append("adults", info.passengerCounts.adults.toString());
out.append("children", info.passengerCounts.children.toString());
if (airportVM.departure) {
out.append("departure", airportVM.departure?.iataCode);
}
if (airportVM.arrival) {
out.append("arrival", airportVM.arrival?.iataCode);
}
out.append("departureDate", info.departureDate);
out.append("returnDate", info.returnDate);
out.append("meta", JSON.stringify(info.meta));