Compare commits

..

17 Commits

Author SHA1 Message Date
hamid 21ed63ca0f dublicate excel 2026-07-12 19:40:03 +03:30
hamid bca45e341e fix excel 2026-07-12 19:26:02 +03:30
hamid 571003377a update excel file 2026-07-12 19:12:08 +03:30
hamid 4048e3424a trial 2026-07-12 19:08:30 +03:30
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
59 changed files with 2768 additions and 86 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",
+30 -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;
}; };
} }
@@ -303,10 +304,26 @@ export class AuthService {
ownerId: user.id, ownerId: user.id,
}); });
await this.grantFreeTrial(salon.id, user.id);
const tokens = await this.issueTokens(user); const tokens = await this.issueTokens(user);
return { ...tokens, salon }; return { ...tokens, salon };
} }
private async grantFreeTrial(
salonId: string,
ownerId: string,
): Promise<void> {
try {
await this.salonSubscriptionsService.startFreeTrial(salonId, ownerId);
} catch (error) {
this.logger.error(
`Failed to grant free trial for salon ${salonId}`,
error instanceof Error ? error.stack : undefined,
);
}
}
private validateSalonSignupFields(dto: VerifySalonOtpDto): void { private validateSalonSignupFields(dto: VerifySalonOtpDto): void {
const requiredFields: Array<keyof VerifySalonOtpDto> = [ const requiredFields: Array<keyof VerifySalonOtpDto> = [
'firstName', 'firstName',
@@ -403,13 +420,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 +488,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}`;
}
@@ -0,0 +1,5 @@
export function normalizeDigits(value: string): string {
return value
.replace(/[۰-۹]/g, (digit) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(digit)))
.replace(/[٠-٩]/g, (digit) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(digit)));
}
+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');
}
}
@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { NotificationScenarioKey } from '../../notifications/enums/notification-scenario-key.enum';
const NEW_NOTIFICATION_KEYS = [NotificationScenarioKey.SUBSCRIPTION_EXPIRED];
/**
* Only adds the new enum value. Postgres forbids using a newly added enum
* value in the same transaction it was created in, so seeding the actual
* scenario row is handled by the follow-up `SeedSubscriptionExpiredScenario`
* migration. `transaction = false` commits ADD VALUE immediately (required
* by Postgres).
*/
export class AddSubscriptionExpiredScenario1722500000000 implements MigrationInterface {
name = 'AddSubscriptionExpiredScenario1722500000000';
transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
for (const key of NEW_NOTIFICATION_KEYS) {
await queryRunner.query(
`ALTER TYPE "notification_scenario_key_enum" ADD VALUE IF NOT EXISTS '${key}'`,
);
}
}
public async down(): Promise<void> {
// Enum values cannot be dropped without recreating the type; left as-is on downgrade.
}
}
@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { NOTIFICATION_SCENARIOS } from '../../notifications/constants/notification-scenarios.constants';
import { NotificationScenarioKey } from '../../notifications/enums/notification-scenario-key.enum';
const NEW_NOTIFICATION_KEYS = [NotificationScenarioKey.SUBSCRIPTION_EXPIRED];
/** Seeds the default scenario row for the enum value added in AddSubscriptionExpiredScenario. */
export class SeedSubscriptionExpiredScenario1722600000000 implements MigrationInterface {
name = 'SeedSubscriptionExpiredScenario1722600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
for (const key of NEW_NOTIFICATION_KEYS) {
const scenario = NOTIFICATION_SCENARIOS.find((s) => s.key === key);
if (!scenario) continue;
await queryRunner.query(
`
INSERT INTO notification_scenario_settings (key, title, body, "isActive")
VALUES ($1, $2, $3, true)
ON CONFLICT (key) DO NOTHING
`,
[scenario.key, scenario.defaultTitle, scenario.defaultBody],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM "notification_scenario_settings" WHERE "key" IN (${NEW_NOTIFICATION_KEYS.map((key) => `'${key}'`).join(', ')})`,
);
}
}
@@ -0,0 +1,54 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
import {
FREE_TRIAL_DURATION_DAYS,
FREE_TRIAL_PLAN_CODE,
FREE_TRIAL_SMS_COUNT,
} from '../../subscriptions/constants/subscription.constants';
export class AddFreeTrialSubscription1722700000000 implements MigrationInterface {
name = 'AddFreeTrialSubscription1722700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumns('subscription_plans', [
new TableColumn({
name: 'durationDays',
type: 'int',
isNullable: true,
}),
new TableColumn({
name: 'isTrial',
type: 'boolean',
default: false,
}),
]);
await queryRunner.addColumn(
'salon_subscriptions',
new TableColumn({
name: 'expiryNotifiedAt',
type: 'timestamp',
isNullable: true,
}),
);
await queryRunner.query(
`
INSERT INTO subscription_plans
(name, code, "durationMonths", "durationDays", price, "freeSmsCount", "isActive", "isTrial")
VALUES
('دوره آزمایشی رایگان', $1, 0, $2, 0, $3, true, true)
ON CONFLICT (code) DO NOTHING
`,
[FREE_TRIAL_PLAN_CODE, FREE_TRIAL_DURATION_DAYS, FREE_TRIAL_SMS_COUNT],
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM subscription_plans WHERE code = '${FREE_TRIAL_PLAN_CODE}'`,
);
await queryRunner.dropColumn('salon_subscriptions', 'expiryNotifiedAt');
await queryRunner.dropColumn('subscription_plans', 'isTrial');
await queryRunner.dropColumn('subscription_plans', 'durationDays');
}
}
@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class DeduplicateServiceSkills1722800000000 implements MigrationInterface {
name = 'DeduplicateServiceSkills1722800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM "service_skills" s1
USING "service_skills" s2
WHERE s1."serviceId" = s2."serviceId"
AND LOWER(TRIM(s1.title)) = LOWER(TRIM(s2.title))
AND s1."createdAt" > s2."createdAt"
`);
await queryRunner.query(`
CREATE UNIQUE INDEX "UQ_service_skills_service_title_normalized"
ON "service_skills" ("serviceId", (LOWER(TRIM("title"))))
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS "UQ_service_skills_service_title_normalized"
`);
}
}
+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);
});
@@ -299,6 +299,35 @@ export const NOTIFICATION_SCENARIOS: NotificationScenarioDefinition[] = [
}, },
], ],
}, },
{
key: NotificationScenarioKey.SUBSCRIPTION_EXPIRED,
label: 'پایان دوره اشتراک یا آزمایشی',
description:
'اعلان برای سالن هنگامی که دوره آزمایشی رایگان یا اشتراک خریداری‌شده به پایان می‌رسد',
defaultTitle: 'دوره {planLabel} شما به پایان رسید',
defaultBody:
'{planLabel} سالن {salonName} در تاریخ {endDate} به پایان رسید. برای ادامه استفاده از امکانات و ارسال پیامک، اشتراک خود را تهیه کنید.',
variables: [
{
key: 'salonName',
label: 'نام سالن',
description: 'نام سالن زیبایی',
sampleValue: 'سالن زیبایی رز',
},
{
key: 'planLabel',
label: 'نوع دوره',
description: 'دوره آزمایشی رایگان یا نام پلن خریداری‌شده',
sampleValue: 'دوره آزمایشی رایگان',
},
{
key: 'endDate',
label: 'تاریخ پایان',
description: 'تاریخ پایان دوره',
sampleValue: '۱۴۰۴/۰۴/۲۰',
},
],
},
]; ];
export const NOTIFICATION_SCENARIO_MAP = new Map( export const NOTIFICATION_SCENARIO_MAP = new Map(
@@ -6,4 +6,5 @@ export enum NotificationScenarioKey {
CAMPAIGN_SENT = 'campaign_sent', CAMPAIGN_SENT = 'campaign_sent',
CAMPAIGN_FAILED = 'campaign_failed', CAMPAIGN_FAILED = 'campaign_failed',
DEPOSIT_RECEIPT_SUBMITTED = 'deposit_receipt_submitted', DEPOSIT_RECEIPT_SUBMITTED = 'deposit_receipt_submitted',
SUBSCRIPTION_EXPIRED = 'subscription_expired',
} }
@@ -158,6 +158,28 @@ export class SalonNotificationsService {
}); });
} }
async notifySubscriptionExpired(params: {
salonId: string;
salonOwnerId: string;
salonName: string;
planLabel: string;
endDate: Date;
}): Promise<void> {
await this.safeCreate({
salonId: params.salonId,
userId: params.salonOwnerId,
key: NotificationScenarioKey.SUBSCRIPTION_EXPIRED,
variables: {
salonName: params.salonName,
planLabel: params.planLabel,
endDate: params.endDate.toLocaleDateString('fa-IR'),
},
link: '/subscription',
relatedEntityType: 'subscription',
relatedEntityId: null,
});
}
private async notifyForReserve( private async notifyForReserve(
reserveId: string, reserveId: string,
key: NotificationScenarioKey, key: NotificationScenarioKey,
@@ -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,27 @@
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({ example: 2 })
skipped: number;
@ApiProperty({ type: [ServiceSkill] })
skills: ServiceSkill[];
@ApiProperty({ type: [ImportServiceSkillRowErrorDto] })
errors: ImportServiceSkillRowErrorDto[];
}
@@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class RemoveDuplicateServiceSkillsResultDto {
@ApiProperty({ example: 3 })
removed: number;
}
@@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class ServiceSkillDuplicateCountDto {
@ApiProperty({ example: 3 })
count: number;
}
+87 -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,9 @@ 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 { RemoveDuplicateServiceSkillsResultDto } from './dto/remove-duplicate-service-skills-result.dto';
import { ServiceSkillDuplicateCountDto } from './dto/service-skill-duplicate-count.dto';
import { import {
CreateSalonServiceDto, CreateSalonServiceDto,
FindSalonServicesQueryDto, FindSalonServicesQueryDto,
@@ -39,6 +54,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 +116,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 +181,57 @@ 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);
}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.SERVICES_WRITE)
@ApiStandardOkResponse(ServiceSkillDuplicateCountDto)
@Get(':serviceId/skills/duplicates/count')
countDuplicateSkills(@Param('serviceId', ParseUUIDPipe) serviceId: string) {
return this.servicesService.countDuplicateSkills(serviceId);
}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.SERVICES_DELETE)
@ApiStandardOkResponse(RemoveDuplicateServiceSkillsResultDto)
@Delete(':serviceId/skills/duplicates')
removeDuplicateSkills(
@Param('serviceId', ParseUUIDPipe) serviceId: string,
@CurrentUser() user: AuthUser,
) {
return this.servicesService.removeDuplicateSkills(serviceId, user);
}
@Public() @Public()
@ApiPublicEndpoint('List skills for a service') @ApiPublicEndpoint('List skills for a service')
@ApiStandardOkResponse(ServiceSkill, { isArray: true }) @ApiStandardOkResponse(ServiceSkill, { isArray: true })
+348
View File
@@ -6,13 +6,17 @@ 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';
import { normalizeDigits } from '../common/utils/normalize-digits.util';
import { Salon } from '../salons/entities/salon.entity'; 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 { RemoveDuplicateServiceSkillsResultDto } from './dto/remove-duplicate-service-skills-result.dto';
import { import {
CreateSalonServiceDto, CreateSalonServiceDto,
FindSalonServicesQueryDto, FindSalonServicesQueryDto,
@@ -23,6 +27,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(
@@ -89,6 +114,7 @@ export class ServicesService {
): Promise<ServiceSkill> { ): Promise<ServiceSkill> {
this.ensureCanManage(user); this.ensureCanManage(user);
await this.findOne(serviceId); await this.findOne(serviceId);
await this.ensureUniqueSkillTitle(serviceId, createServiceSkillDto.title);
const skill = this.serviceSkillsRepository.create({ const skill = this.serviceSkillsRepository.create({
...createServiceSkillDto, ...createServiceSkillDto,
serviceId, serviceId,
@@ -97,6 +123,175 @@ 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 }> = [];
const existingSkills = await this.findSkills(serviceId);
const existingTitles = new Set(
existingSkills.map((skill) => this.normalizeSkillTitle(skill.title)),
);
const seenInFile = new Set<string>();
let skipped = 0;
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;
}
const normalizedTitle = this.normalizeSkillTitle(title);
if (
existingTitles.has(normalizedTitle) ||
seenInFile.has(normalizedTitle)
) {
skipped += 1;
continue;
}
seenInFile.add(normalizedTitle);
toCreate.push({ title, renewalDays });
}
if (toCreate.length === 0) {
if (errors.length > 0) {
throw new BadRequestException(
errors
.map((error) => `ردیف ${error.row}: ${error.message}`)
.join('؛ '),
);
}
return {
created: 0,
failed: 0,
skipped,
skills: [],
errors: [],
};
}
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,
skipped,
skills: saved,
errors,
};
}
async removeDuplicateSkills(
serviceId: string,
user: AuthUser,
): Promise<RemoveDuplicateServiceSkillsResultDto> {
this.ensureCanManage(user);
await this.findOne(serviceId);
const skills = await this.findSkills(serviceId);
const keepIds = new Set<string>();
const seenTitles = new Set<string>();
for (const skill of skills) {
const normalizedTitle = this.normalizeSkillTitle(skill.title);
if (seenTitles.has(normalizedTitle)) {
continue;
}
seenTitles.add(normalizedTitle);
keepIds.add(skill.id);
}
const duplicateIds = skills
.filter((skill) => !keepIds.has(skill.id))
.map((skill) => skill.id);
if (duplicateIds.length === 0) {
return { removed: 0 };
}
await this.serviceSkillsRepository.delete({ id: In(duplicateIds) });
return { removed: duplicateIds.length };
}
async countDuplicateSkills(serviceId: string): Promise<{ count: number }> {
await this.ensureServiceExists(serviceId);
const skills = await this.findSkills(serviceId);
const seenTitles = new Set<string>();
let count = 0;
for (const skill of skills) {
const normalizedTitle = this.normalizeSkillTitle(skill.title);
if (seenTitles.has(normalizedTitle)) {
count += 1;
continue;
}
seenTitles.add(normalizedTitle);
}
return { count };
}
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({
@@ -125,6 +320,13 @@ export class ServicesService {
): Promise<ServiceSkill> { ): Promise<ServiceSkill> {
this.ensureCanManage(user); this.ensureCanManage(user);
const skill = await this.findSkill(serviceId, skillId); const skill = await this.findSkill(serviceId, skillId);
if (updateServiceSkillDto.title) {
await this.ensureUniqueSkillTitle(
serviceId,
updateServiceSkillDto.title,
skillId,
);
}
Object.assign(skill, updateServiceSkillDto); Object.assign(skill, updateServiceSkillDto);
await this.serviceSkillsRepository.save(skill); await this.serviceSkillsRepository.save(skill);
return this.findSkill(serviceId, skillId); return this.findSkill(serviceId, skillId);
@@ -263,4 +465,150 @@ 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 normalizeSkillTitle(title: string): string {
return normalizeDigits(title)
.trim()
.toLowerCase()
.replace(/[يى]/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/\u200c/g, '')
.replace(/\s+/g, ' ');
}
private async ensureUniqueSkillTitle(
serviceId: string,
title: string,
excludeSkillId?: string,
): Promise<void> {
const normalizedTitle = this.normalizeSkillTitle(title);
const existingSkills = await this.serviceSkillsRepository.find({
where: { serviceId },
select: { id: true, title: true },
});
const duplicate = existingSkills.find(
(skill) =>
skill.id !== excludeSkillId &&
this.normalizeSkillTitle(skill.title) === normalizedTitle,
);
if (duplicate) {
throw new ConflictException('مهارت با این نام قبلاً ثبت شده است');
}
}
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' && 'text' in value && typeof value.text === 'string') {
return this.cellToNumber(value.text);
}
if (typeof value === 'object' && 'result' in value) {
return this.cellToNumber(value.result as ExcelJS.CellValue);
}
const parsed = Number(normalizeDigits(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);
}
@@ -1 +1,5 @@
export const SMS_ROLLOVER_GRACE_DAYS = 30; export const SMS_ROLLOVER_GRACE_DAYS = 30;
export const FREE_TRIAL_PLAN_CODE = 'free-trial';
export const FREE_TRIAL_DURATION_DAYS = 7;
export const FREE_TRIAL_SMS_COUNT = 100;
@@ -52,6 +52,9 @@ export class SalonSubscription {
@Column({ type: 'int' }) @Column({ type: 'int' })
freeSmsRemaining: number; freeSmsRemaining: number;
@Column({ type: 'timestamp', nullable: true })
expiryNotifiedAt: Date | null;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
@@ -20,6 +20,9 @@ export class SubscriptionPlan {
@Column({ type: 'int' }) @Column({ type: 'int' })
durationMonths: number; durationMonths: number;
@Column({ type: 'int', nullable: true })
durationDays: number | null;
@Column({ type: 'bigint' }) @Column({ type: 'bigint' })
price: string; price: string;
@@ -29,6 +32,9 @@ export class SubscriptionPlan {
@Column({ default: true }) @Column({ default: true })
isActive: boolean; isActive: boolean;
@Column({ default: false })
isTrial: boolean;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
@@ -42,6 +42,9 @@ export class SalonSubscriptionsService {
} }
const plan = await this.subscriptionPlansService.findActiveById(planId); const plan = await this.subscriptionPlansService.findActiveById(planId);
if (plan.isTrial) {
throw new BadRequestException('This plan cannot be purchased');
}
const now = new Date(); const now = new Date();
const rolloverSms = await this.calculateSmsRollover(salonId, now); const rolloverSms = await this.calculateSmsRollover(salonId, now);
const freeSmsGranted = plan.freeSmsCount + rolloverSms; const freeSmsGranted = plan.freeSmsCount + rolloverSms;
@@ -64,6 +67,64 @@ export class SalonSubscriptionsService {
}); });
} }
/**
* Grants a new salon owner a one-time 7-day free trial with a limited
* number of free SMS. Silently no-ops if the owner has ever had any
* subscription before (trial or paid), to prevent abuse via creating
* extra salons. Failures are swallowed by the caller so signup never
* fails because of trial provisioning.
*/
async startFreeTrial(
salonId: string,
ownerId: string,
): Promise<SalonSubscription | null> {
const ownerSubscriptionCount = await this.subscriptionsRepository
.createQueryBuilder('subscription')
.innerJoin('subscription.salon', 'salon')
.where('salon.ownerId = :ownerId', { ownerId })
.getCount();
if (ownerSubscriptionCount > 0) {
return null;
}
const plan = await this.subscriptionPlansService.findTrialPlan();
const now = new Date();
const subscription = this.subscriptionsRepository.create({
salonId,
planId: plan.id,
status: SalonSubscriptionStatus.ACTIVE,
startDate: now,
endDate: this.addDays(now, plan.durationDays ?? 7),
pricePaid: '0',
freeSmsGranted: plan.freeSmsCount,
freeSmsRemaining: plan.freeSmsCount,
});
return this.subscriptionsRepository.save(subscription);
}
/** Subscriptions whose endDate has passed but no expiry notification was sent yet. */
async findSubscriptionsPendingExpiryNotification(): Promise<
SalonSubscription[]
> {
return this.subscriptionsRepository
.createQueryBuilder('subscription')
.innerJoinAndSelect('subscription.salon', 'salon')
.innerJoinAndSelect('salon.owner', 'owner')
.innerJoinAndSelect('subscription.plan', 'plan')
.where('subscription.endDate <= :now', { now: new Date() })
.andWhere('subscription.expiryNotifiedAt IS NULL')
.getMany();
}
async markExpiryNotified(id: string): Promise<void> {
await this.subscriptionsRepository.update(id, {
expiryNotifiedAt: new Date(),
});
}
async validateSubscriptionPurchase( async validateSubscriptionPurchase(
salonId: string, salonId: string,
planId: string, planId: string,
@@ -74,7 +135,10 @@ export class SalonSubscriptionsService {
throw new ForbiddenException(); throw new ForbiddenException();
} }
await this.subscriptionPlansService.findActiveById(planId); const plan = await this.subscriptionPlansService.findActiveById(planId);
if (plan.isTrial) {
throw new BadRequestException('This plan cannot be purchased');
}
const activeSubscription = await this.findActiveSubscription(salonId); const activeSubscription = await this.findActiveSubscription(salonId);
if (activeSubscription) { if (activeSubscription) {
@@ -0,0 +1,76 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SalonNotificationsService } from '../notifications/salon-notifications.service';
import { SmsService } from '../sms/sms.service';
import { SalonSubscriptionsService } from './salon-subscriptions.service';
@Injectable()
export class SubscriptionAutomationService {
private readonly logger = new Logger(SubscriptionAutomationService.name);
constructor(
private readonly salonSubscriptionsService: SalonSubscriptionsService,
private readonly salonNotificationsService: SalonNotificationsService,
private readonly smsService: SmsService,
) {}
/**
* Detects subscriptions (trial or paid) whose validity period just ended
* and, once per subscription, notifies the salon owner in-app and by SMS
* that they need to purchase a plan to keep using the product. Access is
* already blocked automatically by `ActiveSubscriptionGuard` once the
* subscription's `endDate` has passed, independent of this job.
*/
@Cron(CronExpression.EVERY_DAY_AT_10AM, {
name: 'subscription-expiry-notifications',
})
async notifyExpiredSubscriptions(): Promise<number> {
const subscriptions =
await this.salonSubscriptionsService.findSubscriptionsPendingExpiryNotification();
let notifiedCount = 0;
for (const subscription of subscriptions) {
try {
await this.salonSubscriptionsService.markExpiryNotified(
subscription.id,
);
const planLabel = subscription.plan?.isTrial
? 'دوره آزمایشی رایگان'
: `اشتراک ${subscription.plan?.name ?? ''}`.trim();
await this.salonNotificationsService.notifySubscriptionExpired({
salonId: subscription.salonId,
salonOwnerId: subscription.salon.ownerId,
salonName: subscription.salon.name,
planLabel,
endDate: subscription.endDate,
});
const ownerMobile = subscription.salon.owner?.mobile;
if (ownerMobile) {
await this.smsService.sendSms(
ownerMobile,
`${planLabel} سالن ${subscription.salon.name} به پایان رسید. برای ادامه استفاده از امکانات و ارسال پیامک، همین حالا اشتراک تهیه کنید.`,
);
}
notifiedCount += 1;
} catch (error) {
this.logger.error(
`Failed to send expiry notification for subscription ${subscription.id}`,
error instanceof Error ? error.stack : undefined,
);
}
}
if (notifiedCount > 0) {
this.logger.log(
`Sent expiry notifications for ${notifiedCount} subscription(s)`,
);
}
return notifiedCount;
}
}
@@ -22,11 +22,21 @@ export class SubscriptionPlansService {
findAll(activeOnly = false): Promise<SubscriptionPlan[]> { findAll(activeOnly = false): Promise<SubscriptionPlan[]> {
return this.plansRepository.find({ return this.plansRepository.find({
where: activeOnly ? { isActive: true } : undefined, where: { isActive: activeOnly ? true : undefined, isTrial: false },
order: { durationMonths: 'ASC' }, order: { durationMonths: 'ASC' },
}); });
} }
async findTrialPlan(): Promise<SubscriptionPlan> {
const plan = await this.plansRepository.findOne({
where: { isTrial: true, isActive: true },
});
if (!plan) {
throw new NotFoundException('Free trial plan is not configured');
}
return plan;
}
async findOne(id: string): Promise<SubscriptionPlan> { async findOne(id: string): Promise<SubscriptionPlan> {
const plan = await this.plansRepository.findOne({ where: { id } }); const plan = await this.plansRepository.findOne({ where: { id } });
if (!plan) { if (!plan) {
@@ -3,8 +3,10 @@ import { APP_GUARD } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthGuardsModule } from '../auth/auth-guards.module'; import { AuthGuardsModule } from '../auth/auth-guards.module';
import { AuthorizationModule } from '../authorization/authorization.module'; import { AuthorizationModule } from '../authorization/authorization.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PaymentsModule } from '../payments/payments.module'; import { PaymentsModule } from '../payments/payments.module';
import { SalonsModule } from '../salons/salons.module'; import { SalonsModule } from '../salons/salons.module';
import { SmsModule } from '../sms/sms.module';
import { SalonSmsPurchase } from './entities/salon-sms-purchase.entity'; import { SalonSmsPurchase } from './entities/salon-sms-purchase.entity';
import { SalonSubscription } from './entities/salon-subscription.entity'; import { SalonSubscription } from './entities/salon-subscription.entity';
import { SmsPackage } from './entities/sms-package.entity'; import { SmsPackage } from './entities/sms-package.entity';
@@ -14,6 +16,7 @@ import { SalonSubscriptionsController } from './salon-subscriptions.controller';
import { SalonSubscriptionsService } from './salon-subscriptions.service'; import { SalonSubscriptionsService } from './salon-subscriptions.service';
import { SmsPackagesController } from './sms-packages.controller'; import { SmsPackagesController } from './sms-packages.controller';
import { SmsPackagesService } from './sms-packages.service'; import { SmsPackagesService } from './sms-packages.service';
import { SubscriptionAutomationService } from './subscription-automation.service';
import { SubscriptionPlansController } from './subscription-plans.controller'; import { SubscriptionPlansController } from './subscription-plans.controller';
import { SubscriptionPlansService } from './subscription-plans.service'; import { SubscriptionPlansService } from './subscription-plans.service';
@@ -28,6 +31,8 @@ import { SubscriptionPlansService } from './subscription-plans.service';
SalonsModule, SalonsModule,
AuthorizationModule, AuthorizationModule,
AuthGuardsModule, AuthGuardsModule,
NotificationsModule,
SmsModule,
forwardRef(() => PaymentsModule), forwardRef(() => PaymentsModule),
], ],
controllers: [ controllers: [
@@ -39,6 +44,7 @@ import { SubscriptionPlansService } from './subscription-plans.service';
SubscriptionPlansService, SubscriptionPlansService,
SmsPackagesService, SmsPackagesService,
SalonSubscriptionsService, SalonSubscriptionsService,
SubscriptionAutomationService,
{ {
provide: APP_GUARD, provide: APP_GUARD,
useClass: ActiveSubscriptionGuard, useClass: ActiveSubscriptionGuard,
+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,