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 */
|
/* Modules */
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"baseUrl": ".",
|
// "baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"],
|
"@/*": ["./*"],
|
||||||
"shared-types": ["../shared-types"],
|
"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://10.0.2.2:4000';
|
||||||
// const BASE_API_URL = 'http://192.168.100.101:4000';
|
// const BASE_API_URL = 'http://192.168.100.101:4000';
|
||||||
// const BASE_API_URL = 'http://192.168.1.5: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.100.104:4000';
|
||||||
// let BASE_API_URL = 'http://192.168.29.176:4000';
|
// let BASE_API_URL = 'http://192.168.29.176:4000';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue