chore: add uploader and user profile picture
This commit is contained in:
@@ -25,6 +25,7 @@ import { QuotaSyncModule } from "./modules/quota-sync/quota-sync.module";
|
||||
import { SettingModule } from "./modules/settings/settings.module";
|
||||
import { EmailSignaturesModule } from "./modules/signatures/email-signatures.module";
|
||||
import { TemplatesModule } from "./modules/templates/templates.module";
|
||||
import { UploaderModule } from "./modules/uploader/uploader.module";
|
||||
import { UsersModule } from "./modules/users/users.module";
|
||||
import { UtilsModule } from "./modules/utils/utils.module";
|
||||
|
||||
@@ -50,6 +51,7 @@ import { UtilsModule } from "./modules/utils/utils.module";
|
||||
TemplatesModule,
|
||||
SettingModule,
|
||||
NotificationModule,
|
||||
UploaderModule,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { UseGuards, applyDecorators } from "@nestjs/common";
|
||||
import { ApiBearerAuth } from "@nestjs/swagger";
|
||||
import { ApiBearerAuth, ApiHeader } from "@nestjs/swagger";
|
||||
|
||||
import { AdminRouteGuard } from "../../modules/auth/guards/admin.guard";
|
||||
import { JwtAuthGuard } from "../../modules/auth/guards/auth.guard";
|
||||
|
||||
export function AuthGuards() {
|
||||
return applyDecorators(UseGuards(JwtAuthGuard, AdminRouteGuard), ApiBearerAuth("authorization"));
|
||||
return applyDecorators(
|
||||
UseGuards(JwtAuthGuard, AdminRouteGuard),
|
||||
ApiBearerAuth("authorization"),
|
||||
ApiHeader({ name: "x-business-id", description: "Business ID" }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,6 +107,9 @@ export const enum UserMessage {
|
||||
FORWARDERS_EMAIL_VALID = "آدرس ایمیل فوروارد باید معتبر باشد",
|
||||
FORWARDERS_ARRAY = "آدرس های فوروارد باید یک آرایه باشد",
|
||||
FORWARDERS_STRING = "آدرس های فوروارد باید یک رشته باشد",
|
||||
PROFILE_PICTURE_URL = "آدرس تصویر پروفایل باید یک آدرس اینترنتی معتبر باشد",
|
||||
USER_UPDATED_SUCCESSFULLY = "کاربر با موفقیت به روز رسانی شد",
|
||||
PROFILE_PICTURE_NOT_EMPTY = "تصویر پروفایل نمیتواند خالی باشد",
|
||||
}
|
||||
|
||||
export const enum CommonMessage {
|
||||
|
||||
@@ -1,105 +1,15 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsEnum, IsNotEmpty, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { EmailLength, EmailPurpose, WritingTone } from "../enums/ai.enum";
|
||||
|
||||
export class EmailContextDto {
|
||||
@IsOptional()
|
||||
@IsString({ message: "Original email content must be a string" })
|
||||
@ApiPropertyOptional({
|
||||
description: "Original email content (for replies/forwards)",
|
||||
example: "Hi, I need to discuss the project timeline...",
|
||||
})
|
||||
originalContent?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "Original sender must be a string" })
|
||||
@ApiPropertyOptional({
|
||||
description: "Original sender (for replies/forwards)",
|
||||
example: "john.doe@company.com",
|
||||
})
|
||||
originalSender?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "Original subject must be a string" })
|
||||
@ApiPropertyOptional({
|
||||
description: "Original subject (for replies/forwards)",
|
||||
example: "Project Timeline Discussion",
|
||||
})
|
||||
originalSubject?: string;
|
||||
}
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class WriteEmailAssistanceDto {
|
||||
@IsNotEmpty({ message: "Email purpose is required" })
|
||||
@IsEnum(EmailPurpose, { message: "Email purpose must be one of: reply, forward, new, follow_up" })
|
||||
@IsNotEmpty({ message: "Prompt is required" })
|
||||
@IsString({ message: "Prompt must be a string" })
|
||||
@ApiProperty({
|
||||
description: "Purpose of the email",
|
||||
enum: EmailPurpose,
|
||||
example: EmailPurpose.REPLY,
|
||||
description: "User prompt describing what kind of email they want to write",
|
||||
example:
|
||||
"Write a professional email to schedule a meeting with my team to discuss the Q4 project timeline. I want to address concerns about resource allocation and set a deadline for next Friday.",
|
||||
})
|
||||
purpose: EmailPurpose;
|
||||
|
||||
@IsNotEmpty({ message: "Main message or intent is required" })
|
||||
@IsString({ message: "Main message must be a string" })
|
||||
@ApiProperty({
|
||||
description: "Main message or intent of the email",
|
||||
example: "I want to schedule a meeting to discuss the project timeline and address any concerns.",
|
||||
})
|
||||
mainMessage: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "Recipient information must be a string" })
|
||||
@ApiPropertyOptional({
|
||||
description: "Recipient information (optional)",
|
||||
example: "john.doe@company.com",
|
||||
})
|
||||
recipient?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(WritingTone, { message: "Writing tone must be one of: formal, casual, professional, friendly, urgent" })
|
||||
@ApiPropertyOptional({
|
||||
description: "Desired tone of the email",
|
||||
enum: WritingTone,
|
||||
default: WritingTone.PROFESSIONAL,
|
||||
})
|
||||
tone?: WritingTone;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(EmailLength, { message: "Email length must be one of: short, medium, long" })
|
||||
@ApiPropertyOptional({
|
||||
description: "Desired length of the email",
|
||||
enum: EmailLength,
|
||||
default: EmailLength.MEDIUM,
|
||||
})
|
||||
length?: EmailLength;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray({ message: "Key points must be an array" })
|
||||
@IsString({ each: true, message: "Each key point must be a string" })
|
||||
@ApiPropertyOptional({
|
||||
description: "Key points to include in the email",
|
||||
type: [String],
|
||||
example: ["Discuss project timeline", "Address resource allocation", "Set next meeting date"],
|
||||
})
|
||||
keyPoints?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => EmailContextDto)
|
||||
@ApiPropertyOptional({
|
||||
description: "Context from original email (for replies/forwards)",
|
||||
type: EmailContextDto,
|
||||
})
|
||||
context?: EmailContextDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "Additional instructions must be a string" })
|
||||
@ApiPropertyOptional({
|
||||
description: "Additional instructions or preferences",
|
||||
example: "Please keep it concise and mention the deadline",
|
||||
})
|
||||
additionalInstructions?: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export class EmailSuggestionDto {
|
||||
@@ -122,25 +32,3 @@ export class EmailSuggestionDto {
|
||||
})
|
||||
alternatives: string[];
|
||||
}
|
||||
|
||||
export class EmailWritingResponseDto {
|
||||
@ApiProperty({
|
||||
description: "Primary email suggestion",
|
||||
type: EmailSuggestionDto,
|
||||
})
|
||||
suggestion: EmailSuggestionDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Writing tips and recommendations",
|
||||
type: [String],
|
||||
example: ["Consider adding a specific deadline", "You might want to include meeting agenda", "Don't forget to CC relevant stakeholders"],
|
||||
})
|
||||
tips: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: "Suggested improvements for the original request",
|
||||
type: [String],
|
||||
example: ["Consider being more specific about the timeline", "Add context about project urgency"],
|
||||
})
|
||||
improvements: string[];
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
||||
import { UserDec } from "../../common/decorators/user.decorator";
|
||||
|
||||
@Controller("ai-assistant")
|
||||
// @ApiHeader({ name: "x-business-id", description: "Business ID" })
|
||||
// @UseInterceptors(BusinessInterceptor)
|
||||
@AuthGuards()
|
||||
export class AiAssistantController {
|
||||
constructor(private readonly aiAssistantService: AiAssistantService) {}
|
||||
|
||||
@@ -14,20 +14,6 @@ export interface SummarizationOptions {
|
||||
language?: "Persian" | "English";
|
||||
}
|
||||
|
||||
export interface WritingAssistanceOptions {
|
||||
purpose: "reply" | "forward" | "new" | "follow_up";
|
||||
tone: "formal" | "casual" | "professional" | "friendly" | "urgent";
|
||||
length: "short" | "medium" | "long";
|
||||
keyPoints?: string[];
|
||||
context?: {
|
||||
originalContent?: string;
|
||||
originalSender?: string;
|
||||
originalSubject?: string;
|
||||
};
|
||||
additionalInstructions?: string;
|
||||
language?: "Persian" | "English";
|
||||
}
|
||||
|
||||
export interface AISummaryResponse {
|
||||
summary: string;
|
||||
keyPoints: string[];
|
||||
@@ -46,5 +32,5 @@ export interface AIWritingResponse {
|
||||
|
||||
export interface AIServiceInterface {
|
||||
summarizeEmail(emailData: EmailData, options: SummarizationOptions): Promise<AISummaryResponse>;
|
||||
assistEmailWriting(mainMessage: string, options: WritingAssistanceOptions): Promise<AIWritingResponse>;
|
||||
assistEmailWriting(prompt: string): Promise<AIWritingResponse>;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { WildDuckMessagesService } from "../../mail-server/services/wildduck-mes
|
||||
import { SummarizeEmailDto } from "../DTO/summarize-email.dto";
|
||||
import { WriteEmailAssistanceDto } from "../DTO/write-email-assistance.dto";
|
||||
import { SummaryLength, SummaryTone } from "../enums/ai.enum";
|
||||
import { EmailData, SummarizationOptions, WritingAssistanceOptions } from "../interfaces/ai-service.interface";
|
||||
import { EmailData, SummarizationOptions } from "../interfaces/ai-service.interface";
|
||||
|
||||
@Injectable()
|
||||
export class AiAssistantService {
|
||||
@@ -132,22 +132,7 @@ export class AiAssistantService {
|
||||
|
||||
//************************************************* */
|
||||
private async generateEmailWritingAssistance(writeAssistanceDto: WriteEmailAssistanceDto, _userId: string) {
|
||||
const writingOptions: WritingAssistanceOptions = {
|
||||
purpose: writeAssistanceDto.purpose.toLowerCase() as "reply" | "forward" | "new" | "follow_up",
|
||||
tone: (writeAssistanceDto.tone?.toLowerCase() as "formal" | "casual" | "professional" | "friendly" | "urgent") || "professional",
|
||||
length: (writeAssistanceDto.length?.toLowerCase() as "short" | "medium" | "long") || "medium",
|
||||
keyPoints: writeAssistanceDto.keyPoints,
|
||||
context: writeAssistanceDto.context
|
||||
? {
|
||||
originalContent: writeAssistanceDto.context.originalContent,
|
||||
originalSender: writeAssistanceDto.context.originalSender,
|
||||
originalSubject: writeAssistanceDto.context.originalSubject,
|
||||
}
|
||||
: undefined,
|
||||
additionalInstructions: writeAssistanceDto.additionalInstructions,
|
||||
};
|
||||
|
||||
const aiResponse = await this.aiService.assistEmailWriting(writeAssistanceDto.mainMessage, writingOptions);
|
||||
const aiResponse = await this.aiService.assistEmailWriting(writeAssistanceDto.prompt);
|
||||
|
||||
return {
|
||||
suggestion: {
|
||||
|
||||
@@ -4,18 +4,15 @@ import OpenAI from "openai";
|
||||
import { AI_CONFIG } from "../../../common/constants";
|
||||
import { AiMessage } from "../../../common/enums/message.enum";
|
||||
import { AIConfig } from "../../../configs/ai.config";
|
||||
import {
|
||||
AIServiceInterface,
|
||||
AISummaryResponse,
|
||||
AIWritingResponse,
|
||||
EmailData,
|
||||
SummarizationOptions,
|
||||
WritingAssistanceOptions,
|
||||
} from "../interfaces/ai-service.interface";
|
||||
import { AIServiceInterface, AISummaryResponse, AIWritingResponse, EmailData, SummarizationOptions } from "../interfaces/ai-service.interface";
|
||||
|
||||
@Injectable()
|
||||
export class AiService implements AIServiceInterface {
|
||||
private readonly openai: OpenAI;
|
||||
private readonly language: "Persian" | "English" = "Persian";
|
||||
private readonly defaultTone: "formal" | "casual" | "professional" = "professional";
|
||||
private readonly defaultLength: "short" | "medium" | "detailed" = "medium";
|
||||
private readonly defaultPurpose: "general" | "reply" | "forward" | "new" | "follow_up" = "general";
|
||||
|
||||
constructor(@Inject(AI_CONFIG) private readonly aiConfig: AIConfig) {
|
||||
this.openai = new OpenAI({
|
||||
@@ -25,6 +22,7 @@ export class AiService implements AIServiceInterface {
|
||||
});
|
||||
}
|
||||
|
||||
//************************************************* */
|
||||
async summarizeEmail(emailData: EmailData, options: SummarizationOptions): Promise<AISummaryResponse> {
|
||||
const prompt = this.buildSummarizationPrompt(emailData, options);
|
||||
|
||||
@@ -52,16 +50,15 @@ export class AiService implements AIServiceInterface {
|
||||
|
||||
return parsedResponse;
|
||||
}
|
||||
//************************************************* */
|
||||
async assistEmailWriting(mainMessage: string, options: WritingAssistanceOptions): Promise<AIWritingResponse> {
|
||||
const prompt = this.buildWritingAssistancePrompt(mainMessage, options);
|
||||
|
||||
//************************************************* */
|
||||
async assistEmailWriting(prompt: string): Promise<AIWritingResponse> {
|
||||
const completion = await this.openai.chat.completions.create({
|
||||
model: this.aiConfig.defaultModel,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: this.getWritingAssistanceSystemPrompt(options),
|
||||
content: this.getWritingAssistanceSystemPrompt(),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
@@ -80,24 +77,23 @@ export class AiService implements AIServiceInterface {
|
||||
|
||||
return parsedResponse;
|
||||
}
|
||||
|
||||
//************************************************* */
|
||||
private getSummarizationSystemPrompt(options: SummarizationOptions): string {
|
||||
const language = options.language || "Persian";
|
||||
const language = options.language || this.language;
|
||||
const template = this.aiConfig.farsiPrompts.summarization.systemPrompt;
|
||||
|
||||
return template.replace("{tone}", options.tone).replace("{length}", options.length).replace("{language}", language);
|
||||
}
|
||||
|
||||
//************************************************* */
|
||||
private getWritingAssistanceSystemPrompt(options: WritingAssistanceOptions): string {
|
||||
const language = options.language || "Persian";
|
||||
private getWritingAssistanceSystemPrompt(): string {
|
||||
const language = this.language;
|
||||
const template = this.aiConfig.farsiPrompts.writingAssistance.systemPrompt;
|
||||
|
||||
return template
|
||||
.replace("{tone}", options.tone)
|
||||
.replace("{length}", options.length)
|
||||
.replace("{purpose}", options.purpose)
|
||||
.replace("{tone}", this.defaultTone)
|
||||
.replace("{length}", this.defaultLength)
|
||||
.replace("{purpose}", this.defaultPurpose)
|
||||
.replace("{language}", language);
|
||||
}
|
||||
|
||||
@@ -105,7 +101,6 @@ export class AiService implements AIServiceInterface {
|
||||
private buildSummarizationPrompt(emailData: EmailData, options: SummarizationOptions): string {
|
||||
const template = this.aiConfig.farsiPrompts.summarization.promptTemplate;
|
||||
let prompt = `${template.intro}\n\n`;
|
||||
console.log(emailData);
|
||||
|
||||
prompt += `${template.subjectLabel} ${emailData.subject}\n`;
|
||||
prompt += `${template.fromLabel} ${emailData.from}\n`;
|
||||
@@ -123,44 +118,4 @@ export class AiService implements AIServiceInterface {
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
//************************************************* */
|
||||
private buildWritingAssistancePrompt(mainMessage: string, options: WritingAssistanceOptions): string {
|
||||
const template = this.aiConfig.farsiPrompts.writingAssistance.promptTemplate;
|
||||
let prompt = `${template.intro}\n\n`;
|
||||
|
||||
prompt += `${template.mainMessageLabel} ${mainMessage}\n\n`;
|
||||
prompt += `${template.emailDetailsLabel}\n`;
|
||||
prompt += `${template.purposeLabel} ${options.purpose}\n`;
|
||||
prompt += `${template.toneLabel} ${options.tone}\n`;
|
||||
prompt += `${template.lengthLabel} ${options.length}\n\n`;
|
||||
|
||||
if (options.keyPoints && options.keyPoints.length > 0) {
|
||||
prompt += `${template.keyPointsLabel}\n`;
|
||||
options.keyPoints.forEach((point, index) => {
|
||||
prompt += `${index + 1}. ${point}\n`;
|
||||
});
|
||||
prompt += `\n`;
|
||||
}
|
||||
|
||||
if (options.context) {
|
||||
prompt += `${template.contextLabel}\n`;
|
||||
if (options.context.originalSubject) {
|
||||
prompt += `${template.originalSubjectLabel} ${options.context.originalSubject}\n`;
|
||||
}
|
||||
if (options.context.originalSender) {
|
||||
prompt += `${template.originalSenderLabel} ${options.context.originalSender}\n`;
|
||||
}
|
||||
if (options.context.originalContent) {
|
||||
prompt += `${template.originalContentLabel} ${options.context.originalContent}\n`;
|
||||
}
|
||||
prompt += `\n`;
|
||||
}
|
||||
|
||||
if (options.additionalInstructions) {
|
||||
prompt += `${template.additionalInstructionsLabel} ${options.additionalInstructions}\n\n`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, UseInterceptors } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||
import { ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||
|
||||
import { BusinessQuotaResponseDto } from "./DTO/business-quota-response.dto";
|
||||
import { BusinessSlugParamDto } from "./DTO/business-slug-param.dto";
|
||||
@@ -12,7 +12,6 @@ import { BusinessDec } from "../../common/decorators/business.decorator";
|
||||
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
|
||||
|
||||
@Controller("business")
|
||||
@ApiHeader({ name: "x-business-id", description: "Business ID" })
|
||||
export class BusinessesController {
|
||||
constructor(private readonly businessesService: BusinessesService) {}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseInterceptors } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||
import { ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||
|
||||
import { CreateDomainDto } from "./DTO/create-domain.dto";
|
||||
import { UpdateDomainDto } from "./DTO/update-domain.dto";
|
||||
@@ -12,7 +12,6 @@ import { ParamDto } from "../../common/DTO/param.dto";
|
||||
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
|
||||
|
||||
@Controller("domains")
|
||||
@ApiHeader({ name: "x-business-id", description: "Business ID" })
|
||||
@UseInterceptors(BusinessInterceptor)
|
||||
@AuthGuards()
|
||||
export class DomainsController {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseInterceptors } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||
import { ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||
|
||||
import { CreateTemplateDto } from "./DTO/create-template.dto";
|
||||
import { TemplateParamDto } from "./DTO/template-param.dto";
|
||||
@@ -12,7 +12,6 @@ import { BusinessDec } from "../../common/decorators/business.decorator";
|
||||
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
|
||||
|
||||
@Controller("templates")
|
||||
@ApiHeader({ name: "x-business-id", description: "Business ID" })
|
||||
@UseInterceptors(BusinessInterceptor)
|
||||
@AuthGuards()
|
||||
export class TemplatesController {
|
||||
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
import { File } from "@nest-lab/fastify-multer";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class UploadSingleFileDto {
|
||||
@ApiProperty({ type: "string", format: "binary", nullable: false })
|
||||
file: File;
|
||||
}
|
||||
|
||||
export class UploadMultipleFileDto {
|
||||
@ApiProperty({
|
||||
required: true,
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
format: "binary",
|
||||
},
|
||||
})
|
||||
files: File[];
|
||||
}
|
||||
@@ -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,129 @@
|
||||
import { Readable } from "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 { S3UploadResult } from "../interfaces/s3.interface";
|
||||
|
||||
@Injectable()
|
||||
export class S3Service {
|
||||
private readonly logger = new Logger(S3Service.name);
|
||||
private readonly s3Client: S3Client;
|
||||
private readonly bucketName: string;
|
||||
private readonly region: string;
|
||||
private readonly url: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.bucketName = this.configService.getOrThrow<string>("BUCKET_NAME");
|
||||
this.region = this.configService.getOrThrow<string>("BUCKET_REGION");
|
||||
this.url = this.configService.getOrThrow<string>("BUCKET_URL");
|
||||
|
||||
this.s3Client = new S3Client({
|
||||
region: this.region,
|
||||
endpoint: this.url,
|
||||
credentials: {
|
||||
accessKeyId: this.configService.getOrThrow<string>("BUCKET_ACCESS_KEY"),
|
||||
secretAccessKey: this.configService.getOrThrow<string>("BUCKET_SECRET_KEY"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.bucketName,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ACL: "public-read",
|
||||
ContentType: contentType,
|
||||
Metadata: metadata,
|
||||
});
|
||||
|
||||
await this.s3Client.send(command);
|
||||
|
||||
// const url = `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}`;
|
||||
const url = `https://${this.bucketName}.s3.ir-thr-at1.arvanstorage.ir/${key}`;
|
||||
|
||||
this.logger.log(`File uploaded to S3: ${key}`);
|
||||
|
||||
return {
|
||||
key,
|
||||
url,
|
||||
bucket: this.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.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.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.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): string {
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.round(Math.random() * 1e9);
|
||||
const extension = originalName.split(".").pop();
|
||||
|
||||
const basePath = `images`;
|
||||
return `${basePath}/${timestamp}-${randomSuffix}.${extension}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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";
|
||||
|
||||
@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",
|
||||
];
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
// Validate file
|
||||
this.validateFile(file);
|
||||
|
||||
// Determine file type
|
||||
this.determineFileType(file.mimetype);
|
||||
|
||||
// Generate S3 key
|
||||
const s3Key = this.s3Service.generateFileKey(file.originalname);
|
||||
|
||||
// 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(`File too large. Maximum size is ${this.maxFileSize} bytes`);
|
||||
|
||||
if (!this.allowedMimeTypes.includes(file.mimetype)) throw new BadRequestException(`File type not supported: ${file.mimetype}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine file type based on mimetype
|
||||
*/
|
||||
private determineFileType(mimetype: string) {
|
||||
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";
|
||||
}
|
||||
// Default to video for unknown types
|
||||
return "video";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { File, FileInterceptor, FilesInterceptor } from "@nest-lab/fastify-multer";
|
||||
import { Controller, Post, UploadedFile, UploadedFiles, UseInterceptors } from "@nestjs/common";
|
||||
import { ApiBody, ApiConsumes, ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
import { UploadMultipleFileDto, UploadSingleFileDto } from "./DTO/upload-file.dto";
|
||||
import { UploaderService } from "./providers/uploader.service";
|
||||
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
||||
|
||||
@Controller("uploader")
|
||||
@AuthGuards()
|
||||
export class UploaderController {
|
||||
constructor(private readonly uploaderService: UploaderService) {}
|
||||
|
||||
@ApiOperation({ summary: "Upload a file (admin)" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
@ApiBody({ type: UploadSingleFileDto })
|
||||
@Post("single-file")
|
||||
async uploadFile(@UploadedFile() file: File) {
|
||||
return this.uploaderService.uploadFile(file);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: "Uploads multiple files" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(FilesInterceptor("files"))
|
||||
@ApiBody({ type: UploadMultipleFileDto })
|
||||
@Post("multi-file")
|
||||
uploadFiles(@UploadedFiles() files: File[]) {
|
||||
return this.uploaderService.uploadMultiple(files);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { S3Service } from "./providers/s3.service";
|
||||
import { UploaderService } from "./providers/uploader.service";
|
||||
import { UploaderController } from "./uploader.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [UploaderController],
|
||||
providers: [UploaderService, S3Service],
|
||||
exports: [UploaderService],
|
||||
})
|
||||
export class UploaderModule {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsOptional, IsUrl } from "class-validator";
|
||||
|
||||
import { UserMessage } from "../../../common/enums/message.enum";
|
||||
|
||||
export class UpdateUserProfileDto {
|
||||
@IsOptional()
|
||||
@IsNotEmpty({ message: UserMessage.PROFILE_PICTURE_NOT_EMPTY })
|
||||
@IsUrl({ protocols: ["https"] }, { message: UserMessage.PROFILE_PICTURE_URL })
|
||||
@ApiPropertyOptional({ description: "Profile picture URL", example: "https://example.com/profile.jpg" })
|
||||
profilePic?: string;
|
||||
}
|
||||
@@ -20,6 +20,9 @@ export class User extends BaseEntity {
|
||||
@Property({ type: "varchar", length: 150, nullable: false })
|
||||
password!: string;
|
||||
|
||||
@Property({ type: "varchar", length: 255, nullable: true })
|
||||
profilePic?: string;
|
||||
|
||||
@Property({ type: "varchar", length: 255, nullable: false })
|
||||
emailAddress!: string;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Template } from "../../templates/entities/template.entity";
|
||||
import { PasswordService } from "../../utils/services/password.service";
|
||||
import { CreateEmailUserDto } from "../DTO/create-email-user.dto";
|
||||
import { UpdateEmailUserDto } from "../DTO/update-email-user.dto";
|
||||
import { UpdateUserProfileDto } from "../DTO/update-user-profile.dto";
|
||||
import { UserListQueryDto } from "../DTO/user-list-query.dto";
|
||||
import { Role } from "../entities/role.entity";
|
||||
import { User } from "../entities/user.entity";
|
||||
@@ -80,6 +81,16 @@ export class UsersService {
|
||||
};
|
||||
}
|
||||
/*******************************/
|
||||
async updateProfile(userId: string, updateMeDto: UpdateUserProfileDto) {
|
||||
const user = await this.userRepository.findOne({ id: userId, deletedAt: null });
|
||||
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
|
||||
|
||||
this.userRepository.assign(user, updateMeDto);
|
||||
await this.em.flush();
|
||||
|
||||
return { message: UserMessage.USER_UPDATED_SUCCESSFULLY, user };
|
||||
}
|
||||
/*******************************/
|
||||
|
||||
async findOneByIdWithEntityManager(id: string, em: EntityManager) {
|
||||
const user = await em.findOne(User, { id }, { populate: ["role"] });
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseInterceptors } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||
import { ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||
|
||||
import { CreateEmailUserDto } from "./DTO/create-email-user.dto";
|
||||
import { UpdateEmailUserDto } from "./DTO/update-email-user.dto";
|
||||
import { UpdateUserProfileDto } from "./DTO/update-user-profile.dto";
|
||||
import { UserListQueryDto } from "./DTO/user-list-query.dto";
|
||||
import { UsersService } from "./services/users.service";
|
||||
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
||||
@@ -12,7 +13,6 @@ import { ParamDto } from "../../common/DTO/param.dto";
|
||||
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
|
||||
|
||||
@Controller("users")
|
||||
@ApiHeader({ name: "x-business-id", description: "Business ID" })
|
||||
@AuthGuards()
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
@@ -24,6 +24,21 @@ export class UsersController {
|
||||
return this.usersService.getMe(userId);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@UseInterceptors(BusinessInterceptor)
|
||||
@ApiOperation({ summary: "Update email user" })
|
||||
@ApiResponse({ status: 200, description: "Email user updated successfully" })
|
||||
updateEmailUser(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string, @Body() body: UpdateEmailUserDto) {
|
||||
return this.usersService.updateEmailUser(paramDto.id, businessId, body);
|
||||
}
|
||||
|
||||
@Patch("profile")
|
||||
@ApiOperation({ summary: "Update current user" })
|
||||
@ApiResponse({ status: 200, description: "Current user updated successfully" })
|
||||
updateProfile(@UserDec("id") userId: string, @Body() updateMeDto: UpdateUserProfileDto) {
|
||||
return this.usersService.updateProfile(userId, updateMeDto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(BusinessInterceptor)
|
||||
@ApiOperation({ summary: "Create a new email user" })
|
||||
@@ -48,14 +63,6 @@ export class UsersController {
|
||||
return this.usersService.deleteEmailUser(paramDto.id, businessId);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@UseInterceptors(BusinessInterceptor)
|
||||
@ApiOperation({ summary: "Update email user" })
|
||||
@ApiResponse({ status: 200, description: "Email user updated successfully" })
|
||||
updateEmailUser(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string, @Body() body: UpdateEmailUserDto) {
|
||||
return this.usersService.updateEmailUser(paramDto.id, businessId, body);
|
||||
}
|
||||
|
||||
@Patch(":id/quota")
|
||||
@UseInterceptors(BusinessInterceptor)
|
||||
@ApiOperation({ summary: "Update email user quota" })
|
||||
|
||||
Reference in New Issue
Block a user