💥 Done (almost)

This commit is contained in:
user
2025-10-21 17:29:16 +03:00
parent 8a169f84cc
commit 88d430a15e
9 changed files with 149 additions and 169 deletions

View File

@@ -1,7 +1,12 @@
import { db } from "@pkg/db";
import { Logger } from "@pkg/logger"; import { Logger } from "@pkg/logger";
import type { Result } from "@pkg/result"; import type { Result } from "@pkg/result";
import type { CreateCustomerInfoPayload, CustomerInfoModel } from "./data"; import type {
import type { CustomerInfoRepository } from "./repository"; CreateCustomerInfoPayload,
CustomerInfoModel,
UpdateCustomerInfoPayload,
} from "./data";
import { CustomerInfoRepository } from "./repository";
/** /**
* CustomerInfoController handles business logic for customer information operations. * CustomerInfoController handles business logic for customer information operations.
@@ -44,6 +49,28 @@ export class CustomerInfoController {
Logger.info("Retrieving all customer info records"); Logger.info("Retrieving all customer info records");
return this.repo.getAllCustomerInfo(); 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() { export function getCustomerInfoController() {

View File

@@ -1,10 +1,9 @@
import { SessionOutcome } from "$lib/domains/ckflow/data/entities"; import { SessionOutcome } from "$lib/domains/ckflow/data/entities";
import { getCKUseCases } from "$lib/domains/ckflow/domain/usecases"; import { getCKUseCases } from "$lib/domains/ckflow/domain/usecases";
import { getCustomerInfoController } from "$lib/domains/customerinfo/controller"; import { getCustomerInfoController } from "$lib/domains/customerinfo/controller";
import { EmailerUseCases } from "$lib/domains/email/domain/usecases";
import { import {
CheckoutStep,
createOrderPayloadModel, createOrderPayloadModel,
OrderCreationStep,
} from "$lib/domains/order/data/entities"; } from "$lib/domains/order/data/entities";
import { PaymentInfoRepository } from "$lib/domains/paymentinfo/data/repository"; import { PaymentInfoRepository } from "$lib/domains/paymentinfo/data/repository";
import { PaymentInfoUseCases } from "$lib/domains/paymentinfo/domain/usecases"; 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 { createTRPCRouter, publicProcedure } from "$lib/trpc/t";
import { db } from "@pkg/db"; import { db } from "@pkg/db";
import { getError, Logger } from "@pkg/logger"; 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 { z } from "zod";
import { OrderRepository } from "../data/repository"; import { OrderRepository } from "../data/repository";
import { OrderController } from "./controller"; import { OrderController } from "./controller";
export const orderRouter = createTRPCRouter({ export const orderRouter = createTRPCRouter({
/**
* Creates a new order for product checkout
* Handles customer info creation, payment info creation, and order creation
*/
createOrder: publicProcedure createOrder: publicProcedure
.input(createOrderPayloadModel) .input(createOrderPayloadModel)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const pduc = new PaymentInfoUseCases(new PaymentInfoRepository(db)); const paymentInfoUC = new PaymentInfoUseCases(
const oc = new OrderController(new OrderRepository(db)); new PaymentInfoRepository(db),
const cc = getCustomerInfoController(); );
const puc = getProductUseCases(); const orderController = new OrderController(new OrderRepository(db));
const emailUC = new EmailerUseCases(); const customerInfoController = getCustomerInfoController();
const productUC = getProductUseCases();
const ftRes = await tc.uncacheAndSaveTicket(input.flightTicketId!); // Validate required inputs
if (ftRes.error || !ftRes.data) { if (!input.productId || !input.customerInfo) {
return { error: ftRes.error };
}
if (!input.flightTicketId || !input.paymentInfo) {
return { return {
error: getError({ error: getError({
code: ERROR_CODES.INPUT_ERROR, code: ERROR_CODES.INPUT_ERROR,
message: "Received invalid input", message: "Missing required order information",
detail: "The entered data is incomplete or invalid", detail: "Product ID and customer information are required",
userHint: "Enter valid order data to complete the order", userHint:
"Please ensure product and customer information are provided",
}), }),
}; } as Result<string>;
}
const pdRes = await pduc.createPaymentInfo(input.paymentInfo!);
if (pdRes.error || !pdRes.data) {
return { error: pdRes.error };
} }
Logger.info(`Setting flight ticket info id ${ftRes.data}`); // Verify product exists
input.orderModel.flightTicketInfoId = ftRes.data; Logger.info(`Verifying product with ID: ${input.productId}`);
const productRes = await productUC.getProductById(input.productId);
Logger.info(`Setting payment details id ${pdRes.data}`); if (productRes.error || !productRes.data) {
input.orderModel.paymentInfoId = pdRes.data; return {
error: getError({
Logger.info("Creating order"); code: ERROR_CODES.NOT_FOUND_ERROR,
const out = await oc.createOrder(input.orderModel); message: "Product not found",
if (out.error || !out.data) { detail: `Product with ID ${input.productId} does not exist`,
await pduc.deletePaymentInfo(pdRes.data!); userHint: "Please select a valid product",
return { error: out.error }; }),
} as Result<string>;
} }
Logger.info(`Creating customer infos with oid: ${out.data}`); // Create customer information
const pOut = await pc.createPassengerInfos( Logger.info("Creating customer information");
input.passengerInfos, const customerRes = await customerInfoController.createCustomerInfo(
out.data.id, input.customerInfo,
ftRes.data!,
pdRes.data,
); );
if (pOut.error) { if (customerRes.error || !customerRes.data) {
await oc.deleteOrder(out.data.id); return { error: customerRes.error } as Result<string>;
return { error: pOut.error }; }
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) { if (input.flowId) {
Logger.info( Logger.info(
`Updating checkout flow state for flow ${input.flowId}`, `Updating checkout flow state for flow ${input.flowId}`,
@@ -79,7 +116,7 @@ export const orderRouter = createTRPCRouter({
try { try {
await getCKUseCases().cleanupFlow(input.flowId, { await getCKUseCases().cleanupFlow(input.flowId, {
sessionOutcome: SessionOutcome.COMPLETED, sessionOutcome: SessionOutcome.COMPLETED,
checkoutStep: CheckoutStep.Complete, checkoutStep: OrderCreationStep.SUMMARY,
}); });
Logger.info( Logger.info(
`Checkout flow ${input.flowId} marked as completed`, `Checkout flow ${input.flowId} marked as completed`,
@@ -90,94 +127,29 @@ export const orderRouter = createTRPCRouter({
); );
Logger.error(err); Logger.error(err);
} }
} else {
Logger.warn(
`No flow id found to mark as completed: ${input.flowId}`,
);
} }
const uid = out.data.uid; Logger.debug(
"Rotating the link id for the product now that it's been used",
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}`,
); );
// Send the email with React component directly const out = await productUC.refreshProductLinkId(input.productId);
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",
},
});
if (emailResult.error) { Logger.debug(
Logger.error( `Product link id rotated: ${JSON.stringify(out, null, 2)}`,
`Failed to send PNR confirmation email: ${emailResult.error.message}`,
); );
} 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"); Logger.info("Order creation completed successfully");
return out; return { data: orderUID } as Result<string>;
}), }),
findByUID: publicProcedure /**
* Finds an order by its ID
*/
findByIdUID: publicProcedure
.input(z.object({ uid: z.string() })) .input(z.object({ uid: z.string() }))
.query(async ({ input }) => { .query(async ({ input }) => {
const oc = new OrderController(new OrderRepository(db)); const orderController = new OrderController(new OrderRepository(db));
return oc.getOrderByUID(input.uid); return orderController.getOrderByUID(input.uid);
}), }),
}); });

View File

@@ -119,8 +119,6 @@ export class CreateOrderViewModel {
orderModel: { orderModel: {
...priceDetails, ...priceDetails,
productId: product.id, productId: product.id,
customerInfoId: customerInfoVM.customerInfo.id,
paymentInfoId: undefined,
}, },
flowId: ckFlowVM.flowId, flowId: ckFlowVM.flowId,
}); });
@@ -160,7 +158,7 @@ export class CreateOrderViewModel {
// Redirect to success page after a short delay // Redirect to success page after a short delay
setTimeout(() => { setTimeout(() => {
window.location.href = `/order/success?id=${out.data}`; window.location.href = `/order/success?uid=${out.data}`;
}, 1000); }, 1000);
return true; return true;

View File

@@ -23,7 +23,7 @@
description: "Preparing checkout session", description: "Preparing checkout session",
}); });
window.location.replace( window.location.replace(
`/checkout/${$checkoutSessionIdStore}/${data.data.id}`, `/checkout/${$checkoutSessionIdStore}/${data.data.linkId}`,
); );
}, 3000); }, 3000);
}); });

View File

@@ -2,7 +2,5 @@ import { getProductUseCases } from "$lib/domains/product/usecases";
import type { PageServerLoad } from "./$types"; import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params }) => {
return await getProductUseCases().getProductByLinkId( return await getProductUseCases().getProductByLinkId(params.plid);
Number(params.plid ?? "-1"),
);
}; };

View File

@@ -2,7 +2,6 @@
import Icon from "$lib/components/atoms/icon.svelte"; import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte"; import Title from "$lib/components/atoms/title.svelte";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.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 CheckoutConfirmationSection from "$lib/domains/checkout/checkout-confirmation-section.svelte";
import CheckoutLoadingSection from "$lib/domains/checkout/checkout-loading-section.svelte"; import CheckoutLoadingSection from "$lib/domains/checkout/checkout-loading-section.svelte";
import CheckoutStepsIndicator from "$lib/domains/checkout/checkout-steps-indicator.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="grid h-full min-h-screen w-full place-items-center">
<div class="flex flex-col items-center justify-center gap-4"> <div class="flex flex-col items-center justify-center gap-4">
<Icon icon={SearchIcon} cls="w-12 h-12" /> <Icon icon={SearchIcon} cls="w-12 h-12" />
<Title size="h4" color="black">No ticket found</Title> <Title size="h4" color="black">Product not found</Title>
<Button href="/search" variant="default"> <p>Something went wrong, please try again or contact us</p>
Search for another ticket
</Button>
</div> </div>
</div> </div>
{:else if checkoutVM.checkoutStep === CheckoutStep.Confirmation} {:else if checkoutVM.checkoutStep === CheckoutStep.Confirmation}

View File

@@ -3,6 +3,7 @@
import Title from "$lib/components/atoms/title.svelte"; import Title from "$lib/components/atoms/title.svelte";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte"; import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import CheckIcon from "~icons/ic/round-check"; import CheckIcon from "~icons/ic/round-check";
// Maybe todo? if the `uid` search param is present, do something?? figure out later
</script> </script>
<div class="grid min-h-[80vh] w-full place-items-center px-4 sm:px-6"> <div class="grid min-h-[80vh] w-full place-items-center px-4 sm:px-6">

View File

@@ -3,11 +3,8 @@
import Icon from "$lib/components/atoms/icon.svelte"; import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte"; import Title from "$lib/components/atoms/title.svelte";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.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 { onMount } from "svelte";
import CloseIcon from "~icons/mdi/window-close";
let canRedirect = $state(false); let canRedirect = $state(false);
@@ -37,25 +34,9 @@
<Title size="h3" center weight="medium">Session Terminated</Title> <Title size="h3" center weight="medium">Session Terminated</Title>
<p class="w-full max-w-prose text-center text-gray-600"> <p class="w-full max-w-prose text-center text-gray-600">
Unfortunately, your session has been terminated due to inactivity 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> </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> </div>
</MaxWidthWrapper> </MaxWidthWrapper>
</div> </div>

View File

@@ -102,7 +102,8 @@ export function getDefaultPaginatedOrderInfoModel(): PaginatedOrderInfoModel {
}; };
} }
export const newOrderModel = orderModel.pick({ export const newOrderModel = orderModel
.pick({
basePrice: true, basePrice: true,
displayPrice: true, displayPrice: true,
discountAmount: true, discountAmount: true,
@@ -111,6 +112,11 @@ export const newOrderModel = orderModel.pick({
productId: true, productId: true,
customerInfoId: true, customerInfoId: true,
paymentInfoId: true, paymentInfoId: true,
})
.extend({
currency: z.string().default("USD"),
customerInfoId: z.number().optional(),
paymentInfoId: z.number().optional(),
}); });
export type NewOrderModel = z.infer<typeof newOrderModel>; export type NewOrderModel = z.infer<typeof newOrderModel>;