296 lines
9.6 KiB
TypeScript
296 lines
9.6 KiB
TypeScript
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 };
|
|
}),
|
|
});
|