From e77982e5a6c3934956a0ff13d1ec931b3d095bbf Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:00:23 +0530 Subject: [PATCH] enh --- .../app/(drawer)/dashboard/coupons/edit/[id].tsx | 1 + apps/admin-ui/src/components/CouponForm.tsx | 16 ++++++++++++++++ .../src/trpc/apis/admin-apis/apis/coupon.ts | 8 ++++++-- .../src/trpc/apis/user-apis/apis/coupon.ts | 14 ++++++++++++-- .../drizzle/0003_first_order_only.sql | 6 ++++++ .../db_helper_sqlite/drizzle/meta/_journal.json | 7 +++++++ .../db_helper_sqlite/src/admin-apis/coupon.ts | 5 +++++ packages/db_helper_sqlite/src/db/schema.ts | 2 ++ .../db_helper_sqlite/src/user-apis/coupon.ts | 2 ++ packages/db_helper_sqlite/src/user-apis/order.ts | 9 +++++++++ packages/shared/types/admin.ts | 1 + packages/shared/types/user.ts | 1 + packages/ui/shared-types.ts | 2 ++ 13 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 packages/db_helper_sqlite/drizzle/0003_first_order_only.sql diff --git a/apps/admin-ui/app/(drawer)/dashboard/coupons/edit/[id].tsx b/apps/admin-ui/app/(drawer)/dashboard/coupons/edit/[id].tsx index cfb8dd4..582fd90 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/coupons/edit/[id].tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/coupons/edit/[id].tsx @@ -89,6 +89,7 @@ export default function EditCoupon() { maxValue: coupon.maxValue ? parseFloat(coupon.maxValue) : undefined, validTill: coupon.validTill ? dayjs(coupon.validTill).format('YYYY-MM-DD') : undefined, maxLimitForUser: coupon.maxLimitForUser || undefined, + isFirstOrderOnly: coupon.isFirstOrderOnly, skuIds: coupon.skuIds, isReservedCoupon: false, // Normal coupons }; diff --git a/apps/admin-ui/src/components/CouponForm.tsx b/apps/admin-ui/src/components/CouponForm.tsx index 1e33c2e..7717276 100644 --- a/apps/admin-ui/src/components/CouponForm.tsx +++ b/apps/admin-ui/src/components/CouponForm.tsx @@ -42,6 +42,7 @@ const couponValidationSchema = Yup.object().shape({ validTill: Yup.date().optional(), maxLimitForUser: Yup.number().min(1, 'Must be at least 1').optional(), exclusiveApply: Yup.boolean().optional(), + isFirstOrderOnly: Yup.boolean().optional(), isUserBased: Yup.boolean(), isApplyForAll: Yup.boolean(), applicableUsers: Yup.array().of(Yup.number()).optional(), @@ -133,6 +134,7 @@ export default function CouponForm({ onSubmit, isLoading, initialValues }: Coupo applicableUsers: [], applicableProducts: [], exclusiveApply: false, + isFirstOrderOnly: false, isReservedCoupon: false, }; @@ -379,6 +381,20 @@ export default function CouponForm({ onSubmit, isLoading, initialValues }: Coupo + {/* First Order Only */} + + First Order Only + setFieldValue('isFirstOrderOnly', !values.isFirstOrderOnly)} + style={tw`flex-row items-center`} + > + + {values.isFirstOrderOnly && } + + Valid only on the customer's first order + + + {/* Target Audience */} Target Audience {isReserved ? '(Disabled for Reserved Coupons)' : ''} diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts b/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts index 703cd2a..e1690f1 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts @@ -38,6 +38,7 @@ const createCouponBodySchema = z.object({ validTill: z.string().optional(), maxLimitForUser: z.number().optional(), exclusiveApply: z.boolean().optional(), + isFirstOrderOnly: z.boolean().optional(), }); const validateCouponBodySchema = z.object({ @@ -50,7 +51,7 @@ export const couponRouter = router({ create: protectedProcedure .input(createCouponBodySchema) .mutation(async ({ input, ctx }): Promise => { - const { couponCode, couponCodes, isUserBased, discountPercent, flatDiscount, minOrder, skuIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input; + const { couponCode, couponCodes, isUserBased, discountPercent, flatDiscount, minOrder, skuIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply, isFirstOrderOnly } = input; // Validation: ensure at least one discount type is provided if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { @@ -114,6 +115,7 @@ export const couponRouter = router({ validTill: validTill ? dayjs(validTill).toDate() : undefined, maxLimitForUser, exclusiveApply: exclusiveApply || false, + isFirstOrderOnly: isFirstOrderOnly || false, }, applicableUsers, applicableProducts @@ -190,6 +192,7 @@ export const couponRouter = router({ if (updates.validTill !== undefined) updateData.validTill = updates.validTill ? dayjs(updates.validTill).toDate() : null; if (updates.maxLimitForUser !== undefined) updateData.maxLimitForUser = updates.maxLimitForUser; if (updates.exclusiveApply !== undefined) updateData.exclusiveApply = updates.exclusiveApply; + if (updates.isFirstOrderOnly !== undefined) updateData.isFirstOrderOnly = updates.isFirstOrderOnly; if (updates.isInvalidated !== undefined) updateData.isInvalidated = updates.isInvalidated; if (updates.skuIds !== undefined) updateData.skuIds = updates.skuIds; @@ -375,7 +378,7 @@ export const couponRouter = router({ createReservedCoupon: protectedProcedure .input(createCouponBodySchema) .mutation(async ({ input, ctx }): Promise => { - const { couponCode, couponCodes, isUserBased, discountPercent, flatDiscount, minOrder, skuIds, applicableProducts, maxValue, validTill, maxLimitForUser, exclusiveApply } = input; + const { couponCode, couponCodes, isUserBased, discountPercent, flatDiscount, minOrder, skuIds, applicableProducts, maxValue, validTill, maxLimitForUser, exclusiveApply, isFirstOrderOnly } = input; // Validation: ensure at least one discount type is provided if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { @@ -419,6 +422,7 @@ export const couponRouter = router({ validTill: validTill ? dayjs(validTill).toDate() : undefined, maxLimitForUser, exclusiveApply: exclusiveApply || false, + isFirstOrderOnly: isFirstOrderOnly || false, createdBy: staffUserId, }, applicableProducts diff --git a/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts b/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts index 8f807e3..f762bb8 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts @@ -6,6 +6,7 @@ import { getUserAllCouponsWithRelations as getUserAllCouponsWithRelationsInDb, getUserReservedCouponByCode as getUserReservedCouponByCodeInDb, redeemUserReservedCoupon as redeemUserReservedCouponInDb, + getUserOrderCount as getUserOrderCountInDb, } from '@/src/dbService' import type { UserCouponDisplay, @@ -72,7 +73,9 @@ export const userCouponRouter = router({ */ // Filter to only coupons applicable to current user + const isFirstOrder = (await getUserOrderCountInDb(userId)) === 0; const applicableCoupons = allCoupons.filter(coupon => { + if (coupon.isFirstOrderOnly && !isFirstOrder) return false; if(!coupon.isUserBased) return true; const applicableUsers = coupon.applicableUsers || []; return applicableUsers.some(au => au.userId === userId); @@ -108,13 +111,15 @@ export const userCouponRouter = router({ }); */ - // Filter coupons in JS: not invalidated, applicable to user, and not expired + // Filter coupons in JS: not invalidated, applicable to user, not expired, and first-order eligible + const isFirstOrder = (await getUserOrderCountInDb(userId)) === 0; const applicableCoupons = allCoupons.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; + const isFirstOrderEligible = !coupon.isFirstOrderOnly || isFirstOrder; + return isNotInvalidated && isApplicable && isNotExpired && isFirstOrderEligible; }); // Categorize coupons @@ -186,6 +191,11 @@ export const userCouponRouter = router({ throw new ApiError("You have already redeemed this coupon", 400); } + const isFirstOrder = (await getUserOrderCountInDb(userId)) === 0; + if (reservedCoupon.isFirstOrderOnly && !isFirstOrder) { + throw new ApiError("This is first order only coupon. You already placed your first order. This coupon doesn't apply to you", 400); + } + const couponResult = await redeemUserReservedCouponInDb(userId, reservedCoupon) /* diff --git a/packages/db_helper_sqlite/drizzle/0003_first_order_only.sql b/packages/db_helper_sqlite/drizzle/0003_first_order_only.sql new file mode 100644 index 0000000..660a57d --- /dev/null +++ b/packages/db_helper_sqlite/drizzle/0003_first_order_only.sql @@ -0,0 +1,6 @@ +-- Migration: Add is_first_order_only flag to coupons and reserved_coupons +-- When true, the coupon can only be applied to a user's first order. + +ALTER TABLE `coupons` ADD COLUMN `is_first_order_only` integer DEFAULT false NOT NULL; + +ALTER TABLE `reserved_coupons` ADD COLUMN `is_first_order_only` integer DEFAULT false NOT NULL; diff --git a/packages/db_helper_sqlite/drizzle/meta/_journal.json b/packages/db_helper_sqlite/drizzle/meta/_journal.json index f81f69f..8fd57da 100644 --- a/packages/db_helper_sqlite/drizzle/meta/_journal.json +++ b/packages/db_helper_sqlite/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1785336600165, "tag": "0002_sku_split", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1789236103378, + "tag": "0003_first_order_only", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db_helper_sqlite/src/admin-apis/coupon.ts b/packages/db_helper_sqlite/src/admin-apis/coupon.ts index 2e173b3..61cec5a 100644 --- a/packages/db_helper_sqlite/src/admin-apis/coupon.ts +++ b/packages/db_helper_sqlite/src/admin-apis/coupon.ts @@ -15,6 +15,7 @@ export interface Coupon { validTill: Date | null maxLimitForUser: number | null exclusiveApply: boolean + isFirstOrderOnly: boolean isInvalidated: boolean createdAt: Date createdBy: number @@ -100,6 +101,7 @@ export interface CreateCouponInput { validTill?: Date maxLimitForUser?: number exclusiveApply: boolean + isFirstOrderOnly?: boolean createdBy: number } @@ -122,6 +124,7 @@ export async function createCouponWithRelations( validTill: input.validTill, maxLimitForUser: input.maxLimitForUser, exclusiveApply: input.exclusiveApply, + isFirstOrderOnly: input.isFirstOrderOnly ?? false, }).returning() if (applicableUsers && applicableUsers.length > 0) { @@ -158,6 +161,7 @@ export interface UpdateCouponInput { validTill?: Date | null maxLimitForUser?: number exclusiveApply?: boolean + isFirstOrderOnly?: boolean isInvalidated?: boolean } @@ -328,6 +332,7 @@ export async function createReservedCouponWithProducts( validTill: input.validTill, maxLimitForUser: input.maxLimitForUser, exclusiveApply: input.exclusiveApply, + isFirstOrderOnly: input.isFirstOrderOnly ?? false, createdBy: input.createdBy, }).returning() diff --git a/packages/db_helper_sqlite/src/db/schema.ts b/packages/db_helper_sqlite/src/db/schema.ts index c2247b3..45f4338 100644 --- a/packages/db_helper_sqlite/src/db/schema.ts +++ b/packages/db_helper_sqlite/src/db/schema.ts @@ -470,6 +470,7 @@ export const coupons = sqliteTable('coupons', { maxLimitForUser: integer('max_limit_for_user'), isInvalidated: integer('is_invalidated', { mode: 'boolean' }).notNull().default(false), exclusiveApply: integer('exclusive_apply', { mode: 'boolean' }).notNull().default(false), + isFirstOrderOnly: integer('is_first_order_only', { mode: 'boolean' }).notNull().default(false), createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), }) @@ -520,6 +521,7 @@ export const reservedCoupons = sqliteTable('reserved_coupons', { validTill: timestampText('valid_till'), maxLimitForUser: integer('max_limit_for_user'), exclusiveApply: integer('exclusive_apply', { mode: 'boolean' }).notNull().default(false), + isFirstOrderOnly: integer('is_first_order_only', { mode: 'boolean' }).notNull().default(false), isRedeemed: integer('is_redeemed', { mode: 'boolean' }).notNull().default(false), redeemedBy: integer('redeemed_by').references(() => users.id), redeemedAt: timestampText('redeemed_at'), diff --git a/packages/db_helper_sqlite/src/user-apis/coupon.ts b/packages/db_helper_sqlite/src/user-apis/coupon.ts index 48f2349..014cfbf 100644 --- a/packages/db_helper_sqlite/src/user-apis/coupon.ts +++ b/packages/db_helper_sqlite/src/user-apis/coupon.ts @@ -30,6 +30,7 @@ const mapCoupon = (coupon: CouponRow): UserCoupon => ({ maxLimitForUser: coupon.maxLimitForUser ?? null, isInvalidated: coupon.isInvalidated, exclusiveApply: coupon.exclusiveApply, + isFirstOrderOnly: coupon.isFirstOrderOnly, createdAt: coupon.createdAt, }) @@ -125,6 +126,7 @@ export async function redeemReservedCoupon(userId: number, reservedCoupon: Reser validTill: reservedCoupon.validTill, maxLimitForUser: reservedCoupon.maxLimitForUser, exclusiveApply: reservedCoupon.exclusiveApply, + isFirstOrderOnly: reservedCoupon.isFirstOrderOnly, createdBy: reservedCoupon.createdBy, }).returning() diff --git a/packages/db_helper_sqlite/src/user-apis/order.ts b/packages/db_helper_sqlite/src/user-apis/order.ts index aa82550..aeea6ce 100644 --- a/packages/db_helper_sqlite/src/user-apis/order.ts +++ b/packages/db_helper_sqlite/src/user-apis/order.ts @@ -222,6 +222,15 @@ export async function validateAndGetCoupon( ) throw new Error('Order amount does not meet coupon minimum requirement') + if (coupon.isFirstOrderOnly) { + const orderCount = await getOrderCount(userId) + if (orderCount > 0) { + throw new Error( + "This is first order only coupon. You already placed your first order. This coupon doesn't apply to you" + ) + } + } + return coupon as CouponValidationResult } diff --git a/packages/shared/types/admin.ts b/packages/shared/types/admin.ts index 2c88a1a..bbb9879 100644 --- a/packages/shared/types/admin.ts +++ b/packages/shared/types/admin.ts @@ -53,6 +53,7 @@ export interface Coupon { validTill: Date | null; maxLimitForUser: number | null; exclusiveApply: boolean; + isFirstOrderOnly: boolean; isInvalidated: boolean; createdAt: Date; createdBy: number; diff --git a/packages/shared/types/user.ts b/packages/shared/types/user.ts index e98bd3f..68fbaa2 100644 --- a/packages/shared/types/user.ts +++ b/packages/shared/types/user.ts @@ -474,6 +474,7 @@ export interface UserCoupon { maxLimitForUser: number | null; isInvalidated: boolean; exclusiveApply: boolean; + isFirstOrderOnly?: boolean; createdAt: Date; } diff --git a/packages/ui/shared-types.ts b/packages/ui/shared-types.ts index 6169ca5..0baf18b 100755 --- a/packages/ui/shared-types.ts +++ b/packages/ui/shared-types.ts @@ -266,6 +266,7 @@ export interface Coupon { isApplyForAll: boolean; validTill: Date | null; maxLimitForUser: number | null; + isFirstOrderOnly: boolean; isInvalidated: boolean; createdAt: Date; } @@ -285,6 +286,7 @@ export interface CreateCouponPayload { applicableUsers?: number[]; applicableProducts?: number[]; exclusiveApply?: boolean; + isFirstOrderOnly?: boolean; } // Patient History Types