From f2215da4c1dadebfa792aafe5da5348cc97c9c31 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Thu, 2 Jul 2026 11:34:24 +0330 Subject: [PATCH] create discount camp --- .../users/controllers/campaigns.controller.ts | 58 +++++++ src/modules/users/dto/create-campaign.dto.ts | 29 ++++ src/modules/users/entities/campaign.entity.ts | 23 +++ src/modules/users/interface/campaign.ts | 4 + .../users/providers/campaign.service.ts | 149 ++++++++++++++++++ .../users/repositories/campaign.repository.ts | 10 ++ src/modules/users/user.module.ts | 18 ++- 7 files changed, 285 insertions(+), 6 deletions(-) create mode 100644 src/modules/users/controllers/campaigns.controller.ts create mode 100644 src/modules/users/dto/create-campaign.dto.ts create mode 100644 src/modules/users/entities/campaign.entity.ts create mode 100644 src/modules/users/interface/campaign.ts create mode 100644 src/modules/users/providers/campaign.service.ts create mode 100644 src/modules/users/repositories/campaign.repository.ts diff --git a/src/modules/users/controllers/campaigns.controller.ts b/src/modules/users/controllers/campaigns.controller.ts new file mode 100644 index 0000000..20abe13 --- /dev/null +++ b/src/modules/users/controllers/campaigns.controller.ts @@ -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 }; + } +} diff --git a/src/modules/users/dto/create-campaign.dto.ts b/src/modules/users/dto/create-campaign.dto.ts new file mode 100644 index 0000000..c8950a6 --- /dev/null +++ b/src/modules/users/dto/create-campaign.dto.ts @@ -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; +} diff --git a/src/modules/users/entities/campaign.entity.ts b/src/modules/users/entities/campaign.entity.ts new file mode 100644 index 0000000..7e1be33 --- /dev/null +++ b/src/modules/users/entities/campaign.entity.ts @@ -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; +} diff --git a/src/modules/users/interface/campaign.ts b/src/modules/users/interface/campaign.ts new file mode 100644 index 0000000..55ea9e1 --- /dev/null +++ b/src/modules/users/interface/campaign.ts @@ -0,0 +1,4 @@ +export enum CampaignSentType { + PUSH = 'push', + SMS = 'sms', +} diff --git a/src/modules/users/providers/campaign.service.ts b/src/modules/users/providers/campaign.service.ts new file mode 100644 index 0000000..130142c --- /dev/null +++ b/src/modules/users/providers/campaign.service.ts @@ -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 { + 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 = { + 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 { + const discountTemplateId = this.configService.get('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(); + 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 { + // Intentionally separated flow: push campaign handling is independent from SMS dispatch. + return; + } + + async findAll(restId: string): Promise { + return this.campaignRepository.find( + { restaurant: { id: restId } }, + { orderBy: { createdAt: 'desc' } }, + ); + } + + async findOneOrFail(restId: string, campaignId: string): Promise { + 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 { + const campaign = await this.findOneOrFail(restId, campaignId); + campaign.deletedAt = new Date(); + await this.em.persistAndFlush(campaign); + } +} diff --git a/src/modules/users/repositories/campaign.repository.ts b/src/modules/users/repositories/campaign.repository.ts new file mode 100644 index 0000000..533995f --- /dev/null +++ b/src/modules/users/repositories/campaign.repository.ts @@ -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 { + constructor(readonly em: EntityManager) { + super(em, Campaign); + } +} diff --git a/src/modules/users/user.module.ts b/src/modules/users/user.module.ts index e50d5c4..96eb362 100644 --- a/src/modules/users/user.module.ts +++ b/src/modules/users/user.module.ts @@ -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 { }