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

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
}
}