create discount camp

This commit is contained in:
2026-07-02 11:34:24 +03:30
parent 816fa7a7fc
commit f2215da4c1
7 changed files with 285 additions and 6 deletions
@@ -0,0 +1,58 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards, ValidationPipe } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
import { Permissions } from 'src/common/decorators/permissions.decorator';
import { Permission } from 'src/common/enums/permission.enum';
import { RestId } from 'src/common/decorators/rest-id.decorator';
import { CreateCampaignDto } from '../dto/create-campaign.dto';
import { CampaignService } from '../providers/campaign.service';
import { CommonMessage } from 'src/common/enums/message.enum';
@ApiTags('Campaigns')
@Controller()
export class CampaignsController {
constructor(private readonly campaignService: CampaignService) {}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_USERS)
@Post('admin/campaigns/discount')
@ApiOperation({ summary: 'Create discount campaign' })
@ApiBody({ type: CreateCampaignDto })
createDiscountCampaign(
@Body(new ValidationPipe({ transform: true, whitelist: true })) dto: CreateCampaignDto,
@RestId() restId: string,
) {
return this.campaignService.createDiscountCampaign(restId, dto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_USERS)
@Get('admin/campaigns')
@ApiOperation({ summary: 'Get all campaigns' })
findAll(@RestId() restId: string) {
return this.campaignService.findAll(restId);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_USERS)
@Get('admin/campaigns/:campaignId')
@ApiOperation({ summary: 'Get campaign by id' })
@ApiParam({ name: 'campaignId', description: 'Campaign ID' })
findOne(@Param('campaignId') campaignId: string, @RestId() restId: string) {
return this.campaignService.findOneOrFail(restId, campaignId);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_USERS)
@Delete('admin/campaigns/:campaignId')
@ApiOperation({ summary: 'Delete campaign' })
@ApiParam({ name: 'campaignId', description: 'Campaign ID' })
async remove(@Param('campaignId') campaignId: string, @RestId() restId: string) {
await this.campaignService.remove(restId, campaignId);
return { message: CommonMessage.DELETED };
}
}
@@ -0,0 +1,29 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { CampaignSentType } from '../interface/campaign';
export class CreateCampaignDto {
@ApiProperty({ example: 'کمپین تابستانه' })
@IsNotEmpty()
@IsString()
title!: string;
@ApiProperty({ type: [String], example: ['01JABC...', '01JDEF...'] })
@IsArray()
@IsString({ each: true })
groups!: string[];
@ApiProperty({ enum: CampaignSentType, example: CampaignSentType.PUSH })
@IsEnum(CampaignSentType)
sentType!: CampaignSentType;
@ApiProperty({ example: 'SUMMER20' })
@IsNotEmpty()
@IsString()
discountCode!: string;
@ApiProperty({ example: 'کمپین افتتاحیه' })
@IsNotEmpty()
@IsString()
ocasion!: string;
}
@@ -0,0 +1,23 @@
import { Entity, Enum, Index, ManyToOne, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { CampaignSentType } from '../interface/campaign';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
@Entity({ tableName: 'campaigns' })
@Index({ properties: ['restaurant'] })
export class Campaign extends BaseEntity {
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@Property()
title!: string;
@Property({ type: 'json', default: '[]' })
groups: string[] = [];
@Enum(() => CampaignSentType)
sentType!: CampaignSentType;
@Property()
discountCode!: string;
}
+4
View File
@@ -0,0 +1,4 @@
export enum CampaignSentType {
PUSH = 'push',
SMS = 'sms',
}
@@ -0,0 +1,149 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { EntityManager, RequiredEntityData } from '@mikro-orm/postgresql';
import { Campaign } from '../entities/campaign.entity';
import { CampaignRepository } from '../repositories/campaign.repository';
import { CreateCampaignDto } from '../dto/create-campaign.dto';
import { RestaurantsService } from 'src/modules/restaurants/providers/restaurants.service';
import { UserGroupRepository } from '../repositories/user-group.repository';
import { UserGroupUserRepository } from '../repositories/user-group-user.repository';
import { SmsQueueService } from 'src/modules/notifications/services/sms-queue.service';
import { ConfigService } from '@nestjs/config';
import { CampaignSentType } from '../interface/campaign';
@Injectable()
export class CampaignService {
constructor(
private readonly campaignRepository: CampaignRepository,
private readonly userGroupRepository: UserGroupRepository,
private readonly userGroupUserRepository: UserGroupUserRepository,
private readonly restaurantsService: RestaurantsService,
private readonly smsQueueService: SmsQueueService,
private readonly configService: ConfigService,
private readonly em: EntityManager,
) {}
async createDiscountCampaign(restId: string, dto: CreateCampaignDto): Promise<Campaign> {
const restaurant = await this.restaurantsService.findOneOrFail(restId);
const uniqueGroups = [...new Set(dto.groups)];
if (uniqueGroups.length === 0) {
throw new BadRequestException('حداقل یک گروه الزامی است');
}
const validGroupsCount = await this.userGroupRepository.count({
id: { $in: uniqueGroups },
restaurant: { id: restId },
});
if (validGroupsCount !== uniqueGroups.length) {
throw new BadRequestException('گروه(ها) معتبر نیستند');
}
const data: RequiredEntityData<Campaign> = {
restaurant,
title: dto.title,
groups: uniqueGroups,
sentType: dto.sentType,
discountCode: dto.discountCode,
};
const campaign = this.campaignRepository.create(data);
await this.em.persistAndFlush(campaign);
if (dto.sentType === CampaignSentType.SMS) {
await this.runSmsDiscountCampaignFlow(restId, uniqueGroups, dto, restaurant.name);
} else if (dto.sentType === CampaignSentType.PUSH) {
await this.runPushDiscountCampaignFlow(restId, uniqueGroups, dto);
}
return campaign;
}
private async runSmsDiscountCampaignFlow(
restId: string,
uniqueGroups: string[],
dto: CreateCampaignDto,
restaurantTitle: string,
): Promise<void> {
const discountTemplateId = this.configService.get<string>('SMS_PATTERN_DISCOUNT_CODE');
if (!discountTemplateId) {
throw new BadRequestException('الگوی پیامک تخفیف تنظیم نشده است');
}
const groupMembers = await this.userGroupUserRepository.find(
{
userGroup: {
id: { $in: uniqueGroups },
restaurant: { id: restId },
},
},
{ populate: ['user'] },
);
const recipientsByPhone = new Map<string, string>();
for (const member of groupMembers) {
const phone = member.user?.phone;
if (!phone) continue;
const firstName = member.user?.firstName ?? '';
const existingName = recipientsByPhone.get(phone);
if (!existingName || existingName.trim().length === 0) {
recipientsByPhone.set(phone, firstName);
}
}
if (recipientsByPhone.size === 0) {
return;
}
await this.smsQueueService.enqueueBulk(
[...recipientsByPhone.entries()].map(([phone, firstName]) => ({
phone,
templateId: discountTemplateId,
restaurantId: restId,
quantity: 1,
params: {
name: firstName ?? '',
disc: dto.discountCode,
oca: dto.ocasion,
rest: restaurantTitle,
},
})),
);
}
private async runPushDiscountCampaignFlow(
_restId: string,
_uniqueGroups: string[],
_dto: CreateCampaignDto,
): Promise<void> {
// Intentionally separated flow: push campaign handling is independent from SMS dispatch.
return;
}
async findAll(restId: string): Promise<Campaign[]> {
return this.campaignRepository.find(
{ restaurant: { id: restId } },
{ orderBy: { createdAt: 'desc' } },
);
}
async findOneOrFail(restId: string, campaignId: string): Promise<Campaign> {
const campaign = await this.campaignRepository.findOne({
id: campaignId,
restaurant: { id: restId },
});
if (!campaign) {
throw new NotFoundException('کمپین یافت نشد');
}
return campaign;
}
async remove(restId: string, campaignId: string): Promise<void> {
const campaign = await this.findOneOrFail(restId, campaignId);
campaign.deletedAt = new Date();
await this.em.persistAndFlush(campaign);
}
}
@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { Campaign } from '../entities/campaign.entity';
@Injectable()
export class CampaignRepository extends EntityRepository<Campaign> {
constructor(readonly em: EntityManager) {
super(em, Campaign);
}
}
+12 -6
View File
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { UserService } from './providers/user.service';
import { UsersController } from './controllers/users.controller';
import { MikroOrmModule } from '@mikro-orm/nestjs';
@@ -21,17 +21,23 @@ import { UserGroupRepository } from './repositories/user-group.repository';
import { UserGroupUserRepository } from './repositories/user-group-user.repository';
import { UserGroupService } from './providers/user-group.service';
import { UserGroupsController } from './controllers/user-groups.controller';
import { Campaign } from './entities/campaign.entity';
import { CampaignRepository } from './repositories/campaign.repository';
import { CampaignService } from './providers/campaign.service';
import { CampaignsController } from './controllers/campaigns.controller';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
providers: [UserService, WalletService, UserGroupService,
providers: [UserService, WalletService, UserGroupService, CampaignService,
UserRepository, WalletTransactionRepository,
PointTransactionRepository, UserRestaurantRepository, UserGroupRepository, UserGroupUserRepository, UserRestaurantCrone],
controllers: [UsersController, UserGroupsController],
PointTransactionRepository, UserRestaurantRepository, UserGroupRepository, UserGroupUserRepository, CampaignRepository, UserRestaurantCrone],
controllers: [UsersController, UserGroupsController, CampaignsController],
imports: [
MikroOrmModule.forFeature([User, UserAddress, WalletTransaction, PointTransaction, UserRestaurant, UserGroup, UserGroupUser]),
MikroOrmModule.forFeature([User, UserAddress, WalletTransaction, PointTransaction, UserRestaurant, UserGroup, UserGroupUser, Campaign]),
JwtModule,
RestaurantsModule,
forwardRef(() => NotificationsModule),
],
exports: [UserService, WalletService, UserGroupService, UserRepository, WalletTransactionRepository, PointTransactionRepository, UserGroupRepository],
exports: [UserService, WalletService, UserGroupService, CampaignService, UserRepository, WalletTransactionRepository, PointTransactionRepository, UserGroupRepository, CampaignRepository],
})
export class UserModule { }