@lodashventure/medusa-campaign 1.4.22 → 1.4.24

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.
@@ -9,6 +9,33 @@ import { CustomCampaign } from "../../../admin/flash-sales/route";
9
9
  import { CUSTOM_CAMPAIGN_MODULE } from "../../../../modules/custom-campaigns";
10
10
  import CustomCampaignModuleService from "../../../../modules/custom-campaigns/service";
11
11
 
12
+ type UsablePromoModuleService = {
13
+ listUsablePromotions: (
14
+ selector: Record<string, unknown>,
15
+ ) => Promise<
16
+ Array<{
17
+ id: string;
18
+ promotion_id: string;
19
+ enabled: boolean;
20
+ }>
21
+ >;
22
+ };
23
+
24
+ type PromotionCacheService = {
25
+ exists: (key: string) => Promise<boolean>;
26
+ };
27
+
28
+ const USABLE_PROMO_MODULE = "usable_promotion";
29
+ const PROMOTION_CACHE_MODULE = "promotion_cache";
30
+
31
+ const resolveOptional = <T>(token: string): T | null => {
32
+ try {
33
+ return container.resolve<T>(token);
34
+ } catch {
35
+ return null;
36
+ }
37
+ };
38
+
12
39
  export const GET = async (
13
40
  req: MedusaRequest<{ id: string }>,
14
41
  res: MedusaResponse,
@@ -223,6 +250,117 @@ export const GET = async (
223
250
  }
224
251
  : null,
225
252
  });
253
+ } else if (campaignType === CampaignTypeEnum.Coupon) {
254
+ const usablePromoService = resolveOptional<UsablePromoModuleService>(
255
+ USABLE_PROMO_MODULE,
256
+ );
257
+ const promotionCacheService = resolveOptional<PromotionCacheService>(
258
+ PROMOTION_CACHE_MODULE,
259
+ );
260
+ const customerId = (
261
+ req as unknown as { auth_context?: { actor_id?: string } }
262
+ )?.auth_context?.actor_id;
263
+
264
+ const campaignPromotions = await promotionService.listPromotions(
265
+ {
266
+ campaign_id: campaign.id,
267
+ } as any,
268
+ {
269
+ relations: ["application_method"],
270
+ },
271
+ );
272
+
273
+ const promotions = campaignPromotions.filter(
274
+ (promotion) => promotion.status === "active" && !promotion.is_automatic,
275
+ );
276
+
277
+ const usablePromotions = usablePromoService
278
+ ? await usablePromoService.listUsablePromotions({
279
+ promotion_id: promotions.map((promotion) => promotion.id),
280
+ })
281
+ : [];
282
+
283
+ const usableMap = new Map(
284
+ usablePromotions.map((usable) => [usable.promotion_id, usable.enabled]),
285
+ );
286
+
287
+ const collectedMap = new Map<string, boolean>();
288
+ if (customerId && promotionCacheService) {
289
+ await Promise.all(
290
+ promotions.map(async (promotion) => {
291
+ try {
292
+ const cacheKey = `user:${customerId}:promotion:${promotion.id}`;
293
+ const collected = await promotionCacheService.exists(cacheKey);
294
+ collectedMap.set(promotion.id, collected);
295
+ } catch (error) {
296
+ console.error(
297
+ `[Campaign] Failed to resolve collected status for promotion ${promotion.id}:`,
298
+ error,
299
+ );
300
+ }
301
+ }),
302
+ );
303
+ }
304
+
305
+ const coupons = promotions.map((promotion) => {
306
+ const isCollectable = usableMap.has(promotion.id)
307
+ ? usableMap.get(promotion.id)!
308
+ : true;
309
+ const isCollected = collectedMap.get(promotion.id) ?? false;
310
+
311
+ return {
312
+ id: promotion.id,
313
+ code: promotion.code,
314
+ discount_type: promotion.application_method?.type,
315
+ discount_value: promotion.application_method?.value,
316
+ allocation: promotion.application_method?.allocation,
317
+ target_type: promotion.application_method?.target_type,
318
+ currency_code: promotion.application_method?.currency_code,
319
+ is_collectable: isCollectable,
320
+ is_collected: isCollected,
321
+ collect_endpoint: "/store/customers/me/coupons",
322
+ collect_payload: {
323
+ coupon_id: promotion.id,
324
+ },
325
+ };
326
+ });
327
+
328
+ const collectableCount = coupons.filter(
329
+ (coupon) => coupon.is_collectable && !coupon.is_collected,
330
+ ).length;
331
+ const collectedCount = coupons.filter(
332
+ (coupon) => coupon.is_collected,
333
+ ).length;
334
+
335
+ res.status(200).json({
336
+ id: campaign.id,
337
+ name: campaign.name,
338
+ description: campaign.description,
339
+ type: campaignType,
340
+ starts_at: campaign.starts_at,
341
+ ends_at: campaign.ends_at,
342
+ coupons,
343
+ summary: {
344
+ type: "coupon",
345
+ coupon_count: coupons.length,
346
+ collectable_count: collectableCount,
347
+ collected_count: collectedCount,
348
+ },
349
+ campaign_detail: campaignDetail
350
+ ? {
351
+ image_url: campaignDetail.image_url,
352
+ thumbnail_url: campaignDetail.thumbnail_url,
353
+ detail_content: campaignDetail.detail_content,
354
+ terms_and_conditions: campaignDetail.terms_and_conditions,
355
+ meta_title: campaignDetail.meta_title,
356
+ meta_description: campaignDetail.meta_description,
357
+ meta_keywords: campaignDetail.meta_keywords,
358
+ link_url: campaignDetail.link_url,
359
+ link_text: campaignDetail.link_text,
360
+ display_order: campaignDetail.display_order,
361
+ }
362
+ : null,
363
+ });
226
364
  } else {
227
365
  throw new MedusaError(
228
366
  MedusaError.Types.INVALID_DATA,
@@ -4,14 +4,47 @@ import { CampaignTypeEnum } from "../../../modules/custom-campaigns/types/campai
4
4
  import { CUSTOM_CAMPAIGN_MODULE } from "../../../modules/custom-campaigns";
5
5
  import CustomCampaignModuleService from "../../../modules/custom-campaigns/service";
6
6
 
7
+ type UsablePromoModuleService = {
8
+ listUsablePromotions: (selector: Record<string, unknown>) => Promise<
9
+ Array<{
10
+ id: string;
11
+ promotion_id: string;
12
+ enabled: boolean;
13
+ }>
14
+ >;
15
+ };
16
+
17
+ type PromotionCacheService = {
18
+ exists: (key: string) => Promise<boolean>;
19
+ };
20
+
21
+ const USABLE_PROMO_MODULE = "usable_promotion";
22
+ const PROMOTION_CACHE_MODULE = "promotion_cache";
23
+
24
+ const resolveOptional = <T>(token: string): T | null => {
25
+ try {
26
+ return container.resolve<T>(token);
27
+ } catch {
28
+ return null;
29
+ }
30
+ };
31
+
7
32
  /**
8
- * GET handler for listing all active campaigns (Flash Sales + Buy X Get Y) for storefront
33
+ * GET handler for listing all active campaigns (Flash Sales + Buy X Get Y + Coupon) for storefront
9
34
  */
10
35
  export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
11
36
  const customCampaignModuleService =
12
37
  container.resolve<CustomCampaignModuleService>(CUSTOM_CAMPAIGN_MODULE);
13
38
  const promotionService = container.resolve(Modules.PROMOTION);
14
39
  const productService = container.resolve(Modules.PRODUCT);
40
+ const usablePromoService =
41
+ resolveOptional<UsablePromoModuleService>(USABLE_PROMO_MODULE);
42
+ const promotionCacheService =
43
+ resolveOptional<PromotionCacheService>(PROMOTION_CACHE_MODULE);
44
+
45
+ const customerId = (
46
+ req as unknown as { auth_context?: { actor_id?: string } }
47
+ )?.auth_context?.actor_id;
15
48
  const now = new Date();
16
49
 
17
50
  try {
@@ -98,6 +131,74 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
98
131
  customCampaignTypes.map((ct) => [ct.campaign_id, ct.type])
99
132
  );
100
133
 
134
+ const campaignDetails =
135
+ await customCampaignModuleService.listCampaignDetails({
136
+ campaign_id: campaignIds,
137
+ });
138
+
139
+ const campaignDetailMap = new Map(
140
+ campaignDetails.map((detail) => [detail.campaign_id, detail]),
141
+ );
142
+
143
+ const couponCampaignIds = customCampaignTypes
144
+ .filter((ct) => ct.type === CampaignTypeEnum.Coupon)
145
+ .map((ct) => ct.campaign_id);
146
+
147
+ const couponPromotionsByCampaign = new Map<string, any[]>();
148
+ let couponPromotionIds: string[] = [];
149
+
150
+ if (couponCampaignIds.length > 0) {
151
+ const couponPromotions = await promotionService.listPromotions(
152
+ {
153
+ campaign_id: { $in: couponCampaignIds },
154
+ } as any,
155
+ {
156
+ relations: ["application_method"],
157
+ },
158
+ );
159
+
160
+ couponPromotionIds = couponPromotions.map((promotion) => promotion.id);
161
+ couponPromotions.forEach((promotion) => {
162
+ if (!promotion.campaign_id) {
163
+ return;
164
+ }
165
+ const existing = couponPromotionsByCampaign.get(promotion.campaign_id);
166
+ if (existing) {
167
+ existing.push(promotion);
168
+ } else {
169
+ couponPromotionsByCampaign.set(promotion.campaign_id, [promotion]);
170
+ }
171
+ });
172
+ }
173
+
174
+ const usablePromotions = usablePromoService
175
+ ? await usablePromoService.listUsablePromotions({
176
+ promotion_id: couponPromotionIds,
177
+ })
178
+ : [];
179
+
180
+ const usableMap = new Map(
181
+ usablePromotions.map((usable) => [usable.promotion_id, usable.enabled]),
182
+ );
183
+
184
+ const collectedMap = new Map<string, boolean>();
185
+ if (customerId && promotionCacheService) {
186
+ await Promise.all(
187
+ couponPromotionIds.map(async (promotionId) => {
188
+ try {
189
+ const cacheKey = `user:${customerId}:promotion:${promotionId}`;
190
+ const collected = await promotionCacheService.exists(cacheKey);
191
+ collectedMap.set(promotionId, collected);
192
+ } catch (error) {
193
+ console.error(
194
+ `[Campaign] Failed to determine collected status for promotion ${promotionId}:`,
195
+ error,
196
+ );
197
+ }
198
+ }),
199
+ );
200
+ }
201
+
101
202
  // Process ALL campaigns first (without date filter) for debugging
102
203
  const allProcessedCampaigns = await Promise.all(
103
204
  campaigns.map(async (campaign: any) => {
@@ -113,7 +214,9 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
113
214
  const campaignType = campaignTypeMap.get(campaign.id);
114
215
 
115
216
  // Fetch products for flash-sale campaigns
116
- let products: any[] = [];
217
+ let products: any[] | undefined;
218
+ let summary: Record<string, unknown> | undefined;
219
+ let coupons: any[] | undefined;
117
220
  if (campaignType === CampaignTypeEnum.FlashSale) {
118
221
  try {
119
222
  // Fetch promotion usage limits for this campaign
@@ -128,6 +231,8 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
128
231
  promotionLimitsMap.set(limit.promotion_id, limit);
129
232
  });
130
233
 
234
+ const flashSaleProducts: any[] = [];
235
+
131
236
  // Process promotions to extract product information
132
237
  for (const promotion of campaign.promotions ?? []) {
133
238
  if (!promotion.application_method?.target_rules?.length) {
@@ -149,7 +254,7 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
149
254
  );
150
255
 
151
256
  if (promotion.application_method.value !== undefined) {
152
- products.push({
257
+ flashSaleProducts.push({
153
258
  product: {
154
259
  id: product.id,
155
260
  title: product.title,
@@ -163,15 +268,105 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
163
268
  maxQty: promotion.application_method?.max_quantity ?? 0,
164
269
  limit: promotionLimit?.limit ?? 0,
165
270
  used: promotionLimit?.used ?? 0,
271
+ remaining:
272
+ typeof promotionLimit?.limit === "number" &&
273
+ typeof promotionLimit?.used === "number"
274
+ ? Math.max(
275
+ promotionLimit.limit - promotionLimit.used,
276
+ 0,
277
+ )
278
+ : null,
166
279
  });
167
280
  }
168
281
  } catch (error) {
169
282
  console.error(`Error fetching product ${promotionLimit.product_id}:`, error);
170
283
  }
171
284
  }
285
+
286
+ products = flashSaleProducts;
287
+
288
+ const aggregate = promotionUsageLimits.reduce(
289
+ (acc, limit) => {
290
+ acc.product_count += 1;
291
+ acc.total_limit += limit.limit ?? 0;
292
+ acc.total_used += limit.used ?? 0;
293
+ if (
294
+ typeof limit.limit === "number" &&
295
+ typeof limit.used === "number"
296
+ ) {
297
+ const remaining = Math.max(limit.limit - limit.used, 0);
298
+ acc.total_remaining += remaining;
299
+ if (remaining > 0) {
300
+ acc.active_product_count += 1;
301
+ }
302
+ }
303
+ return acc;
304
+ },
305
+ {
306
+ product_count: 0,
307
+ active_product_count: 0,
308
+ total_limit: 0,
309
+ total_used: 0,
310
+ total_remaining: 0,
311
+ },
312
+ );
313
+
314
+ summary = {
315
+ type: "flash-sale",
316
+ ...aggregate,
317
+ };
172
318
  } catch (error) {
173
319
  console.error(`Error fetching products for campaign ${campaign.id}:`, error);
174
320
  }
321
+ } else if (campaignType === CampaignTypeEnum.Coupon) {
322
+ try {
323
+ const campaignCoupons =
324
+ couponPromotionsByCampaign.get(campaign.id) ?? [];
325
+
326
+ const availableCoupons = campaignCoupons
327
+ .filter(
328
+ (promotion: any) =>
329
+ promotion.status === "active" && !promotion.is_automatic,
330
+ )
331
+ .map((promotion: any) => {
332
+ const isCollectable = usableMap.has(promotion.id)
333
+ ? usableMap.get(promotion.id)!
334
+ : true;
335
+ const isCollected = collectedMap.get(promotion.id) ?? false;
336
+
337
+ return {
338
+ id: promotion.id,
339
+ code: promotion.code,
340
+ description: campaign.description,
341
+ discount_type: promotion.application_method?.type,
342
+ discount_value: promotion.application_method?.value,
343
+ allocation: promotion.application_method?.allocation,
344
+ target_type: promotion.application_method?.target_type,
345
+ currency_code: promotion.application_method?.currency_code,
346
+ is_collectable: isCollectable,
347
+ is_collected: isCollected,
348
+ collect_endpoint: "/store/customers/me/coupons",
349
+ collect_payload: {
350
+ coupon_id: promotion.id,
351
+ },
352
+ };
353
+ });
354
+
355
+ coupons = availableCoupons;
356
+
357
+ summary = {
358
+ type: "coupon",
359
+ coupon_count: availableCoupons.length,
360
+ collectable_count: availableCoupons.filter(
361
+ (coupon) => coupon.is_collectable && !coupon.is_collected,
362
+ ).length,
363
+ collected_count: availableCoupons.filter(
364
+ (coupon) => coupon.is_collected,
365
+ ).length,
366
+ };
367
+ } catch (error) {
368
+ console.error(`Error building coupons for campaign ${campaign.id}:`, error);
369
+ }
175
370
  }
176
371
 
177
372
  return {
@@ -184,6 +379,9 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
184
379
  campaign_type: campaignType,
185
380
  is_active: isActive,
186
381
  products,
382
+ coupons,
383
+ summary,
384
+ campaign_detail: campaignDetailMap.get(campaign.id) ?? null,
187
385
  };
188
386
  })
189
387
  );