enh
This commit is contained in:
parent
56b606ebcf
commit
a23d3bf5b8
51 changed files with 0 additions and 14495 deletions
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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 };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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];
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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' };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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" };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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'}`,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
@ -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
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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 };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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];
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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' };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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" };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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'}`,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
@ -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
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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' };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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' };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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' };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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 };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
}),
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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 };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
Loading…
Add table
Reference in a new issue