@lodashventure/medusa-campaign 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.medusa/server/src/api/admin/buy-x-get-y/[id]/route.js +116 -0
  2. package/.medusa/server/src/api/admin/buy-x-get-y/route.js +83 -0
  3. package/.medusa/server/src/api/admin/campaigns/fix-dates/route.js +103 -0
  4. package/.medusa/server/src/api/admin/campaigns/sync/route.js +138 -0
  5. package/.medusa/server/src/api/admin/flash-sales/[id]/route.js +49 -34
  6. package/.medusa/server/src/api/admin/flash-sales/route.js +46 -19
  7. package/.medusa/server/src/api/admin/force-fix/route.js +176 -0
  8. package/.medusa/server/src/api/admin/test-campaign/route.js +132 -0
  9. package/.medusa/server/src/api/store/buy-x-get-y/[id]/route.js +109 -0
  10. package/.medusa/server/src/api/store/buy-x-get-y/products/[productId]/route.js +94 -0
  11. package/.medusa/server/src/api/store/buy-x-get-y/route.js +114 -0
  12. package/.medusa/server/src/api/store/campaigns/[id]/route.js +132 -70
  13. package/.medusa/server/src/api/store/campaigns/route.js +119 -26
  14. package/.medusa/server/src/index.js +15 -0
  15. package/.medusa/server/src/modules/custom-campaigns/migrations/Migration20251018000000.js +40 -0
  16. package/.medusa/server/src/modules/custom-campaigns/models/buy-x-get-y-config.js +20 -0
  17. package/.medusa/server/src/modules/custom-campaigns/service.js +3 -1
  18. package/.medusa/server/src/modules/custom-campaigns/types/campaign-type.enum.js +2 -1
  19. package/.medusa/server/src/subscribers/cart-updated.js +23 -0
  20. package/.medusa/server/src/subscribers/order-placed.js +9 -2
  21. package/.medusa/server/src/workflows/buy-x-get-y/applyBuyXGetYToCartWorkflow.js +150 -0
  22. package/.medusa/server/src/workflows/custom-campaign/createBuyXGetYCampaignWorkflow.js +127 -0
  23. package/.medusa/server/src/workflows/custom-campaign/updateBuyXGetYCampaignWorkflow.js +114 -0
  24. package/.medusa/server/src/workflows/custom-campaign/updateBuyXGetYUsageWorkflow.js +51 -0
  25. package/package.json +2 -2
  26. package/src/admin/components/BuyXGetYForm.tsx +422 -0
  27. package/src/api/admin/buy-x-get-y/[id]/route.ts +164 -0
  28. package/src/api/admin/buy-x-get-y/route.ts +104 -0
  29. package/src/api/admin/campaigns/fix-dates/route.ts +107 -0
  30. package/src/api/admin/campaigns/sync/route.ts +153 -0
  31. package/src/api/admin/flash-sales/[id]/route.ts +62 -36
  32. package/src/api/admin/flash-sales/route.ts +57 -21
  33. package/src/api/admin/force-fix/route.ts +184 -0
  34. package/src/api/admin/test-campaign/route.ts +141 -0
  35. package/src/api/store/buy-x-get-y/[id]/route.ts +146 -0
  36. package/src/api/store/buy-x-get-y/products/[productId]/route.ts +129 -0
  37. package/src/api/store/buy-x-get-y/route.ts +134 -0
  38. package/src/api/store/campaigns/[id]/route.ts +159 -79
  39. package/src/api/store/campaigns/route.ts +141 -30
  40. package/src/index.ts +10 -0
  41. package/src/modules/custom-campaigns/migrations/Migration20251018000000.ts +42 -0
  42. package/src/modules/custom-campaigns/models/buy-x-get-y-config.ts +19 -0
  43. package/src/modules/custom-campaigns/service.ts +2 -0
  44. package/src/modules/custom-campaigns/types/campaign-type.enum.ts +1 -0
  45. package/src/subscribers/cart-updated.ts +23 -0
  46. package/src/subscribers/order-placed.ts +9 -1
  47. package/src/workflows/buy-x-get-y/applyBuyXGetYToCartWorkflow.ts +222 -0
  48. package/src/workflows/custom-campaign/createBuyXGetYCampaignWorkflow.ts +210 -0
  49. package/src/workflows/custom-campaign/updateBuyXGetYCampaignWorkflow.ts +190 -0
  50. package/src/workflows/custom-campaign/updateBuyXGetYUsageWorkflow.ts +86 -0
@@ -6,6 +6,8 @@ import {
6
6
  } from "@medusajs/framework/utils";
7
7
  import { CampaignTypeEnum } from "../../../../modules/custom-campaigns/types/campaign-type.enum";
8
8
  import { CustomCampaign } from "../../../admin/flash-sales/route";
9
+ import { CUSTOM_CAMPAIGN_MODULE } from "../../../../modules/custom-campaigns";
10
+ import CustomCampaignModuleService from "../../../../modules/custom-campaigns/service";
9
11
 
10
12
  export const GET = async (
11
13
  req: MedusaRequest<{ id: string }>,
@@ -22,112 +24,190 @@ export const GET = async (
22
24
 
23
25
  const query = container.resolve(ContainerRegistrationKeys.QUERY);
24
26
  const productService = container.resolve(Modules.PRODUCT);
27
+ const customCampaignModuleService =
28
+ container.resolve<CustomCampaignModuleService>(CUSTOM_CAMPAIGN_MODULE);
25
29
 
26
30
  try {
27
- // First, find the custom campaign type by campaign ID
28
- const {
29
- data: [customCampaignTypes],
30
- } = await query.graph({
31
- entity: "custom_campaign_type",
32
- fields: [
33
- "id",
34
- "campaign.*",
35
- "campaign.promotions.*",
36
- "campaign.promotions.application_method.*",
37
- "campaign.promotions.application_method.target_rules.*",
38
- ],
39
- filters: {
40
- type: CampaignTypeEnum.FlashSale,
31
+ const now = new Date();
32
+
33
+ // Find the custom campaign type by campaign ID
34
+ const customCampaignTypes =
35
+ await customCampaignModuleService.listCustomCampaignTypes({
41
36
  campaign_id: id,
42
- },
43
- });
37
+ });
38
+
39
+ if (customCampaignTypes.length === 0) {
40
+ throw new MedusaError(
41
+ MedusaError.Types.NOT_FOUND,
42
+ `Campaign with ID ${id} not found`
43
+ );
44
+ }
44
45
 
45
- const campaign = customCampaignTypes.campaign;
46
+ const campaignType = customCampaignTypes[0].type;
47
+
48
+ // Get campaign from promotion service
49
+ const promotionService = container.resolve(Modules.PROMOTION);
50
+ const campaign = await promotionService.retrieveCampaign(id, {
51
+ relations: ["promotions", "promotions.application_method", "promotions.application_method.target_rules"],
52
+ });
46
53
 
47
54
  if (!campaign) {
48
55
  throw new MedusaError(
49
56
  MedusaError.Types.NOT_FOUND,
50
- `Flash sale with ID ${id} not found`
57
+ `Campaign with ID ${id} not found`
51
58
  );
52
59
  }
53
60
 
54
- // Fetch promotion usage limits for the campaign
55
- const { data: promotionUsageLimits } = await query.graph({
56
- entity: "promotion_usage_limit",
57
- fields: ["id", "promotion_id", "product_id", "limit", "used"],
58
- filters: {
59
- campaign_id: id,
60
- },
61
- });
61
+ // Check if campaign is active
62
+ const startsAt = new Date(campaign.starts_at);
63
+ const endsAt = new Date(campaign.ends_at);
64
+ const isActive = startsAt <= now && endsAt >= now;
62
65
 
63
- // Create a map of promotion usage limits by promotion ID
64
- const promotionLimitsMap = new Map();
65
- promotionUsageLimits.forEach((limit) => {
66
- promotionLimitsMap.set(limit.promotion_id, limit);
67
- });
66
+ if (!isActive) {
67
+ throw new MedusaError(
68
+ MedusaError.Types.NOT_ALLOWED,
69
+ "This campaign is not currently active"
70
+ );
71
+ }
72
+
73
+ // Handle different campaign types
74
+ if (campaignType === CampaignTypeEnum.FlashSale) {
75
+ // Fetch promotion usage limits for Flash Sale
76
+ const promotionUsageLimits =
77
+ await customCampaignModuleService.listPromotionUsageLimits({
78
+ campaign_id: id,
79
+ });
68
80
 
69
- // Process promotions to extract product information
70
- const products: CustomCampaign["products"] = [];
71
- for await (const promotion of campaign.promotions ?? []) {
72
- if (!promotion.application_method?.target_rules?.length) {
73
- continue;
81
+ // Create a map of promotion usage limits by promotion ID
82
+ const promotionLimitsMap = new Map();
83
+ promotionUsageLimits.forEach((limit) => {
84
+ promotionLimitsMap.set(limit.promotion_id, limit);
85
+ });
86
+
87
+ // Process promotions to extract product information
88
+ const products: CustomCampaign["products"] = [];
89
+ for await (const promotion of campaign.promotions ?? []) {
90
+ if (!promotion.application_method?.target_rules?.length) {
91
+ continue;
92
+ }
93
+
94
+ const promotionLimit = promotionLimitsMap.get(promotion.id);
95
+ const product = await productService.retrieveProduct(
96
+ promotionLimit?.product_id,
97
+ {
98
+ select: ["id", "title", "thumbnail", "handle", "description"],
99
+ relations: ["images"],
100
+ }
101
+ );
102
+
103
+ if (promotion.application_method.value !== undefined) {
104
+ products.push({
105
+ product: {
106
+ id: product.id,
107
+ title: product.title,
108
+ thumbnail: product.thumbnail,
109
+ handle: product.handle,
110
+ description: product.description,
111
+ images: product.images,
112
+ },
113
+ discountType: promotion.application_method?.type,
114
+ discountValue: promotion.application_method?.value,
115
+ maxQty: promotion.application_method?.max_quantity,
116
+ limit: promotionLimit?.limit,
117
+ used: promotionLimit?.used,
118
+ remaining: promotionLimit?.limit
119
+ ? promotionLimit.limit - promotionLimit.used
120
+ : null,
121
+ });
122
+ }
74
123
  }
75
124
 
76
- const productRule = promotion.application_method.target_rules.find(
77
- (rule) =>
78
- rule.attribute === "items.product.id" && rule.operator === "eq"
125
+ res.status(200).json({
126
+ id: campaign.id,
127
+ name: campaign.name,
128
+ description: campaign.description,
129
+ type: campaignType,
130
+ starts_at: campaign.starts_at,
131
+ ends_at: campaign.ends_at,
132
+ products,
133
+ });
134
+ } else if (campaignType === CampaignTypeEnum.BuyXGetY) {
135
+ // Get BOGO configs for this campaign
136
+ const buyXGetYConfigs =
137
+ await customCampaignModuleService.listBuyXGetYConfigs({
138
+ campaign_id: id,
139
+ });
140
+
141
+ // Filter out configs that have reached their limit
142
+ const availableConfigs = buyXGetYConfigs.filter(
143
+ (config) => !config.limit || config.used < config.limit
79
144
  );
80
- const promotionLimit = promotionLimitsMap.get(promotion.id);
81
145
 
82
- const product = await productService.retrieveProduct(
83
- promotionLimit?.product_id
146
+ // Build rules with product details
147
+ const rules = await Promise.all(
148
+ availableConfigs.map(async (config) => {
149
+ const [triggerProduct, rewardProduct] = await Promise.all([
150
+ productService.retrieveProduct(config.trigger_product_id, {
151
+ select: ["id", "title", "description", "thumbnail", "handle"],
152
+ relations: ["images"],
153
+ }),
154
+ productService.retrieveProduct(config.reward_product_id, {
155
+ select: ["id", "title", "description", "thumbnail", "handle"],
156
+ relations: ["images"],
157
+ }),
158
+ ]);
159
+
160
+ return {
161
+ id: config.id,
162
+ triggerProduct: {
163
+ id: triggerProduct.id,
164
+ title: triggerProduct.title,
165
+ description: triggerProduct.description,
166
+ thumbnail: triggerProduct.thumbnail,
167
+ handle: triggerProduct.handle,
168
+ images: triggerProduct.images,
169
+ },
170
+ triggerQuantity: config.trigger_quantity,
171
+ rewardProduct: {
172
+ id: rewardProduct.id,
173
+ title: rewardProduct.title,
174
+ description: rewardProduct.description,
175
+ thumbnail: rewardProduct.thumbnail,
176
+ handle: rewardProduct.handle,
177
+ images: rewardProduct.images,
178
+ },
179
+ rewardQuantity: config.reward_quantity,
180
+ rewardType: config.reward_type,
181
+ rewardValue: config.reward_value,
182
+ remaining: config.limit ? config.limit - config.used : null,
183
+ };
184
+ })
84
185
  );
85
186
 
86
- if (productRule && promotion.application_method.value) {
87
- products.push({
88
- product: {
89
- id: product.id,
90
- title: product.title,
91
- },
92
- discountType: promotion.application_method?.type,
93
- discountValue: promotion.application_method?.value,
94
- maxQty: promotion.application_method?.max_quantity,
95
- limit: promotionLimitsMap.get(promotion.id)?.limit,
96
- });
97
- }
187
+ res.status(200).json({
188
+ id: campaign.id,
189
+ name: campaign.name,
190
+ description: campaign.description,
191
+ type: campaignType,
192
+ starts_at: campaign.starts_at,
193
+ ends_at: campaign.ends_at,
194
+ rules,
195
+ });
196
+ } else {
197
+ throw new MedusaError(
198
+ MedusaError.Types.INVALID_DATA,
199
+ "Unknown campaign type"
200
+ );
98
201
  }
99
-
100
- const campaignData = {
101
- id: campaign.id,
102
- name: campaign.name,
103
- description: campaign.description,
104
- type: campaign.type,
105
- startsAt: campaign.starts_at,
106
- endsAt: campaign.ends_at,
107
- };
108
-
109
- const productData = products.map((product) => ({
110
- id: product.product.id,
111
- title: product.product.title,
112
- discountType: product.discountType,
113
- discountValue: product.discountValue,
114
- maxQty: product.maxQty,
115
- limit: product.limit,
116
- }));
117
-
118
- res.status(200).json({
119
- ...campaignData,
120
- products: productData,
121
- });
122
202
  } catch (error) {
123
203
  if (error instanceof MedusaError) {
124
204
  throw error;
125
205
  }
126
206
 
127
- console.error("Error fetching flash sale:", error);
207
+ console.error("Error fetching campaign:", error);
128
208
  throw new MedusaError(
129
209
  MedusaError.Types.UNEXPECTED_STATE,
130
- "An error occurred while fetching the flash sale"
210
+ "An error occurred while fetching the campaign"
131
211
  );
132
212
  }
133
213
  };
@@ -1,36 +1,147 @@
1
1
  import { container, MedusaRequest, MedusaResponse } from "@medusajs/framework";
2
- import { ContainerRegistrationKeys } from "@medusajs/framework/utils";
2
+ import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils";
3
3
  import { CampaignTypeEnum } from "../../../modules/custom-campaigns/types/campaign-type.enum";
4
+ import { CUSTOM_CAMPAIGN_MODULE } from "../../../modules/custom-campaigns";
5
+ import CustomCampaignModuleService from "../../../modules/custom-campaigns/service";
4
6
 
7
+ /**
8
+ * GET handler for listing all active campaigns (Flash Sales + Buy X Get Y) for storefront
9
+ */
5
10
  export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
6
- const query = container.resolve(ContainerRegistrationKeys.QUERY);
7
- const {
8
- data: customCampaigns,
9
- metadata: { count, take, skip } = {
11
+ const customCampaignModuleService =
12
+ container.resolve<CustomCampaignModuleService>(CUSTOM_CAMPAIGN_MODULE);
13
+ const promotionService = container.resolve(Modules.PROMOTION);
14
+ const now = new Date();
15
+
16
+ try {
17
+ // Get the campaign type filter from query params (optional)
18
+ const campaignType = req.query?.type as CampaignTypeEnum | undefined;
19
+
20
+ const filters: any = {};
21
+ if (campaignType && Object.values(CampaignTypeEnum).includes(campaignType)) {
22
+ filters.type = campaignType;
23
+ }
24
+
25
+ console.log("=== Store Campaigns Debug ===");
26
+ console.log("Current time:", now.toISOString());
27
+ console.log("Requested type filter:", campaignType);
28
+
29
+ // Get all custom campaign types (including soft deleted check)
30
+ const allCustomCampaignTypes =
31
+ await customCampaignModuleService.listCustomCampaignTypes({});
32
+
33
+ console.log(`Total custom campaign types (unfiltered): ${allCustomCampaignTypes.length}`);
34
+ allCustomCampaignTypes.forEach(ct => {
35
+ console.log(` - Type: ${ct.type}, Campaign ID: ${ct.campaign_id}, Deleted: ${ct.deleted_at}`);
36
+ });
37
+
38
+ // Apply filters
39
+ const customCampaignTypes = filters.type
40
+ ? allCustomCampaignTypes.filter(ct => ct.type === filters.type && !ct.deleted_at)
41
+ : allCustomCampaignTypes.filter(ct => !ct.deleted_at);
42
+
43
+ console.log(`Filtered custom campaign types: ${customCampaignTypes.length}`);
44
+
45
+ if (customCampaignTypes.length === 0) {
46
+ console.log("No custom campaign types found after filtering");
47
+ return res.status(200).json({
48
+ campaigns: [],
49
+ count: 0,
50
+ limit: 20,
51
+ offset: 0,
52
+ debug: {
53
+ message: "No custom campaign types found",
54
+ filters: filters,
55
+ totalUnfiltered: allCustomCampaignTypes.length
56
+ }
57
+ });
58
+ }
59
+
60
+ // Get campaign IDs
61
+ const campaignIds = customCampaignTypes.map((ct) => ct.campaign_id);
62
+ console.log("Campaign IDs to fetch:", JSON.stringify(campaignIds));
63
+
64
+ // Fetch ALL campaigns first for debugging
65
+ const allCampaigns = await promotionService.listCampaigns({});
66
+ console.log(`Total campaigns in system: ${allCampaigns.length}`);
67
+
68
+ // Then fetch specific campaigns
69
+ const campaigns = campaignIds.length > 0
70
+ ? allCampaigns.filter((c: any) => campaignIds.includes(c.id))
71
+ : [];
72
+
73
+ console.log(`Found ${campaigns.length} matching campaigns`);
74
+ campaigns.forEach((c: any) => {
75
+ console.log(` Campaign: ${c.name} (${c.id})`);
76
+ console.log(` Starts: ${c.starts_at}`);
77
+ console.log(` Ends: ${c.ends_at}`);
78
+ });
79
+
80
+ // Create a map of campaign_id to campaign_type
81
+ const campaignTypeMap = new Map(
82
+ customCampaignTypes.map((ct) => [ct.campaign_id, ct.type])
83
+ );
84
+
85
+ // Process ALL campaigns first (without date filter) for debugging
86
+ const allProcessedCampaigns = campaigns.map((campaign: any) => {
87
+ const startsAt = new Date(campaign.starts_at);
88
+ const endsAt = new Date(campaign.ends_at);
89
+ const isActive = startsAt <= now && endsAt >= now;
90
+
91
+ console.log(` Checking campaign "${campaign.name}":`);
92
+ console.log(` Start: ${startsAt.toISOString()} (${startsAt <= now ? 'started' : 'not started'})`);
93
+ console.log(` End: ${endsAt.toISOString()} (${endsAt >= now ? 'not ended' : 'ended'})`);
94
+ console.log(` Active: ${isActive}`);
95
+
96
+ return {
97
+ id: campaign.id,
98
+ name: campaign.name,
99
+ description: campaign.description,
100
+ campaign_identifier: campaign.campaign_identifier,
101
+ starts_at: campaign.starts_at,
102
+ ends_at: campaign.ends_at,
103
+ campaign_type: campaignTypeMap.get(campaign.id),
104
+ is_active: isActive
105
+ };
106
+ });
107
+
108
+ // Filter for active campaigns
109
+ const activeCampaigns = allProcessedCampaigns.filter(c => c.is_active);
110
+
111
+ console.log(`Returning ${activeCampaigns.length} active campaigns out of ${allProcessedCampaigns.length} total`);
112
+
113
+ res.status(200).json({
114
+ campaigns: activeCampaigns,
115
+ count: activeCampaigns.length,
116
+ limit: 20,
117
+ offset: 0,
118
+ debug: {
119
+ currentTime: now.toISOString(),
120
+ totalCustomCampaignTypes: allCustomCampaignTypes.length,
121
+ filteredCustomCampaignTypes: customCampaignTypes.length,
122
+ totalCampaigns: allCampaigns.length,
123
+ matchingCampaigns: campaigns.length,
124
+ allProcessedCampaigns: allProcessedCampaigns.length,
125
+ activeCampaigns: activeCampaigns.length,
126
+ inactiveCampaigns: allProcessedCampaigns.filter(c => !c.is_active).map(c => ({
127
+ name: c.name,
128
+ starts_at: c.starts_at,
129
+ ends_at: c.ends_at,
130
+ reason: new Date(c.starts_at) > now ? 'not_started' : 'ended'
131
+ }))
132
+ }
133
+ });
134
+ } catch (error) {
135
+ console.error("Error fetching campaigns for storefront:", error);
136
+ console.error("Stack:", error instanceof Error ? error.stack : 'No stack');
137
+ res.status(500).json({
138
+ campaigns: [],
10
139
  count: 0,
11
- take: 20,
12
- skip: 0,
13
- },
14
- } = await query.graph({
15
- entity: "custom_campaign_type",
16
- ...req.queryConfig,
17
- fields: [
18
- "id",
19
- "campaign.name",
20
- "campaign.description",
21
- "campaign.type",
22
- "campaign.starts_at",
23
- "campaign.ends_at",
24
- ],
25
- filters: { type: CampaignTypeEnum.FlashSale },
26
- });
27
-
28
- const campaigns = customCampaigns.map((campaign) => campaign.campaign);
29
-
30
- res.status(200).json({
31
- campaigns,
32
- count,
33
- limit: take,
34
- offset: skip,
35
- });
140
+ limit: 20,
141
+ offset: 0,
142
+ error: "Failed to fetch campaigns",
143
+ details: error instanceof Error ? error.message : String(error),
144
+ stack: error instanceof Error ? error.stack : undefined
145
+ });
146
+ }
36
147
  };
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ import CustomCampaignModule from "./modules/custom-campaigns";
2
+
3
+ // Export the module so MedusaJS can load it
4
+ export default {
5
+ modules: [CustomCampaignModule],
6
+ };
7
+
8
+ // Also export individual modules for direct import
9
+ export { CustomCampaignModule };
10
+ export { CUSTOM_CAMPAIGN_MODULE } from "./modules/custom-campaigns";
@@ -0,0 +1,42 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20251018000000 extends Migration {
4
+
5
+ override async up(): Promise<void> {
6
+ // Update custom_campaign_type enum to include 'buy-x-get-y'
7
+ this.addSql(`alter table if exists "custom_campaign_type" drop constraint if exists "custom_campaign_type_type_check";`);
8
+ this.addSql(`alter table if exists "custom_campaign_type" add constraint "custom_campaign_type_type_check" check ("type" in ('flash-sale', 'buy-x-get-y'));`);
9
+
10
+ // Create buy_x_get_y_config table
11
+ this.addSql(`create table if not exists "buy_x_get_y_config" (
12
+ "id" text not null,
13
+ "campaign_id" text not null,
14
+ "promotion_id" text not null,
15
+ "trigger_product_id" text not null,
16
+ "trigger_quantity" integer not null,
17
+ "reward_product_id" text not null,
18
+ "reward_quantity" integer not null,
19
+ "reward_type" text check ("reward_type" in ('free', 'percentage', 'fixed')) not null,
20
+ "reward_value" integer null,
21
+ "limit" integer null,
22
+ "used" integer not null default 0,
23
+ "created_at" timestamptz not null default now(),
24
+ "updated_at" timestamptz not null default now(),
25
+ "deleted_at" timestamptz null,
26
+ constraint "buy_x_get_y_config_pkey" primary key ("id")
27
+ );`);
28
+
29
+ this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_buy_x_get_y_config_campaign_promotion" ON "buy_x_get_y_config" (campaign_id, promotion_id) WHERE deleted_at IS NULL;`);
30
+ this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_buy_x_get_y_config_deleted_at" ON "buy_x_get_y_config" (deleted_at) WHERE deleted_at IS NULL;`);
31
+ }
32
+
33
+ override async down(): Promise<void> {
34
+ // Drop buy_x_get_y_config table
35
+ this.addSql(`drop table if exists "buy_x_get_y_config" cascade;`);
36
+
37
+ // Revert custom_campaign_type enum
38
+ this.addSql(`alter table if exists "custom_campaign_type" drop constraint if exists "custom_campaign_type_type_check";`);
39
+ this.addSql(`alter table if exists "custom_campaign_type" add constraint "custom_campaign_type_type_check" check ("type" in ('flash-sale'));`);
40
+ }
41
+
42
+ }
@@ -0,0 +1,19 @@
1
+ import { model } from "@medusajs/framework/utils";
2
+
3
+ const BuyXGetYConfig = model
4
+ .define("buy_x_get_y_config", {
5
+ id: model.id().primaryKey(),
6
+ campaign_id: model.text(),
7
+ promotion_id: model.text(),
8
+ trigger_product_id: model.text(),
9
+ trigger_quantity: model.number(),
10
+ reward_product_id: model.text(),
11
+ reward_quantity: model.number(),
12
+ reward_type: model.enum(["free", "percentage", "fixed"]),
13
+ reward_value: model.number().nullable(),
14
+ limit: model.number().nullable(),
15
+ used: model.number().default(0),
16
+ })
17
+ .indexes([{ on: ["campaign_id", "promotion_id"], unique: true }]);
18
+
19
+ export default BuyXGetYConfig;
@@ -1,10 +1,12 @@
1
1
  import { MedusaService } from "@medusajs/framework/utils";
2
2
  import CustomCampaignType from "./models/custom-campaign-type";
3
3
  import PromotionUsageLimit from "./models/promotion-usage-limit";
4
+ import BuyXGetYConfig from "./models/buy-x-get-y-config";
4
5
 
5
6
  class CustomCampaignModuleService extends MedusaService({
6
7
  CustomCampaignType,
7
8
  PromotionUsageLimit,
9
+ BuyXGetYConfig,
8
10
  }) {}
9
11
 
10
12
  export default CustomCampaignModuleService;
@@ -1,3 +1,4 @@
1
1
  export enum CampaignTypeEnum {
2
2
  FlashSale = "flash-sale",
3
+ BuyXGetY = "buy-x-get-y",
3
4
  }
@@ -0,0 +1,23 @@
1
+ import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework";
2
+ import { applyBuyXGetYToCartWorkflow } from "../workflows/buy-x-get-y/applyBuyXGetYToCartWorkflow";
3
+
4
+ export default async function handleCartUpdated({
5
+ event: { data },
6
+ }: SubscriberArgs<{ id: string }>) {
7
+ // Apply Buy X Get Y rules to the cart
8
+ await applyBuyXGetYToCartWorkflow.run({
9
+ input: {
10
+ cart_id: data.id,
11
+ },
12
+ });
13
+ }
14
+
15
+ export const config: SubscriberConfig = {
16
+ event: [
17
+ "cart.updated",
18
+ "cart.created",
19
+ "cart-line-item.created",
20
+ "cart-line-item.updated",
21
+ "cart-line-item.deleted",
22
+ ],
23
+ };
@@ -1,15 +1,23 @@
1
1
  import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework";
2
2
  import { updatePromotionUsageWorkflow } from "../workflows/custom-campaign/updatePromotionUsageWorkflow";
3
+ import { updateBuyXGetYUsageWorkflow } from "../workflows/custom-campaign/updateBuyXGetYUsageWorkflow";
3
4
 
4
5
  export default async function updatePromotionUsage({
5
6
  event: { data },
6
7
  }: SubscriberArgs<{ id: string }>) {
7
- // update promotion usage
8
+ // update promotion usage for flash sales
8
9
  await updatePromotionUsageWorkflow.run({
9
10
  input: {
10
11
  order_id: data.id,
11
12
  },
12
13
  });
14
+
15
+ // update promotion usage for Buy X Get Y campaigns
16
+ await updateBuyXGetYUsageWorkflow.run({
17
+ input: {
18
+ order_id: data.id,
19
+ },
20
+ });
13
21
  }
14
22
 
15
23
  export const config: SubscriberConfig = {