diff --git a/.gitignore b/.gitignore index 44adf34..e27c56f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ lerna-debug.log* *.env* # mysql mysql-data + +# uploads +uploads/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1b239cb..823384a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "class-validator": "^0.14.1", "cross-env": "^7.0.3", "express": "^4.21.1", + "multer": "^1.4.5-lts.1", "mysql2": "^3.11.4", "nodemailer": "^6.9.16", "reflect-metadata": "^0.2.2", @@ -36,6 +37,7 @@ "@types/bcrypt": "^5.0.2", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", + "@types/multer": "^1.4.12", "@types/node": "^22.9.0", "@types/nodemailer": "^6.4.17", "@types/supertest": "^6.0.2", @@ -2489,6 +2491,24 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/multer": { + "version": "1.4.4-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", + "integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", @@ -3057,6 +3077,16 @@ "license": "MIT", "optional": true }, + "node_modules/@types/multer": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.12.tgz", + "integrity": "sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "22.9.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", @@ -9797,9 +9827,9 @@ "license": "MIT" }, "node_modules/multer": { - "version": "1.4.4-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", - "integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==", + "version": "1.4.5-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", + "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", diff --git a/package.json b/package.json index 029577c..5cbd7fc 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "class-validator": "^0.14.1", "cross-env": "^7.0.3", "express": "^4.21.1", + "multer": "^1.4.5-lts.1", "mysql2": "^3.11.4", "nodemailer": "^6.9.16", "reflect-metadata": "^0.2.2", @@ -43,6 +44,7 @@ "@types/bcrypt": "^5.0.2", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", + "@types/multer": "^1.4.12", "@types/node": "^22.9.0", "@types/nodemailer": "^6.4.17", "@types/supertest": "^6.0.2", diff --git a/src/controllers/members.controller.ts b/src/controllers/members.controller.ts new file mode 100644 index 0000000..931970f --- /dev/null +++ b/src/controllers/members.controller.ts @@ -0,0 +1,89 @@ +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + NotFoundException, + Param, + ParseIntPipe, + Patch, + UploadedFile, + UseInterceptors, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import * as bcrypt from "bcrypt"; + +import { CurrentMemberDecorator } from "@APP/common/decorators/current-member.decorator"; +import { UpdateMemberDto } from "@APP/dtos/update-member.dto"; +import { MembersService } from "@APP/services/members.service"; + +@Controller("members") +export class MembersController { + constructor(private readonly membersService: MembersService) {} + + @Get(":memberId") + async getMember(@Param("memberId", new ParseIntPipe()) memberId: number) { + return await this.membersService.findById(memberId); + } + + @Delete(":memberId") + async deleteMember( + @CurrentMemberDecorator("id") currentMemberId: number, + @Param("memberId", new ParseIntPipe()) memberId: number, + ) { + const member = await this.membersService.findById(memberId); + + if (!member) { + throw new NotFoundException("존재하지 않는 회원입니다."); + } + + if (currentMemberId !== memberId) { + throw new ForbiddenException("권한이 없습니다."); + } + + await this.membersService.deleteById(memberId); + } + + @Patch(":memberId") + async patchMember( + @CurrentMemberDecorator("id") currentMemberId: number, + @Param("memberId", new ParseIntPipe()) memberId: number, + @Body() dto: UpdateMemberDto, + ) { + const member = await this.membersService.findById(memberId); + + if (!member) { + throw new NotFoundException("존재하지 않는 회원입니다."); + } + + if (currentMemberId !== memberId) { + throw new ForbiddenException("권한이 없습니다."); + } + + if (dto.password) { + const hashedPassword = await bcrypt.hash(dto.password, 10); + dto.password = hashedPassword; + } + + await this.membersService.updateById(memberId, dto); + + return await this.membersService.findById(memberId); + } + + @Patch(":memberId/profile-image") + @UseInterceptors(FileInterceptor("image")) + async patchProfileImage( + @CurrentMemberDecorator("id") currentMemberId: number, + @UploadedFile() file: Express.Multer.File, + ) { + await this.membersService.updateProfileImage( + currentMemberId, + file.filename, + ); + + return { + filename: file.filename, + }; + } +} diff --git a/src/dtos/update-member.dto.ts b/src/dtos/update-member.dto.ts new file mode 100644 index 0000000..d6207aa --- /dev/null +++ b/src/dtos/update-member.dto.ts @@ -0,0 +1,7 @@ +import { OmitType, PartialType } from "@nestjs/swagger"; + +import { RegisterMemberDto } from "./register-member.dto"; + +export class UpdateMemberDto extends PartialType( + OmitType(RegisterMemberDto, ["email"]), +) {} diff --git a/src/entities/member.entity.ts b/src/entities/member.entity.ts index 3475334..8ee8dd9 100644 --- a/src/entities/member.entity.ts +++ b/src/entities/member.entity.ts @@ -1,4 +1,5 @@ -import { IsEmail, IsNotEmpty, IsString } from "class-validator"; +import { Exclude } from "class-transformer"; +import { IsEmail, IsNotEmpty, IsString, Length } from "class-validator"; import { Column, CreateDateColumn, @@ -24,6 +25,10 @@ export class MemberEntity { @IsNotEmpty() @IsString() + @Exclude({ + toPlainOnly: true, + }) + @Length(4, 20) @Column({ type: "varchar", length: 60, nullable: false }) password!: string; diff --git a/src/modules/auth.module.ts b/src/modules/auth.module.ts index ccbc801..2a87f6e 100644 --- a/src/modules/auth.module.ts +++ b/src/modules/auth.module.ts @@ -11,6 +11,6 @@ import { MembersModule } from "./members.module"; imports: [JwtModule.register({}), MembersModule], controllers: [AuthController], providers: [AuthService, MailsService], - exports: [], + exports: [AuthService], }) export class AuthModule {} diff --git a/src/modules/members.module.ts b/src/modules/members.module.ts index 7b0d866..5f54a87 100644 --- a/src/modules/members.module.ts +++ b/src/modules/members.module.ts @@ -1,13 +1,46 @@ -import { Module } from "@nestjs/common"; +import { BadRequestException, Module } from "@nestjs/common"; +import { MulterModule } from "@nestjs/platform-express"; import { TypeOrmModule } from "@nestjs/typeorm"; +import multer from "multer"; +import { extname } from "path"; +import { MembersController } from "@APP/controllers/members.controller"; import { MemberEntity } from "@APP/entities/member.entity"; import { MembersRepository } from "@APP/repositories/members.repository"; import { MembersService } from "@APP/services/members.service"; @Module({ - imports: [TypeOrmModule.forFeature([MemberEntity])], - controllers: [], + imports: [ + TypeOrmModule.forFeature([MemberEntity]), + MulterModule.register({ + limits: { + fileSize: 10000000, + }, + fileFilter: (_req, file, cb) => { + const ext = extname(file.originalname); + if (ext !== ".jpg" && ext !== ".jpeg" && ext !== ".png") { + return cb( + new BadRequestException( + "jpg/jpeg/png 파일만 업로드 가능합니다", + ), + false, + ); + } + + return cb(null, true); + }, + + storage: multer.diskStorage({ + destination: function (_req, _file, cb) { + cb(null, "uploads/"); + }, + filename: function (_req, file, cb) { + cb(null, `${Date.now()}-${file.originalname}`); + }, + }), + }), + ], + controllers: [MembersController], providers: [MembersService, MembersRepository], exports: [MembersService], }) diff --git a/src/services/members.service.ts b/src/services/members.service.ts index be192e7..c80f171 100644 --- a/src/services/members.service.ts +++ b/src/services/members.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable } from "@nestjs/common"; import { RegisterMemberDto } from "@APP/dtos/register-member.dto"; +import { UpdateMemberDto } from "@APP/dtos/update-member.dto"; import { VerifyEmailDto } from "@APP/dtos/verify-email.dto"; import { MembersRepository } from "@APP/repositories/members.repository"; @@ -16,6 +17,29 @@ export class MembersService { }); } + async findById(memberId: number) { + return this.membersRepository.findOne({ + where: { + id: memberId, + }, + }); + } + + async updateById(memberId: number, dto: UpdateMemberDto) { + return this.membersRepository.update( + { + id: memberId, + }, + dto, + ); + } + + async deleteById(memberId: number) { + return this.membersRepository.delete({ + id: memberId, + }); + } + async verifyEmail(dto: VerifyEmailDto) { const verifyCodeExists = await this.membersRepository.exists({ where: { @@ -77,4 +101,11 @@ export class MembersService { }, ); } + + async updateProfileImage(memberId: number, filename: string) { + return await this.membersRepository.update( + { id: memberId }, + { profileImage: filename }, + ); + } }