This commit is contained in:
shafi54 2026-03-22 20:20:28 +05:30
parent 56b606ebcf
commit a23d3bf5b8
51 changed files with 0 additions and 14495 deletions

View file

@ -1,32 +0,0 @@
import { z } from 'zod';
import { addressZones, addressAreas } from '@/src/db/schema'
import { eq, desc } from 'drizzle-orm';
import { db } from '@/src/db/db_index'
import { router,protectedProcedure } from '@/src/trpc/trpc-index'
const addressRouter = router({
getZones: protectedProcedure.query(async () => {
const zones = await db.select().from(addressZones).orderBy(desc(addressZones.addedAt));
return zones
}),
getAreas: protectedProcedure.query(async () => {
const areas = await db.select().from(addressAreas).orderBy(desc(addressAreas.createdAt));
return areas
}),
createZone: protectedProcedure.input(z.object({ zoneName: z.string().min(1) })).mutation(async ({ input }) => {
const zone = await db.insert(addressZones).values({ zoneName: input.zoneName }).returning();
return {zone: zone};
}),
createArea: protectedProcedure.input(z.object({ placeName: z.string().min(1), zoneId: z.number().nullable() })).mutation(async ({ input }) => {
const area = await db.insert(addressAreas).values({ placeName: input.placeName, zoneId: input.zoneId }).returning();
return {area};
}),
// TODO: Add update and delete mutations if needed
});
export default addressRouter;

View file

@ -1,39 +0,0 @@
// import { router } from '@/src/trpc/trpc-index';
import { router } from '@/src/trpc/trpc-index'
import { complaintRouter } from '@/src/trpc/apis/admin-apis/apis/complaint'
import { couponRouter } from '@/src/trpc/apis/admin-apis/apis/coupon'
import { cancelledOrdersRouter } from '@/src/trpc/apis/admin-apis/apis/cancelled-orders'
import { orderRouter } from '@/src/trpc/apis/admin-apis/apis/order'
import { vendorSnippetsRouter } from '@/src/trpc/apis/admin-apis/apis/vendor-snippets'
import { slotsRouter } from '@/src/trpc/apis/admin-apis/apis/slots'
import { productRouter } from '@/src/trpc/apis/admin-apis/apis/product'
import { staffUserRouter } from '@/src/trpc/apis/admin-apis/apis/staff-user'
import { storeRouter } from '@/src/trpc/apis/admin-apis/apis/store'
import { adminPaymentsRouter } from '@/src/trpc/apis/admin-apis/apis/payments'
import addressRouter from '@/src/trpc/apis/admin-apis/apis/address'
import { bannerRouter } from '@/src/trpc/apis/admin-apis/apis/banner'
import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user'
import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const'
import { productAvailabilitySchedulesRouter } from '@/src/trpc/apis/admin-apis/apis/product-availability-schedules'
import { tagRouter } from '@/src/trpc/apis/admin-apis/apis/tag'
export const adminRouter = router({
complaint: complaintRouter,
coupon: couponRouter,
cancelledOrders: cancelledOrdersRouter,
order: orderRouter,
vendorSnippets: vendorSnippetsRouter,
slots: slotsRouter,
product: productRouter,
staffUser: staffUserRouter,
store: storeRouter,
payments: adminPaymentsRouter,
address: addressRouter,
banner: bannerRouter,
user: userRouter,
const: constRouter,
productAvailabilitySchedules: productAvailabilitySchedulesRouter,
tag: tagRouter,
});
export type AdminRouter = typeof adminRouter;

View file

@ -1,176 +0,0 @@
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { homeBanners } from '@/src/db/schema'
import { eq, and, desc, sql } from 'drizzle-orm';
import { protectedProcedure, router } from '@/src/trpc/trpc-index'
import { extractKeyFromPresignedUrl, scaffoldAssetUrl } from '@/src/lib/s3-client'
import { ApiError } from '@/src/lib/api-error';
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
export const bannerRouter = router({
// Get all banners
getBanners: protectedProcedure
.query(async () => {
try {
const banners = await db.query.homeBanners.findMany({
orderBy: desc(homeBanners.createdAt), // Order by creation date instead
// Removed product relationship since we now use productIds array
});
// Convert S3 keys to signed URLs for client
const bannersWithSignedUrls = await Promise.all(
banners.map(async (banner) => {
try {
return {
...banner,
imageUrl: banner.imageUrl ? scaffoldAssetUrl(banner.imageUrl) : banner.imageUrl,
// Ensure productIds is always an array
productIds: banner.productIds || [],
};
} catch (error) {
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
return {
...banner,
imageUrl: banner.imageUrl, // Keep original on error
// Ensure productIds is always an array
productIds: banner.productIds || [],
};
}
})
);
return {
banners: bannersWithSignedUrls,
};
}
catch(e:any) {
console.log(e)
throw new ApiError(e.message);
}
}),
// Get single banner by ID
getBanner: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const banner = await db.query.homeBanners.findFirst({
where: eq(homeBanners.id, input.id),
// Removed product relationship since we now use productIds array
});
if (banner) {
try {
// Convert S3 key to signed URL for client
if (banner.imageUrl) {
banner.imageUrl = scaffoldAssetUrl(banner.imageUrl);
}
} catch (error) {
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
// Keep original imageUrl on error
}
// Ensure productIds is always an array (handle migration compatibility)
if (!banner.productIds) {
banner.productIds = [];
}
}
return banner;
}),
// Create new banner
createBanner: protectedProcedure
.input(z.object({
name: z.string().min(1),
imageUrl: z.string(),
description: z.string().optional(),
productIds: z.array(z.number()).optional(),
redirectUrl: z.string().url().optional(),
// serialNum removed completely
}))
.mutation(async ({ input }) => {
try {
const imageUrl = extractKeyFromPresignedUrl(input.imageUrl)
// const imageUrl = input.imageUrl
const [banner] = await db.insert(homeBanners).values({
name: input.name,
imageUrl: imageUrl,
description: input.description,
productIds: input.productIds || [],
redirectUrl: input.redirectUrl,
serialNum: 999, // Default value, not used
isActive: false, // Default to inactive
}).returning();
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return banner;
} catch (error) {
console.error('Error creating banner:', error);
throw error; // Re-throw to maintain tRPC error handling
}
}),
// Update banner
updateBanner: protectedProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1).optional(),
imageUrl: z.string().url().optional(),
description: z.string().optional(),
productIds: z.array(z.number()).optional(),
redirectUrl: z.string().url().optional(),
serialNum: z.number().nullable().optional(),
isActive: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
try {
const { id, ...updateData } = input;
const incomingProductIds = input.productIds;
// Extract S3 key from presigned URL if imageUrl is provided
const processedData = {
...updateData,
...(updateData.imageUrl && {
imageUrl: extractKeyFromPresignedUrl(updateData.imageUrl)
}),
};
// Handle serialNum null case
const finalData: any = { ...processedData };
if ('serialNum' in finalData && finalData.serialNum === null) {
// Set to null explicitly
finalData.serialNum = null;
}
const [banner] = await db.update(homeBanners)
.set({ ...finalData, lastUpdated: new Date(), })
.where(eq(homeBanners.id, id))
.returning();
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return banner;
} catch (error) {
console.error('Error updating banner:', error);
throw error;
}
}),
// Delete banner
deleteBanner: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await db.delete(homeBanners).where(eq(homeBanners.id, input.id));
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return { success: true };
}),
});

View file

@ -1,179 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { orders, orderStatus, users, addresses, orderItems, productInfo, units, refunds } from '@/src/db/schema'
import { eq, desc } from 'drizzle-orm';
const updateCancellationReviewSchema = z.object({
orderId: z.number(),
cancellationReviewed: z.boolean(),
adminNotes: z.string().optional(),
});
const updateRefundSchema = z.object({
orderId: z.number(),
isRefundDone: z.boolean(),
});
export const cancelledOrdersRouter = router({
getAll: protectedProcedure
.query(async () => {
// First get cancelled order statuses with order details
const cancelledOrderStatuses = await db.query.orderStatus.findMany({
where: eq(orderStatus.isCancelled, true),
with: {
order: {
with: {
user: true,
address: true,
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
refunds: true,
},
},
},
orderBy: [desc(orderStatus.orderTime)],
});
const filteredStatuses = cancelledOrderStatuses.filter(status => {
return status.order.isCod || status.paymentStatus === 'success';
});
return filteredStatuses.map(status => {
const refund = status.order.refunds[0];
return {
id: status.order.id,
readableId: status.order.id,
customerName: `${status.order.user.name}`,
address: `${status.order.address.addressLine1}, ${status.order.address.city}`,
totalAmount: status.order.totalAmount,
cancellationReviewed: status.cancellationReviewed || false,
isRefundDone: refund?.refundStatus === 'processed' || false,
adminNotes: status.order.adminNotes,
cancelReason: status.cancelReason,
paymentMode: status.order.isCod ? 'COD' : 'Online',
paymentStatus: status.paymentStatus || 'pending',
items: status.order.orderItems.map(item => ({
name: item.product.name,
quantity: item.quantity,
price: item.price,
unit: item.product.unit?.shortNotation,
amount: parseFloat(item.price.toString()) * parseFloat(item.quantity || '0'),
})),
createdAt: status.order.createdAt,
};
});
}),
updateReview: protectedProcedure
.input(updateCancellationReviewSchema)
.mutation(async ({ input }) => {
const { orderId, cancellationReviewed, adminNotes } = input;
const result = await db.update(orderStatus)
.set({
cancellationReviewed,
cancellationAdminNotes: adminNotes || null,
cancellationReviewedAt: new Date(),
})
.where(eq(orderStatus.orderId, orderId))
.returning();
if (result.length === 0) {
throw new Error("Cancellation record not found");
}
return result[0];
}),
getById: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const { id } = input;
// Get cancelled order with full details
const cancelledOrderStatus = await db.query.orderStatus.findFirst({
where: eq(orderStatus.id, id),
with: {
order: {
with: {
user: true,
address: true,
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
},
},
},
});
if (!cancelledOrderStatus || !cancelledOrderStatus.isCancelled) {
throw new Error("Cancelled order not found");
}
// Get refund details separately
const refund = await db.query.refunds.findFirst({
where: eq(refunds.orderId, cancelledOrderStatus.orderId),
});
const order = cancelledOrderStatus.order;
// Format the response similar to the getAll method
const formattedOrder = {
id: order.id,
readableId: order.id,
customerName: order.user.name,
address: `${order.address.addressLine1}${order.address.addressLine2 ? ', ' + order.address.addressLine2 : ''}, ${order.address.city}, ${order.address.state} ${order.address.pincode}`,
totalAmount: order.totalAmount,
cancellationReviewed: cancelledOrderStatus.cancellationReviewed || false,
isRefundDone: refund?.refundStatus === 'processed' || false,
adminNotes: cancelledOrderStatus.cancellationAdminNotes || null,
cancelReason: cancelledOrderStatus.cancelReason || null,
items: order.orderItems.map((item: any) => ({
name: item.product.name,
quantity: item.quantity,
price: parseFloat(item.price.toString()),
unit: item.product.unit?.shortNotation || 'unit',
amount: parseFloat(item.price.toString()) * parseFloat(item.quantity),
image: item.product.images?.[0] || null,
})),
createdAt: order.createdAt.toISOString(),
};
return { order: formattedOrder };
}),
updateRefund: protectedProcedure
.input(updateRefundSchema)
.mutation(async ({ input }) => {
const { orderId, isRefundDone } = input;
const refundStatus = isRefundDone ? 'processed' : 'none';
const result = await db.update(refunds)
.set({
refundStatus,
refundProcessedAt: isRefundDone ? new Date() : null,
})
.where(eq(refunds.orderId, orderId))
.returning();
if (result.length === 0) {
throw new Error("Cancellation record not found");
}
return result[0];
}),
});

View file

@ -1,80 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { complaints, users } from '@/src/db/schema'
import { eq, desc, lt, and } from 'drizzle-orm';
import { scaffoldAssetUrl } from '@/src/lib/s3-client'
export const complaintRouter = router({
getAll: protectedProcedure
.input(z.object({
cursor: z.number().optional(),
limit: z.number().default(20),
}))
.query(async ({ input }) => {
const { cursor, limit } = input;
let whereCondition = cursor
? lt(complaints.id, cursor)
: undefined;
const complaintsData = await db
.select({
id: complaints.id,
complaintBody: complaints.complaintBody,
userId: complaints.userId,
orderId: complaints.orderId,
isResolved: complaints.isResolved,
createdAt: complaints.createdAt,
userName: users.name,
userMobile: users.mobile,
images: complaints.images,
})
.from(complaints)
.leftJoin(users, eq(complaints.userId, users.id))
.where(whereCondition)
.orderBy(desc(complaints.id))
.limit(limit + 1);
const hasMore = complaintsData.length > limit;
const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData;
const complaintsWithSignedImages = await Promise.all(
complaintsToReturn.map(async (c) => {
const signedImages = c.images
? scaffoldAssetUrl(c.images as string[])
: [];
return {
id: c.id,
text: c.complaintBody,
userId: c.userId,
userName: c.userName,
userMobile: c.userMobile,
orderId: c.orderId,
status: c.isResolved ? 'resolved' : 'pending',
createdAt: c.createdAt,
images: signedImages,
};
})
);
return {
complaints: complaintsWithSignedImages,
nextCursor: hasMore
? complaintsToReturn[complaintsToReturn.length - 1].id
: undefined,
};
}),
resolve: protectedProcedure
.input(z.object({ id: z.string(), response: z.string().optional() }))
.mutation(async ({ input }) => {
await db
.update(complaints)
.set({ isResolved: true, response: input.response })
.where(eq(complaints.id, parseInt(input.id)));
return { message: 'Complaint resolved successfully' };
}),
});

View file

@ -1,61 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { keyValStore } from '@/src/db/schema'
import { computeConstants } from '@/src/lib/const-store'
import { CONST_KEYS } from '@/src/lib/const-keys'
export const constRouter = router({
getConstants: protectedProcedure
.query(async () => {
const constants = await db.select().from(keyValStore);
const resp = constants.map(c => ({
key: c.key,
value: c.value,
}));
return resp;
}),
updateConstants: protectedProcedure
.input(z.object({
constants: z.array(z.object({
key: z.string(),
value: z.any(),
})),
}))
.mutation(async ({ input }) => {
const { constants } = input;
const validKeys = Object.values(CONST_KEYS) as string[];
const invalidKeys = constants
.filter(c => !validKeys.includes(c.key))
.map(c => c.key);
if (invalidKeys.length > 0) {
throw new Error(`Invalid constant keys: ${invalidKeys.join(', ')}`);
}
await db.transaction(async (tx) => {
for (const { key, value } of constants) {
await tx.insert(keyValStore)
.values({ key, value })
.onConflictDoUpdate({
target: keyValStore.key,
set: { value },
});
}
});
// Refresh all constants in Redis after database update
await computeConstants();
return {
success: true,
updatedCount: constants.length,
keys: constants.map(c => c.key),
};
}),
});

View file

@ -1,711 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { coupons, users, staffUsers, orders, couponApplicableUsers, couponApplicableProducts, orderStatus, reservedCoupons } from '@/src/db/schema'
import { eq, and, like, or, inArray, lt } from 'drizzle-orm';
import dayjs from 'dayjs';
const createCouponBodySchema = z.object({
couponCode: z.string().optional(),
isUserBased: z.boolean().optional(),
discountPercent: z.number().optional(),
flatDiscount: z.number().optional(),
minOrder: z.number().optional(),
targetUser: z.number().optional(),
productIds: z.array(z.number()).optional().nullable(),
applicableUsers: z.array(z.number()).optional(),
applicableProducts: z.array(z.number()).optional(),
maxValue: z.number().optional(),
isApplyForAll: z.boolean().optional(),
validTill: z.string().optional(),
maxLimitForUser: z.number().optional(),
exclusiveApply: z.boolean().optional(),
});
const validateCouponBodySchema = z.object({
code: z.string(),
userId: z.number(),
orderAmount: z.number(),
});
export const couponRouter = router({
create: protectedProcedure
.input(createCouponBodySchema)
.mutation(async ({ input, ctx }) => {
const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input;
// Validation: ensure at least one discount type is provided
if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) {
throw new Error("Either discountPercent or flatDiscount must be provided (but not both)");
}
// If user-based, applicableUsers is required (unless it's apply for all)
if (isUserBased && (!applicableUsers || applicableUsers.length === 0) && !isApplyForAll) {
throw new Error("applicableUsers is required for user-based coupons (or set isApplyForAll to true)");
}
// Cannot be both user-based and apply for all
if (isUserBased && isApplyForAll) {
throw new Error("Cannot be both user-based and apply for all users");
}
// If applicableUsers is provided, verify users exist
if (applicableUsers && applicableUsers.length > 0) {
const existingUsers = await db.query.users.findMany({
where: inArray(users.id, applicableUsers),
columns: { id: true },
});
if (existingUsers.length !== applicableUsers.length) {
throw new Error("Some applicable users not found");
}
}
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id;
if (!staffUserId) {
throw new Error("Unauthorized");
}
// Generate coupon code if not provided
let finalCouponCode = couponCode;
if (!finalCouponCode) {
// Generate a unique coupon code
const timestamp = Date.now().toString().slice(-6);
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
finalCouponCode = `MF${timestamp}${random}`;
}
// Check if coupon code already exists
const existingCoupon = await db.query.coupons.findFirst({
where: eq(coupons.couponCode, finalCouponCode),
});
if (existingCoupon) {
throw new Error("Coupon code already exists");
}
const result = await db.insert(coupons).values({
couponCode: finalCouponCode,
isUserBased: isUserBased || false,
discountPercent: discountPercent?.toString(),
flatDiscount: flatDiscount?.toString(),
minOrder: minOrder?.toString(),
productIds: productIds || null,
createdBy: staffUserId,
maxValue: maxValue?.toString(),
isApplyForAll: isApplyForAll || false,
validTill: validTill ? dayjs(validTill).toDate() : undefined,
maxLimitForUser: maxLimitForUser,
exclusiveApply: exclusiveApply || false,
}).returning();
const coupon = result[0];
// Insert applicable users
if (applicableUsers && applicableUsers.length > 0) {
await db.insert(couponApplicableUsers).values(
applicableUsers.map(userId => ({
couponId: coupon.id,
userId,
}))
);
}
// Insert applicable products
if (applicableProducts && applicableProducts.length > 0) {
await db.insert(couponApplicableProducts).values(
applicableProducts.map(productId => ({
couponId: coupon.id,
productId,
}))
);
}
return coupon;
}),
getAll: protectedProcedure
.input(z.object({
cursor: z.number().optional(),
limit: z.number().default(50),
search: z.string().optional(),
}))
.query(async ({ input }) => {
const { cursor, limit, search } = input;
let whereCondition = undefined;
const conditions = [];
if (cursor) {
conditions.push(lt(coupons.id, cursor));
}
if (search && search.trim()) {
conditions.push(like(coupons.couponCode, `%${search}%`));
}
if (conditions.length > 0) {
whereCondition = and(...conditions);
}
const result = await db.query.coupons.findMany({
where: whereCondition,
with: {
creator: true,
applicableUsers: {
with: {
user: true,
},
},
applicableProducts: {
with: {
product: true,
},
},
},
orderBy: (coupons, { desc }) => [desc(coupons.createdAt)],
limit: limit + 1,
});
const hasMore = result.length > limit;
const couponsList = hasMore ? result.slice(0, limit) : result;
const nextCursor = hasMore ? result[result.length - 1].id : undefined;
return { coupons: couponsList, nextCursor };
}),
getById: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const couponId = input.id;
const result = await db.query.coupons.findFirst({
where: eq(coupons.id, couponId),
with: {
creator: true,
applicableUsers: {
with: {
user: true,
},
},
applicableProducts: {
with: {
product: true,
},
},
},
});
if (!result) {
throw new Error("Coupon not found");
}
return {
...result,
productIds: (result.productIds as number[]) || undefined,
applicableUsers: result.applicableUsers.map(au => au.user),
applicableProducts: result.applicableProducts.map(ap => ap.product),
};
}),
update: protectedProcedure
.input(z.object({
id: z.number(),
updates: createCouponBodySchema.extend({
isInvalidated: z.boolean().optional(),
}),
}))
.mutation(async ({ input }) => {
const { id, updates } = input;
// Validation: ensure discount types are valid
if (updates.discountPercent !== undefined && updates.flatDiscount !== undefined) {
if (updates.discountPercent && updates.flatDiscount) {
throw new Error("Cannot have both discountPercent and flatDiscount");
}
}
// If updating to user-based, applicableUsers is required
if (updates.isUserBased && (!updates.applicableUsers || updates.applicableUsers.length === 0)) {
const existingCount = await db.$count(couponApplicableUsers, eq(couponApplicableUsers.couponId, id));
if (existingCount === 0) {
throw new Error("applicableUsers is required for user-based coupons");
}
}
// If applicableUsers is provided, verify users exist
if (updates.applicableUsers && updates.applicableUsers.length > 0) {
const existingUsers = await db.query.users.findMany({
where: inArray(users.id, updates.applicableUsers),
columns: { id: true },
});
if (existingUsers.length !== updates.applicableUsers.length) {
throw new Error("Some applicable users not found");
}
}
const updateData: any = { ...updates };
delete updateData.applicableUsers; // Remove since we use couponApplicableUsers table
if (updates.discountPercent !== undefined) {
updateData.discountPercent = updates.discountPercent?.toString();
}
if (updates.flatDiscount !== undefined) {
updateData.flatDiscount = updates.flatDiscount?.toString();
}
if (updates.minOrder !== undefined) {
updateData.minOrder = updates.minOrder?.toString();
}
if (updates.maxValue !== undefined) {
updateData.maxValue = updates.maxValue?.toString();
}
if (updates.validTill !== undefined) {
updateData.validTill = updates.validTill ? dayjs(updates.validTill).toDate() : null;
}
const result = await db.update(coupons)
.set(updateData)
.where(eq(coupons.id, id))
.returning();
if (result.length === 0) {
throw new Error("Coupon not found");
}
console.log('updated coupon successfully')
// Update applicable users: delete existing and insert new
if (updates.applicableUsers !== undefined) {
await db.delete(couponApplicableUsers).where(eq(couponApplicableUsers.couponId, id));
if (updates.applicableUsers.length > 0) {
await db.insert(couponApplicableUsers).values(
updates.applicableUsers.map(userId => ({
couponId: id,
userId,
}))
);
}
}
// Update applicable products: delete existing and insert new
if (updates.applicableProducts !== undefined) {
await db.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, id));
if (updates.applicableProducts.length > 0) {
await db.insert(couponApplicableProducts).values(
updates.applicableProducts.map(productId => ({
couponId: id,
productId,
}))
);
}
}
return result[0];
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { id } = input;
const result = await db.update(coupons)
.set({ isInvalidated: true })
.where(eq(coupons.id, id))
.returning();
if (result.length === 0) {
throw new Error("Coupon not found");
}
return { message: "Coupon invalidated successfully" };
}),
validate: protectedProcedure
.input(validateCouponBodySchema)
.query(async ({ input }) => {
const { code, userId, orderAmount } = input;
if (!code || typeof code !== 'string') {
return { valid: false, message: "Invalid coupon code" };
}
const coupon = await db.query.coupons.findFirst({
where: and(
eq(coupons.couponCode, code.toUpperCase()),
eq(coupons.isInvalidated, false)
),
});
if (!coupon) {
return { valid: false, message: "Coupon not found or invalidated" };
}
// Check expiry date
if (coupon.validTill && new Date(coupon.validTill) < new Date()) {
return { valid: false, message: "Coupon has expired" };
}
// Check if coupon applies to all users or specific user
if (!coupon.isApplyForAll && !coupon.isUserBased) {
return { valid: false, message: "Coupon is not available for use" };
}
// Check minimum order amount
const minOrderValue = coupon.minOrder ? parseFloat(coupon.minOrder) : 0;
if (minOrderValue > 0 && orderAmount < minOrderValue) {
return { valid: false, message: `Minimum order amount is ${minOrderValue}` };
}
// Calculate discount
let discountAmount = 0;
if (coupon.discountPercent) {
const percent = parseFloat(coupon.discountPercent);
discountAmount = (orderAmount * percent) / 100;
} else if (coupon.flatDiscount) {
discountAmount = parseFloat(coupon.flatDiscount);
}
// Apply max value limit
const maxValueLimit = coupon.maxValue ? parseFloat(coupon.maxValue) : 0;
if (maxValueLimit > 0 && discountAmount > maxValueLimit) {
discountAmount = maxValueLimit;
}
return {
valid: true,
discountAmount,
coupon: {
id: coupon.id,
discountPercent: coupon.discountPercent,
flatDiscount: coupon.flatDiscount,
maxValue: coupon.maxValue,
}
};
}),
generateCancellationCoupon: protectedProcedure
.input(
z.object({
orderId: z.number(),
})
)
.mutation(async ({ input, ctx }) => {
const { orderId } = input;
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id;
if (!staffUserId) {
throw new Error("Unauthorized");
}
// Find the order with user and order status information
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: {
user: true,
orderStatus: true,
},
});
if (!order) {
throw new Error("Order not found");
}
// Check if order is cancelled (check if any status entry has isCancelled: true)
// const isOrderCancelled = order.orderStatus?.some(status => status.isCancelled) || false;
// if (!isOrderCancelled) {
// throw new Error("Order is not cancelled");
// }
// // Check if payment method is COD
// if (order.isCod) {
// throw new Error("Can't generate refund coupon for CoD Order");
// }
// Verify user exists
if (!order.user) {
throw new Error("User not found for this order");
}
// Generate coupon code: first 3 letters of user name or mobile + orderId
const userNamePrefix = (order.user.name || order.user.mobile || 'USR').substring(0, 3).toUpperCase();
const couponCode = `${userNamePrefix}${orderId}`;
// Check if coupon code already exists
const existingCoupon = await db.query.coupons.findFirst({
where: eq(coupons.couponCode, couponCode),
});
if (existingCoupon) {
throw new Error("Coupon code already exists");
}
// Get order total amount
const orderAmount = parseFloat(order.totalAmount);
// Calculate expiry date (30 days from now)
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + 30);
// Create the coupon and update order status in a transaction
const coupon = await db.transaction(async (tx) => {
// Create the coupon
const result = await tx.insert(coupons).values({
couponCode,
isUserBased: true,
flatDiscount: orderAmount.toString(),
minOrder: orderAmount.toString(),
maxValue: orderAmount.toString(),
validTill: expiryDate,
maxLimitForUser: 1,
createdBy: staffUserId,
isApplyForAll: false,
}).returning();
const coupon = result[0];
// Insert applicable users
await tx.insert(couponApplicableUsers).values({
couponId: coupon.id,
userId: order.userId,
});
// Update order_status with refund coupon ID
await tx.update(orderStatus)
.set({ refundCouponId: coupon.id })
.where(eq(orderStatus.orderId, orderId));
return coupon;
});
return coupon;
}),
getReservedCoupons: protectedProcedure
.input(z.object({
cursor: z.number().optional(),
limit: z.number().default(50),
search: z.string().optional(),
}))
.query(async ({ input }) => {
const { cursor, limit, search } = input;
let whereCondition = undefined;
const conditions = [];
if (cursor) {
conditions.push(lt(reservedCoupons.id, cursor));
}
if (search && search.trim()) {
conditions.push(or(
like(reservedCoupons.secretCode, `%${search}%`),
like(reservedCoupons.couponCode, `%${search}%`)
));
}
if (conditions.length > 0) {
whereCondition = and(...conditions);
}
const result = await db.query.reservedCoupons.findMany({
where: whereCondition,
with: {
redeemedUser: true,
creator: true,
},
orderBy: (reservedCoupons, { desc }) => [desc(reservedCoupons.createdAt)],
limit: limit + 1, // Fetch one extra to check if there's more
});
const hasMore = result.length > limit;
const coupons = hasMore ? result.slice(0, limit) : result;
const nextCursor = hasMore ? result[result.length - 1].id : undefined;
return {
coupons,
nextCursor,
};
}),
createReservedCoupon: protectedProcedure
.input(createCouponBodySchema)
.mutation(async ({ input, ctx }) => {
const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input;
// Validation: ensure at least one discount type is provided
if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) {
throw new Error("Either discountPercent or flatDiscount must be provided (but not both)");
}
// For reserved coupons, applicableUsers is not used, as it's redeemed by one user
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id;
if (!staffUserId) {
throw new Error("Unauthorized");
}
// Generate secret code if not provided (use couponCode as base)
let secretCode = couponCode || `SECRET${Date.now().toString().slice(-6)}${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
// Check if secret code already exists
const existing = await db.query.reservedCoupons.findFirst({
where: eq(reservedCoupons.secretCode, secretCode),
});
if (existing) {
throw new Error("Secret code already exists");
}
const result = await db.insert(reservedCoupons).values({
secretCode,
couponCode: couponCode || `RESERVED${Date.now().toString().slice(-6)}`,
discountPercent: discountPercent?.toString(),
flatDiscount: flatDiscount?.toString(),
minOrder: minOrder?.toString(),
productIds,
maxValue: maxValue?.toString(),
validTill: validTill ? dayjs(validTill).toDate() : undefined,
maxLimitForUser,
exclusiveApply: exclusiveApply || false,
createdBy: staffUserId,
}).returning();
const coupon = result[0];
// Insert applicable products if provided
if (applicableProducts && applicableProducts.length > 0) {
await db.insert(couponApplicableProducts).values(
applicableProducts.map(productId => ({
couponId: coupon.id,
productId,
}))
);
}
return coupon;
}),
getUsersMiniInfo: protectedProcedure
.input(z.object({
search: z.string().optional(),
limit: z.number().min(1).max(50).default(20),
offset: z.number().min(0).default(0),
}))
.query(async ({ input }) => {
const { search, limit } = input;
let whereCondition = undefined;
if (search && search.trim()) {
whereCondition = or(
like(users.name, `%${search}%`),
like(users.mobile, `%${search}%`)
);
}
const userList = await db.query.users.findMany({
where: whereCondition,
columns: {
id: true,
name: true,
mobile: true,
},
limit: limit,
offset: input.offset,
orderBy: (users, { asc }) => [asc(users.name)],
});
return {
users: userList.map(user => ({
id: user.id,
name: user.name || 'Unknown',
mobile: user.mobile,
}))
};
}),
createCoupon: protectedProcedure
.input(z.object({
mobile: z.string().min(1, 'Mobile number is required'),
}))
.mutation(async ({ input, ctx }) => {
const { mobile } = input;
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id;
if (!staffUserId) {
throw new Error("Unauthorized");
}
// Clean mobile number (remove non-digits)
const cleanMobile = mobile.replace(/\D/g, '');
// Validate: exactly 10 digits
if (cleanMobile.length !== 10) {
throw new Error("Mobile number must be exactly 10 digits");
}
// Check if user exists, create if not
let user = await db.query.users.findFirst({
where: eq(users.mobile, cleanMobile),
});
if (!user) {
// Create new user
const [newUser] = await db.insert(users).values({
name: null,
email: null,
mobile: cleanMobile,
}).returning();
user = newUser;
}
// Generate unique coupon code
const timestamp = Date.now().toString().slice(-6);
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
const couponCode = `MF${cleanMobile.slice(-4)}${timestamp}${random}`;
// Check if coupon code already exists (very unlikely but safe)
const existingCode = await db.query.coupons.findFirst({
where: eq(coupons.couponCode, couponCode),
});
if (existingCode) {
throw new Error("Generated coupon code already exists - please try again");
}
// Create the coupon
const [coupon] = await db.insert(coupons).values({
couponCode,
isUserBased: true,
discountPercent: "20", // 20% discount
minOrder: "1000", // ₹1000 minimum order
maxValue: "500", // ₹500 maximum discount
maxLimitForUser: 1, // One-time use
isApplyForAll: false,
exclusiveApply: false,
createdBy: staffUserId,
validTill: dayjs().add(90, 'days').toDate(), // 90 days from now
}).returning();
// Associate coupon with user
await db.insert(couponApplicableUsers).values({
couponId: coupon.id,
userId: user.id,
});
return {
success: true,
coupon: {
id: coupon.id,
couponCode: coupon.couponCode,
userId: user.id,
userMobile: user.mobile,
discountPercent: 20,
minOrder: 1000,
maxValue: 500,
maxLimitForUser: 1,
},
};
}),
});

File diff suppressed because it is too large Load diff

View file

@ -1,146 +0,0 @@
import { router, protectedProcedure } from "@/src/trpc/trpc-index"
import { z } from "zod";
import { db } from "@/src/db/db_index"
import {
orders,
orderStatus,
payments,
refunds,
} from "@/src/db/schema";
import { and, eq } from "drizzle-orm";
import { ApiError } from "@/src/lib/api-error"
import { RazorpayPaymentService } from "@/src/lib/payments-utils"
const initiateRefundSchema = z
.object({
orderId: z.number(),
refundPercent: z.number().min(0).max(100).optional(),
refundAmount: z.number().min(0).optional(),
})
.refine(
(data) => {
const hasPercent = data.refundPercent !== undefined;
const hasAmount = data.refundAmount !== undefined;
return (hasPercent && !hasAmount) || (!hasPercent && hasAmount);
},
{
message:
"Provide either refundPercent or refundAmount, not both or neither",
}
);
export const adminPaymentsRouter = router({
initiateRefund: protectedProcedure
.input(initiateRefundSchema)
.mutation(async ({ input }) => {
try {
const { orderId, refundPercent, refundAmount } = input;
// Validate order exists
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
});
if (!order) {
throw new ApiError("Order not found", 404);
}
// Check if order is paid
const orderStatusRecord = await db.query.orderStatus.findFirst({
where: eq(orderStatus.orderId, orderId),
});
if(order.isCod) {
throw new ApiError("Order is a Cash On Delivery. Not eligible for refund")
}
if (
!orderStatusRecord ||
(orderStatusRecord.paymentStatus !== "success" &&
!(order.isCod && orderStatusRecord.isDelivered))
) {
throw new ApiError("Order payment not verified or not eligible for refund", 400);
}
// Calculate refund amount
let calculatedRefundAmount: number;
if (refundPercent !== undefined) {
calculatedRefundAmount =
(parseFloat(order.totalAmount) * refundPercent) / 100;
} else if (refundAmount !== undefined) {
calculatedRefundAmount = refundAmount;
if (calculatedRefundAmount > parseFloat(order.totalAmount)) {
throw new ApiError("Refund amount cannot exceed order total", 400);
}
} else {
throw new ApiError("Invalid refund parameters", 400);
}
let razorpayRefund = null;
let merchantRefundId = null;
// Get payment record for online payments
const payment = await db.query.payments.findFirst({
where: and(
eq(payments.orderId, orderId),
eq(payments.status, "success")
),
});
if (!payment || payment.status !== "success") {
throw new ApiError("Payment not found or not successful", 404);
}
const payload = payment.payload as any;
// Initiate Razorpay refund
razorpayRefund = await RazorpayPaymentService.initiateRefund(
payload.payment_id,
Math.round(calculatedRefundAmount * 100) // Convert to paisa
);
merchantRefundId = razorpayRefund.id;
// Check if refund already exists for this order
const existingRefund = await db.query.refunds.findFirst({
where: eq(refunds.orderId, orderId),
});
const refundStatus = "initiated";
if (existingRefund) {
// Update existing refund
await db
.update(refunds)
.set({
refundAmount: calculatedRefundAmount.toString(),
refundStatus,
merchantRefundId,
refundProcessedAt: order.isCod ? new Date() : null,
})
.where(eq(refunds.id, existingRefund.id));
} else {
// Insert new refund
await db
.insert(refunds)
.values({
orderId,
refundAmount: calculatedRefundAmount.toString(),
refundStatus,
merchantRefundId,
});
}
return {
refundId: merchantRefundId || `cod_${orderId}`,
amount: calculatedRefundAmount,
status: refundStatus,
message: order.isCod ? "COD refund processed successfully" : "Refund initiated successfully",
};
}
catch(e) {
console.log(e);
throw new ApiError("Failed to initiate refund")
}
}),
});

View file

@ -1,154 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { productAvailabilitySchedules } from '@/src/db/schema'
import { eq } from 'drizzle-orm';
import { refreshScheduleJobs } from '@/src/lib/automatedJobs';
const createScheduleSchema = z.object({
scheduleName: z.string().min(1, "Schedule name is required"),
time: z.string().min(1, "Time is required").regex(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, "Invalid time format. Use HH:MM"),
action: z.enum(['in', 'out']),
productIds: z.array(z.number().int().positive()).min(1, "At least one product is required"),
groupIds: z.array(z.number().int().positive()).default([]),
});
const updateScheduleSchema = z.object({
id: z.number().int().positive(),
updates: createScheduleSchema.partial().extend({
scheduleName: z.string().min(1).optional(),
productIds: z.array(z.number().int().positive()).optional(),
groupIds: z.array(z.number().int().positive()).optional(),
}),
});
export const productAvailabilitySchedulesRouter = router({
create: protectedProcedure
.input(createScheduleSchema)
.mutation(async ({ input, ctx }) => {
const { scheduleName, time, action, productIds, groupIds } = input;
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id;
if (!staffUserId) {
throw new Error("Unauthorized");
}
// Check if schedule name already exists
const existingSchedule = await db.query.productAvailabilitySchedules.findFirst({
where: eq(productAvailabilitySchedules.scheduleName, scheduleName),
});
if (existingSchedule) {
throw new Error("Schedule name already exists");
}
// Create schedule with arrays
const scheduleResult = await db.insert(productAvailabilitySchedules).values({
scheduleName,
time,
action,
productIds,
groupIds,
}).returning();
// Refresh cron jobs to include new schedule
await refreshScheduleJobs();
return scheduleResult[0];
}),
getAll: protectedProcedure
.query(async () => {
const schedules = await db.query.productAvailabilitySchedules.findMany({
orderBy: (productAvailabilitySchedules, { desc }) => [desc(productAvailabilitySchedules.createdAt)],
});
return schedules.map(schedule => ({
...schedule,
productCount: schedule.productIds.length,
groupCount: schedule.groupIds.length,
}));
}),
getById: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.query(async ({ input }) => {
const { id } = input;
const schedule = await db.query.productAvailabilitySchedules.findFirst({
where: eq(productAvailabilitySchedules.id, id),
});
if (!schedule) {
throw new Error("Schedule not found");
}
return schedule;
}),
update: protectedProcedure
.input(updateScheduleSchema)
.mutation(async ({ input }) => {
const { id, updates } = input;
// Check if schedule exists
const existingSchedule = await db.query.productAvailabilitySchedules.findFirst({
where: eq(productAvailabilitySchedules.id, id),
});
if (!existingSchedule) {
throw new Error("Schedule not found");
}
// Check schedule name uniqueness if being updated
if (updates.scheduleName && updates.scheduleName !== existingSchedule.scheduleName) {
const duplicateSchedule = await db.query.productAvailabilitySchedules.findFirst({
where: eq(productAvailabilitySchedules.scheduleName, updates.scheduleName),
});
if (duplicateSchedule) {
throw new Error("Schedule name already exists");
}
}
// Update schedule
const updateData: any = {};
if (updates.scheduleName !== undefined) updateData.scheduleName = updates.scheduleName;
if (updates.time !== undefined) updateData.time = updates.time;
if (updates.action !== undefined) updateData.action = updates.action;
if (updates.productIds !== undefined) updateData.productIds = updates.productIds;
if (updates.groupIds !== undefined) updateData.groupIds = updates.groupIds;
updateData.lastUpdated = new Date();
const result = await db.update(productAvailabilitySchedules)
.set(updateData)
.where(eq(productAvailabilitySchedules.id, id))
.returning();
if (result.length === 0) {
throw new Error("Failed to update schedule");
}
// Refresh cron jobs to reflect changes
await refreshScheduleJobs();
return result[0];
}),
delete: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input }) => {
const { id } = input;
const result = await db.delete(productAvailabilitySchedules)
.where(eq(productAvailabilitySchedules.id, id))
.returning();
if (result.length === 0) {
throw new Error("Schedule not found");
}
// Refresh cron jobs to remove deleted schedule
await refreshScheduleJobs();
return { message: "Schedule deleted successfully" };
}),
});

View file

@ -1,758 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { productInfo, units, specialDeals, productSlots, productTags, productReviews, users, productGroupInfo, productGroupMembership } from '@/src/db/schema'
import { eq, and, inArray, desc, sql } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error'
import { imageUploadS3, scaffoldAssetUrl, getOriginalUrlFromSignedUrl, claimUploadUrl } from '@/src/lib/s3-client'
import { deleteS3Image } from '@/src/lib/delete-image'
import type { SpecialDeal } from '@/src/db/types'
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
type CreateDeal = {
quantity: number;
price: number;
validTill: string;
};
export const productRouter = router({
getProducts: protectedProcedure
.query(async ({ ctx }) => {
const products = await db.query.productInfo.findMany({
orderBy: productInfo.name,
with: {
unit: true,
store: true,
},
});
// Generate signed URLs for all product images
const productsWithSignedUrls = await Promise.all(
products.map(async (product) => ({
...product,
images: scaffoldAssetUrl((product.images as string[]) || []),
}))
);
return {
products: productsWithSignedUrls,
count: productsWithSignedUrls.length,
};
}),
getProductById: protectedProcedure
.input(z.object({
id: z.number(),
}))
.query(async ({ input, ctx }) => {
const { id } = input;
const product = await db.query.productInfo.findFirst({
where: eq(productInfo.id, id),
with: {
unit: true,
},
});
if (!product) {
throw new ApiError("Product not found", 404);
}
// Fetch special deals for this product
const deals = await db.query.specialDeals.findMany({
where: eq(specialDeals.productId, id),
orderBy: specialDeals.quantity,
});
// Fetch associated tags for this product
const productTagsData = await db.query.productTags.findMany({
where: eq(productTags.productId, id),
with: {
tag: true,
},
});
// Generate signed URLs for product images
const productWithSignedUrls = {
...product,
images: scaffoldAssetUrl((product.images as string[]) || []),
deals,
tags: productTagsData.map(pt => pt.tag),
};
return {
product: productWithSignedUrls,
};
}),
deleteProduct: protectedProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { id } = input;
const [deletedProduct] = await db
.delete(productInfo)
.where(eq(productInfo.id, id))
.returning();
if (!deletedProduct) {
throw new ApiError("Product not found", 404);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: "Product deleted successfully",
};
}),
createProduct: protectedProcedure
.input(z.object({
name: z.string().min(1),
shortDescription: z.string().optional(),
longDescription: z.string().optional(),
unitId: z.number(),
storeId: z.number(),
price: z.number(),
marketPrice: z.number().optional(),
incrementStep: z.number().default(1),
productQuantity: z.number().default(1),
isSuspended: z.boolean().default(false),
isFlashAvailable: z.boolean().default(false),
flashPrice: z.number().optional(),
deals: z.array(z.object({
quantity: z.number(),
price: z.number(),
validTill: z.string(),
})).optional(),
tagIds: z.array(z.number()).optional(),
imageKeys: z.array(z.string()).optional(),
}))
.mutation(async ({ input }) => {
const {
name, shortDescription, longDescription, unitId, storeId,
price, marketPrice, incrementStep, productQuantity,
isSuspended, isFlashAvailable, flashPrice,
deals, tagIds, imageKeys
} = input;
// Validation
if (!name || !unitId || !storeId || !price) {
throw new ApiError("Name, unitId, storeId, and price are required", 400);
}
// Check for duplicate name
const existingProduct = await db.query.productInfo.findFirst({
where: eq(productInfo.name, name.trim()),
});
if (existingProduct) {
throw new ApiError("A product with this name already exists", 400);
}
// Check if unit exists
const unit = await db.query.units.findFirst({
where: eq(units.id, unitId),
});
if (!unit) {
throw new ApiError("Invalid unit ID", 400);
}
console.log(imageKeys)
const [newProduct] = await db
.insert(productInfo)
.values({
name: name.trim(),
shortDescription,
longDescription,
unitId,
storeId,
price: price.toString(),
marketPrice: marketPrice?.toString(),
incrementStep,
productQuantity,
isSuspended,
isFlashAvailable,
flashPrice: flashPrice?.toString(),
images: imageKeys || [],
})
.returning();
// Handle deals
if (deals && deals.length > 0) {
const dealInserts = deals.map(deal => ({
productId: newProduct.id,
quantity: deal.quantity.toString(),
price: deal.price.toString(),
validTill: new Date(deal.validTill),
}));
await db.insert(specialDeals).values(dealInserts);
}
// Handle tags
if (tagIds && tagIds.length > 0) {
const tagAssociations = tagIds.map(tagId => ({
productId: newProduct.id,
tagId,
}));
await db.insert(productTags).values(tagAssociations);
}
// Claim upload URLs
if (imageKeys && imageKeys.length > 0) {
for (const key of imageKeys) {
try {
await claimUploadUrl(key);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${key}`, e);
}
}
}
scheduleStoreInitialization();
return {
product: newProduct,
message: "Product created successfully",
};
}),
updateProduct: protectedProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1).optional(),
shortDescription: z.string().optional(),
longDescription: z.string().optional(),
unitId: z.number().optional(),
storeId: z.number().optional(),
price: z.number().optional(),
marketPrice: z.number().optional(),
incrementStep: z.number().optional(),
productQuantity: z.number().optional(),
isSuspended: z.boolean().optional(),
isFlashAvailable: z.boolean().optional(),
flashPrice: z.number().optional(),
deals: z.array(z.object({
quantity: z.number(),
price: z.number(),
validTill: z.string(),
})).optional(),
tagIds: z.array(z.number()).optional(),
newImageKeys: z.array(z.string()).optional(),
imagesToDelete: z.array(z.string()).optional(),
}))
.mutation(async ({ input }) => {
const { id, newImageKeys, imagesToDelete, deals, tagIds, ...updateData } = input;
// Get current product
const currentProduct = await db.query.productInfo.findFirst({
where: eq(productInfo.id, id),
});
if (!currentProduct) {
throw new ApiError("Product not found", 404);
}
// Handle image deletions
let currentImages = (currentProduct.images as string[]) || [];
if (imagesToDelete && imagesToDelete.length > 0) {
for (const imageUrl of imagesToDelete) {
try {
await deleteS3Image(imageUrl);
} catch (e) {
console.error(`Failed to delete image: ${imageUrl}`, e);
}
}
currentImages = currentImages.filter(img => {
//!imagesToDelete.includes(img)
const isRemoved = imagesToDelete.some(item => item.includes(img));
return !isRemoved;
});
}
// Add new images
if (newImageKeys && newImageKeys.length > 0) {
currentImages = [...currentImages, ...newImageKeys];
for (const key of newImageKeys) {
try {
await claimUploadUrl(key);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${key}`, e);
}
}
}
// Update product - convert numeric fields to strings for PostgreSQL numeric type
const { price, marketPrice, flashPrice, ...otherData } = updateData;
const [updatedProduct] = await db
.update(productInfo)
.set({
...otherData,
...(price !== undefined && { price: price.toString() }),
...(marketPrice !== undefined && { marketPrice: marketPrice.toString() }),
...(flashPrice !== undefined && { flashPrice: flashPrice.toString() }),
images: currentImages,
})
.where(eq(productInfo.id, id))
.returning();
// Handle deals update
if (deals !== undefined) {
await db.delete(specialDeals).where(eq(specialDeals.productId, id));
if (deals.length > 0) {
const dealInserts = deals.map(deal => ({
productId: id,
quantity: deal.quantity.toString(),
price: deal.price.toString(),
validTill: new Date(deal.validTill),
}));
await db.insert(specialDeals).values(dealInserts);
}
}
// Handle tags update
if (tagIds !== undefined) {
await db.delete(productTags).where(eq(productTags.productId, id));
if (tagIds.length > 0) {
const tagAssociations = tagIds.map(tagId => ({
productId: id,
tagId,
}));
await db.insert(productTags).values(tagAssociations);
}
}
scheduleStoreInitialization();
return {
product: updatedProduct,
message: "Product updated successfully",
};
}),
toggleOutOfStock: protectedProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { id } = input;
const product = await db.query.productInfo.findFirst({
where: eq(productInfo.id, id),
});
if (!product) {
throw new ApiError("Product not found", 404);
}
const [updatedProduct] = await db
.update(productInfo)
.set({
isOutOfStock: !product.isOutOfStock,
})
.where(eq(productInfo.id, id))
.returning();
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
product: updatedProduct,
message: `Product marked as ${updatedProduct.isOutOfStock ? 'out of stock' : 'in stock'}`,
};
}),
updateSlotProducts: protectedProcedure
.input(z.object({
slotId: z.string(),
productIds: z.array(z.string()),
}))
.mutation(async ({ input, ctx }) => {
const { slotId, productIds } = input;
if (!Array.isArray(productIds)) {
throw new ApiError("productIds must be an array", 400);
}
// Get current associations
const currentAssociations = await db.query.productSlots.findMany({
where: eq(productSlots.slotId, parseInt(slotId)),
columns: {
productId: true,
},
});
const currentProductIds = currentAssociations.map(assoc => assoc.productId);
const newProductIds = productIds.map((id: string) => parseInt(id));
// Find products to add and remove
const productsToAdd = newProductIds.filter(id => !currentProductIds.includes(id));
const productsToRemove = currentProductIds.filter(id => !newProductIds.includes(id));
// Remove associations for products that are no longer selected
if (productsToRemove.length > 0) {
await db.delete(productSlots).where(
and(
eq(productSlots.slotId, parseInt(slotId)),
inArray(productSlots.productId, productsToRemove)
)
);
}
// Add associations for newly selected products
if (productsToAdd.length > 0) {
const newAssociations = productsToAdd.map(productId => ({
productId,
slotId: parseInt(slotId),
}));
await db.insert(productSlots).values(newAssociations);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: "Slot products updated successfully",
added: productsToAdd.length,
removed: productsToRemove.length,
};
}),
getSlotProductIds: protectedProcedure
.input(z.object({
slotId: z.string(),
}))
.query(async ({ input, ctx }) => {
const { slotId } = input;
const associations = await db.query.productSlots.findMany({
where: eq(productSlots.slotId, parseInt(slotId)),
columns: {
productId: true,
},
});
const productIds = associations.map(assoc => assoc.productId);
return {
productIds,
};
}),
getSlotsProductIds: protectedProcedure
.input(z.object({
slotIds: z.array(z.number()),
}))
.query(async ({ input, ctx }) => {
const { slotIds } = input;
if (!Array.isArray(slotIds)) {
throw new ApiError("slotIds must be an array", 400);
}
if (slotIds.length === 0) {
return {};
}
// Fetch all associations for the requested slots
const associations = await db.query.productSlots.findMany({
where: inArray(productSlots.slotId, slotIds),
columns: {
slotId: true,
productId: true,
},
});
// Group by slotId
const result = associations.reduce((acc, assoc) => {
if (!acc[assoc.slotId]) {
acc[assoc.slotId] = [];
}
acc[assoc.slotId].push(assoc.productId);
return acc;
}, {} as Record<number, number[]>);
// Ensure all requested slots have entries (even if empty)
slotIds.forEach(slotId => {
if (!result[slotId]) {
result[slotId] = [];
}
});
return result;
}),
getProductReviews: protectedProcedure
.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,
adminResponse: productReviews.adminResponse,
adminResponseImages: productReviews.adminResponseImages,
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[]) || []),
signedAdminImageUrls: scaffoldAssetUrl((review.adminResponseImages 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 };
}),
respondToReview: protectedProcedure
.input(z.object({
reviewId: z.number().int().positive(),
adminResponse: z.string().optional(),
adminResponseImages: z.array(z.string()).optional().default([]),
uploadUrls: z.array(z.string()).optional().default([]),
}))
.mutation(async ({ input }) => {
const { reviewId, adminResponse, adminResponseImages, uploadUrls } = input;
const [updatedReview] = await db
.update(productReviews)
.set({
adminResponse,
adminResponseImages,
})
.where(eq(productReviews.id, reviewId))
.returning();
if (!updatedReview) {
throw new ApiError('Review not found', 404);
}
// Claim upload URLs
if (uploadUrls && uploadUrls.length > 0) {
// const { claimUploadUrl } = await import('@/src/lib/s3-client');
await Promise.all(uploadUrls.map(url => claimUploadUrl(url)));
}
return { success: true, review: updatedReview };
}),
getGroups: protectedProcedure
.query(async ({ ctx }) => {
const groups = await db.query.productGroupInfo.findMany({
with: {
memberships: {
with: {
product: true,
},
},
},
orderBy: desc(productGroupInfo.createdAt),
});
return {
groups: groups.map(group => ({
...group,
products: group.memberships.map(m => m.product),
productCount: group.memberships.length,
})),
};
}),
createGroup: protectedProcedure
.input(z.object({
group_name: z.string().min(1),
description: z.string().optional(),
product_ids: z.array(z.number()).default([]),
}))
.mutation(async ({ input, ctx }) => {
const { group_name, description, product_ids } = input;
const [newGroup] = await db
.insert(productGroupInfo)
.values({
groupName: group_name,
description,
})
.returning();
if (product_ids.length > 0) {
const memberships = product_ids.map(productId => ({
productId,
groupId: newGroup.id,
}));
await db.insert(productGroupMembership).values(memberships);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
group: newGroup,
message: 'Group created successfully',
};
}),
updateGroup: protectedProcedure
.input(z.object({
id: z.number(),
group_name: z.string().optional(),
description: z.string().optional(),
product_ids: z.array(z.number()).optional(),
}))
.mutation(async ({ input, ctx }) => {
const { id, group_name, description, product_ids } = input;
const updateData: any = {};
if (group_name !== undefined) updateData.groupName = group_name;
if (description !== undefined) updateData.description = description;
const [updatedGroup] = await db
.update(productGroupInfo)
.set(updateData)
.where(eq(productGroupInfo.id, id))
.returning();
if (!updatedGroup) {
throw new ApiError('Group not found', 404);
}
if (product_ids !== undefined) {
// Delete existing memberships
await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id));
// Insert new memberships
if (product_ids.length > 0) {
const memberships = product_ids.map(productId => ({
productId,
groupId: id,
}));
await db.insert(productGroupMembership).values(memberships);
}
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
group: updatedGroup,
message: 'Group updated successfully',
};
}),
deleteGroup: protectedProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { id } = input;
// Delete memberships first
await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id));
// Delete group
const [deletedGroup] = await db
.delete(productGroupInfo)
.where(eq(productGroupInfo.id, id))
.returning();
if (!deletedGroup) {
throw new ApiError('Group not found', 404);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: 'Group deleted successfully',
};
}),
updateProductPrices: protectedProcedure
.input(z.object({
updates: z.array(z.object({
productId: z.number(),
price: z.number().optional(),
marketPrice: z.number().nullable().optional(),
flashPrice: z.number().nullable().optional(),
isFlashAvailable: z.boolean().optional(),
})),
}))
.mutation(async ({ input, ctx }) => {
const { updates } = input;
if (updates.length === 0) {
throw new ApiError('No updates provided', 400);
}
// Validate that all productIds exist
const productIds = updates.map(u => u.productId);
const existingProducts = await db.query.productInfo.findMany({
where: inArray(productInfo.id, productIds),
columns: { id: true },
});
const existingIds = new Set(existingProducts.map(p => p.id));
const invalidIds = productIds.filter(id => !existingIds.has(id));
if (invalidIds.length > 0) {
throw new ApiError(`Invalid product IDs: ${invalidIds.join(', ')}`, 400);
}
// Perform batch update
const updatePromises = updates.map(async (update) => {
const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update;
const updateData: any = {};
if (price !== undefined) updateData.price = price;
if (marketPrice !== undefined) updateData.marketPrice = marketPrice;
if (flashPrice !== undefined) updateData.flashPrice = flashPrice;
if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable;
return db
.update(productInfo)
.set(updateData)
.where(eq(productInfo.id, productId));
});
await Promise.all(updatePromises);
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: `Updated prices for ${updates.length} product(s)`,
updatedCount: updates.length,
};
}),
});

View file

@ -1,610 +0,0 @@
import { router, protectedProcedure } from "@/src/trpc/trpc-index"
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { db } from "@/src/db/db_index"
import { deliverySlotInfo, productSlots, productInfo, vendorSnippets, productGroupInfo } from "@/src/db/schema"
import { eq, inArray, and, desc } from "drizzle-orm";
import { ApiError } from "@/src/lib/api-error"
import { appUrl } from "@/src/lib/env-exporter"
import redisClient from "@/src/lib/redis-client"
import { getSlotSequenceKey } from "@/src/lib/redisKeyGetters"
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
interface CachedDeliverySequence {
[userId: string]: number[];
}
const cachedSequenceSchema = z.record(z.string(), z.array(z.number()));
const createSlotSchema = z.object({
deliveryTime: z.string(),
freezeTime: z.string(),
isActive: z.boolean().optional(),
productIds: z.array(z.number()).optional(),
vendorSnippets: z.array(z.object({
name: z.string().min(1),
productIds: z.array(z.number().int().positive()).min(1),
validTill: z.string().optional(),
})).optional(),
groupIds: z.array(z.number()).optional(),
});
const getSlotByIdSchema = z.object({
id: z.number(),
});
const updateSlotSchema = z.object({
id: z.number(),
deliveryTime: z.string(),
freezeTime: z.string(),
isActive: z.boolean().optional(),
productIds: z.array(z.number()).optional(),
vendorSnippets: z.array(z.object({
name: z.string().min(1),
productIds: z.array(z.number().int().positive()).min(1),
validTill: z.string().optional(),
})).optional(),
groupIds: z.array(z.number()).optional(),
});
const deleteSlotSchema = z.object({
id: z.number(),
});
const getDeliverySequenceSchema = z.object({
id: z.string(),
});
const updateDeliverySequenceSchema = z.object({
id: z.number(),
// deliverySequence: z.array(z.number()),
deliverySequence: z.any(),
});
export const slotsRouter = router({
// Exact replica of GET /av/slots
getAll: protectedProcedure.query(async ({ ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const slots = await db.query.deliverySlotInfo
.findMany({
where: eq(deliverySlotInfo.isActive, true),
orderBy: desc(deliverySlotInfo.deliveryTime),
with: {
productSlots: {
with: {
product: {
columns: {
id: true,
name: true,
images: true,
},
},
},
},
},
})
.then((slots) =>
slots.map((slot) => ({
...slot,
deliverySequence: slot.deliverySequence as number[],
products: slot.productSlots.map((ps) => ps.product),
}))
);
return {
slots,
count: slots.length,
};
}),
// Exact replica of POST /av/products/slots/product-ids
getSlotsProductIds: protectedProcedure
.input(z.object({ slotIds: z.array(z.number()) }))
.query(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const { slotIds } = input;
if (!Array.isArray(slotIds)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "slotIds must be an array",
});
}
if (slotIds.length === 0) {
return {};
}
// Fetch all associations for the requested slots
const associations = await db.query.productSlots.findMany({
where: inArray(productSlots.slotId, slotIds),
columns: {
slotId: true,
productId: true,
},
});
// Group by slotId
const result = associations.reduce((acc, assoc) => {
if (!acc[assoc.slotId]) {
acc[assoc.slotId] = [];
}
acc[assoc.slotId].push(assoc.productId);
return acc;
}, {} as Record<number, number[]>);
// Ensure all requested slots have entries (even if empty)
slotIds.forEach((slotId) => {
if (!result[slotId]) {
result[slotId] = [];
}
});
return result;
}),
// Exact replica of PUT /av/products/slots/:slotId/products
updateSlotProducts: protectedProcedure
.input(
z.object({
slotId: z.number(),
productIds: z.array(z.number()),
})
)
.mutation(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const { slotId, productIds } = input;
if (!Array.isArray(productIds)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "productIds must be an array",
});
}
// Get current associations
const currentAssociations = await db.query.productSlots.findMany({
where: eq(productSlots.slotId, slotId),
columns: {
productId: true,
},
});
const currentProductIds = currentAssociations.map(
(assoc) => assoc.productId
);
const newProductIds = productIds;
// Find products to add and remove
const productsToAdd = newProductIds.filter(
(id) => !currentProductIds.includes(id)
);
const productsToRemove = currentProductIds.filter(
(id) => !newProductIds.includes(id)
);
// Remove associations for products that are no longer selected
if (productsToRemove.length > 0) {
await db
.delete(productSlots)
.where(
and(
eq(productSlots.slotId, slotId),
inArray(productSlots.productId, productsToRemove)
)
);
}
// Add associations for newly selected products
if (productsToAdd.length > 0) {
const newAssociations = productsToAdd.map((productId) => ({
productId,
slotId,
}));
await db.insert(productSlots).values(newAssociations);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: "Slot products updated successfully",
added: productsToAdd.length,
removed: productsToRemove.length,
};
}),
createSlot: protectedProcedure
.input(createSlotSchema)
.mutation(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input;
// Validate required fields
if (!deliveryTime || !freezeTime) {
throw new ApiError("Delivery time and orders close time are required", 400);
}
const result = await db.transaction(async (tx) => {
// Create slot
const [newSlot] = await tx
.insert(deliverySlotInfo)
.values({
deliveryTime: new Date(deliveryTime),
freezeTime: new Date(freezeTime),
isActive: isActive !== undefined ? isActive : true,
groupIds: groupIds !== undefined ? groupIds : [],
})
.returning();
// Insert product associations if provided
if (productIds && productIds.length > 0) {
const associations = productIds.map((productId) => ({
productId,
slotId: newSlot.id,
}));
await tx.insert(productSlots).values(associations);
}
// Create vendor snippets if provided
let createdSnippets: any[] = [];
if (snippets && snippets.length > 0) {
for (const snippet of snippets) {
// Validate products exist
const products = await tx.query.productInfo.findMany({
where: inArray(productInfo.id, snippet.productIds),
});
if (products.length !== snippet.productIds.length) {
throw new ApiError(`One or more invalid product IDs in snippet "${snippet.name}"`, 400);
}
// Check if snippet name already exists
const existingSnippet = await tx.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, snippet.name),
});
if (existingSnippet) {
throw new ApiError(`Snippet name "${snippet.name}" already exists`, 400);
}
const [createdSnippet] = await tx.insert(vendorSnippets).values({
snippetCode: snippet.name,
slotId: newSlot.id,
productIds: snippet.productIds,
validTill: snippet.validTill ? new Date(snippet.validTill) : undefined,
}).returning();
createdSnippets.push(createdSnippet);
}
}
return {
slot: newSlot,
createdSnippets,
message: "Slot created successfully",
};
});
// Reinitialize stores to reflect changes (outside transaction)
scheduleStoreInitialization()
return result;
}),
getSlots: protectedProcedure.query(async ({ ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const slots = await db.query.deliverySlotInfo.findMany({
where: eq(deliverySlotInfo.isActive, true),
});
return {
slots,
count: slots.length,
};
}),
getSlotById: protectedProcedure
.input(getSlotByIdSchema)
.query(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const { id } = input;
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, id),
with: {
productSlots: {
with: {
product: {
columns: {
id: true,
name: true,
images: true,
},
},
},
},
vendorSnippets: true,
},
});
if (!slot) {
throw new ApiError("Slot not found", 404);
}
return {
slot: {
...slot,
deliverySequence: slot.deliverySequence as number[],
groupIds: slot.groupIds as number[],
products: slot.productSlots.map((ps) => ps.product),
vendorSnippets: slot.vendorSnippets?.map(snippet => ({
...snippet,
accessUrl: `${appUrl}/vendor-order-list?id=${snippet.snippetCode}`
})),
},
};
}),
updateSlot: protectedProcedure
.input(updateSlotSchema)
.mutation(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
try{
const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input;
if (!deliveryTime || !freezeTime) {
throw new ApiError("Delivery time and orders close time are required", 400);
}
// Filter groupIds to only include valid (existing) groups
let validGroupIds = groupIds;
if (groupIds && groupIds.length > 0) {
const existingGroups = await db.query.productGroupInfo.findMany({
where: inArray(productGroupInfo.id, groupIds),
columns: { id: true },
});
validGroupIds = existingGroups.map(g => g.id);
}
const result = await db.transaction(async (tx) => {
const [updatedSlot] = await tx
.update(deliverySlotInfo)
.set({
deliveryTime: new Date(deliveryTime),
freezeTime: new Date(freezeTime),
isActive: isActive !== undefined ? isActive : true,
groupIds: validGroupIds !== undefined ? validGroupIds : [],
})
.where(eq(deliverySlotInfo.id, id))
.returning();
if (!updatedSlot) {
throw new ApiError("Slot not found", 404);
}
// Update product associations
if (productIds !== undefined) {
// Delete existing associations
await tx.delete(productSlots).where(eq(productSlots.slotId, id));
// Insert new associations
if (productIds.length > 0) {
const associations = productIds.map((productId) => ({
productId,
slotId: id,
}));
await tx.insert(productSlots).values(associations);
}
}
// Create vendor snippets if provided
let createdSnippets: any[] = [];
if (snippets && snippets.length > 0) {
for (const snippet of snippets) {
// Validate products exist
const products = await tx.query.productInfo.findMany({
where: inArray(productInfo.id, snippet.productIds),
});
if (products.length !== snippet.productIds.length) {
throw new ApiError(`One or more invalid product IDs in snippet "${snippet.name}"`, 400);
}
// Check if snippet name already exists
const existingSnippet = await tx.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, snippet.name),
});
if (existingSnippet) {
throw new ApiError(`Snippet name "${snippet.name}" already exists`, 400);
}
const [createdSnippet] = await tx.insert(vendorSnippets).values({
snippetCode: snippet.name,
slotId: id,
productIds: snippet.productIds,
validTill: snippet.validTill ? new Date(snippet.validTill) : undefined,
}).returning();
createdSnippets.push(createdSnippet);
}
}
return {
slot: updatedSlot,
createdSnippets,
message: "Slot updated successfully",
};
});
// Reinitialize stores to reflect changes (outside transaction)
scheduleStoreInitialization()
return result;
}
catch(e) {
console.log(e)
throw new ApiError("Unable to Update Slot");
}
}),
deleteSlot: protectedProcedure
.input(deleteSlotSchema)
.mutation(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const { id } = input;
const [deletedSlot] = await db
.update(deliverySlotInfo)
.set({ isActive: false })
.where(eq(deliverySlotInfo.id, id))
.returning();
if (!deletedSlot) {
throw new ApiError("Slot not found", 404);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: "Slot deleted successfully",
};
}),
getDeliverySequence: protectedProcedure
.input(getDeliverySequenceSchema)
.query(async ({ input, ctx }) => {
const { id } = input;
const slotId = parseInt(id);
const cacheKey = getSlotSequenceKey(slotId);
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
const parsed = JSON.parse(cached);
const validated = cachedSequenceSchema.parse(parsed) as CachedDeliverySequence;
console.log('sending cached response')
return { deliverySequence: validated };
}
} catch (error) {
console.warn('Redis cache read/validation failed, falling back to DB:', error);
// Continue to DB fallback
}
// Fallback to DB
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, slotId),
});
if (!slot) {
throw new ApiError("Slot not found", 404);
}
const sequence = (slot.deliverySequence || {}) as CachedDeliverySequence;
// Cache the validated result
try {
const validated = cachedSequenceSchema.parse(sequence);
await redisClient.set(cacheKey, JSON.stringify(validated), 3600);
} catch (cacheError) {
console.warn('Redis cache write failed:', cacheError);
}
return { deliverySequence: sequence };
}),
updateDeliverySequence: protectedProcedure
.input(updateDeliverySequenceSchema)
.mutation(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const { id, deliverySequence } = input;
const [updatedSlot] = await db
.update(deliverySlotInfo)
.set({ deliverySequence })
.where(eq(deliverySlotInfo.id, id))
.returning({
id: deliverySlotInfo.id,
deliverySequence: deliverySlotInfo.deliverySequence,
});
if (!updatedSlot) {
throw new ApiError("Slot not found", 404);
}
// Cache the updated sequence
const cacheKey = getSlotSequenceKey(id);
try {
const validated = cachedSequenceSchema.parse(deliverySequence);
await redisClient.set(cacheKey, JSON.stringify(validated), 3600);
} catch (cacheError) {
console.warn('Redis cache write failed:', cacheError);
}
return {
slot: updatedSlot,
message: "Delivery sequence updated successfully",
};
}),
updateSlotCapacity: protectedProcedure
.input(z.object({
slotId: z.number(),
isCapacityFull: z.boolean(),
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const { slotId, isCapacityFull } = input;
const [updatedSlot] = await db
.update(deliverySlotInfo)
.set({ isCapacityFull })
.where(eq(deliverySlotInfo.id, slotId))
.returning();
if (!updatedSlot) {
throw new ApiError("Slot not found", 404);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
success: true,
slot: updatedSlot,
message: `Slot ${isCapacityFull ? 'marked as full capacity' : 'capacity reset'}`,
};
}),
});

View file

@ -1,242 +0,0 @@
import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { staffUsers, staffRoles, users, userDetails, orders } from '@/src/db/schema'
import { eq, or, ilike, and, lt, desc } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import { ApiError } from '@/src/lib/api-error'
import { signToken } from '@/src/lib/jwt-utils'
export const staffUserRouter = router({
login: publicProcedure
.input(z.object({
name: z.string(),
password: z.string(),
}))
.mutation(async ({ input }) => {
const { name, password } = input;
if (!name || !password) {
throw new ApiError('Name and password are required', 400);
}
const staff = await db.query.staffUsers.findFirst({
where: eq(staffUsers.name, name),
});
if (!staff) {
throw new ApiError('Invalid credentials', 401);
}
const isPasswordValid = await bcrypt.compare(password, staff.password);
if (!isPasswordValid) {
throw new ApiError('Invalid credentials', 401);
}
const token = await signToken(
{ staffId: staff.id, name: staff.name },
'30d'
);
return {
message: 'Login successful',
token,
staff: { id: staff.id, name: staff.name },
};
}),
getStaff: protectedProcedure
.query(async ({ ctx }) => {
const staff = await db.query.staffUsers.findMany({
columns: {
id: true,
name: true,
},
with: {
role: {
with: {
rolePermissions: {
with: {
permission: true,
},
},
},
},
},
});
// Transform the data to include role and permissions in a cleaner format
const transformedStaff = staff.map((user) => ({
id: user.id,
name: user.name,
role: user.role ? {
id: user.role.id,
name: user.role.roleName,
} : null,
permissions: user.role?.rolePermissions.map((rp) => ({
id: rp.permission.id,
name: rp.permission.permissionName,
})) || [],
}));
return {
staff: transformedStaff,
};
}),
getUsers: protectedProcedure
.input(z.object({
cursor: z.number().optional(),
limit: z.number().default(20),
search: z.string().optional(),
}))
.query(async ({ input }) => {
const { cursor, limit, search } = input;
let whereCondition = undefined;
if (search) {
whereCondition = or(
ilike(users.name, `%${search}%`),
ilike(users.email, `%${search}%`),
ilike(users.mobile, `%${search}%`)
);
}
if (cursor) {
const cursorCondition = lt(users.id, cursor);
whereCondition = whereCondition ? and(whereCondition, cursorCondition) : cursorCondition;
}
const allUsers = await db.query.users.findMany({
where: whereCondition,
with: {
userDetails: true,
},
orderBy: desc(users.id),
limit: limit + 1, // fetch one extra to check if there's more
});
const hasMore = allUsers.length > limit;
const usersToReturn = hasMore ? allUsers.slice(0, limit) : allUsers;
const formattedUsers = usersToReturn.map(user => ({
id: user.id,
name: user.name,
email: user.email,
mobile: user.mobile,
image: user.userDetails?.profileImage || null,
}));
return {
users: formattedUsers,
nextCursor: hasMore ? usersToReturn[usersToReturn.length - 1].id : undefined,
};
}),
getUserDetails: protectedProcedure
.input(z.object({ userId: z.number() }))
.query(async ({ input }) => {
const { userId } = input;
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
with: {
userDetails: true,
orders: {
orderBy: desc(orders.createdAt),
limit: 1,
},
},
});
if (!user) {
throw new ApiError("User not found", 404);
}
const lastOrder = user.orders[0];
return {
id: user.id,
name: user.name,
email: user.email,
mobile: user.mobile,
addedOn: user.createdAt,
lastOrdered: lastOrder?.createdAt || null,
isSuspended: user.userDetails?.isSuspended || false,
};
}),
updateUserSuspension: protectedProcedure
.input(z.object({ userId: z.number(), isSuspended: z.boolean() }))
.mutation(async ({ input }) => {
const { userId, isSuspended } = input;
await db
.insert(userDetails)
.values({ userId, isSuspended })
.onConflictDoUpdate({
target: userDetails.userId,
set: { isSuspended },
});
return { success: true };
}),
createStaffUser: protectedProcedure
.input(z.object({
name: z.string().min(1, 'Name is required'),
password: z.string().min(6, 'Password must be at least 6 characters'),
roleId: z.number().int().positive('Role is required'),
}))
.mutation(async ({ input, ctx }) => {
const { name, password, roleId } = input;
// Check if staff user already exists
const existingUser = await db.query.staffUsers.findFirst({
where: eq(staffUsers.name, name),
});
if (existingUser) {
throw new ApiError('Staff user with this name already exists', 409);
}
// Check if role exists
const role = await db.query.staffRoles.findFirst({
where: eq(staffRoles.id, roleId),
});
if (!role) {
throw new ApiError('Invalid role selected', 400);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create staff user
const [newUser] = await db.insert(staffUsers).values({
name: name.trim(),
password: hashedPassword,
staffRoleId: roleId,
}).returning();
return { success: true, user: { id: newUser.id, name: newUser.name } };
}),
getRoles: protectedProcedure
.query(async ({ ctx }) => {
const roles = await db.query.staffRoles.findMany({
columns: {
id: true,
roleName: true,
},
});
return {
roles: roles.map(role => ({
id: role.id,
name: role.roleName,
})),
};
}),
});

View file

@ -1,211 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { storeInfo, productInfo } from '@/src/db/schema'
import { eq, inArray } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error'
import { extractKeyFromPresignedUrl, deleteImageUtil, scaffoldAssetUrl } from '@/src/lib/s3-client'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
export const storeRouter = router({
getStores: protectedProcedure
.query(async ({ ctx }) => {
const stores = await db.query.storeInfo.findMany({
with: {
owner: true,
},
});
Promise.all(stores.map(async store => {
if(store.imageUrl)
store.imageUrl = scaffoldAssetUrl(store.imageUrl)
})).catch((e) => {
throw new ApiError("Unable to find store image urls")
}
)
return {
stores,
count: stores.length,
};
}),
getStoreById: protectedProcedure
.input(z.object({
id: z.number(),
}))
.query(async ({ input, ctx }) => {
const { id } = input;
const store = await db.query.storeInfo.findFirst({
where: eq(storeInfo.id, id),
with: {
owner: true,
},
});
if (!store) {
throw new ApiError("Store not found", 404);
}
store.imageUrl = scaffoldAssetUrl(store.imageUrl);
return {
store,
};
}),
createStore: protectedProcedure
.input(z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
imageUrl: z.string().optional(),
owner: z.number().min(1, "Owner is required"),
products: z.array(z.number()).optional(),
}))
.mutation(async ({ input, ctx }) => {
const { name, description, imageUrl, owner, products } = input;
// const imageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : undefined;
const imageKey = imageUrl
const [newStore] = await db
.insert(storeInfo)
.values({
name,
description,
imageUrl: imageKey,
owner,
})
.returning();
// Assign selected products to this store
if (products && products.length > 0) {
await db
.update(productInfo)
.set({ storeId: newStore.id })
.where(inArray(productInfo.id, products));
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
store: newStore,
message: "Store created successfully",
};
}),
updateStore: protectedProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
imageUrl: z.string().optional(),
owner: z.number().min(1, "Owner is required"),
products: z.array(z.number()).optional(),
}))
.mutation(async ({ input, ctx }) => {
const { id, name, description, imageUrl, owner, products } = input;
const existingStore = await db.query.storeInfo.findFirst({
where: eq(storeInfo.id, id),
});
if (!existingStore) {
throw new ApiError("Store not found", 404);
}
const oldImageKey = existingStore.imageUrl;
const newImageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : oldImageKey;
// Delete old image only if:
// 1. New image provided and keys are different, OR
// 2. No new image but old exists (clearing the image)
if (oldImageKey && (
(newImageKey && newImageKey !== oldImageKey) ||
(!newImageKey)
)) {
try {
await deleteImageUtil({keys: [oldImageKey]});
} catch (error) {
console.error('Failed to delete old image:', error);
// Continue with update even if deletion fails
}
}
const [updatedStore] = await db
.update(storeInfo)
.set({
name,
description,
imageUrl: newImageKey,
owner,
})
.where(eq(storeInfo.id, id))
.returning();
if (!updatedStore) {
throw new ApiError("Store not found", 404);
}
// Update products if provided
if (products) {
// First, set storeId to null for products not in the list but currently assigned to this store
await db
.update(productInfo)
.set({ storeId: null })
.where(eq(productInfo.storeId, id));
// Then, assign the selected products to this store
if (products.length > 0) {
await db
.update(productInfo)
.set({ storeId: id })
.where(inArray(productInfo.id, products));
}
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
store: updatedStore,
message: "Store updated successfully",
};
}),
deleteStore: protectedProcedure
.input(z.object({
storeId: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { storeId } = input;
const result = await db.transaction(async (tx) => {
// First, update all products of this store to set storeId to null
await tx
.update(productInfo)
.set({ storeId: null })
.where(eq(productInfo.storeId, storeId));
// Then delete the store
const [deletedStore] = await tx
.delete(storeInfo)
.where(eq(storeInfo.id, storeId))
.returning();
if (!deletedStore) {
throw new ApiError("Store not found", 404);
}
return {
message: "Store deleted successfully",
};
});
// Reinitialize stores to reflect changes (outside transaction)
scheduleStoreInitialization()
return result;
}),
});

View file

@ -1,214 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { productTagInfo } from '@/src/db/schema'
import { eq } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error'
import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client'
import { deleteS3Image } from '@/src/lib/delete-image'
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
export const tagRouter = router({
getTags: protectedProcedure
.query(async () => {
const tags = await db
.select()
.from(productTagInfo)
.orderBy(productTagInfo.tagName);
// Generate asset URLs for tag images
const tagsWithUrls = tags.map(tag => ({
...tag,
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
}));
return {
tags: tagsWithUrls,
message: "Tags retrieved successfully",
};
}),
getTagById: protectedProcedure
.input(z.object({
id: z.number(),
}))
.query(async ({ input }) => {
const tag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.id, input.id),
});
if (!tag) {
throw new ApiError("Tag not found", 404);
}
// Generate asset URL for tag image
const tagWithUrl = {
...tag,
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
};
return {
tag: tagWithUrl,
message: "Tag retrieved successfully",
};
}),
createTag: protectedProcedure
.input(z.object({
tagName: z.string().min(1),
tagDescription: z.string().optional(),
isDashboardTag: z.boolean().default(false),
relatedStores: z.array(z.number()).default([]),
imageKey: z.string().optional(),
}))
.mutation(async ({ input }) => {
const { tagName, tagDescription, isDashboardTag, relatedStores, imageKey } = input;
// Check for duplicate tag name
const existingTag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.tagName, tagName.trim()),
});
if (existingTag) {
throw new ApiError("A tag with this name already exists", 400);
}
const [newTag] = await db
.insert(productTagInfo)
.values({
tagName: tagName.trim(),
tagDescription,
imageUrl: imageKey || null,
isDashboardTag,
relatedStores,
})
.returning();
// Claim upload URL if image was provided
if (imageKey) {
try {
await claimUploadUrl(imageKey);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
}
}
scheduleStoreInitialization();
return {
tag: newTag,
message: "Tag created successfully",
};
}),
updateTag: protectedProcedure
.input(z.object({
id: z.number(),
tagName: z.string().min(1),
tagDescription: z.string().optional(),
isDashboardTag: z.boolean(),
relatedStores: z.array(z.number()),
imageKey: z.string().optional(),
deleteExistingImage: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { id, imageKey, deleteExistingImage, ...updateData } = input;
// Get current tag
const currentTag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.id, id),
});
if (!currentTag) {
throw new ApiError("Tag not found", 404);
}
let newImageUrl = currentTag.imageUrl;
// Handle image deletion
if (deleteExistingImage && currentTag.imageUrl) {
try {
await deleteS3Image(currentTag.imageUrl);
} catch (e) {
console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e);
}
newImageUrl = null;
}
// Handle new image upload (only if different from existing)
if (imageKey && imageKey !== currentTag.imageUrl) {
// Delete old image if exists and not already deleted
if (currentTag.imageUrl && !deleteExistingImage) {
try {
await deleteS3Image(currentTag.imageUrl);
} catch (e) {
console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e);
}
}
newImageUrl = imageKey;
// Claim upload URL
try {
await claimUploadUrl(imageKey);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
}
}
const [updatedTag] = await db
.update(productTagInfo)
.set({
tagName: updateData.tagName.trim(),
tagDescription: updateData.tagDescription,
isDashboardTag: updateData.isDashboardTag,
relatedStores: updateData.relatedStores,
imageUrl: newImageUrl,
})
.where(eq(productTagInfo.id, id))
.returning();
scheduleStoreInitialization();
return {
tag: updatedTag,
message: "Tag updated successfully",
};
}),
deleteTag: protectedProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ input }) => {
const { id } = input;
// Get tag to check for image
const tag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.id, id),
});
if (!tag) {
throw new ApiError("Tag not found", 404);
}
// Delete image from S3 if exists
if (tag.imageUrl) {
try {
await deleteS3Image(tag.imageUrl);
} catch (e) {
console.error(`Failed to delete image: ${tag.imageUrl}`, e);
}
}
// Delete tag (will fail if tag is assigned to products due to FK constraint)
await db.delete(productTagInfo).where(eq(productTagInfo.id, id));
scheduleStoreInitialization();
return {
message: "Tag deleted successfully",
};
}),
});
export type TagRouter = typeof tagRouter;

View file

@ -1,489 +0,0 @@
import { protectedProcedure } from '@/src/trpc/trpc-index';
import { z } from 'zod';
import { db } from '@/src/db/db_index';
import { users, complaints, orders, orderItems, notifCreds, unloggedUserTokens, userDetails, userIncidents } from '@/src/db/schema';
import { eq, sql, desc, asc, count, max, inArray } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error';
import { notificationQueue } from '@/src/lib/notif-job';
import { recomputeUserNegativityScore } from '@/src/stores/user-negativity-store';
async function createUserByMobile(mobile: string): Promise<typeof users.$inferSelect> {
// Clean mobile number (remove non-digits)
const cleanMobile = mobile.replace(/\D/g, '');
// Validate: exactly 10 digits
if (cleanMobile.length !== 10) {
throw new ApiError('Mobile number must be exactly 10 digits', 400);
}
// Check if user already exists
const [existingUser] = await db
.select()
.from(users)
.where(eq(users.mobile, cleanMobile))
.limit(1);
if (existingUser) {
throw new ApiError('User with this mobile number already exists', 409);
}
// Create user
const [newUser] = await db
.insert(users)
.values({
name: null,
email: null,
mobile: cleanMobile,
})
.returning();
return newUser;
}
export const userRouter = {
createUserByMobile: protectedProcedure
.input(z.object({
mobile: z.string().min(1, 'Mobile number is required'),
}))
.mutation(async ({ input }) => {
const newUser = await createUserByMobile(input.mobile);
return {
success: true,
data: newUser,
};
}),
getEssentials: protectedProcedure
.query(async () => {
const count = await db.$count(complaints, eq(complaints.isResolved, false));
return {
unresolvedComplaints: count || 0,
};
}),
getAllUsers: protectedProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(50),
cursor: z.number().optional(),
search: z.string().optional(),
}))
.query(async ({ input }) => {
const { limit, cursor, search } = input;
// Build where conditions
const whereConditions = [];
if (search && search.trim()) {
whereConditions.push(sql`${users.mobile} ILIKE ${`%${search.trim()}%`}`);
}
if (cursor) {
whereConditions.push(sql`${users.id} > ${cursor}`);
}
// Get users with filters applied
const usersList = await db
.select({
id: users.id,
name: users.name,
mobile: users.mobile,
createdAt: users.createdAt,
})
.from(users)
.where(whereConditions.length > 0 ? sql.join(whereConditions, sql` AND `) : undefined)
.orderBy(asc(users.id))
.limit(limit + 1); // Get one extra to determine if there's more
// Check if there are more results
const hasMore = usersList.length > limit;
const usersToReturn = hasMore ? usersList.slice(0, limit) : usersList;
// Get order stats for each user
const userIds = usersToReturn.map(u => u.id);
let orderCounts: { userId: number; totalOrders: number }[] = [];
let lastOrders: { userId: number; lastOrderDate: Date | null }[] = [];
let suspensionStatuses: { userId: number; isSuspended: boolean }[] = [];
if (userIds.length > 0) {
// Get total orders per user
orderCounts = await db
.select({
userId: orders.userId,
totalOrders: count(orders.id),
})
.from(orders)
.where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`)
.groupBy(orders.userId);
// Get last order date per user
lastOrders = await db
.select({
userId: orders.userId,
lastOrderDate: max(orders.createdAt),
})
.from(orders)
.where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`)
.groupBy(orders.userId);
// Get suspension status for each user
suspensionStatuses = await db
.select({
userId: userDetails.userId,
isSuspended: userDetails.isSuspended,
})
.from(userDetails)
.where(sql`${userDetails.userId} IN (${sql.join(userIds, sql`, `)})`);
}
// Create lookup maps
const orderCountMap = new Map(orderCounts.map(o => [o.userId, o.totalOrders]));
const lastOrderMap = new Map(lastOrders.map(o => [o.userId, o.lastOrderDate]));
const suspensionMap = new Map(suspensionStatuses.map(s => [s.userId, s.isSuspended]));
// Combine data
const usersWithStats = usersToReturn.map(user => ({
...user,
totalOrders: orderCountMap.get(user.id) || 0,
lastOrderDate: lastOrderMap.get(user.id) || null,
isSuspended: suspensionMap.get(user.id) ?? false,
}));
// Get next cursor
const nextCursor = hasMore ? usersToReturn[usersToReturn.length - 1].id : undefined;
return {
users: usersWithStats,
nextCursor,
hasMore,
};
}),
getUserDetails: protectedProcedure
.input(z.object({
userId: z.number(),
}))
.query(async ({ input }) => {
const { userId } = input;
// Get user info
const user = await db
.select({
id: users.id,
name: users.name,
mobile: users.mobile,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user || user.length === 0) {
throw new ApiError('User not found', 404);
}
// Get user suspension status
const userDetail = await db
.select({
isSuspended: userDetails.isSuspended,
})
.from(userDetails)
.where(eq(userDetails.userId, userId))
.limit(1);
// Get all orders for this user with order items count
const userOrders = await db
.select({
id: orders.id,
readableId: orders.readableId,
totalAmount: orders.totalAmount,
createdAt: orders.createdAt,
isFlashDelivery: orders.isFlashDelivery,
})
.from(orders)
.where(eq(orders.userId, userId))
.orderBy(desc(orders.createdAt));
// Get order status for each order
const orderIds = userOrders.map(o => o.id);
let orderStatuses: { orderId: number; isDelivered: boolean; isCancelled: boolean }[] = [];
if (orderIds.length > 0) {
const { orderStatus } = await import('@/src/db/schema');
orderStatuses = await db
.select({
orderId: orderStatus.orderId,
isDelivered: orderStatus.isDelivered,
isCancelled: orderStatus.isCancelled,
})
.from(orderStatus)
.where(sql`${orderStatus.orderId} IN (${sql.join(orderIds, sql`, `)})`);
}
// Get item counts for each order
const itemCounts = await db
.select({
orderId: orderItems.orderId,
itemCount: count(orderItems.id),
})
.from(orderItems)
.where(sql`${orderItems.orderId} IN (${sql.join(orderIds, sql`, `)})`)
.groupBy(orderItems.orderId);
// Create lookup maps
const statusMap = new Map(orderStatuses.map(s => [s.orderId, s]));
const itemCountMap = new Map(itemCounts.map(c => [c.orderId, c.itemCount]));
// Determine status string
const getStatus = (status: { isDelivered: boolean; isCancelled: boolean } | undefined) => {
if (!status) return 'pending';
if (status.isCancelled) return 'cancelled';
if (status.isDelivered) return 'delivered';
return 'pending';
};
// Combine data
const ordersWithDetails = userOrders.map(order => {
const status = statusMap.get(order.id);
return {
id: order.id,
readableId: order.readableId,
totalAmount: order.totalAmount,
createdAt: order.createdAt,
isFlashDelivery: order.isFlashDelivery,
status: getStatus(status),
itemCount: itemCountMap.get(order.id) || 0,
};
});
return {
user: {
...user[0],
isSuspended: userDetail[0]?.isSuspended ?? false,
},
orders: ordersWithDetails,
};
}),
updateUserSuspension: protectedProcedure
.input(z.object({
userId: z.number(),
isSuspended: z.boolean(),
}))
.mutation(async ({ input }) => {
const { userId, isSuspended } = input;
// Check if user exists
const user = await db
.select({ id: users.id })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user || user.length === 0) {
throw new ApiError('User not found', 404);
}
// Check if user_details record exists
const existingDetail = await db
.select({ id: userDetails.id })
.from(userDetails)
.where(eq(userDetails.userId, userId))
.limit(1);
if (existingDetail.length > 0) {
// Update existing record
await db
.update(userDetails)
.set({ isSuspended })
.where(eq(userDetails.userId, userId));
} else {
// Insert new record
await db
.insert(userDetails)
.values({
userId,
isSuspended,
});
}
return {
success: true,
message: `User ${isSuspended ? 'suspended' : 'unsuspended'} successfully`,
};
}),
getUsersForNotification: protectedProcedure
.input(z.object({
search: z.string().optional(),
}))
.query(async ({ input }) => {
const { search } = input;
// Get all users
let usersList;
if (search && search.trim()) {
usersList = await db
.select({
id: users.id,
name: users.name,
mobile: users.mobile,
})
.from(users)
.where(sql`${users.mobile} ILIKE ${`%${search.trim()}%`} OR ${users.name} ILIKE ${`%${search.trim()}%`}`);
} else {
usersList = await db
.select({
id: users.id,
name: users.name,
mobile: users.mobile,
})
.from(users);
}
// Get eligible users (have notif_creds entry)
const eligibleUsers = await db
.select({ userId: notifCreds.userId })
.from(notifCreds);
const eligibleSet = new Set(eligibleUsers.map(u => u.userId));
return {
users: usersList.map(user => ({
id: user.id,
name: user.name,
mobile: user.mobile,
isEligibleForNotif: eligibleSet.has(user.id),
})),
};
}),
sendNotification: protectedProcedure
.input(z.object({
userIds: z.array(z.number()).default([]),
title: z.string().min(1, 'Title is required'),
text: z.string().min(1, 'Message is required'),
imageUrl: z.string().optional(),
}))
.mutation(async ({ input }) => {
const { userIds, title, text, imageUrl } = input;
let tokens: string[] = [];
if (userIds.length === 0) {
// Send to all users - get tokens from both logged-in and unlogged users
const loggedInTokens = await db.select({ token: notifCreds.token }).from(notifCreds);
const unloggedTokens = await db.select({ token: unloggedUserTokens.token }).from(unloggedUserTokens);
tokens = [
...loggedInTokens.map(t => t.token),
...unloggedTokens.map(t => t.token)
];
} else {
// Send to specific users - get their tokens
const userTokens = await db
.select({ token: notifCreds.token })
.from(notifCreds)
.where(inArray(notifCreds.userId, userIds));
tokens = userTokens.map(t => t.token);
}
// Queue one job per token
let queuedCount = 0;
for (const token of tokens) {
try {
await notificationQueue.add('send-admin-notification', {
token,
title,
body: text,
imageUrl: imageUrl || null,
}, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
});
queuedCount++;
} catch (error) {
console.error(`Failed to queue notification for token:`, error);
}
}
return {
success: true,
message: `Notification queued for ${queuedCount} users`,
};
}),
getUserIncidents: protectedProcedure
.input(z.object({
userId: z.number(),
}))
.query(async ({ input }) => {
const { userId } = input;
const incidents = await db.query.userIncidents.findMany({
where: eq(userIncidents.userId, userId),
with: {
order: {
with: {
orderStatus: true,
},
},
addedBy: true,
},
orderBy: desc(userIncidents.dateAdded),
});
return {
incidents: incidents.map(incident => ({
id: incident.id,
userId: incident.userId,
orderId: incident.orderId,
dateAdded: incident.dateAdded,
adminComment: incident.adminComment,
addedBy: incident.addedBy?.name || 'Unknown',
negativityScore: incident.negativityScore,
orderStatus: incident.order?.orderStatus?.[0]?.isCancelled ? 'cancelled' : 'active',
})),
};
}),
addUserIncident: protectedProcedure
.input(z.object({
userId: z.number(),
orderId: z.number().optional(),
adminComment: z.string().optional(),
negativityScore: z.number().optional(),
}))
.mutation(async ({ input, ctx }) => {
const { userId, orderId, adminComment, negativityScore } = input;
const adminUserId = ctx.staffUser?.id;
if (!adminUserId) {
throw new ApiError('Admin user not authenticated', 401);
}
const incidentObj = { userId, orderId, adminComment, addedBy: adminUserId, negativityScore };
const [incident] = await db.insert(userIncidents)
.values({
...incidentObj,
})
.returning();
recomputeUserNegativityScore(userId);
return {
success: true,
data: incident,
};
}),
};

View file

@ -1,531 +0,0 @@
import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import dayjs from 'dayjs';
import { db } from '@/src/db/db_index'
import { vendorSnippets, deliverySlotInfo, productInfo, orders, orderItems, users, orderStatus } from '@/src/db/schema'
import { eq, and, inArray, isNotNull, gt, sql, asc, ne } from 'drizzle-orm';
import { appUrl } from '@/src/lib/env-exporter'
const createSnippetSchema = z.object({
snippetCode: z.string().min(1, "Snippet code is required"),
slotId: z.number().optional(),
productIds: z.array(z.number().int().positive()).min(1, "At least one product is required"),
validTill: z.string().optional(),
isPermanent: z.boolean().default(false)
});
const updateSnippetSchema = z.object({
id: z.number().int().positive(),
updates: createSnippetSchema.partial().extend({
snippetCode: z.string().min(1).optional(),
productIds: z.array(z.number().int().positive()).optional(),
isPermanent: z.boolean().default(false)
}),
});
export const vendorSnippetsRouter = router({
create: protectedProcedure
.input(createSnippetSchema)
.mutation(async ({ input, ctx }) => {
const { snippetCode, slotId, productIds, validTill, isPermanent } = input;
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id;
if (!staffUserId) {
throw new Error("Unauthorized");
}
// Validate slot exists
if(slotId) {
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, slotId),
});
if (!slot) {
throw new Error("Invalid slot ID");
}
}
// Validate products exist
const products = await db.query.productInfo.findMany({
where: inArray(productInfo.id, productIds),
});
if (products.length !== productIds.length) {
throw new Error("One or more invalid product IDs");
}
// Check if snippet code already exists
const existingSnippet = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, snippetCode),
});
if (existingSnippet) {
throw new Error("Snippet code already exists");
}
const result = await db.insert(vendorSnippets).values({
snippetCode,
slotId,
productIds,
isPermanent,
validTill: validTill ? new Date(validTill) : undefined,
}).returning();
return result[0];
}),
getAll: protectedProcedure
.query(async () => {
console.log('from the vendor snipptes methods')
try {
const result = await db.query.vendorSnippets.findMany({
with: {
slot: true,
},
orderBy: (vendorSnippets, { desc }) => [desc(vendorSnippets.createdAt)],
});
const snippetsWithProducts = await Promise.all(
result.map(async (snippet) => {
const products = await db.query.productInfo.findMany({
where: inArray(productInfo.id, snippet.productIds),
columns: { id: true, name: true },
});
return {
...snippet,
accessUrl: `${appUrl}/vendor-order-list?id=${snippet.snippetCode}`,
products: products.map(p => ({ id: p.id, name: p.name })),
};
})
);
return snippetsWithProducts;
}
catch(e) {
console.log(e)
}
return [];
}),
getById: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.query(async ({ input }) => {
const { id } = input;
const result = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.id, id),
with: {
slot: true,
},
});
if (!result) {
throw new Error("Vendor snippet not found");
}
return result;
}),
update: protectedProcedure
.input(updateSnippetSchema)
.mutation(async ({ input }) => {
const { id, updates } = input;
// Check if snippet exists
const existingSnippet = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.id, id),
});
if (!existingSnippet) {
throw new Error("Vendor snippet not found");
}
// Validate slot if being updated
if (updates.slotId) {
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, updates.slotId),
});
if (!slot) {
throw new Error("Invalid slot ID");
}
}
// Validate products if being updated
if (updates.productIds) {
const products = await db.query.productInfo.findMany({
where: inArray(productInfo.id, updates.productIds),
});
if (products.length !== updates.productIds.length) {
throw new Error("One or more invalid product IDs");
}
}
// Check snippet code uniqueness if being updated
if (updates.snippetCode && updates.snippetCode !== existingSnippet.snippetCode) {
const duplicateSnippet = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, updates.snippetCode),
});
if (duplicateSnippet) {
throw new Error("Snippet code already exists");
}
}
const updateData: any = { ...updates };
if (updates.validTill !== undefined) {
updateData.validTill = updates.validTill ? new Date(updates.validTill) : null;
}
const result = await db.update(vendorSnippets)
.set(updateData)
.where(eq(vendorSnippets.id, id))
.returning();
if (result.length === 0) {
throw new Error("Failed to update vendor snippet");
}
return result[0];
}),
delete: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input }) => {
const { id } = input;
const result = await db.delete(vendorSnippets)
.where(eq(vendorSnippets.id, id))
.returning();
if (result.length === 0) {
throw new Error("Vendor snippet not found");
}
return { message: "Vendor snippet deleted successfully" };
}),
getOrdersBySnippet: publicProcedure
.input(z.object({
snippetCode: z.string().min(1, "Snippet code is required")
}))
.query(async ({ input }) => {
const { snippetCode } = input;
// Find the snippet
const snippet = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, snippetCode),
});
if (!snippet) {
throw new Error("Vendor snippet not found");
}
// Check if snippet is still valid
if (snippet.validTill && new Date(snippet.validTill) < new Date()) {
throw new Error("Vendor snippet has expired");
}
// Query orders that match the snippet criteria
const matchingOrders = await db.query.orders.findMany({
where: eq(orders.slotId, snippet.slotId!),
with: {
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
orderStatus: true,
user: true,
slot: true,
},
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
});
// Filter orders that contain at least one of the snippet's products
const filteredOrders = matchingOrders.filter(order => {
const status = order.orderStatus;
if (status[0].isCancelled) return false;
const orderProductIds = order.orderItems.map(item => item.productId);
return snippet.productIds.some(productId => orderProductIds.includes(productId));
});
// Format the response
const formattedOrders = filteredOrders.map(order => {
// Filter orderItems to only include products attached to the snippet
const attachedOrderItems = order.orderItems.filter(item =>
snippet.productIds.includes(item.productId)
);
const products = attachedOrderItems.map(item => ({
orderItemId: item.id,
productId: item.productId,
productName: item.product.name,
quantity: parseFloat(item.quantity),
productSize: item.product.productQuantity,
price: parseFloat(item.price.toString()),
unit: item.product.unit?.shortNotation || 'unit',
subtotal: parseFloat(item.price.toString()) * parseFloat(item.quantity),
is_packaged: item.is_packaged,
is_package_verified: item.is_package_verified,
}));
const orderTotal = products.reduce((sum, p) => sum + p.subtotal, 0);
return {
orderId: `ORD${order.id}`,
orderDate: order.createdAt.toISOString(),
customerName: order.user.name,
totalAmount: orderTotal,
slotInfo: order.slot ? {
time: order.slot.deliveryTime.toISOString(),
sequence: order.slot.deliverySequence,
} : null,
products,
matchedProducts: snippet.productIds, // All snippet products are considered matched
snippetCode: snippet.snippetCode,
};
});
return {
success: true,
data: formattedOrders,
snippet: {
id: snippet.id,
snippetCode: snippet.snippetCode,
slotId: snippet.slotId,
productIds: snippet.productIds,
validTill: snippet.validTill?.toISOString(),
createdAt: snippet.createdAt.toISOString(),
isPermanent: snippet.isPermanent,
},
};
}),
getVendorOrders: protectedProcedure
.query(async () => {
const vendorOrders = await db.query.orders.findMany({
with: {
user: true,
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
},
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
});
return vendorOrders.map(order => ({
id: order.id,
status: 'pending', // Default status since orders table may not have status field
orderDate: order.createdAt.toISOString(),
totalQuantity: order.orderItems.reduce((sum, item) => sum + parseFloat(item.quantity || '0'), 0),
products: order.orderItems.map(item => ({
name: item.product.name,
quantity: parseFloat(item.quantity || '0'),
unit: item.product.unit?.shortNotation || 'unit',
})),
}));
}),
getUpcomingSlots: publicProcedure
.query(async () => {
const threeHoursAgo = dayjs().subtract(3, 'hour').toDate();
const slots = await db.query.deliverySlotInfo.findMany({
where: and(
eq(deliverySlotInfo.isActive, true),
gt(deliverySlotInfo.deliveryTime, threeHoursAgo)
),
orderBy: asc(deliverySlotInfo.deliveryTime),
});
return {
success: true,
data: slots.map(slot => ({
id: slot.id,
deliveryTime: slot.deliveryTime.toISOString(),
freezeTime: slot.freezeTime.toISOString(),
deliverySequence: slot.deliverySequence,
})),
};
}),
getOrdersBySnippetAndSlot: publicProcedure
.input(z.object({
snippetCode: z.string().min(1, "Snippet code is required"),
slotId: z.number().int().positive("Valid slot ID is required"),
}))
.query(async ({ input }) => {
const { snippetCode, slotId } = input;
// Find the snippet
const snippet = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, snippetCode),
});
if (!snippet) {
throw new Error("Vendor snippet not found");
}
// Find the slot
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, slotId),
});
if (!slot) {
throw new Error("Slot not found");
}
// Query orders that match the slot and snippet criteria
const matchingOrders = await db.query.orders.findMany({
where: eq(orders.slotId, slotId),
with: {
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
orderStatus: true,
user: true,
slot: true,
},
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
});
// Filter orders that contain at least one of the snippet's products
const filteredOrders = matchingOrders.filter(order => {
const status = order.orderStatus;
if (status[0]?.isCancelled) return false;
const orderProductIds = order.orderItems.map(item => item.productId);
return snippet.productIds.some(productId => orderProductIds.includes(productId));
});
// Format the response
const formattedOrders = filteredOrders.map(order => {
// Filter orderItems to only include products attached to the snippet
const attachedOrderItems = order.orderItems.filter(item =>
snippet.productIds.includes(item.productId)
);
const products = attachedOrderItems.map(item => ({
orderItemId: item.id,
productId: item.productId,
productName: item.product.name,
quantity: parseFloat(item.quantity),
price: parseFloat(item.price.toString()),
unit: item.product.unit?.shortNotation || 'unit',
subtotal: parseFloat(item.price.toString()) * parseFloat(item.quantity),
productSize: item.product.productQuantity,
is_packaged: item.is_packaged,
is_package_verified: item.is_package_verified,
}));
const orderTotal = products.reduce((sum, p) => sum + p.subtotal, 0);
return {
orderId: `ORD${order.id}`,
orderDate: order.createdAt.toISOString(),
customerName: order.user.name,
totalAmount: orderTotal,
slotInfo: order.slot ? {
time: order.slot.deliveryTime.toISOString(),
sequence: order.slot.deliverySequence,
} : null,
products,
matchedProducts: snippet.productIds,
snippetCode: snippet.snippetCode,
};
});
return {
success: true,
data: formattedOrders,
snippet: {
id: snippet.id,
snippetCode: snippet.snippetCode,
slotId: snippet.slotId,
productIds: snippet.productIds,
validTill: snippet.validTill?.toISOString(),
createdAt: snippet.createdAt.toISOString(),
isPermanent: snippet.isPermanent,
},
selectedSlot: {
id: slot.id,
deliveryTime: slot.deliveryTime.toISOString(),
freezeTime: slot.freezeTime.toISOString(),
deliverySequence: slot.deliverySequence,
},
};
}),
updateOrderItemPackaging: publicProcedure
.input(z.object({
orderItemId: z.number().int().positive("Valid order item ID required"),
is_packaged: z.boolean()
}))
.mutation(async ({ input, ctx }) => {
const { orderItemId, is_packaged } = input;
// Get staff user ID from auth middleware
// const staffUserId = ctx.staffUser?.id;
// if (!staffUserId) {
// throw new Error("Unauthorized");
// }
// Check if order item exists and get related data
const orderItem = await db.query.orderItems.findFirst({
where: eq(orderItems.id, orderItemId),
with: {
order: {
with: {
slot: true
}
}
}
});
if (!orderItem) {
throw new Error("Order item not found");
}
// Check if this order item belongs to a slot that has vendor snippets
// This ensures only order items from vendor-accessible orders can be updated
if (!orderItem.order.slotId) {
throw new Error("Order item not associated with a vendor slot");
}
const snippetExists = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.slotId, orderItem.order.slotId),
});
if (!snippetExists) {
throw new Error("No vendor snippet found for this order's slot");
}
// Update the is_packaged field
const result = await db.update(orderItems)
.set({ is_packaged })
.where(eq(orderItems.id, orderItemId))
.returning();
if (result.length === 0) {
throw new Error("Failed to update packaging status");
}
return {
success: true,
orderItemId,
is_packaged
};
}),
});

View file

@ -1,32 +0,0 @@
import { z } from 'zod';
import { addressZones, addressAreas } from '@/src/db/schema'
import { eq, desc } from 'drizzle-orm';
import { db } from '@/src/db/db_index'
import { router,protectedProcedure } from '@/src/trpc/trpc-index'
const addressRouter = router({
getZones: protectedProcedure.query(async () => {
const zones = await db.select().from(addressZones).orderBy(desc(addressZones.addedAt));
return zones
}),
getAreas: protectedProcedure.query(async () => {
const areas = await db.select().from(addressAreas).orderBy(desc(addressAreas.createdAt));
return areas
}),
createZone: protectedProcedure.input(z.object({ zoneName: z.string().min(1) })).mutation(async ({ input }) => {
const zone = await db.insert(addressZones).values({ zoneName: input.zoneName }).returning();
return {zone: zone};
}),
createArea: protectedProcedure.input(z.object({ placeName: z.string().min(1), zoneId: z.number().nullable() })).mutation(async ({ input }) => {
const area = await db.insert(addressAreas).values({ placeName: input.placeName, zoneId: input.zoneId }).returning();
return {area};
}),
// TODO: Add update and delete mutations if needed
});
export default addressRouter;

View file

@ -1,39 +0,0 @@
// import { router } from '@/src/trpc/trpc-index';
import { router } from '@/src/trpc/trpc-index'
import { complaintRouter } from '@/src/trpc/apis/admin-apis/apis/complaint'
import { couponRouter } from '@/src/trpc/apis/admin-apis/apis/coupon'
import { cancelledOrdersRouter } from '@/src/trpc/apis/admin-apis/apis/cancelled-orders'
import { orderRouter } from '@/src/trpc/apis/admin-apis/apis/order'
import { vendorSnippetsRouter } from '@/src/trpc/apis/admin-apis/apis/vendor-snippets'
import { slotsRouter } from '@/src/trpc/apis/admin-apis/apis/slots'
import { productRouter } from '@/src/trpc/apis/admin-apis/apis/product'
import { staffUserRouter } from '@/src/trpc/apis/admin-apis/apis/staff-user'
import { storeRouter } from '@/src/trpc/apis/admin-apis/apis/store'
import { adminPaymentsRouter } from '@/src/trpc/apis/admin-apis/apis/payments'
import addressRouter from '@/src/trpc/apis/admin-apis/apis/address'
import { bannerRouter } from '@/src/trpc/apis/admin-apis/apis/banner'
import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user'
import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const'
import { productAvailabilitySchedulesRouter } from '@/src/trpc/apis/admin-apis/apis/product-availability-schedules'
import { tagRouter } from '@/src/trpc/apis/admin-apis/apis/tag'
export const adminRouter = router({
complaint: complaintRouter,
coupon: couponRouter,
cancelledOrders: cancelledOrdersRouter,
order: orderRouter,
vendorSnippets: vendorSnippetsRouter,
slots: slotsRouter,
product: productRouter,
staffUser: staffUserRouter,
store: storeRouter,
payments: adminPaymentsRouter,
address: addressRouter,
banner: bannerRouter,
user: userRouter,
const: constRouter,
productAvailabilitySchedules: productAvailabilitySchedulesRouter,
tag: tagRouter,
});
export type AdminRouter = typeof adminRouter;

View file

@ -1,176 +0,0 @@
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { homeBanners } from '@/src/db/schema'
import { eq, and, desc, sql } from 'drizzle-orm';
import { protectedProcedure, router } from '@/src/trpc/trpc-index'
import { extractKeyFromPresignedUrl, scaffoldAssetUrl } from '@/src/lib/s3-client'
import { ApiError } from '@/src/lib/api-error';
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
export const bannerRouter = router({
// Get all banners
getBanners: protectedProcedure
.query(async () => {
try {
const banners = await db.query.homeBanners.findMany({
orderBy: desc(homeBanners.createdAt), // Order by creation date instead
// Removed product relationship since we now use productIds array
});
// Convert S3 keys to signed URLs for client
const bannersWithSignedUrls = await Promise.all(
banners.map(async (banner) => {
try {
return {
...banner,
imageUrl: banner.imageUrl ? scaffoldAssetUrl(banner.imageUrl) : banner.imageUrl,
// Ensure productIds is always an array
productIds: banner.productIds || [],
};
} catch (error) {
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
return {
...banner,
imageUrl: banner.imageUrl, // Keep original on error
// Ensure productIds is always an array
productIds: banner.productIds || [],
};
}
})
);
return {
banners: bannersWithSignedUrls,
};
}
catch(e:any) {
console.log(e)
throw new ApiError(e.message);
}
}),
// Get single banner by ID
getBanner: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const banner = await db.query.homeBanners.findFirst({
where: eq(homeBanners.id, input.id),
// Removed product relationship since we now use productIds array
});
if (banner) {
try {
// Convert S3 key to signed URL for client
if (banner.imageUrl) {
banner.imageUrl = scaffoldAssetUrl(banner.imageUrl);
}
} catch (error) {
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
// Keep original imageUrl on error
}
// Ensure productIds is always an array (handle migration compatibility)
if (!banner.productIds) {
banner.productIds = [];
}
}
return banner;
}),
// Create new banner
createBanner: protectedProcedure
.input(z.object({
name: z.string().min(1),
imageUrl: z.string(),
description: z.string().optional(),
productIds: z.array(z.number()).optional(),
redirectUrl: z.string().url().optional(),
// serialNum removed completely
}))
.mutation(async ({ input }) => {
try {
const imageUrl = extractKeyFromPresignedUrl(input.imageUrl)
// const imageUrl = input.imageUrl
const [banner] = await db.insert(homeBanners).values({
name: input.name,
imageUrl: imageUrl,
description: input.description,
productIds: input.productIds || [],
redirectUrl: input.redirectUrl,
serialNum: 999, // Default value, not used
isActive: false, // Default to inactive
}).returning();
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return banner;
} catch (error) {
console.error('Error creating banner:', error);
throw error; // Re-throw to maintain tRPC error handling
}
}),
// Update banner
updateBanner: protectedProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1).optional(),
imageUrl: z.string().url().optional(),
description: z.string().optional(),
productIds: z.array(z.number()).optional(),
redirectUrl: z.string().url().optional(),
serialNum: z.number().nullable().optional(),
isActive: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
try {
const { id, ...updateData } = input;
const incomingProductIds = input.productIds;
// Extract S3 key from presigned URL if imageUrl is provided
const processedData = {
...updateData,
...(updateData.imageUrl && {
imageUrl: extractKeyFromPresignedUrl(updateData.imageUrl)
}),
};
// Handle serialNum null case
const finalData: any = { ...processedData };
if ('serialNum' in finalData && finalData.serialNum === null) {
// Set to null explicitly
finalData.serialNum = null;
}
const [banner] = await db.update(homeBanners)
.set({ ...finalData, lastUpdated: new Date(), })
.where(eq(homeBanners.id, id))
.returning();
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return banner;
} catch (error) {
console.error('Error updating banner:', error);
throw error;
}
}),
// Delete banner
deleteBanner: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await db.delete(homeBanners).where(eq(homeBanners.id, input.id));
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return { success: true };
}),
});

View file

@ -1,179 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { orders, orderStatus, users, addresses, orderItems, productInfo, units, refunds } from '@/src/db/schema'
import { eq, desc } from 'drizzle-orm';
const updateCancellationReviewSchema = z.object({
orderId: z.number(),
cancellationReviewed: z.boolean(),
adminNotes: z.string().optional(),
});
const updateRefundSchema = z.object({
orderId: z.number(),
isRefundDone: z.boolean(),
});
export const cancelledOrdersRouter = router({
getAll: protectedProcedure
.query(async () => {
// First get cancelled order statuses with order details
const cancelledOrderStatuses = await db.query.orderStatus.findMany({
where: eq(orderStatus.isCancelled, true),
with: {
order: {
with: {
user: true,
address: true,
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
refunds: true,
},
},
},
orderBy: [desc(orderStatus.orderTime)],
});
const filteredStatuses = cancelledOrderStatuses.filter(status => {
return status.order.isCod || status.paymentStatus === 'success';
});
return filteredStatuses.map(status => {
const refund = status.order.refunds[0];
return {
id: status.order.id,
readableId: status.order.id,
customerName: `${status.order.user.name}`,
address: `${status.order.address.addressLine1}, ${status.order.address.city}`,
totalAmount: status.order.totalAmount,
cancellationReviewed: status.cancellationReviewed || false,
isRefundDone: refund?.refundStatus === 'processed' || false,
adminNotes: status.order.adminNotes,
cancelReason: status.cancelReason,
paymentMode: status.order.isCod ? 'COD' : 'Online',
paymentStatus: status.paymentStatus || 'pending',
items: status.order.orderItems.map(item => ({
name: item.product.name,
quantity: item.quantity,
price: item.price,
unit: item.product.unit?.shortNotation,
amount: parseFloat(item.price.toString()) * parseFloat(item.quantity || '0'),
})),
createdAt: status.order.createdAt,
};
});
}),
updateReview: protectedProcedure
.input(updateCancellationReviewSchema)
.mutation(async ({ input }) => {
const { orderId, cancellationReviewed, adminNotes } = input;
const result = await db.update(orderStatus)
.set({
cancellationReviewed,
cancellationAdminNotes: adminNotes || null,
cancellationReviewedAt: new Date(),
})
.where(eq(orderStatus.orderId, orderId))
.returning();
if (result.length === 0) {
throw new Error("Cancellation record not found");
}
return result[0];
}),
getById: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const { id } = input;
// Get cancelled order with full details
const cancelledOrderStatus = await db.query.orderStatus.findFirst({
where: eq(orderStatus.id, id),
with: {
order: {
with: {
user: true,
address: true,
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
},
},
},
});
if (!cancelledOrderStatus || !cancelledOrderStatus.isCancelled) {
throw new Error("Cancelled order not found");
}
// Get refund details separately
const refund = await db.query.refunds.findFirst({
where: eq(refunds.orderId, cancelledOrderStatus.orderId),
});
const order = cancelledOrderStatus.order;
// Format the response similar to the getAll method
const formattedOrder = {
id: order.id,
readableId: order.id,
customerName: order.user.name,
address: `${order.address.addressLine1}${order.address.addressLine2 ? ', ' + order.address.addressLine2 : ''}, ${order.address.city}, ${order.address.state} ${order.address.pincode}`,
totalAmount: order.totalAmount,
cancellationReviewed: cancelledOrderStatus.cancellationReviewed || false,
isRefundDone: refund?.refundStatus === 'processed' || false,
adminNotes: cancelledOrderStatus.cancellationAdminNotes || null,
cancelReason: cancelledOrderStatus.cancelReason || null,
items: order.orderItems.map((item: any) => ({
name: item.product.name,
quantity: item.quantity,
price: parseFloat(item.price.toString()),
unit: item.product.unit?.shortNotation || 'unit',
amount: parseFloat(item.price.toString()) * parseFloat(item.quantity),
image: item.product.images?.[0] || null,
})),
createdAt: order.createdAt.toISOString(),
};
return { order: formattedOrder };
}),
updateRefund: protectedProcedure
.input(updateRefundSchema)
.mutation(async ({ input }) => {
const { orderId, isRefundDone } = input;
const refundStatus = isRefundDone ? 'processed' : 'none';
const result = await db.update(refunds)
.set({
refundStatus,
refundProcessedAt: isRefundDone ? new Date() : null,
})
.where(eq(refunds.orderId, orderId))
.returning();
if (result.length === 0) {
throw new Error("Cancellation record not found");
}
return result[0];
}),
});

View file

@ -1,80 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { complaints, users } from '@/src/db/schema'
import { eq, desc, lt, and } from 'drizzle-orm';
import { scaffoldAssetUrl } from '@/src/lib/s3-client'
export const complaintRouter = router({
getAll: protectedProcedure
.input(z.object({
cursor: z.number().optional(),
limit: z.number().default(20),
}))
.query(async ({ input }) => {
const { cursor, limit } = input;
let whereCondition = cursor
? lt(complaints.id, cursor)
: undefined;
const complaintsData = await db
.select({
id: complaints.id,
complaintBody: complaints.complaintBody,
userId: complaints.userId,
orderId: complaints.orderId,
isResolved: complaints.isResolved,
createdAt: complaints.createdAt,
userName: users.name,
userMobile: users.mobile,
images: complaints.images,
})
.from(complaints)
.leftJoin(users, eq(complaints.userId, users.id))
.where(whereCondition)
.orderBy(desc(complaints.id))
.limit(limit + 1);
const hasMore = complaintsData.length > limit;
const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData;
const complaintsWithSignedImages = await Promise.all(
complaintsToReturn.map(async (c) => {
const signedImages = c.images
? scaffoldAssetUrl(c.images as string[])
: [];
return {
id: c.id,
text: c.complaintBody,
userId: c.userId,
userName: c.userName,
userMobile: c.userMobile,
orderId: c.orderId,
status: c.isResolved ? 'resolved' : 'pending',
createdAt: c.createdAt,
images: signedImages,
};
})
);
return {
complaints: complaintsWithSignedImages,
nextCursor: hasMore
? complaintsToReturn[complaintsToReturn.length - 1].id
: undefined,
};
}),
resolve: protectedProcedure
.input(z.object({ id: z.string(), response: z.string().optional() }))
.mutation(async ({ input }) => {
await db
.update(complaints)
.set({ isResolved: true, response: input.response })
.where(eq(complaints.id, parseInt(input.id)));
return { message: 'Complaint resolved successfully' };
}),
});

View file

@ -1,61 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { keyValStore } from '@/src/db/schema'
import { computeConstants } from '@/src/lib/const-store'
import { CONST_KEYS } from '@/src/lib/const-keys'
export const constRouter = router({
getConstants: protectedProcedure
.query(async () => {
const constants = await db.select().from(keyValStore);
const resp = constants.map(c => ({
key: c.key,
value: c.value,
}));
return resp;
}),
updateConstants: protectedProcedure
.input(z.object({
constants: z.array(z.object({
key: z.string(),
value: z.any(),
})),
}))
.mutation(async ({ input }) => {
const { constants } = input;
const validKeys = Object.values(CONST_KEYS) as string[];
const invalidKeys = constants
.filter(c => !validKeys.includes(c.key))
.map(c => c.key);
if (invalidKeys.length > 0) {
throw new Error(`Invalid constant keys: ${invalidKeys.join(', ')}`);
}
await db.transaction(async (tx) => {
for (const { key, value } of constants) {
await tx.insert(keyValStore)
.values({ key, value })
.onConflictDoUpdate({
target: keyValStore.key,
set: { value },
});
}
});
// Refresh all constants in Redis after database update
await computeConstants();
return {
success: true,
updatedCount: constants.length,
keys: constants.map(c => c.key),
};
}),
});

View file

@ -1,711 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { coupons, users, staffUsers, orders, couponApplicableUsers, couponApplicableProducts, orderStatus, reservedCoupons } from '@/src/db/schema'
import { eq, and, like, or, inArray, lt } from 'drizzle-orm';
import dayjs from 'dayjs';
const createCouponBodySchema = z.object({
couponCode: z.string().optional(),
isUserBased: z.boolean().optional(),
discountPercent: z.number().optional(),
flatDiscount: z.number().optional(),
minOrder: z.number().optional(),
targetUser: z.number().optional(),
productIds: z.array(z.number()).optional().nullable(),
applicableUsers: z.array(z.number()).optional(),
applicableProducts: z.array(z.number()).optional(),
maxValue: z.number().optional(),
isApplyForAll: z.boolean().optional(),
validTill: z.string().optional(),
maxLimitForUser: z.number().optional(),
exclusiveApply: z.boolean().optional(),
});
const validateCouponBodySchema = z.object({
code: z.string(),
userId: z.number(),
orderAmount: z.number(),
});
export const couponRouter = router({
create: protectedProcedure
.input(createCouponBodySchema)
.mutation(async ({ input, ctx }) => {
const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input;
// Validation: ensure at least one discount type is provided
if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) {
throw new Error("Either discountPercent or flatDiscount must be provided (but not both)");
}
// If user-based, applicableUsers is required (unless it's apply for all)
if (isUserBased && (!applicableUsers || applicableUsers.length === 0) && !isApplyForAll) {
throw new Error("applicableUsers is required for user-based coupons (or set isApplyForAll to true)");
}
// Cannot be both user-based and apply for all
if (isUserBased && isApplyForAll) {
throw new Error("Cannot be both user-based and apply for all users");
}
// If applicableUsers is provided, verify users exist
if (applicableUsers && applicableUsers.length > 0) {
const existingUsers = await db.query.users.findMany({
where: inArray(users.id, applicableUsers),
columns: { id: true },
});
if (existingUsers.length !== applicableUsers.length) {
throw new Error("Some applicable users not found");
}
}
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id;
if (!staffUserId) {
throw new Error("Unauthorized");
}
// Generate coupon code if not provided
let finalCouponCode = couponCode;
if (!finalCouponCode) {
// Generate a unique coupon code
const timestamp = Date.now().toString().slice(-6);
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
finalCouponCode = `MF${timestamp}${random}`;
}
// Check if coupon code already exists
const existingCoupon = await db.query.coupons.findFirst({
where: eq(coupons.couponCode, finalCouponCode),
});
if (existingCoupon) {
throw new Error("Coupon code already exists");
}
const result = await db.insert(coupons).values({
couponCode: finalCouponCode,
isUserBased: isUserBased || false,
discountPercent: discountPercent?.toString(),
flatDiscount: flatDiscount?.toString(),
minOrder: minOrder?.toString(),
productIds: productIds || null,
createdBy: staffUserId,
maxValue: maxValue?.toString(),
isApplyForAll: isApplyForAll || false,
validTill: validTill ? dayjs(validTill).toDate() : undefined,
maxLimitForUser: maxLimitForUser,
exclusiveApply: exclusiveApply || false,
}).returning();
const coupon = result[0];
// Insert applicable users
if (applicableUsers && applicableUsers.length > 0) {
await db.insert(couponApplicableUsers).values(
applicableUsers.map(userId => ({
couponId: coupon.id,
userId,
}))
);
}
// Insert applicable products
if (applicableProducts && applicableProducts.length > 0) {
await db.insert(couponApplicableProducts).values(
applicableProducts.map(productId => ({
couponId: coupon.id,
productId,
}))
);
}
return coupon;
}),
getAll: protectedProcedure
.input(z.object({
cursor: z.number().optional(),
limit: z.number().default(50),
search: z.string().optional(),
}))
.query(async ({ input }) => {
const { cursor, limit, search } = input;
let whereCondition = undefined;
const conditions = [];
if (cursor) {
conditions.push(lt(coupons.id, cursor));
}
if (search && search.trim()) {
conditions.push(like(coupons.couponCode, `%${search}%`));
}
if (conditions.length > 0) {
whereCondition = and(...conditions);
}
const result = await db.query.coupons.findMany({
where: whereCondition,
with: {
creator: true,
applicableUsers: {
with: {
user: true,
},
},
applicableProducts: {
with: {
product: true,
},
},
},
orderBy: (coupons, { desc }) => [desc(coupons.createdAt)],
limit: limit + 1,
});
const hasMore = result.length > limit;
const couponsList = hasMore ? result.slice(0, limit) : result;
const nextCursor = hasMore ? result[result.length - 1].id : undefined;
return { coupons: couponsList, nextCursor };
}),
getById: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const couponId = input.id;
const result = await db.query.coupons.findFirst({
where: eq(coupons.id, couponId),
with: {
creator: true,
applicableUsers: {
with: {
user: true,
},
},
applicableProducts: {
with: {
product: true,
},
},
},
});
if (!result) {
throw new Error("Coupon not found");
}
return {
...result,
productIds: (result.productIds as number[]) || undefined,
applicableUsers: result.applicableUsers.map(au => au.user),
applicableProducts: result.applicableProducts.map(ap => ap.product),
};
}),
update: protectedProcedure
.input(z.object({
id: z.number(),
updates: createCouponBodySchema.extend({
isInvalidated: z.boolean().optional(),
}),
}))
.mutation(async ({ input }) => {
const { id, updates } = input;
// Validation: ensure discount types are valid
if (updates.discountPercent !== undefined && updates.flatDiscount !== undefined) {
if (updates.discountPercent && updates.flatDiscount) {
throw new Error("Cannot have both discountPercent and flatDiscount");
}
}
// If updating to user-based, applicableUsers is required
if (updates.isUserBased && (!updates.applicableUsers || updates.applicableUsers.length === 0)) {
const existingCount = await db.$count(couponApplicableUsers, eq(couponApplicableUsers.couponId, id));
if (existingCount === 0) {
throw new Error("applicableUsers is required for user-based coupons");
}
}
// If applicableUsers is provided, verify users exist
if (updates.applicableUsers && updates.applicableUsers.length > 0) {
const existingUsers = await db.query.users.findMany({
where: inArray(users.id, updates.applicableUsers),
columns: { id: true },
});
if (existingUsers.length !== updates.applicableUsers.length) {
throw new Error("Some applicable users not found");
}
}
const updateData: any = { ...updates };
delete updateData.applicableUsers; // Remove since we use couponApplicableUsers table
if (updates.discountPercent !== undefined) {
updateData.discountPercent = updates.discountPercent?.toString();
}
if (updates.flatDiscount !== undefined) {
updateData.flatDiscount = updates.flatDiscount?.toString();
}
if (updates.minOrder !== undefined) {
updateData.minOrder = updates.minOrder?.toString();
}
if (updates.maxValue !== undefined) {
updateData.maxValue = updates.maxValue?.toString();
}
if (updates.validTill !== undefined) {
updateData.validTill = updates.validTill ? dayjs(updates.validTill).toDate() : null;
}
const result = await db.update(coupons)
.set(updateData)
.where(eq(coupons.id, id))
.returning();
if (result.length === 0) {
throw new Error("Coupon not found");
}
console.log('updated coupon successfully')
// Update applicable users: delete existing and insert new
if (updates.applicableUsers !== undefined) {
await db.delete(couponApplicableUsers).where(eq(couponApplicableUsers.couponId, id));
if (updates.applicableUsers.length > 0) {
await db.insert(couponApplicableUsers).values(
updates.applicableUsers.map(userId => ({
couponId: id,
userId,
}))
);
}
}
// Update applicable products: delete existing and insert new
if (updates.applicableProducts !== undefined) {
await db.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, id));
if (updates.applicableProducts.length > 0) {
await db.insert(couponApplicableProducts).values(
updates.applicableProducts.map(productId => ({
couponId: id,
productId,
}))
);
}
}
return result[0];
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { id } = input;
const result = await db.update(coupons)
.set({ isInvalidated: true })
.where(eq(coupons.id, id))
.returning();
if (result.length === 0) {
throw new Error("Coupon not found");
}
return { message: "Coupon invalidated successfully" };
}),
validate: protectedProcedure
.input(validateCouponBodySchema)
.query(async ({ input }) => {
const { code, userId, orderAmount } = input;
if (!code || typeof code !== 'string') {
return { valid: false, message: "Invalid coupon code" };
}
const coupon = await db.query.coupons.findFirst({
where: and(
eq(coupons.couponCode, code.toUpperCase()),
eq(coupons.isInvalidated, false)
),
});
if (!coupon) {
return { valid: false, message: "Coupon not found or invalidated" };
}
// Check expiry date
if (coupon.validTill && new Date(coupon.validTill) < new Date()) {
return { valid: false, message: "Coupon has expired" };
}
// Check if coupon applies to all users or specific user
if (!coupon.isApplyForAll && !coupon.isUserBased) {
return { valid: false, message: "Coupon is not available for use" };
}
// Check minimum order amount
const minOrderValue = coupon.minOrder ? parseFloat(coupon.minOrder) : 0;
if (minOrderValue > 0 && orderAmount < minOrderValue) {
return { valid: false, message: `Minimum order amount is ${minOrderValue}` };
}
// Calculate discount
let discountAmount = 0;
if (coupon.discountPercent) {
const percent = parseFloat(coupon.discountPercent);
discountAmount = (orderAmount * percent) / 100;
} else if (coupon.flatDiscount) {
discountAmount = parseFloat(coupon.flatDiscount);
}
// Apply max value limit
const maxValueLimit = coupon.maxValue ? parseFloat(coupon.maxValue) : 0;
if (maxValueLimit > 0 && discountAmount > maxValueLimit) {
discountAmount = maxValueLimit;
}
return {
valid: true,
discountAmount,
coupon: {
id: coupon.id,
discountPercent: coupon.discountPercent,
flatDiscount: coupon.flatDiscount,
maxValue: coupon.maxValue,
}
};
}),
generateCancellationCoupon: protectedProcedure
.input(
z.object({
orderId: z.number(),
})
)
.mutation(async ({ input, ctx }) => {
const { orderId } = input;
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id;
if (!staffUserId) {
throw new Error("Unauthorized");
}
// Find the order with user and order status information
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: {
user: true,
orderStatus: true,
},
});
if (!order) {
throw new Error("Order not found");
}
// Check if order is cancelled (check if any status entry has isCancelled: true)
// const isOrderCancelled = order.orderStatus?.some(status => status.isCancelled) || false;
// if (!isOrderCancelled) {
// throw new Error("Order is not cancelled");
// }
// // Check if payment method is COD
// if (order.isCod) {
// throw new Error("Can't generate refund coupon for CoD Order");
// }
// Verify user exists
if (!order.user) {
throw new Error("User not found for this order");
}
// Generate coupon code: first 3 letters of user name or mobile + orderId
const userNamePrefix = (order.user.name || order.user.mobile || 'USR').substring(0, 3).toUpperCase();
const couponCode = `${userNamePrefix}${orderId}`;
// Check if coupon code already exists
const existingCoupon = await db.query.coupons.findFirst({
where: eq(coupons.couponCode, couponCode),
});
if (existingCoupon) {
throw new Error("Coupon code already exists");
}
// Get order total amount
const orderAmount = parseFloat(order.totalAmount);
// Calculate expiry date (30 days from now)
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + 30);
// Create the coupon and update order status in a transaction
const coupon = await db.transaction(async (tx) => {
// Create the coupon
const result = await tx.insert(coupons).values({
couponCode,
isUserBased: true,
flatDiscount: orderAmount.toString(),
minOrder: orderAmount.toString(),
maxValue: orderAmount.toString(),
validTill: expiryDate,
maxLimitForUser: 1,
createdBy: staffUserId,
isApplyForAll: false,
}).returning();
const coupon = result[0];
// Insert applicable users
await tx.insert(couponApplicableUsers).values({
couponId: coupon.id,
userId: order.userId,
});
// Update order_status with refund coupon ID
await tx.update(orderStatus)
.set({ refundCouponId: coupon.id })
.where(eq(orderStatus.orderId, orderId));
return coupon;
});
return coupon;
}),
getReservedCoupons: protectedProcedure
.input(z.object({
cursor: z.number().optional(),
limit: z.number().default(50),
search: z.string().optional(),
}))
.query(async ({ input }) => {
const { cursor, limit, search } = input;
let whereCondition = undefined;
const conditions = [];
if (cursor) {
conditions.push(lt(reservedCoupons.id, cursor));
}
if (search && search.trim()) {
conditions.push(or(
like(reservedCoupons.secretCode, `%${search}%`),
like(reservedCoupons.couponCode, `%${search}%`)
));
}
if (conditions.length > 0) {
whereCondition = and(...conditions);
}
const result = await db.query.reservedCoupons.findMany({
where: whereCondition,
with: {
redeemedUser: true,
creator: true,
},
orderBy: (reservedCoupons, { desc }) => [desc(reservedCoupons.createdAt)],
limit: limit + 1, // Fetch one extra to check if there's more
});
const hasMore = result.length > limit;
const coupons = hasMore ? result.slice(0, limit) : result;
const nextCursor = hasMore ? result[result.length - 1].id : undefined;
return {
coupons,
nextCursor,
};
}),
createReservedCoupon: protectedProcedure
.input(createCouponBodySchema)
.mutation(async ({ input, ctx }) => {
const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input;
// Validation: ensure at least one discount type is provided
if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) {
throw new Error("Either discountPercent or flatDiscount must be provided (but not both)");
}
// For reserved coupons, applicableUsers is not used, as it's redeemed by one user
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id;
if (!staffUserId) {
throw new Error("Unauthorized");
}
// Generate secret code if not provided (use couponCode as base)
let secretCode = couponCode || `SECRET${Date.now().toString().slice(-6)}${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
// Check if secret code already exists
const existing = await db.query.reservedCoupons.findFirst({
where: eq(reservedCoupons.secretCode, secretCode),
});
if (existing) {
throw new Error("Secret code already exists");
}
const result = await db.insert(reservedCoupons).values({
secretCode,
couponCode: couponCode || `RESERVED${Date.now().toString().slice(-6)}`,
discountPercent: discountPercent?.toString(),
flatDiscount: flatDiscount?.toString(),
minOrder: minOrder?.toString(),
productIds,
maxValue: maxValue?.toString(),
validTill: validTill ? dayjs(validTill).toDate() : undefined,
maxLimitForUser,
exclusiveApply: exclusiveApply || false,
createdBy: staffUserId,
}).returning();
const coupon = result[0];
// Insert applicable products if provided
if (applicableProducts && applicableProducts.length > 0) {
await db.insert(couponApplicableProducts).values(
applicableProducts.map(productId => ({
couponId: coupon.id,
productId,
}))
);
}
return coupon;
}),
getUsersMiniInfo: protectedProcedure
.input(z.object({
search: z.string().optional(),
limit: z.number().min(1).max(50).default(20),
offset: z.number().min(0).default(0),
}))
.query(async ({ input }) => {
const { search, limit } = input;
let whereCondition = undefined;
if (search && search.trim()) {
whereCondition = or(
like(users.name, `%${search}%`),
like(users.mobile, `%${search}%`)
);
}
const userList = await db.query.users.findMany({
where: whereCondition,
columns: {
id: true,
name: true,
mobile: true,
},
limit: limit,
offset: input.offset,
orderBy: (users, { asc }) => [asc(users.name)],
});
return {
users: userList.map(user => ({
id: user.id,
name: user.name || 'Unknown',
mobile: user.mobile,
}))
};
}),
createCoupon: protectedProcedure
.input(z.object({
mobile: z.string().min(1, 'Mobile number is required'),
}))
.mutation(async ({ input, ctx }) => {
const { mobile } = input;
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id;
if (!staffUserId) {
throw new Error("Unauthorized");
}
// Clean mobile number (remove non-digits)
const cleanMobile = mobile.replace(/\D/g, '');
// Validate: exactly 10 digits
if (cleanMobile.length !== 10) {
throw new Error("Mobile number must be exactly 10 digits");
}
// Check if user exists, create if not
let user = await db.query.users.findFirst({
where: eq(users.mobile, cleanMobile),
});
if (!user) {
// Create new user
const [newUser] = await db.insert(users).values({
name: null,
email: null,
mobile: cleanMobile,
}).returning();
user = newUser;
}
// Generate unique coupon code
const timestamp = Date.now().toString().slice(-6);
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
const couponCode = `MF${cleanMobile.slice(-4)}${timestamp}${random}`;
// Check if coupon code already exists (very unlikely but safe)
const existingCode = await db.query.coupons.findFirst({
where: eq(coupons.couponCode, couponCode),
});
if (existingCode) {
throw new Error("Generated coupon code already exists - please try again");
}
// Create the coupon
const [coupon] = await db.insert(coupons).values({
couponCode,
isUserBased: true,
discountPercent: "20", // 20% discount
minOrder: "1000", // ₹1000 minimum order
maxValue: "500", // ₹500 maximum discount
maxLimitForUser: 1, // One-time use
isApplyForAll: false,
exclusiveApply: false,
createdBy: staffUserId,
validTill: dayjs().add(90, 'days').toDate(), // 90 days from now
}).returning();
// Associate coupon with user
await db.insert(couponApplicableUsers).values({
couponId: coupon.id,
userId: user.id,
});
return {
success: true,
coupon: {
id: coupon.id,
couponCode: coupon.couponCode,
userId: user.id,
userMobile: user.mobile,
discountPercent: 20,
minOrder: 1000,
maxValue: 500,
maxLimitForUser: 1,
},
};
}),
});

File diff suppressed because it is too large Load diff

View file

@ -1,146 +0,0 @@
import { router, protectedProcedure } from "@/src/trpc/trpc-index"
import { z } from "zod";
import { db } from "@/src/db/db_index"
import {
orders,
orderStatus,
payments,
refunds,
} from "@/src/db/schema";
import { and, eq } from "drizzle-orm";
import { ApiError } from "@/src/lib/api-error"
import { RazorpayPaymentService } from "@/src/lib/payments-utils"
const initiateRefundSchema = z
.object({
orderId: z.number(),
refundPercent: z.number().min(0).max(100).optional(),
refundAmount: z.number().min(0).optional(),
})
.refine(
(data) => {
const hasPercent = data.refundPercent !== undefined;
const hasAmount = data.refundAmount !== undefined;
return (hasPercent && !hasAmount) || (!hasPercent && hasAmount);
},
{
message:
"Provide either refundPercent or refundAmount, not both or neither",
}
);
export const adminPaymentsRouter = router({
initiateRefund: protectedProcedure
.input(initiateRefundSchema)
.mutation(async ({ input }) => {
try {
const { orderId, refundPercent, refundAmount } = input;
// Validate order exists
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
});
if (!order) {
throw new ApiError("Order not found", 404);
}
// Check if order is paid
const orderStatusRecord = await db.query.orderStatus.findFirst({
where: eq(orderStatus.orderId, orderId),
});
if(order.isCod) {
throw new ApiError("Order is a Cash On Delivery. Not eligible for refund")
}
if (
!orderStatusRecord ||
(orderStatusRecord.paymentStatus !== "success" &&
!(order.isCod && orderStatusRecord.isDelivered))
) {
throw new ApiError("Order payment not verified or not eligible for refund", 400);
}
// Calculate refund amount
let calculatedRefundAmount: number;
if (refundPercent !== undefined) {
calculatedRefundAmount =
(parseFloat(order.totalAmount) * refundPercent) / 100;
} else if (refundAmount !== undefined) {
calculatedRefundAmount = refundAmount;
if (calculatedRefundAmount > parseFloat(order.totalAmount)) {
throw new ApiError("Refund amount cannot exceed order total", 400);
}
} else {
throw new ApiError("Invalid refund parameters", 400);
}
let razorpayRefund = null;
let merchantRefundId = null;
// Get payment record for online payments
const payment = await db.query.payments.findFirst({
where: and(
eq(payments.orderId, orderId),
eq(payments.status, "success")
),
});
if (!payment || payment.status !== "success") {
throw new ApiError("Payment not found or not successful", 404);
}
const payload = payment.payload as any;
// Initiate Razorpay refund
razorpayRefund = await RazorpayPaymentService.initiateRefund(
payload.payment_id,
Math.round(calculatedRefundAmount * 100) // Convert to paisa
);
merchantRefundId = razorpayRefund.id;
// Check if refund already exists for this order
const existingRefund = await db.query.refunds.findFirst({
where: eq(refunds.orderId, orderId),
});
const refundStatus = "initiated";
if (existingRefund) {
// Update existing refund
await db
.update(refunds)
.set({
refundAmount: calculatedRefundAmount.toString(),
refundStatus,
merchantRefundId,
refundProcessedAt: order.isCod ? new Date() : null,
})
.where(eq(refunds.id, existingRefund.id));
} else {
// Insert new refund
await db
.insert(refunds)
.values({
orderId,
refundAmount: calculatedRefundAmount.toString(),
refundStatus,
merchantRefundId,
});
}
return {
refundId: merchantRefundId || `cod_${orderId}`,
amount: calculatedRefundAmount,
status: refundStatus,
message: order.isCod ? "COD refund processed successfully" : "Refund initiated successfully",
};
}
catch(e) {
console.log(e);
throw new ApiError("Failed to initiate refund")
}
}),
});

View file

@ -1,154 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { productAvailabilitySchedules } from '@/src/db/schema'
import { eq } from 'drizzle-orm';
import { refreshScheduleJobs } from '@/src/lib/automatedJobs';
const createScheduleSchema = z.object({
scheduleName: z.string().min(1, "Schedule name is required"),
time: z.string().min(1, "Time is required").regex(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, "Invalid time format. Use HH:MM"),
action: z.enum(['in', 'out']),
productIds: z.array(z.number().int().positive()).min(1, "At least one product is required"),
groupIds: z.array(z.number().int().positive()).default([]),
});
const updateScheduleSchema = z.object({
id: z.number().int().positive(),
updates: createScheduleSchema.partial().extend({
scheduleName: z.string().min(1).optional(),
productIds: z.array(z.number().int().positive()).optional(),
groupIds: z.array(z.number().int().positive()).optional(),
}),
});
export const productAvailabilitySchedulesRouter = router({
create: protectedProcedure
.input(createScheduleSchema)
.mutation(async ({ input, ctx }) => {
const { scheduleName, time, action, productIds, groupIds } = input;
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id;
if (!staffUserId) {
throw new Error("Unauthorized");
}
// Check if schedule name already exists
const existingSchedule = await db.query.productAvailabilitySchedules.findFirst({
where: eq(productAvailabilitySchedules.scheduleName, scheduleName),
});
if (existingSchedule) {
throw new Error("Schedule name already exists");
}
// Create schedule with arrays
const scheduleResult = await db.insert(productAvailabilitySchedules).values({
scheduleName,
time,
action,
productIds,
groupIds,
}).returning();
// Refresh cron jobs to include new schedule
await refreshScheduleJobs();
return scheduleResult[0];
}),
getAll: protectedProcedure
.query(async () => {
const schedules = await db.query.productAvailabilitySchedules.findMany({
orderBy: (productAvailabilitySchedules, { desc }) => [desc(productAvailabilitySchedules.createdAt)],
});
return schedules.map(schedule => ({
...schedule,
productCount: schedule.productIds.length,
groupCount: schedule.groupIds.length,
}));
}),
getById: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.query(async ({ input }) => {
const { id } = input;
const schedule = await db.query.productAvailabilitySchedules.findFirst({
where: eq(productAvailabilitySchedules.id, id),
});
if (!schedule) {
throw new Error("Schedule not found");
}
return schedule;
}),
update: protectedProcedure
.input(updateScheduleSchema)
.mutation(async ({ input }) => {
const { id, updates } = input;
// Check if schedule exists
const existingSchedule = await db.query.productAvailabilitySchedules.findFirst({
where: eq(productAvailabilitySchedules.id, id),
});
if (!existingSchedule) {
throw new Error("Schedule not found");
}
// Check schedule name uniqueness if being updated
if (updates.scheduleName && updates.scheduleName !== existingSchedule.scheduleName) {
const duplicateSchedule = await db.query.productAvailabilitySchedules.findFirst({
where: eq(productAvailabilitySchedules.scheduleName, updates.scheduleName),
});
if (duplicateSchedule) {
throw new Error("Schedule name already exists");
}
}
// Update schedule
const updateData: any = {};
if (updates.scheduleName !== undefined) updateData.scheduleName = updates.scheduleName;
if (updates.time !== undefined) updateData.time = updates.time;
if (updates.action !== undefined) updateData.action = updates.action;
if (updates.productIds !== undefined) updateData.productIds = updates.productIds;
if (updates.groupIds !== undefined) updateData.groupIds = updates.groupIds;
updateData.lastUpdated = new Date();
const result = await db.update(productAvailabilitySchedules)
.set(updateData)
.where(eq(productAvailabilitySchedules.id, id))
.returning();
if (result.length === 0) {
throw new Error("Failed to update schedule");
}
// Refresh cron jobs to reflect changes
await refreshScheduleJobs();
return result[0];
}),
delete: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input }) => {
const { id } = input;
const result = await db.delete(productAvailabilitySchedules)
.where(eq(productAvailabilitySchedules.id, id))
.returning();
if (result.length === 0) {
throw new Error("Schedule not found");
}
// Refresh cron jobs to remove deleted schedule
await refreshScheduleJobs();
return { message: "Schedule deleted successfully" };
}),
});

View file

@ -1,758 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { productInfo, units, specialDeals, productSlots, productTags, productReviews, users, productGroupInfo, productGroupMembership } from '@/src/db/schema'
import { eq, and, inArray, desc, sql } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error'
import { imageUploadS3, scaffoldAssetUrl, getOriginalUrlFromSignedUrl, claimUploadUrl } from '@/src/lib/s3-client'
import { deleteS3Image } from '@/src/lib/delete-image'
import type { SpecialDeal } from '@/src/db/types'
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
type CreateDeal = {
quantity: number;
price: number;
validTill: string;
};
export const productRouter = router({
getProducts: protectedProcedure
.query(async ({ ctx }) => {
const products = await db.query.productInfo.findMany({
orderBy: productInfo.name,
with: {
unit: true,
store: true,
},
});
// Generate signed URLs for all product images
const productsWithSignedUrls = await Promise.all(
products.map(async (product) => ({
...product,
images: scaffoldAssetUrl((product.images as string[]) || []),
}))
);
return {
products: productsWithSignedUrls,
count: productsWithSignedUrls.length,
};
}),
getProductById: protectedProcedure
.input(z.object({
id: z.number(),
}))
.query(async ({ input, ctx }) => {
const { id } = input;
const product = await db.query.productInfo.findFirst({
where: eq(productInfo.id, id),
with: {
unit: true,
},
});
if (!product) {
throw new ApiError("Product not found", 404);
}
// Fetch special deals for this product
const deals = await db.query.specialDeals.findMany({
where: eq(specialDeals.productId, id),
orderBy: specialDeals.quantity,
});
// Fetch associated tags for this product
const productTagsData = await db.query.productTags.findMany({
where: eq(productTags.productId, id),
with: {
tag: true,
},
});
// Generate signed URLs for product images
const productWithSignedUrls = {
...product,
images: scaffoldAssetUrl((product.images as string[]) || []),
deals,
tags: productTagsData.map(pt => pt.tag),
};
return {
product: productWithSignedUrls,
};
}),
deleteProduct: protectedProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { id } = input;
const [deletedProduct] = await db
.delete(productInfo)
.where(eq(productInfo.id, id))
.returning();
if (!deletedProduct) {
throw new ApiError("Product not found", 404);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: "Product deleted successfully",
};
}),
createProduct: protectedProcedure
.input(z.object({
name: z.string().min(1),
shortDescription: z.string().optional(),
longDescription: z.string().optional(),
unitId: z.number(),
storeId: z.number(),
price: z.number(),
marketPrice: z.number().optional(),
incrementStep: z.number().default(1),
productQuantity: z.number().default(1),
isSuspended: z.boolean().default(false),
isFlashAvailable: z.boolean().default(false),
flashPrice: z.number().optional(),
deals: z.array(z.object({
quantity: z.number(),
price: z.number(),
validTill: z.string(),
})).optional(),
tagIds: z.array(z.number()).optional(),
imageKeys: z.array(z.string()).optional(),
}))
.mutation(async ({ input }) => {
const {
name, shortDescription, longDescription, unitId, storeId,
price, marketPrice, incrementStep, productQuantity,
isSuspended, isFlashAvailable, flashPrice,
deals, tagIds, imageKeys
} = input;
// Validation
if (!name || !unitId || !storeId || !price) {
throw new ApiError("Name, unitId, storeId, and price are required", 400);
}
// Check for duplicate name
const existingProduct = await db.query.productInfo.findFirst({
where: eq(productInfo.name, name.trim()),
});
if (existingProduct) {
throw new ApiError("A product with this name already exists", 400);
}
// Check if unit exists
const unit = await db.query.units.findFirst({
where: eq(units.id, unitId),
});
if (!unit) {
throw new ApiError("Invalid unit ID", 400);
}
console.log(imageKeys)
const [newProduct] = await db
.insert(productInfo)
.values({
name: name.trim(),
shortDescription,
longDescription,
unitId,
storeId,
price: price.toString(),
marketPrice: marketPrice?.toString(),
incrementStep,
productQuantity,
isSuspended,
isFlashAvailable,
flashPrice: flashPrice?.toString(),
images: imageKeys || [],
})
.returning();
// Handle deals
if (deals && deals.length > 0) {
const dealInserts = deals.map(deal => ({
productId: newProduct.id,
quantity: deal.quantity.toString(),
price: deal.price.toString(),
validTill: new Date(deal.validTill),
}));
await db.insert(specialDeals).values(dealInserts);
}
// Handle tags
if (tagIds && tagIds.length > 0) {
const tagAssociations = tagIds.map(tagId => ({
productId: newProduct.id,
tagId,
}));
await db.insert(productTags).values(tagAssociations);
}
// Claim upload URLs
if (imageKeys && imageKeys.length > 0) {
for (const key of imageKeys) {
try {
await claimUploadUrl(key);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${key}`, e);
}
}
}
scheduleStoreInitialization();
return {
product: newProduct,
message: "Product created successfully",
};
}),
updateProduct: protectedProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1).optional(),
shortDescription: z.string().optional(),
longDescription: z.string().optional(),
unitId: z.number().optional(),
storeId: z.number().optional(),
price: z.number().optional(),
marketPrice: z.number().optional(),
incrementStep: z.number().optional(),
productQuantity: z.number().optional(),
isSuspended: z.boolean().optional(),
isFlashAvailable: z.boolean().optional(),
flashPrice: z.number().optional(),
deals: z.array(z.object({
quantity: z.number(),
price: z.number(),
validTill: z.string(),
})).optional(),
tagIds: z.array(z.number()).optional(),
newImageKeys: z.array(z.string()).optional(),
imagesToDelete: z.array(z.string()).optional(),
}))
.mutation(async ({ input }) => {
const { id, newImageKeys, imagesToDelete, deals, tagIds, ...updateData } = input;
// Get current product
const currentProduct = await db.query.productInfo.findFirst({
where: eq(productInfo.id, id),
});
if (!currentProduct) {
throw new ApiError("Product not found", 404);
}
// Handle image deletions
let currentImages = (currentProduct.images as string[]) || [];
if (imagesToDelete && imagesToDelete.length > 0) {
for (const imageUrl of imagesToDelete) {
try {
await deleteS3Image(imageUrl);
} catch (e) {
console.error(`Failed to delete image: ${imageUrl}`, e);
}
}
currentImages = currentImages.filter(img => {
//!imagesToDelete.includes(img)
const isRemoved = imagesToDelete.some(item => item.includes(img));
return !isRemoved;
});
}
// Add new images
if (newImageKeys && newImageKeys.length > 0) {
currentImages = [...currentImages, ...newImageKeys];
for (const key of newImageKeys) {
try {
await claimUploadUrl(key);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${key}`, e);
}
}
}
// Update product - convert numeric fields to strings for PostgreSQL numeric type
const { price, marketPrice, flashPrice, ...otherData } = updateData;
const [updatedProduct] = await db
.update(productInfo)
.set({
...otherData,
...(price !== undefined && { price: price.toString() }),
...(marketPrice !== undefined && { marketPrice: marketPrice.toString() }),
...(flashPrice !== undefined && { flashPrice: flashPrice.toString() }),
images: currentImages,
})
.where(eq(productInfo.id, id))
.returning();
// Handle deals update
if (deals !== undefined) {
await db.delete(specialDeals).where(eq(specialDeals.productId, id));
if (deals.length > 0) {
const dealInserts = deals.map(deal => ({
productId: id,
quantity: deal.quantity.toString(),
price: deal.price.toString(),
validTill: new Date(deal.validTill),
}));
await db.insert(specialDeals).values(dealInserts);
}
}
// Handle tags update
if (tagIds !== undefined) {
await db.delete(productTags).where(eq(productTags.productId, id));
if (tagIds.length > 0) {
const tagAssociations = tagIds.map(tagId => ({
productId: id,
tagId,
}));
await db.insert(productTags).values(tagAssociations);
}
}
scheduleStoreInitialization();
return {
product: updatedProduct,
message: "Product updated successfully",
};
}),
toggleOutOfStock: protectedProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { id } = input;
const product = await db.query.productInfo.findFirst({
where: eq(productInfo.id, id),
});
if (!product) {
throw new ApiError("Product not found", 404);
}
const [updatedProduct] = await db
.update(productInfo)
.set({
isOutOfStock: !product.isOutOfStock,
})
.where(eq(productInfo.id, id))
.returning();
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
product: updatedProduct,
message: `Product marked as ${updatedProduct.isOutOfStock ? 'out of stock' : 'in stock'}`,
};
}),
updateSlotProducts: protectedProcedure
.input(z.object({
slotId: z.string(),
productIds: z.array(z.string()),
}))
.mutation(async ({ input, ctx }) => {
const { slotId, productIds } = input;
if (!Array.isArray(productIds)) {
throw new ApiError("productIds must be an array", 400);
}
// Get current associations
const currentAssociations = await db.query.productSlots.findMany({
where: eq(productSlots.slotId, parseInt(slotId)),
columns: {
productId: true,
},
});
const currentProductIds = currentAssociations.map(assoc => assoc.productId);
const newProductIds = productIds.map((id: string) => parseInt(id));
// Find products to add and remove
const productsToAdd = newProductIds.filter(id => !currentProductIds.includes(id));
const productsToRemove = currentProductIds.filter(id => !newProductIds.includes(id));
// Remove associations for products that are no longer selected
if (productsToRemove.length > 0) {
await db.delete(productSlots).where(
and(
eq(productSlots.slotId, parseInt(slotId)),
inArray(productSlots.productId, productsToRemove)
)
);
}
// Add associations for newly selected products
if (productsToAdd.length > 0) {
const newAssociations = productsToAdd.map(productId => ({
productId,
slotId: parseInt(slotId),
}));
await db.insert(productSlots).values(newAssociations);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: "Slot products updated successfully",
added: productsToAdd.length,
removed: productsToRemove.length,
};
}),
getSlotProductIds: protectedProcedure
.input(z.object({
slotId: z.string(),
}))
.query(async ({ input, ctx }) => {
const { slotId } = input;
const associations = await db.query.productSlots.findMany({
where: eq(productSlots.slotId, parseInt(slotId)),
columns: {
productId: true,
},
});
const productIds = associations.map(assoc => assoc.productId);
return {
productIds,
};
}),
getSlotsProductIds: protectedProcedure
.input(z.object({
slotIds: z.array(z.number()),
}))
.query(async ({ input, ctx }) => {
const { slotIds } = input;
if (!Array.isArray(slotIds)) {
throw new ApiError("slotIds must be an array", 400);
}
if (slotIds.length === 0) {
return {};
}
// Fetch all associations for the requested slots
const associations = await db.query.productSlots.findMany({
where: inArray(productSlots.slotId, slotIds),
columns: {
slotId: true,
productId: true,
},
});
// Group by slotId
const result = associations.reduce((acc, assoc) => {
if (!acc[assoc.slotId]) {
acc[assoc.slotId] = [];
}
acc[assoc.slotId].push(assoc.productId);
return acc;
}, {} as Record<number, number[]>);
// Ensure all requested slots have entries (even if empty)
slotIds.forEach(slotId => {
if (!result[slotId]) {
result[slotId] = [];
}
});
return result;
}),
getProductReviews: protectedProcedure
.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,
adminResponse: productReviews.adminResponse,
adminResponseImages: productReviews.adminResponseImages,
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[]) || []),
signedAdminImageUrls: scaffoldAssetUrl((review.adminResponseImages 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 };
}),
respondToReview: protectedProcedure
.input(z.object({
reviewId: z.number().int().positive(),
adminResponse: z.string().optional(),
adminResponseImages: z.array(z.string()).optional().default([]),
uploadUrls: z.array(z.string()).optional().default([]),
}))
.mutation(async ({ input }) => {
const { reviewId, adminResponse, adminResponseImages, uploadUrls } = input;
const [updatedReview] = await db
.update(productReviews)
.set({
adminResponse,
adminResponseImages,
})
.where(eq(productReviews.id, reviewId))
.returning();
if (!updatedReview) {
throw new ApiError('Review not found', 404);
}
// Claim upload URLs
if (uploadUrls && uploadUrls.length > 0) {
// const { claimUploadUrl } = await import('@/src/lib/s3-client');
await Promise.all(uploadUrls.map(url => claimUploadUrl(url)));
}
return { success: true, review: updatedReview };
}),
getGroups: protectedProcedure
.query(async ({ ctx }) => {
const groups = await db.query.productGroupInfo.findMany({
with: {
memberships: {
with: {
product: true,
},
},
},
orderBy: desc(productGroupInfo.createdAt),
});
return {
groups: groups.map(group => ({
...group,
products: group.memberships.map(m => m.product),
productCount: group.memberships.length,
})),
};
}),
createGroup: protectedProcedure
.input(z.object({
group_name: z.string().min(1),
description: z.string().optional(),
product_ids: z.array(z.number()).default([]),
}))
.mutation(async ({ input, ctx }) => {
const { group_name, description, product_ids } = input;
const [newGroup] = await db
.insert(productGroupInfo)
.values({
groupName: group_name,
description,
})
.returning();
if (product_ids.length > 0) {
const memberships = product_ids.map(productId => ({
productId,
groupId: newGroup.id,
}));
await db.insert(productGroupMembership).values(memberships);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
group: newGroup,
message: 'Group created successfully',
};
}),
updateGroup: protectedProcedure
.input(z.object({
id: z.number(),
group_name: z.string().optional(),
description: z.string().optional(),
product_ids: z.array(z.number()).optional(),
}))
.mutation(async ({ input, ctx }) => {
const { id, group_name, description, product_ids } = input;
const updateData: any = {};
if (group_name !== undefined) updateData.groupName = group_name;
if (description !== undefined) updateData.description = description;
const [updatedGroup] = await db
.update(productGroupInfo)
.set(updateData)
.where(eq(productGroupInfo.id, id))
.returning();
if (!updatedGroup) {
throw new ApiError('Group not found', 404);
}
if (product_ids !== undefined) {
// Delete existing memberships
await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id));
// Insert new memberships
if (product_ids.length > 0) {
const memberships = product_ids.map(productId => ({
productId,
groupId: id,
}));
await db.insert(productGroupMembership).values(memberships);
}
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
group: updatedGroup,
message: 'Group updated successfully',
};
}),
deleteGroup: protectedProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { id } = input;
// Delete memberships first
await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id));
// Delete group
const [deletedGroup] = await db
.delete(productGroupInfo)
.where(eq(productGroupInfo.id, id))
.returning();
if (!deletedGroup) {
throw new ApiError('Group not found', 404);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: 'Group deleted successfully',
};
}),
updateProductPrices: protectedProcedure
.input(z.object({
updates: z.array(z.object({
productId: z.number(),
price: z.number().optional(),
marketPrice: z.number().nullable().optional(),
flashPrice: z.number().nullable().optional(),
isFlashAvailable: z.boolean().optional(),
})),
}))
.mutation(async ({ input, ctx }) => {
const { updates } = input;
if (updates.length === 0) {
throw new ApiError('No updates provided', 400);
}
// Validate that all productIds exist
const productIds = updates.map(u => u.productId);
const existingProducts = await db.query.productInfo.findMany({
where: inArray(productInfo.id, productIds),
columns: { id: true },
});
const existingIds = new Set(existingProducts.map(p => p.id));
const invalidIds = productIds.filter(id => !existingIds.has(id));
if (invalidIds.length > 0) {
throw new ApiError(`Invalid product IDs: ${invalidIds.join(', ')}`, 400);
}
// Perform batch update
const updatePromises = updates.map(async (update) => {
const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update;
const updateData: any = {};
if (price !== undefined) updateData.price = price;
if (marketPrice !== undefined) updateData.marketPrice = marketPrice;
if (flashPrice !== undefined) updateData.flashPrice = flashPrice;
if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable;
return db
.update(productInfo)
.set(updateData)
.where(eq(productInfo.id, productId));
});
await Promise.all(updatePromises);
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: `Updated prices for ${updates.length} product(s)`,
updatedCount: updates.length,
};
}),
});

View file

@ -1,610 +0,0 @@
import { router, protectedProcedure } from "@/src/trpc/trpc-index"
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { db } from "@/src/db/db_index"
import { deliverySlotInfo, productSlots, productInfo, vendorSnippets, productGroupInfo } from "@/src/db/schema"
import { eq, inArray, and, desc } from "drizzle-orm";
import { ApiError } from "@/src/lib/api-error"
import { appUrl } from "@/src/lib/env-exporter"
import redisClient from "@/src/lib/redis-client"
import { getSlotSequenceKey } from "@/src/lib/redisKeyGetters"
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
interface CachedDeliverySequence {
[userId: string]: number[];
}
const cachedSequenceSchema = z.record(z.string(), z.array(z.number()));
const createSlotSchema = z.object({
deliveryTime: z.string(),
freezeTime: z.string(),
isActive: z.boolean().optional(),
productIds: z.array(z.number()).optional(),
vendorSnippets: z.array(z.object({
name: z.string().min(1),
productIds: z.array(z.number().int().positive()).min(1),
validTill: z.string().optional(),
})).optional(),
groupIds: z.array(z.number()).optional(),
});
const getSlotByIdSchema = z.object({
id: z.number(),
});
const updateSlotSchema = z.object({
id: z.number(),
deliveryTime: z.string(),
freezeTime: z.string(),
isActive: z.boolean().optional(),
productIds: z.array(z.number()).optional(),
vendorSnippets: z.array(z.object({
name: z.string().min(1),
productIds: z.array(z.number().int().positive()).min(1),
validTill: z.string().optional(),
})).optional(),
groupIds: z.array(z.number()).optional(),
});
const deleteSlotSchema = z.object({
id: z.number(),
});
const getDeliverySequenceSchema = z.object({
id: z.string(),
});
const updateDeliverySequenceSchema = z.object({
id: z.number(),
// deliverySequence: z.array(z.number()),
deliverySequence: z.any(),
});
export const slotsRouter = router({
// Exact replica of GET /av/slots
getAll: protectedProcedure.query(async ({ ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const slots = await db.query.deliverySlotInfo
.findMany({
where: eq(deliverySlotInfo.isActive, true),
orderBy: desc(deliverySlotInfo.deliveryTime),
with: {
productSlots: {
with: {
product: {
columns: {
id: true,
name: true,
images: true,
},
},
},
},
},
})
.then((slots) =>
slots.map((slot) => ({
...slot,
deliverySequence: slot.deliverySequence as number[],
products: slot.productSlots.map((ps) => ps.product),
}))
);
return {
slots,
count: slots.length,
};
}),
// Exact replica of POST /av/products/slots/product-ids
getSlotsProductIds: protectedProcedure
.input(z.object({ slotIds: z.array(z.number()) }))
.query(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const { slotIds } = input;
if (!Array.isArray(slotIds)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "slotIds must be an array",
});
}
if (slotIds.length === 0) {
return {};
}
// Fetch all associations for the requested slots
const associations = await db.query.productSlots.findMany({
where: inArray(productSlots.slotId, slotIds),
columns: {
slotId: true,
productId: true,
},
});
// Group by slotId
const result = associations.reduce((acc, assoc) => {
if (!acc[assoc.slotId]) {
acc[assoc.slotId] = [];
}
acc[assoc.slotId].push(assoc.productId);
return acc;
}, {} as Record<number, number[]>);
// Ensure all requested slots have entries (even if empty)
slotIds.forEach((slotId) => {
if (!result[slotId]) {
result[slotId] = [];
}
});
return result;
}),
// Exact replica of PUT /av/products/slots/:slotId/products
updateSlotProducts: protectedProcedure
.input(
z.object({
slotId: z.number(),
productIds: z.array(z.number()),
})
)
.mutation(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const { slotId, productIds } = input;
if (!Array.isArray(productIds)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "productIds must be an array",
});
}
// Get current associations
const currentAssociations = await db.query.productSlots.findMany({
where: eq(productSlots.slotId, slotId),
columns: {
productId: true,
},
});
const currentProductIds = currentAssociations.map(
(assoc) => assoc.productId
);
const newProductIds = productIds;
// Find products to add and remove
const productsToAdd = newProductIds.filter(
(id) => !currentProductIds.includes(id)
);
const productsToRemove = currentProductIds.filter(
(id) => !newProductIds.includes(id)
);
// Remove associations for products that are no longer selected
if (productsToRemove.length > 0) {
await db
.delete(productSlots)
.where(
and(
eq(productSlots.slotId, slotId),
inArray(productSlots.productId, productsToRemove)
)
);
}
// Add associations for newly selected products
if (productsToAdd.length > 0) {
const newAssociations = productsToAdd.map((productId) => ({
productId,
slotId,
}));
await db.insert(productSlots).values(newAssociations);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: "Slot products updated successfully",
added: productsToAdd.length,
removed: productsToRemove.length,
};
}),
createSlot: protectedProcedure
.input(createSlotSchema)
.mutation(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input;
// Validate required fields
if (!deliveryTime || !freezeTime) {
throw new ApiError("Delivery time and orders close time are required", 400);
}
const result = await db.transaction(async (tx) => {
// Create slot
const [newSlot] = await tx
.insert(deliverySlotInfo)
.values({
deliveryTime: new Date(deliveryTime),
freezeTime: new Date(freezeTime),
isActive: isActive !== undefined ? isActive : true,
groupIds: groupIds !== undefined ? groupIds : [],
})
.returning();
// Insert product associations if provided
if (productIds && productIds.length > 0) {
const associations = productIds.map((productId) => ({
productId,
slotId: newSlot.id,
}));
await tx.insert(productSlots).values(associations);
}
// Create vendor snippets if provided
let createdSnippets: any[] = [];
if (snippets && snippets.length > 0) {
for (const snippet of snippets) {
// Validate products exist
const products = await tx.query.productInfo.findMany({
where: inArray(productInfo.id, snippet.productIds),
});
if (products.length !== snippet.productIds.length) {
throw new ApiError(`One or more invalid product IDs in snippet "${snippet.name}"`, 400);
}
// Check if snippet name already exists
const existingSnippet = await tx.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, snippet.name),
});
if (existingSnippet) {
throw new ApiError(`Snippet name "${snippet.name}" already exists`, 400);
}
const [createdSnippet] = await tx.insert(vendorSnippets).values({
snippetCode: snippet.name,
slotId: newSlot.id,
productIds: snippet.productIds,
validTill: snippet.validTill ? new Date(snippet.validTill) : undefined,
}).returning();
createdSnippets.push(createdSnippet);
}
}
return {
slot: newSlot,
createdSnippets,
message: "Slot created successfully",
};
});
// Reinitialize stores to reflect changes (outside transaction)
scheduleStoreInitialization()
return result;
}),
getSlots: protectedProcedure.query(async ({ ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const slots = await db.query.deliverySlotInfo.findMany({
where: eq(deliverySlotInfo.isActive, true),
});
return {
slots,
count: slots.length,
};
}),
getSlotById: protectedProcedure
.input(getSlotByIdSchema)
.query(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const { id } = input;
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, id),
with: {
productSlots: {
with: {
product: {
columns: {
id: true,
name: true,
images: true,
},
},
},
},
vendorSnippets: true,
},
});
if (!slot) {
throw new ApiError("Slot not found", 404);
}
return {
slot: {
...slot,
deliverySequence: slot.deliverySequence as number[],
groupIds: slot.groupIds as number[],
products: slot.productSlots.map((ps) => ps.product),
vendorSnippets: slot.vendorSnippets?.map(snippet => ({
...snippet,
accessUrl: `${appUrl}/vendor-order-list?id=${snippet.snippetCode}`
})),
},
};
}),
updateSlot: protectedProcedure
.input(updateSlotSchema)
.mutation(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
try{
const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input;
if (!deliveryTime || !freezeTime) {
throw new ApiError("Delivery time and orders close time are required", 400);
}
// Filter groupIds to only include valid (existing) groups
let validGroupIds = groupIds;
if (groupIds && groupIds.length > 0) {
const existingGroups = await db.query.productGroupInfo.findMany({
where: inArray(productGroupInfo.id, groupIds),
columns: { id: true },
});
validGroupIds = existingGroups.map(g => g.id);
}
const result = await db.transaction(async (tx) => {
const [updatedSlot] = await tx
.update(deliverySlotInfo)
.set({
deliveryTime: new Date(deliveryTime),
freezeTime: new Date(freezeTime),
isActive: isActive !== undefined ? isActive : true,
groupIds: validGroupIds !== undefined ? validGroupIds : [],
})
.where(eq(deliverySlotInfo.id, id))
.returning();
if (!updatedSlot) {
throw new ApiError("Slot not found", 404);
}
// Update product associations
if (productIds !== undefined) {
// Delete existing associations
await tx.delete(productSlots).where(eq(productSlots.slotId, id));
// Insert new associations
if (productIds.length > 0) {
const associations = productIds.map((productId) => ({
productId,
slotId: id,
}));
await tx.insert(productSlots).values(associations);
}
}
// Create vendor snippets if provided
let createdSnippets: any[] = [];
if (snippets && snippets.length > 0) {
for (const snippet of snippets) {
// Validate products exist
const products = await tx.query.productInfo.findMany({
where: inArray(productInfo.id, snippet.productIds),
});
if (products.length !== snippet.productIds.length) {
throw new ApiError(`One or more invalid product IDs in snippet "${snippet.name}"`, 400);
}
// Check if snippet name already exists
const existingSnippet = await tx.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, snippet.name),
});
if (existingSnippet) {
throw new ApiError(`Snippet name "${snippet.name}" already exists`, 400);
}
const [createdSnippet] = await tx.insert(vendorSnippets).values({
snippetCode: snippet.name,
slotId: id,
productIds: snippet.productIds,
validTill: snippet.validTill ? new Date(snippet.validTill) : undefined,
}).returning();
createdSnippets.push(createdSnippet);
}
}
return {
slot: updatedSlot,
createdSnippets,
message: "Slot updated successfully",
};
});
// Reinitialize stores to reflect changes (outside transaction)
scheduleStoreInitialization()
return result;
}
catch(e) {
console.log(e)
throw new ApiError("Unable to Update Slot");
}
}),
deleteSlot: protectedProcedure
.input(deleteSlotSchema)
.mutation(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const { id } = input;
const [deletedSlot] = await db
.update(deliverySlotInfo)
.set({ isActive: false })
.where(eq(deliverySlotInfo.id, id))
.returning();
if (!deletedSlot) {
throw new ApiError("Slot not found", 404);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: "Slot deleted successfully",
};
}),
getDeliverySequence: protectedProcedure
.input(getDeliverySequenceSchema)
.query(async ({ input, ctx }) => {
const { id } = input;
const slotId = parseInt(id);
const cacheKey = getSlotSequenceKey(slotId);
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
const parsed = JSON.parse(cached);
const validated = cachedSequenceSchema.parse(parsed) as CachedDeliverySequence;
console.log('sending cached response')
return { deliverySequence: validated };
}
} catch (error) {
console.warn('Redis cache read/validation failed, falling back to DB:', error);
// Continue to DB fallback
}
// Fallback to DB
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, slotId),
});
if (!slot) {
throw new ApiError("Slot not found", 404);
}
const sequence = (slot.deliverySequence || {}) as CachedDeliverySequence;
// Cache the validated result
try {
const validated = cachedSequenceSchema.parse(sequence);
await redisClient.set(cacheKey, JSON.stringify(validated), 3600);
} catch (cacheError) {
console.warn('Redis cache write failed:', cacheError);
}
return { deliverySequence: sequence };
}),
updateDeliverySequence: protectedProcedure
.input(updateDeliverySequenceSchema)
.mutation(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const { id, deliverySequence } = input;
const [updatedSlot] = await db
.update(deliverySlotInfo)
.set({ deliverySequence })
.where(eq(deliverySlotInfo.id, id))
.returning({
id: deliverySlotInfo.id,
deliverySequence: deliverySlotInfo.deliverySequence,
});
if (!updatedSlot) {
throw new ApiError("Slot not found", 404);
}
// Cache the updated sequence
const cacheKey = getSlotSequenceKey(id);
try {
const validated = cachedSequenceSchema.parse(deliverySequence);
await redisClient.set(cacheKey, JSON.stringify(validated), 3600);
} catch (cacheError) {
console.warn('Redis cache write failed:', cacheError);
}
return {
slot: updatedSlot,
message: "Delivery sequence updated successfully",
};
}),
updateSlotCapacity: protectedProcedure
.input(z.object({
slotId: z.number(),
isCapacityFull: z.boolean(),
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const { slotId, isCapacityFull } = input;
const [updatedSlot] = await db
.update(deliverySlotInfo)
.set({ isCapacityFull })
.where(eq(deliverySlotInfo.id, slotId))
.returning();
if (!updatedSlot) {
throw new ApiError("Slot not found", 404);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
success: true,
slot: updatedSlot,
message: `Slot ${isCapacityFull ? 'marked as full capacity' : 'capacity reset'}`,
};
}),
});

View file

@ -1,242 +0,0 @@
import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { staffUsers, staffRoles, users, userDetails, orders } from '@/src/db/schema'
import { eq, or, ilike, and, lt, desc } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import { ApiError } from '@/src/lib/api-error'
import { signToken } from '@/src/lib/jwt-utils'
export const staffUserRouter = router({
login: publicProcedure
.input(z.object({
name: z.string(),
password: z.string(),
}))
.mutation(async ({ input }) => {
const { name, password } = input;
if (!name || !password) {
throw new ApiError('Name and password are required', 400);
}
const staff = await db.query.staffUsers.findFirst({
where: eq(staffUsers.name, name),
});
if (!staff) {
throw new ApiError('Invalid credentials', 401);
}
const isPasswordValid = await bcrypt.compare(password, staff.password);
if (!isPasswordValid) {
throw new ApiError('Invalid credentials', 401);
}
const token = await signToken(
{ staffId: staff.id, name: staff.name },
'30d'
);
return {
message: 'Login successful',
token,
staff: { id: staff.id, name: staff.name },
};
}),
getStaff: protectedProcedure
.query(async ({ ctx }) => {
const staff = await db.query.staffUsers.findMany({
columns: {
id: true,
name: true,
},
with: {
role: {
with: {
rolePermissions: {
with: {
permission: true,
},
},
},
},
},
});
// Transform the data to include role and permissions in a cleaner format
const transformedStaff = staff.map((user) => ({
id: user.id,
name: user.name,
role: user.role ? {
id: user.role.id,
name: user.role.roleName,
} : null,
permissions: user.role?.rolePermissions.map((rp) => ({
id: rp.permission.id,
name: rp.permission.permissionName,
})) || [],
}));
return {
staff: transformedStaff,
};
}),
getUsers: protectedProcedure
.input(z.object({
cursor: z.number().optional(),
limit: z.number().default(20),
search: z.string().optional(),
}))
.query(async ({ input }) => {
const { cursor, limit, search } = input;
let whereCondition = undefined;
if (search) {
whereCondition = or(
ilike(users.name, `%${search}%`),
ilike(users.email, `%${search}%`),
ilike(users.mobile, `%${search}%`)
);
}
if (cursor) {
const cursorCondition = lt(users.id, cursor);
whereCondition = whereCondition ? and(whereCondition, cursorCondition) : cursorCondition;
}
const allUsers = await db.query.users.findMany({
where: whereCondition,
with: {
userDetails: true,
},
orderBy: desc(users.id),
limit: limit + 1, // fetch one extra to check if there's more
});
const hasMore = allUsers.length > limit;
const usersToReturn = hasMore ? allUsers.slice(0, limit) : allUsers;
const formattedUsers = usersToReturn.map(user => ({
id: user.id,
name: user.name,
email: user.email,
mobile: user.mobile,
image: user.userDetails?.profileImage || null,
}));
return {
users: formattedUsers,
nextCursor: hasMore ? usersToReturn[usersToReturn.length - 1].id : undefined,
};
}),
getUserDetails: protectedProcedure
.input(z.object({ userId: z.number() }))
.query(async ({ input }) => {
const { userId } = input;
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
with: {
userDetails: true,
orders: {
orderBy: desc(orders.createdAt),
limit: 1,
},
},
});
if (!user) {
throw new ApiError("User not found", 404);
}
const lastOrder = user.orders[0];
return {
id: user.id,
name: user.name,
email: user.email,
mobile: user.mobile,
addedOn: user.createdAt,
lastOrdered: lastOrder?.createdAt || null,
isSuspended: user.userDetails?.isSuspended || false,
};
}),
updateUserSuspension: protectedProcedure
.input(z.object({ userId: z.number(), isSuspended: z.boolean() }))
.mutation(async ({ input }) => {
const { userId, isSuspended } = input;
await db
.insert(userDetails)
.values({ userId, isSuspended })
.onConflictDoUpdate({
target: userDetails.userId,
set: { isSuspended },
});
return { success: true };
}),
createStaffUser: protectedProcedure
.input(z.object({
name: z.string().min(1, 'Name is required'),
password: z.string().min(6, 'Password must be at least 6 characters'),
roleId: z.number().int().positive('Role is required'),
}))
.mutation(async ({ input, ctx }) => {
const { name, password, roleId } = input;
// Check if staff user already exists
const existingUser = await db.query.staffUsers.findFirst({
where: eq(staffUsers.name, name),
});
if (existingUser) {
throw new ApiError('Staff user with this name already exists', 409);
}
// Check if role exists
const role = await db.query.staffRoles.findFirst({
where: eq(staffRoles.id, roleId),
});
if (!role) {
throw new ApiError('Invalid role selected', 400);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create staff user
const [newUser] = await db.insert(staffUsers).values({
name: name.trim(),
password: hashedPassword,
staffRoleId: roleId,
}).returning();
return { success: true, user: { id: newUser.id, name: newUser.name } };
}),
getRoles: protectedProcedure
.query(async ({ ctx }) => {
const roles = await db.query.staffRoles.findMany({
columns: {
id: true,
roleName: true,
},
});
return {
roles: roles.map(role => ({
id: role.id,
name: role.roleName,
})),
};
}),
});

View file

@ -1,211 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { storeInfo, productInfo } from '@/src/db/schema'
import { eq, inArray } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error'
import { extractKeyFromPresignedUrl, deleteImageUtil, scaffoldAssetUrl } from '@/src/lib/s3-client'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
export const storeRouter = router({
getStores: protectedProcedure
.query(async ({ ctx }) => {
const stores = await db.query.storeInfo.findMany({
with: {
owner: true,
},
});
Promise.all(stores.map(async store => {
if(store.imageUrl)
store.imageUrl = scaffoldAssetUrl(store.imageUrl)
})).catch((e) => {
throw new ApiError("Unable to find store image urls")
}
)
return {
stores,
count: stores.length,
};
}),
getStoreById: protectedProcedure
.input(z.object({
id: z.number(),
}))
.query(async ({ input, ctx }) => {
const { id } = input;
const store = await db.query.storeInfo.findFirst({
where: eq(storeInfo.id, id),
with: {
owner: true,
},
});
if (!store) {
throw new ApiError("Store not found", 404);
}
store.imageUrl = scaffoldAssetUrl(store.imageUrl);
return {
store,
};
}),
createStore: protectedProcedure
.input(z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
imageUrl: z.string().optional(),
owner: z.number().min(1, "Owner is required"),
products: z.array(z.number()).optional(),
}))
.mutation(async ({ input, ctx }) => {
const { name, description, imageUrl, owner, products } = input;
// const imageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : undefined;
const imageKey = imageUrl
const [newStore] = await db
.insert(storeInfo)
.values({
name,
description,
imageUrl: imageKey,
owner,
})
.returning();
// Assign selected products to this store
if (products && products.length > 0) {
await db
.update(productInfo)
.set({ storeId: newStore.id })
.where(inArray(productInfo.id, products));
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
store: newStore,
message: "Store created successfully",
};
}),
updateStore: protectedProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
imageUrl: z.string().optional(),
owner: z.number().min(1, "Owner is required"),
products: z.array(z.number()).optional(),
}))
.mutation(async ({ input, ctx }) => {
const { id, name, description, imageUrl, owner, products } = input;
const existingStore = await db.query.storeInfo.findFirst({
where: eq(storeInfo.id, id),
});
if (!existingStore) {
throw new ApiError("Store not found", 404);
}
const oldImageKey = existingStore.imageUrl;
const newImageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : oldImageKey;
// Delete old image only if:
// 1. New image provided and keys are different, OR
// 2. No new image but old exists (clearing the image)
if (oldImageKey && (
(newImageKey && newImageKey !== oldImageKey) ||
(!newImageKey)
)) {
try {
await deleteImageUtil({keys: [oldImageKey]});
} catch (error) {
console.error('Failed to delete old image:', error);
// Continue with update even if deletion fails
}
}
const [updatedStore] = await db
.update(storeInfo)
.set({
name,
description,
imageUrl: newImageKey,
owner,
})
.where(eq(storeInfo.id, id))
.returning();
if (!updatedStore) {
throw new ApiError("Store not found", 404);
}
// Update products if provided
if (products) {
// First, set storeId to null for products not in the list but currently assigned to this store
await db
.update(productInfo)
.set({ storeId: null })
.where(eq(productInfo.storeId, id));
// Then, assign the selected products to this store
if (products.length > 0) {
await db
.update(productInfo)
.set({ storeId: id })
.where(inArray(productInfo.id, products));
}
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
store: updatedStore,
message: "Store updated successfully",
};
}),
deleteStore: protectedProcedure
.input(z.object({
storeId: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { storeId } = input;
const result = await db.transaction(async (tx) => {
// First, update all products of this store to set storeId to null
await tx
.update(productInfo)
.set({ storeId: null })
.where(eq(productInfo.storeId, storeId));
// Then delete the store
const [deletedStore] = await tx
.delete(storeInfo)
.where(eq(storeInfo.id, storeId))
.returning();
if (!deletedStore) {
throw new ApiError("Store not found", 404);
}
return {
message: "Store deleted successfully",
};
});
// Reinitialize stores to reflect changes (outside transaction)
scheduleStoreInitialization()
return result;
}),
});

View file

@ -1,214 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { productTagInfo } from '@/src/db/schema'
import { eq } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error'
import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client'
import { deleteS3Image } from '@/src/lib/delete-image'
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
export const tagRouter = router({
getTags: protectedProcedure
.query(async () => {
const tags = await db
.select()
.from(productTagInfo)
.orderBy(productTagInfo.tagName);
// Generate asset URLs for tag images
const tagsWithUrls = tags.map(tag => ({
...tag,
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
}));
return {
tags: tagsWithUrls,
message: "Tags retrieved successfully",
};
}),
getTagById: protectedProcedure
.input(z.object({
id: z.number(),
}))
.query(async ({ input }) => {
const tag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.id, input.id),
});
if (!tag) {
throw new ApiError("Tag not found", 404);
}
// Generate asset URL for tag image
const tagWithUrl = {
...tag,
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
};
return {
tag: tagWithUrl,
message: "Tag retrieved successfully",
};
}),
createTag: protectedProcedure
.input(z.object({
tagName: z.string().min(1),
tagDescription: z.string().optional(),
isDashboardTag: z.boolean().default(false),
relatedStores: z.array(z.number()).default([]),
imageKey: z.string().optional(),
}))
.mutation(async ({ input }) => {
const { tagName, tagDescription, isDashboardTag, relatedStores, imageKey } = input;
// Check for duplicate tag name
const existingTag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.tagName, tagName.trim()),
});
if (existingTag) {
throw new ApiError("A tag with this name already exists", 400);
}
const [newTag] = await db
.insert(productTagInfo)
.values({
tagName: tagName.trim(),
tagDescription,
imageUrl: imageKey || null,
isDashboardTag,
relatedStores,
})
.returning();
// Claim upload URL if image was provided
if (imageKey) {
try {
await claimUploadUrl(imageKey);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
}
}
scheduleStoreInitialization();
return {
tag: newTag,
message: "Tag created successfully",
};
}),
updateTag: protectedProcedure
.input(z.object({
id: z.number(),
tagName: z.string().min(1),
tagDescription: z.string().optional(),
isDashboardTag: z.boolean(),
relatedStores: z.array(z.number()),
imageKey: z.string().optional(),
deleteExistingImage: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { id, imageKey, deleteExistingImage, ...updateData } = input;
// Get current tag
const currentTag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.id, id),
});
if (!currentTag) {
throw new ApiError("Tag not found", 404);
}
let newImageUrl = currentTag.imageUrl;
// Handle image deletion
if (deleteExistingImage && currentTag.imageUrl) {
try {
await deleteS3Image(currentTag.imageUrl);
} catch (e) {
console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e);
}
newImageUrl = null;
}
// Handle new image upload (only if different from existing)
if (imageKey && imageKey !== currentTag.imageUrl) {
// Delete old image if exists and not already deleted
if (currentTag.imageUrl && !deleteExistingImage) {
try {
await deleteS3Image(currentTag.imageUrl);
} catch (e) {
console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e);
}
}
newImageUrl = imageKey;
// Claim upload URL
try {
await claimUploadUrl(imageKey);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
}
}
const [updatedTag] = await db
.update(productTagInfo)
.set({
tagName: updateData.tagName.trim(),
tagDescription: updateData.tagDescription,
isDashboardTag: updateData.isDashboardTag,
relatedStores: updateData.relatedStores,
imageUrl: newImageUrl,
})
.where(eq(productTagInfo.id, id))
.returning();
scheduleStoreInitialization();
return {
tag: updatedTag,
message: "Tag updated successfully",
};
}),
deleteTag: protectedProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ input }) => {
const { id } = input;
// Get tag to check for image
const tag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.id, id),
});
if (!tag) {
throw new ApiError("Tag not found", 404);
}
// Delete image from S3 if exists
if (tag.imageUrl) {
try {
await deleteS3Image(tag.imageUrl);
} catch (e) {
console.error(`Failed to delete image: ${tag.imageUrl}`, e);
}
}
// Delete tag (will fail if tag is assigned to products due to FK constraint)
await db.delete(productTagInfo).where(eq(productTagInfo.id, id));
scheduleStoreInitialization();
return {
message: "Tag deleted successfully",
};
}),
});
export type TagRouter = typeof tagRouter;

View file

@ -1,489 +0,0 @@
import { protectedProcedure } from '@/src/trpc/trpc-index';
import { z } from 'zod';
import { db } from '@/src/db/db_index';
import { users, complaints, orders, orderItems, notifCreds, unloggedUserTokens, userDetails, userIncidents } from '@/src/db/schema';
import { eq, sql, desc, asc, count, max, inArray } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error';
import { notificationQueue } from '@/src/lib/notif-job';
import { recomputeUserNegativityScore } from '@/src/stores/user-negativity-store';
async function createUserByMobile(mobile: string): Promise<typeof users.$inferSelect> {
// Clean mobile number (remove non-digits)
const cleanMobile = mobile.replace(/\D/g, '');
// Validate: exactly 10 digits
if (cleanMobile.length !== 10) {
throw new ApiError('Mobile number must be exactly 10 digits', 400);
}
// Check if user already exists
const [existingUser] = await db
.select()
.from(users)
.where(eq(users.mobile, cleanMobile))
.limit(1);
if (existingUser) {
throw new ApiError('User with this mobile number already exists', 409);
}
// Create user
const [newUser] = await db
.insert(users)
.values({
name: null,
email: null,
mobile: cleanMobile,
})
.returning();
return newUser;
}
export const userRouter = {
createUserByMobile: protectedProcedure
.input(z.object({
mobile: z.string().min(1, 'Mobile number is required'),
}))
.mutation(async ({ input }) => {
const newUser = await createUserByMobile(input.mobile);
return {
success: true,
data: newUser,
};
}),
getEssentials: protectedProcedure
.query(async () => {
const count = await db.$count(complaints, eq(complaints.isResolved, false));
return {
unresolvedComplaints: count || 0,
};
}),
getAllUsers: protectedProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(50),
cursor: z.number().optional(),
search: z.string().optional(),
}))
.query(async ({ input }) => {
const { limit, cursor, search } = input;
// Build where conditions
const whereConditions = [];
if (search && search.trim()) {
whereConditions.push(sql`${users.mobile} ILIKE ${`%${search.trim()}%`}`);
}
if (cursor) {
whereConditions.push(sql`${users.id} > ${cursor}`);
}
// Get users with filters applied
const usersList = await db
.select({
id: users.id,
name: users.name,
mobile: users.mobile,
createdAt: users.createdAt,
})
.from(users)
.where(whereConditions.length > 0 ? sql.join(whereConditions, sql` AND `) : undefined)
.orderBy(asc(users.id))
.limit(limit + 1); // Get one extra to determine if there's more
// Check if there are more results
const hasMore = usersList.length > limit;
const usersToReturn = hasMore ? usersList.slice(0, limit) : usersList;
// Get order stats for each user
const userIds = usersToReturn.map(u => u.id);
let orderCounts: { userId: number; totalOrders: number }[] = [];
let lastOrders: { userId: number; lastOrderDate: Date | null }[] = [];
let suspensionStatuses: { userId: number; isSuspended: boolean }[] = [];
if (userIds.length > 0) {
// Get total orders per user
orderCounts = await db
.select({
userId: orders.userId,
totalOrders: count(orders.id),
})
.from(orders)
.where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`)
.groupBy(orders.userId);
// Get last order date per user
lastOrders = await db
.select({
userId: orders.userId,
lastOrderDate: max(orders.createdAt),
})
.from(orders)
.where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`)
.groupBy(orders.userId);
// Get suspension status for each user
suspensionStatuses = await db
.select({
userId: userDetails.userId,
isSuspended: userDetails.isSuspended,
})
.from(userDetails)
.where(sql`${userDetails.userId} IN (${sql.join(userIds, sql`, `)})`);
}
// Create lookup maps
const orderCountMap = new Map(orderCounts.map(o => [o.userId, o.totalOrders]));
const lastOrderMap = new Map(lastOrders.map(o => [o.userId, o.lastOrderDate]));
const suspensionMap = new Map(suspensionStatuses.map(s => [s.userId, s.isSuspended]));
// Combine data
const usersWithStats = usersToReturn.map(user => ({
...user,
totalOrders: orderCountMap.get(user.id) || 0,
lastOrderDate: lastOrderMap.get(user.id) || null,
isSuspended: suspensionMap.get(user.id) ?? false,
}));
// Get next cursor
const nextCursor = hasMore ? usersToReturn[usersToReturn.length - 1].id : undefined;
return {
users: usersWithStats,
nextCursor,
hasMore,
};
}),
getUserDetails: protectedProcedure
.input(z.object({
userId: z.number(),
}))
.query(async ({ input }) => {
const { userId } = input;
// Get user info
const user = await db
.select({
id: users.id,
name: users.name,
mobile: users.mobile,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user || user.length === 0) {
throw new ApiError('User not found', 404);
}
// Get user suspension status
const userDetail = await db
.select({
isSuspended: userDetails.isSuspended,
})
.from(userDetails)
.where(eq(userDetails.userId, userId))
.limit(1);
// Get all orders for this user with order items count
const userOrders = await db
.select({
id: orders.id,
readableId: orders.readableId,
totalAmount: orders.totalAmount,
createdAt: orders.createdAt,
isFlashDelivery: orders.isFlashDelivery,
})
.from(orders)
.where(eq(orders.userId, userId))
.orderBy(desc(orders.createdAt));
// Get order status for each order
const orderIds = userOrders.map(o => o.id);
let orderStatuses: { orderId: number; isDelivered: boolean; isCancelled: boolean }[] = [];
if (orderIds.length > 0) {
const { orderStatus } = await import('@/src/db/schema');
orderStatuses = await db
.select({
orderId: orderStatus.orderId,
isDelivered: orderStatus.isDelivered,
isCancelled: orderStatus.isCancelled,
})
.from(orderStatus)
.where(sql`${orderStatus.orderId} IN (${sql.join(orderIds, sql`, `)})`);
}
// Get item counts for each order
const itemCounts = await db
.select({
orderId: orderItems.orderId,
itemCount: count(orderItems.id),
})
.from(orderItems)
.where(sql`${orderItems.orderId} IN (${sql.join(orderIds, sql`, `)})`)
.groupBy(orderItems.orderId);
// Create lookup maps
const statusMap = new Map(orderStatuses.map(s => [s.orderId, s]));
const itemCountMap = new Map(itemCounts.map(c => [c.orderId, c.itemCount]));
// Determine status string
const getStatus = (status: { isDelivered: boolean; isCancelled: boolean } | undefined) => {
if (!status) return 'pending';
if (status.isCancelled) return 'cancelled';
if (status.isDelivered) return 'delivered';
return 'pending';
};
// Combine data
const ordersWithDetails = userOrders.map(order => {
const status = statusMap.get(order.id);
return {
id: order.id,
readableId: order.readableId,
totalAmount: order.totalAmount,
createdAt: order.createdAt,
isFlashDelivery: order.isFlashDelivery,
status: getStatus(status),
itemCount: itemCountMap.get(order.id) || 0,
};
});
return {
user: {
...user[0],
isSuspended: userDetail[0]?.isSuspended ?? false,
},
orders: ordersWithDetails,
};
}),
updateUserSuspension: protectedProcedure
.input(z.object({
userId: z.number(),
isSuspended: z.boolean(),
}))
.mutation(async ({ input }) => {
const { userId, isSuspended } = input;
// Check if user exists
const user = await db
.select({ id: users.id })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user || user.length === 0) {
throw new ApiError('User not found', 404);
}
// Check if user_details record exists
const existingDetail = await db
.select({ id: userDetails.id })
.from(userDetails)
.where(eq(userDetails.userId, userId))
.limit(1);
if (existingDetail.length > 0) {
// Update existing record
await db
.update(userDetails)
.set({ isSuspended })
.where(eq(userDetails.userId, userId));
} else {
// Insert new record
await db
.insert(userDetails)
.values({
userId,
isSuspended,
});
}
return {
success: true,
message: `User ${isSuspended ? 'suspended' : 'unsuspended'} successfully`,
};
}),
getUsersForNotification: protectedProcedure
.input(z.object({
search: z.string().optional(),
}))
.query(async ({ input }) => {
const { search } = input;
// Get all users
let usersList;
if (search && search.trim()) {
usersList = await db
.select({
id: users.id,
name: users.name,
mobile: users.mobile,
})
.from(users)
.where(sql`${users.mobile} ILIKE ${`%${search.trim()}%`} OR ${users.name} ILIKE ${`%${search.trim()}%`}`);
} else {
usersList = await db
.select({
id: users.id,
name: users.name,
mobile: users.mobile,
})
.from(users);
}
// Get eligible users (have notif_creds entry)
const eligibleUsers = await db
.select({ userId: notifCreds.userId })
.from(notifCreds);
const eligibleSet = new Set(eligibleUsers.map(u => u.userId));
return {
users: usersList.map(user => ({
id: user.id,
name: user.name,
mobile: user.mobile,
isEligibleForNotif: eligibleSet.has(user.id),
})),
};
}),
sendNotification: protectedProcedure
.input(z.object({
userIds: z.array(z.number()).default([]),
title: z.string().min(1, 'Title is required'),
text: z.string().min(1, 'Message is required'),
imageUrl: z.string().optional(),
}))
.mutation(async ({ input }) => {
const { userIds, title, text, imageUrl } = input;
let tokens: string[] = [];
if (userIds.length === 0) {
// Send to all users - get tokens from both logged-in and unlogged users
const loggedInTokens = await db.select({ token: notifCreds.token }).from(notifCreds);
const unloggedTokens = await db.select({ token: unloggedUserTokens.token }).from(unloggedUserTokens);
tokens = [
...loggedInTokens.map(t => t.token),
...unloggedTokens.map(t => t.token)
];
} else {
// Send to specific users - get their tokens
const userTokens = await db
.select({ token: notifCreds.token })
.from(notifCreds)
.where(inArray(notifCreds.userId, userIds));
tokens = userTokens.map(t => t.token);
}
// Queue one job per token
let queuedCount = 0;
for (const token of tokens) {
try {
await notificationQueue.add('send-admin-notification', {
token,
title,
body: text,
imageUrl: imageUrl || null,
}, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
});
queuedCount++;
} catch (error) {
console.error(`Failed to queue notification for token:`, error);
}
}
return {
success: true,
message: `Notification queued for ${queuedCount} users`,
};
}),
getUserIncidents: protectedProcedure
.input(z.object({
userId: z.number(),
}))
.query(async ({ input }) => {
const { userId } = input;
const incidents = await db.query.userIncidents.findMany({
where: eq(userIncidents.userId, userId),
with: {
order: {
with: {
orderStatus: true,
},
},
addedBy: true,
},
orderBy: desc(userIncidents.dateAdded),
});
return {
incidents: incidents.map(incident => ({
id: incident.id,
userId: incident.userId,
orderId: incident.orderId,
dateAdded: incident.dateAdded,
adminComment: incident.adminComment,
addedBy: incident.addedBy?.name || 'Unknown',
negativityScore: incident.negativityScore,
orderStatus: incident.order?.orderStatus?.[0]?.isCancelled ? 'cancelled' : 'active',
})),
};
}),
addUserIncident: protectedProcedure
.input(z.object({
userId: z.number(),
orderId: z.number().optional(),
adminComment: z.string().optional(),
negativityScore: z.number().optional(),
}))
.mutation(async ({ input, ctx }) => {
const { userId, orderId, adminComment, negativityScore } = input;
const adminUserId = ctx.staffUser?.id;
if (!adminUserId) {
throw new ApiError('Admin user not authenticated', 401);
}
const incidentObj = { userId, orderId, adminComment, addedBy: adminUserId, negativityScore };
const [incident] = await db.insert(userIncidents)
.values({
...incidentObj,
})
.returning();
recomputeUserNegativityScore(userId);
return {
success: true,
data: incident,
};
}),
};

View file

@ -1,531 +0,0 @@
import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import dayjs from 'dayjs';
import { db } from '@/src/db/db_index'
import { vendorSnippets, deliverySlotInfo, productInfo, orders, orderItems, users, orderStatus } from '@/src/db/schema'
import { eq, and, inArray, isNotNull, gt, sql, asc, ne } from 'drizzle-orm';
import { appUrl } from '@/src/lib/env-exporter'
const createSnippetSchema = z.object({
snippetCode: z.string().min(1, "Snippet code is required"),
slotId: z.number().optional(),
productIds: z.array(z.number().int().positive()).min(1, "At least one product is required"),
validTill: z.string().optional(),
isPermanent: z.boolean().default(false)
});
const updateSnippetSchema = z.object({
id: z.number().int().positive(),
updates: createSnippetSchema.partial().extend({
snippetCode: z.string().min(1).optional(),
productIds: z.array(z.number().int().positive()).optional(),
isPermanent: z.boolean().default(false)
}),
});
export const vendorSnippetsRouter = router({
create: protectedProcedure
.input(createSnippetSchema)
.mutation(async ({ input, ctx }) => {
const { snippetCode, slotId, productIds, validTill, isPermanent } = input;
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id;
if (!staffUserId) {
throw new Error("Unauthorized");
}
// Validate slot exists
if(slotId) {
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, slotId),
});
if (!slot) {
throw new Error("Invalid slot ID");
}
}
// Validate products exist
const products = await db.query.productInfo.findMany({
where: inArray(productInfo.id, productIds),
});
if (products.length !== productIds.length) {
throw new Error("One or more invalid product IDs");
}
// Check if snippet code already exists
const existingSnippet = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, snippetCode),
});
if (existingSnippet) {
throw new Error("Snippet code already exists");
}
const result = await db.insert(vendorSnippets).values({
snippetCode,
slotId,
productIds,
isPermanent,
validTill: validTill ? new Date(validTill) : undefined,
}).returning();
return result[0];
}),
getAll: protectedProcedure
.query(async () => {
console.log('from the vendor snipptes methods')
try {
const result = await db.query.vendorSnippets.findMany({
with: {
slot: true,
},
orderBy: (vendorSnippets, { desc }) => [desc(vendorSnippets.createdAt)],
});
const snippetsWithProducts = await Promise.all(
result.map(async (snippet) => {
const products = await db.query.productInfo.findMany({
where: inArray(productInfo.id, snippet.productIds),
columns: { id: true, name: true },
});
return {
...snippet,
accessUrl: `${appUrl}/vendor-order-list?id=${snippet.snippetCode}`,
products: products.map(p => ({ id: p.id, name: p.name })),
};
})
);
return snippetsWithProducts;
}
catch(e) {
console.log(e)
}
return [];
}),
getById: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.query(async ({ input }) => {
const { id } = input;
const result = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.id, id),
with: {
slot: true,
},
});
if (!result) {
throw new Error("Vendor snippet not found");
}
return result;
}),
update: protectedProcedure
.input(updateSnippetSchema)
.mutation(async ({ input }) => {
const { id, updates } = input;
// Check if snippet exists
const existingSnippet = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.id, id),
});
if (!existingSnippet) {
throw new Error("Vendor snippet not found");
}
// Validate slot if being updated
if (updates.slotId) {
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, updates.slotId),
});
if (!slot) {
throw new Error("Invalid slot ID");
}
}
// Validate products if being updated
if (updates.productIds) {
const products = await db.query.productInfo.findMany({
where: inArray(productInfo.id, updates.productIds),
});
if (products.length !== updates.productIds.length) {
throw new Error("One or more invalid product IDs");
}
}
// Check snippet code uniqueness if being updated
if (updates.snippetCode && updates.snippetCode !== existingSnippet.snippetCode) {
const duplicateSnippet = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, updates.snippetCode),
});
if (duplicateSnippet) {
throw new Error("Snippet code already exists");
}
}
const updateData: any = { ...updates };
if (updates.validTill !== undefined) {
updateData.validTill = updates.validTill ? new Date(updates.validTill) : null;
}
const result = await db.update(vendorSnippets)
.set(updateData)
.where(eq(vendorSnippets.id, id))
.returning();
if (result.length === 0) {
throw new Error("Failed to update vendor snippet");
}
return result[0];
}),
delete: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input }) => {
const { id } = input;
const result = await db.delete(vendorSnippets)
.where(eq(vendorSnippets.id, id))
.returning();
if (result.length === 0) {
throw new Error("Vendor snippet not found");
}
return { message: "Vendor snippet deleted successfully" };
}),
getOrdersBySnippet: publicProcedure
.input(z.object({
snippetCode: z.string().min(1, "Snippet code is required")
}))
.query(async ({ input }) => {
const { snippetCode } = input;
// Find the snippet
const snippet = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, snippetCode),
});
if (!snippet) {
throw new Error("Vendor snippet not found");
}
// Check if snippet is still valid
if (snippet.validTill && new Date(snippet.validTill) < new Date()) {
throw new Error("Vendor snippet has expired");
}
// Query orders that match the snippet criteria
const matchingOrders = await db.query.orders.findMany({
where: eq(orders.slotId, snippet.slotId!),
with: {
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
orderStatus: true,
user: true,
slot: true,
},
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
});
// Filter orders that contain at least one of the snippet's products
const filteredOrders = matchingOrders.filter(order => {
const status = order.orderStatus;
if (status[0].isCancelled) return false;
const orderProductIds = order.orderItems.map(item => item.productId);
return snippet.productIds.some(productId => orderProductIds.includes(productId));
});
// Format the response
const formattedOrders = filteredOrders.map(order => {
// Filter orderItems to only include products attached to the snippet
const attachedOrderItems = order.orderItems.filter(item =>
snippet.productIds.includes(item.productId)
);
const products = attachedOrderItems.map(item => ({
orderItemId: item.id,
productId: item.productId,
productName: item.product.name,
quantity: parseFloat(item.quantity),
productSize: item.product.productQuantity,
price: parseFloat(item.price.toString()),
unit: item.product.unit?.shortNotation || 'unit',
subtotal: parseFloat(item.price.toString()) * parseFloat(item.quantity),
is_packaged: item.is_packaged,
is_package_verified: item.is_package_verified,
}));
const orderTotal = products.reduce((sum, p) => sum + p.subtotal, 0);
return {
orderId: `ORD${order.id}`,
orderDate: order.createdAt.toISOString(),
customerName: order.user.name,
totalAmount: orderTotal,
slotInfo: order.slot ? {
time: order.slot.deliveryTime.toISOString(),
sequence: order.slot.deliverySequence,
} : null,
products,
matchedProducts: snippet.productIds, // All snippet products are considered matched
snippetCode: snippet.snippetCode,
};
});
return {
success: true,
data: formattedOrders,
snippet: {
id: snippet.id,
snippetCode: snippet.snippetCode,
slotId: snippet.slotId,
productIds: snippet.productIds,
validTill: snippet.validTill?.toISOString(),
createdAt: snippet.createdAt.toISOString(),
isPermanent: snippet.isPermanent,
},
};
}),
getVendorOrders: protectedProcedure
.query(async () => {
const vendorOrders = await db.query.orders.findMany({
with: {
user: true,
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
},
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
});
return vendorOrders.map(order => ({
id: order.id,
status: 'pending', // Default status since orders table may not have status field
orderDate: order.createdAt.toISOString(),
totalQuantity: order.orderItems.reduce((sum, item) => sum + parseFloat(item.quantity || '0'), 0),
products: order.orderItems.map(item => ({
name: item.product.name,
quantity: parseFloat(item.quantity || '0'),
unit: item.product.unit?.shortNotation || 'unit',
})),
}));
}),
getUpcomingSlots: publicProcedure
.query(async () => {
const threeHoursAgo = dayjs().subtract(3, 'hour').toDate();
const slots = await db.query.deliverySlotInfo.findMany({
where: and(
eq(deliverySlotInfo.isActive, true),
gt(deliverySlotInfo.deliveryTime, threeHoursAgo)
),
orderBy: asc(deliverySlotInfo.deliveryTime),
});
return {
success: true,
data: slots.map(slot => ({
id: slot.id,
deliveryTime: slot.deliveryTime.toISOString(),
freezeTime: slot.freezeTime.toISOString(),
deliverySequence: slot.deliverySequence,
})),
};
}),
getOrdersBySnippetAndSlot: publicProcedure
.input(z.object({
snippetCode: z.string().min(1, "Snippet code is required"),
slotId: z.number().int().positive("Valid slot ID is required"),
}))
.query(async ({ input }) => {
const { snippetCode, slotId } = input;
// Find the snippet
const snippet = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, snippetCode),
});
if (!snippet) {
throw new Error("Vendor snippet not found");
}
// Find the slot
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, slotId),
});
if (!slot) {
throw new Error("Slot not found");
}
// Query orders that match the slot and snippet criteria
const matchingOrders = await db.query.orders.findMany({
where: eq(orders.slotId, slotId),
with: {
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
orderStatus: true,
user: true,
slot: true,
},
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
});
// Filter orders that contain at least one of the snippet's products
const filteredOrders = matchingOrders.filter(order => {
const status = order.orderStatus;
if (status[0]?.isCancelled) return false;
const orderProductIds = order.orderItems.map(item => item.productId);
return snippet.productIds.some(productId => orderProductIds.includes(productId));
});
// Format the response
const formattedOrders = filteredOrders.map(order => {
// Filter orderItems to only include products attached to the snippet
const attachedOrderItems = order.orderItems.filter(item =>
snippet.productIds.includes(item.productId)
);
const products = attachedOrderItems.map(item => ({
orderItemId: item.id,
productId: item.productId,
productName: item.product.name,
quantity: parseFloat(item.quantity),
price: parseFloat(item.price.toString()),
unit: item.product.unit?.shortNotation || 'unit',
subtotal: parseFloat(item.price.toString()) * parseFloat(item.quantity),
productSize: item.product.productQuantity,
is_packaged: item.is_packaged,
is_package_verified: item.is_package_verified,
}));
const orderTotal = products.reduce((sum, p) => sum + p.subtotal, 0);
return {
orderId: `ORD${order.id}`,
orderDate: order.createdAt.toISOString(),
customerName: order.user.name,
totalAmount: orderTotal,
slotInfo: order.slot ? {
time: order.slot.deliveryTime.toISOString(),
sequence: order.slot.deliverySequence,
} : null,
products,
matchedProducts: snippet.productIds,
snippetCode: snippet.snippetCode,
};
});
return {
success: true,
data: formattedOrders,
snippet: {
id: snippet.id,
snippetCode: snippet.snippetCode,
slotId: snippet.slotId,
productIds: snippet.productIds,
validTill: snippet.validTill?.toISOString(),
createdAt: snippet.createdAt.toISOString(),
isPermanent: snippet.isPermanent,
},
selectedSlot: {
id: slot.id,
deliveryTime: slot.deliveryTime.toISOString(),
freezeTime: slot.freezeTime.toISOString(),
deliverySequence: slot.deliverySequence,
},
};
}),
updateOrderItemPackaging: publicProcedure
.input(z.object({
orderItemId: z.number().int().positive("Valid order item ID required"),
is_packaged: z.boolean()
}))
.mutation(async ({ input, ctx }) => {
const { orderItemId, is_packaged } = input;
// Get staff user ID from auth middleware
// const staffUserId = ctx.staffUser?.id;
// if (!staffUserId) {
// throw new Error("Unauthorized");
// }
// Check if order item exists and get related data
const orderItem = await db.query.orderItems.findFirst({
where: eq(orderItems.id, orderItemId),
with: {
order: {
with: {
slot: true
}
}
}
});
if (!orderItem) {
throw new Error("Order item not found");
}
// Check if this order item belongs to a slot that has vendor snippets
// This ensures only order items from vendor-accessible orders can be updated
if (!orderItem.order.slotId) {
throw new Error("Order item not associated with a vendor slot");
}
const snippetExists = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.slotId, orderItem.order.slotId),
});
if (!snippetExists) {
throw new Error("No vendor snippet found for this order's slot");
}
// Update the is_packaged field
const result = await db.update(orderItems)
.set({ is_packaged })
.where(eq(orderItems.id, orderItemId))
.returning();
if (result.length === 0) {
throw new Error("Failed to update packaging status");
}
return {
success: true,
orderItemId,
is_packaged
};
}),
});

View file

@ -1,194 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index';
import { z } from 'zod';
import { db } from '@/src/db/db_index';
import { addresses, orders, orderStatus, deliverySlotInfo } from '@/src/db/schema';
import { eq, and, gte } from 'drizzle-orm';
import dayjs from 'dayjs';
import { extractCoordsFromRedirectUrl } from '@/src/lib/license-util';
export const addressRouter = router({
getDefaultAddress: protectedProcedure
.query(async ({ ctx }) => {
const userId = ctx.user.userId;
const [defaultAddress] = await db
.select()
.from(addresses)
.where(and(eq(addresses.userId, userId), eq(addresses.isDefault, true)))
.limit(1);
return { success: true, data: defaultAddress || null };
}),
getUserAddresses: protectedProcedure
.query(async ({ ctx }) => {
const userId = ctx.user.userId;
const userAddresses = await db.select().from(addresses).where(eq(addresses.userId, userId));
return { success: true, data: userAddresses };
}),
createAddress: protectedProcedure
.input(z.object({
name: z.string().min(1, 'Name is required'),
phone: z.string().min(1, 'Phone is required'),
addressLine1: z.string().min(1, 'Address line 1 is required'),
addressLine2: z.string().optional(),
city: z.string().min(1, 'City is required'),
state: z.string().min(1, 'State is required'),
pincode: z.string().min(1, 'Pincode is required'),
isDefault: z.boolean().optional(),
latitude: z.number().optional(),
longitude: z.number().optional(),
googleMapsUrl: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
const userId = ctx.user.userId;
const { name, phone, addressLine1, addressLine2, city, state, pincode, isDefault, googleMapsUrl } = input;
let { latitude, longitude } = input;
if (googleMapsUrl && latitude === undefined && longitude === undefined) {
const coords = await extractCoordsFromRedirectUrl(googleMapsUrl);
if (coords) {
latitude = Number(coords.latitude);
longitude = Number(coords.longitude);
}
}
// Validate required fields
if (!name || !phone || !addressLine1 || !city || !state || !pincode) {
throw new Error('Missing required fields');
}
// If setting as default, unset other defaults
if (isDefault) {
await db.update(addresses).set({ isDefault: false }).where(eq(addresses.userId, userId));
}
const [newAddress] = await db.insert(addresses).values({
userId,
name,
phone,
addressLine1,
addressLine2,
city,
state,
pincode,
isDefault: isDefault || false,
latitude,
longitude,
googleMapsUrl,
}).returning();
return { success: true, data: newAddress };
}),
updateAddress: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
name: z.string().min(1, 'Name is required'),
phone: z.string().min(1, 'Phone is required'),
addressLine1: z.string().min(1, 'Address line 1 is required'),
addressLine2: z.string().optional(),
city: z.string().min(1, 'City is required'),
state: z.string().min(1, 'State is required'),
pincode: z.string().min(1, 'Pincode is required'),
isDefault: z.boolean().optional(),
latitude: z.number().optional(),
longitude: z.number().optional(),
googleMapsUrl: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
const userId = ctx.user.userId;
const { id, name, phone, addressLine1, addressLine2, city, state, pincode, isDefault, googleMapsUrl } = input;
let { latitude, longitude } = input;
if (googleMapsUrl && latitude === undefined && longitude === undefined) {
const coords = await extractCoordsFromRedirectUrl(googleMapsUrl);
if (coords) {
latitude = Number(coords.latitude);
longitude = Number(coords.longitude);
}
}
// Check if address exists and belongs to user
const existingAddress = await db.select().from(addresses).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).limit(1);
if (existingAddress.length === 0) {
throw new Error('Address not found');
}
// If setting as default, unset other defaults
if (isDefault) {
await db.update(addresses).set({ isDefault: false }).where(eq(addresses.userId, userId));
}
const updateData: any = {
name,
phone,
addressLine1,
addressLine2,
city,
state,
pincode,
isDefault: isDefault || false,
googleMapsUrl,
};
if (latitude !== undefined) {
updateData.latitude = latitude;
}
if (longitude !== undefined) {
updateData.longitude = longitude;
}
const [updatedAddress] = await db.update(addresses).set(updateData).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).returning();
return { success: true, data: updatedAddress };
}),
deleteAddress: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
}))
.mutation(async ({ input, ctx }) => {
const userId = ctx.user.userId;
const { id } = input;
// Check if address exists and belongs to user
const existingAddress = await db.select().from(addresses).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).limit(1);
if (existingAddress.length === 0) {
throw new Error('Address not found or does not belong to user');
}
// Check if address is attached to any ongoing orders using joins
const ongoingOrders = await db.select({
order: orders,
status: orderStatus,
slot: deliverySlotInfo
})
.from(orders)
.innerJoin(orderStatus, eq(orders.id, orderStatus.orderId))
.innerJoin(deliverySlotInfo, eq(orders.slotId, deliverySlotInfo.id))
.where(and(
eq(orders.addressId, id),
eq(orderStatus.isCancelled, false),
gte(deliverySlotInfo.deliveryTime, new Date())
))
.limit(1);
if (ongoingOrders.length > 0) {
throw new Error('Address is attached to an ongoing order. Please cancel the order first.');
}
// Prevent deletion of default address
if (existingAddress[0].isDefault) {
throw new Error('Cannot delete default address. Please set another address as default first.');
}
// Delete the address
await db.delete(addresses).where(and(eq(addresses.id, id), eq(addresses.userId, userId)));
return { success: true, message: 'Address deleted successfully' };
}),
});

View file

@ -1,581 +0,0 @@
import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import { eq } from 'drizzle-orm';
import { db } from '@/src/db/db_index';
import {
users, userCreds, userDetails, addresses, cartItems, complaints,
couponApplicableUsers, couponUsage, notifCreds, notifications,
orderItems, orderStatus, orders, payments, refunds,
productReviews, reservedCoupons
} from '@/src/db/schema';
import { generateSignedUrlFromS3Url, claimUploadUrl, scaffoldAssetUrl } from '@/src/lib/s3-client';
import { deleteS3Image } from '@/src/lib/delete-image';
import { ApiError } from '@/src/lib/api-error';
import catchAsync from '@/src/lib/catch-async';
import { sendOtp, verifyOtpUtil, getOtpCreds } from '@/src/lib/otp-utils';
import { signToken } from '@/src/lib/jwt-utils';
interface LoginRequest {
identifier: string; // email or mobile
password: string;
}
interface RegisterRequest {
name: string;
email: string;
mobile: string;
password: string;
}
interface AuthResponse {
token: string;
user: {
id: number;
name?: string | null;
email: string | null;
mobile: string | null;
createdAt: string;
profileImage: string | null;
bio?: string | null;
dateOfBirth?: string | null;
gender?: string | null;
occupation?: string | null;
};
}
const generateToken = async (userId: number): Promise<string> => {
return signToken({ userId });
};
export const authRouter = router({
login: publicProcedure
.input(z.object({
identifier: z.string().min(1, 'Email/mobile is required'),
password: z.string().min(1, 'Password is required'),
}))
.mutation(async ({ input }) => {
const { identifier, password }: LoginRequest = input;
if (!identifier || !password) {
throw new ApiError('Email/mobile and password are required', 400);
}
// Find user by email or mobile
const [user] = await db
.select()
.from(users)
.where(eq(users.email, identifier.toLowerCase()))
.limit(1);
let foundUser = user;
if (!foundUser) {
// Try mobile if email didn't work
const [userByMobile] = await db
.select()
.from(users)
.where(eq(users.mobile, identifier))
.limit(1);
foundUser = userByMobile;
}
if (!foundUser) {
throw new ApiError('Invalid credentials', 401);
}
// Get user credentials
const [userCredentials] = await db
.select()
.from(userCreds)
.where(eq(userCreds.userId, foundUser.id))
.limit(1);
if (!userCredentials) {
throw new ApiError('Account setup incomplete. Please contact support.', 401);
}
// Get user details for profile image
const [userDetail] = await db
.select()
.from(userDetails)
.where(eq(userDetails.userId, foundUser.id))
.limit(1);
// Generate signed URL for profile image if it exists
const profileImageSignedUrl = userDetail?.profileImage
? await generateSignedUrlFromS3Url(userDetail.profileImage)
: null;
// Verify password
const isPasswordValid = await bcrypt.compare(password, userCredentials.userPassword);
if (!isPasswordValid) {
throw new ApiError('Invalid credentials', 401);
}
const token = await generateToken(foundUser.id);
const response: AuthResponse = {
token,
user: {
id: foundUser.id,
name: foundUser.name,
email: foundUser.email,
mobile: foundUser.mobile,
createdAt: foundUser.createdAt.toISOString(),
profileImage: profileImageSignedUrl,
bio: userDetail?.bio || null,
dateOfBirth: userDetail?.dateOfBirth || null,
gender: userDetail?.gender || null,
occupation: userDetail?.occupation || null,
},
};
return {
success: true,
data: response,
};
}),
register: publicProcedure
.input(z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email format'),
mobile: z.string().min(1, 'Mobile is required'),
password: z.string().min(1, 'Password is required'),
imageKey: z.string().optional(),
}))
.mutation(async ({ input }) => {
const { name, email, mobile, password, imageKey } = input;
if (!name || !email || !mobile || !password) {
throw new ApiError('All fields are required', 400);
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new ApiError('Invalid email format', 400);
}
// Validate mobile format (Indian mobile numbers)
const cleanMobile = mobile.replace(/\D/g, '');
if (cleanMobile.length !== 10 || !/^[6-9]/.test(cleanMobile)) {
throw new ApiError('Invalid mobile number', 400);
}
// Check if email already exists
const [existingEmail] = await db
.select()
.from(users)
.where(eq(users.email, email.toLowerCase()))
.limit(1);
if (existingEmail) {
throw new ApiError('Email already registered', 409);
}
// Check if mobile already exists
const [existingMobile] = await db
.select()
.from(users)
.where(eq(users.mobile, cleanMobile))
.limit(1);
if (existingMobile) {
throw new ApiError('Mobile number already registered', 409);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user and credentials in a transaction
const newUser = await db.transaction(async (tx) => {
// Create user
const [user] = await tx
.insert(users)
.values({
name: name.trim(),
email: email.toLowerCase().trim(),
mobile: cleanMobile,
})
.returning();
// Create user credentials
await tx
.insert(userCreds)
.values({
userId: user.id,
userPassword: hashedPassword,
});
// Create user details with profile image if provided
if (imageKey) {
await tx.insert(userDetails).values({
userId: user.id,
profileImage: imageKey,
});
}
return user;
});
// Claim upload URL if image was provided
if (imageKey) {
try {
await claimUploadUrl(imageKey);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
}
}
const token = await generateToken(newUser.id);
// Get user details for profile image
const [userDetail] = await db
.select()
.from(userDetails)
.where(eq(userDetails.userId, newUser.id))
.limit(1);
const profileImageUrl = userDetail?.profileImage
? scaffoldAssetUrl(userDetail.profileImage)
: null;
const response: AuthResponse = {
token,
user: {
id: newUser.id,
name: newUser.name,
email: newUser.email,
mobile: newUser.mobile,
createdAt: newUser.createdAt.toISOString(),
profileImage: profileImageUrl,
},
};
return {
success: true,
data: response,
};
}),
sendOtp: publicProcedure
.input(z.object({
mobile: z.string(),
}))
.mutation(async ({ input }) => {
return await sendOtp(input.mobile);
}),
verifyOtp: publicProcedure
.input(z.object({
mobile: z.string(),
otp: z.string(),
}))
.mutation(async ({ input }) => {
const verificationId = getOtpCreds(input.mobile);
if (!verificationId) {
throw new ApiError("OTP not sent or expired", 400);
}
const isVerified = await verifyOtpUtil(input.mobile, input.otp, verificationId);
if (!isVerified) {
throw new ApiError("Invalid OTP", 400);
}
// Find user
let user = await db.query.users.findFirst({
where: eq(users.mobile, input.mobile),
});
// If user doesn't exist, create one
if (!user) {
const [newUser] = await db
.insert(users)
.values({
name: null,
email: null,
mobile: input.mobile,
})
.returning();
user = newUser;
}
// Generate JWT
const token = await generateToken(user.id);
return {
success: true,
token,
user: {
id: user.id,
name: user.name,
email: user.email,
mobile: user.mobile,
createdAt: user.createdAt.toISOString(),
profileImage: null,
},
};
}),
updatePassword: protectedProcedure
.input(z.object({
password: z.string().min(6, 'Password must be at least 6 characters'),
}))
.mutation(async ({ input, ctx }) => {
const userId = ctx.user.userId;
if (!userId) {
throw new ApiError('User not authenticated', 401);
}
const hashedPassword = await bcrypt.hash(input.password, 10);
// Insert if not exists, then update if exists
try {
await db.insert(userCreds).values({
userId: userId,
userPassword: hashedPassword,
});
// Insert succeeded - new credentials created
} catch (error: any) {
// Insert failed - check if it's a unique constraint violation
if (error.code === '23505') { // PostgreSQL unique constraint violation
// Update existing credentials
await db.update(userCreds).set({
userPassword: hashedPassword,
}).where(eq(userCreds.userId, userId));
} else {
// Re-throw if it's a different error
throw error;
}
}
return { success: true, message: 'Password updated successfully' };
}),
getProfile: protectedProcedure
.query(async ({ ctx }) => {
const userId = ctx.user.userId;
if (!userId) {
throw new ApiError('User not authenticated', 401);
}
const [user] = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) {
throw new ApiError('User not found', 404);
}
// Get user details for profile image
const [userDetail] = await db
.select()
.from(userDetails)
.where(eq(userDetails.userId, userId))
.limit(1);
const profileImageUrl = userDetail?.profileImage
? scaffoldAssetUrl(userDetail.profileImage)
: null;
return {
success: true,
data: {
id: user.id,
name: user.name,
email: user.email,
mobile: user.mobile,
profileImage: profileImageUrl,
bio: userDetail?.bio || null,
dateOfBirth: userDetail?.dateOfBirth || null,
gender: userDetail?.gender || null,
occupation: userDetail?.occupation || null,
},
};
}),
updateProfile: protectedProcedure
.input(z.object({
name: z.string().min(1, 'Name is required').optional(),
email: z.string().email('Invalid email format').optional(),
bio: z.string().optional(),
dateOfBirth: z.string().optional(),
gender: z.string().optional(),
occupation: z.string().optional(),
imageKey: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
const userId = ctx.user.userId;
const { imageKey, ...updateData } = input;
if (!userId) {
throw new ApiError('User not authenticated', 401);
}
// Get current user details
const currentDetail = await db.query.userDetails.findFirst({
where: eq(userDetails.userId, userId),
});
let newImageUrl: string | null | undefined = currentDetail?.profileImage;
// Handle new image upload (only if different from existing)
if (imageKey && imageKey !== currentDetail?.profileImage) {
// Delete old image if exists
if (currentDetail?.profileImage) {
try {
await deleteS3Image(currentDetail.profileImage);
} catch (e) {
console.error(`Failed to delete old image: ${currentDetail.profileImage}`, e);
}
}
newImageUrl = imageKey;
// Claim upload URL
try {
await claimUploadUrl(imageKey);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
}
}
// Update user name if provided
if (updateData.name) {
await db.update(users)
.set({ name: updateData.name.trim() })
.where(eq(users.id, userId));
}
// Update user email if provided
if (updateData.email) {
// Check if email already exists (but belongs to different user)
const [existingUser] = await db
.select()
.from(users)
.where(eq(users.email, updateData.email.toLowerCase().trim()))
.limit(1);
if (existingUser && existingUser.id !== userId) {
throw new ApiError('Email already in use by another account', 409);
}
await db.update(users)
.set({ email: updateData.email.toLowerCase().trim() })
.where(eq(users.id, userId));
}
// Upsert user details
if (currentDetail) {
// Update existing
await db.update(userDetails)
.set({
...updateData,
profileImage: newImageUrl,
})
.where(eq(userDetails.userId, userId));
} else {
// Insert new
await db.insert(userDetails).values({
userId: userId,
...updateData,
profileImage: newImageUrl,
});
}
return {
success: true,
message: 'Profile updated successfully',
};
}),
deleteAccount: protectedProcedure
.input(z.object({
mobile: z.string().min(10, 'Mobile number is required'),
}))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user.userId;
const { mobile } = input;
if (!userId) {
throw new ApiError('User not authenticated', 401);
}
// Double-check: verify user exists and is the authenticated user
const existingUser = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { id: true, mobile: true },
});
if (!existingUser) {
throw new ApiError('User not found', 404);
}
// Additional verification: ensure we're not deleting someone else's data
// The JWT token should already ensure this, but double-checking
if (existingUser.id !== userId) {
throw new ApiError('Unauthorized: Cannot delete another user\'s account', 403);
}
// Verify mobile number matches user's registered mobile
const cleanInputMobile = mobile.replace(/\D/g, '');
const cleanUserMobile = existingUser.mobile?.replace(/\D/g, '');
if (cleanInputMobile !== cleanUserMobile) {
throw new ApiError('Mobile number does not match your registered number', 400);
}
// Use transaction for atomic deletion
await db.transaction(async (tx) => {
// Phase 1: Direct references (safe to delete first)
await tx.delete(notifCreds).where(eq(notifCreds.userId, userId));
await tx.delete(couponApplicableUsers).where(eq(couponApplicableUsers.userId, userId));
await tx.delete(couponUsage).where(eq(couponUsage.userId, userId));
await tx.delete(complaints).where(eq(complaints.userId, userId));
await tx.delete(cartItems).where(eq(cartItems.userId, userId));
await tx.delete(notifications).where(eq(notifications.userId, userId));
await tx.delete(productReviews).where(eq(productReviews.userId, userId));
// Update reserved coupons (set redeemedBy to null)
await tx.update(reservedCoupons)
.set({ redeemedBy: null })
.where(eq(reservedCoupons.redeemedBy, userId));
// Phase 2: Order dependencies
const userOrders = await tx
.select({ id: orders.id })
.from(orders)
.where(eq(orders.userId, userId));
for (const order of userOrders) {
await tx.delete(orderItems).where(eq(orderItems.orderId, order.id));
await tx.delete(orderStatus).where(eq(orderStatus.orderId, order.id));
await tx.delete(payments).where(eq(payments.orderId, order.id));
await tx.delete(refunds).where(eq(refunds.orderId, order.id));
// Additional coupon usage entries linked to specific orders
await tx.delete(couponUsage).where(eq(couponUsage.orderId, order.id));
await tx.delete(complaints).where(eq(complaints.orderId, order.id));
}
// Delete orders
await tx.delete(orders).where(eq(orders.userId, userId));
// Phase 3: Addresses (now safe since orders are deleted)
await tx.delete(addresses).where(eq(addresses.userId, userId));
// Phase 4: Core user data
await tx.delete(userDetails).where(eq(userDetails.userId, userId));
await tx.delete(userCreds).where(eq(userCreds.userId, userId));
await tx.delete(users).where(eq(users.id, userId));
});
return { success: true, message: 'Account deleted successfully' };
}),
});

View file

@ -1,30 +0,0 @@
import { db } from '@/src/db/db_index';
import { homeBanners } from '@/src/db/schema';
import { publicProcedure, router } from '@/src/trpc/trpc-index';
import { scaffoldAssetUrl } from '@/src/lib/s3-client';
import { isNotNull, asc } from 'drizzle-orm';
export async function scaffoldBanners() {
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 = banners.map((banner) => ({
...banner,
imageUrl: banner.imageUrl ? scaffoldAssetUrl(banner.imageUrl) : banner.imageUrl,
}));
return {
banners: bannersWithSignedUrls,
};
}
export const bannerRouter = router({
getBanners: publicProcedure
.query(async () => {
const response = await scaffoldBanners();
return response;
}),
});

View file

@ -1,244 +0,0 @@
import { router, protectedProcedure, publicProcedure } from '@/src/trpc/trpc-index';
import { z } from 'zod';
import { db } from '@/src/db/db_index';
import { cartItems, productInfo, units, productSlots, deliverySlotInfo } from '@/src/db/schema';
import { eq, and, sql, inArray, gt } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error';
import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl } from '@/src/lib/s3-client';
import { getProductSlots, getMultipleProductsSlots } from '@/src/stores/slot-store';
interface CartResponse {
items: any[];
totalItems: number;
totalAmount: number;
}
const getCartData = async (userId: number): Promise<CartResponse> => {
const cartItemsWithProducts = await db
.select({
cartId: cartItems.id,
productId: productInfo.id,
productName: productInfo.name,
productPrice: productInfo.price,
productImages: productInfo.images,
productQuantity: productInfo.productQuantity,
isOutOfStock: productInfo.isOutOfStock,
unitShortNotation: units.shortNotation,
quantity: cartItems.quantity,
addedAt: cartItems.addedAt,
})
.from(cartItems)
.innerJoin(productInfo, eq(cartItems.productId, productInfo.id))
.innerJoin(units, eq(productInfo.unitId, units.id))
.where(eq(cartItems.userId, userId));
// Generate signed URLs for images
const cartWithSignedUrls = await Promise.all(
cartItemsWithProducts.map(async (item) => ({
id: item.cartId,
productId: item.productId,
quantity: parseFloat(item.quantity),
addedAt: item.addedAt,
product: {
id: item.productId,
name: item.productName,
price: item.productPrice,
productQuantity: item.productQuantity,
unit: item.unitShortNotation,
isOutOfStock: item.isOutOfStock,
images: scaffoldAssetUrl((item.productImages as string[]) || []),
},
subtotal: parseFloat(item.productPrice.toString()) * parseFloat(item.quantity),
}))
);
const totalAmount = cartWithSignedUrls.reduce((sum, item) => sum + item.subtotal, 0);
return {
items: cartWithSignedUrls,
totalItems: cartWithSignedUrls.length,
totalAmount,
};
};
export const cartRouter = router({
getCart: protectedProcedure
.query(async ({ ctx }): Promise<CartResponse> => {
const userId = ctx.user.userId;
return await getCartData(userId);
}),
addToCart: protectedProcedure
.input(z.object({
productId: z.number().int().positive(),
quantity: z.number().int().positive(),
}))
.mutation(async ({ input, ctx }): Promise<CartResponse> => {
const userId = ctx.user.userId;
const { productId, quantity } = input;
// Validate input
if (!productId || !quantity || quantity <= 0) {
throw new ApiError("Product ID and positive quantity required", 400);
}
// Check if product exists
const product = await db.query.productInfo.findFirst({
where: eq(productInfo.id, productId),
});
if (!product) {
throw new ApiError("Product not found", 404);
}
// Check if item already exists in cart
const existingItem = await db.query.cartItems.findFirst({
where: and(eq(cartItems.userId, userId), eq(cartItems.productId, productId)),
});
if (existingItem) {
// Update quantity
await db.update(cartItems)
.set({
quantity: sql`${cartItems.quantity} + ${quantity}`,
})
.where(eq(cartItems.id, existingItem.id));
} else {
// Insert new item
await db.insert(cartItems).values({
userId,
productId,
quantity: quantity.toString(),
});
}
// Return updated cart
return await getCartData(userId);
}),
updateCartItem: protectedProcedure
.input(z.object({
itemId: z.number().int().positive(),
quantity: z.number().int().min(0),
}))
.mutation(async ({ input, ctx }): Promise<CartResponse> => {
const userId = ctx.user.userId;
const { itemId, quantity } = input;
if (!quantity || quantity <= 0) {
throw new ApiError("Positive quantity required", 400);
}
const [updatedItem] = await db.update(cartItems)
.set({ quantity: quantity.toString() })
.where(and(
eq(cartItems.id, itemId),
eq(cartItems.userId, userId)
))
.returning();
if (!updatedItem) {
throw new ApiError("Cart item not found", 404);
}
// Return updated cart
return await getCartData(userId);
}),
removeFromCart: protectedProcedure
.input(z.object({
itemId: z.number().int().positive(),
}))
.mutation(async ({ input, ctx }): Promise<CartResponse> => {
const userId = ctx.user.userId;
const { itemId } = input;
const [deletedItem] = await db.delete(cartItems)
.where(and(
eq(cartItems.id, itemId),
eq(cartItems.userId, userId)
))
.returning();
if (!deletedItem) {
throw new ApiError("Cart item not found", 404);
}
// Return updated cart
return await getCartData(userId);
}),
clearCart: protectedProcedure
.mutation(async ({ ctx }) => {
const userId = ctx.user.userId;
await db.delete(cartItems).where(eq(cartItems.userId, userId));
return {
items: [],
totalItems: 0,
totalAmount: 0,
message: "Cart cleared successfully",
};
}),
// Original DB-based getCartSlots (commented out)
// getCartSlots: publicProcedure
// .input(z.object({
// productIds: z.array(z.number().int().positive())
// }))
// .query(async ({ input }) => {
// const { productIds } = input;
//
// if (productIds.length === 0) {
// return {};
// }
//
// // Get slots for these products where freeze time is after current time
// const slotsData = await db
// .select({
// productId: productSlots.productId,
// slotId: deliverySlotInfo.id,
// deliveryTime: deliverySlotInfo.deliveryTime,
// freezeTime: deliverySlotInfo.freezeTime,
// isActive: deliverySlotInfo.isActive,
// })
// .from(productSlots)
// .innerJoin(deliverySlotInfo, eq(productSlots.slotId, deliverySlotInfo.id))
// .where(and(
// inArray(productSlots.productId, productIds),
// gt(deliverySlotInfo.freezeTime, sql`NOW()`),
// eq(deliverySlotInfo.isActive, true)
// ));
//
// // Group by productId
// const result: Record<number, any[]> = {};
// slotsData.forEach(slot => {
// if (!result[slot.productId]) {
// result[slot.productId] = [];
// }
// result[slot.productId].push({
// id: slot.slotId,
// deliveryTime: slot.deliveryTime,
// freezeTime: slot.freezeTime,
// });
// });
//
// return result;
// }),
// Cache-based getCartSlots
getCartSlots: publicProcedure
.input(z.object({
productIds: z.array(z.number().int().positive())
}))
.query(async ({ input }) => {
const { productIds } = input;
if (productIds.length === 0) {
return {};
}
return await getMultipleProductsSlots(productIds);
}),
});

View file

@ -1,70 +0,0 @@
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';
import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client';
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,
images: complaints.images,
})
.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,
images: c.images ? scaffoldAssetUrl(c.images as string[]) : [],
})),
};
}),
raise: protectedProcedure
.input(z.object({
orderId: z.number().optional(),
complaintBody: z.string().min(1, 'Complaint body is required'),
imageKeys: z.array(z.string()).optional(),
}))
.mutation(async ({ input, ctx }) => {
const userId = ctx.user.userId;
const { orderId, complaintBody, imageKeys } = input;
await db.insert(complaints).values({
userId,
orderId: orderId || null,
complaintBody: complaintBody.trim(),
images: imageKeys || [],
});
// Claim upload URLs for images
if (imageKeys && imageKeys.length > 0) {
for (const key of imageKeys) {
try {
await claimUploadUrl(key);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${key}`, e);
}
}
}
return { success: true, message: 'Complaint raised successfully' };
}),
});

View file

@ -1,296 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index';
import { z } from 'zod';
import { db } from '@/src/db/db_index';
import { coupons, couponUsage, couponApplicableUsers, reservedCoupons, couponApplicableProducts } from '@/src/db/schema';
import { eq, and, or, gt, isNull, sql } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error';
import { users } from '@/src/db/schema';
type CouponWithRelations = typeof coupons.$inferSelect & {
applicableUsers: (typeof couponApplicableUsers.$inferSelect & { user: typeof users.$inferSelect })[];
usages: typeof couponUsage.$inferSelect[];
};
export interface EligibleCoupon {
id: number;
code: string;
discountType: 'percentage' | 'flat';
discountValue: number;
maxValue?: number;
minOrder?: number;
description: string;
exclusiveApply?: boolean;
isEligible: boolean;
ineligibilityReason?: string;
}
const generateCouponDescription = (coupon: any): string => {
let desc = '';
if (coupon.discountPercent) {
desc += `${coupon.discountPercent}% off`;
} else if (coupon.flatDiscount) {
desc += `${coupon.flatDiscount} off`;
}
if (coupon.minOrder) {
desc += ` on orders above ₹${coupon.minOrder}`;
}
if (coupon.maxValue) {
desc += ` (max discount ₹${coupon.maxValue})`;
}
return desc;
};
export interface CouponDisplay {
id: number;
code: string;
discountType: 'percentage' | 'flat';
discountValue: number;
maxValue?: number;
minOrder?: number;
description: string;
validTill?: Date;
usageCount: number;
maxLimitForUser?: number;
isExpired: boolean;
isUsedUp: boolean;
}
export const userCouponRouter = router({
getEligible: protectedProcedure
.query(async ({ ctx }) => {
try {
const userId = ctx.user.userId;
// Get all active, non-expired coupons
const allCoupons = await db.query.coupons.findMany({
where: and(
eq(coupons.isInvalidated, false),
or(
isNull(coupons.validTill),
gt(coupons.validTill, new Date())
)
),
with: {
usages: {
where: eq(couponUsage.userId, userId)
},
applicableUsers: {
with: {
user: true
}
},
applicableProducts: {
with: {
product: true
}
},
}
});
// Filter to only coupons applicable to current user
const applicableCoupons = allCoupons.filter(coupon => {
if(!coupon.isUserBased) return true;
const applicableUsers = coupon.applicableUsers || [];
return applicableUsers.some(au => au.userId === userId);
});
return { success: true, data: applicableCoupons };
}
catch(e) {
console.log(e)
throw new ApiError("Unable to get coupons")
}
}),
getProductCoupons: protectedProcedure
.input(z.object({ productId: z.number().int().positive() }))
.query(async ({ input, ctx }) => {
const userId = ctx.user.userId;
const { productId } = input;
// Get all active, non-expired coupons
const allCoupons = await db.query.coupons.findMany({
where: and(
eq(coupons.isInvalidated, false),
or(
isNull(coupons.validTill),
gt(coupons.validTill, new Date())
)
),
with: {
usages: {
where: eq(couponUsage.userId, userId)
},
applicableUsers: {
with: {
user: true
}
},
applicableProducts: {
with: {
product: true
}
},
}
});
// Filter to only coupons applicable to current user and product
const applicableCoupons = allCoupons.filter(coupon => {
const applicableUsers = coupon.applicableUsers || [];
const userApplicable = !coupon.isUserBased || applicableUsers.some(au => au.userId === userId);
const applicableProducts = coupon.applicableProducts || [];
const productApplicable = applicableProducts.length === 0 || applicableProducts.some(ap => ap.productId === productId);
return userApplicable && productApplicable;
});
return { success: true, data: applicableCoupons };
}),
getMyCoupons: protectedProcedure
.query(async ({ ctx }) => {
const userId = ctx.user.userId;
// Get all coupons
const allCoupons = await db.query.coupons.findMany({
with: {
usages: {
where: eq(couponUsage.userId, userId)
},
applicableUsers: {
with: {
user: true
}
}
}
});
// Filter coupons in JS: not invalidated, applicable to user, and not expired
const applicableCoupons = (allCoupons as CouponWithRelations[]).filter(coupon => {
const isNotInvalidated = !coupon.isInvalidated;
const applicableUsers = coupon.applicableUsers || [];
const isApplicable = coupon.isApplyForAll || applicableUsers.some(au => au.userId === userId);
const isNotExpired = !coupon.validTill || new Date(coupon.validTill) > new Date();
return isNotInvalidated && isApplicable && isNotExpired;
});
// Categorize coupons
const personalCoupons: CouponDisplay[] = [];
const generalCoupons: CouponDisplay[] = [];
applicableCoupons.forEach(coupon => {
const usageCount = coupon.usages.length;
const isExpired = false; // Already filtered out expired coupons
const isUsedUp = Boolean(coupon.maxLimitForUser && usageCount >= coupon.maxLimitForUser);
const couponDisplay: CouponDisplay = {
id: coupon.id,
code: coupon.couponCode,
discountType: coupon.discountPercent ? 'percentage' : 'flat',
discountValue: parseFloat(coupon.discountPercent || coupon.flatDiscount || '0'),
maxValue: coupon.maxValue ? parseFloat(coupon.maxValue) : undefined,
minOrder: coupon.minOrder ? parseFloat(coupon.minOrder) : undefined,
description: generateCouponDescription(coupon),
validTill: coupon.validTill ? new Date(coupon.validTill) : undefined,
usageCount,
maxLimitForUser: coupon.maxLimitForUser ? parseInt(coupon.maxLimitForUser.toString()) : undefined,
isExpired,
isUsedUp,
};
if ((coupon.applicableUsers || []).some(au => au.userId === userId) && !coupon.isApplyForAll) {
// Personal coupon
personalCoupons.push(couponDisplay);
} else if (coupon.isApplyForAll) {
// General coupon
generalCoupons.push(couponDisplay);
}
});
return {
success: true,
data: {
personal: personalCoupons,
general: generalCoupons,
}
};
}),
redeemReservedCoupon: protectedProcedure
.input(z.object({ secretCode: z.string() }))
.mutation(async ({ input, ctx }) => {
const userId = ctx.user.userId;
const { secretCode } = input;
// Find the reserved coupon
const reservedCoupon = await db.query.reservedCoupons.findFirst({
where: and(
eq(reservedCoupons.secretCode, secretCode.toUpperCase()),
eq(reservedCoupons.isRedeemed, false)
),
});
if (!reservedCoupon) {
throw new ApiError("Invalid or already redeemed coupon code", 400);
}
// Check if already redeemed by this user (in case of multiple attempts)
if (reservedCoupon.redeemedBy === userId) {
throw new ApiError("You have already redeemed this coupon", 400);
}
// Create the coupon in the main table
const couponResult = await db.transaction(async (tx) => {
// Insert into coupons
const couponInsert = await tx.insert(coupons).values({
couponCode: reservedCoupon.couponCode,
isUserBased: true,
discountPercent: reservedCoupon.discountPercent,
flatDiscount: reservedCoupon.flatDiscount,
minOrder: reservedCoupon.minOrder,
productIds: reservedCoupon.productIds,
maxValue: reservedCoupon.maxValue,
isApplyForAll: false,
validTill: reservedCoupon.validTill,
maxLimitForUser: reservedCoupon.maxLimitForUser,
exclusiveApply: reservedCoupon.exclusiveApply,
createdBy: reservedCoupon.createdBy,
}).returning();
const coupon = couponInsert[0];
// Insert into couponApplicableUsers
await tx.insert(couponApplicableUsers).values({
couponId: coupon.id,
userId,
});
// Copy applicable products
if (reservedCoupon.productIds && Array.isArray(reservedCoupon.productIds) && reservedCoupon.productIds.length > 0) {
// Assuming productIds are the IDs, but wait, in schema, productIds is jsonb, but in relations, couponApplicableProducts has productId
// For simplicity, since reservedCoupons has productIds as jsonb, but to match, perhaps insert into couponApplicableProducts if needed
// But in createReservedCoupon, I inserted applicableProducts into couponApplicableProducts
// So for reserved, perhaps do the same, but since it's jsonb, maybe not.
// For now, skip, as the coupon will have productIds in coupons table.
}
// Update reserved coupon as redeemed
await tx.update(reservedCoupons).set({
isRedeemed: true,
redeemedBy: userId,
redeemedAt: new Date(),
}).where(eq(reservedCoupons.id, reservedCoupon.id));
return coupon;
});
return { success: true, coupon: couponResult };
}),
});

View file

@ -1,55 +0,0 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index';
import { z } from 'zod';
import { generateUploadUrl } from '@/src/lib/s3-client';
import { ApiError } from '@/src/lib/api-error';
export const fileUploadRouter = router({
generateUploadUrls: protectedProcedure
.input(z.object({
contextString: z.enum(['review', 'product_info', 'notification']),
mimeTypes: z.array(z.string()),
}))
.mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => {
const { contextString, mimeTypes } = input;
const uploadUrls: string[] = [];
const keys: string[] = [];
for (const mimeType of mimeTypes) {
// Generate key based on context and mime type
let folder: string;
if (contextString === 'review') {
folder = 'review-images';
} else if(contextString === 'product_info') {
folder = 'product-images';
}
// else if(contextString === 'review_response') {
// folder = 'review-response-images'
// }
else if(contextString === 'notification') {
folder = 'notification-images'
} else {
folder = '';
}
const extension = mimeType === 'image/jpeg' ? '.jpg' :
mimeType === 'image/png' ? '.png' :
mimeType === 'image/gif' ? '.gif' : '.jpg';
const key = `${folder}/${Date.now()}${extension}`;
try {
const uploadUrl = await generateUploadUrl(key, mimeType);
uploadUrls.push(uploadUrl);
keys.push(key);
} catch (error) {
console.error('Error generating upload URL:', error);
throw new ApiError('Failed to generate upload URL', 500);
}
}
return { uploadUrls };
}),
});
export type FileUploadRouter = typeof fileUploadRouter;

View file

@ -1,979 +0,0 @@
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,
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 { 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) {
}
return insertedOrders;
});
await db.delete(cartItems).where(
and(
eq(cartItems.userId, userId),
inArray(
cartItems.productId,
selectedItems.map((item) => item.productId)
)
)
);
if (appliedCoupon && createdOrders.length > 0) {
await db.insert(couponUsage).values({
userId,
couponId: appliedCoupon.id,
orderId: createdOrders[0].id as number,
orderItemId: null,
usedAt: new Date(),
});
}
for (const order of createdOrders) {
sendOrderPlacedNotification(userId, order.id.toString());
}
await publishFormattedOrder(createdOrders, ordersBySlot);
return { success: true, data: createdOrders };
};
export const orderRouter = router({
placeOrder: protectedProcedure
.input(
z.object({
selectedItems: z.array(
z.object({
productId: z.number().int().positive(),
quantity: z.number().int().positive(),
slotId: z.union([z.number().int(), z.null()]),
})
),
addressId: z.number().int().positive(),
paymentMethod: z.enum(["online", "cod"]),
couponId: z.number().int().positive().optional(),
userNotes: z.string().optional(),
isFlashDelivery: z.boolean().optional().default(false),
})
)
.mutation(async ({ input, ctx }) => {
const userId = ctx.user.userId;
// Check if user is suspended from placing orders
const userDetail = await db.query.userDetails.findFirst({
where: eq(userDetails.userId, userId),
});
if (userDetail?.isSuspended) {
throw new ApiError("Unable to place order", 403);
}
const {
selectedItems,
addressId,
paymentMethod,
couponId,
userNotes,
isFlashDelivery,
} = input;
// Check if flash delivery is enabled when placing a flash delivery order
if (isFlashDelivery) {
const isFlashDeliveryEnabled = await getConstant<boolean>(CONST_KEYS.isFlashDeliveryEnabled);
if (!isFlashDeliveryEnabled) {
throw new ApiError("Flash delivery is currently unavailable. Please opt for scheduled delivery.", 403);
}
}
// Check if any selected slot is at full capacity (only for regular delivery)
if (!isFlashDelivery) {
const slotIds = [...new Set(selectedItems.filter(i => i.slotId !== null).map(i => i.slotId as number))];
for (const slotId of slotIds) {
const slot = await getSlotById(slotId);
if (slot?.isCapacityFull) {
throw new ApiError("Selected delivery slot is at full capacity. Please choose another slot.", 403);
}
}
}
let processedItems = selectedItems;
// Handle flash delivery slot resolution
if (isFlashDelivery) {
// For flash delivery, set slotId to null (no specific slot assigned)
processedItems = selectedItems.map(item => ({
...item,
slotId: null as any, // Type override for flash delivery
}));
}
return await placeOrderUtil({
userId,
selectedItems: processedItems,
addressId,
paymentMethod,
couponId,
userNotes,
isFlash: isFlashDelivery,
});
}),
getOrders: protectedProcedure
.input(
z
.object({
page: z.number().min(1).default(1),
pageSize: z.number().min(1).max(50).default(10),
})
.optional()
)
.query(async ({ input, ctx }) => {
const { page = 1, pageSize = 10 } = input || {};
const userId = ctx.user.userId;
const offset = (page - 1) * pageSize;
// Get total count for pagination
const totalCountResult = await db.$count(
orders,
eq(orders.userId, userId)
);
const totalCount = totalCountResult;
const userOrders = await db.query.orders.findMany({
where: eq(orders.userId, userId),
with: {
orderItems: {
with: {
product: true,
},
},
slot: true,
paymentInfo: true,
orderStatus: true,
refunds: true,
},
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
limit: pageSize,
offset: offset,
});
const mappedOrders = await Promise.all(
userOrders.map(async (order) => {
const status = order.orderStatus[0];
const refund = order.refunds[0];
type DeliveryStatus = "cancelled" | "success" | "pending" | "packaged";
type OrderStatus = "cancelled" | "success";
let deliveryStatus: DeliveryStatus;
let orderStatus: OrderStatus;
const allItemsPackaged = order.orderItems.every(
(item) => item.is_packaged
);
if (status?.isCancelled) {
deliveryStatus = "cancelled";
orderStatus = "cancelled";
} else if (status?.isDelivered) {
deliveryStatus = "success";
orderStatus = "success";
} else if (allItemsPackaged) {
deliveryStatus = "packaged";
orderStatus = "success";
} else {
deliveryStatus = "pending";
orderStatus = "success";
}
const paymentMode = order.isCod ? "CoD" : "Online";
const paymentStatus = status?.paymentStatus || "pending";
const refundStatus = refund?.refundStatus || "none";
const refundAmount = refund?.refundAmount
? parseFloat(refund.refundAmount.toString())
: null;
const items = await Promise.all(
order.orderItems.map(async (item) => {
const signedImages = item.product.images
? scaffoldAssetUrl(
item.product.images as string[]
)
: [];
return {
productName: item.product.name,
quantity: parseFloat(item.quantity),
price: parseFloat(item.price.toString()),
discountedPrice: parseFloat(
item.discountedPrice?.toString() || item.price.toString()
),
amount:
parseFloat(item.price.toString()) * parseFloat(item.quantity),
image: signedImages[0] || null,
};
})
);
return {
id: order.id,
orderId: `ORD${order.id}`,
orderDate: order.createdAt.toISOString(),
deliveryStatus,
deliveryDate: order.slot?.deliveryTime.toISOString(),
orderStatus,
cancelReason: status?.cancelReason || null,
paymentMode,
totalAmount: Number(order.totalAmount),
deliveryCharge: Number(order.deliveryCharge),
paymentStatus,
refundStatus,
refundAmount,
userNotes: order.userNotes || null,
items,
isFlashDelivery: order.isFlashDelivery,
createdAt: order.createdAt.toISOString(),
};
})
);
return {
success: true,
data: mappedOrders,
pagination: {
page,
pageSize,
totalCount,
totalPages: Math.ceil(totalCount / pageSize),
},
};
}),
getOrderById: protectedProcedure
.input(z.object({ orderId: z.string() }))
.query(async ({ input, ctx }) => {
const { orderId } = input;
const userId = ctx.user.userId;
const order = await db.query.orders.findFirst({
where: and(eq(orders.id, parseInt(orderId)), eq(orders.userId, userId)),
with: {
orderItems: {
with: {
product: true,
},
},
slot: true,
paymentInfo: true,
orderStatus: {
with: {
refundCoupon: true,
},
},
refunds: true,
},
});
if (!order) {
throw new Error("Order not found");
}
// Get coupon usage for this specific order using new orderId field
const couponUsageData = await db.query.couponUsage.findMany({
where: eq(couponUsage.orderId, order.id), // Use new orderId field
with: {
coupon: true,
},
});
let couponData = null;
if (couponUsageData.length > 0) {
// Calculate total discount from multiple coupons
let totalDiscountAmount = 0;
const orderTotal = parseFloat(order.totalAmount.toString());
for (const usage of couponUsageData) {
let discountAmount = 0;
if (usage.coupon.discountPercent) {
discountAmount =
(orderTotal *
parseFloat(usage.coupon.discountPercent.toString())) /
100;
} else if (usage.coupon.flatDiscount) {
discountAmount = parseFloat(usage.coupon.flatDiscount.toString());
}
// Apply max value limit if set
if (
usage.coupon.maxValue &&
discountAmount > parseFloat(usage.coupon.maxValue.toString())
) {
discountAmount = parseFloat(usage.coupon.maxValue.toString());
}
totalDiscountAmount += discountAmount;
}
couponData = {
couponCode: couponUsageData
.map((u) => u.coupon.couponCode)
.join(", "),
couponDescription: `${couponUsageData.length} coupons applied`,
discountAmount: totalDiscountAmount,
};
}
const status = order.orderStatus[0];
const refund = order.refunds[0];
type DeliveryStatus = "cancelled" | "success" | "pending" | "packaged";
type OrderStatus = "cancelled" | "success";
let deliveryStatus: DeliveryStatus;
let orderStatus: OrderStatus;
const allItemsPackaged = order.orderItems.every(
(item) => item.is_packaged
);
if (status?.isCancelled) {
deliveryStatus = "cancelled";
orderStatus = "cancelled";
} else if (status?.isDelivered) {
deliveryStatus = "success";
orderStatus = "success";
} else if (allItemsPackaged) {
deliveryStatus = "packaged";
orderStatus = "success";
} else {
deliveryStatus = "pending";
orderStatus = "success";
}
const paymentMode = order.isCod ? "CoD" : "Online";
const paymentStatus = status?.paymentStatus || "pending";
const refundStatus = refund?.refundStatus || "none";
const refundAmount = refund?.refundAmount
? parseFloat(refund.refundAmount.toString())
: null;
const items = await Promise.all(
order.orderItems.map(async (item) => {
const signedImages = item.product.images
? scaffoldAssetUrl(
item.product.images as string[]
)
: [];
return {
productName: item.product.name,
quantity: parseFloat(item.quantity),
price: parseFloat(item.price.toString()),
discountedPrice: parseFloat(
item.discountedPrice?.toString() || item.price.toString()
),
amount:
parseFloat(item.price.toString()) * parseFloat(item.quantity),
image: signedImages[0] || null,
};
})
);
return {
id: order.id,
orderId: `ORD${order.id}`,
orderDate: order.createdAt.toISOString(),
deliveryStatus,
deliveryDate: order.slot?.deliveryTime.toISOString(),
orderStatus: order.orderStatus,
cancellationStatus: orderStatus,
cancelReason: status?.cancelReason || null,
paymentMode,
paymentStatus,
refundStatus,
refundAmount,
userNotes: order.userNotes || null,
items,
couponCode: couponData?.couponCode || null,
couponDescription: couponData?.couponDescription || null,
discountAmount: couponData?.discountAmount || null,
orderAmount: parseFloat(order.totalAmount.toString()),
isFlashDelivery: order.isFlashDelivery,
createdAt: order.createdAt.toISOString(),
};
}),
cancelOrder: protectedProcedure
.input(
z.object({
// id: z.string().regex(/^ORD\d+$/, "Invalid order ID format"),
id: z.number(),
reason: z.string().min(1, "Cancellation reason is required"),
})
)
.mutation(async ({ input, ctx }) => {
try {
const userId = ctx.user.userId;
const { id, reason } = input;
// Check if order exists and belongs to user
const order = await db.query.orders.findFirst({
where: eq(orders.id, Number(id)),
with: {
orderStatus: true,
},
});
if (!order) {
console.error("Order not found:", id);
throw new ApiError("Order not found", 404);
}
if (order.userId !== userId) {
console.error("Order does not belong to user:", {
orderId: id,
orderUserId: order.userId,
requestUserId: userId,
});
throw new ApiError("Order not found", 404);
}
const status = order.orderStatus[0];
if (!status) {
console.error("Order status not found for order:", id);
throw new ApiError("Order status not found", 400);
}
if (status.isCancelled) {
console.error("Order is already cancelled:", id);
throw new ApiError("Order is already cancelled", 400);
}
if (status.isDelivered) {
console.error("Cannot cancel delivered order:", id);
throw new ApiError("Cannot cancel delivered order", 400);
}
// Perform database operations in transaction
const result = await db.transaction(async (tx) => {
// Update order status
await tx
.update(orderStatus)
.set({
isCancelled: true,
cancelReason: reason,
cancellationUserNotes: reason,
cancellationReviewed: false,
})
.where(eq(orderStatus.id, status.id));
// Determine refund status based on payment method
const refundStatus = order.isCod ? "na" : "pending";
// Insert refund record
await tx.insert(refunds).values({
orderId: order.id,
refundStatus,
});
return { orderId: order.id, userId };
});
// Send notification outside transaction (idempotent operation)
await sendOrderCancelledNotification(
result.userId,
result.orderId.toString()
);
// Publish to Redis for Telegram notification
await publishCancellation(result.orderId, 'user', reason);
return { success: true, message: "Order cancelled successfully" };
} catch (e) {
console.log(e);
throw new ApiError("failed to cancel order");
}
}),
updateUserNotes: protectedProcedure
.input(
z.object({
id: z.number(),
userNotes: z.string(),
})
)
.mutation(async ({ input, ctx }) => {
const userId = ctx.user.userId;
const { id, userNotes } = input;
// Extract readable ID from orderId (e.g., ORD001 -> 1)
// const readableIdMatch = id.match(/^ORD(\d+)$/);
// if (!readableIdMatch) {
// console.error("Invalid order ID format:", id);
// throw new ApiError("Invalid order ID format", 400);
// }
// const readableId = parseInt(readableIdMatch[1]);
// Check if order exists and belongs to user
const order = await db.query.orders.findFirst({
where: eq(orders.id, Number(id)),
with: {
orderStatus: true,
},
});
if (!order) {
console.error("Order not found:", id);
throw new ApiError("Order not found", 404);
}
if (order.userId !== userId) {
console.error("Order does not belong to user:", {
orderId: id,
orderUserId: order.userId,
requestUserId: userId,
});
throw new ApiError("Order not found", 404);
}
const status = order.orderStatus[0];
if (!status) {
console.error("Order status not found for order:", id);
throw new ApiError("Order status not found", 400);
}
// Only allow updating notes for orders that are not delivered or cancelled
if (status.isDelivered) {
console.error("Cannot update notes for delivered order:", id);
throw new ApiError("Cannot update notes for delivered order", 400);
}
if (status.isCancelled) {
console.error("Cannot update notes for cancelled order:", id);
throw new ApiError("Cannot update notes for cancelled order", 400);
}
// Update user notes
await db
.update(orders)
.set({
userNotes: userNotes || null,
})
.where(eq(orders.id, order.id));
return { success: true, message: "Notes updated successfully" };
}),
getRecentlyOrderedProducts: protectedProcedure
.input(
z
.object({
limit: z.number().min(1).max(50).default(20),
})
.optional()
)
.query(async ({ input, ctx }) => {
const { limit = 20 } = input || {};
const userId = ctx.user.userId;
// Get user's recent delivered orders (last 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const recentOrders = await db
.select({ id: orders.id })
.from(orders)
.innerJoin(orderStatus, eq(orders.id, orderStatus.orderId))
.where(
and(
eq(orders.userId, userId),
eq(orderStatus.isDelivered, true),
gte(orders.createdAt, thirtyDaysAgo)
)
)
.orderBy(desc(orders.createdAt))
.limit(10); // Get last 10 orders
if (recentOrders.length === 0) {
return { success: true, products: [] };
}
const orderIds = recentOrders.map((order) => order.id);
// Get unique product IDs from recent orders
const orderItemsResult = await db
.select({ productId: orderItems.productId })
.from(orderItems)
.where(inArray(orderItems.orderId, orderIds));
const productIds = [
...new Set(orderItemsResult.map((item) => item.productId)),
];
if (productIds.length === 0) {
return { success: true, products: [] };
}
// Get product details
const productsWithUnits = await db
.select({
id: productInfo.id,
name: productInfo.name,
shortDescription: productInfo.shortDescription,
price: productInfo.price,
images: productInfo.images,
isOutOfStock: productInfo.isOutOfStock,
unitShortNotation: units.shortNotation,
incrementStep: productInfo.incrementStep,
})
.from(productInfo)
.innerJoin(units, eq(productInfo.unitId, units.id))
.where(
and(
inArray(productInfo.id, productIds),
eq(productInfo.isSuspended, false)
)
)
.orderBy(desc(productInfo.createdAt))
.limit(limit);
// Generate signed URLs for product images
const formattedProducts = await Promise.all(
productsWithUnits.map(async (product) => {
const nextDeliveryDate = await getNextDeliveryDate(product.id);
return {
id: product.id,
name: product.name,
shortDescription: product.shortDescription,
price: product.price,
unit: product.unitShortNotation,
incrementStep: product.incrementStep,
isOutOfStock: product.isOutOfStock,
nextDeliveryDate: nextDeliveryDate
? nextDeliveryDate.toISOString()
: null,
images: scaffoldAssetUrl(
(product.images as string[]) || []
),
};
})
);
return {
success: true,
products: formattedProducts,
};
}),
});

View file

@ -1,266 +0,0 @@
import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index';
import { z } from 'zod';
import { db } from '@/src/db/db_index';
import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productTagInfo, productTags, productReviews, users } from '@/src/db/schema';
import { claimUploadUrl, extractKeyFromPresignedUrl, scaffoldAssetUrl } from '@/src/lib/s3-client';
import { ApiError } from '@/src/lib/api-error';
import { eq, and, gt, sql, inArray, desc } from 'drizzle-orm';
import { getProductById as getProductByIdFromCache, getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store';
import dayjs from 'dayjs';
// Uniform Product Type
interface Product {
id: number;
name: string;
shortDescription: string | null;
longDescription: string | null;
price: string;
marketPrice: string | null;
unitNotation: string;
images: string[];
isOutOfStock: boolean;
store: { id: number; name: string; description: string | null } | null;
incrementStep: number;
productQuantity: number;
isFlashAvailable: boolean;
flashPrice: string | null;
deliverySlots: Array<{ id: number; deliveryTime: Date; freezeTime: Date }>;
specialDeals: Array<{ quantity: string; price: string; validTill: Date }>;
}
export const productRouter = router({
getProductDetails: publicProcedure
.input(z.object({
id: z.string().regex(/^\d+$/, 'Invalid product ID'),
}))
.query(async ({ input }): Promise<Product> => {
const { id } = input;
const productId = parseInt(id);
if (isNaN(productId)) {
throw new Error('Invalid product ID');
}
console.log('from the api to get product details')
// First, try to get the product from Redis cache
const cachedProduct = await getProductByIdFromCache(productId);
if (cachedProduct) {
// Filter delivery slots to only include those with future freeze times and not at full capacity
const currentTime = new Date();
const filteredSlots = cachedProduct.deliverySlots.filter(slot =>
dayjs(slot.freezeTime).isAfter(currentTime) && !slot.isCapacityFull
);
return {
...cachedProduct,
deliverySlots: filteredSlots
};
}
// If not in cache, fetch from database (fallback)
const productData = await db
.select({
id: productInfo.id,
name: productInfo.name,
shortDescription: productInfo.shortDescription,
longDescription: productInfo.longDescription,
price: productInfo.price,
marketPrice: productInfo.marketPrice,
images: productInfo.images,
isOutOfStock: productInfo.isOutOfStock,
storeId: productInfo.storeId,
unitShortNotation: units.shortNotation,
incrementStep: productInfo.incrementStep,
productQuantity: productInfo.productQuantity,
isFlashAvailable: productInfo.isFlashAvailable,
flashPrice: productInfo.flashPrice,
})
.from(productInfo)
.innerJoin(units, eq(productInfo.unitId, units.id))
.where(eq(productInfo.id, productId))
.limit(1);
if (productData.length === 0) {
throw new Error('Product not found');
}
const product = productData[0];
// Fetch store info for this product
const storeData = product.storeId ? await db.query.storeInfo.findFirst({
where: eq(storeInfo.id, product.storeId),
columns: { id: true, name: true, description: true },
}) : null;
// Fetch delivery slots for this product
const deliverySlotsData = await db
.select({
id: deliverySlotInfo.id,
deliveryTime: deliverySlotInfo.deliveryTime,
freezeTime: deliverySlotInfo.freezeTime,
})
.from(productSlots)
.innerJoin(deliverySlotInfo, eq(productSlots.slotId, deliverySlotInfo.id))
.where(
and(
eq(productSlots.productId, productId),
eq(deliverySlotInfo.isActive, true),
eq(deliverySlotInfo.isCapacityFull, false),
gt(deliverySlotInfo.deliveryTime, sql`NOW()`),
gt(deliverySlotInfo.freezeTime, sql`NOW()`)
)
)
.orderBy(deliverySlotInfo.deliveryTime);
// Fetch special deals for this product
const specialDealsData = await db
.select({
quantity: specialDeals.quantity,
price: specialDeals.price,
validTill: specialDeals.validTill,
})
.from(specialDeals)
.where(
and(
eq(specialDeals.productId, productId),
gt(specialDeals.validTill, sql`NOW()`)
)
)
.orderBy(specialDeals.quantity);
// Generate signed URLs for images
const signedImages = scaffoldAssetUrl((product.images as string[]) || []);
const response: Product = {
id: product.id,
name: product.name,
shortDescription: product.shortDescription,
longDescription: product.longDescription,
price: product.price.toString(),
marketPrice: product.marketPrice?.toString() || null,
unitNotation: product.unitShortNotation,
images: signedImages,
isOutOfStock: product.isOutOfStock,
store: storeData ? {
id: storeData.id,
name: storeData.name,
description: storeData.description,
} : null,
incrementStep: product.incrementStep,
productQuantity: product.productQuantity,
isFlashAvailable: product.isFlashAvailable,
flashPrice: product.flashPrice?.toString() || null,
deliverySlots: deliverySlotsData,
specialDeals: specialDealsData.map(d => ({ quantity: d.quantity.toString(), price: d.price.toString(), validTill: d.validTill })),
};
return response;
}),
getProductReviews: publicProcedure
.input(z.object({
productId: z.number().int().positive(),
limit: z.number().int().min(1).max(50).optional().default(10),
offset: z.number().int().min(0).optional().default(0),
}))
.query(async ({ input }) => {
const { productId, limit, offset } = input;
const reviews = await db
.select({
id: productReviews.id,
reviewBody: productReviews.reviewBody,
ratings: productReviews.ratings,
imageUrls: productReviews.imageUrls,
reviewTime: productReviews.reviewTime,
userName: users.name,
})
.from(productReviews)
.innerJoin(users, eq(productReviews.userId, users.id))
.where(eq(productReviews.productId, productId))
.orderBy(desc(productReviews.reviewTime))
.limit(limit)
.offset(offset);
// Generate signed URLs for images
const reviewsWithSignedUrls = await Promise.all(
reviews.map(async (review) => ({
...review,
signedImageUrls: scaffoldAssetUrl((review.imageUrls as string[]) || []),
}))
);
// Check if more reviews exist
const totalCountResult = await db
.select({ count: sql`count(*)` })
.from(productReviews)
.where(eq(productReviews.productId, productId));
const totalCount = Number(totalCountResult[0].count);
const hasMore = offset + limit < totalCount;
return { reviews: reviewsWithSignedUrls, hasMore };
}),
createReview: protectedProcedure
.input(z.object({
productId: z.number().int().positive(),
reviewBody: z.string().min(1, 'Review body is required'),
ratings: z.number().int().min(1).max(5),
imageUrls: z.array(z.string()).optional().default([]),
uploadUrls: z.array(z.string()).optional().default([]),
}))
.mutation(async ({ input, ctx }) => {
const { productId, reviewBody, ratings, imageUrls, uploadUrls } = input;
const userId = ctx.user.userId;
// Optional: Check if product exists
const product = await db.query.productInfo.findFirst({
where: eq(productInfo.id, productId),
});
if (!product) {
throw new ApiError('Product not found', 404);
}
// Insert review
const [newReview] = await db.insert(productReviews).values({
userId,
productId,
reviewBody,
ratings,
imageUrls: uploadUrls.map(item => extractKeyFromPresignedUrl(item)),
}).returning();
// Claim upload URLs
if (uploadUrls && uploadUrls.length > 0) {
try {
await Promise.all(uploadUrls.map(url => claimUploadUrl(url)));
} catch (error) {
console.error('Error claiming upload URLs:', error);
// Don't fail the review creation
}
}
return { success: true, review: newReview };
}),
getAllProductsSummary: publicProcedure
.query(async (): Promise<Product[]> => {
// Get all products from cache
const allCachedProducts = await getAllProductsFromCache();
// Transform the cached products to match the expected summary format
// (with empty deliverySlots and specialDeals arrays for summary view)
const transformedProducts = allCachedProducts.map(product => ({
...product,
deliverySlots: [], // Empty for summary view
specialDeals: [], // Empty for summary view
}));
return transformedProducts;
}),
});

View file

@ -1,92 +0,0 @@
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 } 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 async function scaffoldSlotsWithProducts() {
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());
// Fetch all products for availability info
const allProducts = await db
.select({
id: productInfo.id,
name: productInfo.name,
isOutOfStock: productInfo.isOutOfStock,
isFlashAvailable: productInfo.isFlashAvailable,
})
.from(productInfo)
.where(eq(productInfo.isSuspended, false));
const productAvailability = allProducts.map(product => ({
id: product.id,
name: product.name,
isOutOfStock: product.isOutOfStock,
isFlashAvailable: product.isFlashAvailable,
}));
return {
slots: validSlots,
productAvailability,
count: validSlots.length,
};
}
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 response = await scaffoldSlotsWithProducts();
return response;
}),
getSlotById: publicProcedure
.input(z.object({ slotId: z.number() }))
.query(async ({ input }) => {
return await getSlotData(input.slotId);
}),
});

View file

@ -1,162 +0,0 @@
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';
import { getTagsByStoreId } from '@/src/stores/product-tag-store';
export async function scaffoldStores() {
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,
};
}
export async function scaffoldStoreWithProducts(storeId: number) {
// 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
}))
);
const tags = await getTagsByStoreId(storeId);
return {
store: {
id: storeData.id,
name: storeData.name,
description: storeData.description,
signedImageUrl,
},
products: productsWithSignedUrls,
tags: tags.map(tag => ({
id: tag.id,
tagName: tag.tagName,
tagDescription: tag.tagDescription,
imageUrl: tag.imageUrl,
productIds: tag.productIds,
})),
};
}
export const storesRouter = router({
getStores: publicProcedure
.query(async () => {
const response = await scaffoldStores();
return response;
}),
getStoreWithProducts: publicProcedure
.input(z.object({
storeId: z.number(),
}))
.query(async ({ input }) => {
const { storeId } = input;
const response = await scaffoldStoreWithProducts(storeId);
return response;
}),
});

View file

@ -1,28 +0,0 @@
import { router, publicProcedure } from '@/src/trpc/trpc-index';
import { z } from 'zod';
import { getTagsByStoreId } from '@/src/stores/product-tag-store';
import { ApiError } from '@/src/lib/api-error';
export const tagsRouter = router({
getTagsByStore: publicProcedure
.input(z.object({
storeId: z.number(),
}))
.query(async ({ input }) => {
const { storeId } = input;
// Get tags from cache that are related to this store
const tags = await getTagsByStoreId(storeId);
return {
tags: tags.map(tag => ({
id: tag.id,
tagName: tag.tagName,
tagDescription: tag.tagDescription,
imageUrl: tag.imageUrl,
productIds: tag.productIds,
})),
};
}),
});

View file

@ -1,32 +0,0 @@
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 { 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({
address: addressRouter,
auth: authRouter,
banner: bannerRouter,
cart: cartRouter,
complaint: complaintRouter,
order: orderRouter,
product: productRouter,
slots: slotsRouter,
user: userDataRouter,
coupon: userCouponRouter,
stores: storesRouter,
fileUpload: fileUploadRouter,
tags: tagsRouter,
});
export type UserRouter = typeof userRouter;

View file

@ -1,164 +0,0 @@
import { router, protectedProcedure, publicProcedure } from '@/src/trpc/trpc-index';
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 { generateSignedUrlFromS3Url } from '@/src/lib/s3-client';
import { signToken } from '@/src/lib/jwt-utils';
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 = async (userId: number): Promise<string> => {
return signToken({ userId });
};
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 };
}),
});