├── .dockerignore ├── .github ├── CODEOWNERS └── workflows │ ├── deploy-dev.yml │ ├── deploy.yml │ ├── test-dev.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── package-lock.json ├── package.json ├── resources └── src │ └── api │ ├── asset │ └── waffle_logo.png │ └── views │ ├── member.html │ ├── privacy_policy.html │ └── terms_of_service.html ├── snutt.yml.example ├── src ├── api │ ├── app.ts │ ├── config │ │ ├── express.ts │ │ ├── log.ts │ │ └── redis.ts │ ├── decorator │ │ └── RestDecorator.ts │ ├── enum │ │ └── ErrorCode.ts │ ├── error │ │ ├── ApiError.ts │ │ └── ApiServerFaultError.ts │ ├── middleware │ │ ├── ApiErrorHandler.ts │ │ ├── ApiKeyAuthorizeMiddleware.ts │ │ ├── NativeClientInfoMiddleware.ts │ │ ├── PublicApiCacheControlMiddleware.ts │ │ ├── Server2ServerMiddleware.ts │ │ ├── StaticPageCacheControlMiddleware.ts │ │ └── UserAuthorizeMiddleware.ts │ ├── model │ │ └── RequestContext.ts │ └── routes │ │ ├── AdminRouter.ts │ │ ├── ApiRouter.ts │ │ ├── AuthRouter.ts │ │ ├── EvaluationRouter.ts │ │ ├── MonitorRouter.ts │ │ ├── NotificationRouter.ts │ │ ├── PopupRouter.ts │ │ ├── RootRouter.ts │ │ ├── SearchQueryRouter.ts │ │ ├── StaticPageRouter.ts │ │ ├── TagListRouter.ts │ │ ├── TimetableRouter.ts │ │ └── UserRouter.ts ├── batch │ ├── common │ │ ├── AbstractJob.ts │ │ ├── ArrayReader.ts │ │ ├── BatchJob.ts │ │ ├── BatchJobBuilder.ts │ │ ├── BatchProcessor.ts │ │ ├── BatchReader.ts │ │ ├── BatchWriter.ts │ │ ├── LambdaJobBuilder.ts │ │ └── SimpleJob.ts │ ├── config │ │ └── log.ts │ ├── coursebook │ │ ├── CoursebookUpdateNotificationService.ts │ │ ├── LectureCompareService.ts │ │ ├── LectureProcessService.ts │ │ ├── TagParseService.ts │ │ ├── excel │ │ │ ├── ExcelUtil.ts │ │ │ └── model │ │ │ │ └── ExcelSheetWrapper.ts │ │ ├── index.ts │ │ ├── model │ │ │ └── LectureDifference.ts │ │ └── sugangsnu │ │ │ ├── SugangSnu2Service.ts │ │ │ ├── SugangSnuLectureCategoryService.ts │ │ │ ├── SugangSnuLectureService.ts │ │ │ ├── SugangSnuService.ts │ │ │ └── model │ │ │ └── SugangSnuLecture.ts │ ├── fix_time_mask │ │ └── index.ts │ └── prune_log │ │ └── index.ts └── core │ ├── admin │ ├── AdminRepository.ts │ ├── AdminService.ts │ └── model │ │ └── AdminStatistics.ts │ ├── apple │ ├── AppleService.ts │ ├── error │ │ ├── AppleApiError.ts │ │ └── InvalidAppleTokenError.ts │ └── model │ │ ├── AppleJWK.ts │ │ ├── AppleUserInfo.ts │ │ └── JwtHeader.ts │ ├── common │ └── util │ │ └── ObjectUtil.ts │ ├── config │ ├── apiKey.ts │ ├── mongo.ts │ ├── property.ts │ └── redis.ts │ ├── coursebook │ ├── CourseBookRepository.ts │ ├── CourseBookService.ts │ ├── model │ │ └── CourseBook.ts │ └── sugangsnu │ │ └── SugangSnuSyllabusService.ts │ ├── facebook │ ├── FacebookService.ts │ └── error │ │ └── InvalidFbIdOrTokenError.ts │ ├── fcm │ ├── FcmKeyUtil.ts │ ├── FcmLogRepository.ts │ ├── FcmLogService.ts │ ├── FcmService.ts │ ├── error │ │ └── FcmError.ts │ └── model │ │ └── FcmLog.ts │ ├── feedback │ ├── FeedbackService.ts │ └── model │ │ └── Feedback.ts │ ├── github │ ├── GithubService.ts │ └── model │ │ └── GithubIssue.ts │ ├── lecture │ ├── LectureService.ts │ ├── RefLectureQueryCacheRepository.ts │ ├── RefLectureQueryEtcTagService.ts │ ├── RefLectureQueryLogRepository.ts │ ├── RefLectureQueryService.ts │ ├── RefLectureRepository.ts │ ├── RefLectureService.ts │ ├── error │ │ ├── InvalidLectureTimeJsonError.ts │ │ ├── InvalidLectureTimemaskError.ts │ │ └── RefLectureNotFoundError.ts │ └── model │ │ ├── Lecture.ts │ │ ├── RefLecture.ts │ │ └── SnuttevLectureKey.ts │ ├── mail │ └── MailUtil.ts │ ├── notification │ ├── NotificationRepository.ts │ ├── NotificationService.ts │ ├── error │ │ ├── InvalidNotificationDetailError.ts │ │ └── NoFcmKeyError.ts │ └── model │ │ ├── Notification.ts │ │ └── NotificationTypeEnum.ts │ ├── popup │ └── PopupService.ts │ ├── redis │ ├── RedisKeyUtil.ts │ ├── RedisUtil.ts │ └── error │ │ └── RedisClientError.ts │ ├── taglist │ ├── TagListEtcTagService.ts │ ├── TagListRepository.ts │ ├── TagListService.ts │ ├── error │ │ └── TagListNotFoundError.ts │ └── model │ │ ├── EtcTagEnum.ts │ │ └── TagList.ts │ ├── timetable │ ├── LectureColorService.ts │ ├── TimetableLectureService.ts │ ├── TimetableRepository.ts │ ├── TimetableService.ts │ ├── error │ │ ├── CusromLectureResetError.ts │ │ ├── DuplicateLectureError.ts │ │ ├── DuplicateTimetableTitleError.ts │ │ ├── InvalidLectureColorError.ts │ │ ├── InvalidLectureColorIndexError.ts │ │ ├── InvalidLectureUpdateRequestError.ts │ │ ├── LectureTimeOverlapError.ts │ │ ├── NotCustomLectureError.ts │ │ ├── TimetableNotEnoughParamError.ts │ │ ├── TimetableNotFoundError.ts │ │ ├── UserLectureNotFoundError.ts │ │ └── WrongRefLectureSemesterError.ts │ ├── model │ │ ├── AbstractTimetable.ts │ │ ├── LectureColor.ts │ │ ├── ThemeTypeEnum.ts │ │ ├── Time.ts │ │ ├── TimePlace.ts │ │ ├── Timetable.ts │ │ └── UserLecture.ts │ └── util │ │ └── TimePlaceUtil.ts │ └── user │ ├── UserCredentialService.ts │ ├── UserDeviceService.ts │ ├── UserRepository.ts │ ├── UserService.ts │ ├── error │ ├── AlreadyRegisteredAppleSubError.ts │ ├── AlreadyRegisteredFbIdError.ts │ ├── DuplicateLocalIdError.ts │ ├── InvalidLocalIdError.ts │ ├── InvalidLocalPasswordError.ts │ └── NotLocalAccountError.ts │ └── model │ ├── RedisVerificationValue.ts │ ├── SnuttevUserInfo.ts │ ├── User.ts │ ├── UserCredential.ts │ └── UserInfo.ts ├── test ├── api │ └── routes │ │ └── StaticPageRouterIntegrationTest.ts ├── batch │ └── coursebook │ │ ├── CoursebookUpdateNotificationServiceUnitTest.ts │ │ └── sugangsnu │ │ └── SugangSnuTestExcel.xlsx ├── config │ ├── log.ts │ └── supertest.ts ├── core │ ├── lecture │ │ └── RefLectureServiceUnitTest.ts │ ├── timetable │ │ └── util │ │ │ └── TimePlaceUtilUnitTest.ts │ └── user │ │ └── UserCredentialServiceUnitTest.ts └── integration │ ├── etc.ts │ ├── index.ts │ ├── tag_list_test.ts │ ├── timetable_test.ts │ └── user_test.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | 4 | *.log.* 5 | 6 | *audit.json 7 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @wafflestudio/snutt-server 2 | -------------------------------------------------------------------------------- /.github/workflows/deploy-dev.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | 7 | jobs: 8 | deploy: 9 | name: Deploy 10 | runs-on: ubuntu-latest 11 | env: 12 | IMAGE_TAG: ${{ github.run_number }} 13 | BUILD_NUMBER: ${{ github.run_number }} 14 | ECR_REGISTRY: 405906814034.dkr.ecr.ap-northeast-2.amazonaws.com 15 | ECR_REPOSITORY: snutt-dev/snutt-core 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Create snutt.yml 22 | run: echo "${{ secrets.SNUTT_DEV_YML }}" > ./snutt.yml 23 | 24 | - name: Configure AWS credentials 25 | uses: aws-actions/configure-aws-credentials@v1 26 | with: 27 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 28 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 29 | aws-region: ap-northeast-2 30 | 31 | - name: Login to ECR 32 | id: login-ecr 33 | uses: aws-actions/amazon-ecr-login@v1 34 | 35 | - name: Docker Build, tag, and push image to ECR 36 | id: build-image 37 | run: | 38 | docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . 39 | docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG 40 | echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" 41 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | deploy: 9 | name: Deploy 10 | runs-on: ubuntu-latest 11 | env: 12 | IMAGE_TAG: ${{ github.run_number }} 13 | BUILD_NUMBER: ${{ github.run_number }} 14 | ECR_REGISTRY: 405906814034.dkr.ecr.ap-northeast-2.amazonaws.com 15 | ECR_REPOSITORY: snutt-prod/snutt-core 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Create snutt.yml 22 | run: echo "${{ secrets.SNUTT_YML }}" > ./snutt.yml 23 | 24 | - name: Configure AWS credentials 25 | uses: aws-actions/configure-aws-credentials@v1 26 | with: 27 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 28 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 29 | aws-region: ap-northeast-2 30 | 31 | - name: Login to ECR 32 | id: login-ecr 33 | uses: aws-actions/amazon-ecr-login@v1 34 | 35 | - name: Docker Build, tag, and push image to ECR 36 | id: build-image 37 | run: | 38 | docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . 39 | docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG 40 | echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" 41 | 42 | - name: Slack Notify 43 | uses: rtCamp/action-slack-notify@v2.1.2 44 | env: 45 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 46 | SLACK_CHANNEL: team-snutt-deploy 47 | SLACK_TITLE: NEW RELEASE 48 | SLACK_USERNAME: snutt-core 49 | SLACK_ICON: https://user-images.githubusercontent.com/35535636/103177470-2237cb00-48be-11eb-9211-3ffa567c8ac3.png 50 | SLACK_MESSAGE: Check for updated environment 51 | SLACK_FOOTER: https://snutt-api.wafflestudio.com/terms_of_service 52 | -------------------------------------------------------------------------------- /.github/workflows/test-dev.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the develop branch 8 | pull_request: 9 | branches: [ develop ] 10 | 11 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 12 | jobs: 13 | test: 14 | # The type of runner that the job will run on 15 | runs-on: ubuntu-latest 16 | 17 | # Steps represent a sequence of tasks that will be executed as part of the job 18 | steps: 19 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 20 | - uses: actions/checkout@v2 21 | 22 | - name: Setup node 8 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: 8.x 26 | 27 | - name: check node version 28 | run: node -v 29 | 30 | - name: setup redis, mongodb 31 | run: | 32 | docker-compose up -d 33 | mv ./snutt.yml.example ./snutt.yml 34 | 35 | - name: install packages 36 | run: npm install 37 | 38 | - name: test 39 | run: yarn test 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the develop branch 8 | pull_request: 9 | branches: [ master ] 10 | 11 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 12 | jobs: 13 | test: 14 | # The type of runner that the job will run on 15 | runs-on: ubuntu-latest 16 | 17 | # Steps represent a sequence of tasks that will be executed as part of the job 18 | steps: 19 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 20 | - uses: actions/checkout@v2 21 | 22 | - name: Setup node 8 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: 8.x 26 | 27 | - name: check node version 28 | run: node -v 29 | 30 | - name: setup redis, mongodb 31 | run: | 32 | docker-compose up -d 33 | mv ./snutt.yml.example ./snutt.yml 34 | 35 | - name: install packages 36 | run: npm install 37 | 38 | - name: test 39 | run: yarn test 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node dependencies 2 | node_modules 3 | .DS_Store 4 | 5 | # IDE specifics 6 | .vscode/ 7 | .idea 8 | 9 | # Log files 10 | *.log.* 11 | 12 | # Application settings 13 | snutt.yml 14 | 15 | # Output files 16 | build/ 17 | 18 | # winston-daily-rotate-file 19 | *audit.json 20 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 8.15 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.15-alpine 2 | WORKDIR /app 3 | RUN apk add g++ make python 4 | COPY package*.json ./ 5 | RUN npm install 6 | COPY . . 7 | RUN npm run-script build 8 | CMD ["npm", "start"] 9 | 10 | EXPOSE 3000 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Wafflestudio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚠️ Deprecated: snutt-nodejs 2 | 3 | > **이 프로젝트는 더 이상 유지보수되지 않습니다.** 4 | 5 | `snutt-nodejs` 프로젝트는 현재 **Kotlin Spring** 기반으로 새롭게 개발된 [`snutt`](https://github.com/snutt) 프로젝트로 대체되었습니다. 6 | 이전 코드베이스는 참고용으로만 제공되며, 새로운 기능 개발 및 유지보수는 진행되지 않습니다. 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | mongo: 4 | image: mongo:4.4.6 5 | ports: 6 | - 27017:27017 7 | redis: 8 | image: redis:6.2.1 9 | ports: 10 | - 6379:6379 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snutt", 3 | "version": "2.3.3", 4 | "private": true, 5 | "scripts": { 6 | "test": "tsc -p . && cp -R resources/* build/ && mocha -r module-alias/register --exit \"build/test/**/*.js\"", 7 | "start": "node ./build/src/api/app.js", 8 | "build": "tsc -p . && cp -R resources/* build/", 9 | "apikey": "node ./build/src/core/config/apiKey list", 10 | "coursebook": "node ./build/src/batch/coursebook", 11 | "prune_log": "node ./build/src/batch/prune_log", 12 | "feedback2github": "node ./build/src/batch/feedback2github" 13 | }, 14 | "dependencies": { 15 | "@types/async": "~2.0.49", 16 | "@types/bcrypt": "~2.0.0", 17 | "@types/express": "^4.0.34", 18 | "@types/express-promise-router": "^2.0.0", 19 | "@types/js-yaml": "~3.11.0", 20 | "@types/jsonwebtoken": "^7.2.0", 21 | "@types/mocha": "^2.2.35", 22 | "@types/mongoose": "^4.7.2", 23 | "@types/morgan": "^1.7.32", 24 | "@types/node": "^6.0.55", 25 | "@types/redis": "^2.8.6", 26 | "@types/request": "^2.0.0", 27 | "@types/request-promise-native": "~1.0.6", 28 | "@types/rewire": "^2.5.28", 29 | "@types/sinon": "^5.0.1", 30 | "@types/supertest": "^2.0.7", 31 | "assert-rejects": "^0.1.1", 32 | "async": "~2.6.4", 33 | "async-retry": "^1.3.1", 34 | "aws-sdk": "^2.1354.0", 35 | "bcrypt": "~5.1.0", 36 | "body-parser": "~1.20.1", 37 | "cookie-parser": "~1.4.3", 38 | "cors": "~2.8.4", 39 | "ejs": "~3.1.7", 40 | "express": "~4.17.3", 41 | "express-promise-router": "^3.0.3", 42 | "immutable": "~3.8.2", 43 | "js-yaml": "^3.13.1", 44 | "jsonwebtoken": "~5.5.0", 45 | "mocha": "~5.2.0", 46 | "module-alias": "^2.0.6", 47 | "mongodb": "~3.1.13", 48 | "mongoose": "^5.7.5", 49 | "morgan": "^1.9.1", 50 | "nodemailer": "^6.7.5", 51 | "pem-jwk": "^2.0.0", 52 | "redis": "^3.1.1", 53 | "request": "^2.74.0", 54 | "request-promise-native": "~1.0.5", 55 | "rewire": "^4.0.1", 56 | "sinon": "^6.1.3", 57 | "streamroller": "^0.8.5", 58 | "supertest": "~3.1.0", 59 | "typescript": "2.9.1", 60 | "winston": "^3.2.0", 61 | "winston-daily-rotate-file": "^3.6.0", 62 | "xlsx": "~0.17.0" 63 | }, 64 | "_moduleAliases": { 65 | "@app": "build/src", 66 | "@test": "build/test" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /resources/src/api/asset/waffle_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wafflestudio/snutt-nodejs/c07331d23242b0a56a215d4adb689fad3f0a344d/resources/src/api/asset/waffle_logo.png -------------------------------------------------------------------------------- /resources/src/api/views/member.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 서울대학교 시간표 : SNUTT 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 75 | 76 |
77 |

서울대학교 시간표

78 |

SNUTT 개발팀

79 |
80 |
81 | 82 | 83 | 84 |
85 |
86 |

# 2012~

87 | 최초 개발
이종민 / 컴퓨터공학부 09
88 |

# 2014~

89 | 김광래 / 컴퓨터공학부 12
90 | 김진형 / 컴퓨터공학부 14
91 | 정형식 / 자유전공학부 10
92 | 김알찬 / 컴퓨터공학부 12
93 |

# 2016~

94 | 김진형 / 컴퓨터공학부 14
95 | 방성원 / 컴퓨터공학부 15
96 | 이주현 / 시각디자인 11
97 | 장렬 / 컴퓨터공학부 14
98 | 정형식 / 자유전공학부 10
99 |

# 2021~

100 | 금진섭 / 서양사학과 14 / iOS
101 | 김상민 / 컴퓨터공학부 18 / Android
102 | 변다빈 / 언어학과 14 / 서버
103 | 서정록 / 컴퓨터공학부 18 / 기획
104 | 서정민 / 홍익대 회화과 15 / 기획 및 디자인
105 | 최한결 / 컴퓨터공학부 15 / 서버
106 |

# 2022~

107 | 금진섭 / 서양사학과 14 / iOS
108 | 김상민 / 컴퓨터공학부 18 / Android
109 | 박수빈 / 컴퓨터공학부 18 / 서버
110 | 박신홍 / 경영학과 17 / iOS
111 | 변다빈 / 언어학과 14 / 서버
112 | 서정록 / 컴퓨터공학부 18 / 기획
113 | 서정민 / 홍익대 회화과 15 / 기획 및 디자인
114 | 양주현 / 지구환경과학부 16 / Android
115 | 우현민 / 컴퓨터공학부 19 / 프론트
116 | 최유림 / 컴퓨터공학부 19 / iOS
117 | 최한결 / 컴퓨터공학부 15 / 서버
118 |

# 2023~

119 | 강지혁 / 컴퓨터공학부 19 / 서버
120 | 김기완 / 컴퓨터공학부 18 / 프론트
121 | 박수빈 / 컴퓨터공학부 18 / 서버
122 | 박신홍 / 경영학과 17 / iOS
123 | 변다빈 / 언어학과 14 / 서버
124 | 송동엽 / 컴퓨터공학부 22 / Android
125 | 양주현 / 지구환경과학부 16 / Android
126 | 우현민 / 컴퓨터공학부 19 / 프론트
127 | 이채민 / 자유전공학부 20 / iOS
128 | 최유림 / 컴퓨터공학부 19 / iOS
129 | 최유진 / 디자인과 22 / 디자인
130 | 최한결 / 컴퓨터공학부 15 / 서버
131 |

# 2024~

132 | 김기완 / 컴퓨터공학부 18 / 프론트
133 | 민유진 / 디자인과 21 / 디자인
134 | 박신홍 / 경영학과 17 / iOS
135 | 변다빈 / 언어학과 14 / 서버
136 | 송동엽 / 컴퓨터공학부 22 / Android
137 | 양주현 / 지구환경과학부 16 / Android
138 | 이채민 / 자유전공학부 20 / iOS
139 | 이현도 / 컴퓨터공학부 23 / Android
140 | 임찬영 / 컴퓨터공학부 23 / 서버
141 | 최유림 / 컴퓨터공학부 19 / iOS
142 | 최유진 / 디자인과 22 / 디자인
143 | 최한결 / 컴퓨터공학부 15 / 서버
144 |
145 | 146 |
147 |
개발자 괴롭히기 - snutt@wafflestudio.com
148 |
149 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /resources/src/api/views/privacy_policy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SNUTT 개인정보처리방침 6 | 22 | 23 | 24 |

SNUTT 개인정보처리방침

25 |
 26 | 

27 | 총칙 28 |

29 | WaffleStudio는 스누티티(SNUTT) 및 관련 제반 서비스(이하 “서비스”라고 합니다)를 이용하는 회원의 개인정보 보호를 소중하게 생각하고, 회원의 개인정보를 보호하기 위하여 항상 최선을 다해 노력하고 있습니다. 30 | WaffleStudio는 개인정보 보호 관련 주요 법률인 개인정보 보호법, 정보통신망 이용촉진 및 정보보호 등에 관한 법률(이하 “정보통신망법”이라고 합니다.)을 비롯한 모든 개인정보 보호에 관련 법률 규정 및 국가기관 등이 제정한 고시, 훈령, 지침 등을 준수합니다. 31 | 32 | 본 개인정보처리방침은 스누티티 및 관련 제반 서비스를 이용하는 회원에 대하여 적용되며, 회원이 제공하는 개인정보가 어떠한 용도와 방식으로 이용되고 있고 개인정보 보호를 위하여 어떠한 조처를 취하고 있는지 알립니다. 33 | 34 | 35 |

개인정보의 수집·이용에 대한 동의

36 | 적법하고 공정한 방법에 의하여 서비스 이용계약의 성립 및 이행에 필요한 최소한의 개인정보를 수집하며 이용자의 개인 식별이 가능한 개인정보를 수집하기 위하여 회원가입시 개인정보수집·이용 동의에 대한 내용을 제공합니다. 이용자가 '계정 만들기' 버튼을 클릭하면 개인정보 수집·이용에 대해 동의한 것으로 봅니다. 37 | 38 | 39 |

개인정보의 수집범위 및 수집방법

40 | 1. SNUTT는 회원가입 등 서비스 제공 및 계약이행을 위해 아래와 같이 개인정보를 수집할 수 있습니다. 41 | - 아이디, 비밀번호, 페이스북 회원번호(페이스북 연동 회원에 한함), 이메일 주소 42 | 2. 서비스 이용과정에서 아래와 같은 정보들이 생성되어 수집될 수 있습니다. 43 | (1) PC : PC MacAddress, PC 사양정보, 브라우저 정보, 기타 서비스 이용 시 사용되는 프로그램 버전 정보 44 | (2) 휴대전화(스마트폰) & 스마트OS 탑재 모바일 기기(Tablet PC 등) : 모델명, 기기별 고유번호(UDID,IMEI 등), OS정보, 이동통신사, 구글/애플 광고 ID 45 | (3) 기타 정보 : 서비스 이용(정지) 기록, 접속 로그, 쿠키, 접속 IP정보 46 | 3. SNUTT는 다음과 같은 방식으로 개인정보를 수집합니다. 47 | (1) 회원이 직접 등록한 개인 정보 48 | (2) SNS 연동 시 제3자 계정에 등록한 개인 정보 49 | 4. 기본적 인권침해의 우려가 있는 개인정보(인종 및 민족, 사상 및 신조, 출신지 및 본적지, 정치적 성향 및 범죄기록, 건강상태 및 성생활 등)는 요구하지 않으며, 위의 항목 이외에 다른 어떠한 목적으로도 수집, 사용하지 않습니다. 50 | 51 | 52 |

개인정보의 수집목적 및 이용목적

53 | SNUTT는 수집한 개인정보를 다음의 목적으로 활용합니다. 54 | 1. 회원관리 55 | 회원제 서비스 이용에 따른 본인확인, 개인식별, 불량회원의 부정 이용 방지와 비인가 사용 방지, 중복가입확인, 가입의사 확인 56 | 2. 서비스 품질 향상을 목적으로 활용 57 | 신규 서비스 개발, 서비스 유효성 확인, 접속 빈도 파악, 회원의 서비스 이용에 대한 통계 58 | 59 | 60 |

수집한 개인정보의 취급 위탁

61 | SNUTT는 외부에 개인정보를 위탁하지 않습니다. 62 | 63 | 64 |

개인정보의 자동 수집 장치의 설치, 운영 및 그 거부에 관한 사항

65 | 쿠키란 웹 서버가 웹 브라우저에 보내어 저장했다가 서버의 부가적인 요청이 있을 때 다시 서버로 보내주는 문자열 정보(텍스트 파일)로 회원의 컴퓨터 하드디스크에 저장되며 쿠키 (cookie)에는 사용한 웹사이트의 정보 및 이용자 의 개인정보가 담길 수 있습니다. 66 | 1. SNUTT 웹 서비스는 인터넷을 통하여 회원의 정보를 저장하고 수시로 찾아내는 쿠키(cookie)를 설치, 운용하고 있습니다. 회원이 웹사이트에 접속을 하면 회원의 브라우저에 있는 쿠키의 내용을 읽고, 추가정보를 찾아 접속에 따른 성명 등의 추가 입력없이 서비스를 제공할 수 있습니다. 67 | 2. 회원은 쿠키 설치에 대한 선택권을 가지고 있으며 회원은 웹브라우저에서 옵션을 설정함으로써 모든 쿠키를 허용하거나, 쿠키가 저장될 때마다 확인을 거치거나, 혹은 모든 쿠키의 저장을 거부할 수도 있습니다. 다만, 회원 님께서 쿠키 설치를 거부했을 경우 서비스 제공에 어려움이 발생할 수도 있습니다. 68 | 69 | 70 |

개인정보의 공유 및 제공

71 | 1. 회사는 회원의 개인정보를 본 개인정보취급방침에서 명시된 범위를 초과하여 이용하거나 제 3자(타인 또는 타기업 기관)에 제공하지 않습니다. 다만, 회원의 동의가 있거나 다음 각호의 어느 하나에 해당하는 경우에는 예외로 합니다. 72 | (1) 서비스 제공에 따른 요금 정산을 위하여 필요한 경우 73 | (2) 관계법령에 의하여 수사, 재판 또는 행정상의 목적으로 관계기관으로부터 요구가 있을 경우 74 | (3) 통계작성, 학술연구나 시장조사를 위하여 특정 개인을 식별할 수 없는 형태로 가공하여 제공하는 경우 75 | (4) 금융실명거래 및 비밀보장에 관한 법률, 신용정보의 이용 및 보호에 관한 법률, 전기통신기본법, 전기통신사업법, 지방 세법, 소비자보호법, 한국은행법, 형사소송법 등 기타 관계법령에서 정한 절차에 따른 요청이 있는 경우 76 | 2. 영업의 전부 또는 일부를 양도하거나, 합병/상속 등으로 서비스제공자의 권리,의무를 이전 승계하는 경우 개인 정보보호 관련 회원의 권리를 보장하기 위하여 반드시 그 사실을 회원에게 통지합니다. 77 | 78 | 79 |

개인정보의 보관기간 및 이용기간

80 | 1. 이용자의 개인정보는 개인정보의 수집목적 또는 제공받은 목적이 달성되면 파기됩니다. 회원이 회원탈퇴를 하거나 개인정보 허위기재로 인해 회원 ID 삭제 처분을 받은 경우, 수집된 전자적 파일 형태의 개인정보는 완전히 삭제되며 어떠한 용도로도 이용할 수 없도록 처리됩니다. 81 | 2. 이용자의 개인정보는 개인정보의 수집 및 이용목적이 달성되면 지체 없이 파기되나, 아래 각 항목에 해당하는 경우에는 명시한 기간 동안 보관할 수 있으며, 그 외 다른 목적으로는 사용하지 않습니다. 82 | (1) 불건전한 서비스 이용으로 서비스에 물의를 일으킨 이용자의 경우 사법기관 수사의뢰를 하거나 다른 회원을 보호할 목적으로 1년간 해당 개인정보를 보관할 수 있습니다. 83 | (2) 관계법령의 규정에 의하여 보관할 필요가 있는 경우 SNUTT는 수집 및 이용 목적 달성 후에도 관계법령 에서 정한 일정 기간 동안 회원의 개인정보를 보관할 수 있습니다. 84 | 가. 계약 또는 청약철회 등에 관한 기록 : 5년 85 | 나. 대금결제 및 재화의 공급에 관한 기록 : 5년 86 | 다. 소비자의 불만 또는 분쟁처리에 관한 기록 : 3년 87 | 라. 표시, 광고에 관한 기록 : 1년 88 | 마. 웹사이트 방문기록 : 1년 89 | 90 | 91 |

회원의 권리와 의무

92 | 회원은 언제든지 [더보기 > 내 계정]에서 자신의 개인정보를 조회하거나 수정 및 삭제할 수 있으며 [더보기 > 내 계정 > 회원탈퇴] 등을 통해 개인정보의 수집 및 이용 동의를 철회할 수 있습니다. 회원은 언제든지 전자우편을 이용하여 개인정보 처리의 정지 및 개인정보 삭제를 요청할 수 있습니다. 93 | 회원은 본인의 개인정보를 최신의 상태로 정확하게 입력하여 불의의 사고를 예방해주시기 바랍니다. 회원이 입력한 부정확한 정보로 인해 발생하는 사고의 책임은 이용자 자신에게 있으며 타인 정보의 도용 등 허위정보를 입력 할 경우 계정의 이용이 제한될 수 있습니다. 94 | 회사가 운영하는 서비스를 이용하는 회원은 개인정보를 보호 받을 권리와 함께 스스로를 보호하고 타인의 정보를 침해하지 않을 의무도 가지고 있습니다. 회원은 아이디(ID), 비밀번호를 포함한 개인정보가 유출되지 않도록 조심 하여야 하며, 게시물을 포함한 타인의 개인정보를 훼손하지 않도록 유의해야 합니다. 만약 이 같은 책임을 다하지 못하고 타인의 정보 및 타인의 존엄성을 훼손할 경우에는 「정보통신망 이용촉진 및 정보보호 등에 관한 법률」등에 의해 처벌받을 수 있습니다. 95 | 96 | 97 |

고지의 의무

98 | 현 「개인정보처리방침」 내용의 추가, 삭제 및 수정이 있을 시에는 개정 최소 7일 전부터 알림과 전자우편을 통해 고지하고, 개정 내용이 회원에게 불리할 경우에는 30일간 고지할 것입니다. 99 | 100 | 101 |

개인정보관리책임자 및 담당자

102 | WaffleStudio는 회원의 개인정보보호를 가장 중요시하며, 회원의 개인정보가 훼손, 침해 또는 누설되지 않도록 최선을 다하고 있습니다. 103 | 104 | [개인정보관리책임자] 105 | SNUTT 담당자 106 | 전자우편 : snutt@wafflestudio.com 107 | 108 | 기타 개인정보에 관한 상담이 필요한 경우에는 정보통신부 산하 공공기관인 한국인터넷진흥원(KISA) 개인정보침 해신고센터 또는 경찰청 사이버테러대응센터로 문의하시기 바랍니다. 109 | [한국인터넷진흥원 개인정보침해신고센터] 110 | 전화번호 : 국번없이 118 111 | 홈페이지 주소 : http://privacy.kisa.or.kr [경찰청 사이버테러대응센터] 112 | 전화번호 : 02-393-9112 113 | 홈페이지 주소 : http://www.netan.go.kr [대검찰청 사이버범죄수사단] 114 | 전화번호 : 02-3480-3751 115 | 홈페이지 주소 : http://www.spo.go.kr 116 |
117 | 118 | -------------------------------------------------------------------------------- /snutt.yml.example: -------------------------------------------------------------------------------- 1 | core: 2 | secretKey: test 3 | email: snutt@wafflestudio.com 4 | mongo: 5 | uri: mongodb://localhost/snutt 6 | redis: 7 | url: redis://localhost 8 | fcm: 9 | apiKey: testapikey 10 | projectId: testprojectid 11 | feedback2github: 12 | token: testapikey 13 | repo: 14 | owner: wafflestudio 15 | name: snutt-feedbacks 16 | 17 | api: 18 | host: localhost 19 | port: 3000 20 | snuttev: 21 | url: http://localhost:5000 22 | winston: 23 | path: './snutt-api.log' 24 | datePattern: 'YYYY-MM-DD' 25 | daysToKeep: 30 26 | logLevel: debug 27 | morgan: 28 | path: './snutt-api-access.log' 29 | datePattern: '.yyyy-MM-dd' 30 | daysToKeep: 30 31 | 32 | batch: 33 | winston: 34 | path: './snutt-batch.log' 35 | datePattern: 'YYYY-MM-DD' 36 | daysToKeep: 30 37 | logLevel: debug 38 | -------------------------------------------------------------------------------- /src/api/app.ts: -------------------------------------------------------------------------------- 1 | require('module-alias/register'); 2 | require('@app/api/config/log'); 3 | require('@app/core/config/mongo'); 4 | require('@app/core/config/redis'); 5 | require('@app/api/config/redis'); 6 | export = require('@app/api/config/express'); 7 | -------------------------------------------------------------------------------- /src/api/config/express.ts: -------------------------------------------------------------------------------- 1 | import express = require("express"); 2 | import morgan = require("morgan"); 3 | import cookieParser = require("cookie-parser"); 4 | import bodyParser = require("body-parser"); 5 | import path = require("path"); 6 | import cors = require("cors"); 7 | import http = require('http'); 8 | import winston = require('winston'); 9 | import streamroller = require('streamroller'); 10 | 11 | import RootRouter = require('@app/api/routes/RootRouter'); 12 | import property = require('@app/core/config/property'); 13 | import ApiErrorHandler from "../middleware/ApiErrorHandler"; 14 | 15 | var logger = winston.loggers.get('default'); 16 | var app = express(); 17 | 18 | // view engine setup 19 | app.set('views', path.join(__dirname, '..', 'views')); 20 | //app.set('view engine', 'jade'); 21 | app.engine('.html', require('ejs').renderFile); 22 | 23 | // X-Forwarded-For 헤더를 신뢰할 주소. 앞단에 nginx 프록시를 둘 경우 필요함. localhost만을 활성화한다 24 | app.set('trust proxy', 'loopback') 25 | 26 | let logPath = property.get("api.morgan.path"); 27 | let logDatePattern = property.get("api.morgan.datePattern"); 28 | let daysToKeep = property.get("api.morgan.daysToKeep"); 29 | 30 | // uncomment after placing your favicon in /public 31 | //app.use(favicon(path.join(__dirname, 'public', 'favicon.ico'))); 32 | if (app.get('env') !== 'mocha') { 33 | let morganPattern = ':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent" :response-time ms'; 34 | let dateRollingStream = new streamroller.DateRollingFileStream(logPath, logDatePattern, { 35 | compress: true, 36 | alwaysIncludePattern: true, 37 | daysToKeep: daysToKeep 38 | }); 39 | // both stdout and file 40 | app.use(morgan(morganPattern)); 41 | app.use(morgan(morganPattern, { stream: dateRollingStream })); 42 | } 43 | 44 | // Only for development 45 | app.use(cors()); 46 | app.use(bodyParser.json()); 47 | app.use(bodyParser.urlencoded({ extended: false })); 48 | app.use(cookieParser()); 49 | 50 | app.use('/', RootRouter); 51 | 52 | app.use(ApiErrorHandler); 53 | 54 | /** 55 | * Get port from environment and store in Express. 56 | */ 57 | 58 | var port = property.get('api.port') || '3000'; 59 | var host = property.get('api.host') || 'localhost'; 60 | app.set('port', port); 61 | app.set('host', host); 62 | 63 | if (process.env.NODE_ENV != 'mocha') { 64 | var server = createServer(); 65 | } 66 | 67 | 68 | /** 69 | * Create server. 70 | */ 71 | function createServer(): http.Server { 72 | var server = http.createServer(app); 73 | server.listen(port, host, function() { 74 | logger.info("Server listening on http://" + host + ":" + port); 75 | }); 76 | server.on('error', onError); 77 | server.on('listening', onListening); 78 | return server; 79 | } 80 | 81 | /** 82 | * Event listener for HTTP server "error" event. 83 | */ 84 | 85 | function onError(error) { 86 | if (error.syscall !== 'listen') { 87 | throw error; 88 | } 89 | 90 | var bind = typeof port === 'string' 91 | ? 'Pipe ' + port 92 | : 'Port ' + port; 93 | 94 | // handle specific listen errors with friendly messages 95 | switch (error.code) { 96 | case 'EACCES': 97 | logger.error(bind + ' requires elevated privileges'); 98 | process.exit(1); 99 | break; 100 | case 'EADDRINUSE': 101 | logger.error(bind + ' is already in use'); 102 | process.exit(1); 103 | break; 104 | default: 105 | throw error; 106 | } 107 | } 108 | 109 | /** 110 | * Event listener for HTTP server "listening" event. 111 | */ 112 | 113 | function onListening() { 114 | var addr = server.address(); 115 | var bind = typeof addr === 'string' 116 | ? 'pipe ' + addr 117 | : 'port ' + addr.port; 118 | logger.debug('Listening on ' + bind); 119 | } 120 | 121 | export = app; 122 | -------------------------------------------------------------------------------- /src/api/config/log.ts: -------------------------------------------------------------------------------- 1 | import winston = require('winston'); 2 | import DailyRotateFile = require('winston-daily-rotate-file'); 3 | import property = require('@app/core/config/property'); 4 | 5 | let logPath = property.get("api.winston.path"); 6 | let logDatePattern = property.get("api.winston.datePattern"); 7 | let logLevel = property.get("api.winston.logLevel"); 8 | let daysToKeep = property.get("api.winston.daysToKeep"); 9 | 10 | var transport = new (DailyRotateFile)({ 11 | filename: logPath, 12 | datePattern: logDatePattern, 13 | zippedArchive: true, 14 | maxFiles: daysToKeep 15 | }); 16 | 17 | if (process.env.NODE_ENV !== 'mocha') { 18 | winston.loggers.add('default', { 19 | level: logLevel, 20 | transports: [transport], 21 | format: winston.format.combine( 22 | winston.format.timestamp({ 23 | format: 'YYYY-MM-DD HH:mm:ss.SSS' 24 | }), 25 | winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${(typeof info.message === 'string') ? info.message : JSON.stringify(info.message)}`) 26 | ) 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/api/config/redis.ts: -------------------------------------------------------------------------------- 1 | import winston = require('winston'); 2 | import RedisUtil = require('@app/core/redis/RedisUtil'); 3 | 4 | let logger = winston.loggers.get('default'); 5 | 6 | RedisUtil.pollRedisClient().then(function() { 7 | logger.info('Flushing all redis data'); 8 | RedisUtil.flushdb(); 9 | }).catch(function(err) { 10 | logger.error("Failed to flush redis"); 11 | }); 12 | -------------------------------------------------------------------------------- /src/api/decorator/RestDecorator.ts: -------------------------------------------------------------------------------- 1 | import express = require('express'); 2 | import RequestContext from '@app/api/model/RequestContext'; 3 | 4 | export function restController(router: express.Router, method:string, url: string) { 5 | return function(target: (context: RequestContext, request?: express.Request) => Promise) { 6 | router[method](url, async function (req, res, next) { 7 | let context: RequestContext = req['context']; 8 | try { 9 | let result = await target(context, req); 10 | return res.json(result); 11 | } catch (err) { 12 | next(err); 13 | } 14 | }); 15 | } 16 | } 17 | 18 | export function restGet(router: express.Router, url: string) { 19 | return restController(router, "get", url); 20 | } 21 | 22 | export function restPost(router: express.Router, url: string) { 23 | return restController(router, "post", url); 24 | } 25 | 26 | export function restPut(router: express.Router, url: string) { 27 | return restController(router, "put", url); 28 | } 29 | 30 | export function restDelete(router: express.Router, url: string) { 31 | return restController(router, "delete", url); 32 | } 33 | -------------------------------------------------------------------------------- /src/api/enum/ErrorCode.ts: -------------------------------------------------------------------------------- 1 | enum ErrorCode { 2 | /* 500 - Server fault */ 3 | SERVER_FAULT = 0x0000, 4 | 5 | /* 400 - Request was invalid */ 6 | NO_FB_ID_OR_TOKEN = 0x1001, 7 | NO_YEAR_OR_SEMESTER = 0x1002, 8 | NOT_ENOUGH_TO_CREATE_TIMETABLE = 0x1003, 9 | NO_LECTURE_INPUT = 0x1004, 10 | NO_LECTURE_ID = 0x1005, 11 | ATTEMPT_TO_MODIFY_IDENTITY = 0x1006, 12 | NO_TIMETABLE_TITLE = 0x1007, 13 | NO_REGISTRATION_ID = 0x1008, 14 | INVALID_TIMEMASK = 0x1009, 15 | INVALID_COLOR = 0x100A, 16 | NO_LECTURE_TITLE = 0x100B, 17 | INVALID_TIMEJSON = 0x100C, 18 | INVALID_NOTIFICATION_DETAIL = 0x100D, 19 | NO_APPLE_ID_OR_TOKEN = 0x100E, 20 | NO_TIMETABLE_THEME = 0x100F, 21 | INVALID_VERIFICATION_CODE = 0x1010, 22 | INVALID_OS_TYPE = 0x1011, 23 | INVALID_OS_VERSION = 0x1012, 24 | INVALID_APP_TYPE = 0x1013, 25 | INVALID_APP_VERSION = 0x1014, 26 | NO_LOCAL_ID = 0x1015, 27 | NO_PASSWORD_RESET_CODE = 0x1016, 28 | NO_LOCAL_ID_OR_CODE = 0x1017, 29 | NO_EMAIL = 0x1018, 30 | NO_PASSWORD = 0x1019, 31 | 32 | /* 401, 403 - Authorization-related */ 33 | WRONG_API_KEY = 0x2000, 34 | NO_USER_TOKEN = 0x2001, 35 | WRONG_USER_TOKEN = 0x2002, 36 | NO_ADMIN_PRIVILEGE = 0x2003, 37 | WRONG_ID = 0x2004, 38 | WRONG_PASSWORD = 0x2005, 39 | WRONG_FB_TOKEN = 0x2006, 40 | UNKNOWN_APP = 0x2007, 41 | WRONG_APPLE_TOKEN = 0x2008, 42 | RESET_PASSWORD_NO_REQUEST = 0x2009, 43 | RESET_PASSWORD_CODE_EXPIRED = 0x2010, 44 | RESET_PASSWORD_WRONG_CODE = 0x2011, 45 | 46 | /* 403 - Restrictions */ 47 | INVALID_ID = 0x3000, 48 | INVALID_PASSWORD = 0x3001, 49 | DUPLICATE_ID = 0x3002, 50 | DUPLICATE_TIMETABLE_TITLE = 0x3003, 51 | DUPLICATE_LECTURE = 0x3004, 52 | ALREADY_LOCAL_ACCOUNT = 0x3005, 53 | ALREADY_FB_ACCOUNT = 0x3006, 54 | NOT_LOCAL_ACCOUNT = 0x3007, 55 | NOT_FB_ACCOUNT = 0x3008, 56 | FB_ID_WITH_SOMEONE_ELSE = 0x3009, 57 | WRONG_SEMESTER = 0x300A, 58 | NOT_CUSTOM_LECTURE = 0x300B, 59 | LECTURE_TIME_OVERLAP = 0x300C, 60 | IS_CUSTOM_LECTURE = 0x300D, 61 | USER_HAS_NO_FCM_KEY = 0x300E, 62 | INVALID_EMAIL = 0x300F, 63 | INPUT_OUT_OF_RANGE = 0x3010, 64 | USER_EMAIL_IS_NOT_VERIFIED = 0x3011, 65 | 66 | /* 404 - Not found */ 67 | TAG_NOT_FOUND = 0x4000, 68 | TIMETABLE_NOT_FOUND = 0x4001, 69 | LECTURE_NOT_FOUND = 0x4002, 70 | REF_LECTURE_NOT_FOUND = 0x4003, 71 | USER_NOT_FOUND = 0x4004, 72 | COLORLIST_NOT_FOUND = 0x4005, 73 | EMAIL_NOT_FOUND = 0x4006, 74 | 75 | /* 409 - Conflict */ 76 | USER_EMAIL_ALREADY_VERIFIED = 0x9000, 77 | EMAIL_ALREADY_VERIFIED_BY_ANOTHER_USER = 0x9001, 78 | 79 | /* 429 - Too many requests */ 80 | TOO_MANY_VERIFICATION_REQUEST = 0xA000, 81 | } 82 | 83 | export default ErrorCode; 84 | -------------------------------------------------------------------------------- /src/api/error/ApiError.ts: -------------------------------------------------------------------------------- 1 | import ErrorCode from "../enum/ErrorCode"; 2 | 3 | export default class ApiError extends Error { 4 | constructor(public statusCode: number, public errorCode: ErrorCode, public message: string, public ext: Record = {}) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/api/error/ApiServerFaultError.ts: -------------------------------------------------------------------------------- 1 | import ApiError from "./ApiError"; 2 | import ErrorCode from "../enum/ErrorCode"; 3 | 4 | export default class ApiServerFaultError extends ApiError { 5 | constructor() { 6 | super(500, ErrorCode.SERVER_FAULT, "Server error occured"); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/api/middleware/ApiErrorHandler.ts: -------------------------------------------------------------------------------- 1 | import winston = require('winston'); 2 | import RequestContext from '@app/api/model/RequestContext'; 3 | import ApiError from '../error/ApiError'; 4 | import ApiServerFaultError from '../error/ApiServerFaultError'; 5 | 6 | let logger = winston.loggers.get('default'); 7 | 8 | export default function(err, req, res, next) { 9 | let context: RequestContext = req["context"]; 10 | if (err instanceof ApiError) { 11 | res.status(err.statusCode).json({ 12 | errcode: err.errorCode, 13 | message: err.message, 14 | ext: err.ext 15 | }); 16 | } else { 17 | logger.error({ 18 | method: context.method, 19 | url: context.url, 20 | platform: context.platform, 21 | cause: err 22 | }); 23 | let serverFaultError = new ApiServerFaultError(); 24 | res.status(serverFaultError.statusCode).json({ 25 | errcode: serverFaultError.errorCode, 26 | message: serverFaultError.message 27 | }); 28 | } 29 | logger.error(err.stack); 30 | } 31 | -------------------------------------------------------------------------------- /src/api/middleware/ApiKeyAuthorizeMiddleware.ts: -------------------------------------------------------------------------------- 1 | import RequestContext from "../model/RequestContext"; 2 | import apiKey = require('@app/core/config/apiKey'); 3 | import ErrorCode from "../enum/ErrorCode"; 4 | import ApiError from "../error/ApiError"; 5 | 6 | export default async function(req, res) { 7 | var token = req.headers['x-access-apikey']; 8 | let context: RequestContext = req['context']; 9 | 10 | try { 11 | context.platform = await apiKey.validateKey(token); 12 | return Promise.resolve('next'); 13 | } catch (err) { 14 | throw new ApiError(403, ErrorCode.WRONG_API_KEY, err); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/api/middleware/NativeClientInfoMiddleware.ts: -------------------------------------------------------------------------------- 1 | import RequestContext from "../model/RequestContext"; 2 | import ErrorCode from "../enum/ErrorCode"; 3 | import ApiError from "../error/ApiError"; 4 | 5 | export default async function (req, res) { 6 | const versionRegex = /^(\d+\.)?(\d+\.)?(\*|\d+)$/; 7 | 8 | const osType = req.headers['x-os-type']; 9 | const osVersion = req.headers['x-os-version']; 10 | const appType = req.headers['x-app-type']; 11 | const appVersion = req.headers['x-app-version']; 12 | 13 | const context: RequestContext = req['context']; 14 | 15 | if (osType != undefined) { 16 | if (osType === 'ios' || osType === 'android') { 17 | context.osType = osType; 18 | } else { 19 | throw new ApiError(400, ErrorCode.INVALID_OS_TYPE, `Invalid os type: ${osType}`); 20 | } 21 | } 22 | 23 | if (osVersion != undefined) { 24 | if (osVersion.match(versionRegex)) { 25 | context.osVersion = osVersion; 26 | } else { 27 | throw new ApiError(400, ErrorCode.INVALID_OS_VERSION, `Invalid os version: ${osVersion}`); 28 | } 29 | } 30 | 31 | if (appType != undefined) { 32 | if (appType === 'release' || appType === 'debug') { 33 | context.appType = appType; 34 | } else { 35 | throw new ApiError(400, ErrorCode.INVALID_APP_TYPE, `Invalid app type: ${appType}`); 36 | } 37 | } else { 38 | context.appType = 'release'; 39 | } 40 | 41 | if (appVersion != undefined) { 42 | let versionWithoutPostfix = (osType === 'android') ? appVersion.split('-')[0] : appVersion 43 | if (!versionWithoutPostfix.match(versionRegex)) { 44 | throw new ApiError(400, ErrorCode.INVALID_APP_VERSION, `Invalid app version: ${appVersion}`); 45 | } 46 | context.appVersion = appVersion; 47 | } 48 | 49 | return Promise.resolve('next'); 50 | } 51 | -------------------------------------------------------------------------------- /src/api/middleware/PublicApiCacheControlMiddleware.ts: -------------------------------------------------------------------------------- 1 | export default function(req, res) { 2 | res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate'); 3 | return Promise.resolve('next'); 4 | } 5 | -------------------------------------------------------------------------------- /src/api/middleware/Server2ServerMiddleware.ts: -------------------------------------------------------------------------------- 1 | import RequestContext from "@app/api/model/RequestContext"; 2 | import * as UserService from "@app/core/user/UserService"; 3 | 4 | export async function replaceAllUserId2UserInfo(body) { 5 | async function recursivelyChangeUserId2UserInfo(o) { 6 | if (o === null) { 7 | return Promise.resolve('next') 8 | } 9 | return Promise.all( 10 | Object.keys(o).map(function (key) { 11 | if (typeof o[key] === 'object') { 12 | return recursivelyChangeUserId2UserInfo(o[key]); 13 | } else { 14 | if (key === 'user_id') { 15 | return new Promise( 16 | async (resolve, reject) => { 17 | o['user'] = UserService.getSnuttevUserInfo(await UserService.getByMongooseId(o[key]), o[key]) 18 | delete o[key] 19 | resolve('next') 20 | } 21 | ); 22 | } 23 | return Promise.resolve('next') 24 | } 25 | }) 26 | ) 27 | } 28 | await recursivelyChangeUserId2UserInfo(body); 29 | 30 | return Promise.resolve('next'); 31 | } 32 | -------------------------------------------------------------------------------- /src/api/middleware/StaticPageCacheControlMiddleware.ts: -------------------------------------------------------------------------------- 1 | export default function(req, res) { 2 | res.setHeader('Cache-Control', 'public, max-age=86400'); 3 | return Promise.resolve('next'); 4 | } 5 | -------------------------------------------------------------------------------- /src/api/middleware/UserAuthorizeMiddleware.ts: -------------------------------------------------------------------------------- 1 | import RequestContext from "../model/RequestContext"; 2 | import ErrorCode from "../enum/ErrorCode"; 3 | import UserService = require('@app/core/user/UserService'); 4 | import ApiError from '../error/ApiError'; 5 | 6 | export default async function(req, res) { 7 | let context: RequestContext = req['context']; 8 | var token = req.query.token || req.body.token || req.headers['x-access-token']; 9 | if (!token) { 10 | throw new ApiError(401, ErrorCode.NO_USER_TOKEN, "No token provided"); 11 | } 12 | 13 | let user = await UserService.getByCredentialHash(token); 14 | if (!user) { 15 | throw new ApiError(403, ErrorCode.WRONG_USER_TOKEN, "Failed to authenticate token"); 16 | } 17 | 18 | res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate'); 19 | UserService.updateLastLoginTimestamp(user); 20 | context.user = user; 21 | return Promise.resolve('next'); 22 | } 23 | -------------------------------------------------------------------------------- /src/api/model/RequestContext.ts: -------------------------------------------------------------------------------- 1 | import User from '@app/core/user/model/User'; 2 | 3 | export default interface RequestContext { 4 | method?: string, 5 | url?: string, 6 | user?: User, 7 | platform?: string, 8 | osType?: string, 9 | osVersion?: string, 10 | appType?: string, 11 | appVersion?: string, 12 | } 13 | -------------------------------------------------------------------------------- /src/api/routes/AdminRouter.ts: -------------------------------------------------------------------------------- 1 | import ExpressPromiseRouter from 'express-promise-router'; 2 | import User from '@app/core/user/model/User'; 3 | import UserService = require('@app/core/user/UserService'); 4 | import UserCredentialService = require('@app/core/user/UserCredentialService'); 5 | import NotificationService = require('@app/core/notification/NotificationService'); 6 | import CourseBookService = require('@app/core/coursebook/CourseBookService'); 7 | import FcmLogService = require('@app/core/fcm/FcmLogService'); 8 | import AdminService = require('@app/core/admin/AdminService'); 9 | import NotificationTypeEnum from '@app/core/notification/model/NotificationTypeEnum'; 10 | import winston = require('winston'); 11 | import NoFcmKeyError from '@app/core/notification/error/NoFcmKeyError'; 12 | import InvalidNotificationDetailError from '@app/core/notification/error/InvalidNotificationDetailError'; 13 | import RequestContext from '../model/RequestContext'; 14 | import { restPost, restGet } from '../decorator/RestDecorator'; 15 | import ApiError from '../error/ApiError'; 16 | import ApiServerFaultError from '../error/ApiServerFaultError'; 17 | import ErrorCode from '../enum/ErrorCode'; 18 | import UserAuthorizeMiddleware from '../middleware/UserAuthorizeMiddleware'; 19 | import InvalidLocalPasswordError from '@app/core/user/error/InvalidLocalPasswordError'; 20 | var logger = winston.loggers.get('default'); 21 | 22 | var router = ExpressPromiseRouter(); 23 | 24 | router.use(UserAuthorizeMiddleware); 25 | 26 | router.use(function(req, res, next) { 27 | let context: RequestContext = req['context']; 28 | if (context.user.isAdmin) return next(); 29 | else { 30 | return res.status(403).json({ errcode: ErrorCode.NO_ADMIN_PRIVILEGE, message: 'Admin privilege required.' }); 31 | } 32 | }); 33 | 34 | restPost(router, '/insert_noti')(async function (context, req) { 35 | let sender: User = context.user; 36 | 37 | let userId: string = req.body.user_id; 38 | let title: string = req.body.title; 39 | let body: string = req.body.body; 40 | let insertFcm: boolean = req.body.insert_fcm ? true : false; 41 | let type = req.body.type ? Number(req.body.type) : NotificationTypeEnum.NORMAL; 42 | let detail = req.body.detail ? req.body.detail : null; 43 | 44 | try { 45 | if (userId && userId.length > 0) { 46 | let receiver = await UserService.getByLocalId(userId); 47 | if (!receiver) { 48 | throw new ApiError(404, ErrorCode.USER_NOT_FOUND, "user not found"); 49 | } 50 | if (insertFcm) { 51 | await NotificationService.sendFcmMsg(receiver, title, body, sender._id, "admin"); 52 | } 53 | await NotificationService.add({ 54 | user_id: receiver._id, 55 | message: body, 56 | type: type, 57 | detail: detail, 58 | created_at: new Date() 59 | }); 60 | } else { 61 | if (insertFcm) { 62 | await NotificationService.sendGlobalFcmMsg(title, body, sender._id, "admin"); 63 | } 64 | await NotificationService.add({ 65 | user_id: null, 66 | message: body, 67 | type: type, 68 | detail: detail, 69 | created_at: new Date() 70 | }); 71 | } 72 | return { 73 | message: "ok" 74 | }; 75 | } catch (err) { 76 | if (err instanceof NoFcmKeyError) 77 | throw new ApiError(404, ErrorCode.USER_HAS_NO_FCM_KEY, "user has no fcm key"); 78 | if (err instanceof InvalidNotificationDetailError) 79 | throw new ApiError(404, ErrorCode.INVALID_NOTIFICATION_DETAIL, "invalid notification detail"); 80 | if (err instanceof ApiError) { 81 | throw err; 82 | } 83 | logger.error(err); 84 | throw new ApiServerFaultError(); 85 | } 86 | }) 87 | 88 | restPost(router, '/change_pw')(async function (context, req) { 89 | let userId: string = req.body.userId; 90 | let toPassword: string = req.body.toPassword; 91 | 92 | try { 93 | let user: User = await UserService.getByMongooseId(userId); 94 | if (user === null) { 95 | throw new ApiError(404, ErrorCode.USER_NOT_FOUND, "user not found"); 96 | } 97 | if (!UserCredentialService.hasLocal(user)) { 98 | throw new ApiError(403, ErrorCode.NOT_LOCAL_ACCOUNT, "not local account"); 99 | } 100 | UserCredentialService.changeLocalPassword(user, toPassword); 101 | return { 102 | message: "ok" 103 | } 104 | } catch (err) { 105 | if (err instanceof InvalidLocalPasswordError) { 106 | throw new ApiError(403, ErrorCode.INVALID_PASSWORD, "invalid password"); 107 | } else if (err instanceof ApiError) { 108 | throw err; 109 | } 110 | logger.error(err); 111 | throw new ApiServerFaultError(); 112 | } 113 | }) 114 | 115 | restGet(router, '/recent_fcm_log')(FcmLogService.getRecentFcmLog) 116 | 117 | restGet(router, '/coursebooks')(CourseBookService.getAll) 118 | 119 | restGet(router, '/statistics')(AdminService.getStatistics); 120 | 121 | export = router; -------------------------------------------------------------------------------- /src/api/routes/ApiRouter.ts: -------------------------------------------------------------------------------- 1 | import ExpressPromiseRouter from 'express-promise-router'; 2 | 3 | var router = ExpressPromiseRouter(); 4 | 5 | import CourseBookService = require('@app/core/coursebook/CourseBookService'); 6 | 7 | import AuthRouter = require('./AuthRouter'); 8 | import TimetableRouter = require('./TimetableRouter'); 9 | import SearchQueryRouter = require('./SearchQueryRouter'); 10 | import TagListRouter = require('./TagListRouter'); 11 | import NotificationRouter = require('./NotificationRouter'); 12 | import EvaluationRouter = require('./EvaluationRouter') 13 | import UserRouter = require('./UserRouter'); 14 | import AdminRouter = require('./AdminRouter'); 15 | import PopupRouter = require('./PopupRouter'); 16 | import FeedbackService = require('@app/core/feedback/FeedbackService'); 17 | import LectureColorService = require('@app/core/timetable/LectureColorService'); 18 | import SugangSnuSyllabusService = require('@app/core/coursebook/sugangsnu/SugangSnuSyllabusService') 19 | 20 | import { restGet, restPost } from '../decorator/RestDecorator'; 21 | import ErrorCode from '../enum/ErrorCode'; 22 | import ApiError from '../error/ApiError'; 23 | import ApiKeyAuthorizeMiddleware from '../middleware/ApiKeyAuthorizeMiddleware'; 24 | import PublicApiCacheControlMiddleware from '../middleware/PublicApiCacheControlMiddleware'; 25 | import NativeClientInfoMiddleware from '../middleware/NativeClientInfoMiddleware'; 26 | 27 | router.use(ApiKeyAuthorizeMiddleware); 28 | router.use(PublicApiCacheControlMiddleware); 29 | router.use(NativeClientInfoMiddleware); 30 | 31 | restGet(router, '/course_books')(CourseBookService.getAll); 32 | 33 | restGet(router, '/course_books/recent')(CourseBookService.getRecent); 34 | 35 | restGet(router, '/course_books/official')(async function(context, req) { 36 | var year = req.query.year; 37 | var semester = Number(req.query.semester); 38 | var lecture_number = req.query.lecture_number; 39 | var course_number = req.query.course_number; 40 | return { 41 | url: SugangSnuSyllabusService.getSyllabusUrl(year, semester, lecture_number, course_number) 42 | }; 43 | }); 44 | 45 | router.use('/search_query', SearchQueryRouter); 46 | 47 | router.use('/tags', TagListRouter); 48 | 49 | restGet(router, '/colors')(async function(context, req) { 50 | return {message: "ok", colors: LectureColorService.getLegacyColors(), names: LectureColorService.getLegacyNames()}; 51 | }); 52 | 53 | restGet(router, '/colors/:colorName')(async function(context, req) { 54 | let colorWithName = LectureColorService.getColorList(req.params.colorName); 55 | if (colorWithName) return {message: "ok", colors: colorWithName.colors, names: colorWithName.names}; 56 | else throw new ApiError(404, ErrorCode.COLORLIST_NOT_FOUND, "color list not found"); 57 | }); 58 | 59 | // deprecated 60 | restGet(router, '/app_version')(async function() { 61 | throw new ApiError(404, ErrorCode.UNKNOWN_APP, "unknown app"); 62 | }); 63 | 64 | restPost(router, '/feedback')(async function(context, req) { 65 | await FeedbackService.add(req.body.email, req.body.message, context.platform, context.appVersion); 66 | return {message:"ok"}; 67 | }); 68 | 69 | router.use('/ev-service', EvaluationRouter) 70 | 71 | router.use('/auth', AuthRouter); 72 | 73 | router.use('/tables', TimetableRouter); 74 | 75 | router.use('/user', UserRouter); 76 | 77 | router.use('/notification', NotificationRouter); 78 | 79 | router.use('/popups', PopupRouter); 80 | 81 | router.use('/admin', AdminRouter); 82 | 83 | export = router; 84 | -------------------------------------------------------------------------------- /src/api/routes/EvaluationRouter.ts: -------------------------------------------------------------------------------- 1 | import ExpressPromiseRouter from "express-promise-router"; 2 | import {replaceAllUserId2UserInfo} from "@app/api/middleware/Server2ServerMiddleware"; 3 | import * as request from "request-promise-native"; 4 | import * as property from '@app/core/config/property'; 5 | import UserAuthorizeMiddleware from "@app/api/middleware/UserAuthorizeMiddleware"; 6 | import {restGet} from "@app/api/decorator/RestDecorator"; 7 | import User from "@app/core/user/model/User"; 8 | import ApiError from "@app/api/error/ApiError"; 9 | import * as TimetableService from "@app/core/timetable/TimetableService"; 10 | import ErrorCode from "@app/api/enum/ErrorCode"; 11 | import {isUserEmailVerified} from "@app/core/user/UserService"; 12 | 13 | let router = ExpressPromiseRouter(); 14 | 15 | const snuttevDefaultRoutingUrl = property.get('api.snuttev.url') 16 | 17 | router.use(UserAuthorizeMiddleware) 18 | 19 | restGet(router, '/v1/users/me/lectures/latest')(async function (context, req) { 20 | const user: User = context.user; 21 | const evaluationServerHeader = { 22 | 'Snutt-User-Id': user._id 23 | } 24 | const snuttLectureInfo: string = JSON.stringify(await TimetableService.getLecturesTakenByUserInLastSemesters(user._id)) 25 | 26 | req.query.snutt_lecture_info = encodeURIComponent(snuttLectureInfo) 27 | let requestUri = snuttevDefaultRoutingUrl + req.path 28 | requestUri += '?' + Object.keys(req.query).map(key => key + '=' + req.query[key]).join('&') 29 | 30 | return request({ 31 | method: req.method, 32 | uri: requestUri, 33 | headers: evaluationServerHeader, 34 | body: req.body, 35 | json: true 36 | }).then(async function (body) { 37 | return body; 38 | }).catch(function (err) { 39 | throw new ApiError(err.response.statusCode, err.response.body.code, err.response.body.message); 40 | }); 41 | }) 42 | 43 | router.all('/*', async function (req, res, next) { 44 | const user = req['context'].user 45 | if (!isUserEmailVerified(user)) return res.status(403).json({ 46 | errcode: ErrorCode.USER_EMAIL_IS_NOT_VERIFIED, 47 | message: "The user's email is not verified" 48 | }); 49 | const evaluationServerHeader = { 50 | 'Snutt-User-Id': user._id 51 | } 52 | try { 53 | return request({ 54 | method: req.method, 55 | uri: snuttevDefaultRoutingUrl + req.url, 56 | headers: evaluationServerHeader, 57 | body: req.body, 58 | json: true 59 | }).then(async function (body) { 60 | if (body !== undefined) { 61 | await replaceAllUserId2UserInfo(body) 62 | } 63 | return res.json(body); 64 | }).catch(function (err) { 65 | return res.status(err.response.statusCode).json(err.response.body); 66 | }); 67 | } catch (err) { 68 | next(err) 69 | } 70 | }) 71 | 72 | export = router; 73 | -------------------------------------------------------------------------------- /src/api/routes/MonitorRouter.ts: -------------------------------------------------------------------------------- 1 | import ExpressPromiseRouter from 'express-promise-router'; 2 | var router = ExpressPromiseRouter(); 3 | import { restGet } from '../decorator/RestDecorator'; 4 | 5 | restGet(router, '/l7check')(() => Promise.resolve({ message: "OK" })); 6 | 7 | export = router; 8 | -------------------------------------------------------------------------------- /src/api/routes/NotificationRouter.ts: -------------------------------------------------------------------------------- 1 | import ExpressPromiseRouter from 'express-promise-router'; 2 | var router = ExpressPromiseRouter(); 3 | import NotificationService = require('@app/core/notification/NotificationService'); 4 | import User from '@app/core/user/model/User'; 5 | import UserService = require('@app/core/user/UserService'); 6 | 7 | import { restGet } from '../decorator/RestDecorator'; 8 | import UserAuthorizeMiddleware from '../middleware/UserAuthorizeMiddleware'; 9 | 10 | router.use(UserAuthorizeMiddleware); 11 | 12 | restGet(router, '/')(async function(context, req){ 13 | var user:User = context.user; 14 | var offset, limit; 15 | if (!req.query.offset) offset = 0; 16 | else offset = Number(req.query.offset); 17 | if (!req.query.limit) limit = 20; 18 | else limit = Number(req.query.limit); 19 | 20 | let notification = await NotificationService.getNewestByUser(user, offset, limit); 21 | if (req.query.explicit) await UserService.updateNotificationCheckDate(user); 22 | return notification; 23 | }); 24 | 25 | restGet(router, '/count')(async function(context, req){ 26 | var user:User = context.user; 27 | let count = await NotificationService.countUnreadByUser(user); 28 | return { count: count }; 29 | }); 30 | 31 | export = router; 32 | -------------------------------------------------------------------------------- /src/api/routes/PopupRouter.ts: -------------------------------------------------------------------------------- 1 | import ExpressPromiseRouter from 'express-promise-router'; 2 | var router = ExpressPromiseRouter(); 3 | import User from '@app/core/user/model/User'; 4 | import PopupService = require('@app/core/popup/PopupService'); 5 | 6 | import { restGet } from '../decorator/RestDecorator'; 7 | import UserAuthorizeMiddleware from '../middleware/UserAuthorizeMiddleware'; 8 | 9 | router.use(UserAuthorizeMiddleware); 10 | 11 | restGet(router, '/')(async function(context, req){ 12 | var user:User = context.user; 13 | 14 | let popups = await PopupService.getPopups(user, context.osType, context.osVersion, context.appType, context.appVersion); 15 | return popups; 16 | }); 17 | 18 | export = router; 19 | -------------------------------------------------------------------------------- /src/api/routes/RootRouter.ts: -------------------------------------------------------------------------------- 1 | import ExpressPromiseRouter from 'express-promise-router'; 2 | import MonitorRouter = require('./MonitorRouter'); 3 | import StaticPageRouter = require('./StaticPageRouter'); 4 | import ApiRouter = require('./ApiRouter'); 5 | import mongoose = require('mongoose'); 6 | 7 | let router = ExpressPromiseRouter(); 8 | 9 | router.get('/health-check', (req, res) => { 10 | if (mongoose.connection.readyState !== 1) { 11 | res.status(500).json({ message: 'MongoDB connection failed' }); 12 | return; 13 | } 14 | 15 | mongoose.connection.db.admin().ping((err, result) => { 16 | if (err) { 17 | res.status(500).json({ message: 'MongoDB connection failed' }); 18 | return; 19 | } 20 | res.status(200).json({ message: 'ok' }); 21 | }); 22 | }); 23 | 24 | router.use('/monitor', MonitorRouter); 25 | 26 | router.use(function(req, res) { 27 | req['context'] = {}; 28 | return Promise.resolve('next'); 29 | }) 30 | router.use('/', StaticPageRouter); 31 | router.use('/v1', StaticPageRouter); 32 | router.use('/', ApiRouter); 33 | router.use('/v1', ApiRouter); 34 | 35 | export = router; 36 | -------------------------------------------------------------------------------- /src/api/routes/SearchQueryRouter.ts: -------------------------------------------------------------------------------- 1 | import ExpressPromiseRouter from 'express-promise-router'; 2 | var router = ExpressPromiseRouter(); 3 | import RefLectureQueryService = require('@app/core/lecture/RefLectureQueryService'); 4 | import InvalidLectureTimemaskError from '@app/core/lecture/error/InvalidLectureTimemaskError'; 5 | import { restPost } from '../decorator/RestDecorator'; 6 | import ApiError from '../error/ApiError'; 7 | import ErrorCode from '../enum/ErrorCode'; 8 | 9 | restPost(router, '/')(async function(context, req) { 10 | if (!req.body.year || !req.body.semester) { 11 | throw new ApiError(400, ErrorCode.NO_YEAR_OR_SEMESTER, "no year or semester"); 12 | } 13 | 14 | var query: any = req.body; 15 | try { 16 | RefLectureQueryService.addQueryLogAsync(query); 17 | return await RefLectureQueryService.getLectureListByLectureQuery(query); 18 | } catch (err) { 19 | if (err instanceof InvalidLectureTimemaskError) { 20 | throw new ApiError(400, ErrorCode.INVALID_TIMEMASK, "invalid timemask"); 21 | } 22 | throw err; 23 | } 24 | }); 25 | 26 | export = router; 27 | -------------------------------------------------------------------------------- /src/api/routes/StaticPageRouter.ts: -------------------------------------------------------------------------------- 1 | import ExpressPromiseRouter from 'express-promise-router'; 2 | import StaticPageCacheControlMiddleware from '../middleware/StaticPageCacheControlMiddleware'; 3 | var router = ExpressPromiseRouter(); 4 | 5 | router.use(StaticPageCacheControlMiddleware); 6 | 7 | router.get('/terms_of_service', function(req, res, next) { 8 | res.render('terms_of_service.html'); 9 | }); 10 | 11 | router.get('/privacy_policy', function(req, res, next) { 12 | res.render('privacy_policy.html'); 13 | }); 14 | 15 | router.get('/member', function(req, res, next) { 16 | res.render('member.html'); 17 | }); 18 | 19 | export = router; 20 | -------------------------------------------------------------------------------- /src/api/routes/TagListRouter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by north on 16. 2. 24. 3 | */ 4 | import ExpressPromiseRouter from 'express-promise-router'; 5 | var router = ExpressPromiseRouter(); 6 | import TagListService = require('@app/core/taglist/TagListService'); 7 | import TagListNotFoundError from '@app/core/taglist/error/TagListNotFoundError'; 8 | import { restGet } from '../decorator/RestDecorator'; 9 | import ApiError from '../error/ApiError'; 10 | import ErrorCode from '../enum/ErrorCode'; 11 | 12 | restGet(router, '/:year/:semester/update_time')(async function(context, req) { 13 | try { 14 | let updateTime = await TagListService.getUpdateTimeBySemester(req.params.year, req.params.semester); 15 | return {updated_at: updateTime}; 16 | } catch (err) { 17 | if (err instanceof TagListNotFoundError) { 18 | throw new ApiError(404, ErrorCode.TAG_NOT_FOUND, "not found"); 19 | } else { 20 | throw err; 21 | } 22 | } 23 | }); 24 | 25 | restGet(router, '/:year/:semester/')(async function(context, req) { 26 | let doc = await TagListService.getBySemester(req.params.year, req.params.semester); 27 | if (!doc) { 28 | throw new ApiError(404, ErrorCode.TAG_NOT_FOUND, "not found"); 29 | } 30 | return { 31 | classification : doc.tags.classification, 32 | department : doc.tags.department, 33 | academic_year : doc.tags.academic_year, 34 | credit : doc.tags.credit, 35 | instructor : doc.tags.instructor, 36 | category : doc.tags.category, 37 | updated_at : doc.updated_at 38 | }; 39 | }); 40 | 41 | export = router; -------------------------------------------------------------------------------- /src/batch/common/AbstractJob.ts: -------------------------------------------------------------------------------- 1 | import winston = require('winston'); 2 | let logger = winston.loggers.get('default'); 3 | 4 | export default abstract class AbstractJob { 5 | constructor( 6 | public jobName: string 7 | ) { } 8 | 9 | public async run(executionContext?): Promise { 10 | logger.info("Starting Job [" + this.jobName + "]"); 11 | let startTime = process.hrtime(); 12 | try { 13 | await this.doRun(executionContext); 14 | let timeMargin = process.hrtime(startTime); 15 | logger.info("Job [" + this.jobName + "] completed, took " + timeMargin[0] + "s"); 16 | } catch (err) { 17 | logger.error(err); 18 | logger.info("Job [" + this.jobName + "] failed"); 19 | } 20 | } 21 | 22 | protected abstract async doRun(executionContext?): Promise; 23 | } 24 | -------------------------------------------------------------------------------- /src/batch/common/ArrayReader.ts: -------------------------------------------------------------------------------- 1 | import BatchReader from "./BatchReader"; 2 | 3 | export default abstract class ArrayReader implements BatchReader { 4 | index: number = 0; 5 | items: R[] = []; 6 | 7 | abstract async getItems(executionContext?): Promise; 8 | 9 | async open(executionContext?): Promise { 10 | this.items = await this.getItems(executionContext); 11 | } 12 | async close() { } 13 | async read() { 14 | if (this.index < this.items.length) { 15 | return this.items[this.index++]; 16 | } else { 17 | return null; 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/batch/common/BatchJob.ts: -------------------------------------------------------------------------------- 1 | import BatchReader from "./BatchReader"; 2 | import BatchProcessor from "./BatchProcessor"; 3 | import BatchWriter from "./BatchWriter"; 4 | import AbstractJob from "./AbstractJob"; 5 | 6 | export default class BatchJob extends AbstractJob { 7 | constructor( 8 | public jobName: string, 9 | private reader: BatchReader, 10 | private processors: BatchProcessor[], 11 | private writer: BatchWriter 12 | ) { 13 | super(jobName); 14 | } 15 | 16 | protected async doRun(executionContext?): Promise { 17 | await this.reader.open(executionContext); 18 | while(true) { 19 | let item = await this.reader.read(executionContext); 20 | if (item === null) break; 21 | await this.processItem(item, executionContext); 22 | } 23 | await this.reader.close(executionContext); 24 | } 25 | 26 | private async processItem(item, executionContext?): Promise { 27 | for (let processor of this.processors) { 28 | item = await processor.process(item, executionContext); 29 | } 30 | await this.writer.write(item, executionContext); 31 | } 32 | } -------------------------------------------------------------------------------- /src/batch/common/BatchJobBuilder.ts: -------------------------------------------------------------------------------- 1 | import BatchReader from "./BatchReader"; 2 | import BatchProcessor from "./BatchProcessor"; 3 | import BatchWriter from "./BatchWriter"; 4 | import BatchJob from "./BatchJob"; 5 | 6 | class IntermediateJob { 7 | _jobName: string; 8 | _reader: BatchReader; 9 | _processors: BatchProcessor[] = []; 10 | 11 | constructor(jobName: string, reader: BatchReader) { 12 | this._jobName = jobName; 13 | this._reader = reader; 14 | } 15 | 16 | processor(processor: BatchProcessor): IntermediateJob { 17 | this._processors.push(processor); 18 | return this; 19 | } 20 | 21 | writer(writer: BatchWriter): BatchJob { 22 | return new BatchJob(this._jobName, this._reader, this._processors, writer); 23 | } 24 | } 25 | 26 | export default class BatchJobBuilder { 27 | constructor(public jobName: string) { } 28 | 29 | reader(reader: BatchReader) { 30 | return new IntermediateJob(this.jobName, reader); 31 | } 32 | } -------------------------------------------------------------------------------- /src/batch/common/BatchProcessor.ts: -------------------------------------------------------------------------------- 1 | export default interface BatchProcessor { 2 | process(item: A, executionContext?): Promise; 3 | } -------------------------------------------------------------------------------- /src/batch/common/BatchReader.ts: -------------------------------------------------------------------------------- 1 | export default interface BatchReader { 2 | open(executionContext?): Promise; 3 | close(executionContext?): Promise; 4 | read(executionContext?): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/batch/common/BatchWriter.ts: -------------------------------------------------------------------------------- 1 | export default interface BatchWriter { 2 | write(item: T, executionContext?): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /src/batch/common/LambdaJobBuilder.ts: -------------------------------------------------------------------------------- 1 | import BatchReader from "./BatchReader"; 2 | import BatchProcessor from "./BatchProcessor"; 3 | import BatchWriter from "./BatchWriter"; 4 | import BatchJob from "./BatchJob"; 5 | import ArrayReader from "./ArrayReader"; 6 | 7 | class IntermediateJob { 8 | _jobName: string; 9 | _reader: BatchReader; 10 | _processors: BatchProcessor[] = []; 11 | 12 | constructor(jobName:string, reader: (any?) => Promise) { 13 | this._jobName = jobName; 14 | this._reader = new (class SimpleReader extends ArrayReader { 15 | getItems(executionContext?) { 16 | return reader(executionContext); 17 | } 18 | }); 19 | } 20 | 21 | processor(processor: (T, any?) => Promise): IntermediateJob { 22 | this._processors.push(new (class SimpleProcessor implements BatchProcessor { 23 | process(item, executionContext?) { 24 | return processor(item, executionContext); 25 | } 26 | })); 27 | return this; 28 | } 29 | 30 | writer(writer: (T, any?) => Promise): BatchJob { 31 | return new BatchJob(this._jobName, this._reader, this._processors, 32 | new (class SimpleWriter implements BatchWriter { 33 | write(item, executionContext?) { 34 | return writer(item, executionContext); 35 | } 36 | })); 37 | } 38 | } 39 | 40 | export default class LambdaJobBuilder { 41 | constructor(public jobName: string) {} 42 | reader(reader: (any?) => Promise) { 43 | return new IntermediateJob(this.jobName, reader); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/batch/common/SimpleJob.ts: -------------------------------------------------------------------------------- 1 | import AbstractJob from "./AbstractJob"; 2 | 3 | export default class SimpleJob extends AbstractJob { 4 | constructor( 5 | public jobName: string, 6 | private runner: (any?) => Promise 7 | ) { 8 | super(jobName); 9 | } 10 | 11 | protected doRun(executionContext?) { 12 | return this.runner(executionContext); 13 | } 14 | } -------------------------------------------------------------------------------- /src/batch/config/log.ts: -------------------------------------------------------------------------------- 1 | import winston = require('winston'); 2 | import DailyRotateFile = require('winston-daily-rotate-file') 3 | import property = require('@app/core/config/property'); 4 | import * as Transport from 'winston-transport'; 5 | 6 | let logPath = property.get("batch.winston.path"); 7 | let logDatePattern = property.get("batch.winston.datePattern"); 8 | let logLevel = property.get("batch.winston.logLevel"); 9 | let daysToKeep = property.get("batch.winston.daysToKeep"); 10 | 11 | var consoleTransport = new winston.transports.Console(); 12 | const transports:Transport[] = [consoleTransport]; 13 | 14 | if (process.env.EXECUTE_ENV !== 'lambda') { 15 | var transport = new (DailyRotateFile)({ 16 | filename: logPath, 17 | datePattern: logDatePattern, 18 | zippedArchive: true, 19 | maxFiles: daysToKeep 20 | }); 21 | transports.push(transport); 22 | } 23 | 24 | if (process.env.NODE_ENV !== 'mocha') { 25 | winston.loggers.add('default', { 26 | level: logLevel, 27 | transports: transports, 28 | format: winston.format.combine( 29 | winston.format.timestamp({ 30 | format: 'YYYY-MM-DD HH:mm:ss.SSS' 31 | }), 32 | winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${(typeof info.message === 'string') ? info.message : JSON.stringify(info.message)}`) 33 | ) 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/batch/coursebook/CoursebookUpdateNotificationService.ts: -------------------------------------------------------------------------------- 1 | import NotificationService = require('@app/core/notification/NotificationService'); 2 | import NotificationTypeEnum from '@app/core/notification/model/NotificationTypeEnum'; 3 | import UserService = require('@app/core/user/UserService'); 4 | import Timetable from '@app/core/timetable/model/Timetable'; 5 | import LectureDifference from './model/LectureDifference'; 6 | import winston = require('winston'); 7 | import RefLecture from '@app/core/lecture/model/RefLecture'; 8 | let logger = winston.loggers.get('default'); 9 | 10 | export async function addCoursebookUpdateNotification(year: number, semester: number, isFcmEnabled: boolean) { 11 | var semesterString = (['1', '여름', '2', '겨울'])[semester - 1]; 12 | var notificationMessage = year + "년도 " + semesterString + "학기 수강편람이 추가되었습니다."; 13 | if (isFcmEnabled) { 14 | await NotificationService.sendGlobalFcmMsg("신규 수강편람", notificationMessage, "batch/coursebook", "new coursebook"); 15 | } 16 | await NotificationService.add({ 17 | user_id: null, 18 | message: notificationMessage, 19 | type: NotificationTypeEnum.COURSEBOOK, 20 | detail: null, 21 | created_at: new Date() 22 | }); 23 | logger.info("Notification inserted"); 24 | } 25 | 26 | export async function sendCoursebookUpdateFcmNotification(userId: string, numUpdated: number, numRemoved: number) { 27 | let msg; 28 | if (numUpdated & numRemoved) { 29 | msg = "수강편람이 업데이트되어 "+numUpdated+"개 강의가 변경되고 "+numRemoved+"개 강의가 삭제되었습니다."; 30 | } else if (numUpdated) { 31 | msg = "수강편람이 업데이트되어 "+numUpdated+"개 강의가 변경되었습니다."; 32 | } else if (numRemoved) { 33 | msg = "수강편람이 업데이트되어 "+numRemoved+"개 강의가 삭제되었습니다."; 34 | } else { 35 | logger.error("Both updated_num and removed_num is undefined"); 36 | return; 37 | } 38 | 39 | let user = await UserService.getByMongooseId(userId); 40 | 41 | if (!user) { 42 | logger.warn("user not found"); 43 | return; 44 | } 45 | 46 | if (!user.fcmKey) { 47 | logger.warn("user has no fcmKey"); 48 | return; 49 | } 50 | try { 51 | await NotificationService.sendFcmMsg(user, "수강편람 업데이트", msg, "batch/coursebook", "lecture updated"); 52 | } catch (err) { 53 | logger.error("Failed to send update fcm: {}", err); 54 | } 55 | } 56 | 57 | export async function addLectureRemovedNotification(timetable: Timetable, removed: RefLecture) { 58 | let noti_detail = { 59 | timetable_id : timetable._id, 60 | lecture : { 61 | course_number: removed.course_number, 62 | lecture_number: removed.lecture_number, 63 | course_title: removed.course_title 64 | } 65 | }; 66 | await NotificationService.add({ 67 | user_id: timetable.user_id, 68 | message: makeSemesterString(timetable.year, timetable.semester) + " '"+timetable.title+"' 시간표의 '"+removed.course_title+"' 강의가 폐강되어 삭제되었습니다.", 69 | type: NotificationTypeEnum.LECTURE_REMOVE, 70 | detail: noti_detail, 71 | created_at: new Date() 72 | }); 73 | } 74 | 75 | export async function addLectureUpdateNotification(timetable: Timetable, lectureDifference: LectureDifference) { 76 | let detail = { 77 | timetable_id : timetable._id, 78 | lecture : { 79 | course_number: lectureDifference.oldLecture.course_number, 80 | lecture_number: lectureDifference.oldLecture.lecture_number, 81 | course_title: lectureDifference.oldLecture.course_title, 82 | after: lectureDifference.difference 83 | } 84 | }; 85 | await NotificationService.add({ 86 | user_id: timetable.user_id, 87 | message: getUpdatedLectureNotificationMessage(timetable, lectureDifference), 88 | type: NotificationTypeEnum.LECTURE_UPDATE, 89 | detail: detail, 90 | created_at: new Date() 91 | }); 92 | } 93 | 94 | export async function addTimeOverlappedLectureRemovedNotification(timetable: Timetable, lectureDifference: LectureDifference) { 95 | let detail = { 96 | timetable_id : timetable._id, 97 | lecture : { 98 | course_number: lectureDifference.oldLecture.course_number, 99 | lecture_number: lectureDifference.oldLecture.lecture_number, 100 | course_title: lectureDifference.oldLecture.course_title, 101 | after: lectureDifference.difference 102 | } 103 | }; 104 | await NotificationService.add({ 105 | user_id: timetable.user_id, 106 | message: makeSemesterString(timetable.year, timetable.semester) + " '"+timetable.title+"' 시간표의 '"+lectureDifference.oldLecture.course_title+ 107 | "' 강의가 업데이트되었으나, 시간표가 겹쳐 삭제되었습니다.", 108 | type: NotificationTypeEnum.LECTURE_REMOVE, 109 | detail: detail, 110 | created_at: new Date() 111 | }); 112 | } 113 | 114 | function getUpdatedLectureNotificationMessage(timetable: Timetable, lectureDifference: LectureDifference): string { 115 | return makeSemesterString(timetable.year, timetable.semester) + " '"+timetable.title+"' 시간표의 '" 116 | + lectureDifference.oldLecture.course_title+"' 강의가 업데이트 되었습니다. " 117 | + "(항목: " + getLectureIdentUpdatedDescription(lectureDifference) + ")"; 118 | } 119 | 120 | function makeSemesterString(year: number, semesterIndex: number) { 121 | var semesterString = (['1', 'S', '2', 'W'])[semesterIndex - 1]; 122 | return year + "-" + semesterString; 123 | } 124 | 125 | function getLectureIdentUpdatedDescription(lectureDifference: LectureDifference): string { 126 | let updatedKeys = Object.keys(lectureDifference.difference); 127 | let updatedKeyDescriptions = updatedKeys.map(getUpdatedKeyDescription); 128 | return updatedKeyDescriptions.join(", "); 129 | } 130 | 131 | function getUpdatedKeyDescription(updatedKey: string): string { 132 | switch (updatedKey) { 133 | case 'classification': 134 | return "교과 구분"; 135 | case 'department': 136 | return "학부"; 137 | case 'academic_year': 138 | return "학년"; 139 | case 'course_title': 140 | return "강의명"; 141 | case 'credit': 142 | return "학점"; 143 | case 'instructor': 144 | return "교수"; 145 | case 'quota': 146 | return "정원"; 147 | case 'remark': 148 | return "비고"; 149 | case 'category': 150 | return "교양 구분"; 151 | case 'class_time_json': 152 | return "강의 시간/장소" 153 | default: 154 | logger.error("Unknown updated key description: " + updatedKey); 155 | return "기타"; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/batch/coursebook/LectureCompareService.ts: -------------------------------------------------------------------------------- 1 | import RefLecture from '@app/core/lecture/model/RefLecture'; 2 | import LectureDifference from './model/LectureDifference'; 3 | import RefLectureService = require('@app/core/lecture/RefLectureService'); 4 | import TimePlaceUtil = require('@app/core/timetable/util/TimePlaceUtil'); 5 | import winston = require('winston'); 6 | var logger = winston.loggers.get('default'); 7 | 8 | function compareLecture(oldLecture: RefLecture, newLecture: RefLecture): LectureDifference { 9 | var difference = {}; 10 | var keys = [ 11 | 'classification', 12 | 'department', 13 | 'academic_year', 14 | 'course_title', 15 | 'credit', 16 | 'instructor', 17 | 'quota', 18 | 'remark', 19 | 'category', 20 | 'class_time', 21 | 'real_class_time' 22 | ]; 23 | for (let key of keys) { 24 | if (oldLecture[key] != newLecture[key]) { 25 | difference[key] = newLecture[key]; 26 | } 27 | } 28 | 29 | if (!TimePlaceUtil.equalTimeJson(oldLecture.class_time_json, newLecture.class_time_json)) { 30 | difference["class_time_json"] = newLecture.class_time_json; 31 | } 32 | 33 | if (Object.keys(difference).length === 0) { 34 | return null; 35 | } else { 36 | return { 37 | oldLecture: oldLecture, 38 | newLecture: newLecture, 39 | difference: difference 40 | }; 41 | } 42 | }; 43 | 44 | export async function compareLectures(year:number, semester: number, newLectureList:RefLecture[]) { 45 | logger.info("Pulling existing lectures..."); 46 | var oldLectureList = await RefLectureService.getBySemester(year, semester); 47 | 48 | let createdList: RefLecture[] = []; 49 | let removedList: RefLecture[] = []; 50 | let updatedList: LectureDifference[] = []; 51 | 52 | let oldLectureMap = makeLectureMap(oldLectureList); 53 | let newLectureMap = makeLectureMap(newLectureList); 54 | 55 | for (let newLectureKey of newLectureMap.keys()) { 56 | let oldLecture = oldLectureMap.get(newLectureKey); 57 | let newLecture = newLectureMap.get(newLectureKey); 58 | if (oldLecture) { 59 | let difference = compareLecture(oldLecture, newLecture); 60 | if (difference) { 61 | updatedList.push(difference); 62 | } 63 | } else { 64 | createdList.push(newLecture); 65 | } 66 | } 67 | 68 | for (let oldLectureKey of oldLectureMap.keys()) { 69 | let oldLecture = oldLectureMap.get(oldLectureKey); 70 | let newLecture = newLectureMap.get(oldLectureKey); 71 | if (!newLecture) { 72 | removedList.push(oldLecture); 73 | } 74 | } 75 | 76 | return { 77 | createdList: createdList, 78 | removedList: removedList, 79 | updatedList: updatedList 80 | }; 81 | } 82 | 83 | function makeLectureMap(lectureList: RefLecture[]): Map { 84 | let lectureMap: Map = new Map(); 85 | for (let lecture of lectureList) { 86 | lectureMap.set(makeLectureMapKey(lecture), lecture); 87 | } 88 | return lectureMap; 89 | } 90 | 91 | function makeLectureMapKey(lecture: RefLecture): string { 92 | return lecture.course_number + '##' + lecture.lecture_number; 93 | } 94 | -------------------------------------------------------------------------------- /src/batch/coursebook/LectureProcessService.ts: -------------------------------------------------------------------------------- 1 | import TimetableService = require('@app/core/timetable/TimetableService'); 2 | import RefLectureService = require('@app/core/lecture/RefLectureService'); 3 | import TimetableLectureService = require('@app/core/timetable/TimetableLectureService'); 4 | import ObjectUtil = require('@app/core/common/util/ObjectUtil'); 5 | import RedisUtil = require('@app/core/redis/RedisUtil'); 6 | import RefLecture from '@app/core/lecture/model/RefLecture'; 7 | import LectureDifference from './model/LectureDifference'; 8 | import winston = require('winston'); 9 | import LectureTimeOverlapError from '@app/core/timetable/error/LectureTimeOverlapError'; 10 | import CoursebookUpdateNotificationService = require('./CoursebookUpdateNotificationService'); 11 | 12 | let logger = winston.loggers.get('default'); 13 | 14 | export async function processUpdatedAndRemoved(year:number, semesterIndex:number, 15 | updatedList: LectureDifference[], removedList: RefLecture[], isFcmEnabled:boolean):Promise { 16 | let userIdNumUpdatedMap: Map = new Map(); 17 | let userIdNumRemovedMap: Map = new Map(); 18 | 19 | function incrementUpdated(userId: any) { 20 | userId = (typeof userId == 'string') ? userId : String(userId); 21 | let oldValue = userIdNumUpdatedMap.get(userId); 22 | if (oldValue) { 23 | userIdNumUpdatedMap.set(userId, oldValue + 1); 24 | } else { 25 | userIdNumUpdatedMap.set(userId, 1); 26 | } 27 | } 28 | 29 | function incrementRemoved(userId: any) { 30 | userId = (typeof userId == 'string') ? userId : String(userId); 31 | let oldValue = userIdNumRemovedMap.get(userId); 32 | if (oldValue) { 33 | userIdNumRemovedMap.set(userId, oldValue + 1); 34 | } else { 35 | userIdNumRemovedMap.set(userId, 1); 36 | } 37 | } 38 | 39 | async function processUpdated(lectureDifference: LectureDifference) { 40 | let refLectureId = lectureDifference.oldLecture._id; 41 | let course_number = lectureDifference.oldLecture.course_number; 42 | let lecture_number = lectureDifference.oldLecture.lecture_number; 43 | 44 | await RefLectureService.partialModifiy(refLectureId, lectureDifference.difference); 45 | 46 | let timetables = await TimetableService.getHavingLecture( 47 | year, semesterIndex, course_number, lecture_number); 48 | 49 | for (let i=0; i = new Set(); 110 | 111 | for (let userId of userIdNumRemovedMap.keys()) { 112 | users.add(userId); 113 | } 114 | 115 | for (let userId of userIdNumUpdatedMap.keys()) { 116 | users.add(userId); 117 | } 118 | 119 | let index = 1; 120 | for (let userId of users) { 121 | logger.info((index++) + "th user fcm"); 122 | let numUpdated = userIdNumUpdatedMap.get(userId); 123 | let numRemoved = userIdNumRemovedMap.get(userId); 124 | await CoursebookUpdateNotificationService.sendCoursebookUpdateFcmNotification(userId, numUpdated, numRemoved); 125 | } 126 | } 127 | } 128 | 129 | -------------------------------------------------------------------------------- /src/batch/coursebook/TagParseService.ts: -------------------------------------------------------------------------------- 1 | import RefLecture from '@app/core/lecture/model/RefLecture'; 2 | import TagListEtcTagService = require('@app/core/taglist/TagListEtcTagService'); 3 | import winston = require('winston'); 4 | var logger = winston.loggers.get('default'); 5 | 6 | export type TagStruct = { 7 | classification : string[], 8 | department : string[], 9 | academic_year : string[], 10 | credit : string[], 11 | instructor : string[], 12 | category : string[], 13 | etc: string[] 14 | }; 15 | 16 | export function parseTagFromLectureList(lines:RefLecture[]): TagStruct { 17 | var tags: TagStruct = { 18 | classification : [], 19 | department : [], 20 | academic_year : [], 21 | credit : [], 22 | instructor : [], 23 | category : [], 24 | etc: TagListEtcTagService.getEtcTagList() 25 | }; 26 | for (let i=0; i parseInt(a) - parseInt(b)); 53 | } else { 54 | tags[key].sort(); 55 | } 56 | } 57 | } 58 | return tags; 59 | } 60 | -------------------------------------------------------------------------------- /src/batch/coursebook/excel/ExcelUtil.ts: -------------------------------------------------------------------------------- 1 | import xlsx = require('xlsx'); 2 | import ExcelSheetWrapper from './model/ExcelSheetWrapper'; 3 | 4 | export function getFirstSheetFromBuffer(buffer: Buffer): ExcelSheetWrapper { 5 | let workbook = xlsx.read(buffer, {type:"buffer"}); 6 | let sheet = workbook.Sheets[workbook.SheetNames[0]]; 7 | return new ExcelSheetWrapper(sheet); 8 | } 9 | -------------------------------------------------------------------------------- /src/batch/coursebook/excel/model/ExcelSheetWrapper.ts: -------------------------------------------------------------------------------- 1 | import xlsx = require('xlsx'); 2 | 3 | export default class ExcelSheetWrapper { 4 | private sheet: xlsx.WorkSheet; 5 | constructor (sheet: xlsx.WorkSheet) { 6 | this.sheet = sheet; 7 | } 8 | 9 | getRowSize(): number { 10 | return xlsx.utils.decode_range(this.sheet['!ref']).e.r + 1; 11 | } 12 | 13 | getCell(r: number, c: number): string { 14 | let obj:xlsx.CellObject = this.sheet[xlsx.utils.encode_cell({r: r, c: c})]; 15 | return obj.v; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/batch/coursebook/index.ts: -------------------------------------------------------------------------------- 1 | require('module-alias/register') 2 | require('@app/batch/config/log'); 3 | require('@app/core/config/mongo'); 4 | require('@app/core/config/redis'); 5 | 6 | import { compareLectures } from './LectureCompareService'; 7 | import { processUpdatedAndRemoved } from './LectureProcessService'; 8 | import CourseBookService = require('@app/core/coursebook/CourseBookService'); 9 | import RefLectureService = require('@app/core/lecture/RefLectureService'); 10 | import TagListService = require('@app/core/taglist/TagListService'); 11 | import SugangSnu2Service = require('./sugangsnu/SugangSnu2Service'); 12 | import TagParseService = require('./TagParseService'); 13 | import winston = require('winston'); 14 | import SimpleJob from '../common/SimpleJob'; 15 | import RefLecture from '@app/core/lecture/model/RefLecture'; 16 | import CoursebookUpdateNotificationService = require('./CoursebookUpdateNotificationService'); 17 | import RedisUtil = require('@app/core/redis/RedisUtil'); 18 | let logger = winston.loggers.get('default'); 19 | 20 | /** 21 | * 현재 수강편람과 다음 수강편람 22 | */ 23 | async function getUpdateCandidate(): Promise> { 24 | let recentCoursebook = await CourseBookService.getRecent(); 25 | if (!recentCoursebook) { 26 | let date = new Date(); 27 | let year = date.getFullYear(); 28 | let month = date.getMonth(); 29 | let semester: number; 30 | if (month < 3) { 31 | semester = 4; // Winter 32 | } else if (month < 7) { 33 | semester = 1; // Spring 34 | } else if (month < 9) { 35 | semester = 2; // Summer 36 | } else { 37 | semester = 3; // Fall 38 | } 39 | logger.info("No recent coursebook found, infer from the current date."); 40 | logger.info("Inferred ", year, semester); 41 | return [[year, semester]]; 42 | } 43 | let year = recentCoursebook.year; 44 | let semester = recentCoursebook.semester; 45 | 46 | let nextYear = year; 47 | let nextSemester = semester + 1; 48 | if (nextSemester > 4) { 49 | nextYear++; 50 | nextSemester = 1; 51 | } 52 | 53 | return [[year, semester], 54 | [nextYear, nextSemester]]; 55 | } 56 | 57 | 58 | export async function fetchAndInsert(year: number, semester: number, isFcmEnabled: boolean): Promise { 59 | logger.info("Fetching from sugang.snu.ac.kr..."); 60 | let fetched = await SugangSnu2Service.getRefLectureList(year, semester); 61 | if (fetched.length == 0) { 62 | logger.warn("No lecture found."); 63 | return; 64 | } 65 | logger.info("Load complete with " + fetched.length + " courses"); 66 | logger.info("Compare lectures..."); 67 | let { updatedList, removedList, createdList } = await compareLectures(year, semester, fetched); 68 | if (updatedList.length === 0 && 69 | createdList.length === 0 && 70 | removedList.length === 0) { 71 | logger.info("Nothing updated."); 72 | return; 73 | } 74 | logger.info(updatedList.length + " updated, " + 75 | createdList.length + " created, " + 76 | removedList.length + " removed."); 77 | 78 | logger.info("Sending notifications..."); 79 | await RefLectureService.addAll(createdList); 80 | await processUpdatedAndRemoved(year, semester, updatedList, removedList, isFcmEnabled); 81 | 82 | await validateRefLecture(year, semester, fetched); 83 | 84 | await upsertTagList(year, semester, fetched); 85 | 86 | await upsertCoursebook(year, semester, isFcmEnabled); 87 | 88 | return; 89 | } 90 | 91 | async function validateRefLecture(year: number, semester: number, fetched: RefLecture[]) { 92 | let { updatedList, removedList, createdList } = await compareLectures(year, semester, fetched); 93 | if (updatedList.length === 0 && 94 | createdList.length === 0 && 95 | removedList.length === 0) { 96 | return; 97 | } 98 | 99 | logger.error("validateRefLecture failed, upsert all lectures"); 100 | await upsertRefLectureList(year, semester, fetched); 101 | } 102 | 103 | async function upsertRefLectureList(year: number, semester: number, fetched: RefLecture[]) { 104 | await RefLectureService.removeBySemester(year, semester); 105 | logger.info("Removed existing lecture for this semester"); 106 | 107 | logger.info("Inserting new lectures..."); 108 | var inserted = await RefLectureService.addAll(fetched); 109 | logger.info("Insert complete with " + inserted + " success and " + (fetched.length - inserted) + " errors"); 110 | 111 | logger.info('Flushing all redis data'); 112 | await RedisUtil.flushdb(); 113 | } 114 | 115 | async function upsertTagList(year: number, semester: number, fetched: RefLecture[]) { 116 | logger.info("Parsing tags..."); 117 | let tags = TagParseService.parseTagFromLectureList(fetched); 118 | logger.info("Inserting tags from new lectures..."); 119 | await TagListService.upsert({year: year, semester: semester, tags: tags}); 120 | logger.info("Inserted tags"); 121 | } 122 | 123 | async function upsertCoursebook(year: number, semester: number, isFcmEnabled: boolean) { 124 | logger.info("saving coursebooks..."); 125 | /* Send notification only when coursebook is new */ 126 | var existingDoc = await CourseBookService.get(year, semester); 127 | if (!existingDoc) { 128 | await CourseBookService.add({ 129 | year: year, 130 | semester: semester, 131 | updated_at: new Date() 132 | }); 133 | await CoursebookUpdateNotificationService.addCoursebookUpdateNotification(year, semester, isFcmEnabled); 134 | } else { 135 | await CourseBookService.modifyUpdatedAt(existingDoc, new Date()); 136 | } 137 | } 138 | 139 | async function run() { 140 | let cands: Array<[number, number]>; 141 | if (process.argv.length != 4) { 142 | cands = await getUpdateCandidate(); 143 | } else { 144 | cands = [[parseInt(process.argv[2]), parseInt(process.argv[3])]]; 145 | } 146 | for (let i = 0; i < cands.length; i++) { 147 | let year = cands[i][0]; 148 | let semester = cands[i][1]; 149 | try { 150 | await fetchAndInsert(Number(year), semester, true); 151 | } catch (err) { 152 | logger.error(err); 153 | continue; 154 | } 155 | } 156 | } 157 | 158 | async function main() { 159 | await new SimpleJob("coursebook", run).run(); 160 | setTimeout(() => process.exit(0), 1000); 161 | } 162 | 163 | if (!module.parent) { 164 | main(); 165 | } 166 | -------------------------------------------------------------------------------- /src/batch/coursebook/model/LectureDifference.ts: -------------------------------------------------------------------------------- 1 | import RefLecture from "@app/core/lecture/model/RefLecture"; 2 | 3 | export default interface LectureDifference { 4 | oldLecture: RefLecture, 5 | newLecture: RefLecture, 6 | difference: any 7 | } 8 | -------------------------------------------------------------------------------- /src/batch/coursebook/sugangsnu/SugangSnuLectureCategoryService.ts: -------------------------------------------------------------------------------- 1 | enum LectureCategory { 2 | FOUNDATION_WRITING = 40, 3 | FOUNDATION_LANGUAGE = 41, 4 | FOUNDATION_MATH = 42, 5 | FOUNDATION_SCIENCE = 43, 6 | FOUNDATION_COMPUTER = 44, 7 | KNOWLEDGE_LITERATURE = 45, 8 | KNOWLEDGE_ART = 46, 9 | KNOWLEDGE_HISTORY = 47, 10 | KNOWLEDGE_POLITICS = 48, 11 | KNOWLEDGE_HUMAN = 49, 12 | KNOWLEDGE_NATURE = 50, 13 | KNOWLEDGE_LIFE = 51, 14 | GENERAL_PHYSICAL = 52, 15 | GENERAL_ART = 53, 16 | GENERAL_COLLEGE = 54, 17 | GENERAL_CREATIVITY = 55, 18 | GENERAL_KOREAN = 56, 19 | } 20 | 21 | export const lectureCategoryList: number[] = Object.keys(LectureCategory) 22 | .map(k => LectureCategory[k]) 23 | .filter(v => typeof v === "number") as number[]; 24 | 25 | const lectureCategoryString = { 26 | [LectureCategory.FOUNDATION_WRITING]: "사고와 표현", 27 | [LectureCategory.FOUNDATION_LANGUAGE]: "외국어", 28 | [LectureCategory.FOUNDATION_MATH]: "수량적 분석과 추론", 29 | [LectureCategory.FOUNDATION_SCIENCE]: "과학적 사고와 실험", 30 | [LectureCategory.FOUNDATION_COMPUTER]: "컴퓨터와 정보 활용", 31 | [LectureCategory.KNOWLEDGE_LITERATURE]: "언어와 문학", 32 | [LectureCategory.KNOWLEDGE_ART]: "문화와 예술", 33 | [LectureCategory.KNOWLEDGE_HISTORY]: "역사와 철학", 34 | [LectureCategory.KNOWLEDGE_POLITICS]: "정치와 경제", 35 | [LectureCategory.KNOWLEDGE_HUMAN]: "인간과 사회", 36 | [LectureCategory.KNOWLEDGE_NATURE]: "자연과 기술", 37 | [LectureCategory.KNOWLEDGE_LIFE]: "생명과 환경", 38 | [LectureCategory.GENERAL_PHYSICAL]: "체육", 39 | [LectureCategory.GENERAL_ART]: "예술실기", 40 | [LectureCategory.GENERAL_COLLEGE]: "대학과 리더쉽", 41 | [LectureCategory.GENERAL_CREATIVITY]: "창의와 융합", 42 | [LectureCategory.GENERAL_KOREAN]: "한국의 이해" 43 | }; 44 | 45 | const lectureUpperCategory = { 46 | // 학문의 기초 47 | [LectureCategory.FOUNDATION_WRITING]: "04", 48 | [LectureCategory.FOUNDATION_LANGUAGE]: "04", 49 | [LectureCategory.FOUNDATION_MATH]: "04", 50 | [LectureCategory.FOUNDATION_SCIENCE]: "04", 51 | [LectureCategory.FOUNDATION_COMPUTER]: "04", 52 | // 학문의 세계 53 | [LectureCategory.KNOWLEDGE_LITERATURE]: "05", 54 | [LectureCategory.KNOWLEDGE_ART]: "05", 55 | [LectureCategory.KNOWLEDGE_HISTORY]: "05", 56 | [LectureCategory.KNOWLEDGE_POLITICS]: "05", 57 | [LectureCategory.KNOWLEDGE_HUMAN]: "05", 58 | [LectureCategory.KNOWLEDGE_NATURE]: "05", 59 | [LectureCategory.KNOWLEDGE_LIFE]: "05", 60 | // 선택 교양 61 | [LectureCategory.GENERAL_PHYSICAL]: "06", 62 | [LectureCategory.GENERAL_ART]: "06", 63 | [LectureCategory.GENERAL_COLLEGE]: "06", 64 | [LectureCategory.GENERAL_CREATIVITY]: "06", 65 | [LectureCategory.GENERAL_KOREAN]: "06" 66 | }; 67 | 68 | export function getLectureCategoryString(lectureCategory: number) { 69 | return lectureCategoryString[lectureCategory]; 70 | } 71 | 72 | export function getLectureUpperCategory(lectureCategory: number) { 73 | return lectureUpperCategory[lectureCategory]; 74 | } 75 | -------------------------------------------------------------------------------- /src/batch/coursebook/sugangsnu/SugangSnuService.ts: -------------------------------------------------------------------------------- 1 | import request = require('request-promise-native'); 2 | import winston = require('winston'); 3 | import ExcelUtil = require('@app/batch/coursebook/excel/ExcelUtil'); 4 | import RefLecture from '@app/core/lecture/model/RefLecture'; 5 | import SugangSnuLectureService = require('@app/batch/coursebook/sugangsnu/SugangSnuLectureService'); 6 | import SugangSnuLectureCategoryService = require('@app/batch/coursebook/sugangsnu/SugangSnuLectureCategoryService'); 7 | let logger = winston.loggers.get('default'); 8 | 9 | const SEMESTER_1 = 1; 10 | const SEMESTER_S = 2; 11 | const SEMESTER_2 = 3; 12 | const SEMESTER_W = 4; 13 | 14 | const semesterString = { 15 | [SEMESTER_1]: "1", 16 | [SEMESTER_S]: "S", 17 | [SEMESTER_2]: "2", 18 | [SEMESTER_W]: "W", 19 | } 20 | 21 | const semesterQueryString = { 22 | [SEMESTER_1]: "U000200001U000300001", 23 | [SEMESTER_S]: "U000200001U000300002", 24 | [SEMESTER_2]: "U000200002U000300001", 25 | [SEMESTER_W]: "U000200002U000300002", 26 | } 27 | 28 | /** 29 | * @Deprecated 30 | * 2020년 겨울 강좌부터 deprecated 되었음 31 | * 대신 SugangSnu2Service.ts 사용 32 | */ 33 | export async function getRefLectureList(year: number, semester: number): Promise { 34 | let ret: RefLecture[] = []; 35 | // apply의 경우 두번째 인자의 개수가 너무 많을 경우 fail할 수 있음 36 | ret.push.apply(ret, await getRefLectureListForCategory(year, semester, null)); 37 | for (let category of SugangSnuLectureCategoryService.lectureCategoryList) { 38 | ret.push.apply(ret, await getRefLectureListForCategory(year, semester, category)); 39 | } 40 | return ret; 41 | } 42 | 43 | async function getRefLectureListForCategory(year: number, semester: number, lectureCategory: number): Promise { 44 | let fileBuffer: Buffer = await getCoursebookExcelFileForCategory(year, semester, lectureCategory); 45 | if (fileBuffer.byteLength == 0) { 46 | logger.info("No response"); 47 | return []; 48 | } 49 | let sheet = ExcelUtil.getFirstSheetFromBuffer(fileBuffer); 50 | return SugangSnuLectureService.getRefLectureListFromExcelSheet(sheet, year, semester, lectureCategory);; 51 | } 52 | 53 | function getCoursebookExcelFileForCategory(year: number, semester: number, lectureCategory: number): Promise { 54 | return request.get(makeCoursebookExcelFileUrl(year, semester, lectureCategory), { 55 | encoding: null, // return as binary 56 | resolveWithFullResponse: true 57 | }).then(function(response: request.FullResponse) { 58 | if (response.statusCode >= 400) { 59 | logger.warn("status code " + response.statusCode); 60 | return Promise.resolve(new Buffer(0)); 61 | } 62 | if (!response.headers["content-disposition"]) { 63 | logger.warn("No content-disposition found"); 64 | return Promise.resolve(new Buffer(0)); 65 | } 66 | return response.body; 67 | }); 68 | } 69 | 70 | const SUGANG_SNU_BASEPATH = "http://sugang.snu.ac.kr/sugang/cc/cc100excel.action?"; 71 | function makeCoursebookExcelFileUrl(year: number, semester: number, lectureCategory: number): string { 72 | let queryStrings: string[] = [ 73 | "srchCond=1", 74 | "pageNo=1", 75 | "workType=EX", 76 | "sortKey=", 77 | "sortOrder=", 78 | "srchOpenSchyy=" + year, 79 | "currSchyy=" + year, 80 | "srchOpenShtm=" + semesterQueryString[semester], 81 | "srchCptnCorsFg=", 82 | "srchOpenShyr=", 83 | "srchSbjtCd=", 84 | "srchSbjtNm=", 85 | "srchOpenUpSbjtFldCd=", 86 | "srchOpenUpDeptCd=", 87 | "srchOpenDeptCd=", 88 | "srchOpenMjCd=", 89 | "srchOpenSubmattFgCd=", 90 | "srchOpenPntMin=", 91 | "srchOpenPntMax=", 92 | "srchCamp=", 93 | "srchBdNo=", 94 | "srchProfNm=", 95 | "srchTlsnAplyCapaCntMin=", 96 | "srchTlsnAplyCapaCntMax=", 97 | "srchTlsnRcntMin=", 98 | "srchTlsnRcntMax=", 99 | "srchOpenSbjtTmNm=", 100 | "srchOpenSbjtTm=", 101 | "srchOpenSbjtTmVal=", 102 | "srchLsnProgType=", 103 | "srchMrksGvMthd=", 104 | ]; 105 | if (lectureCategory === null) { 106 | queryStrings.push("srchOpenSbjtFldCd="); 107 | logger.info("Fetching " + year + "-" + semesterString[semester]); 108 | } else { 109 | queryStrings.push("srchOpenSbjtFldCd=" + lectureCategory); 110 | logger.info("Fetching " + SugangSnuLectureCategoryService.getLectureCategoryString(lectureCategory)); 111 | } 112 | let ret = SUGANG_SNU_BASEPATH + queryStrings.join('&'); 113 | logger.debug(ret); 114 | return ret; 115 | } 116 | -------------------------------------------------------------------------------- /src/batch/coursebook/sugangsnu/model/SugangSnuLecture.ts: -------------------------------------------------------------------------------- 1 | export default interface SugangSnuLecture { 2 | // 교과구분 3 | classification: string; 4 | // 개설대학 5 | college: string; 6 | // 개설학과 7 | department: string; 8 | // 이수과정 9 | academic_course: string; 10 | // 학년 11 | academic_year: string; 12 | // 교과목 번호 13 | course_number: string; 14 | // 강좌 번호 15 | lecture_number: string; 16 | // 교과목명 17 | course_title: string; 18 | // 부제명 19 | course_subtitle: string; 20 | // 학점 21 | credit: string; 22 | // 강의 23 | num_lecture: string; 24 | // 실습 25 | num_practice: string; 26 | // 수업교시 27 | class_time: string; 28 | // 수업형태 29 | class_type: string; 30 | // 강의실 31 | location: string; 32 | // 주담당교수 33 | instructor: string; 34 | // 장바구니 신청 35 | pre_booking: string; 36 | // 신입생 장바구니 신청 37 | freshman_pre_booking: string; 38 | // 재학생 장바구니 신청 39 | existing_student_pre_booking: string; 40 | // 정원 41 | quota: string; 42 | // 수강신청인원 43 | enrollment: string; 44 | // 비고 45 | remark: string; 46 | // 강의 언어 47 | lecture_language: string; 48 | // 개설 상태 49 | lecture_status: string; 50 | } 51 | -------------------------------------------------------------------------------- /src/batch/fix_time_mask/index.ts: -------------------------------------------------------------------------------- 1 | require('module-alias/register'); 2 | require('@app/batch/config/log'); 3 | require('@app/core/config/mongo'); 4 | 5 | import winston = require('winston'); 6 | 7 | import LambdaJobBuilder from '../common/LambdaJobBuilder'; 8 | import Lecture from '@app/core/lecture/model/Lecture'; 9 | import { timeJsonToMask } from '@app/core/timetable/util/TimePlaceUtil'; 10 | import RefLectureService = require('@app/core/lecture/RefLectureService'); 11 | import TimetableService = require('@app/core/timetable/TimetableService'); 12 | import TimetableRepository = require('@app/core/timetable/TimetableRepository'); 13 | 14 | let logger = winston.loggers.get('default'); 15 | 16 | async function reader(executionContext) { 17 | let year: number = executionContext.year; 18 | let semesterIndex: number = executionContext.semesterIndex; 19 | 20 | let lectureWithTypeList: {type: string, tableId: string, lecture: Lecture}[] = []; 21 | logger.info("Loading " + year + " " + semesterIndex); 22 | 23 | let refLectureList = await RefLectureService.getBySemester(year, semesterIndex); 24 | for (let refLecture of refLectureList) { 25 | lectureWithTypeList.push({type: "RefLecture", lecture: refLecture, tableId: null}); 26 | } 27 | logger.info("RefLecture loaded"); 28 | 29 | let tableList = await TimetableService.getBySemester(year, semesterIndex); 30 | for (let table of tableList) { 31 | for (let userLecture of table.lecture_list) { 32 | lectureWithTypeList.push({type: "UserLecture", lecture: userLecture, tableId: table._id}); 33 | } 34 | } 35 | logger.info("UserLecture loaded"); 36 | 37 | logger.info(lectureWithTypeList.length + " Loaded"); 38 | return lectureWithTypeList; 39 | } 40 | 41 | async function processor(lectureWithType: {type: string, tableId: string, lecture: Lecture}) { 42 | let type = lectureWithType.type; 43 | let lecture = lectureWithType.lecture; 44 | 45 | let expectedTimeMask = timeJsonToMask(lecture.class_time_json); 46 | let actualTimeMask = lecture.class_time_mask; 47 | for (let i=0; i<7; i++) { 48 | if (expectedTimeMask[i] !== actualTimeMask[i]) { 49 | lecture.class_time_mask = expectedTimeMask; 50 | return { 51 | type: type, 52 | lecture: lecture, 53 | result: "ERROR", 54 | tableId: lectureWithType.tableId 55 | }; 56 | } 57 | } 58 | return { 59 | type: type, 60 | lecture: lecture, 61 | result: "OK", 62 | tableId: lectureWithType.tableId 63 | }; 64 | } 65 | 66 | async function writer(processed: {type: string, result: string, tableId: string, lecture: any}) { 67 | if (processed.result !== "OK") { 68 | logger.error("Timemask does not match (type: " + processed.type + 69 | ", _id: " + processed.lecture._id + ", title: " + processed.lecture.course_title + ")"); 70 | if (processed.type === "UserLecture") { 71 | logger.error("updated_at: " + processed.lecture.updated_at); 72 | try { 73 | await TimetableRepository.partialUpdateUserLecture(processed.tableId, {_id: processed.lecture._id, 74 | class_time_json: processed.lecture.class_time_json, class_time_mask: processed.lecture.class_time_mask}); 75 | logger.info("Fixed"); 76 | } catch (e) { 77 | logger.error(e); 78 | } 79 | } else { 80 | logger.error("Skip error"); 81 | } 82 | } 83 | } 84 | 85 | async function main() { 86 | let year = parseInt(process.argv[2]); 87 | let semesterIndex = parseInt(process.argv[3]); 88 | await new LambdaJobBuilder("FixTimeMaskJob") 89 | .reader(reader) 90 | .processor(processor) 91 | .writer(writer) 92 | .run({year: year, semesterIndex: semesterIndex}); 93 | 94 | setTimeout(() => process.exit(0), 1000); 95 | } 96 | 97 | if (!module.parent) { 98 | main(); 99 | } 100 | -------------------------------------------------------------------------------- /src/batch/prune_log/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 오래된 로그를 삭제합니다. 3 | * $ npm run prune_log 4 | * 5 | * @author Jang Ryeol, ryeolj5911@gmail.com 6 | */ 7 | 8 | require('module-alias/register'); 9 | require('@app/batch/config/log'); 10 | require('@app/core/config/mongo'); 11 | 12 | import FcmLogService = require('@app/core/fcm/FcmLogService'); 13 | import RefLectureQueryService = require('@app/core/lecture/RefLectureQueryService'); 14 | import SimpleJob from '../common/SimpleJob'; 15 | 16 | async function run() { 17 | let currentTimestamp = Date.now(); 18 | let thresholdTimestamp = currentTimestamp - 1000 * 3600 * 24 * 180; // 180 days 19 | await FcmLogService.removeBeforeTimestamp(thresholdTimestamp); 20 | await RefLectureQueryService.removeQueryLogBeforeTimestamp(thresholdTimestamp); 21 | } 22 | 23 | async function main() { 24 | await new SimpleJob("prune_log", run).run(); 25 | setTimeout(() => process.exit(0), 1000); 26 | } 27 | 28 | if (!module.parent) { 29 | main(); 30 | } 31 | -------------------------------------------------------------------------------- /src/core/admin/AdminRepository.ts: -------------------------------------------------------------------------------- 1 | import mongoose = require('mongoose'); 2 | 3 | export async function findStatistics() { 4 | let yesterdayTime = Date.now() - 24 * 3600000; 5 | let userCountPromise = mongoose.connection.db.collection('users').count({}); 6 | let tempUserCountPromise = mongoose.connection.db.collection('users') 7 | .count({ 8 | $and: [{"credential.localId": null}, {"credential.fbId": null}] 9 | }); 10 | let tableCountPromise = mongoose.connection.db.collection('timetables').count({}); 11 | let recentQueryCountPromise = mongoose.connection.db.collection('query_logs') 12 | .count({timestamp: { $gt: yesterdayTime}}); 13 | return { 14 | userCount: await userCountPromise, 15 | tempUserCount: await tempUserCountPromise, 16 | tableCount: await tableCountPromise, 17 | recentQueryCount: await recentQueryCountPromise 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/core/admin/AdminService.ts: -------------------------------------------------------------------------------- 1 | import AdminRepository = require('./AdminRepository'); 2 | import AdminStatistics from './model/AdminStatistics'; 3 | 4 | export function getStatistics(): Promise { 5 | return AdminRepository.findStatistics(); 6 | } 7 | -------------------------------------------------------------------------------- /src/core/admin/model/AdminStatistics.ts: -------------------------------------------------------------------------------- 1 | export default interface AdminStatistics { 2 | userCount: number; 3 | tempUserCount: number; 4 | tableCount: number; 5 | recentQueryCount: number; 6 | } -------------------------------------------------------------------------------- /src/core/apple/AppleService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 받은 identityToken(JWT) header를 분석, kid와 alg에 해당하는 JWK를 찾아 토큰 검증, 해독 3 | * 4 | * @author Hank Choi, zlzlqlzl1@gmail.com 5 | */ 6 | 7 | import * as jwt from "jsonwebtoken"; 8 | import AppleUserInfo from "@app/core/apple/model/AppleUserInfo"; 9 | import AppleJWK from "@app/core/apple/model/AppleJWK"; 10 | import request = require("request"); 11 | import AppleApiError from "@app/core/apple/error/AppleApiError"; 12 | import pemjwk = require("pem-jwk"); 13 | import JwtHeader from "@app/core/apple/model/JwtHeader"; 14 | import InvalidAppleTokenError from "@app/core/apple/error/InvalidAppleTokenError"; 15 | 16 | async function getMatchedKeyBy(kid: string, alg: string): Promise { 17 | try { 18 | const keys: Array = await new Promise>(function (resolve, reject) { 19 | request({ 20 | url: "https://appleid.apple.com/auth/keys", 21 | method: "GET", 22 | json: true, 23 | }, function (err, res, body) { 24 | if (err || res.statusCode != 200 || !body) { 25 | return reject(new AppleApiError()); 26 | } else { 27 | return resolve(body.keys); 28 | } 29 | }); 30 | }); 31 | return keys.filter((key) => key.kid === kid && key.alg === alg)[0] 32 | } catch (err) { 33 | throw err 34 | } 35 | } 36 | 37 | export async function verifyAndDecodeAppleToken(identityToken: string, appType: string): Promise { 38 | const headerOfIdentityToken: JwtHeader = JSON.parse(Buffer.from(identityToken.substr(0, identityToken.indexOf('.')), 'base64').toString()); 39 | const appleJwk: AppleJWK = await getMatchedKeyBy(headerOfIdentityToken.kid, headerOfIdentityToken.alg); 40 | const publicKey: string = pemjwk.jwk2pem(appleJwk); 41 | try { 42 | jwt.verify(identityToken, publicKey, { 43 | algorithms: [appleJwk.alg], 44 | issuer: 'https://appleid.apple.com', 45 | audience: appType === 'release' ? 'com.wafflestudio.snutt' : 'com.wafflestudio.snutt.dev', 46 | }); 47 | } 48 | catch (err) { 49 | throw new InvalidAppleTokenError(identityToken); 50 | } 51 | return jwt.decode(identityToken) 52 | } 53 | -------------------------------------------------------------------------------- /src/core/apple/error/AppleApiError.ts: -------------------------------------------------------------------------------- 1 | export default class AppleApiError extends Error { 2 | constructor() { 3 | super("Apple server has a problem"); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/apple/error/InvalidAppleTokenError.ts: -------------------------------------------------------------------------------- 1 | export default class InvalidAppleTokenError extends Error { 2 | constructor(public appleToken: string) { 3 | super("Invalid apple token: '" + appleToken + "'"); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/apple/model/AppleJWK.ts: -------------------------------------------------------------------------------- 1 | export default interface AppleJWK { 2 | kty: string, 3 | kid: string, 4 | use: string, 5 | alg: string, 6 | n: string, 7 | e: string 8 | } 9 | -------------------------------------------------------------------------------- /src/core/apple/model/AppleUserInfo.ts: -------------------------------------------------------------------------------- 1 | export default interface AppleUserInfo { 2 | iss: string, 3 | sub: string, 4 | aud: string, 5 | iat: string, 6 | exp: string, 7 | nonce: string, 8 | nonce_supporting: string, 9 | email: string, 10 | email_verified: string, 11 | is_private_email: string, 12 | real_user_status: string, 13 | transfer_sub: string 14 | } 15 | -------------------------------------------------------------------------------- /src/core/apple/model/JwtHeader.ts: -------------------------------------------------------------------------------- 1 | export default interface JwtHeader { 2 | kid: string, 3 | alg: string, 4 | } 5 | -------------------------------------------------------------------------------- /src/core/common/util/ObjectUtil.ts: -------------------------------------------------------------------------------- 1 | import { List } from 'immutable'; 2 | 3 | import winston = require('winston'); 4 | var logger = winston.loggers.get('default'); 5 | 6 | export function deepCopy(src: T): T { 7 | return JSON.parse(JSON.stringify(src)); 8 | } 9 | 10 | export function isNumber(n: any): boolean { 11 | return typeof n === 'number' && !isNaN(n); 12 | } 13 | 14 | function deleteObjectIdRecur(obj: any, stack: List) { 15 | for (let i=0; i< stack.size; i++) { 16 | if (i > 10) { 17 | logger.warn("deleteObjectIdRecur: Too deep stack"); 18 | logger.warn(obj); 19 | return; 20 | } 21 | if (obj === stack[i]) { 22 | logger.warn("deleteObjectIdRecur: recurrence found"); 23 | logger.warn(obj); 24 | return; 25 | } 26 | } 27 | if (obj !== null && typeof(obj) == 'object') { 28 | if (obj instanceof Promise) { 29 | logger.warn("deleteObjectIdRecur: Object is promise"); 30 | } else if (Array.isArray(obj)) { 31 | for (let i = 0; i < obj.length; i++) { 32 | if (obj[i] && obj[i]._id) deleteObjectIdRecur(obj[i], stack.push(obj)); //recursive del calls on array elements 33 | } 34 | } else { 35 | delete obj._id; 36 | Object.keys(obj).forEach(function(key) { 37 | if (obj[key]) deleteObjectIdRecur(obj[key], stack.push(obj)); //recursive del calls on object elements 38 | }); 39 | } 40 | } 41 | } 42 | 43 | /** 44 | * Delete '_id' prop of the object and its sub-object recursively 45 | * This is for copying mongo objects or sanitizing json objects by removing all _id properties 46 | */ 47 | export function deleteObjectId(object) { 48 | return deleteObjectIdRecur(object, List()); 49 | }; 50 | -------------------------------------------------------------------------------- /src/core/config/apiKey.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * credential field for making tokens 3 | * 모든 클라이언트는 이 파일에서 생성된 api key를 4 | * HTTP 요청 헤더에 포함해야 합니다. (위키 참조)) 5 | * 6 | * API 키를 새로 발급하고 싶으면, npm run build를 수행 후 7 | * node로 다음 명령을 수행합니다. 8 | * $ npm run api-key 9 | * 10 | * @author Jang Ryeol, ryeolj5911@gmail.com 11 | */ 12 | require('module-alias/register'); 13 | import jwt = require('jsonwebtoken'); 14 | import property = require('@app/core/config/property'); 15 | 16 | let secretKey = property.get('core.secretKey'); 17 | 18 | /** 19 | * api key를 발급할 때 암호화에 사용되는 json입니다. 20 | * jwt에 고유한 config.secretKey를 사용하여 암호화하기 때문에 21 | * key_version을 바꾸면 새로운 키를 발급할 수 있습니다. 22 | * 혹은 임의의 필드를 삽입하여 salt로 사용할 수 있습니다. 23 | */ 24 | var api_list = { 25 | ios : { 26 | string : "ios", 27 | key_version : "0" 28 | }, 29 | web : { 30 | string : "web", 31 | key_version : "0" 32 | }, 33 | android : { 34 | string : "android", 35 | key_version : "0" 36 | }, 37 | test : { 38 | string : "test", 39 | key_version : "0" 40 | } 41 | }; 42 | 43 | /** 44 | * Deprecated 45 | */ 46 | var app_version = { 47 | ios : "1.0.0", 48 | web : "1.0.0", 49 | android : "1.0.0" 50 | }; 51 | 52 | /** 53 | * Deprecated 54 | * @param string 55 | */ 56 | export function getAppVersion(string:string) { 57 | return app_version[string]; 58 | }; 59 | 60 | /** 61 | * secretKey를 이용하여 json을 암호화합니다. 62 | * @param api_obj 63 | */ 64 | function issueKey(api_obj) { 65 | return jwt.sign(api_obj, secretKey); 66 | }; 67 | 68 | /** 69 | * API 키가 올바른 키인지 확인합니다. 70 | * API 키를 요구하는 모든 라우터에서 이 함수를 사용합니다. 71 | * @param api_key 72 | * @returns {Promise} 73 | */ 74 | export function validateKey(api_key:string):Promise { 75 | if (process.env.NODE_ENV == 'mocha') { 76 | return new Promise(function(resolve, reject) { 77 | resolve('mocha'); 78 | }); 79 | } 80 | return new Promise(function(resolve, reject){ 81 | jwt.verify(api_key, secretKey, function(err, decoded: any) { 82 | if (err) return reject("invalid api key"); 83 | if (!decoded.string || !decoded.key_version) return reject("invalid api key"); 84 | if (api_list[decoded.string] && 85 | api_list[decoded.string].key_version == decoded.key_version) 86 | return resolve(decoded.string); 87 | }); 88 | }); 89 | }; 90 | 91 | if (!module.parent) { 92 | if (process.argv.length != 3 || process.argv[2] != "list") { 93 | console.log("Invalid arguments"); 94 | console.log("usage: $ node apiKey.js list"); 95 | process.exit(1); 96 | } 97 | 98 | for (var api in api_list) { 99 | if (api_list.hasOwnProperty(api)) { 100 | console.log(api_list[api].string); 101 | console.log("\n"+issueKey(api_list[api])+"\n"); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/core/config/mongo.ts: -------------------------------------------------------------------------------- 1 | import mongoose = require('mongoose'); 2 | import property = require('@app/core/config/property'); 3 | import winston = require('winston'); 4 | 5 | var logger = winston.loggers.get('default'); 6 | 7 | // nodejs Promise를 사용 8 | mongoose.Promise = global.Promise; 9 | 10 | mongoose.connect(property.get('core.mongo.uri'), function(err) { 11 | if(err) { 12 | logger.error(err); 13 | return; 14 | } 15 | logger.info('MongoDB connected'); 16 | }); 17 | -------------------------------------------------------------------------------- /src/core/config/property.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author ryeolj5911@gmail.com 3 | */ 4 | import fs = require("fs"); 5 | import yaml = require("js-yaml"); 6 | 7 | try { 8 | let yamlString = fs.readFileSync(__dirname + '/../../../../snutt.yml', 'utf8'); 9 | var config:any = yaml.safeLoad(yamlString); 10 | } catch (e) { 11 | throw new Error("Could not find config file."); 12 | } 13 | 14 | export function get(key: string) { 15 | let resolved = resolve(config, key); 16 | if (resolved === undefined) { 17 | throw new Error("Could not find config '" + key + "'"); 18 | } 19 | return resolved; 20 | } 21 | 22 | function resolve(obj, path: string){ 23 | let splitted = path.split('.'); 24 | let current = obj; 25 | while(splitted.length > 0) { 26 | if (typeof current !== 'object') { 27 | return undefined; 28 | } 29 | current = current[splitted.shift()]; 30 | } 31 | return current; 32 | } 33 | 34 | /* 35 | export = { 36 | secretKey: config.secretKey, 37 | host: config.host, 38 | port: config.port, 39 | fcm_api_key: config.fcm.api_key, 40 | fcm_project_id: config.fcm.project_id, 41 | feedback2github_token: config.feedback2github.token, 42 | feedback2github_repo_name: config.feedback2github.repo_name, 43 | feedback2github_repo_owner: config.feedback2github.repo_owner, 44 | mongoUri: config.mongo, 45 | redisPort: config.redis.port 46 | }; 47 | */ 48 | -------------------------------------------------------------------------------- /src/core/config/redis.ts: -------------------------------------------------------------------------------- 1 | import redis = require('redis'); 2 | import winston = require('winston'); 3 | import property = require('@app/core/config/property'); 4 | import RedisUtil = require('@app/core/redis/RedisUtil'); 5 | let logger = winston.loggers.get('default'); 6 | 7 | let client = redis.createClient({ 8 | url: property.get('core.redis.url') 9 | }); 10 | 11 | client.on('connect', async function() { 12 | logger.info('Redis client connected'); 13 | global['redisClient'] = client; 14 | 15 | let maxmemory = await RedisUtil.configGet('maxmemory'); 16 | let maxmemoryPolicy = await RedisUtil.configGet('maxmemory-policy'); 17 | logger.info("Redis maxmemory: " + maxmemory); 18 | logger.info("Redis maxmemory-policy: " + maxmemoryPolicy); 19 | if (Number(maxmemory) === 0) { 20 | logger.error("Redis maxmemory infinite. It's highly recommended to set maxmemory"); 21 | } 22 | if (maxmemoryPolicy !== "allkeys-lru") { 23 | logger.error("Redis eviction policy is not allkeys-lru"); 24 | } 25 | }); 26 | 27 | client.on('error', function (err) { 28 | logger.error('Redis client error: ' + err); 29 | }); 30 | -------------------------------------------------------------------------------- /src/core/coursebook/CourseBookRepository.ts: -------------------------------------------------------------------------------- 1 | import mongoose = require('mongoose'); 2 | import CourseBook from './model/CourseBook'; 3 | 4 | var CourseBookSchema = new mongoose.Schema({ 5 | year: { type: Number, required: true }, 6 | semester: { type: Number, required: true }, 7 | updated_at: Date 8 | }); 9 | 10 | let mongooseModel = mongoose.model('CourseBook', CourseBookSchema, 'coursebooks'); 11 | 12 | export async function findAll(): Promise { 13 | let docs = await mongooseModel 14 | .find({}, '-_id year semester updated_at') 15 | .sort([["year", -1], ["semester", -1]]) 16 | .exec(); 17 | return docs.map(fromMongoose); 18 | } 19 | 20 | export async function findRecent(): Promise { 21 | let doc = await mongooseModel 22 | .findOne({}, '-_id year semester updated_at') 23 | .sort([["year", -1], ["semester", -1]]) 24 | .exec(); 25 | return fromMongoose(doc); 26 | } 27 | 28 | export async function findLastTwoSemesters(): Promise { 29 | let docs = await mongooseModel 30 | .find({}, '-_id year semester updated_at') 31 | .sort([["year", -1], ["semester", -1]]) 32 | .skip(1) 33 | .limit(2) 34 | .exec(); 35 | return docs.map(fromMongoose); 36 | } 37 | 38 | export async function findByYearAndSemester(year: number, semester: number): Promise { 39 | let doc = await mongooseModel 40 | .findOne({year: year, semester: semester}) 41 | .exec(); 42 | return fromMongoose(doc); 43 | } 44 | 45 | export async function insert(courseBook: CourseBook): Promise { 46 | await new mongooseModel(courseBook).save(); 47 | } 48 | 49 | export async function update(courseBook: CourseBook): Promise { 50 | await mongooseModel.updateOne( 51 | {year: courseBook.year, semester: courseBook.semester},courseBook).exec(); 52 | } 53 | 54 | function fromMongoose(mongooseDoc): CourseBook | null { 55 | if (mongooseDoc === null) return null; 56 | return { 57 | year: mongooseDoc.year, 58 | semester: mongooseDoc.semester, 59 | updated_at: mongooseDoc.updated_at 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/core/coursebook/CourseBookService.ts: -------------------------------------------------------------------------------- 1 | import CourseBookRepository = require('./CourseBookRepository'); 2 | import CourseBook from './model/CourseBook'; 3 | 4 | export function getAll(): Promise { 5 | return CourseBookRepository.findAll(); 6 | } 7 | 8 | export function getRecent(): Promise { 9 | return CourseBookRepository.findRecent(); 10 | } 11 | 12 | export function get(year: number, semester: number): Promise { 13 | return CourseBookRepository.findByYearAndSemester(year, semester); 14 | } 15 | 16 | export function add(courseBook: CourseBook): Promise { 17 | return CourseBookRepository.insert(courseBook); 18 | } 19 | 20 | export function modifyUpdatedAt(courseBook: CourseBook, updatedAt: Date): Promise { 21 | courseBook.updated_at = updatedAt; 22 | return CourseBookRepository.update(courseBook); 23 | } 24 | -------------------------------------------------------------------------------- /src/core/coursebook/model/CourseBook.ts: -------------------------------------------------------------------------------- 1 | export default interface CourseBook { 2 | year: number, 3 | semester: number, 4 | updated_at: Date 5 | }; 6 | -------------------------------------------------------------------------------- /src/core/coursebook/sugangsnu/SugangSnuSyllabusService.ts: -------------------------------------------------------------------------------- 1 | export function getSyllabusUrl(year: number, semester: number, lectureNumber: number, courseNumber: number): string { 2 | let openShtmFg = makeOpenShtmFg(semester); 3 | let openDetaShtmFg = makeOpenDetaShtmFg(semester); 4 | return "http://sugang.snu.ac.kr/sugang/cc/cc103.action?openSchyy="+year+ 5 | "&openShtmFg="+openShtmFg+"&openDetaShtmFg="+openDetaShtmFg+ 6 | "&sbjtCd="+courseNumber+"<No="+lectureNumber+"&sbjtSubhCd=000"; 7 | } 8 | 9 | function makeOpenShtmFg(semester: number): string { 10 | switch (semester) { 11 | case 1: 12 | return "U000200001"; 13 | case 2: 14 | return "U000200001"; 15 | case 3: 16 | return "U000200002"; 17 | case 4: 18 | return "U000200002"; 19 | } 20 | } 21 | 22 | function makeOpenDetaShtmFg(semester: number): string { 23 | switch (semester) { 24 | case 1: 25 | return "U000300001"; 26 | case 2: 27 | return "U000300002"; 28 | case 3: 29 | return "U000300001"; 30 | case 4: 31 | return "U000300002"; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/core/facebook/FacebookService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 아이디 혹은 페이스북을 이용한 로그인 3 | * 토큰을 콜백함수로 반환 4 | * 5 | * @author Jang Ryeol, ryeolj5911@gmail.com 6 | */ 7 | 8 | import request = require('request'); 9 | import InvalidFbIdOrTokenError from './error/InvalidFbIdOrTokenError'; 10 | 11 | export function getFbInfo(fbId, fbToken): Promise<{fbName:string, fbId:string}> { 12 | return new Promise(function(resolve, reject) { 13 | request({ 14 | url: "https://graph.facebook.com/me", 15 | method: "GET", 16 | json: true, 17 | qs: {access_token: fbToken} 18 | }, function (err, res, body) { 19 | if (err || res.statusCode != 200 || !body || !body.id || fbId !== body.id) { 20 | return reject(new InvalidFbIdOrTokenError(fbId, fbToken)); 21 | } else { 22 | return resolve({fbName: body.name, fbId: body.id}); 23 | } 24 | }); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/core/facebook/error/InvalidFbIdOrTokenError.ts: -------------------------------------------------------------------------------- 1 | export default class InvalidFbIdOrTokenError extends Error { 2 | constructor(public fbId: string, public fbToken: string) { 3 | super("Invalid fbId or fbToken: '" + fbId + "', '" + fbToken + "'"); 4 | } 5 | } -------------------------------------------------------------------------------- /src/core/fcm/FcmKeyUtil.ts: -------------------------------------------------------------------------------- 1 | import User from '@app/core/user/model/User'; 2 | 3 | export function getUserFcmKeyName(user: User): string { 4 | return "user-" + user._id; 5 | } 6 | -------------------------------------------------------------------------------- /src/core/fcm/FcmLogRepository.ts: -------------------------------------------------------------------------------- 1 | import mongoose = require('mongoose'); 2 | 3 | import FcmLog from '@app/core/fcm/model/FcmLog'; 4 | 5 | var FcmLogSchema = new mongoose.Schema({ 6 | date: Date, 7 | author: String, 8 | to: String, 9 | message: String, 10 | cause: String, 11 | response: String 12 | }); 13 | 14 | FcmLogSchema.index({date: -1}) 15 | 16 | var mongooseModel = mongoose.model('FcmLog', FcmLogSchema, 'fcmlogs'); 17 | 18 | export async function insertFcmLog(fcmLog: FcmLog): Promise { 19 | var log = new mongooseModel(fcmLog); 20 | log.save(); 21 | } 22 | 23 | export async function findRecentFcmLog(): Promise{ 24 | let mongoDocs = await mongooseModel.find().sort({date: -1}).limit(10).exec(); 25 | return mongoDocs.map(fromMongooseModel); 26 | } 27 | 28 | export function deleteBeforeDate(date: Date): Promise { 29 | return mongooseModel.deleteMany({ date: { $lt: date }}).exec(); 30 | } 31 | 32 | function fromMongooseModel(mongooseDoc: any): FcmLog { 33 | let fcmLog: FcmLog = { 34 | date: mongooseDoc.date, 35 | author: mongooseDoc.author, 36 | cause: mongooseDoc.cause, 37 | to: mongooseDoc.to, 38 | message: mongooseDoc.message, 39 | response: mongooseDoc.response 40 | } 41 | return fcmLog; 42 | } 43 | -------------------------------------------------------------------------------- /src/core/fcm/FcmLogService.ts: -------------------------------------------------------------------------------- 1 | import FcmLog from '@app/core/fcm/model/FcmLog'; 2 | import FcmLogRepository = require('@app/core/fcm/FcmLogRepository'); 3 | 4 | export function addFcmLog(to: string, author: string, message: string, cause: string, response: any): Promise { 5 | let fcmLog: FcmLog = { 6 | date: new Date(), 7 | to: to, 8 | author: author, 9 | message: message, 10 | cause: cause, 11 | response: JSON.stringify(response) 12 | } 13 | return FcmLogRepository.insertFcmLog(fcmLog); 14 | } 15 | 16 | export function getRecentFcmLog(): Promise{ 17 | return FcmLogRepository.findRecentFcmLog(); 18 | } 19 | 20 | export function removeBeforeTimestamp(timestamp: number): Promise { 21 | return FcmLogRepository.deleteBeforeDate(new Date(timestamp)); 22 | } 23 | -------------------------------------------------------------------------------- /src/core/fcm/FcmService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Google FIrebase를 이용한 Notification을 돕는 모듈 3 | * Firebase 설정 값은 {@link config}에서 불러 옴 4 | * 5 | * @author ryeolj5911@gmail.com 6 | */ 7 | 8 | import request = require('request-promise-native'); 9 | import property = require('@app/core/config/property'); 10 | 11 | import FcmError from './error/FcmError'; 12 | import FcmLogServie = require('@app/core/fcm/FcmLogService'); 13 | 14 | let apiKey = property.get('core.fcm.apiKey'); 15 | let projectId = property.get('core.fcm.projectId'); 16 | 17 | const device_api_header = { 18 | "Content-Type":"application/json", 19 | "Authorization":"key="+apiKey, 20 | "project_id":projectId 21 | }; 22 | 23 | export function createNotiKey(key_name:string, registration_ids:[string]): Promise { 24 | return request({ 25 | method: 'POST', 26 | uri: 'https://android.googleapis.com/gcm/notification', 27 | headers: device_api_header, 28 | body: { 29 | "operation": "create", 30 | "notification_key_name": key_name, 31 | "registration_ids": registration_ids 32 | }, 33 | json: true 34 | }).then(function(body){ 35 | return Promise.resolve(body.notification_key); 36 | }).catch(function(err){ 37 | return Promise.reject(new FcmError(err.response.statusMessage, err.response.body)); 38 | }); 39 | } 40 | 41 | export function getNotiKey(key_name:string): Promise { 42 | return request({ 43 | method: 'GET', 44 | uri: 'https://android.googleapis.com/gcm/notification', 45 | headers: device_api_header, 46 | qs: { 47 | "notification_key_name": key_name 48 | }, 49 | json: true 50 | }).then(function (body) { 51 | return Promise.resolve(body.notification_key); 52 | }).catch(function (err) { 53 | return Promise.reject(new FcmError(err.response.statusMessage, err.response.body)); 54 | }); 55 | } 56 | 57 | export function addDevice(key_name:string, key:string, registration_ids:[string]): Promise { 58 | return request({ 59 | method: 'POST', 60 | uri: 'https://android.googleapis.com/gcm/notification', 61 | headers: device_api_header, 62 | body: { 63 | "operation": "add", 64 | "notification_key_name": key_name, 65 | "notification_key": key, 66 | "registration_ids": registration_ids 67 | }, 68 | json: true 69 | }).then(function(body){ 70 | return Promise.resolve(body.notification_key); 71 | }).catch(function(err){ 72 | return Promise.reject(new FcmError(err.response.statusMessage, err.response.body)); 73 | }); 74 | } 75 | 76 | export function removeDevice(key_name:string, key:string, registration_ids:[string]): Promise { 77 | return request({ 78 | method: 'POST', 79 | uri: 'https://android.googleapis.com/gcm/notification', 80 | headers: device_api_header, 81 | body: { 82 | "operation": "remove", 83 | "notification_key_name": key_name, 84 | "notification_key": key, 85 | "registration_ids": registration_ids 86 | }, 87 | json: true 88 | }).then(function(body){ 89 | return Promise.resolve(body.notification_key); 90 | }).catch(function(err){ 91 | return Promise.reject(new FcmError(err.response.statusMessage, err.response.body)); 92 | }); 93 | } 94 | 95 | export function addTopic(registration_id:string): Promise { 96 | return request({ 97 | method: 'POST', 98 | uri: 'https://iid.googleapis.com/iid/v1/'+registration_id+'/rel/topics/global', 99 | headers: { 100 | "Content-Type":"application/json", 101 | "Authorization":"key="+apiKey 102 | // no need for project_id 103 | } 104 | }).catch(function(err){ 105 | return Promise.reject(new FcmError(err.response.statusMessage, err.response.body)); 106 | }); 107 | } 108 | 109 | export function removeTopicBatch(registration_tokens:[string]): Promise { 110 | return request({ 111 | method: 'POST', 112 | uri: 'https://iid.googleapis.com/iid/v1:batchRemove', 113 | headers: { 114 | "Content-Type":"application/json", 115 | "Authorization":"key="+apiKey 116 | // no need for project_id 117 | }, 118 | body: { 119 | "to": "/topics/global", 120 | "registration_tokens": registration_tokens 121 | }, 122 | json: true 123 | }).catch(function(err){ 124 | return Promise.reject(new FcmError(err.response.statusMessage, err.response.body)); 125 | }); 126 | } 127 | 128 | export async function sendMsg(to:string, title:string, body:string, author: string, cause: string): Promise { 129 | let promise = request({ 130 | method: 'POST', 131 | uri: 'https://fcm.googleapis.com/fcm/send', 132 | headers: { 133 | "Content-Type":"application/json", 134 | "Authorization":"key="+apiKey 135 | }, 136 | body: { 137 | "to": to, 138 | "notification" : { 139 | "body" : body, 140 | "title" : title, 141 | "sound": "default" 142 | }, 143 | "priority" : "high", 144 | "content_available" : true 145 | }, 146 | json:true, 147 | }).catch(function(err){ 148 | return Promise.reject(new FcmError(err.response.statusMessage, err.response.body)); 149 | }); 150 | 151 | let response = await promise; 152 | await FcmLogServie.addFcmLog(to, author, title + '\n' + body, cause, response); 153 | return response; 154 | } 155 | 156 | export function sendGlobalMsg(title: string, body:string, author: string, cause: string): Promise { 157 | return sendMsg("/topics/global", title, body, author, cause); 158 | } 159 | -------------------------------------------------------------------------------- /src/core/fcm/error/FcmError.ts: -------------------------------------------------------------------------------- 1 | export default class FcmError extends Error { 2 | constructor(public statusMessage: string, public detail: any) { 3 | super("Fcm error occured: '" + statusMessage + "'" + " with message: '" + JSON.stringify(detail) + "'"); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/fcm/model/FcmLog.ts: -------------------------------------------------------------------------------- 1 | export default interface FcmLog { 2 | date: Date, 3 | author: string, 4 | to: string, 5 | message: string, 6 | cause: string, 7 | response: string 8 | } 9 | -------------------------------------------------------------------------------- /src/core/feedback/FeedbackService.ts: -------------------------------------------------------------------------------- 1 | import Feedback from './model/Feedback'; 2 | import GithubIssue from '@app/core/github/model/GithubIssue'; 3 | import GithubService = require('@app/core/github/GithubService'); 4 | import property = require('@app/core/config/property'); 5 | 6 | let repoOwner = property.get('core.feedback2github.repo.owner'); 7 | let repoName = property.get('core.feedback2github.repo.name'); 8 | 9 | export async function add(email: string, message: string, platform: string, version: string): Promise { 10 | let feedback: Feedback = { 11 | email: email, 12 | message: message, 13 | version: version, 14 | platform: platform, 15 | timestamp: Date.now() 16 | } 17 | let issue: GithubIssue = feedbackToGithubIssue(feedback); 18 | await GithubService.addIssue(repoOwner, repoName, issue); 19 | } 20 | 21 | function feedbackToGithubIssue(feedback: Feedback): GithubIssue { 22 | let title; 23 | if (!feedback.message) { 24 | title = "(Empty Message)"; 25 | } else { 26 | title = feedback.message; 27 | } 28 | 29 | let body = "Issue created automatically by feedback2github\n"; 30 | if (feedback.timestamp) { 31 | body += "Timestamp: " + new Date(feedback.timestamp).toISOString() + " (UTC)\n"; 32 | } 33 | if (feedback.email) { 34 | body += "Email: " + feedback.email + "\n"; 35 | } 36 | if (feedback.platform) { 37 | body += "Platform: " + feedback.platform + "\n"; 38 | } 39 | if (feedback.version) { 40 | body += "Version: " + feedback.version + "\n"; 41 | } 42 | body += "\n"; 43 | body += feedback.message; 44 | 45 | let labels; 46 | if (feedback.platform) { 47 | labels = [feedback.platform]; 48 | } 49 | 50 | let issue: GithubIssue = { 51 | title: title, 52 | body: body, 53 | labels: labels 54 | } 55 | 56 | return issue; 57 | } 58 | -------------------------------------------------------------------------------- /src/core/feedback/model/Feedback.ts: -------------------------------------------------------------------------------- 1 | export default interface Feedback { 2 | _id?: string, 3 | email: string, 4 | message: string, 5 | version: string, 6 | timestamp: number, 7 | platform: string 8 | } 9 | -------------------------------------------------------------------------------- /src/core/github/GithubService.ts: -------------------------------------------------------------------------------- 1 | import request = require('request-promise-native'); 2 | import property = require('@app/core/config/property'); 3 | import GithubIssue from './model/GithubIssue'; 4 | 5 | let githubToken = property.get('core.feedback2github.token'); 6 | let apiHeader = { 7 | Accept: "application/vnd.github.v3+json", 8 | Authorization: "token " + githubToken, 9 | "User-Agent": "feedback2github" 10 | }; 11 | 12 | export async function getUserName(): Promise { 13 | let result = await request({ 14 | method: 'GET', 15 | uri: "https://api.github.com/user", 16 | headers: apiHeader, 17 | json: true 18 | }) 19 | 20 | return result.login; 21 | } 22 | 23 | export async function addIssue(repoOwner: string, repoName: string, issue: GithubIssue): Promise { 24 | let apiIssuesUrl = "https://api.github.com/repos/" + repoOwner + "/" + repoName + "/issues"; 25 | let response = await request({ 26 | method: 'POST', 27 | uri: apiIssuesUrl, 28 | headers: apiHeader, 29 | body: issue, 30 | json: true 31 | }); 32 | 33 | return response; 34 | } 35 | -------------------------------------------------------------------------------- /src/core/github/model/GithubIssue.ts: -------------------------------------------------------------------------------- 1 | export default interface GithubIssue { 2 | title: string, 3 | body: string, 4 | labels: string[] 5 | } 6 | -------------------------------------------------------------------------------- /src/core/lecture/LectureService.ts: -------------------------------------------------------------------------------- 1 | import Lecture from './model/Lecture'; 2 | import TimePlaceUtil = require('@app/core/timetable/util/TimePlaceUtil'); 3 | import InvalidLectureTimemaskError from './error/InvalidLectureTimemaskError'; 4 | 5 | export function setTimemask(lecture: Lecture): void { 6 | if (lecture.class_time_json) { 7 | if (!lecture.class_time_mask) { 8 | lecture.class_time_mask = TimePlaceUtil.timeJsonToMask(lecture.class_time_json, true); 9 | } else { 10 | var timemask = TimePlaceUtil.timeJsonToMask(lecture.class_time_json); 11 | for (var i=0; i { 7 | let queryHash = makeMd5HashFromObject(query); 8 | let keyList = pageList.map(page => RedisKeyUtil.getLectureQueryKey(queryHash, page)) 9 | let lectureListStringList = await RedisUtil.mget(keyList); 10 | let lectureListList: RefLecture[][] = lectureListStringList.map(parseLectureListString); 11 | return lectureListList; 12 | } 13 | 14 | function parseLectureListString(str: string): RefLecture[] | null { 15 | let lectureList: RefLecture[] = JSON.parse(str); 16 | if (!lectureList || typeof lectureList.length !== 'number') { 17 | return null; 18 | } else { 19 | return lectureList; 20 | } 21 | } 22 | 23 | export async function setLectureListCache(query: any, page: number, lectureList: RefLecture[]): Promise { 24 | let queryHash = makeMd5HashFromObject(query); 25 | let key = RedisKeyUtil.getLectureQueryKey(queryHash, page); 26 | let lectureListString = JSON.stringify(lectureList); 27 | await RedisUtil.setex(key, 60 * 60 * 24, lectureListString); 28 | } 29 | 30 | function makeMd5HashFromObject(object: any) { 31 | return crypto.createHash('md5').update(JSON.stringify(object)).digest('hex'); 32 | } 33 | -------------------------------------------------------------------------------- /src/core/lecture/RefLectureQueryEtcTagService.ts: -------------------------------------------------------------------------------- 1 | import EtcTagEnum from "@app/core/taglist/model/EtcTagEnum"; 2 | import winston = require('winston'); 3 | let logger = winston.loggers.get('default'); 4 | 5 | export function getMQueryFromEtcTagList(etcTags: string[]): any { 6 | let andQueryList = etcTags.map(getMQueryFromEtcTag).filter(x => x !== null); 7 | return { $and: andQueryList }; 8 | } 9 | 10 | export function getMQueryFromEtcTag(etcTag: string): object | null { 11 | switch (etcTag) { 12 | case EtcTagEnum.ENGLISH_LECTURE: 13 | return {remark: {$regex: ".*ⓔ.*", $options: 'i'}}; 14 | case EtcTagEnum.MILITARY_REMOTE_LECTURE: 15 | return {remark: {$regex: ".*ⓜⓞ.*", $options: 'i'}}; 16 | default: 17 | logger.error("Unknown etc tag :", etcTag); 18 | return null; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/core/lecture/RefLectureQueryLogRepository.ts: -------------------------------------------------------------------------------- 1 | import mongoose = require('mongoose'); 2 | import ObjectUtil = require('@app/core/common/util/ObjectUtil'); 3 | 4 | export async function insert(obj: any) { 5 | let cloned = ObjectUtil.deepCopy(obj); 6 | cloned.timestamp = Date.now(); 7 | await mongoose.connection.collection("query_logs").insert(cloned); 8 | } 9 | 10 | export async function deleteBeforeTimestamp(timestamp: number) { 11 | let query = { timestamp: { $lt: timestamp }}; 12 | await mongoose.connection.collection("query_logs").deleteMany(query); 13 | } 14 | -------------------------------------------------------------------------------- /src/core/lecture/RefLectureService.ts: -------------------------------------------------------------------------------- 1 | import RefLectureRepository = require('./RefLectureRepository'); 2 | import RefLecture from './model/RefLecture'; 3 | import ObjectUtil = require('@app/core/common/util/ObjectUtil'); 4 | import LectureService = require('@app/core/lecture/LectureService'); 5 | 6 | export function query(query: any, limit: number, offset: number): Promise { 7 | return RefLectureRepository.query(query, limit, offset); 8 | } 9 | 10 | export function queryWithCourseTitle(query: any, courseTitle: string, limit: number, offset: number): Promise { 11 | return RefLectureRepository.queryWithCourseTitle(query, courseTitle, limit, offset); 12 | } 13 | 14 | export function getByMongooseId(mongooseId: string): Promise { 15 | return RefLectureRepository.findByMongooseId(mongooseId); 16 | } 17 | 18 | export function getByCourseNumber(year: number, semester: number, courseNumber: string, lectureNumber: string): Promise { 19 | return RefLectureRepository.findByCourseNumber(year, semester, courseNumber, lectureNumber); 20 | } 21 | 22 | export function getBySemester(year: number, semester: number): Promise { 23 | return RefLectureRepository.findBySemester(year, semester); 24 | } 25 | 26 | export function addAll(lectures: RefLecture[]): Promise { 27 | return RefLectureRepository.insertAll(lectures); 28 | } 29 | 30 | export function removeBySemester(year: number, semester: number): Promise { 31 | return RefLectureRepository.deleteBySemester(year, semester); 32 | } 33 | 34 | export function remove(lectureId: string): Promise { 35 | return RefLectureRepository.deleteByLectureId(lectureId); 36 | } 37 | 38 | export function partialModifiy(lectureId: string, lecture: any): Promise { 39 | let lectureCopy = ObjectUtil.deepCopy(lecture); 40 | ObjectUtil.deleteObjectId(lectureCopy); 41 | return RefLectureRepository.partialUpdateRefLecture(lectureId, lectureCopy); 42 | } 43 | -------------------------------------------------------------------------------- /src/core/lecture/error/InvalidLectureTimeJsonError.ts: -------------------------------------------------------------------------------- 1 | export default class InvalidLectureTimeJsonError extends Error { 2 | constructor() { 3 | super("Invalid Lecture Time Json"); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/lecture/error/InvalidLectureTimemaskError.ts: -------------------------------------------------------------------------------- 1 | export default class InvalidLectureTimemaskError extends Error { 2 | constructor() { 3 | super("Invalid Lecture Timemask"); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/lecture/error/RefLectureNotFoundError.ts: -------------------------------------------------------------------------------- 1 | export default class RefLectrureNotFoundError extends Error { 2 | constructor() { 3 | super("RefLecture not found"); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/lecture/model/Lecture.ts: -------------------------------------------------------------------------------- 1 | import TimePlace from '@app/core/timetable/model/TimePlace'; 2 | 3 | export default interface Lecture { 4 | _id?: string, 5 | classification: string, // 교과 구분 6 | department: string, // 학부 7 | academic_year: string, // 학년 8 | course_title: string, // 과목명 9 | credit: number, // 학점 10 | class_time: string, 11 | real_class_time: string, 12 | class_time_json: TimePlace[], 13 | class_time_mask: number[], 14 | instructor: string, // 강사 15 | quota: number, // 정원 16 | freshmanQuota?: number, // 신입생정원 17 | remark: string, // 비고 18 | category: string, 19 | } 20 | -------------------------------------------------------------------------------- /src/core/lecture/model/RefLecture.ts: -------------------------------------------------------------------------------- 1 | import Lecture from "./Lecture"; 2 | 3 | export default interface RefLecture extends Lecture { 4 | year: number, // 연도 5 | semester: number, // 학기 6 | course_number: string, // 교과목 번호 7 | lecture_number: string, // 강좌 번호 8 | } 9 | -------------------------------------------------------------------------------- /src/core/lecture/model/SnuttevLectureKey.ts: -------------------------------------------------------------------------------- 1 | export default interface SnuttevLectureKey { 2 | year: number, 3 | semester: number, 4 | instructor: string, 5 | course_number: string, 6 | }; 7 | -------------------------------------------------------------------------------- /src/core/mail/MailUtil.ts: -------------------------------------------------------------------------------- 1 | import * as nodemailer from 'nodemailer'; 2 | import * as AWS from "aws-sdk"; 3 | import {error} from "util"; 4 | 5 | AWS.config.update({ 6 | region: 'ap-northeast-2' 7 | }); 8 | 9 | const transporter = nodemailer.createTransport({ 10 | SES: new AWS.SES({ 11 | apiVersion: '2010-12-01' 12 | }) 13 | }) 14 | 15 | export function sendMail(to: String, subject: String, body: String) { 16 | transporter.sendMail({ 17 | from: 'snutt@wafflestudio.com', 18 | to: to, 19 | subject: subject, 20 | html: body 21 | }, (err, info) => { 22 | if (err) { 23 | error(err); 24 | } 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/core/notification/NotificationRepository.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Notification Model 3 | * Jang Ryeol, ryeolj5911@gmail.com 4 | */ 5 | import mongoose = require('mongoose'); 6 | import User from '@app/core/user/model/User'; 7 | import NotificationTypeEnum from './model/NotificationTypeEnum'; 8 | import Notification from './model/Notification'; 9 | 10 | var NotificationSchema = new mongoose.Schema({ 11 | user_id : { type: mongoose.Schema.Types.ObjectId, ref: 'User', default : null}, 12 | message : { type : String, required : true }, 13 | created_at : { type : Date, required : true}, 14 | type : { type: Number, required : true, default : NotificationTypeEnum.NORMAL }, 15 | detail : { type: mongoose.Schema.Types.Mixed, default : null } 16 | }); 17 | 18 | NotificationSchema.index({user_id: 1}); 19 | NotificationSchema.index({created_at: -1}); 20 | 21 | let mongooseModel = mongoose.model('Notification', NotificationSchema, 'notifications'); 22 | 23 | export async function findNewestByUser(user: User, offset: number, limit: number): Promise { 24 | let query = { 25 | user_id: { $in: [ null, user._id ] }, 26 | created_at: {$gt: user.regDate} 27 | }; 28 | let mongooseDocs = await mongooseModel.find(query) 29 | .sort('-created_at') 30 | .skip(offset) 31 | .limit(limit) 32 | .exec(); 33 | return mongooseDocs.map(fromMongoose); 34 | } 35 | 36 | export function countUnreadByUser(user: User): Promise { 37 | return mongooseModel.where('user_id').in([null, user._id]) 38 | .count({created_at : {$gt : user.notificationCheckedAt}}) 39 | .exec(); 40 | } 41 | 42 | export async function insert(notification: Notification): Promise { 43 | await new mongooseModel(notification).save(); 44 | } 45 | 46 | function fromMongoose(mongooseDoc): Notification { 47 | return { 48 | user_id: mongooseDoc.user_id, 49 | message: mongooseDoc.message, 50 | created_at: mongooseDoc.created_at, 51 | type: mongooseDoc.type, 52 | detail: mongooseDoc.detail 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/core/notification/NotificationService.ts: -------------------------------------------------------------------------------- 1 | import FcmService = require('@app/core/fcm/FcmService'); 2 | import NotificationRepository = require('./NotificationRepository'); 3 | import NoFcmKeyError from './error/NoFcmKeyError'; 4 | 5 | import User from '@app/core/user/model/User'; 6 | import Notification from './model/Notification'; 7 | import NotificationTypeEnum from './model/NotificationTypeEnum'; 8 | import InvalidNotificationDetailError from './error/InvalidNotificationDetailError'; 9 | 10 | export async function sendFcmMsg(user: User, title: string, body: string, author: string, cause: string) { 11 | if (!user.fcmKey) throw new NoFcmKeyError(); 12 | let destination = user.fcmKey; 13 | let response = await FcmService.sendMsg(destination, title, body, author, cause); 14 | return response; 15 | } 16 | 17 | export async function sendGlobalFcmMsg(title: string, body: string, author: string, cause: string) { 18 | let response = await FcmService.sendGlobalMsg(title, body, author, cause); 19 | return response; 20 | } 21 | 22 | export function add(notification: Notification): Promise { 23 | validateNotificationDetail(notification); 24 | return NotificationRepository.insert(notification); 25 | } 26 | 27 | function validateNotificationDetail(notification: Notification) { 28 | if (notification.type === NotificationTypeEnum.LINK_ADDR && typeof notification.detail !== 'string') { 29 | throw new InvalidNotificationDetailError(notification.type, notification.detail); 30 | } 31 | } 32 | 33 | export function getNewestByUser(user: User, offset: number, limit: number): Promise { 34 | return NotificationRepository.findNewestByUser(user, offset, limit); 35 | } 36 | 37 | export function countUnreadByUser(user: User): Promise { 38 | return NotificationRepository.countUnreadByUser(user); 39 | } 40 | -------------------------------------------------------------------------------- /src/core/notification/error/InvalidNotificationDetailError.ts: -------------------------------------------------------------------------------- 1 | import NotificationTypeEnum from "../model/NotificationTypeEnum"; 2 | 3 | export default class InvalidNotificationDetailError extends Error { 4 | constructor(public notiType: NotificationTypeEnum, public notiDetail: any) { 5 | super("Invalid notification detail for type: type = " + 6 | NotificationTypeEnum + ", detail = " + notiDetail); 7 | } 8 | } -------------------------------------------------------------------------------- /src/core/notification/error/NoFcmKeyError.ts: -------------------------------------------------------------------------------- 1 | export default class NoFcmKeyError extends Error { 2 | constructor() { 3 | super("Failed to send notification fcm due to no fcm key"); 4 | } 5 | } -------------------------------------------------------------------------------- /src/core/notification/model/Notification.ts: -------------------------------------------------------------------------------- 1 | import NotificationTypeEnum from "./NotificationTypeEnum"; 2 | 3 | export default interface Notification { 4 | user_id: string; 5 | message: string; 6 | created_at: Date; 7 | type: NotificationTypeEnum; 8 | detail?: any; 9 | }; 10 | -------------------------------------------------------------------------------- /src/core/notification/model/NotificationTypeEnum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Types 3 | * - Type.NORMAL : Normal Messages. Detail would be null 4 | * - Type.COURSEBOOK : Course Book Changes. Detail contains lecture difference 5 | * - Type.LECTURE : Lecture Changes. Course book changes are for all users. 6 | * Lecture changes contains per-user update log. 7 | * - Type.LINK_ADDR : 사용자가 클릭하면 브라우저로 연결되도록 하는 알림 8 | */ 9 | enum NotificationTypeEnum { 10 | NORMAL = 0, 11 | COURSEBOOK = 1, 12 | LECTURE_UPDATE = 2, 13 | LECTURE_REMOVE = 3, 14 | LINK_ADDR = 4 15 | }; 16 | 17 | export default NotificationTypeEnum; 18 | -------------------------------------------------------------------------------- /src/core/popup/PopupService.ts: -------------------------------------------------------------------------------- 1 | import User from '@app/core/user/model/User'; 2 | 3 | export async function getPopups(user: User, osType?: string, osVersion?: string, appType?: string, appVersion?: string) { 4 | return { 5 | content: [], 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /src/core/redis/RedisKeyUtil.ts: -------------------------------------------------------------------------------- 1 | export function getLectureQueryKey(queryHash: string, page: number) { 2 | return "lq-" + queryHash + "-" + page; 3 | } 4 | -------------------------------------------------------------------------------- /src/core/redis/RedisUtil.ts: -------------------------------------------------------------------------------- 1 | import redis = require('redis'); 2 | import winston = require('winston'); 3 | import RedisClientError from './error/RedisClientError'; 4 | 5 | let redisClient: redis.RedisClient = global['redisClient']; 6 | 7 | function checkRedisClient() { 8 | if (!redisClient) { 9 | if (global['redisClient']) { 10 | redisClient = global['redisClient']; 11 | } else { 12 | throw new RedisClientError("Redis not connected"); 13 | } 14 | } 15 | } 16 | 17 | export async function pollRedisClient(): Promise { 18 | if (redisClient) return redisClient; 19 | 20 | function checkRedisClientTimeoutWithoutReject(timeout: number) { 21 | return new Promise(function(resolve, reject) { 22 | setTimeout(function() { 23 | try { 24 | checkRedisClient(); 25 | resolve(redisClient); 26 | } catch (err) { 27 | } 28 | }, timeout); 29 | }) 30 | } 31 | 32 | function checkRedisClientTimeout(timeout: number) { 33 | return new Promise(function(resolve, reject) { 34 | setTimeout(function() { 35 | try { 36 | checkRedisClient(); 37 | resolve(redisClient); 38 | } catch (err) { 39 | reject(err); 40 | } 41 | }, timeout); 42 | }) 43 | } 44 | 45 | return await Promise.race([ 46 | checkRedisClientTimeoutWithoutReject(100), 47 | checkRedisClientTimeoutWithoutReject(200), 48 | checkRedisClientTimeoutWithoutReject(500), 49 | checkRedisClientTimeout(1000)]); 50 | } 51 | 52 | export function configGet(key: string): Promise { 53 | checkRedisClient(); 54 | return new Promise(function (resolve, reject) { 55 | redisClient.sendCommand("config", ["get", key], function(err, values: string[]) { 56 | if (err) { 57 | reject(err); 58 | } else { 59 | resolve(values[1]); 60 | } 61 | }) 62 | }); 63 | } 64 | 65 | export function flushdb(): Promise { 66 | checkRedisClient(); 67 | return new Promise(function (resolve, reject) { 68 | redisClient.flushdb(function(err, value: string) { 69 | if (err) { 70 | reject(err); 71 | } else { 72 | resolve(value); 73 | } 74 | }) 75 | }); 76 | } 77 | 78 | export function get(key: string): Promise { 79 | checkRedisClient(); 80 | return new Promise(function (resolve, reject) { 81 | redisClient.get(key, function(err, value: string) { 82 | if (err) { 83 | reject(err); 84 | } else { 85 | resolve(value); 86 | } 87 | }) 88 | }) 89 | } 90 | 91 | export function setex(key: string, seconds: number, value: string): Promise { 92 | checkRedisClient(); 93 | return new Promise(function (resolve, reject) { 94 | redisClient.setex(key, seconds, value, function(err) { 95 | if (err) { 96 | reject(err); 97 | } else { 98 | resolve(); 99 | } 100 | }) 101 | }) 102 | } 103 | 104 | export function mget(keyList: string[]): Promise { 105 | checkRedisClient(); 106 | return new Promise(function (resolve, reject) { 107 | redisClient.mget(...keyList, function(err, value: string[]) { 108 | if (err) { 109 | reject(err); 110 | } else { 111 | resolve(value); 112 | } 113 | }) 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /src/core/redis/error/RedisClientError.ts: -------------------------------------------------------------------------------- 1 | export default class RedisClientError extends Error { 2 | constructor(public msg: string) { 3 | super(msg); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/taglist/TagListEtcTagService.ts: -------------------------------------------------------------------------------- 1 | import EtcTagEnum from "./model/EtcTagEnum"; 2 | 3 | export function getEtcTagList(): string[] { 4 | return Object.keys(EtcTagEnum).filter(key => !isNaN(Number(EtcTagEnum[key]))); 5 | } 6 | -------------------------------------------------------------------------------- /src/core/taglist/TagListRepository.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by north on 16. 2. 24. 3 | */ 4 | import mongoose = require('mongoose'); 5 | import TagListNotFoundError from './error/TagListNotFoundError'; 6 | import TagList from './model/TagList'; 7 | 8 | var TagListSchema = new mongoose.Schema({ 9 | year: {type: Number, required: true}, 10 | semester: {type: Number, required: true}, 11 | updated_at: mongoose.Schema.Types.Mixed, // Issue #92. After all data converted to nubmer, modify this to Number 12 | tags: { 13 | classification: {type: [String]}, 14 | department: {type: [String]}, 15 | academic_year: {type: [String]}, 16 | credit: {type: [String]}, 17 | instructor: {type: [String]}, 18 | category: {type: [String]}, 19 | etc: {type: [String]}, 20 | } 21 | }); 22 | 23 | TagListSchema.index({year: 1, semester: 1}); 24 | 25 | let mongooseModel = mongoose.model('TagList', TagListSchema, 'taglists'); 26 | 27 | export async function findBySemester(year: number, semester: number): Promise { 28 | let mongooseDocument = await mongooseModel.findOne({'year' : year, 'semester' : semester}).exec(); 29 | return fromMongooseDocument(mongooseDocument); 30 | } 31 | 32 | export async function findUpdateTimeBySemester(year: number, semester: number): Promise { 33 | let mongooseDocument = await mongooseModel.findOne({'year' : year, 'semester' : semester},'updated_at').exec(); 34 | if (!mongooseDocument) throw new TagListNotFoundError(); 35 | return getUpdateTimeFromMongooseDocument(mongooseDocument); 36 | } 37 | 38 | export async function upsert(tagList: TagList): Promise { 39 | await mongooseModel.findOneAndUpdate( 40 | {'year': tagList.year, 'semester': tagList.semester}, 41 | {'tags': tagList.tags, 'updated_at': Date.now()}, 42 | {upsert: true}) 43 | .exec(); 44 | } 45 | 46 | function fromMongooseDocument(doc: mongoose.Document): TagList { 47 | if (doc === null) return null; 48 | return { 49 | year: doc['year'], 50 | semester: doc['semester'], 51 | updated_at: getUpdateTimeFromMongooseDocument(doc), 52 | tags: doc['tags'] 53 | }; 54 | } 55 | 56 | // Issue #92. After all data converted to nubmer, remove this function 57 | function getUpdateTimeFromMongooseDocument(doc: mongoose.Document): number { 58 | let updateTime = doc['updated_at']; 59 | if (updateTime instanceof Date) { 60 | return updateTime.getTime(); 61 | } else if (typeof updateTime === 'number') { 62 | return updateTime; 63 | } else { 64 | throw new Error("Tag update time is neither Date or number"); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/core/taglist/TagListService.ts: -------------------------------------------------------------------------------- 1 | import TagListRepository = require('./TagListRepository'); 2 | import TagList from './model/TagList'; 3 | 4 | export function getBySemester(year: number, semester: number): Promise { 5 | return TagListRepository.findBySemester(year, semester); 6 | } 7 | 8 | export function getUpdateTimeBySemester(year: number, semester: number): Promise { 9 | return TagListRepository.findUpdateTimeBySemester(year, semester); 10 | } 11 | 12 | export function upsert(tagList: TagList): Promise { 13 | return TagListRepository.upsert(tagList); 14 | } 15 | -------------------------------------------------------------------------------- /src/core/taglist/error/TagListNotFoundError.ts: -------------------------------------------------------------------------------- 1 | export default class TagListNotFoundError extends Error { 2 | constructor() { 3 | super("Tag not found"); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/taglist/model/EtcTagEnum.ts: -------------------------------------------------------------------------------- 1 | enum EtcTagEnum { 2 | ENGLISH_LECTURE = "E", 3 | MILITARY_REMOTE_LECTURE = "MO" 4 | } 5 | 6 | export default EtcTagEnum; 7 | -------------------------------------------------------------------------------- /src/core/taglist/model/TagList.ts: -------------------------------------------------------------------------------- 1 | export default interface TagList { 2 | year: number; 3 | semester: number; 4 | updated_at?: number; 5 | tags: { 6 | classification: string[], 7 | department: string[], 8 | academic_year: string[], 9 | credit: string[], 10 | instructor: string[], 11 | category: string[], 12 | etc: string[] 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/core/timetable/LectureColorService.ts: -------------------------------------------------------------------------------- 1 | import UserLecture from "./model/UserLecture"; 2 | import InvalidLectureColorIndexError from "./error/InvalidLectureColorIndexError"; 3 | import InvalidLectureColorError from "./error/InvalidLectureColorError"; 4 | 5 | /** 6 | * 클라이언트는 개별로 색을 가지고 있지 않고, 7 | * 서버로부터 색 목록을 얻어 옴 8 | */ 9 | 10 | export const MAX_NUM_COLOR = 9; 11 | export const CUSTOM_COLOR = 0; 12 | 13 | /** 14 | * 하위 버전 호환을 위한 파스텔 색상 15 | */ 16 | const legacypastel9 = [ 17 | { fg: "#2B8728", bg: "#B6F9B2"}, 18 | { fg: "#45B2B8", bg: "#BFF7F8"}, 19 | { fg: "#1579C2", bg: "#94E6FE"}, 20 | { fg: "#A337A1", bg: "#F6B5F5"}, 21 | { fg: "#B8991B", bg: "#FFF49A"}, 22 | { fg: "#BA313B", bg: "#FFB2BC"}, 23 | { fg: "#649624", bg: "#DAF9B2"}, 24 | { fg: "#5249D7", bg: "#DBD9FD"}, 25 | { fg: "#E27B35", bg: "#FFDAB7"} 26 | ]; 27 | 28 | /** 29 | * 하위 버전 호환을 위한 파스텔 색상 30 | */ 31 | const legacyname9 = [ 32 | "초록색", 33 | "하늘색", 34 | "파랑색", 35 | "보라색", 36 | "노랑색", 37 | "빨강색", 38 | "라임색", 39 | "남색", 40 | "오렌지색"]; 41 | 42 | const vivid9_ios = [ 43 | { fg: "#ffffff", bg: "#e54459"}, 44 | { fg: "#ffffff", bg: "#f58d3d"}, 45 | { fg: "#ffffff", bg: "#fac52d"}, 46 | { fg: "#ffffff", bg: "#a6d930"}, 47 | { fg: "#ffffff", bg: "#2bc366"}, 48 | { fg: "#ffffff", bg: "#1bd0c9"}, 49 | { fg: "#ffffff", bg: "#1d99e9"}, 50 | { fg: "#ffffff", bg: "#4f48c4"}, 51 | { fg: "#ffffff", bg: "#af56b3"} 52 | ]; 53 | 54 | const vividname9 = [ 55 | "석류", 56 | "감귤", 57 | "들국", 58 | "완두", 59 | "비취", 60 | "지중해", 61 | "하늘", 62 | "라벤더", 63 | "자수정" 64 | ] 65 | 66 | // deprecated 67 | export function get_random_color_legacy(): { fg:string, bg:string } { 68 | return legacypastel9[Math.floor(Math.random() * legacypastel9.length)] 69 | }; 70 | 71 | // deprecated 72 | export function getLegacyColors() { 73 | return legacypastel9; 74 | } 75 | 76 | //deprecated 77 | export function getLegacyNames() { 78 | return legacyname9; 79 | } 80 | 81 | export function getColorList(name:string) { 82 | if (name == 'legacy' || name == 'pastel') return { colors: legacypastel9, names: legacyname9 }; 83 | if (name == 'vivid_ios') return { colors:vivid9_ios, names: vividname9 }; 84 | return null; 85 | } 86 | 87 | var re = /#[0-9A-Fa-f]{6}/g; 88 | function isColor(colorString:string):boolean { 89 | var result = re.test(colorString); 90 | re.lastIndex = 0; 91 | return result; 92 | } 93 | 94 | export function validateLectureColor(lecture: UserLecture): void { 95 | if (lecture.colorIndex > MAX_NUM_COLOR) { 96 | throw new InvalidLectureColorIndexError(lecture.colorIndex); 97 | } 98 | if (lecture.color) { 99 | if (lecture.color.fg && !isColor(lecture.color.fg)) throw new InvalidLectureColorError(lecture.color); 100 | if (lecture.color.bg && !isColor(lecture.color.bg)) throw new InvalidLectureColorError(lecture.color); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/core/timetable/TimetableService.ts: -------------------------------------------------------------------------------- 1 | import Timetable from "./model/Timetable"; 2 | import DuplicateTimetableTitleError from "./error/DuplicateTimetableTitleError"; 3 | import ObjectUtil = require('@app/core/common/util/ObjectUtil'); 4 | import TimetableRepository = require('./TimetableRepository'); 5 | import * as CourseBookRepository from "@app/core/coursebook/CourseBookRepository"; 6 | 7 | import AbstractTimetable from './model/AbstractTimetable'; 8 | import TimetableNotEnoughParamError from './error/TimetableNotEnoughParamError'; 9 | import ThemeTypeEnum from "@app/core/timetable/model/ThemeTypeEnum"; 10 | import SnuttevLectureKey from "@app/core/lecture/model/SnuttevLectureKey"; 11 | import {existsByUseridAndSemester} from "@app/core/timetable/TimetableRepository"; 12 | 13 | //deprecated 14 | export async function copy(timetable: Timetable): Promise { 15 | for (let trial = 1; true; trial++) { 16 | let newTitle = timetable.title + " (" + trial + ")"; 17 | try { 18 | return await copyWithTitle(timetable, newTitle); 19 | } catch (err) { 20 | if (err instanceof DuplicateTimetableTitleError) { 21 | continue; 22 | } 23 | throw err; 24 | } 25 | } 26 | } 27 | 28 | //deprecated 29 | export async function copyWithTitle(src: Timetable, newTitle: string): Promise { 30 | if (newTitle === src.title) { 31 | throw new DuplicateTimetableTitleError(src.user_id, src.year, src.semester, newTitle); 32 | } 33 | 34 | let copied = ObjectUtil.deepCopy(src); 35 | ObjectUtil.deleteObjectId(copied); 36 | copied.title = newTitle; 37 | copied.updated_at = Date.now(); 38 | 39 | await validateTimetable(copied); 40 | 41 | await TimetableRepository.insert(copied); 42 | } 43 | 44 | export function remove(userId, tableId): Promise { 45 | return TimetableRepository.deleteByUserIdAndMongooseId(userId, tableId); 46 | } 47 | 48 | export async function getByTitle(userId: string, year: number, semester: number, title: string): Promise { 49 | return TimetableRepository.findByUserIdAndSemesterAndTitle(userId, year, semester, title); 50 | } 51 | 52 | export async function getByMongooseId(userId, tableId: string): Promise { 53 | return TimetableRepository.findByUserIdAndMongooseId(userId, tableId); 54 | } 55 | 56 | export async function getHavingLecture(year: number, semester: number, courseNumber: string, lectureNumber: string): Promise { 57 | return TimetableRepository.findHavingLecture(year, semester, courseNumber, lectureNumber); 58 | } 59 | 60 | export async function modifyTitle(tableId, userId, newTitle): Promise { 61 | let target = await TimetableRepository.findByUserIdAndMongooseId(userId, tableId); 62 | if (target.title === newTitle) { 63 | return; 64 | } 65 | 66 | let duplicate = await TimetableRepository.findByUserIdAndSemesterAndTitle(userId, target.year, target.semester, newTitle); 67 | if (duplicate !== null) { 68 | throw new DuplicateTimetableTitleError(userId, target.year, target.semester, newTitle); 69 | } 70 | 71 | await TimetableRepository.updateTitleByUserId(tableId, userId, newTitle); 72 | await TimetableRepository.updateUpdatedAt(tableId, Date.now()); 73 | } 74 | 75 | export async function modifyTheme(tableId, userId, newTheme): Promise { 76 | let target = await TimetableRepository.findByUserIdAndMongooseId(userId, tableId); 77 | if (target.theme === newTheme) { 78 | return; 79 | } 80 | 81 | await TimetableRepository.updateThemeByUserId(tableId, userId, newTheme); 82 | await TimetableRepository.updateUpdatedAt(tableId, Date.now()); 83 | } 84 | 85 | export async function addCopyFromSourceId(user, sourceId): Promise { 86 | const source:Timetable = await TimetableRepository.findByUserIdAndMongooseId(user._id, sourceId) 87 | let newTitle: string = `${source.title} copy` 88 | for(let trial = 1; true; trial++) { 89 | try { 90 | const newTimetable: Timetable = { 91 | user_id: user.user_id, 92 | year: source.year, 93 | semester: source.semester, 94 | title: newTitle, 95 | theme : source.theme, 96 | lecture_list: source.lecture_list, 97 | is_primary: source.is_primary, 98 | updated_at: Date.now() 99 | }; 100 | await validateTimetable(newTimetable) 101 | return await TimetableRepository.insert(newTimetable); 102 | } catch (err) { 103 | if (err instanceof DuplicateTimetableTitleError) { 104 | newTitle = source.title + ` copy(${trial})` 105 | continue; 106 | } 107 | throw err; 108 | } 109 | } 110 | } 111 | 112 | export async function addFromParam(params): Promise { 113 | let isFirstTimeTableOfTheSemester = 114 | !await TimetableRepository.existsByUseridAndSemester(params.user_id, params.year, params.semester) 115 | 116 | let newTimetable: Timetable = { 117 | user_id : params.user_id, 118 | year : params.year, 119 | semester : params.semester, 120 | title : params.title, 121 | theme : ThemeTypeEnum.SNUTT, 122 | lecture_list : [], 123 | is_primary: isFirstTimeTableOfTheSemester, 124 | updated_at: Date.now() 125 | }; 126 | 127 | await validateTimetable(newTimetable); 128 | 129 | return await TimetableRepository.insert(newTimetable); 130 | }; 131 | 132 | async function validateTimetable(timetable: Timetable) { 133 | if (!timetable.user_id || !timetable.year || !timetable.semester || !timetable.title) { 134 | throw new TimetableNotEnoughParamError(timetable); 135 | } 136 | 137 | let duplicate = await TimetableRepository.findByUserIdAndSemesterAndTitle(timetable.user_id, timetable.year, timetable.semester, timetable.title); 138 | if (duplicate !== null) { 139 | throw new DuplicateTimetableTitleError(timetable.user_id, timetable.year, timetable.semester, timetable.title); 140 | } 141 | } 142 | 143 | export function getAbstractListByUserId(userId: string): Promise { 144 | return TimetableRepository.findAbstractListByUserId(userId); 145 | } 146 | 147 | export function getRecentByUserId(userId: string): Promise { 148 | return TimetableRepository.findRecentByUserId(userId); 149 | } 150 | 151 | export function getBySemester(year: number, semester: number): Promise { 152 | return TimetableRepository.findBySemester(year, semester); 153 | } 154 | 155 | export function getByUserIdAndSemester(userId: string, year: number, semester: number): Promise { 156 | return TimetableRepository.findByUserIdAndSemester(userId, year, semester); 157 | } 158 | 159 | export async function getLecturesTakenByUserInLastSemesters(userId: string): Promise { 160 | const lastCourseBooks = await CourseBookRepository.findLastTwoSemesters() 161 | return TimetableRepository.findLecturesCourseNumberByUserIdAndSemesterIsIn(userId, lastCourseBooks) 162 | } 163 | -------------------------------------------------------------------------------- /src/core/timetable/error/CusromLectureResetError.ts: -------------------------------------------------------------------------------- 1 | export default class CustomLectureResetError extends Error { 2 | constructor() { 3 | super("Tried to reset a custom lecture"); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/timetable/error/DuplicateLectureError.ts: -------------------------------------------------------------------------------- 1 | export default class DuplicateLectureError extends Error { 2 | constructor() { 3 | super("Duplicate lecture error"); 4 | } 5 | } -------------------------------------------------------------------------------- /src/core/timetable/error/DuplicateTimetableTitleError.ts: -------------------------------------------------------------------------------- 1 | export default class DuplicateTimetableTitleError extends Error { 2 | constructor(public userId: string, public year: number, public semester: number, public title: string) { 3 | super("Duplicate Timetable Title '" + title + "'"); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/timetable/error/InvalidLectureColorError.ts: -------------------------------------------------------------------------------- 1 | import LectureColor from "../model/LectureColor"; 2 | 3 | export default class InvalidLectureColorError extends Error { 4 | constructor(public lectureColor: LectureColor) { 5 | super("Invalid Lecture Color: " + lectureColor); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/core/timetable/error/InvalidLectureColorIndexError.ts: -------------------------------------------------------------------------------- 1 | import LectureColor from "../model/LectureColor"; 2 | 3 | export default class InvalidLectureColorIndexError extends Error { 4 | constructor(public lectureColorIndex: number) { 5 | super("Invalid Lecture Color Index: " + lectureColorIndex); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/core/timetable/error/InvalidLectureUpdateRequestError.ts: -------------------------------------------------------------------------------- 1 | import UserLecture from "../model/UserLecture"; 2 | 3 | export default class InvalidLectureUpdateRequestError extends Error { 4 | constructor(public lecture: UserLecture) { 5 | super("Invalid lecture update request '" + lecture + "'"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/core/timetable/error/LectureTimeOverlapError.ts: -------------------------------------------------------------------------------- 1 | export default class LectureTimeOverlapError extends Error { 2 | confirmMessage: string 3 | 4 | constructor(confirmMessage: string = "") { 5 | super("Lecture time overlapped"); 6 | this.confirmMessage = confirmMessage 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/core/timetable/error/NotCustomLectureError.ts: -------------------------------------------------------------------------------- 1 | import UserLecture from "../model/UserLecture"; 2 | 3 | export default class NotCustomLectureError extends Error { 4 | constructor(public lecture: UserLecture) { 5 | super("Not custom lecture '" + lecture + "'"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/core/timetable/error/TimetableNotEnoughParamError.ts: -------------------------------------------------------------------------------- 1 | export default class TimetableNotEnoughParamError extends Error { 2 | constructor(public timetable: any) { 3 | super("Timetable not enough param: " + timetable); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/timetable/error/TimetableNotFoundError.ts: -------------------------------------------------------------------------------- 1 | export default class TimetableNotFoundError extends Error { 2 | constructor() { 3 | super("Timetable not found"); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/timetable/error/UserLectureNotFoundError.ts: -------------------------------------------------------------------------------- 1 | export default class UserLectureNotFoundError extends Error { 2 | constructor() { 3 | super("User lecture not found"); 4 | } 5 | } -------------------------------------------------------------------------------- /src/core/timetable/error/WrongRefLectureSemesterError.ts: -------------------------------------------------------------------------------- 1 | export default class WrongRefLectureSemesterError extends Error { 2 | constructor(public year: number, public semester: number) { 3 | super("Wrong ref lecture semester " + year + ", " + semester); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/timetable/model/AbstractTimetable.ts: -------------------------------------------------------------------------------- 1 | export default interface AbstractTimetable { 2 | _id: string, 3 | year: number; 4 | semester: number; 5 | title: string; 6 | total_credit: number; 7 | updated_at: Date; 8 | }; 9 | -------------------------------------------------------------------------------- /src/core/timetable/model/LectureColor.ts: -------------------------------------------------------------------------------- 1 | export default interface LectureColor { 2 | fg: string, 3 | bg: string 4 | } 5 | -------------------------------------------------------------------------------- /src/core/timetable/model/ThemeTypeEnum.ts: -------------------------------------------------------------------------------- 1 | enum ThemeTypeEnum { 2 | SNUTT = 0, 3 | FALL = 1, 4 | MODERN = 2, 5 | CHERRY_BLOSSOM = 3, 6 | ICE = 4, 7 | LAWN = 5 8 | }; 9 | 10 | export default ThemeTypeEnum; 11 | -------------------------------------------------------------------------------- /src/core/timetable/model/Time.ts: -------------------------------------------------------------------------------- 1 | export interface Time { 2 | subtract(other: Time): Time; 3 | 4 | addMinute(minute: number); 5 | 6 | addHour(hour: number); 7 | 8 | subtractMinute(minute: number); 9 | 10 | subtractHour(hour: number); 11 | 12 | toHourMinuteFormat(); 13 | 14 | getHour(): number; 15 | 16 | getMinute(): number; 17 | 18 | getDecimalHour(): number; 19 | } 20 | 21 | export class Time implements Time { 22 | totalMinute: number; 23 | 24 | constructor(minute: number) { 25 | this.totalMinute = minute 26 | } 27 | 28 | static fromHourMinuteString(hourMinuteString: string): Time { 29 | const hourMinute = hourMinuteString.split(":") 30 | .map((hourOrMinute) => parseInt(hourOrMinute, 10)); 31 | if (hourMinute.length !== 2) throw new HourMinuteFormatException(); 32 | const [hour, minute] = hourMinute; 33 | const isInvalidHour = hour < 0 || hour > 23; 34 | const isInvalidMinute = minute < 0 || minute > 59; 35 | const containsNaN = Number.isNaN(hour) || Number.isNaN(minute) 36 | if (isInvalidHour || isInvalidMinute || containsNaN) throw new HourMinuteFormatException(); 37 | const totalMinute = hour * 60 + minute; 38 | return new Time(totalMinute); 39 | } 40 | 41 | subtract(other: Time): Time { 42 | return new Time(this.totalMinute - other.totalMinute) 43 | } 44 | 45 | addMinute(minute: number) { 46 | return new Time(this.totalMinute + minute) 47 | } 48 | 49 | addHour(hour: number) { 50 | return new Time(this.totalMinute + hour * 60) 51 | } 52 | 53 | subtractMinute(minute: number) { 54 | return new Time(this.totalMinute - minute) 55 | } 56 | 57 | subtractHour(hour: number) { 58 | return new Time(this.totalMinute - hour * 60) 59 | } 60 | 61 | toHourMinuteFormat() { 62 | return `${String(this.getHour()).padStart(2, '0')}:${String(this.getMinute()).padStart(2, '0')}` 63 | } 64 | 65 | getHour(): number { 66 | return Math.trunc(this.totalMinute / 60) 67 | } 68 | 69 | getMinute(): number { 70 | return Math.trunc(this.totalMinute % 60) 71 | } 72 | 73 | getDecimalHour(): number { 74 | return this.totalMinute / 60 75 | } 76 | 77 | } 78 | 79 | export class HourMinuteFormatException { 80 | } 81 | -------------------------------------------------------------------------------- /src/core/timetable/model/TimePlace.ts: -------------------------------------------------------------------------------- 1 | export default interface TimePlace { 2 | day: number; 3 | start: number; 4 | len: number; 5 | start_time: string; 6 | end_time: string; 7 | place: string; 8 | startMinute: number; 9 | endMinute: number; 10 | }; 11 | -------------------------------------------------------------------------------- /src/core/timetable/model/Timetable.ts: -------------------------------------------------------------------------------- 1 | import UserLecture from './UserLecture'; 2 | 3 | export default interface Timetable { 4 | _id?: string; 5 | user_id: string; 6 | year: number; 7 | semester: number; 8 | title: string; 9 | lecture_list: UserLecture[]; 10 | theme: number; 11 | is_primary: boolean; 12 | updated_at: number; 13 | }; 14 | -------------------------------------------------------------------------------- /src/core/timetable/model/UserLecture.ts: -------------------------------------------------------------------------------- 1 | import Lecture from '@app/core/lecture/model/Lecture'; 2 | import LectureColor from './LectureColor'; 3 | 4 | export default interface UserLecture extends Lecture { 5 | created_at: Date, 6 | updated_at: Date, 7 | color?: LectureColor, 8 | colorIndex: number, 9 | course_number?: string, // 교과목 번호 10 | lecture_number?: string, // 강좌 번호 11 | lecture_id?: string, 12 | } 13 | -------------------------------------------------------------------------------- /src/core/timetable/util/TimePlaceUtil.ts: -------------------------------------------------------------------------------- 1 | import winston = require('winston'); 2 | 3 | import TimePlace from '@app/core/timetable/model/TimePlace'; 4 | import LectureTimeOverlapError from '../error/LectureTimeOverlapError'; 5 | import InvalidLectureTimeJsonError from '../../lecture/error/InvalidLectureTimeJsonError'; 6 | 7 | var logger = winston.loggers.get('default'); 8 | 9 | export function timeAndPlaceToJson(timesString: string, locationsString: string, realTimesString: string): TimePlace[] { 10 | try { 11 | // 시간 정보가 없다면 빈 정보를 반환한다. 12 | if (timesString === '') { 13 | return []; 14 | } 15 | 16 | let locations = locationsString.split('/'); 17 | let times = timesString.split('/'); 18 | let realTimes = realTimesString.split('/') 19 | 20 | // 만약 강의실이 하나 뿐이거나 없다면, 시간에 맞춰서 강의 장소를 추가해준다. 21 | if (locations.length != times.length) { 22 | if (locations.length == 0) { 23 | for (let i=0; i { 36 | let timeSplitted = time.split('-'); 37 | const parsedRealTimes = realTimes[idx].match(/\d{2}:\d{2}/g) 38 | const startTime = parsedRealTimes[0] 39 | const endTime = parsedRealTimes[1] 40 | let day = ['월', '화', '수', '목', '금', '토', '일'].indexOf(timeSplitted[0].charAt(0)); 41 | let start = Number(timeSplitted[0].slice(2)); 42 | let len = Number(timeSplitted[1].slice(0, -1)); 43 | let place = locations[idx]; 44 | return { 45 | day, 46 | start, 47 | start_time: startTime, 48 | end_time: endTime, 49 | len, 50 | place, 51 | startMinute: null, 52 | endMinute: null, 53 | }; 54 | }); 55 | 56 | for (let i = 0; i < classes.length; i++) { 57 | // If the day of the week is not the one we expected 58 | if (classes[i].day < 0) { 59 | throw "wrong day (i: " + i + ", day: " + classes[i].day + ")"; 60 | } 61 | } 62 | 63 | // 시작 시간 순으로 오름차순 정렬 64 | classes.sort((a, b) => { 65 | if (a.day < b.day) return -1; 66 | if (a.day > b.day) return 1; 67 | if (a.start < b.start) return -1; 68 | if (a.start > b.start) return 1; 69 | return 0; 70 | }) 71 | 72 | // Merge same time with different location 73 | for (let i = 1; i < classes.length; i++) { 74 | let prev = classes[i-1]; 75 | let curr = classes[i]; 76 | if (prev.day == curr.day && prev.start == curr.start && prev.len == curr.len) { 77 | prev.place += '/' + curr.place; 78 | classes.splice(i--, 1); 79 | } 80 | } 81 | return classes; 82 | } catch (err) { 83 | logger.error(err); 84 | logger.error("Failed to parse timePlace (times: " + timesString + ", locations: " + locationsString + ", realTime: " + realTimesString + ")"); 85 | return []; 86 | } 87 | } 88 | 89 | export function equalTimeJson(t1:Array, t2:Array) { 90 | if (t1.length != t2.length) return false; 91 | for (var i=0; i, duplicateCheck?:boolean): number[] { 102 | var i,j; 103 | var bitTable2D = []; 104 | for (i = 0; i < 7; i++) { 105 | bitTable2D.push(new Array().fill(0, 0, 30)); 106 | } 107 | 108 | timeJson.forEach(function(lecture, lectureIdx) { 109 | let dayIdx = Number(lecture.day); 110 | let start = Math.floor((lecture.startMinute - 8 * 60) / 60 * 2) / 2 111 | let end = Math.ceil((lecture.endMinute - 8 * 60) / 60 * 2) / 2 112 | if (Number(lecture.len) <= 0) throw new InvalidLectureTimeJsonError(); 113 | if (start >= 0 && start <= 14 && end >= 0 && end <= 15) { 114 | if (duplicateCheck) { 115 | for (let i = start * 2; i < end*2; i++) { 116 | if (bitTable2D[dayIdx][i]) throw new LectureTimeOverlapError(); 117 | bitTable2D[dayIdx][i] = 1; 118 | } 119 | } else { 120 | for (let i = start * 2; i < end*2; i++) { 121 | bitTable2D[dayIdx][i] = 1; 122 | } 123 | } 124 | } 125 | }); 126 | 127 | var timeMasks = []; 128 | for (i = 0; i < 7; i++) { 129 | var mask = 0; 130 | for (j = 0; j < 30; j++) { 131 | mask = mask << 1; 132 | if (bitTable2D[i][j] === 1) 133 | mask = mask + 1; 134 | } 135 | timeMasks.push(mask); 136 | } 137 | return timeMasks; 138 | } 139 | -------------------------------------------------------------------------------- /src/core/user/UserCredentialService.ts: -------------------------------------------------------------------------------- 1 | import bcrypt = require('bcrypt'); 2 | import crypto = require('crypto'); 3 | 4 | import property = require('@app/core/config/property'); 5 | import User from '@app/core/user/model/User'; 6 | import UserCredential from '@app/core/user/model/UserCredential'; 7 | import UserService = require('@app/core/user/UserService'); 8 | import FacebookService = require('@app/core/facebook/FacebookService'); 9 | import InvalidLocalPasswordError from '@app/core/user/error/InvalidLocalPasswordError'; 10 | import InvalidLocalIdError from '@app/core/user/error/InvalidLocalIdError'; 11 | import DuplicateLocalIdError from '@app/core/user/error/DuplicateLocalIdError'; 12 | import AlreadyRegisteredFbIdError from '@app/core/user/error/AlreadyRegisteredFbIdError'; 13 | import InvalidFbIdOrTokenError from '@app/core/facebook/error/InvalidFbIdOrTokenError'; 14 | import NotLocalAccountError from './error/NotLocalAccountError'; 15 | import winston = require('winston'); 16 | import AlreadyRegisteredAppleSubError from "@app/core/user/error/AlreadyRegisteredAppleSubError"; 17 | var logger = winston.loggers.get('default'); 18 | 19 | let secretKey = property.get('core.secretKey'); 20 | 21 | export async function isRightPassword(user: User, password: string): Promise { 22 | let originalHash = user.credential.localPw; 23 | if (!password || !originalHash) return false; 24 | 25 | return await bcrypt.compare(password, originalHash); 26 | } 27 | 28 | export async function isRightFbToken(user: User, fbToken: string): Promise { 29 | try { 30 | let fbInfo = await FacebookService.getFbInfo(user.credential.fbId, fbToken); 31 | return fbInfo.fbId === user.credential.fbId; 32 | } catch (err) { 33 | return false; 34 | } 35 | } 36 | 37 | export function compareCredentialHash(user: User, hash: string): boolean { 38 | return user.credentialHash === hash; 39 | } 40 | 41 | export function makeCredentialHmac(userCredential: UserCredential): string { 42 | var hmac = crypto.createHmac('sha256', secretKey); 43 | hmac.update(JSON.stringify(userCredential)); 44 | return hmac.digest('hex'); 45 | } 46 | 47 | async function modifyCredential(user: User): Promise { 48 | user.credentialHash = makeCredentialHmac(user.credential); 49 | await UserService.modify(user); 50 | } 51 | 52 | 53 | function validatePassword(password: string): void { 54 | if (!password || !password.match(/^(?=.*\d)(?=.*[a-z])\S{6,20}$/i)) { 55 | throw new InvalidLocalPasswordError(password); 56 | } 57 | } 58 | 59 | function makePasswordHash(password: string): Promise { 60 | return bcrypt.hash(password, 4); 61 | } 62 | 63 | export async function changeLocalPassword(user: User, password: string): Promise { 64 | 65 | validatePassword(password); 66 | let passwordHash = await makePasswordHash(password); 67 | user.credential.localPw = passwordHash; 68 | await modifyCredential(user); 69 | } 70 | 71 | export function hasFb(user: User): boolean { 72 | return user.credential.fbId !== null && user.credential.fbId !== undefined; 73 | } 74 | 75 | export function hasLocal(user: User): boolean { 76 | return user.credential.localId !== null && user.credential.localId !== undefined; 77 | } 78 | 79 | export async function attachFb(user: User, fbId: string, fbToken: string): Promise { 80 | if (!fbId) { 81 | throw new InvalidFbIdOrTokenError(fbId, fbToken); 82 | } 83 | 84 | let fbCredential = await makeFbCredential(fbId, fbToken); 85 | user.credential.fbName = fbCredential.fbName; 86 | user.credential.fbId = fbCredential.fbId; 87 | await modifyCredential(user); 88 | } 89 | 90 | export async function detachFb(user: User): Promise { 91 | if (!hasLocal(user)) { 92 | return Promise.reject(new NotLocalAccountError(user._id)); 93 | } 94 | user.credential.fbName = null; 95 | user.credential.fbId = null; 96 | await modifyCredential(user); 97 | } 98 | 99 | function validateLocalId(id: string): void { 100 | if (!id || !id.match(/^[a-z0-9]{4,32}$/i)) { 101 | throw new InvalidLocalIdError(id); 102 | } 103 | } 104 | 105 | export async function attachLocal(user: User, id: string, password: string): Promise { 106 | let localCredential = await makeLocalCredential(id, password); 107 | 108 | user.credential.localId = localCredential.localId; 109 | user.credential.localPw = localCredential.localPw; 110 | await modifyCredential(user); 111 | } 112 | 113 | export async function attachTemp(user: User): Promise { 114 | let tempCredential = await makeTempCredential(); 115 | user.credential.tempDate = tempCredential.tempDate; 116 | user.credential.tempSeed = tempCredential.tempSeed; 117 | await modifyCredential(user); 118 | } 119 | 120 | export async function makeLocalCredential(id: string, password: string): Promise { 121 | validateLocalId(id); 122 | validatePassword(password); 123 | 124 | if (await UserService.getByLocalId(id)) { 125 | throw new DuplicateLocalIdError(id); 126 | } 127 | 128 | let passwordHash = await makePasswordHash(password); 129 | 130 | return { 131 | localId: id, 132 | localPw: passwordHash 133 | } 134 | } 135 | 136 | export async function makeAppleCredential(appleEmail: string, appleSub: string, appleTransferSub?: string): Promise { 137 | if (await UserService.getByAppleSub(appleSub)) { 138 | throw new AlreadyRegisteredAppleSubError(appleSub); 139 | } 140 | return { 141 | appleSub: appleSub, 142 | appleEmail: appleEmail, 143 | appleTransferSub: appleTransferSub 144 | } 145 | } 146 | 147 | export async function transferAppleCredential(user: User, appleSub: string, appleEmail: string): Promise { 148 | user.credential.appleSub = appleSub; 149 | user.credential.appleEmail = appleEmail; 150 | await modifyCredential(user); 151 | } 152 | 153 | export async function makeFbCredential(fbId: string, fbToken: string): Promise { 154 | if (await UserService.getByFb(fbId)) { 155 | throw new AlreadyRegisteredFbIdError(fbId); 156 | } 157 | logger.info("Trying to get fb info: fbId - " + fbId + " / fbToken - " + fbToken); 158 | let fbInfo = await FacebookService.getFbInfo(fbId, fbToken); 159 | logger.info("Got fb info: " + JSON.stringify(fbInfo)); 160 | return { 161 | fbId: fbInfo.fbId, 162 | fbName: fbInfo.fbName 163 | } 164 | } 165 | 166 | export async function makeTempCredential(): Promise { 167 | return { 168 | tempDate: new Date(), 169 | tempSeed: Math.floor(Math.random() * 1000) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/core/user/UserDeviceService.ts: -------------------------------------------------------------------------------- 1 | import FcmService = require('@app/core/fcm/FcmService'); 2 | import FcmKeyUtil = require('@app/core/fcm/FcmKeyUtil'); 3 | import User from '@app/core/user/model/User'; 4 | import UserService = require('@app/core/user/UserService'); 5 | 6 | /* 7 | * create_device 8 | * Add this registration_id for the user 9 | * and add topic 10 | */ 11 | export async function attachDevice(user: User, registrationId: string): Promise { 12 | if (!user.fcmKey) await refreshFcmKey(user, registrationId); 13 | 14 | let keyName = FcmKeyUtil.getUserFcmKeyName(user); 15 | try { 16 | await FcmService.addDevice(keyName, user.fcmKey, [registrationId]); 17 | } catch (err) { 18 | await refreshFcmKey(user, registrationId); 19 | await FcmService.addDevice(keyName, user.fcmKey, [registrationId]); 20 | } 21 | 22 | await FcmService.addTopic(registrationId); 23 | } 24 | 25 | export async function detachDevice(user: User, registrationId: string): Promise { 26 | if (!user.fcmKey) await refreshFcmKey(user, registrationId); 27 | 28 | let keyName = FcmKeyUtil.getUserFcmKeyName(user); 29 | try { 30 | await FcmService.removeDevice(keyName, user.fcmKey, [registrationId]); 31 | } catch (err) { 32 | await refreshFcmKey(user, registrationId); 33 | await FcmService.removeDevice(keyName, user.fcmKey, [registrationId]); 34 | } 35 | 36 | await FcmService.removeTopicBatch([registrationId]); 37 | } 38 | 39 | async function refreshFcmKey(user: User, registrationId: string): Promise { 40 | let keyName = FcmKeyUtil.getUserFcmKeyName(user); 41 | var keyValue: string; 42 | 43 | try { 44 | keyValue = await FcmService.getNotiKey(keyName); 45 | } catch (err) { 46 | keyValue = await FcmService.createNotiKey(keyName, [registrationId]); 47 | } 48 | 49 | if (!keyValue) throw "refreshFcmKey failed"; 50 | 51 | user.fcmKey = keyValue; 52 | await UserService.modify(user); 53 | } 54 | -------------------------------------------------------------------------------- /src/core/user/UserRepository.ts: -------------------------------------------------------------------------------- 1 | import mongoose = require('mongoose'); 2 | import winston = require('winston'); 3 | 4 | import User from '@app/core/user/model/User'; 5 | 6 | var logger = winston.loggers.get('default'); 7 | 8 | let UserSchema = new mongoose.Schema({ 9 | credential : { 10 | localId: {type: String, default: null}, 11 | localPw: {type: String, default: null}, 12 | fbName: {type: String, default: null}, 13 | fbId: {type: String, default: null}, 14 | appleEmail: {type: String, default: null}, 15 | appleSub: {type: String, default: null}, 16 | appleTransferSub: {type: String, default: null}, 17 | 18 | // 위 항목이 없어도 unique credentialHash을 생성할 수 있도록 19 | tempDate: {type: Date, default: null}, // 임시 가입 날짜 20 | tempSeed: {type: Number, default: null} // 랜덤 seed 21 | 22 | }, 23 | credentialHash : {type: String, default: null}, // credential이 변경될 때 마다 SHA 해싱 (model/user.ts 참조) 24 | isAdmin: {type: Boolean, default: false}, // admin 항목 접근 권한 25 | regDate: Date, // 회원가입 날짜 26 | lastLoginTimestamp: Number, // routes/api/api.ts의 토큰 인증에서 업데이트 27 | notificationCheckedAt: Date, // 새로운 알림이 있는지 확인하는 용도 28 | email: String, 29 | isEmailVerified: Boolean, 30 | nickname: String, 31 | fcmKey: String, // Firebase Message Key 32 | 33 | // if the user remove its account, active status becomes false 34 | // Should not remove user object, because we must preserve the user data and its related objects 35 | active: {type: Boolean, default: true} 36 | }); 37 | 38 | UserSchema.index({ credentialHash : 1 }) // 토큰 인증 시 39 | UserSchema.index({ "credential.localId": 1 }) // ID로 로그인 시 40 | UserSchema.index({ "credential.fbId": 1 }) // 페이스북으로 로그인 시 41 | 42 | let MongooseUserModel = mongoose.model('User', UserSchema ,'users'); 43 | 44 | function fromMongoose(mongooseDocument: mongoose.MongooseDocument): User { 45 | if (mongooseDocument === null) { 46 | return null; 47 | } 48 | 49 | let wrapper = mongooseDocument; 50 | return { 51 | _id: wrapper._id, 52 | credential: wrapper.credential, 53 | credentialHash: wrapper.credentialHash, 54 | isAdmin: wrapper.isAdmin, 55 | regDate: wrapper.regDate, 56 | notificationCheckedAt: wrapper.notificationCheckedAt, 57 | email: wrapper.email, 58 | isEmailVerified: wrapper.isEmailVerified, 59 | nickname: wrapper.nickname, 60 | fcmKey: wrapper.fcmKey, 61 | active: wrapper.active, 62 | lastLoginTimestamp: wrapper.lastLoginTimestamp, 63 | } 64 | } 65 | export async function findActiveByVerifiedEmail(email: string) : Promise { 66 | const mongooseDocument = await MongooseUserModel.findOne({'email' : email, 'active' : true , 'isEmailVerified': true}).exec(); 67 | return fromMongoose(mongooseDocument); 68 | } 69 | 70 | export async function findActiveByEmail(email: string) : Promise { 71 | const mongooseDocument = await MongooseUserModel.findOne({'email' : email, 'active' : true }).exec(); 72 | return fromMongoose(mongooseDocument); 73 | } 74 | 75 | export async function findActiveByFb(fbId: string) : Promise { 76 | const mongooseDocument = await MongooseUserModel.findOne({'credential.fbId' : fbId, 'active' : true }).exec(); 77 | return fromMongoose(mongooseDocument); 78 | } 79 | 80 | export async function findActiveByAppleSub(appleSub: string) : Promise { 81 | const mongooseDocument = await MongooseUserModel.findOne({'credential.appleSub' : appleSub, 'active' : true}).exec(); 82 | return fromMongoose(mongooseDocument); 83 | } 84 | 85 | export async function findActiveByAppleTransferSub(appleTransferSub: string) : Promise { 86 | const mongooseDocument = await MongooseUserModel.findOne({'credential.appleTransferSub' : appleTransferSub, 'active' : true}).exec(); 87 | return fromMongoose(mongooseDocument); 88 | } 89 | 90 | export async function findActiveByCredentialHash(hash: string): Promise { 91 | const mongooseDocument = await MongooseUserModel.findOne({'credentialHash' : hash, 'active' : true }).exec(); 92 | return fromMongoose(mongooseDocument); 93 | } 94 | 95 | export function findActiveByMongooseId(mid: string): Promise { 96 | return MongooseUserModel.findOne({ '_id': mid, 'active': true }) 97 | .exec().then(function (userDocument) { 98 | return fromMongoose(userDocument); 99 | }); 100 | } 101 | 102 | export function findActiveByLocalId(id: string): Promise { 103 | return MongooseUserModel.findOne({ 'credential.localId': id, 'active': true }) 104 | .exec().then(function (userDocument) { 105 | return fromMongoose(userDocument); 106 | }); 107 | } 108 | 109 | export function update(user: User): Promise { 110 | return MongooseUserModel.findOne({ '_id': user._id }) 111 | .exec().then(function (userDocument: any) { 112 | userDocument.credential = user.credential; 113 | userDocument.credentialHash = user.credentialHash; 114 | userDocument.isAdmin = user.isAdmin; 115 | userDocument.regDate = user.regDate; 116 | userDocument.notificationCheckedAt = user.notificationCheckedAt; 117 | userDocument.email = user.email; 118 | userDocument.fcmKey = user.fcmKey; 119 | userDocument.active = user.active; 120 | userDocument.lastLoginTimestamp = user.lastLoginTimestamp; 121 | userDocument.isEmailVerified = user.isEmailVerified; 122 | userDocument.nickname = user.nickname; 123 | userDocument.save(); 124 | }) 125 | } 126 | 127 | export async function insert(user: User): Promise { 128 | let mongooseUserModel = new MongooseUserModel(user); 129 | await mongooseUserModel.save(); 130 | return fromMongoose(mongooseUserModel); 131 | } 132 | 133 | export function updateLastLoginTimestamp(user: User): void { 134 | let timestamp = Date.now(); 135 | // Mongoose를 사용하면 성능이 저하되므로, raw mongodb를 사용한다. 136 | mongoose.connection.db.collection('users').updateOne({ _id: user._id }, { $set: { lastLoginTimestamp: timestamp } }) 137 | .catch(function (err) { 138 | logger.error("Failed to update timestamp"); 139 | logger.error(err); 140 | }); 141 | user.lastLoginTimestamp = timestamp; 142 | } 143 | -------------------------------------------------------------------------------- /src/core/user/error/AlreadyRegisteredAppleSubError.ts: -------------------------------------------------------------------------------- 1 | export default class AlreadyRegisteredAppleSubError extends Error { 2 | constructor(public appleSub: string) { 3 | super("Already registered apple sub '" + appleSub + "'"); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/user/error/AlreadyRegisteredFbIdError.ts: -------------------------------------------------------------------------------- 1 | export default class AlreadyRegisteredFbIdError extends Error { 2 | constructor(public fbId: string) { 3 | super("Already registered facebook ID '" + fbId + "'"); 4 | } 5 | } -------------------------------------------------------------------------------- /src/core/user/error/DuplicateLocalIdError.ts: -------------------------------------------------------------------------------- 1 | export default class DuplicateLocalIdError extends Error { 2 | constructor(public localId: string) { 3 | super("Duplicate local id '" + localId + "'"); 4 | } 5 | } -------------------------------------------------------------------------------- /src/core/user/error/InvalidLocalIdError.ts: -------------------------------------------------------------------------------- 1 | export default class InvalidLocalIdError extends Error { 2 | constructor(public localId: string) { 3 | super("Invalid local id '" + localId + "'"); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/user/error/InvalidLocalPasswordError.ts: -------------------------------------------------------------------------------- 1 | export default class InvalidLocalPasswordError extends Error { 2 | constructor(public localPassword: string) { 3 | super("Invalid local password '" + localPassword + "'"); 4 | } 5 | } -------------------------------------------------------------------------------- /src/core/user/error/NotLocalAccountError.ts: -------------------------------------------------------------------------------- 1 | export default class NotLocalAccountError extends Error { 2 | constructor(public userId: string) { 3 | super("Not local account. user._id = '" + userId + "'"); 4 | } 5 | } -------------------------------------------------------------------------------- /src/core/user/model/RedisVerificationValue.ts: -------------------------------------------------------------------------------- 1 | export default interface RedisVerificationValue { 2 | email: string, 3 | code: string, 4 | count: number, 5 | createdAtTimestamp: number 6 | } 7 | -------------------------------------------------------------------------------- /src/core/user/model/SnuttevUserInfo.ts: -------------------------------------------------------------------------------- 1 | export default interface SnuttevUserInfo { 2 | id: string; 3 | email: string; 4 | local_id: string; 5 | }; 6 | -------------------------------------------------------------------------------- /src/core/user/model/User.ts: -------------------------------------------------------------------------------- 1 | import UserCredential from '@app/core/user/model/UserCredential'; 2 | 3 | export default interface User { 4 | _id?: string; 5 | credential: UserCredential; 6 | credentialHash: string; 7 | isAdmin?: boolean; 8 | regDate?: Date; 9 | notificationCheckedAt?: Date; 10 | email?: string; 11 | isEmailVerified?: boolean; 12 | fcmKey?: string; 13 | active?: boolean; 14 | lastLoginTimestamp?: number; 15 | nickname?: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/core/user/model/UserCredential.ts: -------------------------------------------------------------------------------- 1 | export default interface UserCredential { 2 | localId?: string; 3 | localPw?: string; 4 | fbName?: string; 5 | fbId?: string; 6 | appleEmail?: string; 7 | appleSub?: string; 8 | appleTransferSub?: string; 9 | tempDate?: Date; 10 | tempSeed?: number; 11 | }; 12 | -------------------------------------------------------------------------------- /src/core/user/model/UserInfo.ts: -------------------------------------------------------------------------------- 1 | export default interface UserInfo { 2 | isAdmin: boolean; 3 | regDate: Date; 4 | notificationCheckedAt: Date; 5 | email: string; 6 | local_id: string; 7 | fb_name: string; 8 | }; 9 | -------------------------------------------------------------------------------- /test/api/routes/StaticPageRouterIntegrationTest.ts: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'mocha'; 2 | import assert = require('assert'); 3 | 4 | require('@test/config/log'); 5 | import request = require('@test/config/supertest'); 6 | 7 | describe("StaticPageRouterIntegrationTest", function() { 8 | it("terms_of_service__success", function(done) { 9 | request.get('/terms_of_service') 10 | .expect(200) 11 | .end(function(err, res) { 12 | if (err) { 13 | done(err); 14 | } else { 15 | done(); 16 | } 17 | }); 18 | }); 19 | 20 | it("privacy_policy__success", function(done) { 21 | request.get('/privacy_policy') 22 | .expect(200) 23 | .end(function(err, res) { 24 | if (err) { 25 | done(err); 26 | } else { 27 | done(); 28 | } 29 | }); 30 | }); 31 | 32 | it("member__success", function(done) { 33 | request.get('/member') 34 | .expect(200) 35 | .end(function(err, res) { 36 | if (err) { 37 | done(err); 38 | } else { 39 | done(); 40 | } 41 | }); 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /test/batch/coursebook/CoursebookUpdateNotificationServiceUnitTest.ts: -------------------------------------------------------------------------------- 1 | import assert = require('assert'); 2 | import rewire = require('rewire'); 3 | 4 | let CoursebookUpdateNotificationServiceRewire = rewire('@app/batch/coursebook/CoursebookUpdateNotificationService'); 5 | 6 | describe('notifyUnitTest', function() { 7 | it("getUpdatedLectureNotificationMessage__success", async function() { 8 | let timetableTitle = "나으 시간표"; 9 | let timetable = { 10 | year: 2019, 11 | semester: 2, 12 | title: timetableTitle 13 | }; 14 | let difference = { 15 | oldLecture: { 16 | course_title: "그 강의" 17 | }, 18 | difference: { 19 | instructor: "김교수님", 20 | credit: "3학점", 21 | class_time_json: {} 22 | } 23 | } 24 | let actual = CoursebookUpdateNotificationServiceRewire.__get__("getUpdatedLectureNotificationMessage")(timetable, difference); 25 | let expected = "2019-S '나으 시간표' 시간표의 '그 강의' 강의가 업데이트 되었습니다. (항목: 교수, 학점, 강의 시간/장소)"; 26 | assert.deepEqual(actual, expected); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/batch/coursebook/sugangsnu/SugangSnuTestExcel.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wafflestudio/snutt-nodejs/c07331d23242b0a56a215d4adb689fad3f0a344d/test/batch/coursebook/sugangsnu/SugangSnuTestExcel.xlsx -------------------------------------------------------------------------------- /test/config/log.ts: -------------------------------------------------------------------------------- 1 | import winston = require('winston'); 2 | 3 | winston.loggers.add('default', { 4 | level: 'debug', 5 | transports: [ 6 | new winston.transports.Console() 7 | ], 8 | format: winston.format.combine( 9 | winston.format.colorize(), 10 | winston.format.timestamp({ 11 | format: 'YYYY-MM-DD HH:mm:ss.SSS' 12 | }), 13 | winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${(typeof info.message === 'string') ? info.message : JSON.stringify(info.message)}`) 14 | ) 15 | }); 16 | -------------------------------------------------------------------------------- /test/config/supertest.ts: -------------------------------------------------------------------------------- 1 | require('@app/core/config/mongo'); 2 | require('@app/core/config/redis'); 3 | require('@app/api/config/redis'); 4 | import express = require('@app/api/config/express'); 5 | import supertest = require('supertest'); 6 | export = supertest(express); 7 | -------------------------------------------------------------------------------- /test/core/lecture/RefLectureServiceUnitTest.ts: -------------------------------------------------------------------------------- 1 | import sinon = require('sinon'); 2 | import assert = require('assert'); 3 | import rewire = require('rewire'); 4 | 5 | import RefLecture from '@app/core/lecture/model/RefLecture'; 6 | import RefLectureService = require('@app/core/lecture/RefLectureService'); 7 | import RefLectureRepository = require('@app/core/lecture/RefLectureRepository'); 8 | 9 | let RefLectureServiceRewire = rewire('@app/core/lecture/RefLectureService'); 10 | 11 | describe("RefLectureServiceUnitTest", function() { 12 | let sinonSandbox = sinon.createSandbox(); 13 | 14 | afterEach(function() { 15 | sinonSandbox.restore(); 16 | }); 17 | }) 18 | -------------------------------------------------------------------------------- /test/core/timetable/util/TimePlaceUtilUnitTest.ts: -------------------------------------------------------------------------------- 1 | import assert = require('assert'); 2 | import TimePlaceUtil = require('@app/core/timetable/util/TimePlaceUtil'); 3 | import TimePlace from '@app/core/timetable/model/TimePlace'; 4 | 5 | describe('TimePlaceUtilUnitTest', function () { 6 | it("timeJsonToMask__success__emptyJson", async function () { 7 | assert.deepEqual([0, 0, 0, 0, 0, 0, 0], TimePlaceUtil.timeJsonToMask([])); 8 | }) 9 | 10 | it("timeJsonToMask__success", async function () { 11 | // assert.deepEqual([0, parseInt("00011" + "11000" + "00000" + "00000" + "00000" + "00000", 2), 0, 12 | // parseInt("00011" + "11000" + "00000" + "00000" + "00000" + "00000", 2), 0, 0, 0], 13 | // TimePlaceUtil.timeJsonToMask([{day: 1, start: 1.5, len: 2, place: '220-317', start_time: null, end_time: null, startMinute: null, endMinute: null}, 14 | // {day: 3, start: 1.5, len: 2, place: '220-317', start_time: null, end_time: null, startMinute: null, endMinute: null}])); 15 | // assert.deepEqual([0, parseInt("00011" + "11100" + "00000" + "00000" + "00000" + "00000", 2), 0, 16 | // parseInt("00011" + "11100" + "00000" + "00000" + "00000" + "00000", 2), 0, 0, 0], 17 | // TimePlaceUtil.timeJsonToMask([{day: 1, start: 1.5, len: 2.5, place: '220-317', start_time: null, end_time: null, startMinute: null, endMinute: null}, 18 | // {day: 3, start: 1.5, len: 2.5, place: '220-317', start_time: null, end_time: null, startMinute: null, endMinute: null}])); 19 | // assert.deepEqual([0, parseInt("00001" + "11100" + "00000" + "00000" + "00000" + "00000", 2), 0, 20 | // parseInt("00001" + "11100" + "00000" + "00000" + "00000" + "00000", 2), 0, 0, 0], 21 | // TimePlaceUtil.timeJsonToMask([{day: 1, start: 2, len: 2, place: '220-317', start_time: null, end_time: null, startMinute: null, endMinute: null}, 22 | // {day: 3, start: 2, len: 2, place: '220-317', start_time: null, end_time: null, startMinute: null, endMinute: null}])); 23 | // assert.deepEqual([0, parseInt("00000" + "00000" + "00000" + "00000" + "11111" + "11111", 2), 0, 24 | // parseInt("00000" + "00000" + "00000" + "00000" + "11111" + "11111", 2), 0, 0, 0], 25 | // TimePlaceUtil.timeJsonToMask([{day: 1, start: 10, len: 5, place: '220-317', start_time: null, end_time: null, startMinute: null, endMinute: null}, 26 | // {day: 3, start: 10, len: 5, place: '220-317', start_time: null, end_time: null, startMinute: null, endMinute: null}])); 27 | // assert.deepEqual([0, parseInt("00000" + "00000" + "00000" + "00000" + "00000" + "01100", 2), 0, 28 | // parseInt("00000" + "00000" + "00000" + "00000" + "00000" + "01100", 2), 0, 0, 0], 29 | // TimePlaceUtil.timeJsonToMask([{day: 1, start: 13, len: 1, place: "302-308", start_time: null, end_time: null, startMinute: null, endMinute: null}, 30 | // {day: 3, start: 13, len: 1, place: "302-308", start_time: null, end_time: null, startMinute: null, endMinute: null}])); 31 | // assert.deepEqual([0, parseInt("00000" + "00000" + "00000" + "00000" + "00000" + "00000", 2), 0, 32 | // parseInt("00000" + "00000" + "00000" + "00000" + "00000" + "00000", 2), 0, 0, 0], 33 | // TimePlaceUtil.timeJsonToMask([{day: 1, start: -1, len: 1, place: "302-308", start_time: null, end_time: null, startMinute: null, endMinute: null}, 34 | // {day: 3, start: 15, len: 1, place: "302-308", start_time: null, end_time: null, startMinute: null, endMinute: null}])); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/integration/etc.ts: -------------------------------------------------------------------------------- 1 | import supertest = require('supertest'); 2 | 3 | export = function(request: supertest.SuperTest) { 4 | it('Color lists', function(done) { 5 | request.get('/colors') 6 | .expect(200) 7 | .end(function(err, res){ 8 | if (err) return done(err); 9 | var colors = res.body.colors; 10 | var names = res.body.names; 11 | if (!colors[0].fg || !colors[0].bg) 12 | return done("No colors"); 13 | if (!names[0]) 14 | return done("No color names"); 15 | done(err); 16 | }); 17 | }); 18 | } -------------------------------------------------------------------------------- /test/integration/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * test/api/api_init.js 3 | * This script is parent script for api tests. 4 | * usage : $ npm test 5 | */ 6 | process.env.NODE_ENV = 'mocha'; 7 | 8 | require('@test/config/log'); 9 | import request = require('@test/config/supertest'); 10 | 11 | import winston = require('winston'); 12 | import assert = require('assert'); 13 | import property = require('@app/core/config/property'); 14 | import mongoose = require('mongoose'); 15 | 16 | import CourseBookService = require('@app/core/coursebook/CourseBookService'); 17 | import RefLectureService = require('@app/core/lecture/RefLectureService'); 18 | 19 | let logger = winston.loggers.get('default'); 20 | logger.info("Loaded"); 21 | 22 | describe('Integration Test', function() { 23 | before('valid snutt.yml', function(done) { 24 | if (property.get('core.secretKey') && property.get('api.host') && property.get('api.port')) 25 | return done(); 26 | else 27 | return done(new Error("Invalid config. Please set conf.yml")); 28 | }); 29 | 30 | // Change connection into test DB in order not to corrupt production DB 31 | before('open snutt_test', function(done) { 32 | mongoose.connect('mongodb://localhost/snutt_test', function(err){ 33 | return done(err); 34 | }); 35 | }); 36 | 37 | // Clean Test DB 38 | // mongoose.connection.db.dropDatabase() 39 | // dose not actually drop the db, but actually clears it 40 | before('clear snutt_test db', function(done) { 41 | mongoose.connection.db.dropDatabase(function(err) { 42 | done(err); 43 | }); 44 | }); 45 | 46 | // Add 2 coursebooks, 2016-2 and 2015-W 47 | before('add initial coursebooks for test', function(done) { 48 | let promise1 = CourseBookService.add({ year: 2015, semester: 4, updated_at: new Date()}); 49 | let promise2 = CourseBookService.add({ year: 2016, semester: 3, updated_at: new Date()}); 50 | Promise.all([promise1, promise2]).catch(function(err) { 51 | done(err); 52 | }).then(function(result) { 53 | done(); 54 | }); 55 | }); 56 | 57 | before('insert initial lecture for test', async function() { 58 | var myLecture = { 59 | "year": 2016, 60 | "semester": 3, 61 | "classification": "전선", 62 | "department": "컴퓨터공학부", 63 | "academic_year": "3학년", 64 | "course_number": "400.320", 65 | "lecture_number": "002", 66 | "course_title": "공학연구의 실습 1", 67 | "credit": 1, 68 | "class_time": "화(13-1)/목(13-1)", 69 | "real_class_time": "화(21:00~21:50)/목(21:00~21:50)", 70 | "instructor": "이제희", 71 | "quota": 15, 72 | "enrollment": 0, 73 | "remark": "컴퓨터공학부 및 제2전공생만 수강가능", 74 | "category": "", 75 | /* 76 | * See to it that the server removes _id fields correctly 77 | */ 78 | "_id": "56fcd83c041742971bd20a86", 79 | "class_time_mask": [ 80 | 0, 81 | 12, 82 | 0, 83 | 12, 84 | 0, 85 | 0, 86 | 0 87 | ], 88 | "class_time_json": [ 89 | { 90 | "day": 1, 91 | "start": 13, 92 | "len": 1, 93 | "start_time": "21:00", 94 | "end_time": "22:00", 95 | "startMinute": 1260, 96 | "endMinute": 1320, 97 | "place": "302-308", 98 | "_id": "56fcd83c041742971bd20a88" 99 | }, 100 | { 101 | "day": 3, 102 | "start": 13, 103 | "len": 1, 104 | "start_time": "21:00", 105 | "end_time": "22:00", 106 | "startMinute": 1260, 107 | "endMinute": 1320, 108 | "place": "302-308", 109 | "_id": "56fcd83c041742971bd20a87" 110 | } 111 | ], 112 | }; 113 | await RefLectureService.addAll([myLecture]); 114 | }); 115 | 116 | // Register test user 117 | before('register initial test user', function(done) { 118 | request.post('/auth/register_local') 119 | .send({id:"snutt", password:"abc1234"}) 120 | .expect(200) 121 | .end(function(err, res){ 122 | assert.equal(res.body.message, 'ok'); 123 | done(err); 124 | }); 125 | }); 126 | 127 | it('MongoDB >= 2.4', function(done) { 128 | var admin = mongoose.connection.db.admin(); 129 | admin.buildInfo(function (err, info) { 130 | if (err) 131 | return done(err); 132 | if (parseFloat(info.version) < 2.4) 133 | return done(new Error("MongoDB version("+info.version+") is outdated(< 2.4). Service might not work properly")); 134 | done(); 135 | }); 136 | }); 137 | 138 | it('Recent Coursebook', function(done) { 139 | request.get('/course_books/recent') 140 | .expect(200) 141 | .end(function(err, res){ 142 | assert.equal(res.body.semester, 3); 143 | done(err); 144 | }); 145 | }); 146 | 147 | describe('etc', function () { 148 | require('./etc')(request); 149 | }); 150 | 151 | describe('User', function () { 152 | require('./user_test')(request); 153 | }); 154 | 155 | describe('Timetable', function () { 156 | require('./timetable_test')(request); 157 | }); 158 | 159 | describe('TagList', function () { 160 | require('./tag_list_test')(request); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /test/integration/tag_list_test.ts: -------------------------------------------------------------------------------- 1 | import assert = require('assert'); 2 | import supertest = require('supertest'); 3 | import TagListService = require('@app/core/taglist/TagListService'); 4 | 5 | export = function(request: supertest.SuperTest) { 6 | it ('Insert tag lists', async function() { 7 | await TagListService.upsert({year: 2015, semester: 1, tags: { 8 | classification: ['교양'], 9 | department: ["컴퓨터공학부"], 10 | academic_year: ["1학년"], 11 | credit: [ "1학점", "2학점" ], 12 | instructor: [ "장병탁" ], 13 | category: ["인간과 사회"], 14 | etc: [] 15 | }}); 16 | }); 17 | 18 | it ('Find by semester', async function() { 19 | let tagList = await TagListService.getBySemester(2015, 1); 20 | assert.equal(tagList.tags.classification[0], '교양'); 21 | assert.equal(tagList.tags.instructor[0], '장병탁'); 22 | assert.equal(typeof tagList.updated_at, "number"); 23 | }); 24 | 25 | it ('Update tag lists', async function() { 26 | await TagListService.upsert({year: 2015, semester: 1, tags: { 27 | classification: ['교양'], 28 | department: ["컴퓨터공학부"], 29 | academic_year: ["1학년"], 30 | credit: [ "1학점", "2학점" ], 31 | instructor: [ "문병로" ], 32 | category: ["인간과 사회"], 33 | etc: [] 34 | }}); 35 | let updated = await TagListService.getBySemester(2015, 1); 36 | assert.equal(updated.tags.instructor[0], '문병로'); 37 | }); 38 | } 39 | 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2017", 5 | "noImplicitAny": false, 6 | "sourceMap": true, 7 | "outDir": "./build", 8 | "baseUrl": "./", 9 | "paths": { 10 | "@app/*": [ 11 | "src/*" 12 | ], 13 | "@test/*": [ 14 | "test/*" 15 | ] 16 | } 17 | }, 18 | "exclude": [ 19 | "node_modules", 20 | "build" 21 | ] 22 | } 23 | 24 | --------------------------------------------------------------------------------