diff --git a/src/auth/oauth.v2.controller.ts b/src/auth/oauth.v2.controller.ts index 400bff2..5f7a891 100644 --- a/src/auth/oauth.v2.controller.ts +++ b/src/auth/oauth.v2.controller.ts @@ -61,15 +61,15 @@ export class OauthV2Controller { return this.oauthService.googleOauth(oauthRequest); } - // @ApiOperation({ - // summary: '애플 로그인', - // description: - // '애플 로그인 메서드. (회원가입이 안되어 있으면 회원가입 처리 후 로그인 처리)', - // }) - // @Get('apple-login') - // async appleLogin( - // @Body() oauthRequest: OAuthLoginRequest, - // ): Promise { - // return this.oauthService.appleLogin(oauthRequest); - // } + @ApiOperation({ + summary: '애플 로그인', + description: + '애플 로그인 메서드. (회원가입이 안되어 있으면 회원가입 처리 후 로그인 처리)', + }) + @Post('apple') + async appleLogin( + @Body() oauthRequest: OAuthLoginRequest, + ): Promise { + return this.oauthService.appleLogin(oauthRequest); + } } diff --git a/src/auth/oauth.v2.service.ts b/src/auth/oauth.v2.service.ts index 274562d..6990e80 100644 --- a/src/auth/oauth.v2.service.ts +++ b/src/auth/oauth.v2.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Injectable, - InternalServerErrorException, UnauthorizedException, } from '@nestjs/common'; import { LoginOutput } from './dtos/login.dto'; @@ -22,6 +21,10 @@ import { ConfigService } from '@nestjs/config'; @Injectable() export class OAuthV2Service { + private readonly googleClient = new OAuth2Client( + this.configService.get('GOOGLE_SECRET'), + ); + constructor( private readonly jwtService: customJwtService, private readonly userRepository: UserRepository, @@ -31,10 +34,6 @@ export class OAuthV2Service { private readonly configService: ConfigService, ) {} - private readonly googleClient = new OAuth2Client( - this.configService.get('GOOGLE_SECRET'), - ); - // OAuth Login async oauthLogin(email: string, provider: PROVIDER): Promise { try { @@ -111,6 +110,7 @@ export class OAuthV2Service { } // Login with Google account info + // @todo 액세스 토큰 파싱하는 부분 추상화 async googleOauth({ authorizationToken, }: OAuthLoginRequest): Promise { @@ -150,19 +150,8 @@ export class OAuthV2Service { return this.oauthLogin(newUser.email, PROVIDER.GOOGLE); } - private encodePasswordFromEmail(email: string, key?: string): string { - return CryptoJS.SHA256(email + key).toString(); - } - - public async appleLogin(code: string) { + public async appleLogin({ authorizationToken: code }: OAuthLoginRequest) { const data = await this.oauthUtil.getAppleToken(code); - - if (!data.id_token) { - throw new InternalServerErrorException( - `No token: ${JSON.stringify(data)}`, - ); - } - const { sub: id, email } = this.jwtService.decode(data.id_token); const user = await this.userRepository.findOneByEmailAndProvider( @@ -189,4 +178,8 @@ export class OAuthV2Service { return this.oauthLogin(newUser.email, PROVIDER.APPLE); } + + private encodePasswordFromEmail(email: string, key?: string): string { + return CryptoJS.SHA256(email + key).toString(); + } } diff --git a/src/categories/category.repository.ts b/src/categories/category.repository.ts index 23988b0..e021018 100644 --- a/src/categories/category.repository.ts +++ b/src/categories/category.repository.ts @@ -133,6 +133,9 @@ export class CategoryRepository extends Repository { user: { id: userId }, }, relations: ['contents'], + order: { + createdAt: 'desc', + }, }); } @@ -143,4 +146,23 @@ export class CategoryRepository extends Repository { }, }); } + + async findByParentId( + parentId: number, + entityManager?: EntityManager, + ): Promise { + if (entityManager) { + return entityManager.find(Category, { + where: { + parentId, + }, + }); + } + + return await this.find({ + where: { + parentId, + }, + }); + } } diff --git a/src/categories/category.service.ts b/src/categories/category.service.ts index 2231b57..04beda7 100644 --- a/src/categories/category.service.ts +++ b/src/categories/category.service.ts @@ -1,24 +1,21 @@ import { - Injectable, - NotFoundException, ConflictException, - InternalServerErrorException, Inject, + Injectable, + InternalServerErrorException, + NotFoundException, } from '@nestjs/common'; import { EntityManager } from 'typeorm'; import { AddCategoryBodyDto, AddCategoryOutput, - UpdateCategoryBodyDto, - UpdateCategoryOutput, DeleteCategoryOutput, RecentCategoryList, RecentCategoryListWithSaveCount, + UpdateCategoryBodyDto, + UpdateCategoryOutput, } from './dtos/category.dto'; -import { - LoadPersonalCategoriesOutput, - LoadFrequentCategoriesOutput, -} from './dtos/load-personal-categories.dto'; +import { LoadFrequentCategoriesOutput, LoadPersonalCategoriesOutput, } from './dtos/load-personal-categories.dto'; import { Category } from './category.entity'; import { Content } from '../contents/entities/content.entity'; import { CategoryRepository } from './category.repository'; @@ -26,12 +23,7 @@ import { ContentRepository } from '../contents/repository/content.repository'; import { getLinkContent, getLinkInfo } from '../contents/util/content.util'; import { User } from '../users/entities/user.entity'; import { UserRepository } from '../users/repository/user.repository'; -import { - generateCategoriesTree, - generateSlug, - loadLogs, - makeCategoryListWithSaveCount, -} from './utils/category.util'; +import { generateCategoriesTree, generateSlug, loadLogs, makeCategoryListWithSaveCount, } from './utils/category.util'; import { Transactional } from '../common/aop/transactional'; import { AiService } from '../ai/ai.service'; @@ -477,7 +469,7 @@ Present your reply options in JSON format below. try { const categoryStr = await this.aiService.chat({ - model: 'llama3-8b-8192', + model: 'llama-3.1-8b-instant', messages: [{ role: 'user', content: question }], temperature: 0, responseType: 'json_object', diff --git a/src/contents/contents.controller.ts b/src/contents/contents.controller.ts index f99f590..e2f212a 100644 --- a/src/contents/contents.controller.ts +++ b/src/contents/contents.controller.ts @@ -2,14 +2,13 @@ import { Body, Controller, Delete, - Post, + Get, Param, - UseGuards, ParseIntPipe, Patch, - Get, - UseInterceptors, + Post, Query, + UseGuards, } from '@nestjs/common'; import { ApiBadRequestResponse, @@ -24,10 +23,7 @@ import { } from '@nestjs/swagger'; import { AuthUser } from '../auth/auth-user.decorator'; import { JwtAuthGuard } from '../auth/jwt/jwt.guard'; -import { TransactionInterceptor } from '../common/interceptors/transaction.interceptor'; -import { TransactionManager } from '../common/transaction.decorator'; import { User } from '../users/entities/user.entity'; -import { EntityManager } from 'typeorm'; import { ContentsService } from './contents.service'; import { AddContentBodyDto, @@ -38,6 +34,7 @@ import { toggleFavoriteOutput, UpdateContentBodyDto, UpdateContentOutput, + UpdateContentRequest, } from './dtos/content.dto'; import { ErrorOutput } from '../common/dtos/output.dto'; import { @@ -118,6 +115,34 @@ export class ContentsController { return this.contentsService.updateContent(user, content); } + @ApiOperation({ + summary: '콘텐츠 정보 수정', + description: '콘텐츠을 수정하는 메서드', + }) + @ApiCreatedResponse({ + description: '콘텐츠 수정 성공 여부를 반환한다.', + type: UpdateContentOutput, + }) + @ApiConflictResponse({ + description: '동일한 링크의 콘텐츠가 같은 카테고리 내에 존재할 경우', + type: ErrorOutput, + }) + @ApiNotFoundResponse({ + description: '존재하지 않는 콘텐츠 또는 유저인 경우', + type: ErrorOutput, + }) + @Patch(':contentId') + async updateContentV2( + @AuthUser() user: User, + @Body() content: UpdateContentRequest, + @Param('contentId') contentId: number, + ): Promise { + return this.contentsService.updateContent(user, { + ...content, + id: contentId, + }); + } + @ApiOperation({ summary: '즐겨찾기 등록 및 해제', description: '즐겨찾기에 등록 및 해제하는 메서드', diff --git a/src/contents/contents.service.ts b/src/contents/contents.service.ts index 2a57f2c..60fa9e5 100644 --- a/src/contents/contents.service.ts +++ b/src/contents/contents.service.ts @@ -1,10 +1,11 @@ import { BadRequestException, + ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; -import { DataSource, EntityManager } from 'typeorm'; +import { DataSource, EntityManager, In, Not } from 'typeorm'; import { AddContentBodyDto, @@ -86,17 +87,23 @@ export class ContentsService { const content = new Content(); if (categoryId) { - const category = await this.categoryRepository.findById( - categoryId, - entityManager, - ); + const [category, subCategories] = await Promise.all([ + (async () => { + const category = await this.categoryRepository.findById(categoryId); - if (!category) throw new NotFoundException('Category not found'); + if (!category) { + throw new NotFoundException('카테고리가 존재하지 않습니다.'); + } - await checkContentDuplicateAndAddCategorySaveLog( - link, - category, - userInDb, + return category; + })(), + this.categoryRepository.findByParentId(categoryId), + ]); + + await this.isDuplicatedContents( + [category, ...subCategories], + content.link, + content.id, ); content.category = category; @@ -184,8 +191,6 @@ export class ContentsService { reminder, favorite, categoryId, - categoryName, - parentId, }: UpdateContentBodyDto, entityManager?: EntityManager, ): Promise { @@ -197,33 +202,38 @@ export class ContentsService { reminder, favorite, }; - const userInDb = await this.userRepository.findOneWithContentsAndCategories( - user.id, - ); - if (!userInDb) { - throw new NotFoundException('User not found'); - } - const content = userInDb?.contents?.filter( - (content) => content.id === contentId, - )[0]; + const content = await this.contentRepository.findOne({ + where: { + id: contentId, + }, + relations: ['category'], + }); + if (!content) { - throw new NotFoundException('Content not found.'); + throw new NotFoundException('컨텐츠가 존재하지 않습니다.'); } - if (categoryId !== undefined) { - const category = - categoryId !== null - ? await this.categoryRepository.findById(categoryId, entityManager) - : null; + // 카테고리 변경이 발생하는 경우 + if (categoryId && !content.isSameCategory(categoryId)) { + const [category, subCategories] = await Promise.all([ + (async () => { + const category = await this.categoryRepository.findById(categoryId); - if (category) { - await checkContentDuplicateAndAddCategorySaveLog( - link, - category, - userInDb, - ); - } + if (!category) { + throw new NotFoundException('카테고리가 존재하지 않습니다.'); + } + + return category; + })(), + this.categoryRepository.findByParentId(categoryId), + ]); + + await this.isDuplicatedContents( + [category, ...subCategories], + content.link, + content.id, + ); await this.contentRepository.updateOne( { @@ -471,4 +481,24 @@ export class ContentsService { throw e; } } + + private async isDuplicatedContents( + categories: Category[], + link: string, + id?: number, + ) { + const existingContents = await this.contentRepository.find({ + where: { + ...(id && { id: Not(id) }), + category: { + id: In(categories.map((category) => category.id)), + }, + link, + }, + }); + + if (existingContents.length > 0) { + throw new ConflictException('이미 저장된 컨텐츠입니다.'); + } + } } diff --git a/src/contents/dtos/content.dto.ts b/src/contents/dtos/content.dto.ts index 25384e5..970447b 100644 --- a/src/contents/dtos/content.dto.ts +++ b/src/contents/dtos/content.dto.ts @@ -1,7 +1,6 @@ import { ApiProperty, ApiPropertyOptional, - IntersectionType, PartialType, PickType, } from '@nestjs/swagger'; @@ -121,15 +120,133 @@ export class AddMultipleContentsBodyDto { } class ContentBody extends PartialType(AddContentBodyDto) {} + class ContentIdAndDescription extends PickType(Content, [ 'id', 'description', ]) {} -export class UpdateContentBodyDto extends IntersectionType( - ContentIdAndDescription, - ContentBody, -) {} +export class UpdateContentBodyDto { + @ApiProperty({ + description: '컨텐츠 id', + }) + @IsInt() + @IsPositive() + id: number; + + @ApiPropertyOptional({ + description: '컨텐츠 설명', + }) + @IsString() + @IsNotEmpty() + @IsOptional() + readonly description?: string; + + @ApiPropertyOptional({ + description: '컨텐츠 링크', + }) + @IsUrl() + @IsNotEmpty() + @IsOptional() + readonly link?: string; + + @ApiPropertyOptional({ + description: '컨텐츠 제목', + }) + @IsString() + @IsNotEmpty() + @IsOptional() + readonly title?: string; + + @ApiPropertyOptional({ + description: '컨텐츠 메모', + }) + @IsString() + @IsNotEmpty() + @IsOptional() + readonly comment?: string; + + @ApiPropertyOptional({ + description: '리마인더 시간', + }) + @Type(() => Date) + @IsDate() + @IsOptional() + readonly reminder?: Date; + + @ApiPropertyOptional({ + description: '즐겨찾기 여부', + }) + @IsBoolean() + @IsOptional() + readonly favorite?: boolean; + + @ApiPropertyOptional({ + description: '카테고리 id', + }) + @IsInt() + @IsPositive() + @IsOptional() + readonly categoryId?: number; +} + +export class UpdateContentRequest { + @ApiPropertyOptional({ + description: '컨텐츠 설명', + }) + @IsString() + @IsNotEmpty() + @IsOptional() + readonly description?: string; + + @ApiPropertyOptional({ + description: '컨텐츠 링크', + }) + @IsUrl() + @IsNotEmpty() + @IsOptional() + readonly link?: string; + + @ApiPropertyOptional({ + description: '컨텐츠 제목', + }) + @IsString() + @IsNotEmpty() + @IsOptional() + readonly title?: string; + + @ApiPropertyOptional({ + description: '컨텐츠 메모', + }) + @IsString() + @IsNotEmpty() + @IsOptional() + readonly comment?: string; + + @ApiPropertyOptional({ + description: '리마인더 시간', + }) + @Type(() => Date) + @IsDate() + @IsOptional() + readonly reminder?: Date; + + @ApiPropertyOptional({ + description: '즐겨찾기 여부', + }) + @IsBoolean() + @IsOptional() + readonly favorite?: boolean; + + @ApiPropertyOptional({ + description: '카테고리 id', + }) + @IsInt() + @IsPositive() + @IsOptional() + readonly categoryId?: number; +} + export class UpdateContentOutput extends CoreOutput {} export class DeleteContentOutput extends CoreOutput {} diff --git a/src/contents/entities/content.entity.ts b/src/contents/entities/content.entity.ts index a0faa39..8524767 100644 --- a/src/contents/entities/content.entity.ts +++ b/src/contents/entities/content.entity.ts @@ -86,4 +86,8 @@ export class Content extends CoreEntity { @ApiProperty({ description: 'Owner ID' }) @RelationId((content: Content) => content.user) userId: number; + + isSameCategory(categoryId: number): boolean { + return this.category?.id === categoryId; + } }