freshyo/verifier/user-apis/apis/coupon.ts
2026-03-22 20:20:18 +05:30

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