💥 Done (almost)
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
import { db } from "@pkg/db";
|
||||
import { Logger } from "@pkg/logger";
|
||||
import type { Result } from "@pkg/result";
|
||||
import type { CreateCustomerInfoPayload, CustomerInfoModel } from "./data";
|
||||
import type { CustomerInfoRepository } from "./repository";
|
||||
import type {
|
||||
CreateCustomerInfoPayload,
|
||||
CustomerInfoModel,
|
||||
UpdateCustomerInfoPayload,
|
||||
} from "./data";
|
||||
import { CustomerInfoRepository } from "./repository";
|
||||
|
||||
/**
|
||||
* CustomerInfoController handles business logic for customer information operations.
|
||||
@@ -44,6 +49,28 @@ export class CustomerInfoController {
|
||||
Logger.info("Retrieving all customer info records");
|
||||
return this.repo.getAllCustomerInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates existing customer information
|
||||
* @param payload - Customer information data to update (must include id)
|
||||
* @returns Result containing success boolean or error
|
||||
*/
|
||||
async updateCustomerInfo(
|
||||
payload: UpdateCustomerInfoPayload,
|
||||
): Promise<Result<boolean>> {
|
||||
Logger.info(`Updating customer info with ID: ${payload.id}`);
|
||||
return this.repo.updateCustomerInfo(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes customer information by ID
|
||||
* @param id - Customer info ID to delete
|
||||
* @returns Result containing success boolean or error
|
||||
*/
|
||||
async deleteCustomerInfo(id: number): Promise<Result<boolean>> {
|
||||
Logger.info(`Deleting customer info with ID: ${id}`);
|
||||
return this.repo.deleteCustomerInfo(id);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCustomerInfoController() {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { SessionOutcome } from "$lib/domains/ckflow/data/entities";
|
||||
import { getCKUseCases } from "$lib/domains/ckflow/domain/usecases";
|
||||
import { getCustomerInfoController } from "$lib/domains/customerinfo/controller";
|
||||
import { EmailerUseCases } from "$lib/domains/email/domain/usecases";
|
||||
import {
|
||||
CheckoutStep,
|
||||
createOrderPayloadModel,
|
||||
OrderCreationStep,
|
||||
} from "$lib/domains/order/data/entities";
|
||||
import { PaymentInfoRepository } from "$lib/domains/paymentinfo/data/repository";
|
||||
import { PaymentInfoUseCases } from "$lib/domains/paymentinfo/domain/usecases";
|
||||
@@ -12,66 +11,104 @@ import { getProductUseCases } from "$lib/domains/product/usecases";
|
||||
import { createTRPCRouter, publicProcedure } from "$lib/trpc/t";
|
||||
import { db } from "@pkg/db";
|
||||
import { getError, Logger } from "@pkg/logger";
|
||||
import { ERROR_CODES } from "@pkg/result";
|
||||
import { ERROR_CODES, type Result } from "@pkg/result";
|
||||
import { z } from "zod";
|
||||
import { OrderRepository } from "../data/repository";
|
||||
import { OrderController } from "./controller";
|
||||
|
||||
export const orderRouter = createTRPCRouter({
|
||||
/**
|
||||
* Creates a new order for product checkout
|
||||
* Handles customer info creation, payment info creation, and order creation
|
||||
*/
|
||||
createOrder: publicProcedure
|
||||
.input(createOrderPayloadModel)
|
||||
.mutation(async ({ input }) => {
|
||||
const pduc = new PaymentInfoUseCases(new PaymentInfoRepository(db));
|
||||
const oc = new OrderController(new OrderRepository(db));
|
||||
const cc = getCustomerInfoController();
|
||||
const puc = getProductUseCases();
|
||||
const emailUC = new EmailerUseCases();
|
||||
const paymentInfoUC = new PaymentInfoUseCases(
|
||||
new PaymentInfoRepository(db),
|
||||
);
|
||||
const orderController = new OrderController(new OrderRepository(db));
|
||||
const customerInfoController = getCustomerInfoController();
|
||||
const productUC = getProductUseCases();
|
||||
|
||||
const ftRes = await tc.uncacheAndSaveTicket(input.flightTicketId!);
|
||||
if (ftRes.error || !ftRes.data) {
|
||||
return { error: ftRes.error };
|
||||
}
|
||||
|
||||
if (!input.flightTicketId || !input.paymentInfo) {
|
||||
// Validate required inputs
|
||||
if (!input.productId || !input.customerInfo) {
|
||||
return {
|
||||
error: getError({
|
||||
code: ERROR_CODES.INPUT_ERROR,
|
||||
message: "Received invalid input",
|
||||
detail: "The entered data is incomplete or invalid",
|
||||
userHint: "Enter valid order data to complete the order",
|
||||
message: "Missing required order information",
|
||||
detail: "Product ID and customer information are required",
|
||||
userHint:
|
||||
"Please ensure product and customer information are provided",
|
||||
}),
|
||||
};
|
||||
}
|
||||
const pdRes = await pduc.createPaymentInfo(input.paymentInfo!);
|
||||
if (pdRes.error || !pdRes.data) {
|
||||
return { error: pdRes.error };
|
||||
} as Result<string>;
|
||||
}
|
||||
|
||||
Logger.info(`Setting flight ticket info id ${ftRes.data}`);
|
||||
input.orderModel.flightTicketInfoId = ftRes.data;
|
||||
|
||||
Logger.info(`Setting payment details id ${pdRes.data}`);
|
||||
input.orderModel.paymentInfoId = pdRes.data;
|
||||
|
||||
Logger.info("Creating order");
|
||||
const out = await oc.createOrder(input.orderModel);
|
||||
if (out.error || !out.data) {
|
||||
await pduc.deletePaymentInfo(pdRes.data!);
|
||||
return { error: out.error };
|
||||
// Verify product exists
|
||||
Logger.info(`Verifying product with ID: ${input.productId}`);
|
||||
const productRes = await productUC.getProductById(input.productId);
|
||||
if (productRes.error || !productRes.data) {
|
||||
return {
|
||||
error: getError({
|
||||
code: ERROR_CODES.NOT_FOUND_ERROR,
|
||||
message: "Product not found",
|
||||
detail: `Product with ID ${input.productId} does not exist`,
|
||||
userHint: "Please select a valid product",
|
||||
}),
|
||||
} as Result<string>;
|
||||
}
|
||||
|
||||
Logger.info(`Creating customer infos with oid: ${out.data}`);
|
||||
const pOut = await pc.createPassengerInfos(
|
||||
input.passengerInfos,
|
||||
out.data.id,
|
||||
ftRes.data!,
|
||||
pdRes.data,
|
||||
// Create customer information
|
||||
Logger.info("Creating customer information");
|
||||
const customerRes = await customerInfoController.createCustomerInfo(
|
||||
input.customerInfo,
|
||||
);
|
||||
if (pOut.error) {
|
||||
await oc.deleteOrder(out.data.id);
|
||||
return { error: pOut.error };
|
||||
if (customerRes.error || !customerRes.data) {
|
||||
return { error: customerRes.error } as Result<string>;
|
||||
}
|
||||
const customerInfoId = customerRes.data;
|
||||
Logger.info(`Customer info created with ID: ${customerInfoId}`);
|
||||
|
||||
// Create payment information if provided
|
||||
let paymentInfoId: number | undefined = undefined;
|
||||
if (input.paymentInfo) {
|
||||
Logger.info("Creating payment information");
|
||||
const paymentRes = await paymentInfoUC.createPaymentInfo(
|
||||
input.paymentInfo,
|
||||
);
|
||||
if (paymentRes.error || !paymentRes.data) {
|
||||
// Cleanup customer info on payment failure
|
||||
await customerInfoController.deleteCustomerInfo(
|
||||
customerInfoId,
|
||||
);
|
||||
return { error: paymentRes.error } as Result<string>;
|
||||
}
|
||||
paymentInfoId = paymentRes.data;
|
||||
Logger.info(`Payment info created with ID: ${paymentInfoId}`);
|
||||
}
|
||||
|
||||
// Set IDs in order model
|
||||
input.orderModel.productId = input.productId;
|
||||
input.orderModel.customerInfoId = customerInfoId;
|
||||
input.orderModel.paymentInfoId = paymentInfoId;
|
||||
|
||||
// Create the order
|
||||
Logger.info("Creating order");
|
||||
const orderRes = await orderController.createOrder(input.orderModel);
|
||||
if (orderRes.error || !orderRes.data) {
|
||||
// Cleanup on order creation failure
|
||||
if (paymentInfoId) {
|
||||
await paymentInfoUC.deletePaymentInfo(paymentInfoId);
|
||||
}
|
||||
await customerInfoController.deleteCustomerInfo(customerInfoId);
|
||||
return { error: orderRes.error } as Result<string>;
|
||||
}
|
||||
|
||||
const orderId = orderRes.data.id;
|
||||
const orderUID = orderRes.data.uid;
|
||||
Logger.info(`Order created successfully with ID: ${orderId}`);
|
||||
|
||||
// Update checkout flow state if flowId is provided
|
||||
if (input.flowId) {
|
||||
Logger.info(
|
||||
`Updating checkout flow state for flow ${input.flowId}`,
|
||||
@@ -79,7 +116,7 @@ export const orderRouter = createTRPCRouter({
|
||||
try {
|
||||
await getCKUseCases().cleanupFlow(input.flowId, {
|
||||
sessionOutcome: SessionOutcome.COMPLETED,
|
||||
checkoutStep: CheckoutStep.Complete,
|
||||
checkoutStep: OrderCreationStep.SUMMARY,
|
||||
});
|
||||
Logger.info(
|
||||
`Checkout flow ${input.flowId} marked as completed`,
|
||||
@@ -90,94 +127,29 @@ export const orderRouter = createTRPCRouter({
|
||||
);
|
||||
Logger.error(err);
|
||||
}
|
||||
} else {
|
||||
Logger.warn(
|
||||
`No flow id found to mark as completed: ${input.flowId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const uid = out.data.uid;
|
||||
|
||||
if (!uid) {
|
||||
return out;
|
||||
}
|
||||
try {
|
||||
// Get order details for email
|
||||
const orderDetails = await oc.getOrderByPNR(uid);
|
||||
|
||||
if (!orderDetails.data) {
|
||||
return out;
|
||||
}
|
||||
const order = orderDetails.data;
|
||||
const ticketInfo = order.flightTicketInfo;
|
||||
const primaryPassenger = order.passengerInfos[0]?.passengerPii!;
|
||||
|
||||
if (!ticketInfo || !primaryPassenger) {
|
||||
Logger.warn(
|
||||
`No email address found for passenger to send PNR confirmation`,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
// Get passenger email address to send confirmation to
|
||||
const passengerEmail = primaryPassenger.email;
|
||||
if (!passengerEmail) {
|
||||
Logger.warn(
|
||||
`No email address found for passenger to send PNR confirmation`,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
Logger.info(
|
||||
`Sending PNR confirmation email to ${passengerEmail}`,
|
||||
Logger.debug(
|
||||
"Rotating the link id for the product now that it's been used",
|
||||
);
|
||||
|
||||
// Send the email with React component directly
|
||||
const emailResult = await emailUC.sendEmailWithTemplate({
|
||||
to: passengerEmail,
|
||||
subject: `Flight Confirmation: ${ticketInfo.departure} to ${ticketInfo.arrival} - PNR: ${uid}`,
|
||||
template: "uid-confirmation",
|
||||
templateData: {
|
||||
uid: uid,
|
||||
origin: ticketInfo.departure,
|
||||
destination: ticketInfo.arrival,
|
||||
departureDate: new Date(
|
||||
ticketInfo.departureDate,
|
||||
).toISOString(),
|
||||
returnDate: new Date(
|
||||
ticketInfo.returnDate,
|
||||
)?.toISOString(),
|
||||
passengerName: `${primaryPassenger.firstName}`,
|
||||
baseUrl: "https://FlyTicketTravel.com",
|
||||
logoPath:
|
||||
"https://FlyTicketTravel.com/assets/logos/logo-main.svg",
|
||||
companyName: "FlyTicketTravel",
|
||||
},
|
||||
});
|
||||
const out = await productUC.refreshProductLinkId(input.productId);
|
||||
|
||||
if (emailResult.error) {
|
||||
Logger.error(
|
||||
`Failed to send PNR confirmation email: ${emailResult.error.message}`,
|
||||
Logger.debug(
|
||||
`Product link id rotated: ${JSON.stringify(out, null, 2)}`,
|
||||
);
|
||||
} else {
|
||||
Logger.info(
|
||||
`PNR confirmation email sent to ${passengerEmail}`,
|
||||
);
|
||||
}
|
||||
} catch (emailError) {
|
||||
// Don't fail the order if email sending fails
|
||||
Logger.error(
|
||||
`Error sending PNR confirmation email: ${emailError}`,
|
||||
);
|
||||
Logger.error(emailError);
|
||||
}
|
||||
|
||||
Logger.info("Done with order creation, returning");
|
||||
return out;
|
||||
Logger.info("Order creation completed successfully");
|
||||
return { data: orderUID } as Result<string>;
|
||||
}),
|
||||
|
||||
findByUID: publicProcedure
|
||||
/**
|
||||
* Finds an order by its ID
|
||||
*/
|
||||
findByIdUID: publicProcedure
|
||||
.input(z.object({ uid: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
const oc = new OrderController(new OrderRepository(db));
|
||||
return oc.getOrderByUID(input.uid);
|
||||
const orderController = new OrderController(new OrderRepository(db));
|
||||
return orderController.getOrderByUID(input.uid);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -119,8 +119,6 @@ export class CreateOrderViewModel {
|
||||
orderModel: {
|
||||
...priceDetails,
|
||||
productId: product.id,
|
||||
customerInfoId: customerInfoVM.customerInfo.id,
|
||||
paymentInfoId: undefined,
|
||||
},
|
||||
flowId: ckFlowVM.flowId,
|
||||
});
|
||||
@@ -160,7 +158,7 @@ export class CreateOrderViewModel {
|
||||
|
||||
// Redirect to success page after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = `/order/success?id=${out.data}`;
|
||||
window.location.href = `/order/success?uid=${out.data}`;
|
||||
}, 1000);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
description: "Preparing checkout session",
|
||||
});
|
||||
window.location.replace(
|
||||
`/checkout/${$checkoutSessionIdStore}/${data.data.id}`,
|
||||
`/checkout/${$checkoutSessionIdStore}/${data.data.linkId}`,
|
||||
);
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,5 @@ import { getProductUseCases } from "$lib/domains/product/usecases";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
return await getProductUseCases().getProductByLinkId(
|
||||
Number(params.plid ?? "-1"),
|
||||
);
|
||||
return await getProductUseCases().getProductByLinkId(params.plid);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
import CheckoutConfirmationSection from "$lib/domains/checkout/checkout-confirmation-section.svelte";
|
||||
import CheckoutLoadingSection from "$lib/domains/checkout/checkout-loading-section.svelte";
|
||||
import CheckoutStepsIndicator from "$lib/domains/checkout/checkout-steps-indicator.svelte";
|
||||
@@ -51,10 +50,8 @@
|
||||
<div class="grid h-full min-h-screen w-full place-items-center">
|
||||
<div class="flex flex-col items-center justify-center gap-4">
|
||||
<Icon icon={SearchIcon} cls="w-12 h-12" />
|
||||
<Title size="h4" color="black">No ticket found</Title>
|
||||
<Button href="/search" variant="default">
|
||||
Search for another ticket
|
||||
</Button>
|
||||
<Title size="h4" color="black">Product not found</Title>
|
||||
<p>Something went wrong, please try again or contact us</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if checkoutVM.checkoutStep === CheckoutStep.Confirmation}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||
import CheckIcon from "~icons/ic/round-check";
|
||||
// Maybe todo? if the `uid` search param is present, do something?? figure out later
|
||||
</script>
|
||||
|
||||
<div class="grid min-h-[80vh] w-full place-items-center px-4 sm:px-6">
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
import CloseIcon from "~icons/mdi/window-close";
|
||||
import ArrowRightIcon from "~icons/solar/multiple-forward-right-line-duotone";
|
||||
import RestartIcon from "~icons/material-symbols/restart-alt-rounded";
|
||||
import { onMount } from "svelte";
|
||||
import CloseIcon from "~icons/mdi/window-close";
|
||||
|
||||
let canRedirect = $state(false);
|
||||
|
||||
@@ -37,25 +34,9 @@
|
||||
<Title size="h3" center weight="medium">Session Terminated</Title>
|
||||
<p class="w-full max-w-prose text-center text-gray-600">
|
||||
Unfortunately, your session has been terminated due to inactivity
|
||||
or expiration of the booking.
|
||||
or something went wrong, please contact us to walk through the
|
||||
steps again.
|
||||
</p>
|
||||
<div class="flex w-full flex-col justify-center gap-4 md:flex-row">
|
||||
{#if canRedirect}
|
||||
<Button
|
||||
variant="default"
|
||||
size="lg"
|
||||
href={`/checkout/${sid}/${tid}`}
|
||||
>
|
||||
<Icon icon={RestartIcon} cls="w-auto h-6" />
|
||||
<span>Try Again</span>
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Button variant="defaultInverted" size="lg" href="/search">
|
||||
<Icon icon={ArrowRightIcon} cls="w-auto h-6" />
|
||||
<span>Back To Search</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</MaxWidthWrapper>
|
||||
</div>
|
||||
|
||||
@@ -102,7 +102,8 @@ export function getDefaultPaginatedOrderInfoModel(): PaginatedOrderInfoModel {
|
||||
};
|
||||
}
|
||||
|
||||
export const newOrderModel = orderModel.pick({
|
||||
export const newOrderModel = orderModel
|
||||
.pick({
|
||||
basePrice: true,
|
||||
displayPrice: true,
|
||||
discountAmount: true,
|
||||
@@ -111,6 +112,11 @@ export const newOrderModel = orderModel.pick({
|
||||
productId: true,
|
||||
customerInfoId: true,
|
||||
paymentInfoId: true,
|
||||
})
|
||||
.extend({
|
||||
currency: z.string().default("USD"),
|
||||
customerInfoId: z.number().optional(),
|
||||
paymentInfoId: z.number().optional(),
|
||||
});
|
||||
export type NewOrderModel = z.infer<typeof newOrderModel>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user