import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index'; import { z } from 'zod'; import bcrypt from 'bcryptjs'; 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, claimUploadUrl, scaffoldAssetUrl } from '@/src/lib/s3-client'; import { deleteS3Image } from '@/src/lib/delete-image'; import { ApiError } from '@/src/lib/api-error'; import catchAsync from '@/src/lib/catch-async'; import { sendOtp, verifyOtpUtil, getOtpCreds } from '@/src/lib/otp-utils'; import { signToken } from '@/src/lib/jwt-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 = async (userId: number): Promise => { return signToken({ userId }); }; 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 = await 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'), imageKey: z.string().optional(), })) .mutation(async ({ input }) => { const { name, email, mobile, password, imageKey } = 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, }); // Create user details with profile image if provided if (imageKey) { await tx.insert(userDetails).values({ userId: user.id, profileImage: imageKey, }); } return user; }); // Claim upload URL if image was provided if (imageKey) { try { await claimUploadUrl(imageKey); } catch (e) { console.warn(`Failed to claim upload URL for key: ${imageKey}`, e); } } const token = await generateToken(newUser.id); // Get user details for profile image const [userDetail] = await db .select() .from(userDetails) .where(eq(userDetails.userId, newUser.id)) .limit(1); const profileImageUrl = userDetail?.profileImage ? scaffoldAssetUrl(userDetail.profileImage) : null; const response: AuthResponse = { token, user: { id: newUser.id, name: newUser.name, email: newUser.email, mobile: newUser.mobile, createdAt: newUser.createdAt.toISOString(), profileImage: profileImageUrl, }, }; 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 = await 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); } // Get user details for profile image const [userDetail] = await db .select() .from(userDetails) .where(eq(userDetails.userId, userId)) .limit(1); const profileImageUrl = userDetail?.profileImage ? scaffoldAssetUrl(userDetail.profileImage) : null; return { success: true, data: { id: user.id, name: user.name, email: user.email, mobile: user.mobile, profileImage: profileImageUrl, bio: userDetail?.bio || null, dateOfBirth: userDetail?.dateOfBirth || null, gender: userDetail?.gender || null, occupation: userDetail?.occupation || null, }, }; }), updateProfile: protectedProcedure .input(z.object({ name: z.string().min(1, 'Name is required').optional(), email: z.string().email('Invalid email format').optional(), bio: z.string().optional(), dateOfBirth: z.string().optional(), gender: z.string().optional(), occupation: z.string().optional(), imageKey: z.string().optional(), })) .mutation(async ({ input, ctx }) => { const userId = ctx.user.userId; const { imageKey, ...updateData } = input; if (!userId) { throw new ApiError('User not authenticated', 401); } // Get current user details const currentDetail = await db.query.userDetails.findFirst({ where: eq(userDetails.userId, userId), }); let newImageUrl: string | null | undefined = currentDetail?.profileImage; // Handle new image upload (only if different from existing) if (imageKey && imageKey !== currentDetail?.profileImage) { // Delete old image if exists if (currentDetail?.profileImage) { try { await deleteS3Image(currentDetail.profileImage); } catch (e) { console.error(`Failed to delete old image: ${currentDetail.profileImage}`, e); } } newImageUrl = imageKey; // Claim upload URL try { await claimUploadUrl(imageKey); } catch (e) { console.warn(`Failed to claim upload URL for key: ${imageKey}`, e); } } // Update user name if provided if (updateData.name) { await db.update(users) .set({ name: updateData.name.trim() }) .where(eq(users.id, userId)); } // Update user email if provided if (updateData.email) { // Check if email already exists (but belongs to different user) const [existingUser] = await db .select() .from(users) .where(eq(users.email, updateData.email.toLowerCase().trim())) .limit(1); if (existingUser && existingUser.id !== userId) { throw new ApiError('Email already in use by another account', 409); } await db.update(users) .set({ email: updateData.email.toLowerCase().trim() }) .where(eq(users.id, userId)); } // Upsert user details if (currentDetail) { // Update existing await db.update(userDetails) .set({ ...updateData, profileImage: newImageUrl, }) .where(eq(userDetails.userId, userId)); } else { // Insert new await db.insert(userDetails).values({ userId: userId, ...updateData, profileImage: newImageUrl, }); } return { success: true, message: 'Profile updated successfully', }; }), 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' }; }), });