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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,205 +0,0 @@
import { eq, 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 { readFileSync } from "fs";
import { airport } from "@pkg/airports-db/schema";
import { chunk } from "$lib/core/array.utils";
import { COUNTRIES } from "$lib/core/countries";
export class AirportsRepository {
private db: AirportsDatabase;
constructor(db: AirportsDatabase) {
this.db = db;
}
async getAirport(query: string): Promise<Result<Airport>> {
try {
const qRes = await this.db.query.airport.findFirst({
where: eq(airport.gpsCode, query),
});
if (!qRes) {
return {
error: getError({
code: ERROR_CODES.DATABASE_ERROR,
message: "Could not find airport",
detail: "Could not find airport",
userHint:
"Please try again later, or contact us for more assistance",
}),
};
}
const parsed = airportModel.safeParse({
id: qRes.id,
type: qRes.type,
name: qRes.name,
latitudeDeg: qRes.latitudeDeg,
longitudeDeg: qRes.longitudeDeg,
elevationFt: qRes.elevationFt,
continent: qRes.continent,
isoCountry: qRes.isoCountry,
isoRegion: qRes.isoRegion,
municipality: qRes.municipality,
scheduledService: qRes.scheduledService,
gpsCode: qRes.gpsCode,
iataCode: qRes.iataCode,
localCode: qRes.localCode,
createdAt: qRes.createdAt,
updatedAt: qRes.updatedAt,
});
if (!parsed.success) {
Logger.info(parsed.error.errors);
return {
error: getError({
code: ERROR_CODES.DATABASE_ERROR,
message: "An error occcured while",
detail: "An error occured ",
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: "An error occcured while",
detail: "An error occured ",
userHint:
"Please try again later, or contact us for more assistance",
},
err,
),
};
}
}
async seedAirportsDb(filepath: string): Promise<Result<boolean>> {
try {
Logger.info(`Seeding airports from ${filepath}`);
const fileContent = readFileSync(filepath, {
flag: "r",
encoding: "utf-8",
});
const airportsData = JSON.parse(fileContent);
const payload = [] as any[];
const processedIataCodes = new Set<string>();
for (const [_, airportInfo] of Object.entries(airportsData)) {
const airport = airportInfo as {
icao: string;
iata: string;
name: string;
city: string;
state: string;
country: string;
elevation: number;
lat: number;
lon: number;
tz: string;
};
// Skip airports with empty IATA codes
if (!airport.iata || airport.iata.length < 1) {
continue;
}
// Skip if this IATA code has already been processed
if (processedIataCodes.has(airport.iata)) {
Logger.info(
`Skipping duplicate IATA code: ${airport.iata} for airport: ${airport.name}`,
);
continue;
}
processedIataCodes.add(airport.iata);
// Find matching country name from country code
const countryCode = airport.country;
const matchingCountry =
COUNTRIES.find((c) => c.code === countryCode)?.name ?? "";
const result = airportModel.safeParse({
id: -1,
type: "airport", // Assuming default type as "airport"
name: airport.name,
latitudeDeg: airport.lat,
longitudeDeg: airport.lon,
elevationFt: airport.elevation,
continent: "", // This field isn't in the JSON, might need a mapping function
isoCountry: countryCode,
country: matchingCountry,
isoRegion: `${countryCode}-${airport.state}`, // Constructing isoRegion from country and state
municipality: airport.city,
scheduledService: "yes", // Assuming default as "yes"
gpsCode: airport.icao,
iataCode: airport.iata,
localCode: airport.iata, // Using IATA as localCode as it's not provided
createdAt: new Date(),
updatedAt: new Date(),
});
if (!result.success) {
Logger.error(result.error.errors);
Logger.debug(airport);
continue;
}
const parsed = result.data;
payload.push({
ident: parsed.ident,
type: parsed.type,
name: parsed.name,
latitudeDeg: parsed.latitudeDeg,
longitudeDeg: parsed.longitudeDeg,
elevationFt: parsed.elevationFt,
continent: parsed.continent,
country: parsed.country,
isoCountry: parsed.isoCountry,
isoRegion: parsed.isoRegion,
municipality: parsed.municipality,
scheduledService: parsed.scheduledService,
gpsCode: parsed.gpsCode,
iataCode: parsed.iataCode,
localCode: parsed.localCode,
createdAt: parsed.createdAt,
updatedAt: parsed.updatedAt,
});
}
Logger.info(`Inserting ${payload.length} airports into the database`);
const chunkSize = 500;
for (const each of chunk(payload, chunkSize)) {
const out = await this.db
.insert(airport)
.values(each)
.returning({ insertedId: airport.id });
Logger.info(`Inserted ${out.length} airports into the database`);
}
return { data: true };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Could not seed the database",
detail: "An error occurred while seeding the database",
userHint:
"Please try again later, or contact us for more assistance",
},
err,
),
};
}
}
}

View File

@@ -1,16 +1,15 @@
<script lang="ts"> <script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import Icon from "$lib/components/atoms/icon.svelte"; import Icon from "$lib/components/atoms/icon.svelte";
import UsersIcon from "~icons/solar/users-group-rounded-broken"; import Title from "$lib/components/atoms/title.svelte";
import PackageIcon from "~icons/solar/box-broken";
import BackpackIcon from "~icons/solar/backpack-linear";
import BagIcon from "~icons/lucide/briefcase";
import SuitcaseIcon from "~icons/bi/suitcase2";
import SeatIcon from "~icons/solar/armchair-2-linear";
import type { FullOrderModel } from "$lib/domains/order/data/entities";
import { capitalize } from "$lib/core/string.utils";
import PinfoCard from "$lib/domains/passengerinfo/view/pinfo-card.svelte";
import { adminSiteNavMap } from "$lib/core/constants"; import { adminSiteNavMap } from "$lib/core/constants";
import { capitalize } from "$lib/core/string.utils";
import type { FullOrderModel } from "$lib/domains/order/data/entities";
import PinfoCard from "$lib/domains/passengerinfo/view/pinfo-card.svelte";
import SuitcaseIcon from "~icons/bi/suitcase2";
import BagIcon from "~icons/lucide/briefcase";
import BackpackIcon from "~icons/solar/backpack-linear";
import PackageIcon from "~icons/solar/box-broken";
import UsersIcon from "~icons/solar/users-group-rounded-broken";
let { order }: { order: FullOrderModel } = $props(); let { order }: { order: FullOrderModel } = $props();

View File

@@ -0,0 +1,4 @@
<script lang="ts">
</script>
<span>Show the product list here</span>

View File

@@ -0,0 +1 @@
<span>Show the product create and details modals here</span>

View File

@@ -0,0 +1,9 @@
import { protectedProcedure } from "$lib/server/trpc/t";
import { createTRPCRouter } from "$lib/trpc/t";
export const productRouter = createTRPCRouter({
getAllProducts: protectedProcedure.query(async ({}) => {
return {};
}),
// TODO: complete the implementation of this
});

View File

@@ -1,12 +1,9 @@
import type { UserModel } from "@pkg/logic/domains/user/data/entities";
import { AirportsRepository } from "$lib/domains/airport/data/repository";
import type { FlightTicket, TicketSearchPayload } from "./data/entities";
import { TicketRepository } from "$lib/domains/ticket/data/repository";
import { Logger } from "@pkg/logger";
import { env } from "$env/dynamic/private"; import { env } from "$env/dynamic/private";
import { ScrapedTicketsDataSource } from "./data/scrape.data.source"; import { TicketRepository } from "$lib/domains/ticket/data/repository";
import { db } from "@pkg/db"; import { db } from "@pkg/db";
import { airportsDb } from "@pkg/airports-db"; import { Logger } from "@pkg/logger";
import type { FlightTicket } from "./data/entities";
import { ScrapedTicketsDataSource } from "./data/scrape.data.source";
// if the discount is lower than 1 or more than 100, then we don't apply the discount // if the discount is lower than 1 or more than 100, then we don't apply the discount
function applyDiscount(price: number, discountPercent: number) { function applyDiscount(price: number, discountPercent: number) {
@@ -20,15 +17,9 @@ function applyDiscount(price: number, discountPercent: number) {
export class TicketController { export class TicketController {
private repo: TicketRepository; private repo: TicketRepository;
private airportsRepo: AirportsRepository;
constructor(repo: TicketRepository, airportsRepo: AirportsRepository) { constructor(repo: TicketRepository) {
this.repo = repo; this.repo = repo;
this.airportsRepo = airportsRepo;
}
async getAirport(query: string) {
return this.airportsRepo.getAirport(query);
} }
async createTicket(payload: FlightTicket) { async createTicket(payload: FlightTicket) {
@@ -56,10 +47,6 @@ export class TicketController {
async getTicketById(id: number) { async getTicketById(id: number) {
return this.repo.getTicketById(id); return this.repo.getTicketById(id);
} }
async seedAirportsDb(filepath: string) {
return this.airportsRepo.seedAirportsDb(filepath);
}
} }
export function getTC() { export function getTC() {
@@ -67,8 +54,5 @@ export function getTC() {
env.TICKET_SCRAPER_API_URL, env.TICKET_SCRAPER_API_URL,
env.API_KEY, env.API_KEY,
); );
return new TicketController( return new TicketController(new TicketRepository(db, ds));
new TicketRepository(db, ds),
new AirportsRepository(airportsDb),
);
} }

View File

@@ -1,10 +1,11 @@
import { userRouter } from "$lib/domains/user/domain/router";
import { createTRPCRouter } from "../t";
import { authRouter } from "$lib/domains/auth/domain/router";
import { emailAccountsRouter } from "$lib/domains/account/domain/router"; import { emailAccountsRouter } from "$lib/domains/account/domain/router";
import { authRouter } from "$lib/domains/auth/domain/router";
import { ckflowRouter } from "$lib/domains/ckflow/router"; import { ckflowRouter } from "$lib/domains/ckflow/router";
import { couponRouter } from "$lib/domains/coupon/router"; import { couponRouter } from "$lib/domains/coupon/router";
import { orderRouter } from "$lib/domains/order/domain/router"; import { orderRouter } from "$lib/domains/order/domain/router";
import { productRouter } from "$lib/domains/product/router";
import { userRouter } from "$lib/domains/user/domain/router";
import { createTRPCRouter } from "../t";
export const router = createTRPCRouter({ export const router = createTRPCRouter({
auth: authRouter, auth: authRouter,
@@ -13,6 +14,7 @@ export const router = createTRPCRouter({
emailAccounts: emailAccountsRouter, emailAccounts: emailAccountsRouter,
ckflow: ckflowRouter, ckflow: ckflowRouter,
coupon: couponRouter, coupon: couponRouter,
product: productRouter,
}); });
export type Router = typeof router; export type Router = typeof router;

View File

@@ -1,28 +1,27 @@
<script lang="ts"> <script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import { pageTitle } from "$lib/hooks/page-title.svelte";
import type { PageData } from "./$types";
import { users } from "$lib/domains/user/data/store";
import { userInfo } from "$lib/stores/session.info";
import { trpcApiStore } from "$lib/stores/api";
import { get } from "svelte/store";
import { onMount } from "svelte";
import { Button } from "$lib/components/ui/button";
import Icon from "$lib/components/atoms/icon.svelte"; import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import { Button } from "$lib/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "$lib/components/ui/card"; } from "$lib/components/ui/card";
import { users } from "$lib/domains/user/data/store";
import { pageTitle } from "$lib/hooks/page-title.svelte";
import { trpcApiStore } from "$lib/stores/api";
import { userInfo } from "$lib/stores/session.info";
import { onMount } from "svelte";
import { get } from "svelte/store";
import type { PageData } from "./$types";
// Icons // Icons
import RefreshIcon from "~icons/solar/refresh-linear";
import OrderIcon from "~icons/solar/box-minimalistic-broken"; import OrderIcon from "~icons/solar/box-minimalistic-broken";
import CheckoutIcon from "~icons/solar/cart-large-minimalistic-broken"; import CheckoutIcon from "~icons/solar/cart-large-minimalistic-broken";
import AlertIcon from "~icons/solar/shield-warning-linear";
import CheckIcon from "~icons/solar/check-circle-broken"; import CheckIcon from "~icons/solar/check-circle-broken";
import EyeIcon from "~icons/solar/eye-broken"; import EyeIcon from "~icons/solar/eye-broken";
import RefreshIcon from "~icons/solar/refresh-linear";
import AlertIcon from "~icons/solar/shield-warning-linear";
pageTitle.set("Dashboard"); pageTitle.set("Dashboard");

View File

@@ -1,21 +0,0 @@
import type { RequestHandler } from "./$types";
import { Logger } from "$lib/core/logger";
import { getTC } from "$lib/domains/ticket/controller";
import path from "path";
export const POST: RequestHandler = async () => {
Logger.debug("Seeding airports");
const tc = getTC();
const filepath = path.join(
process.cwd(),
"src/lib/domains/airport/data/airports.json",
);
const res = await tc.seedAirportsDb(filepath);
Logger.info("Done seeding airports");
return new Response(JSON.stringify(res), { status: 200 });
};

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { UserRoleMap } from "$lib/core/enums";
import { authClient } from "$lib/domains/auth/config/client"; import { authClient } from "$lib/domains/auth/config/client";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { UserRoleMap } from "$lib/core/enums";
onMount(async () => { onMount(async () => {
authClient.signUp authClient.signUp
@@ -12,6 +12,7 @@
username: "admin", username: "admin",
// @ts-ignore // @ts-ignore
role: UserRoleMap.ADMIN, role: UserRoleMap.ADMIN,
discountPercent: 0,
}) })
.then((res) => console.log(res)); .then((res) => console.log(res));
}); });

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 { 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 { import {
flightPriceDetailsModel, flightPriceDetailsModel,
flightTicketModel, flightTicketModel,
@@ -7,32 +15,17 @@ import {
type FlightTicket, type FlightTicket,
type TicketSearchDTO, type TicketSearchDTO,
} from "./entities"; } from "./entities";
import { type Result, ERROR_CODES } from "@pkg/result";
import { getError, Logger } from "@pkg/logger";
import type { ScrapedTicketsDataSource } from "./scrape.data.source"; 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 { export class TicketRepository {
private db: Database; private db: Database;
private ckUseCases: CheckoutFlowUseCases; private ckUseCases: CheckoutFlowUseCases;
private scraper: ScrapedTicketsDataSource; private scraper: ScrapedTicketsDataSource;
private amadeusApi: AmadeusTicketsAPIDataSource;
constructor( constructor(db: Database, scraper: ScrapedTicketsDataSource) {
db: Database,
scraper: ScrapedTicketsDataSource,
amadeusApi: AmadeusTicketsAPIDataSource,
) {
this.db = db; this.db = db;
this.scraper = scraper; this.scraper = scraper;
this.ckUseCases = getCKUseCases(); this.ckUseCases = getCKUseCases();
this.amadeusApi = amadeusApi;
} }
async getCheckoutUrlByRefOId(refOId: number): Promise<Result<string>> { async getCheckoutUrlByRefOId(refOId: number): Promise<Result<string>> {
@@ -96,11 +89,8 @@ export class TicketRepository {
try { try {
let out = await this.scraper.searchForTickets(payload); let out = await this.scraper.searchForTickets(payload);
if (out.error || (out.data && out.data.length < 1)) { if (out.error || (out.data && out.data.length < 1)) {
out = await this.amadeusApi.searchForTickets(payload);
if (out.error) {
return out; return out;
} }
}
return out; return out;
} catch (err) { } catch (err) {
return { return {

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 { import type {
FlightPriceDetails, FlightPriceDetails,
FlightTicket, FlightTicket,
TicketSearchPayload, TicketSearchPayload,
} from "../data/entities"; } from "../data/entities";
import { TicketRepository } from "../data/repository"; 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 { 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 { export class TicketController {
private repo: TicketRepository; private repo: TicketRepository;
private airportsRepo: AirportsRepository;
private TICKET_SCRAPERS = ["kiwi"]; private TICKET_SCRAPERS = ["kiwi"];
constructor(repo: TicketRepository, airportsRepo: AirportsRepository) { constructor(repo: TicketRepository) {
this.repo = repo; 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( async searchForTickets(
payload: TicketSearchPayload, payload: TicketSearchPayload,
coupon: CouponModel | undefined, coupon: CouponModel | undefined,
@@ -166,14 +153,5 @@ export function getTC() {
env.TICKET_SCRAPER_API_URL, env.TICKET_SCRAPER_API_URL,
env.API_KEY, env.API_KEY,
); );
const amadeusApi = new AmadeusTicketsAPIDataSource( return new TicketController(new TicketRepository(db, ds));
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),
);
} }

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

View File

@@ -1,438 +0,0 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import TicketSearchInput from "$lib/domains/ticket/view/ticket-search-input.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import CheckIcon from "~icons/material-symbols/check";
import Icon from "$lib/components/atoms/icon.svelte";
import { flightTicketVM } from "$lib/domains/ticket/view/ticket.vm.svelte";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import {
ArrowRight,
Calendar,
CreditCard,
Headphones,
Plane,
Search,
Shield,
} from "@lucide/svelte";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "$lib/components/ui/accordion";
import Badge from "$lib/components/ui/badge/badge.svelte";
import Card from "$lib/components/ui/card/card.svelte";
import CardContent from "$lib/components/ui/card/card-content.svelte";
import Testimonials from "$lib/components/organisms/testimonials.svelte";
import { airportVM } from "$lib/domains/airport/view/airport.vm.svelte";
type Destination = {
id: number;
name: string;
country: string;
image: string;
code: string;
discount?: number;
popular?: boolean;
};
const destinations: Destination[] = [
{
id: 1,
name: "Bali",
country: "Indonesia",
code: "DPS",
image: "https://images.unsplash.com/photo-1537996194471-e657df975ab4?auto=format&fit=crop&q=80&w=2338&ixlib=rb-4.0.3",
discount: 25,
popular: true,
},
{
id: 2,
name: "Tokyo",
country: "Japan",
code: "HND",
image: "https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?auto=format&fit=crop&q=80&w=1788&ixlib=rb-4.0.3",
discount: 25,
},
{
id: 3,
name: "Dubai",
country: "United Arab Emirates",
code: "DXB",
image: "/assets/images/destinations/dubai.jpg",
discount: 25,
popular: true,
},
{
id: 4,
name: "Santorini",
country: "Greece",
code: "JTR",
image: "https://images.unsplash.com/photo-1469796466635-455ede028aca?auto=format&fit=crop&q=80&w=2338&ixlib=rb-4.0.3",
discount: 25,
},
];
const features = [
{
icon: Search,
title: "Smart Flight Search",
description:
"Our advanced algorithm finds the best connections and prices across 500+ airlines, often saving you hundreds on each booking.",
},
{
icon: Shield,
title: "Secure Booking Protection",
description:
"Bank-level security for your payment details, with fraud monitoring and instant booking confirmations for peace of mind.",
},
{
icon: Calendar,
title: "Flexible Date Options",
description:
"Compare prices across multiple dates to find the best fares, with calendar view showing price variations throughout the month.",
},
{
icon: Headphones,
title: "24/7 Flight Assistance",
description:
"Our flight specialists are available around the clock for rebooking, cancellations, or help with unexpected travel changes.",
},
{
icon: CreditCard,
title: "Transparent Pricing",
description:
"See all fees and charges upfront with no hidden costs. Choose from multiple payment options including installment plans.",
},
{
icon: Plane,
title: "Flight Status Updates",
description:
"Receive real-time notifications about gate changes, delays, or cancellations directly to your email or mobile device.",
},
];
const faqItems = [
{
question: "How does the 25% discount work on FlyTicketTravel?",
answer: "Our 25% discount is automatically applied to all flight bookings made through FlyTicketTravel. There's no need for discount codes or special memberships - simply search for your flight, and you'll see the discounted price reflected in your total. This exclusive limited time discount is available on all routes and airlines in our system, making every destination more affordable for you.",
},
{
question: "How do I find the best flight deals on FlyTicketTravel?",
answer: "FlyTicketTravel searches across 300+ airlines to find the best prices. For the best deals, we recommend booking 6-8 weeks in advance, using our flexible date search option, and setting up price alerts for your desired route. Our smart search algorithm compares prices to ensure you always get competitive rates.",
},
{
question: "Can I cancel or change my flight after booking?",
answer: "Yes, most flights can be changed or canceled via contacting us. The specific policies and fees depend on the airline and fare type you've selected. You can view the change/cancellation policies before completing your booking. For assistance with changes, our customer support team is available 24/7.",
},
{
question: "How early should I arrive at the airport for my flight?",
answer: "We recommend arriving 2 hours before departure for domestic flights and 3 hours for international flights. During peak travel seasons or at busy airports, consider adding an extra 30 minutes. This allows sufficient time for check-in, security screening, and boarding procedures.",
},
{
question: "Does FlyTicketTravel charge booking fees?",
answer: "FlyTicketTravel maintains complete transparency in pricing. We don't cost you anything to use our platform. All mandatory fees and taxes are included in the prices you see during the search process. We do offer premium booking options with additional services for a small fee, but these are clearly marked and always optional.",
},
{
question: "How do I check in for my flight?",
answer: "Most airlines offer online check-in 24-48 hours before departure. You'll receive an email reminder from FlyTicketTravel when check-in opens for your flight with a direct link to the airline's check-in page. Alternatively, you can check in at the airport using the airline's kiosks or check-in counters.",
},
{
question: "What if my flight is delayed or canceled?",
answer: "FlyTicketTravel will notify you immediately via email and SMS if your flight is delayed or canceled. Our system automatically searches for alternative options when disruptions occur. You can also contact our 24/7 support team who will assist in rebooking or securing refunds according to the airline's policies.",
},
{
question: "How do baggage allowances work?",
answer: "Baggage allowances vary by airline, route, and fare class. The specific baggage details for your flight are displayed during the booking process and in your confirmation email. Most airlines include a personal item and cabin baggage in standard fares, while checked baggage may incur additional fees depending on the ticket type.",
},
{
question: "Is my payment information secure?",
answer: "Absolutely. FlyTicketTravel employs bank-level encryption and security protocols to protect your payment information. We are PCI-DSS compliant and never store your complete credit card details on our servers. We also offer secure payment options including credit/debit cards, PayPal, and digital wallets.",
},
];
async function handleDestinationSelect(destination: Destination) {
await airportVM.searchForAirportByCode(destination.code);
const searchElement = document.querySelector("#flight-search-form");
if (searchElement) {
searchElement.scrollIntoView({ behavior: "smooth" });
}
}
</script>
<MaxWidthWrapper cls="px-4">
<section
class="grid min-h-[70vh] w-full place-items-center rounded-xl bg-cover bg-right md:bg-center"
style="background-image: url('/assets/images/hero-bg.jpg');"
>
<div
class="grid h-full w-full place-items-center rounded-xl bg-gradient-to-br from-black/50 via-brand-950/50 to-black/50 p-5 lg:p-12 xl:px-20"
>
<div class="w-full pt-28">
<div
class="flex h-full w-full flex-col items-center justify-center gap-4 md:gap-8"
>
<div class="flex w-full flex-col items-center gap-4 md:gap-8">
<Title color="white" size="h3" weight="semibold" center>
Get 25% Off Your Next Adventure
</Title>
<p
class="max-w-prose text-center text-white/80 lg:text-lg"
>
Enjoy exclusive savings on flights worldwide. Search,
compare, and book with confidence on FlyTicketTravel.
</p>
</div>
<div
id="flight-search-form"
class="w-full rounded-lg bg-white/90 p-4 drop-shadow-xl md:p-8"
>
<TicketSearchInput
rowify
onSubmit={() => {
flightTicketVM.beginSearch();
}}
/>
</div>
</div>
</div>
</div>
</section>
</MaxWidthWrapper>
<section
id="destinations"
class="bg-gradient-to-b from-white via-brand-100 to-white py-24"
>
<div class="container mx-auto px-4">
<div class="mx-auto mb-16 flex max-w-3xl flex-col gap-2 text-center">
<Title color="black" weight="semibold" size="h3">
Top Flight Destinations
</Title>
<p class="text-lg text-muted-foreground">
Explore our most popular routes with competitive fares and
frequent departures. Click on a destination to start saving today.
</p>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{#each destinations as destination}
<Card
class="card-hover cursor-pointer overflow-hidden transition-all hover:scale-[1.02] hover:shadow-lg"
onclick={() => handleDestinationSelect(destination)}
>
<div class="relative h-48 overflow-hidden">
<img
src={destination.image}
alt={destination.name}
class="h-full w-full object-cover transition-transform duration-700 hover:scale-110"
/>
{#if destination.discount}
<div class="absolute left-3 top-3">
<Badge class="bg-brand-500">
{destination.discount}% OFF
</Badge>
</div>
{/if}
{#if destination.popular}
<div class="absolute right-3 top-3">
<Badge variant="secondary">Trending</Badge>
</div>
{/if}
</div>
<CardContent class="p-5">
<div class="flex items-start justify-between">
<div>
<h3 class="text-lg font-bold">
{destination.name}
</h3>
<p class="text-muted-foreground">
{destination.country}
</p>
<p class="mt-1 text-xs text-brand-600">
{destination.code} · Click to book
</p>
</div>
</div>
</CardContent>
</Card>
{/each}
</div>
</div>
</section>
<section class="py-24">
<div class="container mx-auto px-4">
<div class="mx-auto mb-16 flex max-w-3xl flex-col gap-2 text-center">
<Title color="black" weight="semibold" size="h3">
Flight Booking Features
</Title>
<p class="text-lg text-muted-foreground">
Enjoy premium booking tools with our guaranteed 25% discount on
every flight you book through our platform.
</p>
</div>
<div class="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
{#each features as feature}
<div
class="rounded-xl border border-border bg-card p-6 transition-colors hover:border-brand-300"
>
<div class="mb-4 inline-block rounded-full bg-brand-100 p-3">
<Icon icon={feature.icon} cls="h-8 w-8 text-brand-600" />
</div>
<h3 class="mb-2 text-xl font-semibold">{feature.title}</h3>
<p class="text-muted-foreground">{feature.description}</p>
</div>
{/each}
</div>
</div>
</section>
<MaxWidthWrapper
cls="grid grid-cols-1 md:place-items-center gap-8 p-4 md:p-8 md:grid-cols-2"
>
<div class="grid w-full place-items-center">
<img
src="/assets/images/about.jpg"
alt="Flight booking experience"
class="aspect-square h-full w-full max-w-xl rounded-xl object-cover"
/>
</div>
<div class="flex w-full flex-col gap-1 p-4 text-start">
<Title color="gradientPrimary" size="p" capitalize weight="medium">
Flight Booking Made Easy
</Title>
<Title color="black" size="h4">
Save a quarter of your travel budget with our exclusive discount
</Title>
<p>
Our advanced flight search technology compares thousands of routes and
fares in seconds, finding you the best value tickets with transparent
pricing and instant confirmation. Enjoy a seamless booking experience
from search to takeoff.
</p>
<div class="mt-4 flex flex-col gap-4">
<div class="flex items-start gap-3">
<Icon
icon={CheckIcon}
cls="h-6 w-auto text-brand-600 flex-shrink-0 mt-1"
/>
<div>
<h4 class="text-lg font-semibold">
Guaranteed 25% Discount Technology
</h4>
<p class="text-xs text-gray-600 lg:text-base">
Our engine analyzes millions of flight combinations across
hundreds of airlines to find you the the most competitive
price with convenience.
</p>
</div>
</div>
<div class="flex items-start gap-3">
<Icon
icon={CheckIcon}
cls="h-6 w-auto text-brand-600 flex-shrink-0 mt-1"
/>
<div>
<h4 class="text-lg font-semibold">
Real-Time Flight Information
</h4>
<p class="text-xs text-gray-600 lg:text-base">
From booking confirmation to landing, receive timely
updates about your flight status, gate changes, and
check-in reminders to ensure a smooth travel experience.
</p>
</div>
</div>
</div>
</div>
</MaxWidthWrapper>
<Testimonials />
<section id="faq" class="bg-gradient-to-b from-white via-brand-50 to-white py-24">
<div class="container mx-auto px-4">
<div class="mx-auto mb-16 max-w-3xl text-center">
<h2 class="mb-4 text-3xl font-bold md:text-4xl">
Frequently Asked Questions
</h2>
<p class="text-lg text-muted-foreground">
Get answers to common questions about booking flights with
FlyTicketTravel.
</p>
</div>
<div class="mx-auto max-w-3xl">
<div class="rounded-xl border border-border bg-card p-6">
<Accordion type="single" class="w-full">
{#each faqItems as item, i}
<AccordionItem value="item-{i}">
<AccordionTrigger>{item.question}</AccordionTrigger>
<AccordionContent>
<p class="text-muted-foreground">{item.answer}</p>
</AccordionContent>
</AccordionItem>
{/each}
</Accordion>
</div>
</div>
</div>
</section>
<section class="relative overflow-hidden bg-brand-600 py-24">
<div
class="absolute left-0 top-0 h-64 w-64 rounded-full bg-brand-500 opacity-20 blur-3xl"
></div>
<div
class="absolute bottom-0 right-0 h-96 w-96 rounded-full bg-brand-700 opacity-20 blur-3xl"
></div>
<div class="container relative z-10 mx-auto px-4">
<div class="mx-auto max-w-4xl text-center text-white">
<h2 class="mb-6 text-3xl font-bold md:text-5xl">
Ready to Book Your Flight?
</h2>
<p class="mb-10 text-xl text-white/90 md:text-2xl">
Find and book your perfect flight in just a few clicks. No hidden
fees, instant confirmation, and the best prices guaranteed.
</p>
<div class="flex flex-col justify-center gap-4 sm:flex-row">
<a href="/search">
<Button size="lg" variant="white">
Search Flights <ArrowRight class="ml-2 h-5 w-5" />
</Button>
</a>
<a href="/search">
<Button size="lg" variant="glassWhite">
View Flight Deals <ArrowRight class="ml-2 h-5 w-5" />
</Button>
</a>
</div>
<div class="mt-12 grid grid-cols-2 gap-6 text-center md:grid-cols-4">
<div>
<p class="text-3xl font-bold md:text-4xl">100+</p>
<p class="text-white/80">Airlines</p>
</div>
<div>
<p class="text-3xl font-bold md:text-4xl">2.4k+</p>
<p class="text-white/80">Flights Booked</p>
</div>
<div>
<p class="text-3xl font-bold md:text-4xl">24/7</p>
<p class="text-white/80">Flight Support</p>
</div>
<div>
<p class="text-3xl font-bold md:text-4xl">95%</p>
<p class="text-white/80">Customer Satisfaction</p>
</div>
</div>
</div>
</div>
</section>

View File

@@ -0,0 +1,4 @@
<script lang="ts">
</script>
<span>Show the thing to do the thing about the thing around the thing</span>

View File

@@ -27,12 +27,12 @@
let callback = function () { let callback = function () {
console.log("ct-ed"); console.log("ct-ed");
}; };
window.gtag("event", "conversion", { // window.gtag("event", "conversion", {
send_to: "AW-17085207253/ZFAVCLD6tMkaENWl7tI_", // send_to: "AW-17085207253/ZFAVCLD6tMkaENWl7tI_",
value: 1.0, // value: 1.0,
currency: "USD", // currency: "USD",
event_callback: callback, // event_callback: callback,
}); // });
return false; return false;
} }

View File

@@ -24,12 +24,12 @@
></script> ></script>
<script> <script>
window.dataLayer = window.dataLayer || []; // window.dataLayer = window.dataLayer || [];
window.gtag = function () { // window.gtag = function () {
dataLayer.push(arguments); // dataLayer.push(arguments);
}; // };
window.gtag("js", new Date()); // window.gtag("js", new Date());
window.gtag("config", "AW-17085207253"); // window.gtag("config", "AW-17085207253");
</script> </script>
</svelte:head> </svelte:head>

View File

@@ -9,7 +9,6 @@ WORKDIR /app
COPY package.json bun.lockb turbo.json ./ COPY package.json bun.lockb turbo.json ./
COPY packages/db packages/db COPY packages/db packages/db
COPY packages/airportsdb packages/airportsdb
RUN bun install RUN bun install

View File

@@ -1,175 +0,0 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

View File

@@ -1,14 +0,0 @@
import { defineConfig } from "drizzle-kit";
import "dotenv/config";
export default defineConfig({
schema: "./schema",
out: "./migrations",
dialect: "postgresql",
verbose: true,
strict: false,
dbCredentials: {
url: process.env.AIRPORTS_DB_URL ?? "",
},
});

View File

@@ -1,17 +0,0 @@
import "dotenv/config";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const dbUrl = process.env.AIRPORTS_DB_URL ?? "";
const client = postgres(dbUrl);
const db = drizzle(client, { schema });
export type AirportsDatabase = typeof db;
export * from "drizzle-orm";
export { db as airportsDb, schema };

View File

@@ -1,30 +0,0 @@
CREATE TABLE IF NOT EXISTS "airport" (
"id" serial PRIMARY KEY NOT NULL,
"type" varchar(64) NOT NULL,
"name" varchar(128) NOT NULL,
"gps_code" varchar(64) NOT NULL,
"ident" varchar(128),
"latitude_deg" numeric(10, 2),
"longitude_deg" numeric(10, 2),
"elevation_ft" numeric(10, 2),
"continent" varchar(64),
"country" varchar(172),
"iso_country" varchar(4) NOT NULL,
"iso_region" varchar(64) NOT NULL,
"municipality" varchar(64),
"scheduled_service" varchar(64),
"iata_code" varchar(16) NOT NULL,
"local_code" varchar(16) NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
"search_vector" "tsvector" GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce("airport"."name", '')), 'A') ||
setweight(to_tsvector('english', coalesce("airport"."iata_code", '')), 'A') ||
setweight(to_tsvector('english', coalesce("airport"."municipality", '')), 'B') ||
setweight(to_tsvector('english', coalesce("airport"."country", '')), 'C')
) STORED
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "name_idx" ON "airport" USING btree ("name");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "gps_code_idx" ON "airport" USING btree ("gps_code");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "search_vector_idx" ON "airport" USING gin ("search_vector");

View File

@@ -1,16 +0,0 @@
-- Custom SQL migration file, put your code below! --
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS btree_gin;
CREATE INDEX IF NOT EXISTS "trgm_idx_airport_name"
ON airport USING gin ("name" gin_trgm_ops);
CREATE INDEX IF NOT EXISTS "trgm_idx_airport_iatacode"
ON airport USING gin ("iata_code" gin_trgm_ops);
CREATE INDEX IF NOT EXISTS "trgm_idx_airport_municipality"
ON airport USING gin ("municipality" gin_trgm_ops);
CREATE INDEX IF NOT EXISTS "trgm_idx_airport_country"
ON airport USING gin ("country" gin_trgm_ops);

View File

@@ -1,198 +0,0 @@
{
"id": "2202b3d8-281c-474f-903d-27296ca06458",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.airport": {
"name": "airport",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"gps_code": {
"name": "gps_code",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"ident": {
"name": "ident",
"type": "varchar(128)",
"primaryKey": false,
"notNull": false
},
"latitude_deg": {
"name": "latitude_deg",
"type": "numeric(10, 2)",
"primaryKey": false,
"notNull": false
},
"longitude_deg": {
"name": "longitude_deg",
"type": "numeric(10, 2)",
"primaryKey": false,
"notNull": false
},
"elevation_ft": {
"name": "elevation_ft",
"type": "numeric(10, 2)",
"primaryKey": false,
"notNull": false
},
"continent": {
"name": "continent",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"country": {
"name": "country",
"type": "varchar(172)",
"primaryKey": false,
"notNull": false
},
"iso_country": {
"name": "iso_country",
"type": "varchar(4)",
"primaryKey": false,
"notNull": true
},
"iso_region": {
"name": "iso_region",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"municipality": {
"name": "municipality",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"scheduled_service": {
"name": "scheduled_service",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"iata_code": {
"name": "iata_code",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true
},
"local_code": {
"name": "local_code",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"search_vector": {
"name": "search_vector",
"type": "tsvector",
"primaryKey": false,
"notNull": false,
"generated": {
"as": "\n setweight(to_tsvector('english', coalesce(\"airport\".\"name\", '')), 'A') ||\n setweight(to_tsvector('english', coalesce(\"airport\".\"iata_code\", '')), 'A') ||\n setweight(to_tsvector('english', coalesce(\"airport\".\"municipality\", '')), 'B') ||\n setweight(to_tsvector('english', coalesce(\"airport\".\"country\", '')), 'C')\n ",
"type": "stored"
}
}
},
"indexes": {
"name_idx": {
"name": "name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"gps_code_idx": {
"name": "gps_code_idx",
"columns": [
{
"expression": "gps_code",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"search_vector_idx": {
"name": "search_vector_idx",
"columns": [
{
"expression": "search_vector",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "gin",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,198 +0,0 @@
{
"id": "864dcba9-6d53-4311-be3e-0a51a483dac2",
"prevId": "2202b3d8-281c-474f-903d-27296ca06458",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.airport": {
"name": "airport",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"gps_code": {
"name": "gps_code",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"ident": {
"name": "ident",
"type": "varchar(128)",
"primaryKey": false,
"notNull": false
},
"latitude_deg": {
"name": "latitude_deg",
"type": "numeric(10, 2)",
"primaryKey": false,
"notNull": false
},
"longitude_deg": {
"name": "longitude_deg",
"type": "numeric(10, 2)",
"primaryKey": false,
"notNull": false
},
"elevation_ft": {
"name": "elevation_ft",
"type": "numeric(10, 2)",
"primaryKey": false,
"notNull": false
},
"continent": {
"name": "continent",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"country": {
"name": "country",
"type": "varchar(172)",
"primaryKey": false,
"notNull": false
},
"iso_country": {
"name": "iso_country",
"type": "varchar(4)",
"primaryKey": false,
"notNull": true
},
"iso_region": {
"name": "iso_region",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"municipality": {
"name": "municipality",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"scheduled_service": {
"name": "scheduled_service",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"iata_code": {
"name": "iata_code",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true
},
"local_code": {
"name": "local_code",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"search_vector": {
"name": "search_vector",
"type": "tsvector",
"primaryKey": false,
"notNull": false,
"generated": {
"type": "stored",
"as": "\n setweight(to_tsvector('english', coalesce(\"airport\".\"name\", '')), 'A') ||\n setweight(to_tsvector('english', coalesce(\"airport\".\"iata_code\", '')), 'A') ||\n setweight(to_tsvector('english', coalesce(\"airport\".\"municipality\", '')), 'B') ||\n setweight(to_tsvector('english', coalesce(\"airport\".\"country\", '')), 'C')\n "
}
}
},
"indexes": {
"name_idx": {
"name": "name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"with": {},
"method": "btree",
"concurrently": false
},
"gps_code_idx": {
"name": "gps_code_idx",
"columns": [
{
"expression": "gps_code",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"with": {},
"method": "btree",
"concurrently": false
},
"search_vector_idx": {
"name": "search_vector_idx",
"columns": [
{
"expression": "search_vector",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"with": {},
"method": "gin",
"concurrently": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"views": {},
"sequences": {},
"roles": {},
"policies": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,20 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1746008605929,
"tag": "0000_friendly_thunderbolts",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1746008876617,
"tag": "0001_military_hannibal_king",
"breakpoints": true
}
]
}

View File

@@ -1,26 +0,0 @@
{
"name": "@pkg/airports-db",
"module": "index.ts",
"type": "module",
"scripts": {
"db:gen": "drizzle-kit generate --config=drizzle.config.ts",
"db:drop": "drizzle-kit drop --config=drizzle.config.ts",
"db:push": "drizzle-kit push --config=drizzle.config.ts",
"db:migrate": "drizzle-kit generate --config=drizzle.config.ts && drizzle-kit push --config=drizzle.config.ts",
"db:forcemigrate": "drizzle-kit generate --config=drizzle.config.ts && drizzle-kit push --config=drizzle.config.ts --force",
"dev": "drizzle-kit studio --host=0.0.0.0 --port=5421 --config=drizzle.config.ts --verbose"
},
"devDependencies": {
"@types/bun": "latest",
"@types/pg": "^8.11.10",
"drizzle-kit": "^0.28.0"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"dotenv": "^16.4.7",
"drizzle-orm": "^0.36.1",
"postgres": "^3.4.5"
}
}

View File

@@ -1,70 +0,0 @@
import { SQL, sql } from "drizzle-orm";
import {
pgTable,
serial,
varchar,
decimal,
timestamp,
index,
customType,
} from "drizzle-orm/pg-core";
const tsvector = customType<{ data: unknown }>({
dataType() {
return "tsvector";
},
});
export const airport = pgTable(
"airport",
{
id: serial("id").primaryKey(),
type: varchar("type", { length: 64 }).notNull(),
name: varchar("name", { length: 128 }).notNull(),
gpsCode: varchar("gps_code", { length: 64 }).notNull(),
ident: varchar("ident", { length: 128 }),
latitudeDeg: decimal("latitude_deg", { precision: 10, scale: 2 }),
longitudeDeg: decimal("longitude_deg", {
precision: 10,
scale: 2,
}),
elevationFt: decimal("elevation_ft", { precision: 10, scale: 2 }),
continent: varchar("continent", { length: 64 }),
country: varchar("country", { length: 172 }),
isoCountry: varchar("iso_country", { length: 4 }).notNull(),
isoRegion: varchar("iso_region", { length: 64 }).notNull(),
municipality: varchar("municipality", { length: 64 }),
scheduledService: varchar("scheduled_service", { length: 64 }),
iataCode: varchar("iata_code", { length: 16 }).notNull(),
localCode: varchar("local_code", { length: 16 }).notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
searchVector: tsvector("search_vector").generatedAlwaysAs(
(): SQL => sql`
setweight(to_tsvector('english', coalesce(${airport.name}, '')), 'A') ||
setweight(to_tsvector('english', coalesce(${airport.iataCode}, '')), 'A') ||
setweight(to_tsvector('english', coalesce(${airport.municipality}, '')), 'B') ||
setweight(to_tsvector('english', coalesce(${airport.country}, '')), 'C')
`,
),
},
(table) => {
return {
nameIdx: index("name_idx").on(table.name),
gpsCodeIdx: index("gps_code_idx").on(table.gpsCode),
searchVectorIdx: index("search_vector_idx").using(
"gin",
table.searchVector,
),
};
},
);

View File

@@ -0,0 +1,10 @@
ALTER TABLE "account" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "user" ALTER COLUMN "email_verified" SET DEFAULT false;--> statement-breakpoint
ALTER TABLE "user" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "user" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "user" ALTER COLUMN "banned" SET DEFAULT false;--> statement-breakpoint
ALTER TABLE "user" ALTER COLUMN "discount_percent" SET DEFAULT 0;--> statement-breakpoint
ALTER TABLE "verification" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "verification" ALTER COLUMN "created_at" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "verification" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "verification" ALTER COLUMN "updated_at" SET NOT NULL;

View File

@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS "product" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar(64) NOT NULL,
"description" text NOT NULL,
"long_description" text NOT NULL,
"price" numeric(12, 2) DEFAULT '0',
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);

View File

@@ -0,0 +1 @@
ALTER TABLE "product" ADD COLUMN "discount_price" numeric(12, 2) DEFAULT '0';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,27 @@
"when": 1746375159329, "when": 1746375159329,
"tag": "0003_unique_stephen_strange", "tag": "0003_unique_stephen_strange",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1760973109915,
"tag": "0004_parched_blue_shield",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1760975750129,
"tag": "0005_great_doomsday",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1760975763245,
"tag": "0006_puzzling_avengers",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,27 +1,30 @@
import { import {
boolean,
integer,
pgTable, pgTable,
text, text,
timestamp, timestamp,
boolean,
integer,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
export const user = pgTable("user", { export const user = pgTable("user", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
name: text("name").notNull(), name: text("name").notNull(),
email: text("email").notNull().unique(), email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull(), emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"), image: text("image"),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").notNull(), updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
username: text("username").unique(), username: text("username").unique(),
displayUsername: text("display_username"), displayUsername: text("display_username"),
role: text("role"), role: text("role"),
banned: boolean("banned"), banned: boolean("banned").default(false),
banReason: text("ban_reason"), banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"), banExpires: timestamp("ban_expires"),
parentId: text("parent_id"), parentId: text("parent_id"),
discountPercent: integer("discount_percent"), discount_percent: integer("discount_percent").default(0),
}); });
export const account = pgTable("account", { export const account = pgTable("account", {
@@ -38,8 +41,10 @@ export const account = pgTable("account", {
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"), scope: text("scope"),
password: text("password"), password: text("password"),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").notNull(), updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
}); });
export const verification = pgTable("verification", { export const verification = pgTable("verification", {
@@ -47,6 +52,9 @@ export const verification = pgTable("verification", {
identifier: text("identifier").notNull(), identifier: text("identifier").notNull(),
value: text("value").notNull(), value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(), expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at"), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at"), updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
}); });

View File

@@ -1,17 +1,17 @@
import { relations } from "drizzle-orm";
import { import {
pgTable,
serial,
varchar,
decimal,
boolean, boolean,
timestamp, date,
decimal,
integer, integer,
json, json,
pgTable,
serial,
text, text,
date, timestamp,
varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { user } from "./auth.out"; import { user } from "./auth.out";
import { relations } from "drizzle-orm";
export * from "./auth.out"; export * from "./auth.out";
@@ -60,6 +60,21 @@ export const order = pgTable("order", {
updatedAt: timestamp("updated_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(),
}); });
export const product = pgTable("product", {
id: serial("id").primaryKey(),
title: varchar("title", { length: 64 }).notNull(),
description: text("description").notNull(),
longDescription: text("long_description").notNull(),
price: decimal("price", { precision: 12, scale: 2 })
.$type<string>()
.default("0"),
discountPrice: decimal("discount_price", { precision: 12, scale: 2 })
.$type<string>()
.default("0"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const passengerPII = pgTable("passenger_pii", { export const passengerPII = pgTable("passenger_pii", {
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
firstName: varchar("first_name", { length: 64 }).notNull(), firstName: varchar("first_name", { length: 64 }).notNull(),

View File

@@ -10,25 +10,3 @@ export const numberModel = z
.transform((value) => { .transform((value) => {
return value !== undefined && isNaN(value) ? undefined : value; return value !== undefined && isNaN(value) ? undefined : value;
}); });
export const airportModel = z.object({
id: z.coerce.number().int(),
ident: z.string().nullable().optional(),
type: z.string(),
name: z.string(),
latitudeDeg: numberModel.default(0.0),
longitudeDeg: numberModel.default(0.0),
elevationFt: numberModel.default(0.0),
continent: z.string(),
isoCountry: z.string(),
country: z.string(),
isoRegion: z.string(),
municipality: z.string(),
scheduledService: z.string(),
gpsCode: z.coerce.string().default("----"),
iataCode: z.coerce.string().min(1),
localCode: z.coerce.string().default("----"),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
export type Airport = z.infer<typeof airportModel>;

View File

@@ -10,8 +10,8 @@ import {
type Database, type Database,
} from "@pkg/db"; } from "@pkg/db";
import { coupon } from "@pkg/db/schema"; import { coupon } from "@pkg/db/schema";
import { ERROR_CODES, type Result } from "@pkg/result";
import { getError, Logger } from "@pkg/logger"; import { getError, Logger } from "@pkg/logger";
import { ERROR_CODES, type Result } from "@pkg/result";
import { import {
couponModel, couponModel,
type CouponModel, type CouponModel,

View File

@@ -0,0 +1,14 @@
import { z } from "zod";
export const productModel = z.object({
id: z.string(),
linkId: z.string(),
title: z.string(),
description: z.string(),
longDescription: z.string(),
price: z.number(),
discountPrice: z.number(),
createdAt: z.coerce.string().optional(),
updatedAt: z.coerce.string().optional(),
});
export type ProductModel = z.infer<typeof productModel>;

View File

@@ -0,0 +1,15 @@
import { Database } from "@pkg/db";
export class ProductRepository {
private db: Database;
constructor(db: Database) {
this.db = db;
}
// TODO: compelte the crud method implementation
async listAllProducts() {}
async createProduct() {}
async updateProduct() {}
async deleteProduct() {}
}

View File

@@ -4,10 +4,6 @@ cd packages/db
bun run db:migrate bun run db:migrate
echo "🔄 Migrating Airports DB" echo " Migration complete"
cd ../airportsdb
bun run db:migrate
cd ../../ cd ../../