merge test #1
17 changed files with 3213 additions and 5 deletions
|
|
@ -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' };
|
||||
}),
|
||||
});
|
||||
|
|
@ -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' };
|
||||
}),
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
|
@ -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<CartResponse> => {
|
||||
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<CartResponse> => {
|
||||
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<CartResponse> => {
|
||||
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<CartResponse> => {
|
||||
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<CartResponse> => {
|
||||
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<number, any[]> = {};
|
||||
// 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);
|
||||
}),
|
||||
});
|
||||
|
|
@ -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' };
|
||||
}),
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}),
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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<number>([
|
||||
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<typeof orders.$inferInsert, "id">;
|
||||
orderItems: Omit<typeof orderItems.$inferInsert, "id">[];
|
||||
orderStatus: Omit<typeof orderStatus.$inferInsert, "id">;
|
||||
};
|
||||
|
||||
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<typeof orders.$inferInsert, "id"> = {
|
||||
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<typeof orderItems.$inferInsert, "id">[] = 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<typeof orderStatus.$inferInsert, "id"> = {
|
||||
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<typeof orders.$inferInsert, "id">[] = ordersData.map(
|
||||
(od) => ({
|
||||
...od.order,
|
||||
paymentInfoId: sharedPaymentInfoId,
|
||||
})
|
||||
);
|
||||
|
||||
const insertedOrders = await tx.insert(orders).values(ordersToInsert).returning();
|
||||
|
||||
const allOrderItems: Omit<typeof orderItems.$inferInsert, "id">[] = [];
|
||||
const allOrderStatuses: Omit<typeof orderStatus.$inferInsert, "id">[] = [];
|
||||
|
||||
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<boolean>(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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
|
@ -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",
|
||||
};
|
||||
}),
|
||||
|
||||
});
|
||||
|
|
@ -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<Product> => {
|
||||
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<Product[]> => {
|
||||
// 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;
|
||||
}),
|
||||
|
||||
});
|
||||
|
|
@ -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);
|
||||
}),
|
||||
});
|
||||
|
|
@ -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<number>`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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
|
@ -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,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<AuthResponse, 'token'> = {
|
||||
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 };
|
||||
}),
|
||||
});
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
|
||||
/* Modules */
|
||||
"module": "commonjs",
|
||||
"baseUrl": ".",
|
||||
// "baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"shared-types": ["../shared-types"],
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue