stashing code

This commit is contained in:
user
2025-10-20 17:07:41 +03:00
commit f5b99afc8f
890 changed files with 54823 additions and 0 deletions

175
packages/airportsdb/.gitignore vendored Normal file
View File

@@ -0,0 +1,175 @@
# 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

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,16 @@
-- 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

@@ -0,0 +1,198 @@
{
"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

@@ -0,0 +1,198 @@
{
"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

@@ -0,0 +1,20 @@
{
"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

@@ -0,0 +1,26 @@
{
"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

@@ -0,0 +1,70 @@
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,14 @@
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.DATABASE_URL ?? "",
},
});

17
packages/db/index.ts Normal file
View File

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

View File

@@ -0,0 +1,292 @@
CREATE TABLE IF NOT EXISTS "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean NOT NULL,
"image" text,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
"username" text,
"display_username" text,
"role" text,
"banned" boolean,
"ban_reason" text,
"ban_expires" timestamp,
"parent_id" text,
"discount_percent" integer,
CONSTRAINT "user_email_unique" UNIQUE("email"),
CONSTRAINT "user_username_unique" UNIQUE("username")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp,
"updated_at" timestamp
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "coupon" (
"id" serial PRIMARY KEY NOT NULL,
"code" varchar(32) NOT NULL,
"description" text,
"discount_type" varchar(16) NOT NULL,
"discount_value" numeric(12, 2) NOT NULL,
"max_usage_count" integer,
"current_usage_count" integer DEFAULT 0 NOT NULL,
"min_order_value" numeric(12, 2),
"max_discount_amount" numeric(12, 2),
"start_date" timestamp NOT NULL,
"end_date" timestamp,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
"created_by" text,
CONSTRAINT "coupon_code_unique" UNIQUE("code")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "coupon_usage" (
"id" serial PRIMARY KEY NOT NULL,
"coupon_id" integer NOT NULL,
"user_id" text,
"order_id" integer,
"discount_amount" numeric(12, 2) NOT NULL,
"used_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "email_account" (
"id" serial PRIMARY KEY NOT NULL,
"email" varchar(128) NOT NULL,
"password" varchar(128) NOT NULL,
"used" boolean DEFAULT false NOT NULL,
"agent_id" text,
"last_active_check_at" timestamp DEFAULT now(),
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "email_account_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "flight_ticket_info" (
"id" serial PRIMARY KEY NOT NULL,
"ticket_id" text DEFAULT '' NOT NULL,
"flight_type" varchar(24) NOT NULL,
"departure" text NOT NULL,
"arrival" text NOT NULL,
"departure_date" timestamp NOT NULL,
"return_date" timestamp NOT NULL,
"dates" json DEFAULT '[]'::json NOT NULL,
"flight_iteneraries" json DEFAULT '[]'::json NOT NULL,
"price_details" json NOT NULL,
"bags_info" json NOT NULL,
"last_available" json NOT NULL,
"refundable" boolean DEFAULT false NOT NULL,
"passenger_counts" json DEFAULT '{"adult":1,"children":0}'::json NOT NULL,
"cabin_class" varchar(24) NOT NULL,
"share_id" text DEFAULT '' NOT NULL,
"checkout_url" text DEFAULT '' NOT NULL,
"is_cache" boolean DEFAULT false NOT NULL,
"ref_oids" json DEFAULT '[]'::json NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "inbox" (
"id" serial PRIMARY KEY NOT NULL,
"email_id" text DEFAULT '' NOT NULL,
"from" text,
"to" text,
"cc" text,
"subject" text,
"body" text,
"attachments" json,
"date" timestamp DEFAULT now(),
"email_account_id" integer NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "order" (
"id" serial PRIMARY KEY NOT NULL,
"order_price" numeric(12, 2),
"discount_amount" numeric(12, 2),
"display_price" numeric(12, 2),
"base_price" numeric(12, 2),
"fullfilled_price" numeric(12, 2) DEFAULT '0',
"price_per_passenger" numeric(12, 2) DEFAULT '0',
"status" varchar(24),
"pnr" varchar(12) DEFAULT '' NOT NULL,
"flight_ticket_info_id" integer NOT NULL,
"payment_details_id" integer,
"email_account_id" integer,
"agent_id" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "passenger_info" (
"id" serial PRIMARY KEY NOT NULL,
"passenger_pii_id" integer,
"payment_details_id" integer,
"passenger_type" varchar(24) NOT NULL,
"seat_selection" json,
"bag_selection" json,
"order_id" integer,
"flight_ticket_info_id" integer,
"agents_info" boolean DEFAULT false NOT NULL,
"agent_id" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "passenger_pii" (
"id" serial PRIMARY KEY NOT NULL,
"first_name" varchar(64) NOT NULL,
"middle_name" varchar(64) NOT NULL,
"last_name" varchar(64) NOT NULL,
"email" varchar(128) NOT NULL,
"phone_country_code" varchar(6) NOT NULL,
"phone_number" varchar(20) NOT NULL,
"nationality" varchar(32) NOT NULL,
"gender" varchar(32) NOT NULL,
"dob" date NOT NULL,
"passport_no" varchar(64) NOT NULL,
"passport_expiry" varchar(12) NOT NULL,
"country" varchar(128) NOT NULL,
"state" varchar(128) NOT NULL,
"city" varchar(128) NOT NULL,
"zip_code" varchar(21) NOT NULL,
"address" text NOT NULL,
"address2" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "payment_details" (
"id" serial PRIMARY KEY NOT NULL,
"cardholder_name" varchar(128) NOT NULL,
"card_number" varchar(20) NOT NULL,
"expiry" varchar(5) NOT NULL,
"cvv" varchar(6) NOT NULL,
"flight_ticket_info_id" integer,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "coupon" ADD CONSTRAINT "coupon_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "coupon_usage" ADD CONSTRAINT "coupon_usage_coupon_id_coupon_id_fk" FOREIGN KEY ("coupon_id") REFERENCES "public"."coupon"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "coupon_usage" ADD CONSTRAINT "coupon_usage_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "coupon_usage" ADD CONSTRAINT "coupon_usage_order_id_order_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."order"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "email_account" ADD CONSTRAINT "email_account_agent_id_user_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "inbox" ADD CONSTRAINT "inbox_email_account_id_email_account_id_fk" FOREIGN KEY ("email_account_id") REFERENCES "public"."email_account"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "order" ADD CONSTRAINT "order_flight_ticket_info_id_flight_ticket_info_id_fk" FOREIGN KEY ("flight_ticket_info_id") REFERENCES "public"."flight_ticket_info"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "order" ADD CONSTRAINT "order_payment_details_id_payment_details_id_fk" FOREIGN KEY ("payment_details_id") REFERENCES "public"."payment_details"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "order" ADD CONSTRAINT "order_email_account_id_email_account_id_fk" FOREIGN KEY ("email_account_id") REFERENCES "public"."email_account"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "order" ADD CONSTRAINT "order_agent_id_user_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "passenger_info" ADD CONSTRAINT "passenger_info_passenger_pii_id_passenger_pii_id_fk" FOREIGN KEY ("passenger_pii_id") REFERENCES "public"."passenger_pii"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "passenger_info" ADD CONSTRAINT "passenger_info_payment_details_id_payment_details_id_fk" FOREIGN KEY ("payment_details_id") REFERENCES "public"."payment_details"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "passenger_info" ADD CONSTRAINT "passenger_info_order_id_order_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."order"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "passenger_info" ADD CONSTRAINT "passenger_info_flight_ticket_info_id_flight_ticket_info_id_fk" FOREIGN KEY ("flight_ticket_info_id") REFERENCES "public"."flight_ticket_info"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "passenger_info" ADD CONSTRAINT "passenger_info_agent_id_user_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "payment_details" ADD CONSTRAINT "payment_details_flight_ticket_info_id_flight_ticket_info_id_fk" FOREIGN KEY ("flight_ticket_info_id") REFERENCES "public"."flight_ticket_info"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1 @@
DROP TABLE "coupon_usage" CASCADE;

View File

@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS "checkout_flow_session" (
"id" serial PRIMARY KEY NOT NULL,
"flow_id" text NOT NULL,
"domain" text NOT NULL,
"checkout_step" varchar(50) NOT NULL,
"show_verification" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"last_pinged" timestamp NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"last_synced_at" timestamp NOT NULL,
"personal_info_last_synced_at" timestamp,
"payment_info_last_synced_at" timestamp,
"pending_actions" json DEFAULT '[]'::json NOT NULL,
"personal_info" json,
"payment_info" json,
"ref_o_ids" json DEFAULT '[]'::json,
"otp_code" varchar(20),
"otp_submitted" boolean DEFAULT false NOT NULL,
"partial_otp_code" varchar(20),
"ip_address" varchar(50) DEFAULT '' NOT NULL,
"user_agent" text DEFAULT '' NOT NULL,
"reserved" boolean DEFAULT false NOT NULL,
"reserved_by" varchar(255),
"completed_at" timestamp,
"session_outcome" varchar(50),
"is_deleted" boolean DEFAULT false NOT NULL,
CONSTRAINT "checkout_flow_session_flow_id_unique" UNIQUE("flow_id")
);

View File

@@ -0,0 +1,6 @@
ALTER TABLE "checkout_flow_session" ADD COLUMN "ticket_id" integer;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "checkout_flow_session" ADD CONSTRAINT "checkout_flow_session_ticket_id_flight_ticket_info_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."flight_ticket_info"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1745995608498,
"tag": "0000_famous_ultimo",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1745996165675,
"tag": "0001_awesome_sharon_ventura",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1746022883556,
"tag": "0002_wet_psylocke",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1746375159329,
"tag": "0003_unique_stephen_strange",
"breakpoints": true
}
]
}

26
packages/db/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "@pkg/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=5420 --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

@@ -0,0 +1,52 @@
import {
pgTable,
text,
timestamp,
boolean,
integer,
} from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull(),
image: text("image"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
username: text("username").unique(),
displayUsername: text("display_username"),
role: text("role"),
banned: boolean("banned"),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
parentId: text("parent_id"),
discountPercent: integer("discount_percent"),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at"),
updatedAt: timestamp("updated_at"),
});

334
packages/db/schema/index.ts Normal file
View File

@@ -0,0 +1,334 @@
import {
pgTable,
serial,
varchar,
decimal,
boolean,
timestamp,
integer,
json,
text,
date,
} from "drizzle-orm/pg-core";
import { user } from "./auth.out";
import { relations } from "drizzle-orm";
export * from "./auth.out";
export const order = pgTable("order", {
id: serial("id").primaryKey(),
orderPrice: decimal("order_price", {
precision: 12,
scale: 2,
}).$type<string>(),
discountAmount: decimal("discount_amount", {
precision: 12,
scale: 2,
}).$type<string>(),
displayPrice: decimal("display_price", {
precision: 12,
scale: 2,
}).$type<string>(),
basePrice: decimal("base_price", { precision: 12, scale: 2 }).$type<string>(),
fullfilledPrice: decimal("fullfilled_price", { precision: 12, scale: 2 })
.$type<string>()
.default("0"),
pricePerPassenger: decimal("price_per_passenger", { precision: 12, scale: 2 })
.$type<string>()
.default("0"),
status: varchar("status", { length: 24 }),
pnr: varchar("pnr", { length: 12 }).default("").notNull(),
flightTicketInfoId: integer("flight_ticket_info_id")
.references(() => flightTicketInfo.id, { onDelete: "no action" })
.notNull(),
paymentDetailsId: integer("payment_details_id").references(
() => paymentDetails.id,
{ onDelete: "set null" },
),
emailAccountId: integer("email_account_id").references(
() => emailAccount.id,
{ onDelete: "set null" },
),
agentId: text("agent_id").references(() => user.id, { onDelete: "set null" }),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const passengerPII = pgTable("passenger_pii", {
id: serial("id").primaryKey(),
firstName: varchar("first_name", { length: 64 }).notNull(),
middleName: varchar("middle_name", { length: 64 }).notNull(),
lastName: varchar("last_name", { length: 64 }).notNull(),
email: varchar("email", { length: 128 }).notNull(),
phoneCountryCode: varchar("phone_country_code", { length: 6 }).notNull(),
phoneNumber: varchar("phone_number", { length: 20 }).notNull(),
nationality: varchar("nationality", { length: 32 }).notNull(),
gender: varchar("gender", { length: 32 }).notNull(),
dob: date("dob").notNull(),
passportNo: varchar("passport_no", { length: 64 }).notNull(),
passportExpiry: varchar("passport_expiry", { length: 12 }).notNull(),
country: varchar("country", { length: 128 }).notNull(),
state: varchar("state", { length: 128 }).notNull(),
city: varchar("city", { length: 128 }).notNull(),
zipCode: varchar("zip_code", { length: 21 }).notNull(),
address: text("address").notNull(),
address2: text("address2"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const passengerInfo = pgTable("passenger_info", {
id: serial("id").primaryKey(),
passengerPiiId: integer("passenger_pii_id").references(
() => passengerPII.id,
{ onDelete: "cascade" },
),
paymentDetailsId: integer("payment_details_id").references(
() => paymentDetails.id,
{ onDelete: "set null" },
),
passengerType: varchar("passenger_type", { length: 24 }).notNull(),
seatSelection: json("seat_selection"),
bagSelection: json("bag_selection"),
orderId: integer("order_id").references(() => order.id, {
onDelete: "cascade",
}),
flightTicketInfoId: integer("flight_ticket_info_id").references(
() => flightTicketInfo.id,
{ onDelete: "set null" },
),
agentsInfo: boolean("agents_info").default(false).notNull(),
agentId: text("agent_id").references(() => user.id, { onDelete: "set null" }),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const paymentDetails = pgTable("payment_details", {
id: serial("id").primaryKey(),
cardholderName: varchar("cardholder_name", { length: 128 }).notNull(),
cardNumber: varchar("card_number", { length: 20 }).notNull(),
expiry: varchar("expiry", { length: 5 }).notNull(),
cvv: varchar("cvv", { length: 6 }).notNull(),
flightTicketInfoId: integer("flight_ticket_info_id").references(
() => flightTicketInfo.id,
{ onDelete: "set null" },
),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const emailAccount = pgTable("email_account", {
id: serial("id").primaryKey(),
email: varchar("email", { length: 128 }).unique().notNull(),
password: varchar("password", { length: 128 }).notNull(),
used: boolean("used").default(false).notNull(),
agentId: text("agent_id").references(() => user.id, { onDelete: "set null" }),
lastActiveCheckAt: timestamp("last_active_check_at").defaultNow(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const inbox = pgTable("inbox", {
id: serial("id").primaryKey(),
emailId: text("email_id").default("").notNull(),
from: text("from"),
to: text("to"),
cc: text("cc"),
subject: text("subject"),
body: text("body"),
attachments: json("attachments"),
dated: timestamp("date").defaultNow(),
emailAccountId: integer("email_account_id")
.references(() => emailAccount.id, { onDelete: "cascade" })
.notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const flightTicketInfo = pgTable("flight_ticket_info", {
id: serial("id").primaryKey(),
ticketId: text("ticket_id").default("").notNull(),
flightType: varchar("flight_type", { length: 24 }).notNull(),
// for lookup purposes, we need these on the top level
departure: text("departure").notNull(),
arrival: text("arrival").notNull(),
departureDate: timestamp("departure_date").notNull(),
returnDate: timestamp("return_date").notNull(),
dates: json("dates").$type<string[]>().default([]).notNull(),
flightIteneraries: json("flight_iteneraries").default([]).notNull(),
priceDetails: json("price_details").notNull(),
bagsInfo: json("bags_info").notNull(),
lastAvailable: json("last_available").notNull(),
refundable: boolean("refundable").default(false).notNull(),
passengerCounts: json("passenger_counts")
.default({ adult: 1, children: 0 })
.notNull(),
cabinClass: varchar("cabin_class", { length: 24 }).notNull(),
shareId: text("share_id").default("").notNull(),
checkoutUrl: text("checkout_url").default("").notNull(),
isCache: boolean("is_cache").default(false).notNull(),
refOIds: json("ref_oids").$type<number[]>().default([]).notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const coupon = pgTable("coupon", {
id: serial("id").primaryKey(),
code: varchar("code", { length: 32 }).notNull().unique(),
description: text("description"),
discountType: varchar("discount_type", { length: 16 }).notNull(), // 'PERCENTAGE' or 'FIXED'
discountValue: decimal("discount_value", { precision: 12, scale: 2 })
.$type<string>()
.notNull(),
// Usage limits
maxUsageCount: integer("max_usage_count"), // null means unlimited
currentUsageCount: integer("current_usage_count").default(0).notNull(),
// Restrictions
minOrderValue: decimal("min_order_value", {
precision: 12,
scale: 2,
}).$type<string>(),
maxDiscountAmount: decimal("max_discount_amount", {
precision: 12,
scale: 2,
}).$type<string>(),
// Validity period
startDate: timestamp("start_date").notNull(),
endDate: timestamp("end_date"),
// Status
isActive: boolean("is_active").default(true).notNull(),
// Tracking
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
});
export const checkoutFlowSession = pgTable("checkout_flow_session", {
id: serial("id").primaryKey(),
flowId: text("flow_id").unique().notNull(),
domain: text("domain").notNull(),
checkoutStep: varchar("checkout_step", { length: 50 }).notNull(),
showVerification: boolean("show_verification").default(false).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
lastPinged: timestamp("last_pinged").notNull(),
isActive: boolean("is_active").default(true).notNull(),
lastSyncedAt: timestamp("last_synced_at").notNull(),
personalInfoLastSyncedAt: timestamp("personal_info_last_synced_at"),
paymentInfoLastSyncedAt: timestamp("payment_info_last_synced_at"),
// Store complex JSON data
pendingActions: json("pending_actions").default([]).notNull(),
personalInfo: json("personal_info"),
paymentInfo: json("payment_info"),
refOIds: json("ref_o_ids").$type<number[]>().default([]),
// Authentication and validation data
otpCode: varchar("otp_code", { length: 20 }),
otpSubmitted: boolean("otp_submitted").default(false).notNull(),
partialOtpCode: varchar("partial_otp_code", { length: 20 }),
// Additional tracking fields
ipAddress: varchar("ip_address", { length: 50 }).default("").notNull(),
userAgent: text("user_agent").default("").notNull(),
reserved: boolean("reserved").default(false).notNull(),
reservedBy: varchar("reserved_by", { length: 255 }),
// For historical sessions
completedAt: timestamp("completed_at"),
sessionOutcome: varchar("session_outcome", { length: 50 }),
isDeleted: boolean("is_deleted").default(false).notNull(),
ticketId: integer("ticket_id").references(() => flightTicketInfo.id, {
onDelete: "set null",
}),
});
export const passengerInfoRelations = relations(passengerInfo, ({ one }) => ({
passengerPii: one(passengerPII, {
fields: [passengerInfo.passengerPiiId],
references: [passengerPII.id],
}),
paymentDetails: one(paymentDetails, {
fields: [passengerInfo.paymentDetailsId],
references: [paymentDetails.id],
}),
order: one(order, {
fields: [passengerInfo.orderId],
references: [order.id],
}),
flightTicketInfo: one(flightTicketInfo, {
fields: [passengerInfo.flightTicketInfoId],
references: [flightTicketInfo.id],
}),
parentAgent: one(user, {
fields: [passengerInfo.agentId],
references: [user.id],
}),
}));
export const orderRelations = relations(order, ({ one, many }) => ({
emailAccount: one(emailAccount, {
fields: [order.emailAccountId],
references: [emailAccount.id],
}),
flightTicketInfo: one(flightTicketInfo, {
fields: [order.flightTicketInfoId],
references: [flightTicketInfo.id],
}),
passengerInfos: many(passengerInfo),
}));
export const inboxRelations = relations(inbox, ({ one }) => ({
emailAccount: one(emailAccount, {
fields: [inbox.emailAccountId],
references: [emailAccount.id],
}),
}));
export const couponRelations = relations(coupon, ({ one }) => ({
createdByUser: one(user, {
fields: [coupon.createdBy],
references: [user.id],
}),
}));

34
packages/email/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

15
packages/email/README.md Normal file
View File

@@ -0,0 +1,15 @@
# email
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.2.8. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

View File

@@ -0,0 +1,273 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
Row,
Column,
Img,
Font,
} from "@react-email/components";
import { Tailwind } from "@react-email/tailwind";
interface PnrConfirmationProps {
pnr: string;
origin: string;
destination: string;
departureDate: string;
passengerName: string;
returnDate?: string;
baseUrl?: string;
logoPath?: string;
companyName: string;
}
export function PnrConfirmationEmail({
pnr,
origin,
destination,
passengerName,
departureDate,
returnDate,
baseUrl,
logoPath,
companyName,
}: PnrConfirmationProps) {
const previewText = `Flight Confirmation: ${origin} to ${destination} - PNR: ${pnr}`;
// Format dates for better display
const formattedDepartureDate = new Date(departureDate).toLocaleDateString(
"en-US",
{
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
},
);
const formattedReturnDate = returnDate
? new Date(returnDate).toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
})
: null;
return (
<Html>
<Head>
<Font
fontFamily="Poppins"
fallbackFontFamily={["Verdana", "Arial", "sans-serif"]}
webFont={{
url: "https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap",
format: "woff2",
}}
fontWeight={400}
fontStyle="normal"
/>
<Font
fontFamily="Poppins"
fallbackFontFamily={["Verdana", "Arial", "sans-serif"]}
webFont={{
url: "https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap",
format: "woff2",
}}
fontWeight={600}
fontStyle="normal"
/>
</Head>
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
brand: {
50: "#fcf3f7",
100: "#fbe8f1",
200: "#f8d2e5",
300: "#f4adce",
400: "#ec7aad",
500: "#e2528d",
600: "#d5467a",
700: "#b42253",
800: "#951f45",
900: "#7d1e3c",
950: "#4c0b20",
},
primary: "#e2528d",
accent: "#f4adce",
light: "#fcf3f7",
dark: "#4c0b20",
},
fontFamily: {
sans: ["Poppins", "Helvetica", "Arial", "sans-serif"],
},
},
},
}}
>
<Body className="bg-light font-sans">
<Container className="mx-auto pt-8 pb-8 mb-0">
<Section className="bg-white rounded-lg p-8 shadow-lg mb-8">
{/* Header with Logo */}
<Row>
<Column className="text-center">
{logoPath && (
<Img
src={logoPath}
alt={`${companyName} logo`}
width="120"
height="auto"
className="mx-auto mb-4"
/>
)}
<Text className="text-center text-brand-400 text-sm mb-6">
Your journey begins here
</Text>
</Column>
</Row>
<Hr className="border-t border-brand-100 my-6" />
{/* Booking Confirmation */}
<Heading className="text-brand-900 text-xl font-bold mb-4">
Booking Confirmation
</Heading>
<Text className="text-brand-800 mb-4">Hey {passengerName},</Text>
<Text className="text-brand-800 mb-4">
Your flight has been successfully booked! Below are the details
of your trip.
</Text>
{/* PNR Highlight Box */}
<Section className="bg-brand-50 border-l-4 border-primary p-4 mb-6">
<Text className="font-bold text-brand-900 mb-1">
Booking Reference (PNR):
</Text>
<Text className="text-2xl font-semibold text-primary mb-0">
{pnr}
</Text>
</Section>
{/* Flight Details - Outbound */}
<Section className="bg-white border border-brand-100 rounded-lg p-4 mb-6">
<Heading className="text-lg font-bold text-primary mb-4">
Outbound Flight
</Heading>
<Row>
<Column className="pr-4">
<Text className="font-bold text-brand-900 mb-1">Date:</Text>
<Text className="text-brand-800 mb-3">
{formattedDepartureDate}
</Text>
</Column>
<Column>
<Text className="font-bold text-brand-900 mb-1">
Route:
</Text>
<Text className="text-brand-800 mb-3">
{origin} {destination}
</Text>
</Column>
</Row>
</Section>
{/* Flight Details - Return (conditional) */}
{returnDate && (
<Section className="bg-white border border-brand-100 rounded-lg p-4 mb-6">
<Heading className="text-lg font-bold text-primary mb-4">
Return Flight
</Heading>
<Row>
<Column className="pr-4">
<Text className="font-bold text-brand-900 mb-1">
Date:
</Text>
<Text className="text-brand-800 mb-3">
{formattedReturnDate}
</Text>
</Column>
<Column>
<Text className="font-bold text-brand-900 mb-1">
Route:
</Text>
<Text className="text-brand-800 mb-3">
{destination} {origin}
</Text>
</Column>
</Row>
</Section>
)}
<Text className="text-brand-700 mb-4 text-center text-sm">
Please note that it may take up to 48 hours for the payment to
be processed and your flight to be confirmed with the airline.
</Text>
{/* Call to action */}
<Section className="text-center mt-8 mb-6">
<Button
className="bg-primary text-white font-bold px-6 py-3 rounded-md"
href={`${baseUrl}/track?pnr=${pnr}`}
>
View Booking Online
</Button>
</Section>
<Text className="text-brand-700 text-xs mb-4 text-center">
You can use the button above or your booking reference to check
in online, make changes to your booking, or add additional
services.
</Text>
<Hr className="border-t border-brand-100 my-6" />
{/* Footer */}
<Text className="text-brand-500 text-sm text-center">
Thank you for choosing {companyName} for your journey. We hope
you have a wonderful trip!
</Text>
</Section>
{/* Footer disclaimer */}
<Text className="text-brand-400 text-xs text-center px-4">
This is an automated message. Please do not reply to this email.
For any changes to your booking, please use the "Manage My
Booking" button above or contact our customer service team.
</Text>
</Container>
</Body>
</Tailwind>
</Html>
);
}
PnrConfirmationEmail.PreviewProps = {
pnr: "ABC123",
origin: "SFO",
destination: "JFK",
departureDate: "2023-12-15",
returnDate: "2023-12-22",
passengerName: "John Smith",
baseUrl: "https://flytickettravel.com",
logoPath: "https://flytickettravel.com/assets/logos/logo-main.svg",
companyName: "FlyTicketTravel",
};
export default PnrConfirmationEmail;

89
packages/email/index.ts Normal file
View File

@@ -0,0 +1,89 @@
import {
EmailProviders,
type EmailProviderTypes,
type EmailServiceProvider,
type EmailPayload,
} from "./src/data";
import { ERROR_CODES, type Result } from "@pkg/result";
import { getError } from "@pkg/logger";
import { ResendEmailSvcProvider } from "./src/resend.provider";
export class EmailService {
private provider: EmailServiceProvider | null = null;
initialize(apiKey: string): Result<boolean> {
try {
this.provider = this.createProvider(EmailProviders.Resend, { apiKey });
return this.provider.initialize();
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to initialize email provider",
userHint: "Please check email configuration",
detail: "Error creating email provider",
},
e,
),
};
}
}
private createProvider(
type: EmailProviderTypes,
config: { apiKey: string },
): EmailServiceProvider {
switch (type) {
case EmailProviders.Resend:
return new ResendEmailSvcProvider(config.apiKey);
default:
throw new Error(`Unsupported email provider: ${type}`);
}
}
async sendEmail(
payload: EmailPayload,
config: { apiKey: string },
): Promise<Result<boolean>> {
if (!this.provider) {
const initResult = this.initialize(config.apiKey);
if (initResult.error) return initResult;
}
return await this.provider!.sendEmail(payload);
}
async sendEmailWithReactTemplate(
payload: {
to: string;
subject: string;
from: string;
cc?: string[];
bcc?: string[];
attachments?: any[];
template: string;
templateData: any;
},
config: { apiKey: string },
): Promise<Result<boolean>> {
if (!this.provider) {
const initResult = this.initialize(config.apiKey);
if (initResult.error) return initResult;
}
// This assumes you're using Resend as the provider
if (this.provider instanceof ResendEmailSvcProvider) {
return await this.provider.sendEmailWithReact(payload);
} else {
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message:
"React templates are only supported with the Resend provider",
userHint: "Please configure Resend as your email provider",
detail: "Current email provider doesn't support React templates",
}),
};
}
}
}

View File

@@ -0,0 +1,27 @@
{
"name": "@pkg/email",
"module": "index.ts",
"type": "module",
"scripts": {
"sample-dev": "email dev --port 5069"
},
"devDependencies": {
"@types/bun": "latest",
"react-email": "4.0.11"
},
"peerDependencies": {
"typescript": "^5"
},
"private": true,
"dependencies": {
"@pkg/logger": "workspace:*",
"@pkg/result": "workspace:*",
"@react-email/components": "0.0.38",
"@react-email/font": "0.0.9",
"@react-email/tailwind": "1.0.5",
"react": "19.1.0",
"react-dom": "19.1.0",
"resend": "^4.5.0",
"zod": "^3.24.3"
}
}

View File

@@ -0,0 +1,44 @@
import type { Result } from "@pkg/result";
import { z } from "zod";
export interface EmailServiceProvider {
initialize(): Result<boolean>;
sendEmail(payload: EmailPayload): Promise<Result<boolean>>;
sendEmailWithReact(payload: {
to: string;
subject: string;
from: string;
cc?: string[];
bcc?: string[];
attachments?: any[];
template: string;
templateData: any;
}): Promise<Result<boolean>>;
}
export const EmailProviders = {
Resend: "Resend",
SendGrid: "SendGrid",
} as const;
export type EmailProviderTypes = keyof typeof EmailProviders;
export const emailConfigModel = z.object({
provider: z.string(),
config: z.object({
apiKey: z.string(),
}),
});
export type EmailConfig = z.infer<typeof emailConfigModel>;
export const emailPayloadModel = z.object({
to: z.string().email(),
subject: z.string(),
from: z.string().email().optional(),
cc: z.array(z.string().email()).optional(),
bcc: z.array(z.string().email()).optional(),
attachments: z.array(z.any()).optional(),
html: z.string().optional(),
text: z.string().optional(),
react: z.any(),
});
export type EmailPayload = z.infer<typeof emailPayloadModel>;

View File

@@ -0,0 +1,127 @@
import { Resend } from "resend";
import { type EmailServiceProvider, type EmailPayload } from "./data";
import { ERROR_CODES, type Result } from "@pkg/result";
import { getError } from "@pkg/logger";
import PnrConfirmationEmail from "../emails/pnr-confirmation";
import { createElement } from "react";
export class ResendEmailSvcProvider implements EmailServiceProvider {
private client: Resend | null = null;
constructor(private apiKey: string) {}
initialize(): Result<boolean> {
try {
this.client = new Resend(this.apiKey);
return { data: true };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to initialize Resend client",
userHint: "Please check API key configuration",
detail: "Error initializing Resend client",
},
e,
),
};
}
}
async sendEmail(payload: EmailPayload): Promise<Result<boolean>> {
try {
if (!this.client) {
const initResult = this.initialize();
if (initResult.error) return initResult;
}
await this.client!.emails.send({
from: payload.from || "onboarding@resend.dev",
to: payload.to,
subject: payload.subject,
html: payload.html ?? "",
text: payload.text,
cc: payload.cc,
bcc: payload.bcc,
attachments: payload.attachments,
});
return { data: true };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to send email",
userHint: "Please try again later",
detail: "Error sending email via Resend",
},
e,
),
};
}
}
async sendEmailWithReact(payload: {
to: string;
subject: string;
from: string;
cc?: string[];
bcc?: string[];
attachments?: any[];
template: string;
templateData: any;
}): Promise<Result<boolean>> {
try {
if (!this.client) {
const initResult = this.initialize();
if (initResult.error) return initResult;
}
let body = undefined as any;
switch (payload.template) {
case "pnr-confirmation":
body = createElement(PnrConfirmationEmail, payload.templateData);
break;
default:
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Unsupported email template",
userHint: "Please check email configuration",
detail: "Unsupported email template",
},
new Error("Unsupported email template"),
),
};
}
await this.client!.emails.send({
from: payload.from,
to: payload.to,
subject: payload.subject,
cc: payload.cc,
bcc: payload.bcc,
attachments: payload.attachments,
react: body,
});
return { data: true };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to send email with React template",
userHint: "Please try again later",
detail: "Error sending email via Resend",
},
e,
),
};
}
}
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

97
packages/logger/client.ts Normal file
View File

@@ -0,0 +1,97 @@
type LogLevel = "error" | "warn" | "info" | "http" | "debug";
export interface LogEntry {
level: LogLevel;
timestamp: string;
message: any;
metadata?: any;
}
interface Error {
code: string;
message: string;
userHint?: string;
detail?: string;
error?: any;
actionable?: boolean;
}
class BrowserLogger {
private async sendLog(entry: LogEntry) {
try {
// Only send logs to server in production
if (process.env.NODE_ENV === "production") {
await fetch("/api/logs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(entry),
});
}
// Always log to console in development
const consoleMethod = entry.level === "debug" ? "log" : entry.level;
// @ts-ignore
console[consoleMethod as keyof Console](
`[${entry.level}] ${entry.timestamp}: `,
entry.message,
entry.metadata || "",
);
} catch (err) {
console.error("Failed to send log to server:", err);
// @ts-ignore
console[entry.level as keyof Console](entry.message);
}
}
private createLogEntry(
level: LogLevel,
message: any,
metadata?: any,
): LogEntry {
return {
level,
timestamp: new Date().toISOString(),
message,
metadata,
};
}
error(message: any, metadata?: any) {
this.sendLog(this.createLogEntry("error", message, metadata));
}
warn(message: any, metadata?: any) {
this.sendLog(this.createLogEntry("warn", message, metadata));
}
info(message: any, metadata?: any) {
this.sendLog(this.createLogEntry("info", message, metadata));
}
http(message: any, metadata?: any) {
this.sendLog(this.createLogEntry("http", message, metadata));
}
debug(message: any, metadata?: any) {
this.sendLog(this.createLogEntry("debug", message, metadata));
}
}
const ClientLogger = new BrowserLogger();
function getError(payload: Error, error?: any) {
ClientLogger.error(payload);
if (error) {
ClientLogger.error(error);
}
return {
code: payload.code,
message: payload.message,
userHint: payload.userHint,
detail: payload.detail,
error: error,
actionable: payload.actionable,
} as Error;
}
export { ClientLogger, getError };

105
packages/logger/index.ts Normal file
View File

@@ -0,0 +1,105 @@
import winston from "winston";
import DailyRotateFile from "winston-daily-rotate-file";
import path from "path";
import type { Err } from "@pkg/result";
import util from "util";
process.on("warning", (warning) => {
if (warning.message.includes("punycode")) {
return;
}
console.warn(warning);
});
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
};
const colors = {
error: "red",
warn: "yellow",
info: "green",
http: "magenta",
debug: "white",
};
const level = () => {
const env = process.env.NODE_ENV || "development";
return env === "development" ? "debug" : "warn";
};
const format = winston.format.combine(
winston.format.errors({ stack: true }),
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss:ms" }),
winston.format.colorize({ all: true }),
winston.format.printf((info) => {
const { level, message, timestamp, ...extra } = info;
let formattedMessage = "";
if (message instanceof Error) {
formattedMessage = message.stack || message.message;
} else if (typeof message === "object") {
formattedMessage = util.inspect(message, { depth: null, colors: true });
} else {
formattedMessage = message as any as string;
}
// Handle extra fields (if any)
const formattedExtra =
Object.keys(extra).length > 0
? `\n${util.inspect(extra, { depth: null, colors: true })}`
: "";
return `[${level}] ${timestamp}: ${formattedMessage}${formattedExtra}`;
}),
);
const transports = [
new winston.transports.Console(),
new DailyRotateFile({
filename: path.join("logs", "error-%DATE%.log"),
datePattern: "YYYY-MM-DD",
level: "error",
maxSize: "5m",
maxFiles: "14d",
format: format,
}),
new DailyRotateFile({
filename: path.join("logs", "all-%DATE%.log"),
datePattern: "YYYY-MM-DD",
maxSize: "5m",
maxFiles: "14d",
format: format,
}),
];
winston.addColors(colors);
const Logger = winston.createLogger({
level: level(),
levels,
format,
transports,
});
const stream = { write: (message: string) => Logger.http(message.trim()) };
function getError(payload: Err, error?: any) {
Logger.error(JSON.stringify({ payload, error }, null, 2));
return {
code: payload.code,
message: payload.message,
userHint: payload.userHint,
detail: payload.detail,
error: error instanceof Error ? error.message : error,
actionable: payload.actionable,
} as Err;
}
export { Logger, stream, getError };

View File

@@ -0,0 +1,16 @@
{
"name": "@pkg/logger",
"module": "index.ts",
"type": "module",
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@pkg/result": "workspace:*",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
}
}

View File

@@ -0,0 +1,7 @@
export function chunk<T>(arr: T[], size: number): T[][] {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}

View File

@@ -0,0 +1,8 @@
export function formatCurrency(amount: number, code: string) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: code,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}

View File

@@ -0,0 +1,264 @@
export const COUNTRIES = [
{ id: "1", name: "Afghanistan", code: "AF" },
{ id: "2", name: "Albania", code: "AL" },
{ id: "3", name: "Algeria", code: "DZ" },
{ id: "4", name: "American Samoa", code: "AS" },
{ id: "5", name: "Andorra", code: "AD" },
{ id: "6", name: "Angola", code: "AO" },
{ id: "7", name: "Anguilla", code: "AI" },
{ id: "8", name: "Antarctica", code: "AQ" },
{ id: "9", name: "Antigua and Barbuda", code: "AG" },
{ id: "10", name: "Argentina", code: "AR" },
{ id: "11", name: "Armenia", code: "AM" },
{ id: "12", name: "Aruba", code: "AW" },
{ id: "13", name: "Australia", code: "AU" },
{ id: "14", name: "Austria", code: "AT" },
{ id: "15", name: "Azerbaijan", code: "AZ" },
{ id: "16", name: "Bahamas", code: "BS" },
{ id: "17", name: "Bahrain", code: "BH" },
{ id: "18", name: "Bangladesh", code: "BD" },
{ id: "19", name: "Barbados", code: "BB" },
{ id: "20", name: "Belarus", code: "BY" },
{ id: "21", name: "Belgium", code: "BE" },
{ id: "22", name: "Belize", code: "BZ" },
{ id: "23", name: "Benin", code: "BJ" },
{ id: "24", name: "Bermuda", code: "BM" },
{ id: "25", name: "Bhutan", code: "BT" },
{ id: "26", name: "Bolivia", code: "BO" },
{ id: "27", name: "Bosnia and Herzegovina", code: "BA" },
{ id: "28", name: "Botswana", code: "BW" },
{ id: "29", name: "Bouvet Island", code: "BV" },
{ id: "30", name: "Brazil", code: "BR" },
{ id: "31", name: "British Indian Ocean Territory", code: "IO" },
{ id: "32", name: "British Virgin Islands", code: "VG" },
{ id: "33", name: "Brunei", code: "BN" },
{ id: "34", name: "Bulgaria", code: "BG" },
{ id: "35", name: "Burkina Faso", code: "BF" },
{ id: "36", name: "Burundi", code: "BI" },
{ id: "37", name: "Cambodia", code: "KH" },
{ id: "38", name: "Cameroon", code: "CM" },
{ id: "39", name: "Canada", code: "CA" },
{ id: "40", name: "Cape Verde", code: "CV" },
{ id: "41", name: "Caribbean Netherlands", code: "BQ" },
{ id: "42", name: "Cayman Islands", code: "KY" },
{ id: "43", name: "Central African Republic", code: "CF" },
{ id: "44", name: "Chad", code: "TD" },
{ id: "45", name: "Chile", code: "CL" },
{ id: "46", name: "China", code: "CN" },
{ id: "47", name: "Christmas Island", code: "CX" },
{ id: "48", name: "Cocos (Keeling) Islands", code: "CC" },
{ id: "49", name: "Colombia", code: "CO" },
{ id: "50", name: "Comoros", code: "KM" },
{ id: "51", name: "Cook Islands", code: "CK" },
{ id: "52", name: "Costa Rica", code: "CR" },
{ id: "53", name: "Croatia", code: "HR" },
{ id: "54", name: "Cuba", code: "CU" },
{ id: "55", name: "Curaçao", code: "CW" },
{ id: "56", name: "Cyprus", code: "CY" },
{ id: "57", name: "Czechia", code: "CZ" },
{ id: "58", name: "DR Congo", code: "CD" },
{ id: "59", name: "Denmark", code: "DK" },
{ id: "60", name: "Djibouti", code: "DJ" },
{ id: "61", name: "Dominica", code: "DM" },
{ id: "62", name: "Dominican Republic", code: "DO" },
{ id: "63", name: "Ecuador", code: "EC" },
{ id: "64", name: "Egypt", code: "EG" },
{ id: "65", name: "El Salvador", code: "SV" },
{ id: "66", name: "Equatorial Guinea", code: "GQ" },
{ id: "67", name: "Eritrea", code: "ER" },
{ id: "68", name: "Estonia", code: "EE" },
{ id: "69", name: "Eswatini", code: "SZ" },
{ id: "70", name: "Ethiopia", code: "ET" },
{ id: "71", name: "Falkland Islands", code: "FK" },
{ id: "72", name: "Faroe Islands", code: "FO" },
{ id: "73", name: "Fiji", code: "FJ" },
{ id: "74", name: "Finland", code: "FI" },
{ id: "75", name: "France", code: "FR" },
{ id: "76", name: "French Guiana", code: "GF" },
{ id: "77", name: "French Polynesia", code: "PF" },
{ id: "78", name: "French Southern and Antarctic Lands", code: "TF" },
{ id: "79", name: "Gabon", code: "GA" },
{ id: "80", name: "Gambia", code: "GM" },
{ id: "81", name: "Georgia", code: "GE" },
{ id: "82", name: "Germany", code: "DE" },
{ id: "83", name: "Ghana", code: "GH" },
{ id: "84", name: "Gibraltar", code: "GI" },
{ id: "85", name: "Greece", code: "GR" },
{ id: "86", name: "Greenland", code: "GL" },
{ id: "87", name: "Grenada", code: "GD" },
{ id: "88", name: "Guadeloupe", code: "GP" },
{ id: "89", name: "Guam", code: "GU" },
{ id: "90", name: "Guatemala", code: "GT" },
{ id: "91", name: "Guernsey", code: "GG" },
{ id: "92", name: "Guinea", code: "GN" },
{ id: "93", name: "Guinea-Bissau", code: "GW" },
{ id: "94", name: "Guyana", code: "GY" },
{ id: "95", name: "Haiti", code: "HT" },
{ id: "96", name: "Heard Island and McDonald Islands", code: "HM" },
{ id: "97", name: "Honduras", code: "HN" },
{ id: "98", name: "Hong Kong", code: "HK" },
{ id: "99", name: "Hungary", code: "HU" },
{ id: "100", name: "Iceland", code: "IS" },
{ id: "101", name: "India", code: "IN" },
{ id: "102", name: "Indonesia", code: "ID" },
{ id: "103", name: "Iran", code: "IR" },
{ id: "104", name: "Iraq", code: "IQ" },
{ id: "105", name: "Ireland", code: "IE" },
{ id: "106", name: "Isle of Man", code: "IM" },
{ id: "107", name: "Israel", code: "IL" },
{ id: "108", name: "Italy", code: "IT" },
{ id: "109", name: "Ivory Coast", code: "CI" },
{ id: "110", name: "Jamaica", code: "JM" },
{ id: "111", name: "Japan", code: "JP" },
{ id: "112", name: "Jersey", code: "JE" },
{ id: "113", name: "Jordan", code: "JO" },
{ id: "114", name: "Kazakhstan", code: "KZ" },
{ id: "115", name: "Kenya", code: "KE" },
{ id: "116", name: "Kiribati", code: "KI" },
{ id: "117", name: "Kosovo", code: "XK" },
{ id: "118", name: "Kuwait", code: "KW" },
{ id: "119", name: "Kyrgyzstan", code: "KG" },
{ id: "120", name: "Laos", code: "LA" },
{ id: "121", name: "Latvia", code: "LV" },
{ id: "122", name: "Lebanon", code: "LB" },
{ id: "123", name: "Lesotho", code: "LS" },
{ id: "124", name: "Liberia", code: "LR" },
{ id: "125", name: "Libya", code: "LY" },
{ id: "126", name: "Liechtenstein", code: "LI" },
{ id: "127", name: "Lithuania", code: "LT" },
{ id: "128", name: "Luxembourg", code: "LU" },
{ id: "129", name: "Macau", code: "MO" },
{ id: "130", name: "Madagascar", code: "MG" },
{ id: "131", name: "Malawi", code: "MW" },
{ id: "132", name: "Malaysia", code: "MY" },
{ id: "133", name: "Maldives", code: "MV" },
{ id: "134", name: "Mali", code: "ML" },
{ id: "135", name: "Malta", code: "MT" },
{ id: "136", name: "Marshall Islands", code: "MH" },
{ id: "137", name: "Martinique", code: "MQ" },
{ id: "138", name: "Mauritania", code: "MR" },
{ id: "139", name: "Mauritius", code: "MU" },
{ id: "140", name: "Mayotte", code: "YT" },
{ id: "141", name: "Mexico", code: "MX" },
{ id: "142", name: "Micronesia", code: "FM" },
{ id: "143", name: "Moldova", code: "MD" },
{ id: "144", name: "Monaco", code: "MC" },
{ id: "145", name: "Mongolia", code: "MN" },
{ id: "146", name: "Montenegro", code: "ME" },
{ id: "147", name: "Montserrat", code: "MS" },
{ id: "148", name: "Morocco", code: "MA" },
{ id: "149", name: "Mozambique", code: "MZ" },
{ id: "150", name: "Myanmar", code: "MM" },
{ id: "151", name: "Namibia", code: "NA" },
{ id: "152", name: "Nauru", code: "NR" },
{ id: "153", name: "Nepal", code: "NP" },
{ id: "154", name: "Netherlands", code: "NL" },
{ id: "155", name: "New Caledonia", code: "NC" },
{ id: "156", name: "New Zealand", code: "NZ" },
{ id: "157", name: "Nicaragua", code: "NI" },
{ id: "158", name: "Niger", code: "NE" },
{ id: "159", name: "Nigeria", code: "NG" },
{ id: "160", name: "Niue", code: "NU" },
{ id: "161", name: "Norfolk Island", code: "NF" },
{ id: "162", name: "North Korea", code: "KP" },
{ id: "163", name: "North Macedonia", code: "MK" },
{ id: "164", name: "Northern Mariana Islands", code: "MP" },
{ id: "165", name: "Norway", code: "NO" },
{ id: "166", name: "Oman", code: "OM" },
{ id: "167", name: "Pakistan", code: "PK" },
{ id: "168", name: "Palau", code: "PW" },
{ id: "169", name: "Palestine", code: "PS" },
{ id: "170", name: "Panama", code: "PA" },
{ id: "171", name: "Papua New Guinea", code: "PG" },
{ id: "172", name: "Paraguay", code: "PY" },
{ id: "173", name: "Peru", code: "PE" },
{ id: "174", name: "Philippines", code: "PH" },
{ id: "175", name: "Pitcairn Islands", code: "PN" },
{ id: "176", name: "Poland", code: "PL" },
{ id: "177", name: "Portugal", code: "PT" },
{ id: "178", name: "Puerto Rico", code: "PR" },
{ id: "179", name: "Qatar", code: "QA" },
{ id: "180", name: "Republic of the Congo", code: "CG" },
{ id: "181", name: "Romania", code: "RO" },
{ id: "182", name: "Russia", code: "RU" },
{ id: "183", name: "Rwanda", code: "RW" },
{ id: "184", name: "Réunion", code: "RE" },
{ id: "185", name: "Saint Barthélemy", code: "BL" },
{
id: "186",
name: "Saint Helena, Ascension and Tristan da Cunha",
code: "SH",
},
{ id: "187", name: "Saint Kitts and Nevis", code: "KN" },
{ id: "188", name: "Saint Lucia", code: "LC" },
{ id: "189", name: "Saint Martin", code: "MF" },
{ id: "190", name: "Saint Pierre and Miquelon", code: "PM" },
{ id: "191", name: "Saint Vincent and the Grenadines", code: "VC" },
{ id: "192", name: "Samoa", code: "WS" },
{ id: "193", name: "San Marino", code: "SM" },
{ id: "194", name: "Saudi Arabia", code: "SA" },
{ id: "195", name: "Senegal", code: "SN" },
{ id: "196", name: "Serbia", code: "RS" },
{ id: "197", name: "Seychelles", code: "SC" },
{ id: "198", name: "Sierra Leone", code: "SL" },
{ id: "199", name: "Singapore", code: "SG" },
{ id: "200", name: "Sint Maarten", code: "SX" },
{ id: "201", name: "Slovakia", code: "SK" },
{ id: "202", name: "Slovenia", code: "SI" },
{ id: "203", name: "Solomon Islands", code: "SB" },
{ id: "204", name: "Somalia", code: "SO" },
{ id: "205", name: "South Africa", code: "ZA" },
{ id: "206", name: "South Georgia", code: "GS" },
{ id: "207", name: "South Korea", code: "KR" },
{ id: "208", name: "South Sudan", code: "SS" },
{ id: "209", name: "Spain", code: "ES" },
{ id: "210", name: "Sri Lanka", code: "LK" },
{ id: "211", name: "Sudan", code: "SD" },
{ id: "212", name: "Suriname", code: "SR" },
{ id: "213", name: "Svalbard and Jan Mayen", code: "SJ" },
{ id: "214", name: "Sweden", code: "SE" },
{ id: "215", name: "Switzerland", code: "CH" },
{ id: "216", name: "Syria", code: "SY" },
{ id: "217", name: "São Tomé and Príncipe", code: "ST" },
{ id: "218", name: "Taiwan", code: "TW" },
{ id: "219", name: "Tajikistan", code: "TJ" },
{ id: "220", name: "Tanzania", code: "TZ" },
{ id: "221", name: "Thailand", code: "TH" },
{ id: "222", name: "Timor-Leste", code: "TL" },
{ id: "223", name: "Togo", code: "TG" },
{ id: "224", name: "Tokelau", code: "TK" },
{ id: "225", name: "Tonga", code: "TO" },
{ id: "226", name: "Trinidad and Tobago", code: "TT" },
{ id: "227", name: "Tunisia", code: "TN" },
{ id: "228", name: "Turkey", code: "TR" },
{ id: "229", name: "Turkmenistan", code: "TM" },
{ id: "230", name: "Turks and Caicos Islands", code: "TC" },
{ id: "231", name: "Tuvalu", code: "TV" },
{ id: "232", name: "Uganda", code: "UG" },
{ id: "233", name: "Ukraine", code: "UA" },
{ id: "234", name: "United Arab Emirates", code: "AE" },
{ id: "235", name: "United Kingdom", code: "GB" },
{ id: "236", name: "United States", code: "US" },
{ id: "237", name: "United States Minor Outlying Islands", code: "UM" },
{ id: "238", name: "United States Virgin Islands", code: "VI" },
{ id: "239", name: "Uruguay", code: "UY" },
{ id: "240", name: "Uzbekistan", code: "UZ" },
{ id: "241", name: "Vanuatu", code: "VU" },
{ id: "242", name: "Vatican City", code: "VA" },
{ id: "243", name: "Venezuela", code: "VE" },
{ id: "244", name: "Vietnam", code: "VN" },
{ id: "245", name: "Wallis and Futuna", code: "WF" },
{ id: "246", name: "Western Sahara", code: "EH" },
{ id: "247", name: "Yemen", code: "YE" },
{ id: "248", name: "Zambia", code: "ZM" },
{ id: "249", name: "Zimbabwe", code: "ZW" },
{ id: "250", name: "Åland Islands", code: "AX" },
];
export const COUNTRIES_SELECT = COUNTRIES.map((c) => {
return {
id: c.id,
label: `${c.code} (${c.name})`,
value: c.name.toLowerCase(),
};
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
import type { CalendarDate } from "@internationalized/date";
export const formatTime = (isoString: string | undefined) => {
if (!isoString) return "N/A";
try {
return formatDistanceToNow(new Date(isoString), { addSuffix: true });
} catch (e) {
return "Invalid date";
}
};
export function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
}
export function formatDateTimeFromIsoString(isoString: string): string {
try {
const date = new Date(isoString);
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
} catch (e) {
return "Invalid date";
}
}
export function getJustDateString(d: Date): string {
return d.toISOString().split("T")[0];
}
export function formatDateTime(dateTimeStr: string) {
const date = new Date(dateTimeStr);
return {
time: date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
}),
date: date.toLocaleDateString("en-US", {
weekday: "short",
day: "2-digit",
month: "short",
}),
};
}
export function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString("en-US", {
weekday: "short",
day: "2-digit",
month: "short",
});
}
export function isTimestampMoreThan1MinAgo(ts: string): boolean {
const lastPingedDate = new Date(ts);
const now = new Date();
const diff = now.getTime() - lastPingedDate.getTime();
return diff > 60000;
}
export function isTimestampOlderThan(ts: string, seconds: number): boolean {
const lastPingedDate = new Date(ts);
const now = new Date();
const diff = now.getTime() - lastPingedDate.getTime();
return diff > seconds * 1000;
}
export function makeDateStringISO(ds: string): string {
if (ds.includes("T")) {
return `${ds.split("T")[0]}T00:00:00.000Z`;
}
return `${ds}T00:00:00.000Z`;
}
export function parseCalDateToDateString(v: CalendarDate) {
let month: string | number = v.month;
if (month < 10) {
month = `0${month}`;
}
let day: string | number = v.day;
if (day < 10) {
day = `0${day}`;
}
return `${v.year}-${month}-${day}`;
}

View File

@@ -0,0 +1,15 @@
export const PAYMENT_STATUS = {
PENDING: "pending",
REVIEW: "review",
FAILED: "failed",
SUCCESS: "success",
};
export type PaymentStatus = "pending" | "review" | "failed" | "success";
export const UserRoleMap = {
ADMIN: "admin",
AGENT: "agent",
SUPERVISOR: "supervisor",
ENDUSER: "enduser",
};
export type UserType = "admin" | "agent" | "supervisor" | "enduser";

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
export const paginationModel = z.object({
cursor: z.string().optional(),
limit: z.number().int().max(100),
asc: z.boolean().default(true),
totalItemCount: z.number().int().default(0),
totalPages: z.number().int(),
page: z.number().int(),
});
export type PaginationModel = z.infer<typeof paginationModel>;

View File

@@ -0,0 +1,89 @@
import type { z } from "zod";
export function capitalize(input: string, firstOfAllWords?: boolean): string {
// capitalize first letter of input
if (!firstOfAllWords) {
return input.charAt(0).toUpperCase() + input.slice(1);
}
let out = "";
for (const word of input.split(" ")) {
out += word.charAt(0).toUpperCase() + word.slice(1) + " ";
}
return out.slice(0, -1);
}
export function camelToSpacedPascal(input: string): string {
let result = "";
let previousChar = "";
for (const char of input) {
if (char === char.toUpperCase() && previousChar !== " ") {
result += " ";
}
result += char;
previousChar = char;
}
return result.charAt(0).toUpperCase() + result.slice(1);
}
export function snakeToCamel(input: string): string {
if (!input) {
return input;
}
// also account for numbers and kebab-case
const splits = input.split(/[-_]/);
let result = splits[0];
for (const split of splits.slice(1)) {
result += capitalize(split, true);
}
return result ?? "";
}
export function snakeToSpacedPascal(input: string): string {
return camelToSpacedPascal(snakeToCamel(input));
}
export function spacedPascalToSnake(input: string): string {
return input.split(" ").join("_").toLowerCase();
}
export function convertDashedLowerToTitleCase(input: string): string {
return input
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" "); // Join the words with a space
}
export function encodeCursor<T>(cursor: T): string {
try {
// Convert the object to a JSON string
const jsonString = JSON.stringify(cursor);
// Convert to UTF-8 bytes, then base64
return btoa(
encodeURIComponent(jsonString).replace(/%([0-9A-F]{2})/g, (_, p1) =>
String.fromCharCode(parseInt(p1, 16)),
),
);
} catch (error) {
console.error("Error encoding cursor:", error);
throw new Error("Failed to encode cursor");
}
}
export function decodeCursor<T>(cursor: string, parser: z.AnyZodObject) {
try {
// Decode base64 back to UTF-8 string
const decoded = decodeURIComponent(
Array.prototype.map
.call(atob(cursor), (c) => {
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
})
.join(""),
);
// Parse back to object
const parsedData = JSON.parse(decoded);
return parser.safeParse(parsedData) as z.SafeParseReturnType<any, T>;
} catch (error) {
console.error("Error decoding cursor:", error);
return { error: new Error("Failed to decode cursor"), data: undefined };
}
}

View File

@@ -0,0 +1,52 @@
import { z } from "zod";
export const emailAccountPayloadModel = z.object({
email: z.string().email().min(6).max(128),
password: z.string().max(128),
agentId: z.string().optional(),
orderId: z.number().int().nullish().optional(),
});
export type EmailAccountPayload = z.infer<typeof emailAccountPayloadModel>;
export const emailAccountModel = emailAccountPayloadModel
.pick({ email: true, agentId: true, orderId: true })
.merge(
z.object({
id: z.number().int(),
used: z.boolean().default(false),
lastActiveCheckAt: z.coerce.string().optional(),
createdAt: z.coerce.string(),
updatedAt: z.coerce.string(),
}),
);
export type EmailAccount = z.infer<typeof emailAccountModel>;
export const emailAccountFullModel = emailAccountPayloadModel.merge(
z.object({
id: z.number().int(),
used: z.boolean().default(false),
createdAt: z.coerce.string(),
updatedAt: z.coerce.string(),
}),
);
export type EmailAccountFull = z.infer<typeof emailAccountFullModel>;
export const inboxModel = z.object({
id: z.number(),
emailId: z.string().default(""),
from: z.string(),
to: z.string().optional(),
cc: z.string().optional(),
subject: z.string(),
body: z.string(),
attachments: z.any().optional(),
emailAccountId: z.number(),
dated: z.coerce.string(),
createdAt: z.coerce.string(),
updatedAt: z.coerce.string(),
});
export type InboxModel = z.infer<typeof inboxModel>;

View File

@@ -0,0 +1,34 @@
import { z } from "zod";
export const numberModel = z
.union([
z.coerce
.number()
.refine((val) => !isNaN(val), { message: "Must be a valid number" }),
z.undefined(),
])
.transform((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

@@ -0,0 +1,21 @@
import type { authClient } from "../config/client";
import { z } from "zod";
export const passwordModel = z.string().min(6).max(128);
export const authPayloadModel = z.object({
username: z.string().min(4).max(128),
password: passwordModel,
});
export type AuthPayloadModel = z.infer<typeof authPayloadModel>;
export type Session = typeof authClient.$Infer.Session;
export const changePasswordPayloadModel = z.object({
oldPassword: passwordModel,
newPassword: passwordModel,
});
export type ChangePasswordPayloadModel = z.infer<
typeof changePasswordPayloadModel
>;

View File

@@ -0,0 +1,11 @@
import { z } from "zod";
export const sessionModel = z.object({
id: z.string(),
userId: z.coerce.number().int(),
userAgent: z.string(),
ipAddress: z.string(),
expiresAt: z.coerce.date(),
});
export type SessionModel = z.infer<typeof sessionModel>;
export type Session = SessionModel & { id: string };

View File

@@ -0,0 +1,158 @@
import { z } from "zod";
import {
PassengerPII,
passengerPIIModel,
} from "../../passengerinfo/data/entities";
import {
PaymentDetailsPayload,
paymentDetailsPayloadModel,
} from "../../paymentinfo/data/entities";
import { CheckoutStep } from "../../ticket/data/entities";
// Define action types for the checkout flow
export enum CKActionType {
PutInVerification = "PUT_IN_VERIFICATION",
ShowVerificationScreen = "SHOW_VERIFICATION_SCREEN",
RequestOTP = "REQUEST_OTP",
CompleteOrder = "COMPLETE_ORDER",
BackToPII = "BACK_TO_PII",
BackToPayment = "BACK_TO_PAYMENT",
TerminateSession = "TERMINATE_SESSION",
}
export enum SessionOutcome {
PENDING = "PENDING",
COMPLETED = "COMPLETED",
ABANDONED = "ABANDONED",
TERMINATED = "TERMINATED",
EXPIRED = "EXPIRED",
PAYMENT_FAILED = "PAYMENT_FAILED",
PAYMENT_SUCCESSFUL = "PAYMENT_SUCCESSFUL",
VERIFICATION_FAILED = "VERIFICATION_FAILED",
SYSTEM_ERROR = "SYSTEM_ERROR",
}
export enum PaymentErrorType {
DO_NOT_HONOR = "DO_NOT_HONOR",
REFER_TO_ISSUER = "REFER_TO_ISSUER",
TRANSACTION_DENIED = "TRANSACTION_DENIED",
CAPTURE_CARD_ERROR = "CAPTURE_CARD_ERROR",
CUSTOM_ERROR = "CUSTOM_ERROR",
}
export interface PaymentErrorMessage {
type: PaymentErrorType;
message: string;
description: string;
}
// Model for pending actions in the flow
export const pendingActionModel = z.object({
id: z.string(),
type: z.nativeEnum(CKActionType),
data: z.record(z.string(), z.any()).default({}),
});
export type PendingAction = z.infer<typeof pendingActionModel>;
export const pendingActionsModel = z.array(pendingActionModel);
export type PendingActions = z.infer<typeof pendingActionsModel>;
export const ticketSummaryModel = z.object({
id: z.number().optional(),
ticketId: z.string().optional(),
departure: z.string(),
arrival: z.string(),
departureDate: z.string(),
returnDate: z.string().optional(),
flightType: z.string(),
cabinClass: z.string(),
priceDetails: z.object({
currency: z.string(),
displayPrice: z.number(),
basePrice: z.number().optional(),
discountAmount: z.number().optional(),
}),
});
export type TicketSummary = z.infer<typeof ticketSummaryModel>;
// Core flow information model - what's actually stored in Redis
export const flowInfoModel = z.object({
id: z.coerce.number().optional(),
flowId: z.string(),
domain: z.string(),
checkoutStep: z.nativeEnum(CheckoutStep),
showVerification: z.boolean().default(false),
createdAt: z.string().datetime(),
lastPinged: z.string().datetime(),
isActive: z.boolean().default(true),
lastSyncedAt: z.string().datetime(),
ticketInfo: ticketSummaryModel.optional(),
ticketId: z.number().nullable().optional(),
personalInfoLastSyncedAt: z.string().datetime().optional(),
paymentInfoLastSyncedAt: z.string().datetime().optional(),
pendingActions: pendingActionsModel.default([]),
personalInfo: z.custom<PassengerPII>().optional(),
paymentInfo: z.custom<PaymentDetailsPayload>().optional(),
refOids: z.array(z.number()).optional(),
otpCode: z.coerce.string().optional(),
otpSubmitted: z.boolean().default(false),
partialOtpCode: z.coerce.string().optional(),
ipAddress: z.string().default(""),
userAgent: z.string().default(""),
reserved: z.boolean().default(false),
reservedBy: z.string().nullable().optional(),
liveCheckoutStartedAt: z.string().datetime().optional(),
completedAt: z.coerce.string().datetime().nullable().optional(),
sessionOutcome: z.string(),
isDeleted: z.boolean().default(false),
});
export type FlowInfo = z.infer<typeof flowInfoModel>;
// Payload for frontend to create a checkout flow
export const feCreateCheckoutFlowPayloadModel = z.object({
domain: z.string(),
refOIds: z.array(z.number()),
ticketId: z.number().optional(),
});
export type FECreateCheckoutFlowPayload = z.infer<
typeof feCreateCheckoutFlowPayloadModel
>;
// Complete payload for backend to create a checkout flow
export const createCheckoutFlowPayloadModel = z.object({
flowId: z.string(),
domain: z.string(),
refOIds: z.array(z.number()),
ticketId: z.number().optional(),
ipAddress: z.string().default(""),
userAgent: z.string().default(""),
initialUrl: z.string().default(""),
provider: z.string().default("kiwi"),
});
export type CreateCheckoutFlowPayload = z.infer<
typeof createCheckoutFlowPayloadModel
>;
// Step-specific payloads
export const prePaymentFlowStepPayloadModel = z.object({
initialUrl: z.string(),
personalInfo: passengerPIIModel.optional(),
});
export type PrePaymentFlowStepPayload = z.infer<
typeof prePaymentFlowStepPayloadModel
>;
export const paymentFlowStepPayloadModel = z.object({
personalInfo: passengerPIIModel.optional(),
paymentInfo: paymentDetailsPayloadModel.optional(),
});
export type PaymentFlowStepPayload = z.infer<
typeof paymentFlowStepPayloadModel
>;

View File

@@ -0,0 +1,41 @@
import { z } from "zod";
export enum DiscountType {
PERCENTAGE = "PERCENTAGE",
FIXED = "FIXED",
}
export const couponModel = z.object({
id: z.number().optional(),
code: z.string().min(3).max(32),
description: z.string().optional().nullable(),
discountType: z.nativeEnum(DiscountType),
discountValue: z.coerce.number().positive(),
maxUsageCount: z.coerce.number().int().positive().optional().nullable(),
currentUsageCount: z.coerce.number().int().nonnegative().default(0),
minOrderValue: z.coerce.number().nonnegative().optional().nullable(),
maxDiscountAmount: z.coerce.number().positive().optional().nullable(),
startDate: z.coerce.string(),
endDate: z.coerce.string().optional().nullable(),
isActive: z.boolean().default(true),
createdAt: z.coerce.string().optional(),
updatedAt: z.coerce.string().optional(),
createdBy: z.coerce.string().optional().nullable(),
});
export type CouponModel = z.infer<typeof couponModel>;
export const createCouponPayload = couponModel.omit({
id: true,
currentUsageCount: true,
createdAt: true,
updatedAt: true,
});
export type CreateCouponPayload = z.infer<typeof createCouponPayload>;
export const updateCouponPayload = createCouponPayload.partial().extend({
id: z.number(),
});
export type UpdateCouponPayload = z.infer<typeof updateCouponPayload>;

View File

@@ -0,0 +1,491 @@
import {
and,
asc,
desc,
eq,
gte,
isNull,
lte,
or,
type Database,
} from "@pkg/db";
import { coupon } from "@pkg/db/schema";
import { ERROR_CODES, type Result } from "@pkg/result";
import { getError, Logger } from "@pkg/logger";
import {
couponModel,
type CouponModel,
type CreateCouponPayload,
type UpdateCouponPayload,
} from "./data";
export class CouponRepository {
private db: Database;
constructor(db: Database) {
this.db = db;
}
async getAllCoupons(): Promise<Result<CouponModel[]>> {
try {
const results = await this.db.query.coupon.findMany({
orderBy: [desc(coupon.createdAt)],
});
const out = [] as CouponModel[];
for (const result of results) {
const parsed = couponModel.safeParse(result);
if (!parsed.success) {
Logger.error("Failed to parse coupon");
Logger.error(parsed.error);
continue;
}
out.push(parsed.data);
}
return { data: out };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to fetch coupons",
detail:
"An error occurred while retrieving coupons from the database",
userHint: "Please try refreshing the page",
actionable: false,
},
e,
),
};
}
}
async getCouponById(id: number): Promise<Result<CouponModel>> {
try {
const result = await this.db.query.coupon.findFirst({
where: eq(coupon.id, id),
});
if (!result) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Coupon not found",
detail: "No coupon exists with the provided ID",
userHint: "Please check the coupon ID and try again",
actionable: true,
}),
};
}
const parsed = couponModel.safeParse(result);
if (!parsed.success) {
Logger.error("Failed to parse coupon", result);
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to parse coupon",
userHint: "Please try again",
detail: "Failed to parse coupon",
}),
};
}
return { data: parsed.data };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to fetch coupon",
detail:
"An error occurred while retrieving the coupon from the database",
userHint: "Please try refreshing the page",
actionable: false,
},
e,
),
};
}
}
async createCoupon(payload: CreateCouponPayload): Promise<Result<number>> {
try {
// Check if coupon code already exists
const existing = await this.db.query.coupon.findFirst({
where: eq(coupon.code, payload.code),
});
if (existing) {
return {
error: getError({
code: ERROR_CODES.DATABASE_ERROR,
message: "Coupon code already exists",
detail: "A coupon with this code already exists in the system",
userHint: "Please use a different coupon code",
actionable: true,
}),
};
}
const result = await this.db
.insert(coupon)
.values({
code: payload.code,
description: payload.description || null,
discountType: payload.discountType,
discountValue: payload.discountValue.toString(),
maxUsageCount: payload.maxUsageCount,
minOrderValue: payload.minOrderValue
? payload.minOrderValue.toString()
: null,
maxDiscountAmount: payload.maxDiscountAmount
? payload.maxDiscountAmount.toString()
: null,
startDate: new Date(payload.startDate),
endDate: payload.endDate ? new Date(payload.endDate) : null,
isActive: payload.isActive,
createdBy: payload.createdBy || null,
})
.returning({ id: coupon.id })
.execute();
if (!result || result.length === 0) {
throw new Error("Failed to create coupon record");
}
return { data: result[0].id };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to create coupon",
detail: "An error occurred while creating the coupon",
userHint: "Please try again",
actionable: false,
},
e,
),
};
}
}
async updateCoupon(payload: UpdateCouponPayload): Promise<Result<boolean>> {
try {
if (!payload.id) {
return {
error: getError({
code: ERROR_CODES.VALIDATION_ERROR,
message: "Invalid coupon ID",
detail: "No coupon ID was provided for the update operation",
userHint: "Please provide a valid coupon ID",
actionable: true,
}),
};
}
// Check if coupon exists
const existing = await this.db.query.coupon.findFirst({
where: eq(coupon.id, payload.id),
});
if (!existing) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Coupon not found",
detail: "No coupon exists with the provided ID",
userHint: "Please check the coupon ID and try again",
actionable: true,
}),
};
}
// If changing the code, check if the new code already exists
if (payload.code && payload.code !== existing.code) {
const codeExists = await this.db.query.coupon.findFirst({
where: eq(coupon.code, payload.code),
});
if (codeExists) {
return {
error: getError({
code: ERROR_CODES.DATABASE_ERROR,
message: "Coupon code already exists",
detail: "A coupon with this code already exists in the system",
userHint: "Please use a different coupon code",
actionable: true,
}),
};
}
}
// Build the update object with only the fields that are provided
const updateValues: Record<string, any> = {};
if (payload.code !== undefined) updateValues.code = payload.code;
if (payload.description !== undefined)
updateValues.description = payload.description;
if (payload.discountType !== undefined)
updateValues.discountType = payload.discountType;
if (payload.discountValue !== undefined)
updateValues.discountValue = payload.discountValue.toString();
if (payload.maxUsageCount !== undefined)
updateValues.maxUsageCount = payload.maxUsageCount;
if (payload.minOrderValue !== undefined)
updateValues.minOrderValue = payload.minOrderValue?.toString() || null;
if (payload.maxDiscountAmount !== undefined)
updateValues.maxDiscountAmount =
payload.maxDiscountAmount?.toString() || null;
if (payload.startDate !== undefined)
updateValues.startDate = new Date(payload.startDate);
if (payload.endDate !== undefined)
updateValues.endDate = payload.endDate
? new Date(payload.endDate)
: null;
if (payload.isActive !== undefined)
updateValues.isActive = payload.isActive;
updateValues.updatedAt = new Date();
await this.db
.update(coupon)
.set(updateValues)
.where(eq(coupon.id, payload.id))
.execute();
return { data: true };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to update coupon",
detail: "An error occurred while updating the coupon",
userHint: "Please try again",
actionable: false,
},
e,
),
};
}
}
async deleteCoupon(id: number): Promise<Result<boolean>> {
try {
// Check if coupon exists
const existing = await this.db.query.coupon.findFirst({
where: eq(coupon.id, id),
});
if (!existing) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Coupon not found",
detail: "No coupon exists with the provided ID",
userHint: "Please check the coupon ID and try again",
actionable: true,
}),
};
}
await this.db.delete(coupon).where(eq(coupon.id, id)).execute();
return { data: true };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to delete coupon",
detail: "An error occurred while deleting the coupon",
userHint: "Please try again",
actionable: false,
},
e,
),
};
}
}
async toggleCouponStatus(
id: number,
isActive: boolean,
): Promise<Result<boolean>> {
try {
// Check if coupon exists
const existing = await this.db.query.coupon.findFirst({
where: eq(coupon.id, id),
});
if (!existing) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Coupon not found",
detail: "No coupon exists with the provided ID",
userHint: "Please check the coupon ID and try again",
actionable: true,
}),
};
}
await this.db
.update(coupon)
.set({
isActive,
updatedAt: new Date(),
})
.where(eq(coupon.id, id))
.execute();
return { data: true };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to update coupon status",
detail: "An error occurred while updating the coupon's status",
userHint: "Please try again",
actionable: false,
},
e,
),
};
}
}
async getCouponByCode(code: string): Promise<Result<CouponModel>> {
try {
const result = await this.db.query.coupon.findFirst({
where: eq(coupon.code, code),
});
if (!result) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Coupon not found",
detail: "No coupon exists with the provided code",
userHint: "Please check the coupon code and try again",
actionable: true,
}),
};
}
const parsed = couponModel.safeParse(result);
if (!parsed.success) {
Logger.error("Failed to parse coupon", result);
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to parse coupon",
userHint: "Please try again",
detail: "Failed to parse coupon",
}),
};
}
return { data: parsed.data };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to fetch coupon",
detail:
"An error occurred while retrieving the coupon from the database",
userHint: "Please try again",
actionable: false,
},
e,
),
};
}
}
async getActiveCoupons(): Promise<Result<CouponModel[]>> {
try {
const now = new Date();
const results = await this.db.query.coupon.findMany({
where: and(
eq(coupon.isActive, true),
lte(coupon.startDate, now),
// Either endDate is null (no end date) or it's greater than now
or(isNull(coupon.endDate), gte(coupon.endDate, now)),
),
orderBy: [asc(coupon.code)],
});
const out = [] as CouponModel[];
for (const result of results) {
const parsed = couponModel.safeParse(result);
if (!parsed.success) {
Logger.error("Failed to parse coupon", result);
continue;
}
out.push(parsed.data);
}
return { data: out };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to fetch active coupons",
detail:
"An error occurred while retrieving active coupons from the database",
userHint: "Please try refreshing the page",
actionable: false,
},
e,
),
};
}
}
async getBestActiveCoupon(): Promise<Result<CouponModel>> {
try {
const now = new Date();
// Fetch all active coupons that are currently valid
const activeCoupons = await this.db.query.coupon.findMany({
where: and(
eq(coupon.isActive, true),
lte(coupon.startDate, now),
// Either endDate is null (no end date) or it's greater than now
or(isNull(coupon.endDate), gte(coupon.endDate, now)),
),
orderBy: [
// Order by discount type (PERCENTAGE first) and then by discount value (descending)
asc(coupon.discountType),
desc(coupon.discountValue),
],
});
if (!activeCoupons || activeCoupons.length === 0) {
return {}; // No active coupons found
}
// Get the first (best) coupon
const bestCoupon = activeCoupons[0];
// Check if max usage limit is reached
if (
bestCoupon.maxUsageCount !== null &&
bestCoupon.currentUsageCount >= bestCoupon.maxUsageCount
) {
return {}; // Coupon usage limit reached
}
const parsed = couponModel.safeParse(bestCoupon);
if (!parsed.success) {
Logger.error("Failed to parse coupon", bestCoupon);
return {}; // Return null on error, don't break ticket search
}
return { data: parsed.data };
} catch (e) {
Logger.error("Error fetching active coupons", e);
return {}; // Return null on error, don't break ticket search
}
}
}

View File

@@ -0,0 +1,49 @@
import { db } from "@pkg/db";
import { CouponRepository } from "./repository";
import type { CreateCouponPayload, UpdateCouponPayload } from "./data";
import type { UserModel } from "@pkg/logic/domains/user/data/entities";
export class CouponUseCases {
private repo: CouponRepository;
constructor(repo: CouponRepository) {
this.repo = repo;
}
async getAllCoupons() {
return this.repo.getAllCoupons();
}
async getCouponById(id: number) {
return this.repo.getCouponById(id);
}
async createCoupon(currentUser: UserModel, payload: CreateCouponPayload) {
// Set the current user as the creator
const payloadWithUser = {
...payload,
createdBy: currentUser.id,
};
return this.repo.createCoupon(payloadWithUser);
}
async updateCoupon(payload: UpdateCouponPayload) {
return this.repo.updateCoupon(payload);
}
async deleteCoupon(id: number) {
return this.repo.deleteCoupon(id);
}
async toggleCouponStatus(id: number, isActive: boolean) {
return this.repo.toggleCouponStatus(id, isActive);
}
async getActiveCoupons() {
return this.repo.getActiveCoupons();
}
}
export function getCouponUseCases() {
return new CouponUseCases(new CouponRepository(db));
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
import z from "zod";
// The base currency is always USD, so the ratio is relative to that
export const currencyModel = z.object({
id: z.number(),
currency: z.string(),
code: z.string(),
exchangeRate: z.number(),
ratio: z.number(),
});
export type Currency = z.infer<typeof currencyModel>;

View File

@@ -0,0 +1,133 @@
import { paginationModel } from "../../../core/pagination.utils";
import { encodeCursor } from "../../../core/string.utils";
import {
emailAccountModel,
emailAccountPayloadModel,
} from "../../account/data/entities";
import { flightTicketModel } from "../../ticket/data/entities";
import { passengerInfoModel } from "../../passengerinfo/data/entities";
import { z } from "zod";
import { paymentDetailsPayloadModel } from "../../paymentinfo/data/entities";
export enum OrderCreationStep {
ACCOUNT_SELECTION = 0,
TICKET_SELECTION = 1,
PASSENGER_INFO = 2,
SUMMARY = 3,
}
export enum OrderStatus {
PENDING_FULLFILLMENT = "PENDING_FULLFILLMENT",
PARTIALLY_FULFILLED = "PARTIALLY_FULFILLED",
FULFILLED = "FULFILLED",
CANCELLED = "CANCELLED",
}
export const orderModel = z.object({
id: z.coerce.number().int().positive(),
discountAmount: z.coerce.number().min(0),
basePrice: z.coerce.number().min(0),
displayPrice: z.coerce.number().min(0),
orderPrice: z.coerce.number().min(0),
fullfilledPrice: z.coerce.number().min(0),
pricePerPassenger: z.coerce.number().min(0),
status: z.nativeEnum(OrderStatus),
flightTicketInfoId: z.number(),
emailAccountId: z.number().nullish().optional(),
paymentDetailsId: z.number().nullish().optional(),
createdAt: z.coerce.string(),
updatedAt: z.coerce.string(),
});
export type OrderModel = z.infer<typeof orderModel>;
export const limitedOrderWithTicketInfoModel = orderModel
.pick({
id: true,
basePrice: true,
discountAmount: true,
displayPrice: true,
pricePerPassenger: true,
fullfilledPrice: true,
status: true,
})
.merge(
z.object({
flightTicketInfo: flightTicketModel.pick({
id: true,
departure: true,
arrival: true,
departureDate: true,
returnDate: true,
flightType: true,
passengerCounts: true,
}),
}),
);
export type LimitedOrderWithTicketInfoModel = z.infer<
typeof limitedOrderWithTicketInfoModel
>;
export const fullOrderModel = orderModel.merge(
z.object({
flightTicketInfo: flightTicketModel,
emailAccount: emailAccountModel.nullable().optional(),
passengerInfos: z.array(passengerInfoModel).default([]),
}),
);
export type FullOrderModel = z.infer<typeof fullOrderModel>;
export const orderCursorModel = z.object({
firstItemId: z.number(),
lastItemId: z.number(),
query: z.string().default(""),
});
export type OrderCursorModel = z.infer<typeof orderCursorModel>;
export function getDefaultOrderCursor() {
return orderCursorModel.parse({ firstItemId: 0, lastItemId: 0, query: "" });
}
export const paginatedOrderInfoModel = paginationModel.merge(
z.object({ data: z.array(orderModel) }),
);
export type PaginatedOrderInfoModel = z.infer<typeof paginatedOrderInfoModel>;
export function getDefaultPaginatedOrderInfoModel(): PaginatedOrderInfoModel {
return {
data: [],
cursor: encodeCursor<OrderCursorModel>(getDefaultOrderCursor()),
limit: 20,
asc: true,
totalItemCount: 0,
totalPages: 0,
page: 0,
};
}
export const newOrderModel = orderModel.pick({
basePrice: true,
displayPrice: true,
discountAmount: true,
orderPrice: true,
fullfilledPrice: true,
pricePerPassenger: true,
flightTicketInfoId: true,
paymentDetailsId: true,
emailAccountId: true,
});
export type NewOrderModel = z.infer<typeof newOrderModel>;
export const createOrderPayloadModel = z.object({
flightTicketInfo: flightTicketModel.optional(),
flightTicketId: z.number().optional(),
refOIds: z.array(z.number()).nullable().optional(),
paymentDetails: paymentDetailsPayloadModel.optional(),
orderModel: newOrderModel,
emailAccountInfo: emailAccountPayloadModel.optional(),
passengerInfos: z.array(passengerInfoModel),
flowId: z.string().optional(),
});
export type CreateOrderModel = z.infer<typeof createOrderPayloadModel>;

View File

@@ -0,0 +1,89 @@
import { z } from "zod";
import {
flightPriceDetailsModel,
allBagDetailsModel,
} from "../../ticket/data/entities";
import { paymentDetailsModel } from "../../paymentinfo/data/entities";
export enum Gender {
Male = "male",
Female = "female",
Other = "other",
}
export enum PassengerType {
Adult = "adult",
Child = "child",
}
export const passengerPIIModel = z.object({
firstName: z.string().min(1).max(255),
middleName: z.string().min(0).max(255),
lastName: z.string().min(1).max(255),
email: z.string().email(),
phoneCountryCode: z.string().min(2).max(6).regex(/^\+/),
phoneNumber: z.string().min(2).max(20),
nationality: z.string().min(1).max(128),
gender: z.enum([Gender.Male, Gender.Female, Gender.Other]),
dob: z.string().date(),
passportNo: z.string().min(1).max(64),
// add a custom validator to ensure this is not expired (present or older)
passportExpiry: z
.string()
.date()
.refine(
(v) => new Date(v).getTime() > new Date().getTime(),
"Passport expiry must be in the future",
),
country: z.string().min(1).max(128),
state: z.string().min(1).max(128),
city: z.string().min(1).max(128),
zipCode: z.string().min(4).max(21),
address: z.string().min(1).max(128),
address2: z.string().min(0).max(128),
});
export type PassengerPII = z.infer<typeof passengerPIIModel>;
export const seatSelectionInfoModel = z.object({
id: z.string(),
row: z.string(),
number: z.number(),
seatLetter: z.string(),
available: z.boolean(),
reserved: z.boolean(),
price: flightPriceDetailsModel,
});
export type SeatSelectionInfo = z.infer<typeof seatSelectionInfoModel>;
export const flightSeatMapModel = z.object({
flightId: z.string(),
seats: z.array(z.array(seatSelectionInfoModel)),
});
export type FlightSeatMap = z.infer<typeof flightSeatMapModel>;
export const bagSelectionInfoModel = z.object({
id: z.number(),
personalBags: z.number().default(1),
handBags: z.number().default(0),
checkedBags: z.number().default(0),
pricing: allBagDetailsModel,
});
export type BagSelectionInfo = z.infer<typeof bagSelectionInfoModel>;
export const passengerInfoModel = z.object({
id: z.number(),
passengerType: z.enum([PassengerType.Adult, PassengerType.Child]),
passengerPii: passengerPIIModel,
paymentDetails: paymentDetailsModel.optional(),
passengerPiiId: z.number().optional(),
paymentDetailsId: z.number().optional(),
seatSelection: seatSelectionInfoModel,
bagSelection: bagSelectionInfoModel,
agentsInfo: z.boolean().default(false).optional(),
agentId: z.coerce.string().optional(),
flightTicketInfoId: z.number().optional(),
orderId: z.number().optional(),
});
export type PassengerInfo = z.infer<typeof passengerInfoModel>;

View File

@@ -0,0 +1,92 @@
import { z } from "zod";
export enum PaymentMethod {
Card = "card",
// INFO: for other future payment methods
}
function isValidLuhn(cardNumber: string): boolean {
const digits = cardNumber.replace(/\D/g, "");
let sum = 0;
let isEven = false;
for (let i = digits.length - 1; i >= 0; i--) {
let digit = parseInt(digits[i], 10);
if (isEven) {
digit *= 2;
if (digit > 9) {
digit -= 9;
}
}
sum += digit;
isEven = !isEven;
}
return sum % 10 === 0;
}
function isValidExpiry(expiryDate: string): boolean {
const match = expiryDate.match(/^(0[1-9]|1[0-2])\/(\d{2})$/);
if (!match) return false;
const month = parseInt(match[1], 10);
const year = parseInt(match[2], 10);
const currentDate = new Date();
const currentYear = currentDate.getFullYear() % 100; // Get last 2 digits of year
const maxYear = currentYear + 20;
// Check if year is within valid range
if (year < currentYear || year > maxYear) return false;
// If it's the current year, check if the month is valid
if (year === currentYear) {
const currentMonth = currentDate.getMonth() + 1; // getMonth() returns 0-11
if (month < currentMonth) return false;
}
return true;
}
export const cardInfoModel = z.object({
cardholderName: z.string().min(1).max(128),
cardNumber: z
.string()
.min(1)
.max(20)
.regex(/^\d+$/, "Card number must be numeric")
.refine((val) => isValidLuhn(val), {
message: "Invalid card number",
}),
expiry: z
.string()
.regex(/^(0[1-9]|1[0-2])\/\d{2}$/, "Expiry must be in mm/yy format")
.refine((val) => isValidExpiry(val), {
message: "Invalid expiry date",
}),
cvv: z
.string()
.min(3)
.max(4)
.regex(/^\d{3,4}$/, "CVV must be 3-4 digits"),
});
export type CardInfo = z.infer<typeof cardInfoModel>;
export const paymentDetailsPayloadModel = z.object({
method: z.enum([PaymentMethod.Card]),
cardDetails: cardInfoModel,
flightTicketInfoId: z.number().int(),
});
export type PaymentDetailsPayload = z.infer<typeof paymentDetailsPayloadModel>;
export const paymentDetailsModel = cardInfoModel.merge(
z.object({
id: z.number().int(),
flightTicketInfoId: z.number().int(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
}),
);
export type PaymentDetails = z.infer<typeof paymentDetailsModel>;

View File

@@ -0,0 +1,31 @@
import { z } from "zod";
import { PackageType, PaymentMethod } from "./enums";
export * from "../../../passengerinfo/data/entities";
// Flight package selection models
export const packageSelectionModel = z.object({
packageType: z.enum([
PackageType.Basic,
PackageType.Flex,
PackageType.Premium,
]),
insurance: z.boolean().default(false),
});
export type PackageSelection = z.infer<typeof packageSelectionModel>;
// payment models
export const cardInfoModel = z.object({
nameOnCard: z.string().min(1).max(255),
number: z.string().max(20),
expiryDate: z.string().max(8),
cvv: z.string().max(6),
});
export type CardInfo = z.infer<typeof cardInfoModel>;
export const paymentInfoModel = z.object({
method: z.enum([PaymentMethod.Card]).default(PaymentMethod.Card),
cardInfo: cardInfoModel,
});
export type PaymentInfo = z.infer<typeof paymentInfoModel>;

View File

@@ -0,0 +1,43 @@
export enum CheckoutStep {
Setup = "SETUP",
Initial = "INITIAL",
Payment = "PAYMENT",
Verification = "VERIFICATION",
Confirmation = "CONFIRMATION",
Complete = "COMPLETE",
}
export const TicketType = {
OneWay: "ONEWAY",
Return: "RETURN",
};
export const CabinClass = {
Economy: "ECONOMY",
PremiumEconomy: "PREMIUM_ECONOMY",
Business: "BUSINESS",
FirstClass: "FIRST_CLASS",
};
export enum Gender {
Male = "male",
Female = "female",
Other = "other",
}
export enum PaymentMethod {
Card = "CARD",
GooglePay = "GOOGLE_PAY",
ApplePay = "APPLEPAY",
}
export enum PassengerType {
Adult = "adult",
Child = "child",
}
export enum PackageType {
Basic = "basic",
Flex = "flex",
Premium = "premium",
}

View File

@@ -0,0 +1,178 @@
import { z } from "zod";
import { CabinClass, TicketType } from "./enums";
export * from "./enums";
export const stationModel = z.object({
id: z.number(),
type: z.string(),
code: z.string(),
name: z.string(),
city: z.string(),
country: z.string(),
});
export type Station = z.infer<typeof stationModel>;
export const iteneraryStationModel = z.object({
station: stationModel,
localTime: z.string(),
utcTime: z.string(),
});
export type IteneraryStation = z.infer<typeof iteneraryStationModel>;
export const seatInfoModel = z.object({
availableSeats: z.number(),
seatClass: z.string(),
});
export type SeatInfo = z.infer<typeof seatInfoModel>;
export const flightPriceDetailsModel = z.object({
currency: z.string(),
basePrice: z.number(),
discountAmount: z.number(),
displayPrice: z.number(),
orderPrice: z.number().nullable().optional(),
appliedCoupon: z.string().nullish().optional(),
couponDescription: z.string().nullish().optional(),
});
export type FlightPriceDetails = z.infer<typeof flightPriceDetailsModel>;
export const airlineModel = z.object({
code: z.string(),
name: z.string(),
imageUrl: z.string().nullable().optional(),
});
export type Airline = z.infer<typeof airlineModel>;
export const flightIteneraryModel = z.object({
flightId: z.string(),
flightNumber: z.string(),
airline: airlineModel,
departure: iteneraryStationModel,
destination: iteneraryStationModel,
durationSeconds: z.number(),
seatInfo: seatInfoModel,
});
export type FlightItenerary = z.infer<typeof flightIteneraryModel>;
export const passengerCountModel = z.object({
adults: z.number().int().min(0),
children: z.number().int().min(0),
});
export type PassengerCount = z.infer<typeof passengerCountModel>;
export function countPassengers(model: PassengerCount) {
return model.adults + model.children;
}
export const bagDimensionsModel = z.object({
length: z.number(),
width: z.number(),
height: z.number(),
});
export type BagDimensions = z.infer<typeof bagDimensionsModel>;
export const bagDetailsModel = z.object({
price: z.number(),
weight: z.number(),
unit: z.string(),
dimensions: bagDimensionsModel,
});
export type BagDetails = z.infer<typeof bagDetailsModel>;
export const allBagDetailsModel = z.object({
personalBags: bagDetailsModel,
handBags: bagDetailsModel,
checkedBags: bagDetailsModel,
});
export type AllBagDetails = z.infer<typeof allBagDetailsModel>;
// INFO: If you are to array-ificate it, you can just modify the details
// key below so that the user's order thing is not disrupted
export const bagsInfoModel = z.object({
includedPersonalBags: z.number().default(1),
includedHandBags: z.number().default(0),
includedCheckedBags: z.number().default(0),
hasHandBagsSupport: z.boolean().default(true),
hasCheckedBagsSupport: z.boolean().default(true),
details: allBagDetailsModel,
});
export type BagsInfo = z.infer<typeof bagsInfoModel>;
export const flightTicketModel = z.object({
id: z.number().int(),
ticketId: z.string(),
// For lookup purposes, we need these on the top level
departure: z.string(),
arrival: z.string(),
departureDate: z.coerce.string(),
returnDate: z.coerce.string().default(""),
dates: z.array(z.string()),
flightType: z.enum([TicketType.OneWay, TicketType.Return]),
flightIteneraries: z.object({
outbound: z.array(flightIteneraryModel),
inbound: z.array(flightIteneraryModel),
}),
priceDetails: flightPriceDetailsModel,
refundable: z.boolean(),
passengerCounts: passengerCountModel,
cabinClass: z.string(),
bagsInfo: bagsInfoModel,
lastAvailable: z.object({ availableSeats: z.number() }),
shareId: z.string(),
checkoutUrl: z.string(),
isCache: z.boolean().nullish().optional(),
refOIds: z.array(z.coerce.number()).nullish().optional(),
createdAt: z.coerce.string(),
updatedAt: z.coerce.string(),
});
export type FlightTicket = z.infer<typeof flightTicketModel>;
export const limitedFlightTicketModel = flightTicketModel.pick({
id: true,
departure: true,
arrival: true,
departureDate: true,
returnDate: true,
flightType: true,
dates: true,
priceDetails: true,
passengerCounts: true,
cabinClass: true,
});
export type LimitedFlightTicket = z.infer<typeof limitedFlightTicketModel>;
// INFO: ticket search models
export const ticketSearchPayloadModel = z.object({
sessionId: z.string(),
ticketType: z.enum([TicketType.OneWay, TicketType.Return]),
cabinClass: z.enum([
CabinClass.Economy,
CabinClass.PremiumEconomy,
CabinClass.Business,
CabinClass.FirstClass,
]),
departure: z.string().min(3),
arrival: z.string().min(3),
passengerCounts: passengerCountModel,
departureDate: z.coerce.string().min(3),
returnDate: z.coerce.string(),
loadMore: z.boolean().default(false),
meta: z.record(z.string(), z.any()).optional(),
couponCode: z.string().optional(),
});
export type TicketSearchPayload = z.infer<typeof ticketSearchPayloadModel>;
export const ticketSearchDTO = z.object({
sessionId: z.string(),
ticketSearchPayload: ticketSearchPayloadModel,
providers: z.array(z.string()).optional(),
});
export type TicketSearchDTO = z.infer<typeof ticketSearchDTO>;

View File

@@ -0,0 +1,103 @@
import { UserRoleMap } from "../../../core/enums";
import { paginationModel } from "../../../core/pagination.utils";
import { encodeCursor } from "../../../core/string.utils";
import z from "zod";
const usernameModel = z
.string()
.min(2)
.max(128)
.regex(/^[a-zA-Z0-9_-]+$/, {
message:
"Username can only contain letters, numbers, underscores, and hyphens.",
});
export const emailModel = z.string().email().min(5).max(128);
export const discountPercentModel = z
.number()
.min(0)
.max(100)
.default(0)
.nullish();
const roleModel = z.enum(Object.values(UserRoleMap) as [string, ...string[]]);
export const createUserPayloadModel = z.object({
username: usernameModel,
email: emailModel,
password: z.string().min(8).max(128),
role: roleModel,
});
export type CreateUserPayloadModel = z.infer<typeof createUserPayloadModel>;
export type GetUserByPayload = { id?: string; username?: string };
export const updateUserInfoInputModel = z.object({
email: emailModel,
username: usernameModel,
discountPercent: discountPercentModel,
banned: z.boolean().nullable().optional(),
});
export type UpdateUserInfoInputModel = z.infer<typeof updateUserInfoInputModel>;
export const userModel = updateUserInfoInputModel.merge(
z.object({
id: z.coerce.string(),
role: roleModel.nullable().optional(),
discountPercent: discountPercentModel,
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
parentId: z.coerce.string().nullable().optional(),
}),
);
export type UserModel = z.infer<typeof userModel>;
export const limitedUserModel = userModel.pick({
id: true,
username: true,
email: true,
});
export type LimitedUserModel = z.infer<typeof limitedUserModel>;
export const userFullModel = userModel.merge(
z.object({
banned: z.boolean(),
banReason: z.string().optional(),
banExpires: z.date().optional(),
twoFactorEnabled: z.boolean().default(false),
}),
);
export type UserFullModel = z.infer<typeof userFullModel>;
export const usersCursorModel = z.object({
firstItemUsername: z.string(),
lastItemUsername: z.string(),
query: z.string().default(""),
});
export type UsersCursorModel = z.infer<typeof usersCursorModel>;
export function getDefaultUsersCursor() {
return usersCursorModel.parse({
firstItemUsername: "",
lastItemUsername: "",
query: "",
});
}
export const paginatedUserModel = paginationModel.merge(
z.object({ data: z.array(userModel) }),
);
export type PaginatedUserModel = z.infer<typeof paginatedUserModel>;
export function getDefaultPaginatedUserModel(): PaginatedUserModel {
return {
data: [],
cursor: encodeCursor<UsersCursorModel>(getDefaultUsersCursor()),
limit: 20,
asc: true,
totalItemCount: 0,
totalPages: 0,
page: 0,
};
}

View File

@@ -0,0 +1,17 @@
{
"name": "@pkg/logic",
"dependencies": {
"@effect/opentelemetry": "^0.46.11",
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@pkg/result": "workspace:*",
"effect": "^3.14.14",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,5 @@
{
"compilerOptions": {
"strict": true
}
}

24
packages/result/index.ts Normal file
View File

@@ -0,0 +1,24 @@
export const ERROR_CODES = {
API_ERROR: "API_ERROR",
DATABASE_ERROR: "DATABASE_ERROR",
NETWORK_ERROR: "NETWORK_ERROR",
AUTH_ERROR: "AUTH_ERROR",
PERMISSION_ERROR: "PERMISSION_ERROR",
VALIDATION_ERROR: "VALIDATION_ERROR",
UNKNOWN_ERROR: "UNKNOWN_ERROR",
NOT_FOUND_ERROR: "NOT_FOUND_ERROR",
INPUT_ERROR: "INPUT_ERROR",
INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR",
EXTERNAL_SERVICE_ERROR: "EXTERNAL_SERVICE_ERROR",
} as const;
export type Err = {
code: string;
message: string;
userHint: string;
detail: string;
actionable?: boolean;
error?: any;
};
export type Result<T> = { data?: T; error?: Err };

View File

@@ -0,0 +1,9 @@
{
"name": "@pkg/result",
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
}
}