├── .adminjs └── .entry.js ├── .github ├── pull_request_template.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── auto_author_assign.yml │ ├── auto-label.yml │ ├── dependabot-reviewer.yml │ ├── test.yml │ └── deploy.yml ├── .dockerignore ├── libs ├── common │ ├── src │ │ ├── types │ │ │ ├── plain.ts │ │ │ └── omit-by-type.ts │ │ ├── cache │ │ │ ├── dto │ │ │ │ ├── ft-checkin.constant.ts │ │ │ │ ├── ft-checkin.dto.ts │ │ │ │ └── intra-auth.dto.ts │ │ │ ├── cache.module.ts │ │ │ └── cache.service.ts │ │ ├── database │ │ │ ├── seeder │ │ │ │ ├── user │ │ │ │ │ ├── user-seeder.module.ts │ │ │ │ │ ├── user-seeder.service.ts │ │ │ │ │ └── data.ts │ │ │ │ ├── category │ │ │ │ │ ├── category-seeder.module.ts │ │ │ │ │ ├── category-seeder.service.ts │ │ │ │ │ └── data.ts │ │ │ │ ├── intra-auth │ │ │ │ │ ├── intra-auth-seeder.module.ts │ │ │ │ │ ├── data.ts │ │ │ │ │ └── intra-auth-seeder.service.ts │ │ │ │ ├── article │ │ │ │ │ ├── article-seeder.service.ts │ │ │ │ │ ├── article-seeder.module.ts │ │ │ │ │ └── data.ts │ │ │ │ ├── seeder.module.ts │ │ │ │ └── seeder.ts │ │ │ ├── migrations │ │ │ │ ├── 1644422087542-add_notification_articleid.ts │ │ │ │ ├── 1645622620898-intra-auth.ts │ │ │ │ ├── 1644473307391-add_category_roles.ts │ │ │ │ └── 1658252663130-add-guest.ts │ │ │ ├── seed.ts │ │ │ └── database.module.ts │ │ ├── interceptor │ │ │ └── sentry.interceptor.ts │ │ └── filters │ │ │ └── http-exception.filter.ts │ └── tsconfig.lib.json ├── entity │ ├── src │ │ ├── notification │ │ │ ├── interfaces │ │ │ │ └── notifiaction.interface.ts │ │ │ └── notification.entity.ts │ │ ├── user │ │ │ └── interfaces │ │ │ │ └── userrole.interface.ts │ │ ├── best │ │ │ └── best.entity.ts │ │ ├── intra-auth │ │ │ └── intra-auth.entity.ts │ │ ├── reaction │ │ │ ├── reaction-article.entity.ts │ │ │ └── reaction-comment.entity.ts │ │ ├── category │ │ │ └── category.entity.ts │ │ └── comment │ │ │ └── comment.entity.ts │ └── tsconfig.lib.json └── utils │ ├── tsconfig.lib.json │ └── src │ ├── phase.ts │ ├── utils.spec.ts │ ├── logger.ts │ └── utils.ts ├── apps ├── api │ ├── src │ │ ├── article │ │ │ ├── dto │ │ │ │ ├── response │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── article-response.dto.ts │ │ │ │ │ └── __test__ │ │ │ │ │ │ └── article-response.dto.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── request │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── find-all-article-request.dto.ts │ │ │ │ │ ├── search-article-request.dto.ts │ │ │ │ │ ├── create-article-request.dto.ts │ │ │ │ │ ├── __test__ │ │ │ │ │ │ ├── create-article-request.dto.test.ts │ │ │ │ │ │ ├── update-article-request.dto.test.ts │ │ │ │ │ │ ├── find-all-aritcle-request.dto.test.ts │ │ │ │ │ │ └── search-article-request.dto.test.ts │ │ │ │ │ └── update-article-request.dto.ts │ │ │ │ └── article.mapper.ts │ │ │ ├── service │ │ │ │ ├── index.ts │ │ │ │ ├── remove-article-api.service.ts │ │ │ │ ├── create-article-api.service.ts │ │ │ │ ├── update-article-api.service.ts │ │ │ │ ├── search-article-api.service.ts │ │ │ │ └── find-article-api.service.ts │ │ │ ├── article.module.ts │ │ │ └── article-api.module.ts │ │ ├── pagination │ │ │ ├── interface │ │ │ │ └── pagination.enum.ts │ │ │ ├── dto │ │ │ │ ├── pagination-response.dto.ts │ │ │ │ ├── pagination-request.dto.ts │ │ │ │ └── page-meta.dto.ts │ │ │ └── pagination.decorator.ts │ │ ├── auth │ │ │ ├── types │ │ │ │ ├── github-profile.interface.ts │ │ │ │ ├── jwt-payload.interface.ts │ │ │ │ ├── index.ts │ │ │ │ └── auth.type.ts │ │ │ ├── constant.ts │ │ │ ├── github-auth │ │ │ │ ├── github-auth.module.ts │ │ │ │ ├── github-auth.guard.ts │ │ │ │ ├── __test__ │ │ │ │ │ ├── github-auth.module.test.ts │ │ │ │ │ ├── github-auth.strategy.test.ts │ │ │ │ │ └── github-auth.guard.test.ts │ │ │ │ └── github-auth.strategy.ts │ │ │ ├── jwt-auth │ │ │ │ ├── jwt-auth.module.ts │ │ │ │ ├── __test__ │ │ │ │ │ ├── jwt-auth.module.test.ts │ │ │ │ │ └── jwt-auth.strategy.test.ts │ │ │ │ ├── jwt-auth.strategy.ts │ │ │ │ └── jwt-auth.guard.ts │ │ │ ├── __test__ │ │ │ │ ├── auth.module.test.ts │ │ │ │ ├── getParamDecoratorFactory.ts │ │ │ │ └── auth.controller.test.ts │ │ │ ├── auth.module.ts │ │ │ ├── auth.decorator.ts │ │ │ ├── auth.service.ts │ │ │ └── auth.controller.ts │ │ ├── intra-auth │ │ │ ├── mail.service.ts │ │ │ ├── unsubscribe-stibee.service.ts │ │ │ ├── intra-auth.constant.ts │ │ │ ├── intra-auth.utils.ts │ │ │ ├── dto │ │ │ │ └── signin-intra-auth-request.dto.ts │ │ │ ├── intra-auth.module.ts │ │ │ ├── intra-auth.controller.ts │ │ │ └── stibee.service.ts │ │ ├── image │ │ │ ├── interfaces │ │ │ │ └── s3-param.interface.ts │ │ │ ├── image.constant.ts │ │ │ ├── dto │ │ │ │ └── upload-image-url-response.dto.ts │ │ │ ├── image.module.ts │ │ │ ├── image.service.ts │ │ │ └── image.controller.ts │ │ ├── best │ │ │ ├── dto │ │ │ │ ├── request │ │ │ │ │ ├── find-all-best-request.dto.ts │ │ │ │ │ └── create-best-request.dto.ts │ │ │ │ └── base-best.dto.ts │ │ │ ├── best.module.ts │ │ │ ├── best.service.ts │ │ │ └── best.controller.ts │ │ ├── category │ │ │ ├── dto │ │ │ │ ├── request │ │ │ │ │ └── create-category-request.dto.ts │ │ │ │ ├── base-category.dto.ts │ │ │ │ └── response │ │ │ │ │ └── category-response.dto.ts │ │ │ ├── category.module.ts │ │ │ └── repositories │ │ │ │ └── category.repository.ts │ │ ├── comment │ │ │ ├── dto │ │ │ │ ├── request │ │ │ │ │ ├── update-comment-request.dto.ts │ │ │ │ │ └── create-comment-request.dto.ts │ │ │ │ ├── base-comment.dto.ts │ │ │ │ └── response │ │ │ │ │ └── my-comment-response.dto.ts │ │ │ ├── comment.module.ts │ │ │ ├── services │ │ │ │ ├── update-comment-api.service.ts │ │ │ │ ├── remove-comment-api.service.ts │ │ │ │ ├── create-comment-api.service.ts │ │ │ │ ├── get-comment-api.service.ts │ │ │ │ └── comment.service.ts │ │ │ ├── comment-api.module.ts │ │ │ ├── get-comment-api.controller.ts │ │ │ └── repositories │ │ │ │ └── comment.repository.ts │ │ ├── user │ │ │ ├── user.constant.ts │ │ │ ├── dto │ │ │ │ ├── update-user-to-cadet.dto.ts │ │ │ │ ├── request │ │ │ │ │ └── update-user-profile-request.dto.ts │ │ │ │ ├── user-profile.mapper.ts │ │ │ │ ├── response │ │ │ │ │ ├── anony-user-response.dto.ts │ │ │ │ │ ├── user-profile-response.dto.ts │ │ │ │ │ ├── base-user-response.dto.ts │ │ │ │ │ └── user-response.dto.ts │ │ │ │ └── base-user.dto.ts │ │ │ ├── repositories │ │ │ │ └── user.repository.ts │ │ │ ├── user.module.ts │ │ │ └── user.service.ts │ │ ├── ft-checkin │ │ │ ├── ft-checkin.module.ts │ │ │ ├── ft-checkin.service.ts │ │ │ └── ft-checkin.controller.ts │ │ ├── notification │ │ │ ├── notification.module.ts │ │ │ ├── dto │ │ │ │ ├── base-notification.dto.ts │ │ │ │ ├── create-notification.dto.ts │ │ │ │ └── response │ │ │ │ │ └── notification-response.dto.ts │ │ │ ├── notification.controller.ts │ │ │ └── notification.service.ts │ │ ├── app.controller.ts │ │ ├── reaction │ │ │ ├── dto │ │ │ │ └── response │ │ │ │ │ └── reaction-response.dto.ts │ │ │ ├── reaction.module.ts │ │ │ ├── repositories │ │ │ │ └── reaction-article.repository.ts │ │ │ └── reaction.controller.ts │ │ ├── getEnvFromSecretManager.ts │ │ └── app.module.ts │ ├── tsconfig.app.json │ ├── test │ │ └── e2e │ │ │ ├── utils │ │ │ ├── validate-test.ts │ │ │ └── utils.ts │ │ │ ├── jest-e2e.json │ │ │ ├── e2e-test.base.module.ts │ │ │ └── image.e2e-spec.ts │ └── views │ │ └── intra-auth │ │ ├── results.ejs │ │ └── signin.ejs ├── batch │ ├── src │ │ ├── batch.controller.ts │ │ ├── ft-checkin │ │ │ ├── ft-checkin.module.ts │ │ │ ├── ft-checkin.constant.ts │ │ │ └── ft-checkin.service.ts │ │ ├── batch.module.ts │ │ └── main.ts │ ├── tsconfig.app.json │ └── test │ │ ├── jest-e2e.json │ │ └── app.e2e-spec.ts └── admin │ ├── src │ ├── entity │ │ ├── notification │ │ │ ├── interfaces │ │ │ │ └── notifiaction.interface.ts │ │ │ └── notification.entity.ts │ │ ├── user │ │ │ └── interfaces │ │ │ │ └── userrole.interface.ts │ │ ├── best │ │ │ └── best.entity.ts │ │ ├── intra-auth │ │ │ └── intra-auth.entity.ts │ │ ├── reaction │ │ │ ├── reaction-article.entity.ts │ │ │ └── reaction-comment.entity.ts │ │ ├── category │ │ │ └── category.entity.ts │ │ └── comment │ │ │ └── comment.entity.ts │ ├── main.ts │ ├── admin.module.ts │ └── adminJs │ │ └── admin-js.module.ts │ └── tsconfig.app.json ├── .prettierrc ├── tsconfig.build.json ├── infra ├── api.Dockerfile ├── Dockerrun.aws.json ├── admin.Dockerfile ├── batch.Dockerfile ├── wait-for-healthy.sh ├── run_test_db.sh └── docker-compose.yml ├── .envrc.sample ├── .gitignore ├── jest.config.json ├── .eslintrc.ts ├── tsconfig.json └── nest-cli.json /.adminjs/.entry.js: -------------------------------------------------------------------------------- 1 | AdminJS.UserComponents = {} 2 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 바뀐점 2 | 3 | ## 바꾼이유 4 | 5 | ## 설명 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | Dockerfile 3 | docker-compose.yml 4 | .git 5 | .idea -------------------------------------------------------------------------------- /libs/common/src/types/plain.ts: -------------------------------------------------------------------------------- 1 | export type Plain = Record; 2 | -------------------------------------------------------------------------------- /apps/api/src/article/dto/response/index.ts: -------------------------------------------------------------------------------- 1 | export * from './article-response.dto'; 2 | -------------------------------------------------------------------------------- /apps/api/src/pagination/interface/pagination.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Order { 2 | ASC = 'ASC', 3 | DESC = 'DESC', 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 120, 5 | "endOfLine": "auto" 6 | } -------------------------------------------------------------------------------- /apps/api/src/article/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './article.mapper'; 2 | export * from './request'; 3 | export * from './response'; 4 | -------------------------------------------------------------------------------- /apps/api/src/auth/types/github-profile.interface.ts: -------------------------------------------------------------------------------- 1 | export interface GithubProfile { 2 | id: string; 3 | username: string; 4 | } 5 | -------------------------------------------------------------------------------- /apps/api/src/auth/types/jwt-payload.interface.ts: -------------------------------------------------------------------------------- 1 | export interface JWTPayload { 2 | userId: number; 3 | userRole: string; 4 | } 5 | -------------------------------------------------------------------------------- /libs/common/src/types/omit-by-type.ts: -------------------------------------------------------------------------------- 1 | export type OmitByType = Pick; 2 | -------------------------------------------------------------------------------- /apps/batch/src/batch.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | @Controller() 4 | export class BatchController {} 5 | -------------------------------------------------------------------------------- /apps/api/src/intra-auth/mail.service.ts: -------------------------------------------------------------------------------- 1 | export default interface MailService { 2 | send(name: string, code: string, githubId: string); 3 | } 4 | -------------------------------------------------------------------------------- /apps/api/src/intra-auth/unsubscribe-stibee.service.ts: -------------------------------------------------------------------------------- 1 | export default interface UnsubscribeStibeeService { 2 | unsubscribe(name: string); 3 | } 4 | -------------------------------------------------------------------------------- /apps/api/src/auth/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.type'; 2 | export * from './github-profile.interface'; 3 | export * from './jwt-payload.interface'; 4 | -------------------------------------------------------------------------------- /libs/common/src/cache/dto/ft-checkin.constant.ts: -------------------------------------------------------------------------------- 1 | export const FT_CHECKIN_KEY = 'FT_CHECKIN_API'; 2 | export const MAX_CHECKIN_KEY = 'MAX_CHECKIN_API'; 3 | -------------------------------------------------------------------------------- /apps/api/src/auth/constant.ts: -------------------------------------------------------------------------------- 1 | export const FORBIDDEN_USER_ROLE = '접근 권한 없음'; 2 | 3 | // metadata keys 4 | export const REQUIRE_ROLES = Symbol('RequireRoles'); 5 | -------------------------------------------------------------------------------- /libs/entity/src/notification/interfaces/notifiaction.interface.ts: -------------------------------------------------------------------------------- 1 | export enum NotificationType { 2 | NEW_COMMENT = 'NEW_COMMENT', 3 | FROM_ADMIN = 'FROM_ADMIN', 4 | } 5 | -------------------------------------------------------------------------------- /apps/admin/src/entity/notification/interfaces/notifiaction.interface.ts: -------------------------------------------------------------------------------- 1 | export enum NotificationType { 2 | NEW_COMMENT = 'NEW_COMMENT', 3 | FROM_ADMIN = 'FROM_ADMIN', 4 | } 5 | -------------------------------------------------------------------------------- /libs/entity/src/user/interfaces/userrole.interface.ts: -------------------------------------------------------------------------------- 1 | export enum UserRole { 2 | CADET = 'CADET', 3 | ADMIN = 'ADMIN', 4 | NOVICE = 'NOVICE', 5 | GUEST = 'GUEST', 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "**/test/e2e/*", "dist", "**/*spec.ts", "**/*test.ts", "**/__test__/**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /apps/admin/src/entity/user/interfaces/userrole.interface.ts: -------------------------------------------------------------------------------- 1 | export enum UserRole { 2 | CADET = 'CADET', 3 | ADMIN = 'ADMIN', 4 | NOVICE = 'NOVICE', 5 | GUEST = 'GUEST', 6 | } 7 | -------------------------------------------------------------------------------- /apps/api/src/image/interfaces/s3-param.interface.ts: -------------------------------------------------------------------------------- 1 | export interface S3Param { 2 | Bucket: string; 3 | Key: string; 4 | Expires: number; 5 | ContentType: string; 6 | ACL: string; 7 | } 8 | -------------------------------------------------------------------------------- /apps/api/src/best/dto/request/find-all-best-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { PaginationRequestDto } from '@api/pagination/dto/pagination-request.dto'; 2 | 3 | export class FindAllBestRequestDto extends PaginationRequestDto {} 4 | -------------------------------------------------------------------------------- /apps/api/src/auth/types/auth.type.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 2 | 3 | export type AuthType = 'allow' | 'deny'; 4 | 5 | export type AuthDecoratorParam = [AuthType, ...UserRole[]]; 6 | -------------------------------------------------------------------------------- /apps/api/src/image/image.constant.ts: -------------------------------------------------------------------------------- 1 | export const AWS_REGION = 'AWS_REGION'; 2 | export const AWS_ACCESS_KEY = 'AWS_ACCESS_KEY'; 3 | export const AWS_SECRET_KEY = 'AWS_SECRET_KEY'; 4 | export const S3_URL_EXPIRATION_SECONDS = 300; 5 | -------------------------------------------------------------------------------- /apps/admin/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false 5 | }, 6 | "include": ["src/**/*"], 7 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/api/src/article/dto/request/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-article-request.dto'; 2 | export * from './find-all-article-request.dto'; 3 | export * from './search-article-request.dto'; 4 | export * from './update-article-request.dto'; 5 | -------------------------------------------------------------------------------- /apps/batch/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false 5 | }, 6 | "include": ["src/**/*"], 7 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/api/src/best/dto/request/create-best-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { PickType } from '@nestjs/swagger'; 2 | import { BaseBestDto } from '../base-best.dto'; 3 | 4 | export class CreateBestRequestDto extends PickType(BaseBestDto, ['articleId']) {} 5 | -------------------------------------------------------------------------------- /apps/batch/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[Bug]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 오류 사진 및 설명 11 | 12 | 13 | ## 발생 위치 14 | 15 | 16 | ## 해결시도 17 | -------------------------------------------------------------------------------- /apps/api/src/category/dto/request/create-category-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { PickType } from '@nestjs/swagger'; 2 | import { BaseCategoryDto } from '../base-category.dto'; 3 | 4 | export class CreateCategoryRequestDto extends PickType(BaseCategoryDto, ['name']) {} 5 | -------------------------------------------------------------------------------- /libs/common/src/cache/dto/ft-checkin.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class FtCheckinDto { 4 | @ApiProperty({ example: 42 }) 5 | gaepo: number; 6 | 7 | @ApiProperty({ example: 0 }) 8 | seocho: number; 9 | } 10 | -------------------------------------------------------------------------------- /infra/api.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine3.14 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | RUN yarn install --forzon-lockfile 8 | RUN yarn build api 9 | RUN yarn install --production --forzon-lockfile 10 | 11 | ENTRYPOINT ["node", "dist/apps/api/src/main.js"] -------------------------------------------------------------------------------- /infra/Dockerrun.aws.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSEBDockerrunVersion": "1", 3 | "Image": { 4 | "Name": "registry.hub.docker.com/42world/backend-api:latest", 5 | "Update": "true" 6 | }, 7 | "Ports": [ 8 | { 9 | "ContainerPort": 8888 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /apps/api/src/article/service/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-article-api.service'; 2 | export * from './find-article-api.service'; 3 | export * from './remove-article-api.service'; 4 | export * from './search-article-api.service'; 5 | export * from './update-article-api.service'; 6 | -------------------------------------------------------------------------------- /libs/common/src/cache/dto/intra-auth.dto.ts: -------------------------------------------------------------------------------- 1 | export class IntraAuthMailDto { 2 | readonly userId: number; 3 | readonly intraId: string; 4 | 5 | constructor(userId: number, intraId: string) { 6 | this.userId = userId; 7 | this.intraId = intraId; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 작업할 내용 11 | 12 | ## 해야할 일 13 | - [ ] todo1 14 | - [ ] todo2 15 | - [ ] todo3 16 | -------------------------------------------------------------------------------- /libs/utils/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/utils" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /libs/common/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/common" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /libs/entity/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/entity" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/api/src/image/dto/upload-image-url-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class UploadImageUrlResponseDto { 4 | @ApiProperty() 5 | uploadUrl!: string; 6 | 7 | constructor(uploadUrl: string) { 8 | this.uploadUrl = uploadUrl; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /apps/api/src/intra-auth/intra-auth.constant.ts: -------------------------------------------------------------------------------- 1 | export const SIGNIN_ALREADY_AUTH_ERROR_MESSAGE = '이미 인증된 사용자입니다.'; 2 | export const CADET_ALREADY_EXIST_ERROR_MESSAGE = '이미 가입된 카뎃입니다.'; 3 | export const NOT_EXIST_TOKEN_ERROR_MESSAGE = '존재하지 않는 토큰입니다.'; 4 | 5 | export const EMAIL = 'student.42seoul.kr'; 6 | -------------------------------------------------------------------------------- /apps/api/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false 5 | }, 6 | "include": [ 7 | "src/**/*" 8 | ], 9 | "exclude": [ 10 | "node_modules", 11 | "dist", 12 | "test", 13 | "**/*spec.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/auto_author_assign.yml: -------------------------------------------------------------------------------- 1 | name: 'Auto Author Assign' 2 | 3 | on: 4 | pull_request_target: 5 | types: [opened, reopened] 6 | 7 | permissions: 8 | pull-requests: write 9 | 10 | jobs: 11 | assign-author: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: toshimaru/auto-author-assign@v1.4.0 15 | -------------------------------------------------------------------------------- /.github/workflows/auto-label.yml: -------------------------------------------------------------------------------- 1 | name: 'Auto Label' 2 | 3 | on: 4 | pull_request: 5 | types: [labeled, unlabeled, opened, synchronize, reopened] 6 | 7 | permissions: 8 | pull-requests: write 9 | 10 | jobs: 11 | auto-label: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: Yaminyam/auto-label-in-issue@1.1.0 15 | -------------------------------------------------------------------------------- /apps/batch/src/ft-checkin/ft-checkin.module.ts: -------------------------------------------------------------------------------- 1 | import { CacheModule } from '@app/common/cache/cache.module'; 2 | import { Module } from '@nestjs/common'; 3 | import { FtCheckinService } from './ft-checkin.service'; 4 | 5 | @Module({ 6 | imports: [CacheModule], 7 | providers: [FtCheckinService], 8 | }) 9 | export class FtCheckinModule {} 10 | -------------------------------------------------------------------------------- /apps/api/src/auth/github-auth/github-auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { GithubAuthStrategy } from './github-auth.strategy'; 4 | 5 | @Module({ 6 | imports: [PassportModule], 7 | providers: [GithubAuthStrategy], 8 | }) 9 | export class GithubAuthModule {} 10 | -------------------------------------------------------------------------------- /apps/api/src/comment/dto/request/update-comment-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { IsOptional, MaxLength } from 'class-validator'; 3 | 4 | export class UpdateCommentRequestDto { 5 | @IsOptional() 6 | @MaxLength(420) 7 | @ApiPropertyOptional({ example: '수정된 내용 입니다.' }) 8 | readonly content?: string; 9 | } 10 | -------------------------------------------------------------------------------- /apps/api/src/intra-auth/intra-auth.utils.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | 3 | export const getNickname = (email: string) => email.split('@')[0]; 4 | 5 | export const getCode = async (nickname: string) => { 6 | const code = crypto 7 | .createHash('sha1') 8 | .update(nickname + new Date()) 9 | .digest('hex'); 10 | return code; 11 | }; 12 | -------------------------------------------------------------------------------- /apps/api/src/user/user.constant.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '../../../../libs/entity/src/user/interfaces/userrole.interface'; 2 | 3 | export const ANONY_USER_NICKNAME = '익명'; 4 | export const ANONY_USER_CHARACTER = 0; 5 | export const ANONY_USER_ID = -1; 6 | export const ANONY_USER_ROLE = UserRole.GUEST; 7 | export const ANONY_USER_DATE = new Date('2020-11-11T01:23:45'); 8 | -------------------------------------------------------------------------------- /apps/api/src/user/dto/update-user-to-cadet.dto.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 2 | import { IsNotEmpty, IsString } from 'class-validator'; 3 | 4 | export class UpdateToCadetDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | readonly role: UserRole; 8 | 9 | @IsString() 10 | @IsNotEmpty() 11 | readonly nickname: string; 12 | } 13 | -------------------------------------------------------------------------------- /libs/utils/src/phase.ts: -------------------------------------------------------------------------------- 1 | export type Phase = 'dev' | 'alpha' | 'staging' | 'prod' | 'test'; 2 | 3 | export const PHASE: Phase = 4 | process.env.PHASE === 'prod' // 5 | ? 'prod' 6 | : process.env.PHASE === 'staging' 7 | ? 'staging' 8 | : process.env.PHASE === 'alpha' 9 | ? 'alpha' 10 | : process.env.PHASE === 'test' 11 | ? 'test' 12 | : 'dev'; 13 | -------------------------------------------------------------------------------- /apps/api/src/auth/jwt-auth/jwt-auth.module.ts: -------------------------------------------------------------------------------- 1 | import { UserModule } from '@api/user/user.module'; 2 | import { Module } from '@nestjs/common'; 3 | import { PassportModule } from '@nestjs/passport'; 4 | import { JwtAuthStrategy } from './jwt-auth.strategy'; 5 | 6 | @Module({ 7 | imports: [UserModule, PassportModule], 8 | providers: [JwtAuthStrategy], 9 | }) 10 | export class JwtAuthModule {} 11 | -------------------------------------------------------------------------------- /apps/api/src/article/article.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { ArticleService } from './article.service'; 4 | import { ArticleRepository } from './repository/article.repository'; 5 | 6 | @Module({ 7 | imports: [], 8 | providers: [ArticleService, ArticleRepository], 9 | exports: [ArticleService], 10 | }) 11 | export class ArticleModule {} 12 | -------------------------------------------------------------------------------- /infra/admin.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine3.14 2 | RUN apk add --no-cache 3 | 4 | RUN mkdir /home/ft-world 5 | 6 | WORKDIR /home/ft-world 7 | 8 | COPY nest-cli.json . 9 | COPY tsconfig.build.json . 10 | COPY tsconfig.json . 11 | COPY package.json . 12 | COPY yarn.lock . 13 | 14 | RUN yarn install 15 | 16 | COPY libs libs 17 | COPY apps apps 18 | 19 | RUN yarn build admin 20 | 21 | ENTRYPOINT ["node", "dist/apps/admin/src/main.js"] -------------------------------------------------------------------------------- /infra/batch.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine3.14 2 | RUN apk add --no-cache 3 | 4 | RUN mkdir /home/ft-world 5 | 6 | WORKDIR /home/ft-world 7 | 8 | COPY nest-cli.json . 9 | COPY tsconfig.build.json . 10 | COPY tsconfig.json . 11 | COPY package.json . 12 | COPY yarn.lock . 13 | 14 | RUN yarn install 15 | 16 | COPY libs libs 17 | COPY apps apps 18 | 19 | RUN yarn build batch 20 | 21 | ENTRYPOINT ["node", "dist/apps/batch/src/main.js"] -------------------------------------------------------------------------------- /apps/api/src/intra-auth/dto/signin-intra-auth-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Transform } from 'class-transformer'; 3 | import { IsNotEmpty, IsString } from 'class-validator'; 4 | 5 | export class SigninIntraAuthRequestDto { 6 | @IsString() 7 | @IsNotEmpty() 8 | @Transform(({ value }) => value.toLowerCase()) 9 | @ApiProperty({ example: 'ycha' }) 10 | readonly intraId!: string; 11 | } 12 | -------------------------------------------------------------------------------- /apps/api/test/e2e/utils/validate-test.ts: -------------------------------------------------------------------------------- 1 | export function testDto(testDtos: Array<[keyof T, any, string?]>) { 2 | const tests: Array<[string, (normalDto: T) => Partial]> = testDtos.map(([key, value, detail]) => { 3 | const buildDto = (dto: T) => ({ 4 | ...dto, 5 | [key]: value, 6 | }); 7 | 8 | return [`${String(key)} 값이 ${detail ?? value}`, buildDto]; 9 | }); 10 | 11 | return test.each(tests); 12 | } 13 | -------------------------------------------------------------------------------- /apps/api/src/auth/__test__/auth.module.test.ts: -------------------------------------------------------------------------------- 1 | import { AuthModule } from '../auth.module'; 2 | 3 | describe('AuthModule', () => { 4 | test('모듈이 잘 컴파일된다.', async () => { 5 | // TODO: Test.createTestingModule 로 complie 할것 6 | // const module = await Test.createTestingModule({ 7 | // imports: [AuthModule], 8 | // }).compile(); 9 | const module = new AuthModule(); 10 | 11 | expect(module).toBeDefined(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /apps/api/src/comment/comment.module.ts: -------------------------------------------------------------------------------- 1 | import { CommentRepository } from '@api/comment/repositories/comment.repository'; 2 | import { Module } from '@nestjs/common'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { CommentService } from './services/comment.service'; 5 | 6 | @Module({ 7 | imports: [], 8 | providers: [CommentService, CommentRepository], 9 | exports: [CommentService], 10 | }) 11 | export class CommentModule {} 12 | -------------------------------------------------------------------------------- /apps/api/src/ft-checkin/ft-checkin.module.ts: -------------------------------------------------------------------------------- 1 | import { CacheModule } from '@app/common/cache/cache.module'; 2 | import { Module } from '@nestjs/common'; 3 | import { FtCheckinController } from './ft-checkin.controller'; 4 | import { FtCheckinService } from './ft-checkin.service'; 5 | 6 | @Module({ 7 | imports: [CacheModule], 8 | controllers: [FtCheckinController], 9 | providers: [FtCheckinService], 10 | }) 11 | export class FtCheckinModule {} 12 | -------------------------------------------------------------------------------- /libs/common/src/database/seeder/user/user-seeder.module.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@app/entity/user/user.entity'; 2 | import { Module } from '@nestjs/common'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { UserSeederService } from './user-seeder.service'; 5 | 6 | @Module({ 7 | imports: [TypeOrmModule.forFeature([User])], 8 | providers: [UserSeederService], 9 | exports: [UserSeederService], 10 | }) 11 | export class UserSeederModule {} 12 | -------------------------------------------------------------------------------- /apps/api/src/auth/jwt-auth/__test__/jwt-auth.module.test.ts: -------------------------------------------------------------------------------- 1 | import { JwtAuthModule } from '../jwt-auth.module'; 2 | 3 | describe('JwtAuthGuard', () => { 4 | test('모듈이 잘 컴파일된다.', async () => { 5 | // TODO: Test.createTestingModule 로 complie 할것 6 | // const module = await Test.createTestingModule({ 7 | // imports: [JwtAuthModule], 8 | // }).compile(); 9 | const module = new JwtAuthModule(); 10 | 11 | expect(module).toBeDefined(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /apps/api/src/image/image.module.ts: -------------------------------------------------------------------------------- 1 | import { ImageController } from '@api/image/image.controller'; 2 | import { ImageService } from '@api/image/image.service'; 3 | import { Module } from '@nestjs/common'; 4 | import { S3 } from 'aws-sdk'; 5 | import { AwsSdkModule } from 'nest-aws-sdk'; 6 | 7 | @Module({ 8 | imports: [AwsSdkModule.forFeatures([S3])], 9 | controllers: [ImageController], 10 | providers: [ImageService], 11 | exports: [], 12 | }) 13 | export class ImageModule {} 14 | -------------------------------------------------------------------------------- /apps/api/src/category/category.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CategoryController } from './category.controller'; 3 | import { CategoryService } from './category.service'; 4 | import { CategoryRepository } from './repositories/category.repository'; 5 | 6 | @Module({ 7 | imports: [], 8 | controllers: [CategoryController], 9 | providers: [CategoryService, CategoryRepository], 10 | exports: [CategoryService], 11 | }) 12 | export class CategoryModule {} 13 | -------------------------------------------------------------------------------- /apps/api/src/comment/dto/request/create-comment-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsInt, IsNotEmpty, IsString, MaxLength, Min } from 'class-validator'; 3 | 4 | export class CreateCommentRequestDto { 5 | @IsString() 6 | @MaxLength(420) 7 | @IsNotEmpty() 8 | @ApiProperty({ example: '댓글 입니다.' }) 9 | content!: string; 10 | 11 | @IsInt() 12 | @Min(0) 13 | @IsNotEmpty() 14 | @ApiProperty({ example: 1 }) 15 | articleId!: number; 16 | } 17 | -------------------------------------------------------------------------------- /apps/api/src/best/dto/base-best.dto.ts: -------------------------------------------------------------------------------- 1 | import { ArticleResponseDto } from '@api/article/dto/response/article-response.dto'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsInt, Min } from 'class-validator'; 4 | 5 | export class BaseBestDto { 6 | @ApiProperty() 7 | id!: number; 8 | 9 | @IsInt() 10 | @Min(0) 11 | @ApiProperty({ example: 1 }) 12 | articleId!: number; 13 | 14 | @ApiProperty({ type: () => ArticleResponseDto }) 15 | article?: ArticleResponseDto; 16 | } 17 | -------------------------------------------------------------------------------- /libs/common/src/database/seeder/category/category-seeder.module.ts: -------------------------------------------------------------------------------- 1 | import { Category } from '@app/entity/category/category.entity'; 2 | import { Module } from '@nestjs/common'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { CategorySeederService } from './category-seeder.service'; 5 | 6 | @Module({ 7 | imports: [TypeOrmModule.forFeature([Category])], 8 | providers: [CategorySeederService], 9 | exports: [CategorySeederService], 10 | }) 11 | export class CategorySeederModule {} 12 | -------------------------------------------------------------------------------- /apps/batch/src/ft-checkin/ft-checkin.constant.ts: -------------------------------------------------------------------------------- 1 | import { HOUR, MINUTE } from '@app/utils/utils'; 2 | 3 | export const GAEPO = 'gaepo'; 4 | export const SEOCHO = 'seocho'; 5 | 6 | export const FT_CHECKIN_CACHE_TTL = MINUTE * 30; 7 | export const FT_CHECKIN_BASE_URL = 'https://api.checkin.42seoul.io'; 8 | export const FT_CHECKIN_END_POINT = `${FT_CHECKIN_BASE_URL}/user/using`; 9 | 10 | export const MAX_CHECKIN_CACHE_TTL = HOUR * 48; 11 | export const MAX_CHECKIN_END_POINT = `${FT_CHECKIN_BASE_URL}/config`; 12 | -------------------------------------------------------------------------------- /apps/admin/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { NestExpressApplication } from '@nestjs/platform-express'; 4 | import { AdminModule } from './admin.module'; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AdminModule); 8 | const configService = app.get(ConfigService); 9 | const port = configService.get('ADMIN_PORT'); 10 | await app.listen(port); 11 | } 12 | bootstrap(); 13 | -------------------------------------------------------------------------------- /apps/api/src/article/dto/request/find-all-article-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { PaginationRequestDto } from '@api/pagination/dto/pagination-request.dto'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { Type } from 'class-transformer'; 4 | import { IsInt, IsNotEmpty, Min } from 'class-validator'; 5 | 6 | export class FindAllArticleRequestDto extends PaginationRequestDto { 7 | @Min(0) 8 | @IsInt() 9 | @Type(() => Number) 10 | @IsNotEmpty() 11 | @ApiProperty({ example: 1 }) 12 | categoryId: number; 13 | } 14 | -------------------------------------------------------------------------------- /apps/api/src/user/dto/request/update-user-profile-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional, PartialType, PickType } from '@nestjs/swagger'; 2 | import { IsOptional } from 'class-validator'; 3 | import { BaseUserDto } from '../base-user.dto'; 4 | 5 | export class UpdateUserProfileRequestDto extends PickType(PartialType(BaseUserDto), ['nickname', 'character']) { 6 | @IsOptional() 7 | @ApiPropertyOptional() 8 | nickname?: string; 9 | 10 | @IsOptional() 11 | @ApiPropertyOptional() 12 | character?: number; 13 | } 14 | -------------------------------------------------------------------------------- /apps/api/src/auth/github-auth/github-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class GithubAuthGuard extends AuthGuard('github') { 6 | handleRequest(err: any, user: any, info: any, context: any, status?: any): TUser { 7 | try { 8 | return super.handleRequest(err, user, info, context, status); 9 | } catch (e) { 10 | throw new BadRequestException(e.message); 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/api/src/best/best.module.ts: -------------------------------------------------------------------------------- 1 | import { ArticleModule } from '@api/article/article.module'; 2 | import { Best } from '@app/entity/best/best.entity'; 3 | import { Module } from '@nestjs/common'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { BestController } from './best.controller'; 6 | import { BestService } from './best.service'; 7 | 8 | @Module({ 9 | imports: [ArticleModule, TypeOrmModule.forFeature([Best])], 10 | controllers: [BestController], 11 | providers: [BestService], 12 | }) 13 | export class BestModule {} 14 | -------------------------------------------------------------------------------- /libs/common/src/database/seeder/intra-auth/intra-auth-seeder.module.ts: -------------------------------------------------------------------------------- 1 | import { IntraAuthSeederService } from '@app/common/database/seeder/intra-auth/intra-auth-seeder.service'; 2 | import { IntraAuth } from '@app/entity/intra-auth/intra-auth.entity'; 3 | import { Module } from '@nestjs/common'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | 6 | @Module({ 7 | imports: [TypeOrmModule.forFeature([IntraAuth])], 8 | providers: [IntraAuthSeederService], 9 | exports: [IntraAuthSeederService], 10 | }) 11 | export class IntraAuthSeederModule {} 12 | -------------------------------------------------------------------------------- /libs/common/src/database/seeder/user/user-seeder.service.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@app/entity/user/user.entity'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { Repository } from 'typeorm'; 5 | import { users } from './data'; 6 | 7 | @Injectable() 8 | export class UserSeederService { 9 | constructor( 10 | @InjectRepository(User) 11 | private readonly userRepository: Repository, 12 | ) {} 13 | 14 | create() { 15 | return this.userRepository.save(users); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /infra/wait-for-healthy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | container_name=$1 4 | 5 | is_db_healthy() { 6 | test "$( docker container inspect -f '{{.State.Health.Status}}' $container_name )" == "healthy" 7 | } 8 | 9 | wait_for() 10 | { 11 | if is_db_healthy; then 12 | return 0 13 | fi 14 | while : 15 | do 16 | echo "waiting for healthy db..." 17 | if is_db_healthy; then 18 | sleep 5 19 | echo "waiting done!" 20 | break 21 | fi 22 | sleep 5 23 | done 24 | } 25 | 26 | wait_for -------------------------------------------------------------------------------- /libs/common/src/database/seeder/intra-auth/data.ts: -------------------------------------------------------------------------------- 1 | import { IntraAuth } from '@app/entity/intra-auth/intra-auth.entity'; 2 | import { PartialType } from '@nestjs/mapped-types'; 3 | 4 | export class SeederDataIntraAuth extends PartialType(IntraAuth) { 5 | id: number; 6 | intraId: string; 7 | userId: number; 8 | } 9 | 10 | export const intraAuthData: SeederDataIntraAuth[] = [ 11 | { 12 | id: 1, 13 | intraId: 'cadetUserIntraId', 14 | userId: 2, 15 | }, 16 | { 17 | id: 2, 18 | intraId: 'adminUserIntraId', 19 | userId: 3, 20 | }, 21 | ]; 22 | -------------------------------------------------------------------------------- /.envrc.sample: -------------------------------------------------------------------------------- 1 | export PORT= 2 | 3 | export DB_HOST= 4 | export DB_PORT= 5 | export DB_NAME= 6 | export DB_USER_NAME= 7 | export DB_USER_PASSWORD= 8 | 9 | export REDIS_HOST= 10 | export REDIS_PORT= 11 | 12 | export GITHUB_CLIENT_ID= 13 | export GITHUB_CLIENT_SECRET= 14 | export GITHUB_CALLBACK_URL= 15 | 16 | export STIBEE_API_KEY= 17 | export STIBEE_ENDPOINT= 18 | export STIBEE_SUBSCRIBE_URL= 19 | export STIBEE_MAIL_SEND_URL= 20 | export SLACK_HOOK_URL= 21 | export SENTRY_KEY= 22 | 23 | export FRONT_URL= 24 | export ORIGIN_LIST= 25 | export JWT_SECRET= 26 | export ACCESS_TOKEN_KEY= 27 | -------------------------------------------------------------------------------- /apps/admin/src/admin.module.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseModule } from '@app/common/database/database.module'; 2 | import { Module } from '@nestjs/common'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { AdminJsModule } from './adminJs/admin-js.module'; 5 | 6 | @Module({ 7 | imports: [ 8 | ConfigModule.forRoot({ 9 | envFilePath: 'infra/config/.env', 10 | isGlobal: true, 11 | cache: true, 12 | }), 13 | DatabaseModule.register(), 14 | AdminJsModule.register(), 15 | ], 16 | controllers: [], 17 | providers: [], 18 | }) 19 | export class AdminModule {} 20 | -------------------------------------------------------------------------------- /apps/api/src/comment/services/update-comment-api.service.ts: -------------------------------------------------------------------------------- 1 | import { CommentService } from '@api/comment/services/comment.service'; 2 | import { Injectable } from '@nestjs/common'; 3 | 4 | @Injectable() 5 | export class UpdateCommentApiService { 6 | constructor(private readonly commentService: CommentService) {} 7 | 8 | async updateContent(id: number, writerId: number, content: string): Promise { 9 | const comment = await this.commentService.findByIdAndWriterIdOrFail(id, writerId); 10 | comment.updateContent(content); 11 | await this.commentService.save(comment); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/api/src/notification/notification.module.ts: -------------------------------------------------------------------------------- 1 | import { Notification } from '@app/entity/notification/notification.entity'; 2 | import { Module } from '@nestjs/common'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { NotificationController } from './notification.controller'; 5 | import { NotificationService } from './notification.service'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([Notification])], 9 | controllers: [NotificationController], 10 | providers: [NotificationService], 11 | exports: [NotificationService], 12 | }) 13 | export class NotificationModule {} 14 | -------------------------------------------------------------------------------- /libs/common/src/database/seeder/article/article-seeder.service.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@app/entity/article/article.entity'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { Repository } from 'typeorm'; 5 | import { articles } from './data'; 6 | 7 | @Injectable() 8 | export class ArticleSeederService { 9 | constructor( 10 | @InjectRepository(Article) 11 | private readonly articleRepository: Repository
, 12 | ) {} 13 | 14 | async create() { 15 | return this.articleRepository.save(articles); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/api/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '@app/utils/logger'; 2 | import { Controller, Get } from '@nestjs/common'; 3 | import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; 4 | 5 | @ApiTags('Hello') 6 | @Controller() 7 | export class AppController { 8 | @Get() 9 | @ApiOperation({ summary: 'Hello world!' }) 10 | @ApiOkResponse({ description: 'Hello world!' }) 11 | getHello(): string { 12 | logger.info('Hello World!!'); 13 | return 'Hello World!'; 14 | } 15 | 16 | @Get('/error') 17 | getError(): void { 18 | throw new Error('Hi Sentry!'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /libs/common/src/database/seeder/category/category-seeder.service.ts: -------------------------------------------------------------------------------- 1 | import { Category } from '@app/entity/category/category.entity'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { Repository } from 'typeorm'; 5 | import { categories } from './data'; 6 | 7 | @Injectable() 8 | export class CategorySeederService { 9 | constructor( 10 | @InjectRepository(Category) 11 | private readonly categoryRepository: Repository, 12 | ) {} 13 | 14 | create() { 15 | return this.categoryRepository.save(categories); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | .envrc 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | pnpm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # OS 17 | .DS_Store 18 | 19 | # Tests 20 | /coverage 21 | /.nyc_output 22 | 23 | # IDEs and editors 24 | /.idea 25 | .project 26 | .classpath 27 | .c9/ 28 | *.launch 29 | .settings/ 30 | *.sublime-workspace 31 | 32 | # IDE - VSCode 33 | .vscode 34 | .vscode/* 35 | !.vscode/settings.json 36 | !.vscode/tasks.json 37 | !.vscode/launch.json 38 | !.vscode/extensions.json 39 | 40 | db 41 | *.env 42 | .env.* -------------------------------------------------------------------------------- /apps/api/src/article/service/remove-article-api.service.ts: -------------------------------------------------------------------------------- 1 | import { ArticleService } from '@api/article/article.service'; 2 | import { User } from '@app/entity/user/user.entity'; 3 | import { Injectable } from '@nestjs/common'; 4 | 5 | @Injectable() 6 | export class RemoveArticleApiService { 7 | constructor( 8 | private readonly articleService: ArticleService, // 9 | ) {} 10 | 11 | /** 12 | * 게시글 삭제 13 | * 14 | * @param user 삭제하는 유저 15 | * @param id 게시글 ID 16 | */ 17 | async remove(user: User, id: number): Promise { 18 | await this.articleService.remove(user, id); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/api/src/user/dto/user-profile.mapper.ts: -------------------------------------------------------------------------------- 1 | import { UserProfileResponseDto } from '@api/user/dto/response/user-profile-response.dto'; 2 | import { IntraAuth } from '@app/entity/intra-auth/intra-auth.entity'; 3 | import { User } from '@app/entity/user/user.entity'; 4 | 5 | export class UserProfileMapper { 6 | static toMapResponse(user: User, intraAuth?: IntraAuth | null): UserProfileResponseDto { 7 | return new UserProfileResponseDto( 8 | user.id, 9 | user.nickname, 10 | user.githubUsername, 11 | user.role, 12 | user.character, 13 | intraAuth?.intraId, 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/batch/src/batch.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { ScheduleModule } from '@nestjs/schedule'; 4 | import { BatchController } from './batch.controller'; 5 | import { FtCheckinModule } from './ft-checkin/ft-checkin.module'; 6 | 7 | @Module({ 8 | imports: [ 9 | ScheduleModule.forRoot(), 10 | ConfigModule.forRoot({ 11 | envFilePath: 'infra/config/.env', 12 | isGlobal: true, 13 | cache: true, 14 | }), 15 | FtCheckinModule, 16 | ], 17 | controllers: [BatchController], 18 | }) 19 | export class BatchModule {} 20 | -------------------------------------------------------------------------------- /libs/common/src/database/seeder/article/article-seeder.module.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@app/entity/article/article.entity'; 2 | import { Category } from '@app/entity/category/category.entity'; 3 | import { User } from '@app/entity/user/user.entity'; 4 | import { Module } from '@nestjs/common'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { ArticleSeederService } from './article-seeder.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([Article, User, Category])], 10 | providers: [ArticleSeederService], 11 | exports: [ArticleSeederService], 12 | }) 13 | export class ArticleSeederModule {} 14 | -------------------------------------------------------------------------------- /libs/common/src/database/migrations/1644422087542-add_notification_articleid.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class addNotificationArticleid1644422087542 implements MigrationInterface { 4 | name = 'addNotificationArticleid1644422087542'; 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`ALTER TABLE \`notification\` ADD \`article_id\` int NOT NULL`); 8 | } 9 | 10 | public async down(queryRunner: QueryRunner): Promise { 11 | await queryRunner.query(`ALTER TABLE \`notification\` DROP COLUMN \`article_id\``); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /libs/common/src/interceptor/sentry.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import * as Sentry from '@sentry/minimal'; 3 | import { Observable, throwError } from 'rxjs'; 4 | import { catchError } from 'rxjs/operators'; 5 | 6 | @Injectable() 7 | export class SentryInterceptor implements NestInterceptor { 8 | intercept(_: ExecutionContext, next: CallHandler): Observable { 9 | return next.handle().pipe( 10 | catchError((error) => { 11 | Sentry.captureException(error); 12 | return throwError(() => error); 13 | }), 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/api/test/e2e/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | }, 9 | "moduleNameMapper": { 10 | "^@api/(.*)$": "/../../src/$1", 11 | "^@test/(.*)$": "/../../test/$1", 12 | "^@app/common/(.*)$": "/../../../../libs/common/src/$1", 13 | "^@app/utils/(.*)$": "/../../../../libs/utils/src/$1", 14 | "^@app/entity/(.*)$": "/../../../../libs/entity/src/$1", 15 | "^axios$": "axios/dist/node/axios.cjs" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /libs/common/src/database/seeder/intra-auth/intra-auth-seeder.service.ts: -------------------------------------------------------------------------------- 1 | import { intraAuthData } from '@app/common/database/seeder/intra-auth/data'; 2 | import { IntraAuth } from '@app/entity/intra-auth/intra-auth.entity'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { InjectRepository } from '@nestjs/typeorm'; 5 | import { Repository } from 'typeorm'; 6 | 7 | @Injectable() 8 | export class IntraAuthSeederService { 9 | constructor( 10 | @InjectRepository(IntraAuth) 11 | private readonly intraRepository: Repository, 12 | ) {} 13 | 14 | create() { 15 | return this.intraRepository.save(intraAuthData); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/api/src/article/dto/request/search-article-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { PaginationRequestDto } from '@api/pagination/dto/pagination-request.dto'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { Type } from 'class-transformer'; 4 | import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; 5 | 6 | export class SearchArticleRequestDto extends PaginationRequestDto { 7 | @IsString() 8 | @IsNotEmpty() 9 | @ApiProperty({ example: '검색할 단어' }) 10 | readonly q: string; 11 | 12 | @Min(0) 13 | @IsInt() 14 | @Type(() => Number) 15 | @IsOptional() 16 | @ApiProperty({ example: 2 }) 17 | readonly categoryId?: number; 18 | } 19 | -------------------------------------------------------------------------------- /apps/api/src/notification/dto/base-notification.dto.ts: -------------------------------------------------------------------------------- 1 | import { NotificationType } from '@app/entity/notification/interfaces/notifiaction.interface'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class BaseNotificationDto { 5 | @ApiProperty() 6 | id!: number; 7 | 8 | @ApiProperty({ enum: NotificationType }) 9 | type!: string; 10 | 11 | @ApiProperty() 12 | content!: string; 13 | 14 | @ApiProperty() 15 | articleId: number; 16 | 17 | @ApiProperty() 18 | isRead!: boolean; 19 | 20 | @ApiProperty() 21 | userId!: number; 22 | 23 | @ApiProperty() 24 | createdAt!: Date; 25 | 26 | @ApiProperty() 27 | updatedAt!: Date; 28 | } 29 | -------------------------------------------------------------------------------- /apps/api/src/notification/dto/create-notification.dto.ts: -------------------------------------------------------------------------------- 1 | import { NotificationType } from '@app/entity/notification/interfaces/notifiaction.interface'; 2 | import { PickType } from '@nestjs/swagger'; 3 | import { BaseNotificationDto } from './base-notification.dto'; 4 | 5 | export class CreateNotificationDto extends PickType(BaseNotificationDto, ['type', 'content', 'articleId', 'userId']) { 6 | constructor(config: { type: NotificationType; content: string; articleId: number; userId: number }) { 7 | super(); 8 | 9 | this.type = config.type; 10 | this.content = config.content; 11 | this.articleId = config.articleId; 12 | this.userId = config.userId; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/api/src/article/dto/request/create-article-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsInt, IsNotEmpty, IsString, MaxLength, Min } from 'class-validator'; 4 | 5 | export class CreateArticleRequestDto { 6 | @MaxLength(42) 7 | @IsString() 8 | @IsNotEmpty() 9 | @ApiProperty({ example: '제목 입니다.' }) 10 | title: string; 11 | 12 | @MaxLength(4242) 13 | @IsString() 14 | @IsNotEmpty() 15 | @ApiProperty({ example: '내용 입니다.' }) 16 | content: string; 17 | 18 | @Min(0) 19 | @IsInt() 20 | @Type(() => Number) 21 | @IsNotEmpty() 22 | @ApiProperty({ example: 1 }) 23 | categoryId: number; 24 | } 25 | -------------------------------------------------------------------------------- /apps/api/src/user/repositories/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@app/entity/user/user.entity'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { DataSource, Repository } from 'typeorm'; 4 | 5 | @Injectable() 6 | export class UserRepository extends Repository { 7 | constructor(dataSource: DataSource) { 8 | super(User, dataSource.createEntityManager()); 9 | } 10 | async isExistById(id: number): Promise { 11 | const existQuery = await this.query(`SELECT EXISTS 12 | (SELECT * FROM user WHERE id=${id} AND deleted_at IS NULL)`); 13 | const isExist = Object.values(existQuery[0])[0]; 14 | return isExist === '1' ? true : false; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testMatch": ["**/?(*.)+(spec|test).+(ts)"], 5 | "transform": { "^.+\\.ts$": "ts-jest" }, 6 | "collectCoverageFrom": ["**/*.ts", "!**/__test__/**/*.ts", "!**/index.ts"], 7 | "coverageDirectory": "./coverage", 8 | "testEnvironment": "node", 9 | "moduleNameMapper": { 10 | "^@api(|/.*)$": "/apps/api/src/$1", 11 | "^@test/(.*)$": "/apps/api/test/$1", 12 | "^@app/common(|/.*)$": "/libs/common/src/$1", 13 | "^@app/utils(|/.*)$": "/libs/utils/src/$1", 14 | "^@app/entity(|/.*)$": "/libs/entity/src/$1", 15 | "^axios$": "axios/dist/node/axios.cjs" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/api/src/article/dto/request/__test__/create-article-request.dto.test.ts: -------------------------------------------------------------------------------- 1 | import { Plain } from '@app/common/types/plain'; 2 | import { plainToInstance } from 'class-transformer'; 3 | import { validateSync } from 'class-validator'; 4 | import { CreateArticleRequestDto } from '../create-article-request.dto'; 5 | 6 | describe('CreateArticleRequestDto', () => { 7 | const plain: Plain = { 8 | title: '제목 입니다.', 9 | content: '내용 입니다.', 10 | categoryId: '1', 11 | }; 12 | 13 | it('Dto 가 잘 생성된다.', async () => { 14 | const obj = plainToInstance(CreateArticleRequestDto, plain); 15 | 16 | const result = validateSync(obj); 17 | 18 | expect(result.length).toBe(0); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /apps/api/src/article/dto/request/__test__/update-article-request.dto.test.ts: -------------------------------------------------------------------------------- 1 | import { Plain } from '@app/common/types/plain'; 2 | import { plainToInstance } from 'class-transformer'; 3 | import { validateSync } from 'class-validator'; 4 | import { UpdateArticleRequestDto } from '../update-article-request.dto'; 5 | 6 | describe('UpdateArticleRequestDto', () => { 7 | const plain: Plain = { 8 | title: '제목 입니다.', 9 | content: '내용 입니다.', 10 | categoryId: '1', 11 | }; 12 | 13 | it('Dto 가 잘 생성된다.', async () => { 14 | const obj = plainToInstance(UpdateArticleRequestDto, plain); 15 | 16 | const result = validateSync(obj); 17 | 18 | expect(result.length).toBe(0); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /apps/api/src/comment/services/remove-comment-api.service.ts: -------------------------------------------------------------------------------- 1 | import { ArticleService } from '@api/article/article.service'; 2 | import { CommentService } from '@api/comment/services/comment.service'; 3 | import { Injectable } from '@nestjs/common'; 4 | 5 | @Injectable() 6 | export class RemoveCommentApiService { 7 | constructor(private readonly commentService: CommentService, private readonly articleService: ArticleService) {} 8 | 9 | async remove(id: number, writerId: number): Promise { 10 | const comment = await this.commentService.findByIdAndWriterIdOrFail(id, writerId); 11 | 12 | await this.commentService.softDelete(id); 13 | await this.articleService.decreaseCommentCount(comment.articleId); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/api/src/user/dto/response/anony-user-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ANONY_USER_CHARACTER, ANONY_USER_ID, ANONY_USER_NICKNAME } from '@api/user/user.constant'; 2 | import { PickType } from '@nestjs/swagger'; 3 | import { BaseUserDto } from '../base-user.dto'; 4 | 5 | export class AnonyUserResponseDto extends PickType(BaseUserDto, ['id', 'nickname', 'character']) { 6 | constructor(config: { nickname: string }) { 7 | super(); 8 | 9 | this.id = ANONY_USER_ID; 10 | this.nickname = config.nickname; 11 | this.character = ANONY_USER_CHARACTER; 12 | } 13 | 14 | static of(nickname = ANONY_USER_NICKNAME): AnonyUserResponseDto { 15 | return new AnonyUserResponseDto({ 16 | nickname, 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/batch/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import * as request from 'supertest'; 4 | import { BatchModule } from './../src/batch.module'; 5 | 6 | describe('BatchController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [BatchModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /.eslintrc.ts: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | tsconfigRootDir: __dirname, 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], 10 | root: true, 11 | env: { 12 | node: true, 13 | jest: true, 14 | }, 15 | ignorePatterns: ['.eslintrc.ts'], 16 | rules: { 17 | '@typescript-eslint/interface-name-prefix': 'off', 18 | '@typescript-eslint/explicit-function-return-type': 'off', 19 | '@typescript-eslint/explicit-module-boundary-types': 'off', 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /apps/api/src/article/dto/request/__test__/find-all-aritcle-request.dto.test.ts: -------------------------------------------------------------------------------- 1 | import { Plain } from '@app/common/types/plain'; 2 | import { plainToInstance } from 'class-transformer'; 3 | import { validateSync } from 'class-validator'; 4 | import { FindAllArticleRequestDto } from '../find-all-article-request.dto'; 5 | 6 | describe('FindAllArticleRequestDto', () => { 7 | const plain: Plain = { 8 | take: '1', 9 | page: '1', 10 | order: 'DESC', 11 | categoryId: '1', 12 | }; 13 | 14 | it('Dto 가 잘 생성된다.', async () => { 15 | const obj = plainToInstance(FindAllArticleRequestDto, plain); 16 | 17 | const result = validateSync(obj); 18 | 19 | expect(result.length).toBe(0); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /apps/api/src/auth/__test__/getParamDecoratorFactory.ts: -------------------------------------------------------------------------------- 1 | import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; 2 | 3 | /** 4 | * @description Param Decorator를 테스트하기 위한 헬퍼 함수 5 | * 6 | * @example 7 | * ``` 8 | * const result = getParamDecorator(AuthUser)('id', context) 9 | * ``` 10 | * 11 | * @see https://github.com/nestjs/nest/issues/1020 12 | * @see https://github.com/EnricoFerro/test-NestJs7-Decorator/blob/master/src/app.controller.spec.ts 13 | */ 14 | export function getParamDecorator(decorator: Function) { 15 | class Test { 16 | public test(@decorator() value: unknown) {} 17 | } 18 | 19 | const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, Test, 'test'); 20 | return args[Object.keys(args)[0]].factory; 21 | } 22 | -------------------------------------------------------------------------------- /libs/common/src/cache/cache.module.ts: -------------------------------------------------------------------------------- 1 | import { CacheService } from '@app/common/cache/cache.service'; 2 | import { CacheModule as RedisModule, Module } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import * as redisStore from 'cache-manager-ioredis'; 5 | 6 | @Module({ 7 | imports: [ 8 | RedisModule.registerAsync({ 9 | inject: [ConfigService], 10 | useFactory: (configService: ConfigService) => ({ 11 | store: redisStore, 12 | host: configService.get('REDIS_HOST'), 13 | port: configService.get('REDIS_PORT'), 14 | }), 15 | isGlobal: true, 16 | }), 17 | ], 18 | providers: [CacheService], 19 | exports: [CacheService], 20 | }) 21 | export class CacheModule {} 22 | -------------------------------------------------------------------------------- /apps/api/src/article/dto/request/__test__/search-article-request.dto.test.ts: -------------------------------------------------------------------------------- 1 | import { Plain } from '@app/common/types/plain'; 2 | import { plainToInstance } from 'class-transformer'; 3 | import { validateSync } from 'class-validator'; 4 | import { SearchArticleRequestDto } from '../search-article-request.dto'; 5 | 6 | describe('SearchArticleRequestDto', () => { 7 | const plain: Plain = { 8 | take: '1', 9 | page: '1', 10 | order: 'DESC', 11 | q: '검색할 단어', 12 | categoryId: '1', 13 | }; 14 | 15 | it('Dto 가 잘 생성된다.', async () => { 16 | const obj = plainToInstance(SearchArticleRequestDto, plain); 17 | 18 | const result = validateSync(obj); 19 | 20 | expect(result.length).toBe(0); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /apps/api/src/article/dto/request/update-article-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; 4 | 5 | export class UpdateArticleRequestDto { 6 | @MaxLength(42) 7 | @IsString() 8 | @IsOptional() 9 | @ApiPropertyOptional({ example: '수정된 제목 입니다.' }) 10 | readonly title?: string; 11 | 12 | @MaxLength(4242) 13 | @IsString() 14 | @IsOptional() 15 | @ApiPropertyOptional({ example: '수정된 내용 입니다.' }) 16 | readonly content?: string; 17 | 18 | @Min(0) 19 | @IsInt() 20 | @Type(() => Number) 21 | @IsOptional() 22 | @ApiPropertyOptional({ example: 1 }) 23 | readonly categoryId?: number; 24 | } 25 | -------------------------------------------------------------------------------- /apps/api/src/user/dto/base-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsInt, IsString, Max, Min } from 'class-validator'; 4 | 5 | export class BaseUserDto { 6 | @ApiProperty() 7 | id!: number; 8 | 9 | @IsString() 10 | @ApiProperty() 11 | nickname!: string; 12 | 13 | @IsString() 14 | @ApiProperty({ example: UserRole.NOVICE }) 15 | role!: UserRole; 16 | 17 | @IsInt() 18 | @Min(0) 19 | @Max(11) 20 | @ApiProperty({ 21 | minimum: 0, 22 | maximum: 11, 23 | }) 24 | character!: number; 25 | 26 | @ApiProperty() 27 | createdAt!: Date; 28 | 29 | @ApiProperty() 30 | updatedAt!: Date; 31 | 32 | @ApiProperty() 33 | deletedAt?: Date; 34 | } 35 | -------------------------------------------------------------------------------- /apps/api/src/reaction/dto/response/reaction-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@app/entity/article/article.entity'; 2 | import { Comment } from '@app/entity/comment/comment.entity'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | 5 | export class ReactionResponseDto { 6 | @ApiProperty() 7 | likeCount!: number; 8 | 9 | @ApiProperty({ example: false }) 10 | isLike!: boolean; 11 | 12 | constructor(config: { likeCount: number; isLike: boolean }) { 13 | this.likeCount = config.likeCount; 14 | this.isLike = config.isLike; 15 | } 16 | 17 | static of(config: { entity: T; isLike: boolean }) { 18 | return new ReactionResponseDto({ 19 | likeCount: config.entity.likeCount, 20 | isLike: config.isLike, 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /infra/run_test_db.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if ! $( docker container inspect -f '{{.State.Running}}' ft_world-mysql-test 2> /dev/null ); then 4 | docker run -d --rm --name ft_world-mysql-test \ 5 | -e MYSQL_DATABASE=ft_world \ 6 | -e MYSQL_USER=ft_world \ 7 | -e MYSQL_PASSWORD=ft_world \ 8 | -e MYSQL_ALLOW_EMPTY_PASSWORD=true \ 9 | -e MYSQL_INITDB_ARGS=--encoding=UTF-8 \ 10 | --health-cmd='mysqladmin ping -h localhost -u ft_world -passworld=ft_world' \ 11 | --health-interval=5s \ 12 | --health-retries=20 \ 13 | --health-start-period=0s \ 14 | --health-timeout=1s \ 15 | -e TZ=Asia/Seoul \ 16 | -p 3308:3306 mysql:8.0 \ 17 | mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci 18 | fi -------------------------------------------------------------------------------- /apps/api/src/auth/github-auth/__test__/github-auth.module.test.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule } from '@nestjs/config'; 2 | import { Test } from '@nestjs/testing'; 3 | import { GithubAuthModule } from '../github-auth.module'; 4 | 5 | describe('GithubAuthModule', () => { 6 | test('모듈이 잘 컴파일된다.', async () => { 7 | const module = await Test.createTestingModule({ 8 | imports: [ 9 | ConfigModule.forRoot({ 10 | isGlobal: true, 11 | load: [ 12 | () => ({ 13 | GITHUB_CLIENT_ID: '123', 14 | GITHUB_CLIENT_SECRET: '123', 15 | GITHUB_CALLBACK_URL: '123', 16 | }), 17 | ], 18 | }), 19 | GithubAuthModule, 20 | ], 21 | }).compile(); 22 | 23 | expect(module).toBeDefined(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /libs/utils/src/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 2 | import { includeRole } from './utils'; 3 | 4 | describe('includeRole', () => { 5 | test('ADMIN 권한을 확인하는 경우', () => { 6 | const result = includeRole(UserRole.ADMIN); 7 | 8 | expect(result.sort()).toStrictEqual([UserRole.ADMIN, UserRole.CADET, UserRole.GUEST, UserRole.NOVICE]); 9 | }); 10 | 11 | test('CADET 권한을 확인하는 경우', () => { 12 | const result = includeRole(UserRole.CADET); 13 | 14 | expect(result.sort()).toStrictEqual([UserRole.CADET, UserRole.GUEST, UserRole.NOVICE]); 15 | }); 16 | 17 | test('NOVICE 권한을 확인하는 경우', () => { 18 | const result = includeRole(UserRole.NOVICE); 19 | 20 | expect(result.sort()).toStrictEqual([UserRole.GUEST, UserRole.NOVICE]); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /apps/api/src/user/dto/response/user-profile-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { BaseUserResponseDto } from '@api/user/dto/response/base-user-response.dto'; 2 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | import { IsString } from 'class-validator'; 5 | 6 | export class UserProfileResponseDto extends BaseUserResponseDto { 7 | @IsString() 8 | @ApiProperty({ required: false, example: 'chlim', nullable: true }) 9 | intraId: string | null; 10 | 11 | constructor( 12 | id: number, 13 | nickname: string, 14 | githubUsername: string, 15 | role: UserRole, 16 | character: number, 17 | intraId: string | null, 18 | ) { 19 | super(id, nickname, githubUsername, role, character); 20 | this.intraId = intraId; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /apps/batch/src/main.ts: -------------------------------------------------------------------------------- 1 | import { SentryInterceptor } from '@app/common/interceptor/sentry.interceptor'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { NestFactory } from '@nestjs/core'; 4 | import * as Sentry from '@sentry/node'; 5 | import { BatchModule } from './batch.module'; 6 | import { FtCheckinService } from './ft-checkin/ft-checkin.service'; 7 | 8 | async function bootstrap() { 9 | const app = await NestFactory.create(BatchModule); 10 | const configService = app.get(ConfigService); 11 | const ftcheckinService = app.get(FtCheckinService); 12 | 13 | Sentry.init({ 14 | dsn: configService.get('SENTRY_KEY'), 15 | }); 16 | app.useGlobalInterceptors(new SentryInterceptor()); 17 | ftcheckinService.getMax(); 18 | ftcheckinService.getNow(); 19 | 20 | await app.listen(3000); 21 | } 22 | bootstrap(); 23 | -------------------------------------------------------------------------------- /apps/api/src/pagination/dto/pagination-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsArray } from 'class-validator'; 3 | import { PageMetaDto } from './page-meta.dto'; 4 | import { PaginationRequestDto } from './pagination-request.dto'; 5 | 6 | export class PaginationResponseDto { 7 | @IsArray() 8 | @ApiProperty({ isArray: true }) 9 | readonly data: T[]; 10 | 11 | @ApiProperty({ type: () => PageMetaDto }) 12 | readonly meta: PageMetaDto; 13 | 14 | constructor(data: T[], meta: PageMetaDto) { 15 | this.data = data; 16 | this.meta = meta; 17 | } 18 | 19 | static of(config: { data: T[]; options: PaginationRequestDto; totalCount: number }): PaginationResponseDto { 20 | return new PaginationResponseDto(config.data, new PageMetaDto(config.options, config.totalCount)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /libs/common/src/database/seed.ts: -------------------------------------------------------------------------------- 1 | import { Seeder } from '@app/common/database/seeder/seeder'; 2 | import { SeederModule } from '@app/common/database/seeder/seeder.module'; 3 | import { Logger } from '@nestjs/common'; 4 | import { NestFactory } from '@nestjs/core'; 5 | 6 | async function bootstrap() { 7 | NestFactory.createApplicationContext(SeederModule) 8 | .then((appContext) => { 9 | const logger = appContext.get(Logger); 10 | const seeder = appContext.get(Seeder); 11 | seeder 12 | .seed() 13 | .then(() => { 14 | logger.debug('Seeding complete!'); 15 | }) 16 | .catch((error) => { 17 | logger.error('Seeding failed!'); 18 | throw error; 19 | }) 20 | .finally(() => appContext.close()); 21 | }) 22 | .catch((error) => { 23 | throw error; 24 | }); 25 | } 26 | bootstrap(); 27 | -------------------------------------------------------------------------------- /apps/api/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { ArticleModule } from '@api/article/article.module'; 2 | import { CommentModule } from '@api/comment/comment.module'; 3 | import { NotificationModule } from '@api/notification/notification.module'; 4 | import { ReactionModule } from '@api/reaction/reaction.module'; 5 | import { Module } from '@nestjs/common'; 6 | import { TypeOrmModule } from '@nestjs/typeorm'; 7 | import { UserRepository } from './repositories/user.repository'; 8 | import { UserController } from './user.controller'; 9 | import { UserService } from './user.service'; 10 | 11 | @Module({ 12 | imports: [ 13 | NotificationModule, 14 | ArticleModule, 15 | CommentModule, 16 | ReactionModule, 17 | ], 18 | controllers: [UserController], 19 | providers: [UserService, UserRepository], 20 | exports: [UserService, UserRepository], 21 | }) 22 | export class UserModule {} 23 | -------------------------------------------------------------------------------- /libs/common/src/cache/cache.service.ts: -------------------------------------------------------------------------------- 1 | import { TIME2LIVE } from '@app/utils/utils'; 2 | import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common'; 3 | import { Cache, CachingConfig } from 'cache-manager'; 4 | 5 | @Injectable() 6 | export class CacheService { 7 | private readonly defaultCachingConfig: CachingConfig = { 8 | ttl: TIME2LIVE, 9 | }; 10 | 11 | constructor( 12 | @Inject(CACHE_MANAGER) 13 | private readonly cacheManager: Cache, 14 | ) {} 15 | 16 | async set(key: string, value: T, options: CachingConfig = this.defaultCachingConfig): Promise { 17 | await this.cacheManager.set(key, value, options); 18 | } 19 | 20 | async get(key: string): Promise { 21 | return this.cacheManager.get(key); 22 | } 23 | 24 | async del(code: string): Promise { 25 | await this.cacheManager.del(code); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/api/src/category/dto/base-category.dto.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsString, MaxLength } from 'class-validator'; 4 | 5 | export class BaseCategoryDto { 6 | @ApiProperty() 7 | id!: number; 8 | 9 | @IsString() 10 | @MaxLength(40) 11 | @ApiProperty() 12 | name!: string; 13 | 14 | @ApiProperty({ example: UserRole.CADET }) 15 | writableArticle!: UserRole; 16 | 17 | @ApiProperty({ example: UserRole.CADET }) 18 | readableArticle!: UserRole; 19 | 20 | @ApiProperty({ example: UserRole.CADET }) 21 | writableComment!: UserRole; 22 | 23 | @ApiProperty({ example: UserRole.CADET }) 24 | readableComment!: UserRole; 25 | 26 | @ApiProperty({ example: UserRole.CADET }) 27 | reactionable!: UserRole; 28 | 29 | @ApiProperty() 30 | isAnonymous!: boolean; 31 | } 32 | -------------------------------------------------------------------------------- /apps/api/src/pagination/pagination.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, Type } from '@nestjs/common'; 2 | import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger'; 3 | import { PaginationResponseDto } from './dto/pagination-response.dto'; 4 | 5 | export const ApiPaginatedResponse = >(model: TModel) => { 6 | return applyDecorators( 7 | ApiExtraModels(PaginationResponseDto), 8 | ApiOkResponse({ 9 | description: 'Successfully received model list', 10 | schema: { 11 | allOf: [ 12 | { $ref: getSchemaPath(PaginationResponseDto) }, 13 | { 14 | properties: { 15 | data: { 16 | type: 'array', 17 | items: { $ref: getSchemaPath(model) }, 18 | }, 19 | }, 20 | }, 21 | ], 22 | }, 23 | }), 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /libs/entity/src/best/best.entity.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@app/entity/article/article.entity'; 2 | import { 3 | Column, 4 | CreateDateColumn, 5 | Entity, 6 | Index, 7 | JoinColumn, 8 | OneToOne, 9 | PrimaryGeneratedColumn, 10 | UpdateDateColumn, 11 | } from 'typeorm'; 12 | 13 | @Entity('best') 14 | export class Best { 15 | @PrimaryGeneratedColumn() 16 | id!: number; 17 | 18 | @Column({ nullable: false, unique: true }) 19 | @Index('ix_article_id') 20 | articleId!: number; 21 | 22 | @OneToOne(() => Article, (article) => article.best, { 23 | createForeignKeyConstraints: false, 24 | nullable: false, 25 | }) 26 | @JoinColumn({ name: 'article_id', referencedColumnName: 'id' }) 27 | article?: Article; 28 | 29 | @CreateDateColumn({ type: 'timestamp' }) 30 | createdAt!: Date; 31 | 32 | @UpdateDateColumn({ type: 'timestamp' }) 33 | updatedAt!: Date; 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-reviewer.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions 2 | name: Approve and enable auto-merge for dependabot 3 | on: pull_request 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | review: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | pull-requests: write 13 | contents: write 14 | if: ${{ github.actor == 'dependabot[bot]' && github.repository == 'argoproj/argo-workflows'}} 15 | steps: 16 | - name: Dependabot metadata 17 | id: metadata 18 | uses: dependabot/fetch-metadata@v1.6.0 19 | with: 20 | github-token: "${{ secrets.GITHUB_TOKEN }}" 21 | - name: Approve PR 22 | run: gh pr review --approve "$PR_URL" 23 | env: 24 | PR_URL: ${{github.event.pull_request.html_url}} 25 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 26 | -------------------------------------------------------------------------------- /apps/admin/src/entity/best/best.entity.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@admin/entity/article/article.entity'; 2 | import { 3 | BaseEntity, 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | Index, 8 | JoinColumn, 9 | OneToOne, 10 | PrimaryGeneratedColumn, 11 | UpdateDateColumn, 12 | } from 'typeorm'; 13 | 14 | @Entity('best') 15 | export class Best extends BaseEntity { 16 | @PrimaryGeneratedColumn() 17 | id!: number; 18 | 19 | @Column({ nullable: false, unique: true }) 20 | @Index('ix_article_id') 21 | articleId!: number; 22 | 23 | @OneToOne(() => Article, (article) => article.best, { 24 | createForeignKeyConstraints: false, 25 | nullable: false, 26 | }) 27 | @JoinColumn({ name: 'article_id', referencedColumnName: 'id' }) 28 | article?: Article; 29 | 30 | @CreateDateColumn({ type: 'timestamp' }) 31 | createdAt!: Date; 32 | 33 | @UpdateDateColumn({ type: 'timestamp' }) 34 | updatedAt!: Date; 35 | } 36 | -------------------------------------------------------------------------------- /apps/api/src/pagination/dto/pagination-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator'; 4 | import { Order } from '../interface/pagination.enum'; 5 | 6 | export class PaginationRequestDto { 7 | @ApiPropertyOptional({ enum: Order, default: Order.DESC }) 8 | @IsEnum(Order) 9 | @IsOptional() 10 | readonly order?: Order = Order.DESC; 11 | 12 | @ApiPropertyOptional({ 13 | minimum: 1, 14 | default: 1, 15 | }) 16 | @Type(() => Number) 17 | @IsInt() 18 | @Min(1) 19 | @IsOptional() 20 | readonly page?: number = 1; 21 | 22 | @ApiPropertyOptional({ 23 | minimum: 1, 24 | maximum: 50, 25 | default: 10, 26 | description: '가져올 갯수', 27 | }) 28 | @Type(() => Number) 29 | @IsInt() 30 | @Min(1) 31 | @Max(1000) 32 | @IsOptional() 33 | readonly take?: number = 10; 34 | } 35 | -------------------------------------------------------------------------------- /libs/common/src/database/seeder/seeder.module.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseModule } from '@app/common/database/database.module'; 2 | import { IntraAuthSeederModule } from '@app/common/database/seeder/intra-auth/intra-auth-seeder.module'; 3 | import { Logger, Module } from '@nestjs/common'; 4 | import { ConfigModule } from '@nestjs/config'; 5 | import { ArticleSeederModule } from './article/article-seeder.module'; 6 | import { CategorySeederModule } from './category/category-seeder.module'; 7 | import { Seeder } from './seeder'; 8 | import { UserSeederModule } from './user/user-seeder.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | ConfigModule.forRoot({ 13 | isGlobal: true, 14 | cache: true, 15 | }), 16 | DatabaseModule.register(), 17 | UserSeederModule, 18 | CategorySeederModule, 19 | ArticleSeederModule, 20 | IntraAuthSeederModule, 21 | ], 22 | providers: [Logger, Seeder], 23 | }) 24 | export class SeederModule {} 25 | -------------------------------------------------------------------------------- /apps/api/src/article/article-api.module.ts: -------------------------------------------------------------------------------- 1 | import { ArticleModule } from '@api/article/article.module'; 2 | import { 3 | CreateArticleApiService, 4 | FindArticleApiService, 5 | RemoveArticleApiService, 6 | SearchArticleApiService, 7 | UpdateArticleApiService, 8 | } from '@api/article/service'; 9 | import { CategoryModule } from '@api/category/category.module'; 10 | import { ReactionModule } from '@api/reaction/reaction.module'; 11 | import { Module } from '@nestjs/common'; 12 | import { ArticleApiController } from './article-api.controller'; 13 | 14 | @Module({ 15 | imports: [ 16 | ArticleModule, // 17 | CategoryModule, 18 | ReactionModule, 19 | ], 20 | providers: [ 21 | CreateArticleApiService, 22 | FindArticleApiService, 23 | UpdateArticleApiService, 24 | SearchArticleApiService, 25 | RemoveArticleApiService, 26 | ], 27 | controllers: [ArticleApiController], 28 | }) 29 | export class ArticleApiModule {} 30 | -------------------------------------------------------------------------------- /infra/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | services: 3 | redis: 4 | image: redis:6.2.5 5 | container_name: 42world-backend-redis 6 | command: redis-server --port 6379 7 | ports: 8 | - '${REDIS_PORT}:6379' 9 | 10 | db: 11 | image: mysql:8.0 12 | container_name: 42world-backend-db 13 | ports: 14 | - '${DB_PORT}:3306' 15 | environment: 16 | - MYSQL_DATABASE=${DB_NAME} 17 | - MYSQL_USER=${DB_USER_NAME} 18 | - MYSQL_PASSWORD=${DB_USER_PASSWORD} 19 | - MYSQL_ALLOW_EMPTY_PASSWORD=true 20 | - MYSQL_INITDB_ARGS=--encoding=UTF-8 21 | - TZ=UTC 22 | command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci 23 | healthcheck: 24 | test: 'mysqladmin ping -h localhost -u ${DB_USER_NAME} --password=${DB_USER_PASSWORD}' 25 | interval: 5s 26 | timeout: 1s 27 | retries: 20 28 | start_period: 0s 29 | volumes: 30 | - ./db:/var/lib/mysql 31 | -------------------------------------------------------------------------------- /apps/api/src/pagination/dto/page-meta.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { PaginationRequestDto } from './pagination-request.dto'; 3 | 4 | export class PageMetaDto { 5 | @ApiProperty() 6 | readonly page: number; 7 | 8 | @ApiProperty({ description: '가져올 갯수' }) 9 | readonly take: number; 10 | 11 | @ApiProperty() 12 | readonly totalCount: number; 13 | 14 | @ApiProperty() 15 | readonly pageCount: number; 16 | 17 | @ApiProperty() 18 | readonly hasPreviousPage: boolean; 19 | 20 | @ApiProperty() 21 | readonly hasNextPage: boolean; 22 | 23 | constructor(paginationRequestDto: PaginationRequestDto, totalCount: number) { 24 | this.page = paginationRequestDto.page; 25 | this.take = paginationRequestDto.take; 26 | this.totalCount = totalCount; 27 | this.pageCount = Math.ceil(this.totalCount / this.take); 28 | this.hasPreviousPage = this.page > 1; 29 | this.hasNextPage = this.page < this.pageCount; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /apps/api/src/auth/github-auth/github-auth.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { Strategy, VerifyCallback } from 'passport-github2'; 5 | import { GithubProfile } from '../types'; 6 | 7 | @Injectable() 8 | export class GithubAuthStrategy extends PassportStrategy(Strategy) { 9 | constructor(readonly configService: ConfigService) { 10 | super({ 11 | clientID: configService.get('GITHUB_CLIENT_ID'), 12 | clientSecret: configService.get('GITHUB_CLIENT_SECRET'), 13 | callbackURL: configService.get('GITHUB_CALLBACK_URL'), // frontend url 14 | }); 15 | } 16 | 17 | async validate(accessToken: string, refreshToken: string, profile: any, done: VerifyCallback): Promise { 18 | const githubProfile: GithubProfile = { 19 | id: profile.id, 20 | username: profile.username, 21 | }; 22 | done(null, githubProfile); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/api/src/user/dto/response/base-user-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsInt, IsString, Max, Min } from 'class-validator'; 4 | 5 | export class BaseUserResponseDto { 6 | @ApiProperty() 7 | id!: number; 8 | 9 | @IsString() 10 | @ApiProperty() 11 | nickname!: string; 12 | 13 | @IsString() 14 | @ApiProperty() 15 | githubUsername!: string; 16 | 17 | @IsString() 18 | @ApiProperty({ example: UserRole.NOVICE }) 19 | role!: UserRole; 20 | 21 | @IsInt() 22 | @Min(0) 23 | @Max(10) 24 | @ApiProperty({ 25 | minimum: 0, 26 | maximum: 10, 27 | }) 28 | character!: number; 29 | 30 | constructor(id: number, nickname: string, githubUsername: string, role: UserRole, character: number) { 31 | this.id = id; 32 | this.nickname = nickname; 33 | this.githubUsername = githubUsername; 34 | this.role = role; 35 | this.character = character; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /libs/entity/src/intra-auth/intra-auth.entity.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@app/entity/user/user.entity'; 2 | import { 3 | Column, 4 | CreateDateColumn, 5 | Entity, 6 | Index, 7 | JoinColumn, 8 | OneToOne, 9 | PrimaryGeneratedColumn, 10 | UpdateDateColumn, 11 | } from 'typeorm'; 12 | 13 | @Entity('intra_auth') 14 | export class IntraAuth { 15 | @PrimaryGeneratedColumn() 16 | id!: number; 17 | 18 | @Index('ix_intra_id') 19 | @Column({ type: 'varchar', length: 20, nullable: false }) 20 | intraId?: string; 21 | 22 | @Column({ nullable: false, unique: true }) 23 | @Index('ix_user_id') 24 | userId!: number; 25 | 26 | @OneToOne(() => User, (user) => user.intraAuth, { 27 | createForeignKeyConstraints: false, 28 | nullable: false, 29 | }) 30 | @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) 31 | user?: User; 32 | 33 | @CreateDateColumn({ type: 'timestamp' }) 34 | createdAt!: Date; 35 | 36 | @UpdateDateColumn({ type: 'timestamp' }) 37 | updatedAt!: Date; 38 | } 39 | -------------------------------------------------------------------------------- /apps/api/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { UserModule } from '@api/user/user.module'; 2 | import { Module } from '@nestjs/common'; 3 | import { ConfigModule, ConfigService } from '@nestjs/config'; 4 | import { JwtModule } from '@nestjs/jwt'; 5 | import { AuthController } from './auth.controller'; 6 | import { AuthService } from './auth.service'; 7 | import { GithubAuthModule } from './github-auth/github-auth.module'; 8 | import { JwtAuthModule } from './jwt-auth/jwt-auth.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | UserModule, 13 | GithubAuthModule, 14 | JwtAuthModule, 15 | JwtModule.registerAsync({ 16 | imports: [ConfigModule], 17 | inject: [ConfigService], 18 | useFactory: async (configService: ConfigService) => ({ 19 | secret: configService.get('JWT_SECRET'), 20 | signOptions: { expiresIn: '7d' }, 21 | }), 22 | }), 23 | ], 24 | providers: [AuthService], 25 | exports: [AuthService], 26 | controllers: [AuthController], 27 | }) 28 | export class AuthModule {} 29 | -------------------------------------------------------------------------------- /apps/api/src/getEnvFromSecretManager.ts: -------------------------------------------------------------------------------- 1 | import { PHASE } from '@app/utils/phase'; 2 | import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; 3 | 4 | export const getEnvFromSecretManager = async (): Promise> => { 5 | if (PHASE === 'dev' || PHASE === 'test') { 6 | return {}; 7 | } 8 | 9 | const secretName = `${PHASE}/rookies/api`; 10 | 11 | console.log(`[${new Date().toISOString()}] Fetch secret from ${secretName}...`); 12 | 13 | const client = new SecretsManagerClient({ 14 | region: 'ap-northeast-2', 15 | }); 16 | const response = await client.send( 17 | new GetSecretValueCommand({ 18 | SecretId: secretName, 19 | VersionStage: 'AWSCURRENT', // VersionStage defaults to AWSCURRENT if unspecified 20 | }), 21 | ); 22 | console.log(`[${new Date().toISOString()}] Fetch secret from ${secretName}... Done!`); 23 | 24 | const secrets = JSON.parse(response.SecretString); 25 | 26 | // TODO: add validate 27 | return secrets; 28 | }; 29 | -------------------------------------------------------------------------------- /apps/api/src/image/image.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { S3 } from 'aws-sdk'; 4 | import { InjectAwsService } from 'nest-aws-sdk'; 5 | import { S3_URL_EXPIRATION_SECONDS } from './image.constant'; 6 | import { S3Param } from './interfaces/s3-param.interface'; 7 | 8 | @Injectable() 9 | export class ImageService { 10 | constructor( 11 | @InjectAwsService(S3) 12 | private readonly s3: S3, // 13 | private readonly configService: ConfigService, 14 | ) {} 15 | 16 | async createUploadURL(): Promise { 17 | const randomId = Math.random() * 10000000; 18 | const key = `${randomId}.png`; 19 | 20 | const s3Params: S3Param = { 21 | Bucket: this.configService.get('AWS_S3_UPLOAD_BUCKET'), 22 | Key: key, 23 | Expires: S3_URL_EXPIRATION_SECONDS, 24 | ContentType: 'image/png', 25 | ACL: 'public-read', 26 | }; 27 | 28 | return this.s3.getSignedUrlPromise('putObject', s3Params); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /libs/common/src/database/seeder/seeder.ts: -------------------------------------------------------------------------------- 1 | import { IntraAuthSeederService } from '@app/common/database/seeder/intra-auth/intra-auth-seeder.service'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { ArticleSeederService } from './article/article-seeder.service'; 4 | import { CategorySeederService } from './category/category-seeder.service'; 5 | import { UserSeederService } from './user/user-seeder.service'; 6 | 7 | @Injectable() 8 | export class Seeder { 9 | constructor( 10 | private readonly userSeederService: UserSeederService, 11 | private readonly categorySeederService: CategorySeederService, 12 | private readonly articleSeederService: ArticleSeederService, 13 | private readonly intraAuthSeederService: IntraAuthSeederService, 14 | ) {} 15 | 16 | async seed() { 17 | await Promise.all([ 18 | this.userSeederService.create(), 19 | this.categorySeederService.create(), 20 | this.articleSeederService.create(), 21 | this.intraAuthSeederService.create(), 22 | ]); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /libs/common/src/database/seeder/user/data.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 2 | import { User } from '@app/entity/user/user.entity'; 3 | import { PartialType } from '@nestjs/mapped-types'; 4 | 5 | export class SeederDataUser extends PartialType(User) { 6 | id: number; 7 | githubUid: string; 8 | nickname: string; 9 | githubUsername: string; 10 | role: UserRole; 11 | } 12 | 13 | export const users: SeederDataUser[] = [ 14 | { 15 | id: 1, 16 | githubUid: 'noviceUserGithubUid', 17 | nickname: 'noviceUserNickName', 18 | githubUsername: 'noviceGithubUserName', 19 | role: UserRole.NOVICE, 20 | }, 21 | { 22 | id: 2, 23 | githubUid: 'cadetGithubUid', 24 | nickname: 'cadetNickname', 25 | githubUsername: 'cadetGithubUsername', 26 | role: UserRole.CADET, 27 | }, 28 | { 29 | id: 3, 30 | githubUid: 'adminGithubUid', 31 | nickname: 'adminNickname', 32 | githubUsername: 'adminGithubUsername', 33 | role: UserRole.ADMIN, 34 | }, 35 | ]; 36 | -------------------------------------------------------------------------------- /apps/api/src/reaction/reaction.module.ts: -------------------------------------------------------------------------------- 1 | import { ArticleModule } from '@api/article/article.module'; 2 | import { CategoryModule } from '@api/category/category.module'; 3 | import { CommentModule } from '@api/comment/comment.module'; 4 | import { ReactionComment } from '@app/entity/reaction/reaction-comment.entity'; 5 | import { forwardRef, Module } from '@nestjs/common'; 6 | import { TypeOrmModule } from '@nestjs/typeorm'; 7 | import { ReactionController } from './reaction.controller'; 8 | import { ReactionService } from './reaction.service'; 9 | import { ReactionArticleRepository } from './repositories/reaction-article.repository'; 10 | 11 | @Module({ 12 | imports: [ 13 | CategoryModule, 14 | TypeOrmModule.forFeature([ReactionComment]), 15 | forwardRef(() => ArticleModule), 16 | forwardRef(() => CommentModule), 17 | ], 18 | controllers: [ReactionController], 19 | providers: [ReactionService, ReactionArticleRepository], 20 | exports: [ReactionService, ReactionArticleRepository], 21 | }) 22 | export class ReactionModule {} 23 | -------------------------------------------------------------------------------- /apps/api/src/article/service/create-article-api.service.ts: -------------------------------------------------------------------------------- 1 | import { ArticleService } from '@api/article/article.service'; 2 | import { CategoryService } from '@api/category/category.service'; 3 | import { Article } from '@app/entity/article/article.entity'; 4 | import { User } from '@app/entity/user/user.entity'; 5 | import { Injectable } from '@nestjs/common'; 6 | 7 | @Injectable() 8 | export class CreateArticleApiService { 9 | constructor( 10 | private readonly articleService: ArticleService, // 11 | private readonly categoryService: CategoryService, 12 | ) {} 13 | 14 | /** 15 | * 게시글 업로드 16 | * 17 | * @param writer 작성자 18 | * @param title 제목 19 | * @param content 내용 20 | * @param categoryId 카테고리 ID 21 | * @returns 22 | */ 23 | async create(writer: User, title: string, content: string, categoryId: number): Promise
{ 24 | await this.categoryService.checkAvailable(writer, categoryId, 'writableArticle'); 25 | 26 | return await this.articleService.create(writer, title, content, categoryId); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/admin/src/entity/intra-auth/intra-auth.entity.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@admin/entity/user/user.entity'; 2 | import { 3 | BaseEntity, 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | Index, 8 | JoinColumn, 9 | OneToOne, 10 | PrimaryGeneratedColumn, 11 | UpdateDateColumn, 12 | } from 'typeorm'; 13 | 14 | @Entity('intra_auth') 15 | export class IntraAuth extends BaseEntity { 16 | @PrimaryGeneratedColumn() 17 | id!: number; 18 | 19 | @Index('ix_intra_id') 20 | @Column({ type: 'varchar', length: 20, nullable: false }) 21 | intraId?: string; 22 | 23 | @Column({ nullable: false, unique: true }) 24 | @Index('ix_user_id') 25 | userId!: number; 26 | 27 | @OneToOne(() => User, (user) => user.intraAuth, { 28 | createForeignKeyConstraints: false, 29 | nullable: false, 30 | }) 31 | @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) 32 | user?: User; 33 | 34 | @CreateDateColumn({ type: 'timestamp' }) 35 | createdAt!: Date; 36 | 37 | @UpdateDateColumn({ type: 'timestamp' }) 38 | updatedAt!: Date; 39 | } 40 | -------------------------------------------------------------------------------- /apps/api/src/image/image.controller.ts: -------------------------------------------------------------------------------- 1 | import { Auth } from '@api/auth/auth.decorator'; 2 | import { UploadImageUrlResponseDto } from '@api/image/dto/upload-image-url-response.dto'; 3 | import { ImageService } from '@api/image/image.service'; 4 | import { Controller, HttpCode, Post } from '@nestjs/common'; 5 | import { ApiCookieAuth, ApiCreatedResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger'; 6 | 7 | @ApiCookieAuth() 8 | @ApiUnauthorizedResponse({ description: '인증 실패' }) 9 | @ApiTags('Image') 10 | @Controller('image') 11 | export class ImageController { 12 | constructor(private readonly imageService: ImageService) {} 13 | 14 | @Post() 15 | @Auth() 16 | @HttpCode(200) 17 | @ApiOperation({ summary: '이미지 업로드 URL 생성' }) 18 | @ApiCreatedResponse({ 19 | description: '생성된 업로드 URL', 20 | type: UploadImageUrlResponseDto, 21 | }) 22 | async createUploadURL(): Promise { 23 | const uploadUrl = await this.imageService.createUploadURL(); 24 | return new UploadImageUrlResponseDto(uploadUrl); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "paths": { 14 | "@api/*": [ 15 | "./apps/api/src/*" 16 | ], 17 | "@admin/*": [ 18 | "./apps/admin/src/*" 19 | ], 20 | "@test/*": [ 21 | "./apps/api/test/*" 22 | ], 23 | "@app/common/*": [ 24 | "./libs/common/src/*" 25 | ], 26 | "@app/utils/*": [ 27 | "./libs/utils/src/*" 28 | ], 29 | "@app/entity/*": [ 30 | "libs/entity/src/*" 31 | ] 32 | }, 33 | "incremental": true, 34 | "skipLibCheck": true, 35 | "strictNullChecks": false, 36 | "noImplicitAny": false, 37 | "strictBindCallApply": false, 38 | "forceConsistentCasingInFileNames": false, 39 | "noFallthroughCasesInSwitch": false 40 | } 41 | } -------------------------------------------------------------------------------- /apps/api/src/auth/github-auth/__test__/github-auth.strategy.test.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { mock, mockFn } from 'jest-mock-extended'; 3 | import { VerifiedCallback } from 'passport-jwt'; 4 | import { GithubAuthStrategy } from '../github-auth.strategy'; 5 | 6 | describe('GithubAuthStrategy', () => { 7 | const mockConfigService = mock({ 8 | get: mockFn().mockReturnValue('test'), 9 | }); 10 | const githubAuthStrategy = new GithubAuthStrategy(mockConfigService); 11 | 12 | describe('validate', () => { 13 | test('GithubProfile이 반환된다', async () => { 14 | const accessToken = ''; 15 | const refreshToken = ''; 16 | const profile = { 17 | id: 'testid', 18 | username: 'testusername', 19 | other: 'zzz', 20 | }; 21 | const done = mockFn(); 22 | 23 | await githubAuthStrategy.validate(accessToken, refreshToken, profile, done); 24 | 25 | expect(done).toBeCalledWith(null, { 26 | id: 'testid', 27 | username: 'testusername', 28 | }); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /apps/api/src/article/service/update-article-api.service.ts: -------------------------------------------------------------------------------- 1 | import { ArticleService } from '@api/article/article.service'; 2 | import { CategoryService } from '@api/category/category.service'; 3 | import { User } from '@app/entity/user/user.entity'; 4 | import { Injectable } from '@nestjs/common'; 5 | 6 | @Injectable() 7 | export class UpdateArticleApiService { 8 | constructor( 9 | private readonly articleService: ArticleService, // 10 | private readonly categoryService: CategoryService, 11 | ) {} 12 | 13 | /** 14 | * 게시글 수정 15 | * 16 | * @param user 수정하는 유저 17 | * @param id 게시글 ID 18 | * @param title 제목 19 | * @param content 내용 20 | * @param categoryId 카테고리 ID 21 | */ 22 | async update( 23 | user: User, 24 | id: number, 25 | title: string | undefined, 26 | content: string | undefined, 27 | categoryId: number | undefined, 28 | ): Promise { 29 | if (categoryId) { 30 | await this.categoryService.checkAvailable(user, categoryId, 'writableArticle'); 31 | } 32 | 33 | await this.articleService.update(user, id, title, content, categoryId); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /apps/api/src/category/repositories/category.repository.ts: -------------------------------------------------------------------------------- 1 | import { Category } from '@app/entity/category/category.entity'; 2 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 3 | import { Injectable, NotFoundException } from '@nestjs/common'; 4 | import { DataSource, Repository } from 'typeorm'; 5 | 6 | @Injectable() 7 | export class CategoryRepository extends Repository { 8 | constructor(dataSource: DataSource) { 9 | super(Category, dataSource.createEntityManager()); 10 | } 11 | async existOrFail(id: number): Promise { 12 | const existQuery = await this.query(`SELECT EXISTS 13 | (SELECT * FROM category WHERE id=${id} AND deleted_at IS NULL)`); 14 | const isExist = Object.values(existQuery[0])[0]; 15 | if (isExist === '0') { 16 | throw new NotFoundException(`Can't find Category with id ${id}`); 17 | } 18 | } 19 | 20 | getAvailable(role: UserRole[]): Promise { 21 | const query = this.createQueryBuilder('category').where('category.readableArticle IN (:...userRole)', { 22 | userRole: role, 23 | }); 24 | return query.getMany(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/api/src/ft-checkin/ft-checkin.service.ts: -------------------------------------------------------------------------------- 1 | import { CacheService } from '@app/common/cache/cache.service'; 2 | import { FtCheckinDto } from '@app/common/cache/dto/ft-checkin.dto'; 3 | import { logger } from '@app/utils/logger'; 4 | import { errorHook } from '@app/utils/utils'; 5 | import { Injectable } from '@nestjs/common'; 6 | import { ConfigService } from '@nestjs/config'; 7 | 8 | @Injectable() 9 | export class FtCheckinService { 10 | constructor( 11 | private readonly cacheService: CacheService, // 12 | private readonly configService: ConfigService, 13 | ) {} 14 | 15 | async fetchData(cacheKey: string): Promise { 16 | const ftCheckinData = await this.cacheService.get(cacheKey); 17 | 18 | if (!ftCheckinData) { 19 | logger.error(`Can't get data from cache with key: ${cacheKey}`); 20 | errorHook( 21 | 'GetCheckInDataFromRedisError', 22 | `Can't get data from cache with key: ${cacheKey}`, 23 | this.configService.get('SLACK_HOOK_URL'), 24 | ); 25 | return { 26 | gaepo: 0, 27 | seocho: 0, 28 | }; 29 | } 30 | return ftCheckinData; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/api/src/intra-auth/intra-auth.module.ts: -------------------------------------------------------------------------------- 1 | import { UserModule } from '@api/user/user.module'; 2 | import { CacheModule } from '@app/common/cache/cache.module'; 3 | import { IntraAuth } from '@app/entity/intra-auth/intra-auth.entity'; 4 | import { Module } from '@nestjs/common'; 5 | import { ConfigModule, ConfigService } from '@nestjs/config'; 6 | import { TypeOrmModule } from '@nestjs/typeorm'; 7 | import { IntraAuthController } from './intra-auth.controller'; 8 | import { IntraAuthService } from './intra-auth.service'; 9 | import StibeeService from './stibee.service'; 10 | 11 | @Module({ 12 | imports: [UserModule, CacheModule, TypeOrmModule.forFeature([IntraAuth]), ConfigModule], 13 | controllers: [IntraAuthController], 14 | providers: [ 15 | IntraAuthService, 16 | { 17 | provide: 'MailService', 18 | inject: [ConfigService], 19 | useFactory: async (config: ConfigService) => { 20 | return new StibeeService(config); 21 | }, 22 | }, 23 | { 24 | provide: 'UnsubscribeStibeeService', 25 | useClass: StibeeService, 26 | }, 27 | ], 28 | exports: [IntraAuthService], 29 | }) 30 | export class IntraAuthModule {} 31 | -------------------------------------------------------------------------------- /apps/api/src/article/service/search-article-api.service.ts: -------------------------------------------------------------------------------- 1 | import { ArticleService } from '@api/article/article.service'; 2 | import { CategoryService } from '@api/category/category.service'; 3 | import { PaginationRequestDto } from '@api/pagination/dto/pagination-request.dto'; 4 | import { Article } from '@app/entity/article/article.entity'; 5 | import { User } from '@app/entity/user/user.entity'; 6 | import { Injectable } from '@nestjs/common'; 7 | 8 | @Injectable() 9 | export class SearchArticleApiService { 10 | constructor( 11 | private readonly articleService: ArticleService, // 12 | private readonly categoryService: CategoryService, 13 | ) {} 14 | 15 | /** 16 | * 게시글 검색 17 | * 18 | * @param user 검색하는 유저 19 | * @param q 검색어 20 | * @param categoryId 카테고리 ID 21 | * @param options 페이징 옵션 22 | * @returns 23 | */ 24 | async search( 25 | user: User, 26 | q: string, 27 | categoryId: number | undefined, 28 | options: PaginationRequestDto, 29 | ): Promise<{ articles: Article[]; totalCount: number }> { 30 | const availableCategories = await this.categoryService.getAvailable(user); 31 | 32 | return await this.articleService.search(q, categoryId, options, availableCategories); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /apps/api/test/e2e/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { HttpExceptionFilter } from '@app/common/filters/http-exception.filter'; 2 | import { INestApplication, ValidationPipe } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { TestingModule } from '@nestjs/testing'; 5 | import * as cookieParser from 'cookie-parser'; 6 | import { DataSource } from 'typeorm'; 7 | 8 | export const clearDB = async (dataSource: DataSource) => { 9 | const entities = dataSource.entityMetadatas; 10 | for (const entity of entities) { 11 | const repository = dataSource.getRepository(entity.name); 12 | await repository.query(`TRUNCATE TABLE ${entity.tableName}`); 13 | } 14 | }; 15 | 16 | export const createTestApp = (moduleFixture: TestingModule): INestApplication => { 17 | const app = moduleFixture.createNestApplication(); 18 | 19 | const configService = app.get(ConfigService); 20 | 21 | app.use(cookieParser()); 22 | app.useGlobalFilters(new HttpExceptionFilter(configService)); 23 | app.useGlobalPipes( 24 | new ValidationPipe({ 25 | whitelist: true, 26 | forbidNonWhitelisted: true, 27 | transform: true, 28 | }), 29 | ); 30 | (app as any).set('views', './apps/api/views'); 31 | return app; 32 | }; 33 | -------------------------------------------------------------------------------- /apps/api/views/intra-auth/results.ejs: -------------------------------------------------------------------------------- 1 |
2 |
3 | 42world logo 8 |
9 |
10 |

<%= locals.title %>

11 |

<%= locals.message %>

12 | 13 | 39 |
- 42WORLD -
40 |
41 |
42 | -------------------------------------------------------------------------------- /apps/api/src/comment/dto/base-comment.dto.ts: -------------------------------------------------------------------------------- 1 | import { ArticleResponseDto } from '@api/article/dto/response/article-response.dto'; 2 | import { AnonyUserResponseDto } from '@api/user/dto/response/anony-user-response.dto'; 3 | import { UserResponseDto } from '@api/user/dto/response/user-response.dto'; 4 | import { ApiProperty } from '@nestjs/swagger'; 5 | import { IsInt, IsNotEmpty, IsString, MaxLength, Min } from 'class-validator'; 6 | 7 | export class BaseCommentDto { 8 | @ApiProperty() 9 | id!: number; 10 | 11 | @IsString() 12 | @MaxLength(420) 13 | @IsNotEmpty() 14 | @ApiProperty({ example: '댓글 입니다.' }) 15 | content!: string; 16 | 17 | @ApiProperty() 18 | likeCount!: number; 19 | 20 | @IsInt() 21 | @Min(0) 22 | @IsNotEmpty() 23 | @ApiProperty({ example: 1 }) 24 | articleId!: number; 25 | 26 | @ApiProperty({ type: () => ArticleResponseDto }) 27 | article?: ArticleResponseDto; 28 | 29 | @IsInt() 30 | @Min(0) 31 | @IsNotEmpty() 32 | @ApiProperty() 33 | writerId!: number; 34 | 35 | @ApiProperty({ type: () => UserResponseDto }) 36 | writer?: UserResponseDto | AnonyUserResponseDto; 37 | 38 | @ApiProperty() 39 | createdAt!: Date; 40 | 41 | @ApiProperty() 42 | updatedAt!: Date; 43 | 44 | @ApiProperty() 45 | deletedAt?: Date; 46 | } 47 | -------------------------------------------------------------------------------- /libs/common/src/database/seeder/category/data.ts: -------------------------------------------------------------------------------- 1 | import { Category } from '@app/entity/category/category.entity'; 2 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 3 | import { PartialType } from '@nestjs/mapped-types'; 4 | 5 | export class SeederDataCategory extends PartialType(Category) { 6 | id: number; 7 | name: string; 8 | } 9 | 10 | export const categories: SeederDataCategory[] = [ 11 | { 12 | id: 1, 13 | name: '자유게시판', 14 | writableArticle: UserRole.CADET, 15 | readableArticle: UserRole.CADET, 16 | writableComment: UserRole.CADET, 17 | readableComment: UserRole.CADET, 18 | reactionable: UserRole.CADET, 19 | anonymity: false, 20 | }, 21 | { 22 | id: 2, 23 | name: '익명게시판', 24 | writableArticle: UserRole.CADET, 25 | readableArticle: UserRole.CADET, 26 | writableComment: UserRole.CADET, 27 | readableComment: UserRole.CADET, 28 | reactionable: UserRole.CADET, 29 | anonymity: true, 30 | }, 31 | { 32 | id: 3, 33 | name: '42born2code 공지', 34 | writableArticle: UserRole.ADMIN, 35 | readableArticle: UserRole.CADET, 36 | writableComment: UserRole.ADMIN, 37 | readableComment: UserRole.ADMIN, 38 | reactionable: UserRole.ADMIN, 39 | anonymity: false, 40 | }, 41 | ]; 42 | -------------------------------------------------------------------------------- /apps/api/src/comment/comment-api.module.ts: -------------------------------------------------------------------------------- 1 | import { ArticleModule } from '@api/article/article.module'; 2 | import { CategoryModule } from '@api/category/category.module'; 3 | import { CommentApiController } from '@api/comment/comment-api.controller'; 4 | import { CommentModule } from '@api/comment/comment.module'; 5 | import { CreateCommentApiService } from '@api/comment/services/create-comment-api.service'; 6 | import { GetCommentApiService } from '@api/comment/services/get-comment-api.service'; 7 | import { RemoveCommentApiService } from '@api/comment/services/remove-comment-api.service'; 8 | import { UpdateCommentApiService } from '@api/comment/services/update-comment-api.service'; 9 | import { NotificationModule } from '@api/notification/notification.module'; 10 | import { Module } from '@nestjs/common'; 11 | import {ReactionModule} from "@api/reaction/reaction.module"; 12 | import {GetCommentApiController} from "@api/comment/get-comment-api.controller"; 13 | 14 | @Module({ 15 | imports: [CommentModule, ArticleModule, CategoryModule, NotificationModule, ReactionModule], 16 | providers: [CreateCommentApiService, UpdateCommentApiService, RemoveCommentApiService, GetCommentApiService], 17 | controllers: [CommentApiController, GetCommentApiController], 18 | }) 19 | export class CommentApiModule {} 20 | -------------------------------------------------------------------------------- /apps/api/views/intra-auth/signin.ejs: -------------------------------------------------------------------------------- 1 |
2 |
3 | 42world logo 8 |
9 |
10 |

<%= locals.nickname %>님 안녕하세요!

11 |

아래 버튼을 눌러 인증을 완료해주세요

12 |
25 | 인증 하기 37 |
38 |

39 | 요청하신 Github ID가 <%= locals.github %>와 일치하는지 확인해주세요. 40 |

41 |

이 버튼은 30분 뒤 만료됩니다.

42 |
42WORLD에서 보냄.
43 |
44 |
45 | -------------------------------------------------------------------------------- /libs/common/src/database/seeder/article/data.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@app/entity/article/article.entity'; 2 | import { PartialType } from '@nestjs/mapped-types'; 3 | 4 | export class SeederDataArticle extends PartialType(Article) { 5 | categoryId?: number; 6 | writerId?: number; 7 | } 8 | 9 | export const articles: SeederDataArticle[] = [ 10 | { 11 | title: 'title1', 12 | content: 'haha', 13 | categoryId: 1, 14 | writerId: 1, 15 | }, 16 | { 17 | title: 'title2', 18 | content: 'haha haha', 19 | categoryId: 1, 20 | writerId: 2, 21 | }, 22 | { 23 | title: 'title3', 24 | content: 'haha haha haha', 25 | categoryId: 2, 26 | writerId: 1, 27 | }, 28 | { 29 | title: 'title4', 30 | content: 'haha haha haha', 31 | categoryId: 2, 32 | writerId: 1, 33 | }, 34 | { 35 | title: 'title5', 36 | content: 'haha haha haha', 37 | categoryId: 2, 38 | writerId: 1, 39 | }, 40 | { 41 | title: 'title6', 42 | content: 'haha haha haha', 43 | categoryId: 2, 44 | writerId: 1, 45 | }, 46 | { 47 | title: 'title7', 48 | content: 'haha haha haha', 49 | categoryId: 2, 50 | writerId: 1, 51 | }, 52 | { 53 | title: 'title8', 54 | content: 'haha haha haha', 55 | categoryId: 2, 56 | writerId: 1, 57 | }, 58 | ]; 59 | -------------------------------------------------------------------------------- /apps/api/src/auth/jwt-auth/jwt-auth.strategy.ts: -------------------------------------------------------------------------------- 1 | import { UserService } from '@api/user/user.service'; 2 | import { User } from '@app/entity/user/user.entity'; 3 | import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { PassportStrategy } from '@nestjs/passport'; 6 | import { ExtractJwt, Strategy } from 'passport-jwt'; 7 | import { JWTPayload } from '../types'; 8 | 9 | export const getAccessToken = (key: string, request: any): string => { 10 | return request.cookies[key]; 11 | }; 12 | 13 | @Injectable() 14 | export class JwtAuthStrategy extends PassportStrategy(Strategy) { 15 | constructor(private userService: UserService, private configService: ConfigService) { 16 | super({ 17 | jwtFromRequest: ExtractJwt.fromExtractors([getAccessToken.bind(null, configService.get('ACCESS_TOKEN_KEY'))]), 18 | ignoreExpiration: false, 19 | secretOrKey: configService.get('JWT_SECRET'), 20 | }); 21 | } 22 | 23 | async validate(payload: JWTPayload): Promise { 24 | try { 25 | return await this.userService.findOneByIdOrFail(payload.userId); 26 | } catch (e) { 27 | if (e instanceof NotFoundException) { 28 | throw new UnauthorizedException(); 29 | } 30 | throw e; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/api/src/notification/notification.controller.ts: -------------------------------------------------------------------------------- 1 | import { Auth, AuthUser } from '@api/auth/auth.decorator'; 2 | import { Controller, Get, Patch } from '@nestjs/common'; 3 | import { ApiCookieAuth, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger'; 4 | import { NotificationResponseDto } from './dto/response/notification-response.dto'; 5 | import { NotificationService } from './notification.service'; 6 | 7 | @ApiCookieAuth() 8 | @ApiUnauthorizedResponse({ description: '인증 실패' }) 9 | @ApiTags('Notification') 10 | @Controller('notifications') 11 | export class NotificationController { 12 | constructor(private readonly notificationService: NotificationService) {} 13 | 14 | @Get() 15 | @Auth() 16 | @ApiOperation({ summary: '알람 가져오기 ' }) 17 | @ApiOkResponse({ description: '알람들', type: [NotificationResponseDto] }) 18 | async findAll(@AuthUser('id') id: number): Promise { 19 | const notifications = await this.notificationService.findByUserId(id); 20 | 21 | return NotificationResponseDto.ofArray({ notifications }); 22 | } 23 | 24 | @Patch('/readall') 25 | @Auth() 26 | @ApiOperation({ summary: '알람 다 읽기' }) 27 | @ApiOkResponse({ description: '알림 다 읽음' }) 28 | async update(@AuthUser('id') id: number): Promise { 29 | return this.notificationService.updateIsReadByUserId(id); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /apps/api/src/ft-checkin/ft-checkin.controller.ts: -------------------------------------------------------------------------------- 1 | import { FT_CHECKIN_KEY, MAX_CHECKIN_KEY } from '@app/common/cache/dto/ft-checkin.constant'; 2 | import { FtCheckinDto } from '@app/common/cache/dto/ft-checkin.dto'; 3 | import { Controller, Get, NotFoundException } from '@nestjs/common'; 4 | import { ApiOkResponse, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; 5 | import { FtCheckinService } from './ft-checkin.service'; 6 | 7 | class FtCheckData { 8 | @ApiProperty() 9 | now: FtCheckinDto; 10 | @ApiProperty() 11 | max: FtCheckinDto; 12 | } 13 | 14 | @ApiTags('ft-checkin') 15 | @Controller('ft-checkin') 16 | export class FtCheckinController { 17 | constructor(private readonly ftCheckinService: FtCheckinService) {} 18 | 19 | @Get() 20 | @ApiOperation({ summary: '42checkin api' }) 21 | @ApiOkResponse({ 22 | description: '42체크인 user using api 결과', 23 | type: FtCheckData, 24 | }) 25 | async getFtCheckin(): Promise { 26 | try { 27 | const checkinData = await this.ftCheckinService.fetchData(FT_CHECKIN_KEY); 28 | 29 | const checkinInfo = await this.ftCheckinService.fetchData(MAX_CHECKIN_KEY); 30 | 31 | const data = { 32 | now: checkinData, 33 | max: checkinInfo, 34 | }; 35 | 36 | return data; 37 | } catch (e) { 38 | throw new NotFoundException('데이터를 받아올 수 없습니다'); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apps/api/src/notification/dto/response/notification-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Notification } from '@app/entity/notification/notification.entity'; 2 | import { PickType } from '@nestjs/swagger'; 3 | import { BaseNotificationDto } from '../base-notification.dto'; 4 | 5 | export class NotificationResponseDto extends PickType(BaseNotificationDto, [ 6 | 'id', 7 | 'type', 8 | 'content', 9 | 'articleId', 10 | 'isRead', 11 | 'createdAt', 12 | 'updatedAt', 13 | ]) { 14 | constructor(config: { 15 | id: number; 16 | type: string; 17 | content: string; 18 | articleId: number; 19 | isRead: boolean; 20 | createdAt: Date; 21 | updatedAt: Date; 22 | }) { 23 | super(); 24 | 25 | this.id = config.id; 26 | this.type = config.type; 27 | this.content = config.content; 28 | this.articleId = config.articleId; 29 | this.isRead = config.isRead; 30 | this.createdAt = config.createdAt; 31 | this.updatedAt = config.updatedAt; 32 | } 33 | 34 | static of(config: { notification: Notification }): NotificationResponseDto { 35 | return new NotificationResponseDto({ 36 | ...config.notification, 37 | ...config, 38 | }); 39 | } 40 | 41 | static ofArray(config: { notifications: Notification[] }): NotificationResponseDto[] { 42 | return config.notifications.map((notification) => { 43 | return NotificationResponseDto.of({ 44 | notification, 45 | }); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /apps/api/src/auth/github-auth/__test__/github-auth.guard.test.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | import { mockFn } from 'jest-mock-extended'; 3 | import { GithubAuthGuard } from '../github-auth.guard'; 4 | 5 | describe('GithubAuthGuard', () => { 6 | const guard = new GithubAuthGuard(); 7 | const mockSuperHandleRequest = mockFn(); 8 | 9 | beforeAll(async () => { 10 | // 부모 클래스 mock 처리 11 | (GithubAuthGuard.prototype as any).__proto__.handleRequest = mockSuperHandleRequest; 12 | }); 13 | 14 | beforeEach(async () => { 15 | mockSuperHandleRequest.mockClear(); 16 | jest.clearAllTimers(); 17 | }); 18 | 19 | describe('handleRequest', () => { 20 | test('정상적인 요청은 그대로 반환한다.', async () => { 21 | const githubProfile = { id: 1 }; 22 | mockSuperHandleRequest.mockReturnValue(githubProfile); 23 | 24 | const result = guard.handleRequest(null, null, null, null); 25 | 26 | expect(result).toBe(githubProfile); 27 | expect(mockSuperHandleRequest).toBeCalledTimes(1); 28 | }); 29 | 30 | test('정상적인 요청이 아니면 BadRequestException 에러를 던진다.', async () => { 31 | mockSuperHandleRequest.mockImplementation(() => { 32 | throw new Error('error'); 33 | }); 34 | 35 | const act = () => guard.handleRequest(null, null, null, null); 36 | 37 | expect(act).toThrowError(BadRequestException); 38 | expect(mockSuperHandleRequest).toBeCalledTimes(1); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /apps/api/src/best/best.service.ts: -------------------------------------------------------------------------------- 1 | import { ArticleService } from '@api/article/article.service'; 2 | import { PaginationRequestDto } from '@api/pagination/dto/pagination-request.dto'; 3 | import { Article } from '@app/entity/article/article.entity'; 4 | import { Best } from '@app/entity/best/best.entity'; 5 | import { Injectable, NotFoundException } from '@nestjs/common'; 6 | import { InjectRepository } from '@nestjs/typeorm'; 7 | import { QueryFailedError, Repository } from 'typeorm'; 8 | import { CreateBestRequestDto } from './dto/request/create-best-request.dto'; 9 | 10 | @Injectable() 11 | export class BestService { 12 | constructor( 13 | @InjectRepository(Best) 14 | private readonly bestRepository: Repository, 15 | private readonly articleService: ArticleService, 16 | ) {} 17 | 18 | async createOrNot(createBestDto: CreateBestRequestDto): Promise { 19 | try { 20 | return await this.bestRepository.save(createBestDto); 21 | } catch (error) { 22 | if (error instanceof QueryFailedError) return; 23 | } 24 | } 25 | 26 | async findAll(findAllBestDto: PaginationRequestDto): Promise { 27 | return this.articleService.findAllBest(findAllBestDto); 28 | } 29 | 30 | async remove(id: number): Promise { 31 | const result = await this.bestRepository.delete({ id }); 32 | 33 | if (result.affected === 0) { 34 | throw new NotFoundException(`Can't find Best with id ${id}`); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/api/src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@app/entity/user/user.entity'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { UpdateUserProfileRequestDto } from './dto/request/update-user-profile-request.dto'; 4 | import { UpdateToCadetDto } from './dto/update-user-to-cadet.dto'; 5 | import { UserRepository } from './repositories/user.repository'; 6 | 7 | @Injectable() 8 | export class UserService { 9 | constructor(private readonly userRepository: UserRepository) {} 10 | 11 | async create(user: User): Promise { 12 | return this.userRepository.save(user); 13 | } 14 | 15 | async findOneByGithubUId(githubUid: string): Promise { 16 | return this.userRepository.findOne({ where: { githubUid } }); 17 | } 18 | 19 | async findOneByIdOrFail(id: number): Promise { 20 | return this.userRepository.findOneOrFail({ where: { id } }); 21 | } 22 | 23 | async updateProfile(user: User, updateUserProfileDto: UpdateUserProfileRequestDto): Promise { 24 | return this.userRepository.save({ 25 | ...user, 26 | ...updateUserProfileDto, 27 | }); 28 | } 29 | 30 | async updateToCadet(user: User, updateToCadetDto: UpdateToCadetDto): Promise { 31 | return this.userRepository.save({ ...user, ...updateToCadetDto }); 32 | } 33 | 34 | async update(user: User): Promise { 35 | return this.userRepository.save(user); 36 | } 37 | 38 | async remove(id: number): Promise { 39 | await this.userRepository.softDelete({ id }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /libs/common/src/filters/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '@app/utils/logger'; 2 | import { errorHook } from '@app/utils/utils'; 3 | import { 4 | ArgumentsHost, 5 | Catch, 6 | ExceptionFilter, 7 | HttpException, 8 | InternalServerErrorException, 9 | NotFoundException, 10 | } from '@nestjs/common'; 11 | import { ConfigService } from '@nestjs/config'; 12 | import { Response } from 'express'; 13 | import { EntityNotFoundError } from 'typeorm/error/EntityNotFoundError'; 14 | 15 | const FIND_DOUBLE_QUOTE = /\"/g; 16 | 17 | @Catch() 18 | export class HttpExceptionFilter implements ExceptionFilter { 19 | constructor(private readonly configService: ConfigService) {} 20 | 21 | public catch(exception: Error, host: ArgumentsHost) { 22 | const ctx = host.switchToHttp(); 23 | const response = ctx.getResponse(); 24 | const request = ctx.getRequest(); 25 | 26 | if (exception instanceof EntityNotFoundError) { 27 | const message = exception.message.replace(FIND_DOUBLE_QUOTE, ''); 28 | exception = new NotFoundException(message); 29 | } else if (!(exception instanceof HttpException)) { 30 | logger.error(`${request.url} ${exception.name} ${exception.message}`); 31 | errorHook(exception.name, exception.message, this.configService.get('SLACK_HOOK_URL')); 32 | exception = new InternalServerErrorException('Unknown Error'); 33 | } 34 | 35 | return response.status((exception as HttpException).getStatus()).json((exception as HttpException).getResponse()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/api/src/user/dto/response/user-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 2 | import { User } from '@app/entity/user/user.entity'; 3 | import { PickType } from '@nestjs/swagger'; 4 | import { 5 | ANONY_USER_CHARACTER, 6 | ANONY_USER_DATE, 7 | ANONY_USER_ID, 8 | ANONY_USER_NICKNAME, 9 | ANONY_USER_ROLE, 10 | } from '../../user.constant'; 11 | import { BaseUserDto } from '../base-user.dto'; 12 | 13 | export class UserResponseDto extends PickType(BaseUserDto, [ 14 | 'id', 15 | 'nickname', 16 | 'role', 17 | 'character', 18 | 'createdAt', 19 | 'updatedAt', 20 | ]) { 21 | constructor(config: { 22 | id: number; 23 | nickname: string; 24 | role: UserRole; 25 | character: number; 26 | createdAt: Date; 27 | updatedAt: Date; 28 | }) { 29 | super(); 30 | 31 | this.id = config.id; 32 | this.nickname = config.nickname; 33 | this.role = config.role; 34 | this.character = config.character; 35 | this.createdAt = config.createdAt; 36 | this.updatedAt = config.updatedAt; 37 | } 38 | 39 | static of({ user, isAnonymous = false }: { user: User; isAnonymous?: boolean }): UserResponseDto { 40 | if (isAnonymous) { 41 | return new UserResponseDto({ 42 | id: ANONY_USER_ID, 43 | nickname: ANONY_USER_NICKNAME, 44 | role: ANONY_USER_ROLE, 45 | character: ANONY_USER_CHARACTER, 46 | createdAt: ANONY_USER_DATE, 47 | updatedAt: ANONY_USER_DATE, 48 | }); 49 | } 50 | 51 | return new UserResponseDto(user); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /libs/entity/src/reaction/reaction-article.entity.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@app/entity/article/article.entity'; 2 | import { User } from '@app/entity/user/user.entity'; 3 | import { 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | Index, 8 | JoinColumn, 9 | ManyToOne, 10 | PrimaryGeneratedColumn, 11 | UpdateDateColumn, 12 | } from 'typeorm'; 13 | 14 | export enum ReactionArticleType { 15 | LIKE = 'LIKE', 16 | } 17 | 18 | @Entity('reaction_article') 19 | export class ReactionArticle { 20 | @PrimaryGeneratedColumn() 21 | id!: number; 22 | 23 | @Column({ nullable: false }) 24 | @Index('ix_user_id') 25 | userId!: number; 26 | 27 | @Column({ 28 | type: 'enum', 29 | enum: ReactionArticleType, 30 | nullable: false, 31 | default: ReactionArticleType.LIKE, 32 | }) 33 | type!: ReactionArticleType; 34 | 35 | @Column({ nullable: false }) 36 | @Index('ix_article_id') 37 | articleId!: number; 38 | 39 | @CreateDateColumn({ type: 'timestamp' }) 40 | createdAt!: Date; 41 | 42 | @UpdateDateColumn({ type: 'timestamp' }) 43 | updatedAt!: Date; 44 | 45 | @ManyToOne(() => User, (user) => user.reactionArticle, { 46 | createForeignKeyConstraints: false, 47 | nullable: false, 48 | }) 49 | @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) 50 | user?: User; 51 | 52 | @ManyToOne(() => Article, (article) => article.reactionArticle, { 53 | createForeignKeyConstraints: false, 54 | nullable: false, 55 | }) 56 | @JoinColumn({ name: 'article_id', referencedColumnName: 'id' }) 57 | article?: Article; 58 | } 59 | -------------------------------------------------------------------------------- /apps/api/src/article/dto/article.mapper.ts: -------------------------------------------------------------------------------- 1 | import { ArticleResponseDto } from '@api/article/dto/response/article-response.dto'; 2 | import { CategoryResponseDto } from '@api/category/dto/response/category-response.dto'; 3 | import { UserResponseDto } from '@api/user/dto/response/user-response.dto'; 4 | import { Article } from '@app/entity/article/article.entity'; 5 | import { User } from '@app/entity/user/user.entity'; 6 | 7 | export class ArticleDtoMapper { 8 | static toResponseDto({ 9 | article, 10 | user, 11 | isLike, 12 | }: { 13 | article: Article; 14 | user: User; 15 | isLike?: boolean; 16 | }): ArticleResponseDto { 17 | const category = CategoryResponseDto.of({ category: article.category, user }); 18 | const writer = UserResponseDto.of({ user: article.writer, isAnonymous: category.isAnonymous }); 19 | 20 | return new ArticleResponseDto({ 21 | id: article.id, 22 | title: article.title, 23 | content: article.content, 24 | viewCount: article.viewCount, 25 | categoryId: article.categoryId, 26 | category, 27 | writerId: writer.id, 28 | writer, 29 | commentCount: article.commentCount, 30 | likeCount: article.likeCount, 31 | createdAt: article.createdAt, 32 | updatedAt: article.updatedAt, 33 | isSelf: user.id === article.writer.id, 34 | isLike, 35 | }); 36 | } 37 | 38 | static toResponseDtoList({ articles, user }: { articles: Article[]; user: User }): ArticleResponseDto[] { 39 | return articles.map((article) => this.toResponseDto({ article, user })); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apps/api/src/comment/services/create-comment-api.service.ts: -------------------------------------------------------------------------------- 1 | import { ArticleService } from '@api/article/article.service'; 2 | import { CategoryService } from '@api/category/category.service'; 3 | import { CommentService } from '@api/comment/services/comment.service'; 4 | import { NotificationService } from '@api/notification/notification.service'; 5 | import { Comment } from '@app/entity/comment/comment.entity'; 6 | import { User } from '@app/entity/user/user.entity'; 7 | import { Injectable } from '@nestjs/common'; 8 | 9 | @Injectable() 10 | export class CreateCommentApiService { 11 | constructor( 12 | private readonly commentService: CommentService, 13 | private readonly categoryService: CategoryService, 14 | private readonly articleService: ArticleService, 15 | private readonly notificationService: NotificationService, 16 | ) {} 17 | 18 | async create(props: { writer: User; content: string; articleId: number }): Promise { 19 | const article = await this.articleService.findOneByIdOrFail(props.articleId); 20 | await this.categoryService.checkAvailable(props.writer, article.categoryId, 'writableComment'); 21 | 22 | const comment = Comment.createComment({ 23 | content: props.content, 24 | articleId: props.articleId, 25 | writerId: props.writer.id, 26 | }); 27 | await this.commentService.save(comment); 28 | 29 | if (props.writer.id !== article.writerId) { 30 | await this.notificationService.createNewComment(article, comment); 31 | } 32 | await this.articleService.increaseCommentCount(article.id); 33 | return comment; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /apps/admin/src/entity/reaction/reaction-article.entity.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@admin/entity/article/article.entity'; 2 | import { User } from '@admin/entity/user/user.entity'; 3 | import { 4 | BaseEntity, 5 | Column, 6 | CreateDateColumn, 7 | Entity, 8 | Index, 9 | JoinColumn, 10 | ManyToOne, 11 | PrimaryGeneratedColumn, 12 | UpdateDateColumn, 13 | } from 'typeorm'; 14 | 15 | export enum ReactionArticleType { 16 | LIKE = 'LIKE', 17 | } 18 | 19 | @Entity('reaction_article') 20 | export class ReactionArticle extends BaseEntity { 21 | @PrimaryGeneratedColumn() 22 | id!: number; 23 | 24 | @Column({ nullable: false }) 25 | @Index('ix_user_id') 26 | userId!: number; 27 | 28 | @Column({ 29 | type: 'enum', 30 | enum: ReactionArticleType, 31 | nullable: false, 32 | default: ReactionArticleType.LIKE, 33 | }) 34 | type!: ReactionArticleType; 35 | 36 | @Column({ nullable: false }) 37 | @Index('ix_article_id') 38 | articleId!: number; 39 | 40 | @CreateDateColumn({ type: 'timestamp' }) 41 | createdAt!: Date; 42 | 43 | @UpdateDateColumn({ type: 'timestamp' }) 44 | updatedAt!: Date; 45 | 46 | @ManyToOne(() => User, (user) => user.reactionArticle, { 47 | createForeignKeyConstraints: false, 48 | nullable: false, 49 | }) 50 | @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) 51 | user?: User; 52 | 53 | @ManyToOne(() => Article, (article) => article.reactionArticle, { 54 | createForeignKeyConstraints: false, 55 | nullable: false, 56 | }) 57 | @JoinColumn({ name: 'article_id', referencedColumnName: 'id' }) 58 | article?: Article; 59 | } 60 | -------------------------------------------------------------------------------- /apps/batch/src/ft-checkin/ft-checkin.service.ts: -------------------------------------------------------------------------------- 1 | import { CacheService } from '@app/common/cache/cache.service'; 2 | import { FT_CHECKIN_KEY, MAX_CHECKIN_KEY } from '@app/common/cache/dto/ft-checkin.constant'; 3 | import { logger } from '@app/utils/logger'; 4 | import { Injectable, NotFoundException } from '@nestjs/common'; 5 | import { Cron } from '@nestjs/schedule'; 6 | import axios from 'axios'; 7 | import { 8 | FT_CHECKIN_CACHE_TTL, 9 | FT_CHECKIN_END_POINT, 10 | GAEPO, 11 | MAX_CHECKIN_CACHE_TTL, 12 | MAX_CHECKIN_END_POINT, 13 | SEOCHO, 14 | } from './ft-checkin.constant'; 15 | 16 | @Injectable() 17 | export class FtCheckinService { 18 | constructor(private readonly cacheService: CacheService) {} 19 | 20 | private async getData(cacheKey: string, endpoint: string, cacheTTL: number): Promise { 21 | try { 22 | const { data } = await axios.get(endpoint); 23 | const realData = { 24 | seocho: data[SEOCHO] || 0, 25 | gaepo: data[GAEPO] || 0, 26 | }; 27 | 28 | await this.cacheService.set(cacheKey, realData, { 29 | ttl: cacheTTL, 30 | }); 31 | } catch (e) { 32 | throw new NotFoundException(`데이터를 받아올 수 없습니다. ${e.message}`); 33 | } 34 | } 35 | 36 | @Cron('0 */5 * * * *') 37 | async getNow() { 38 | await this.getData(FT_CHECKIN_KEY, FT_CHECKIN_END_POINT, FT_CHECKIN_CACHE_TTL); 39 | logger.info('FtCheckinService.getNow()'); 40 | } 41 | 42 | @Cron('0 0 0 * * *') 43 | async getMax() { 44 | await this.getData(MAX_CHECKIN_KEY, MAX_CHECKIN_END_POINT, MAX_CHECKIN_CACHE_TTL); 45 | logger.info('FtCheckinService.getMax()'); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /libs/entity/src/category/category.entity.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@app/entity/article/article.entity'; 2 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 3 | import { 4 | Column, 5 | CreateDateColumn, 6 | DeleteDateColumn, 7 | Entity, 8 | Index, 9 | OneToMany, 10 | PrimaryGeneratedColumn, 11 | UpdateDateColumn, 12 | } from 'typeorm'; 13 | 14 | @Entity('category') 15 | export class Category { 16 | @PrimaryGeneratedColumn() 17 | id!: number; 18 | 19 | @Column({ type: 'varchar', length: 40, nullable: false }) 20 | name!: string; 21 | 22 | @Column({ type: 'enum', enum: UserRole, default: UserRole.CADET }) 23 | writableArticle!: string; 24 | 25 | @Column({ type: 'enum', enum: UserRole, default: UserRole.CADET }) 26 | readableArticle!: string; 27 | 28 | @Column({ type: 'enum', enum: UserRole, default: UserRole.CADET }) 29 | writableComment!: string; 30 | 31 | @Column({ type: 'enum', enum: UserRole, default: UserRole.CADET }) 32 | readableComment!: string; 33 | 34 | @Column({ type: 'enum', enum: UserRole, default: UserRole.CADET }) 35 | reactionable!: string; 36 | 37 | @Column({ nullable: false, default: false }) 38 | anonymity!: boolean; 39 | 40 | @CreateDateColumn({ type: 'timestamp' }) 41 | createdAt!: Date; 42 | 43 | @UpdateDateColumn({ type: 'timestamp' }) 44 | updatedAt!: Date; 45 | 46 | @DeleteDateColumn({ type: 'timestamp' }) 47 | @Index('ix_deleted_at') 48 | deletedAt?: Date; 49 | 50 | @OneToMany(() => Article, (article) => article.category, { 51 | createForeignKeyConstraints: false, 52 | nullable: true, 53 | }) 54 | article?: Article[]; 55 | } 56 | -------------------------------------------------------------------------------- /libs/entity/src/notification/notification.entity.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@app/entity/article/article.entity'; 2 | import { User } from '@app/entity/user/user.entity'; 3 | import { 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | Index, 8 | JoinColumn, 9 | ManyToOne, 10 | PrimaryGeneratedColumn, 11 | UpdateDateColumn, 12 | } from 'typeorm'; 13 | import { NotificationType } from './interfaces/notifiaction.interface'; 14 | 15 | @Entity('notification') 16 | export class Notification { 17 | @PrimaryGeneratedColumn() 18 | id!: number; 19 | 20 | @Column({ 21 | type: 'enum', 22 | enum: NotificationType, 23 | default: NotificationType.FROM_ADMIN, 24 | }) 25 | type!: string; 26 | 27 | @Column({ type: 'text', nullable: false }) 28 | content!: string; 29 | 30 | @Column({ nullable: false }) 31 | articleId: number; 32 | 33 | @ManyToOne(() => Article, (article) => article.notification, { 34 | createForeignKeyConstraints: false, 35 | nullable: false, 36 | }) 37 | @JoinColumn({ name: 'article_id', referencedColumnName: 'id' }) 38 | article?: Article; 39 | 40 | @Column({ nullable: false, default: false }) 41 | isRead!: boolean; 42 | 43 | @Column({ nullable: false }) 44 | @Index('ix_user_id') 45 | userId!: number; 46 | 47 | @ManyToOne(() => User, (user) => user.notification, { 48 | createForeignKeyConstraints: false, 49 | nullable: false, 50 | }) 51 | @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) 52 | user?: User; 53 | 54 | @CreateDateColumn({ type: 'timestamp' }) 55 | createdAt!: Date; 56 | 57 | @UpdateDateColumn({ type: 'timestamp' }) 58 | updatedAt!: Date; 59 | } 60 | -------------------------------------------------------------------------------- /libs/utils/src/logger.ts: -------------------------------------------------------------------------------- 1 | import { PHASE } from '@app/utils/phase'; 2 | import 'process'; 3 | import { createLogger, format, transports } from 'winston'; 4 | import 'winston-daily-rotate-file'; 5 | 6 | const logDir = 'logs'; 7 | 8 | const { combine, timestamp, printf } = format; 9 | 10 | const logFormat = printf(({ level, message, timestamp }) => { 11 | return `${timestamp} ${level}: ${message}`; 12 | }); 13 | 14 | const options = { 15 | file: { 16 | level: 'info', 17 | filename: `%DATE%.log`, 18 | dirname: logDir, 19 | maxFiles: 30, 20 | zippedArchive: true, 21 | format: combine(timestamp(), format.json()), 22 | }, 23 | console: { 24 | level: 'debug', 25 | handleExceptions: true, 26 | json: false, 27 | colorize: true, 28 | format: combine( 29 | format.colorize(), 30 | timestamp({ 31 | format: 'YYYY-MM-DD HH:mm:ss', 32 | }), 33 | logFormat, 34 | ), 35 | }, 36 | }; 37 | 38 | export const logger = createLogger({ 39 | transports: [ 40 | new transports.DailyRotateFile(options.file), 41 | new transports.DailyRotateFile({ 42 | ...options.file, 43 | level: 'error', 44 | filename: `%DATE%.error.log`, 45 | }), 46 | ], 47 | exceptionHandlers: [ 48 | new transports.DailyRotateFile({ 49 | ...options.file, 50 | level: 'error', 51 | filename: `%DATE%.exception.log`, 52 | }), 53 | ], 54 | }); 55 | 56 | // morgan wiston 설정 57 | export const stream = { 58 | write: (message) => { 59 | logger.info(message); 60 | }, 61 | }; 62 | 63 | if (PHASE !== 'prod' && PHASE !== 'test') { 64 | logger.add(new transports.Console(options.console)); 65 | } 66 | -------------------------------------------------------------------------------- /apps/admin/src/entity/category/category.entity.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@admin/entity/article/article.entity'; 2 | import { UserRole } from '@admin/entity/user/interfaces/userrole.interface'; 3 | import { 4 | BaseEntity, 5 | Column, 6 | CreateDateColumn, 7 | DeleteDateColumn, 8 | Entity, 9 | Index, 10 | OneToMany, 11 | PrimaryGeneratedColumn, 12 | UpdateDateColumn, 13 | } from 'typeorm'; 14 | 15 | @Entity('category') 16 | export class Category extends BaseEntity { 17 | @PrimaryGeneratedColumn() 18 | id!: number; 19 | 20 | @Column({ type: 'varchar', length: 40, nullable: false }) 21 | name!: string; 22 | 23 | @Column({ type: 'enum', enum: UserRole, default: UserRole.CADET }) 24 | writableArticle!: string; 25 | 26 | @Column({ type: 'enum', enum: UserRole, default: UserRole.CADET }) 27 | readableArticle!: string; 28 | 29 | @Column({ type: 'enum', enum: UserRole, default: UserRole.CADET }) 30 | writableComment!: string; 31 | 32 | @Column({ type: 'enum', enum: UserRole, default: UserRole.CADET }) 33 | readableComment!: string; 34 | 35 | @Column({ type: 'enum', enum: UserRole, default: UserRole.CADET }) 36 | reactionable!: string; 37 | 38 | @Column({ nullable: false, default: false }) 39 | anonymity!: boolean; 40 | 41 | @CreateDateColumn({ type: 'timestamp' }) 42 | createdAt!: Date; 43 | 44 | @UpdateDateColumn({ type: 'timestamp' }) 45 | updatedAt!: Date; 46 | 47 | @DeleteDateColumn({ type: 'timestamp' }) 48 | @Index('ix_deleted_at') 49 | deletedAt?: Date; 50 | 51 | @OneToMany(() => Article, (article) => article.category, { 52 | createForeignKeyConstraints: false, 53 | nullable: true, 54 | }) 55 | article?: Article[]; 56 | } 57 | -------------------------------------------------------------------------------- /libs/common/src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { PHASE } from '@app/utils/phase'; 2 | import { DynamicModule } from '@nestjs/common'; 3 | import { ConfigModule, ConfigService } from '@nestjs/config'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; 6 | 7 | export class DatabaseModule { 8 | static register(): DynamicModule { 9 | return { 10 | module: DatabaseModule, 11 | imports: [ 12 | TypeOrmModule.forRootAsync({ 13 | imports: [ConfigModule], 14 | inject: [ConfigService], 15 | useFactory: async (configService: ConfigService) => { 16 | return { 17 | type: 'mysql', 18 | host: configService.get('DB_HOST'), 19 | port: parseInt(configService.get('DB_PORT'), 10), 20 | username: configService.get('DB_USER_NAME'), 21 | password: configService.get('DB_USER_PASSWORD'), 22 | database: configService.get('DB_NAME'), 23 | entities: [__dirname + '../../../../**/*.entity{.ts,.js}'], 24 | namingStrategy: new SnakeNamingStrategy(), 25 | 26 | timezone: 'Z', // UTC 27 | 28 | synchronize: false, 29 | migrationsRun: true, 30 | logging: PHASE === 'dev', 31 | 32 | migrations: [__dirname + '/migrations/**/*{.ts,.js}'], 33 | cli: { 34 | migrationsDir: 'src/database/migrations', 35 | }, 36 | retryAttempts: 5, 37 | keepConnectionAlive: false, 38 | }; 39 | }, 40 | }), 41 | ], 42 | }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /apps/admin/src/entity/notification/notification.entity.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@admin/entity/article/article.entity'; 2 | import { User } from '@admin/entity/user/user.entity'; 3 | import { 4 | BaseEntity, 5 | Column, 6 | CreateDateColumn, 7 | Entity, 8 | Index, 9 | JoinColumn, 10 | ManyToOne, 11 | PrimaryGeneratedColumn, 12 | UpdateDateColumn, 13 | } from 'typeorm'; 14 | import { NotificationType } from './interfaces/notifiaction.interface'; 15 | 16 | @Entity('notification') 17 | export class Notification extends BaseEntity { 18 | @PrimaryGeneratedColumn() 19 | id!: number; 20 | 21 | @Column({ 22 | type: 'enum', 23 | enum: NotificationType, 24 | default: NotificationType.FROM_ADMIN, 25 | }) 26 | type!: string; 27 | 28 | @Column({ type: 'text', nullable: false }) 29 | content!: string; 30 | 31 | @Column({ nullable: false }) 32 | articleId: number; 33 | 34 | @ManyToOne(() => Article, (article) => article.notification, { 35 | createForeignKeyConstraints: false, 36 | nullable: false, 37 | }) 38 | @JoinColumn({ name: 'article_id', referencedColumnName: 'id' }) 39 | article?: Article; 40 | 41 | @Column({ nullable: false, default: false }) 42 | isRead!: boolean; 43 | 44 | @Column({ nullable: false }) 45 | @Index('ix_user_id') 46 | userId!: number; 47 | 48 | @ManyToOne(() => User, (user) => user.notification, { 49 | createForeignKeyConstraints: false, 50 | nullable: false, 51 | }) 52 | @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) 53 | user?: User; 54 | 55 | @CreateDateColumn({ type: 'timestamp' }) 56 | createdAt!: Date; 57 | 58 | @UpdateDateColumn({ type: 'timestamp' }) 59 | updatedAt!: Date; 60 | } 61 | -------------------------------------------------------------------------------- /apps/api/src/auth/auth.decorator.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 2 | import { User } from '@app/entity/user/user.entity'; 3 | import { createParamDecorator, ExecutionContext, SetMetadata } from '@nestjs/common'; 4 | import { REQUIRE_ROLES } from './constant'; 5 | import { AuthType, GithubProfile } from './types'; 6 | 7 | /** 8 | * @description 9 | * 1. Auth() 없음 => 누구나 사용가능(인증 X, 인가 X) 10 | * 2. Auth('public') or Auth('deny') => 누구나 접속가능하지만 권한따라 사용가능(인증 △, 인가 O) 11 | * 3. Auth() or Auth('deny', UserRole.GUEST) => 로그인한 사용자만 권한따라 사용가능(인증 O, 인가 O) 12 | * 4. Auth('only', UserRole.ADMIN) => 특정 사용자만 사용가능(인증 O, 인가 O) 13 | * 14 | * 1 번과 2번이 다른점 : 2번은 AuthUser를 쓸 수 있지만, 1번은 못씀! 15 | */ 16 | export const Auth = (allow?: AuthType | 'public', ...param: UserRole[]) => { 17 | if (!allow) { 18 | return SetMetadata(REQUIRE_ROLES, ['deny', UserRole.GUEST]); 19 | } 20 | 21 | if (allow === 'public') { 22 | return SetMetadata(REQUIRE_ROLES, ['deny']); 23 | } 24 | 25 | return SetMetadata(REQUIRE_ROLES, [allow, ...param]); 26 | }; 27 | 28 | /** 29 | * @description Request에 담긴 User를 가져온다. 30 | * @note Auth decorator를 써야 사용가능 31 | */ 32 | export const AuthUser = createParamDecorator((data: 'id' | null, ctx: ExecutionContext): User => { 33 | const req = ctx.switchToHttp().getRequest(); 34 | if (data) return req.user[data]; 35 | return req.user; 36 | }); 37 | 38 | /** 39 | * @description Request에 담긴 GithubProfile를 가져온다. 40 | * @note GithubAuthGuard 를 써야 사용가능 41 | */ 42 | export const ReqGithubProfile = createParamDecorator((data, ctx: ExecutionContext): GithubProfile => { 43 | const req = ctx.switchToHttp().getRequest(); 44 | return req.user; 45 | }); 46 | -------------------------------------------------------------------------------- /apps/api/src/notification/notification.service.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@app/entity/article/article.entity'; 2 | import { Comment } from '@app/entity/comment/comment.entity'; 3 | import { NotificationType } from '@app/entity/notification/interfaces/notifiaction.interface'; 4 | import { Notification } from '@app/entity/notification/notification.entity'; 5 | import { Injectable } from '@nestjs/common'; 6 | import { InjectRepository } from '@nestjs/typeorm'; 7 | import { Repository } from 'typeorm'; 8 | import { CreateNotificationDto } from './dto/create-notification.dto'; 9 | 10 | @Injectable() 11 | export class NotificationService { 12 | constructor( 13 | @InjectRepository(Notification) 14 | private readonly notificationRepository: Repository, 15 | ) {} 16 | 17 | async createNewComment(article: Article, comment: Comment): Promise { 18 | const notification = new CreateNotificationDto({ 19 | type: NotificationType.NEW_COMMENT, 20 | content: `게시글 ${article.title} 에 새로운 댓글이 달렸습니다.\n${comment.content}`, 21 | articleId: article.id, 22 | userId: article.writerId, 23 | }); 24 | return this.notificationRepository.save(notification); 25 | } 26 | 27 | async findByUserId(userId: number): Promise { 28 | return this.notificationRepository.find({ where: { userId }, order: { createdAt: 'DESC' } }); 29 | } 30 | 31 | async updateIsReadByUserId(userId: number): Promise { 32 | const notifications = await this.notificationRepository.find({ 33 | where: { userId, isRead: false }, 34 | }); 35 | notifications.forEach((notification) => (notification.isRead = true)); 36 | await this.notificationRepository.save(notifications); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /libs/common/src/database/migrations/1645622620898-intra-auth.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class intraAuth1645622620898 implements MigrationInterface { 4 | name = 'intraAuth1645622620898'; 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`DROP INDEX \`IDX_e2364281027b926b879fa2fa1e\` ON \`user\``); 8 | await queryRunner.query(`ALTER TABLE \`ft_auth\` RENAME TO \`intra_auth\``); 9 | await queryRunner.query(`ALTER TABLE \`user\` CHANGE \`oauth_token\` \`github_uid\` varchar(42) NOT NULL`); 10 | await queryRunner.query(`ALTER TABLE \`user\` ADD \`github_username\` varchar(20) NOT NULL AFTER \`character\``); 11 | await queryRunner.query(`UPDATE user SET github_username = nickname;`); 12 | await queryRunner.query( 13 | `UPDATE user, intra_auth SET user.nickname = intra_auth.intra_id WHERE user.id = intra_auth.user_id;`, 14 | ); 15 | await queryRunner.query( 16 | `ALTER TABLE \`user\` ADD UNIQUE INDEX \`IDX_07eccd596501ea0b6b1805a2f1\` (\`github_username\`)`, 17 | ); 18 | } 19 | 20 | public async down(queryRunner: QueryRunner): Promise { 21 | await queryRunner.query(`ALTER TABLE \`user\` DROP INDEX \`IDX_07eccd596501ea0b6b1805a2f1\``); 22 | await queryRunner.query(`UPDATE user SET nickname = github_username;`); 23 | await queryRunner.query(`ALTER TABLE \`user\` DROP COLUMN \`github_username\``); 24 | await queryRunner.query(`ALTER TABLE \`user\` CHANGE \`github_uid\` \`oauth_token\` varchar(42) NOT NULL`); 25 | await queryRunner.query(`ALTER TABLE \`intra_auth\` RENAME TO \`ft_auth\``); 26 | await queryRunner.query(`CREATE UNIQUE INDEX \`IDX_e2364281027b926b879fa2fa1e\` ON \`user\` (\`nickname\`)`); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/api/src/reaction/repositories/reaction-article.repository.ts: -------------------------------------------------------------------------------- 1 | import { PaginationRequestDto } from '@api/pagination/dto/pagination-request.dto'; 2 | import { ReactionArticle, ReactionArticleType } from '@app/entity/reaction/reaction-article.entity'; 3 | import { getPaginationSkip } from '@app/utils/utils'; 4 | import { Injectable } from '@nestjs/common'; 5 | import { DataSource, Repository } from 'typeorm'; 6 | 7 | @Injectable() 8 | export class ReactionArticleRepository extends Repository { 9 | constructor(dataSource: DataSource) { 10 | super(ReactionArticle, dataSource.createEntityManager()); 11 | } 12 | async isExist(userId: number, articleId: number, type: ReactionArticleType): Promise { 13 | const existQuery = await this.query(`SELECT EXISTS 14 | (SELECT * FROM reaction_article WHERE user_id=${userId} AND article_id=${articleId} AND type='${type}')`); 15 | return Object.values(existQuery[0])[0] === '1'; 16 | } 17 | 18 | async findAllArticleByUserId( 19 | userId: number, 20 | options: PaginationRequestDto, 21 | ): Promise<{ 22 | likeArticles: ReactionArticle[]; 23 | totalCount: number; 24 | }> { 25 | const query = this.createQueryBuilder('reactionArticle') 26 | .innerJoinAndSelect('reactionArticle.article', 'article') 27 | .leftJoinAndSelect('article.writer', 'writer') 28 | .leftJoinAndSelect('article.category', 'category') 29 | .where('reactionArticle.userId = :id', { id: userId }) 30 | .skip(getPaginationSkip(options)) 31 | .take(options.take) 32 | .orderBy('reactionArticle.createdAt', options.order); 33 | 34 | const likeArticles = await query.getMany(); 35 | const totalCount = await query.getCount(); 36 | 37 | return { likeArticles, totalCount }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | branches: ['**'] 6 | types: [labeled, unlabeled, opened, synchronize, reopened] 7 | 8 | # Cancel previous workflows if they are the same workflow on same ref (branch/tags) 9 | # with the same event (push/pull_request) even they are in progress. 10 | # This setting will help reduce the number of duplicated workflows. 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | test: 17 | if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }} 18 | runs-on: ubuntu-latest 19 | 20 | env: 21 | PHASE: test 22 | DB_HOST: ${{secrets.DB_HOST}} 23 | DB_NAME: ${{secrets.DB_NAME}} 24 | DB_PORT: ${{secrets.DB_PORT}} 25 | DB_USER_NAME: ${{secrets.DB_USER_NAME}} 26 | DB_USER_PASSWORD: ${{secrets.DB_USER_PASSWORD}} 27 | JWT_SECRET: ${{secrets.JWT_SECRET}} 28 | GITHUB_CALLBACK_URL: ${{secrets.NEST_GITHUB_CALLBACK_URL}} 29 | GITHUB_CLIENT_ID: ${{secrets.NEST_GITHUB_CLIENT_ID}} 30 | GITHUB_CLIENT_SECRET: ${{secrets.NEST_GITHUB_CLIENT_SECRET}} 31 | AWS_REGION: ${{secrets.AWS_REGION}} 32 | AWS_ACCESS_KEY: ${{secrets.AWS_ACCESS_KEY}} 33 | AWS_SECRET_KEY: ${{secrets.AWS_SECRET_KEY}} 34 | AWS_S3_UPLOAD_BUCKET: ${{secrets.AWS_S3_UPLOAD_BUCKET}} 35 | ACCESS_TOKEN_KEY: ${{secrets.ACCESS_TOKEN_KEY}} 36 | FRONT_URL: ${{secrets.FRONT_URL}} 37 | 38 | steps: 39 | - uses: actions/checkout@v3 40 | - name: Use Node.js 16.x 41 | uses: actions/setup-node@v2 42 | with: 43 | node-version: 16.x 44 | cache: 'yarn' 45 | - run: yarn 46 | - run: yarn typecheck 47 | - run: yarn test-set-infra 48 | - run: yarn test 49 | - run: yarn test:e2e 50 | -------------------------------------------------------------------------------- /apps/api/test/e2e/e2e-test.base.module.ts: -------------------------------------------------------------------------------- 1 | import { JwtAuthGuard } from '@api/auth/jwt-auth/jwt-auth.guard'; 2 | import { AWS_ACCESS_KEY, AWS_REGION, AWS_SECRET_KEY } from '@api/image/image.constant'; 3 | import { Module } from '@nestjs/common'; 4 | import { ConfigModule, ConfigService } from '@nestjs/config'; 5 | import { APP_GUARD } from '@nestjs/core'; 6 | import { TypeOrmModule } from '@nestjs/typeorm'; 7 | import { AwsSdkModule } from 'nest-aws-sdk'; 8 | import * as path from 'path'; 9 | import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; 10 | 11 | @Module({ 12 | imports: [ 13 | ConfigModule.forRoot({ 14 | envFilePath: 'infra/config/.env', 15 | isGlobal: true, 16 | cache: true, 17 | load: [], 18 | }), 19 | TypeOrmModule.forRoot({ 20 | type: 'mysql', 21 | host: process.env.DB_HOST, 22 | port: parseInt(process.env.DB_PORT, 10), 23 | username: process.env.DB_USER_NAME, 24 | password: process.env.DB_USER_PASSWORD, 25 | database: process.env.DB_NAME, 26 | entities: [path.join(__dirname, '../../../../libs/**/*.entity{.ts,.js}')], 27 | namingStrategy: new SnakeNamingStrategy(), 28 | synchronize: true, 29 | logging: false, 30 | }), 31 | AwsSdkModule.forRootAsync({ 32 | defaultServiceOptions: { 33 | imports: [ConfigModule], 34 | inject: [ConfigService], 35 | useFactory: (configService: ConfigService) => { 36 | return { 37 | region: configService.get(AWS_REGION), 38 | accessKeyId: configService.get(AWS_ACCESS_KEY), 39 | secretAccessKey: configService.get(AWS_SECRET_KEY), 40 | }; 41 | }, 42 | }, 43 | }), 44 | ], 45 | providers: [ 46 | { 47 | provide: APP_GUARD, 48 | useClass: JwtAuthGuard, 49 | }, 50 | ], 51 | }) 52 | export class E2eTestBaseModule {} 53 | -------------------------------------------------------------------------------- /apps/api/src/comment/get-comment-api.controller.ts: -------------------------------------------------------------------------------- 1 | import { Auth, AuthUser } from '@api/auth/auth.decorator'; 2 | import { CommentResponseDto } from '@api/comment/dto/response/comment-response.dto'; 3 | import { GetCommentApiService } from '@api/comment/services/get-comment-api.service'; 4 | import { PaginationRequestDto } from '@api/pagination/dto/pagination-request.dto'; 5 | import { PaginationResponseDto } from '@api/pagination/dto/pagination-response.dto'; 6 | import { ApiPaginatedResponse } from '@api/pagination/pagination.decorator'; 7 | import { User } from '@app/entity/user/user.entity'; 8 | import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common'; 9 | import { ApiCookieAuth, ApiOperation, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger'; 10 | 11 | @ApiCookieAuth() 12 | @ApiUnauthorizedResponse({ description: '인증 실패' }) 13 | @ApiTags('Comment') 14 | @Controller('articles') 15 | export class GetCommentApiController { 16 | constructor(private readonly getCommentApiService: GetCommentApiService) {} 17 | 18 | @Get(':id/comments') 19 | @Auth('public') 20 | @ApiOperation({ summary: '게시글 댓글 가져오기' }) 21 | @ApiPaginatedResponse(CommentResponseDto) 22 | async getComments( 23 | @AuthUser() user: User, 24 | @Param('id', ParseIntPipe) articleId: number, 25 | @Query() options: PaginationRequestDto, 26 | ): Promise> { 27 | const { comments, category, totalCount, reactionComments } = await this.getCommentApiService.getComments( 28 | user, 29 | articleId, 30 | options, 31 | ); 32 | 33 | return PaginationResponseDto.of({ 34 | data: CommentResponseDto.ofArray({ 35 | comments, 36 | reactionComments, 37 | userId: user.id, 38 | isAnonymous: category.anonymity, 39 | }), 40 | options, 41 | totalCount, 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /libs/common/src/database/migrations/1644473307391-add_category_roles.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class addCategoryRoles1644473307391 implements MigrationInterface { 4 | name = 'addCategoryRoles1644473307391'; 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query( 8 | `ALTER TABLE \`category\` ADD \`writable_article\` enum ('CADET', 'ADMIN', 'NOVICE') NOT NULL DEFAULT 'CADET'`, 9 | ); 10 | await queryRunner.query( 11 | `ALTER TABLE \`category\` ADD \`readable_article\` enum ('CADET', 'ADMIN', 'NOVICE') NOT NULL DEFAULT 'CADET'`, 12 | ); 13 | await queryRunner.query( 14 | `ALTER TABLE \`category\` ADD \`writable_comment\` enum ('CADET', 'ADMIN', 'NOVICE') NOT NULL DEFAULT 'CADET'`, 15 | ); 16 | await queryRunner.query( 17 | `ALTER TABLE \`category\` ADD \`readable_comment\` enum ('CADET', 'ADMIN', 'NOVICE') NOT NULL DEFAULT 'CADET'`, 18 | ); 19 | await queryRunner.query( 20 | `ALTER TABLE \`category\` ADD \`reactionable\` enum ('CADET', 'ADMIN', 'NOVICE') NOT NULL DEFAULT 'CADET'`, 21 | ); 22 | await queryRunner.query(`ALTER TABLE \`category\` ADD \`anonymity\` tinyint NOT NULL DEFAULT 0`); 23 | } 24 | 25 | public async down(queryRunner: QueryRunner): Promise { 26 | await queryRunner.query(`ALTER TABLE \`category\` DROP COLUMN \`anonymity\``); 27 | await queryRunner.query(`ALTER TABLE \`category\` DROP COLUMN \`reactionable\``); 28 | await queryRunner.query(`ALTER TABLE \`category\` DROP COLUMN \`readable_comment\``); 29 | await queryRunner.query(`ALTER TABLE \`category\` DROP COLUMN \`writable_comment\``); 30 | await queryRunner.query(`ALTER TABLE \`category\` DROP COLUMN \`readable_article\``); 31 | await queryRunner.query(`ALTER TABLE \`category\` DROP COLUMN \`writable_article\``); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/admin/src/entity/comment/comment.entity.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@admin/entity/article/article.entity'; 2 | import { ReactionComment } from '@admin/entity/reaction/reaction-comment.entity'; 3 | import { User } from '@admin/entity/user/user.entity'; 4 | import { 5 | BaseEntity, 6 | Column, 7 | CreateDateColumn, 8 | DeleteDateColumn, 9 | Entity, 10 | Index, 11 | JoinColumn, 12 | ManyToOne, 13 | OneToMany, 14 | PrimaryGeneratedColumn, 15 | UpdateDateColumn, 16 | } from 'typeorm'; 17 | 18 | @Entity('comment') 19 | export class Comment extends BaseEntity { 20 | @PrimaryGeneratedColumn() 21 | id!: number; 22 | 23 | @Column({ type: 'text', nullable: false }) 24 | content!: string; 25 | 26 | @Column({ default: 0 }) 27 | likeCount!: number; 28 | 29 | @Column({ nullable: false }) 30 | @Index('ix_article_id') 31 | articleId!: number; 32 | 33 | @ManyToOne(() => Article, (article) => article.comment, { 34 | createForeignKeyConstraints: false, 35 | nullable: false, 36 | }) 37 | @JoinColumn({ name: 'article_id', referencedColumnName: 'id' }) 38 | article?: Article; 39 | 40 | @Column({ nullable: false }) 41 | @Index('ix_writer_id') 42 | writerId!: number; 43 | 44 | @ManyToOne(() => User, (user) => user.comment, { 45 | createForeignKeyConstraints: false, 46 | nullable: false, 47 | }) 48 | @JoinColumn({ name: 'writer_id', referencedColumnName: 'id' }) 49 | writer?: User; 50 | 51 | @CreateDateColumn({ type: 'timestamp' }) 52 | createdAt!: Date; 53 | 54 | @UpdateDateColumn({ type: 'timestamp' }) 55 | updatedAt!: Date; 56 | 57 | @DeleteDateColumn({ type: 'timestamp' }) 58 | @Index('ix_deleted_at') 59 | deletedAt?: Date; 60 | 61 | @OneToMany(() => ReactionComment, (reactionComment) => reactionComment.comment, { 62 | createForeignKeyConstraints: false, 63 | nullable: true, 64 | }) 65 | reactionComment?: ReactionComment[]; 66 | } 67 | -------------------------------------------------------------------------------- /apps/api/src/article/service/find-article-api.service.ts: -------------------------------------------------------------------------------- 1 | import { ArticleService } from '@api/article/article.service'; 2 | import { CategoryService } from '@api/category/category.service'; 3 | import { PaginationRequestDto } from '@api/pagination/dto/pagination-request.dto'; 4 | import { ReactionService } from '@api/reaction/reaction.service'; 5 | import { Article } from '@app/entity/article/article.entity'; 6 | import { User } from '@app/entity/user/user.entity'; 7 | import { Injectable } from '@nestjs/common'; 8 | 9 | @Injectable() 10 | export class FindArticleApiService { 11 | constructor( 12 | private readonly articleService: ArticleService, // 13 | private readonly categoryService: CategoryService, 14 | private readonly reactionService: ReactionService, 15 | ) {} 16 | 17 | /** 18 | * 게시글 목록 조회 19 | * 20 | * @param user 조회하는 유저 21 | * @param categoryId 카테고리 ID 22 | * @param options 페이징 옵션 23 | * @returns 24 | */ 25 | async findAllByCategoryId( 26 | user: User, 27 | categoryId: number, 28 | options: PaginationRequestDto, 29 | ): Promise<{ articles: Article[]; totalCount: number }> { 30 | await this.categoryService.checkAvailable(user, categoryId, 'readableArticle'); 31 | 32 | return await this.articleService.findAllByCategoryId(categoryId, options); 33 | } 34 | 35 | /** 36 | * 게시글 조회 37 | * 38 | * @param user 조회하는 유저 39 | * @param articleId 게시글 ID 40 | * @returns 41 | */ 42 | async findOneById(user: User, articleId: number): Promise<{ article: Article; isLike: boolean }> { 43 | const article = await this.articleService.findOneById(user, articleId); 44 | const isLike = await this.reactionService.isMyReactionArticle(user.id, article.id); 45 | 46 | await this.categoryService.checkAvailable(user, article.categoryId, 'readableArticle'); 47 | await this.articleService.increaseViewCount(user, article); 48 | 49 | return { article, isLike }; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /libs/entity/src/reaction/reaction-comment.entity.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@app/entity/article/article.entity'; 2 | import { Comment } from '@app/entity/comment/comment.entity'; 3 | import { User } from '@app/entity/user/user.entity'; 4 | import { 5 | Column, 6 | CreateDateColumn, 7 | Entity, 8 | Index, 9 | JoinColumn, 10 | ManyToOne, 11 | PrimaryGeneratedColumn, 12 | UpdateDateColumn, 13 | } from 'typeorm'; 14 | 15 | export enum ReactionCommentType { 16 | LIKE = 'LIKE', 17 | } 18 | 19 | @Entity('reaction_comment') 20 | export class ReactionComment { 21 | @PrimaryGeneratedColumn() 22 | id!: number; 23 | 24 | @Column({ nullable: false }) 25 | @Index('ix_user_id') 26 | userId!: number; 27 | 28 | @Column({ 29 | type: 'enum', 30 | enum: ReactionCommentType, 31 | nullable: false, 32 | default: ReactionCommentType.LIKE, 33 | }) 34 | type!: ReactionCommentType; 35 | 36 | @Column({ nullable: false }) 37 | @Index('ix_comment_id') 38 | commentId!: number; 39 | 40 | @Column({ nullable: false }) 41 | @Index('ix_article_id') 42 | articleId!: number; 43 | 44 | @CreateDateColumn({ type: 'timestamp' }) 45 | createdAt!: Date; 46 | 47 | @UpdateDateColumn({ type: 'timestamp' }) 48 | updatedAt!: Date; 49 | 50 | @ManyToOne(() => User, (user) => user.reactionComment, { 51 | createForeignKeyConstraints: false, 52 | nullable: false, 53 | }) 54 | @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) 55 | user?: User; 56 | 57 | @ManyToOne(() => Comment, (comment) => comment.reactionComment, { 58 | createForeignKeyConstraints: false, 59 | nullable: false, 60 | }) 61 | @JoinColumn({ name: 'comment_id', referencedColumnName: 'id' }) 62 | comment?: Comment; 63 | 64 | @ManyToOne(() => Article, (article) => article.reactionComment, { 65 | createForeignKeyConstraints: false, 66 | nullable: false, 67 | }) 68 | @JoinColumn({ name: 'article_id', referencedColumnName: 'id' }) 69 | article?: Article; 70 | } 71 | -------------------------------------------------------------------------------- /apps/admin/src/entity/reaction/reaction-comment.entity.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@admin/entity/article/article.entity'; 2 | import { Comment } from '@admin/entity/comment/comment.entity'; 3 | import { User } from '@admin/entity/user/user.entity'; 4 | import { 5 | BaseEntity, 6 | Column, 7 | CreateDateColumn, 8 | Entity, 9 | Index, 10 | JoinColumn, 11 | ManyToOne, 12 | PrimaryGeneratedColumn, 13 | UpdateDateColumn, 14 | } from 'typeorm'; 15 | 16 | export enum ReactionCommentType { 17 | LIKE = 'LIKE', 18 | } 19 | 20 | @Entity('reaction_comment') 21 | export class ReactionComment extends BaseEntity { 22 | @PrimaryGeneratedColumn() 23 | id!: number; 24 | 25 | @Column({ nullable: false }) 26 | @Index('ix_user_id') 27 | userId!: number; 28 | 29 | @Column({ 30 | type: 'enum', 31 | enum: ReactionCommentType, 32 | nullable: false, 33 | default: ReactionCommentType.LIKE, 34 | }) 35 | type!: ReactionCommentType; 36 | 37 | @Column({ nullable: false }) 38 | @Index('ix_comment_id') 39 | commentId!: number; 40 | 41 | @Column({ nullable: false }) 42 | @Index('ix_article_id') 43 | articleId!: number; 44 | 45 | @CreateDateColumn({ type: 'timestamp' }) 46 | createdAt!: Date; 47 | 48 | @UpdateDateColumn({ type: 'timestamp' }) 49 | updatedAt!: Date; 50 | 51 | @ManyToOne(() => User, (user) => user.reactionComment, { 52 | createForeignKeyConstraints: false, 53 | nullable: false, 54 | }) 55 | @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) 56 | user?: User; 57 | 58 | @ManyToOne(() => Comment, (comment) => comment.reactionComment, { 59 | createForeignKeyConstraints: false, 60 | nullable: false, 61 | }) 62 | @JoinColumn({ name: 'comment_id', referencedColumnName: 'id' }) 63 | comment?: Comment; 64 | 65 | @ManyToOne(() => Article, (article) => article.reactionComment, { 66 | createForeignKeyConstraints: false, 67 | nullable: false, 68 | }) 69 | @JoinColumn({ name: 'article_id', referencedColumnName: 'id' }) 70 | article?: Article; 71 | } 72 | -------------------------------------------------------------------------------- /apps/api/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { UserRepository } from '@api/user/repositories/user.repository'; 2 | import { UserService } from '@api/user/user.service'; 3 | import { User } from '@app/entity/user/user.entity'; 4 | import { PHASE } from '@app/utils/phase'; 5 | import { Injectable } from '@nestjs/common'; 6 | import { ConfigService } from '@nestjs/config'; 7 | import { JwtService } from '@nestjs/jwt'; 8 | import { CookieOptions } from 'express'; 9 | import { GithubProfile, JWTPayload } from './types'; 10 | 11 | @Injectable() 12 | export class AuthService { 13 | constructor( 14 | private readonly userService: UserService, 15 | private readonly jwtService: JwtService, 16 | private readonly configService: ConfigService, 17 | private readonly userRepository: UserRepository, 18 | ) {} 19 | 20 | async login(githubProfile: GithubProfile): Promise { 21 | const user = await this.userService.findOneByGithubUId(githubProfile.id); 22 | 23 | if (user) { 24 | user.lastLogin = new Date(); 25 | return await this.userRepository.save(user); 26 | } 27 | 28 | const newUser = new User(); 29 | newUser.nickname = githubProfile.username; 30 | newUser.githubUsername = githubProfile.username; 31 | newUser.githubUid = githubProfile.id; 32 | newUser.lastLogin = new Date(); 33 | 34 | return await this.userService.create(newUser); 35 | } 36 | 37 | getJwt(user: User): string { 38 | const payload: JWTPayload = { 39 | userId: user.id, 40 | userRole: user.role, 41 | }; 42 | return this.jwtService.sign(payload); 43 | } 44 | 45 | getCookieOption = (): CookieOptions => { 46 | const oneHour = 60 * 60 * 1000; 47 | const maxAge = 7 * 24 * oneHour; // 7days 48 | 49 | if (PHASE === 'prod') { 50 | return { httpOnly: true, secure: true, sameSite: 'lax', maxAge }; 51 | } else if (PHASE === 'alpha' || PHASE === 'staging') { 52 | return { httpOnly: true, secure: true, sameSite: 'none', maxAge }; 53 | } 54 | 55 | return { httpOnly: true, maxAge }; 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "apps/api", 4 | "compilerOptions": { 5 | "webpack": false, 6 | "tsConfigPath": "./tsconfig.json" 7 | }, 8 | "monorepo": true, 9 | "root": "apps/api", 10 | "projects": { 11 | "api": { 12 | "type": "application", 13 | "root": "apps/api", 14 | "entryFile": "src/main", 15 | "sourceRoot": "apps/api", 16 | "compilerOptions": { 17 | "tsConfigPath": "apps/api/tsconfig.app.json", 18 | "assets": [ 19 | { 20 | "include": "views/**/*.ejs", 21 | "outDir": "dist/apps/api" 22 | } 23 | ], 24 | "watchAssets": true 25 | } 26 | }, 27 | "batch": { 28 | "type": "application", 29 | "root": "apps/batch", 30 | "entryFile": "main", 31 | "sourceRoot": "apps/batch/src", 32 | "compilerOptions": { 33 | "tsConfigPath": "apps/batch/tsconfig.app.json" 34 | } 35 | }, 36 | "common": { 37 | "type": "library", 38 | "root": "libs/common", 39 | "entryFile": "index", 40 | "sourceRoot": "libs/common/src", 41 | "compilerOptions": { 42 | "tsConfigPath": "libs/common/tsconfig.lib.json" 43 | } 44 | }, 45 | "utils": { 46 | "type": "library", 47 | "root": "libs/utils", 48 | "entryFile": "index", 49 | "sourceRoot": "libs/utils/src", 50 | "compilerOptions": { 51 | "tsConfigPath": "libs/utils/tsconfig.lib.json" 52 | } 53 | }, 54 | "entity": { 55 | "type": "library", 56 | "root": "libs/entity", 57 | "entryFile": "index", 58 | "sourceRoot": "libs/entity/src", 59 | "compilerOptions": { 60 | "tsConfigPath": "libs/entity/tsconfig.lib.json" 61 | } 62 | }, 63 | "admin": { 64 | "type": "application", 65 | "root": "apps/admin", 66 | "entryFile": "main", 67 | "sourceRoot": "apps/admin/src", 68 | "compilerOptions": { 69 | "tsConfigPath": "apps/admin/tsconfig.app.json" 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | 3 | on: 4 | push: 5 | branches: ['develop'] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 16.x 17 | cache: 'yarn' 18 | 19 | - name: extract version 20 | run: | 21 | VERSION=$(git rev-parse --short "$GITHUB_SHA") 22 | echo "VERSION=${VERSION}" >> $GITHUB_ENV 23 | 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v2 26 | 27 | - name: Login to Docker Hub 28 | uses: docker/login-action@v2 29 | with: 30 | username: ${{ secrets.DOCKERHUB_USERNAME }} 31 | password: ${{ secrets.DOCKERHUB_TOKEN }} 32 | 33 | - name: Build and push Docker images backend api 34 | run: | 35 | docker build -t 42world/backend-api:${{ env.VERSION }} -f ./infra/api.Dockerfile . --platform linux/x86_64 36 | docker push 42world/backend-api:${{ env.VERSION }} 37 | docker tag 42world/backend-api:${{ env.VERSION }} 42world/backend-api:latest 38 | docker push 42world/backend-api:latest 39 | 40 | # - name: Build and push Docker images backend admin 41 | # run: | 42 | # docker build -t 42world/backend-admin:latest -f ./infra/admin.Dockerfile . --platform linux/x86_64 43 | # docker push 42world/backend-admin:latest 44 | # docker tag 42world/backend-admin:latest 42world/backend-admin:${{ env.VERSION }} 45 | # docker push 42world/backend-admin:${{ env.VERSION }} 46 | 47 | # - name: Build and push Docker images backend batch 48 | # run: | 49 | # docker build -t 42world/backend-batch:latest -f ./infra/batch.Dockerfile . --platform linux/x86_64 50 | # docker push 42world/backend-batch:latest 51 | # docker tag 42world/backend-batch:latest 42world/backend-batch:${{ env.VERSION }} 52 | # docker push 42world/backend-batch:${{ env.VERSION }} 53 | -------------------------------------------------------------------------------- /apps/api/src/auth/__test__/auth.controller.test.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { Response } from 'express'; 3 | import { mock, mockFn } from 'jest-mock-extended'; 4 | import { AuthController } from '../auth.controller'; 5 | import { AuthService } from '../auth.service'; 6 | import { GithubProfile } from '../types'; 7 | 8 | describe('AuthController', () => { 9 | const mockAuthService = mock({ 10 | login: mockFn().mockResolvedValue({ id: 1 }), 11 | getJwt: mockFn().mockReturnValue('jwt'), 12 | getCookieOption: mockFn().mockReturnValue({ cookie: 'test' }), 13 | }); 14 | const mockConfigService = mock({ 15 | get: mockFn().mockReturnValue('access-token-key'), 16 | }); 17 | const authController = new AuthController(mockAuthService, mockConfigService); 18 | 19 | beforeEach(() => { 20 | jest.clearAllTimers(); 21 | }); 22 | 23 | describe('githubLogin', () => { 24 | test('정상 호출', async () => { 25 | const actual = () => authController.githubLogin(); 26 | 27 | expect(actual).not.toThrow(); 28 | }); 29 | }); 30 | 31 | describe('githubCallback', () => { 32 | test('로그인하면 쿠키를 세팅한다', async () => { 33 | const githubProfile: GithubProfile = { id: '1', username: 'test' }; 34 | const mockResponse = mock({ 35 | cookie: mockFn().mockReturnThis(), 36 | }); 37 | 38 | await authController.githubCallback(githubProfile, mockResponse); 39 | 40 | expect(mockResponse.cookie).toBeCalledTimes(1); 41 | expect(mockResponse.cookie).toBeCalledWith('access-token-key', 'jwt', { cookie: 'test' }); 42 | }); 43 | }); 44 | 45 | describe('signout', () => { 46 | test('로그아웃하면 쿠키를 비운다', async () => { 47 | const mockResponse = mock({ 48 | clearCookie: mockFn().mockReturnThis(), 49 | }); 50 | 51 | authController.signout(mockResponse); 52 | 53 | expect(mockResponse.clearCookie).toBeCalledTimes(1); 54 | expect(mockResponse.clearCookie).toBeCalledWith('access-token-key', { cookie: 'test' }); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /apps/api/src/comment/services/get-comment-api.service.ts: -------------------------------------------------------------------------------- 1 | import { ArticleService } from '@api/article/article.service'; 2 | import { CategoryService } from '@api/category/category.service'; 3 | import { CommentService } from '@api/comment/services/comment.service'; 4 | import { PaginationRequestDto } from '@api/pagination/dto/pagination-request.dto'; 5 | import { ReactionService } from '@api/reaction/reaction.service'; 6 | import { Category } from '@app/entity/category/category.entity'; 7 | import { Comment } from '@app/entity/comment/comment.entity'; 8 | import { ReactionComment } from '@app/entity/reaction/reaction-comment.entity'; 9 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 10 | import { User } from '@app/entity/user/user.entity'; 11 | import { compareRole } from '@app/utils/utils'; 12 | import { Injectable } from '@nestjs/common'; 13 | 14 | @Injectable() 15 | export class GetCommentApiService { 16 | constructor( 17 | private readonly articleService: ArticleService, 18 | private readonly categoryService: CategoryService, 19 | private readonly reactionService: ReactionService, 20 | private readonly commentService: CommentService, 21 | ) {} 22 | 23 | async getComments( 24 | user: User, 25 | articleId: number, 26 | options: PaginationRequestDto, 27 | ): Promise<{ 28 | comments: Comment[]; 29 | reactionComments: ReactionComment[]; 30 | category: Category; 31 | totalCount: number; 32 | }> { 33 | const article = await this.articleService.findOneByIdOrFail(articleId); 34 | const category = await this.categoryService.findOneOrFail(article.categoryId); 35 | this.categoryService.checkAvailableSync(user, category, 'readableComment'); 36 | 37 | const { comments, totalCount } = await this.commentService.findAllByArticleId(user, articleId, options); 38 | 39 | let reactionComments = []; 40 | if (compareRole(category.reactionable as UserRole, user.role as UserRole)) 41 | reactionComments = await this.reactionService.findAllMyReactionComment(user.id, articleId); 42 | 43 | return { comments, category, totalCount, reactionComments }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /apps/api/src/intra-auth/intra-auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Auth, AuthUser } from '@api/auth/auth.decorator'; 2 | import { User } from '@app/entity/user/user.entity'; 3 | import { logger } from '@app/utils/logger'; 4 | import { Body, Controller, Get, HttpCode, Post, Query, Render } from '@nestjs/common'; 5 | import { ConfigService } from '@nestjs/config'; 6 | import { 7 | ApiCookieAuth, 8 | ApiForbiddenResponse, 9 | ApiOkResponse, 10 | ApiOperation, 11 | ApiTags, 12 | ApiUnauthorizedResponse, 13 | } from '@nestjs/swagger'; 14 | import { SigninIntraAuthRequestDto } from './dto/signin-intra-auth-request.dto'; 15 | import { IntraAuthService } from './intra-auth.service'; 16 | 17 | @ApiTags('Intra Auth') 18 | @Controller('intra-auth') 19 | export class IntraAuthController { 20 | constructor( 21 | private readonly intraAuthService: IntraAuthService, // 22 | private readonly configService: ConfigService, 23 | ) {} 24 | 25 | @Post() 26 | @HttpCode(200) 27 | @Auth() 28 | @ApiCookieAuth() 29 | @ApiOperation({ summary: '42인증 메일 전송' }) 30 | @ApiOkResponse({ description: '메일 전송 성공' }) 31 | @ApiUnauthorizedResponse({ description: '인증 실패' }) 32 | @ApiForbiddenResponse({ 33 | description: '접근 권한 없음 | 이미 인증된 사용자 | 이미 가입된 카뎃', 34 | }) 35 | async sendMail(@AuthUser() user: User, @Body() { intraId }: SigninIntraAuthRequestDto) { 36 | await this.intraAuthService.signin(intraId, user); 37 | } 38 | 39 | @Get() 40 | @Render('intra-auth/results.ejs') 41 | @ApiOperation({ summary: '42인증 메일 코드 확인' }) 42 | @ApiOkResponse({ description: 'results.ejs 파일 렌더링' }) 43 | async getAuthCode(@Query('code') code: string) { 44 | try { 45 | await this.intraAuthService.getAuth(code); 46 | 47 | return { 48 | title: 'Hello World!', 49 | message: '인증에 성공했습니다! 🥳', 50 | button: 'Welcome, Cadet!', 51 | endpoint: this.configService.get('FRONT_URL'), 52 | }; 53 | } catch (e) { 54 | logger.error(e); 55 | return { 56 | title: 'Oops! There is an error ...', 57 | message: '인증에 실패했습니다 😭', 58 | button: 'Retry', 59 | endpoint: this.configService.get('FRONT_URL'), 60 | }; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /libs/utils/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { PaginationRequestDto } from '@api/pagination/dto/pagination-request.dto'; 2 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 3 | import { PHASE } from '@app/utils/phase'; 4 | import axios from 'axios'; 5 | import { logger } from './logger'; 6 | 7 | export const MINUTE = 60; 8 | export const HOUR = 60 * MINUTE; 9 | export const TIME2LIVE = 30 * MINUTE; 10 | 11 | export function getRandomInt(min: number, max: number) { 12 | min = Math.ceil(min); 13 | max = Math.floor(max); 14 | return Math.floor(Math.random() * (max - min)) + min; //최댓값은 제외, 최솟값은 포함 15 | } 16 | 17 | export const isExpired = (exp: Date): boolean => { 18 | const now = new Date(); 19 | return now >= exp; 20 | }; 21 | 22 | export const errorHook = async (exceptionName: string, exceptionMessage: string, slackHookUrl: string) => { 23 | const slackMessage = `[${PHASE}] ${exceptionName}: ${exceptionMessage}`; 24 | 25 | try { 26 | if (PHASE !== 'dev' && PHASE !== 'test') { 27 | await axios.post(slackHookUrl, { text: slackMessage }); 28 | } 29 | } catch (e) { 30 | logger.error(e); 31 | } 32 | }; 33 | 34 | export function compareRole(rule: UserRole, mine: UserRole): boolean { 35 | const toRoleId = (r: UserRole) => { 36 | switch (r) { 37 | case UserRole.ADMIN: 38 | return 3; 39 | case UserRole.CADET: 40 | return 2; 41 | case UserRole.NOVICE: 42 | return 1; 43 | case UserRole.GUEST: 44 | return 0; 45 | } 46 | }; 47 | return toRoleId(rule) <= toRoleId(mine); 48 | } 49 | 50 | export function includeRole(mine: UserRole): UserRole[] { 51 | const toRoleId = (r: UserRole) => { 52 | switch (r) { 53 | case UserRole.ADMIN: 54 | return 3; 55 | case UserRole.CADET: 56 | return 2; 57 | case UserRole.NOVICE: 58 | return 1; 59 | case UserRole.GUEST: 60 | return 0; 61 | } 62 | }; 63 | const includeRole: UserRole[] = Object.values(UserRole).filter((r) => toRoleId(r) <= toRoleId(mine)); 64 | return includeRole; 65 | } 66 | 67 | export const getPaginationSkip = (paginationDto: PaginationRequestDto) => { 68 | return (paginationDto.page - 1) * paginationDto.take; 69 | }; 70 | -------------------------------------------------------------------------------- /apps/admin/src/adminJs/admin-js.module.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@admin/entity/article/article.entity'; 2 | import { Best } from '@admin/entity/best/best.entity'; 3 | import { Category } from '@admin/entity/category/category.entity'; 4 | import { Comment } from '@admin/entity/comment/comment.entity'; 5 | import { IntraAuth } from '@admin/entity/intra-auth/intra-auth.entity'; 6 | import { Notification } from '@admin/entity/notification/notification.entity'; 7 | import { ReactionArticle } from '@admin/entity/reaction/reaction-article.entity'; 8 | import { ReactionComment } from '@admin/entity/reaction/reaction-comment.entity'; 9 | import { User } from '@admin/entity/user/user.entity'; 10 | import { AdminModule, AdminModuleOptions } from '@adminjs/nestjs'; 11 | import { Database, Resource } from '@adminjs/typeorm'; 12 | import { DynamicModule } from '@nestjs/common'; 13 | import { ConfigModule, ConfigService } from '@nestjs/config'; 14 | import AdminJS from 'adminjs'; 15 | 16 | AdminJS.registerAdapter({ Database, Resource }); 17 | 18 | export class AdminJsModule { 19 | static register(): DynamicModule { 20 | return AdminModule.createAdminAsync({ 21 | imports: [ConfigModule], 22 | inject: [ConfigService], 23 | useFactory(configService: ConfigService): AdminModuleOptions { 24 | return { 25 | adminJsOptions: { 26 | rootPath: configService.get('ADMIN_URL'), 27 | resources: [ 28 | IntraAuth, 29 | Best, 30 | Notification, 31 | Category, 32 | Comment, 33 | ReactionArticle, 34 | ReactionComment, 35 | User, 36 | Article, 37 | ], 38 | }, 39 | auth: { 40 | authenticate: async (email, password) => { 41 | if (email === configService.get('ADMIN_EMAIL') && password === configService.get('ADMIN_PASSWORD')) { 42 | return { email }; 43 | } 44 | return null; 45 | }, 46 | cookieName: configService.get('ADMIN_COOKIE_NAME'), 47 | cookiePassword: configService.get('ADMIN_COOKIE_PASSWORD'), 48 | }, 49 | }; 50 | }, 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /apps/api/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Delete, Get, Res, UseGuards } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { ApiCookieAuth, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger'; 4 | import { Response } from 'express'; 5 | import { Auth, ReqGithubProfile } from './auth.decorator'; 6 | import { AuthService } from './auth.service'; 7 | import { GithubAuthGuard } from './github-auth/github-auth.guard'; 8 | import { GithubProfile } from './types'; 9 | 10 | @ApiTags('Auth') 11 | @Controller('auth') 12 | export class AuthController { 13 | constructor(private readonly authService: AuthService, private readonly configService: ConfigService) {} 14 | 15 | @Get('github') 16 | @UseGuards(GithubAuthGuard) 17 | @ApiOperation({ 18 | summary: '깃허브 로그인', 19 | description: ` 20 | 로그인이 되어있지 않은경우, 깃허브 로그인 페이지로 이동합니다 21 | 깃허브 로그인이 끝나면 지정된 프론트 페이지로 이동합니다.`, 22 | }) 23 | @ApiOkResponse({ description: '깃허브 페이지' }) 24 | githubLogin(): void { 25 | return; 26 | } 27 | 28 | @Get('github/callback') 29 | @UseGuards(GithubAuthGuard) 30 | @ApiOperation({ 31 | summary: '깃허브 로그인 콜백', 32 | description: ` 33 | 깃허브 로그인이 끝나고 호출해주세요. 34 | 깃허브 로그인이 끝나지 않았다면, 35 | 다시 깃허브 로그인 페이지로 이동합니다.`, 36 | }) 37 | @ApiOkResponse({ description: '로그인 성공' }) 38 | async githubCallback( 39 | @ReqGithubProfile() githubProfile: GithubProfile, 40 | @Res({ passthrough: true }) response: Response, 41 | ): Promise { 42 | const user = await this.authService.login(githubProfile); 43 | const jwt = this.authService.getJwt(user); 44 | const cookieOption = this.authService.getCookieOption(); 45 | 46 | response.cookie(this.configService.get('ACCESS_TOKEN_KEY'), jwt, cookieOption); 47 | } 48 | 49 | @Delete('signout') 50 | @Auth() 51 | @ApiCookieAuth() 52 | @ApiOperation({ summary: '로그아웃' }) 53 | @ApiOkResponse({ description: '로그아웃 성공' }) 54 | @ApiUnauthorizedResponse({ description: '인증 실패' }) 55 | signout(@Res({ passthrough: true }) response: Response): void { 56 | const cookieOption = this.authService.getCookieOption(); 57 | 58 | response.clearCookie(this.configService.get('ACCESS_TOKEN_KEY'), cookieOption); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /apps/api/src/article/dto/response/article-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { CategoryResponseDto } from '@api/category/dto/response/category-response.dto'; 2 | import { UserResponseDto } from '@api/user/dto/response/user-response.dto'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | 5 | export class ArticleResponseDto { 6 | @ApiProperty() 7 | id: number; 8 | 9 | @ApiProperty({ example: '제목 입니다.' }) 10 | title: string; 11 | 12 | @ApiProperty({ example: '내용 입니다.' }) 13 | content: string; 14 | 15 | @ApiProperty() 16 | viewCount: number; 17 | 18 | @ApiProperty({ example: 1 }) 19 | categoryId: number; 20 | 21 | @ApiProperty({ type: CategoryResponseDto }) 22 | category: CategoryResponseDto; 23 | 24 | @ApiProperty() 25 | writerId: number; 26 | 27 | @ApiProperty({ type: UserResponseDto }) 28 | writer: UserResponseDto; 29 | 30 | @ApiProperty() 31 | commentCount: number; 32 | 33 | @ApiProperty() 34 | likeCount: number; 35 | 36 | @ApiProperty() 37 | createdAt: Date; 38 | 39 | @ApiProperty() 40 | updatedAt: Date; 41 | 42 | @ApiProperty({ example: false }) 43 | isSelf: boolean; 44 | 45 | @ApiProperty({ required: false, example: false, nullable: true }) 46 | isLike?: boolean; 47 | 48 | constructor(property: { 49 | id: number; 50 | title: string; 51 | content: string; 52 | viewCount: number; 53 | categoryId: number; 54 | category: CategoryResponseDto; 55 | writerId: number; 56 | writer: UserResponseDto; 57 | commentCount: number; 58 | likeCount: number; 59 | createdAt: Date; 60 | updatedAt: Date; 61 | isSelf: boolean; 62 | isLike: boolean | undefined; 63 | }) { 64 | this.id = property.id; 65 | this.title = property.title; 66 | this.content = property.content; 67 | this.viewCount = property.viewCount; 68 | this.categoryId = property.categoryId; 69 | this.category = property.category; 70 | this.writerId = property.writerId; 71 | this.writer = property.writer; 72 | this.commentCount = property.commentCount; 73 | this.likeCount = property.likeCount; 74 | this.createdAt = property.createdAt; 75 | this.updatedAt = property.updatedAt; 76 | this.isSelf = property.isSelf; 77 | if (property.isLike !== undefined) { 78 | this.isLike = property.isLike; 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /apps/api/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { AppController } from '@api/app.controller'; 2 | import { AuthModule } from '@api/auth/auth.module'; 3 | import { JwtAuthGuard } from '@api/auth/jwt-auth/jwt-auth.guard'; 4 | import { BestModule } from '@api/best/best.module'; 5 | import { CategoryModule } from '@api/category/category.module'; 6 | import { CommentApiModule } from '@api/comment/comment-api.module'; 7 | import { FtCheckinModule } from '@api/ft-checkin/ft-checkin.module'; 8 | // import { ConfigModule } from '@app/common/config/config.module'; 9 | import { AWS_ACCESS_KEY, AWS_REGION, AWS_SECRET_KEY } from '@api/image/image.constant'; 10 | import { ImageModule } from '@api/image/image.module'; 11 | import { IntraAuthModule } from '@api/intra-auth/intra-auth.module'; 12 | import { NotificationModule } from '@api/notification/notification.module'; 13 | import { ReactionModule } from '@api/reaction/reaction.module'; 14 | import { UserModule } from '@api/user/user.module'; 15 | import { DatabaseModule } from '@app/common/database/database.module'; 16 | import { Module } from '@nestjs/common'; 17 | import { ConfigModule, ConfigService } from '@nestjs/config'; 18 | import { APP_GUARD } from '@nestjs/core'; 19 | import { AwsSdkModule } from 'nest-aws-sdk'; 20 | import { ArticleApiModule } from './article/article-api.module'; 21 | 22 | @Module({ 23 | imports: [ 24 | ConfigModule.forRoot({ 25 | isGlobal: true, 26 | cache: true, 27 | }), 28 | DatabaseModule.register(), 29 | AwsSdkModule.forRootAsync({ 30 | defaultServiceOptions: { 31 | inject: [ConfigService], 32 | useFactory: (configService: ConfigService) => ({ 33 | region: configService.get(AWS_REGION), 34 | accessKeyId: configService.get(AWS_ACCESS_KEY), 35 | secretAccessKey: configService.get(AWS_SECRET_KEY), 36 | }), 37 | }, 38 | }), 39 | CommentApiModule, 40 | UserModule, 41 | ArticleApiModule, 42 | CategoryModule, 43 | NotificationModule, 44 | IntraAuthModule, 45 | AuthModule, 46 | BestModule, 47 | ReactionModule, 48 | FtCheckinModule, 49 | ImageModule, 50 | ], 51 | controllers: [AppController], 52 | providers: [ 53 | { 54 | provide: APP_GUARD, 55 | useClass: JwtAuthGuard, 56 | }, 57 | ], 58 | }) 59 | export class AppModule {} 60 | -------------------------------------------------------------------------------- /apps/api/src/comment/repositories/comment.repository.ts: -------------------------------------------------------------------------------- 1 | import { PaginationRequestDto } from '@api/pagination/dto/pagination-request.dto'; 2 | import { Comment } from '@app/entity/comment/comment.entity'; 3 | import { getPaginationSkip } from '@app/utils/utils'; 4 | import { Injectable, NotFoundException } from '@nestjs/common'; 5 | import { DataSource, Repository } from 'typeorm'; 6 | 7 | @Injectable() 8 | export class CommentRepository extends Repository { 9 | constructor(dataSource: DataSource) { 10 | super(Comment, dataSource.createEntityManager()); 11 | } 12 | async findAllByArticleId( 13 | articleId: number, 14 | options: PaginationRequestDto, 15 | ): Promise<{ 16 | comments: Comment[]; 17 | totalCount: number; 18 | }> { 19 | const query = this.createQueryBuilder('comment') 20 | .leftJoinAndSelect('comment.writer', 'writer') 21 | .andWhere('comment.articleId = :id', { id: articleId }) 22 | .skip(getPaginationSkip(options)) 23 | .take(options.take) 24 | .orderBy('comment.createdAt', options.order); 25 | 26 | const totalCount = await query.getCount(); 27 | const comments = await query.getMany(); 28 | 29 | return { comments, totalCount }; 30 | } 31 | 32 | async findAllByWriterId( 33 | writerId: number, 34 | options: PaginationRequestDto, 35 | ): Promise<{ 36 | comments: Comment[]; 37 | totalCount: number; 38 | }> { 39 | const query = this.createQueryBuilder('comment') 40 | .innerJoinAndSelect('comment.article', 'article') 41 | .leftJoinAndSelect('article.category', 'category') 42 | .where('comment.writerId = :id', { id: writerId }) 43 | .skip(getPaginationSkip(options)) 44 | .take(options.take) 45 | .orderBy('comment.createdAt', options.order); 46 | 47 | const totalCount = await query.getCount(); 48 | const comments = await query.getMany(); 49 | 50 | return { comments, totalCount }; 51 | } 52 | 53 | async existOrFail(id: number): Promise { 54 | const existQuery = await this.query(`SELECT EXISTS 55 | (SELECT * FROM comment WHERE id=${id} AND deleted_at IS NULL)`); 56 | const isExist = Object.values(existQuery[0])[0]; 57 | if (isExist === '0') { 58 | throw new NotFoundException(`Can't find Comments with id ${id}`); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /apps/api/src/best/best.controller.ts: -------------------------------------------------------------------------------- 1 | import { Auth } from '@api/auth/auth.decorator'; 2 | import { PaginationRequestDto } from '@api/pagination/dto/pagination-request.dto'; 3 | import { Article } from '@app/entity/article/article.entity'; 4 | import { Best } from '@app/entity/best/best.entity'; 5 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 6 | import { Body, ConflictException, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; 7 | import { 8 | ApiConflictResponse, 9 | ApiCookieAuth, 10 | ApiForbiddenResponse, 11 | ApiNotFoundResponse, 12 | ApiOkResponse, 13 | ApiOperation, 14 | ApiTags, 15 | ApiUnauthorizedResponse, 16 | } from '@nestjs/swagger'; 17 | import { BestService } from './best.service'; 18 | import { CreateBestRequestDto } from './dto/request/create-best-request.dto'; 19 | 20 | @ApiCookieAuth() 21 | @ApiUnauthorizedResponse({ description: '인증 실패' }) 22 | @ApiTags('Best') 23 | @Controller('best') 24 | export class BestController { 25 | constructor(private readonly bestService: BestService) {} 26 | 27 | @Post() 28 | @Auth('allow', UserRole.ADMIN) 29 | @ApiOperation({ summary: '인기글 추가하기 (관리자)' }) 30 | @ApiOkResponse({ description: '인기글에 추가 성공', type: Best }) 31 | @ApiForbiddenResponse({ description: '접근 권한 없음' }) 32 | @ApiConflictResponse({ description: '이미 인기글에 추가된 글입니다.' }) 33 | async create(@Body() createBestDto: CreateBestRequestDto): Promise { 34 | const best = await this.bestService.createOrNot(createBestDto); 35 | if (!best) { 36 | throw new ConflictException('이미 인기글에 추가된 글입니다.'); 37 | } 38 | return best; 39 | } 40 | 41 | @Get() 42 | @ApiOperation({ summary: '인기글 가져오기' }) 43 | @ApiOkResponse({ description: '인기글 목록', type: [Article] }) 44 | async findAll(@Query() findAllBestDto: PaginationRequestDto): Promise { 45 | return this.bestService.findAll(findAllBestDto); 46 | } 47 | 48 | @Delete(':id') 49 | @Auth('allow', UserRole.ADMIN) 50 | @ApiOperation({ summary: '인기글에서 내리기 (관리자)' }) 51 | @ApiOkResponse({ description: '인기글 내리기 성공' }) 52 | @ApiForbiddenResponse({ description: '접근 권한 없음' }) 53 | @ApiNotFoundResponse({ description: '존재하지 않는 인기글입니다.' }) 54 | async remove(@Param('id') id: number): Promise { 55 | return this.bestService.remove(id); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /libs/entity/src/comment/comment.entity.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '@app/entity/article/article.entity'; 2 | import { ReactionComment } from '@app/entity/reaction/reaction-comment.entity'; 3 | import { User } from '@app/entity/user/user.entity'; 4 | import { 5 | Column, 6 | CreateDateColumn, 7 | DeleteDateColumn, 8 | Entity, 9 | Index, 10 | JoinColumn, 11 | ManyToOne, 12 | OneToMany, 13 | PrimaryGeneratedColumn, 14 | UpdateDateColumn, 15 | } from 'typeorm'; 16 | 17 | @Entity('comment') 18 | export class Comment { 19 | @PrimaryGeneratedColumn() 20 | id!: number; 21 | 22 | @Column({ type: 'text', nullable: false }) 23 | content!: string; 24 | 25 | @Column({ default: 0 }) 26 | likeCount!: number; 27 | 28 | @Column({ nullable: false }) 29 | @Index('ix_article_id') 30 | articleId!: number; 31 | 32 | @ManyToOne(() => Article, (article) => article.comment, { 33 | createForeignKeyConstraints: false, 34 | nullable: false, 35 | }) 36 | @JoinColumn({ name: 'article_id', referencedColumnName: 'id' }) 37 | article?: Article; 38 | 39 | @Column({ nullable: false }) 40 | @Index('ix_writer_id') 41 | writerId!: number; 42 | 43 | @ManyToOne(() => User, (user) => user.comment, { 44 | createForeignKeyConstraints: false, 45 | nullable: false, 46 | }) 47 | @JoinColumn({ name: 'writer_id', referencedColumnName: 'id' }) 48 | writer?: User; 49 | 50 | @CreateDateColumn({ type: 'timestamp' }) 51 | createdAt!: Date; 52 | 53 | @UpdateDateColumn({ type: 'timestamp' }) 54 | updatedAt!: Date; 55 | 56 | @DeleteDateColumn({ type: 'timestamp' }) 57 | @Index('ix_deleted_at') 58 | deletedAt?: Date; 59 | 60 | @OneToMany(() => ReactionComment, (reactionComment) => reactionComment.comment, { 61 | createForeignKeyConstraints: false, 62 | nullable: true, 63 | }) 64 | reactionComment?: ReactionComment[]; 65 | 66 | // TODO: 지금은 BaseEntity 때문에 함수이름이 겹쳐서 조금 이상한 이름, 추후 그냥 create로 변경 해야함 67 | public static createComment(props: { content: string; articleId: number; writerId: number }) { 68 | const comment = new Comment(); 69 | comment.content = props.content; 70 | comment.articleId = props.articleId; 71 | comment.writerId = props.writerId; 72 | return comment; 73 | } 74 | 75 | public updateContent(content: string) { 76 | this.content = content; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /apps/api/src/comment/services/comment.service.ts: -------------------------------------------------------------------------------- 1 | import { CommentRepository } from '@api/comment/repositories/comment.repository'; 2 | import { PaginationRequestDto } from '@api/pagination/dto/pagination-request.dto'; 3 | import { Comment } from '@app/entity/comment/comment.entity'; 4 | import { User } from '@app/entity/user/user.entity'; 5 | import { BadRequestException, Injectable } from '@nestjs/common'; 6 | import { FindOneOptions } from 'typeorm'; 7 | 8 | @Injectable() 9 | export class CommentService { 10 | constructor(private readonly commentRepository: CommentRepository) {} 11 | 12 | async findAllByArticleId( 13 | user: User, 14 | articleId: number, 15 | options: PaginationRequestDto, 16 | ): Promise<{ 17 | comments: Comment[]; 18 | totalCount: number; 19 | }> { 20 | return await this.commentRepository.findAllByArticleId(articleId, options); 21 | } 22 | 23 | async findOneByIdOrFail(id: number): Promise { 24 | return this.commentRepository.findOneOrFail({ where: { id } }); 25 | } 26 | 27 | async findAllByWriterId( 28 | writerId: number, 29 | options: PaginationRequestDto, 30 | ): Promise<{ 31 | comments: Comment[]; 32 | totalCount: number; 33 | }> { 34 | return this.commentRepository.findAllByWriterId(writerId, options); 35 | } 36 | 37 | async findByIdAndWriterIdOrFail(id: number, writerId: number): Promise { 38 | return this.commentRepository.findOneOrFail({ where: { id, writerId } }); 39 | } 40 | 41 | async save(comment: Comment): Promise { 42 | return this.commentRepository.save(comment); 43 | } 44 | 45 | async softDelete(id: number): Promise { 46 | await this.commentRepository.softDelete(id); 47 | } 48 | 49 | async increaseLikeCount(comment: Comment): Promise { 50 | await this.commentRepository.update(comment.id, { 51 | likeCount: () => 'like_count + 1', 52 | }); 53 | comment.likeCount += 1; 54 | return comment; 55 | } 56 | 57 | async decreaseLikeCount(comment: Comment): Promise { 58 | if (comment.likeCount <= 0) { 59 | throw new BadRequestException('좋아요는 0이하가 될 수 없습니다.'); 60 | } 61 | await this.commentRepository.update(comment.id, { 62 | likeCount: () => 'like_count - 1', 63 | }); 64 | comment.likeCount -= 1; 65 | return comment; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /apps/api/src/reaction/reaction.controller.ts: -------------------------------------------------------------------------------- 1 | import { Auth, AuthUser } from '@api/auth/auth.decorator'; 2 | import { Article } from '@app/entity/article/article.entity'; 3 | import { Comment } from '@app/entity/comment/comment.entity'; 4 | import { User } from '@app/entity/user/user.entity'; 5 | import { Controller, HttpCode, Param, ParseIntPipe, Post } from '@nestjs/common'; 6 | import { 7 | ApiCookieAuth, 8 | ApiCreatedResponse, 9 | ApiNotFoundResponse, 10 | ApiOperation, 11 | ApiTags, 12 | ApiUnauthorizedResponse, 13 | } from '@nestjs/swagger'; 14 | import { ReactionResponseDto } from './dto/response/reaction-response.dto'; 15 | import { ReactionService } from './reaction.service'; 16 | 17 | @ApiCookieAuth() 18 | @ApiUnauthorizedResponse({ description: '인증 실패' }) 19 | @ApiTags('Reaction') 20 | @Controller('reactions') 21 | export class ReactionController { 22 | constructor(private readonly reactionService: ReactionService) {} 23 | 24 | @Post('articles/:id') 25 | @Auth() 26 | @HttpCode(200) 27 | @ApiOperation({ summary: '게시글 좋아요 버튼' }) 28 | @ApiCreatedResponse({ 29 | description: '게시글 좋아요 버튼 누름', 30 | type: ReactionResponseDto, 31 | }) 32 | @ApiNotFoundResponse({ description: '존재하지 않는 게시글' }) 33 | async reactionArticleCreateOrDelete( 34 | @AuthUser() user: User, 35 | @Param('id', ParseIntPipe) articleId: number, 36 | ): Promise { 37 | const { article, isLike } = await this.reactionService.articleCreateOrDelete(user, articleId); 38 | 39 | return ReactionResponseDto.of
({ entity: article, isLike }); 40 | } 41 | 42 | @Post('articles/:articleId/comments/:commentId') 43 | @Auth() 44 | @HttpCode(200) 45 | @ApiOperation({ summary: '댓글 좋아요 버튼' }) 46 | @ApiCreatedResponse({ 47 | description: '댓글 좋아요 버튼 누름', 48 | type: ReactionResponseDto, 49 | }) 50 | @ApiNotFoundResponse({ description: '존재하지 않는 댓글' }) 51 | async reactionCommentCreateOrDelete( 52 | @AuthUser() user: User, 53 | @Param('articleId', ParseIntPipe) articleId: number, 54 | @Param('commentId', ParseIntPipe) commentId: number, 55 | ): Promise { 56 | const { comment, isLike } = await this.reactionService.commentCreateOrDelete(user, articleId, commentId); 57 | 58 | return ReactionResponseDto.of({ entity: comment, isLike }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /apps/api/src/intra-auth/stibee.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, InternalServerErrorException } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import axios from 'axios'; 4 | import { EMAIL } from './intra-auth.constant'; 5 | import MailService from './mail.service'; 6 | import UnsubscribeStibeeService from './unsubscribe-stibee.service'; 7 | @Injectable() 8 | export default class StibeeService implements MailService, UnsubscribeStibeeService { 9 | constructor(private readonly configService: ConfigService) {} 10 | 11 | private accessToken = this.configService.get('STIBEE_API_KEY'); 12 | 13 | async send(name: string, code: string, githubId: string) { 14 | await this.subscribe(name); 15 | const url = this.configService.get('STIBEE_MAIL_SEND_URL'); 16 | 17 | try { 18 | await axios.post( 19 | url, 20 | { 21 | subscriber: `${name}@${EMAIL}`, 22 | name, 23 | code, 24 | githubId, 25 | }, 26 | { headers: { AccessToken: this.accessToken } }, 27 | ); 28 | } catch (err: any) { 29 | this.printError(err); 30 | throw new InternalServerErrorException('이메일 전송 실패'); 31 | } 32 | } 33 | 34 | private async subscribe(name: string) { 35 | const url = this.configService.get('STIBEE_SUBSCRIBE_URL'); 36 | 37 | try { 38 | await axios.post( 39 | url, 40 | { 41 | subscribers: [ 42 | { 43 | email: `${name}@student.42seoul.kr`, 44 | name, 45 | }, 46 | ], 47 | }, 48 | { headers: { AccessToken: this.accessToken } }, 49 | ); 50 | } catch (err) { 51 | this.printError(err); 52 | throw new InternalServerErrorException('스티비 구독 실패'); 53 | } 54 | } 55 | 56 | async unsubscribe(name: string) { 57 | const url = this.configService.get('STIBEE_SUBSCRIBE_URL'); 58 | try { 59 | await axios.delete(url, { 60 | data: [`${name}@student.42seoul.kr`], 61 | headers: { AccessToken: this.accessToken }, 62 | }); 63 | } catch (err) { 64 | this.printError(err); 65 | throw new InternalServerErrorException('스티비 구독 해지 실패'); 66 | } 67 | } 68 | 69 | private printError(err: any) { 70 | console.error({ status: err.response.status, message: err.response.data }); 71 | console.trace(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /apps/api/src/auth/jwt-auth/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { FORBIDDEN_USER_ROLE, REQUIRE_ROLES } from '@api/auth/constant'; 2 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 3 | import { User } from '@app/entity/user/user.entity'; 4 | import { ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; 5 | import { Reflector } from '@nestjs/core'; 6 | import { AuthGuard } from '@nestjs/passport'; 7 | import { AuthDecoratorParam } from '../types'; 8 | 9 | /** 10 | * Custom AuthGuard to check public handler and user roles 11 | * @see also https://docs.nestjs.com/security/authentication#extending-guards 12 | * 13 | * 1. JwtAuthGuard.canActivate -> check if handler is public or not 14 | * 2. JwtStrategy.validate -> get user from jwt payload or db 15 | * 3. JwtAuthGuard.handleRequest -> check user roles 16 | */ 17 | @Injectable() 18 | export class JwtAuthGuard extends AuthGuard('jwt') { 19 | constructor(private readonly reflector: Reflector) { 20 | super(); 21 | } 22 | 23 | canActivate(context: ExecutionContext) { 24 | const requireAuth = this.reflector.get(REQUIRE_ROLES, context.getHandler()); 25 | 26 | if (!requireAuth) { 27 | return true; 28 | } 29 | 30 | return super.canActivate(context); 31 | } 32 | 33 | handleRequest(err: any, _user: any, info: any, context: any, status?: any): TUser { 34 | const requireAuth = this.reflector.get(REQUIRE_ROLES, context.getHandler()); 35 | 36 | let user: User; 37 | 38 | try { 39 | // verity JWT token 40 | user = super.handleRequest(err, _user, info, context, status); 41 | } catch (error) { 42 | // Auth('public') 인경우 Guest 로 처리 43 | if (this.isAuthAllowRole(requireAuth, UserRole.GUEST)) { 44 | user = new User(); 45 | user.id = -1; 46 | user.role = UserRole.GUEST; 47 | return user as unknown as TUser; 48 | } 49 | throw error; 50 | } 51 | 52 | if (!this.isAuthAllowRole(requireAuth, user.role)) { 53 | throw new ForbiddenException(FORBIDDEN_USER_ROLE); 54 | } 55 | 56 | return user as unknown as TUser; 57 | } 58 | 59 | private isAuthAllowRole(requireAuth: AuthDecoratorParam, role: UserRole): boolean { 60 | return ( 61 | (requireAuth[0] === 'allow' && requireAuth.includes(role)) || 62 | (requireAuth[0] === 'deny' && !requireAuth.includes(role)) 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /apps/api/src/auth/jwt-auth/__test__/jwt-auth.strategy.test.ts: -------------------------------------------------------------------------------- 1 | import { UserService } from '@api/user/user.service'; 2 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 3 | import { User } from '@app/entity/user/user.entity'; 4 | import { NotFoundException, UnauthorizedException } from '@nestjs/common'; 5 | import { ConfigService } from '@nestjs/config'; 6 | import { mock, mockFn } from 'jest-mock-extended'; 7 | import { JWTPayload } from '../../types'; 8 | import { getAccessToken, JwtAuthStrategy } from '../jwt-auth.strategy'; 9 | 10 | describe('JwtAuthStrategy', () => { 11 | const mockConfigService = mock({ 12 | get: mockFn().mockReturnValue('test'), 13 | }); 14 | const mockUserService = mock(); 15 | const jwtAuthStrategy = new JwtAuthStrategy(mockUserService, mockConfigService); 16 | const payload: JWTPayload = { 17 | userId: 1, 18 | userRole: UserRole.GUEST, 19 | }; 20 | 21 | beforeEach(() => { 22 | mockUserService.findOneByIdOrFail.mockClear(); 23 | jest.clearAllTimers(); 24 | }); 25 | 26 | describe('validate', () => { 27 | test('유저가 존재하면 유저를 반환한다', async () => { 28 | const user = new User(); 29 | mockUserService.findOneByIdOrFail.mockResolvedValue(user); 30 | 31 | const result = await jwtAuthStrategy.validate(payload); 32 | 33 | expect(result).toBe(user); 34 | expect(mockUserService.findOneByIdOrFail).toBeCalledTimes(1); 35 | }); 36 | 37 | test('유저가 없으면 UnauthorizedException 에러를 던진다', async () => { 38 | mockUserService.findOneByIdOrFail.mockRejectedValue(new NotFoundException()); 39 | 40 | const act = async () => await jwtAuthStrategy.validate(payload); 41 | 42 | expect(act).rejects.toThrowError(UnauthorizedException); 43 | expect(mockUserService.findOneByIdOrFail).toBeCalledTimes(1); 44 | }); 45 | 46 | test('에러가 나면 에러를 던진다', async () => { 47 | mockUserService.findOneByIdOrFail.mockRejectedValue(new Error()); 48 | 49 | const act = async () => await jwtAuthStrategy.validate(payload); 50 | 51 | expect(act).rejects.toThrowError(Error); 52 | expect(mockUserService.findOneByIdOrFail).toBeCalledTimes(1); 53 | }); 54 | }); 55 | 56 | describe('getAccessToken', () => { 57 | test('쿠키에서 ACCESS_TOKEN_KEY를 반환한다', () => { 58 | const request = { 59 | cookies: { 60 | ACCESS_TOKEN_KEY: 'test', 61 | }, 62 | }; 63 | 64 | const result = getAccessToken('ACCESS_TOKEN_KEY', request); 65 | 66 | expect(result).toBe('test'); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /libs/common/src/database/migrations/1658252663130-add-guest.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class addGuest1658252663130 implements MigrationInterface { 4 | name = 'addGuest1658252663130'; 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query( 8 | `ALTER TABLE \`category\` CHANGE \`writable_article\` \`writable_article\` enum ('CADET', 'ADMIN', 'NOVICE', 'GUEST') NOT NULL DEFAULT 'CADET'`, 9 | ); 10 | await queryRunner.query( 11 | `ALTER TABLE \`category\` CHANGE \`readable_article\` \`readable_article\` enum ('CADET', 'ADMIN', 'NOVICE', 'GUEST') NOT NULL DEFAULT 'CADET'`, 12 | ); 13 | await queryRunner.query( 14 | `ALTER TABLE \`category\` CHANGE \`writable_comment\` \`writable_comment\` enum ('CADET', 'ADMIN', 'NOVICE', 'GUEST') NOT NULL DEFAULT 'CADET'`, 15 | ); 16 | await queryRunner.query( 17 | `ALTER TABLE \`category\` CHANGE \`readable_comment\` \`readable_comment\` enum ('CADET', 'ADMIN', 'NOVICE', 'GUEST') NOT NULL DEFAULT 'CADET'`, 18 | ); 19 | await queryRunner.query( 20 | `ALTER TABLE \`category\` CHANGE \`reactionable\` \`reactionable\` enum ('CADET', 'ADMIN', 'NOVICE', 'GUEST') NOT NULL DEFAULT 'CADET'`, 21 | ); 22 | await queryRunner.query( 23 | `ALTER TABLE \`user\` CHANGE \`role\` \`role\` enum ('CADET', 'ADMIN', 'NOVICE', 'GUEST') NOT NULL DEFAULT 'NOVICE'`, 24 | ); 25 | } 26 | 27 | public async down(queryRunner: QueryRunner): Promise { 28 | await queryRunner.query( 29 | `ALTER TABLE \`user\` CHANGE \`role\` \`role\` enum ('CADET', 'ADMIN', 'NOVICE') NOT NULL DEFAULT 'NOVICE'`, 30 | ); 31 | await queryRunner.query( 32 | `ALTER TABLE \`category\` CHANGE \`reactionable\` \`reactionable\` enum ('CADET', 'ADMIN', 'NOVICE') NOT NULL DEFAULT 'CADET'`, 33 | ); 34 | await queryRunner.query( 35 | `ALTER TABLE \`category\` CHANGE \`readable_comment\` \`readable_comment\` enum ('CADET', 'ADMIN', 'NOVICE') NOT NULL DEFAULT 'CADET'`, 36 | ); 37 | await queryRunner.query( 38 | `ALTER TABLE \`category\` CHANGE \`writable_comment\` \`writable_comment\` enum ('CADET', 'ADMIN', 'NOVICE') NOT NULL DEFAULT 'CADET'`, 39 | ); 40 | await queryRunner.query( 41 | `ALTER TABLE \`category\` CHANGE \`readable_article\` \`readable_article\` enum ('CADET', 'ADMIN', 'NOVICE') NOT NULL DEFAULT 'CADET'`, 42 | ); 43 | await queryRunner.query( 44 | `ALTER TABLE \`category\` CHANGE \`writable_article\` \`writable_article\` enum ('CADET', 'ADMIN', 'NOVICE') NOT NULL DEFAULT 'CADET'`, 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /apps/api/src/category/dto/response/category-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Category } from '@app/entity/category/category.entity'; 2 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 3 | import { User } from '@app/entity/user/user.entity'; 4 | import { compareRole } from '@app/utils/utils'; 5 | import { ApiProperty, PickType } from '@nestjs/swagger'; 6 | import { BaseCategoryDto } from '../base-category.dto'; 7 | 8 | export class CategoryResponseDto extends PickType(BaseCategoryDto, ['id', 'name', 'isAnonymous']) { 9 | @ApiProperty({ example: true }) 10 | isArticleWritable!: boolean; 11 | 12 | @ApiProperty({ example: true }) 13 | isArticleReadable!: boolean; 14 | 15 | @ApiProperty({ example: true }) 16 | isCommentWritable!: boolean; 17 | 18 | @ApiProperty({ example: true }) 19 | isCommentReadable!: boolean; 20 | 21 | @ApiProperty({ example: true }) 22 | isReactionable!: boolean; 23 | 24 | constructor(config: { 25 | id: number; 26 | name: string; 27 | isArticleWritable: boolean; 28 | isArticleReadable: boolean; 29 | isCommentWritable: boolean; 30 | isCommentReadable: boolean; 31 | isReactionable: boolean; 32 | isAnonymous: boolean; 33 | }) { 34 | super(); 35 | 36 | this.id = config.id; 37 | this.name = config.name; 38 | this.isArticleWritable = config.isArticleWritable; 39 | this.isArticleReadable = config.isArticleReadable; 40 | this.isCommentWritable = config.isCommentWritable; 41 | this.isCommentReadable = config.isCommentReadable; 42 | this.isReactionable = config.isReactionable; 43 | this.isAnonymous = config.isAnonymous; 44 | } 45 | 46 | static of(config: { category: Category; user: User }): CategoryResponseDto { 47 | const userRole = config.user.role as UserRole; 48 | const writableArticle = config.category.writableArticle as UserRole; 49 | const readableArticle = config.category.readableArticle as UserRole; 50 | const writableComment = config.category.writableComment as UserRole; 51 | const readableComment = config.category.readableComment as UserRole; 52 | const reactionable = config.category.reactionable as UserRole; 53 | 54 | return new CategoryResponseDto({ 55 | ...config.category, 56 | isArticleWritable: compareRole(writableArticle, userRole), 57 | isArticleReadable: compareRole(readableArticle, userRole), 58 | isCommentWritable: compareRole(writableComment, userRole), 59 | isCommentReadable: compareRole(readableComment, userRole), 60 | isReactionable: compareRole(reactionable, userRole), 61 | isAnonymous: config.category.anonymity, 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /apps/api/src/article/dto/response/__test__/article-response.dto.test.ts: -------------------------------------------------------------------------------- 1 | import { ArticleResponseDto } from '@api/article/dto/response/article-response.dto'; 2 | import { CategoryResponseDto } from '@api/category/dto/response/category-response.dto'; 3 | import { UserResponseDto } from '@api/user/dto/response/user-response.dto'; 4 | import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; 5 | 6 | describe('ArticleResponseDto', () => { 7 | it('Dto 가 잘 생성된다.', async () => { 8 | const articleResponseDto: ArticleResponseDto = new ArticleResponseDto({ 9 | id: 1, 10 | title: '제목 입니다.', 11 | content: '내용 입니다.', 12 | viewCount: 1, 13 | categoryId: 1, 14 | category: new CategoryResponseDto({ 15 | id: 1, 16 | name: '카테고리 이름 입니다.', 17 | isArticleReadable: true, 18 | isArticleWritable: true, 19 | isCommentReadable: true, 20 | isCommentWritable: true, 21 | isAnonymous: true, 22 | isReactionable: true, 23 | }), 24 | writerId: 1, 25 | writer: new UserResponseDto({ 26 | id: 1, 27 | nickname: '닉네임 입니다.', 28 | role: UserRole.CADET, 29 | character: 0, 30 | createdAt: new Date('2022-01-01'), 31 | updatedAt: new Date('2022-01-01'), 32 | }), 33 | commentCount: 1, 34 | likeCount: 1, 35 | createdAt: new Date('2022-01-01'), 36 | updatedAt: new Date('2022-01-01'), 37 | isSelf: false, 38 | isLike: false, 39 | }); 40 | 41 | expect(articleResponseDto).toMatchInlineSnapshot(` 42 | ArticleResponseDto { 43 | "category": CategoryResponseDto { 44 | "id": 1, 45 | "isAnonymous": true, 46 | "isArticleReadable": true, 47 | "isArticleWritable": true, 48 | "isCommentReadable": true, 49 | "isCommentWritable": true, 50 | "isReactionable": true, 51 | "name": "카테고리 이름 입니다.", 52 | }, 53 | "categoryId": 1, 54 | "commentCount": 1, 55 | "content": "내용 입니다.", 56 | "createdAt": 2022-01-01T00:00:00.000Z, 57 | "id": 1, 58 | "isLike": false, 59 | "isSelf": false, 60 | "likeCount": 1, 61 | "title": "제목 입니다.", 62 | "updatedAt": 2022-01-01T00:00:00.000Z, 63 | "viewCount": 1, 64 | "writer": UserResponseDto { 65 | "character": 0, 66 | "createdAt": 2022-01-01T00:00:00.000Z, 67 | "id": 1, 68 | "nickname": "닉네임 입니다.", 69 | "role": "CADET", 70 | "updatedAt": 2022-01-01T00:00:00.000Z, 71 | }, 72 | "writerId": 1, 73 | } 74 | `); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /apps/api/src/comment/dto/response/my-comment-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { CategoryResponseDto } from '@api/category/dto/response/category-response.dto'; 2 | import { BaseCommentDto } from '@api/comment/dto/base-comment.dto'; 3 | import { Article } from '@app/entity/article/article.entity'; 4 | import { Category } from '@app/entity/category/category.entity'; 5 | import { Comment } from '@app/entity/comment/comment.entity'; 6 | import { User } from '@app/entity/user/user.entity'; 7 | import { ApiProperty, PickType } from '@nestjs/swagger'; 8 | import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; 9 | 10 | class InnerArticleDto { 11 | @ApiProperty() 12 | id!: number; 13 | 14 | @IsString() 15 | @MaxLength(42) 16 | @IsNotEmpty() 17 | @ApiProperty({ example: '제목 입니다.' }) 18 | title!: string; 19 | 20 | @ApiProperty({ type: () => CategoryResponseDto }) 21 | category?: CategoryResponseDto; 22 | 23 | constructor(config: { id: number; title: string; category: CategoryResponseDto }) { 24 | this.id = config.id; 25 | this.title = config.title; 26 | this.category = config.category; 27 | } 28 | 29 | static of(config: { article: Article; category: Category; user: User }): InnerArticleDto { 30 | return new InnerArticleDto({ 31 | ...config.article, 32 | category: CategoryResponseDto.of({ 33 | category: config.category, 34 | user: config.user, 35 | }), 36 | }); 37 | } 38 | } 39 | 40 | export class MyCommentResponseDto extends PickType(BaseCommentDto, ['id', 'content', 'createdAt', 'updatedAt']) { 41 | @ApiProperty({ type: () => InnerArticleDto }) 42 | article: InnerArticleDto; 43 | 44 | constructor(config: { id: number; content: string; article: InnerArticleDto; createdAt: Date; updatedAt: Date }) { 45 | super(); 46 | 47 | this.id = config.id; 48 | this.content = config.content; 49 | this.article = config.article; 50 | this.createdAt = config.createdAt; 51 | this.updatedAt = config.updatedAt; 52 | } 53 | 54 | static of(config: { comment: Comment; article: Article; user: User }): MyCommentResponseDto { 55 | return new MyCommentResponseDto({ 56 | ...config.comment, 57 | article: InnerArticleDto.of({ 58 | article: config.article, 59 | category: config.article.category, 60 | user: config.user, 61 | }), 62 | }); 63 | } 64 | 65 | static ofArray(config: { comments: Comment[]; user: User }): MyCommentResponseDto[] { 66 | return config.comments.map((comment: Comment) => { 67 | return MyCommentResponseDto.of({ 68 | comment, 69 | article: comment.article, 70 | user: config.user, 71 | }); 72 | }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /apps/api/test/e2e/image.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AuthModule } from '@api/auth/auth.module'; 2 | import { AuthService } from '@api/auth/auth.service'; 3 | import { UploadImageUrlResponseDto } from '@api/image/dto/upload-image-url-response.dto'; 4 | import { ImageModule } from '@api/image/image.module'; 5 | import { UserRepository } from '@api/user/repositories/user.repository'; 6 | import { UserModule } from '@api/user/user.module'; 7 | import { HttpStatus, INestApplication } from '@nestjs/common'; 8 | import { Test, TestingModule } from '@nestjs/testing'; 9 | import { E2eTestBaseModule } from '@test/e2e/e2e-test.base.module'; 10 | import { clearDB, createTestApp } from '@test/e2e/utils/utils'; 11 | import * as request from 'supertest'; 12 | import { DataSource } from 'typeorm'; 13 | import * as dummy from './utils/dummy'; 14 | 15 | describe('Image', () => { 16 | let httpServer: INestApplication; 17 | let userRepository: UserRepository; 18 | let authService: AuthService; 19 | let JWT: string; 20 | let users: dummy.DummyUsers; 21 | let dataSource: DataSource; 22 | 23 | beforeAll(async () => { 24 | const moduleFixture: TestingModule = await Test.createTestingModule({ 25 | imports: [E2eTestBaseModule, UserModule, AuthModule, ImageModule], 26 | }).compile(); 27 | 28 | const app = createTestApp(moduleFixture); 29 | await app.init(); 30 | 31 | userRepository = moduleFixture.get(UserRepository); 32 | authService = moduleFixture.get(AuthService); 33 | dataSource = moduleFixture.get(DataSource); 34 | 35 | httpServer = app.getHttpServer(); 36 | }); 37 | 38 | afterAll(async () => { 39 | await dataSource.dropDatabase(); 40 | await dataSource.destroy(); 41 | await httpServer.close(); 42 | }); 43 | 44 | describe('/image', () => { 45 | beforeEach(async () => { 46 | users = await dummy.createDummyUsers(userRepository); 47 | const user = users.cadet[0]; 48 | JWT = dummy.jwt(user, authService); 49 | }); 50 | 51 | afterEach(async () => { 52 | await clearDB(dataSource); 53 | }); 54 | 55 | // TODO: S3 연동 수정할것!! 56 | test.skip('[성공] POST', async () => { 57 | const response = await request(httpServer).post('/image').set('Cookie', `${process.env.ACCESS_TOKEN_KEY}=${JWT}`); 58 | 59 | const result = response.body as UploadImageUrlResponseDto; 60 | 61 | expect(response.status).toEqual(HttpStatus.OK); 62 | expect(result.uploadUrl).toBeTruthy(); 63 | }); 64 | 65 | test('[실패] POST - unauthorized', async () => { 66 | const response = await request(httpServer).post('/image'); 67 | 68 | expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); 69 | }); 70 | }); 71 | }); 72 | --------------------------------------------------------------------------------