diff --git a/apps/backend/src/trpc/apis/user-apis/apis/address.ts b/apps/backend/src/trpc/apis/user-apis/apis/address.ts index e69de29..0022c7e 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/address.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/address.ts @@ -0,0 +1,194 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { addresses, orders, orderStatus, deliverySlotInfo } from '@/src/db/schema'; +import { eq, and, gte } from 'drizzle-orm'; +import dayjs from 'dayjs'; +import { extractCoordsFromRedirectUrl } from '@/src/lib/license-util'; + +export const addressRouter = router({ + getDefaultAddress: protectedProcedure + .query(async ({ ctx }) => { + const userId = ctx.user.userId; + + const [defaultAddress] = await db + .select() + .from(addresses) + .where(and(eq(addresses.userId, userId), eq(addresses.isDefault, true))) + .limit(1); + + return { success: true, data: defaultAddress || null }; + }), + + getUserAddresses: protectedProcedure + .query(async ({ ctx }) => { + const userId = ctx.user.userId; + const userAddresses = await db.select().from(addresses).where(eq(addresses.userId, userId)); + return { success: true, data: userAddresses }; + }), + + createAddress: protectedProcedure + .input(z.object({ + name: z.string().min(1, 'Name is required'), + phone: z.string().min(1, 'Phone is required'), + addressLine1: z.string().min(1, 'Address line 1 is required'), + addressLine2: z.string().optional(), + city: z.string().min(1, 'City is required'), + state: z.string().min(1, 'State is required'), + pincode: z.string().min(1, 'Pincode is required'), + isDefault: z.boolean().optional(), + latitude: z.number().optional(), + longitude: z.number().optional(), + googleMapsUrl: z.string().optional(), + })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + const { name, phone, addressLine1, addressLine2, city, state, pincode, isDefault, googleMapsUrl } = input; + + let { latitude, longitude } = input; + + if (googleMapsUrl && latitude === undefined && longitude === undefined) { + const coords = await extractCoordsFromRedirectUrl(googleMapsUrl); + if (coords) { + latitude = Number(coords.latitude); + longitude = Number(coords.longitude); + } + } + + // Validate required fields + if (!name || !phone || !addressLine1 || !city || !state || !pincode) { + throw new Error('Missing required fields'); + } + + // If setting as default, unset other defaults + if (isDefault) { + await db.update(addresses).set({ isDefault: false }).where(eq(addresses.userId, userId)); + } + + const [newAddress] = await db.insert(addresses).values({ + userId, + name, + phone, + addressLine1, + addressLine2, + city, + state, + pincode, + isDefault: isDefault || false, + latitude, + longitude, + googleMapsUrl, + }).returning(); + + return { success: true, data: newAddress }; + }), + + updateAddress: protectedProcedure + .input(z.object({ + id: z.number().int().positive(), + name: z.string().min(1, 'Name is required'), + phone: z.string().min(1, 'Phone is required'), + addressLine1: z.string().min(1, 'Address line 1 is required'), + addressLine2: z.string().optional(), + city: z.string().min(1, 'City is required'), + state: z.string().min(1, 'State is required'), + pincode: z.string().min(1, 'Pincode is required'), + isDefault: z.boolean().optional(), + latitude: z.number().optional(), + longitude: z.number().optional(), + googleMapsUrl: z.string().optional(), + })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + const { id, name, phone, addressLine1, addressLine2, city, state, pincode, isDefault, googleMapsUrl } = input; + + let { latitude, longitude } = input; + + if (googleMapsUrl && latitude === undefined && longitude === undefined) { + const coords = await extractCoordsFromRedirectUrl(googleMapsUrl); + if (coords) { + latitude = Number(coords.latitude); + longitude = Number(coords.longitude); + } + } + + // Check if address exists and belongs to user + const existingAddress = await db.select().from(addresses).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).limit(1); + if (existingAddress.length === 0) { + throw new Error('Address not found'); + } + + // If setting as default, unset other defaults + if (isDefault) { + await db.update(addresses).set({ isDefault: false }).where(eq(addresses.userId, userId)); + } + + const updateData: any = { + name, + phone, + addressLine1, + addressLine2, + city, + state, + pincode, + isDefault: isDefault || false, + googleMapsUrl, + }; + + if (latitude !== undefined) { + updateData.latitude = latitude; + } + if (longitude !== undefined) { + updateData.longitude = longitude; + } + + const [updatedAddress] = await db.update(addresses).set(updateData).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).returning(); + + return { success: true, data: updatedAddress }; + }), + + deleteAddress: protectedProcedure + .input(z.object({ + id: z.number().int().positive(), + })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + const { id } = input; + + // Check if address exists and belongs to user + const existingAddress = await db.select().from(addresses).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).limit(1); + if (existingAddress.length === 0) { + throw new Error('Address not found or does not belong to user'); + } + + // Check if address is attached to any ongoing orders using joins + const ongoingOrders = await db.select({ + order: orders, + status: orderStatus, + slot: deliverySlotInfo + }) + .from(orders) + .innerJoin(orderStatus, eq(orders.id, orderStatus.orderId)) + .innerJoin(deliverySlotInfo, eq(orders.slotId, deliverySlotInfo.id)) + .where(and( + eq(orders.addressId, id), + eq(orderStatus.isCancelled, false), + gte(deliverySlotInfo.deliveryTime, new Date()) + )) + .limit(1); + + if (ongoingOrders.length > 0) { + throw new Error('Address is attached to an ongoing order. Please cancel the order first.'); + } + + // Prevent deletion of default address + if (existingAddress[0].isDefault) { + throw new Error('Cannot delete default address. Please set another address as default first.'); + } + + // Delete the address + await db.delete(addresses).where(and(eq(addresses.id, id), eq(addresses.userId, userId))); + + return { success: true, message: 'Address deleted successfully' }; + }), +}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/auth.ts b/apps/backend/src/trpc/apis/user-apis/apis/auth.ts index e69de29..56e9a3c 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/auth.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/auth.ts @@ -0,0 +1,447 @@ +import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { eq } from 'drizzle-orm'; +import { db } from '@/src/db/db_index'; +import { + users, userCreds, userDetails, addresses, cartItems, complaints, + couponApplicableUsers, couponUsage, notifCreds, notifications, + orderItems, orderStatus, orders, payments, refunds, + productReviews, reservedCoupons +} from '@/src/db/schema'; +import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client'; +import { ApiError } from '@/src/lib/api-error'; +import catchAsync from '@/src/lib/catch-async'; +import { jwtSecret } from '@/src/lib/env-exporter'; +import { sendOtp, verifyOtpUtil, getOtpCreds } from '@/src/lib/otp-utils'; + +interface LoginRequest { + identifier: string; // email or mobile + password: string; +} + +interface RegisterRequest { + name: string; + email: string; + mobile: string; + password: string; +} + +interface AuthResponse { + token: string; + user: { + id: number; + name?: string | null; + email: string | null; + mobile: string | null; + createdAt: string; + profileImage: string | null; + bio?: string | null; + dateOfBirth?: string | null; + gender?: string | null; + occupation?: string | null; + }; +} + +const generateToken = (userId: number): string => { + const secret = jwtSecret; + if (!secret) { + throw new ApiError('JWT secret not configured', 500); + } + + return jwt.sign({ userId }, secret, { expiresIn: '7d' }); +}; + + + +export const authRouter = router({ + login: publicProcedure + .input(z.object({ + identifier: z.string().min(1, 'Email/mobile is required'), + password: z.string().min(1, 'Password is required'), + })) + .mutation(async ({ input }) => { + const { identifier, password }: LoginRequest = input; + + if (!identifier || !password) { + throw new ApiError('Email/mobile and password are required', 400); + } + + // Find user by email or mobile + const [user] = await db + .select() + .from(users) + .where(eq(users.email, identifier.toLowerCase())) + .limit(1); + + let foundUser = user; + + if (!foundUser) { + // Try mobile if email didn't work + const [userByMobile] = await db + .select() + .from(users) + .where(eq(users.mobile, identifier)) + .limit(1); + foundUser = userByMobile; + } + + if (!foundUser) { + throw new ApiError('Invalid credentials', 401); + } + + // Get user credentials + const [userCredentials] = await db + .select() + .from(userCreds) + .where(eq(userCreds.userId, foundUser.id)) + .limit(1); + + if (!userCredentials) { + throw new ApiError('Account setup incomplete. Please contact support.', 401); + } + + // Get user details for profile image + const [userDetail] = await db + .select() + .from(userDetails) + .where(eq(userDetails.userId, foundUser.id)) + .limit(1); + + // Generate signed URL for profile image if it exists + const profileImageSignedUrl = userDetail?.profileImage + ? await generateSignedUrlFromS3Url(userDetail.profileImage) + : null; + + // Verify password + const isPasswordValid = await bcrypt.compare(password, userCredentials.userPassword); + if (!isPasswordValid) { + throw new ApiError('Invalid credentials', 401); + } + + const token = generateToken(foundUser.id); + + const response: AuthResponse = { + token, + user: { + id: foundUser.id, + name: foundUser.name, + email: foundUser.email, + mobile: foundUser.mobile, + createdAt: foundUser.createdAt.toISOString(), + profileImage: profileImageSignedUrl, + bio: userDetail?.bio || null, + dateOfBirth: userDetail?.dateOfBirth || null, + gender: userDetail?.gender || null, + occupation: userDetail?.occupation || null, + }, + }; + + return { + success: true, + data: response, + }; + }), + + register: publicProcedure + .input(z.object({ + name: z.string().min(1, 'Name is required'), + email: z.string().email('Invalid email format'), + mobile: z.string().min(1, 'Mobile is required'), + password: z.string().min(1, 'Password is required'), + })) + .mutation(async ({ input }) => { + const { name, email, mobile, password }: RegisterRequest = input; + + if (!name || !email || !mobile || !password) { + throw new ApiError('All fields are required', 400); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + throw new ApiError('Invalid email format', 400); + } + + // Validate mobile format (Indian mobile numbers) + const cleanMobile = mobile.replace(/\D/g, ''); + if (cleanMobile.length !== 10 || !/^[6-9]/.test(cleanMobile)) { + throw new ApiError('Invalid mobile number', 400); + } + + // Check if email already exists + const [existingEmail] = await db + .select() + .from(users) + .where(eq(users.email, email.toLowerCase())) + .limit(1); + + if (existingEmail) { + throw new ApiError('Email already registered', 409); + } + + // Check if mobile already exists + const [existingMobile] = await db + .select() + .from(users) + .where(eq(users.mobile, cleanMobile)) + .limit(1); + + if (existingMobile) { + throw new ApiError('Mobile number already registered', 409); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 12); + + // Create user and credentials in a transaction + const newUser = await db.transaction(async (tx) => { + // Create user + const [user] = await tx + .insert(users) + .values({ + name: name.trim(), + email: email.toLowerCase().trim(), + mobile: cleanMobile, + }) + .returning(); + + // Create user credentials + await tx + .insert(userCreds) + .values({ + userId: user.id, + userPassword: hashedPassword, + }); + + return user; + }); + + const token = generateToken(newUser.id); + + const response: AuthResponse = { + token, + user: { + id: newUser.id, + name: newUser.name, + email: newUser.email, + mobile: newUser.mobile, + createdAt: newUser.createdAt.toISOString(), + profileImage: null, + }, + }; + + return { + success: true, + data: response, + }; + }), + + sendOtp: publicProcedure + .input(z.object({ + mobile: z.string(), + })) + .mutation(async ({ input }) => { + + return await sendOtp(input.mobile); + }), + + verifyOtp: publicProcedure + .input(z.object({ + mobile: z.string(), + otp: z.string(), + })) + .mutation(async ({ input }) => { + const verificationId = getOtpCreds(input.mobile); + if (!verificationId) { + throw new ApiError("OTP not sent or expired", 400); + } + const isVerified = await verifyOtpUtil(input.mobile, input.otp, verificationId); + + if (!isVerified) { + throw new ApiError("Invalid OTP", 400); + } + + // Find user + let user = await db.query.users.findFirst({ + where: eq(users.mobile, input.mobile), + }); + + // If user doesn't exist, create one + if (!user) { + const [newUser] = await db + .insert(users) + .values({ + name: null, + email: null, + mobile: input.mobile, + }) + .returning(); + user = newUser; + } + + // Generate JWT + const token = generateToken(user.id); + + return { + success: true, + token, + user: { + id: user.id, + name: user.name, + email: user.email, + mobile: user.mobile, + createdAt: user.createdAt.toISOString(), + profileImage: null, + }, + }; + }), + + updatePassword: protectedProcedure + .input(z.object({ + password: z.string().min(6, 'Password must be at least 6 characters'), + })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + if (!userId) { + throw new ApiError('User not authenticated', 401); + } + + const hashedPassword = await bcrypt.hash(input.password, 10); + + // Insert if not exists, then update if exists + try { + await db.insert(userCreds).values({ + userId: userId, + userPassword: hashedPassword, + }); + // Insert succeeded - new credentials created + } catch (error: any) { + // Insert failed - check if it's a unique constraint violation + if (error.code === '23505') { // PostgreSQL unique constraint violation + // Update existing credentials + await db.update(userCreds).set({ + userPassword: hashedPassword, + }).where(eq(userCreds.userId, userId)); + } else { + // Re-throw if it's a different error + throw error; + } + } + + return { success: true, message: 'Password updated successfully' }; + }), + + getProfile: protectedProcedure + .query(async ({ ctx }) => { + const userId = ctx.user.userId; + + if (!userId) { + throw new ApiError('User not authenticated', 401); + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user) { + throw new ApiError('User not found', 404); + } + + return { + success: true, + data: { + id: user.id, + name: user.name, + email: user.email, + mobile: user.mobile, + }, + }; + }), + + deleteAccount: protectedProcedure + .input(z.object({ + mobile: z.string().min(10, 'Mobile number is required'), + })) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user.userId; + const { mobile } = input; + + if (!userId) { + throw new ApiError('User not authenticated', 401); + } + + // Double-check: verify user exists and is the authenticated user + const existingUser = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { id: true, mobile: true }, + }); + + if (!existingUser) { + throw new ApiError('User not found', 404); + } + + // Additional verification: ensure we're not deleting someone else's data + // The JWT token should already ensure this, but double-checking + if (existingUser.id !== userId) { + throw new ApiError('Unauthorized: Cannot delete another user\'s account', 403); + } + + // Verify mobile number matches user's registered mobile + const cleanInputMobile = mobile.replace(/\D/g, ''); + const cleanUserMobile = existingUser.mobile?.replace(/\D/g, ''); + + if (cleanInputMobile !== cleanUserMobile) { + throw new ApiError('Mobile number does not match your registered number', 400); + } + + // Use transaction for atomic deletion + await db.transaction(async (tx) => { + // Phase 1: Direct references (safe to delete first) + await tx.delete(notifCreds).where(eq(notifCreds.userId, userId)); + await tx.delete(couponApplicableUsers).where(eq(couponApplicableUsers.userId, userId)); + await tx.delete(couponUsage).where(eq(couponUsage.userId, userId)); + await tx.delete(complaints).where(eq(complaints.userId, userId)); + await tx.delete(cartItems).where(eq(cartItems.userId, userId)); + await tx.delete(notifications).where(eq(notifications.userId, userId)); + await tx.delete(productReviews).where(eq(productReviews.userId, userId)); + + // Update reserved coupons (set redeemedBy to null) + await tx.update(reservedCoupons) + .set({ redeemedBy: null }) + .where(eq(reservedCoupons.redeemedBy, userId)); + + // Phase 2: Order dependencies + const userOrders = await tx + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.userId, userId)); + + for (const order of userOrders) { + await tx.delete(orderItems).where(eq(orderItems.orderId, order.id)); + await tx.delete(orderStatus).where(eq(orderStatus.orderId, order.id)); + await tx.delete(payments).where(eq(payments.orderId, order.id)); + await tx.delete(refunds).where(eq(refunds.orderId, order.id)); + // Additional coupon usage entries linked to specific orders + await tx.delete(couponUsage).where(eq(couponUsage.orderId, order.id)); + await tx.delete(complaints).where(eq(complaints.orderId, order.id)); + } + + // Delete orders + await tx.delete(orders).where(eq(orders.userId, userId)); + + // Phase 3: Addresses (now safe since orders are deleted) + await tx.delete(addresses).where(eq(addresses.userId, userId)); + + // Phase 4: Core user data + await tx.delete(userDetails).where(eq(userDetails.userId, userId)); + await tx.delete(userCreds).where(eq(userCreds.userId, userId)); + await tx.delete(users).where(eq(users.id, userId)); + }); + + return { success: true, message: 'Account deleted successfully' }; + }), +}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/banners.ts b/apps/backend/src/trpc/apis/user-apis/apis/banners.ts index e69de29..6f4a53b 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/banners.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/banners.ts @@ -0,0 +1,38 @@ +import { db } from '@/src/db/db_index'; +import { homeBanners } from '@/src/db/schema'; +import { publicProcedure, router } from '@/src/trpc/trpc-index'; +import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client'; +import { isNotNull, asc } from 'drizzle-orm'; + +export const bannerRouter = router({ + getBanners: publicProcedure + .query(async () => { + const banners = await db.query.homeBanners.findMany({ + where: isNotNull(homeBanners.serialNum), // Only show assigned banners + orderBy: asc(homeBanners.serialNum), // Order by slot number 1-4 + }); + + // Convert S3 keys to signed URLs for client + const bannersWithSignedUrls = await Promise.all( + banners.map(async (banner) => { + try { + return { + ...banner, + imageUrl: banner.imageUrl ? await generateSignedUrlFromS3Url(banner.imageUrl) : banner.imageUrl, + }; + } catch (error) { + console.error(`Failed to generate signed URL for banner ${banner.id}:`, error); + return { + ...banner, + imageUrl: banner.imageUrl, // Keep original on error + }; + } + }) + ); + + + return { + banners: bannersWithSignedUrls, + }; + }), +}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/cart.ts b/apps/backend/src/trpc/apis/user-apis/apis/cart.ts index e69de29..a2495bc 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/cart.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/cart.ts @@ -0,0 +1,244 @@ +import { router, protectedProcedure, publicProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { cartItems, productInfo, units, productSlots, deliverySlotInfo } from '@/src/db/schema'; +import { eq, and, sql, inArray, gt } from 'drizzle-orm'; +import { ApiError } from '@/src/lib/api-error'; +import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl } from '@/src/lib/s3-client'; +import { getProductSlots, getMultipleProductsSlots } from '@/src/stores/slot-store'; + +interface CartResponse { + items: any[]; + totalItems: number; + totalAmount: number; +} + +const getCartData = async (userId: number): Promise => { + const cartItemsWithProducts = await db + .select({ + cartId: cartItems.id, + productId: productInfo.id, + productName: productInfo.name, + productPrice: productInfo.price, + productImages: productInfo.images, + productQuantity: productInfo.productQuantity, + isOutOfStock: productInfo.isOutOfStock, + unitShortNotation: units.shortNotation, + quantity: cartItems.quantity, + addedAt: cartItems.addedAt, + }) + .from(cartItems) + .innerJoin(productInfo, eq(cartItems.productId, productInfo.id)) + .innerJoin(units, eq(productInfo.unitId, units.id)) + .where(eq(cartItems.userId, userId)); + + // Generate signed URLs for images + const cartWithSignedUrls = await Promise.all( + cartItemsWithProducts.map(async (item) => ({ + id: item.cartId, + productId: item.productId, + quantity: parseFloat(item.quantity), + addedAt: item.addedAt, + product: { + id: item.productId, + name: item.productName, + price: item.productPrice, + productQuantity: item.productQuantity, + unit: item.unitShortNotation, + isOutOfStock: item.isOutOfStock, + images: scaffoldAssetUrl((item.productImages as string[]) || []), + }, + subtotal: parseFloat(item.productPrice.toString()) * parseFloat(item.quantity), + })) + ); + + const totalAmount = cartWithSignedUrls.reduce((sum, item) => sum + item.subtotal, 0); + + return { + items: cartWithSignedUrls, + totalItems: cartWithSignedUrls.length, + totalAmount, + }; +}; + +export const cartRouter = router({ + getCart: protectedProcedure + .query(async ({ ctx }): Promise => { + const userId = ctx.user.userId; + return await getCartData(userId); + }), + + addToCart: protectedProcedure + .input(z.object({ + productId: z.number().int().positive(), + quantity: z.number().int().positive(), + })) + .mutation(async ({ input, ctx }): Promise => { + const userId = ctx.user.userId; + const { productId, quantity } = input; + + // Validate input + if (!productId || !quantity || quantity <= 0) { + throw new ApiError("Product ID and positive quantity required", 400); + } + + // Check if product exists + const product = await db.query.productInfo.findFirst({ + where: eq(productInfo.id, productId), + }); + + if (!product) { + throw new ApiError("Product not found", 404); + } + + // Check if item already exists in cart + const existingItem = await db.query.cartItems.findFirst({ + where: and(eq(cartItems.userId, userId), eq(cartItems.productId, productId)), + }); + + if (existingItem) { + // Update quantity + await db.update(cartItems) + .set({ + quantity: sql`${cartItems.quantity} + ${quantity}`, + }) + .where(eq(cartItems.id, existingItem.id)); + } else { + // Insert new item + await db.insert(cartItems).values({ + userId, + productId, + quantity: quantity.toString(), + }); + } + + // Return updated cart + return await getCartData(userId); + }), + + updateCartItem: protectedProcedure + .input(z.object({ + itemId: z.number().int().positive(), + quantity: z.number().int().min(0), + })) + .mutation(async ({ input, ctx }): Promise => { + const userId = ctx.user.userId; + const { itemId, quantity } = input; + + if (!quantity || quantity <= 0) { + throw new ApiError("Positive quantity required", 400); + } + + const [updatedItem] = await db.update(cartItems) + .set({ quantity: quantity.toString() }) + .where(and( + eq(cartItems.id, itemId), + eq(cartItems.userId, userId) + )) + .returning(); + + if (!updatedItem) { + throw new ApiError("Cart item not found", 404); + } + + // Return updated cart + return await getCartData(userId); + }), + + removeFromCart: protectedProcedure + .input(z.object({ + itemId: z.number().int().positive(), + })) + .mutation(async ({ input, ctx }): Promise => { + const userId = ctx.user.userId; + const { itemId } = input; + + const [deletedItem] = await db.delete(cartItems) + .where(and( + eq(cartItems.id, itemId), + eq(cartItems.userId, userId) + )) + .returning(); + + if (!deletedItem) { + throw new ApiError("Cart item not found", 404); + } + + // Return updated cart + return await getCartData(userId); + }), + + clearCart: protectedProcedure + .mutation(async ({ ctx }) => { + const userId = ctx.user.userId; + + await db.delete(cartItems).where(eq(cartItems.userId, userId)); + + return { + items: [], + totalItems: 0, + totalAmount: 0, + message: "Cart cleared successfully", + }; + }), + + // Original DB-based getCartSlots (commented out) + // getCartSlots: publicProcedure + // .input(z.object({ + // productIds: z.array(z.number().int().positive()) + // })) + // .query(async ({ input }) => { + // const { productIds } = input; + // + // if (productIds.length === 0) { + // return {}; + // } + // + // // Get slots for these products where freeze time is after current time + // const slotsData = await db + // .select({ + // productId: productSlots.productId, + // slotId: deliverySlotInfo.id, + // deliveryTime: deliverySlotInfo.deliveryTime, + // freezeTime: deliverySlotInfo.freezeTime, + // isActive: deliverySlotInfo.isActive, + // }) + // .from(productSlots) + // .innerJoin(deliverySlotInfo, eq(productSlots.slotId, deliverySlotInfo.id)) + // .where(and( + // inArray(productSlots.productId, productIds), + // gt(deliverySlotInfo.freezeTime, sql`NOW()`), + // eq(deliverySlotInfo.isActive, true) + // )); + // + // // Group by productId + // const result: Record = {}; + // slotsData.forEach(slot => { + // if (!result[slot.productId]) { + // result[slot.productId] = []; + // } + // result[slot.productId].push({ + // id: slot.slotId, + // deliveryTime: slot.deliveryTime, + // freezeTime: slot.freezeTime, + // }); + // }); + // + // return result; + // }), + + // Cache-based getCartSlots + getCartSlots: publicProcedure + .input(z.object({ + productIds: z.array(z.number().int().positive()) + })) + .query(async ({ input }) => { + const { productIds } = input; + + if (productIds.length === 0) { + return {}; + } + + return await getMultipleProductsSlots(productIds); + }), +}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/complaint.ts b/apps/backend/src/trpc/apis/user-apis/apis/complaint.ts index e69de29..08dae54 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/complaint.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/complaint.ts @@ -0,0 +1,63 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { complaints } from '@/src/db/schema'; +import { eq } from 'drizzle-orm'; + +export const complaintRouter = router({ + getAll: protectedProcedure + .query(async ({ ctx }) => { + const userId = ctx.user.userId; + + const userComplaints = await db + .select({ + id: complaints.id, + complaintBody: complaints.complaintBody, + response: complaints.response, + isResolved: complaints.isResolved, + createdAt: complaints.createdAt, + orderId: complaints.orderId, + }) + .from(complaints) + .where(eq(complaints.userId, userId)) + .orderBy(complaints.createdAt); + + return { + complaints: userComplaints.map(c => ({ + id: c.id, + complaintBody: c.complaintBody, + response: c.response, + isResolved: c.isResolved, + createdAt: c.createdAt, + orderId: c.orderId, + })), + }; + }), + + raise: protectedProcedure + .input(z.object({ + orderId: z.string().optional(), + complaintBody: z.string().min(1, 'Complaint body is required'), + })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + const { orderId, complaintBody } = input; + + let orderIdNum: number | null = null; + + if (orderId) { + const readableIdMatch = orderId.match(/^ORD(\d+)$/); + if (readableIdMatch) { + orderIdNum = parseInt(readableIdMatch[1]); + } + } + + await db.insert(complaints).values({ + userId, + orderId: orderIdNum, + complaintBody: complaintBody.trim(), + }); + + return { success: true, message: 'Complaint raised successfully' }; + }), +}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts b/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts index e69de29..6eab804 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts @@ -0,0 +1,296 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { coupons, couponUsage, couponApplicableUsers, reservedCoupons, couponApplicableProducts } from '@/src/db/schema'; +import { eq, and, or, gt, isNull, sql } from 'drizzle-orm'; +import { ApiError } from '@/src/lib/api-error'; + +import { users } from '@/src/db/schema'; + +type CouponWithRelations = typeof coupons.$inferSelect & { + applicableUsers: (typeof couponApplicableUsers.$inferSelect & { user: typeof users.$inferSelect })[]; + usages: typeof couponUsage.$inferSelect[]; +}; + +export interface EligibleCoupon { + id: number; + code: string; + discountType: 'percentage' | 'flat'; + discountValue: number; + maxValue?: number; + minOrder?: number; + description: string; + exclusiveApply?: boolean; + isEligible: boolean; + ineligibilityReason?: string; +} + +const generateCouponDescription = (coupon: any): string => { + let desc = ''; + + if (coupon.discountPercent) { + desc += `${coupon.discountPercent}% off`; + } else if (coupon.flatDiscount) { + desc += `₹${coupon.flatDiscount} off`; + } + + if (coupon.minOrder) { + desc += ` on orders above ₹${coupon.minOrder}`; + } + + if (coupon.maxValue) { + desc += ` (max discount ₹${coupon.maxValue})`; + } + + return desc; +}; + +export interface CouponDisplay { + id: number; + code: string; + discountType: 'percentage' | 'flat'; + discountValue: number; + maxValue?: number; + minOrder?: number; + description: string; + validTill?: Date; + usageCount: number; + maxLimitForUser?: number; + isExpired: boolean; + isUsedUp: boolean; +} + +export const userCouponRouter = router({ + getEligible: protectedProcedure + .query(async ({ ctx }) => { + try { + + const userId = ctx.user.userId; + + // Get all active, non-expired coupons + const allCoupons = await db.query.coupons.findMany({ + where: and( + eq(coupons.isInvalidated, false), + or( + isNull(coupons.validTill), + gt(coupons.validTill, new Date()) + ) + ), + with: { + usages: { + where: eq(couponUsage.userId, userId) + }, + applicableUsers: { + with: { + user: true + } + }, + applicableProducts: { + with: { + product: true + } + }, + } + }); + + // Filter to only coupons applicable to current user + const applicableCoupons = allCoupons.filter(coupon => { + if(!coupon.isUserBased) return true; + const applicableUsers = coupon.applicableUsers || []; + return applicableUsers.some(au => au.userId === userId); + }); + + return { success: true, data: applicableCoupons }; + } + catch(e) { + console.log(e) + throw new ApiError("Unable to get coupons") + } + }), + + getProductCoupons: protectedProcedure + .input(z.object({ productId: z.number().int().positive() })) + .query(async ({ input, ctx }) => { + const userId = ctx.user.userId; + const { productId } = input; + + // Get all active, non-expired coupons + const allCoupons = await db.query.coupons.findMany({ + where: and( + eq(coupons.isInvalidated, false), + or( + isNull(coupons.validTill), + gt(coupons.validTill, new Date()) + ) + ), + with: { + usages: { + where: eq(couponUsage.userId, userId) + }, + applicableUsers: { + with: { + user: true + } + }, + applicableProducts: { + with: { + product: true + } + }, + } + }); + + // Filter to only coupons applicable to current user and product + const applicableCoupons = allCoupons.filter(coupon => { + const applicableUsers = coupon.applicableUsers || []; + const userApplicable = !coupon.isUserBased || applicableUsers.some(au => au.userId === userId); + + const applicableProducts = coupon.applicableProducts || []; + const productApplicable = applicableProducts.length === 0 || applicableProducts.some(ap => ap.productId === productId); + + return userApplicable && productApplicable; + }); + + return { success: true, data: applicableCoupons }; + }), + + getMyCoupons: protectedProcedure + .query(async ({ ctx }) => { + const userId = ctx.user.userId; + + // Get all coupons + const allCoupons = await db.query.coupons.findMany({ + with: { + usages: { + where: eq(couponUsage.userId, userId) + }, + applicableUsers: { + with: { + user: true + } + } + } + }); + + // Filter coupons in JS: not invalidated, applicable to user, and not expired + const applicableCoupons = (allCoupons as CouponWithRelations[]).filter(coupon => { + const isNotInvalidated = !coupon.isInvalidated; + const applicableUsers = coupon.applicableUsers || []; + const isApplicable = coupon.isApplyForAll || applicableUsers.some(au => au.userId === userId); + const isNotExpired = !coupon.validTill || new Date(coupon.validTill) > new Date(); + return isNotInvalidated && isApplicable && isNotExpired; + }); + + // Categorize coupons + const personalCoupons: CouponDisplay[] = []; + const generalCoupons: CouponDisplay[] = []; + + applicableCoupons.forEach(coupon => { + const usageCount = coupon.usages.length; + const isExpired = false; // Already filtered out expired coupons + const isUsedUp = Boolean(coupon.maxLimitForUser && usageCount >= coupon.maxLimitForUser); + + const couponDisplay: CouponDisplay = { + id: coupon.id, + code: coupon.couponCode, + discountType: coupon.discountPercent ? 'percentage' : 'flat', + discountValue: parseFloat(coupon.discountPercent || coupon.flatDiscount || '0'), + maxValue: coupon.maxValue ? parseFloat(coupon.maxValue) : undefined, + minOrder: coupon.minOrder ? parseFloat(coupon.minOrder) : undefined, + description: generateCouponDescription(coupon), + validTill: coupon.validTill ? new Date(coupon.validTill) : undefined, + usageCount, + maxLimitForUser: coupon.maxLimitForUser ? parseInt(coupon.maxLimitForUser.toString()) : undefined, + isExpired, + isUsedUp, + }; + + if ((coupon.applicableUsers || []).some(au => au.userId === userId) && !coupon.isApplyForAll) { + // Personal coupon + personalCoupons.push(couponDisplay); + } else if (coupon.isApplyForAll) { + // General coupon + generalCoupons.push(couponDisplay); + } + }); + + return { + success: true, + data: { + personal: personalCoupons, + general: generalCoupons, + } + }; + }), + + redeemReservedCoupon: protectedProcedure + .input(z.object({ secretCode: z.string() })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + const { secretCode } = input; + + // Find the reserved coupon + const reservedCoupon = await db.query.reservedCoupons.findFirst({ + where: and( + eq(reservedCoupons.secretCode, secretCode.toUpperCase()), + eq(reservedCoupons.isRedeemed, false) + ), + }); + + if (!reservedCoupon) { + throw new ApiError("Invalid or already redeemed coupon code", 400); + } + + // Check if already redeemed by this user (in case of multiple attempts) + if (reservedCoupon.redeemedBy === userId) { + throw new ApiError("You have already redeemed this coupon", 400); + } + + // Create the coupon in the main table + const couponResult = await db.transaction(async (tx) => { + // Insert into coupons + const couponInsert = await tx.insert(coupons).values({ + couponCode: reservedCoupon.couponCode, + isUserBased: true, + discountPercent: reservedCoupon.discountPercent, + flatDiscount: reservedCoupon.flatDiscount, + minOrder: reservedCoupon.minOrder, + productIds: reservedCoupon.productIds, + maxValue: reservedCoupon.maxValue, + isApplyForAll: false, + validTill: reservedCoupon.validTill, + maxLimitForUser: reservedCoupon.maxLimitForUser, + exclusiveApply: reservedCoupon.exclusiveApply, + createdBy: reservedCoupon.createdBy, + }).returning(); + + const coupon = couponInsert[0]; + + // Insert into couponApplicableUsers + await tx.insert(couponApplicableUsers).values({ + couponId: coupon.id, + userId, + }); + + // Copy applicable products + if (reservedCoupon.productIds && Array.isArray(reservedCoupon.productIds) && reservedCoupon.productIds.length > 0) { + // Assuming productIds are the IDs, but wait, in schema, productIds is jsonb, but in relations, couponApplicableProducts has productId + // For simplicity, since reservedCoupons has productIds as jsonb, but to match, perhaps insert into couponApplicableProducts if needed + // But in createReservedCoupon, I inserted applicableProducts into couponApplicableProducts + // So for reserved, perhaps do the same, but since it's jsonb, maybe not. + // For now, skip, as the coupon will have productIds in coupons table. + } + + // Update reserved coupon as redeemed + await tx.update(reservedCoupons).set({ + isRedeemed: true, + redeemedBy: userId, + redeemedAt: new Date(), + }).where(eq(reservedCoupons.id, reservedCoupon.id)); + + return coupon; + }); + + return { success: true, coupon: couponResult }; + }), +}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/file-upload.ts b/apps/backend/src/trpc/apis/user-apis/apis/file-upload.ts index e69de29..bacf1f7 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/file-upload.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/file-upload.ts @@ -0,0 +1,55 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { generateUploadUrl } from '@/src/lib/s3-client'; +import { ApiError } from '@/src/lib/api-error'; + +export const fileUploadRouter = router({ + generateUploadUrls: protectedProcedure + .input(z.object({ + contextString: z.enum(['review', 'product_info', 'notification']), + mimeTypes: z.array(z.string()), + })) + .mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => { + const { contextString, mimeTypes } = input; + + const uploadUrls: string[] = []; + const keys: string[] = []; + + for (const mimeType of mimeTypes) { + // Generate key based on context and mime type + let folder: string; + if (contextString === 'review') { + folder = 'review-images'; + } else if(contextString === 'product_info') { + folder = 'product-images'; + } + // else if(contextString === 'review_response') { + // folder = 'review-response-images' + // } + else if(contextString === 'notification') { + folder = 'notification-images' + } else { + folder = ''; + } + + const extension = mimeType === 'image/jpeg' ? '.jpg' : + mimeType === 'image/png' ? '.png' : + mimeType === 'image/gif' ? '.gif' : '.jpg'; + const key = `${folder}/${Date.now()}${extension}`; + + try { + const uploadUrl = await generateUploadUrl(key, mimeType); + uploadUrls.push(uploadUrl); + keys.push(key); + + } catch (error) { + console.error('Error generating upload URL:', error); + throw new ApiError('Failed to generate upload URL', 500); + } + } + + return { uploadUrls }; + }), +}); + +export type FileUploadRouter = typeof fileUploadRouter; diff --git a/apps/backend/src/trpc/apis/user-apis/apis/order.ts b/apps/backend/src/trpc/apis/user-apis/apis/order.ts index e69de29..8118683 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/order.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/order.ts @@ -0,0 +1,989 @@ +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, + payments, + 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 { RazorpayPaymentService } from "@/src/lib/payments-utils"; +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) { + const razorpayOrder = await RazorpayPaymentService.createOrder( + sharedPaymentInfoId, + totalWithDelivery.toString() + ); + await RazorpayPaymentService.insertPaymentRecord( + sharedPaymentInfoId, + razorpayOrder, + tx + ); + } + + 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, + }; + }), +}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/payments.ts b/apps/backend/src/trpc/apis/user-apis/apis/payments.ts index e69de29..0adb0bd 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/payments.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/payments.ts @@ -0,0 +1,158 @@ + +import { router, protectedProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { orders, payments, orderStatus } from '@/src/db/schema'; +import { eq } from 'drizzle-orm'; +import { ApiError } from '@/src/lib/api-error'; +import crypto from 'crypto'; +import { razorpayId, razorpaySecret } from "@/src/lib/env-exporter"; +import { DiskPersistedSet } from "@/src/lib/disk-persisted-set"; +import { RazorpayPaymentService } from "@/src/lib/payments-utils"; + + + + +export const paymentRouter = router({ + createRazorpayOrder: protectedProcedure //either create a new payment order or return the existing one + .input(z.object({ + orderId: z.string(), + })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + const { orderId } = input; + + // Validate order exists and belongs to user + const order = await db.query.orders.findFirst({ + where: eq(orders.id, parseInt(orderId)), + }); + + if (!order) { + throw new ApiError("Order not found", 404); + } + + if (order.userId !== userId) { + throw new ApiError("Order does not belong to user", 403); + } + + // Check for existing pending payment + const existingPayment = await db.query.payments.findFirst({ + where: eq(payments.orderId, parseInt(orderId)), + }); + + if (existingPayment && existingPayment.status === 'pending') { + return { + razorpayOrderId: existingPayment.merchantOrderId, + key: razorpayId, + }; + } + + // Create Razorpay order and insert payment record + const razorpayOrder = await RazorpayPaymentService.createOrder(parseInt(orderId), order.totalAmount); + await RazorpayPaymentService.insertPaymentRecord(parseInt(orderId), razorpayOrder); + + return { + razorpayOrderId: razorpayOrder.id, + key: razorpayId, + }; + }), + + + + verifyPayment: protectedProcedure + .input(z.object({ + razorpay_payment_id: z.string(), + razorpay_order_id: z.string(), + razorpay_signature: z.string(), + })) + .mutation(async ({ input, ctx }) => { + const { razorpay_payment_id, razorpay_order_id, razorpay_signature } = input; + + // Verify signature + const expectedSignature = crypto + .createHmac('sha256', razorpaySecret) + .update(razorpay_order_id + '|' + razorpay_payment_id) + .digest('hex'); + + if (expectedSignature !== razorpay_signature) { + throw new ApiError("Invalid payment signature", 400); + } + + // Get current payment record + const currentPayment = await db.query.payments.findFirst({ + where: eq(payments.merchantOrderId, razorpay_order_id), + }); + + if (!currentPayment) { + throw new ApiError("Payment record not found", 404); + } + + // Update payment status and payload + const updatedPayload = { + ...((currentPayment.payload as any) || {}), + payment_id: razorpay_payment_id, + signature: razorpay_signature, + }; + + const [updatedPayment] = await db + .update(payments) + .set({ + status: 'success', + payload: updatedPayload, + }) + .where(eq(payments.merchantOrderId, razorpay_order_id)) + .returning(); + + // Update order status to mark payment as processed + await db + .update(orderStatus) + .set({ + paymentStatus: 'success', + }) + .where(eq(orderStatus.orderId, updatedPayment.orderId)); + + return { + success: true, + message: "Payment verified successfully", + }; + }), + + markPaymentFailed: protectedProcedure + .input(z.object({ + merchantOrderId: z.string(), + })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + const { merchantOrderId } = input; + + // Find payment by merchantOrderId + const payment = await db.query.payments.findFirst({ + where: eq(payments.merchantOrderId, merchantOrderId), + }); + + if (!payment) { + throw new ApiError("Payment not found", 404); + } + + // Check if payment belongs to user's order + const order = await db.query.orders.findFirst({ + where: eq(orders.id, payment.orderId), + }); + + if (!order || order.userId !== userId) { + throw new ApiError("Payment does not belong to user", 403); + } + + // Update payment status to failed + await db + .update(payments) + .set({ status: 'failed' }) + .where(eq(payments.id, payment.id)); + + return { + success: true, + message: "Payment marked as failed", + }; + }), + +}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/product.ts b/apps/backend/src/trpc/apis/user-apis/apis/product.ts index e69de29..51ec140 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/product.ts @@ -0,0 +1,265 @@ +import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productTagInfo, productTags, productReviews, users } from '@/src/db/schema'; +import { claimUploadUrl, extractKeyFromPresignedUrl, scaffoldAssetUrl } from '@/src/lib/s3-client'; +import { ApiError } from '@/src/lib/api-error'; +import { eq, and, gt, sql, inArray, desc } from 'drizzle-orm'; +import { getProductById as getProductByIdFromCache, getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store'; +import dayjs from 'dayjs'; + +// Uniform Product Type +interface Product { + id: number; + name: string; + shortDescription: string | null; + longDescription: string | null; + price: string; + marketPrice: string | null; + unitNotation: string; + images: string[]; + isOutOfStock: boolean; + store: { id: number; name: string; description: string | null } | null; + incrementStep: number; + productQuantity: number; + isFlashAvailable: boolean; + flashPrice: string | null; + deliverySlots: Array<{ id: number; deliveryTime: Date; freezeTime: Date }>; + specialDeals: Array<{ quantity: string; price: string; validTill: Date }>; +} + +export const productRouter = router({ + getProductDetails: publicProcedure + .input(z.object({ + id: z.string().regex(/^\d+$/, 'Invalid product ID'), + })) + .query(async ({ input }): Promise => { + const { id } = input; + const productId = parseInt(id); + + if (isNaN(productId)) { + throw new Error('Invalid product ID'); + } + + console.log('from the api to get product details') + +// First, try to get the product from Redis cache + const cachedProduct = await getProductByIdFromCache(productId); + + if (cachedProduct) { + // Filter delivery slots to only include those with future freeze times and not at full capacity + const currentTime = new Date(); + const filteredSlots = cachedProduct.deliverySlots.filter(slot => + dayjs(slot.freezeTime).isAfter(currentTime) && !slot.isCapacityFull + ); + + return { + ...cachedProduct, + deliverySlots: filteredSlots + }; + } + + // If not in cache, fetch from database (fallback) + const productData = await db + .select({ + id: productInfo.id, + name: productInfo.name, + shortDescription: productInfo.shortDescription, + longDescription: productInfo.longDescription, + price: productInfo.price, + marketPrice: productInfo.marketPrice, + images: productInfo.images, + isOutOfStock: productInfo.isOutOfStock, + storeId: productInfo.storeId, + unitShortNotation: units.shortNotation, + incrementStep: productInfo.incrementStep, + productQuantity: productInfo.productQuantity, + isFlashAvailable: productInfo.isFlashAvailable, + flashPrice: productInfo.flashPrice, + }) + .from(productInfo) + .innerJoin(units, eq(productInfo.unitId, units.id)) + .where(eq(productInfo.id, productId)) + .limit(1); + + if (productData.length === 0) { + throw new Error('Product not found'); + } + + const product = productData[0]; + + // Fetch store info for this product + const storeData = product.storeId ? await db.query.storeInfo.findFirst({ + where: eq(storeInfo.id, product.storeId), + columns: { id: true, name: true, description: true }, + }) : null; + + // Fetch delivery slots for this product + const deliverySlotsData = await db + .select({ + id: deliverySlotInfo.id, + deliveryTime: deliverySlotInfo.deliveryTime, + freezeTime: deliverySlotInfo.freezeTime, + }) + .from(productSlots) + .innerJoin(deliverySlotInfo, eq(productSlots.slotId, deliverySlotInfo.id)) + .where( + and( + eq(productSlots.productId, productId), + eq(deliverySlotInfo.isActive, true), + eq(deliverySlotInfo.isCapacityFull, false), + gt(deliverySlotInfo.deliveryTime, sql`NOW()`), + gt(deliverySlotInfo.freezeTime, sql`NOW()`) + ) + ) + .orderBy(deliverySlotInfo.deliveryTime); + + // Fetch special deals for this product + const specialDealsData = await db + .select({ + quantity: specialDeals.quantity, + price: specialDeals.price, + validTill: specialDeals.validTill, + }) + .from(specialDeals) + .where( + and( + eq(specialDeals.productId, productId), + gt(specialDeals.validTill, sql`NOW()`) + ) + ) + .orderBy(specialDeals.quantity); + + // Generate signed URLs for images + const signedImages = scaffoldAssetUrl((product.images as string[]) || []); + + const response: Product = { + id: product.id, + name: product.name, + shortDescription: product.shortDescription, + longDescription: product.longDescription, + price: product.price.toString(), + marketPrice: product.marketPrice?.toString() || null, + unitNotation: product.unitShortNotation, + images: signedImages, + isOutOfStock: product.isOutOfStock, + store: storeData ? { + id: storeData.id, + name: storeData.name, + description: storeData.description, + } : null, + incrementStep: product.incrementStep, + productQuantity: product.productQuantity, + isFlashAvailable: product.isFlashAvailable, + flashPrice: product.flashPrice?.toString() || null, + deliverySlots: deliverySlotsData, + specialDeals: specialDealsData.map(d => ({ quantity: d.quantity.toString(), price: d.price.toString(), validTill: d.validTill })), + }; + + return response; + }), + + getProductReviews: publicProcedure + .input(z.object({ + productId: z.number().int().positive(), + limit: z.number().int().min(1).max(50).optional().default(10), + offset: z.number().int().min(0).optional().default(0), + })) + .query(async ({ input }) => { + const { productId, limit, offset } = input; + + const reviews = await db + .select({ + id: productReviews.id, + reviewBody: productReviews.reviewBody, + ratings: productReviews.ratings, + imageUrls: productReviews.imageUrls, + reviewTime: productReviews.reviewTime, + userName: users.name, + }) + .from(productReviews) + .innerJoin(users, eq(productReviews.userId, users.id)) + .where(eq(productReviews.productId, productId)) + .orderBy(desc(productReviews.reviewTime)) + .limit(limit) + .offset(offset); + + // Generate signed URLs for images + const reviewsWithSignedUrls = await Promise.all( + reviews.map(async (review) => ({ + ...review, + signedImageUrls: scaffoldAssetUrl((review.imageUrls as string[]) || []), + })) + ); + + // Check if more reviews exist + const totalCountResult = await db + .select({ count: sql`count(*)` }) + .from(productReviews) + .where(eq(productReviews.productId, productId)); + + const totalCount = Number(totalCountResult[0].count); + const hasMore = offset + limit < totalCount; + + return { reviews: reviewsWithSignedUrls, hasMore }; + }), + + createReview: protectedProcedure + .input(z.object({ + productId: z.number().int().positive(), + reviewBody: z.string().min(1, 'Review body is required'), + ratings: z.number().int().min(1).max(5), + imageUrls: z.array(z.string()).optional().default([]), + uploadUrls: z.array(z.string()).optional().default([]), + })) + .mutation(async ({ input, ctx }) => { + const { productId, reviewBody, ratings, imageUrls, uploadUrls } = input; + const userId = ctx.user.userId; + + // Optional: Check if product exists + const product = await db.query.productInfo.findFirst({ + where: eq(productInfo.id, productId), + }); + if (!product) { + throw new ApiError('Product not found', 404); + } + + // Insert review + const [newReview] = await db.insert(productReviews).values({ + userId, + productId, + reviewBody, + ratings, + imageUrls: uploadUrls.map(item => extractKeyFromPresignedUrl(item)), + }).returning(); + + // Claim upload URLs + if (uploadUrls && uploadUrls.length > 0) { + try { + await Promise.all(uploadUrls.map(url => claimUploadUrl(url))); + } catch (error) { + console.error('Error claiming upload URLs:', error); + // Don't fail the review creation + } + } + + return { success: true, review: newReview }; + }), + + getAllProductsSummary: publicProcedure + .query(async (): Promise => { + // Get all products from cache + const allCachedProducts = await getAllProductsFromCache(); + + // Transform the cached products to match the expected summary format + // (with empty deliverySlots and specialDeals arrays for summary view) + const transformedProducts = allCachedProducts.map(product => ({ + ...product, + deliverySlots: [], // Empty for summary view + specialDeals: [], // Empty for summary view + })); + + return transformedProducts; + }), + +}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/slots.ts b/apps/backend/src/trpc/apis/user-apis/apis/slots.ts index e69de29..9fdd268 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/slots.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/slots.ts @@ -0,0 +1,88 @@ +import { router, publicProcedure } from "@/src/trpc/trpc-index"; +import { z } from "zod"; +import { db } from "@/src/db/db_index"; +import { + deliverySlotInfo, + productSlots, + productInfo, + units, +} from "@/src/db/schema"; +import { eq, and, gt, asc } from "drizzle-orm"; +import { getAllSlots as getAllSlotsFromCache, getSlotById as getSlotByIdFromCache } from "@/src/stores/slot-store"; +import dayjs from 'dayjs'; + +// Helper method to get formatted slot data by ID +async function getSlotData(slotId: number) { + const slot = await getSlotByIdFromCache(slotId); + + if (!slot) { + return null; + } + + const currentTime = new Date(); + if (dayjs(slot.freezeTime).isBefore(currentTime)) { + return null; + } + + return { + deliveryTime: slot.deliveryTime, + freezeTime: slot.freezeTime, + slotId: slot.id, + products: slot.products.filter((product) => !product.isOutOfStock), + }; +} + +export const slotsRouter = router({ + getSlots: publicProcedure.query(async () => { + const slots = await db.query.deliverySlotInfo.findMany({ + where: eq(deliverySlotInfo.isActive, true), + }); + return { + slots, + count: slots.length, + }; + }), + + getSlotsWithProducts: publicProcedure.query(async () => { + const allSlots = await getAllSlotsFromCache(); + const currentTime = new Date(); + const validSlots = allSlots + .filter((slot) => { + return dayjs(slot.freezeTime).isAfter(currentTime) && + dayjs(slot.deliveryTime).isAfter(currentTime) && + !slot.isCapacityFull; + }) + .sort((a, b) => dayjs(a.deliveryTime).valueOf() - dayjs(b.deliveryTime).valueOf()); + + return { + slots: validSlots, + count: validSlots.length, + }; + }), + + nextMajorDelivery: publicProcedure.query(async () => { + const now = new Date(); + + // Find the next upcoming active delivery slot ID + const nextSlot = await db.query.deliverySlotInfo.findFirst({ + where: and( + eq(deliverySlotInfo.isActive, true), + gt(deliverySlotInfo.deliveryTime, now), + ), + orderBy: asc(deliverySlotInfo.deliveryTime), + }); + + if (!nextSlot) { + return null; // No upcoming delivery slots + } + + // Get formatted data using helper method + return await getSlotData(nextSlot.id); + }), + + getSlotById: publicProcedure + .input(z.object({ slotId: z.number() })) + .query(async ({ input }) => { + return await getSlotData(input.slotId); + }), +}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/stores.ts b/apps/backend/src/trpc/apis/user-apis/apis/stores.ts index e69de29..a2989aa 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/stores.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/stores.ts @@ -0,0 +1,143 @@ +import { router, publicProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { storeInfo, productInfo, units } from '@/src/db/schema'; +import { eq, and, sql } from 'drizzle-orm'; +import { scaffoldAssetUrl } from '@/src/lib/s3-client'; +import { ApiError } from '@/src/lib/api-error'; + +export const storesRouter = router({ + getStores: publicProcedure + .query(async () => { + const storesData = await db + .select({ + id: storeInfo.id, + name: storeInfo.name, + description: storeInfo.description, + imageUrl: storeInfo.imageUrl, + productCount: sql`count(${productInfo.id})`.as('productCount'), + }) + .from(storeInfo) + .leftJoin( + productInfo, + and(eq(productInfo.storeId, storeInfo.id), eq(productInfo.isSuspended, false)) + ) + .groupBy(storeInfo.id); + + // Generate signed URLs for store images and fetch sample products + const storesWithDetails = await Promise.all( + storesData.map(async (store) => { + const signedImageUrl = store.imageUrl ? scaffoldAssetUrl(store.imageUrl) : null; + + // Fetch up to 3 products for this store + const sampleProducts = await db + .select({ + id: productInfo.id, + name: productInfo.name, + images: productInfo.images, + }) + .from(productInfo) + .where(and(eq(productInfo.storeId, store.id), eq(productInfo.isSuspended, false))) + .limit(3); + + // Generate signed URLs for product images + const productsWithSignedUrls = await Promise.all( + sampleProducts.map(async (product) => { + const images = product.images as string[]; + return { + id: product.id, + name: product.name, + signedImageUrl: (images && images.length > 0) ? scaffoldAssetUrl(images[0]) : null, + }; + }) + ); + + return { + id: store.id, + name: store.name, + description: store.description, + signedImageUrl, + productCount: store.productCount, + sampleProducts: productsWithSignedUrls, + }; + }) + ); + + return { + stores: storesWithDetails, + }; + }), + + getStoreWithProducts: publicProcedure + .input(z.object({ + storeId: z.number(), + })) + .query(async ({ input }) => { + const { storeId } = input; + + // Fetch store info + const storeData = await db.query.storeInfo.findFirst({ + where: eq(storeInfo.id, storeId), + columns: { + id: true, + name: true, + description: true, + imageUrl: true, + }, + }); + + if (!storeData) { + throw new ApiError('Store not found', 404); + } + + // Generate signed URL for store image + const signedImageUrl = storeData.imageUrl ? scaffoldAssetUrl(storeData.imageUrl) : null; + + // Fetch products for this store + const productsData = await db + .select({ + id: productInfo.id, + name: productInfo.name, + shortDescription: productInfo.shortDescription, + price: productInfo.price, + marketPrice: productInfo.marketPrice, + images: productInfo.images, + isOutOfStock: productInfo.isOutOfStock, + incrementStep: productInfo.incrementStep, + unitShortNotation: units.shortNotation, + unitNotation: units.shortNotation, + productQuantity: productInfo.productQuantity, + }) + .from(productInfo) + .innerJoin(units, eq(productInfo.unitId, units.id)) + .where(and(eq(productInfo.storeId, storeId), eq(productInfo.isSuspended, false))); + + + // Generate signed URLs for product images + const productsWithSignedUrls = await Promise.all( + productsData.map(async (product) => ({ + id: product.id, + name: product.name, + shortDescription: product.shortDescription, + price: product.price, + marketPrice: product.marketPrice, + incrementStep: product.incrementStep, + unit: product.unitShortNotation, + unitNotation: product.unitNotation, + images: scaffoldAssetUrl((product.images as string[]) || []), + isOutOfStock: product.isOutOfStock, + productQuantity: product.productQuantity + })) + ); + + return { + store: { + id: storeData.id, + name: storeData.name, + description: storeData.description, + signedImageUrl, + }, + products: productsWithSignedUrls, + }; + }), +}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/tags.ts b/apps/backend/src/trpc/apis/user-apis/apis/tags.ts index e69de29..d21b229 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/tags.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/tags.ts @@ -0,0 +1,28 @@ +import { router, publicProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { getTagsByStoreId } from '@/src/stores/product-tag-store'; +import { ApiError } from '@/src/lib/api-error'; + +export const tagsRouter = router({ + getTagsByStore: publicProcedure + .input(z.object({ + storeId: z.number(), + })) + .query(async ({ input }) => { + const { storeId } = input; + + // Get tags from cache that are related to this store + const tags = await getTagsByStoreId(storeId); + + + return { + tags: tags.map(tag => ({ + id: tag.id, + tagName: tag.tagName, + tagDescription: tag.tagDescription, + imageUrl: tag.imageUrl, + productIds: tag.productIds, + })), + }; + }), +}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/user-trpc-index.ts b/apps/backend/src/trpc/apis/user-apis/apis/user-trpc-index.ts index d4fb958..52e6531 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/user-trpc-index.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/user-trpc-index.ts @@ -1,5 +1,34 @@ -import { router } from '@/src/trpc/trpc-index' +import { router } from '@/src/trpc/trpc-index'; +import { addressRouter } from '@/src/trpc/apis/user-apis/apis/address'; +import { authRouter } from '@/src/trpc/apis/user-apis/apis/auth'; +import { bannerRouter } from '@/src/trpc/apis/user-apis/apis/banners'; +import { cartRouter } from '@/src/trpc/apis/user-apis/apis/cart'; +import { complaintRouter } from '@/src/trpc/apis/user-apis/apis/complaint'; +import { orderRouter } from '@/src/trpc/apis/user-apis/apis/order'; +import { productRouter } from '@/src/trpc/apis/user-apis/apis/product'; +import { slotsRouter } from '@/src/trpc/apis/user-apis/apis/slots'; +import { userRouter as userDataRouter } from '@/src/trpc/apis/user-apis/apis/user'; +import { userCouponRouter } from '@/src/trpc/apis/user-apis/apis/coupon'; +import { paymentRouter } from '@/src/trpc/apis/user-apis/apis/payments'; +import { storesRouter } from '@/src/trpc/apis/user-apis/apis/stores'; +import { fileUploadRouter } from '@/src/trpc/apis/user-apis/apis/file-upload'; +import { tagsRouter } from '@/src/trpc/apis/user-apis/apis/tags'; -export const userRouter = router({}) +export const userRouter = router({ + address: addressRouter, + auth: authRouter, + banner: bannerRouter, + cart: cartRouter, + complaint: complaintRouter, + order: orderRouter, + product: productRouter, + slots: slotsRouter, + user: userDataRouter, + coupon: userCouponRouter, + payment: paymentRouter, + stores: storesRouter, + fileUpload: fileUploadRouter, + tags: tagsRouter, +}); -export type UserRouter = typeof userRouter +export type UserRouter = typeof userRouter; diff --git a/apps/backend/src/trpc/apis/user-apis/apis/user.ts b/apps/backend/src/trpc/apis/user-apis/apis/user.ts index e69de29..730c1af 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/user.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/user.ts @@ -0,0 +1,170 @@ +import { router, protectedProcedure, publicProcedure } from '@/src/trpc/trpc-index'; +import jwt from 'jsonwebtoken'; +import { eq, and } from 'drizzle-orm'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { users, userDetails, userCreds, notifCreds, unloggedUserTokens } from '@/src/db/schema'; +import { ApiError } from '@/src/lib/api-error'; +import { jwtSecret } from '@/src/lib/env-exporter'; +import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client'; + +interface AuthResponse { + token: string; + user: { + id: number; + name: string | null; + email: string | null; + mobile: string | null; + profileImage?: string | null; + bio?: string | null; + dateOfBirth?: string | null; + gender?: string | null; + occupation?: string | null; + }; +} + +const generateToken = (userId: number): string => { + const secret = jwtSecret; + if (!secret) { + throw new ApiError('JWT secret not configured', 500); + } + + return jwt.sign({ userId }, secret, { expiresIn: '7d' }); +}; + +export const userRouter = router({ + getSelfData: protectedProcedure + .query(async ({ ctx }) => { + const userId = ctx.user.userId; + + if (!userId) { + throw new ApiError('User not authenticated', 401); + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user) { + throw new ApiError('User not found', 404); + } + + // Get user details for profile image + const [userDetail] = await db + .select() + .from(userDetails) + .where(eq(userDetails.userId, userId)) + .limit(1); + + // Generate signed URL for profile image if it exists + const profileImageSignedUrl = userDetail?.profileImage + ? await generateSignedUrlFromS3Url(userDetail.profileImage) + : null; + + const response: Omit = { + user: { + id: user.id, + name: user.name, + email: user.email, + mobile: user.mobile, + profileImage: profileImageSignedUrl, + bio: userDetail?.bio || null, + dateOfBirth: userDetail?.dateOfBirth || null, + gender: userDetail?.gender || null, + occupation: userDetail?.occupation || null, + }, + }; + + return { + success: true, + data: response, + }; + }), + + checkProfileComplete: protectedProcedure + .query(async ({ ctx }) => { + const userId = ctx.user.userId; + + if (!userId) { + throw new ApiError('User not authenticated', 401); + } + + const result = await db + .select() + .from(users) + .leftJoin(userCreds, eq(users.id, userCreds.userId)) + .where(eq(users.id, userId)) + .limit(1); + + if (result.length === 0) { + throw new ApiError('User not found', 404); + } + + const { users: user, user_creds: creds } = result[0]; + + return { + isComplete: !!(user.name && user.email && creds), + }; + }), + + savePushToken: publicProcedure + .input(z.object({ token: z.string() })) + .mutation(async ({ input, ctx }) => { + const { token } = input; + const userId = ctx.user?.userId; + + if (userId) { + // AUTHENTICATED USER + // Check if token exists in notif_creds for this user + const existing = await db.query.notifCreds.findFirst({ + where: and( + eq(notifCreds.userId, userId), + eq(notifCreds.token, token) + ), + }); + + if (existing) { + // Update lastVerified timestamp + await db + .update(notifCreds) + .set({ lastVerified: new Date() }) + .where(eq(notifCreds.id, existing.id)); + } else { + // Insert new token into notif_creds + await db.insert(notifCreds).values({ + userId, + token, + lastVerified: new Date(), + }); + } + + // Remove from unlogged_user_tokens if it exists + await db + .delete(unloggedUserTokens) + .where(eq(unloggedUserTokens.token, token)); + + } else { + // UNAUTHENTICATED USER + // Save/update in unlogged_user_tokens + const existing = await db.query.unloggedUserTokens.findFirst({ + where: eq(unloggedUserTokens.token, token), + }); + + if (existing) { + await db + .update(unloggedUserTokens) + .set({ lastVerified: new Date() }) + .where(eq(unloggedUserTokens.id, existing.id)); + } else { + await db.insert(unloggedUserTokens).values({ + token, + lastVerified: new Date(), + }); + } + } + + return { success: true }; + }), +}); diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 4ddf857..d522745 100755 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -27,7 +27,7 @@ /* Modules */ "module": "commonjs", - "baseUrl": ".", + // "baseUrl": ".", "paths": { "@/*": ["./*"], "shared-types": ["../shared-types"], diff --git a/packages/ui/index.ts b/packages/ui/index.ts index cb5abc5..77fc758 100755 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -64,7 +64,8 @@ const isDevMode = Constants.executionEnvironment !== "standalone"; // const BASE_API_URL = 'http://10.0.2.2:4000'; // const BASE_API_URL = 'http://192.168.100.101:4000'; // const BASE_API_URL = 'http://192.168.1.5:4000'; -let BASE_API_URL = "https://mf.freshyo.in"; +// let BASE_API_URL = "https://mf.freshyo.in"; +let BASE_API_URL = "https://freshyo.technocracy.ovh"; // let BASE_API_URL = 'http://192.168.100.104:4000'; // let BASE_API_URL = 'http://192.168.29.176:4000';