├── .dockerignore ├── .eslintrc.js ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .prettierrc.json ├── Dockerfile ├── README.md ├── maza ├── nest-cli.json ├── package.json ├── src ├── app.module.ts ├── database │ ├── abstract-models │ │ └── timestamped-model.ts │ ├── database.module.ts │ ├── decorators │ │ └── timestamp-property.ts │ └── mikro-orm.config.ts ├── main.ts ├── migrations │ ├── .snapshot-mazacofo.json │ ├── Migration20250719141829.ts │ ├── Migration20250719144110.ts │ ├── Migration20250719144543.ts │ └── Migration20250719144832.ts ├── packages │ ├── badge │ │ ├── badge.module.ts │ │ ├── controllers │ │ │ └── badge.controller.ts │ │ └── utils │ │ │ ├── cofo-rating-info.ts │ │ │ └── svg.utils.ts │ ├── client │ │ ├── client.module.ts │ │ ├── entities │ │ │ └── client.entity.ts │ │ └── services │ │ │ └── client.service.ts │ └── codeforces │ │ ├── codeforces.module.ts │ │ └── services │ │ └── codeforces.service.ts ├── schedular │ ├── schedular.module.ts │ └── services │ │ └── scheduler.service.ts ├── sentry.ts └── utils │ └── random-id.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/no-unused-vars': [ 21 | 'error', 22 | { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, 23 | ], 24 | '@typescript-eslint/interface-name-prefix': 'off', 25 | '@typescript-eslint/explicit-function-return-type': 'off', 26 | '@typescript-eslint/explicit-module-boundary-types': 'off', 27 | '@typescript-eslint/no-explicit-any': 'off', 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deployment(production) 2 | 3 | on: 4 | push: 5 | branches: [release] 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | prepare: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | 18 | - name: Set outputs 19 | id: vars 20 | run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 21 | 22 | - name: Create .env file 23 | run: | 24 | touch .env 25 | 26 | echo "MAZA_ENV=$MAZA_ENV" >> .env 27 | echo "PORT=$PORT" >> .env 28 | 29 | echo "MYSQL_HOST=$MYSQL_HOST" >> .env 30 | echo "MYSQL_USER=$MYSQL_USER" >> .env 31 | echo "MYSQL_PASSWORD=$MYSQL_PASSWORD" >> .env 32 | echo "MYSQL_DB=$MYSQL_DB" >> .env 33 | 34 | echo "SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN" >> .env 35 | env: 36 | MAZA_ENV: 'production' 37 | PORT: ${{ secrets.PORT }} 38 | MYSQL_HOST: ${{ secrets.MYSQL_HOST }} 39 | MYSQL_USER: ${{ secrets.MYSQL_USER }} 40 | MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }} 41 | MYSQL_DB: ${{ secrets.MYSQL_DB }} 42 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 43 | 44 | - name: Upload workspace 45 | uses: actions/upload-artifact@v4 46 | with: 47 | name: workspace 48 | include-hidden-files: true 49 | path: | 50 | . 51 | !./node_modules 52 | outputs: 53 | sha_short: ${{ steps.vars.outputs.sha_short }} 54 | 55 | build_server: 56 | needs: prepare 57 | runs-on: ubuntu-latest 58 | steps: 59 | - name: Download workspace 60 | uses: actions/download-artifact@v4 61 | with: 62 | name: workspace 63 | path: . 64 | 65 | - name: Configure AWS Credentials 66 | uses: aws-actions/configure-aws-credentials@v4 67 | with: 68 | aws-region: ap-northeast-2 69 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 70 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 71 | 72 | - name: Login to Amazon ECR 73 | id: login-ecr 74 | uses: aws-actions/amazon-ecr-login@v2 75 | 76 | - name: Setup Docker Buildx 77 | uses: docker/setup-buildx-action@v3 78 | 79 | - name: Build and Push image to ECR 80 | uses: docker/build-push-action@v6 81 | env: 82 | REGISTRY: ${{ steps.login-ecr.outputs.registry }} 83 | REPOSITORY: mazacofo 84 | IMAGE_TAG: ${{ github.sha }} 85 | with: 86 | context: . 87 | file: ./Dockerfile 88 | push: true 89 | tags: ${{ env.REGISTRY }}/${{ env.REPOSITORY }}:${{ env.IMAGE_TAG }} 90 | outputs: 91 | registry: ${{ steps.login-ecr.outputs.registry }} 92 | 93 | deploy: 94 | needs: build_server 95 | runs-on: ubuntu-latest 96 | steps: 97 | - name: Deploy to server 98 | uses: appleboy/ssh-action@v1.2.0 99 | env: 100 | REGISTRY: ${{ needs.build_server.outputs.registry }} 101 | TAG: ${{ github.sha }} 102 | REPOSITORY: mazacofo 103 | PORT: ${{ secrets.PORT }} 104 | with: 105 | host: ${{ secrets.SERVER_HOST }} 106 | username: ${{ secrets.SERVER_USERNAME }} 107 | passphrase: ${{ secrets.SERVER_PASSWORD }} 108 | key: ${{ secrets.SSH_LEED_AT_KEY }} 109 | envs: REGISTRY, TAG, REPOSITORY, PORT 110 | script: | 111 | aws ecr get-login-password --region ap-northeast-2 \ 112 | | docker login --username AWS --password-stdin 889566267001.dkr.ecr.ap-northeast-2.amazonaws.com 113 | 114 | docker pull $REGISTRY/$REPOSITORY:$TAG 115 | 116 | if [ "$( docker container inspect -f '{{.ID}}' $REPOSITORY )" ]; then 117 | docker container stop $REPOSITORY 118 | docker container rm $REPOSITORY -f 119 | fi 120 | docker run -d --name $REPOSITORY --restart=always --network=mysql_network -p $PORT:$PORT $REGISTRY/$REPOSITORY:$TAG 121 | 122 | docker image prune --all --force 123 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Runtime data 8 | pids 9 | *.pid 10 | *.seed 11 | *.pid.lock 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage/ 15 | *.lcov 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # Bower dependency directory (https://bower.io/) 24 | bower_components 25 | 26 | # node-waf configuration 27 | .lock-wscript 28 | 29 | # Compiled binary addons (https://nodejs.org/api/addons.html) 30 | build/Release 31 | 32 | # Dependency directories 33 | node_modules/ 34 | jspm_packages/ 35 | 36 | # TypeScript cache 37 | *.tsbuildinfo 38 | 39 | # Optional npm cache directory 40 | .npm 41 | 42 | # Optional eslint cache 43 | .eslintcache 44 | 45 | # Microbundle cache 46 | .rpt2_cache/ 47 | .rts2_cache_cjs/ 48 | .rts2_cache_es/ 49 | .rts2_cache_umd/ 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | .env.test 63 | .env.production 64 | 65 | # parcel-bundler cache (https://parceljs.org/) 66 | .cache 67 | .parcel-cache 68 | 69 | # Next.js build output 70 | .next 71 | 72 | # Nuxt.js build / generate output 73 | .nuxt 74 | dist 75 | 76 | # Gatsby files 77 | .cache/ 78 | public 79 | 80 | # Storybook build outputs 81 | .out 82 | .storybook-out 83 | 84 | # Temporary folders 85 | tmp/ 86 | temp/ 87 | 88 | # Logs 89 | logs 90 | *.log 91 | 92 | # IDE 93 | .vscode/ 94 | .idea/ 95 | *.swp 96 | *.swo 97 | 98 | # OS 99 | .DS_Store 100 | Thumbs.db 101 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # STEP 1: Install dependencies 2 | FROM node:22.12-alpine AS deps 3 | WORKDIR /app 4 | COPY package.json yarn.lock ./ 5 | RUN yarn --frozen-lockfile 6 | 7 | # STEP 2: Build 8 | FROM node:22.12-alpine AS builder 9 | WORKDIR /app 10 | COPY --from=deps /app/node_modules ./node_modules 11 | COPY . . 12 | RUN yarn build 13 | 14 | # Step 3: Expose 15 | FROM node:22.12-alpine AS runner 16 | RUN apk add --no-cache tzdata vim 17 | ENV TZ=Asia/Seoul 18 | RUN cp /usr/share/zoneinfo/$TZ /etc/localtime 19 | ENV NODE_ENV=production 20 | 21 | WORKDIR /app 22 | COPY --from=builder /app/dist ./dist 23 | COPY --from=deps /app/node_modules ./node_modules 24 | COPY package.json yarn.lock tsconfig.* .env ./ 25 | 26 | CMD ["sh", "-c", "yarn mikro-orm migration:up --config './dist/database/mikro-orm.config.js' ; yarn start:prod"] 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mazacofo 2 | 3 | Codeforces rating badge generator for Github Readme 4 | 5 | ## Features 6 | 7 | ### Automatic Updates 8 | 9 | The application automatically updates Codeforces data every minute using a cron task. 10 | 11 | ### Badge Styles 12 | 13 | - **Regular**: Full-size badge with detailed information 14 | - **Mini**: Compact badge showing only rating 15 | - **Legendary Grandmaster**: Special styling for top-tier users 16 | 17 | ## Usage 18 | 19 | ```markdown 20 | [![CodeForces Profile](https://cf.leed.at?id={your handle})](https://codeforces.com/profile/{your handle}) 21 | ``` 22 | 23 | See [#api-endpoints](#api-endpoints) for more options. 24 | 25 | ## Screenshots 26 | 27 | #### Newbie 28 | 29 | newbie 30 | 31 | #### Pupil 32 | 33 | pupil 34 | 35 | #### Specialist 36 | 37 | specialist 38 | 39 | #### Expert 40 | 41 | expert 42 | 43 | #### Candidate Master 44 | 45 | cmaster 46 | 47 | #### Master & International Master 48 | 49 | master 50 | imaster 51 | 52 | #### Grandmaster & International GrandMaster 53 | 54 | gmaster 55 | igmaster 56 | 57 | #### Legendary Grandmaster 58 | 59 | lgmaster 60 | 61 | ## Development 62 | 63 | 1. Clone the repository: 64 | 65 | ```bash 66 | git clone 67 | cd mazacofo 68 | ``` 69 | 70 | 2. Install dependencies: 71 | 72 | ```bash 73 | yarn 74 | ``` 75 | 76 | 3. Create a `.env` file with your database configuration: 77 | 78 | ```env 79 | PORT=2021 80 | 81 | MAZA_ENV=development 82 | 83 | MYSQL_HOST=localhost 84 | MYSQL_USER=your_username 85 | MYSQL_PW=your_password 86 | MYSQL_DB=mazacofo 87 | ``` 88 | 89 | 4. Set up the database: 90 | 91 | ```bash 92 | # Create the database 93 | mysql -u your_username -p -e "CREATE DATABASE mazacofo;" 94 | 95 | # Run migrations (if needed) 96 | npm run migration:run 97 | ``` 98 | 99 | 5. Run development server 100 | 101 | ```bash 102 | ./maza run server 103 | ``` 104 | 105 | ### API Endpoints 106 | 107 | ``` 108 | GET /?id={handle}&mini={true|false} 109 | ``` 110 | 111 | Parameters: 112 | 113 | - `id`: Codeforces handle (required) 114 | - `mini`: Show mini badge (optional) 115 | -------------------------------------------------------------------------------- /maza: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source ./.env 4 | 5 | bind="./maza" 6 | 7 | y(){ 8 | export SENTRYCLI_SKIP_DOWNLOAD=1 9 | yarn $@ 10 | } 11 | 12 | run() { 13 | local target=${1:-all} 14 | shift 15 | 16 | case $target in 17 | server) 18 | y start:dev 19 | ;; 20 | esac 21 | } 22 | migrate() { 23 | local action=${1:-all} 24 | shift 25 | 26 | case $action in 27 | generate) 28 | y mikro-orm migration:create 29 | ;; 30 | run) 31 | y mikro-orm migration:up 32 | ;; 33 | down) 34 | y mikro-orm migration:down 35 | ;; 36 | esac 37 | } 38 | concurrently() { 39 | # `--raw`: 40 | # Output only raw output of processes, disables prettifying and concurrently coloring. 41 | # 42 | # `--kill-others`: 43 | # It will kill other processes if one exits or dies and prevent the development server from 44 | # starting without dependencies like a database. 45 | # 46 | # `| cat`: 47 | # The Create React App(CRA)'s command will automatically clear the previous console output, 48 | # and that's troublesome because it also clears the other console output written by Docker and Django. 49 | # So far, there is no option for disabling this CRA's behavior, but it can be achieved by piping 50 | # the CRA's output to the cat commmand. See https://github.com/facebook/create-react-app/issues/794. 51 | yarn concurrently --raw --kill-others-on-fail "$@" 52 | } 53 | 54 | if [ "$#" -eq "0" ]; then 55 | help 56 | exit 0 57 | fi 58 | 59 | trap exit SIGINT 60 | 61 | eval "$@" 62 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mazacofo", 3 | "version": "1.1.0", 4 | "description": "Codeforces rating badge generator", 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "NODE_OPTIONS=--max_old_space_size=4096 nest build && yarn sentry:sourcemaps", 8 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 9 | "start": "NODE_OPTIONS=--max_old_space_size=4096 nest start", 10 | "start:dev": "NODE_OPTIONS=--max_old_space_size=4096 nest start --watch", 11 | "start:debug": "NODE_OPTIONS=--max_old_space_size=4096 nest start --debug --watch", 12 | "start:prod": "NODE_OPTIONS=--max_old_space_size=4096 node dist/main", 13 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 14 | "test": "jest", 15 | "test:watch": "jest --watch", 16 | "test:cov": "jest --coverage", 17 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 18 | "test:e2e": "jest --config ./test/jest-e2e.json", 19 | "sentry:sourcemaps": "sentry-cli sourcemaps inject --org kodori --project mazacofo ./dist && sentry-cli sourcemaps upload --org kodori --project mazacofo ./dist" 20 | }, 21 | "dependencies": { 22 | "@mikro-orm/core": "^6.1.1", 23 | "@mikro-orm/mysql": "^6.1.1", 24 | "@mikro-orm/nestjs": "^6.1.1", 25 | "@nestjs/common": "^10.0.0", 26 | "@nestjs/core": "^10.0.0", 27 | "@nestjs/platform-express": "^10.0.0", 28 | "@nestjs/schedule": "^4.0.0", 29 | "@sentry/nestjs": "^9.40.0", 30 | "@sentry/profiling-node": "^9.40.0", 31 | "axios": "^1.6.0", 32 | "dayjs": "^1.11.13", 33 | "dotenv": "^16.3.1", 34 | "nanoid": "^5.1.5", 35 | "reflect-metadata": "^0.1.13", 36 | "rxjs": "^7.8.1" 37 | }, 38 | "devDependencies": { 39 | "@mikro-orm/cli": "^6.4.16", 40 | "@mikro-orm/migrations": "^6.4.16", 41 | "@nestjs/cli": "^10.0.0", 42 | "@nestjs/schematics": "^10.0.0", 43 | "@nestjs/testing": "^10.0.0", 44 | "@sentry/cli": "^2.50.0", 45 | "@types/express": "^4.17.17", 46 | "@types/jest": "^29.5.2", 47 | "@types/node": "^20.3.1", 48 | "@types/supertest": "^2.0.12", 49 | "@typescript-eslint/eslint-plugin": "^6.0.0", 50 | "@typescript-eslint/parser": "^6.0.0", 51 | "eslint": "^8.42.0", 52 | "eslint-config-prettier": "^9.0.0", 53 | "eslint-plugin-prettier": "^5.0.0", 54 | "jest": "^29.5.0", 55 | "prettier": "^3.0.0", 56 | "source-map-support": "^0.5.21", 57 | "supertest": "^6.3.3", 58 | "ts-jest": "^29.1.0", 59 | "ts-loader": "^9.4.3", 60 | "ts-node": "^10.9.1", 61 | "tsconfig-paths": "^4.2.0", 62 | "typescript": "^5.1.3" 63 | }, 64 | "jest": { 65 | "moduleFileExtensions": [ 66 | "js", 67 | "json", 68 | "ts" 69 | ], 70 | "rootDir": "src", 71 | "testRegex": ".*\\.spec\\.ts$", 72 | "transform": { 73 | "^.+\\.(t|j)s$": "ts-jest" 74 | }, 75 | "collectCoverageFrom": [ 76 | "**/*.(t|j)s" 77 | ], 78 | "coverageDirectory": "../coverage", 79 | "testEnvironment": "node" 80 | }, 81 | "mikro-orm": { 82 | "useTsNode": true, 83 | "configPaths": [ 84 | "./src/database/mikro-orm.config.ts" 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { APP_FILTER } from '@nestjs/core'; 3 | import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup'; 4 | 5 | import { CodeforcesModule } from './packages/codeforces/codeforces.module'; 6 | import { BadgeModule } from './packages/badge/badge.module'; 7 | import { ClientModule } from './packages/client/client.module'; 8 | import { DatabaseModule } from './database/database.module'; 9 | import { SchedularModule } from './schedular/schedular.module'; 10 | 11 | @Module({ 12 | imports: [ 13 | SentryModule.forRoot(), 14 | DatabaseModule, 15 | CodeforcesModule, 16 | BadgeModule, 17 | ClientModule, 18 | SchedularModule, 19 | ], 20 | controllers: [], 21 | providers: [{ provide: APP_FILTER, useClass: SentryGlobalFilter }], 22 | }) 23 | export class AppModule {} 24 | -------------------------------------------------------------------------------- /src/database/abstract-models/timestamped-model.ts: -------------------------------------------------------------------------------- 1 | import dayjs, { Dayjs } from 'dayjs'; 2 | import { sql } from '@mikro-orm/core'; 3 | 4 | import { TimestampProperty } from '../decorators/timestamp-property'; 5 | 6 | export abstract class TimestampedModel { 7 | @TimestampProperty({ default: sql.now() }) 8 | createdAt: Dayjs = dayjs(); 9 | 10 | @TimestampProperty({ 11 | onUpdate: () => dayjs(), 12 | default: sql.now(), 13 | }) 14 | updatedAt: Dayjs = dayjs(); 15 | } 16 | -------------------------------------------------------------------------------- /src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { FlushMode } from '@mikro-orm/core'; 4 | import { MikroOrmModule } from '@mikro-orm/nestjs'; 5 | 6 | import mikroOrmConfig from './mikro-orm.config'; 7 | 8 | @Module({ 9 | imports: [ 10 | MikroOrmModule.forRootAsync({ 11 | useFactory: () => ({ 12 | // Ref: https://wanago.io/2022/06/06/api-nestjs-transactions-postgresql-mikroorm 13 | flushMode: FlushMode.COMMIT, 14 | debug: process.env.BINDING_ENV === 'development', 15 | ...mikroOrmConfig, 16 | }), 17 | }), 18 | ], 19 | }) 20 | export class DatabaseModule {} 21 | -------------------------------------------------------------------------------- /src/database/decorators/timestamp-property.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | import { 4 | BeforeCreate, 5 | BeforeUpdate, 6 | Property, 7 | BeforeUpsert, 8 | OnInit, 9 | OnLoad, 10 | AfterCreate, 11 | AfterUpdate, 12 | AfterUpsert, 13 | PropertyOptions, 14 | } from '@mikro-orm/core'; 15 | 16 | import { randomID } from '@/utils/random-id'; 17 | 18 | /** 19 | * Automatically converts timestamp value when load/unloading Memory <-> DB. 20 | * 21 | * - BeforeCreate, BeforeUpdate, BeforeUpsert 22 | * : Memory(Dayjs) -> DB(Timestamp String) 23 | * - AfterCreate, AfterUpdate, AfterUpsert, OnInit, OnLoad 24 | * : DB(Timestamp String) -> Memory(Dayjs) 25 | */ 26 | export function TimestampProperty(options?: PropertyOptions) { 27 | return (target, propertyName) => { 28 | const preHookName = `convertDayjsIntoTimestamp_${randomID(6)}`; 29 | const postHookName = `convertTimestampIntoDayjs_${randomID(6)}`; 30 | 31 | Object.assign(target, { 32 | [preHookName]: (args) => { 33 | const entity = args.entity; 34 | 35 | if (entity[propertyName]) { 36 | if (!dayjs.isDayjs(entity[propertyName])) { 37 | entity[propertyName] = dayjs(entity[propertyName]); 38 | } 39 | 40 | entity[propertyName] = entity[propertyName].format( 41 | 'YYYY-MM-DD HH:mm:ss', 42 | ); 43 | } 44 | }, 45 | [postHookName]: (args) => { 46 | const entity = args.entity; 47 | 48 | if (entity[propertyName]) { 49 | entity[propertyName] = dayjs(entity[propertyName]); 50 | } 51 | }, 52 | }); 53 | 54 | BeforeCreate()(target, preHookName); 55 | BeforeUpdate()(target, preHookName); 56 | BeforeUpsert()(target, preHookName); 57 | 58 | OnInit()(target, postHookName); 59 | OnLoad()(target, postHookName); 60 | AfterCreate()(target, postHookName); 61 | AfterUpdate()(target, postHookName); 62 | AfterUpsert()(target, postHookName); 63 | 64 | return Property({ type: 'timestamp', ...options })(target, propertyName); 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /src/database/mikro-orm.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@mikro-orm/mysql'; 2 | import { Migrator } from '@mikro-orm/migrations'; 3 | import { config } from 'dotenv'; 4 | import { join } from 'path'; 5 | 6 | config({ path: './.env' }); 7 | export default defineConfig({ 8 | pool: { max: 50 }, 9 | host: process.env.MYSQL_HOST, 10 | port: 3306, 11 | user: process.env.MYSQL_USER, 12 | password: process.env.MYSQL_PASSWORD, 13 | dbName: process.env.MYSQL_DB, 14 | baseDir: process.cwd(), 15 | entities: ['dist/**/*.entity.js'], 16 | migrations: 17 | process.env.MAZA_ENV === 'production' 18 | ? { path: join(process.cwd(), 'dist/migrations') } 19 | : undefined, 20 | charset: 'utf8mb4', 21 | collate: 'utf8mb4_general_ci', 22 | extensions: [Migrator], 23 | debug: process.env.MAZA_ENV === 'development', 24 | }); 25 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import './sentry'; 2 | 3 | import { NestFactory } from '@nestjs/core'; 4 | import { AppModule } from './app.module'; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AppModule); 8 | 9 | app.enableCors(); 10 | 11 | const port = process.env.PORT; 12 | await app.listen(port); 13 | 14 | console.log(`Server has started on port ${port}`); 15 | } 16 | 17 | bootstrap(); 18 | -------------------------------------------------------------------------------- /src/migrations/.snapshot-mazacofo.json: -------------------------------------------------------------------------------- 1 | { 2 | "namespaces": [], 3 | "tables": [ 4 | { 5 | "columns": { 6 | "id": { 7 | "name": "id", 8 | "type": "int", 9 | "unsigned": true, 10 | "autoincrement": true, 11 | "primary": true, 12 | "nullable": false, 13 | "length": null, 14 | "mappedType": "integer" 15 | }, 16 | "created_at": { 17 | "name": "created_at", 18 | "type": "datetime", 19 | "unsigned": false, 20 | "autoincrement": false, 21 | "primary": false, 22 | "nullable": false, 23 | "length": null, 24 | "default": "current_timestamp", 25 | "mappedType": "datetime" 26 | }, 27 | "updated_at": { 28 | "name": "updated_at", 29 | "type": "datetime", 30 | "unsigned": false, 31 | "autoincrement": false, 32 | "primary": false, 33 | "nullable": false, 34 | "length": null, 35 | "default": "current_timestamp", 36 | "mappedType": "datetime" 37 | }, 38 | "handle": { 39 | "name": "handle", 40 | "type": "varchar(255)", 41 | "unsigned": false, 42 | "autoincrement": false, 43 | "primary": false, 44 | "nullable": false, 45 | "length": 255, 46 | "mappedType": "string" 47 | }, 48 | "rank": { 49 | "name": "rank", 50 | "type": "varchar(255)", 51 | "unsigned": false, 52 | "autoincrement": false, 53 | "primary": false, 54 | "nullable": true, 55 | "length": 255, 56 | "mappedType": "string" 57 | }, 58 | "rating": { 59 | "name": "rating", 60 | "type": "int", 61 | "unsigned": false, 62 | "autoincrement": false, 63 | "primary": false, 64 | "nullable": true, 65 | "length": null, 66 | "mappedType": "integer" 67 | }, 68 | "max_rank": { 69 | "name": "max_rank", 70 | "type": "varchar(255)", 71 | "unsigned": false, 72 | "autoincrement": false, 73 | "primary": false, 74 | "nullable": true, 75 | "length": 255, 76 | "mappedType": "string" 77 | }, 78 | "top_rating": { 79 | "name": "top_rating", 80 | "type": "int", 81 | "unsigned": false, 82 | "autoincrement": false, 83 | "primary": false, 84 | "nullable": true, 85 | "length": null, 86 | "mappedType": "integer" 87 | } 88 | }, 89 | "name": "clients", 90 | "indexes": [ 91 | { 92 | "columnNames": [ 93 | "handle" 94 | ], 95 | "composite": false, 96 | "keyName": "clients_handle_unique", 97 | "constraint": true, 98 | "primary": false, 99 | "unique": true 100 | }, 101 | { 102 | "keyName": "PRIMARY", 103 | "columnNames": [ 104 | "id" 105 | ], 106 | "composite": false, 107 | "constraint": true, 108 | "primary": true, 109 | "unique": true 110 | } 111 | ], 112 | "checks": [], 113 | "foreignKeys": {}, 114 | "nativeEnums": {} 115 | } 116 | ], 117 | "nativeEnums": {} 118 | } 119 | -------------------------------------------------------------------------------- /src/migrations/Migration20250719141829.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations'; 2 | 3 | export class Migration20250719141829 extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql(`create table \`clients\` (\`id\` int unsigned not null auto_increment primary key, \`handle\` varchar(255) not null, \`rank\` varchar(255) not null, \`rating\` int not null, \`max_rank\` varchar(255) not null, \`topRating\` int not null, \`created_at\` datetime not null, \`updated_at\` datetime not null) default character set utf8mb4 collate utf8mb4_general_ci engine = InnoDB;`); 7 | this.addSql(`alter table \`clients\` add unique \`clients_handle_unique\`(\`handle\`);`); 8 | } 9 | 10 | override async down(): Promise { 11 | this.addSql(`drop table if exists \`clients\`;`); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/migrations/Migration20250719144110.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations'; 2 | 3 | export class Migration20250719144110 extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql(`alter table \`clients\` change \`topRating\` \`top_rating\` int not null;`); 7 | } 8 | 9 | override async down(): Promise { 10 | this.addSql(`alter table \`clients\` change \`top_rating\` \`topRating\` int not null;`); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/migrations/Migration20250719144543.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations'; 2 | 3 | export class Migration20250719144543 extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql(`alter table \`clients\` modify \`created_at\` datetime not null default current_timestamp, modify \`updated_at\` datetime not null default current_timestamp;`); 7 | } 8 | 9 | override async down(): Promise { 10 | this.addSql(`alter table \`clients\` modify \`created_at\` datetime not null, modify \`updated_at\` datetime not null;`); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/migrations/Migration20250719144832.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations'; 2 | 3 | export class Migration20250719144832 extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql(`alter table \`clients\` modify \`rank\` varchar(255) null, modify \`rating\` int null, modify \`max_rank\` varchar(255) null, modify \`top_rating\` int null;`); 7 | } 8 | 9 | override async down(): Promise { 10 | this.addSql(`alter table \`clients\` modify \`rank\` varchar(255) not null, modify \`rating\` int not null, modify \`max_rank\` varchar(255) not null, modify \`top_rating\` int not null;`); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/packages/badge/badge.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { BadgeController } from './controllers/badge.controller'; 4 | import { ClientModule } from '@/packages/client/client.module'; 5 | 6 | @Module({ 7 | imports: [ClientModule], 8 | controllers: [BadgeController], 9 | providers: [], 10 | }) 11 | export class BadgeModule {} 12 | -------------------------------------------------------------------------------- /src/packages/badge/controllers/badge.controller.ts: -------------------------------------------------------------------------------- 1 | import { MikroORM } from '@mikro-orm/core'; 2 | import { 3 | Controller, 4 | Get, 5 | Query, 6 | Res, 7 | HttpStatus, 8 | Logger, 9 | } from '@nestjs/common'; 10 | import type { Response } from 'express'; 11 | 12 | import { CodeforcesError } from '@/packages/codeforces/services/codeforces.service'; 13 | 14 | import { ClientService } from '../../client/services/client.service'; 15 | import { SvgUtils } from '../utils/svg.utils'; 16 | 17 | @Controller() 18 | export class BadgeController { 19 | private readonly logger = new Logger(BadgeController.name); 20 | 21 | constructor( 22 | private readonly clientService: ClientService, 23 | private readonly orm: MikroORM, 24 | ) {} 25 | 26 | @Get() 27 | async getBadge( 28 | @Query('id') id: string, 29 | @Query('mini') mini: string, 30 | @Res() res: Response, 31 | ) { 32 | res.setHeader('Content-Type', 'image/svg+xml'); 33 | res.setHeader('Cache-Control', 'public, max-age=3600'); 34 | 35 | if (!id) { 36 | return res 37 | .status(HttpStatus.BAD_REQUEST) 38 | .send('id query parameter is required'); 39 | } 40 | 41 | const idFormat = id.replace(/[a-zA-Z0-9-_]/g, ''); 42 | if (idFormat) { 43 | return res 44 | .status(HttpStatus.BAD_REQUEST) 45 | .send( 46 | 'id should only contain Latin letters, digits, underscore or dash characters', 47 | ); 48 | } 49 | 50 | const em = this.orm.em.fork(); 51 | 52 | try { 53 | const client = await this.clientService.getOrCreateClient(em, id); 54 | await em.flush(); 55 | 56 | if (!client.rank) { 57 | return res 58 | .status(HttpStatus.NOT_FOUND) 59 | .end('rating not available'); 60 | } 61 | 62 | if (mini) { 63 | return res.end(SvgUtils.svgDataMini(client)); 64 | } 65 | if (client.rank === 'legendary grandmaster') { 66 | return res.end(SvgUtils.svgDataForLGM(client)); 67 | } 68 | return res.end(SvgUtils.svgDataForGeneralRating(client)); 69 | } catch (e) { 70 | if (e instanceof CodeforcesError) { 71 | if (e.message === 'Profile not found') { 72 | return res 73 | .status(HttpStatus.NOT_FOUND) 74 | .end('Codeforces profile not found'); 75 | } 76 | } else { 77 | this.logger.error(`Unknown error (${id})`, e); 78 | } 79 | 80 | return res 81 | .status(HttpStatus.INTERNAL_SERVER_ERROR) 82 | .end('Internal server error'); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/packages/badge/utils/cofo-rating-info.ts: -------------------------------------------------------------------------------- 1 | export const TIER_COLOR: Record = { 2 | newbie: '#CBCBCB', 3 | pupil: '#32D336', 4 | specialist: '#00D9CC', 5 | expert: '#656AFF', 6 | 'candidate master': '#EE00EE', 7 | master: '#FF8C00', 8 | 'international master': '#FF8C00', 9 | grandmaster: '#FC1212', 10 | 'international grandmaster': '#FC1212', 11 | 'legendary grandmaster': '#FC1212', 12 | }; 13 | 14 | const COLOR_CODE = { 15 | GRAY: ['#968A87', '#808080', '#96878F'], 16 | GREEN: ['#089630', '#008000', '#399608'], 17 | MINT: ['#069ABF', '#03A89E', '#06BF7E'], 18 | BLUE: ['#480CE8', '#0000FF', '#0C46E8'], 19 | PURPLE: ['#C20A66', '#AA00AA', '#900AC2'], 20 | ORANGE: ['#E89D0C', '#FF8C00', '#E8650C'], 21 | RED: ['#E82C0C', '#FF0000', '#E80C7A'], 22 | }; 23 | 24 | export const MINI_COLOR: Record = { 25 | newbie: COLOR_CODE.GRAY, 26 | pupil: COLOR_CODE.GREEN, 27 | specialist: COLOR_CODE.MINT, 28 | expert: COLOR_CODE.BLUE, 29 | 'candidate master': COLOR_CODE.PURPLE, 30 | master: COLOR_CODE.ORANGE, 31 | 'international master': COLOR_CODE.ORANGE, 32 | grandmaster: COLOR_CODE.RED, 33 | 'international grandmaster': COLOR_CODE.RED, 34 | 'legendary grandmaster': COLOR_CODE.RED, 35 | }; 36 | 37 | export const RANK_EXP: Record< 38 | string, 39 | { displayName: string; min: number; max: number } 40 | > = { 41 | newbie: { 42 | displayName: 'Newbie', 43 | min: 0, 44 | max: 1199, 45 | }, 46 | pupil: { 47 | displayName: 'Pupil', 48 | min: 1200, 49 | max: 1399, 50 | }, 51 | specialist: { 52 | displayName: 'Specialist', 53 | min: 1400, 54 | max: 1599, 55 | }, 56 | expert: { 57 | displayName: 'Expert', 58 | min: 1600, 59 | max: 1899, 60 | }, 61 | 'candidate master': { 62 | displayName: 'Candidate Master', 63 | min: 1900, 64 | max: 2099, 65 | }, 66 | master: { 67 | displayName: 'Master', 68 | min: 2100, 69 | max: 2299, 70 | }, 71 | 'international master': { 72 | displayName: 'International Master', 73 | min: 2300, 74 | max: 2399, 75 | }, 76 | grandmaster: { 77 | displayName: 'Grandmaster', 78 | min: 2400, 79 | max: 2599, 80 | }, 81 | 'international grandmaster': { 82 | displayName: 'International Grandmaster', 83 | min: 2600, 84 | max: 2999, 85 | }, 86 | 'legendary grandmaster': { 87 | displayName: 'Legendary Grandmaster', 88 | min: 3000, 89 | max: 5000, 90 | }, 91 | }; 92 | -------------------------------------------------------------------------------- /src/packages/badge/utils/svg.utils.ts: -------------------------------------------------------------------------------- 1 | import { TIER_COLOR, RANK_EXP, MINI_COLOR } from './cofo-rating-info'; 2 | import { Client } from '@/packages/client/entities/client.entity'; 3 | 4 | export class SvgUtils { 5 | static svgDataForLGM(client: Client): string { 6 | return ` 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ${ 21 | client.handle[0] 22 | }${client.handle.substring( 23 | 1, 24 | )} 25 | Legendary Grandmaster 28 | 29 | 34 | ${ 42 | client.rating 43 | } 44 | 45 | 46 | top rating 47 | max rank 48 | ${ 49 | client.maxRank.length === 21 50 | ? `Legendary Grandmaster` 51 | : `${ 55 | RANK_EXP[client.maxRank]?.displayName ?? client.maxRank 56 | }` 57 | } 58 | ${ 62 | client.topRating 63 | } 64 | 65 | 66 | 67 | 68 | `; 69 | } 70 | 71 | static svgDataForGeneralRating(client: Client): string { 72 | return ` 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | ${ 85 | client.handle 86 | } 87 | ${ 90 | RANK_EXP[client.rank]['displayName'] 91 | } 92 | 93 | 98 | ${ 106 | client.rating 107 | } 108 | ${ 111 | ((client.rating - RANK_EXP[client.rank]['min']) / 112 | (RANK_EXP[client.rank]['max'] - RANK_EXP[client.rank]['min'])) * 113 | 315 + 114 | 16 >= 115 | 288 116 | ? '' 117 | : RANK_EXP[client.rank]['max'] 118 | } 119 | 120 | 121 | top rating 122 | max rank 123 | ${ 124 | client.maxRank.length === 21 125 | ? `Legendary Grandmaster` 126 | : `${ 129 | RANK_EXP[client.maxRank]['displayName'] 130 | }` 131 | } 132 | ${ 135 | client.topRating 136 | } 137 | 138 | 139 | 140 | 141 | `; 142 | } 143 | 144 | static svgDataMini(client: Client): string { 145 | const rank = client.rank; 146 | const rating = client.rating; 147 | const color = MINI_COLOR[rank]; 148 | return ` 151 | 156 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | codeforces 191 | 192 | ${rating} 193 | 194 | `; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/packages/client/client.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { ClientService } from './services/client.service'; 4 | import { CodeforcesModule } from '../codeforces/codeforces.module'; 5 | 6 | @Module({ 7 | imports: [CodeforcesModule], 8 | controllers: [], 9 | providers: [ClientService], 10 | exports: [ClientService], 11 | }) 12 | export class ClientModule {} 13 | -------------------------------------------------------------------------------- /src/packages/client/entities/client.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; 2 | 3 | import { TimestampedModel } from '@/database/abstract-models/timestamped-model'; 4 | 5 | @Entity({ tableName: 'clients' }) 6 | export class Client extends TimestampedModel { 7 | @PrimaryKey() 8 | id: number; 9 | 10 | @Property({ length: 255, unique: true }) 11 | handle: string; 12 | 13 | @Property({ length: 255, nullable: true }) 14 | rank: string | null; 15 | 16 | @Property({ nullable: true }) 17 | rating: number | null; 18 | 19 | @Property({ nullable: true }) 20 | maxRank: string | null; 21 | 22 | @Property({ nullable: true }) 23 | topRating: number | null; 24 | } 25 | -------------------------------------------------------------------------------- /src/packages/client/services/client.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { EntityManager } from '@mikro-orm/core'; 3 | import { Client } from '../entities/client.entity'; 4 | import { 5 | CodeforcesService, 6 | TCodeforcesProfile, 7 | } from '../../codeforces/services/codeforces.service'; 8 | 9 | @Injectable() 10 | export class ClientService { 11 | constructor( 12 | private readonly em: EntityManager, 13 | private readonly codeforcesService: CodeforcesService, 14 | ) {} 15 | 16 | async getClient(em: EntityManager, handle: string): Promise { 17 | return em.findOne(Client, { handle }); 18 | } 19 | 20 | async getAllClients(em: EntityManager): Promise { 21 | return em.find(Client, {}); 22 | } 23 | 24 | insertClient(em: EntityManager, userData: TCodeforcesProfile) { 25 | if (!userData.handle) return; 26 | 27 | const client = em.create(Client, { 28 | handle: userData.handle, 29 | rank: userData.rank, 30 | rating: userData.rating, 31 | maxRank: userData.maxRank, 32 | topRating: userData.maxRating, 33 | }); 34 | em.persist(client); 35 | 36 | return client; 37 | } 38 | 39 | updateClient( 40 | em: EntityManager, 41 | client: Client, 42 | userData: TCodeforcesProfile, 43 | ) { 44 | client.rank = userData.rank; 45 | client.rating = userData.rating; 46 | client.maxRank = userData.maxRank; 47 | client.topRating = userData.maxRating; 48 | 49 | em.persist(client); 50 | 51 | return client; 52 | } 53 | 54 | async getOrCreateClient(em: EntityManager, handle: string) { 55 | const c = await this.getClient(em, handle); 56 | if (c) { 57 | return c; 58 | } 59 | 60 | const profile = await this.codeforcesService.getCodeforcesProfile(handle); 61 | if (profile.length === 0) { 62 | throw new Error('never'); 63 | } 64 | 65 | // NOTE: Rename한 handle의 경우 66 | if (profile[0].handle !== handle) { 67 | return this.getOrCreateClient(em, profile[0].handle); 68 | } 69 | 70 | const client = this.insertClient(em, profile[0]); 71 | return client; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/packages/codeforces/codeforces.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { CodeforcesService } from './services/codeforces.service'; 4 | 5 | @Module({ 6 | imports: [], 7 | controllers: [], 8 | providers: [CodeforcesService], 9 | exports: [CodeforcesService], 10 | }) 11 | export class CodeforcesModule {} 12 | -------------------------------------------------------------------------------- /src/packages/codeforces/services/codeforces.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import axios from 'axios'; 3 | 4 | export interface TCodeforcesProfile { 5 | handle: string; 6 | rank: string; 7 | rating: number; 8 | maxRank: string; 9 | maxRating: number; 10 | } 11 | 12 | export type TCodeforcesResponse = 13 | | { 14 | status: 'OK'; 15 | result: TCodeforcesProfile[]; 16 | } 17 | | { 18 | status: 'FAILED'; 19 | comment: string; 20 | }; 21 | 22 | export class CodeforcesError extends Error { 23 | message: 'Profile not found' | 'Unknown error'; 24 | 25 | constructor(message: string) { 26 | super(message); 27 | this.name = 'CodeforcesError'; 28 | } 29 | } 30 | 31 | @Injectable() 32 | export class CodeforcesService { 33 | private readonly logger = new Logger(CodeforcesService.name); 34 | 35 | async getCodeforcesProfile(handle: string): Promise { 36 | const url = `https://codeforces.com/api/user.info?handles=${handle}`; 37 | 38 | try { 39 | const { data } = await axios.get(url); 40 | if (data.status === 'OK') { 41 | return data.result; 42 | } 43 | 44 | // NOTE: never 45 | throw new CodeforcesError(data.comment); 46 | } catch (error) { 47 | const { ok, changedHandle } = await this.checkIfHandleIsRenamed(handle); 48 | if (ok) { 49 | return await this.getCodeforcesProfile(changedHandle); 50 | } 51 | 52 | const reg = new RegExp('handles: User with handle (.*?) not found'); 53 | const errorMessage = error.response?.data?.comment; 54 | const matched = errorMessage?.match(reg); 55 | 56 | if (matched && matched[1]) { 57 | this.logger.warn(`Codeforces profile not found (${matched[1]})`); 58 | throw new CodeforcesError('Profile not found'); 59 | } 60 | 61 | this.logger.error(`Unknown error: ${errorMessage} (${handle})`, error); 62 | throw new CodeforcesError('Unknown error'); 63 | } 64 | } 65 | 66 | async getBulkCodeforcesProfile( 67 | handles: string[], 68 | ): Promise { 69 | const handlesStr = handles.join(';'); 70 | return this.getCodeforcesProfile(handlesStr); 71 | } 72 | 73 | private async checkIfHandleIsRenamed( 74 | handle: string, 75 | ): Promise<{ ok: boolean; changedHandle: string | null }> { 76 | try { 77 | await axios.head(`https://codeforces.com/profile/${handle}`, { 78 | maxRedirects: 0, 79 | }); 80 | } catch (e) { 81 | const response = e.response; 82 | 83 | if (response?.status === 302) { 84 | const movedURL = response.headers.location; 85 | return { 86 | ok: true, 87 | changedHandle: movedURL.replace( 88 | 'https://codeforces.com/profile/', 89 | '', 90 | ), 91 | }; 92 | } 93 | } 94 | 95 | return { ok: false, changedHandle: null }; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/schedular/schedular.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ScheduleModule } from '@nestjs/schedule'; 3 | 4 | import { SchedulerService } from './services/scheduler.service'; 5 | import { CodeforcesModule } from '@/packages/codeforces/codeforces.module'; 6 | import { ClientModule } from '@/packages/client/client.module'; 7 | 8 | @Module({ 9 | imports: [CodeforcesModule, ClientModule, ScheduleModule.forRoot()], 10 | controllers: [], 11 | providers: [SchedulerService], 12 | }) 13 | export class SchedularModule {} 14 | -------------------------------------------------------------------------------- /src/schedular/services/scheduler.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { Cron, CronExpression } from '@nestjs/schedule'; 3 | import { SentryCron } from '@sentry/nestjs'; 4 | import { MikroORM } from '@mikro-orm/core'; 5 | 6 | import { ClientService } from '@/packages/client/services/client.service'; 7 | import { CodeforcesService } from '@/packages/codeforces/services/codeforces.service'; 8 | 9 | @Injectable() 10 | export class SchedulerService { 11 | private readonly logger = new Logger(SchedulerService.name); 12 | 13 | constructor( 14 | private readonly orm: MikroORM, 15 | private readonly clientService: ClientService, 16 | private readonly codeforcesService: CodeforcesService, 17 | ) {} 18 | 19 | @Cron(CronExpression.EVERY_MINUTE) 20 | @SentryCron('update-codeforces-data', { 21 | schedule: { 22 | type: 'crontab', 23 | value: CronExpression.EVERY_MINUTE, 24 | }, 25 | checkinMargin: 2, 26 | maxRuntime: 3, 27 | timezone: 'Asia/Seoul', 28 | }) 29 | async updateCodeforcesData() { 30 | const em = this.orm.em.fork(); 31 | this.logger.log('Starting Codeforces data update...'); 32 | 33 | try { 34 | await em.begin(); 35 | 36 | const clientList = await this.clientService.getAllClients(em); 37 | const handles = clientList.map((client) => client.handle); 38 | 39 | if (handles.length === 0) { 40 | this.logger.log('No clients to update'); 41 | return; 42 | } 43 | 44 | const profiles = 45 | await this.codeforcesService.getBulkCodeforcesProfile(handles); 46 | 47 | const updatedHandles = [], 48 | insertedHandles = []; 49 | for (const p of profiles) { 50 | const c = await this.clientService.getClient(em, p.handle); 51 | if (c) { 52 | this.clientService.updateClient(em, c, p); 53 | updatedHandles.push(p.handle); 54 | continue; 55 | } 56 | 57 | this.clientService.insertClient(em, p); 58 | insertedHandles.push(p.handle); 59 | } 60 | 61 | await em.commit(); 62 | 63 | this.logger.log( 64 | `Successfully updated ${updatedHandles.length} clients (${updatedHandles.join( 65 | ',', 66 | )})`, 67 | ); 68 | this.logger.log( 69 | `Successfully inserted ${insertedHandles.length} clients (${insertedHandles.join( 70 | ',', 71 | )})`, 72 | ); 73 | } catch (e) { 74 | await em.rollback(); 75 | 76 | this.logger.error('Error updating Codeforces data:', e); 77 | 78 | throw e; 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/sentry.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/nestjs'; 2 | import type { NodeOptions } from '@sentry/nestjs'; 3 | import { nodeProfilingIntegration } from '@sentry/profiling-node'; 4 | import { config } from 'dotenv'; 5 | config({ path: './.env' }); 6 | 7 | const getOptions = (integrations: any[]): NodeOptions => ({ 8 | environment: process.env.MAZA_ENV, 9 | dsn: 'https://3c1748b137f2d0ce8a22f9589c0d9477@o4507556578852864.ingest.us.sentry.io/4509700977852416', 10 | integrations, 11 | // Tracing 12 | tracesSampleRate: 1.0, // Capture 100% of the transactions 13 | 14 | // Set sampling rate for profiling - this is relative to tracesSampleRate 15 | profilesSampleRate: 1.0, 16 | }); 17 | 18 | if (process.env.MAZA_ENV === 'production') { 19 | Sentry.init(getOptions([nodeProfilingIntegration()])); 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/random-id.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid'; 2 | 3 | type TOptions = { 4 | numberOnly?: boolean; 5 | }; 6 | export const randomID = (size = 8, options: TOptions = {}) => { 7 | const { numberOnly } = options; 8 | 9 | if (numberOnly) { 10 | return customAlphabet('1234567890')(size); 11 | } 12 | 13 | return customAlphabet( 14 | '1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-', 15 | )(size); 16 | }; 17 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2020", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "paths": { 14 | "@/*": ["./src/*"] 15 | }, 16 | "incremental": true, 17 | "skipLibCheck": true, 18 | "strictNullChecks": false, 19 | "noImplicitAny": false, 20 | "strictBindCallApply": false, 21 | "forceConsistentCasingInFileNames": false, 22 | "noFallthroughCasesInSwitch": false, 23 | "esModuleInterop": true 24 | } 25 | } --------------------------------------------------------------------------------