This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20260704140000_addCampaignUserGroupsTable extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
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<void> {
|
||||
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;`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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<number> {
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<RestaurantCreditTransaction> {
|
||||
constructor(readonly em: EntityManager) {
|
||||
super(em, RestaurantCreditTransaction);
|
||||
}
|
||||
|
||||
async findAllPaginated(
|
||||
restaurantId: string,
|
||||
dto: FindRestaurantWalletTransactionsDto,
|
||||
): Promise<PaginatedResult<RestaurantCreditTransaction>> {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
type,
|
||||
reason,
|
||||
startDate,
|
||||
endDate,
|
||||
minAmount,
|
||||
maxAmount,
|
||||
orderBy = 'createdAt',
|
||||
order = 'desc',
|
||||
} = dto;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const where: FilterQuery<RestaurantCreditTransaction> = {
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<UserGroup>(this);
|
||||
|
||||
@Enum(() => CampaignSentType)
|
||||
sentType!: CampaignSentType;
|
||||
|
||||
@@ -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<UserGroupUser>(this);
|
||||
|
||||
@ManyToMany({ entity: () => Campaign, mappedBy: 'groups' })
|
||||
campaigns = new Collection<Campaign>(this);
|
||||
}
|
||||
|
||||
@@ -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<Campaign> {
|
||||
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<ReturnType<typeof this.buildSmsDiscountDispatchPlan>> | undefined;
|
||||
|
||||
if (dto.sentType === CampaignSentType.SMS) {
|
||||
smsDispatchPlan = await this.buildSmsDiscountDispatchPlan(restId, uniqueGroupIds, dto, restaurant.name);
|
||||
}
|
||||
|
||||
const data: RequiredEntityData<Campaign> = {
|
||||
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<string>('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<Campaign[]> {
|
||||
return this.campaignRepository.find(
|
||||
{ restaurant: { id: restId } },
|
||||
{ orderBy: { createdAt: 'desc' } },
|
||||
);
|
||||
findAll(restId: string, dto: FindCampaignsDto): Promise<PaginatedResult<Campaign>> {
|
||||
return this.campaignRepository.findAllPaginated(restId, dto);
|
||||
}
|
||||
|
||||
async findOneOrFail(restId: string, campaignId: string): Promise<Campaign> {
|
||||
const campaign = await this.campaignRepository.findOne({
|
||||
id: campaignId,
|
||||
restaurant: { id: restId },
|
||||
});
|
||||
}, { populate: ['groups'] });
|
||||
|
||||
if (!campaign) {
|
||||
throw new NotFoundException('کمپین یافت نشد');
|
||||
|
||||
@@ -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<Campaign> {
|
||||
constructor(readonly em: EntityManager) {
|
||||
super(em, Campaign);
|
||||
}
|
||||
|
||||
async findAllPaginated(restId: string, opts: FindCampaignsOpts = {}): Promise<PaginatedResult<Campaign>> {
|
||||
const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc', sentType, status } = opts;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const where: FilterQuery<Campaign> = { 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user