Compare commits
17 Commits
eb8d87bd48
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d76986577 | |||
| 1df68e8788 | |||
| 1ead663ddf | |||
| 366ab5092e | |||
| 13d41ed0ac | |||
| b8c1e06e65 | |||
| d6d79f83d3 | |||
| 78b82e05b5 | |||
| b4e5fd4e7e | |||
| 5dcd2d8a9f | |||
| a4c50496c5 | |||
| ac4e34436a | |||
| ce0b98783c | |||
| 2cfda9ca45 | |||
| e462606416 | |||
| 823d43fc75 | |||
| 366e2e2a92 |
@@ -20,20 +20,35 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1099.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1099.0",
|
||||
"@fastify/multipart": "^10.1.0",
|
||||
"@fastify/static": "^10.1.2",
|
||||
"@keyv/redis": "^5.1.6",
|
||||
"@nest-lab/fastify-multer": "^1.3.0",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/bullmq": "^11.0.4",
|
||||
"@nestjs/cache-manager": "^3.1.3",
|
||||
"@nestjs/common": "^11.1.28",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.28",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.28",
|
||||
"@nestjs/platform-fastify": "^11.1.28",
|
||||
"@nestjs/swagger": "^11.4.6",
|
||||
"@nestjs/typeorm": "^11.0.3",
|
||||
"axios": "^1.18.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.81.3",
|
||||
"cache-manager": "^7.2.9",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"fastify": "^5.11.0",
|
||||
"fastify-multer": "^2.0.3",
|
||||
"ioredis": "^5.11.1",
|
||||
"joi": "^18.2.3",
|
||||
"keyv": "^5.6.0",
|
||||
"ms": "^2.1.3",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
@@ -52,6 +67,7 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
||||
Generated
+1168
-7
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
allowBuilds:
|
||||
'@scarf/scarf': false
|
||||
bcrypt: false
|
||||
msgpackr-extract: false
|
||||
unrs-resolver: false
|
||||
|
||||
+35
-7
@@ -2,25 +2,53 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { validationSchema } from './config/config.validation';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { databaseConfig } from './config/typeorm.config';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { OrganModule } from './organ/organ.module';
|
||||
import { ComplaintModule } from './complaint/complaint.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { OrganModule } from './modules/organ/organ.module';
|
||||
import { ComplaintModule } from './modules/complaint/complaint.module';
|
||||
import { AttachmentModule } from './modules/attachment/attachment.module';
|
||||
import { ReplyModule } from './modules/reply/reply.module';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validationSchema
|
||||
validationSchema,
|
||||
}),
|
||||
CacheModule.registerAsync({
|
||||
isGlobal: true,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
stores: [new KeyvRedis(config.getOrThrow('REDIS_URL'))],
|
||||
}),
|
||||
}),
|
||||
BullModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
connection: {
|
||||
host: configService.getOrThrow<string>('REDIS_HOST'),
|
||||
port: configService.getOrThrow<number>('REDIS_PORT'),
|
||||
// password: configService.get<string>('REDIS_PASSWORD'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
TypeOrmModule.forRootAsync(databaseConfig()),
|
||||
HttpModule.register({global: true, timeout: 10000, headers: {"Content-Type": "application/json"}}),
|
||||
HttpModule.register({
|
||||
global: true,
|
||||
timeout: 10000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
OrganModule,
|
||||
ComplaintModule
|
||||
ComplaintModule,
|
||||
AttachmentModule,
|
||||
ReplyModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Matches } from 'class-validator';
|
||||
|
||||
export class SendOtpDto {
|
||||
@ApiProperty({
|
||||
example: '09123456789',
|
||||
})
|
||||
@Matches(/^09\d{9}$/, {
|
||||
message: 'Phone number must be a valid Iranian mobile number.',
|
||||
})
|
||||
phoneNumber!: string;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Matches, Length } from 'class-validator';
|
||||
|
||||
export class VerifyOtpDto {
|
||||
@ApiProperty({
|
||||
example: '09123456789',
|
||||
})
|
||||
@Matches(/^09\d{9}$/)
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '123456',
|
||||
})
|
||||
@IsString()
|
||||
@Length(6, 6)
|
||||
code: string;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UtilsModule } from 'src/utils/utils.module';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User } from 'src/users/entities/user.entity';
|
||||
import { OTP } from './entities/otp.entity';
|
||||
import { OtpRepository } from './repositories/otp.repository';
|
||||
import { UsersModule } from 'src/users/users.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UserRole } from 'src/users/entities/user-role.entity';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([OTP]),
|
||||
PassportModule,
|
||||
JwtModule.register({}),
|
||||
UtilsModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, OtpRepository, JwtStrategy],
|
||||
exports: [JwtModule, PassportModule]
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Column, Entity } from 'typeorm';
|
||||
import { BaseEntity } from 'src/common/entities/base.entity';
|
||||
import { OtpStatus } from '../enums/otp-status.enum';
|
||||
|
||||
@Entity('otps')
|
||||
export class OTP extends BaseEntity {
|
||||
@Column({ type: 'varchar', length: 6 })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
expirationDate: Date;
|
||||
|
||||
@Column({type: 'enum', enum: OtpStatus, default: OtpStatus.PENDING})
|
||||
status: OtpStatus;
|
||||
|
||||
@Column({ type: 'smallint', default: 0 })
|
||||
numberOfTries: number;
|
||||
|
||||
@Column({
|
||||
length: 11,
|
||||
})
|
||||
phoneNumber: string;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { OTP } from '../entities/otp.entity';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
export class OtpRepository extends Repository<OTP> {
|
||||
constructor(@InjectRepository(OTP) otpRepository: Repository<OTP>) {
|
||||
super(
|
||||
otpRepository.target,
|
||||
otpRepository.manager,
|
||||
otpRepository.queryRunner,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { applyDecorators, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth } from "@nestjs/swagger";
|
||||
import { JwtAuthGuard } from "src/auth/guards/jwt-auth.guard";
|
||||
import { applyDecorators, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from 'src/modules/auth/guards/jwt-auth.guard';
|
||||
import { RoleGuard } from 'src/modules/auth/guards/role-guard';
|
||||
|
||||
export function AuthGuard() {
|
||||
return applyDecorators(
|
||||
UseGuards(JwtAuthGuard),
|
||||
ApiBearerAuth('authorization')
|
||||
UseGuards(JwtAuthGuard, RoleGuard),
|
||||
ApiBearerAuth('authorization'),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
import { RoleType } from "src/modules/users/enums/user-role.enum";
|
||||
|
||||
export const ROLE_KEY = 'roles';
|
||||
export const RoleDec = (...roles: RoleType[]) => SetMetadata(ROLE_KEY, roles);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { IRequest } from 'src/auth/interfaces/IRequest';
|
||||
import { ITokenPayload } from 'src/auth/interfaces/IToken-payload';
|
||||
import { IRequest } from 'src/modules/auth/interfaces/IRequest';
|
||||
import { ITokenPayload } from 'src/modules/auth/interfaces/IToken-payload';
|
||||
|
||||
export const UserDec = createParamDecorator(
|
||||
(data: keyof ITokenPayload | undefined, ctx: ExecutionContext) => {
|
||||
|
||||
@@ -2,28 +2,49 @@ export enum UserMessages {
|
||||
USER_ALREADY_EXISTS = 'کاربر مورد نظر با این ایمیل یا شماره همراه در حال حاضر وجود دارد. لطفا وارد حساب شوید!',
|
||||
USER_NOT_FOUND = 'کاربر مورد نظر پیدا نشد!',
|
||||
USER_PASSWORD_INVALID = 'رمز وارد شده اشتباده است!',
|
||||
USER_VALID_CREDENTIALS = 'اطلاعات وارد شده صحیح میباشد!'
|
||||
USER_VALID_CREDENTIALS = 'اطلاعات وارد شده صحیح میباشد!',
|
||||
}
|
||||
|
||||
export enum OTPMessages {
|
||||
OTP_NOT_FOUND = 'این کد پیدا نشد! لطفا دوباره درخواست بدهید!',
|
||||
OTP_EXPIRED = 'این کد منسوخ شده است لطفا دوباره درخواست بدهید!',
|
||||
OTP_TOO_MANY_TRIES = 'تعداد تلاش ها بیش از حد مجاز است لطفا دوباره درخواست کد بدهید!',
|
||||
OTP_INVALID = 'کد وارد شده نا معتبر است!'
|
||||
OTP_INVALID = 'کد وارد شده نا معتبر است!',
|
||||
}
|
||||
|
||||
export enum UserRoleMessages {
|
||||
USER_ROLE_ALREADY_EXISTS = 'نقش مورد نظر برای کاربر مد نظر وجود دارد!'
|
||||
USER_ROLE_ALREADY_EXISTS = 'نقش مورد نظر برای کاربر مد نظر وجود دارد!',
|
||||
}
|
||||
|
||||
export enum AuthMessages {
|
||||
TOKEN_EXPIRED_OR_INVALID = 'توکن منسوخ شده یا نامعتبر است!',
|
||||
UNAUTHORIZED_ACCESS = 'شما مجاز به دسترسی به این بخش نیستید!'
|
||||
}
|
||||
|
||||
export enum OrganMessages {
|
||||
ORGAN_NOT_FOUND = 'ارگان مورد نظر پیدا نشد!'
|
||||
ORGAN_NOT_FOUND = 'ارگان مورد نظر پیدا نشد!',
|
||||
}
|
||||
|
||||
export enum ComplaintMessages {
|
||||
COMPLAINT_NOT_FOUND = 'شکایت مورد نظر پیدا نشد!'
|
||||
COMPLAINT_NOT_FOUND = 'شکایت مورد نظر پیدا نشد!',
|
||||
}
|
||||
|
||||
export enum AttachmentMessages {
|
||||
ATTACHMENT_NOT_FOUND = 'ضمیمه مورد نظر پیدا نشد!',
|
||||
}
|
||||
|
||||
export enum ReplyMessages {
|
||||
REPLY_NOT_FOUND = 'پاسخ مورد نظر پیدا نشد!',
|
||||
}
|
||||
|
||||
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] نوع فایل مجاز نیست',
|
||||
}
|
||||
|
||||
export const enum Notificationmessage {
|
||||
NOTIFICATION_NOT_FOUND = 'اعلان مورد نظر پیدا نشد!'
|
||||
}
|
||||
@@ -21,5 +21,20 @@ export const validationSchema = Joi.object({
|
||||
JWT_ACCESS_SECRET: Joi.string().required(),
|
||||
JWT_REFRESH_SECRET: Joi.string().required(),
|
||||
JWT_ACCESS_EXPIRES: Joi.string().required(),
|
||||
JWT_REFRESH_EXPIRES: Joi.string().required()
|
||||
JWT_REFRESH_EXPIRES: Joi.string().required(),
|
||||
|
||||
// CACHE Config
|
||||
CACHE_TTL : Joi.number().required(),
|
||||
|
||||
// Redis Config
|
||||
REDIS_URL : Joi.string().required(),
|
||||
REDIS_HOST: Joi.string().required(),
|
||||
REDIS_PORT: Joi.number().required(),
|
||||
|
||||
BUCKET_NAME: Joi.string().required(),
|
||||
BUCKET_REGION: Joi.string().required(),
|
||||
BUCKET_URL: Joi.string().required(),
|
||||
BUCKET_ACCESS_KEY: Joi.string().required(),
|
||||
BUCKET_SECRET_KEY: Joi.string().required(),
|
||||
BUCKET_UPLOAD_URL: Joi.string().required()
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { FactoryProvider } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { S3_CONFIG } from 'src/modules/uploader/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;
|
||||
bucketName: string;
|
||||
bucketUploadedUrl: string;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FactoryProvider } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
import { SMS_CONFIG } from "src/utils/constants";
|
||||
import { SMS_CONFIG } from "src/modules/notifications/constants";
|
||||
|
||||
export const smsConfigsProvider: FactoryProvider<ISmsConfigs> = {
|
||||
provide: SMS_CONFIG,
|
||||
|
||||
+11
-1
@@ -1,5 +1,9 @@
|
||||
import { NestFactory, Reflector } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import {
|
||||
FastifyAdapter,
|
||||
NestFastifyApplication,
|
||||
} from '@nestjs/platform-fastify';
|
||||
import {
|
||||
ClassSerializerInterceptor,
|
||||
Logger,
|
||||
@@ -9,11 +13,17 @@ import { setupSwagger } from './config/swagger.config';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AllExceptionFilter } from './common/filters/all-exception.filter';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
import multipart from '@fastify/multipart';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('APP');
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter(),
|
||||
);
|
||||
|
||||
await app.register(multipart);
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
|
||||
import { AttachmentService } from './attachment.service';
|
||||
import { CreateAttachmentDto } from './dto/create-attachment.dto';
|
||||
import { UpdateAttachmentDto } from './dto/update-attachment.dto';
|
||||
|
||||
@Controller('attachment')
|
||||
export class AttachmentController {
|
||||
constructor(private readonly attachmentService: AttachmentService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createAttachmentDto: CreateAttachmentDto) {
|
||||
return this.attachmentService.create(createAttachmentDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.attachmentService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.attachmentService.findOneOrFail(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateAttachmentDto: UpdateAttachmentDto) {
|
||||
return this.attachmentService.update(id, updateAttachmentDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.attachmentService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AttachmentService } from './attachment.service';
|
||||
import { AttachmentController } from './attachment.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Attachment } from './entities/attachment.entity';
|
||||
import { AttachmentRepository } from './repositories/attachment.repository';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Attachment])],
|
||||
controllers: [AttachmentController],
|
||||
providers: [AttachmentService, AttachmentRepository],
|
||||
})
|
||||
export class AttachmentModule {}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CreateAttachmentDto } from './dto/create-attachment.dto';
|
||||
import { UpdateAttachmentDto } from './dto/update-attachment.dto';
|
||||
import { Attachment } from './entities/attachment.entity';
|
||||
import { AttachmentRepository } from './repositories/attachment.repository';
|
||||
import { AttachmentMessages } from 'src/common/enums/messages.enum';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentService {
|
||||
constructor(private readonly attachmentRepository: AttachmentRepository) {}
|
||||
|
||||
async create(createAttachmentDto: CreateAttachmentDto): Promise<Attachment> {
|
||||
const attachment = this.attachmentRepository.create(createAttachmentDto);
|
||||
await this.attachmentRepository.save(attachment);
|
||||
return attachment;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Attachment[]> {
|
||||
return await this.attachmentRepository.find();
|
||||
}
|
||||
|
||||
async findOneOrFail(id: string): Promise<Attachment> {
|
||||
const attachment = await this.attachmentRepository.findOne({where: {id}});
|
||||
if(!attachment) throw new NotFoundException(AttachmentMessages.ATTACHMENT_NOT_FOUND);
|
||||
return attachment;
|
||||
}
|
||||
|
||||
async update(id: string, updateAttachmentDto: UpdateAttachmentDto): Promise<Attachment> {
|
||||
const attachment = await this.findOneOrFail(id);
|
||||
Object.assign(attachment, updateAttachmentDto);
|
||||
return await this.attachmentRepository.save(attachment);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<Attachment> {
|
||||
const attachment = await this.findOneOrFail(id);
|
||||
await this.attachmentRepository.remove(attachment);
|
||||
return attachment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
|
||||
import { AttachmentType } from "../enums/attachment-type.enum";
|
||||
|
||||
export class CreateAttachmentDto {
|
||||
@ApiProperty({
|
||||
example: 'فایل شکایت',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: AttachmentType.IMAGE,
|
||||
})
|
||||
@IsEnum(AttachmentType)
|
||||
@IsNotEmpty()
|
||||
type: AttachmentType;
|
||||
|
||||
@ApiProperty({
|
||||
example: '/images/file.pdf'
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
filepath: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateAttachmentDto } from './create-attachment.dto';
|
||||
|
||||
export class UpdateAttachmentDto extends PartialType(CreateAttachmentDto) {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BaseEntity } from "src/common/entities/base.entity";
|
||||
import { Column, Entity, OneToOne } from "typeorm";
|
||||
import { AttachmentType } from "../enums/attachment-type.enum";
|
||||
import { ComplaintAttachment } from "src/modules/complaint/entities/complaint-attachment.entity";
|
||||
import { ReplyAttachment } from "src/modules/reply/entities/reply-attachment.entity";
|
||||
|
||||
@Entity('attachments')
|
||||
export class Attachment extends BaseEntity{
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 50
|
||||
})
|
||||
title: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: AttachmentType,
|
||||
})
|
||||
type: AttachmentType;
|
||||
|
||||
@Column({
|
||||
type: 'text'
|
||||
})
|
||||
filepath: string;
|
||||
|
||||
@OneToOne(() => ComplaintAttachment, (ca) => ca.attachment)
|
||||
complaint: ComplaintAttachment;
|
||||
|
||||
@OneToOne(() => ReplyAttachment, (ra) => ra.attachment)
|
||||
reply: ReplyAttachment;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum AttachmentType {
|
||||
FILE = "file",
|
||||
LINK = "link",
|
||||
IMAGE = "image",
|
||||
VIDEO = "video",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Repository } from "typeorm";
|
||||
import { Attachment } from "../entities/attachment.entity";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentRepository extends Repository<Attachment> {
|
||||
constructor(@InjectRepository(Attachment) attachmentRepository: Repository<Attachment>) {
|
||||
super(attachmentRepository.target, attachmentRepository.manager, attachmentRepository.queryRunner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, Matches } from 'class-validator';
|
||||
import { RoleType } from 'src/modules/users/enums/user-role.enum';
|
||||
|
||||
export class SendOtpDto {
|
||||
@ApiProperty({
|
||||
example: '09123456789',
|
||||
})
|
||||
@Matches(/^09\d{9}$/, {
|
||||
message: 'Phone number must be a valid Iranian mobile number.',
|
||||
})
|
||||
phoneNumber!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'normal_user',
|
||||
enum: RoleType,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(RoleType)
|
||||
role?: RoleType;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsString, Matches, Length, IsEnum, IsOptional } from 'class-validator';
|
||||
import { RoleType } from 'src/modules/users/enums/user-role.enum';
|
||||
|
||||
export class VerifyOtpDto {
|
||||
@ApiProperty({
|
||||
example: '09123456789',
|
||||
})
|
||||
@Matches(/^09\d{9}$/)
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '123456',
|
||||
})
|
||||
@IsString()
|
||||
@Length(6, 6)
|
||||
code: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'normal_user',
|
||||
enum: RoleType,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(RoleType)
|
||||
role?: RoleType;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export class AuthController {
|
||||
@ApiResponse({ status: 200, description: 'Sent Otp Successfully!' })
|
||||
@ApiResponse({ status: 400, description: 'Bad Request from User!' })
|
||||
async sendOtp(@Body() dto: SendOtpDto) {
|
||||
return this.authService.sendOTP(dto.phoneNumber);
|
||||
return this.authService.sendOTP(dto);
|
||||
}
|
||||
|
||||
@Post('verify-otp')
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UsersModule } from 'src/modules/users/users.module';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { OtpService } from './otp.service';
|
||||
import { NotificationModule } from '../notifications/notification.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.register({}),
|
||||
UsersModule,
|
||||
NotificationModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, OtpService, JwtStrategy],
|
||||
exports: [JwtModule, PassportModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -1,35 +1,38 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { ISmsConfigs } from 'src/config/sms.config';
|
||||
import { SMS_CONFIG } from 'src/utils/constants';
|
||||
import { SmsService } from 'src/utils/providers/sms.service';
|
||||
import { OtpRepository } from './repositories/otp.repository';
|
||||
import { OtpStatus } from './enums/otp-status.enum';
|
||||
import { SMS_CONFIG } from '../notifications/constants';
|
||||
import { VerifyOtpDto } from './DTO/verify-otp.dto';
|
||||
import { OTPMessages, UserMessages } from 'src/common/enums/messages.enum';
|
||||
import { UserMessages } from 'src/common/enums/messages.enum';
|
||||
import { LoginCredentialDto } from './DTO/login-credential.dto';
|
||||
import { UsersService } from 'src/users/users.service';
|
||||
import { UsersService } from 'src/modules/users/users.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import ms from 'ms';
|
||||
import { OtpService } from './otp.service';
|
||||
import { SendOtpDto } from './DTO/send-otp.dto';
|
||||
import { SmsProducer } from '../notifications/producers/sms.producer';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@Inject(SMS_CONFIG) private smsConfig: ISmsConfigs,
|
||||
private readonly smsService: SmsService,
|
||||
private readonly smsProducer: SmsProducer,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly OtpRepository: OtpRepository,
|
||||
private readonly userService: UsersService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly otpService: OtpService,
|
||||
) {}
|
||||
|
||||
async sendOTP(phone: string) {
|
||||
const otp = this.generateOtp();
|
||||
async sendOTP(dto: SendOtpDto) {
|
||||
const otp = await this.otpService.generate({
|
||||
phoneNumber: dto.phoneNumber,
|
||||
role: dto.role,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.smsService.sendSms({
|
||||
Mobile: phone,
|
||||
await this.smsProducer.sendSms({
|
||||
Mobile: dto.phoneNumber,
|
||||
TemplateId: this.smsConfig.SMS_PATTERN_OTP,
|
||||
Parameters: [
|
||||
{
|
||||
@@ -39,21 +42,6 @@ export class AuthService {
|
||||
],
|
||||
});
|
||||
|
||||
await this.OtpRepository.update(
|
||||
{ phoneNumber: phone, status: OtpStatus.PENDING },
|
||||
{ status: OtpStatus.EXPIRED },
|
||||
);
|
||||
|
||||
await this.OtpRepository.save(
|
||||
this.OtpRepository.create({
|
||||
phoneNumber: phone,
|
||||
code: otp,
|
||||
expirationDate: new Date(Date.now() + 2 * 60 * 1000),
|
||||
status: OtpStatus.PENDING,
|
||||
numberOfTries: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
return { otp };
|
||||
} catch (error) {
|
||||
if (this.configService.get('NODE_ENV') !== 'production') {
|
||||
@@ -65,48 +53,10 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async verifyOTP(dto: VerifyOtpDto) {
|
||||
const otp = await this.OtpRepository.findOne({
|
||||
where: { phoneNumber: dto.phoneNumber, status: OtpStatus.PENDING },
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
});
|
||||
|
||||
if (!otp) {
|
||||
throw new BadRequestException(OTPMessages.OTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (otp.expirationDate < new Date()) {
|
||||
otp.status = OtpStatus.EXPIRED;
|
||||
|
||||
await this.OtpRepository.save(otp);
|
||||
|
||||
throw new BadRequestException(OTPMessages.OTP_EXPIRED);
|
||||
}
|
||||
|
||||
if (otp.code !== dto.code) {
|
||||
otp.numberOfTries++;
|
||||
|
||||
if (otp.numberOfTries >= 3) {
|
||||
otp.status = OtpStatus.EXPIRED;
|
||||
|
||||
await this.OtpRepository.save(otp);
|
||||
|
||||
throw new BadRequestException(OTPMessages.OTP_TOO_MANY_TRIES);
|
||||
}
|
||||
|
||||
await this.OtpRepository.save(otp);
|
||||
|
||||
throw new BadRequestException(OTPMessages.OTP_INVALID);
|
||||
}
|
||||
|
||||
otp.status = OtpStatus.VERIFIED;
|
||||
|
||||
await this.OtpRepository.save(otp);
|
||||
}
|
||||
|
||||
private generateOtp(): string {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
await this.otpService.verify(
|
||||
{ phoneNumber: dto.phoneNumber, role: dto.role },
|
||||
dto.code,
|
||||
);
|
||||
}
|
||||
|
||||
async checkLoginCredentials(credential: LoginCredentialDto): Promise<void> {
|
||||
@@ -120,7 +70,7 @@ export class AuthService {
|
||||
throw new BadRequestException(UserMessages.USER_PASSWORD_INVALID);
|
||||
}
|
||||
|
||||
await this.sendOTP(phoneNumber);
|
||||
await this.sendOTP({ phoneNumber } as SendOtpDto);
|
||||
}
|
||||
|
||||
async verifyOtpAndLogin(dto: VerifyOtpDto) {
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ROLE_KEY } from 'src/common/decorators/role-decorator';
|
||||
import { RoleType } from 'src/modules/users/enums/user-role.enum';
|
||||
import { IRequest } from '../interfaces/IRequest';
|
||||
import { AuthMessages } from 'src/common/enums/messages.enum';
|
||||
|
||||
@Injectable()
|
||||
export class RoleGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(
|
||||
context: ExecutionContext,
|
||||
): boolean | Promise<boolean> | Observable<boolean> {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<RoleType[]>(
|
||||
ROLE_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredRoles) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest<IRequest>();
|
||||
const user = req.user;
|
||||
|
||||
if (!user) throw new ForbiddenException(AuthMessages.UNAUTHORIZED_ACCESS);
|
||||
|
||||
const hasRole = requiredRoles.every((role) => user.roles.includes(role));
|
||||
|
||||
if (!hasRole)
|
||||
throw new ForbiddenException(AuthMessages.UNAUTHORIZED_ACCESS);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { RoleType } from "src/users/enums/user-role.enum";
|
||||
import { RoleType } from "src/modules/users/enums/user-role.enum";
|
||||
|
||||
export interface ITokenPayload {
|
||||
id: string;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Cache } from 'cache-manager';
|
||||
import { metadata } from 'reflect-metadata/no-conflict';
|
||||
import { OTPMessages } from 'src/common/enums/messages.enum';
|
||||
|
||||
interface OtpCache<T = any> {
|
||||
code: string;
|
||||
tries: number;
|
||||
metadata: T; // holds role, etc...
|
||||
}
|
||||
|
||||
type OtpParams<T extends Record<string, any> = {}> = T & {
|
||||
phoneNumber: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OtpService {
|
||||
private readonly TTL: number;
|
||||
|
||||
private readonly keyTemplate: (keyof any)[] = ['role']; // could add more later if necessary
|
||||
|
||||
constructor(
|
||||
@Inject(CACHE_MANAGER)
|
||||
private readonly cache: Cache,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.TTL = this.configService.getOrThrow<number>('CACHE_TTL');
|
||||
}
|
||||
|
||||
private buildKey(phoneNumber: string, metadata: Record<string, any>): string {
|
||||
const prefixParts = this.keyTemplate
|
||||
.map((field) => metadata[field as string])
|
||||
.filter((value) => value !== undefined && value !== null);
|
||||
|
||||
if (prefixParts.length > 0) {
|
||||
return `otp:${prefixParts.join(':')}:${phoneNumber}`;
|
||||
}
|
||||
return `otp:${phoneNumber}`;
|
||||
}
|
||||
|
||||
async generate<T extends Record<string, any>>(
|
||||
params: OtpParams<T>,
|
||||
): Promise<string> {
|
||||
const { phoneNumber, ...metadata } = params;
|
||||
const key = this.buildKey(phoneNumber, metadata);
|
||||
const code = Math.floor(100_000 + Math.random() * 900_000).toString();
|
||||
|
||||
const value: OtpCache<T> = {
|
||||
code,
|
||||
tries: 0,
|
||||
metadata: metadata as unknown as T,
|
||||
};
|
||||
|
||||
await this.cache.set(key, value, this.TTL);
|
||||
return code;
|
||||
}
|
||||
|
||||
async verify<T extends Record<string, any>>(
|
||||
params: OtpParams<T>,
|
||||
code: string,
|
||||
): Promise<boolean> {
|
||||
const { phoneNumber, ...metadata } = params;
|
||||
const key = this.buildKey(phoneNumber, metadata);
|
||||
|
||||
const otp = await this.cache.get<OtpCache<T>>(key);
|
||||
|
||||
if (!otp) throw new BadRequestException(OTPMessages.OTP_EXPIRED);
|
||||
|
||||
if (otp.tries >= 5) {
|
||||
await this.cache.del(key);
|
||||
throw new BadRequestException(OTPMessages.OTP_TOO_MANY_TRIES);
|
||||
}
|
||||
|
||||
if (otp.code !== code) {
|
||||
otp.tries++;
|
||||
await this.cache.set(key, otp, this.TTL);
|
||||
throw new BadRequestException(OTPMessages.OTP_INVALID);
|
||||
}
|
||||
|
||||
await this.cache.del(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
async delete<T extends Record<string, any>>(
|
||||
params: OtpParams<T>,
|
||||
): Promise<void> {
|
||||
const { phoneNumber, ...metadata } = params;
|
||||
await this.cache.del(this.buildKey(phoneNumber, metadata));
|
||||
}
|
||||
|
||||
async exists<T extends Record<string, any>>(
|
||||
params: OtpParams<T>,
|
||||
): Promise<boolean> {
|
||||
const { phoneNumber, ...metadata } = params;
|
||||
return !!(await this.cache.get(this.buildKey(phoneNumber, metadata)));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RoleType } from 'src/users/enums/user-role.enum';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy, ExtractJwt } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -4,9 +4,10 @@ import { ComplaintController } from './complaint.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Complaint } from './entities/complaint.entity';
|
||||
import { ComplaintRepository } from './repositories/complaint.repository';
|
||||
import { ComplaintAttachment } from './entities/complaint-attachment.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Complaint])],
|
||||
imports: [TypeOrmModule.forFeature([Complaint, ComplaintAttachment])],
|
||||
controllers: [ComplaintController],
|
||||
providers: [ComplaintService, ComplaintRepository],
|
||||
})
|
||||
@@ -30,7 +30,7 @@ export class ComplaintService {
|
||||
async update(id: string, updateComplaintDto: UpdateComplaintDto): Promise<Complaint> {
|
||||
const complaint = await this.findOneOrFail(id);
|
||||
Object.assign(complaint, updateComplaintDto);
|
||||
return complaint;
|
||||
return this.complaintRepository.save(complaint);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<Complaint> {
|
||||
@@ -0,0 +1,18 @@
|
||||
import { BaseEntity } from "src/common/entities/base.entity";
|
||||
import { Entity, JoinColumn, ManyToOne, OneToOne, Unique } from "typeorm";
|
||||
import { Complaint } from "./complaint.entity";
|
||||
import { Attachment } from "src/modules/attachment/entities/attachment.entity";
|
||||
|
||||
@Entity('complaint_attachments')
|
||||
@Unique(['attachment'])
|
||||
export class ComplaintAttachment extends BaseEntity {
|
||||
@ManyToOne(() => Complaint, (complaint) => complaint.attachments)
|
||||
complaint: Complaint;
|
||||
|
||||
@OneToOne(() => Attachment, (a) => a.complaint, {
|
||||
cascade: true,
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
attachment: Attachment;
|
||||
}
|
||||
+11
-3
@@ -1,8 +1,10 @@
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToOne } from 'typeorm';
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
||||
import { ComplaintStatus } from '../enums/complaint.enum';
|
||||
import { User } from 'src/users/entities/user.entity';
|
||||
import { Organ } from 'src/organ/entities/organ.entity';
|
||||
import { User } from 'src/modules/users/entities/user.entity';
|
||||
import { Organ } from 'src/modules/organ/entities/organ.entity';
|
||||
import { BaseEntity } from 'src/common/entities/base.entity';
|
||||
import { ComplaintAttachment } from './complaint-attachment.entity';
|
||||
import { Reply } from 'src/modules/reply/entities/reply.entity';
|
||||
|
||||
@Entity('complaints')
|
||||
export class Complaint extends BaseEntity {
|
||||
@@ -41,4 +43,10 @@ export class Complaint extends BaseEntity {
|
||||
name: 'organId'
|
||||
})
|
||||
organ: Organ;
|
||||
|
||||
@OneToMany(() => ComplaintAttachment, (ca) => ca.complaint)
|
||||
attachments: ComplaintAttachment[];
|
||||
|
||||
@OneToMany(() => Reply, (reply) => reply.complaint)
|
||||
messages: Reply[];
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator";
|
||||
|
||||
export class CreateNotificationDto {
|
||||
@ApiProperty({
|
||||
description: 'Id of the receiver user'
|
||||
})
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Id of the sender organization'
|
||||
})
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
organId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'whether the notif is read or not'
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isRead: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'notification subject'
|
||||
})
|
||||
@IsString()
|
||||
subject: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'notification message'
|
||||
})
|
||||
@IsString()
|
||||
message: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
import { CreateNotificationDto } from "./create-notification.dto";
|
||||
|
||||
export class UpdateNotificationDto extends PartialType(CreateNotificationDto) {}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const SMS_CONFIG = "SMS_CONFIG";
|
||||
export const SMS_QUEUE = 'sms';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { BaseEntity } from "src/common/entities/base.entity";
|
||||
import { Organ } from "src/modules/organ/entities/organ.entity";
|
||||
import { User } from "src/modules/users/entities/user.entity";
|
||||
import { Column, Entity, ManyToOne } from "typeorm";
|
||||
|
||||
@Entity('notifications')
|
||||
export class AppNotification extends BaseEntity {
|
||||
@Column({
|
||||
type: 'boolean',
|
||||
default: false
|
||||
})
|
||||
isRead: boolean;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 100
|
||||
})
|
||||
subject: string;
|
||||
|
||||
@Column({
|
||||
type: 'text'
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.notifications)
|
||||
receiver: User;
|
||||
|
||||
@ManyToOne(() => Organ, (organ) => organ.sentNotifications)
|
||||
senderOrgan: Organ;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { NotificationService } from './providers/notification.service';
|
||||
import { CreateNotificationDto } from './DTO/create-notification.dto';
|
||||
import { UpdateNotificationDto } from './DTO/update-notification.dto';
|
||||
import { AppNotification } from './entities/notification.entity';
|
||||
|
||||
@Controller('notification')
|
||||
export class NotificationController {
|
||||
constructor(
|
||||
private readonly notificationService: NotificationService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Body() dto: CreateNotificationDto,
|
||||
): Promise<AppNotification> {
|
||||
return this.notificationService.create(dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@Param('id') id: string,
|
||||
): Promise<AppNotification> {
|
||||
return this.notificationService.findOneOrFail(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateNotificationDto,
|
||||
): Promise<AppNotification> {
|
||||
return this.notificationService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@Param('id') id: string,
|
||||
): Promise<AppNotification> {
|
||||
return this.notificationService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { SmsService } from './providers/sms.service';
|
||||
import { SmsProducer } from './producers/sms.producer';
|
||||
import { SmsProcessor } from './processors/sms.processor';
|
||||
import { SMS_CONFIG, SMS_QUEUE } from './constants';
|
||||
import { smsConfigsProvider } from 'src/config/sms.config';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { NotificationController } from './notification.controller';
|
||||
import { NotificationService } from './providers/notification.service';
|
||||
import { NotificationRepository } from './repositories/notification.repository';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppNotification } from './entities/notification.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AppNotification]),
|
||||
HttpModule,
|
||||
BullModule.registerQueue({
|
||||
name: SMS_QUEUE,
|
||||
}),
|
||||
],
|
||||
controllers: [NotificationController],
|
||||
providers: [
|
||||
SmsService,
|
||||
smsConfigsProvider,
|
||||
SmsProducer,
|
||||
SmsProcessor,
|
||||
NotificationService,
|
||||
NotificationRepository
|
||||
],
|
||||
exports: [SmsProducer, SMS_CONFIG, NotificationService],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
|
||||
export { SMS_QUEUE };
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { SMS_QUEUE } from '../constants';
|
||||
import { SmsService } from '../providers/sms.service';
|
||||
import { SmsRequest } from '../interfaces/ISms';
|
||||
|
||||
@Processor(SMS_QUEUE)
|
||||
export class SmsProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(SmsProcessor.name);
|
||||
|
||||
constructor(private readonly smsService: SmsService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<SmsRequest>): Promise<void> {
|
||||
this.logger.log(
|
||||
`Processing SMS job ${job.id} (attempt ${job.attemptsMade + 1})`,
|
||||
);
|
||||
await this.smsService.sendSms(job.data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { SMS_QUEUE } from '../constants';
|
||||
import { SmsRequest } from '../interfaces/ISms';
|
||||
|
||||
@Injectable()
|
||||
export class SmsProducer {
|
||||
constructor(@InjectQueue(SMS_QUEUE) private readonly smsQueue: Queue) {}
|
||||
|
||||
async sendSms(body: SmsRequest) {
|
||||
return this.smsQueue.add('send-sms', body, {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 2000 },
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { NotificationRepository } from '../repositories/notification.repository';
|
||||
import { AppNotification } from '../entities/notification.entity';
|
||||
import { CreateNotificationDto } from '../DTO/create-notification.dto';
|
||||
import { Notificationmessage } from 'src/common/enums/messages.enum';
|
||||
import { UpdateNotificationDto } from '../DTO/update-notification.dto';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
constructor(private readonly notifRepository: NotificationRepository) {}
|
||||
|
||||
async create(dto: CreateNotificationDto): Promise<AppNotification> {
|
||||
const notif = this.notifRepository.create(dto);
|
||||
await this.notifRepository.save(notif);
|
||||
return notif;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateNotificationDto): Promise<AppNotification> {
|
||||
const notif = await this.findOneOrFail(id);
|
||||
Object.assign(notif, dto);
|
||||
return await this.notifRepository.save(notif);
|
||||
}
|
||||
|
||||
async findOneOrFail(id: string): Promise<AppNotification> {
|
||||
const notif = await this.notifRepository.findOne({ where: { id } });
|
||||
if (!notif)
|
||||
throw new NotFoundException(Notificationmessage.NOTIFICATION_NOT_FOUND);
|
||||
return notif;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<AppNotification> {
|
||||
const notif = await this.findOneOrFail(id);
|
||||
await this.notifRepository.remove(notif);
|
||||
return notif;
|
||||
}
|
||||
}
|
||||
-1
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Repository } from "typeorm";
|
||||
import { AppNotification } from "../entities/notification.entity";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class NotificationRepository extends Repository<AppNotification> {
|
||||
constructor(@InjectRepository(AppNotification) notifRepository: Repository<AppNotification>) {
|
||||
super(notifRepository.target, notifRepository.manager, notifRepository.queryRunner);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BaseEntity } from 'src/common/entities/base.entity';
|
||||
import { Complaint } from 'src/complaint/entities/complaint.entity';
|
||||
import { Complaint } from 'src/modules/complaint/entities/complaint.entity';
|
||||
import { AppNotification } from 'src/modules/notifications/entities/notification.entity';
|
||||
import { Column, Entity, OneToMany } from 'typeorm';
|
||||
|
||||
@Entity('organs')
|
||||
@@ -11,10 +12,13 @@ export class Organ extends BaseEntity {
|
||||
name: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
type: 'text',
|
||||
})
|
||||
logo: string;
|
||||
|
||||
@OneToMany(() => Complaint, (complaint) => complaint.organ)
|
||||
complaints: Complaint[];
|
||||
|
||||
@OneToMany(() => AppNotification, (notif) => notif.senderOrgan)
|
||||
sentNotifications: AppNotification;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsString, IsUUID } from "class-validator";
|
||||
|
||||
export class CreateReplyDto {
|
||||
@ApiProperty({
|
||||
description: 'text content of the reply'
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
content: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Id of the sender user'
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsUUID()
|
||||
senderId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Id of the receiver user'
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsUUID()
|
||||
receiverId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Id of the Complaint'
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsUUID()
|
||||
complaintId: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateReplyDto } from './create-reply.dto';
|
||||
|
||||
export class UpdateReplyDto extends PartialType(CreateReplyDto) {}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { BaseEntity } from "src/common/entities/base.entity";
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToOne } from "typeorm";
|
||||
import { Reply } from "./reply.entity";
|
||||
import { Attachment } from "src/modules/attachment/entities/attachment.entity";
|
||||
|
||||
@Entity('reply_attachments')
|
||||
export class ReplyAttachment extends BaseEntity {
|
||||
@ManyToOne(() => Reply, (rp) => rp.attachments, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
reply: Reply;
|
||||
|
||||
@OneToOne(() => Attachment, (attch) => attch.reply, {
|
||||
cascade: true,
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
attachment: Attachment;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Attachment } from "src/modules/attachment/entities/attachment.entity";
|
||||
import { BaseEntity } from "src/common/entities/base.entity";
|
||||
import { Complaint } from "src/modules/complaint/entities/complaint.entity";
|
||||
import { User } from "src/modules/users/entities/user.entity";
|
||||
import { Column, Entity, ManyToOne, OneToMany } from "typeorm";
|
||||
import { ReplyAttachment } from "./reply-attachment.entity";
|
||||
|
||||
@Entity('replies')
|
||||
export class Reply extends BaseEntity {
|
||||
@Column({
|
||||
type: 'text'
|
||||
})
|
||||
content: string; // the text or message written.
|
||||
|
||||
@ManyToOne(() => User)
|
||||
sender: User;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
receiver: User;
|
||||
|
||||
@ManyToOne(() => Complaint, (complaint) => complaint.messages)
|
||||
complaint: Complaint;
|
||||
|
||||
@OneToMany(() => ReplyAttachment, (ra) => ra.reply)
|
||||
attachments: ReplyAttachment;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
|
||||
import { ReplyService } from './reply.service';
|
||||
import { CreateReplyDto } from './dto/create-reply.dto';
|
||||
import { UpdateReplyDto } from './dto/update-reply.dto';
|
||||
|
||||
@Controller('reply')
|
||||
export class ReplyController {
|
||||
constructor(private readonly replyService: ReplyService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createReplyDto: CreateReplyDto) {
|
||||
return this.replyService.create(createReplyDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.replyService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.replyService.findOneOrFail(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateReplyDto: UpdateReplyDto) {
|
||||
return this.replyService.update(id, updateReplyDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.replyService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReplyService } from './reply.service';
|
||||
import { ReplyController } from './reply.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Reply } from './entities/reply.entity';
|
||||
import { ReplyAttachment } from './entities/reply-attachment.entity';
|
||||
import { ReplyRepository } from './repositories/reply.repository';
|
||||
import { ReplyAttachmentRepository } from './repositories/reply-attachment.repository';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Reply, ReplyAttachment])],
|
||||
controllers: [ReplyController],
|
||||
providers: [ReplyService, ReplyRepository, ReplyAttachmentRepository],
|
||||
})
|
||||
export class ReplyModule {}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CreateReplyDto } from './dto/create-reply.dto';
|
||||
import { UpdateReplyDto } from './dto/update-reply.dto';
|
||||
import { ReplyRepository } from './repositories/reply.repository';
|
||||
// import { ReplyAttachmentRepository } from './repositories/reply-attachment.repository';
|
||||
import { Reply } from './entities/reply.entity';
|
||||
import { ReplyMessages } from 'src/common/enums/messages.enum';
|
||||
|
||||
@Injectable()
|
||||
export class ReplyService {
|
||||
constructor(
|
||||
private readonly replyRepository: ReplyRepository,
|
||||
// private readonly replyAttachmentRepository: ReplyAttachmentRepository,
|
||||
) {}
|
||||
|
||||
async create(createReplyDto: CreateReplyDto): Promise<Reply> {
|
||||
const reply = this.replyRepository.create(createReplyDto);
|
||||
await this.replyRepository.save(reply);
|
||||
return reply;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Reply[]> {
|
||||
return await this.replyRepository.find();
|
||||
}
|
||||
|
||||
async findOneOrFail(id: string): Promise<Reply> {
|
||||
const reply = await this.replyRepository.findOne({where: {id}});
|
||||
if(!reply) throw new NotFoundException(ReplyMessages.REPLY_NOT_FOUND);
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
async update(id: string, updateReplyDto: UpdateReplyDto): Promise<Reply> {
|
||||
const reply = await this.findOneOrFail(id);
|
||||
Object.assign(reply, updateReplyDto);
|
||||
return reply;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<Reply> {
|
||||
const reply = await this.findOneOrFail(id);
|
||||
await this.replyRepository.remove(reply);
|
||||
return reply;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Repository } from "typeorm";
|
||||
import { ReplyAttachment } from "../entities/reply-attachment.entity";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
|
||||
@Injectable()
|
||||
export class ReplyAttachmentRepository extends Repository<ReplyAttachment> {
|
||||
constructor(@InjectRepository(ReplyAttachment) replyAttachmentRepository: Repository<ReplyAttachment>) {
|
||||
super(replyAttachmentRepository.target, replyAttachmentRepository.manager, replyAttachmentRepository.queryRunner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Repository } from "typeorm";
|
||||
import { Reply } from "../entities/reply.entity";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class ReplyRepository extends Repository<Reply> {
|
||||
constructor(@InjectRepository(Reply) replyRepository: Repository<Reply>) {
|
||||
super(replyRepository.target, replyRepository.manager, replyRepository.queryRunner);
|
||||
}
|
||||
}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
import type { 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 @@
|
||||
export const S3_CONFIG = "S3_CONFIG";
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 type { IS3Configs } from "src/config/s3.config";
|
||||
import { S3_CONFIG } from "../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,131 @@
|
||||
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 "src/common/enums/messages.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/mpeg",
|
||||
"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);
|
||||
|
||||
// Sanitize filename for HTTP headers (S3 metadata)
|
||||
const sanitizedOriginalName = this.sanitizeFilenameForHeader(file.originalname);
|
||||
|
||||
// Upload to S3
|
||||
const s3Result = await this.s3Service.uploadFile(file.buffer, s3Key, file.mimetype, {
|
||||
originalName: sanitizedOriginalName,
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename for use in HTTP headers
|
||||
* Removes invalid characters that are not allowed in HTTP header values
|
||||
* HTTP headers only allow ASCII printable characters (0x20-0x7E) excluding certain special chars
|
||||
*/
|
||||
private sanitizeFilenameForHeader(filename: string): string {
|
||||
if (!filename) return "";
|
||||
|
||||
// Remove control characters (0x00-0x1F, 0x7F) and newlines/tabs
|
||||
// Replace non-ASCII characters with underscore to keep the filename readable
|
||||
// This ensures the header value is valid while preserving as much of the original name as possible
|
||||
return filename
|
||||
.replace(/[\x00-\x1F\x7F\r\n\t]/g, "") // Remove control characters, newlines, tabs
|
||||
.replace(/[^\x20-\x7E]/g, "_") // Replace non-ASCII characters with underscore
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { FileInterceptor, FilesInterceptor } from "@nest-lab/fastify-multer";
|
||||
import type { File } 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 "./services/uploader.service";
|
||||
import { AuthGuard } from "src/common/decorators/auth-guard.decorator";
|
||||
|
||||
@Controller("uploader")
|
||||
@AuthGuard()
|
||||
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")
|
||||
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,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { S3Service } from "./services/s3.service";
|
||||
import { UploaderService } from "./services/uploader.service";
|
||||
import { UploaderController } from "./uploader.controller";
|
||||
import { S3ConfigsProvider } from "src/config/s3.config";
|
||||
|
||||
@Module({
|
||||
controllers: [UploaderController],
|
||||
providers: [UploaderService, S3Service, S3ConfigsProvider],
|
||||
exports: [UploaderService],
|
||||
})
|
||||
export class UploaderModule {}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Entity, Column, ManyToMany, JoinTable, OneToMany } from 'typeorm';
|
||||
import { Entity, Column, OneToMany } from 'typeorm';
|
||||
import { BaseEntity } from 'src/common/entities/base.entity';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { UserRole } from './user-role.entity';
|
||||
import { Complaint } from 'src/complaint/entities/complaint.entity';
|
||||
import { Complaint } from 'src/modules/complaint/entities/complaint.entity';
|
||||
import { AppNotification } from 'src/modules/notifications/entities/notification.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class User extends BaseEntity {
|
||||
@@ -39,4 +40,7 @@ export class User extends BaseEntity {
|
||||
cascade: true
|
||||
})
|
||||
complaints: Complaint[];
|
||||
|
||||
@OneToMany(() => AppNotification, (notif) => notif.receiver)
|
||||
notifications: AppNotification[];
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { UserMessages, UserRoleMessages } from 'src/common/enums/messages.enum';
|
||||
import { UserRole } from './entities/user-role.entity';
|
||||
import { CreateUserRoleDto } from './DTO/create-user-role.dto';
|
||||
import { UserRoleRepository } from './repositories/user-role.repository';
|
||||
import { Complaint } from '../complaint/entities/complaint.entity';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
@@ -1 +0,0 @@
|
||||
export const SMS_CONFIG = "SMS_CONFIG";
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SmsService } from './providers/sms.service';
|
||||
import { smsConfigsProvider } from 'src/config/sms.config';
|
||||
import { SMS_CONFIG } from './constants';
|
||||
|
||||
@Module({
|
||||
providers: [SmsService, smsConfigsProvider],
|
||||
exports: [SMS_CONFIG, SmsService],
|
||||
})
|
||||
export class UtilsModule {}
|
||||
Reference in New Issue
Block a user