first commit v3 initialiazed a temporary repository for darmanet client

This commit is contained in:
2026-09-08 16:04:01 +03:30
commit 5c7fd95052
216 changed files with 30050 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
import {
ExpertPreparedMessageCategory,
EXPERT_PREPARED_MESSAGE_CATEGORIES,
} from 'src/common/types/expert-prepared-message-category.type';
export class CreateExpertPreparedMessageDto {
@ApiProperty({
example: 'سلام، چطور می‌تونم کمکتون کنم؟',
description: 'Full text inserted into the chat when the expert selects this snippet',
})
@IsString()
@IsNotEmpty()
@MaxLength(2000)
message: string;
@ApiProperty({
enum: EXPERT_PREPARED_MESSAGE_CATEGORIES,
example: ExpertPreparedMessageCategory.Greetings,
})
@IsEnum(ExpertPreparedMessageCategory)
category: ExpertPreparedMessageCategory;
@ApiPropertyOptional({
example: 'سلام',
description: 'Short label shown on the chip/button in the expert chat UI',
})
@IsOptional()
@IsString()
@MaxLength(120)
label?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isEnabled?: boolean;
@ApiPropertyOptional({ default: 0, description: 'Lower values appear first within a category' })
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
@ApiPropertyOptional({ default: 'fa', example: 'fa' })
@IsOptional()
@IsString()
@MaxLength(10)
locale?: string;
}

View File

@@ -0,0 +1,23 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
import {
ExpertPreparedMessageCategory,
EXPERT_PREPARED_MESSAGE_CATEGORIES,
} from 'src/common/types/expert-prepared-message-category.type';
export class ExpertPreparedMessagesLocaleQueryDto {
@ApiPropertyOptional({ default: 'fa', example: 'fa' })
@IsOptional()
@IsString()
@MaxLength(10)
locale?: string;
}
export class ExpertPreparedMessagesByCategoryQueryDto extends ExpertPreparedMessagesLocaleQueryDto {
@ApiProperty({
enum: EXPERT_PREPARED_MESSAGE_CATEGORIES,
example: ExpertPreparedMessageCategory.Greetings,
})
@IsEnum(ExpertPreparedMessageCategory)
category: ExpertPreparedMessageCategory;
}

View File

@@ -0,0 +1,30 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
import { PageOptionsDto } from 'src/common/dto/base-response.dto';
import {
ExpertPreparedMessageCategory,
EXPERT_PREPARED_MESSAGE_CATEGORIES,
} from 'src/common/types/expert-prepared-message-category.type';
export class ListExpertPreparedMessagesQueryDto extends PageOptionsDto {
@ApiPropertyOptional({ enum: EXPERT_PREPARED_MESSAGE_CATEGORIES })
@IsOptional()
@IsEnum(ExpertPreparedMessageCategory)
category?: ExpertPreparedMessageCategory;
@ApiPropertyOptional({ description: 'Filter by enabled flag' })
@IsOptional()
@Transform(({ value }) => {
if (value === 'true' || value === true) return true;
if (value === 'false' || value === false) return false;
return value;
})
@IsBoolean()
isEnabled?: boolean;
@ApiPropertyOptional({ example: 'fa' })
@IsOptional()
@IsString()
locale?: string;
}

View File

@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsInt,
IsMongoId,
IsNotEmpty,
Min,
ValidateNested,
} from 'class-validator';
export class ReorderExpertPreparedMessageItemDto {
@ApiProperty({ example: '507f1f77bcf86cd799439011' })
@IsMongoId()
@IsNotEmpty()
id: string;
@ApiProperty({ example: 0 })
@IsInt()
@Min(0)
sortOrder: number;
}
export class ReorderExpertPreparedMessagesDto {
@ApiProperty({ type: [ReorderExpertPreparedMessageItemDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ReorderExpertPreparedMessageItemDto)
items: ReorderExpertPreparedMessageItemDto[];
}

View File

@@ -0,0 +1,6 @@
import { PartialType } from '@nestjs/swagger';
import { CreateExpertPreparedMessageDto } from './create-expert-prepared-message.dto';
export class UpdateExpertPreparedMessageDto extends PartialType(
CreateExpertPreparedMessageDto,
) {}

View File

@@ -0,0 +1,126 @@
import {
Body,
Controller,
Delete,
Get,
HttpException,
HttpStatus,
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { AdminGuard } from 'src/auth/guards/admin.guard';
import { AdminIdentity } from 'src/common/decorators/Identity.decorator';
import { Permissions } from 'src/common/decorators/permission.decorator';
import { Permission } from 'src/common/types/permissions.catalog';
import { AdminModel } from 'src/database/model/admin.model';
import { CreateExpertPreparedMessageDto } from './dto/create-expert-prepared-message.dto';
import { ListExpertPreparedMessagesQueryDto } from './dto/list-expert-prepared-messages-query.dto';
import { ReorderExpertPreparedMessagesDto } from './dto/reorder-expert-prepared-messages.dto';
import { UpdateExpertPreparedMessageDto } from './dto/update-expert-prepared-message.dto';
import { ExpertPreparedMessagesService } from './expert-prepared-messages.service';
@ApiBearerAuth()
@UseGuards(AdminGuard)
@Permissions(Permission.PreparedMessagesManage)
@ApiTags('expert-prepared-messages (admin)')
@Controller('admin/expert-prepared-messages')
export class ExpertPreparedMessagesAdminController {
constructor(
private readonly preparedMessagesService: ExpertPreparedMessagesService,
) {}
@Get('categories')
@ApiOperation({ summary: 'List available prepared-message categories' })
getCategories() {
return {
statusCode: HttpStatus.OK,
message: 'SUCCESS',
data: this.preparedMessagesService.getCategories(),
};
}
@Get()
@ApiOperation({
summary: 'List all prepared messages for expert online chat (admin management)',
})
findAll(@Query() query: ListExpertPreparedMessagesQueryDto) {
return this.preparedMessagesService.findAllForAdmin(query);
}
@Post()
@ApiOperation({ summary: 'Create a prepared message snippet' })
create(
@Body() dto: CreateExpertPreparedMessageDto,
@AdminIdentity() admin: AdminModel,
) {
return this.preparedMessagesService.create(dto, admin.username);
}
@Put(':id')
@ApiOperation({ summary: 'Update a prepared message snippet' })
@ApiParam({ name: 'id' })
update(
@Param('id') id: string,
@Body() dto: UpdateExpertPreparedMessageDto,
@AdminIdentity() admin: AdminModel,
) {
try {
return this.preparedMessagesService.update(id, dto, admin.username);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new HttpException(
error.message || 'Failed to update prepared message',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Patch('reorder')
@ApiOperation({ summary: 'Bulk update sort order for prepared messages' })
reorder(
@Body() dto: ReorderExpertPreparedMessagesDto,
@AdminIdentity() admin: AdminModel,
) {
return this.preparedMessagesService.reorder(dto, admin.username);
}
@Patch(':id/toggle')
@ApiOperation({ summary: 'Enable or disable a prepared message snippet' })
@ApiParam({ name: 'id' })
toggle(@Param('id') id: string, @AdminIdentity() admin: AdminModel) {
try {
return this.preparedMessagesService.toggleEnabled(id, admin.username);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new HttpException(
error.message || 'Failed to toggle prepared message',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a prepared message snippet' })
@ApiParam({ name: 'id' })
remove(@Param('id') id: string) {
try {
return this.preparedMessagesService.remove(id);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new HttpException(
error.message || 'Failed to delete prepared message',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

View File

@@ -0,0 +1,72 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { AdminGuard } from 'src/auth/guards/admin.guard';
import { Permissions } from 'src/common/decorators/permission.decorator';
import { Permission } from 'src/common/types/permissions.catalog';
import {
ExpertPreparedMessagesByCategoryQueryDto,
ExpertPreparedMessagesLocaleQueryDto,
} from './dto/expert-prepared-messages-query.dto';
import { ExpertPreparedMessagesService } from './expert-prepared-messages.service';
@ApiBearerAuth()
@UseGuards(AdminGuard)
@Permissions(Permission.PreparedMessagesRead)
@ApiTags('expert-prepared-messages (expert)')
@Controller('expert/prepared-messages')
export class ExpertPreparedMessagesExpertController {
constructor(
private readonly preparedMessagesService: ExpertPreparedMessagesService,
) {}
@Get('categories')
@ApiOperation({
summary:
'List prepared-message categories that have at least one enabled message',
})
@ApiQuery({
name: 'locale',
required: false,
example: 'fa',
description: 'Locale filter (defaults to fa)',
})
findCategories(@Query() query: ExpertPreparedMessagesLocaleQueryDto) {
return this.preparedMessagesService.findEnabledCategoriesForExpert(
query.locale || 'fa',
);
}
@Get()
@ApiOperation({
summary: 'Get enabled prepared messages for a specific category',
})
@ApiQuery({
name: 'category',
required: true,
enum: [
'greetings',
'quick_answers',
'closing',
'follow_up',
'apology',
'other',
],
})
@ApiQuery({
name: 'locale',
required: false,
example: 'fa',
description: 'Locale filter (defaults to fa)',
})
findByCategory(@Query() query: ExpertPreparedMessagesByCategoryQueryDto) {
return this.preparedMessagesService.findEnabledByCategoryForExpert(
query.category,
query.locale || 'fa',
);
}
}

View File

@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { DatabaseModule } from 'src/database/database.module';
import {
ExpertPreparedMessageModel,
ExpertPreparedMessageSchema,
} from 'src/database/model/expert-prepared-message.model';
import { ExpertPreparedMessagesAdminController } from './expert-prepared-messages-admin.controller';
import { ExpertPreparedMessagesExpertController } from './expert-prepared-messages-expert.controller';
import { ExpertPreparedMessagesService } from './expert-prepared-messages.service';
@Module({
imports: [
DatabaseModule,
MongooseModule.forFeature([
{
name: ExpertPreparedMessageModel.name,
schema: ExpertPreparedMessageSchema,
},
]),
],
controllers: [
ExpertPreparedMessagesAdminController,
ExpertPreparedMessagesExpertController,
],
providers: [ExpertPreparedMessagesService],
exports: [ExpertPreparedMessagesService],
})
export class ExpertPreparedMessagesModule {}

View File

@@ -0,0 +1,263 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { FilterQuery, Model } from 'mongoose';
import {
BaseResponseDTO,
PageMetaDto,
} from 'src/common/dto/base-response.dto';
import {
EXPERT_PREPARED_MESSAGE_CATEGORIES,
EXPERT_PREPARED_MESSAGE_CATEGORY_LABELS,
ExpertPreparedMessageCategory,
} from 'src/common/types/expert-prepared-message-category.type';
import { ExpertPreparedMessageModel } from 'src/database/model/expert-prepared-message.model';
import { CreateExpertPreparedMessageDto } from './dto/create-expert-prepared-message.dto';
import { ListExpertPreparedMessagesQueryDto } from './dto/list-expert-prepared-messages-query.dto';
import { ReorderExpertPreparedMessagesDto } from './dto/reorder-expert-prepared-messages.dto';
import { UpdateExpertPreparedMessageDto } from './dto/update-expert-prepared-message.dto';
export type ExpertPreparedMessageItem = {
id: string;
message: string;
label: string;
category: ExpertPreparedMessageCategory;
sortOrder: number;
locale: string;
};
export type ExpertPreparedMessageCategorySummary = {
value: ExpertPreparedMessageCategory;
label: string;
messageCount: number;
};
@Injectable()
export class ExpertPreparedMessagesService {
constructor(
@InjectModel(ExpertPreparedMessageModel.name)
private readonly preparedMessageModel: Model<ExpertPreparedMessageModel>,
) {}
getCategories() {
return EXPERT_PREPARED_MESSAGE_CATEGORIES.map((value) => ({
value,
label: EXPERT_PREPARED_MESSAGE_CATEGORY_LABELS[value],
}));
}
async findAllForAdmin(query: ListExpertPreparedMessagesQueryDto) {
const filter: FilterQuery<ExpertPreparedMessageModel> = {};
if (query.category) filter.category = query.category;
if (query.isEnabled !== undefined) filter.isEnabled = query.isEnabled;
if (query.locale) filter.locale = query.locale;
const [items, itemCount] = await Promise.all([
this.preparedMessageModel
.find(filter)
.sort({ category: 1, sortOrder: 1, createdAt: 1 })
.skip(query.skip)
.limit(query.take)
.lean()
.exec(),
this.preparedMessageModel.countDocuments(filter).exec(),
]);
const meta = new PageMetaDto({ pageOptionsDto: query, itemCount });
return new BaseResponseDTO(
HttpStatus.OK,
'Expert prepared messages retrieved successfully',
items.map((doc) => this.toAdminDto(doc)),
meta,
);
}
async findEnabledCategoriesForExpert(locale = 'fa') {
const counts = await this.preparedMessageModel
.aggregate<{ _id: ExpertPreparedMessageCategory; count: number }>([
{ $match: { isEnabled: true, locale } },
{ $group: { _id: '$category', count: { $sum: 1 } } },
])
.exec();
const countByCategory = new Map(
counts.map((row) => [row._id, row.count]),
);
const categories: ExpertPreparedMessageCategorySummary[] =
EXPERT_PREPARED_MESSAGE_CATEGORIES.filter((value) =>
countByCategory.has(value),
).map((value) => ({
value,
label: EXPERT_PREPARED_MESSAGE_CATEGORY_LABELS[value],
messageCount: countByCategory.get(value) ?? 0,
}));
return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', {
locale,
categories,
});
}
async findEnabledByCategoryForExpert(
category: ExpertPreparedMessageCategory,
locale = 'fa',
) {
const docs = await this.preparedMessageModel
.find({ isEnabled: true, locale, category })
.sort({ sortOrder: 1, createdAt: 1 })
.lean()
.exec();
return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', {
locale,
category,
label: EXPERT_PREPARED_MESSAGE_CATEGORY_LABELS[category],
messages: docs.map((doc) => this.toExpertItem(doc)),
});
}
async create(dto: CreateExpertPreparedMessageDto, adminUsername: string) {
const sortOrder =
dto.sortOrder ??
(await this.nextSortOrderForCategory(dto.category, dto.locale ?? 'fa'));
const created = await this.preparedMessageModel.create({
message: dto.message.trim(),
category: dto.category,
label: (dto.label ?? '').trim(),
isEnabled: dto.isEnabled ?? true,
sortOrder,
locale: (dto.locale ?? 'fa').trim(),
createdBy: adminUsername,
updatedBy: adminUsername,
});
return new BaseResponseDTO(
HttpStatus.CREATED,
'Expert prepared message created successfully',
this.toAdminDto(created.toObject()),
);
}
async update(
id: string,
dto: UpdateExpertPreparedMessageDto,
adminUsername: string,
) {
const existing = await this.preparedMessageModel.findById(id).exec();
if (!existing) {
throw new HttpException(
'prepared_message_not_found',
HttpStatus.NOT_FOUND,
);
}
if (dto.message !== undefined) existing.message = dto.message.trim();
if (dto.category !== undefined) existing.category = dto.category;
if (dto.label !== undefined) existing.label = dto.label.trim();
if (dto.isEnabled !== undefined) existing.isEnabled = dto.isEnabled;
if (dto.sortOrder !== undefined) existing.sortOrder = dto.sortOrder;
if (dto.locale !== undefined) existing.locale = dto.locale.trim();
existing.updatedBy = adminUsername;
const saved = await existing.save();
return new BaseResponseDTO(
HttpStatus.OK,
'Expert prepared message updated successfully',
this.toAdminDto(saved.toObject()),
);
}
async toggleEnabled(id: string, adminUsername: string) {
const existing = await this.preparedMessageModel.findById(id).exec();
if (!existing) {
throw new HttpException(
'prepared_message_not_found',
HttpStatus.NOT_FOUND,
);
}
existing.isEnabled = !existing.isEnabled;
existing.updatedBy = adminUsername;
const saved = await existing.save();
return new BaseResponseDTO(
HttpStatus.OK,
'Expert prepared message toggled successfully',
this.toAdminDto(saved.toObject()),
);
}
async reorder(dto: ReorderExpertPreparedMessagesDto, adminUsername: string) {
const bulk = dto.items.map((item) => ({
updateOne: {
filter: { _id: item.id },
update: { $set: { sortOrder: item.sortOrder, updatedBy: adminUsername } },
},
}));
await this.preparedMessageModel.bulkWrite(bulk);
return new BaseResponseDTO(
HttpStatus.OK,
'Expert prepared messages reordered successfully',
null,
);
}
async remove(id: string) {
const result = await this.preparedMessageModel.findByIdAndDelete(id).exec();
if (!result) {
throw new HttpException(
'prepared_message_not_found',
HttpStatus.NOT_FOUND,
);
}
return new BaseResponseDTO(
HttpStatus.OK,
'Expert prepared message deleted successfully',
null,
);
}
private async nextSortOrderForCategory(
category: ExpertPreparedMessageCategory,
locale: string,
): Promise<number> {
const last = await this.preparedMessageModel
.findOne({ category, locale })
.sort({ sortOrder: -1 })
.select({ sortOrder: 1 })
.lean()
.exec();
return (last?.sortOrder ?? -1) + 1;
}
private toExpertItem(doc: any): ExpertPreparedMessageItem {
return {
id: String(doc._id),
message: doc.message,
label: doc.label || doc.message.slice(0, 40),
category: doc.category,
sortOrder: doc.sortOrder ?? 0,
locale: doc.locale ?? 'fa',
};
}
private toAdminDto(doc: any) {
return {
id: String(doc._id),
message: doc.message,
label: doc.label ?? '',
category: doc.category,
isEnabled: doc.isEnabled,
sortOrder: doc.sortOrder ?? 0,
locale: doc.locale ?? 'fa',
createdBy: doc.createdBy ?? null,
updatedBy: doc.updatedBy ?? null,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
};
}
}