581 lines
17 KiB
TypeScript
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' };
|
|
}),
|
|
});
|