@lodashventure/medusa-campaign 1.4.23 → 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.
- package/.medusa/server/src/api/admin/flash-sales/route.js +37 -3
- package/.medusa/server/src/api/middlewares/add-flash-sale-highlights.js +150 -0
- package/.medusa/server/src/api/middlewares.js +13 -2
- package/.medusa/server/src/api/store/campaigns/[id]/route.js +93 -1
- package/.medusa/server/src/api/store/campaigns/route.js +139 -4
- package/package.json +1 -1
- package/src/api/admin/flash-sales/route.ts +54 -2
- package/src/api/middlewares/add-flash-sale-highlights.ts +243 -0
- package/src/api/middlewares.ts +12 -1
- package/src/api/store/campaigns/[id]/route.ts +138 -0
- package/src/api/store/campaigns/route.ts +201 -3
|
@@ -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
|
-
|
|
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
|
);
|