From 687ee80842248c61a69edf041bae91210ade1b6c Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Sat, 16 Aug 2025 16:39:24 +0330 Subject: [PATCH] feat: add tts service --- src/common/enums/message.enum.ts | 3 + src/configs/ai.config.ts | 1 + .../ai-assistant/DTO/text-to-speech.dto.ts | 64 +++++++++++ .../ai-assistant/ai-assistant.controller.ts | 11 ++ .../ai-assistant/ai-assistant.module.ts | 3 +- .../interfaces/ai-service.interface.ts | 24 +++++ .../ai-assistant/interfaces/ai.interface.ts | 1 + .../services/ai-assistant.service.ts | 100 +++++++++++++++++- .../ai-assistant/services/ai.service.ts | 33 ++++++ .../uploader/interfaces/file.interface.ts | 41 +++++++ src/modules/uploader/providers/s3.service.ts | 19 +++- .../uploader/providers/uploader.service.ts | 44 +++++--- src/modules/uploader/uploader.controller.ts | 2 +- src/modules/uploader/uploader.module.ts | 2 +- 14 files changed, 328 insertions(+), 20 deletions(-) create mode 100644 src/modules/ai-assistant/DTO/text-to-speech.dto.ts create mode 100644 src/modules/uploader/interfaces/file.interface.ts diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 3e66ae0..94c2755 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -134,6 +134,9 @@ export const enum CommonMessage { export const enum UploaderMessage { UPLOAD_FILE_INVALID = "فایل معتبر نیست", + UPLOAD_FILE_TOO_LARGE = "فایل بزرگتر از حد مجاز است . حداکثر حجم فایل [MAX_FILE_SIZE] مگابایت است", + UPLOAD_FILE_TYPE_NOT_SUPPORTED = "نوع فایل معتبر نیست . مجاز است : [MIME_TYPES]", + UPLOAD_FILE_SUCCESS = "فایل با موفقیت آپلود شد", } export const enum EmailMessage { diff --git a/src/configs/ai.config.ts b/src/configs/ai.config.ts index 4be9cfb..f05dd86 100644 --- a/src/configs/ai.config.ts +++ b/src/configs/ai.config.ts @@ -239,6 +239,7 @@ export const aiConfigProvider: FactoryProvider = { baseUrl: configService.getOrThrow("AI_BASE_URL"), apiKey: configService.getOrThrow("AI_API_KEY"), defaultModel: configService.getOrThrow("AI_DEFAULT_MODEL"), + defaultTTSModel: configService.getOrThrow("AI_DEFAULT_TTS_MODEL"), temperature: configService.get("AI_TEMPERATURE", 0.7), maxTokens: configService.get("AI_MAX_TOKENS", 5000), timeout: configService.get("AI_TIMEOUT", 80000), diff --git a/src/modules/ai-assistant/DTO/text-to-speech.dto.ts b/src/modules/ai-assistant/DTO/text-to-speech.dto.ts new file mode 100644 index 0000000..c8325f1 --- /dev/null +++ b/src/modules/ai-assistant/DTO/text-to-speech.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator"; + +export enum VoiceEnum { + ALLOY = "alloy", + ECHO = "echo", + FABLE = "fable", + ONYX = "onyx", + NOVA = "nova", + SHIMMER = "shimmer", +} + +export enum SpeedEnum { + SLOW = 0.5, + NORMAL = 1.0, + FAST = 1.3, + VERY_FAST = 2.0, +} + +export enum AudioFormatEnum { + MP3 = "mp3", + OPUS = "opus", + AAC = "aac", + FLAC = "flac", +} + +export class TextToSpeechDto { + @IsNotEmpty({ message: "Message ID is required" }) + @IsNumber({}, { message: "Message ID must be a number" }) + @ApiProperty({ description: "The message ID to fetch and convert to speech", example: 12345 }) + messageId: number; + + @IsNotEmpty({ message: "Mailbox ID is required" }) + @IsString({ message: "Mailbox ID must be a string" }) + @ApiProperty({ description: "The mailbox ID where the message is located", example: "507f1f77bcf86cd799439012" }) + mailbox: string; + + @IsOptional() + @IsEnum(VoiceEnum, { message: "Voice must be one of: alloy, echo, fable, onyx, nova, shimmer" }) + @ApiPropertyOptional({ + description: "Voice to use for text-to-speech", + enum: VoiceEnum, + default: VoiceEnum.ALLOY, + }) + voice?: VoiceEnum; + + @IsOptional() + @IsEnum(SpeedEnum, { message: "Speed must be one of: 0.5, 1.0, 1.5, 2.0" }) + @ApiPropertyOptional({ + description: "Speech speed", + enum: SpeedEnum, + default: SpeedEnum.NORMAL, + }) + speed?: SpeedEnum; + + @IsOptional() + @IsEnum(AudioFormatEnum, { message: "Format must be one of: mp3, opus, aac, flac" }) + @ApiPropertyOptional({ + description: "Audio output format", + enum: AudioFormatEnum, + default: AudioFormatEnum.MP3, + }) + format?: AudioFormatEnum; +} diff --git a/src/modules/ai-assistant/ai-assistant.controller.ts b/src/modules/ai-assistant/ai-assistant.controller.ts index 104b802..ed314d5 100644 --- a/src/modules/ai-assistant/ai-assistant.controller.ts +++ b/src/modules/ai-assistant/ai-assistant.controller.ts @@ -3,6 +3,7 @@ import { ApiOperation, ApiResponse } from "@nestjs/swagger"; import { GenerateTemplateDto } from "./DTO/generate-template.dto"; import { SummarizeEmailDto } from "./DTO/summarize-email.dto"; +import { TextToSpeechDto } from "./DTO/text-to-speech.dto"; import { WriteEmailAssistanceDto } from "./DTO/write-email-assistance.dto"; import { AiAssistantService } from "./services/ai-assistant.service"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; @@ -37,4 +38,14 @@ export class AiAssistantController { async generateTemplate(@Body() generateTemplateDto: GenerateTemplateDto) { return this.aiAssistantService.generateTemplate(generateTemplateDto); } + + @Post("text-to-speech") + @ApiOperation({ summary: "Convert email summary to speech audio and save as file" }) + @ApiResponse({ status: 201, description: "Audio file generated and saved successfully" }) + @ApiResponse({ status: 400, description: "Bad request - invalid message ID or mailbox" }) + @ApiResponse({ status: 404, description: "Message not found or access denied" }) + @ApiResponse({ status: 401, description: "Unauthorized - invalid authentication" }) + async textToSpeech(@Body() textToSpeechDto: TextToSpeechDto, @UserDec("wildduckUserId") wildduckUserId: string) { + return this.aiAssistantService.textToSpeechEmailSummary(textToSpeechDto, wildduckUserId); + } } diff --git a/src/modules/ai-assistant/ai-assistant.module.ts b/src/modules/ai-assistant/ai-assistant.module.ts index 77b7a8c..9337263 100644 --- a/src/modules/ai-assistant/ai-assistant.module.ts +++ b/src/modules/ai-assistant/ai-assistant.module.ts @@ -5,9 +5,10 @@ import { AiAssistantService } from "./services/ai-assistant.service"; import { AiService } from "./services/ai.service"; import { aiConfigProvider } from "../../configs/ai.config"; import { MailServerModule } from "../mail-server/mail-server.module"; +import { UploaderModule } from "../uploader/uploader.module"; @Module({ - imports: [MailServerModule], + imports: [MailServerModule, UploaderModule], controllers: [AiAssistantController], providers: [AiAssistantService, AiService, aiConfigProvider], exports: [AiAssistantService, AiService], diff --git a/src/modules/ai-assistant/interfaces/ai-service.interface.ts b/src/modules/ai-assistant/interfaces/ai-service.interface.ts index d41ecfc..6350b66 100644 --- a/src/modules/ai-assistant/interfaces/ai-service.interface.ts +++ b/src/modules/ai-assistant/interfaces/ai-service.interface.ts @@ -2,6 +2,7 @@ import { PriorityEnum } from "../../email/enums/email-header.enum"; import { MessageAttachment } from "../../mail-server/interfaces/messages-response.interface"; import { PersonalityDataType } from "../../templates/interfaces/structure.interface"; import { TemplatePurposeEnum, TemplateStyleEnum, TemplateThemeEnum } from "../DTO/generate-template.dto"; +import { AudioFormatEnum, SpeedEnum, VoiceEnum } from "../DTO/text-to-speech.dto"; import { LanguageEnum, SentimentEnum, SummaryLengthEnum, SummaryToneEnum } from "../enums/ai.enum"; export interface IEmailData { @@ -66,8 +67,31 @@ export interface AIWritingResponse { improvements: string[]; } +export interface TextToSpeechOptions { + voice: VoiceEnum; + speed: SpeedEnum; + format: AudioFormatEnum; +} + +export interface AITextToSpeechResponse { + audioBuffer: Buffer; + format: AudioFormatEnum; + duration?: number; + contentType: string; +} + +export interface TextToSpeechFileResponse { + fileUrl: string; + fileName: string; + format: AudioFormatEnum; + contentType: string; + fileSize: number; + s3Key: string; +} + export interface AIServiceInterface { summarizeEmail(emailData: IEmailData, options: SummarizationOptions): Promise; assistEmailWriting(prompt: string): Promise; generateTemplate(request: ITemplateGenerationRequest): Promise; + textToSpeech(text: string, options: TextToSpeechOptions): Promise; } diff --git a/src/modules/ai-assistant/interfaces/ai.interface.ts b/src/modules/ai-assistant/interfaces/ai.interface.ts index b462927..00b69f8 100644 --- a/src/modules/ai-assistant/interfaces/ai.interface.ts +++ b/src/modules/ai-assistant/interfaces/ai.interface.ts @@ -2,6 +2,7 @@ export interface AIConfig { baseUrl: string; apiKey: string; defaultModel: string; + defaultTTSModel: string; temperature: number; maxTokens: number; timeout: number; diff --git a/src/modules/ai-assistant/services/ai-assistant.service.ts b/src/modules/ai-assistant/services/ai-assistant.service.ts index 09617c4..89c4d4f 100644 --- a/src/modules/ai-assistant/services/ai-assistant.service.ts +++ b/src/modules/ai-assistant/services/ai-assistant.service.ts @@ -5,17 +5,27 @@ import { AiService } from "./ai.service"; import { AiMessage } from "../../../common/enums/message.enum"; import { MessageDetails } from "../../mail-server/interfaces/messages-response.interface"; import { WildDuckMessagesService } from "../../mail-server/services/wildduck-messages.service"; +import { S3Service } from "../../uploader/providers/s3.service"; import { GenerateTemplateDto, TemplatePurposeEnum, TemplateStyleEnum, TemplateThemeEnum } from "../DTO/generate-template.dto"; import { SummarizeEmailDto } from "../DTO/summarize-email.dto"; +import { AudioFormatEnum, SpeedEnum, TextToSpeechDto, VoiceEnum } from "../DTO/text-to-speech.dto"; import { WriteEmailAssistanceDto } from "../DTO/write-email-assistance.dto"; import { SummaryLengthEnum, SummaryToneEnum } from "../enums/ai.enum"; -import { IEmailData, ITemplateGenerationRequest, SummarizationOptions, TemplateGenerationOptions } from "../interfaces/ai-service.interface"; +import { AITextToSpeechResponse } from "../interfaces/ai-service.interface"; +import { + IEmailData, + ITemplateGenerationRequest, + SummarizationOptions, + TemplateGenerationOptions, + TextToSpeechOptions, +} from "../interfaces/ai-service.interface"; @Injectable() export class AiAssistantService { constructor( private readonly messagesService: WildDuckMessagesService, private readonly aiService: AiService, + private readonly s3Service: S3Service, ) {} //************************************************* */ @@ -72,6 +82,35 @@ export class AiAssistantService { }; } + //************************************************* */ + async textToSpeechEmailSummary(textToSpeechDto: TextToSpeechDto, userId: string) { + const message = await firstValueFrom(this.messagesService.getMessage(userId, textToSpeechDto.mailbox, textToSpeechDto.messageId)); + + if (!message) throw new NotFoundException(AiMessage.MESSAGE_NOT_FOUND_OR_ACCESS_DENIED); + + const emailData = this.extractEmailData(message); + const summaryResponse = await this.generateEmailSummary(emailData, SummaryLengthEnum.MEDIUM, SummaryToneEnum.PROFESSIONAL); + + // const textForSpeech = this.buildTextForSpeech(summaryResponse, emailData); + const textForSpeech = summaryResponse.summary; + + const ttsOptions: TextToSpeechOptions = { + voice: textToSpeechDto.voice || VoiceEnum.ALLOY, + speed: textToSpeechDto.speed || SpeedEnum.NORMAL, + format: textToSpeechDto.format || AudioFormatEnum.MP3, + }; + + const audioResponse = await this.aiService.textToSpeech(textForSpeech, ttsOptions); + + const fileResponse = await this.saveAudioFile(audioResponse, textToSpeechDto, emailData); + + return { + file: fileResponse, + summary: summaryResponse, + textUsed: textForSpeech, + }; + } + //************************************************* */ private buildTemplateGenerationOptions(dto: GenerateTemplateDto): TemplateGenerationOptions { return { @@ -176,6 +215,65 @@ export class AiAssistantService { }; } + //************************************************* */ + private async saveAudioFile(audioResponse: AITextToSpeechResponse, textToSpeechDto: TextToSpeechDto, emailData: IEmailData) { + const timestamp = Date.now(); + const sanitizedSubject = emailData.subject?.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 50) || "email_summary"; + const fileName = `tts_${sanitizedSubject}_${timestamp}.${audioResponse.format}`; + + const s3Key = this.s3Service.generateFileKey(fileName, "audio"); + + const s3Result = await this.s3Service.uploadFile(audioResponse.audioBuffer, s3Key, audioResponse.contentType, { + originalName: fileName, + messageId: textToSpeechDto.messageId.toString(), + mailbox: textToSpeechDto.mailbox, + voice: textToSpeechDto.voice || VoiceEnum.ALLOY, + speed: (textToSpeechDto.speed || SpeedEnum.NORMAL).toString(), + format: audioResponse.format, + generatedAt: new Date().toISOString(), + }); + + return { + fileUrl: s3Result.url, + fileName, + format: audioResponse.format, + contentType: audioResponse.contentType, + fileSize: audioResponse.audioBuffer.length, + s3Key: s3Result.key, + }; + } + + //************************************************* */ + // private buildTextForSpeech(summaryResponse: AISummaryResponse, emailData: IEmailData): string { + // let text = `Email Summary: `; + + // if (emailData.subject) { + // text += `Subject: ${emailData.subject}. `; + // } + + // if (emailData.from) { + // text += `From: ${emailData.from}. `; + // } + + // if (summaryResponse.summary) { + // text += `Summary: ${summaryResponse.summary}. `; + // } + + // if (summaryResponse.keyPoints && summaryResponse.keyPoints.length > 0) { + // text += `Key Points: ${summaryResponse.keyPoints.join(". ")}. `; + // } + + // if (summaryResponse.suggestedActions && summaryResponse.suggestedActions.length > 0) { + // text += `Suggested Actions: ${summaryResponse.suggestedActions.join(". ")}. `; + // } + + // if (summaryResponse.priority) { + // text += `Priority: ${summaryResponse.priority}.`; + // } + + // return text; + // } + //************************************************* */ private async generateEmailWritingAssistance(writeAssistanceDto: WriteEmailAssistanceDto, _userId: string) { const aiResponse = await this.aiService.assistEmailWriting(writeAssistanceDto.prompt); diff --git a/src/modules/ai-assistant/services/ai.service.ts b/src/modules/ai-assistant/services/ai.service.ts index feaca3f..dfe13e7 100644 --- a/src/modules/ai-assistant/services/ai.service.ts +++ b/src/modules/ai-assistant/services/ai.service.ts @@ -9,10 +9,12 @@ import { AIServiceInterface, AISummaryResponse, AITemplateGenerationResponse, + AITextToSpeechResponse, AIWritingResponse, IEmailData, ITemplateGenerationRequest, SummarizationOptions, + TextToSpeechOptions, } from "../interfaces/ai-service.interface"; import { AIConfig } from "../interfaces/ai.interface"; @@ -118,6 +120,37 @@ export class AiService implements AIServiceInterface { return parsedResponse; } + + //************************************************* */ + async textToSpeech(text: string, options: TextToSpeechOptions): Promise { + try { + const response = await this.openai.audio.speech.create({ + model: this.aiConfig.defaultTTSModel, + voice: options.voice, + input: text, + response_format: options.format, + speed: options.speed, + }); + + const audioBuffer = Buffer.from(await response.arrayBuffer()); + + const contentTypeMap = { + mp3: "audio/mpeg", + opus: "audio/opus", + aac: "audio/aac", + flac: "audio/flac", + }; + + return { + audioBuffer, + format: options.format, + contentType: contentTypeMap[options.format], + }; + } catch (error) { + this.logger.error("Error generating text-to-speech", error); + throw new InternalServerErrorException(AiMessage.NO_RESPONSE_FROM_AI_MODEL); + } + } //************************************************* */ private parseJsonResponse(response: string): T { diff --git a/src/modules/uploader/interfaces/file.interface.ts b/src/modules/uploader/interfaces/file.interface.ts new file mode 100644 index 0000000..bc8a5ff --- /dev/null +++ b/src/modules/uploader/interfaces/file.interface.ts @@ -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; +} + +// File processing status +export type FileProcessingStatus = "pending" | "processing" | "completed" | "failed"; + +export interface FileProcessingResult { + status: FileProcessingStatus; + metadata?: FileMetadata; + error?: string; +} diff --git a/src/modules/uploader/providers/s3.service.ts b/src/modules/uploader/providers/s3.service.ts index 4ece82e..e507ca8 100644 --- a/src/modules/uploader/providers/s3.service.ts +++ b/src/modules/uploader/providers/s3.service.ts @@ -1,10 +1,12 @@ -import { Readable } from "stream"; +import { randomInt } from "node:crypto"; +import { Readable } from "node:stream"; import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { FileType } from "../interfaces/file.interface"; import { S3UploadResult } from "../interfaces/s3.interface"; @Injectable() @@ -118,12 +120,21 @@ export class S3Service { /** * Generate S3 key for file */ - generateFileKey(originalName: string): string { + generateFileKey(originalName: string, fileType: FileType): string { const timestamp = Date.now(); - const randomSuffix = Math.round(Math.random() * 1e9); + const randomSuffix = randomInt(1000000, 9999999); const extension = originalName.split(".").pop(); - const basePath = `images`; + const basePathMap: Record = { + image: "images", + video: "videos", + audio: "audio", + document: "documents", + text: "texts", + unknown: "unknowns", + }; + + const basePath = basePathMap[fileType]; return `${basePath}/${timestamp}-${randomSuffix}.${extension}`; } } diff --git a/src/modules/uploader/providers/uploader.service.ts b/src/modules/uploader/providers/uploader.service.ts index aee3393..08c8943 100644 --- a/src/modules/uploader/providers/uploader.service.ts +++ b/src/modules/uploader/providers/uploader.service.ts @@ -4,6 +4,7 @@ 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 { @@ -38,6 +39,27 @@ export class UploaderService { "image/svg+xml", "image/tiff", "image/bmp", + "application/pdf", + "text/plain", + "text/csv", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", + "application/vnd.ms-excel.sheet.macroEnabled.12", + "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + "application/vnd.ms-excel.template.macroEnabled.12", + "application/vnd.ms-powerpoint.template.macroEnabled.12", + "application/vnd.ms-excel.addin.macroEnabled.12", + "application/vnd.ms-powerpoint.addin.macroEnabled.12", + "application/vnd.ms-excel.sheet.binary.macroEnabled.12", + "application/vnd.ms-powerpoint.presentation.binary.macroEnabled.12", + "application/vnd.ms-excel.template.binary.macroEnabled.12", + "application/vnd.ms-powerpoint.template.binary.macroEnabled.12", + "application/vnd.ms-excel.addin.binary.macroEnabled.12", ]; } @@ -54,14 +76,11 @@ export class UploaderService { } this.logger.log(`Uploading file: ${file.originalname}`); - // Validate file this.validateFile(file); - // Determine file type - this.determineFileType(file.mimetype); + const fileType = await this.determineFileType(file.mimetype); - // Generate S3 key - const s3Key = this.s3Service.generateFileKey(file.originalname); + const s3Key = this.s3Service.generateFileKey(file.originalname, fileType); // Upload to S3 const s3Result = await this.s3Service.uploadFile(file.buffer, s3Key, file.mimetype, { @@ -83,27 +102,28 @@ export class UploaderService { private validateFile(file: File): void { if (!file) throw new BadRequestException(UploaderMessage.UPLOAD_FILE_INVALID); - if (file.size && file.size > this.maxFileSize) throw new BadRequestException(`File too large. Maximum size is ${this.maxFileSize} bytes`); + 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(`File type not supported: ${file.mimetype}`); + 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 determineFileType(mimetype: string) { + private async determineFileType(mimetype: string): Promise { if (mimetype.startsWith("video/")) { return "video"; } else if (mimetype.startsWith("audio/")) { return "audio"; } else if (mimetype.startsWith("image/")) { return "image"; - } else if (mimetype.startsWith("application/")) { - return "application"; } else if (mimetype.startsWith("text/")) { return "text"; + } else if (mimetype.startsWith("application/")) { + return "document"; } - // Default to video for unknown types - return "video"; + return "unknown"; } } diff --git a/src/modules/uploader/uploader.controller.ts b/src/modules/uploader/uploader.controller.ts index 93188e5..0e4b143 100644 --- a/src/modules/uploader/uploader.controller.ts +++ b/src/modules/uploader/uploader.controller.ts @@ -16,7 +16,7 @@ export class UploaderController { @UseInterceptors(FileInterceptor("file")) @ApiBody({ type: UploadSingleFileDto }) @Post("single-file") - async uploadFile(@UploadedFile() file: File) { + uploadFile(@UploadedFile() file: File) { return this.uploaderService.uploadFile(file); } diff --git a/src/modules/uploader/uploader.module.ts b/src/modules/uploader/uploader.module.ts index f5b7d24..857f254 100644 --- a/src/modules/uploader/uploader.module.ts +++ b/src/modules/uploader/uploader.module.ts @@ -7,6 +7,6 @@ import { UploaderController } from "./uploader.controller"; @Module({ controllers: [UploaderController], providers: [UploaderService, S3Service], - exports: [UploaderService], + exports: [UploaderService, S3Service], }) export class UploaderModule {}