Compare commits

...

13 Commits

Author SHA1 Message Date
hamid 9202d1b2ca fix build 2026-07-11 22:18:29 +03:30
hamid 25559191c1 fix build 2026-07-11 22:11:37 +03:30
hamid b1da7fdeac templete campain 2026-07-11 22:08:41 +03:30
hamid bf4e4c8d65 list of card to card 2026-07-11 20:56:38 +03:30
hamid 8b2d1b0a47 fix warning 2026-07-11 20:00:41 +03:30
hamid c41f5567c5 migration 2026-07-10 15:11:29 +03:30
hamid 7a5d2bb137 Fix SMS/notification migrations seeding keys before enum values exist 2026-07-10 15:03:38 +03:30
hamid 3e79cb69b3 port 2026-07-10 15:00:59 +03:30
hamid 83f8db336e Default port 4000 2026-07-10 14:52:49 +03:30
hamid 0020f95a61 fix 2026-07-10 14:35:05 +03:30
hamid 7c0f3a1d0e added service with excel 2026-07-10 14:09:41 +03:30
hamid 33f0afaf10 order sms 2026-07-10 13:55:59 +03:30
hamid 42f08397a2 profile avatar 2026-07-10 13:50:25 +03:30
42 changed files with 2226 additions and 84 deletions
+11 -13
View File
@@ -12,25 +12,23 @@ RUN npm config set registry https://package-mirror.liara.ir/repository/npm/
WORKDIR /app WORKDIR /app
# Stage 2: Build dependencies # Stage 2: Build (install all deps including @nestjs/cli)
FROM base AS deps
WORKDIR /temp-deps
COPY package*.json ./
RUN npm ci
# Stage 3: Production dependencies (prune dev deps from the already-installed tree)
FROM deps AS prod-deps
RUN npm prune --omit=dev
# Stage 4: Build
FROM base AS builder FROM base AS builder
WORKDIR /build WORKDIR /build
COPY package*.json ./ COPY package*.json ./
COPY --from=deps /temp-deps/node_modules ./node_modules # Deployment platforms set NODE_ENV=production globally; devDependencies are required to build.
ENV NODE_ENV=development
RUN npm ci --include=dev
COPY . . COPY . .
RUN npm run build RUN npm run build
# Stage 5: Runner # Stage 3: Production dependencies only
FROM base AS prod-deps
WORKDIR /temp-deps
COPY package*.json ./
RUN npm ci --omit=dev
# Stage 4: Runner
FROM base AS runner FROM base AS runner
RUN addgroup -S appgroup && adduser -S appuser -G appgroup RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+791 -13
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -39,6 +39,7 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"exceljs": "^4.4.0",
"multer": "^2.2.0", "multer": "^2.2.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
+14 -14
View File
@@ -39,6 +39,7 @@ export interface AuthTokensResponse {
lastName: string; lastName: string;
mobile: string; mobile: string;
role: UserRole; role: UserRole;
avatarUrl: string | null;
}; };
} }
@@ -403,13 +404,7 @@ export class AuthService {
const user = await this.usersService.findOne(authUser.id); const user = await this.usersService.findOne(authUser.id);
const response: MeResponse = { const response: MeResponse = {
user: { user: this.toAuthUserResponse(user),
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
mobile: user.mobile,
role: user.role,
},
}; };
if (user.role === UserRole.SALON) { if (user.role === UserRole.SALON) {
@@ -477,13 +472,18 @@ export class AuthService {
return { return {
accessToken, accessToken,
refreshToken, refreshToken,
user: { user: this.toAuthUserResponse(user),
id: user.id, };
firstName: user.firstName, }
lastName: user.lastName,
mobile: user.mobile, private toAuthUserResponse(user: User): AuthTokensResponse['user'] {
role: user.role, return {
}, id: user.id,
firstName: user.firstName,
lastName: user.lastName,
mobile: user.mobile,
role: user.role,
avatarUrl: user.avatarUrl ?? null,
}; };
} }
+3
View File
@@ -16,6 +16,9 @@ class AuthUserResponseDto {
@ApiProperty({ enum: UserRole }) @ApiProperty({ enum: UserRole })
role: UserRole; role: UserRole;
@ApiProperty({ nullable: true, required: false })
avatarUrl?: string | null;
} }
export class AuthTokensResponseDto { export class AuthTokensResponseDto {
+3
View File
@@ -18,6 +18,9 @@ class MeUserResponseDto {
@ApiProperty({ enum: UserRole }) @ApiProperty({ enum: UserRole })
role: UserRole; role: UserRole;
@ApiPropertyOptional({ nullable: true })
avatarUrl?: string | null;
} }
export class MeResponseDto { export class MeResponseDto {
+12 -3
View File
@@ -9,7 +9,6 @@ import { CampaignTargetingService } from './campaign-targeting.service';
import { CampaignsService } from './campaigns.service'; import { CampaignsService } from './campaigns.service';
import { CampaignTrigger } from './enums/campaign-trigger.enum'; import { CampaignTrigger } from './enums/campaign-trigger.enum';
import { CampaignType } from './enums/campaign-type.enum'; import { CampaignType } from './enums/campaign-type.enum';
import { getCampaignTypeDefinition } from './constants/campaign-types.constants';
@Injectable() @Injectable()
export class CampaignAutomationService { export class CampaignAutomationService {
@@ -115,9 +114,16 @@ export class CampaignAutomationService {
} }
try { try {
const definition = getCampaignTypeDefinition(type); const defaultTemplate =
await this.campaignsService.getDefaultTemplateForType(type);
if (!defaultTemplate) {
throw new Error('No active template configured for this campaign');
}
const smsParts = await this.campaignsService.calculateCampaignSmsParts( const smsParts = await this.campaignsService.calculateCampaignSmsParts(
definition.scenarioKey, defaultTemplate.templateId,
defaultTemplate.template.body,
{ discountPercent: null, discountAmount: null },
salon, salon,
targets, targets,
); );
@@ -142,6 +148,9 @@ export class CampaignAutomationService {
trigger: CampaignTrigger.AUTOMATIC, trigger: CampaignTrigger.AUTOMATIC,
targets, targets,
triggeredByUserId: null, triggeredByUserId: null,
templateId: (
await this.campaignsService.getDefaultTemplateForType(type)
)?.templateId,
}); });
return true; return true;
@@ -0,0 +1,167 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SmsTemplatesService } from '../sms-templates/sms-templates.service';
import { getCampaignTypeDefinition } from './constants/campaign-types.constants';
import { AssignCampaignTemplateDto } from './dto/assign-campaign-template.dto';
import { CampaignTypeTemplate } from './entities/campaign-type-template.entity';
import { CampaignType } from './enums/campaign-type.enum';
@Injectable()
export class CampaignTypeTemplatesService {
constructor(
@InjectRepository(CampaignTypeTemplate)
private readonly assignmentsRepository: Repository<CampaignTypeTemplate>,
private readonly smsTemplatesService: SmsTemplatesService,
) {}
async findAllGrouped(): Promise<CampaignTypeTemplate[]> {
return this.assignmentsRepository.find({
relations: { template: true },
order: { campaignType: 'ASC', sortOrder: 'ASC', createdAt: 'ASC' },
});
}
async findByCampaignType(
campaignType: CampaignType,
): Promise<CampaignTypeTemplate[]> {
getCampaignTypeDefinition(campaignType);
return this.assignmentsRepository.find({
where: { campaignType },
relations: { template: true },
order: { sortOrder: 'ASC', createdAt: 'ASC' },
});
}
async findActiveByCampaignType(
campaignType: CampaignType,
): Promise<CampaignTypeTemplate[]> {
const assignments = await this.findByCampaignType(campaignType);
return assignments.filter((assignment) => assignment.template.isActive);
}
async findDefaultForCampaignType(
campaignType: CampaignType,
): Promise<CampaignTypeTemplate | null> {
const active = await this.findActiveByCampaignType(campaignType);
return (
active.find((assignment) => assignment.isDefault) ?? active[0] ?? null
);
}
async resolveAssignment(
campaignType: CampaignType,
templateId?: string | null,
): Promise<CampaignTypeTemplate> {
if (templateId) {
const assignment = await this.assignmentsRepository.findOne({
where: { campaignType, templateId },
relations: { template: true },
});
if (!assignment) {
throw new BadRequestException(
'قالب انتخاب‌شده برای این کمپین معتبر نیست',
);
}
if (!assignment.template.isActive) {
throw new BadRequestException('قالب انتخاب‌شده غیرفعال است');
}
return assignment;
}
const fallback = await this.findDefaultForCampaignType(campaignType);
if (!fallback) {
throw new BadRequestException(
'هیچ قالب فعالی برای این کمپین تنظیم نشده است',
);
}
return fallback;
}
async assignTemplate(
dto: AssignCampaignTemplateDto,
): Promise<CampaignTypeTemplate> {
getCampaignTypeDefinition(dto.campaignType);
await this.smsTemplatesService.findOne(dto.templateId);
const existing = await this.assignmentsRepository.findOne({
where: {
campaignType: dto.campaignType,
templateId: dto.templateId,
},
});
if (existing) {
throw new BadRequestException('این قالب قبلاً به این کمپین اضافه شده است');
}
const count = await this.assignmentsRepository.count({
where: { campaignType: dto.campaignType },
});
const assignment = await this.assignmentsRepository.save(
this.assignmentsRepository.create({
campaignType: dto.campaignType,
templateId: dto.templateId,
isDefault: count === 0,
sortOrder: count,
}),
);
return this.findOne(assignment.id);
}
async setDefault(id: string): Promise<CampaignTypeTemplate> {
const assignment = await this.findOne(id);
await this.assignmentsRepository.update(
{ campaignType: assignment.campaignType },
{ isDefault: false },
);
assignment.isDefault = true;
await this.assignmentsRepository.save(assignment);
return this.findOne(id);
}
async remove(id: string): Promise<void> {
const assignment = await this.findOne(id);
const wasDefault = assignment.isDefault;
await this.assignmentsRepository.remove(assignment);
if (wasDefault) {
const next = await this.assignmentsRepository.findOne({
where: { campaignType: assignment.campaignType },
order: { sortOrder: 'ASC', createdAt: 'ASC' },
});
if (next) {
next.isDefault = true;
await this.assignmentsRepository.save(next);
}
}
}
async findOne(id: string): Promise<CampaignTypeTemplate> {
const assignment = await this.assignmentsRepository.findOne({
where: { id },
relations: { template: true },
});
if (!assignment) {
throw new NotFoundException(`Campaign template assignment #${id} not found`);
}
return assignment;
}
}
+63
View File
@@ -1,9 +1,11 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Patch,
Post, Post,
Query, Query,
UseGuards, UseGuards,
@@ -18,15 +20,20 @@ import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { PermissionCode } from '../authorization/enums/permission-code.enum'; import { PermissionCode } from '../authorization/enums/permission-code.enum';
import { import {
ApiStandardController, ApiStandardController,
ApiStandardEmptyResponse,
ApiStandardOkResponse, ApiStandardOkResponse,
} from '../swagger/decorators/swagger-response.decorator'; } from '../swagger/decorators/swagger-response.decorator';
import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup'; import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup';
import { UserRole } from '../users/enums/user-role.enum'; import { UserRole } from '../users/enums/user-role.enum';
import { CampaignAutomationService } from './campaign-automation.service'; import { CampaignAutomationService } from './campaign-automation.service';
import { CampaignTypeTemplatesService } from './campaign-type-templates.service';
import { CampaignsService } from './campaigns.service'; import { CampaignsService } from './campaigns.service';
import { AssignCampaignTemplateDto } from './dto/assign-campaign-template.dto';
import { FindCampaignRunsQueryDto } from './dto/find-campaign-runs-query.dto'; import { FindCampaignRunsQueryDto } from './dto/find-campaign-runs-query.dto';
import { PreviewCampaignTemplateQueryDto } from './dto/preview-campaign-template.dto';
import { SendCampaignDto } from './dto/send-campaign.dto'; import { SendCampaignDto } from './dto/send-campaign.dto';
import { CampaignRun } from './entities/campaign-run.entity'; import { CampaignRun } from './entities/campaign-run.entity';
import { CampaignTypeTemplate } from './entities/campaign-type-template.entity';
import { CampaignType } from './enums/campaign-type.enum'; import { CampaignType } from './enums/campaign-type.enum';
@ApiTags('Campaigns') @ApiTags('Campaigns')
@@ -38,6 +45,7 @@ export class CampaignsController {
constructor( constructor(
private readonly campaignsService: CampaignsService, private readonly campaignsService: CampaignsService,
private readonly campaignAutomationService: CampaignAutomationService, private readonly campaignAutomationService: CampaignAutomationService,
private readonly campaignTypeTemplatesService: CampaignTypeTemplatesService,
) {} ) {}
@Roles(UserRole.SALON) @Roles(UserRole.SALON)
@@ -58,6 +66,38 @@ export class CampaignsController {
return this.campaignsService.findRuns(user, query); return this.campaignsService.findRuns(user, query);
} }
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.CAMPAIGNS_READ)
@ApiStandardOkResponse(CampaignTypeTemplate, { isArray: true })
@Get('type-templates')
findAllTypeTemplates() {
return this.campaignTypeTemplatesService.findAllGrouped();
}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.CAMPAIGNS_WRITE)
@ApiStandardOkResponse(CampaignTypeTemplate)
@Post('type-templates')
assignTypeTemplate(@Body() dto: AssignCampaignTemplateDto) {
return this.campaignTypeTemplatesService.assignTemplate(dto);
}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.CAMPAIGNS_WRITE)
@ApiStandardOkResponse(CampaignTypeTemplate)
@Patch('type-templates/:id/default')
setDefaultTypeTemplate(@Param('id', ParseUUIDPipe) id: string) {
return this.campaignTypeTemplatesService.setDefault(id);
}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.CAMPAIGNS_WRITE)
@ApiStandardEmptyResponse()
@Delete('type-templates/:id')
removeTypeTemplate(@Param('id', ParseUUIDPipe) id: string) {
return this.campaignTypeTemplatesService.remove(id);
}
@Roles(UserRole.SALON, UserRole.ADMIN) @Roles(UserRole.SALON, UserRole.ADMIN)
@Permissions(PermissionCode.CAMPAIGNS_READ) @Permissions(PermissionCode.CAMPAIGNS_READ)
@ApiStandardOkResponse(CampaignRun) @ApiStandardOkResponse(CampaignRun)
@@ -80,6 +120,29 @@ export class CampaignsController {
return this.campaignsService.getRunRecipients(id, user); return this.campaignsService.getRunRecipients(id, user);
} }
@Roles(UserRole.SALON)
@ApiStandardOkResponse(CampaignTypeTemplate, { isArray: true })
@Get(':type/templates')
getTemplates(@Param('type') type: CampaignType, @CurrentUser() user: AuthUser) {
return this.campaignsService.getTemplates(type, user);
}
@Roles(UserRole.SALON)
@ApiStandardOkResponse(Object)
@Get(':type/preview')
previewTemplate(
@Param('type') type: CampaignType,
@Query() query: PreviewCampaignTemplateQueryDto,
@CurrentUser() user: AuthUser,
) {
return this.campaignsService.previewTemplate(
type,
query.templateId,
{ discountPercent: query.discountPercent, discountAmount: query.discountAmount },
user,
);
}
@Roles(UserRole.SALON) @Roles(UserRole.SALON)
@ApiStandardOkResponse(Object, { isArray: true }) @ApiStandardOkResponse(Object, { isArray: true })
@Get(':type/targets') @Get(':type/targets')
+11 -1
View File
@@ -9,19 +9,23 @@ import { Salon } from '../salons/entities/salon.entity';
import { SentMessage } from '../sent-messages/entities/sent-message.entity'; import { SentMessage } from '../sent-messages/entities/sent-message.entity';
import { SmsModule } from '../sms/sms.module'; import { SmsModule } from '../sms/sms.module';
import { SmsSettingsModule } from '../sms-settings/sms-settings.module'; import { SmsSettingsModule } from '../sms-settings/sms-settings.module';
import { SmsTemplatesModule } from '../sms-templates/sms-templates.module';
import { ServiceSuggestion } from '../suggestions/entities/service-suggestion.entity'; import { ServiceSuggestion } from '../suggestions/entities/service-suggestion.entity';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
import { CampaignAutomationService } from './campaign-automation.service'; import { CampaignAutomationService } from './campaign-automation.service';
import { CampaignTargetingService } from './campaign-targeting.service'; import { CampaignTargetingService } from './campaign-targeting.service';
import { CampaignTypeTemplatesService } from './campaign-type-templates.service';
import { CampaignsController } from './campaigns.controller'; import { CampaignsController } from './campaigns.controller';
import { CampaignsService } from './campaigns.service'; import { CampaignsService } from './campaigns.service';
import { CampaignRun } from './entities/campaign-run.entity'; import { CampaignRun } from './entities/campaign-run.entity';
import { CampaignTypeTemplate } from './entities/campaign-type-template.entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ TypeOrmModule.forFeature([
Salon, Salon,
CampaignRun, CampaignRun,
CampaignTypeTemplate,
SentMessage, SentMessage,
SalonCustomer, SalonCustomer,
Reserve, Reserve,
@@ -29,6 +33,7 @@ import { CampaignRun } from './entities/campaign-run.entity';
]), ]),
SmsModule, SmsModule,
SmsSettingsModule, SmsSettingsModule,
SmsTemplatesModule,
NotificationsModule, NotificationsModule,
SubscriptionsModule, SubscriptionsModule,
AuthorizationModule, AuthorizationModule,
@@ -39,7 +44,12 @@ import { CampaignRun } from './entities/campaign-run.entity';
CampaignsService, CampaignsService,
CampaignTargetingService, CampaignTargetingService,
CampaignAutomationService, CampaignAutomationService,
CampaignTypeTemplatesService,
],
exports: [
CampaignsService,
CampaignTargetingService,
CampaignTypeTemplatesService,
], ],
exports: [CampaignsService, CampaignTargetingService],
}) })
export class CampaignsModule {} export class CampaignsModule {}
+138 -29
View File
@@ -21,6 +21,7 @@ import {
CampaignTargetCustomer, CampaignTargetCustomer,
CampaignTargetingService, CampaignTargetingService,
} from './campaign-targeting.service'; } from './campaign-targeting.service';
import { CampaignTypeTemplatesService } from './campaign-type-templates.service';
import { import {
CAMPAIGN_TYPES, CAMPAIGN_TYPES,
getCampaignTypeDefinition, getCampaignTypeDefinition,
@@ -28,9 +29,21 @@ import {
import { FindCampaignRunsQueryDto } from './dto/find-campaign-runs-query.dto'; import { FindCampaignRunsQueryDto } from './dto/find-campaign-runs-query.dto';
import { SendCampaignDto } from './dto/send-campaign.dto'; import { SendCampaignDto } from './dto/send-campaign.dto';
import { CampaignRun } from './entities/campaign-run.entity'; import { CampaignRun } from './entities/campaign-run.entity';
import { CampaignTypeTemplate } from './entities/campaign-type-template.entity';
import { CampaignRunStatus } from './enums/campaign-run-status.enum'; import { CampaignRunStatus } from './enums/campaign-run-status.enum';
import { CampaignTrigger } from './enums/campaign-trigger.enum'; import { CampaignTrigger } from './enums/campaign-trigger.enum';
import { CampaignType } from './enums/campaign-type.enum'; import { CampaignType } from './enums/campaign-type.enum';
import { getSampleVariables } from '../sms-settings/constants/sms-scenarios.constants';
import {
buildCampaignMessageVariables,
buildCampaignPreviewVariables,
} from './utils/build-campaign-message-variables.util';
import {
CampaignDiscountFields,
applyCampaignDiscountFallback,
validateCampaignDiscountFields,
normalizeCampaignDiscountFields,
} from './utils/format-campaign-discount.util';
export interface CampaignOverviewItem { export interface CampaignOverviewItem {
type: CampaignType; type: CampaignType;
@@ -59,6 +72,7 @@ export class CampaignsService {
private readonly salonSubscriptionsService: SalonSubscriptionsService, private readonly salonSubscriptionsService: SalonSubscriptionsService,
private readonly salonNotificationsService: SalonNotificationsService, private readonly salonNotificationsService: SalonNotificationsService,
private readonly authorizationService: AuthorizationService, private readonly authorizationService: AuthorizationService,
private readonly campaignTypeTemplatesService: CampaignTypeTemplatesService,
) {} ) {}
async getOverview(user: AuthUser): Promise<CampaignOverviewItem[]> { async getOverview(user: AuthUser): Promise<CampaignOverviewItem[]> {
@@ -98,6 +112,56 @@ export class CampaignsService {
return this.targetingService.getTargetsForType(type, salon.id); return this.targetingService.getTargetsForType(type, salon.id);
} }
async getTemplates(
type: CampaignType,
user: AuthUser,
): Promise<CampaignTypeTemplate[]> {
this.ensureValidType(type);
await this.resolveSalonForUser(user);
return this.campaignTypeTemplatesService.findActiveByCampaignType(type);
}
/**
* Preview is generated with the discount the salon is currently configuring
* for this send (not stored anywhere) so they see exactly what customers
* will receive.
*/
async previewTemplate(
type: CampaignType,
templateId: string,
discountInput: { discountPercent?: number | null; discountAmount?: number | null },
user: AuthUser,
): Promise<string> {
const definition = this.ensureValidType(type);
const salon = await this.resolveSalonForUser(user);
const assignment = await this.campaignTypeTemplatesService.resolveAssignment(
type,
templateId,
);
const discount = this.resolveDiscountFields(discountInput);
const variables = buildCampaignPreviewVariables(
salon.name,
getSampleVariables(definition.scenarioKey),
discount,
);
const message = await this.smsDispatchService.resolveMessageFromTemplateId(
assignment.templateId,
variables,
);
if (!message) {
throw new BadRequestException('امکان پیش‌نمایش این قالب وجود ندارد');
}
return applyCampaignDiscountFallback(
message,
assignment.template.body,
discount,
);
}
async sendCampaign( async sendCampaign(
type: CampaignType, type: CampaignType,
dto: SendCampaignDto, dto: SendCampaignDto,
@@ -126,18 +190,28 @@ export class CampaignsService {
throw new BadRequestException('مشتری هدفی برای این کمپین یافت نشد'); throw new BadRequestException('مشتری هدفی برای این کمپین یافت نشد');
} }
const sampleMessage = await this.smsDispatchService.resolveMessage( const assignment = await this.campaignTypeTemplatesService.resolveAssignment(
definition.scenarioKey, type,
{}, dto.templateId,
);
const discount = this.resolveDiscountFields(dto);
const sampleMessage = await this.smsDispatchService.resolveMessageFromTemplateId(
assignment.templateId,
buildCampaignPreviewVariables(
salon.name,
getSampleVariables(definition.scenarioKey),
discount,
),
); );
if (!sampleMessage) { if (!sampleMessage) {
throw new BadRequestException( throw new BadRequestException('قالب انتخاب‌شده فعال نیست');
'متن پیامک این کمپین در تنظیمات پیامک ادمین فعال نشده است',
);
} }
const smsParts = await this.calculateCampaignSmsParts( const smsParts = await this.calculateCampaignSmsParts(
definition.scenarioKey, assignment.templateId,
assignment.template.body,
discount,
salon, salon,
targets, targets,
); );
@@ -149,6 +223,9 @@ export class CampaignsService {
trigger: CampaignTrigger.MANUAL, trigger: CampaignTrigger.MANUAL,
targets, targets,
triggeredByUserId: user.id, triggeredByUserId: user.id,
templateId: assignment.templateId,
discountPercent: discount.discountPercent,
discountAmount: discount.discountAmount,
}); });
return this.findRun(run.id, user); return this.findRun(run.id, user);
@@ -158,6 +235,7 @@ export class CampaignsService {
* Creates a campaign run and dispatches SMS to every target. Used by both the * Creates a campaign run and dispatches SMS to every target. Used by both the
* manual send endpoint and the automatic (cron-driven) campaigns. SMS balance * manual send endpoint and the automatic (cron-driven) campaigns. SMS balance
* must already have been consumed by the caller before invoking this method. * must already have been consumed by the caller before invoking this method.
* Automatic campaigns have no salon input, so they never carry a discount.
*/ */
async runCampaignForSalon(params: { async runCampaignForSalon(params: {
salon: Salon; salon: Salon;
@@ -165,8 +243,15 @@ export class CampaignsService {
trigger: CampaignTrigger; trigger: CampaignTrigger;
targets: CampaignTargetCustomer[]; targets: CampaignTargetCustomer[];
triggeredByUserId?: string | null; triggeredByUserId?: string | null;
templateId?: string | null;
discountPercent?: number | null;
discountAmount?: number | null;
}): Promise<CampaignRun> { }): Promise<CampaignRun> {
const definition = getCampaignTypeDefinition(params.type); const assignment = await this.campaignTypeTemplatesService.resolveAssignment(
params.type,
params.templateId,
);
const discount = this.resolveDiscountFields(params);
const run = await this.campaignRunsRepository.save( const run = await this.campaignRunsRepository.save(
this.campaignRunsRepository.create({ this.campaignRunsRepository.create({
@@ -179,13 +264,18 @@ export class CampaignsService {
failedCount: 0, failedCount: 0,
estimatedRevenue: this.sumEstimatedRevenue(params.targets), estimatedRevenue: this.sumEstimatedRevenue(params.targets),
triggeredByUserId: params.triggeredByUserId ?? null, triggeredByUserId: params.triggeredByUserId ?? null,
templateId: assignment.templateId,
discountPercent: discount.discountPercent,
discountAmount: discount.discountAmount,
}), }),
); );
await this.dispatchToTargets( await this.dispatchToTargets(
run, run,
params.salon, params.salon,
definition.scenarioKey, assignment.templateId,
assignment.template.body,
discount,
params.targets, params.targets,
); );
@@ -228,27 +318,29 @@ export class CampaignsService {
} }
async calculateCampaignSmsParts( async calculateCampaignSmsParts(
scenarioKey: (typeof CAMPAIGN_TYPES)[number]['scenarioKey'], templateId: string,
templateBody: string,
discount: CampaignDiscountFields,
salon: Salon, salon: Salon,
targets: CampaignTargetCustomer[], targets: CampaignTargetCustomer[],
): Promise<number> { ): Promise<number> {
let totalParts = 0; let totalParts = 0;
for (const target of targets) { for (const target of targets) {
const variables: Record<string, string> = { const variables = buildCampaignMessageVariables(salon, target, discount);
customerName:
`${target.firstName} ${target.lastName}`.trim() || 'مشتری',
salonName: salon.name,
...target.meta,
};
const message = await this.smsDispatchService.resolveMessage( const message = await this.smsDispatchService.resolveMessageFromTemplateId(
scenarioKey, templateId,
variables, variables,
); );
if (message) { if (message) {
totalParts += calculateSmsParts(message); const finalMessage = applyCampaignDiscountFallback(
message,
templateBody,
discount,
);
totalParts += calculateSmsParts(finalMessage);
} }
} }
@@ -287,7 +379,7 @@ export class CampaignsService {
async findRun(id: string, user: AuthUser): Promise<CampaignRun> { async findRun(id: string, user: AuthUser): Promise<CampaignRun> {
const run = await this.campaignRunsRepository.findOne({ const run = await this.campaignRunsRepository.findOne({
where: { id }, where: { id },
relations: { salon: true, triggeredByUser: true }, relations: { salon: true, triggeredByUser: true, template: true },
}); });
if (!run) { if (!run) {
throw new NotFoundException(`Campaign run #${id} not found`); throw new NotFoundException(`Campaign run #${id} not found`);
@@ -305,27 +397,31 @@ export class CampaignsService {
}); });
} }
async getDefaultTemplateForType(type: CampaignType) {
return this.campaignTypeTemplatesService.findDefaultForCampaignType(type);
}
private async dispatchToTargets( private async dispatchToTargets(
run: CampaignRun, run: CampaignRun,
salon: Salon, salon: Salon,
scenarioKey: (typeof CAMPAIGN_TYPES)[number]['scenarioKey'], templateId: string,
templateBody: string,
discount: CampaignDiscountFields,
targets: CampaignTargetCustomer[], targets: CampaignTargetCustomer[],
): Promise<void> { ): Promise<void> {
let sentCount = 0; let sentCount = 0;
let failedCount = 0; let failedCount = 0;
for (const target of targets) { for (const target of targets) {
const variables: Record<string, string> = { const variables = buildCampaignMessageVariables(salon, target, discount);
customerName:
`${target.firstName} ${target.lastName}`.trim() || 'مشتری',
salonName: salon.name,
...target.meta,
};
const message = await this.smsDispatchService.resolveMessage( const rawMessage = await this.smsDispatchService.resolveMessageFromTemplateId(
scenarioKey, templateId,
variables, variables,
); );
const message = rawMessage
? applyCampaignDiscountFallback(rawMessage, templateBody, discount)
: rawMessage;
let status = SentMessageStatus.FAILED; let status = SentMessageStatus.FAILED;
if (message) { if (message) {
@@ -374,6 +470,19 @@ export class CampaignsService {
}); });
} }
private resolveDiscountFields(input: {
discountPercent?: number | null;
discountAmount?: number | null;
}): CampaignDiscountFields {
try {
validateCampaignDiscountFields(input);
} catch (error) {
throw new BadRequestException((error as Error).message);
}
return normalizeCampaignDiscountFields(input);
}
private ensureValidType(type: CampaignType) { private ensureValidType(type: CampaignType) {
try { try {
return getCampaignTypeDefinition(type); return getCampaignTypeDefinition(type);
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsUUID } from 'class-validator';
import { CampaignType } from '../enums/campaign-type.enum';
export class AssignCampaignTemplateDto {
@ApiProperty({ enum: CampaignType })
@IsEnum(CampaignType)
campaignType: CampaignType;
@ApiProperty()
@IsUUID('4')
templateId: string;
}
@@ -0,0 +1,29 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsNumber,
IsOptional,
Max,
Min,
ValidateIf,
} from 'class-validator';
export class CampaignDiscountFieldsDto {
@ApiPropertyOptional({ description: 'Discount percentage (1100)' })
@IsOptional()
@ValidateIf((_, value) => value != null)
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
discountPercent?: number | null;
@ApiPropertyOptional({ description: 'Fixed discount amount in tomans' })
@IsOptional()
@ValidateIf((_, value) => value != null)
@Type(() => Number)
@IsNumber()
@Min(1)
discountAmount?: number | null;
}
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
import { CampaignDiscountFieldsDto } from './campaign-discount-fields.dto';
export class PreviewCampaignTemplateQueryDto extends CampaignDiscountFieldsDto {
@ApiProperty()
@IsUUID('4')
templateId: string;
}
+9 -2
View File
@@ -1,7 +1,14 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsOptional, IsUUID } from 'class-validator'; import { ArrayMinSize, IsArray, IsOptional, IsUUID } from 'class-validator';
import { CampaignDiscountFieldsDto } from './campaign-discount-fields.dto';
export class SendCampaignDto extends CampaignDiscountFieldsDto {
@ApiProperty({
description: 'Selected SMS template id for this campaign run.',
})
@IsUUID('4')
templateId: string;
export class SendCampaignDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'Optional subset of currently targeted customer ids to send to. When omitted, all current targets receive the campaign.', 'Optional subset of currently targeted customer ids to send to. When omitted, all current targets receive the campaign.',
@@ -9,6 +9,7 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { Salon } from '../../salons/entities/salon.entity'; import { Salon } from '../../salons/entities/salon.entity';
import { SmsTemplate } from '../../sms-templates/entities/sms-template.entity';
import { User } from '../../users/entities/user.entity'; import { User } from '../../users/entities/user.entity';
import { CampaignRunStatus } from '../enums/campaign-run-status.enum'; import { CampaignRunStatus } from '../enums/campaign-run-status.enum';
import { CampaignTrigger } from '../enums/campaign-trigger.enum'; import { CampaignTrigger } from '../enums/campaign-trigger.enum';
@@ -77,6 +78,29 @@ export class CampaignRun {
@JoinColumn({ name: 'triggeredByUserId' }) @JoinColumn({ name: 'triggeredByUserId' })
triggeredByUser: User | null; triggeredByUser: User | null;
@Column({ type: 'uuid', nullable: true })
templateId: string | null;
@ManyToOne(() => SmsTemplate, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'templateId' })
template: SmsTemplate | null;
@Column({ type: 'int', nullable: true })
discountPercent: number | null;
@Column({
type: 'decimal',
precision: 14,
scale: 2,
nullable: true,
transformer: {
to: (value: number | null) => value,
from: (value: string | null) =>
value == null ? null : parseFloat(value),
},
})
discountAmount: number | null;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
@@ -0,0 +1,46 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { SmsTemplate } from '../../sms-templates/entities/sms-template.entity';
import { CampaignType } from '../enums/campaign-type.enum';
@Entity('campaign_type_templates')
@Index(['campaignType', 'templateId'], { unique: true })
@Index(['campaignType', 'sortOrder'])
export class CampaignTypeTemplate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
type: 'enum',
enum: CampaignType,
enumName: 'campaign_type_enum',
})
campaignType: CampaignType;
@Column()
templateId: string;
@ManyToOne(() => SmsTemplate, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'templateId' })
template: SmsTemplate;
@Column({ default: false })
isDefault: boolean;
@Column({ type: 'int', default: 0 })
sortOrder: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,29 @@
import { Salon } from '../../salons/entities/salon.entity';
import { CampaignTargetCustomer } from '../campaign-targeting.service';
import { CampaignDiscountFields, formatCampaignDiscount } from './format-campaign-discount.util';
export function buildCampaignMessageVariables(
salon: Salon,
target: CampaignTargetCustomer,
discount: CampaignDiscountFields,
): Record<string, string> {
return {
customerName:
`${target.firstName} ${target.lastName}`.trim() || 'مشتری',
salonName: salon.name,
discount: formatCampaignDiscount(discount),
...target.meta,
};
}
export function buildCampaignPreviewVariables(
salonName: string,
sampleVariables: Record<string, string>,
discount: CampaignDiscountFields,
): Record<string, string> {
return {
...sampleVariables,
salonName,
discount: formatCampaignDiscount(discount),
};
}
@@ -0,0 +1,77 @@
export interface CampaignDiscountFields {
discountPercent: number | null;
discountAmount: number | null;
}
export function formatCampaignDiscount(
fields: CampaignDiscountFields,
): string {
if (fields.discountPercent != null && fields.discountPercent > 0) {
return `${fields.discountPercent.toLocaleString('fa-IR')}٪ تخفیف`;
}
if (fields.discountAmount != null && fields.discountAmount > 0) {
return `${Math.round(fields.discountAmount).toLocaleString('fa-IR')} تومان تخفیف`;
}
return '';
}
export function validateCampaignDiscountFields(fields: {
discountPercent?: number | null;
discountAmount?: number | null;
}): void {
const hasPercent =
fields.discountPercent != null && fields.discountPercent > 0;
const hasAmount = fields.discountAmount != null && fields.discountAmount > 0;
if (hasPercent && hasAmount) {
throw new Error('فقط یک نوع تخفیف می‌تواند تنظیم شود');
}
if (
fields.discountPercent != null &&
(fields.discountPercent < 1 || fields.discountPercent > 100)
) {
throw new Error('درصد تخفیف باید بین ۱ تا ۱۰۰ باشد');
}
if (fields.discountAmount != null && fields.discountAmount <= 0) {
throw new Error('مبلغ تخفیف باید بیشتر از صفر باشد');
}
}
export function normalizeCampaignDiscountFields(fields: {
discountPercent?: number | null;
discountAmount?: number | null;
}): CampaignDiscountFields {
return {
discountPercent:
fields.discountPercent != null && fields.discountPercent > 0
? fields.discountPercent
: null,
discountAmount:
fields.discountAmount != null && fields.discountAmount > 0
? fields.discountAmount
: null,
};
}
/**
* Templates aren't required to contain a "{discount}" placeholder. If the
* salon set a discount for this send but the chosen template's raw body has
* no placeholder for it, append it to the end of the message so the
* discount the salon configured is never silently dropped.
*/
export function applyCampaignDiscountFallback(
message: string,
templateBody: string,
discount: CampaignDiscountFields,
): string {
const label = formatCampaignDiscount(discount);
if (!label || templateBody.includes('{discount}')) {
return message;
}
return `${message}\n\n${label}`;
}
+1
View File
@@ -13,5 +13,6 @@ export default new DataSource({
database: process.env.DB_NAME, database: process.env.DB_NAME,
entities: [join(__dirname, '../**/*.entity.{js,ts}')], entities: [join(__dirname, '../**/*.entity.{js,ts}')],
migrations: [join(__dirname, '/migrations/*.{js,ts}')], migrations: [join(__dirname, '/migrations/*.{js,ts}')],
migrationsTransactionMode: 'each',
...(isCompiled ? {} : { requireTsNode: true }), ...(isCompiled ? {} : { requireTsNode: true }),
}); });
@@ -6,6 +6,16 @@ import {
TableIndex, TableIndex,
} from 'typeorm'; } from 'typeorm';
import { SMS_SCENARIOS } from '../../sms-settings/constants/sms-scenarios.constants'; import { SMS_SCENARIOS } from '../../sms-settings/constants/sms-scenarios.constants';
import { SmsScenarioKey } from '../../sms-settings/enums/sms-scenario-key.enum';
/** Keys that exist on the enum created in this migration (later keys are seeded elsewhere). */
const INITIAL_SCENARIO_KEYS = [
SmsScenarioKey.OTP_LOGIN,
SmsScenarioKey.RESERVE_CREATED,
SmsScenarioKey.RESERVE_REMINDER,
SmsScenarioKey.RESERVE_CONFIRMED,
SmsScenarioKey.RESERVE_CANCELLED,
];
export class AddSmsScenarioSettings1721200000000 implements MigrationInterface { export class AddSmsScenarioSettings1721200000000 implements MigrationInterface {
name = 'AddSmsScenarioSettings1721200000000'; name = 'AddSmsScenarioSettings1721200000000';
@@ -67,11 +77,17 @@ export class AddSmsScenarioSettings1721200000000 implements MigrationInterface {
}), }),
); );
for (const scenario of SMS_SCENARIOS) { for (const key of INITIAL_SCENARIO_KEYS) {
const scenario = SMS_SCENARIOS.find((item) => item.key === key);
if (!scenario) {
continue;
}
await queryRunner.query( await queryRunner.query(
` `
INSERT INTO sms_scenario_settings (key, body, "isActive") INSERT INTO sms_scenario_settings (key, body, "isActive")
VALUES ($1, $2, true) VALUES ($1, $2, true)
ON CONFLICT (key) DO NOTHING
`, `,
[scenario.key, scenario.defaultBody], [scenario.key, scenario.defaultBody],
); );
@@ -6,6 +6,17 @@ import {
TableIndex, TableIndex,
} from 'typeorm'; } from 'typeorm';
import { NOTIFICATION_SCENARIOS } from '../../notifications/constants/notification-scenarios.constants'; import { NOTIFICATION_SCENARIOS } from '../../notifications/constants/notification-scenarios.constants';
import { NotificationScenarioKey } from '../../notifications/enums/notification-scenario-key.enum';
/** Keys that exist on the enum created in this migration (deposit key is seeded later). */
const INITIAL_NOTIFICATION_KEYS = [
NotificationScenarioKey.RESERVE_CREATED,
NotificationScenarioKey.RESERVE_CONFIRMED,
NotificationScenarioKey.RESERVE_CANCELLED,
NotificationScenarioKey.RESERVE_REMINDER_SENT,
NotificationScenarioKey.CAMPAIGN_SENT,
NotificationScenarioKey.CAMPAIGN_FAILED,
];
export class AddNotifications1721500000000 implements MigrationInterface { export class AddNotifications1721500000000 implements MigrationInterface {
name = 'AddNotifications1721500000000'; name = 'AddNotifications1721500000000';
@@ -142,11 +153,17 @@ export class AddNotifications1721500000000 implements MigrationInterface {
}), }),
); );
for (const scenario of NOTIFICATION_SCENARIOS) { for (const key of INITIAL_NOTIFICATION_KEYS) {
const scenario = NOTIFICATION_SCENARIOS.find((item) => item.key === key);
if (!scenario) {
continue;
}
await queryRunner.query( await queryRunner.query(
` `
INSERT INTO notification_scenario_settings (key, title, body, "isActive") INSERT INTO notification_scenario_settings (key, title, body, "isActive")
VALUES ($1, $2, $3, true) VALUES ($1, $2, $3, true)
ON CONFLICT (key) DO NOTHING
`, `,
[scenario.key, scenario.defaultTitle, scenario.defaultBody], [scenario.key, scenario.defaultTitle, scenario.defaultBody],
); );
@@ -15,9 +15,11 @@ const NEW_NOTIFICATION_KEYS = [NotificationScenarioKey.DEPOSIT_RECEIPT_SUBMITTED
* Only adds the new enum values. Postgres forbids using a newly added enum * Only adds the new enum values. Postgres forbids using a newly added enum
* value in the same transaction it was created in, so seeding the actual * value in the same transaction it was created in, so seeding the actual
* scenario rows is handled by the follow-up `SeedDepositScenarios` migration. * scenario rows is handled by the follow-up `SeedDepositScenarios` migration.
* `transaction = false` commits ADD VALUE immediately (required by Postgres).
*/ */
export class AddDepositScenarios1722000000000 implements MigrationInterface { export class AddDepositScenarios1722000000000 implements MigrationInterface {
name = 'AddDepositScenarios1722000000000'; name = 'AddDepositScenarios1722000000000';
transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
for (const key of NEW_SMS_KEYS) { for (const key of NEW_SMS_KEYS) {
@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddUserAvatarUrl1722200000000 implements MigrationInterface {
name = 'AddUserAvatarUrl1722200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn(
'users',
new TableColumn({
name: 'avatarUrl',
type: 'varchar',
length: '500',
isNullable: true,
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('users', 'avatarUrl');
}
}
@@ -0,0 +1,157 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableForeignKey,
TableIndex,
} from 'typeorm';
import { CAMPAIGN_TYPES } from '../../campaigns/constants/campaign-types.constants';
import { CampaignType } from '../../campaigns/enums/campaign-type.enum';
import { SMS_SCENARIOS } from '../../sms-settings/constants/sms-scenarios.constants';
export class AddCampaignTypeTemplates1722300000000
implements MigrationInterface
{
name = 'AddCampaignTypeTemplates1722300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await this.createCampaignTypeTemplatesTable(queryRunner);
await this.addTemplateIdToCampaignRuns(queryRunner);
await this.seedCampaignTypeTemplates(queryRunner);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "campaign_runs" DROP CONSTRAINT IF EXISTS "FK_campaign_runs_templateId"`,
);
await queryRunner.query(
`ALTER TABLE "campaign_runs" DROP COLUMN IF EXISTS "templateId"`,
);
await queryRunner.dropTable('campaign_type_templates', true);
}
private async createCampaignTypeTemplatesTable(
queryRunner: QueryRunner,
): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'campaign_type_templates',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'gen_random_uuid()',
},
{ name: 'campaignType', type: 'campaign_type_enum' },
{ name: 'templateId', type: 'uuid' },
{ name: 'isDefault', type: 'boolean', default: false },
{ name: 'sortOrder', type: 'int', default: 0 },
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
],
}),
true,
);
await queryRunner.createForeignKey(
'campaign_type_templates',
new TableForeignKey({
columnNames: ['templateId'],
referencedTableName: 'sms_templates',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createIndex(
'campaign_type_templates',
new TableIndex({
name: 'IDX_campaign_type_templates_type_template',
columnNames: ['campaignType', 'templateId'],
isUnique: true,
}),
);
await queryRunner.createIndex(
'campaign_type_templates',
new TableIndex({
name: 'IDX_campaign_type_templates_type_sort',
columnNames: ['campaignType', 'sortOrder'],
}),
);
}
private async addTemplateIdToCampaignRuns(
queryRunner: QueryRunner,
): Promise<void> {
await queryRunner.query(
`ALTER TABLE "campaign_runs" ADD COLUMN "templateId" uuid NULL`,
);
await queryRunner.createForeignKey(
'campaign_runs',
new TableForeignKey({
name: 'FK_campaign_runs_templateId',
columnNames: ['templateId'],
referencedTableName: 'sms_templates',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
}
private async seedCampaignTypeTemplates(
queryRunner: QueryRunner,
): Promise<void> {
for (const definition of CAMPAIGN_TYPES) {
const existing = await queryRunner.query(
`SELECT id FROM campaign_type_templates WHERE "campaignType" = $1 LIMIT 1`,
[definition.type],
);
if (existing.length > 0) {
continue;
}
const settingRows = await queryRunner.query(
`SELECT body, "templateId" FROM sms_scenario_settings WHERE key = $1 LIMIT 1`,
[definition.scenarioKey],
);
const setting = settingRows[0] as
| { body: string | null; templateId: string | null }
| undefined;
let templateId: string | null = setting?.templateId ?? null;
if (!templateId) {
const scenario = SMS_SCENARIOS.find(
(item) => item.key === definition.scenarioKey,
);
const body = setting?.body ?? scenario?.defaultBody;
if (!body) {
continue;
}
const inserted = await queryRunner.query(
`
INSERT INTO sms_templates (title, body, "isActive")
VALUES ($1, $2, true)
RETURNING id
`,
[`${definition.label} (پیش‌فرض)`, body],
);
templateId = inserted[0].id as string;
}
await queryRunner.query(
`
INSERT INTO campaign_type_templates ("campaignType", "templateId", "isDefault", "sortOrder")
VALUES ($1, $2, true, 0)
ON CONFLICT DO NOTHING
`,
[definition.type, templateId],
);
}
}
}
@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
/**
* Discount is chosen by the salon at send time, not fixed per admin
* template — so these columns live only on campaign_runs, as a snapshot of
* what was actually used for that specific send.
*/
export class AddCampaignTemplateDiscount1722400000000
implements MigrationInterface
{
name = 'AddCampaignTemplateDiscount1722400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumns('campaign_runs', [
new TableColumn({
name: 'discountPercent',
type: 'int',
isNullable: true,
}),
new TableColumn({
name: 'discountAmount',
type: 'decimal',
precision: 14,
scale: 2,
isNullable: true,
}),
]);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('campaign_runs', 'discountAmount');
await queryRunner.dropColumn('campaign_runs', 'discountPercent');
}
}
+3
View File
@@ -15,5 +15,8 @@ export function getTypeOrmOptions(): TypeOrmModuleOptions {
// by both reserves and reserve_status_history). Use migrations instead. // by both reserves and reserve_status_history). Use migrations instead.
synchronize: false, synchronize: false,
migrationsRun: true, migrationsRun: true,
// Each migration commits separately so Postgres enum ADD VALUE can be
// used by a follow-up seed migration (unsafe in the same transaction).
migrationsTransactionMode: 'each',
}; };
} }
+7 -2
View File
@@ -37,6 +37,11 @@ async function bootstrap() {
app.enableCors(); app.enableCors();
} }
await app.listen(process.env.PORT ?? 3000); const port = Number(process.env.PORT ?? 4000);
await app.listen(port, '0.0.0.0');
console.log(`grow-api listening on 0.0.0.0:${port}`);
} }
bootstrap(); bootstrap().catch((error) => {
console.error('Failed to start grow-api:', error);
process.exit(1);
});
@@ -27,6 +27,8 @@ import { SalonSubscriptionsService } from '../subscriptions/salon-subscriptions.
import { UploaderService } from '../uploader/uploader.service'; import { UploaderService } from '../uploader/uploader.service';
import { UserRole } from '../users/enums/user-role.enum'; import { UserRole } from '../users/enums/user-role.enum';
import { ShortLinksService } from '../short-links/short-links.service'; import { ShortLinksService } from '../short-links/short-links.service';
import { DepositListItemDto } from './dto/deposit-list-item.dto';
import { FindDepositsQueryDto } from './dto/find-deposits-query.dto';
import { PublicDepositPaymentDto } from './dto/public-deposit-payment.dto'; import { PublicDepositPaymentDto } from './dto/public-deposit-payment.dto';
import { ReviewDepositDto } from './dto/review-deposit.dto'; import { ReviewDepositDto } from './dto/review-deposit.dto';
import { Payment } from './entities/payment.entity'; import { Payment } from './entities/payment.entity';
@@ -35,6 +37,8 @@ import { PaymentStatus } from './enums/payment-status.enum';
import { PaymentType } from './enums/payment-type.enum'; import { PaymentType } from './enums/payment-type.enum';
const MIN_AMOUNT_TOMAN = 100; const MIN_AMOUNT_TOMAN = 100;
/** Gives the reserve-created SMS time to reach the customer before the deposit link SMS. */
const DEPOSIT_SMS_DELAY_MS = 2_500;
@Injectable() @Injectable()
export class CardToCardDepositService { export class CardToCardDepositService {
@@ -146,6 +150,8 @@ export class CardToCardDepositService {
const variables = buildReserveSmsVariables(reserve); const variables = buildReserveSmsVariables(reserve);
const mobile = reserve.customer?.user?.mobile; const mobile = reserve.customer?.user?.mobile;
if (mobile) { if (mobile) {
await this.delay(DEPOSIT_SMS_DELAY_MS);
await this.sendDepositSms( await this.sendDepositSms(
SmsScenarioKey.DEPOSIT_PAYMENT_REQUEST, SmsScenarioKey.DEPOSIT_PAYMENT_REQUEST,
mobile, mobile,
@@ -249,6 +255,43 @@ export class CardToCardDepositService {
return this.getPublicPaymentByToken(token); return this.getPublicPaymentByToken(token);
} }
async findForSalon(
user: AuthUser,
query: FindDepositsQueryDto,
): Promise<DepositListItemDto[]> {
if (user.role !== UserRole.SALON && user.role !== UserRole.ADMIN) {
throw new ForbiddenException();
}
const qb = this.paymentsRepository
.createQueryBuilder('payment')
.innerJoinAndSelect('payment.reserve', 'reserve')
.innerJoinAndSelect('reserve.salon', 'salon')
.leftJoinAndSelect('reserve.customer', 'customer')
.leftJoinAndSelect('customer.user', 'customerUser')
.where('payment.type = :type', { type: PaymentType.DEPOSIT })
.andWhere('payment.method = :method', {
method: PaymentMethod.CARD_TO_CARD,
})
.orderBy('payment.createdAt', 'DESC');
if (query.status) {
qb.andWhere('payment.status = :status', { status: query.status });
}
if (!this.authorizationService.isAdmin(user)) {
qb.andWhere('salon.ownerId = :ownerId', { ownerId: user.id });
}
const limit = query.limit ?? 50;
const offset = query.offset ?? 0;
qb.take(limit).skip(offset);
const payments = await qb.getMany();
return payments.map((payment) => this.toDepositListItem(payment));
}
async findForReview(paymentId: string, user: AuthUser): Promise<Payment> { async findForReview(paymentId: string, user: AuthUser): Promise<Payment> {
const payment = await this.loadPaymentWithRelations(paymentId); const payment = await this.loadPaymentWithRelations(paymentId);
this.assertCanReview(user, payment); this.assertCanReview(user, payment);
@@ -376,6 +419,10 @@ export class CardToCardDepositService {
return this.smsService.sendSms(mobile, message); return this.smsService.sendSms(mobile, message);
} }
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private tomanToRial(amountToman: number): number { private tomanToRial(amountToman: number): number {
return Math.round(amountToman * 10); return Math.round(amountToman * 10);
} }
@@ -383,4 +430,23 @@ export class CardToCardDepositService {
private rialToToman(amountRial: number): number { private rialToToman(amountRial: number): number {
return Math.round(amountRial / 10); return Math.round(amountRial / 10);
} }
private toDepositListItem(payment: Payment): DepositListItemDto {
const user = payment.reserve?.customer?.user;
const customerName = user
? `${user.firstName} ${user.lastName}`.trim()
: 'مشتری';
return {
id: payment.id,
status: payment.status,
amountToman: this.rialToToman(payment.amountRial),
receiptUrl: payment.receiptUrl,
createdAt: payment.createdAt,
customerName,
customerMobile: user?.mobile ?? null,
appointmentDate: payment.reserve?.appointmentDate ?? null,
startTime: payment.reserve?.startTime ?? null,
};
}
} }
+15
View File
@@ -5,6 +5,7 @@ import {
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Post, Post,
Query,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@@ -25,6 +26,8 @@ import { ApiPublicEndpoint } from '../swagger/decorators/swagger-auth.decorator'
import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup'; import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup';
import { UserRole } from '../users/enums/user-role.enum'; import { UserRole } from '../users/enums/user-role.enum';
import { CardToCardDepositService } from './card-to-card-deposit.service'; import { CardToCardDepositService } from './card-to-card-deposit.service';
import { DepositListItemDto } from './dto/deposit-list-item.dto';
import { FindDepositsQueryDto } from './dto/find-deposits-query.dto';
import { PublicDepositPaymentDto } from './dto/public-deposit-payment.dto'; import { PublicDepositPaymentDto } from './dto/public-deposit-payment.dto';
import { ReviewDepositDto } from './dto/review-deposit.dto'; import { ReviewDepositDto } from './dto/review-deposit.dto';
import { Payment } from './entities/payment.entity'; import { Payment } from './entities/payment.entity';
@@ -84,6 +87,18 @@ export class DepositsController {
return this.cardToCardDepositService.submitReceipt(token, file); return this.cardToCardDepositService.submitReceipt(token, file);
} }
@ApiBearerAuth(SWAGGER_JWT_AUTH)
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SALON)
@ApiStandardOkResponse(DepositListItemDto, { isArray: true })
@Get()
findAll(
@Query() query: FindDepositsQueryDto,
@CurrentUser() user: AuthUser,
) {
return this.cardToCardDepositService.findForSalon(user, query);
}
@ApiBearerAuth(SWAGGER_JWT_AUTH) @ApiBearerAuth(SWAGGER_JWT_AUTH)
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SALON) @Roles(UserRole.ADMIN, UserRole.SALON)
+31
View File
@@ -0,0 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
import { PaymentStatus } from '../enums/payment-status.enum';
export class DepositListItemDto {
@ApiProperty()
id: string;
@ApiProperty({ enum: PaymentStatus })
status: PaymentStatus;
@ApiProperty({ example: 500000 })
amountToman: number;
@ApiProperty({ required: false, nullable: true })
receiptUrl: string | null;
@ApiProperty()
createdAt: Date;
@ApiProperty({ example: 'مریم احمدی' })
customerName: string;
@ApiProperty({ required: false, nullable: true, example: '09121234567' })
customerMobile: string | null;
@ApiProperty({ required: false, nullable: true })
appointmentDate: string | null;
@ApiProperty({ required: false, nullable: true })
startTime: string | null;
}
@@ -0,0 +1,22 @@
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
import { PaymentStatus } from '../enums/payment-status.enum';
export class FindDepositsQueryDto {
@IsOptional()
@IsEnum(PaymentStatus)
status?: PaymentStatus;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
}
@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { ServiceSkill } from '../entities/service-skill.entity';
export class ImportServiceSkillRowErrorDto {
@ApiProperty({ example: 3 })
row: number;
@ApiProperty({ example: 'عنوان مهارت الزامی است' })
message: string;
}
export class ImportServiceSkillsResultDto {
@ApiProperty({ example: 5 })
created: number;
@ApiProperty({ example: 1 })
failed: number;
@ApiProperty({ type: [ServiceSkill] })
skills: ServiceSkill[];
@ApiProperty({ type: [ImportServiceSkillRowErrorDto] })
errors: ImportServiceSkillRowErrorDto[];
}
+66 -1
View File
@@ -8,9 +8,21 @@ import {
Patch, Patch,
Post, Post,
Query, Query,
Res,
UploadedFile,
UseGuards, UseGuards,
UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiProduces,
ApiTags,
} from '@nestjs/swagger';
import type { Response } from 'express';
import { memoryStorage } from 'multer';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Public } from '../auth/decorators/public.decorator'; import { Public } from '../auth/decorators/public.decorator';
import { Permissions } from '../auth/decorators/permissions.decorator'; import { Permissions } from '../auth/decorators/permissions.decorator';
@@ -22,6 +34,7 @@ import { PermissionCode } from '../authorization/enums/permission-code.enum';
import { UserRole } from '../users/enums/user-role.enum'; import { UserRole } from '../users/enums/user-role.enum';
import { CreateServiceSkillDto } from './dto/create-service-skill.dto'; import { CreateServiceSkillDto } from './dto/create-service-skill.dto';
import { CreateServiceDto } from './dto/create-service.dto'; import { CreateServiceDto } from './dto/create-service.dto';
import { ImportServiceSkillsResultDto } from './dto/import-service-skills-result.dto';
import { import {
CreateSalonServiceDto, CreateSalonServiceDto,
FindSalonServicesQueryDto, FindSalonServicesQueryDto,
@@ -39,6 +52,8 @@ import {
import { ServiceSkill } from './entities/service-skill.entity'; import { ServiceSkill } from './entities/service-skill.entity';
import { Service } from './entities/service.entity'; import { Service } from './entities/service.entity';
const MAX_EXCEL_SIZE_BYTES = 5 * 1024 * 1024;
@ApiTags('Services') @ApiTags('Services')
@ApiBearerAuth(SWAGGER_JWT_AUTH) @ApiBearerAuth(SWAGGER_JWT_AUTH)
@ApiStandardController() @ApiStandardController()
@@ -99,6 +114,24 @@ export class ServicesController {
return this.servicesService.removeServiceForSalon(serviceId, user); return this.servicesService.removeServiceForSalon(serviceId, user);
} }
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.SERVICES_WRITE)
@ApiProduces(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
)
@Get('skills/import-template')
async downloadSkillsImportTemplate(@Res() res: Response) {
const buffer = await this.servicesService.buildSkillsImportTemplate();
res.set({
'Content-Type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition':
'attachment; filename="service-skills-import-template.xlsx"',
'Content-Length': buffer.length,
});
res.send(buffer);
}
@Public() @Public()
@ApiPublicEndpoint('Get service by ID') @ApiPublicEndpoint('Get service by ID')
@ApiStandardOkResponse(Service) @ApiStandardOkResponse(Service)
@@ -146,6 +179,38 @@ export class ServicesController {
); );
} }
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.SERVICES_WRITE)
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
required: ['file'],
properties: {
file: {
type: 'string',
format: 'binary',
description: 'Excel file (.xlsx) with skill title and renewalDays columns',
},
},
},
})
@ApiStandardOkResponse(ImportServiceSkillsResultDto)
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: MAX_EXCEL_SIZE_BYTES },
}),
)
@Post(':serviceId/skills/import')
importSkills(
@Param('serviceId', ParseUUIDPipe) serviceId: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUser,
) {
return this.servicesService.importSkillsFromExcel(serviceId, file, user);
}
@Public() @Public()
@ApiPublicEndpoint('List skills for a service') @ApiPublicEndpoint('List skills for a service')
@ApiStandardOkResponse(ServiceSkill, { isArray: true }) @ApiStandardOkResponse(ServiceSkill, { isArray: true })
+225
View File
@@ -6,6 +6,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import ExcelJS from 'exceljs';
import { In, Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import { ServicePolicy } from '../authorization/policies/service.policy'; import { ServicePolicy } from '../authorization/policies/service.policy';
import { AuthUser } from '../auth/interfaces/auth-user.interface'; import { AuthUser } from '../auth/interfaces/auth-user.interface';
@@ -13,6 +14,7 @@ import { Salon } from '../salons/entities/salon.entity';
import { UserRole } from '../users/enums/user-role.enum'; import { UserRole } from '../users/enums/user-role.enum';
import { CreateServiceSkillDto } from './dto/create-service-skill.dto'; import { CreateServiceSkillDto } from './dto/create-service-skill.dto';
import { CreateServiceDto } from './dto/create-service.dto'; import { CreateServiceDto } from './dto/create-service.dto';
import { ImportServiceSkillsResultDto } from './dto/import-service-skills-result.dto';
import { import {
CreateSalonServiceDto, CreateSalonServiceDto,
FindSalonServicesQueryDto, FindSalonServicesQueryDto,
@@ -23,6 +25,27 @@ import { SalonServiceOffering } from './entities/salon-service-offering.entity';
import { ServiceSkill } from './entities/service-skill.entity'; import { ServiceSkill } from './entities/service-skill.entity';
import { Service } from './entities/service.entity'; import { Service } from './entities/service.entity';
const TITLE_HEADERS = new Set(['عنوان مهارت', 'title', 'عنوان']);
const RENEWAL_HEADERS = new Set([
'دوره بازگشت (روز)',
'دوره بازگشت',
'renewaldays',
'renewal_days',
'renewal days',
]);
const SAMPLE_SKILL_ROWS: Array<{ title: string; renewalDays: number }> = [
{ title: 'کاشت ناخن ژل', renewalDays: 30 },
{ title: 'مانیکور ساده', renewalDays: 21 },
{ title: 'پدیکور', renewalDays: 30 },
];
const EXCEL_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'application/octet-stream',
]);
@Injectable() @Injectable()
export class ServicesService { export class ServicesService {
constructor( constructor(
@@ -97,6 +120,97 @@ export class ServicesService {
return this.findSkill(serviceId, saved.id); return this.findSkill(serviceId, saved.id);
} }
async buildSkillsImportTemplate(): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('مهارت‌ها');
sheet.columns = [
{ header: 'عنوان مهارت', key: 'title', width: 40 },
{ header: 'دوره بازگشت (روز)', key: 'renewalDays', width: 22 },
];
sheet.getRow(1).font = { bold: true };
SAMPLE_SKILL_ROWS.forEach((row) => sheet.addRow(row));
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
async importSkillsFromExcel(
serviceId: string,
file: Express.Multer.File | undefined,
user: AuthUser,
): Promise<ImportServiceSkillsResultDto> {
this.ensureCanManage(user);
await this.findOne(serviceId);
this.ensureExcelFile(file);
const rows = await this.parseSkillsExcel(file.buffer);
if (rows.length === 0) {
throw new BadRequestException('فایل اکسل هیچ ردیف معتبری ندارد');
}
const errors: ImportServiceSkillsResultDto['errors'] = [];
const toCreate: Array<{ title: string; renewalDays: number }> = [];
for (const row of rows) {
const title = row.title?.trim() ?? '';
const renewalDays = row.renewalDays;
if (!title) {
errors.push({ row: row.rowNumber, message: 'عنوان مهارت الزامی است' });
continue;
}
if (title.length > 200) {
errors.push({
row: row.rowNumber,
message: 'عنوان مهارت نباید بیشتر از ۲۰۰ کاراکتر باشد',
});
continue;
}
if (
renewalDays === null ||
!Number.isInteger(renewalDays) ||
renewalDays < 1
) {
errors.push({
row: row.rowNumber,
message: 'دوره بازگشت باید عدد صحیح و حداقل ۱ باشد',
});
continue;
}
toCreate.push({ title, renewalDays });
}
if (toCreate.length === 0) {
throw new BadRequestException(
errors.length > 0
? errors
.map((error) => `ردیف ${error.row}: ${error.message}`)
.join('؛ ')
: 'هیچ ردیف معتبری برای ثبت یافت نشد',
);
}
const saved = await this.serviceSkillsRepository.manager.transaction(
async (manager) => {
const repository = manager.getRepository(ServiceSkill);
const entities = toCreate.map((item) =>
repository.create({ ...item, serviceId }),
);
return repository.save(entities);
},
);
return {
created: saved.length,
failed: errors.length,
skills: saved,
errors,
};
}
async findSkills(serviceId: string): Promise<ServiceSkill[]> { async findSkills(serviceId: string): Promise<ServiceSkill[]> {
await this.ensureServiceExists(serviceId); await this.ensureServiceExists(serviceId);
return this.serviceSkillsRepository.find({ return this.serviceSkillsRepository.find({
@@ -263,4 +377,115 @@ export class ServicesService {
throw new NotFoundException(`Service #${serviceId} not found`); throw new NotFoundException(`Service #${serviceId} not found`);
} }
} }
private ensureExcelFile(file: Express.Multer.File | undefined): asserts file is Express.Multer.File {
if (!file) {
throw new BadRequestException('فایل اکسل الزامی است');
}
const extension = file.originalname.split('.').pop()?.toLowerCase();
const isExcelExtension = extension === 'xlsx' || extension === 'xls';
const isExcelMime = EXCEL_MIME_TYPES.has(file.mimetype);
if (!isExcelExtension && !isExcelMime) {
throw new BadRequestException('فقط فایل‌های Excel (.xlsx) مجاز هستند');
}
}
private async parseSkillsExcel(
buffer: Buffer,
): Promise<
Array<{ rowNumber: number; title: string | null; renewalDays: number | null }>
> {
const workbook = new ExcelJS.Workbook();
// exceljs Buffer typings conflict with Node 22+ Buffer generics
await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer);
const sheet = workbook.worksheets[0];
if (!sheet) {
throw new BadRequestException('فایل اکسل خالی است');
}
const headerRow = sheet.getRow(1);
let titleCol = 0;
let renewalCol = 0;
headerRow.eachCell((cell, colNumber) => {
const header = this.normalizeHeader(cell.value);
if (TITLE_HEADERS.has(header)) {
titleCol = colNumber;
}
if (RENEWAL_HEADERS.has(header)) {
renewalCol = colNumber;
}
});
if (!titleCol || !renewalCol) {
throw new BadRequestException(
'ستون‌های «عنوان مهارت» و «دوره بازگشت (روز)» در ردیف اول الزامی هستند',
);
}
const rows: Array<{
rowNumber: number;
title: string | null;
renewalDays: number | null;
}> = [];
sheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) {
return;
}
const titleValue = this.cellToString(row.getCell(titleCol).value);
const renewalValue = this.cellToNumber(row.getCell(renewalCol).value);
if (!titleValue && renewalValue === null) {
return;
}
rows.push({
rowNumber,
title: titleValue,
renewalDays: renewalValue,
});
});
return rows;
}
private normalizeHeader(value: ExcelJS.CellValue): string {
const text = this.cellToString(value)?.trim().toLowerCase() ?? '';
return text.replace(/\s+/g, ' ');
}
private cellToString(value: ExcelJS.CellValue): string | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value === 'string' || typeof value === 'number') {
return String(value).trim() || null;
}
if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
return value.text.trim() || null;
}
if (typeof value === 'object' && 'result' in value) {
return this.cellToString(value.result as ExcelJS.CellValue);
}
return String(value).trim() || null;
}
private cellToNumber(value: ExcelJS.CellValue): number | null {
if (value === null || value === undefined || value === '') {
return null;
}
if (typeof value === 'number') {
return value;
}
if (typeof value === 'object' && 'result' in value) {
return this.cellToNumber(value.result as ExcelJS.CellValue);
}
const parsed = Number(String(value).trim());
return Number.isFinite(parsed) ? parsed : null;
}
} }
@@ -15,6 +15,13 @@ export interface SmsScenarioDefinition {
variables: SmsScenarioVariable[]; variables: SmsScenarioVariable[];
} }
export const CAMPAIGN_DISCOUNT_VARIABLE: SmsScenarioVariable = {
key: 'discount',
label: 'تخفیف',
description: 'متن تخفیف تعریف‌شده برای قالب کمپین (مثلاً ۲۰٪ تخفیف)',
sampleValue: '۲۰٪ تخفیف',
};
export const SMS_SCENARIOS: SmsScenarioDefinition[] = [ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
{ {
key: SmsScenarioKey.OTP_LOGIN, key: SmsScenarioKey.OTP_LOGIN,
@@ -255,6 +262,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
description: 'تعداد روز باقی‌مانده تا موعد تجدید (۰ یعنی امروز)', description: 'تعداد روز باقی‌مانده تا موعد تجدید (۰ یعنی امروز)',
sampleValue: '۴', sampleValue: '۴',
}, },
CAMPAIGN_DISCOUNT_VARIABLE,
], ],
}, },
{ {
@@ -277,6 +285,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
description: 'نام سالن زیبایی', description: 'نام سالن زیبایی',
sampleValue: 'سالن زیبایی رز', sampleValue: 'سالن زیبایی رز',
}, },
CAMPAIGN_DISCOUNT_VARIABLE,
], ],
}, },
{ {
@@ -305,6 +314,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
description: 'تعداد روزهایی که از آخرین مراجعه مشتری گذشته است', description: 'تعداد روزهایی که از آخرین مراجعه مشتری گذشته است',
sampleValue: '۴۰', sampleValue: '۴۰',
}, },
CAMPAIGN_DISCOUNT_VARIABLE,
], ],
}, },
{ {
@@ -333,6 +343,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
description: 'مجموع خرید مشتری تا کنون (تومان)', description: 'مجموع خرید مشتری تا کنون (تومان)',
sampleValue: '۱۵,۰۰۰,۰۰۰', sampleValue: '۱۵,۰۰۰,۰۰۰',
}, },
CAMPAIGN_DISCOUNT_VARIABLE,
], ],
}, },
{ {
@@ -361,6 +372,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
description: 'تعداد روزهایی که از آخرین مراجعه مشتری گذشته است', description: 'تعداد روزهایی که از آخرین مراجعه مشتری گذشته است',
sampleValue: '۹۰', sampleValue: '۹۰',
}, },
CAMPAIGN_DISCOUNT_VARIABLE,
], ],
}, },
{ {
@@ -389,6 +401,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
description: 'نام خدمت مکمل پیشنهادی', description: 'نام خدمت مکمل پیشنهادی',
sampleValue: 'کراتین', sampleValue: 'کراتین',
}, },
CAMPAIGN_DISCOUNT_VARIABLE,
], ],
}, },
{ {
+12
View File
@@ -63,6 +63,18 @@ export class SmsDispatchService {
return renderSmsTemplate(body, variables); return renderSmsTemplate(body, variables);
} }
async resolveMessageFromTemplateId(
templateId: string,
variables: Record<string, string>,
): Promise<string | null> {
const template = await this.smsTemplatesService.findOne(templateId);
if (!template.isActive) {
return null;
}
return renderSmsTemplate(template.body, variables);
}
async previewMessage(dto: PreviewSmsScenarioDto): Promise<string> { async previewMessage(dto: PreviewSmsScenarioDto): Promise<string> {
const body = await this.resolvePreviewBody(dto); const body = await this.resolvePreviewBody(dto);
const variables = getSampleVariables(dto.key); const variables = getSampleVariables(dto.key);
+9 -2
View File
@@ -20,6 +20,7 @@ import {
import { SmsScenarioSetting } from './entities/sms-scenario-setting.entity'; import { SmsScenarioSetting } from './entities/sms-scenario-setting.entity';
import { SmsScenarioKey } from './enums/sms-scenario-key.enum'; import { SmsScenarioKey } from './enums/sms-scenario-key.enum';
import { SmsDispatchService } from './sms-dispatch.service'; import { SmsDispatchService } from './sms-dispatch.service';
import { isCampaignSmsScenarioKey } from './utils/is-campaign-sms-scenario.util';
export interface SmsScenarioSettingResponse { export interface SmsScenarioSettingResponse {
key: SmsScenarioKey; key: SmsScenarioKey;
@@ -42,14 +43,20 @@ export class SmsSettingsService {
) {} ) {}
getScenarioDefinitions(): SmsScenarioDefinition[] { getScenarioDefinitions(): SmsScenarioDefinition[] {
return this.smsDispatchService.getScenarioDefinitions(); return this.smsDispatchService
.getScenarioDefinitions()
.filter((definition) => !isCampaignSmsScenarioKey(definition.key));
} }
async findAll(user: AuthUser): Promise<SmsScenarioSettingResponse[]> { async findAll(user: AuthUser): Promise<SmsScenarioSettingResponse[]> {
this.ensureCanManage(user); this.ensureCanManage(user);
const settings = await this.smsDispatchService.findAllSettings(); const settings = await this.smsDispatchService.findAllSettings();
return Promise.all(settings.map((setting) => this.toResponse(setting))); return Promise.all(
settings
.filter((setting) => !isCampaignSmsScenarioKey(setting.key))
.map((setting) => this.toResponse(setting)),
);
} }
async update( async update(
@@ -0,0 +1,14 @@
import { SmsScenarioKey } from '../enums/sms-scenario-key.enum';
const CAMPAIGN_SMS_SCENARIO_KEYS = new Set<SmsScenarioKey>([
SmsScenarioKey.CAMPAIGN_RETURNING_CUSTOMERS,
SmsScenarioKey.CAMPAIGN_BIRTHDAY,
SmsScenarioKey.CAMPAIGN_AT_RISK,
SmsScenarioKey.CAMPAIGN_VIP,
SmsScenarioKey.CAMPAIGN_INACTIVE,
SmsScenarioKey.CAMPAIGN_COMPLEMENTARY,
]);
export function isCampaignSmsScenarioKey(key: SmsScenarioKey): boolean {
return CAMPAIGN_SMS_SCENARIO_KEYS.has(key);
}
+15 -1
View File
@@ -1,4 +1,12 @@
import { IsEnum, IsOptional, IsString, Length, Matches } from 'class-validator'; import {
IsEnum,
IsOptional,
IsString,
IsUrl,
Length,
Matches,
ValidateIf,
} from 'class-validator';
import { UserRole } from '../enums/user-role.enum'; import { UserRole } from '../enums/user-role.enum';
export class UpdateUserDto { export class UpdateUserDto {
@@ -19,4 +27,10 @@ export class UpdateUserDto {
@IsOptional() @IsOptional()
@IsEnum(UserRole) @IsEnum(UserRole)
role?: UserRole; role?: UserRole;
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsString()
@IsUrl()
avatarUrl?: string | null;
} }
+3
View File
@@ -24,6 +24,9 @@ export class User {
@Column({ type: 'enum', enum: UserRole, default: UserRole.CUSTOMER }) @Column({ type: 'enum', enum: UserRole, default: UserRole.CUSTOMER })
role: UserRole; role: UserRole;
@Column({ type: 'varchar', length: 500, nullable: true })
avatarUrl: string | null;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
-1
View File
@@ -13,7 +13,6 @@
"target": "ES2023", "target": "ES2023",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"baseUrl": "./",
"incremental": true, "incremental": true,
"skipLibCheck": true, "skipLibCheck": true,
"strictNullChecks": true, "strictNullChecks": true,