Compare commits
13 Commits
cefd42de17
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9202d1b2ca | |||
| 25559191c1 | |||
| b1da7fdeac | |||
| bf4e4c8d65 | |||
| 8b2d1b0a47 | |||
| c41f5567c5 | |||
| 7a5d2bb137 | |||
| 3e79cb69b3 | |||
| 83f8db336e | |||
| 0020f95a61 | |||
| 7c0f3a1d0e | |||
| 33f0afaf10 | |||
| 42f08397a2 |
+11
-13
@@ -12,25 +12,23 @@ RUN npm config set registry https://package-mirror.liara.ir/repository/npm/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Stage 2: Build dependencies
|
||||
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
|
||||
# Stage 2: Build (install all deps including @nestjs/cli)
|
||||
FROM base AS builder
|
||||
WORKDIR /build
|
||||
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 . .
|
||||
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
|
||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||
|
||||
|
||||
Generated
+791
-13
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"multer": "^2.2.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface AuthTokensResponse {
|
||||
lastName: string;
|
||||
mobile: string;
|
||||
role: UserRole;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -403,13 +404,7 @@ export class AuthService {
|
||||
const user = await this.usersService.findOne(authUser.id);
|
||||
|
||||
const response: MeResponse = {
|
||||
user: {
|
||||
id: user.id,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
},
|
||||
user: this.toAuthUserResponse(user),
|
||||
};
|
||||
|
||||
if (user.role === UserRole.SALON) {
|
||||
@@ -477,13 +472,18 @@ export class AuthService {
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
user: this.toAuthUserResponse(user),
|
||||
};
|
||||
}
|
||||
|
||||
private toAuthUserResponse(user: User): AuthTokensResponse['user'] {
|
||||
return {
|
||||
id: user.id,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
},
|
||||
avatarUrl: user.avatarUrl ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ class AuthUserResponseDto {
|
||||
|
||||
@ApiProperty({ enum: UserRole })
|
||||
role: UserRole;
|
||||
|
||||
@ApiProperty({ nullable: true, required: false })
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export class AuthTokensResponseDto {
|
||||
|
||||
@@ -18,6 +18,9 @@ class MeUserResponseDto {
|
||||
|
||||
@ApiProperty({ enum: UserRole })
|
||||
role: UserRole;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export class MeResponseDto {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { CampaignTargetingService } from './campaign-targeting.service';
|
||||
import { CampaignsService } from './campaigns.service';
|
||||
import { CampaignTrigger } from './enums/campaign-trigger.enum';
|
||||
import { CampaignType } from './enums/campaign-type.enum';
|
||||
import { getCampaignTypeDefinition } from './constants/campaign-types.constants';
|
||||
|
||||
@Injectable()
|
||||
export class CampaignAutomationService {
|
||||
@@ -115,9 +114,16 @@ export class CampaignAutomationService {
|
||||
}
|
||||
|
||||
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(
|
||||
definition.scenarioKey,
|
||||
defaultTemplate.templateId,
|
||||
defaultTemplate.template.body,
|
||||
{ discountPercent: null, discountAmount: null },
|
||||
salon,
|
||||
targets,
|
||||
);
|
||||
@@ -142,6 +148,9 @@ export class CampaignAutomationService {
|
||||
trigger: CampaignTrigger.AUTOMATIC,
|
||||
targets,
|
||||
triggeredByUserId: null,
|
||||
templateId: (
|
||||
await this.campaignsService.getDefaultTemplateForType(type)
|
||||
)?.templateId,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
@@ -18,15 +20,20 @@ import { AuthUser } from '../auth/interfaces/auth-user.interface';
|
||||
import { PermissionCode } from '../authorization/enums/permission-code.enum';
|
||||
import {
|
||||
ApiStandardController,
|
||||
ApiStandardEmptyResponse,
|
||||
ApiStandardOkResponse,
|
||||
} from '../swagger/decorators/swagger-response.decorator';
|
||||
import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup';
|
||||
import { UserRole } from '../users/enums/user-role.enum';
|
||||
import { CampaignAutomationService } from './campaign-automation.service';
|
||||
import { CampaignTypeTemplatesService } from './campaign-type-templates.service';
|
||||
import { CampaignsService } from './campaigns.service';
|
||||
import { AssignCampaignTemplateDto } from './dto/assign-campaign-template.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 { CampaignRun } from './entities/campaign-run.entity';
|
||||
import { CampaignTypeTemplate } from './entities/campaign-type-template.entity';
|
||||
import { CampaignType } from './enums/campaign-type.enum';
|
||||
|
||||
@ApiTags('Campaigns')
|
||||
@@ -38,6 +45,7 @@ export class CampaignsController {
|
||||
constructor(
|
||||
private readonly campaignsService: CampaignsService,
|
||||
private readonly campaignAutomationService: CampaignAutomationService,
|
||||
private readonly campaignTypeTemplatesService: CampaignTypeTemplatesService,
|
||||
) {}
|
||||
|
||||
@Roles(UserRole.SALON)
|
||||
@@ -58,6 +66,38 @@ export class CampaignsController {
|
||||
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)
|
||||
@Permissions(PermissionCode.CAMPAIGNS_READ)
|
||||
@ApiStandardOkResponse(CampaignRun)
|
||||
@@ -80,6 +120,29 @@ export class CampaignsController {
|
||||
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)
|
||||
@ApiStandardOkResponse(Object, { isArray: true })
|
||||
@Get(':type/targets')
|
||||
|
||||
@@ -9,19 +9,23 @@ import { Salon } from '../salons/entities/salon.entity';
|
||||
import { SentMessage } from '../sent-messages/entities/sent-message.entity';
|
||||
import { SmsModule } from '../sms/sms.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 { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
||||
import { CampaignAutomationService } from './campaign-automation.service';
|
||||
import { CampaignTargetingService } from './campaign-targeting.service';
|
||||
import { CampaignTypeTemplatesService } from './campaign-type-templates.service';
|
||||
import { CampaignsController } from './campaigns.controller';
|
||||
import { CampaignsService } from './campaigns.service';
|
||||
import { CampaignRun } from './entities/campaign-run.entity';
|
||||
import { CampaignTypeTemplate } from './entities/campaign-type-template.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Salon,
|
||||
CampaignRun,
|
||||
CampaignTypeTemplate,
|
||||
SentMessage,
|
||||
SalonCustomer,
|
||||
Reserve,
|
||||
@@ -29,6 +33,7 @@ import { CampaignRun } from './entities/campaign-run.entity';
|
||||
]),
|
||||
SmsModule,
|
||||
SmsSettingsModule,
|
||||
SmsTemplatesModule,
|
||||
NotificationsModule,
|
||||
SubscriptionsModule,
|
||||
AuthorizationModule,
|
||||
@@ -39,7 +44,12 @@ import { CampaignRun } from './entities/campaign-run.entity';
|
||||
CampaignsService,
|
||||
CampaignTargetingService,
|
||||
CampaignAutomationService,
|
||||
CampaignTypeTemplatesService,
|
||||
],
|
||||
exports: [
|
||||
CampaignsService,
|
||||
CampaignTargetingService,
|
||||
CampaignTypeTemplatesService,
|
||||
],
|
||||
exports: [CampaignsService, CampaignTargetingService],
|
||||
})
|
||||
export class CampaignsModule {}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
CampaignTargetCustomer,
|
||||
CampaignTargetingService,
|
||||
} from './campaign-targeting.service';
|
||||
import { CampaignTypeTemplatesService } from './campaign-type-templates.service';
|
||||
import {
|
||||
CAMPAIGN_TYPES,
|
||||
getCampaignTypeDefinition,
|
||||
@@ -28,9 +29,21 @@ import {
|
||||
import { FindCampaignRunsQueryDto } from './dto/find-campaign-runs-query.dto';
|
||||
import { SendCampaignDto } from './dto/send-campaign.dto';
|
||||
import { CampaignRun } from './entities/campaign-run.entity';
|
||||
import { CampaignTypeTemplate } from './entities/campaign-type-template.entity';
|
||||
import { CampaignRunStatus } from './enums/campaign-run-status.enum';
|
||||
import { CampaignTrigger } from './enums/campaign-trigger.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 {
|
||||
type: CampaignType;
|
||||
@@ -59,6 +72,7 @@ export class CampaignsService {
|
||||
private readonly salonSubscriptionsService: SalonSubscriptionsService,
|
||||
private readonly salonNotificationsService: SalonNotificationsService,
|
||||
private readonly authorizationService: AuthorizationService,
|
||||
private readonly campaignTypeTemplatesService: CampaignTypeTemplatesService,
|
||||
) {}
|
||||
|
||||
async getOverview(user: AuthUser): Promise<CampaignOverviewItem[]> {
|
||||
@@ -98,6 +112,56 @@ export class CampaignsService {
|
||||
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(
|
||||
type: CampaignType,
|
||||
dto: SendCampaignDto,
|
||||
@@ -126,18 +190,28 @@ export class CampaignsService {
|
||||
throw new BadRequestException('مشتری هدفی برای این کمپین یافت نشد');
|
||||
}
|
||||
|
||||
const sampleMessage = await this.smsDispatchService.resolveMessage(
|
||||
definition.scenarioKey,
|
||||
{},
|
||||
const assignment = await this.campaignTypeTemplatesService.resolveAssignment(
|
||||
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) {
|
||||
throw new BadRequestException(
|
||||
'متن پیامک این کمپین در تنظیمات پیامک ادمین فعال نشده است',
|
||||
);
|
||||
throw new BadRequestException('قالب انتخابشده فعال نیست');
|
||||
}
|
||||
|
||||
const smsParts = await this.calculateCampaignSmsParts(
|
||||
definition.scenarioKey,
|
||||
assignment.templateId,
|
||||
assignment.template.body,
|
||||
discount,
|
||||
salon,
|
||||
targets,
|
||||
);
|
||||
@@ -149,6 +223,9 @@ export class CampaignsService {
|
||||
trigger: CampaignTrigger.MANUAL,
|
||||
targets,
|
||||
triggeredByUserId: user.id,
|
||||
templateId: assignment.templateId,
|
||||
discountPercent: discount.discountPercent,
|
||||
discountAmount: discount.discountAmount,
|
||||
});
|
||||
|
||||
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
|
||||
* manual send endpoint and the automatic (cron-driven) campaigns. SMS balance
|
||||
* 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: {
|
||||
salon: Salon;
|
||||
@@ -165,8 +243,15 @@ export class CampaignsService {
|
||||
trigger: CampaignTrigger;
|
||||
targets: CampaignTargetCustomer[];
|
||||
triggeredByUserId?: string | null;
|
||||
templateId?: string | null;
|
||||
discountPercent?: number | null;
|
||||
discountAmount?: number | null;
|
||||
}): 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(
|
||||
this.campaignRunsRepository.create({
|
||||
@@ -179,13 +264,18 @@ export class CampaignsService {
|
||||
failedCount: 0,
|
||||
estimatedRevenue: this.sumEstimatedRevenue(params.targets),
|
||||
triggeredByUserId: params.triggeredByUserId ?? null,
|
||||
templateId: assignment.templateId,
|
||||
discountPercent: discount.discountPercent,
|
||||
discountAmount: discount.discountAmount,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.dispatchToTargets(
|
||||
run,
|
||||
params.salon,
|
||||
definition.scenarioKey,
|
||||
assignment.templateId,
|
||||
assignment.template.body,
|
||||
discount,
|
||||
params.targets,
|
||||
);
|
||||
|
||||
@@ -228,27 +318,29 @@ export class CampaignsService {
|
||||
}
|
||||
|
||||
async calculateCampaignSmsParts(
|
||||
scenarioKey: (typeof CAMPAIGN_TYPES)[number]['scenarioKey'],
|
||||
templateId: string,
|
||||
templateBody: string,
|
||||
discount: CampaignDiscountFields,
|
||||
salon: Salon,
|
||||
targets: CampaignTargetCustomer[],
|
||||
): Promise<number> {
|
||||
let totalParts = 0;
|
||||
|
||||
for (const target of targets) {
|
||||
const variables: Record<string, string> = {
|
||||
customerName:
|
||||
`${target.firstName} ${target.lastName}`.trim() || 'مشتری',
|
||||
salonName: salon.name,
|
||||
...target.meta,
|
||||
};
|
||||
const variables = buildCampaignMessageVariables(salon, target, discount);
|
||||
|
||||
const message = await this.smsDispatchService.resolveMessage(
|
||||
scenarioKey,
|
||||
const message = await this.smsDispatchService.resolveMessageFromTemplateId(
|
||||
templateId,
|
||||
variables,
|
||||
);
|
||||
|
||||
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> {
|
||||
const run = await this.campaignRunsRepository.findOne({
|
||||
where: { id },
|
||||
relations: { salon: true, triggeredByUser: true },
|
||||
relations: { salon: true, triggeredByUser: true, template: true },
|
||||
});
|
||||
if (!run) {
|
||||
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(
|
||||
run: CampaignRun,
|
||||
salon: Salon,
|
||||
scenarioKey: (typeof CAMPAIGN_TYPES)[number]['scenarioKey'],
|
||||
templateId: string,
|
||||
templateBody: string,
|
||||
discount: CampaignDiscountFields,
|
||||
targets: CampaignTargetCustomer[],
|
||||
): Promise<void> {
|
||||
let sentCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const target of targets) {
|
||||
const variables: Record<string, string> = {
|
||||
customerName:
|
||||
`${target.firstName} ${target.lastName}`.trim() || 'مشتری',
|
||||
salonName: salon.name,
|
||||
...target.meta,
|
||||
};
|
||||
const variables = buildCampaignMessageVariables(salon, target, discount);
|
||||
|
||||
const message = await this.smsDispatchService.resolveMessage(
|
||||
scenarioKey,
|
||||
const rawMessage = await this.smsDispatchService.resolveMessageFromTemplateId(
|
||||
templateId,
|
||||
variables,
|
||||
);
|
||||
const message = rawMessage
|
||||
? applyCampaignDiscountFallback(rawMessage, templateBody, discount)
|
||||
: rawMessage;
|
||||
|
||||
let status = SentMessageStatus.FAILED;
|
||||
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) {
|
||||
try {
|
||||
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 (1–100)' })
|
||||
@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;
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
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({
|
||||
description:
|
||||
'Optional subset of currently targeted customer ids to send to. When omitted, all current targets receive the campaign.',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Salon } from '../../salons/entities/salon.entity';
|
||||
import { SmsTemplate } from '../../sms-templates/entities/sms-template.entity';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { CampaignRunStatus } from '../enums/campaign-run-status.enum';
|
||||
import { CampaignTrigger } from '../enums/campaign-trigger.enum';
|
||||
@@ -77,6 +78,29 @@ export class CampaignRun {
|
||||
@JoinColumn({ name: 'triggeredByUserId' })
|
||||
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()
|
||||
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}`;
|
||||
}
|
||||
@@ -13,5 +13,6 @@ export default new DataSource({
|
||||
database: process.env.DB_NAME,
|
||||
entities: [join(__dirname, '../**/*.entity.{js,ts}')],
|
||||
migrations: [join(__dirname, '/migrations/*.{js,ts}')],
|
||||
migrationsTransactionMode: 'each',
|
||||
...(isCompiled ? {} : { requireTsNode: true }),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,16 @@ import {
|
||||
TableIndex,
|
||||
} from 'typeorm';
|
||||
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 {
|
||||
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(
|
||||
`
|
||||
INSERT INTO sms_scenario_settings (key, body, "isActive")
|
||||
VALUES ($1, $2, true)
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`,
|
||||
[scenario.key, scenario.defaultBody],
|
||||
);
|
||||
|
||||
@@ -6,6 +6,17 @@ import {
|
||||
TableIndex,
|
||||
} from 'typeorm';
|
||||
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 {
|
||||
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(
|
||||
`
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
* value in the same transaction it was created in, so seeding the actual
|
||||
* scenario rows is handled by the follow-up `SeedDepositScenarios` migration.
|
||||
* `transaction = false` commits ADD VALUE immediately (required by Postgres).
|
||||
*/
|
||||
export class AddDepositScenarios1722000000000 implements MigrationInterface {
|
||||
name = 'AddDepositScenarios1722000000000';
|
||||
transaction = false;
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,8 @@ export function getTypeOrmOptions(): TypeOrmModuleOptions {
|
||||
// by both reserves and reserve_status_history). Use migrations instead.
|
||||
synchronize: false,
|
||||
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
@@ -37,6 +37,11 @@ async function bootstrap() {
|
||||
app.enableCors();
|
||||
}
|
||||
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
const port = Number(process.env.PORT ?? 4000);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
console.log(`grow-api listening on 0.0.0.0:${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
bootstrap().catch((error) => {
|
||||
console.error('Failed to start grow-api:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -27,6 +27,8 @@ import { SalonSubscriptionsService } from '../subscriptions/salon-subscriptions.
|
||||
import { UploaderService } from '../uploader/uploader.service';
|
||||
import { UserRole } from '../users/enums/user-role.enum';
|
||||
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 { ReviewDepositDto } from './dto/review-deposit.dto';
|
||||
import { Payment } from './entities/payment.entity';
|
||||
@@ -35,6 +37,8 @@ import { PaymentStatus } from './enums/payment-status.enum';
|
||||
import { PaymentType } from './enums/payment-type.enum';
|
||||
|
||||
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()
|
||||
export class CardToCardDepositService {
|
||||
@@ -146,6 +150,8 @@ export class CardToCardDepositService {
|
||||
const variables = buildReserveSmsVariables(reserve);
|
||||
const mobile = reserve.customer?.user?.mobile;
|
||||
if (mobile) {
|
||||
await this.delay(DEPOSIT_SMS_DELAY_MS);
|
||||
|
||||
await this.sendDepositSms(
|
||||
SmsScenarioKey.DEPOSIT_PAYMENT_REQUEST,
|
||||
mobile,
|
||||
@@ -249,6 +255,43 @@ export class CardToCardDepositService {
|
||||
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> {
|
||||
const payment = await this.loadPaymentWithRelations(paymentId);
|
||||
this.assertCanReview(user, payment);
|
||||
@@ -376,6 +419,10 @@ export class CardToCardDepositService {
|
||||
return this.smsService.sendSms(mobile, message);
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private tomanToRial(amountToman: number): number {
|
||||
return Math.round(amountToman * 10);
|
||||
}
|
||||
@@ -383,4 +430,23 @@ export class CardToCardDepositService {
|
||||
private rialToToman(amountRial: number): number {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
@@ -25,6 +26,8 @@ import { ApiPublicEndpoint } from '../swagger/decorators/swagger-auth.decorator'
|
||||
import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup';
|
||||
import { UserRole } from '../users/enums/user-role.enum';
|
||||
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 { ReviewDepositDto } from './dto/review-deposit.dto';
|
||||
import { Payment } from './entities/payment.entity';
|
||||
@@ -84,6 +87,18 @@ export class DepositsController {
|
||||
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)
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.SALON)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PaymentStatus } from '../enums/payment-status.enum';
|
||||
|
||||
export class DepositListItemDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ enum: PaymentStatus })
|
||||
status: PaymentStatus;
|
||||
|
||||
@ApiProperty({ example: 500000 })
|
||||
amountToman: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
receiptUrl: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({ example: 'مریم احمدی' })
|
||||
customerName: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, example: '09121234567' })
|
||||
customerMobile: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
appointmentDate: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
startTime: string | null;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import { PaymentStatus } from '../enums/payment-status.enum';
|
||||
|
||||
export class FindDepositsQueryDto {
|
||||
@IsOptional()
|
||||
@IsEnum(PaymentStatus)
|
||||
status?: PaymentStatus;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset?: number;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ServiceSkill } from '../entities/service-skill.entity';
|
||||
|
||||
export class ImportServiceSkillRowErrorDto {
|
||||
@ApiProperty({ example: 3 })
|
||||
row: number;
|
||||
|
||||
@ApiProperty({ example: 'عنوان مهارت الزامی است' })
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class ImportServiceSkillsResultDto {
|
||||
@ApiProperty({ example: 5 })
|
||||
created: number;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
failed: number;
|
||||
|
||||
@ApiProperty({ type: [ServiceSkill] })
|
||||
skills: ServiceSkill[];
|
||||
|
||||
@ApiProperty({ type: [ImportServiceSkillRowErrorDto] })
|
||||
errors: ImportServiceSkillRowErrorDto[];
|
||||
}
|
||||
@@ -8,9 +8,21 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} 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 { Public } from '../auth/decorators/public.decorator';
|
||||
import { Permissions } from '../auth/decorators/permissions.decorator';
|
||||
@@ -22,6 +34,7 @@ import { PermissionCode } from '../authorization/enums/permission-code.enum';
|
||||
import { UserRole } from '../users/enums/user-role.enum';
|
||||
import { CreateServiceSkillDto } from './dto/create-service-skill.dto';
|
||||
import { CreateServiceDto } from './dto/create-service.dto';
|
||||
import { ImportServiceSkillsResultDto } from './dto/import-service-skills-result.dto';
|
||||
import {
|
||||
CreateSalonServiceDto,
|
||||
FindSalonServicesQueryDto,
|
||||
@@ -39,6 +52,8 @@ import {
|
||||
import { ServiceSkill } from './entities/service-skill.entity';
|
||||
import { Service } from './entities/service.entity';
|
||||
|
||||
const MAX_EXCEL_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
@ApiTags('Services')
|
||||
@ApiBearerAuth(SWAGGER_JWT_AUTH)
|
||||
@ApiStandardController()
|
||||
@@ -99,6 +114,24 @@ export class ServicesController {
|
||||
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()
|
||||
@ApiPublicEndpoint('Get service by ID')
|
||||
@ApiStandardOkResponse(Service)
|
||||
@@ -146,6 +179,38 @@ export class ServicesController {
|
||||
);
|
||||
}
|
||||
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Permissions(PermissionCode.SERVICES_WRITE)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['file'],
|
||||
properties: {
|
||||
file: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
description: 'Excel file (.xlsx) with skill title and renewalDays columns',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiStandardOkResponse(ImportServiceSkillsResultDto)
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: MAX_EXCEL_SIZE_BYTES },
|
||||
}),
|
||||
)
|
||||
@Post(':serviceId/skills/import')
|
||||
importSkills(
|
||||
@Param('serviceId', ParseUUIDPipe) serviceId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.servicesService.importSkillsFromExcel(serviceId, file, user);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ApiPublicEndpoint('List skills for a service')
|
||||
@ApiStandardOkResponse(ServiceSkill, { isArray: true })
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { ServicePolicy } from '../authorization/policies/service.policy';
|
||||
import { AuthUser } from '../auth/interfaces/auth-user.interface';
|
||||
@@ -13,6 +14,7 @@ import { Salon } from '../salons/entities/salon.entity';
|
||||
import { UserRole } from '../users/enums/user-role.enum';
|
||||
import { CreateServiceSkillDto } from './dto/create-service-skill.dto';
|
||||
import { CreateServiceDto } from './dto/create-service.dto';
|
||||
import { ImportServiceSkillsResultDto } from './dto/import-service-skills-result.dto';
|
||||
import {
|
||||
CreateSalonServiceDto,
|
||||
FindSalonServicesQueryDto,
|
||||
@@ -23,6 +25,27 @@ import { SalonServiceOffering } from './entities/salon-service-offering.entity';
|
||||
import { ServiceSkill } from './entities/service-skill.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()
|
||||
export class ServicesService {
|
||||
constructor(
|
||||
@@ -97,6 +120,97 @@ export class ServicesService {
|
||||
return this.findSkill(serviceId, saved.id);
|
||||
}
|
||||
|
||||
async buildSkillsImportTemplate(): Promise<Buffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('مهارتها');
|
||||
|
||||
sheet.columns = [
|
||||
{ header: 'عنوان مهارت', key: 'title', width: 40 },
|
||||
{ header: 'دوره بازگشت (روز)', key: 'renewalDays', width: 22 },
|
||||
];
|
||||
|
||||
sheet.getRow(1).font = { bold: true };
|
||||
SAMPLE_SKILL_ROWS.forEach((row) => sheet.addRow(row));
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
async importSkillsFromExcel(
|
||||
serviceId: string,
|
||||
file: Express.Multer.File | undefined,
|
||||
user: AuthUser,
|
||||
): Promise<ImportServiceSkillsResultDto> {
|
||||
this.ensureCanManage(user);
|
||||
await this.findOne(serviceId);
|
||||
this.ensureExcelFile(file);
|
||||
|
||||
const rows = await this.parseSkillsExcel(file.buffer);
|
||||
if (rows.length === 0) {
|
||||
throw new BadRequestException('فایل اکسل هیچ ردیف معتبری ندارد');
|
||||
}
|
||||
|
||||
const errors: ImportServiceSkillsResultDto['errors'] = [];
|
||||
const toCreate: Array<{ title: string; renewalDays: number }> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const title = row.title?.trim() ?? '';
|
||||
const renewalDays = row.renewalDays;
|
||||
|
||||
if (!title) {
|
||||
errors.push({ row: row.rowNumber, message: 'عنوان مهارت الزامی است' });
|
||||
continue;
|
||||
}
|
||||
if (title.length > 200) {
|
||||
errors.push({
|
||||
row: row.rowNumber,
|
||||
message: 'عنوان مهارت نباید بیشتر از ۲۰۰ کاراکتر باشد',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
renewalDays === null ||
|
||||
!Number.isInteger(renewalDays) ||
|
||||
renewalDays < 1
|
||||
) {
|
||||
errors.push({
|
||||
row: row.rowNumber,
|
||||
message: 'دوره بازگشت باید عدد صحیح و حداقل ۱ باشد',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
toCreate.push({ title, renewalDays });
|
||||
}
|
||||
|
||||
if (toCreate.length === 0) {
|
||||
throw new BadRequestException(
|
||||
errors.length > 0
|
||||
? errors
|
||||
.map((error) => `ردیف ${error.row}: ${error.message}`)
|
||||
.join('؛ ')
|
||||
: 'هیچ ردیف معتبری برای ثبت یافت نشد',
|
||||
);
|
||||
}
|
||||
|
||||
const saved = await this.serviceSkillsRepository.manager.transaction(
|
||||
async (manager) => {
|
||||
const repository = manager.getRepository(ServiceSkill);
|
||||
const entities = toCreate.map((item) =>
|
||||
repository.create({ ...item, serviceId }),
|
||||
);
|
||||
return repository.save(entities);
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
created: saved.length,
|
||||
failed: errors.length,
|
||||
skills: saved,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
async findSkills(serviceId: string): Promise<ServiceSkill[]> {
|
||||
await this.ensureServiceExists(serviceId);
|
||||
return this.serviceSkillsRepository.find({
|
||||
@@ -263,4 +377,115 @@ export class ServicesService {
|
||||
throw new NotFoundException(`Service #${serviceId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureExcelFile(file: Express.Multer.File | undefined): asserts file is Express.Multer.File {
|
||||
if (!file) {
|
||||
throw new BadRequestException('فایل اکسل الزامی است');
|
||||
}
|
||||
|
||||
const extension = file.originalname.split('.').pop()?.toLowerCase();
|
||||
const isExcelExtension = extension === 'xlsx' || extension === 'xls';
|
||||
const isExcelMime = EXCEL_MIME_TYPES.has(file.mimetype);
|
||||
|
||||
if (!isExcelExtension && !isExcelMime) {
|
||||
throw new BadRequestException('فقط فایلهای Excel (.xlsx) مجاز هستند');
|
||||
}
|
||||
}
|
||||
|
||||
private async parseSkillsExcel(
|
||||
buffer: Buffer,
|
||||
): Promise<
|
||||
Array<{ rowNumber: number; title: string | null; renewalDays: number | null }>
|
||||
> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
// exceljs Buffer typings conflict with Node 22+ Buffer generics
|
||||
await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer);
|
||||
const sheet = workbook.worksheets[0];
|
||||
|
||||
if (!sheet) {
|
||||
throw new BadRequestException('فایل اکسل خالی است');
|
||||
}
|
||||
|
||||
const headerRow = sheet.getRow(1);
|
||||
let titleCol = 0;
|
||||
let renewalCol = 0;
|
||||
|
||||
headerRow.eachCell((cell, colNumber) => {
|
||||
const header = this.normalizeHeader(cell.value);
|
||||
if (TITLE_HEADERS.has(header)) {
|
||||
titleCol = colNumber;
|
||||
}
|
||||
if (RENEWAL_HEADERS.has(header)) {
|
||||
renewalCol = colNumber;
|
||||
}
|
||||
});
|
||||
|
||||
if (!titleCol || !renewalCol) {
|
||||
throw new BadRequestException(
|
||||
'ستونهای «عنوان مهارت» و «دوره بازگشت (روز)» در ردیف اول الزامی هستند',
|
||||
);
|
||||
}
|
||||
|
||||
const rows: Array<{
|
||||
rowNumber: number;
|
||||
title: string | null;
|
||||
renewalDays: number | null;
|
||||
}> = [];
|
||||
|
||||
sheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const titleValue = this.cellToString(row.getCell(titleCol).value);
|
||||
const renewalValue = this.cellToNumber(row.getCell(renewalCol).value);
|
||||
|
||||
if (!titleValue && renewalValue === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
rowNumber,
|
||||
title: titleValue,
|
||||
renewalDays: renewalValue,
|
||||
});
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private normalizeHeader(value: ExcelJS.CellValue): string {
|
||||
const text = this.cellToString(value)?.trim().toLowerCase() ?? '';
|
||||
return text.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
private cellToString(value: ExcelJS.CellValue): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value).trim() || null;
|
||||
}
|
||||
if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
|
||||
return value.text.trim() || null;
|
||||
}
|
||||
if (typeof value === 'object' && 'result' in value) {
|
||||
return this.cellToString(value.result as ExcelJS.CellValue);
|
||||
}
|
||||
return String(value).trim() || null;
|
||||
}
|
||||
|
||||
private cellToNumber(value: ExcelJS.CellValue): number | null {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'object' && 'result' in value) {
|
||||
return this.cellToNumber(value.result as ExcelJS.CellValue);
|
||||
}
|
||||
const parsed = Number(String(value).trim());
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ export interface SmsScenarioDefinition {
|
||||
variables: SmsScenarioVariable[];
|
||||
}
|
||||
|
||||
export const CAMPAIGN_DISCOUNT_VARIABLE: SmsScenarioVariable = {
|
||||
key: 'discount',
|
||||
label: 'تخفیف',
|
||||
description: 'متن تخفیف تعریفشده برای قالب کمپین (مثلاً ۲۰٪ تخفیف)',
|
||||
sampleValue: '۲۰٪ تخفیف',
|
||||
};
|
||||
|
||||
export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
|
||||
{
|
||||
key: SmsScenarioKey.OTP_LOGIN,
|
||||
@@ -255,6 +262,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
|
||||
description: 'تعداد روز باقیمانده تا موعد تجدید (۰ یعنی امروز)',
|
||||
sampleValue: '۴',
|
||||
},
|
||||
CAMPAIGN_DISCOUNT_VARIABLE,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -277,6 +285,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
|
||||
description: 'نام سالن زیبایی',
|
||||
sampleValue: 'سالن زیبایی رز',
|
||||
},
|
||||
CAMPAIGN_DISCOUNT_VARIABLE,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -305,6 +314,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
|
||||
description: 'تعداد روزهایی که از آخرین مراجعه مشتری گذشته است',
|
||||
sampleValue: '۴۰',
|
||||
},
|
||||
CAMPAIGN_DISCOUNT_VARIABLE,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -333,6 +343,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
|
||||
description: 'مجموع خرید مشتری تا کنون (تومان)',
|
||||
sampleValue: '۱۵,۰۰۰,۰۰۰',
|
||||
},
|
||||
CAMPAIGN_DISCOUNT_VARIABLE,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -361,6 +372,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
|
||||
description: 'تعداد روزهایی که از آخرین مراجعه مشتری گذشته است',
|
||||
sampleValue: '۹۰',
|
||||
},
|
||||
CAMPAIGN_DISCOUNT_VARIABLE,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -389,6 +401,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
|
||||
description: 'نام خدمت مکمل پیشنهادی',
|
||||
sampleValue: 'کراتین',
|
||||
},
|
||||
CAMPAIGN_DISCOUNT_VARIABLE,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -63,6 +63,18 @@ export class SmsDispatchService {
|
||||
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> {
|
||||
const body = await this.resolvePreviewBody(dto);
|
||||
const variables = getSampleVariables(dto.key);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { SmsScenarioSetting } from './entities/sms-scenario-setting.entity';
|
||||
import { SmsScenarioKey } from './enums/sms-scenario-key.enum';
|
||||
import { SmsDispatchService } from './sms-dispatch.service';
|
||||
import { isCampaignSmsScenarioKey } from './utils/is-campaign-sms-scenario.util';
|
||||
|
||||
export interface SmsScenarioSettingResponse {
|
||||
key: SmsScenarioKey;
|
||||
@@ -42,14 +43,20 @@ export class SmsSettingsService {
|
||||
) {}
|
||||
|
||||
getScenarioDefinitions(): SmsScenarioDefinition[] {
|
||||
return this.smsDispatchService.getScenarioDefinitions();
|
||||
return this.smsDispatchService
|
||||
.getScenarioDefinitions()
|
||||
.filter((definition) => !isCampaignSmsScenarioKey(definition.key));
|
||||
}
|
||||
|
||||
async findAll(user: AuthUser): Promise<SmsScenarioSettingResponse[]> {
|
||||
this.ensureCanManage(user);
|
||||
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(
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@@ -19,4 +27,10 @@ export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsEnum(UserRole)
|
||||
role?: UserRole;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value !== null)
|
||||
@IsString()
|
||||
@IsUrl()
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ export class User {
|
||||
@Column({ type: 'enum', enum: UserRole, default: UserRole.CUSTOMER })
|
||||
role: UserRole;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
avatarUrl: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
|
||||
Reference in New Issue
Block a user