From 5b6fb16f5cf52a378e286622c81a8d023de73b85 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sat, 4 Jul 2026 12:26:30 +0330 Subject: [PATCH] transactions --- ...260704140000_addCampaignUserGroupsTable.ts | 54 +++++++++ .../controllers/restaurants.controller.ts | 25 ++++- ...find-restaurant-wallet-transactions.dto.ts | 68 ++++++++++++ .../providers/restaurant-wallet.service.ts | 9 +- .../providers/restaurants.service.ts | 10 ++ ...estaurant-credit-transaction.repository.ts | 81 ++++++++++++++ src/modules/restaurants/restaurants.module.ts | 2 + .../users/controllers/campaigns.controller.ts | 29 +++-- src/modules/users/dto/find-campaigns.dto.ts | 43 +++++++ .../entities/campaign-user-group.entity.ts | 12 ++ src/modules/users/entities/campaign.entity.ts | 8 +- .../users/entities/user-group.entity.ts | 6 +- .../users/providers/campaign.service.ts | 105 ++++++++++-------- .../users/repositories/campaign.repository.ts | 53 +++++++++ 14 files changed, 435 insertions(+), 70 deletions(-) create mode 100644 database/migrations/Migration20260704140000_addCampaignUserGroupsTable.ts create mode 100644 src/modules/restaurants/dto/find-restaurant-wallet-transactions.dto.ts create mode 100644 src/modules/restaurants/repositories/restaurant-credit-transaction.repository.ts create mode 100644 src/modules/users/dto/find-campaigns.dto.ts create mode 100644 src/modules/users/entities/campaign-user-group.entity.ts diff --git a/database/migrations/Migration20260704140000_addCampaignUserGroupsTable.ts b/database/migrations/Migration20260704140000_addCampaignUserGroupsTable.ts new file mode 100644 index 0000000..74afffc --- /dev/null +++ b/database/migrations/Migration20260704140000_addCampaignUserGroupsTable.ts @@ -0,0 +1,54 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260704140000_addCampaignUserGroupsTable extends Migration { + + override async up(): Promise { + this.addSql(` + create table "campaign_user_groups" ( + "campaign_id" char(26) not null, + "user_group_id" char(26) not null, + constraint "campaign_user_groups_pkey" primary key ("campaign_id", "user_group_id") + ); + `); + this.addSql(` + alter table "campaign_user_groups" + add constraint "campaign_user_groups_campaign_id_foreign" + foreign key ("campaign_id") references "campaigns" ("id") on update cascade on delete cascade; + `); + this.addSql(` + alter table "campaign_user_groups" + add constraint "campaign_user_groups_user_group_id_foreign" + foreign key ("user_group_id") references "user_groups" ("id") on update cascade on delete cascade; + `); + + this.addSql(` + insert into "campaign_user_groups" ("campaign_id", "user_group_id") + select c."id", g."id" + from "campaigns" c + cross join lateral jsonb_array_elements_text(c."groups") as gid("id") + inner join "user_groups" g on g."id" = gid."id" + on conflict do nothing; + `); + + this.addSql(`alter table "campaigns" drop column "groups";`); + } + + override async down(): Promise { + this.addSql(`alter table "campaigns" add column "groups" jsonb not null default '[]';`); + + this.addSql(` + update "campaigns" c + set "groups" = coalesce( + ( + select jsonb_agg(cug."user_group_id") + from "campaign_user_groups" cug + where cug."campaign_id" = c."id" + ), + '[]'::jsonb + ); + `); + + this.addSql(`drop table if exists "campaign_user_groups" cascade;`); + } + +} diff --git a/src/modules/restaurants/controllers/restaurants.controller.ts b/src/modules/restaurants/controllers/restaurants.controller.ts index 4d76e59..86b17c5 100644 --- a/src/modules/restaurants/controllers/restaurants.controller.ts +++ b/src/modules/restaurants/controllers/restaurants.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Body, Patch, Param, UseGuards, Delete, Query } from '@nestjs/common'; +import { Controller, Get, Post, Body, Patch, Param, UseGuards, Delete, Query, ValidationPipe } from '@nestjs/common'; import { RestaurantsService } from '../providers/restaurants.service'; import { SetupRestaurantDto } from '../dto/setup-restaurant.dto'; import { UpdateRestaurantDto } from '../dto/update-restaurant.dto'; @@ -26,6 +26,7 @@ import { CacheKeyPrefixes } from 'src/common/constants/cache-keys.constant'; import { UpdateRestaurantBgDto } from '../dto/update-restaurant-bg.dto'; import { ChargeRequestDto } from '../dto/charge-request.dto'; import { FinishChargeRequestDto } from '../dto/finish-charge-request.dto'; +import { FindRestaurantWalletTransactionsDto } from '../dto/find-restaurant-wallet-transactions.dto'; @ApiTags('restaurants') @Controller() @@ -99,6 +100,28 @@ export class RestaurantsController { return this.restaurantsService.findAllBackgrounds(); } + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Permissions(Permission.UPDATE_RESTAURANT) + @ApiOperation({ summary: 'Get restaurant wallet balance' }) + @Get('admin/restaurants/wallet/balance') + getWalletBalance(@RestId() restId: string) { + return this.restaurantsService.getCurrentBalance(restId); + } + + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Permissions(Permission.UPDATE_RESTAURANT) + @ApiOperation({ summary: 'Get restaurant wallet transactions with pagination' }) + @Get('admin/restaurants/wallet/transactions') + getWalletTransactions( + @RestId() restId: string, + @Query(new ValidationPipe({ transform: true, whitelist: true })) + query: FindRestaurantWalletTransactionsDto, + ) { + return this.restaurantsService.getWalletTransactions(restId, query); + } + @UseGuards(AdminAuthGuard) @ApiBearerAuth() @Permissions(Permission.UPDATE_RESTAURANT) diff --git a/src/modules/restaurants/dto/find-restaurant-wallet-transactions.dto.ts b/src/modules/restaurants/dto/find-restaurant-wallet-transactions.dto.ts new file mode 100644 index 0000000..29a8d58 --- /dev/null +++ b/src/modules/restaurants/dto/find-restaurant-wallet-transactions.dto.ts @@ -0,0 +1,68 @@ +import { IsOptional, IsString, IsNumber, Min, IsIn, IsDateString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + RestaurantCreditTransactionReason, + RestaurantCreditTransactionType, +} from '../interface/restaurant-credit-transaction.interface'; + +const sortOrderOptions = ['asc', 'desc'] as const; +type SortOrder = (typeof sortOrderOptions)[number]; + +export class FindRestaurantWalletTransactionsDto { + @ApiPropertyOptional({ description: 'Page number', type: Number, default: 1, minimum: 1 }) + @IsOptional() + @IsNumber() + @Min(1) + @Type(() => Number) + page?: number = 1; + + @ApiPropertyOptional({ description: 'Items per page', type: Number, default: 10, minimum: 1 }) + @IsOptional() + @IsNumber() + @Min(1) + @Type(() => Number) + limit?: number = 10; + + @ApiPropertyOptional({ description: 'Transaction type filter', enum: RestaurantCreditTransactionType }) + @IsOptional() + @IsIn(Object.values(RestaurantCreditTransactionType)) + type?: RestaurantCreditTransactionType; + + @ApiPropertyOptional({ description: 'Transaction reason filter', enum: RestaurantCreditTransactionReason }) + @IsOptional() + @IsIn(Object.values(RestaurantCreditTransactionReason)) + reason?: RestaurantCreditTransactionReason; + + @ApiPropertyOptional({ description: 'Start date filter (ISO 8601 format)', type: String }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional({ description: 'End date filter (ISO 8601 format)', type: String }) + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional({ description: 'Minimum amount filter', type: Number }) + @IsOptional() + @IsNumber() + @Type(() => Number) + minAmount?: number; + + @ApiPropertyOptional({ description: 'Maximum amount filter', type: Number }) + @IsOptional() + @IsNumber() + @Type(() => Number) + maxAmount?: number; + + @ApiPropertyOptional({ description: 'Sort field', type: String, default: 'createdAt' }) + @IsOptional() + @IsString() + orderBy?: string = 'createdAt'; + + @ApiPropertyOptional({ description: 'Sort direction', enum: sortOrderOptions, default: 'desc' }) + @IsOptional() + @IsIn(sortOrderOptions) + order?: SortOrder = 'desc'; +} diff --git a/src/modules/restaurants/providers/restaurant-wallet.service.ts b/src/modules/restaurants/providers/restaurant-wallet.service.ts index b7981cb..59b1fbb 100644 --- a/src/modules/restaurants/providers/restaurant-wallet.service.ts +++ b/src/modules/restaurants/providers/restaurant-wallet.service.ts @@ -15,6 +15,8 @@ import { RestaurantCreditRequestStatus } from '../interface/restaurant-credit-re import { CreateExternalInvoice } from '../interface/external-invoice.interface'; import { ChargeRequestDto } from '../dto/charge-request.dto'; import { FinishChargeRequestDto } from '../dto/finish-charge-request.dto'; +import { FindRestaurantWalletTransactionsDto } from '../dto/find-restaurant-wallet-transactions.dto'; +import { RestaurantCreditTransactionRepository } from '../repositories/restaurant-credit-transaction.repository'; import { RestMessage } from 'src/common/enums/message.enum'; @Injectable() @@ -25,6 +27,7 @@ export class RestaurantWalletService { private readonly defaultEm: EntityManager, private readonly httpService: HttpService, private readonly configService: ConfigService, + private readonly restaurantCreditTransactionRepository: RestaurantCreditTransactionRepository, ) {} async getCurrentBalance(restaurantId: string, em: EntityManager = this.defaultEm): Promise { @@ -37,6 +40,10 @@ export class RestaurantWalletService { return Number(latestTransaction?.balance ?? 0); } + getTransactions(restaurantId: string, dto: FindRestaurantWalletTransactionsDto) { + return this.restaurantCreditTransactionRepository.findAllPaginated(restaurantId, dto); + } + async createTransaction(params: { restaurant: Restaurant; amount: number; @@ -74,7 +81,7 @@ export class RestaurantWalletService { const restaurant = await this.defaultEm.findOne(Restaurant, { id: restId }); if (!restaurant) { throw new NotFoundException(RestMessage.NOT_FOUND); - } + } if (!restaurant.subscriptionId) { throw new BadRequestException('اشتراک رستوران یافت نشد'); diff --git a/src/modules/restaurants/providers/restaurants.service.ts b/src/modules/restaurants/providers/restaurants.service.ts index 4a9d3db..6059d2b 100644 --- a/src/modules/restaurants/providers/restaurants.service.ts +++ b/src/modules/restaurants/providers/restaurants.service.ts @@ -35,6 +35,7 @@ import { ActiveRestaurantListItemDto } from '../dto/active-restaurant-list-item. import { RestaurantWalletService } from './restaurant-wallet.service'; import { ChargeRequestDto } from '../dto/charge-request.dto'; import { FinishChargeRequestDto } from '../dto/finish-charge-request.dto'; +import { FindRestaurantWalletTransactionsDto } from '../dto/find-restaurant-wallet-transactions.dto'; @Injectable() @@ -330,6 +331,15 @@ export class RestaurantsService { return this.backgroundRepository.findAll(); } + async getCurrentBalance(restId: string): Promise<{ balance: number }> { + const balance = await this.restaurantWalletService.getCurrentBalance(restId); + return { balance }; + } + + getWalletTransactions(restId: string, dto: FindRestaurantWalletTransactionsDto) { + return this.restaurantWalletService.getTransactions(restId, dto); + } + chargeRequest(restId: string, dto: ChargeRequestDto) { return this.restaurantWalletService.chargeRequest(restId, dto); } diff --git a/src/modules/restaurants/repositories/restaurant-credit-transaction.repository.ts b/src/modules/restaurants/repositories/restaurant-credit-transaction.repository.ts new file mode 100644 index 0000000..8b7d5ca --- /dev/null +++ b/src/modules/restaurants/repositories/restaurant-credit-transaction.repository.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { FilterQuery } from '@mikro-orm/core'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { RestaurantCreditTransaction } from '../entities/restaurant-credit-transaction.entity'; +import { FindRestaurantWalletTransactionsDto } from '../dto/find-restaurant-wallet-transactions.dto'; +import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; + +@Injectable() +export class RestaurantCreditTransactionRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, RestaurantCreditTransaction); + } + + async findAllPaginated( + restaurantId: string, + dto: FindRestaurantWalletTransactionsDto, + ): Promise> { + const { + page = 1, + limit = 10, + type, + reason, + startDate, + endDate, + minAmount, + maxAmount, + orderBy = 'createdAt', + order = 'desc', + } = dto; + + const offset = (page - 1) * limit; + + const where: FilterQuery = { + restaurant: { id: restaurantId }, + }; + + if (type) { + where.type = type; + } + + if (reason) { + where.reason = reason; + } + + if (startDate || endDate) { + where.createdAt = {}; + if (startDate) { + where.createdAt.$gte = new Date(startDate); + } + if (endDate) { + where.createdAt.$lte = new Date(endDate); + } + } + + if (minAmount !== undefined || maxAmount !== undefined) { + where.amount = {}; + if (minAmount !== undefined) { + where.amount.$gte = minAmount; + } + if (maxAmount !== undefined) { + where.amount.$lte = maxAmount; + } + } + + const [data, total] = await this.findAndCount(where, { + limit, + offset, + orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, + }); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } +} diff --git a/src/modules/restaurants/restaurants.module.ts b/src/modules/restaurants/restaurants.module.ts index 3aac060..8ac67f8 100644 --- a/src/modules/restaurants/restaurants.module.ts +++ b/src/modules/restaurants/restaurants.module.ts @@ -15,6 +15,7 @@ import { AuthModule } from '../auth/auth.module'; import { UtilsModule } from '../utils/utils.module'; import { Background } from './entities/background.entity'; import { BackgroundRepository } from './repositories/background.repository'; +import { RestaurantCreditTransactionRepository } from './repositories/restaurant-credit-transaction.repository'; import { RestaurantCreditTransaction } from './entities/restaurant-credit-transaction.entity'; import { RestaurantCreditRequest } from './entities/restaurant-credit-request.entity'; import { RestaurantWalletService } from './providers/restaurant-wallet.service'; @@ -29,6 +30,7 @@ import { httpConfig } from 'src/config/http.config'; ScheduleService, RestaurantCrone, BackgroundRepository, + RestaurantCreditTransactionRepository, RestaurantWalletService, ], imports: [ diff --git a/src/modules/users/controllers/campaigns.controller.ts b/src/modules/users/controllers/campaigns.controller.ts index 20abe13..bcf1a04 100644 --- a/src/modules/users/controllers/campaigns.controller.ts +++ b/src/modules/users/controllers/campaigns.controller.ts @@ -1,10 +1,11 @@ -import { Body, Controller, Delete, Get, Param, Post, UseGuards, ValidationPipe } from '@nestjs/common'; -import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards, ValidationPipe } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, 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 { FindCampaignsDto } from '../dto/find-campaigns.dto'; import { CampaignService } from '../providers/campaign.service'; import { CommonMessage } from 'src/common/enums/message.enum'; @@ -30,9 +31,16 @@ export class CampaignsController { @ApiBearerAuth() @Permissions(Permission.MANAGE_USERS) @Get('admin/campaigns') - @ApiOperation({ summary: 'Get all campaigns' }) - findAll(@RestId() restId: string) { - return this.campaignService.findAll(restId); + @ApiOperation({ summary: 'Get paginated list of campaigns' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'search', required: false, type: String }) + @ApiQuery({ name: 'sentType', required: false, enum: ['push', 'sms'] }) + @ApiQuery({ name: 'status', required: false, enum: ['sending', 'completed'] }) + @ApiQuery({ name: 'orderBy', required: false, type: String }) + @ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] }) + findAll(@Query() dto: FindCampaignsDto, @RestId() restId: string) { + return this.campaignService.findAll(restId, dto); } @UseGuards(AdminAuthGuard) @@ -45,14 +53,5 @@ export class CampaignsController { 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/find-campaigns.dto.ts b/src/modules/users/dto/find-campaigns.dto.ts new file mode 100644 index 0000000..70b6674 --- /dev/null +++ b/src/modules/users/dto/find-campaigns.dto.ts @@ -0,0 +1,43 @@ +import { IsOptional, IsString, IsNumber, IsEnum } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { CampaignSentType, CampaignStatus } from '../interface/campaign'; + +export class FindCampaignsDto { + @IsOptional() + @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 1, description: 'Page number' }) + page?: number; + + @IsOptional() + @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 10, description: 'Items per page' }) + limit?: number; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ example: 'تخفیف', description: 'Search by title or discount code' }) + search?: string; + + @IsOptional() + @IsEnum(CampaignSentType) + @ApiPropertyOptional({ enum: CampaignSentType, description: 'Filter by sent type' }) + sentType?: CampaignSentType; + + @IsOptional() + @IsEnum(CampaignStatus) + @ApiPropertyOptional({ enum: CampaignStatus, description: 'Filter by status' }) + status?: CampaignStatus; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ example: 'createdAt', description: 'Field to order by' }) + orderBy?: string; + + @IsOptional() + @IsEnum(['asc', 'desc']) + @ApiPropertyOptional({ enum: ['asc', 'desc'], example: 'desc', description: 'Sort order' }) + order?: 'asc' | 'desc'; +} diff --git a/src/modules/users/entities/campaign-user-group.entity.ts b/src/modules/users/entities/campaign-user-group.entity.ts new file mode 100644 index 0000000..6805dc1 --- /dev/null +++ b/src/modules/users/entities/campaign-user-group.entity.ts @@ -0,0 +1,12 @@ +import { Entity, ManyToOne } from '@mikro-orm/core'; +import { Campaign } from './campaign.entity'; +import { UserGroup } from './user-group.entity'; + +@Entity({ tableName: 'campaign_user_groups' }) +export class CampaignUserGroup { + @ManyToOne(() => Campaign, { primary: true }) + campaign!: Campaign; + + @ManyToOne(() => UserGroup, { primary: true }) + userGroup!: UserGroup; +} diff --git a/src/modules/users/entities/campaign.entity.ts b/src/modules/users/entities/campaign.entity.ts index 5bec59b..737d696 100644 --- a/src/modules/users/entities/campaign.entity.ts +++ b/src/modules/users/entities/campaign.entity.ts @@ -1,7 +1,9 @@ -import { Entity, Enum, Index, ManyToOne, Property } from '@mikro-orm/core'; +import { Collection, Entity, Enum, Index, ManyToMany, ManyToOne, Property } from '@mikro-orm/core'; import { BaseEntity } from '../../../common/entities/base.entity'; import { CampaignSentType, CampaignStatus } from '../interface/campaign'; import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity'; +import { UserGroup } from './user-group.entity'; +import { CampaignUserGroup } from './campaign-user-group.entity'; @Entity({ tableName: 'campaigns' }) @Index({ properties: ['restaurant'] }) @@ -12,8 +14,8 @@ export class Campaign extends BaseEntity { @Property() title!: string; - @Property({ type: 'json', default: '[]' }) - groups: string[] = []; + @ManyToMany({ entity: () => UserGroup, pivotEntity: () => CampaignUserGroup, inversedBy: g => g.campaigns }) + groups = new Collection(this); @Enum(() => CampaignSentType) sentType!: CampaignSentType; diff --git a/src/modules/users/entities/user-group.entity.ts b/src/modules/users/entities/user-group.entity.ts index d835c33..9d67a4c 100644 --- a/src/modules/users/entities/user-group.entity.ts +++ b/src/modules/users/entities/user-group.entity.ts @@ -1,7 +1,8 @@ -import { Collection, Entity, Index, ManyToOne, OneToMany, Property, Unique } from '@mikro-orm/core'; +import { Collection, Entity, Index, ManyToMany, ManyToOne, OneToMany, Property, Unique } from '@mikro-orm/core'; import { BaseEntity } from '../../../common/entities/base.entity'; import { Restaurant } from '../../restaurants/entities/restaurant.entity'; import { UserGroupUser } from './user-group-user.entity'; +import { Campaign } from './campaign.entity'; @Entity({ tableName: 'user_groups' }) @Index({ properties: ['restaurant'] }) @@ -18,4 +19,7 @@ export class UserGroup extends BaseEntity { @OneToMany(() => UserGroupUser, member => member.userGroup) members = new Collection(this); + + @ManyToMany({ entity: () => Campaign, mappedBy: 'groups' }) + campaigns = new Collection(this); } diff --git a/src/modules/users/providers/campaign.service.ts b/src/modules/users/providers/campaign.service.ts index bc1f541..5935e69 100644 --- a/src/modules/users/providers/campaign.service.ts +++ b/src/modules/users/providers/campaign.service.ts @@ -3,6 +3,8 @@ 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 { FindCampaignsDto } from '../dto/find-campaigns.dto'; +import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; import { RestaurantsService } from 'src/modules/restaurants/providers/restaurants.service'; import { UserGroupRepository } from '../repositories/user-group.repository'; import { UserGroupUserRepository } from '../repositories/user-group-user.repository'; @@ -27,69 +29,79 @@ export class CampaignService { 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)]; + const uniqueGroupIds = [...new Set(dto.groups)]; - if (uniqueGroups.length === 0) { + if (uniqueGroupIds.length === 0) { throw new BadRequestException('حداقل یک گروه الزامی است'); } - const validGroupsCount = await this.userGroupRepository.count({ - id: { $in: uniqueGroups }, + const userGroups = await this.userGroupRepository.find({ + id: { $in: uniqueGroupIds }, restaurant: { id: restId }, }); - if (validGroupsCount !== uniqueGroups.length) { + if (userGroups.length !== uniqueGroupIds.length) { throw new BadRequestException('گروه(ها) معتبر نیستند'); } + let smsDispatchPlan: Awaited> | undefined; + + if (dto.sentType === CampaignSentType.SMS) { + smsDispatchPlan = await this.buildSmsDiscountDispatchPlan(restId, uniqueGroupIds, dto, restaurant.name); + } + const data: RequiredEntityData = { restaurant, title: dto.title, - groups: uniqueGroups, sentType: dto.sentType, discountCode: dto.discountCode, - status: dto.sentType === CampaignSentType.SMS ? CampaignStatus.SENDING : CampaignStatus.COMPLETED, - totalCount: 0, - totalCost: 0, + status: + dto.sentType === CampaignSentType.SMS + ? smsDispatchPlan!.totalCount === 0 + ? CampaignStatus.COMPLETED + : CampaignStatus.SENDING + : CampaignStatus.COMPLETED, + totalCount: smsDispatchPlan?.totalCount ?? 0, + totalCost: smsDispatchPlan?.totalCost ?? 0, refunded: 0, sentCount: 0, failedCount: 0, }; - const campaign = this.campaignRepository.create(data); - await this.em.persistAndFlush(campaign); + let campaign: Campaign; - if (dto.sentType === CampaignSentType.SMS) { - const smsDispatchPlan = await this.buildSmsDiscountDispatchPlan( - restId, - uniqueGroups, - dto, - restaurant.name, - campaign.id, - ); + if (dto.sentType === CampaignSentType.SMS && smsDispatchPlan && smsDispatchPlan.totalCount > 0) { + campaign = await this.em.transactional(async (em) => { + const created = em.create(Campaign, data); + userGroups.forEach(group => created.groups.add(group)); + await em.persistAndFlush(created); - campaign.totalCount = smsDispatchPlan.totalCount; - campaign.totalCost = smsDispatchPlan.totalCost; - if (smsDispatchPlan.totalCount === 0) { - campaign.status = CampaignStatus.COMPLETED; - } else { await this.restaurantWalletService.createTransaction({ restaurant, - amount: smsDispatchPlan.totalCost, + amount: smsDispatchPlan!.totalCost, type: RestaurantCreditTransactionType.DEBIT, reason: RestaurantCreditTransactionReason.SMS_SEND, insufficientBalanceMessage: 'اعتبار کیف پول رستوران برای ارسال کمپین پیامکی کافی نیست', + em, }); - await this.smsQueueService.enqueueBulk(smsDispatchPlan.jobs); - } + return created; + }); + + const jobs = smsDispatchPlan.jobs.map((job) => ({ ...job, campaignId: campaign.id })); + await this.smsQueueService.enqueueBulk(jobs); + } else { + campaign = this.campaignRepository.create(data); + userGroups.forEach(group => campaign.groups.add(group)); await this.em.persistAndFlush(campaign); - } else if (dto.sentType === CampaignSentType.PUSH) { - await this.runPushDiscountCampaignFlow(restId, uniqueGroups, dto); + + if (dto.sentType === CampaignSentType.PUSH) { + await this.runPushDiscountCampaignFlow(restId, uniqueGroupIds, dto); + } } return campaign; @@ -100,7 +112,6 @@ export class CampaignService { uniqueGroups: string[], dto: CreateCampaignDto, restaurantTitle: string, - campaignId: string, ): Promise<{ jobs: DirectSmsQueueJob[]; totalCount: number; totalCost: number }> { const discountTemplateId = this.configService.get('SMS_PATTERN_DISCOUNT_CODE'); if (!discountTemplateId) { @@ -138,18 +149,17 @@ export class CampaignService { } const jobs: DirectSmsQueueJob[] = [...recipientsByPhone.entries()].map(([phone, firstName]) => ({ - phone, - templateId: discountTemplateId, - restaurantId: restId, - quantity: 1, - params: { - name: firstName ?? '', - disc: dto.discountCode, - oca: dto.ocasion, - rest: restaurantTitle, - }, - campaignId, - })); + phone, + templateId: discountTemplateId, + restaurantId: restId, + quantity: 1, + params: { + name: firstName ?? '', + disc: dto.discountCode, + oca: dto.ocasion, + rest: restaurantTitle, + }, + })); const totalCount = jobs.length; const smsUnitPrice = this.getSmsUnitPrice(); @@ -180,18 +190,15 @@ export class CampaignService { return; } - async findAll(restId: string): Promise { - return this.campaignRepository.find( - { restaurant: { id: restId } }, - { orderBy: { createdAt: 'desc' } }, - ); + findAll(restId: string, dto: FindCampaignsDto): Promise> { + return this.campaignRepository.findAllPaginated(restId, dto); } async findOneOrFail(restId: string, campaignId: string): Promise { const campaign = await this.campaignRepository.findOne({ id: campaignId, restaurant: { id: restId }, - }); + }, { populate: ['groups'] }); if (!campaign) { throw new NotFoundException('کمپین یافت نشد'); diff --git a/src/modules/users/repositories/campaign.repository.ts b/src/modules/users/repositories/campaign.repository.ts index 533995f..444a358 100644 --- a/src/modules/users/repositories/campaign.repository.ts +++ b/src/modules/users/repositories/campaign.repository.ts @@ -1,10 +1,63 @@ import { Injectable } from '@nestjs/common'; import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { FilterQuery } from '@mikro-orm/core'; import { Campaign } from '../entities/campaign.entity'; +import { CampaignSentType, CampaignStatus } from '../interface/campaign'; +import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; + +type FindCampaignsOpts = { + page?: number; + limit?: number; + search?: string; + orderBy?: string; + order?: 'asc' | 'desc'; + sentType?: CampaignSentType; + status?: CampaignStatus; +}; @Injectable() export class CampaignRepository extends EntityRepository { constructor(readonly em: EntityManager) { super(em, Campaign); } + + async findAllPaginated(restId: string, opts: FindCampaignsOpts = {}): Promise> { + const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc', sentType, status } = opts; + + const offset = (page - 1) * limit; + + const where: FilterQuery = { restaurant: { id: restId } }; + + if (sentType) { + where.sentType = sentType; + } + + if (status) { + where.status = status; + } + + if (search) { + const pattern = `%${search}%`; + where.$or = [{ title: { $ilike: pattern } }, { discountCode: { $ilike: pattern } }]; + } + + const [data, total] = await this.findAndCount(where, { + limit, + offset, + populate: ['groups'], + orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, + }); + + const totalPages = Math.ceil(total / limit); + + return { + data, + meta: { + total, + page, + limit, + totalPages, + }, + }; + } }