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