merge test #1

Merged
shafi merged 8 commits from test into main 2026-03-09 15:50:27 +00:00
17 changed files with 3213 additions and 5 deletions
Showing only changes of commit d08020ff80 - Show all commits

View file

@ -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' };
}),
});

View file

@ -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' };
}),
});

View file

@ -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,
};
}),
});

View file

@ -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);
}),
});

View file

@ -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' };
}),
});

View file

@ -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 };
}),
});

View file

@ -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;

View file

@ -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,
};
}),
});

View file

@ -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",
};
}),
});

View file

@ -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;
}),
});

View file

@ -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);
}),
});

View file

@ -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,
};
}),
});

View file

@ -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,
})),
};
}),
});

View file

@ -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;

View file

@ -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 };
}),
});

View file

@ -27,7 +27,7 @@
/* Modules */
"module": "commonjs",
"baseUrl": ".",
// "baseUrl": ".",
"paths": {
"@/*": ["./*"],
"shared-types": ["../shared-types"],

View file

@ -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';