From 369af370d638fd6744d0adf0f023e552efc713b9 Mon Sep 17 00:00:00 2001 From: Parkchanyoung0710 <120624055+Parkchanyoung0710@users.noreply.github.com> Date: Sat, 7 Feb 2026 15:32:30 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EB=A9=94=EB=89=B4=20=EA=B9=80?= =?UTF-8?q?=EC=B9=98=EC=B0=8C=EA=B0=9C=EC=97=90=EC=84=9C=20=EA=B5=AD?= =?UTF-8?q?=EB=B0=A5=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/menus/seed/menu.seed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/menus/seed/menu.seed.ts b/src/menus/seed/menu.seed.ts index 068d67e..1a41598 100644 --- a/src/menus/seed/menu.seed.ts +++ b/src/menus/seed/menu.seed.ts @@ -4,7 +4,7 @@ import { MenuContext } from '../enum/menu-context.enum'; export const menuSeedData = [ // 한식 { - name: '김치찌개', + name: '국밥', category: MenuCategory.KOREAN, contexts: [MenuContext.LUNCH, MenuContext.SOLO, MenuContext.LIGHT], From ed41242c3b79cedd7aedadc36c72e451a8d9decb Mon Sep 17 00:00:00 2001 From: Parkchanyoung0710 <120624055+Parkchanyoung0710@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:14:51 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=9D=8C=EC=8B=9D=20=EB=8F=84?= =?UTF-8?q?=ED=8A=B8=20=EC=84=A0=ED=83=9D=20=EC=A1=B0=ED=9A=8C/=EC=B6=94?= =?UTF-8?q?=EA=B0=80/=EC=82=AD=EC=A0=9C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.ts | 4 +- src/users/schemas/user.schema.ts | 5 ++- src/users/users.controller.ts | 70 ++++++++++++++++++++++++++++++++ src/users/users.module.ts | 10 ++++- src/users/users.service.ts | 62 +++++++++++++++++++++++++++- 5 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 src/users/users.controller.ts diff --git a/src/main.ts b/src/main.ts index a05dc27..7dce53f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,12 @@ import { NestFactory } from '@nestjs/core'; -import { NestExpressApplication } from '@nestjs/platform-express'; // [1] 타입 추가 +import { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; import cookieParser from 'cookie-parser'; async function bootstrap() { const app = await NestFactory.create(AppModule); - app.set('trust proxy', 1); // 프록스 설정 + app.set('trust proxy', 1); app.use(cookieParser()); app.useGlobalPipes( diff --git a/src/users/schemas/user.schema.ts b/src/users/schemas/user.schema.ts index 9308f99..cdfdd34 100644 --- a/src/users/schemas/user.schema.ts +++ b/src/users/schemas/user.schema.ts @@ -8,7 +8,7 @@ export class User { @Prop({ required: true, unique: true, lowercase: true, trim: true }) email!: string; - @Prop({ type: String, required: false, default: null }) // 타입 명시 + @Prop({ type: String, required: false, default: null }) passwordHash?: string | null; @Prop({ required: true, trim: true }) @@ -17,6 +17,9 @@ export class User { @Prop({ type: String, default: null }) profileImage!: string | null; + @Prop({ type: [String], default: [] }) + selectedFoodDotIds!: string[]; + createdAt!: Date; updatedAt!: Date; } diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts new file mode 100644 index 0000000..65c4fa5 --- /dev/null +++ b/src/users/users.controller.ts @@ -0,0 +1,70 @@ +import { + Controller, + Get, + Post, + Delete, + Param, + Body, + Req, + UnauthorizedException, + Header, +} from '@nestjs/common'; +import type { Request } from 'express'; + +import { AuthService } from '../auth/service/auth.service'; +import { UsersService } from './users.service'; + +interface AuthCookies { + accessToken?: string; + refreshToken?: string; +} + +interface AuthRequest extends Request { + cookies: AuthCookies; +} + +@Controller('users') +export class UsersController { + constructor( + private readonly usersService: UsersService, + private readonly authService: AuthService, + ) {} + + private async extractUserId(req: AuthRequest): Promise { + const accessToken = req.cookies?.accessToken; + + if (!accessToken) { + throw new UnauthorizedException('인증 토큰이 없습니다'); + } + + const user = await this.authService.verifyAccessToken(accessToken); + + if (!user) { + throw new UnauthorizedException('사용자를 찾을 수 없습니다'); + } + + return user._id.toString(); + } + + // 내 음식 도트 목록 조회 + @Get('me/food-dots') + @Header('Cache-Control', 'no-store, no-cache, must-revalidate, private') + async getMyFoodDots(@Req() req: AuthRequest) { + const userId = await this.extractUserId(req); + return this.usersService.getMyFoodDots(userId); + } + + // 음식 도트 추가 + @Post('me/food-dots') + async addFoodDot(@Req() req: AuthRequest, @Body('dotId') dotId: string) { + const userId = await this.extractUserId(req); + return this.usersService.addFoodDot(userId, dotId); + } + + // 음식 도트 제거 + @Delete('me/food-dots/:dotId') + async removeFoodDot(@Req() req: AuthRequest, @Param('dotId') dotId: string) { + const userId = await this.extractUserId(req); + return this.usersService.removeFoodDot(userId, dotId); + } +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 8d16978..67dfc90 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,11 +1,17 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { User, UserSchema } from './schemas/user.schema'; import { UsersService } from './users.service'; +import { UsersController } from './users.controller'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])], + imports: [ + MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), + forwardRef(() => AuthModule), + ], + controllers: [UsersController], providers: [UsersService], exports: [UsersService], }) diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 5c32436..634320e 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model, Types } from 'mongoose'; @@ -9,6 +9,7 @@ export type UserUpdateData = { passwordHash?: string; profileImage?: string | null; }; +const MAX_FOOD_DOTS = 9; @Injectable() export class UsersService { @@ -53,4 +54,63 @@ export class UsersService { return updatedUser; } + + // 내 음식 도트 목록 조회 + async getMyFoodDots(userId: Types.ObjectId | string): Promise { + const user = await this.userModel.findById(userId); + + if (!user) { + throw new NotFoundException('사용자를 찾을 수 없습니다'); + } + + return user.selectedFoodDotIds ?? []; + } + + // 음식 도트 추가 + async addFoodDot(userId: Types.ObjectId | string, dotId: string): Promise { + const user = await this.userModel.findById(userId); + + if (!user) { + throw new NotFoundException('사용자를 찾을 수 없습니다'); + } + + // 초기값 보장 + if (!user.selectedFoodDotIds) { + user.selectedFoodDotIds = []; + } + + // 이미 선택된 경우 그대로 반환 + if (user.selectedFoodDotIds.includes(dotId)) { + return user.selectedFoodDotIds; + } + + // 최대 개수 제한 + if (user.selectedFoodDotIds.length >= MAX_FOOD_DOTS) { + throw new BadRequestException(`음식 도트는 최대 ${MAX_FOOD_DOTS}개까지 선택할 수 있습니다`); + } + + user.selectedFoodDotIds.push(dotId); + await user.save(); + + return user.selectedFoodDotIds; + } + + // 음식 도트 제거 + async removeFoodDot(userId: Types.ObjectId | string, dotId: string): Promise { + const user = await this.userModel.findById(userId); + + if (!user) { + throw new NotFoundException('사용자를 찾을 수 없습니다'); + } + + if (!user.selectedFoodDotIds) { + return []; + } + + user.selectedFoodDotIds = user.selectedFoodDotIds.filter((id) => id !== dotId); + + await user.save(); + + return user.selectedFoodDotIds; + } } From 9dcabb61cd88d7bf0376e1406743b222099c6394 Mon Sep 17 00:00:00 2001 From: Parkchanyoung0710 <120624055+Parkchanyoung0710@users.noreply.github.com> Date: Sun, 8 Feb 2026 14:25:23 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20Auth/Users=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=88=9C=ED=99=98=20=EC=9D=98=EC=A1=B4=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/auth.module.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 6555931..095857c 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { UsersModule } from '../users/users.module'; @@ -7,7 +7,10 @@ import { AuthService } from '../auth/service/auth.service'; import { Token, TokenSchema } from '../auth/schemas/token.schema'; @Module({ - imports: [MongooseModule.forFeature([{ name: Token.name, schema: TokenSchema }]), UsersModule], + imports: [ + MongooseModule.forFeature([{ name: Token.name, schema: TokenSchema }]), + forwardRef(() => UsersModule), + ], controllers: [AuthController], providers: [AuthService], exports: [AuthService], From 0f1dfa858f947654e6175bc9cfb26543f969f592 Mon Sep 17 00:00:00 2001 From: Parkchanyoung0710 <120624055+Parkchanyoung0710@users.noreply.github.com> Date: Sun, 8 Feb 2026 14:28:16 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/users/dto/add-food-dot.dto.ts | 11 +++++ src/users/users.controller.ts | 12 +++--- src/users/users.service.ts | 70 ++++++++++++++++--------------- 3 files changed, 52 insertions(+), 41 deletions(-) create mode 100644 src/users/dto/add-food-dot.dto.ts diff --git a/src/users/dto/add-food-dot.dto.ts b/src/users/dto/add-food-dot.dto.ts new file mode 100644 index 0000000..70bde61 --- /dev/null +++ b/src/users/dto/add-food-dot.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString, Length, Matches } from 'class-validator'; + +export class AddFoodDotDto { + @IsString() + @IsNotEmpty({ message: 'dotId는 필수 값입니다.' }) + @Length(1, 50, { message: 'dotId는 1~50자여야 합니다.' }) + @Matches(/^[a-zA-Z0-9_-]+$/, { + message: 'dotId는 영문, 숫자, -, _ 만 허용됩니다.', + }) + dotId: string; +} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 65c4fa5..134ff26 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -13,6 +13,7 @@ import type { Request } from 'express'; import { AuthService } from '../auth/service/auth.service'; import { UsersService } from './users.service'; +import { AddFoodDotDto } from './dto/add-food-dot.dto'; interface AuthCookies { accessToken?: string; @@ -38,17 +39,14 @@ export class UsersController { } const user = await this.authService.verifyAccessToken(accessToken); - - if (!user) { - throw new UnauthorizedException('사용자를 찾을 수 없습니다'); - } - return user._id.toString(); } // 내 음식 도트 목록 조회 @Get('me/food-dots') @Header('Cache-Control', 'no-store, no-cache, must-revalidate, private') + @Header('Pragma', 'no-cache') + @Header('Expires', '0') async getMyFoodDots(@Req() req: AuthRequest) { const userId = await this.extractUserId(req); return this.usersService.getMyFoodDots(userId); @@ -56,9 +54,9 @@ export class UsersController { // 음식 도트 추가 @Post('me/food-dots') - async addFoodDot(@Req() req: AuthRequest, @Body('dotId') dotId: string) { + async addFoodDot(@Req() req: AuthRequest, @Body() body: AddFoodDotDto) { const userId = await this.extractUserId(req); - return this.usersService.addFoodDot(userId, dotId); + return this.usersService.addFoodDot(userId, body.dotId); } // 음식 도트 제거 diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 634320e..9371376 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -9,6 +9,7 @@ export type UserUpdateData = { passwordHash?: string; profileImage?: string | null; }; + const MAX_FOOD_DOTS = 9; @Injectable() @@ -25,7 +26,7 @@ export class UsersService { } // ID로 유저 조회 - async findById(userId: Types.ObjectId | string) { + findById(userId: Types.ObjectId | string) { return this.userModel.findById(userId).exec(); } @@ -33,7 +34,7 @@ export class UsersService { async create(userData: { nickname: string; email: string; - passwordHash?: string; // oauth 사용자도 고려 + passwordHash?: string; profileImage?: string | null; }) { const user = new this.userModel(userData); @@ -45,7 +46,7 @@ export class UsersService { const updatedUser = await this.userModel.findByIdAndUpdate( userId, { $set: updateData }, - { new: true }, // 업데이트된 문서 반환 + { new: true }, ); if (!updatedUser) { @@ -57,7 +58,7 @@ export class UsersService { // 내 음식 도트 목록 조회 async getMyFoodDots(userId: Types.ObjectId | string): Promise { - const user = await this.userModel.findById(userId); + const user = await this.userModel.findById(userId).select('selectedFoodDotIds'); if (!user) { throw new NotFoundException('사용자를 찾을 수 없습니다'); @@ -68,49 +69,50 @@ export class UsersService { // 음식 도트 추가 async addFoodDot(userId: Types.ObjectId | string, dotId: string): Promise { - const user = await this.userModel.findById(userId); - - if (!user) { - throw new NotFoundException('사용자를 찾을 수 없습니다'); - } + const updatedUser = await this.userModel.findOneAndUpdate( + { + _id: userId, + $expr: { + $lt: [{ $size: { $ifNull: ['$selectedFoodDotIds', []] } }, MAX_FOOD_DOTS], + }, + }, + { + $addToSet: { selectedFoodDotIds: dotId }, + }, + { + new: true, + }, + ); - // 초기값 보장 - if (!user.selectedFoodDotIds) { - user.selectedFoodDotIds = []; - } + if (!updatedUser) { + const exists = await this.userModel.exists({ _id: userId }); - // 이미 선택된 경우 그대로 반환 - if (user.selectedFoodDotIds.includes(dotId)) { - return user.selectedFoodDotIds; - } + if (!exists) { + throw new NotFoundException('사용자를 찾을 수 없습니다'); + } - // 최대 개수 제한 - if (user.selectedFoodDotIds.length >= MAX_FOOD_DOTS) { throw new BadRequestException(`음식 도트는 최대 ${MAX_FOOD_DOTS}개까지 선택할 수 있습니다`); } - user.selectedFoodDotIds.push(dotId); - await user.save(); - - return user.selectedFoodDotIds; + return updatedUser.selectedFoodDotIds ?? []; } // 음식 도트 제거 async removeFoodDot(userId: Types.ObjectId | string, dotId: string): Promise { - const user = await this.userModel.findById(userId); + const updatedUser = await this.userModel.findByIdAndUpdate( + userId, + { + $pull: { selectedFoodDotIds: dotId }, + }, + { + new: true, + }, + ); - if (!user) { + if (!updatedUser) { throw new NotFoundException('사용자를 찾을 수 없습니다'); } - if (!user.selectedFoodDotIds) { - return []; - } - - user.selectedFoodDotIds = user.selectedFoodDotIds.filter((id) => id !== dotId); - - await user.save(); - - return user.selectedFoodDotIds; + return updatedUser.selectedFoodDotIds ?? []; } }