From faea152d5d5015161432030c35e010ff9b2f9909 Mon Sep 17 00:00:00 2001
From: shafi54 <108669266+shafi-aviz@users.noreply.github.com>
Date: Sun, 13 Sep 2026 00:06:15 +0530
Subject: [PATCH] first users only coupons.
---
.../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 | 2 ++
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/coupon.types.ts | 1 +
packages/shared/types/user.ts | 1 +
packages/ui/shared-types.ts | 1 +
13 files changed, 66 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 a6b94d9..fd13af9 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,
};
@@ -378,6 +380,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 b5f1f26..c71f5fe 100644
--- a/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts
+++ b/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts
@@ -37,13 +37,14 @@ const createCouponBodySchema = z.object({
validTill: z.string().optional(),
maxLimitForUser: z.number().optional(),
exclusiveApply: z.boolean().optional(),
+ isFirstOrderOnly: z.boolean().optional(),
});
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)) {
@@ -108,6 +109,7 @@ export const couponRouter = router({
validTill: validTill ? dayjs(validTill).toDate() : null,
maxLimitForUser: maxLimitForUser ?? null,
exclusiveApply: exclusiveApply || false,
+ isFirstOrderOnly: isFirstOrderOnly || false,
isInvalidated: false,
},
applicableUsers,
@@ -192,6 +194,7 @@ export const couponRouter = router({
validTill: updates.validTill !== undefined ? (updates.validTill ? dayjs(updates.validTill).toDate() : null) : existing.validTill,
maxLimitForUser: updates.maxLimitForUser ?? existing.maxLimitForUser,
exclusiveApply: updates.exclusiveApply ?? existing.exclusiveApply,
+ isFirstOrderOnly: updates.isFirstOrderOnly ?? existing.isFirstOrderOnly,
isInvalidated: updates.isInvalidated ?? existing.isInvalidated,
createdBy: existing.createdBy,
};
@@ -364,7 +367,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)) {
@@ -408,6 +411,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 441df78..94c7191 100644
--- a/packages/db_helper_sqlite/src/admin-apis/coupon.ts
+++ b/packages/db_helper_sqlite/src/admin-apis/coupon.ts
@@ -90,6 +90,7 @@ export async function createCouponWithRelations(
validTill: input.validTill,
maxLimitForUser: input.maxLimitForUser,
exclusiveApply: input.exclusiveApply,
+ isFirstOrderOnly: input.isFirstOrderOnly,
}).returning()
if (applicableUsers && applicableUsers.length > 0) {
@@ -223,6 +224,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 5eeed24..4555e40 100644
--- a/packages/db_helper_sqlite/src/db/schema.ts
+++ b/packages/db_helper_sqlite/src/db/schema.ts
@@ -451,6 +451,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`),
})
@@ -501,6 +502,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 b6dcb29..400186a 100644
--- a/packages/db_helper_sqlite/src/user-apis/order.ts
+++ b/packages/db_helper_sqlite/src/user-apis/order.ts
@@ -121,6 +121,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/coupon.types.ts b/packages/shared/types/coupon.types.ts
index 63ef7f8..4c4323d 100644
--- a/packages/shared/types/coupon.types.ts
+++ b/packages/shared/types/coupon.types.ts
@@ -19,6 +19,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 e1aa02b..4e4cf97 100644
--- a/packages/shared/types/user.ts
+++ b/packages/shared/types/user.ts
@@ -431,6 +431,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 9921986..8f0caed 100755
--- a/packages/ui/shared-types.ts
+++ b/packages/ui/shared-types.ts
@@ -14,4 +14,5 @@ export interface CreateCouponPayload {
applicableUsers?: number[];
applicableProducts?: number[];
exclusiveApply?: boolean;
+ isFirstOrderOnly?: boolean;
}