import { router, protectedProcedure } from "@/src/trpc/trpc-index"; import { z } from "zod"; import { db } from "@/src/db/db_index"; import { orders, orderItems, orderStatus, addresses, productInfo, paymentInfoTable, coupons, couponUsage, cartItems, refunds, units, userDetails, } from "@/src/db/schema"; import { eq, and, inArray, desc, gte, lte } from "drizzle-orm"; import { scaffoldAssetUrl } from "@/src/lib/s3-client"; import { ApiError } from "@/src/lib/api-error"; import { sendOrderPlacedNotification, sendOrderCancelledNotification, } from "@/src/lib/notif-job"; import { getNextDeliveryDate } from "@/src/trpc/apis/common-apis/common"; import { CONST_KEYS, getConstant, getConstants } from "@/src/lib/const-store"; import { publishFormattedOrder, publishCancellation } from "@/src/lib/post-order-handler"; import { getSlotById } from "@/src/stores/slot-store"; const validateAndGetCoupon = async ( couponId: number | undefined, userId: number, totalAmount: number ) => { if (!couponId) return null; const coupon = await db.query.coupons.findFirst({ where: eq(coupons.id, couponId), with: { usages: { where: eq(couponUsage.userId, userId) }, }, }); if (!coupon) throw new ApiError("Invalid coupon", 400); if (coupon.isInvalidated) throw new ApiError("Coupon is no longer valid", 400); if (coupon.validTill && new Date(coupon.validTill) < new Date()) throw new ApiError("Coupon has expired", 400); if ( coupon.maxLimitForUser && coupon.usages.length >= coupon.maxLimitForUser ) throw new ApiError("Coupon usage limit exceeded", 400); if ( coupon.minOrder && parseFloat(coupon.minOrder.toString()) > totalAmount ) throw new ApiError( "Order amount does not meet coupon minimum requirement", 400 ); return coupon; }; const applyDiscountToOrder = ( orderTotal: number, appliedCoupon: typeof coupons.$inferSelect | null, proportion: number ) => { let finalOrderTotal = orderTotal; // const proportion = totalAmount / orderTotal; if (appliedCoupon) { if (appliedCoupon.discountPercent) { const discount = Math.min( (orderTotal * parseFloat(appliedCoupon.discountPercent.toString())) / 100, appliedCoupon.maxValue ? parseFloat(appliedCoupon.maxValue.toString()) * proportion : Infinity ); finalOrderTotal -= discount; } else if (appliedCoupon.flatDiscount) { const discount = Math.min( parseFloat(appliedCoupon.flatDiscount.toString()) * proportion, appliedCoupon.maxValue ? parseFloat(appliedCoupon.maxValue.toString()) * proportion : finalOrderTotal ); finalOrderTotal -= discount; } } // let orderDeliveryCharge = 0; // if (isFirstOrder && finalOrderTotal < minOrderValue) { // orderDeliveryCharge = deliveryCharge; // finalOrderTotal += deliveryCharge; // } return { finalOrderTotal, orderGroupProportion: proportion }; }; const placeOrderUtil = async (params: { userId: number; selectedItems: Array<{ productId: number; quantity: number; slotId: number | null; }>; addressId: number; paymentMethod: "online" | "cod"; couponId?: number; userNotes?: string; isFlash?: boolean; }) => { const { userId, selectedItems, addressId, paymentMethod, couponId, userNotes, } = params; const constants = await getConstants([ CONST_KEYS.minRegularOrderValue, CONST_KEYS.deliveryCharge, CONST_KEYS.flashFreeDeliveryThreshold, CONST_KEYS.flashDeliveryCharge, ]); const isFlashDelivery = params.isFlash; const minOrderValue = (isFlashDelivery ? constants[CONST_KEYS.flashFreeDeliveryThreshold] : constants[CONST_KEYS.minRegularOrderValue]) || 0; const deliveryCharge = (isFlashDelivery ? constants[CONST_KEYS.flashDeliveryCharge] : constants[CONST_KEYS.deliveryCharge]) || 0; const orderGroupId = `${Date.now()}-${userId}`; const address = await db.query.addresses.findFirst({ where: and(eq(addresses.userId, userId), eq(addresses.id, addressId)), }); if (!address) { throw new ApiError("Invalid address", 400); } const ordersBySlot = new Map< number | null, Array<{ productId: number; quantity: number; slotId: number | null; product: any; }> >(); for (const item of selectedItems) { const product = await db.query.productInfo.findFirst({ where: eq(productInfo.id, item.productId), }); if (!product) { throw new ApiError(`Product ${item.productId} not found`, 400); } if (!ordersBySlot.has(item.slotId)) { ordersBySlot.set(item.slotId, []); } ordersBySlot.get(item.slotId)!.push({ ...item, product }); } if (params.isFlash) { for (const item of selectedItems) { const product = await db.query.productInfo.findFirst({ where: eq(productInfo.id, item.productId), }); if (!product?.isFlashAvailable) { throw new ApiError(`Product ${item.productId} is not available for flash delivery`, 400); } } } let totalAmount = 0; for (const [slotId, items] of ordersBySlot) { const orderTotal = items.reduce( (sum, item) => { const itemPrice = params.isFlash ? parseFloat((item.product.flashPrice || item.product.price).toString()) : parseFloat(item.product.price.toString()); return sum + itemPrice * item.quantity; }, 0 ); totalAmount += orderTotal; } const appliedCoupon = await validateAndGetCoupon(couponId, userId, totalAmount); const expectedDeliveryCharge = totalAmount < minOrderValue ? deliveryCharge : 0; const totalWithDelivery = totalAmount + expectedDeliveryCharge; type OrderData = { order: Omit; orderItems: Omit[]; orderStatus: Omit; }; const ordersData: OrderData[] = []; let isFirstOrder = true; for (const [slotId, items] of ordersBySlot) { const subOrderTotal = items.reduce( (sum, item) => { const itemPrice = params.isFlash ? parseFloat((item.product.flashPrice || item.product.price).toString()) : parseFloat(item.product.price.toString()); return sum + itemPrice * item.quantity; }, 0 ); const subOrderTotalWithDelivery = subOrderTotal + expectedDeliveryCharge; const orderGroupProportion = subOrderTotal / totalAmount; const orderTotalAmount = isFirstOrder ? subOrderTotalWithDelivery : subOrderTotal; const { finalOrderTotal: finalOrderAmount } = applyDiscountToOrder( orderTotalAmount, appliedCoupon, orderGroupProportion ); const order: Omit = { userId, addressId, slotId: params.isFlash ? null : slotId, isCod: paymentMethod === "cod", isOnlinePayment: paymentMethod === "online", paymentInfoId: null, totalAmount: finalOrderAmount.toString(), deliveryCharge: isFirstOrder ? expectedDeliveryCharge.toString() : "0", readableId: -1, userNotes: userNotes || null, orderGroupId, orderGroupProportion: orderGroupProportion.toString(), isFlashDelivery: params.isFlash, }; const orderItemsData: Omit[] = items.map( (item) => ({ orderId: 0, productId: item.productId, quantity: item.quantity.toString(), price: params.isFlash ? item.product.flashPrice || item.product.price : item.product.price, discountedPrice: ( params.isFlash ? item.product.flashPrice || item.product.price : item.product.price ).toString(), }) ); const orderStatusData: Omit = { userId, orderId: 0, paymentStatus: paymentMethod === "cod" ? "cod" : "pending", }; ordersData.push({ order, orderItems: orderItemsData, orderStatus: orderStatusData }); isFirstOrder = false; } const createdOrders = await db.transaction(async (tx) => { let sharedPaymentInfoId: number | null = null; if (paymentMethod === "online") { const [paymentInfo] = await tx .insert(paymentInfoTable) .values({ status: "pending", gateway: "razorpay", merchantOrderId: `multi_order_${Date.now()}`, }) .returning(); sharedPaymentInfoId = paymentInfo.id; } const ordersToInsert: Omit[] = ordersData.map( (od) => ({ ...od.order, paymentInfoId: sharedPaymentInfoId, }) ); const insertedOrders = await tx.insert(orders).values(ordersToInsert).returning(); const allOrderItems: Omit[] = []; const allOrderStatuses: Omit[] = []; insertedOrders.forEach((order, index) => { const od = ordersData[index]; od.orderItems.forEach((item) => { allOrderItems.push({ ...item, orderId: order.id as number }); }); allOrderStatuses.push({ ...od.orderStatus, orderId: order.id as number, }); }); await tx.insert(orderItems).values(allOrderItems); await tx.insert(orderStatus).values(allOrderStatuses); if (paymentMethod === "online" && sharedPaymentInfoId) { } return insertedOrders; }); await db.delete(cartItems).where( and( eq(cartItems.userId, userId), inArray( cartItems.productId, selectedItems.map((item) => item.productId) ) ) ); if (appliedCoupon && createdOrders.length > 0) { await db.insert(couponUsage).values({ userId, couponId: appliedCoupon.id, orderId: createdOrders[0].id as number, orderItemId: null, usedAt: new Date(), }); } for (const order of createdOrders) { sendOrderPlacedNotification(userId, order.id.toString()); } await publishFormattedOrder(createdOrders, ordersBySlot); return { success: true, data: createdOrders }; }; export const orderRouter = router({ placeOrder: protectedProcedure .input( z.object({ selectedItems: z.array( z.object({ productId: z.number().int().positive(), quantity: z.number().int().positive(), slotId: z.union([z.number().int(), z.null()]), }) ), addressId: z.number().int().positive(), paymentMethod: z.enum(["online", "cod"]), couponId: z.number().int().positive().optional(), userNotes: z.string().optional(), isFlashDelivery: z.boolean().optional().default(false), }) ) .mutation(async ({ input, ctx }) => { const userId = ctx.user.userId; // Check if user is suspended from placing orders const userDetail = await db.query.userDetails.findFirst({ where: eq(userDetails.userId, userId), }); if (userDetail?.isSuspended) { throw new ApiError("Unable to place order", 403); } const { selectedItems, addressId, paymentMethod, couponId, userNotes, isFlashDelivery, } = input; // Check if flash delivery is enabled when placing a flash delivery order if (isFlashDelivery) { const isFlashDeliveryEnabled = await getConstant(CONST_KEYS.isFlashDeliveryEnabled); if (!isFlashDeliveryEnabled) { throw new ApiError("Flash delivery is currently unavailable. Please opt for scheduled delivery.", 403); } } // Check if any selected slot is at full capacity (only for regular delivery) if (!isFlashDelivery) { const slotIds = [...new Set(selectedItems.filter(i => i.slotId !== null).map(i => i.slotId as number))]; for (const slotId of slotIds) { const slot = await getSlotById(slotId); if (slot?.isCapacityFull) { throw new ApiError("Selected delivery slot is at full capacity. Please choose another slot.", 403); } } } let processedItems = selectedItems; // Handle flash delivery slot resolution if (isFlashDelivery) { // For flash delivery, set slotId to null (no specific slot assigned) processedItems = selectedItems.map(item => ({ ...item, slotId: null as any, // Type override for flash delivery })); } return await placeOrderUtil({ userId, selectedItems: processedItems, addressId, paymentMethod, couponId, userNotes, isFlash: isFlashDelivery, }); }), getOrders: protectedProcedure .input( z .object({ page: z.number().min(1).default(1), pageSize: z.number().min(1).max(50).default(10), }) .optional() ) .query(async ({ input, ctx }) => { const { page = 1, pageSize = 10 } = input || {}; const userId = ctx.user.userId; const offset = (page - 1) * pageSize; // Get total count for pagination const totalCountResult = await db.$count( orders, eq(orders.userId, userId) ); const totalCount = totalCountResult; const userOrders = await db.query.orders.findMany({ where: eq(orders.userId, userId), with: { orderItems: { with: { product: true, }, }, slot: true, paymentInfo: true, orderStatus: true, refunds: true, }, orderBy: (orders, { desc }) => [desc(orders.createdAt)], limit: pageSize, offset: offset, }); const mappedOrders = await Promise.all( userOrders.map(async (order) => { const status = order.orderStatus[0]; const refund = order.refunds[0]; type DeliveryStatus = "cancelled" | "success" | "pending" | "packaged"; type OrderStatus = "cancelled" | "success"; let deliveryStatus: DeliveryStatus; let orderStatus: OrderStatus; const allItemsPackaged = order.orderItems.every( (item) => item.is_packaged ); if (status?.isCancelled) { deliveryStatus = "cancelled"; orderStatus = "cancelled"; } else if (status?.isDelivered) { deliveryStatus = "success"; orderStatus = "success"; } else if (allItemsPackaged) { deliveryStatus = "packaged"; orderStatus = "success"; } else { deliveryStatus = "pending"; orderStatus = "success"; } const paymentMode = order.isCod ? "CoD" : "Online"; const paymentStatus = status?.paymentStatus || "pending"; const refundStatus = refund?.refundStatus || "none"; const refundAmount = refund?.refundAmount ? parseFloat(refund.refundAmount.toString()) : null; const items = await Promise.all( order.orderItems.map(async (item) => { const signedImages = item.product.images ? scaffoldAssetUrl( item.product.images as string[] ) : []; return { productName: item.product.name, quantity: parseFloat(item.quantity), price: parseFloat(item.price.toString()), discountedPrice: parseFloat( item.discountedPrice?.toString() || item.price.toString() ), amount: parseFloat(item.price.toString()) * parseFloat(item.quantity), image: signedImages[0] || null, }; }) ); return { id: order.id, orderId: `ORD${order.id}`, orderDate: order.createdAt.toISOString(), deliveryStatus, deliveryDate: order.slot?.deliveryTime.toISOString(), orderStatus, cancelReason: status?.cancelReason || null, paymentMode, totalAmount: Number(order.totalAmount), deliveryCharge: Number(order.deliveryCharge), paymentStatus, refundStatus, refundAmount, userNotes: order.userNotes || null, items, isFlashDelivery: order.isFlashDelivery, createdAt: order.createdAt.toISOString(), }; }) ); return { success: true, data: mappedOrders, pagination: { page, pageSize, totalCount, totalPages: Math.ceil(totalCount / pageSize), }, }; }), getOrderById: protectedProcedure .input(z.object({ orderId: z.string() })) .query(async ({ input, ctx }) => { const { orderId } = input; const userId = ctx.user.userId; const order = await db.query.orders.findFirst({ where: and(eq(orders.id, parseInt(orderId)), eq(orders.userId, userId)), with: { orderItems: { with: { product: true, }, }, slot: true, paymentInfo: true, orderStatus: { with: { refundCoupon: true, }, }, refunds: true, }, }); if (!order) { throw new Error("Order not found"); } // Get coupon usage for this specific order using new orderId field const couponUsageData = await db.query.couponUsage.findMany({ where: eq(couponUsage.orderId, order.id), // Use new orderId field with: { coupon: true, }, }); let couponData = null; if (couponUsageData.length > 0) { // Calculate total discount from multiple coupons let totalDiscountAmount = 0; const orderTotal = parseFloat(order.totalAmount.toString()); for (const usage of couponUsageData) { let discountAmount = 0; if (usage.coupon.discountPercent) { discountAmount = (orderTotal * parseFloat(usage.coupon.discountPercent.toString())) / 100; } else if (usage.coupon.flatDiscount) { discountAmount = parseFloat(usage.coupon.flatDiscount.toString()); } // Apply max value limit if set if ( usage.coupon.maxValue && discountAmount > parseFloat(usage.coupon.maxValue.toString()) ) { discountAmount = parseFloat(usage.coupon.maxValue.toString()); } totalDiscountAmount += discountAmount; } couponData = { couponCode: couponUsageData .map((u) => u.coupon.couponCode) .join(", "), couponDescription: `${couponUsageData.length} coupons applied`, discountAmount: totalDiscountAmount, }; } const status = order.orderStatus[0]; const refund = order.refunds[0]; type DeliveryStatus = "cancelled" | "success" | "pending" | "packaged"; type OrderStatus = "cancelled" | "success"; let deliveryStatus: DeliveryStatus; let orderStatus: OrderStatus; const allItemsPackaged = order.orderItems.every( (item) => item.is_packaged ); if (status?.isCancelled) { deliveryStatus = "cancelled"; orderStatus = "cancelled"; } else if (status?.isDelivered) { deliveryStatus = "success"; orderStatus = "success"; } else if (allItemsPackaged) { deliveryStatus = "packaged"; orderStatus = "success"; } else { deliveryStatus = "pending"; orderStatus = "success"; } const paymentMode = order.isCod ? "CoD" : "Online"; const paymentStatus = status?.paymentStatus || "pending"; const refundStatus = refund?.refundStatus || "none"; const refundAmount = refund?.refundAmount ? parseFloat(refund.refundAmount.toString()) : null; const items = await Promise.all( order.orderItems.map(async (item) => { const signedImages = item.product.images ? scaffoldAssetUrl( item.product.images as string[] ) : []; return { productName: item.product.name, quantity: parseFloat(item.quantity), price: parseFloat(item.price.toString()), discountedPrice: parseFloat( item.discountedPrice?.toString() || item.price.toString() ), amount: parseFloat(item.price.toString()) * parseFloat(item.quantity), image: signedImages[0] || null, }; }) ); return { id: order.id, orderId: `ORD${order.id}`, orderDate: order.createdAt.toISOString(), deliveryStatus, deliveryDate: order.slot?.deliveryTime.toISOString(), orderStatus: order.orderStatus, cancellationStatus: orderStatus, cancelReason: status?.cancelReason || null, paymentMode, paymentStatus, refundStatus, refundAmount, userNotes: order.userNotes || null, items, couponCode: couponData?.couponCode || null, couponDescription: couponData?.couponDescription || null, discountAmount: couponData?.discountAmount || null, orderAmount: parseFloat(order.totalAmount.toString()), isFlashDelivery: order.isFlashDelivery, createdAt: order.createdAt.toISOString(), }; }), cancelOrder: protectedProcedure .input( z.object({ // id: z.string().regex(/^ORD\d+$/, "Invalid order ID format"), id: z.number(), reason: z.string().min(1, "Cancellation reason is required"), }) ) .mutation(async ({ input, ctx }) => { try { const userId = ctx.user.userId; const { id, reason } = input; // Check if order exists and belongs to user const order = await db.query.orders.findFirst({ where: eq(orders.id, Number(id)), with: { orderStatus: true, }, }); if (!order) { console.error("Order not found:", id); throw new ApiError("Order not found", 404); } if (order.userId !== userId) { console.error("Order does not belong to user:", { orderId: id, orderUserId: order.userId, requestUserId: userId, }); throw new ApiError("Order not found", 404); } const status = order.orderStatus[0]; if (!status) { console.error("Order status not found for order:", id); throw new ApiError("Order status not found", 400); } if (status.isCancelled) { console.error("Order is already cancelled:", id); throw new ApiError("Order is already cancelled", 400); } if (status.isDelivered) { console.error("Cannot cancel delivered order:", id); throw new ApiError("Cannot cancel delivered order", 400); } // Perform database operations in transaction const result = await db.transaction(async (tx) => { // Update order status await tx .update(orderStatus) .set({ isCancelled: true, cancelReason: reason, cancellationUserNotes: reason, cancellationReviewed: false, }) .where(eq(orderStatus.id, status.id)); // Determine refund status based on payment method const refundStatus = order.isCod ? "na" : "pending"; // Insert refund record await tx.insert(refunds).values({ orderId: order.id, refundStatus, }); return { orderId: order.id, userId }; }); // Send notification outside transaction (idempotent operation) await sendOrderCancelledNotification( result.userId, result.orderId.toString() ); // Publish to Redis for Telegram notification await publishCancellation(result.orderId, 'user', reason); return { success: true, message: "Order cancelled successfully" }; } catch (e) { console.log(e); throw new ApiError("failed to cancel order"); } }), updateUserNotes: protectedProcedure .input( z.object({ id: z.number(), userNotes: z.string(), }) ) .mutation(async ({ input, ctx }) => { const userId = ctx.user.userId; const { id, userNotes } = input; // Extract readable ID from orderId (e.g., ORD001 -> 1) // const readableIdMatch = id.match(/^ORD(\d+)$/); // if (!readableIdMatch) { // console.error("Invalid order ID format:", id); // throw new ApiError("Invalid order ID format", 400); // } // const readableId = parseInt(readableIdMatch[1]); // Check if order exists and belongs to user const order = await db.query.orders.findFirst({ where: eq(orders.id, Number(id)), with: { orderStatus: true, }, }); if (!order) { console.error("Order not found:", id); throw new ApiError("Order not found", 404); } if (order.userId !== userId) { console.error("Order does not belong to user:", { orderId: id, orderUserId: order.userId, requestUserId: userId, }); throw new ApiError("Order not found", 404); } const status = order.orderStatus[0]; if (!status) { console.error("Order status not found for order:", id); throw new ApiError("Order status not found", 400); } // Only allow updating notes for orders that are not delivered or cancelled if (status.isDelivered) { console.error("Cannot update notes for delivered order:", id); throw new ApiError("Cannot update notes for delivered order", 400); } if (status.isCancelled) { console.error("Cannot update notes for cancelled order:", id); throw new ApiError("Cannot update notes for cancelled order", 400); } // Update user notes await db .update(orders) .set({ userNotes: userNotes || null, }) .where(eq(orders.id, order.id)); return { success: true, message: "Notes updated successfully" }; }), getRecentlyOrderedProducts: protectedProcedure .input( z .object({ limit: z.number().min(1).max(50).default(20), }) .optional() ) .query(async ({ input, ctx }) => { const { limit = 20 } = input || {}; const userId = ctx.user.userId; // Get user's recent delivered orders (last 30 days) const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); const recentOrders = await db .select({ id: orders.id }) .from(orders) .innerJoin(orderStatus, eq(orders.id, orderStatus.orderId)) .where( and( eq(orders.userId, userId), eq(orderStatus.isDelivered, true), gte(orders.createdAt, thirtyDaysAgo) ) ) .orderBy(desc(orders.createdAt)) .limit(10); // Get last 10 orders if (recentOrders.length === 0) { return { success: true, products: [] }; } const orderIds = recentOrders.map((order) => order.id); // Get unique product IDs from recent orders const orderItemsResult = await db .select({ productId: orderItems.productId }) .from(orderItems) .where(inArray(orderItems.orderId, orderIds)); const productIds = [ ...new Set(orderItemsResult.map((item) => item.productId)), ]; if (productIds.length === 0) { return { success: true, products: [] }; } // Get product details const productsWithUnits = await db .select({ id: productInfo.id, name: productInfo.name, shortDescription: productInfo.shortDescription, price: productInfo.price, images: productInfo.images, isOutOfStock: productInfo.isOutOfStock, unitShortNotation: units.shortNotation, incrementStep: productInfo.incrementStep, }) .from(productInfo) .innerJoin(units, eq(productInfo.unitId, units.id)) .where( and( inArray(productInfo.id, productIds), eq(productInfo.isSuspended, false) ) ) .orderBy(desc(productInfo.createdAt)) .limit(limit); // Generate signed URLs for product images const formattedProducts = await Promise.all( productsWithUnits.map(async (product) => { const nextDeliveryDate = await getNextDeliveryDate(product.id); return { id: product.id, name: product.name, shortDescription: product.shortDescription, price: product.price, unit: product.unitShortNotation, incrementStep: product.incrementStep, isOutOfStock: product.isOutOfStock, nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null, images: scaffoldAssetUrl( (product.images as string[]) || [] ), }; }) ); return { success: true, products: formattedProducts, }; }), });