freshyo/verifier/user-apis/apis/auth.ts
2026-03-22 20:20:18 +05:30

581 lines
17 KiB
TypeScript

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<string> => {
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' };
}),
});