diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index c688737..2e8132e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -1,7 +1,7 @@ name: deploy-cluster-prod on: push: - branches: ['master'] + branches: ['develop'] jobs: build: name: Build @@ -10,20 +10,20 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 - - name: Login to GitHub Container Registry + - name: Login to Vultr Container Registry uses: docker/login-action@v2 with: - registry: ghcr.io - username: ${{github.actor}} - password: ${{ secrets.GH_TOKEN }} + registry: sjc.vultrcr.com + username: ${{ secrets.VULTR_REGU }} + password: ${{ secrets.VULTR_REPW }} - name: Build and push the Docker image uses: docker/build-push-action@v3 with: push: true tags: | - ghcr.io/medici-mansion/shinnyang-server:latest - ghcr.io/medici-mansion/shinnyang-server:${{ github.sha }} + sjc.vultrcr.com/medici/shin-server:latest + sjc.vultrcr.com/medici/shin-server:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max deploy: @@ -35,7 +35,7 @@ jobs: uses: azure/k8s-set-context@v2 with: method: service-account - k8s-url: https://783da7b8-3679-4834-aadf-a68204c5ebc2.vultr-k8s.com:6443 + k8s-url: https://cc33adee-ae13-46cb-9388-19d5a494a094.vultr-k8s.com:6443 k8s-secret: ${{ secrets.KUBERNETES_SECRET }} - name: Checkout source code uses: actions/checkout@v3 @@ -46,4 +46,4 @@ jobs: manifests: | k8s/deployment.yaml images: | - ghcr.io/medici-mansion/shinnyang-server:${{ github.sha }} + sjc.vultrcr.com/medici/shin-server:${{ github.sha }} diff --git a/global.d.ts b/global.d.ts index 567fad5..64be943 100644 --- a/global.d.ts +++ b/global.d.ts @@ -6,16 +6,13 @@ declare namespace NodeJS { readonly DB_USER: string; readonly DB_PWD: string; readonly DB_NAME: string; - readonly REDIS_HOST: string; - readonly REDIS_PORT: string; - readonly REDIS_PASSWORD: string; readonly GOOGLE_API_KEY: string; readonly GOOGLE_AUTH_CLIENT_ID: string; readonly GOOGLE_REDIRECT_URL: string; readonly OOGLE_AUTH_CLIENT_SECRET: string; - readonly JWT_SECRET: string; - readonly JWT_EXPIRATION: string; - readonly JWT_REFRESH_EXPIRATION: string; - readonly GOOGLE_CLIENT_SECRET: string; + readonly SALT: string; + readonly ROUND: string; + readonly EXPIRESTOKEN: string; + readonly RES_EXPIRESTOKEN: string; } } diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index ee28176..5c3aca6 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -2,7 +2,14 @@ apiVersion: apps/v1 kind: Deployment metadata: name: catsnewyear + spec: + strategy: + type: Recreate + # rollingUpdate: + # maxUnavailable: 1 + # maxSurge: 1 + minReadySeconds: 5 replicas: 3 selector: matchLabels: @@ -13,59 +20,59 @@ spec: app: catsnewyear spec: containers: - - name: catsnewyear - image: ghcr.io/medici/catsnewyear-server:latest - resources: - limits: - memory: "128Mi" - cpu: "500m" - ports: - - containerPort: 3000 - envFrom: - - secretRef: - name: catsnewyear-prod - # env: - # - name: NODE_ENV - # valueFrom: - # secretKeyRef: - # name: catsnewyear-prod - # key: NODE_ENV - # optional: false - # - name: PORT - # valueFrom: - # secretKeyRef: - # name: catsnewyear-prod - # key: PORT - # optional: false - # - name: DB_HOST - # valueFrom: - # secretKeyRef: - # name: catsnewyear-prod - # key: DB_HOST - # optional: false - # - name: DB_PORT - # valueFrom: - # secretKeyRef: - # name: catsnewyear-prod - # key: DB_PORT - # optional: false - # - name: DB_USER - # valueFrom: - # secretKeyRef: - # name: catsnewyear-prod - # key: DB_USER - # optional: false - # - name: DB_PWD - # valueFrom: - # secretKeyRef: - # name: catsnewyear-prod - # key: DB_PWD - # optional: false - # - name: DB_NAME - # valueFrom: - # secretKeyRef: - # name: catsnewyear-prod - # key: DB_NAME - # optional: false + - name: catsnewyear + image: sjc.vultrcr.com/medici/shin-server + resources: + limits: + memory: '512Mi' + cpu: '1000m' + ports: + - containerPort: 3000 + envFrom: + - secretRef: + name: catsnewyear-prod + # env: + # - name: NODE_ENV + # valueFrom: + # secretKeyRef: + # name: catsnewyear-prod + # key: NODE_ENV + # optional: false + # - name: PORT + # valueFrom: + # secretKeyRef: + # name: catsnewyear-prod + # key: PORT + # optional: false + # - name: DB_HOST + # valueFrom: + # secretKeyRef: + # name: catsnewyear-prod + # key: DB_HOST + # optional: false + # - name: DB_PORT + # valueFrom: + # secretKeyRef: + # name: catsnewyear-prod + # key: DB_PORT + # optional: false + # - name: DB_USER + # valueFrom: + # secretKeyRef: + # name: catsnewyear-prod + # key: DB_USER + # optional: false + # - name: DB_PWD + # valueFrom: + # secretKeyRef: + # name: catsnewyear-prod + # key: DB_PWD + # optional: false + # - name: DB_NAME + # valueFrom: + # secretKeyRef: + # name: catsnewyear-prod + # key: DB_NAME + # optional: false imagePullSecrets: - - name: github-container-registry \ No newline at end of file + - name: vultr-registry diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..add2c01 --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: catsnewyear +spec: + ports: + - port: 80 + protocol: TCP + targetPort: 3000 + selector: + app: catsnewyear diff --git a/package-lock.json b/package-lock.json index ed9df64..9d0b0d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cross-env": "^7.0.3", + "helmet": "^7.1.0", "joi": "^17.11.0", "pg": "^8.11.3", "reflect-metadata": "^0.1.13", @@ -5563,6 +5564,14 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.1.0.tgz", + "integrity": "sha512-g+HZqgfbpXdCkme/Cd/mZkV0aV3BZZZSugecH03kl38m/Kmdx8jKjBikpDj2cr+Iynv4KpYEviojNdTJActJAg==", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/hexoid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", diff --git a/package.json b/package.json index b9b969d..0d20377 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shinnyang", - "version": "0.1.1", + "version": "0.1.3", "description": "", "author": { "name": "Medici", @@ -24,6 +24,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@hapi/joi": "^17.1.1", "@nestjs/axios": "^3.0.1", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.1.1", @@ -32,13 +33,12 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.1.16", "@nestjs/typeorm": "^10.0.1", - "@types/joi": "^17.2.3", "axios": "^1.6.2", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cross-env": "^7.0.3", - "joi": "^17.11.0", + "helmet": "^7.1.0", "pg": "^8.11.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", @@ -50,6 +50,8 @@ "@nestjs/testing": "^10.0.0", "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.17", + "@types/hapi__joi": "^17.1.14", + "@types/helmet": "^4.0.0", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/supertest": "^2.0.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02ee6ee..91e067e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + '@hapi/joi': + specifier: ^17.1.1 + version: 17.1.1 '@nestjs/axios': specifier: ^3.0.1 version: 3.0.1(@nestjs/common@10.2.10)(axios@1.6.2)(reflect-metadata@0.1.13)(rxjs@7.8.1) @@ -29,9 +32,6 @@ dependencies: '@nestjs/typeorm': specifier: ^10.0.1 version: 10.0.1(@nestjs/common@10.2.10)(@nestjs/core@10.2.10)(reflect-metadata@0.1.13)(rxjs@7.8.1)(typeorm@0.3.17) - '@types/joi': - specifier: ^17.2.3 - version: 17.2.3 axios: specifier: ^1.6.2 version: 1.6.2 @@ -47,9 +47,9 @@ dependencies: cross-env: specifier: ^7.0.3 version: 7.0.3 - joi: - specifier: ^17.11.0 - version: 17.11.0 + helmet: + specifier: ^7.1.0 + version: 7.1.0 pg: specifier: ^8.11.3 version: 8.11.3 @@ -79,6 +79,12 @@ devDependencies: '@types/express': specifier: ^4.17.17 version: 4.17.21 + '@types/hapi__joi': + specifier: ^17.1.14 + version: 17.1.14 + '@types/helmet': + specifier: ^4.0.0 + version: 4.0.0 '@types/jest': specifier: ^29.5.2 version: 29.5.11 @@ -578,10 +584,37 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@hapi/address@4.1.0: + resolution: {integrity: sha512-SkszZf13HVgGmChdHo/PxchnSaCJ6cetVqLzyciudzZRT0jcOouIF/Q93mgjw8cce+D+4F4C1Z/WrfFN+O3VHQ==} + deprecated: Moved to 'npm install @sideway/address' + dependencies: + '@hapi/hoek': 9.3.0 + dev: false + + /@hapi/formula@2.0.0: + resolution: {integrity: sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A==} + deprecated: Moved to 'npm install @sideway/formula' + dev: false + /@hapi/hoek@9.3.0: resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} dev: false + /@hapi/joi@17.1.1: + resolution: {integrity: sha512-p4DKeZAoeZW4g3u7ZeRo+vCDuSDgSvtsB/NpfjXEHTUjSeINAi/RrVOWiVQ1isaoLzMvFEhe8n5065mQq1AdQg==} + deprecated: Switch to 'npm install joi' + dependencies: + '@hapi/address': 4.1.0 + '@hapi/formula': 2.0.0 + '@hapi/hoek': 9.3.0 + '@hapi/pinpoint': 2.0.1 + '@hapi/topo': 5.1.0 + dev: false + + /@hapi/pinpoint@2.0.1: + resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} + dev: false + /@hapi/topo@5.1.0: resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} dependencies: @@ -1366,6 +1399,17 @@ packages: '@types/node': 20.10.3 dev: true + /@types/hapi__joi@17.1.14: + resolution: {integrity: sha512-elV1VhwXUfA1sw59ij75HWyCH+3cA7xLbaOY9GQ+iQo/S+jSSf22LNZAmsXMdfV8DZwquCZaCT+F43Xf6/txrQ==} + dev: true + + /@types/helmet@4.0.0: + resolution: {integrity: sha512-ONIn/nSNQA57yRge3oaMQESef/6QhoeX7llWeDli0UZIfz8TQMkfNPTXA8VnnyeA1WUjG2pGqdjEIueYonMdfQ==} + deprecated: This is a stub types definition. helmet provides its own type definitions, so you do not need this installed. + dependencies: + helmet: 7.1.0 + dev: true + /@types/http-errors@2.0.4: resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} dev: true @@ -3369,6 +3413,10 @@ packages: dependencies: function-bind: 1.1.2 + /helmet@7.1.0: + resolution: {integrity: sha512-g+HZqgfbpXdCkme/Cd/mZkV0aV3BZZZSugecH03kl38m/Kmdx8jKjBikpDj2cr+Iynv4KpYEviojNdTJActJAg==} + engines: {node: '>=16.0.0'} + /hexoid@1.0.0: resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} engines: {node: '>=8'} diff --git a/src/app.module.ts b/src/app.module.ts index 5014835..2cf9af5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,38 +1,38 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; - import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { LoggerMiddleware } from './middlewares/logger.middleware'; import { DatabaseModule } from './database/database.module'; -import { LettersModule } from './letters/letters.module'; import { UserModule } from './users/user.module'; import { AuthModule } from './auth/auth.module'; import { OauthModule } from './oauth/oauth.module'; -import * as joi from 'joi'; +import { CommonModule } from './common/common.module'; +import Joi from '@hapi/joi'; + @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: `.env.${process.env.NODE_ENV}`, - validationSchema: joi.object({ - GOOGLE_API_KEY: joi.string().required(), - GOOGLE_AUTH_CLIENT_ID: joi.string().required(), - GOOGLE_AUTH_CLIENT_SECRET: joi.string().required(), - GOOGLE_REDIRECT_URL: joi.string().required(), - DB_HOST: joi.string().required(), - DB_PORT: joi.string().required(), - DB_USER: joi.string().required(), - DB_PWD: joi.string().required(), - DB_NAME: joi.string().required(), - PORT: joi.string().required(), + validationSchema: Joi.object({ + GOOGLE_API_KEY: Joi.string().required(), + GOOGLE_AUTH_CLIENT_ID: Joi.string().required(), + GOOGLE_AUTH_CLIENT_SECRET: Joi.string().required(), + GOOGLE_REDIRECT_URL: Joi.string().required(), + DB_HOST: Joi.string().required(), + DB_PORT: Joi.string().required(), + DB_USER: Joi.string().required(), + DB_PWD: Joi.string().required(), + DB_NAME: Joi.string().required(), + PORT: Joi.string().required(), }), }), // 아직 어떤 db를 쓸지 정하지 않았음 - LettersModule, DatabaseModule, UserModule, AuthModule, OauthModule, + CommonModule, ], controllers: [AppController], providers: [], diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..82c2de0 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,57 @@ +import { AuthService } from './auth.service'; +import { Body, Controller, Post } from '@nestjs/common'; +import { JWT } from './dtos/jwt.dto'; +import { + ApiBody, + ApiCreatedResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; + +@Controller('auth') +@ApiTags('Auth API') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @ApiOperation({ + summary: '토큰 재발급', + description: '리프레스 토큰 검증 후 재발급', + }) + @ApiBody({ + type: String, + description: '리프레시', + required: true, + schema: { + properties: { + refresh: { + type: 'string', + }, + }, + }, + }) + @ApiCreatedResponse({ + description: '재발급된 토큰', + schema: { + example: { + access: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1....yrIzzKZwDQ', + refresh: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1....b-Ct7gGJBA', + }, + }, + }) + @ApiUnauthorizedResponse({ + description: 'Unauthorized', + schema: { + example: { + statusCode: 401, + message: 'Unauthorized', + }, + }, + }) + @Post('refresh') + async refreshToken(@Body('refresh') refreshToken: string): Promise { + return await this.authService.refreshToken(refreshToken); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 7768ccc..500bf26 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,9 +1,13 @@ -import { Module } from '@nestjs/common'; +import { Global, Module } from '@nestjs/common'; import { AuthService } from './auth.service'; import { AuthRepository } from './auth.repository'; -import { HttpModule } from '@nestjs/axios'; +import { JwtModule } from '@nestjs/jwt'; +import { AuthController } from './auth.controller'; +@Global() @Module({ - imports: [HttpModule], + imports: [JwtModule.register({})], providers: [AuthService, AuthRepository], + exports: [AuthService], + controllers: [AuthController], }) export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index b164ff7..4d10ecc 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,6 +1,71 @@ -import { Injectable } from '@nestjs/common'; +import { JwtService, JwtVerifyOptions } from '@nestjs/jwt'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JWT, Payload } from './dtos/jwt.dto'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { User } from 'src/users/entities/user.entity'; @Injectable() export class AuthService { - constructor() {} + constructor( + private readonly jwtService: JwtService, + @InjectDataSource() private readonly dataSource: DataSource, + ) {} + + async refreshToken(refresh: string): Promise { + this.verify(refresh, { + secret: process.env.SALT, + }); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const user = await this.dataSource.manager.findOne(User, { + where: { refresh }, + }); + if (!user) { + throw new UnauthorizedException(); + } + + const tokens = this.sign(user.id); + await this.dataSource.manager.update( + User, + { id: user.id }, + { refresh: tokens.refresh }, + ); + await queryRunner.commitTransaction(); + return tokens; + } catch (err) { + await queryRunner.rollbackTransaction(); + throw new Error('트랜잭션 에러 발생'); + } finally { + await queryRunner.release(); + } + } + + sign(id: number) { + const payload: Payload = { + id, + }; + return new JWT({ + access: this.jwtService.sign(payload, { + secret: process.env.SALT, + expiresIn: process.env.EXPIRESTOKEN, + }), + refresh: this.jwtService.sign(payload, { + secret: process.env.SALT, + expiresIn: process.env.RES_EXPIRESTOKEN, + }), + }); + } + + verify(token: string, options: JwtVerifyOptions): Payload { + try { + return this.jwtService.verify(token, options); + } catch (e) { + throw new UnauthorizedException(); + } + } } diff --git a/src/auth/decorators/auth-user.decorator.ts b/src/auth/decorators/auth-user.decorator.ts new file mode 100644 index 0000000..905a9be --- /dev/null +++ b/src/auth/decorators/auth-user.decorator.ts @@ -0,0 +1,8 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const AuthUser = createParamDecorator( + (_data: unknown, context: ExecutionContext) => { + const req = context.switchToHttp().getRequest(); + return req.user || {}; + }, +); diff --git a/src/auth/dtos/jwt.dto.ts b/src/auth/dtos/jwt.dto.ts new file mode 100644 index 0000000..460a762 --- /dev/null +++ b/src/auth/dtos/jwt.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class JWT { + @ApiProperty({ description: '엑세스 토큰' }) + @IsString() + access: string; + + @ApiProperty({ description: '리프레시 토큰' }) + @IsString() + refresh: string; + + constructor(token: JWT) { + this.access = token.access; + this.refresh = token.refresh; + } +} + +export class Payload { + @ApiProperty({ description: 'Guard를 통과한 후 사용자 아이디' }) + @IsString() + id: number; +} diff --git a/src/auth/guards/acess.guard.ts b/src/auth/guards/acess.guard.ts new file mode 100644 index 0000000..33a5588 --- /dev/null +++ b/src/auth/guards/acess.guard.ts @@ -0,0 +1,30 @@ +import { AuthService } from './../auth.service'; +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Request } from 'express'; +import { Observable } from 'rxjs'; + +@Injectable() +export class AccessGuard implements CanActivate { + constructor(private readonly authService: AuthService) {} + + canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { + const request = context.switchToHttp().getRequest() as Request; + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new UnauthorizedException(); + } + const payload = this.authService.verify(token, { + secret: process.env.SALT, + }); + + request['user'] = payload; + return true; + } +} diff --git a/src/common/base.entity.ts b/src/common/base.entity.ts deleted file mode 100644 index d8311cf..0000000 --- a/src/common/base.entity.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - CreateDateColumn, - DeleteDateColumn, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; - -export class BaseEntity { - @PrimaryGeneratedColumn() - id: number; - - @CreateDateColumn({ name: 'created_at', default: new Date() }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at', default: new Date() }) - updatedAt: Date; - - @DeleteDateColumn({ name: 'deleted_at' }) - deletedAt: Date | null; -} diff --git a/src/common/common.controller.ts b/src/common/common.controller.ts new file mode 100644 index 0000000..e2f468c --- /dev/null +++ b/src/common/common.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get } from '@nestjs/common'; +import { CommonService } from './common.service'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CatDTO } from './dto/cat.dto'; + +@ApiTags('Common API') +@Controller('common') +export class CommonController { + constructor(private readonly commonService: CommonService) {} + + @ApiOperation({ + description: '냥이 정보 조회', + summary: '이미지를 보유한 냥이정보를 조회한다.', + }) + @ApiOkResponse({ + description: '냥이 정보 조회 성공', + type: [CatDTO], + }) + @Get('cats') + async getCatsData() { + return await this.commonService.findAllCats(); + } +} diff --git a/src/common/common.module.ts b/src/common/common.module.ts new file mode 100644 index 0000000..46ae0b1 --- /dev/null +++ b/src/common/common.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { CommonService } from './common.service'; +import { CommonController } from './common.controller'; + +@Module({ + controllers: [CommonController], + providers: [CommonService], +}) +export class CommonModule {} diff --git a/src/common/common.service.ts b/src/common/common.service.ts new file mode 100644 index 0000000..c7fb0d0 --- /dev/null +++ b/src/common/common.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, IsNull, Not } from 'typeorm'; +import { Cats } from './entities/cats.entity'; +import { CatDTO } from './dto/cat.dto'; + +@Injectable() +export class CommonService { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + async findAllCats() { + const cats = await this.dataSource.getRepository(Cats).find({ + where: { + image: Not(IsNull()), + }, + }); + return cats.map((cat) => new CatDTO(cat)); + } +} diff --git a/src/common/dto/cat.dto.ts b/src/common/dto/cat.dto.ts new file mode 100644 index 0000000..5b72ca0 --- /dev/null +++ b/src/common/dto/cat.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsUUID } from 'class-validator'; +import { Cats } from '../entities/cats.entity'; + +export class CatDTO { + @ApiProperty({ + description: '아이디', + example: 'be17e0ab-6463-4db8-bdfa-bc9011193038', + }) + @IsUUID('all') + id: string; + + @ApiProperty({ description: '이름', example: '우무' }) + @IsString() + name: string; + + @ApiProperty({ description: '객체코드 (영어이름)', example: 'umu' }) + @IsString() + code: string; + + @ApiProperty({ + description: '이미지 주소', + example: + 'https://res.cloudinary.com/dzfrlb2nb/image/upload/f_auto,q_auto/qrlibxs63hintzlq7jsh.png', + }) + @IsString() + image: string; + + constructor(cats: Cats) { + this.id = cats.id; + this.code = cats.code; + this.image = cats.image; + this.name = cats.name; + } +} diff --git a/src/common/dto/create-common.dto.ts b/src/common/dto/create-common.dto.ts new file mode 100644 index 0000000..ba2b1e7 --- /dev/null +++ b/src/common/dto/create-common.dto.ts @@ -0,0 +1 @@ +export class CreateCommonDto {} diff --git a/src/common/dto/update-common.dto.ts b/src/common/dto/update-common.dto.ts new file mode 100644 index 0000000..a8b00a0 --- /dev/null +++ b/src/common/dto/update-common.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateCommonDto } from './create-common.dto'; + +export class UpdateCommonDto extends PartialType(CreateCommonDto) {} diff --git a/src/common/emoji.logger.ts b/src/common/emoji.logger.ts new file mode 100644 index 0000000..340f8fa --- /dev/null +++ b/src/common/emoji.logger.ts @@ -0,0 +1,23 @@ +import { ConsoleLogger } from '@nestjs/common'; + +export class EmojiLogger extends ConsoleLogger { + constructor() { + super(); + } + log(message: string) { + super.log('📢 ' + message); + } + + error(message: string, trace: string) { + super.error('❌ ' + message); + super.error('🔍 Stack Trace: ' + trace); + } + + warn(message: string) { + super.warn('⚠️ ' + message); + } + + debug(message: string) { + super.debug('🐞 ' + message); + } +} diff --git a/src/common/entities/base.entity.ts b/src/common/entities/base.entity.ts new file mode 100644 index 0000000..83f1f59 --- /dev/null +++ b/src/common/entities/base.entity.ts @@ -0,0 +1,28 @@ +import { + CreateDateColumn, + DeleteDateColumn, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +export class BaseEntity { + @PrimaryGeneratedColumn('uuid', { comment: '아이디' }) + id: string; + + @CreateDateColumn({ + name: 'created_at', + comment: '생성일자', + default: new Date(), + }) + createdAt: Date; + + @UpdateDateColumn({ + name: 'updated_at', + comment: '수정일자', + default: new Date(), + }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', comment: '삭제일자' }) + deletedAt: Date | null; +} diff --git a/src/common/entities/cats.entity.ts b/src/common/entities/cats.entity.ts new file mode 100644 index 0000000..ce95033 --- /dev/null +++ b/src/common/entities/cats.entity.ts @@ -0,0 +1,14 @@ +import { Column, Entity } from 'typeorm'; +import { BaseEntity } from './base.entity'; + +@Entity({ name: 'cats' }) +export class Cats extends BaseEntity { + @Column({ name: 'name', comment: '표기 이름', nullable: true }) + name: string; + + @Column({ name: 'code', comment: '코드명', nullable: true }) + code: string; + + @Column({ name: 'image', comment: '이미지 경로', nullable: true }) + image: string; +} diff --git a/src/common/pipes/eum.pipe.ts b/src/common/pipes/eum.pipe.ts new file mode 100644 index 0000000..d22d96a --- /dev/null +++ b/src/common/pipes/eum.pipe.ts @@ -0,0 +1,25 @@ +import { + PipeTransform, + BadRequestException, + Logger, + ArgumentMetadata, +} from '@nestjs/common'; +import Joi from '@hapi/joi'; +export class ParseExplicitEnumPipe implements PipeTransform { + private readonly logger = new Logger(ParseExplicitEnumPipe.name); + constructor(private schema: unknown) {} + + transform(value: unknown, {}: ArgumentMetadata) { + try { + const valid = Joi.string().valid(...Object.values(this.schema)); + const { error, value: validValue } = valid.validate(value); + if (error) { + throw new BadRequestException('서비스가 원할하지 않아요.'); + } + return validValue; + } catch (error) { + this.logger.error(error.message); + throw new BadRequestException(error.message); + } + } +} diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index e1a6069..94f63c1 100644 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -1,5 +1,5 @@ +import { BaseEntity } from 'src/common/entities/base.entity'; import { Entity, Column } from 'typeorm'; -import { BaseEntity } from 'src/common/base.entity'; enum UserStatus { ACTIVE = 'active', @@ -31,4 +31,11 @@ export class User extends BaseEntity { default: UserStatus.ACTIVE, }) status: UserStatus; + + @Column({ + name: 'refresh_token', + comment: '리프래시 토큰', + nullable: true, + }) + refresh: string; } diff --git a/src/letters/dtos/create-letters.dto.ts b/src/letters/dtos/create-letter.dto.ts similarity index 87% rename from src/letters/dtos/create-letters.dto.ts rename to src/letters/dtos/create-letter.dto.ts index 9dffd99..62f340f 100644 --- a/src/letters/dtos/create-letters.dto.ts +++ b/src/letters/dtos/create-letter.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, PickType } from '@nestjs/swagger'; import { IsNumber, IsOptional, IsString } from 'class-validator'; -import { LetterDto } from './letters.dto'; +import { LetterDto } from './letter.dto'; export class PostLetterRequestDto { @ApiProperty({ description: '보낼 편지의 내용', default: '안녕하세요' }) @@ -10,7 +10,7 @@ export class PostLetterRequestDto { @ApiProperty({ description: '전달 받을 사용자 아이디' }) @ApiProperty() @IsNumber() - receiverId: number; + receiverId: number | null; @ApiProperty({ description: '보내는 사용자 아이디' }) @IsOptional() @@ -23,7 +23,7 @@ export class PostLetterRequestDto { } export class PostLetterResponseDto extends PickType(LetterDto, ['id']) { - constructor(id: number) { + constructor(id: string) { super(); this.id = id; } diff --git a/src/letters/dtos/letters.dto.ts b/src/letters/dtos/letter.dto.ts similarity index 90% rename from src/letters/dtos/letters.dto.ts rename to src/letters/dtos/letter.dto.ts index 2319698..2ede75d 100644 --- a/src/letters/dtos/letters.dto.ts +++ b/src/letters/dtos/letter.dto.ts @@ -1,11 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNumber, IsOptional, IsString } from 'class-validator'; +import { IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; import { Letter } from '../entities/letter.entity'; export class LetterDto { @ApiProperty({ description: '아이디' }) - @IsNumber() - id: number; + @IsUUID('all') + id: string; @ApiProperty({ description: '받는 사용자 아이디', default: null }) @IsOptional() diff --git a/src/letters/entities/letter.entity.ts b/src/letters/entities/letter.entity.ts index 10cbcfa..5ebf572 100644 --- a/src/letters/entities/letter.entity.ts +++ b/src/letters/entities/letter.entity.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from 'src/common/base.entity'; +import { BaseEntity } from 'src/common/entities/base.entity'; import { Column, Entity } from 'typeorm'; @Entity({ name: 'letters' }) diff --git a/src/letters/letter.controller.ts b/src/letters/letter.controller.ts new file mode 100644 index 0000000..eaf4ddd --- /dev/null +++ b/src/letters/letter.controller.ts @@ -0,0 +1,77 @@ +import { + ApiBearerAuth, + ApiCreatedResponse, + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, + getSchemaPath, +} from '@nestjs/swagger'; +import { LetterService } from './letter.service'; +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { GetLettersResponseDto } from './dtos/letter.dto'; +import { Response } from 'src/common/interface'; +import { + PostLetterRequestDto, + PostLetterResponseDto, +} from './dtos/create-letter.dto'; +import { AccessGuard } from 'src/auth/guards/acess.guard'; +import { AuthUser } from '../auth/decorators/auth-user.decorator'; + +@Controller('letters') +@ApiTags('Letters API') +@ApiExtraModels(GetLettersResponseDto, PostLetterResponseDto) +export class LetterController { + constructor(private readonly lettersService: LetterService) {} + + @Get() + @UseGuards(AccessGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: '내가 받은 편지 목록 조회', + description: '내가 받은 편지 목록을 조회한다', + }) + @ApiOkResponse({ + description: '내가 받은 편지 목록 조회', + schema: { + $ref: getSchemaPath(GetLettersResponseDto), + }, + }) + async getLetterList( + @AuthUser() { id }, + ): Promise> { + return this.lettersService.getLetterList(id); + } + + @Get(':letterId') + @ApiOperation({ + summary: '편지 상세 조회', + description: '편지의 상세 내용을 조회한다', + }) + @ApiOkResponse({ + description: '편지 상세 조회', + schema: { + $ref: getSchemaPath(GetLettersResponseDto), + }, + }) + async getLetterDetail( + @Param('letterId') letterId: number, + ): Promise> { + return this.lettersService.getLetterDetail(letterId); + } + + @Post('letter') + @ApiOperation({ + summary: '편지 생성하기', + description: '편지를 생성한다', + }) + @ApiCreatedResponse({ + description: '편지 생성 완료', + schema: { + $ref: getSchemaPath(PostLetterResponseDto), + }, + }) + async postLetter(@Body() postLetterRequestDto: PostLetterRequestDto) { + return this.lettersService.createLetter(postLetterRequestDto); + } +} diff --git a/src/letters/letter.module.ts b/src/letters/letter.module.ts new file mode 100644 index 0000000..77e3991 --- /dev/null +++ b/src/letters/letter.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { LetterService } from './letter.service'; +import { LetterController } from './letter.controller'; +import { LetterRepository } from './letter.repository'; + +@Module({ + providers: [LetterService, LetterRepository], + controllers: [LetterController], +}) +export class LetterModule {} diff --git a/src/letters/letters.repository.ts b/src/letters/letter.repository.ts similarity index 76% rename from src/letters/letters.repository.ts rename to src/letters/letter.repository.ts index 19e6455..07b284e 100644 --- a/src/letters/letters.repository.ts +++ b/src/letters/letter.repository.ts @@ -1,9 +1,9 @@ import { DataSource, Repository } from 'typeorm'; -import { Letter } from './entities/letter.entity'; import { InjectDataSource } from '@nestjs/typeorm'; -import { PostLetterRequestDto } from './dtos/create-letters.dto'; +import { PostLetterRequestDto } from './dtos/create-letter.dto'; +import { Letter } from './entities/letter.entity'; -export class LettersRepository extends Repository { +export class LetterRepository extends Repository { constructor(@InjectDataSource() dataSource: DataSource) { super(Letter, dataSource.createEntityManager()); } diff --git a/src/letters/letters.service.ts b/src/letters/letter.service.ts similarity index 55% rename from src/letters/letters.service.ts rename to src/letters/letter.service.ts index d350d2a..018d9ca 100644 --- a/src/letters/letters.service.ts +++ b/src/letters/letter.service.ts @@ -1,16 +1,16 @@ -import { LettersRepository } from './letters.repository'; +import { LetterRepository } from './letter.repository'; import { Injectable } from '@nestjs/common'; import { createResponse } from 'src/utils/response.utils'; -import { GetLettersResponseDto } from './dtos/letters.dto'; +import { GetLettersResponseDto } from './dtos/letter.dto'; import { Response } from 'src/common/interface'; import { PostLetterRequestDto, PostLetterResponseDto, -} from './dtos/create-letters.dto'; +} from './dtos/create-letter.dto'; @Injectable() -export class LettersService { - constructor(private readonly lettersRepository: LettersRepository) {} +export class LetterService { + constructor(private readonly lettersRepository: LetterRepository) {} /** * 편지를 조회한다. @@ -20,13 +20,25 @@ export class LettersService { * @author raymondanything * @returns {Promise>} GetLettersResponseDto[] */ - async getLetters(): Promise> { - const lettersResult = await this.lettersRepository.find(); + async getLetterList(id: number): Promise> { + const letterList = await this.lettersRepository.find({ + where: { receiverId: id }, + order: { createdAt: 'asc' }, + }); return createResponse( - lettersResult.map((letters) => new GetLettersResponseDto(letters)), + letterList.map((letter) => new GetLettersResponseDto(letter)), ); } + async getLetterDetail( + letterId: number, + ): Promise> { + const letter = await this.lettersRepository.findOne({ + where: { id: letterId }, + }); + return createResponse(new GetLettersResponseDto(letter)); + } + /** * 편지를 생성한다. * @@ -43,4 +55,8 @@ export class LettersService { await this.lettersRepository.createLetter(postLetterRequestDto); return createResponse(new PostLetterResponseDto(newLetter.id)); } + + createAnswer(postLetterRequestDto: PostLetterRequestDto) { + return Promise.resolve(undefined); + } } diff --git a/src/letters/letters.controller.ts b/src/letters/letters.controller.ts deleted file mode 100644 index 344db0e..0000000 --- a/src/letters/letters.controller.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - ApiCreatedResponse, - ApiExtraModels, - ApiOkResponse, - ApiOperation, - ApiTags, - getSchemaPath, -} from '@nestjs/swagger'; -import { LettersService } from './letters.service'; -import { Body, Controller, Get, Post } from '@nestjs/common'; -import { GetLettersResponseDto } from './dtos/letters.dto'; -import { Response } from 'src/common/interface'; -import { - PostLetterRequestDto, - PostLetterResponseDto, -} from './dtos/create-letters.dto'; - -@Controller('letters') -@ApiTags('Letters API') -@ApiExtraModels(GetLettersResponseDto, PostLetterResponseDto) -export class LettersController { - constructor(private readonly lettersService: LettersService) {} - @Get() - @ApiOperation({ - summary: '내가 받은 편지 목록 조회', - description: '내가 받은 편지 목록을 조회한다', - }) - @ApiOkResponse({ - description: '내가 받은 편지 목록 조회', - schema: { - $ref: getSchemaPath(GetLettersResponseDto), - }, - }) - async getLetters(): Promise> { - return this.lettersService.getLetters(); - } - - @Post() - @ApiOperation({ - summary: '편지 생성하기', - description: '편지를 생성한다', - }) - @ApiCreatedResponse({ - description: '편지 생성 완료', - schema: { - $ref: getSchemaPath(PostLetterResponseDto), - }, - }) - async postLetters(@Body() postLetterRequestDto: PostLetterRequestDto) { - return this.lettersService.createLetter(postLetterRequestDto); - } -} diff --git a/src/letters/letters.module.ts b/src/letters/letters.module.ts deleted file mode 100644 index 87f75a9..0000000 --- a/src/letters/letters.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { LettersService } from './letters.service'; -import { LettersController } from './letters.controller'; -import { LettersRepository } from './letters.repository'; - -@Module({ - providers: [LettersService, LettersRepository], - controllers: [LettersController], -}) -export class LettersModule {} diff --git a/src/main.ts b/src/main.ts index 1f9aa62..64f063a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,16 @@ import { NestFactory } from '@nestjs/core'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { AppModule } from './app.module'; -import { ValidationPipe } from '@nestjs/common'; +import { Logger, ValidationPipe } from '@nestjs/common'; +import helmet from 'helmet'; +import { EmojiLogger } from './common/emoji.logger'; async function bootstrap() { const PORT = process.env.PORT || 3000; - const app = await NestFactory.create(AppModule, { cors: true }); + const app = await NestFactory.create(AppModule, { + logger: new EmojiLogger(), + }); + app.enableCors(); + app.use(helmet()); app.useGlobalPipes( new ValidationPipe({ @@ -21,10 +27,13 @@ async function bootstrap() { .setDescription('The Shinnyang Project description') .setVersion('1.0') .addTag('cat') + .addBearerAuth() .build(); const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api', app, document); + SwaggerModule.setup('api-docs', app, document); - await app.listen(PORT); + await app.listen(PORT, () => { + new Logger().log(`SERVER RUNNING ON : ${PORT}`); + }); } bootstrap(); diff --git a/src/oauth/dtos/google.dto.ts b/src/oauth/dtos/google.dto.ts index 9193e9d..309acdf 100644 --- a/src/oauth/dtos/google.dto.ts +++ b/src/oauth/dtos/google.dto.ts @@ -1,16 +1,37 @@ +import { ApiProperty } from '@nestjs/swagger'; import { IsBoolean, IsEmail, IsOptional, IsString } from 'class-validator'; +import { JWT } from 'src/auth/dtos/jwt.dto'; +import { UserResponse } from 'src/users/dtos/user.dto'; +import { User } from 'src/users/entities/user.entity'; export class GoogleUserInfo { + @ApiProperty({ description: '아이디' }) @IsString() id: string; + @ApiProperty({ description: '이메일' }) @IsEmail() email: string; + @ApiProperty({ description: '이메일 검증 여부' }) @IsBoolean() verified_email: boolean; + @ApiProperty({ description: '프로필 사진 주소' }) @IsOptional() @IsString() picture: string; } + +export class GoogleAuthResponse { + @ApiProperty({ type: () => JWT }) + token: JWT; + + @ApiProperty({ type: () => UserResponse }) + user: UserResponse; + + constructor(token: JWT, user: User) { + this.token = token; + this.user = new UserResponse(user); + } +} diff --git a/src/oauth/dtos/service-provider.dto.ts b/src/oauth/dtos/service-provider.dto.ts new file mode 100644 index 0000000..b0f3186 --- /dev/null +++ b/src/oauth/dtos/service-provider.dto.ts @@ -0,0 +1,3 @@ +export enum ServiceProvider { + GOOGLE = 'google', +} diff --git a/src/oauth/oauth.controller.ts b/src/oauth/oauth.controller.ts index 254789d..dc1ff35 100644 --- a/src/oauth/oauth.controller.ts +++ b/src/oauth/oauth.controller.ts @@ -1,31 +1,50 @@ -import { Controller, Get, Query, Res } from '@nestjs/common'; +import { Controller, Get, Param, Query, Res } from '@nestjs/common'; import { OauthService } from './oauth.service'; import { Response } from 'express'; -import { URL } from 'url'; +import { + ApiExtraModels, + ApiOkResponse, + ApiTags, + getSchemaPath, +} from '@nestjs/swagger'; +import { GoogleAuthResponse } from './dtos/google.dto'; +import { UserResponse } from 'src/users/dtos/user.dto'; +import { JWT } from 'src/auth/dtos/jwt.dto'; +import { ServiceProvider } from './dtos/service-provider.dto'; +import { ParseExplicitEnumPipe } from 'src/common/pipes/eum.pipe'; @Controller('oauth') +@ApiExtraModels(GoogleAuthResponse, UserResponse, JWT) +@ApiTags('Oauth API') export class OauthController { constructor(private readonly oauthService: OauthService) {} - @Get('google/auth') - getGoogleAuth(@Res() res: Response): void { - const url = new URL( - 'https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount', - ); - url.searchParams.set('client_id', process.env.GOOGLE_AUTH_CLIENT_ID); - url.searchParams.set('redirect_uri', process.env.GOOGLE_REDIRECT_URL); - url.searchParams.set('response_type', 'code'); - url.searchParams.set( - 'scope', - 'https://www.googleapis.com/auth/userinfo.email', - ); - url.searchParams.set('access_type', 'offline'); + @Get(':serviceName/auth') + getGoogleAuth( + @Param('serviceName', new ParseExplicitEnumPipe(ServiceProvider)) + service: ServiceProvider, + @Res() res: Response, + ): void { + const url = this.oauthService.getServiceRedirectUrl(service); return res.redirect(url.toString()); } - @Get('google/user') - async getUserFromGoogle(@Query('code') code: string) { - const user = await this.oauthService.userFromGoogle(code); - return user; + @ApiOkResponse({ + description: '소셜 로그인 유저 및 토큰 정보', + schema: { + $ref: getSchemaPath(GoogleAuthResponse), + }, + }) + @Get(':serviceName/user') + async getUserFromServiceProvider( + @Param('serviceName', new ParseExplicitEnumPipe(ServiceProvider)) + serviceName: ServiceProvider, + @Query('code') code: string, + // @Query('state') state: string, + ) { + switch (serviceName) { + case ServiceProvider.GOOGLE: + return await this.oauthService.userFromGoogle(code); + } } } diff --git a/src/oauth/oauth.module.ts b/src/oauth/oauth.module.ts index 384ddfc..e2f0588 100644 --- a/src/oauth/oauth.module.ts +++ b/src/oauth/oauth.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { OauthService } from './oauth.service'; import { OauthController } from './oauth.controller'; import { UserModule } from 'src/users/user.module'; +import { AuthModule } from 'src/auth/auth.module'; @Module({ - imports: [UserModule], + imports: [UserModule, AuthModule], controllers: [OauthController], providers: [OauthService], }) diff --git a/src/oauth/oauth.service.ts b/src/oauth/oauth.service.ts index 6f89c01..f83798d 100644 --- a/src/oauth/oauth.service.ts +++ b/src/oauth/oauth.service.ts @@ -1,36 +1,79 @@ +import { AuthService } from './../auth/auth.service'; import { UserService } from './../users/user.service'; import { BadRequestException, Injectable } from '@nestjs/common'; import axios from 'axios'; -import { GoogleUserInfo } from './dtos/google.dto'; +import { GoogleAuthResponse, GoogleUserInfo } from './dtos/google.dto'; +import { URL } from 'url'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { User } from 'src/users/entities/user.entity'; +import { ServiceProvider } from './dtos/service-provider.dto'; @Injectable() export class OauthService { - constructor(private readonly userService: UserService) {} - async userFromGoogle(code: string) { - const form = new FormData(); - form.append('client_id', process.env.GOOGLE_AUTH_CLIENT_ID); - form.append('client_secret', process.env.GOOGLE_AUTH_CLIENT_SECRET); - form.append('code', code); - form.append('grant_type', 'authorization_code'); - form.append('redirect_uri', process.env.GOOGLE_REDIRECT_URL); - const response = await axios.post( - 'https://oauth2.googleapis.com/token', - form, - ); - if (!response.data['access_token']) { - throw new BadRequestException('Access-Token을 받아오지 못 했습니다.'); + constructor( + private readonly userService: UserService, + private readonly authService: AuthService, + @InjectDataSource() + private readonly dataSource: DataSource, + ) {} + + getServiceRedirectUrl(service: ServiceProvider) { + switch (service) { + case ServiceProvider.GOOGLE: + const url = new URL( + 'https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount', + ); + url.searchParams.set('client_id', process.env.GOOGLE_AUTH_CLIENT_ID); + url.searchParams.set('redirect_uri', process.env.GOOGLE_REDIRECT_URL); + url.searchParams.set('response_type', 'code'); + url.searchParams.set( + 'scope', + 'https://www.googleapis.com/auth/userinfo.email', + ); + url.searchParams.set('access_type', 'offline'); + return url; + default: + break; } - // 회원정보 가져오기 - const userUrl = 'https://www.googleapis.com/oauth2/v2/userinfo'; - const userResponse = await axios.get(userUrl, { - params: { - access_token: response.data['access_token'], - }, - }); + throw new BadRequestException(); + } - const googleUesr = userResponse.data as GoogleUserInfo; + async userFromGoogle(code: string) { + try { + const form = new FormData(); + form.append('client_id', process.env.GOOGLE_AUTH_CLIENT_ID); + form.append('client_secret', process.env.GOOGLE_AUTH_CLIENT_SECRET); + form.append('code', code); + form.append('grant_type', 'authorization_code'); + form.append('redirect_uri', process.env.GOOGLE_REDIRECT_URL); + const response = await axios.post( + 'https://oauth2.googleapis.com/token', + form, + ); + if (!response.data['access_token']) { + throw new BadRequestException('Access-Token을 받아오지 못 했습니다.'); + } + // 회원정보 가져오기 + const userUrl = 'https://www.googleapis.com/oauth2/v2/userinfo'; + const userResponse = await axios.get(userUrl, { + params: { + access_token: response.data['access_token'], + }, + }); + + const googleUesr = userResponse.data as GoogleUserInfo; - const user = await this.userService.findByUserEmail(googleUesr.email); - return user; + let user = await this.userService.findByUserEmail(googleUesr.email); + if (!user) { + user = await this.userService.create({ email: googleUesr.email }); + } + const token = this.authService.sign(user.id); + user.refresh = token.refresh; + await this.dataSource.getRepository(User).save(user); + return new GoogleAuthResponse(token, user); + } catch (err) { + throw new BadRequestException('invalid request'); + } } } diff --git a/src/users/dtos/set-nickname.dto.ts b/src/users/dtos/set-nickname.dto.ts new file mode 100644 index 0000000..413268f --- /dev/null +++ b/src/users/dtos/set-nickname.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class ChangeNicknameDTO { + @ApiProperty({ description: '번경할 닉네임', default: '츄츄' }) + @IsString() + nickname: string; +} diff --git a/src/users/dtos/user.dto.ts b/src/users/dtos/user.dto.ts index 04f786d..873a3d6 100644 --- a/src/users/dtos/user.dto.ts +++ b/src/users/dtos/user.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEmail, IsNumber } from 'class-validator'; +import { User } from '../entities/user.entity'; export class UserDto { @ApiProperty({ description: '회원 아이디' }) @@ -18,3 +19,22 @@ export class UserDto { }) status: string; } + +export class UserResponse { + @ApiProperty({ description: '회원 아이디' }) + @IsNumber() + id: number; + + @ApiProperty({ description: '회원 이메일' }) + @IsEmail() + email: string; + + @ApiProperty({ description: '회원 닉네임' }) + nickname: string; + + constructor(user: User) { + this.id = user.id; + this.email = user.email; + this.nickname = user.nickname; + } +} diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts new file mode 100644 index 0000000..cae62e7 --- /dev/null +++ b/src/users/entities/user.entity.ts @@ -0,0 +1,37 @@ +import { Entity, Column } from 'typeorm'; +import { BaseEntity } from 'src/common/entities/base.entity'; + +enum UserStatus { + ACTIVE = 'active', + SLEEP = 'sleep', + WITHDRAWAL = 'withdrawal', +} + +@Entity({ name: 'user' }) +export class User extends BaseEntity { + @Column({ + name: 'email', + comment: '사용자 이메일', + unique: true, + }) + email: string; + + @Column({ + name: 'nickname', + nullable: true, + comment: '사용자 닉네임', + }) + nickname: string | null; + + @Column({ + name: 'status', + comment: '사용자의 회원 상태', + type: 'enum', + enum: UserStatus, + default: UserStatus.ACTIVE, + }) + status: UserStatus; + + @Column({ name: 'refresh_token', nullable: true, comment: '리프레시 토큰' }) + refresh: string | null; +} diff --git a/src/users/user.controller.ts b/src/users/user.controller.ts index 64ba1bb..6b72271 100644 --- a/src/users/user.controller.ts +++ b/src/users/user.controller.ts @@ -1,18 +1,57 @@ -import { Controller } from '@nestjs/common'; +import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { UserService } from './user.service'; +import { AccessGuard } from 'src/auth/guards/acess.guard'; +import { AuthUser } from 'src/auth/decorators/auth-user.decorator'; +import { Payload } from 'src/auth/dtos/jwt.dto'; +import { + ApiBearerAuth, + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, + getSchemaPath, +} from '@nestjs/swagger'; +import { UserResponse } from './dtos/user.dto'; +import { ChangeNicknameDTO } from './dtos/set-nickname.dto'; @Controller('user') +@ApiBearerAuth() +@ApiExtraModels(UserResponse) +@ApiTags('User API') export class UserController { constructor(private readonly userService: UserService) {} - // @Post() - // create(@Body() createUserDto: CreateUserDto) { - // // 작업들 + @ApiOperation({ + summary: '내정보 조회', + description: '나의 계정정보를 조회한다.', + }) + @ApiOkResponse({ + description: '나의 정보', + schema: { + $ref: getSchemaPath(UserResponse), + }, + }) + @UseGuards(AccessGuard) + @Get('me') + async getMe(@AuthUser() { id }: Payload) { + return await this.userService.getMe(id); + } - // return userdata: CreateUserDto - // } + @ApiOperation({ + summary: '닉네임 변경', + description: '나의 닉네임을 설정/변경한다.', + }) + @ApiOkResponse({ + description: '성공 여부', + type: Boolean, + }) + @ApiBearerAuth() + @UseGuards(AccessGuard) + @Post('nickname') + async changeNickname( + @AuthUser() { id }: Payload, + @Body() changeNicknameDTO: ChangeNicknameDTO, + ) { + return await this.userService.changeNickname(id, changeNicknameDTO); + } } - -// 이메일 ,닉네임 - -// 닉네임 updateUserDto diff --git a/src/users/user.module.ts b/src/users/user.module.ts index e680967..07ac7ce 100644 --- a/src/users/user.module.ts +++ b/src/users/user.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { UserController } from './user.controller'; import { UserService } from './user.service'; +import { UserRepository } from './user.repository'; @Module({ imports: [], controllers: [UserController], - providers: [UserService], + providers: [UserService, UserRepository], exports: [UserService], }) export class UserModule {} diff --git a/src/users/user.repository.ts b/src/users/user.repository.ts index e69de29..a7fa8e3 100644 --- a/src/users/user.repository.ts +++ b/src/users/user.repository.ts @@ -0,0 +1,13 @@ +import { DataSource, DeepPartial, Repository } from 'typeorm'; +import { User } from './entities/user.entity'; +import { InjectDataSource } from '@nestjs/typeorm'; + +export class UserRepository extends Repository { + constructor(@InjectDataSource() private readonly dataSource: DataSource) { + super(User, dataSource.manager); + } + + async updateAndReturning(userId: number, userLike: DeepPartial) { + return await this.update(userId, userLike); + } +} diff --git a/src/users/user.service.ts b/src/users/user.service.ts index 6682464..051d52e 100644 --- a/src/users/user.service.ts +++ b/src/users/user.service.ts @@ -1,16 +1,22 @@ +import { UserRepository } from './user.repository'; +import { UserResponse } from './dtos/user.dto'; import { Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; -import { User } from 'src/entities/user.entity'; +import { User } from 'src/users/entities/user.entity'; import { DataSource } from 'typeorm'; import { PostUserRequestDto, PostUserResponseDto, } from './dtos/create-users.dto'; import { createResponse } from 'src/utils/response.utils'; +import { ChangeNicknameDTO } from './dtos/set-nickname.dto'; @Injectable() export class UserService { - constructor(@InjectDataSource() private readonly connection: DataSource) {} + constructor( + @InjectDataSource() private readonly connection: DataSource, + private readonly userRepository: UserRepository, + ) {} async createUser(postUserRequestDto: PostUserRequestDto) { const { email } = postUserRequestDto; @@ -25,6 +31,22 @@ export class UserService { return createResponse(new PostUserResponseDto(result.generatedMaps[0].id)); } + /** + * 사용자를 생성하고 반환한다 + * + * @issue SNP-64 + * @author raymondanything + * @param {PostUserRequestDto} postUserRequestDto + * @returns {Promise} user + */ + async create(postUserRequestDto: PostUserRequestDto): Promise { + const { email } = postUserRequestDto; + const repository = this.connection.getRepository(User); + const result = await repository.save(repository.create({ email })); + + return result; + } + async findByUserEmail(email: string) { const result = await this.connection .createQueryBuilder(User, 'users') @@ -33,4 +55,41 @@ export class UserService { return result; } + + /** + * 내정보를 조회한다 + * + * @issue SNP-64 + * @author raymondanything + * @param {number} id + * @returns {Promise} UserResponse + */ + async getMe(id: number): Promise { + const user = await this.connection + .getRepository(User) + .findOne({ where: { id } }); + return new UserResponse(user); + } + + /** + * 닉네임을 설정 / 변경한다. + * + * @issue SNP-64 + * @link https://www.notion.so/raymondanything/SNP-64-Google-b3c69d93313d47fba51201412b70c635?pvs=4 + * @author raymondanything + * @param userId + * @param changeNicknameDTO + * @returns {Promise} + */ + async changeNickname( + userId: number, + changeNicknameDTO: ChangeNicknameDTO, + ): Promise { + try { + await this.userRepository.updateAndReturning(userId, changeNicknameDTO); + return true; + } catch (error) { + return false; + } + } } diff --git a/tsconfig.json b/tsconfig.json index 95f5641..28ed2de 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "module": "commonjs", + "esModuleInterop": true, "declaration": true, "removeComments": true, "emitDecoratorMetadata": true,