chore: add service image and audio

This commit is contained in:
mahyargdz
2025-08-30 09:40:44 +03:30
parent 9869e603d3
commit 9c409bef27
28 changed files with 2111 additions and 1631 deletions
+23 -22
View File
@@ -30,7 +30,8 @@
"prepare": "husky || true"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/client-s3": "^3.879.0",
"@aws-sdk/s3-request-presigner": "^3.879.0",
"@fastify/static": "^8.2.0",
"@keyv/redis": "^4.6.0",
"@nest-lab/fastify-multer": "^1.3.0",
@@ -39,12 +40,12 @@
"@nestjs/axios": "^4.0.1",
"@nestjs/bullmq": "^11.0.3",
"@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.1.5",
"@nestjs/common": "^11.1.6",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.5",
"@nestjs/core": "^11.1.6",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.5",
"@nestjs/platform-fastify": "^11.1.6",
"@nestjs/swagger": "^11.2.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0",
@@ -52,14 +53,14 @@
"aws-sdk": "^2.1692.0",
"axios": "^1.11.0",
"bcrypt": "^5.1.1",
"bullmq": "^5.56.8",
"bullmq": "^5.58.3",
"cache-manager": "^6.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"dayjs": "^1.11.13",
"dayjs": "^1.11.18",
"decimal.js": "^10.6.0",
"dotenv": "^16.6.1",
"fastify": "^5.4.0",
"fastify": "^5.5.0",
"handlebars": "^4.7.8",
"nestjs-soap": "^3.0.4",
"nestjs-telegraf": "^2.9.1",
@@ -69,29 +70,29 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"slugify": "^1.6.6",
"soap": "^1.2.1",
"soap": "^1.3.0",
"telegraf": "^4.16.3",
"typeorm": "^0.3.25"
"typeorm": "^0.3.26"
},
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@nestjs/cli": "^11.0.8",
"@nestjs/schematics": "^11.0.5",
"@nestjs/testing": "^11.1.5",
"@nestjs/cli": "^11.0.10",
"@nestjs/schematics": "^11.0.7",
"@nestjs/testing": "^11.1.6",
"@types/bcrypt": "^5.0.2",
"@types/jest": "^29.5.14",
"@types/node": "^20.19.9",
"@types/nodemailer": "^6.4.17",
"@types/node": "^20.19.11",
"@types/nodemailer": "^6.4.19",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.3",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"eslint": "^9.32.0",
"@typescript-eslint/eslint-plugin": "^8.41.0",
"@typescript-eslint/parser": "^8.41.0",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^3.10.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-prettier": "^5.5.4",
"globals": "^16.3.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
@@ -99,12 +100,12 @@
"prettier": "^3.6.2",
"source-map-support": "^0.5.21",
"supertest": "^7.1.4",
"ts-jest": "^29.4.0",
"ts-loader": "^9.5.2",
"ts-jest": "^29.4.1",
"ts-loader": "^9.5.4",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0"
"typescript": "^5.9.2",
"typescript-eslint": "^8.41.0"
},
"jest": {
"moduleFileExtensions": [
+1500 -1420
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -223,6 +223,11 @@ export const enum ServiceMessage {
GUIDE_ORDER_IS_NUMBER = "ترتیب راهنمای سرویس باید یک عدد صحیح باشد",
GUIDE_IS_ACTIVE_BOOLEAN = "وضعیت راهنمای سرویس باید یک بولین باشد",
GUIDE_TITLE_LENGTH = "عنوان راهنمای سرویس باید بین ۳ تا ۲۰۰ کاراکتر باشد",
IMAGES_SHOULD_BE_URL = "عکس های سرویس باید یک آدرس یو آر ال باشد",
AUDIOS_REQUIRED = "صوت های سرویس مورد نیاز است",
AUDIOS_SHOULD_BE_URL = "صوت های سرویس باید یک آدرس یو آر ال باشد",
VIDEOS_REQUIRED = "ویدیو های سرویس مورد نیاز است",
VIDEOS_SHOULD_BE_URL = "ویدیو های سرویس باید یک آدرس یو آر ال باشد",
}
export const enum AnnouncementMessage {
@@ -863,3 +868,11 @@ export const enum SupportPlanMessage {
SUPPORT_PLAN_ALREADY_SUBSCRIBED = "کاربر قبلا اشتراک ثبت کرده است",
DOWN_GRADE_NOT_ALLOWED = "امکان تغییر به پلن پایین‌تر وجود ندارد",
}
export const enum UploaderMessage {
FILE_TYPE_NOT_ALLOWED = "نوع فایل مجاز نیست",
FILE_SIZE_TOO_SMALL = "فایل کوچکتر از حد مجاز است",
UPLOAD_FILE_INVALID = "فایل معتبر نیست",
UPLOAD_FILE_TOO_LARGE = "[MAX_FILE_SIZE] فایل بزرگتر از حد مجاز است",
UPLOAD_FILE_TYPE_NOT_SUPPORTED = "[MIME_TYPES] نوع فایل مجاز نیست",
}
+20 -17
View File
@@ -1,26 +1,29 @@
import { FactoryProvider } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
export function S3Configs() {
return {
inject: [ConfigService],
useFactory(configService: ConfigService) {
return {
accessKeyId: configService.getOrThrow<string>("BUCKET_ACCESS_KEY"),
secretAccessKey: configService.getOrThrow<string>("BUCKET_SECRET_KEY"),
endpoint: configService.getOrThrow<string>("BUCKET_URL"),
region: configService.getOrThrow<string>("BUCKET_REGION"),
bucket: configService.getOrThrow<string>("BUCKET_NAME"),
url: configService.getOrThrow<string>("BUCKET_UPLOAD_URL"),
};
},
};
}
import { S3_CONFIG } from "../modules/utils/constants";
export const S3ConfigsProvider: FactoryProvider<IS3Configs> = {
provide: S3_CONFIG,
inject: [ConfigService],
useFactory(configService: ConfigService) {
return {
accessKeyId: configService.getOrThrow<string>("BUCKET_ACCESS_KEY"),
secretAccessKey: configService.getOrThrow<string>("BUCKET_SECRET_KEY"),
endpoint: configService.getOrThrow<string>("BUCKET_URL"),
region: configService.getOrThrow<string>("BUCKET_REGION"),
bucketName: configService.getOrThrow<string>("BUCKET_NAME"),
bucketUploadedUrl: configService.getOrThrow<string>("BUCKET_UPLOAD_URL"),
};
},
};
export interface IS3Configs {
accessKeyId: string;
secretAccessKey: string;
endpoint: string;
region: string;
bucket: string;
url: string;
bucketName: string;
bucketUploadedUrl: string;
}
+39 -37
View File
@@ -1,42 +1,44 @@
import { FactoryProvider } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
export function smsConfigs() {
return {
inject: [ConfigService],
useFactory(configService: ConfigService) {
return {
API_URL: configService.getOrThrow<string>("SMS_API_URL"),
API_KEY: configService.getOrThrow<string>("SMS_API_KEY"),
SMS_PATTERN_OTP: configService.getOrThrow<string>("SMS_PATTERN_OTP"),
SMS_PATTERN_INVOICE: configService.getOrThrow<string>("SMS_PATTERN_INVOICE"),
SMS_PATTERN_LOGIN: configService.getOrThrow<string>("SMS_PATTERN_LOGIN"),
SMS_PATTERN_INVOICE_CREATED: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_CREATED"),
SMS_PATTERN_ANNOUNCEMENT: configService.getOrThrow<string>("SMS_PATTERN_ANNOUNCEMENT"),
SMS_PATTERN_WALLET_CHARGE: configService.getOrThrow<string>("SMS_PATTERN_WALLET_CHARGE"),
SMS_PATTERN_WALLET_DEDUCTION: configService.getOrThrow<string>("SMS_PATTERN_WALLET_DEDUCTION"),
SMS_PATTERN_TICKET_CREATED: configService.getOrThrow<string>("SMS_PATTERN_TICKET_CREATED"),
SMS_PATTERN_TICKET_ANSWERED: configService.getOrThrow<string>("SMS_PATTERN_WALLET_DEDUCTION"),
SMS_PATTERN_TICKET_ASSIGNED_ADMIN: configService.getOrThrow<string>("SMS_PATTERN_TICKET_ASSIGNED_ADMIN"),
SMS_PATTERN_INVOICE_APPROVED: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_APPROVED"),
SMS_PATTERN_INVOICE_PAID: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_PAID"),
SMS_PATTERN_RECURRING_INVOICE_DRAFT: configService.getOrThrow<string>("SMS_PATTERN_RECURRING_INVOICE_DRAFT"),
SMS_PATTERN_SUBSCRIPTION_CANCELLED: configService.getOrThrow<string>("SMS_PATTERN_SUBSCRIPTION_CANCELLED"),
SMS_PATTERN_INVOICE_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_REMINDER"),
SMS_PATTERN_INVOICE_OVERDUE: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_OVERDUE"),
SMS_PATTERN_PAYMENT_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_PAYMENT_REMINDER"),
SMS_PATTERN_PAYMENT_CANCELLATION: configService.getOrThrow<string>("SMS_PATTERN_PAYMENT_CANCELLATION"),
//
SMS_PATTERN_BLOG_NEW_COMMENT: configService.getOrThrow<string>("SMS_PATTERN_BLOG_NEW_COMMENT"),
SMS_PATTERN_NEW_SERVICE_REVIEW: configService.getOrThrow<string>("SMS_PATTERN_NEW_SERVICE_REVIEW"),
SMS_PATTERN_NEW_CUSTOMER: configService.getOrThrow<string>("SMS_PATTERN_NEW_CUSTOMER"),
SMS_PATTERN_NEW_SUBSCRIPTION: configService.getOrThrow<string>("SMS_PATTERN_NEW_SUBSCRIPTION"),
SMS_PATTERN_NEW_TICKET_GLOBAL: configService.getOrThrow<string>("SMS_PATTERN_NEW_TICKET_GLOBAL"),
SMS_PATTERN_NEW_CRITICISM: configService.getOrThrow<string>("SMS_PATTERN_NEW_CRITICISM"),
SMS_PATTERN_USER_PASSWORD: configService.getOrThrow<string>("SMS_PATTERN_USER_PASSWORD"),
};
},
};
}
import { SMS_CONFIG } from "../modules/utils/constants";
export const smsConfigsProvider: FactoryProvider<ISmsConfigs> = {
provide: SMS_CONFIG,
inject: [ConfigService],
useFactory(configService: ConfigService) {
return {
API_URL: configService.getOrThrow<string>("SMS_API_URL"),
API_KEY: configService.getOrThrow<string>("SMS_API_KEY"),
SMS_PATTERN_OTP: configService.getOrThrow<string>("SMS_PATTERN_OTP"),
SMS_PATTERN_INVOICE: configService.getOrThrow<string>("SMS_PATTERN_INVOICE"),
SMS_PATTERN_LOGIN: configService.getOrThrow<string>("SMS_PATTERN_LOGIN"),
SMS_PATTERN_INVOICE_CREATED: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_CREATED"),
SMS_PATTERN_ANNOUNCEMENT: configService.getOrThrow<string>("SMS_PATTERN_ANNOUNCEMENT"),
SMS_PATTERN_WALLET_CHARGE: configService.getOrThrow<string>("SMS_PATTERN_WALLET_CHARGE"),
SMS_PATTERN_WALLET_DEDUCTION: configService.getOrThrow<string>("SMS_PATTERN_WALLET_DEDUCTION"),
SMS_PATTERN_TICKET_CREATED: configService.getOrThrow<string>("SMS_PATTERN_TICKET_CREATED"),
SMS_PATTERN_TICKET_ANSWERED: configService.getOrThrow<string>("SMS_PATTERN_WALLET_DEDUCTION"),
SMS_PATTERN_TICKET_ASSIGNED_ADMIN: configService.getOrThrow<string>("SMS_PATTERN_TICKET_ASSIGNED_ADMIN"),
SMS_PATTERN_INVOICE_APPROVED: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_APPROVED"),
SMS_PATTERN_INVOICE_PAID: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_PAID"),
SMS_PATTERN_RECURRING_INVOICE_DRAFT: configService.getOrThrow<string>("SMS_PATTERN_RECURRING_INVOICE_DRAFT"),
SMS_PATTERN_SUBSCRIPTION_CANCELLED: configService.getOrThrow<string>("SMS_PATTERN_SUBSCRIPTION_CANCELLED"),
SMS_PATTERN_INVOICE_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_REMINDER"),
SMS_PATTERN_INVOICE_OVERDUE: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_OVERDUE"),
SMS_PATTERN_PAYMENT_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_PAYMENT_REMINDER"),
SMS_PATTERN_PAYMENT_CANCELLATION: configService.getOrThrow<string>("SMS_PATTERN_PAYMENT_CANCELLATION"),
//
SMS_PATTERN_BLOG_NEW_COMMENT: configService.getOrThrow<string>("SMS_PATTERN_BLOG_NEW_COMMENT"),
SMS_PATTERN_NEW_SERVICE_REVIEW: configService.getOrThrow<string>("SMS_PATTERN_NEW_SERVICE_REVIEW"),
SMS_PATTERN_NEW_CUSTOMER: configService.getOrThrow<string>("SMS_PATTERN_NEW_CUSTOMER"),
SMS_PATTERN_NEW_SUBSCRIPTION: configService.getOrThrow<string>("SMS_PATTERN_NEW_SUBSCRIPTION"),
SMS_PATTERN_NEW_TICKET_GLOBAL: configService.getOrThrow<string>("SMS_PATTERN_NEW_TICKET_GLOBAL"),
SMS_PATTERN_NEW_CRITICISM: configService.getOrThrow<string>("SMS_PATTERN_NEW_CRITICISM"),
SMS_PATTERN_USER_PASSWORD: configService.getOrThrow<string>("SMS_PATTERN_USER_PASSWORD"),
};
},
};
export interface ISmsConfigs {
API_URL: string;
@@ -109,9 +109,24 @@ export class CreateServiceDto {
@IsNotEmpty({ message: ServiceMessage.IMAGES_REQUIRED })
@IsString({ each: true })
@IsUrl({ protocols: ["http", "https"], require_protocol: true }, { message: ServiceMessage.IMAGES_SHOULD_BE_URL })
@ApiProperty({ description: "The images of the service", example: ["https://example.com/image1.png", "https://example.com/image2.png"] })
images: string[];
@IsOptional()
@IsNotEmpty({ message: ServiceMessage.AUDIOS_REQUIRED })
@IsString({ each: true })
@IsUrl({ protocols: ["http", "https"], require_protocol: true }, { message: ServiceMessage.AUDIOS_SHOULD_BE_URL })
@ApiProperty({ description: "The audios of the service", example: ["https://example.com/audio1.mp3", "https://example.com/audio2.mp3"] })
audios?: string[];
@IsOptional()
@IsNotEmpty({ message: ServiceMessage.VIDEOS_REQUIRED })
@IsString({ each: true })
@IsUrl({ protocols: ["http", "https"], require_protocol: true }, { message: ServiceMessage.VIDEOS_SHOULD_BE_URL })
@ApiProperty({ description: "The videos of the service", example: ["https://example.com/video1.mp4", "https://example.com/video2.mp4"] })
videos?: string[];
@IsOptional()
@IsNotEmpty({ message: ServiceMessage.COVER_URL_REQUIRED })
@IsUrl({ protocols: ["http", "https"], require_protocol: true }, { message: ServiceMessage.COVER_URL_SHOULD_BE_URL })
@@ -100,8 +100,8 @@ export class DanakServicesController {
@PermissionsDec(PermissionEnum.SERVICES)
@ApiOperation({ summary: "create new danak services => admin route" })
@Post()
createService(@Body() createDto: CreateServiceDto) {
return this.danakServicesService.createService(createDto);
createService(@Body() createDto: CreateServiceDto, @UserDec("id") userId: string) {
return this.danakServicesService.createService(createDto, userId);
}
@AuthGuards()
@@ -185,8 +185,8 @@ export class DanakServicesController {
@AuthGuards()
@PermissionsDec(PermissionEnum.SERVICES)
@Patch(":id")
updateDanakService(@Param() paramDto: ParamDto, @Body() updateDto: UpdateServiceDto) {
return this.danakServicesService.updateDanakService(paramDto.id, updateDto);
updateDanakService(@Param() paramDto: ParamDto, @Body() updateDto: UpdateServiceDto, @UserDec("id") userId: string) {
return this.danakServicesService.updateDanakService(paramDto.id, updateDto, userId);
}
@ApiOperation({ summary: "delete danak service" })
@@ -17,6 +17,10 @@ import { DanakServiceReviewRepository } from "./repositories/danak-services-revi
import { DanakServicesRepository } from "./repositories/danak-services.repository";
import { NotificationModule } from "../notifications/notifications.module";
import { UsersModule } from "../users/users.module";
import { DanakServiceAudio } from "./entities/danak-service-audio.entity";
import { DanakServiceVideo } from "./entities/danak-service-video.entity";
import { DanakServicesAudioRepository } from "./repositories/danak-services-audio.repository";
import { DanakServicesVideoRepository } from "./repositories/danak-services-video.repository";
@Module({
imports: [
@@ -27,6 +31,8 @@ import { UsersModule } from "../users/users.module";
DanakServiceReview,
DanakServiceFeedback,
DanakServiceGuide,
DanakServiceAudio,
DanakServiceVideo,
]),
UsersModule,
NotificationModule,
@@ -39,6 +45,8 @@ import { UsersModule } from "../users/users.module";
DanakServiceReviewRepository,
DanakServiceFeedbackRepository,
DanakServiceGuideRepository,
DanakServicesAudioRepository,
DanakServicesVideoRepository,
],
controllers: [DanakServicesController],
exports: [DanakServicesService],
@@ -0,0 +1,17 @@
import { Column, Entity, ManyToOne } from "typeorm";
import { DanakService } from "./danak-service.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { User } from "../../users/entities/user.entity";
@Entity()
export class DanakServiceAudio extends BaseEntity {
@ManyToOne(() => DanakService, (danakService) => danakService.audios, { nullable: false, onDelete: "CASCADE" })
danakService: DanakService;
@Column({ type: "varchar", length: 255, nullable: false })
audioUrl: string;
@ManyToOne(() => User, (user) => user.audios, { nullable: false, onDelete: "SET NULL" })
createBy: User;
}
@@ -0,0 +1,17 @@
import { Column, Entity, ManyToOne } from "typeorm";
import { DanakService } from "./danak-service.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { User } from "../../users/entities/user.entity";
@Entity()
export class DanakServiceVideo extends BaseEntity {
@ManyToOne(() => DanakService, (danakService) => danakService.videos, { nullable: false, onDelete: "CASCADE" })
danakService: DanakService;
@Column({ type: "varchar", length: 255, nullable: false })
videoUrl: string;
@ManyToOne(() => User, (user) => user.videos, { nullable: false, onDelete: "SET NULL" })
createBy: User;
}
@@ -1,10 +1,12 @@
import { Column, DeleteDateColumn, Entity, Index, ManyToOne, OneToMany } from "typeorm";
import { DanakServiceAudio } from "./danak-service-audio.entity";
import { DanakServiceCategory } from "./danak-service-category.entity";
import { DanakServiceFeedback } from "./danak-service-feedback.entity";
import { DanakServiceGuide } from "./danak-service-guide.entity";
import { DanakServiceImage } from "./danak-service-image.entity";
import { DanakServiceReview } from "./danak-service-review.entity";
import { DanakServiceVideo } from "./danak-service-video.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { Ads } from "../../ads/entities/ads.entity";
import { Announcement } from "../../announcements/entities/announcement.entity";
@@ -79,6 +81,12 @@ export class DanakService extends BaseEntity {
@OneToMany(() => DanakServiceImage, (danakServiceImage) => danakServiceImage.danakService, { cascade: true })
images: DanakServiceImage[];
@OneToMany(() => DanakServiceAudio, (danakServiceAudio) => danakServiceAudio.danakService, { cascade: true })
audios: DanakServiceAudio[];
@OneToMany(() => DanakServiceVideo, (danakServiceVideo) => danakServiceVideo.danakService, { cascade: true })
videos: DanakServiceVideo[];
@OneToMany(() => Announcement, (announcement) => announcement.targetService)
announcements: Announcement[];
@@ -23,14 +23,18 @@ import { UpdateDanakServiceCategoryDto } from "../DTO/update-category.dto";
import { UpdateReviewStatusDto } from "../DTO/update-review-status.dto";
import { UpdateServiceGuideDto } from "../DTO/update-service-guide.dto";
import { UpdateServiceDto } from "../DTO/update-service.dto";
import { DanakServiceAudio } from "../entities/danak-service-audio.entity";
import { DanakServiceCategory } from "../entities/danak-service-category.entity";
import { DanakServiceImage } from "../entities/danak-service-image.entity";
import { DanakServiceVideo } from "../entities/danak-service-video.entity";
import { ReviewStatus } from "../enums/review-status.enum";
import { DanakServiceFeedbackRepository } from "../repositories/danak-service-feedback.repository";
import { DanakServiceGuideRepository } from "../repositories/danak-service-guide.repository";
import { DanakServicesAudioRepository } from "../repositories/danak-services-audio.repository";
import { DanakServicesCategoryRepository } from "../repositories/danak-services-category.repository";
import { DanakServicesImageRepository } from "../repositories/danak-services-image.repository";
import { DanakServiceReviewRepository } from "../repositories/danak-services-review.repository";
import { DanakServicesVideoRepository } from "../repositories/danak-services-video.repository";
import { DanakServicesRepository } from "../repositories/danak-services.repository";
@Injectable()
export class DanakServicesService {
@@ -42,6 +46,8 @@ export class DanakServicesService {
private readonly danakServiceFeedbackRepository: DanakServiceFeedbackRepository,
private readonly danakServiceGuideRepository: DanakServiceGuideRepository,
private readonly danakServicesImageRepository: DanakServicesImageRepository,
private readonly danakServicesAudioRepository: DanakServicesAudioRepository,
private readonly danakServicesVideoRepository: DanakServicesVideoRepository,
private readonly dataSource: DataSource,
private readonly adminsService: AdminsService,
private readonly usersService: UsersService,
@@ -279,7 +285,7 @@ export class DanakServicesService {
};
}
/******************************************** */
async createService(createDto: CreateServiceDto) {
async createService(createDto: CreateServiceDto, userId: string) {
const category = await this.danakServicesCategoryRepository.findOneById(createDto.categoryId);
if (!category) throw new BadRequestException(CategoryMessage.CATEGORY_NOT_EXIST);
@@ -292,7 +298,23 @@ export class DanakServicesService {
return { imageUrl: image };
});
const service = this.danakServicesRepository.create({ ...createDto, category, images, slug });
let audios: Partial<DanakServiceAudio>[] = [];
if (createDto.audios) {
audios = createDto.audios.map((audio) => {
return { audioUrl: audio, createBy: { id: userId } as User };
});
}
let videos: Partial<DanakServiceVideo>[] = [];
if (createDto.videos) {
videos = createDto.videos.map((video) => {
return { videoUrl: video, createBy: { id: userId } as User };
});
}
const service = this.danakServicesRepository.create({ ...createDto, category, images, audios, videos, slug });
await this.danakServicesRepository.save(service);
return {
message: CommonMessage.CREATED,
@@ -300,10 +322,12 @@ export class DanakServicesService {
};
}
/******************************************** */
async updateDanakService(serviceId: string, updateDto: UpdateServiceDto) {
async updateDanakService(serviceId: string, updateDto: UpdateServiceDto, userId: string) {
const service = await this.findServiceById(serviceId);
let images: DanakServiceImage[] = [];
let audios: DanakServiceAudio[] = [];
let videos: DanakServiceVideo[] = [];
if (updateDto.categoryId) {
const category = await this.danakServicesCategoryRepository.findOneById(updateDto.categoryId);
@@ -331,6 +355,37 @@ export class DanakServicesService {
images = newImages;
}
if (updateDto.audios) {
await this.danakServicesAudioRepository.delete({ danakService: { id: serviceId } });
const newAudios = updateDto.audios.map((audio) => {
return this.danakServicesAudioRepository.create({
audioUrl: audio,
danakService: service,
createBy: { id: userId } as User,
});
});
await this.danakServicesAudioRepository.save(newAudios);
audios = newAudios;
}
if (updateDto.videos) {
await this.danakServicesVideoRepository.delete({ danakService: { id: serviceId } });
const newVideos = updateDto.videos.map((video) => {
return this.danakServicesVideoRepository.create({
videoUrl: video,
danakService: service,
createBy: { id: userId } as User,
});
});
await this.danakServicesVideoRepository.save(newVideos);
videos = newVideos;
}
if (updateDto.slug) {
const existSlug = await this.danakServicesRepository.findOne({ where: { slug: updateDto.slug, id: Not(serviceId) } });
if (existSlug) throw new BadRequestException(ServiceMessage.SERVICE_ALREADY_EXISTS_WITH_THIS_SLUG);
@@ -341,6 +396,8 @@ export class DanakServicesService {
...updateDto,
slug: this.generateSlug(updateDto.slug ?? service.slug ?? service.name),
images: images.length ? images : service.images,
audios: audios.length ? audios : service.audios,
videos: videos.length ? videos : service.videos,
});
return {
message: CommonMessage.UPDATE_SUCCESS,
@@ -0,0 +1,12 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { DanakServiceAudio } from "../entities/danak-service-audio.entity";
@Injectable()
export class DanakServicesAudioRepository extends Repository<DanakServiceAudio> {
constructor(@InjectRepository(DanakServiceAudio) danakServicesAudioRepository: Repository<DanakServiceAudio>) {
super(danakServicesAudioRepository.target, danakServicesAudioRepository.manager, danakServicesAudioRepository.queryRunner);
}
}
@@ -0,0 +1,12 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { DanakServiceVideo } from "../entities/danak-service-video.entity";
@Injectable()
export class DanakServicesVideoRepository extends Repository<DanakServiceVideo> {
constructor(@InjectRepository(DanakServiceVideo) danakServicesVideoRepository: Repository<DanakServiceVideo>) {
super(danakServicesVideoRepository.target, danakServicesVideoRepository.manager, danakServicesVideoRepository.queryRunner);
}
}
@@ -95,6 +95,8 @@ export class DanakServicesRepository extends Repository<DanakService> {
.leftJoin("service.category", "category")
.addSelect(["category.id", "category.title"])
.leftJoinAndSelect("service.images", "images")
.leftJoinAndSelect("service.audios", "audios")
.leftJoinAndSelect("service.videos", "videos")
.leftJoinAndSelect("service.subscriptionPlans", "subscriptionPlans")
.leftJoinAndSelect("subscriptionPlans.directDiscount", "directDiscount", "directDiscount.id IS NOT NULL")
.leftJoin("service.reviews", "reviews", "reviews.status = :reviewStatus", { reviewStatus: ReviewStatus.APPROVED })
@@ -122,7 +124,7 @@ export class DanakServicesRepository extends Repository<DanakService> {
// "averageRating",
// )
.andWhere("service.id = :serviceId", { serviceId })
.groupBy("service.id,category.id, images.id, subscriptionPlans.id, reviews.id, user.id, directDiscount.id");
.groupBy("service.id,category.id, images.id,audios.id,videos.id, subscriptionPlans.id, reviews.id, user.id, directDiscount.id");
if (!isAdmin) {
serviceQueryBuilder
@@ -141,6 +143,8 @@ export class DanakServicesRepository extends Repository<DanakService> {
.leftJoin("service.category", "category")
.addSelect(["category.id", "category.title"])
.leftJoinAndSelect("service.images", "images")
.leftJoinAndSelect("service.audios", "audios")
.leftJoinAndSelect("service.videos", "videos")
.leftJoinAndSelect("service.subscriptionPlans", "subscriptionPlans")
.leftJoinAndSelect("subscriptionPlans.directDiscount", "directDiscount", "directDiscount.id IS NOT NULL")
.leftJoin("service.reviews", "reviews", "reviews.status = :reviewStatus", { reviewStatus: ReviewStatus.APPROVED })
@@ -168,7 +172,7 @@ export class DanakServicesRepository extends Repository<DanakService> {
// "averageRating",
// )
.andWhere("service.slug = :slug", { slug })
.groupBy("service.id,category.id, images.id, subscriptionPlans.id, reviews.id, user.id, directDiscount.id");
.groupBy("service.id,category.id, images.id,audios.id,videos.id, subscriptionPlans.id, reviews.id, user.id, directDiscount.id");
if (!isAdmin) {
serviceQueryBuilder
+3 -4
View File
@@ -1,10 +1,9 @@
import { File } from "@nest-lab/fastify-multer";
import { ApiProperty } from "@nestjs/swagger";
import { IFile } from "../../utils/interfaces/IFile";
export class UploadSingleFileDto {
@ApiProperty({ type: "string", format: "binary", nullable: false })
file: IFile;
file: File;
}
export class UploadMultipleFileDto {
@@ -16,5 +15,5 @@ export class UploadMultipleFileDto {
format: "binary",
},
})
files: IFile[];
files: File[];
}
@@ -0,0 +1,41 @@
import { File } from "@nest-lab/fastify-multer";
export interface FileUploadResult {
file: File;
url: string;
}
export interface FileStreamResult {
stream: NodeJS.ReadableStream;
file: File;
}
export interface FileMetadata {
width?: number;
height?: number;
duration?: number;
bitrate?: number;
codec?: string;
language?: string;
}
export type FileType = "video" | "audio" | "image" | "document" | "text" | "unknown";
export interface FileValidationOptions {
maxFileSize: number;
allowedMimeTypes: string[];
}
export interface FileUploadOptions {
roomId?: string;
metadata?: Record<string, string>;
}
// File processing status
export type FileProcessingStatus = "pending" | "processing" | "completed" | "failed";
export interface FileProcessingResult {
status: FileProcessingStatus;
metadata?: FileMetadata;
error?: string;
}
@@ -0,0 +1,37 @@
export interface S3UploadResult {
key: string;
url: string;
bucket: string;
}
export interface S3UploadOptions {
contentType: string;
metadata?: Record<string, string>;
acl?: "private" | "public-read" | "public-read-write";
}
export interface S3DownloadOptions {
expiresIn?: number;
responseContentType?: string;
responseContentDisposition?: string;
}
export interface S3FileInfo {
key: string;
bucket: string;
size: number;
lastModified: Date;
contentType: string;
etag: string;
}
export interface S3DeleteResult {
deleted: boolean;
key: string;
}
export interface S3ListResult {
files: S3FileInfo[];
continuationToken?: string;
isTruncated: boolean;
}
@@ -0,0 +1,10 @@
export interface FileSearchOptions {
userId?: string;
roomId?: string;
fileType?: "video" | "audio" | "subtitle";
isActive?: boolean;
limit?: number;
offset?: number;
sortBy?: "uploadedAt" | "size" | "originalName";
sortOrder?: "ASC" | "DESC";
}
+132
View File
@@ -0,0 +1,132 @@
import { Readable } from "stream";
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { Inject, Injectable, Logger } from "@nestjs/common";
import { IS3Configs } from "../../../configs/s3.config";
import { S3_CONFIG } from "../../utils/constants";
import { FileType } from "../interfaces/file.interface";
import { S3UploadResult } from "../interfaces/s3.interface";
@Injectable()
export class S3Service {
private readonly logger = new Logger(S3Service.name);
private readonly s3Client: S3Client;
constructor(@Inject(S3_CONFIG) private readonly s3Configs: IS3Configs) {
this.s3Client = new S3Client({
region: this.s3Configs.region,
endpoint: this.s3Configs.endpoint,
credentials: {
accessKeyId: this.s3Configs.accessKeyId,
secretAccessKey: this.s3Configs.secretAccessKey,
},
});
}
/**
* Upload file to S3
*/
async uploadFile(buffer: Buffer, key: string, contentType: string, metadata?: Record<string, string>): Promise<S3UploadResult> {
try {
const command = new PutObjectCommand({
Bucket: this.s3Configs.bucketName,
Key: key,
Body: buffer,
ACL: "public-read",
ContentType: contentType,
Metadata: metadata,
});
await this.s3Client.send(command);
const url = `${this.s3Configs.bucketUploadedUrl}/${key}`;
this.logger.log(`File uploaded to S3: ${key}`);
return {
key,
url,
bucket: this.s3Configs.bucketName,
};
} catch (error: unknown) {
this.logger.error(`Failed to upload file to S3: ${(error as Error).message}`, (error as Error).stack);
throw error;
}
}
/**
* Get file stream from S3
*/
async getFileStream(key: string): Promise<Readable> {
try {
const command = new GetObjectCommand({
Bucket: this.s3Configs.bucketName,
Key: key,
});
const response = await this.s3Client.send(command);
return response.Body as Readable;
} catch (error: unknown) {
this.logger.error(`Failed to get file from S3: ${(error as Error).message}`, (error as Error).stack);
throw error;
}
}
/**
* Generate presigned URL for file download
*/
async getPresignedUrl(key: string, expiresIn: number = 3600): Promise<string> {
try {
const command = new GetObjectCommand({
Bucket: this.s3Configs.bucketName,
Key: key,
});
return await getSignedUrl(this.s3Client, command, { expiresIn });
} catch (error: unknown) {
this.logger.error(`Failed to generate presigned URL: ${(error as Error).message}`, (error as Error).stack);
throw error;
}
}
/**
* Delete file from S3
*/
async deleteFile(key: string): Promise<void> {
try {
const command = new DeleteObjectCommand({
Bucket: this.s3Configs.bucketName,
Key: key,
});
await this.s3Client.send(command);
this.logger.log(`File deleted from S3: ${key}`);
} catch (error: unknown) {
this.logger.error(`Failed to delete file from S3: ${(error as Error).message}`, (error as Error).stack);
throw error;
}
}
/**
* Generate S3 key for file
*/
generateFileKey(originalName: string, fileType: FileType): string {
const timestamp = Date.now();
const randomSuffix = Math.round(Math.random() * 1e9);
const extension = originalName.split(".").pop();
const basePathMap: Record<FileType, string> = {
image: "images",
video: "videos",
audio: "audio",
document: "documents",
text: "texts",
unknown: "unknowns",
};
const basePath = basePathMap[fileType];
return `${basePath}/${timestamp}-${randomSuffix}.${extension}`;
}
}
@@ -0,0 +1,111 @@
import { File } from "@nest-lab/fastify-multer";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { S3Service } from "./s3.service";
import { UploaderMessage } from "../../../common/enums/message.enum";
import { FileType } from "../interfaces/file.interface";
@Injectable()
export class UploaderService {
private readonly logger = new Logger(UploaderService.name);
private readonly maxFileSize: number;
private readonly allowedMimeTypes: string[];
constructor(
private readonly configService: ConfigService,
private readonly s3Service: S3Service,
) {
this.maxFileSize = this.configService.get("MAX_FILE_SIZE", 1024 * 1024 * 100); // 100MB
this.allowedMimeTypes = [
"video/mp4",
"video/webm",
"video/ogg",
"video/avi",
"video/mov",
"video/mkv",
"audio/mp3",
"audio/wav",
"audio/ogg",
"audio/webm",
"audio/m4a",
"audio/aac",
"audio/flac",
"audio/m4a",
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
"image/tiff",
"image/bmp",
"application/pdf",
"text/plain",
"text/csv",
];
}
async uploadMultiple(files: File[]) {
return await Promise.all(files.map((file) => this.uploadFile(file)));
}
/**
* Handle file upload
*/
async uploadFile(file: File) {
if (!file || !file.buffer || !file.size) {
throw new BadRequestException(UploaderMessage.UPLOAD_FILE_INVALID);
}
this.logger.log(`Uploading file: ${file.originalname}`);
this.validateFile(file);
const fileType = await this.determineFileType(file.mimetype);
const s3Key = this.s3Service.generateFileKey(file.originalname, fileType);
// Upload to S3
const s3Result = await this.s3Service.uploadFile(file.buffer, s3Key, file.mimetype, {
originalName: file.originalname,
uploadedBy: "admin",
});
this.logger.log(`File uploaded successfully: `);
return {
file: s3Result,
url: s3Result.url,
};
}
/**
* Validate uploaded file
*/
private validateFile(file: File): void {
if (!file) throw new BadRequestException(UploaderMessage.UPLOAD_FILE_INVALID);
if (file.size && file.size > this.maxFileSize)
throw new BadRequestException(UploaderMessage.UPLOAD_FILE_TOO_LARGE.replace("[MAX_FILE_SIZE]", this.maxFileSize.toString()));
if (!this.allowedMimeTypes.includes(file.mimetype))
throw new BadRequestException(UploaderMessage.UPLOAD_FILE_TYPE_NOT_SUPPORTED.replace("[MIME_TYPES]", file.mimetype));
}
/**
* Determine file type based on mimetype
*/
private async determineFileType(mimetype: string): Promise<FileType> {
if (mimetype.startsWith("video/")) {
return "video";
} else if (mimetype.startsWith("audio/")) {
return "audio";
} else if (mimetype.startsWith("image/")) {
return "image";
} else if (mimetype.startsWith("text/")) {
return "text";
} else if (mimetype.startsWith("application/")) {
return "document";
}
return "unknown";
}
}
+7 -9
View File
@@ -1,25 +1,23 @@
import { FileInterceptor, FilesInterceptor } from "@nest-lab/fastify-multer";
import { File, FileInterceptor, FilesInterceptor } from "@nest-lab/fastify-multer";
import { Controller, Post, UploadedFile, UploadedFiles, UseInterceptors } from "@nestjs/common";
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
import { ApiBody, ApiConsumes, ApiOperation } from "@nestjs/swagger";
import { UploadMultipleFileDto, UploadSingleFileDto } from "./DTO/upload-file.dto";
import { UploaderService } from "./uploader.service";
import { UploaderService } from "./services/uploader.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { IFile } from "../utils/interfaces/IFile";
@ApiTags("Uploader")
@Controller("uploader")
@AuthGuards()
export class UploaderController {
constructor(private readonly uploaderService: UploaderService) {}
@ApiOperation({ summary: "Uploads a single file" })
@ApiOperation({ summary: "Upload a file (admin)" })
@ApiConsumes("multipart/form-data")
@UseInterceptors(FileInterceptor("file"))
@ApiBody({ type: UploadSingleFileDto })
@Post("single-file")
uploadFile(@UploadedFile() file: IFile) {
return this.uploaderService.upload(file);
uploadFile(@UploadedFile() file: File) {
return this.uploaderService.uploadFile(file);
}
@ApiOperation({ summary: "Uploads multiple files" })
@@ -27,7 +25,7 @@ export class UploaderController {
@UseInterceptors(FilesInterceptor("files"))
@ApiBody({ type: UploadMultipleFileDto })
@Post("multi-file")
uploadFiles(@UploadedFiles() files: IFile[]) {
uploadFiles(@UploadedFiles() files: File[]) {
return this.uploaderService.uploadMultiple(files);
}
}
+4 -5
View File
@@ -1,14 +1,13 @@
import { Module } from "@nestjs/common";
import { S3Service } from "./services/s3.service";
import { UploaderService } from "./services/uploader.service";
import { UploaderController } from "./uploader.controller";
import { UploaderService } from "./uploader.service";
import { S3Service } from "../utils/providers/s3.service";
import { UtilsModule } from "../utils/utils.module";
import { S3ConfigsProvider } from "../../configs/s3.config";
@Module({
imports: [UtilsModule],
providers: [UploaderService, S3Service],
controllers: [UploaderController],
providers: [UploaderService, S3Service, S3ConfigsProvider],
exports: [UploaderService],
})
export class UploaderModule {}
-20
View File
@@ -1,20 +0,0 @@
import { Injectable } from "@nestjs/common";
import { IFile } from "../utils/interfaces/IFile";
import { S3Service } from "../utils/providers/s3.service";
@Injectable()
export class UploaderService {
constructor(private s3Service: S3Service) {}
async upload(file: IFile) {
return await this.s3Service.upload(file);
}
async uploadMultiple(files: IFile[]) {
// const uploadPromises = files.map((file) => this.s3Service.upload(file));
// return await Promise.all(uploadPromises);
return this.s3Service.uploadMultiple(files);
}
}
@@ -11,8 +11,10 @@ import { UserAnnouncement } from "../../announcements/entities/user-announcement
import { BlogComment } from "../../blogs/entities/blog-comment.entity";
import { Blog } from "../../blogs/entities/blog.entity";
import { Criticism } from "../../criticisms/entities/criticism.entity";
import { DanakServiceAudio } from "../../danak-services/entities/danak-service-audio.entity";
import { DanakServiceFeedback } from "../../danak-services/entities/danak-service-feedback.entity";
import { DanakServiceReview } from "../../danak-services/entities/danak-service-review.entity";
import { DanakServiceVideo } from "../../danak-services/entities/danak-service-video.entity";
import { Discount } from "../../discounts/entities/discount.entity";
import { Invoice } from "../../invoices/entities/invoice.entity";
import { LearningProgress } from "../../learnings/entities/learning-progress.entity";
@@ -79,6 +81,12 @@ export class User extends BaseEntity {
//---------------------------------------
@OneToMany(() => DanakServiceAudio, (danakServiceAudio) => danakServiceAudio.createBy)
audios: DanakServiceAudio[];
@OneToMany(() => DanakServiceVideo, (danakServiceVideo) => danakServiceVideo.createBy)
videos: DanakServiceVideo[];
@OneToMany(() => DanakServiceReview, (danakServiceReview) => danakServiceReview.user)
reviews: DanakServiceReview[];
-8
View File
@@ -1,8 +0,0 @@
export interface IFile {
fieldname: string;
originalname: string;
encoding: string;
mimetype: string;
buffer: Buffer;
size: number;
}
-57
View File
@@ -1,57 +0,0 @@
import { randomUUID } from "node:crypto";
import { extname } from "node:path";
import { PutObjectCommand, PutObjectCommandInput, S3Client } from "@aws-sdk/client-s3";
import { Inject, Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
import { IS3Configs } from "../../../configs/s3.config";
import { S3_CONFIG } from "../constants";
import { IFile } from "../interfaces/IFile";
@Injectable()
export class S3Service {
private readonly logger = new Logger(S3Service.name);
private client: S3Client;
//
constructor(@Inject(S3_CONFIG) private s3Configs: IS3Configs) {
this.client = new S3Client({
endpoint: this.s3Configs.endpoint,
region: this.s3Configs.region,
credentials: {
accessKeyId: this.s3Configs.accessKeyId,
secretAccessKey: this.s3Configs.secretAccessKey,
},
});
}
async upload(file: IFile) {
const extName = extname(file.originalname);
const params: PutObjectCommandInput = {
Body: file.buffer,
Bucket: this.s3Configs.bucket,
Key: `images/${randomUUID()}${Date.now()}${extName}`,
ACL: "public-read",
};
//
try {
await this.client.send(new PutObjectCommand(params));
const url = `${this.s3Configs.url}/${params.Key}`;
return {
url,
originalname: file.originalname,
size: file.size,
};
} catch (error) {
this.logger.error(error);
throw new InternalServerErrorException("Failed to upload file");
}
}
async uploadMultiple(files: IFile[]) {
const uploadPromises = files.map((file) => this.upload(file));
const results = await Promise.all(uploadPromises);
return results;
}
}
+4 -23
View File
@@ -1,34 +1,15 @@
import { Module } from "@nestjs/common";
import { S3_CONFIG, SMS_CONFIG } from "./constants";
import { SMS_CONFIG } from "./constants";
import { CacheService } from "./providers/cache.service";
import { EmailService } from "./providers/email.service";
import { OTPService } from "./providers/otp.service";
import { PasswordService } from "./providers/password.service";
import { S3Service } from "./providers/s3.service";
import { SmsService } from "./providers/sms.service";
import { S3Configs } from "../../configs/s3.config";
import { smsConfigs } from "../../configs/sms.config";
import { smsConfigsProvider } from "../../configs/sms.config";
@Module({
providers: [
EmailService,
OTPService,
PasswordService,
CacheService,
SmsService,
S3Service,
{
provide: SMS_CONFIG,
useFactory: smsConfigs().useFactory,
inject: smsConfigs().inject,
},
{
provide: S3_CONFIG,
useFactory: S3Configs().useFactory,
inject: S3Configs().inject,
},
],
exports: [SMS_CONFIG, S3_CONFIG, S3Service, OTPService, PasswordService, CacheService, SmsService, EmailService],
providers: [EmailService, OTPService, PasswordService, CacheService, SmsService, smsConfigsProvider],
exports: [SMS_CONFIG, OTPService, PasswordService, CacheService, SmsService, EmailService],
})
export class UtilsModule {}