diff --git a/.all-contributorsrc b/.all-contributorsrc index 2661aa0b..f0fe22b7 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -112,9 +112,38 @@ "contributions": [ "doc" ] + }, + { + "login": "AdiPol1359", + "name": "Adrian Polak", + "avatar_url": "https://avatars.githubusercontent.com/u/27779154?v=4", + "profile": "https://projectcode.pl/", + "contributions": [ + "code" + ] + }, + { + "login": "xStrixU", + "name": "xStrixU", + "avatar_url": "https://avatars.githubusercontent.com/u/41890821?v=4", + "profile": "https://github.com/xStrixU", + "contributions": [ + "code" + ] + }, + { + "login": "grzegorzpokorski", + "name": "Grzegorz Pokorski", + "avatar_url": "https://avatars.githubusercontent.com/u/27455716?v=4", + "profile": "https://github.com/grzegorzpokorski", + "contributions": [ + "doc", + "bug", + "code" + ] } ], - "contributorsPerLine": 7, + "contributorsPerLine": 3, "skipCi": true, "badgeTemplate": "[![All Contributors](https://img.shields.io/badge/Contributors-<%= contributors.length %>-673ab7.svg)](#contributors-)" } diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index c25ab495..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,84 +0,0 @@ -version: 2.1 -orbs: - node: circleci/node@1.1.6 - -jobs: - dangerfile: - docker: - - image: circleci/node:12-browsers - steps: - - run: | - if [ "$CIRCLE_BRANCH" = "develop" ] || [ "$CIRCLE_BRANCH" = "main" ]; then - circleci-agent step halt - fi - - - checkout - - restore_cache: - name: Restore Yarn Package Cache - keys: - - yarn-packages-{{ checksum "yarn.lock" }} - - yarn-packages- - - run: yarn install --frozen-lockfile - - run: yarn cache dir - - - run: yarn get-base-branch - - run: git status - - run: cat /tmp/.basebranch - - run: git fetch && git checkout $CIRCLE_BRANCH && git reset --hard origin/$CIRCLE_BRANCH - - run: git diff --name-only HEAD $(cat /tmp/.basebranch) - - when: - condition: git diff --name-only HEAD $(cat /tmp/.basebranch) | grep -q "apps/www" - steps: - - run: cp apps/www/.env apps/www/.env.staging - - run: cp apps/www/.env apps/www/.env.production - - run: yarn workspace www build > analyze.next - - run: rm apps/www/.env.staging - - run: rm apps/www/.env.production - - run: cat analyze.next - - run: yarn create-size && mv size-snapshot.json /tmp/current-size-snapshot.json - - run: rm analyze.next && rm -rf apps/www/.next - - - run: git checkout $(cat /tmp/.basebranch) && git reset --hard origin/$(cat /tmp/.basebranch) - - run: git status - - run: yarn install --frozen-lockfile - - run: cp apps/www/.env apps/www/.env.staging - - run: cp apps/www/.env apps/www/.env.production - - run: yarn workspace www build > analyze.next - - run: rm apps/www/.env.staging - - run: rm apps/www/.env.production - - run: cat analyze.next - - run: mv analyze.next /tmp/ - - - run: git checkout $CIRCLE_BRANCH && git reset --hard origin/$CIRCLE_BRANCH - - run: git status - - run: yarn install --frozen-lockfile - - run: cp apps/www/.env apps/www/.env.staging - - run: cp apps/www/.env apps/www/.env.production - - - run: mv /tmp/analyze.next ./ - - run: yarn create-size && mv size-snapshot.json previous-size-snapshot.json - - run: mv /tmp/current-size-snapshot.json ./ - - # - run: mkdir -p /tmp/lighthouse/ - - run: yarn danger ci - - - save_cache: - name: Save Yarn Package Cache - key: yarn-packages-{{ checksum "yarn.lock" }} - paths: - - ~/.cache/yarn - - /home/circleci/.cache/yarn/v6 - - - store_artifacts: - path: ./current-size-snapshot.json - # - store_artifacts: - # path: /tmp/lighthouse - -workflows: - dangerfile: - jobs: - - dangerfile: - filters: - branches: - ignore: - - /dependabot\/*/ diff --git a/apps/www/.editorconfig b/.editorconfig similarity index 100% rename from apps/www/.editorconfig rename to .editorconfig diff --git a/.eslintignore b/.eslintignore deleted file mode 120000 index 3e4e48b0..00000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -.gitignore \ No newline at end of file diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index a0a954dd..00000000 --- a/.eslintrc +++ /dev/null @@ -1,18 +0,0 @@ -{ - "root": true, - "parser": "@typescript-eslint/parser", - "parserOptions": { - "sourceType": "module" - }, - "plugins": [], - "extends": ["prettier", "plugin:import/typescript"], - "rules": { - "no-const-assign": "error", - "import/no-anonymous-default-export": "error", - "import/dynamic-import-chunkname": "error", - "import/order": ["error", { "newlines-between": "always", "alphabetize": { "order": "asc" } }], - "import/no-duplicates": "error", - "import/no-cycle": "error", - "@typescript-eslint/no-unused-vars": "off" - } -} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..5bc6cff8 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,10 @@ +module.exports = { + root: true, + // This tells ESLint to load the config from the package `eslint-config-devfaq` + extends: ["devfaq"], + settings: { + next: { + rootDir: ["apps/*/"], + }, + }, +}; diff --git a/.fossa.yml b/.fossa.yml deleted file mode 100755 index 546a93fd..00000000 --- a/.fossa.yml +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by FOSSA CLI (https://github.com/fossas/fossa-cli) -# Visit https://fossa.com to learn more - -version: 2 -cli: - server: https://app.fossa.com - fetcher: custom - project: git@github.com:typeofweb/devfaq.git -analyze: - modules: - - name: api - type: npm - target: apps/api - path: apps/api - - name: www - type: npm - target: apps/www - path: apps/www - - name: . - type: npm - target: . - path: . diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 62db841e..00000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,9 +0,0 @@ -apps/api/app.js @mmiszy -apps/api/src @mmiszy - -apps/www/app.js @mmiszy -apps/www/pages @mmiszy -apps/www/components @mmiszy -apps/www/public @mmiszy - -scripts @mmiszy diff --git a/.github/workflows/auto-approve-dependabot.yml b/.github/workflows/auto-approve-dependabot.yml deleted file mode 100644 index 0c4d9b11..00000000 --- a/.github/workflows/auto-approve-dependabot.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Auto approve dependabot - -on: - pull_request: - branches: [develop] - -jobs: - auto-approve: - runs-on: ubuntu-latest - steps: - - uses: hmarr/auto-approve-action@v2.0.0 - if: github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]' - with: - github-token: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 7e8b529f..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: "CodeQL" - -on: - push: - branches: [develop, main] - pull_request: - # The branches below must be a subset of the branches above - branches: [develop] - schedule: - - cron: '0 19 * * 3' - -jobs: - analyse: - name: Analyse - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 - - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - # Override language selection by uncommenting this and choosing your languages - # with: - # languages: go, javascript, csharp, python, cpp, java - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..8e1d8816 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,73 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: ["develop", main] + pull_request: + # The branches below must be a subset of the branches above + branches: ["develop"] + schedule: + - cron: "41 18 * * 2" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["javascript"] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml new file mode 100644 index 00000000..654e0523 --- /dev/null +++ b/.github/workflows/deploy-api.yml @@ -0,0 +1,66 @@ +name: Fly Deploy +on: + workflow_dispatch: + push: + branches: + - develop +jobs: + deploy: + name: Deploy API + runs-on: ubuntu-latest + environment: staging-api + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - name: Fail if branch is not main + if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/develop' + run: | + echo "This workflow should not be triggered with workflow_dispatch on a branch other than develop" + exit 1 + + - name: Verify if deploy is needed + id: should_run + if: github.event_name != 'workflow_dispatch' + shell: bash + run: | + HAS_CHANGES=$(npx -y turbo run build --filter='api...[HEAD^]' --dry=json | jq '.packages | length > 0') + echo "HAS_CHANGES=$HAS_CHANGES" >> $GITHUB_OUTPUT + + - name: Create GitHub deployment + if: steps.should_run.outputs.HAS_CHANGES != 'false' + uses: chrnorm/deployment-action@v2 + id: deployment + with: + token: "${{ github.token }}" + environment-url: https://staging-api.devfaq.pl + environment: staging-api + + - name: Prepare flyctl + if: steps.should_run.outputs.HAS_CHANGES != 'false' + uses: superfly/flyctl-actions/setup-flyctl@master + + - name: Deploy to fly.io + if: steps.should_run.outputs.HAS_CHANGES != 'false' + run: flyctl deploy --config apps/api/fly.toml --remote-only -e GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) -e GIT_COMMIT_HASH=$(git rev-parse HEAD) + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + + - name: Update deployment status (success) + if: steps.should_run.outputs.HAS_CHANGES != 'false' && success() + uses: chrnorm/deployment-status@v2 + with: + token: "${{ github.token }}" + environment-url: ${{ steps.deployment.outputs.environment_url }} + deployment-id: ${{ steps.deployment.outputs.deployment_id }} + state: "success" + + - name: Update deployment status (failure) + if: steps.should_run.outputs.HAS_CHANGES != 'false' && failure() + uses: chrnorm/deployment-status@v2 + with: + token: "${{ github.token }}" + environment-url: ${{ steps.deployment.outputs.environment_url }} + deployment-id: ${{ steps.deployment.outputs.deployment_id }} + state: "failure" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 5ee86cd9..00000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Deploy to staging and production - -on: - push: - branches: [main, develop] - -jobs: - deploy: - runs-on: ubuntu-latest - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_LOG_LEVEL: debug - - steps: - - uses: actions/checkout@v2 - - - name: Setup SSH Keys and known_hosts - env: - SSH_AUTH_SOCK: /tmp/ssh_agent.sock - run: | - mkdir -p ~/.ssh - ssh-keyscan -H github.com >> ~/.ssh/known_hosts - ssh-keyscan -H s18.mydevil.net >> ~/.ssh/known_hosts - ssh-agent -a $SSH_AUTH_SOCK > /dev/null - ssh-add - <<< "${{ secrets.SSH_PRIVATE_KEY }}" - if [[ "${GITHUB_REF##*/}" == 'develop' ]]; then ENV="staging"; fi - if [[ "${GITHUB_REF##*/}" == 'main' ]]; then ENV="production"; fi - ssh typeofweb@s18.mydevil.net 'source ~/.bashrc && ssh-add ~/.ssh/github && bash -s' < ./scripts/ssh-script-deploy.sh $ENV - - - name: Create Sentry Release - run: | - if [[ "${GITHUB_REF##*/}" == 'develop' ]]; then ENV="staging"; fi - if [[ "${GITHUB_REF##*/}" == 'main' ]]; then ENV="production"; fi - - # Install Sentry CLI - curl -sL https://sentry.io/get-cli/ | bash - - # Create new Sentry release - export SENTRY_VERSION=$(sentry-cli releases propose-version) - - sentry-cli releases --org=typeofweb --project=devfaq-api new $SENTRY_VERSION - sentry-cli releases --org=typeofweb --project=devfaq-api set-commits --auto $SENTRY_VERSION - sentry-cli releases --org=typeofweb --project=devfaq-api finalize $SENTRY_VERSION - sentry-cli releases --org=typeofweb --project=devfaq-api deploys $SENTRY_VERSION new -e $ENV - - sentry-cli releases --org=typeofweb --project=devfaq-www new $SENTRY_VERSION - sentry-cli releases --org=typeofweb --project=devfaq-www set-commits --auto $SENTRY_VERSION - sentry-cli releases --org=typeofweb --project=devfaq-www finalize $SENTRY_VERSION - sentry-cli releases --org=typeofweb --project=devfaq-www deploys $SENTRY_VERSION new -e $ENV diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml new file mode 100644 index 00000000..792004db --- /dev/null +++ b/.github/workflows/lighthouse.yml @@ -0,0 +1,33 @@ +name: Lighthouse + +concurrency: + group: lighthouse-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +on: [pull_request] +jobs: + lighthouse: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Wait for Vercel preview deployment + uses: patrickedqvist/wait-for-vercel-preview@v1.2.0 + id: waitForVercelPreviewDeployment + with: + token: ${{ secrets.GITHUB_TOKEN }} + max_timeout: 600 + check_interval: 15 + + - name: Warm-up devfaq cache + run: | + curl ${{ steps.waitForVercelPreviewDeployment.outputs.url }} + curl ${{ steps.waitForVercelPreviewDeployment.outputs.url }}/questions/js/1 + + - name: Lighthouse + uses: foo-software/lighthouse-check-action@v9.1.0 + with: + urls: ${{ steps.waitForVercelPreviewDeployment.outputs.url }} + gitHubAccessToken: ${{ secrets.GITHUB_TOKEN }} + locale: pl + prCommentEnabled: true diff --git a/.github/workflows/nextjs_bundle_analysis.yml b/.github/workflows/nextjs_bundle_analysis.yml new file mode 100644 index 00000000..1bc2c73a --- /dev/null +++ b/.github/workflows/nextjs_bundle_analysis.yml @@ -0,0 +1,138 @@ +name: "Next.js Bundle Analysis" + +concurrency: + group: analyze-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +on: + pull_request: + push: + branches: + - develop + workflow_dispatch: + +defaults: + run: + working-directory: ./ + +jobs: + analyze: + runs-on: ubuntu-latest + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + NEXT_PUBLIC_API_URL: https://staging-api.devfaq.pl + + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-node@v3 + with: + node-version-file: ".nvmrc" + + - uses: actions/setup-node@v3 + with: + node-version-file: ".nvmrc" + + - uses: pnpm/action-setup@v2 + with: + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: node-cache-${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-cache-${{ runner.os }}-pnpm- + + - name: Install dependencies + run: pnpm --version && pnpm install --frozen-lockfile + + - name: Restore next build + uses: actions/cache@v3 + id: restore-build-cache + env: + cache-name: cache-next-build + with: + path: apps/app/.next/cache + key: ${{ runner.os }}-build-${{ env.cache-name }} + + - name: Build next.js app + run: pnpm build --filter=app + + - name: Analyze bundle + run: cd apps/app && npx -p nextjs-bundle-analysis report + + - name: Upload bundle + uses: actions/upload-artifact@v3 + with: + name: bundle + path: apps/app/.next/analyze/__bundle_analysis.json + + - name: Download base branch bundle stats + uses: dawidd6/action-download-artifact@v2 + if: success() && github.event.number + with: + workflow: nextjs_bundle_analysis.yml + # branch: ${{ github.event.pull_request.base.ref }} + commit: ${{ github.event.pull_request.base.sha }} + path: apps/app/.next/analyze/base + + # And here's the second place - this runs after we have both the current and + # base branch bundle stats, and will compare them to determine what changed. + # There are two configurable arguments that come from package.json: + # + # - budget: optional, set a budget (bytes) against which size changes are measured + # it's set to 350kb here by default, as informed by the following piece: + # https://infrequently.org/2021/03/the-performance-inequality-gap/ + # + # - red-status-percentage: sets the percent size increase where you get a red + # status indicator, defaults to 20% + # + # Either of these arguments can be changed or removed by editing the `nextBundleAnalysis` + # entry in your package.json file. + - name: Compare with base branch bundle + if: success() && github.event.number + run: ls -laR apps/app/.next/analyze/base && cd apps/app && npx -p nextjs-bundle-analysis compare + + - name: Get comment body + id: get-comment-body + if: success() && github.event.number + uses: actions/github-script@v6 + with: + result-encoding: string + script: | + const fs = require('fs') + const comment = fs.readFileSync('apps/app/.next/analyze/__bundle_analysis_comment.txt', 'utf8') + core.setOutput('body', comment) + + - name: Find Comment + uses: peter-evans/find-comment@v2 + if: success() && github.event.number + id: fc + with: + issue-number: ${{ github.event.number }} + body-includes: "" + + - name: Create Comment + uses: peter-evans/create-or-update-comment@v2 + if: success() && github.event.number && steps.fc.outputs.comment-id == 0 + with: + issue-number: ${{ github.event.number }} + body: ${{ steps.get-comment-body.outputs.body }} + + - name: Update Comment + uses: peter-evans/create-or-update-comment@v2 + if: success() && github.event.number && steps.fc.outputs.comment-id != 0 + with: + issue-number: ${{ github.event.number }} + body: ${{ steps.get-comment-body.outputs.body }} + comment-id: ${{ steps.fc.outputs.comment-id }} + edit-mode: replace diff --git a/.github/workflows/test-PR.yml b/.github/workflows/test-PR.yml deleted file mode 100644 index caa8aa22..00000000 --- a/.github/workflows/test-PR.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: Test and Build - -on: - pull_request: - branches: [develop, main] - -jobs: - test_www: - if: "! contains(toJSON(github.event.commits.*.message), '[skip-ci]')" - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 100 - - - uses: marceloprado/has-changed-path@master - id: changed-www - with: - paths: apps/www - - - name: Read .nvmrc - if: steps.changed-www.outputs.changed == 'true' - run: echo "##[set-output name=NVMRC;]$(cat .nvmrc)" - id: nvm - - name: Use Node.js - if: steps.changed-www.outputs.changed == 'true' - uses: actions/setup-node@v1 - with: - node-version: '${{ steps.nvm.outputs.NVMRC }}' - - - name: Get yarn cache directory path - if: steps.changed-www.outputs.changed == 'true' - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - - name: Cache Node.js modules - if: steps.changed-www.outputs.changed == 'true' - uses: actions/cache@v1 - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - ${{ runner.OS }}- - - - run: cp apps/www/.env apps/www/.env.staging - - run: cp apps/www/.env apps/www/.env.production - - - name: Install dependencies - if: steps.changed-www.outputs.changed == 'true' - run: yarn workspace www install --frozen-lockfile - - - name: Run tests - if: steps.changed-www.outputs.changed == 'true' - run: yarn workspace www test - - - name: Run build - if: steps.changed-www.outputs.changed == 'true' - run: yarn workspace www build - - test_api: - if: "! contains(toJSON(github.event.commits.*.message), '[skip-ci]')" - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 100 - - - uses: marceloprado/has-changed-path@master - id: changed-api - with: - paths: apps/api - - - name: Setup PostgreSQL - if: steps.changed-api.outputs.changed == 'true' - uses: Harmon758/postgresql-action@v1.0.0 - with: - postgresql version: 12-alpine - postgresql db: database_development - postgresql user: postgres - postgresql password: -api2018 - - - name: Read .nvmrc - if: steps.changed-api.outputs.changed == 'true' - run: echo "##[set-output name=NVMRC;]$(cat .nvmrc)" - id: nvm - - name: Use Node.js - if: steps.changed-api.outputs.changed == 'true' - uses: actions/setup-node@v1 - with: - node-version: '${{ steps.nvm.outputs.NVMRC }}' - - name: Get yarn cache directory path - if: steps.changed-api.outputs.changed == 'true' - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - - name: Cache Node.js modules - if: steps.changed-api.outputs.changed == 'true' - uses: actions/cache@v1 - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - ${{ runner.OS }}- - - - name: Install dependencies - if: steps.changed-api.outputs.changed == 'true' - run: yarn workspace api install --frozen-lockfile - - - name: Run tests - if: steps.changed-api.outputs.changed == 'true' - run: yarn workspace api test - - - name: Run build for dependabot - if: steps.changed-api.outputs.changed == 'true' && (github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]') - run: yarn workspace api build diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..b45c8135 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,97 @@ +name: Tests + +concurrency: + group: tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +on: [pull_request] + +jobs: + build_and_test: + runs-on: ubuntu-latest + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + NEXT_PUBLIC_API_URL: https://staging-api.devfaq.pl + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + node-version-file: ".nvmrc" + + - uses: pnpm/action-setup@v2 + with: + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: node-cache-${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-cache-${{ runner.os }}-pnpm- + + - name: Install dependencies + run: pnpm --version && pnpm install --frozen-lockfile + + - name: Turbo Cache + uses: actions/cache@v3 + with: + path: .turbo + key: turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} + restore-keys: | + turbo-${{ github.job }}-${{ github.ref_name }}- + + - name: Build + run: pnpm run build --cache-dir=".turbo" + + - name: Check linters + run: pnpm run lint --cache-dir=".turbo" + + - name: Run tests + run: pnpm run test --cache-dir=".turbo" + + - name: Check TypeScript + run: pnpm run check-types --cache-dir=".turbo" + + storybook: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + node-version-file: ".nvmrc" + + - uses: pnpm/action-setup@v2 + with: + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: node-cache-${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-cache-${{ runner.os }}-pnpm- + + - name: Install dependencies + run: pnpm --version && pnpm install --frozen-lockfile + + - name: Build Storybook + run: pnpm --filter=app run build-storybook diff --git a/.gitignore b/.gitignore index d6c45f29..c524296a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,91 +1,35 @@ -npm-debug.log -.next -out -previous-size-snapshot.json -current-size-snapshot.json -size-snapshot.json -analyze.next -.deployment-url -.basebranch -package-lock.json -spmdb/ -spmlogs/ -newrelic.js - +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +# dependencies node_modules -.tmp -.idea -.DS_Store -.version -dist -.history - -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* +.pnp +.pnp.js -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -junit -test-results.xml - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul +# testing coverage -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript +# next.js +.next/ +out/ +build -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz +# misc +.DS_Store +*.pem -# Yarn Integrity file -.yarn-integrity +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* -# dotenv environment variables file .env -.env.dev -apps/www/.env.staging -apps/www/.env.production +.env.local +.env.development.local +.env.test.local +.env.production.local -*.tsbuildinfo - -# cypress +# turbo +.turbo -apps/www/cypress/screenshots -apps/www/cypress/videos +*.tsbuildinfo diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..a5a29d9f --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +pnpm lint-staged diff --git a/.lintstagedrc.js b/.lintstagedrc.js new file mode 100644 index 00000000..c8932c05 --- /dev/null +++ b/.lintstagedrc.js @@ -0,0 +1,4 @@ +module.exports = { + "*.{js,jsx,ts,tsx,md,mdx,graphql,yml,yaml,css,scss,json}": ["pnpm prettier --write"], + "*.{js,jsx,ts,tsx}": [() => "pnpm lint:fix"], +}; diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..daefc04d --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +auto-install-peers=true +strict-peer-dependencies=false +save-exact=true diff --git a/.nvmrc b/.nvmrc index 48082f72..3c032078 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -12 +18 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..3ae76f13 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +apps/app/.next +pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index cbd1fe37..00000000 --- a/.prettierrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "semi": true, - "singleQuote": true, - "trailingComma": "es5", - "printWidth": 100 -} diff --git a/.vscode/settings.json b/.vscode/settings.json index 65266697..1b6500b0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,44 +1,14 @@ { - "editor.formatOnSave": true, - "editor.formatOnType": true, - "prettier.disableLanguages": ["json", "scss", "markdown"], - "[json]": { - "editor.formatOnSave": false, - "editor.formatOnType": false - }, - "[markdown]": { - "editor.formatOnSave": false, - "editor.formatOnType": false - }, - "[scss]": { - "editor.formatOnSave": false, - "editor.formatOnType": false - }, - "search.exclude": { - "**/node_modules": true, - "**/bower_components": true, - ".next": true, - "**/dist": true - }, - "typescript.tsdk": "node_modules/typescript/lib", - "typescript.preferences.importModuleSpecifier": "relative", - "editor.codeActionsOnSave": { - "source.fixAll.eslint": true - }, - "workbench.colorCustomizations": { - "titleBar.activeBackground": "#673ab7", - "titleBar.inactiveBackground": "#401886", - "titleBar.activeForeground": "#ffffff", - "titleBar.inactiveForeground": "#ffffff" - }, - "tslint.autoFixOnSave": true, - "files.exclude": { - "**/.git": true, - "**/.svn": true, - "**/.hg": true, - "**/CVS": true, - "**/.DS_Store": true, - "**/dist": true - }, - "prettier.configPath": "./.prettierrc" + "typescript.tsdk": "node_modules/typescript/lib", + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + }, + "workbench.colorCustomizations": { + "titleBar.activeBackground": "#673ab7", + "titleBar.inactiveBackground": "#401886", + "titleBar.activeForeground": "#ffffff", + "titleBar.inactiveForeground": "#ffffff" + } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85c6dd10..e977a31b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,18 +6,19 @@ ## Introduction -DevFAQ is organised into a monorepo with lerna and yarn workspaces. You'll find frontend ([www](./apps/www)) and backend ([api](./apps/api)) in the [apps](./apps) directory. +DevFAQ is organised into a monorepo with Turborepo. You'll find frontend ([app](./apps/app)) and backend ([api](./apps/api)) in the [apps](./apps) directory. - Frontend is written in **Next.js (React) with TypeScript**. -- Backend is a REST API, and uses **HapiJS, PostgreSQL, and TypeScript**. +- Backend is a REST API, and uses **Fastify, PostgreSQL, and TypeScript**. ## Project setup -0. Make sure you have Docker installed and `docker-compose` command is available. +0. Make sure you have Docker installed and `docker compose` command is available. 1. Fork and clone the repo. `develop` is the default branch and you should base your work off of it. -2. Run `yarn` inside the repo to install all the dependencies. -3. Run `yarn dev` to start both frontend and backend locally. +2. Run `pnpm install` inside the repo to install all the dependencies. +3. Run `pnpm dev` to start both frontend and backend locally. 4. In order for everything to work smoothly, you'll need to add two entries to your `/etc/hosts`. See [Configuring localhost domain](#configuring-localhost-domain) section. +5. Remember to add `.env*` files to each app located in `apps` directory. For `apps/api` it will be `.env` - example file with variables is named `.env-example` and you can find it in `apps/api`. For `apps/app`, example env file is named `.env.local-example` and it is located in `apps/app`. ### Configuring localhost domain @@ -35,14 +36,14 @@ Now you should be able to access your app at [app.devfaq.localhost:3000](http:// There are only a few tests and we definitely need more! To run all tests execute the following command: ``` -yarn test +pnpm test ``` If you need to run only www or only api tests, you can do it as follows: ``` -yarn workspace www test -yarn workspace api test +pnpm test --filter=www +pnpm test --filter=api ``` ## Creating a PR diff --git a/README.md b/README.md index 0ddec95c..828d9f21 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # DevFAQ -[![All Contributors](https://img.shields.io/badge/Contributors-11-673ab7.svg)](#contributors-) + +[![All Contributors](https://img.shields.io/badge/Contributors-14-673ab7.svg)](#contributors-) + [![Sponsor Type of Web](https://badgen.net/badge/icon/Sponsor%20%E2%9D%A4?icon=github&label&color=ea4aaa)](https://github.com/sponsors/typeofweb) ![Test and Build](https://github.com/typeofweb/devfaq/workflows/Test%20and%20Build/badge.svg) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=typeofweb_devfaq&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=typeofweb_devfaq) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftypeofweb%2Fdevfaq.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftypeofweb%2Fdevfaq?ref=badge_shield) [![Discord](https://img.shields.io/discord/440163731704643589?color=738ADB&label=Discord&logo=discord&logoColor=white)](https://discord.typeofweb.com/) @@ -14,7 +16,6 @@ See [opencollective.com/typeofweb](https://opencollective.com/typeofweb) or [git - ## Contributors ✨ **See [CONTRIBUTING](./CONTRIBUTING.md).** @@ -25,21 +26,32 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + +

Michał Miszczyszyn

💻 🚧 📦 🤔

Tomasz Nastały

💻 🤔

Bartosz Cytrowski

🖋

Pawel Pawlowski

🎨

Survikrowa

💻

mczeplowski

💻

Bartosz Dryl

💻

Kuba Domański

👀

Jakub Kisielewski

👀

KonradNojman

👀

Patryk Górka

📖
Michał Miszczyszyn
Michał Miszczyszyn

💻 🚧 📦 🤔
Tomasz Nastały
Tomasz Nastały

💻 🤔
Bartosz Cytrowski
Bartosz Cytrowski

🖋
Pawel Pawlowski
Pawel Pawlowski

🎨
Survikrowa
Survikrowa

💻
mczeplowski
mczeplowski

💻
Bartosz Dryl
Bartosz Dryl

💻
Kuba Domański
Kuba Domański

👀
Jakub Kisielewski
Jakub Kisielewski

👀
KonradNojman
KonradNojman

👀
Patryk Górka
Patryk Górka

📖
Adrian Polak
Adrian Polak

💻
xStrixU
xStrixU

💻
Grzegorz Pokorski
Grzegorz Pokorski

📖 🐛 💻
diff --git a/apps/api/.dockerignore b/apps/api/.dockerignore new file mode 100644 index 00000000..7d7c6fe9 --- /dev/null +++ b/apps/api/.dockerignore @@ -0,0 +1,42 @@ +# flyctl launch added from .gitignore +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +**/node_modules +**/.pnp +**/.pnp.js + +# testing +**/coverage + +# next.js +**/.next +**/out +**/build + +# misc +**/.DS_Store +**/*.pem + +# debug +**/npm-debug.log* +**/yarn-debug.log* +**/yarn-error.log* +**/.pnpm-debug.log* + +**/.env +**/.env.local +**/.env.development.local +**/.env.test.local +**/.env.production.local + +# turbo +**/.turbo + +**/*.tsbuildinfo + +# flyctl launch added from .husky/_/.gitignore +.husky/_/**/* + +# flyctl launch added from apps/app/.gitignore +apps/app/**/.vscode diff --git a/apps/api/.editorconfig b/apps/api/.editorconfig deleted file mode 100644 index c2cdfb8a..00000000 --- a/apps/api/.editorconfig +++ /dev/null @@ -1,21 +0,0 @@ -# EditorConfig helps developers define and maintain consistent -# coding styles between different editors and IDEs -# editorconfig.org - -root = true - - -[*] - -# Change these settings to your own preference -indent_style = space -indent_size = 2 - -# We recommend you to keep these unchanged -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.md] -trim_trailing_whitespace = false diff --git a/apps/api/.env-example b/apps/api/.env-example new file mode 100644 index 00000000..5199fc16 --- /dev/null +++ b/apps/api/.env-example @@ -0,0 +1,11 @@ +ENV=development +NODE_ENV=development +PORT=3002 + +COOKIE_DOMAIN=devfaq.localhost +COOKIE_PASSWORD=blablablblablablblablablblablabl + +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +DATABASE_URL=postgres://postgres:api2022@localhost:54421/database_development diff --git a/apps/api/.env.dev b/apps/api/.env.dev deleted file mode 100644 index 0eb50dbb..00000000 --- a/apps/api/.env.dev +++ /dev/null @@ -1,13 +0,0 @@ -PORT=3002 -DB_USERNAME=postgres -DB_PASSWORD=-api2018 -DB_NAME=database_development -DB_HOSTNAME=127.0.0.1 -SENTRY_DSN= - -COOKIE_DOMAIN="devfaq.localhost" -COOKIE_PASSWORD="Xj-#?B#f+1#agiD8QiQvh=RLhy;+Ybj|/+f#|KPH5bs20w^pN@X]q1" - -GITHUB_CLIENT_ID=e65b7b90cd7d2a85acd8 -GITHUB_CLIENT_SECRET=30087b1687598ce76ffa30ac5b6d3a45a7da9a17 -GITHUB_PASSWORD="g-X,-/O7oJ[EWVvE#*aK*!UKDS/zoudbEn!1T+`Ud|n(25EU/*gO::6QnffK+IZ`" diff --git a/apps/api/.eslintignore b/apps/api/.eslintignore new file mode 100644 index 00000000..37609a60 --- /dev/null +++ b/apps/api/.eslintignore @@ -0,0 +1,3 @@ +*.d.ts +*.d.ts.map +*.js diff --git a/apps/api/.eslintrc b/apps/api/.eslintrc deleted file mode 100644 index 545e94ec..00000000 --- a/apps/api/.eslintrc +++ /dev/null @@ -1,15 +0,0 @@ -{ - "root": false, - "extends": ["plugin:import/errors"], - "rules": { - "@typescript-eslint/no-unused-vars": "off" - }, - "overrides": [ - { - "files": ["src/models/*.ts"], - "rules": { - "import/no-cycle": "off" - } - } - ] -} diff --git a/apps/api/.eslintrc.cjs b/apps/api/.eslintrc.cjs new file mode 100644 index 00000000..a49c3500 --- /dev/null +++ b/apps/api/.eslintrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + root: true, + extends: ["devfaq"], + parserOptions: { + tsconfigRootDir: __dirname, + }, +}; diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 00000000..37609a60 --- /dev/null +++ b/apps/api/.gitignore @@ -0,0 +1,3 @@ +*.d.ts +*.d.ts.map +*.js diff --git a/apps/api/.sequelizerc b/apps/api/.sequelizerc deleted file mode 100644 index 7d09aaa1..00000000 --- a/apps/api/.sequelizerc +++ /dev/null @@ -1,6 +0,0 @@ -const path = require('path'); - -module.exports = { - config: path.resolve('src', 'config', 'database.js'), - 'models-path': path.resolve('src', 'models'), -}; diff --git a/apps/api/.version b/apps/api/.version deleted file mode 100644 index d00491fd..00000000 --- a/apps/api/.version +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/apps/api/.vscode/settings.json b/apps/api/.vscode/settings.json new file mode 100644 index 00000000..9c283982 --- /dev/null +++ b/apps/api/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "typescript.preferences.importModuleSpecifierEnding": "js", + "[prisma]": { + "editor.defaultFormatter": "Prisma.prisma" + } +} diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 0aa3b858..7fb9477f 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,10 +1,21 @@ -FROM node:12-alpine +FROM node:18-bullseye-slim AS builder +RUN apt-get update; apt install -y openssl +RUN npm install -g pnpm@7.17.0 + +RUN mkdir /app WORKDIR /app -#copy all the app files COPY . . -RUN yarn install -RUN yarn run build +ENV ENV="(unknown)" +ENV GIT_BRANCH="(unknown)" +ENV GIT_COMMIT_HASH="(unknown)" +RUN pnpm --filter=api... i --frozen-lockfile + +ENV NODE_ENV=production +RUN pnpm --filter=api... build + +LABEL fly_launch_runtime="nodejs" -CMD NODE_ENV=production yarn start +ENV PORT=3000 +CMD [ "pnpm", "--filter=api", "run", "start" ] diff --git a/apps/api/apiTypes.ts b/apps/api/apiTypes.ts deleted file mode 100644 index fe1835e0..00000000 --- a/apps/api/apiTypes.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * This file was auto-generated by swagger-to-ts. - * Do not make direct changes to the file. - */ - -export interface definitions { - Model1: { - id: number; - question: string; - _categoryId: 'html' | 'css' | 'js' | 'angular' | 'react' | 'git' | 'other'; - _levelId: 'junior' | 'mid' | 'senior'; - _statusId: 'accepted' | 'pending'; - acceptedAt?: string; - votesCount: number; - currentUserVotedOn?: boolean; - }; - data: definitions['Model1'][]; - meta: { total: number }; - Model2: { data: definitions['data']; meta?: definitions['meta'] }; - _user: { - id: number; - email: string; - createdAt: string; - updatedAt: string; - _roleId: string; - firstName?: string; - lastName?: string; - socialLogin?: string; - }; - Model3: { - keepMeSignedIn: boolean; - validUntil: string; - createdAt: string; - updatedAt: string; - version: number; - _userId: number; - _user: definitions['_user']; - }; - Model4: { data: definitions['Model3'] }; - Model5: { data: definitions['Model1'] }; - Model6: { _userId: number; _questionId: number }; - Model7: { data: definitions['Model6'] }; - Model8: { - question: string; - level: 'junior' | 'mid' | 'senior'; - category: 'html' | 'css' | 'js' | 'angular' | 'react' | 'git' | 'other'; - }; - Model9: { - question: string; - level: 'junior' | 'mid' | 'senior'; - category: 'html' | 'css' | 'js' | 'angular' | 'react' | 'git' | 'other'; - status: 'accepted' | 'pending'; - }; - - /** - * @summary Health check endpoint default Successful response - */ - getHealthCheckDefaultResponse: string; - - /** - * @summary Test endpoint default Successful response - */ - getHelloWorldDefaultResponse: string; - getQuestionsRequestQuery: { - category?: 'html' | 'css' | 'js' | 'angular' | 'react' | 'git' | 'other'; - status?: 'accepted' | 'pending'; - level?: ('junior' | 'mid' | 'senior')[]; - limit?: number; - offset?: number; - orderBy?: 'acceptedAt' | 'level' | 'votesCount'; - order?: 'asc' | 'desc'; - }; - - /** - * @summary Returns questions 200 Successful response - */ - getQuestions200Response: definitions['Model2']; - postQuestionsRequestBody: definitions['Model8']; - - /** - * @description When user is not an admin, it won't publish the question - * @summary Creates a question 200 Successful response - */ - postQuestions200Response: definitions['Model5']; - - getOauthGithubDefaultResponse: string; - - postOauthGithubDefaultResponse: string; - - getOauthMe200Response: definitions['Model4']; - getQuestionsIdRequestPathParams: { - id: number; - }; - - /** - * @summary Returns one question 200 Successful response - */ - getQuestionsId200Response: definitions['Model5']; - patchQuestionsIdRequestPathParams: { - id: number; - }; - patchQuestionsIdRequestBody: definitions['Model9']; - - /** - * @summary Updates a question 200 Successful response - */ - patchQuestionsId200Response: definitions['Model5']; - deleteQuestionsIdRequestPathParams: { - id: number; - }; - - /** - * @summary Deletes one question default Successful response - */ - deleteQuestionsIdDefaultResponse: string; - postQuestionVotesRequestQuery: { - _userId: number; - _questionId: number; - }; - - /** - * @summary Votes on a question 200 Successful response - */ - postQuestionVotes200Response: definitions['Model7']; - deleteQuestionVotesRequestQuery: { - _userId: number; - _questionId: number; - }; - - /** - * @summary Votes on a question default Successful response - */ - deleteQuestionVotesDefaultResponse: string; - - postOauthLogoutDefaultResponse: string; -} diff --git a/apps/api/app.js b/apps/api/app.js deleted file mode 100644 index cf1a2178..00000000 --- a/apps/api/app.js +++ /dev/null @@ -1,4 +0,0 @@ -require('newrelic'); - -// MyDevil Hack -require('./dist/src/index.js'); diff --git a/apps/api/benchmark.ts b/apps/api/benchmark.ts deleted file mode 100644 index 33d99e71..00000000 --- a/apps/api/benchmark.ts +++ /dev/null @@ -1,23 +0,0 @@ -// tslint:disable-next-line: no-implicit-dependencies -import Autocannon from 'autocannon'; - -const benchmark = async () => { - // tslint:disable-next-line: no-magic-numbers - for (let mutableI = 0; mutableI < 10; ++mutableI) { - const result = await Autocannon({ - url: 'http://localhost:3002/questions', - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - console.log(`${mutableI}: ${result.requests.mean} requests per second`); - } - process.exit(); -}; - -benchmark().catch((err) => { - console.error(err); - process.exit(); -}); diff --git a/apps/api/config/config.ts b/apps/api/config/config.ts new file mode 100644 index 00000000..cd232ffe --- /dev/null +++ b/apps/api/config/config.ts @@ -0,0 +1,42 @@ +export function getConfig(name: "PORT"): number; +export function getConfig(name: "NODE_ENV"): "production" | "development"; +export function getConfig(name: "ENV"): "production" | "staging" | "development" | "test"; +export function getConfig(name: "GITHUB_CLIENT_ID"): string; +export function getConfig(name: "GITHUB_CLIENT_SECRET"): string; +export function getConfig(name: "GIT_BRANCH"): string; +export function getConfig(name: "GIT_COMMIT_HASH"): string; +export function getConfig(name: "VERSION"): string; +export function getConfig(name: "SENTRY_VERSION"): string; +export function getConfig(name: string): string; +export function getConfig(name: string): string | number { + const val = process.env[name]; + + switch (name) { + case "PORT": + return val ? Number(val) : "3002"; + case "NODE_ENV": + return val || "development"; + case "ENV": + return val || "development"; + case "GITHUB_CLIENT_ID": + case "GITHUB_CLIENT_SECRET": + return val || ""; + case "GIT_BRANCH": + return val || "(unknown_branch)"; + case "GIT_COMMIT_HASH": + return val || "(unknown_commit_hash)"; + case "VERSION": + return getConfig("ENV") + ":" + getConfig("GIT_BRANCH") + ":" + getConfig("GIT_COMMIT_HASH"); + case "SENTRY_VERSION": + return getConfig("GIT_COMMIT_HASH") || ""; + } + + if (!val) { + throw new Error(`Cannot find environmental variable: ${name}`); + } + + return val; +} + +export const isProd = () => getConfig("ENV") === "production"; +export const isStaging = () => getConfig("ENV") === "staging"; diff --git a/apps/api/docker-compose.yml b/apps/api/docker-compose.yml index d7e2d500..7aa6f31c 100644 --- a/apps/api/docker-compose.yml +++ b/apps/api/docker-compose.yml @@ -1,11 +1,11 @@ -version: '2' +version: "3" services: devfaq_db: - image: postgres:12-alpine + image: postgres:14.4-alpine ports: - - '5432:5432' + - "54421:5432" environment: - POSTGRES_USER: 'postgres' - POSTGRES_DB: 'database_development' - POSTGRES_PASSWORD: '-api2018' + POSTGRES_USER: "postgres" + POSTGRES_DB: "database_development" + POSTGRES_PASSWORD: "api2022" diff --git a/apps/api/fly.toml b/apps/api/fly.toml new file mode 100644 index 00000000..02b9afcc --- /dev/null +++ b/apps/api/fly.toml @@ -0,0 +1,45 @@ +# fly.toml file generated for devfaq-api on 2022-11-19T13:46:34+01:00 + +app = "devfaq-api" +kill_signal = "SIGINT" +kill_timeout = 5 +processes = [] + +[env] + PORT = "8080" + +[build] + dockerfile = "Dockerfile" + +[deploy] + release_command = "pnpm --filter=api exec prisma migrate deploy" + +[experimental] + allowed_public_ports = [] + auto_rollback = true + +[[services]] + http_checks = [] + internal_port = 8080 + processes = ["app"] + protocol = "tcp" + script_checks = [] + [services.concurrency] + hard_limit = 25 + soft_limit = 20 + type = "connections" + + [[services.ports]] + force_https = true + handlers = ["http"] + port = 80 + + [[services.ports]] + handlers = ["tls", "http"] + port = 443 + + [[services.tcp_checks]] + grace_period = "1s" + interval = "15s" + restart_limit = 0 + timeout = "2s" diff --git a/apps/api/index.ts b/apps/api/index.ts new file mode 100644 index 00000000..186769ef --- /dev/null +++ b/apps/api/index.ts @@ -0,0 +1,19 @@ +import { getConfig } from "./config/config.js"; +import { fastify } from "./server.js"; + +const start = async () => { + try { + await fastify.listen({ port: getConfig("PORT"), host: "0.0.0.0" }); + console.log({ + NODE_ENV: process.env.NODE_ENV, + ENV: process.env.ENV, + }); + } catch (err) { + fastify.log.error(err); + process.exit(1); + } +}; +start().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/api/migrate.ts b/apps/api/migrate.ts deleted file mode 100644 index fd4aa224..00000000 --- a/apps/api/migrate.ts +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env ts-node-script - -import Path from 'path'; - -import { Sequelize } from 'sequelize'; -// tslint:disable-next-line: no-implicit-dependencies -import { Umzug, SequelizeStorage, Migration } from 'umzug'; - -import { sequelizeConfig } from './src/db'; - -const sequelize = new Sequelize({ ...sequelizeConfig, logging: undefined }); - -const storageTableName = { - migration: { modelName: 'SequelizeMeta', path: './src/migrations' }, - seeder: { modelName: 'SequelizeData', path: './src/seeders' }, -} as const; - -const getUmzug = (type: keyof typeof storageTableName) => { - return new Umzug({ - logging: console.info, - migrations: { - path: storageTableName[type].path, - pattern: /\.ts$/, - params: [sequelize.getQueryInterface(), Sequelize], - nameFormatter(path) { - // ignore file extension to make it compatible with older .js migrations - return Path.basename(path, Path.extname(path)); - }, - }, - storage: new SequelizeStorage({ - sequelize, - modelName: storageTableName[type].modelName, - }), - }); -}; - -const execute = async (fn: () => Promise, msg: string) => { - fn() - .then((result) => { - console.log( - msg, - result.map((r) => r?.file ?? r) - ); - process.exit(); - }) - .catch((err) => { - console.error(err); - process.exit(1); - }); -}; - -export const seedUp = () => execute(() => getUmzug('seeder').up(), 'Executed seeds:'); -export const seedDown = () => execute(() => getUmzug('seeder').down(), 'Reverted seeds:'); -export const migrateUp = () => execute(() => getUmzug('migration').up(), 'Executed migrations:'); -export const migrateDown = () => execute(() => getUmzug('migration').down(), 'Reverted migration:'); diff --git a/apps/api/modules/answers/answers.mapper.ts b/apps/api/modules/answers/answers.mapper.ts new file mode 100644 index 00000000..b4012500 --- /dev/null +++ b/apps/api/modules/answers/answers.mapper.ts @@ -0,0 +1,21 @@ +import { Prisma } from "@prisma/client"; +import { answerSelect } from "./answers.routes"; + +export const dbAnswerToDto = ({ + id, + content, + sources, + createdAt, + CreatedBy: { socialLogin, ...createdBy }, +}: Prisma.QuestionAnswerGetPayload<{ select: typeof answerSelect }>) => { + return { + id, + content, + sources, + createdAt: createdAt.toISOString(), + createdBy: { + socialLogin: socialLogin as Record, + ...createdBy, + }, + }; +}; diff --git a/apps/api/modules/answers/answers.routes.ts b/apps/api/modules/answers/answers.routes.ts new file mode 100644 index 00000000..fa69795c --- /dev/null +++ b/apps/api/modules/answers/answers.routes.ts @@ -0,0 +1,143 @@ +import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox"; +import { Prisma } from "@prisma/client"; +import { FastifyPluginAsync, preHandlerAsyncHookHandler, preHandlerHookHandler } from "fastify"; +import { PrismaErrorCode } from "../db/prismaErrors.js"; +import { isPrismaError } from "../db/prismaErrors.util.js"; +import { dbAnswerToDto } from "./answers.mapper.js"; +import { + getAnswersSchema, + createAnswerSchema, + deleteAnswerSchema, + updateAnswerSchema, +} from "./answers.schemas.js"; + +export const answerSelect = { + id: true, + content: true, + sources: true, + createdAt: true, + CreatedBy: { + select: { id: true, firstName: true, lastName: true, socialLogin: true }, + }, +} satisfies Prisma.QuestionAnswerSelect; + +const answersPlugin: FastifyPluginAsync = async (fastify) => { + const checkAnswerUserHook: preHandlerAsyncHookHandler = async (request) => { + const { + session: { data: sessionData }, + } = request; + const { id } = request.params as { id: number }; + + if (!sessionData) { + throw fastify.httpErrors.unauthorized(); + } + + const answer = await fastify.db.questionAnswer.findFirst({ + where: { id }, + select: { createdById: true }, + }); + + if (!answer) { + throw fastify.httpErrors.notFound(`Answer with id: ${id} not found!`); + } + + if (sessionData._user._roleId !== "admin" && answer.createdById !== sessionData._user.id) { + throw fastify.httpErrors.forbidden(); + } + }; + + fastify.withTypeProvider().route({ + url: "/questions/:id/answers", + method: "GET", + schema: getAnswersSchema, + async handler(request) { + const { + params: { id }, + } = request; + + const answers = await fastify.db.questionAnswer.findMany({ + where: { questionId: id }, + select: answerSelect, + }); + + return { + data: answers.map(dbAnswerToDto), + }; + }, + }); + + fastify.withTypeProvider().route({ + url: "/questions/:id/answers", + method: "POST", + schema: createAnswerSchema, + async handler(request) { + const { + session: { data: sessionData }, + params: { id }, + body: { content, sources }, + } = request; + + if (!sessionData) { + throw fastify.httpErrors.unauthorized(); + } + + try { + const answer = await fastify.db.questionAnswer.create({ + data: { questionId: id, createdById: sessionData._user.id, content, sources }, + select: answerSelect, + }); + + return { data: dbAnswerToDto(answer) }; + } catch (err) { + if (isPrismaError(err) && err.code === PrismaErrorCode.UniqueKeyViolation) { + throw fastify.httpErrors.conflict( + `You have already answered on question with id: ${id}!`, + ); + } + + throw err; + } + }, + }); + + fastify.withTypeProvider().route({ + url: "/answers/:id", + method: "PATCH", + schema: updateAnswerSchema, + preHandler: checkAnswerUserHook as preHandlerHookHandler, + async handler(request) { + const { + params: { id }, + body: { content, sources }, + } = request; + + const answer = await fastify.db.questionAnswer.update({ + where: { id }, + data: { content, sources }, + select: answerSelect, + }); + + return { data: dbAnswerToDto(answer) }; + }, + }); + + fastify.withTypeProvider().route({ + url: "/answers/:id", + method: "DELETE", + schema: deleteAnswerSchema, + preHandler: checkAnswerUserHook as preHandlerHookHandler, + async handler(request, reply) { + const { + params: { id }, + } = request; + + await fastify.db.questionAnswer.delete({ + where: { id }, + }); + + return reply.status(204).send(); + }, + }); +}; + +export default answersPlugin; diff --git a/apps/api/modules/answers/answers.schemas.ts b/apps/api/modules/answers/answers.schemas.ts new file mode 100644 index 00000000..8f562cd7 --- /dev/null +++ b/apps/api/modules/answers/answers.schemas.ts @@ -0,0 +1,66 @@ +import { Type } from "@sinclair/typebox"; + +const answerSchema = Type.Object({ + id: Type.Number(), + content: Type.String(), + sources: Type.Array(Type.String()), + createdAt: Type.String({ format: "date-time" }), + createdBy: Type.Object({ + id: Type.Integer(), + firstName: Type.Union([Type.String(), Type.Null()]), + lastName: Type.Union([Type.String(), Type.Null()]), + socialLogin: Type.Record(Type.String(), Type.Union([Type.String(), Type.Number()])), + }), +}); + +export const getAnswersSchema = { + params: Type.Object({ + id: Type.Integer(), + }), + response: { + 200: Type.Object({ + data: Type.Array(answerSchema), + }), + }, +}; + +export const createAnswerSchema = { + params: Type.Object({ + id: Type.Integer(), + }), + body: Type.Object({ + content: Type.String(), + sources: Type.Array(Type.String()), + }), + response: { + 200: Type.Object({ + data: answerSchema, + }), + }, +}; + +export const updateAnswerSchema = { + params: Type.Object({ + id: Type.Integer(), + }), + body: Type.Partial( + Type.Object({ + content: Type.String(), + sources: Type.Array(Type.String()), + }), + ), + response: { + 200: Type.Object({ + data: answerSchema, + }), + }, +}; + +export const deleteAnswerSchema = { + params: Type.Object({ + id: Type.Integer(), + }), + response: { + 204: Type.Never(), + }, +}; diff --git a/apps/api/modules/auth/auth.mapper.ts b/apps/api/modules/auth/auth.mapper.ts new file mode 100644 index 00000000..7d51e99b --- /dev/null +++ b/apps/api/modules/auth/auth.mapper.ts @@ -0,0 +1,24 @@ +import { Prisma } from "@prisma/client"; +import { sessionSelect } from "./auth.js"; + +export const dbAuthToDto = (db: Prisma.SessionGetPayload) => { + return { + id: db.id, + validUntil: db.validUntil.toISOString(), + keepMeSignedIn: db.keepMeSignedIn, + createdAt: db.createdAt.toISOString(), + updatedAt: db.updatedAt.toISOString(), + _user: { + id: db.User.id, + email: db.User.email, + firstName: db.User.firstName, + lastName: db.User.lastName, + _roleId: db.User.UserRole.id, + createdAt: db.User.createdAt.toISOString(), + updatedAt: db.User.updatedAt.toISOString(), + socialLogin: { + ...(db.User.socialLogin as Record), + }, + }, + }; +}; diff --git a/apps/api/modules/auth/auth.routes.ts b/apps/api/modules/auth/auth.routes.ts new file mode 100644 index 00000000..5412a45d --- /dev/null +++ b/apps/api/modules/auth/auth.routes.ts @@ -0,0 +1,48 @@ +import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox"; +import { Type } from "@sinclair/typebox"; +import { FastifyPluginAsync } from "fastify"; +import { meSchema } from "./auth.schemas.js"; + +const authRoutesPlugin: FastifyPluginAsync = async (fastify) => { + fastify.withTypeProvider().route({ + url: "/auth/me", + method: "GET", + schema: { + description: "Get currently logged-in user", + tags: ["auth"], + response: { + 200: Type.Object({ data: meSchema }), + }, + }, + async handler(request, reply) { + if (!request.session.data) { + throw fastify.httpErrors.unauthorized(); + } + + return { data: request.session.data }; + }, + }); + + fastify.withTypeProvider().route({ + url: "/auth/me", + method: "DELETE", + schema: { + description: "Log out", + tags: ["auth"], + response: { + 204: {}, + }, + }, + async handler(request, reply) { + if (!request.session.data) { + throw fastify.httpErrors.unauthorized(); + } + + await request.session.destroy(); + + return reply.status(204).send(); + }, + }); +}; + +export default authRoutesPlugin; diff --git a/apps/api/modules/auth/auth.schemas.ts b/apps/api/modules/auth/auth.schemas.ts new file mode 100644 index 00000000..11f17c0d --- /dev/null +++ b/apps/api/modules/auth/auth.schemas.ts @@ -0,0 +1,20 @@ +import { Static, Type } from "@sinclair/typebox"; + +export const meSchema = Type.Object({ + createdAt: Type.String({ format: "date-time" }), + updatedAt: Type.String({ format: "date-time" }), + keepMeSignedIn: Type.Boolean(), + validUntil: Type.String({ format: "date-time" }), + _user: Type.Object({ + id: Type.Number(), + email: Type.String(), + firstName: Type.Union([Type.String(), Type.Null()]), + lastName: Type.Union([Type.String(), Type.Null()]), + _roleId: Type.String(), + createdAt: Type.String({ format: "date-time" }), + updatedAt: Type.String({ format: "date-time" }), + socialLogin: Type.Record(Type.String(), Type.Union([Type.String(), Type.Number()])), + }), +}); + +export type MeSchema = Static; diff --git a/apps/api/modules/auth/auth.ts b/apps/api/modules/auth/auth.ts new file mode 100644 index 00000000..6d16c10b --- /dev/null +++ b/apps/api/modules/auth/auth.ts @@ -0,0 +1,70 @@ +import type { SessionStore } from "@fastify/session"; +import { Prisma } from "@prisma/client"; +import type { FastifyPluginAsync } from "fastify"; +import FP from "fastify-plugin"; +import ms from "ms"; +import { getConfig } from "../../config/config.js"; +import { MeSchema } from "./auth.schemas.js"; +import { PrismaSessionStore } from "./prismaSessionStore.js"; + +declare module "fastify" { + interface FastifyInstance {} + + interface Session { + data?: MeSchema & { id: string }; + } +} + +export const userSelect = Prisma.validator()({ + select: { + id: true, + email: true, + firstName: true, + lastName: true, + socialLogin: true, + createdAt: true, + updatedAt: true, + UserRole: { + select: { + id: true, + }, + }, + }, +}); + +export const sessionSelect = Prisma.validator()({ + select: { + id: true, + validUntil: true, + keepMeSignedIn: true, + createdAt: true, + updatedAt: true, + User: userSelect, + }, +}); + +const auth: FastifyPluginAsync = async (fastify, options) => { + const sessionStore = new PrismaSessionStore(fastify.db); + + await fastify.register(import("@fastify/cookie")); + await fastify.register(import("@fastify/session"), { + cookieName: "session", + secret: getConfig("COOKIE_PASSWORD"), + store: sessionStore as SessionStore, + rolling: true, + cookie: { + sameSite: "lax", + httpOnly: true, + secure: getConfig("NODE_ENV") === "production", + domain: getConfig("COOKIE_DOMAIN"), + maxAge: ms("7 days"), + }, + }); + + await fastify.register(import("./githubAuth.js")); + await fastify.register(import("./auth.routes.js")); +}; + +const authPlugin = FP(auth); + +export default authPlugin; diff --git a/apps/api/modules/auth/githubAuth.ts b/apps/api/modules/auth/githubAuth.ts new file mode 100644 index 00000000..abd9bf0c --- /dev/null +++ b/apps/api/modules/auth/githubAuth.ts @@ -0,0 +1,188 @@ +import { randomUUID } from "crypto"; +import { FastifyInstance, FastifyPluginAsync } from "fastify"; +import FastifyOauth, { OAuth2Namespace, OAuth2Token } from "@fastify/oauth2"; +import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox"; +import { fetch } from "undici"; +import ms from "ms"; +import { getConfig } from "../../config/config.js"; +import { GitHubUser } from "./githubProfile.type.js"; +import { userSelect } from "./auth.js"; +import { dbAuthToDto } from "./auth.mapper.js"; + +declare module "fastify" { + interface FastifyInstance { + githubOAuth2: OAuth2Namespace; + } +} + +const authRoutesPlugin: FastifyPluginAsync = async (fastify) => { + const protocol = getConfig("ENV") === "development" ? "http" : "https"; + const subdomain = getConfig("ENV") === "staging" ? "staging-api" : "api"; + const domain = getConfig("COOKIE_DOMAIN"); + const port = getConfig("ENV") === "development" ? `:${getConfig("PORT")}` : ""; + const callbackUri = `${protocol}://${subdomain}.${domain}${port}/oauth/github`; + + await fastify.register(FastifyOauth, { + name: "githubOAuth2", + credentials: { + client: { + id: getConfig("GITHUB_CLIENT_ID"), + secret: getConfig("GITHUB_CLIENT_SECRET"), + }, + auth: FastifyOauth.GITHUB_CONFIGURATION, + }, + scope: ["user"], + schema: { + tags: ["auth"], + }, + startRedirectPath: "/oauth/github/login", + callbackUri, + }); + + fastify.withTypeProvider().route({ + url: "/oauth/github", + method: "GET", + async handler(request, reply) { + const gitHubCredentials = await fastify.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow( + request, + ); + + const [{ primaryEmail }, { user }] = await Promise.all([ + getGitHubPrimaryEmail(fastify, gitHubCredentials), + getGitHubProfile(fastify, gitHubCredentials), + ]); + + const { firstName, lastName } = getName(user); + + const result = { + externalServiceId: user.id, + email: primaryEmail.email, + firstName, + lastName, + }; + + const userAccount = await findOrCreateAccountFor(fastify, result); + + const socialLogin = validateSocialLogin(userAccount.socialLogin) + ? userAccount.socialLogin + : null; + + await request.session.regenerate(); + request.session.data = dbAuthToDto({ + id: randomUUID(), + validUntil: new Date(Date.now() + ms("7 days")), + keepMeSignedIn: true, + createdAt: new Date(), + updatedAt: new Date(), + User: { ...userAccount, socialLogin }, + }); + + // this is a hack to close the popup initiated on the frontend + return reply.header("content-type", "text/html").send( + ` + + + + + `.trim(), + ); + }, + }); +}; + +export default authRoutesPlugin; + +async function getGitHubProfile(fastify: FastifyInstance, gitHubCredentials: OAuth2Token) { + const res = await fetch("https://api.github.com/user", { + headers: { + Authorization: `token ${gitHubCredentials.token.access_token}`, + }, + }); + + if (!res.ok) { + throw fastify.httpErrors.serviceUnavailable(`GitHub responded with an error!`); + } + + const user = (await res.json()) as GitHubUser; + + return { user }; +} + +async function getGitHubPrimaryEmail(fastify: FastifyInstance, gitHubCredentials: OAuth2Token) { + const res = await fetch("https://api.github.com/user/emails", { + headers: { + Authorization: `token ${gitHubCredentials.token.access_token}`, + }, + }); + + if (!res.ok) { + throw fastify.httpErrors.serviceUnavailable(`GitHub responded with an error!`); + } + + const emails = (await res.json()) as Array<{ + email: string; + primary: boolean; + verified: boolean; + visibility: unknown; + }>; + const primaryEmail = emails.find((e) => e.primary && e.verified); + + if (!primaryEmail) { + throw fastify.httpErrors.unauthorized("Your primary email is not verified!"); + } + return { primaryEmail, emails }; +} + +function getName(gitHubUser: GitHubUser) { + const [firstName = "", ...rest] = (gitHubUser.name ?? "").split(" "); + return { + firstName, + lastName: rest.join(" "), + }; +} + +async function findOrCreateAccountFor( + fastify: FastifyInstance, + { externalServiceId, email, firstName, lastName }: AuthDetails, +) { + const dbUser = await fastify.db.user.findFirst({ + where: { socialLogin: { equals: { github: externalServiceId } } }, + select: userSelect.select, + }); + + if (dbUser) { + return dbUser; + } + + const dbUserByEmail = await fastify.db.user.findFirst({ where: { email } }); + if (dbUserByEmail) { + // @todo merge accounts + throw fastify.httpErrors.conflict("User with provided email already exists!"); + } + + const newDbUser = await fastify.db.user.create({ + data: { + email, + socialLogin: { github: externalServiceId }, + firstName, + lastName, + }, + select: userSelect.select, + }); + + return newDbUser; +} + +const validateSocialLogin = (sl: unknown): sl is Record => { + if (sl === null || sl === undefined || typeof sl !== "object" || Array.isArray(sl)) { + return false; + } + return Object.values(sl).every((val) => typeof val === "string" || typeof val === "number"); +}; + +type AuthDetails = { + externalServiceId: number; + email: string; + firstName: string; + lastName: string; +}; diff --git a/apps/api/modules/auth/githubProfile.type.ts b/apps/api/modules/auth/githubProfile.type.ts new file mode 100644 index 00000000..06d3819b --- /dev/null +++ b/apps/api/modules/auth/githubProfile.type.ts @@ -0,0 +1,108 @@ +// https://docs.github.com/en/rest/users/users#get-the-authenticated-user + +export type GitHubUser = PrivateUser | PublicUser; + +/** + * Private User + */ +export interface PrivateUser { + login: string; + id: number; + node_id: string; + avatar_url: string; + gravatar_id: string | null; + url: string; + html_url: string; + followers_url: string; + following_url: string; + gists_url: string; + starred_url: string; + subscriptions_url: string; + organizations_url: string; + repos_url: string; + events_url: string; + received_events_url: string; + type: string; + site_admin: boolean; + name: string | null; + company: string | null; + blog: string | null; + location: string | null; + email: string | null; + hireable: boolean | null; + bio: string | null; + twitter_username?: string | null; + public_repos: number; + public_gists: number; + followers: number; + following: number; + created_at: string; + updated_at: string; + private_gists: number; + total_private_repos: number; + owned_private_repos: number; + disk_usage: number; + collaborators: number; + two_factor_authentication: boolean; + plan?: { + collaborators: number; + name: string; + space: number; + private_repos: number; + [k: string]: unknown; + }; + suspended_at?: string | null; + business_plus?: boolean; + ldap_dn?: string; + [k: string]: unknown; +} +/** + * Public User + */ +export interface PublicUser { + login: string; + id: number; + node_id: string; + avatar_url: string; + gravatar_id: string | null; + url: string; + html_url: string; + followers_url: string; + following_url: string; + gists_url: string; + starred_url: string; + subscriptions_url: string; + organizations_url: string; + repos_url: string; + events_url: string; + received_events_url: string; + type: string; + site_admin: boolean; + name: string | null; + company: string | null; + blog: string | null; + location: string | null; + email: string | null; + hireable: boolean | null; + bio: string | null; + twitter_username?: string | null; + public_repos: number; + public_gists: number; + followers: number; + following: number; + created_at: string; + updated_at: string; + plan?: { + collaborators: number; + name: string; + space: number; + private_repos: number; + [k: string]: unknown; + }; + suspended_at?: string | null; + private_gists?: number; + total_private_repos?: number; + owned_private_repos?: number; + disk_usage?: number; + collaborators?: number; +} diff --git a/apps/api/modules/auth/prismaSessionStore.ts b/apps/api/modules/auth/prismaSessionStore.ts new file mode 100644 index 00000000..68c77a0e --- /dev/null +++ b/apps/api/modules/auth/prismaSessionStore.ts @@ -0,0 +1,79 @@ +/** + * Copyright © 2022 Type of Web - Michał Miszczyszyn + * GNU AFFERO GENERAL PUBLIC LICENSE (AGPL) + * https://github.com/typeofweb + */ + +import { PrismaClient } from "@prisma/client"; +import type * as Fastify from "fastify"; +import ms from "ms"; +import { sessionSelect } from "./auth.js"; +import { dbAuthToDto } from "./auth.mapper.js"; + +export const defer = void>( + callback: T, + ...args: A +) => { + setImmediate(() => { + callback(...args); + }); +}; + +export class PrismaSessionStore { + constructor(private readonly prisma: PrismaClient) {} + + async set(sessionId: string, session: Fastify.Session, callback: (err?: unknown) => void) { + if (!session.data?._user) { + return defer(callback); + } + try { + const sessionData = { + id: sessionId, + keepMeSignedIn: true, + validUntil: + session.cookie.expires || new Date(Date.now() + (session.cookie.maxAge || ms("7 days"))), + User: { connect: { id: session.data._user.id } }, + }; + + await this.prisma.session.upsert({ + where: { id: sessionId }, + create: sessionData, + update: sessionData, + }); + defer(callback); + } catch (err) { + return defer(callback, err); + } + } + + async get(sessionId: string, callback: (err?: unknown, session?: Fastify.Session) => void) { + try { + await this.prisma.session.deleteMany({ where: { validUntil: { lt: new Date() } } }); + + const sessionDb = await this.prisma.session.findUnique({ + where: { id: sessionId }, + select: sessionSelect.select, + }); + + if (!sessionDb) { + return defer(callback); + } + + const data: Fastify.Session["data"] = dbAuthToDto(sessionDb); + + return defer(callback, undefined, { data } as Fastify.Session); + } catch (err) { + return defer(callback, err); + } + } + + async destroy(sessionId: string, callback: (err?: unknown) => void) { + try { + await this.prisma.session.deleteMany({ where: { id: sessionId } }); + defer(callback); + } catch (err) { + console.dir(err); + return defer(callback, err); + } + } +} diff --git a/apps/api/modules/db/db.ts b/apps/api/modules/db/db.ts new file mode 100644 index 00000000..cd6b13c8 --- /dev/null +++ b/apps/api/modules/db/db.ts @@ -0,0 +1,35 @@ +import { Prisma, PrismaClient } from "@prisma/client"; +import type { FastifyPluginAsync } from "fastify"; +import FP from "fastify-plugin"; +import { PrismaErrorCode } from "./prismaErrors.js"; +import { isPrismaError } from "./prismaErrors.util.js"; + +declare module "fastify" { + interface FastifyInstance { + db: PrismaClient; + } +} + +const db: FastifyPluginAsync = async (fastify, options) => { + const prisma = new PrismaClient(); + + fastify.addHook("onClose", () => prisma.$disconnect()); + fastify.decorate("db", prisma); + + const originalErrorHandler = fastify.errorHandler; + + fastify.setErrorHandler((error, request, reply) => { + if (isPrismaError(error)) { + switch (error.code) { + case PrismaErrorCode.RecordNotFound: + return fastify.httpErrors.notFound(error.message); + } + } + + return originalErrorHandler(error, request, reply); + }); +}; + +const dbPlugin = FP(db); + +export default dbPlugin; diff --git a/apps/api/modules/db/prismaErrors.ts b/apps/api/modules/db/prismaErrors.ts new file mode 100644 index 00000000..5cc08056 --- /dev/null +++ b/apps/api/modules/db/prismaErrors.ts @@ -0,0 +1,442 @@ +import { Prisma } from "@prisma/client"; + +/** + * The provided value for the column is too long for the column's type. Column: {column_name} + */ +export interface InputValueTooLong extends Prisma.PrismaClientKnownRequestError { + code: "P2000"; + meta: { + column_name: string; + }; +} +/** + * The record searched for in the where condition (`{model_name}.{argument_name} = {argument_value}`) does not exist + */ +export interface RecordNotFound extends Prisma.PrismaClientKnownRequestError { + code: "P2001"; + meta: { + /** + * Model name from Prisma schema + */ + model_name: string; + /** + * Argument name from a supported query on a Prisma schema model + */ + argument_name: string; + /** + * Concrete value provided for an argument on a query. Should be peeked/truncated if too long to display in the error message + */ + argument_value: string; + }; +} +/** + * Unique constraint failed on the {constraint} + */ +export interface UniqueKeyViolation extends Prisma.PrismaClientKnownRequestError { + code: "P2002"; + meta: { + /** + * Field name from one model from Prisma schema + */ + constraint: /* @todo */ object; + }; +} +/** + * Foreign key constraint failed on the field: `{field_name}` + */ +export interface ForeignKeyViolation extends Prisma.PrismaClientKnownRequestError { + code: "P2003"; + meta: { + /** + * Field name from one model from Prisma schema + */ + field_name: string; + }; +} +/** + * A constraint failed on the database: `{database_error}` + */ +export interface ConstraintViolation extends Prisma.PrismaClientKnownRequestError { + code: "P2004"; + meta: { + /** + * Database error returned by the underlying data source + */ + database_error: string; + }; +} +/** + * The value `{field_value}` stored in the database for the field `{field_name}` is invalid for the field's type + */ +export interface StoredValueIsInvalid extends Prisma.PrismaClientKnownRequestError { + code: "P2005"; + meta: { + /** + * Concrete value provided for a field on a model in Prisma schema. Should be peeked/truncated if too long to display in the error message + */ + field_value: string; + /** + * Field name from one model from Prisma schema + */ + field_name: string; + }; +} +/** + * The provided value `{field_value}` for `{model_name}` field `{field_name}` is not valid + */ +export interface TypeMismatch extends Prisma.PrismaClientKnownRequestError { + code: "P2006"; + meta: { + /** + * Concrete value provided for a field on a model in Prisma schema. Should be peeked/truncated if too long to display in the error message + */ + field_value: string; + /** + * Model name from Prisma schema + */ + model_name: string; + /** + * Field name from one model from Prisma schema + */ + field_name: string; + }; +} +/** + * Data validation error `{database_error}` + */ +export interface TypeMismatchInvalidCustomType extends Prisma.PrismaClientKnownRequestError { + code: "P2007"; + meta: { + /** + * Database error returned by the underlying data source + */ + database_error: string; + }; +} +/** + * Failed to parse the query `{query_parsing_error}` at `{query_position}` + */ +export interface QueryParsingFailed extends Prisma.PrismaClientKnownRequestError { + code: "P2008"; + meta: { + /** + * Error(s) encountered when trying to parse a query in the query engine + */ + query_parsing_error: string; + /** + * Location of the incorrect parsing, validation in a query. Represented by tuple or object with (line, character) + */ + query_position: string; + }; +} +/** + * Failed to validate the query: `{query_validation_error}` at `{query_position}` + */ +export interface QueryValidationFailed extends Prisma.PrismaClientKnownRequestError { + code: "P2009"; + meta: { + /** + * Error(s) encountered when trying to validate a query in the query engine + */ + query_validation_error: string; + /** + * Location of the incorrect parsing, validation in a query. Represented by tuple or object with (line, character) + */ + query_position: string; + }; +} +/** + * Raw query failed. Code: `{code}`. Message: `{message}` + */ +export interface RawQueryFailed extends Prisma.PrismaClientKnownRequestError { + code: "P2010"; + meta: { + code: string; + message: string; + }; +} +/** + * Null constraint violation on the {constraint} + */ +export interface NullConstraintViolation extends Prisma.PrismaClientKnownRequestError { + code: "P2011"; + meta: { + constraint: /* @todo */ object; + }; +} +/** + * Missing a required value at `{path}` + */ +export interface MissingRequiredValue extends Prisma.PrismaClientKnownRequestError { + code: "P2012"; + meta: { + path: string; + }; +} +/** + * Missing the required argument `{argument_name}` for field `{field_name}` on `{object_name}`. + */ +export interface MissingRequiredArgument extends Prisma.PrismaClientKnownRequestError { + code: "P2013"; + meta: { + argument_name: string; + field_name: string; + object_name: string; + }; +} +/** + * The change you are trying to make would violate the required relation '{relation_name}' between the `{model_a_name}` and `{model_b_name}` models. + */ +export interface RelationViolation extends Prisma.PrismaClientKnownRequestError { + code: "P2014"; + meta: { + relation_name: string; + model_a_name: string; + model_b_name: string; + }; +} +/** + * A related record could not be found. {details} + */ +export interface RelatedRecordNotFound extends Prisma.PrismaClientKnownRequestError { + code: "P2015"; + meta: { + details: string; + }; +} +/** + * Query interpretation error. {details} + */ +export interface InterpretationError extends Prisma.PrismaClientKnownRequestError { + code: "P2016"; + meta: { + details: string; + }; +} +/** + * The records for relation `{relation_name}` between the `{parent_name}` and `{child_name}` models are not connected. + */ +export interface RecordsNotConnected extends Prisma.PrismaClientKnownRequestError { + code: "P2017"; + meta: { + relation_name: string; + parent_name: string; + child_name: string; + }; +} +/** + * The required connected records were not found. {details} + */ +export interface ConnectedRecordsNotFound extends Prisma.PrismaClientKnownRequestError { + code: "P2018"; + meta: { + details: string; + }; +} +/** + * Input error. {details} + */ +export interface InputError extends Prisma.PrismaClientKnownRequestError { + code: "P2019"; + meta: { + details: string; + }; +} +/** + * Value out of range for the type. {details} + */ +export interface ValueOutOfRange extends Prisma.PrismaClientKnownRequestError { + code: "P2020"; + meta: { + details: string; + }; +} +/** + * The table `{table}` does not exist in the current database. + */ +export interface TableDoesNotExist extends Prisma.PrismaClientKnownRequestError { + code: "P2021"; + meta: { + table: string; + }; +} +/** + * The column `{column}` does not exist in the current database. + */ +export interface ColumnDoesNotExist extends Prisma.PrismaClientKnownRequestError { + code: "P2022"; + meta: { + column: string; + }; +} +/** + * Inconsistent column data: {message} + */ +export interface InconsistentColumnData extends Prisma.PrismaClientKnownRequestError { + code: "P2023"; + meta: { + message: string; + }; +} +/** + * Timed out fetching a new connection from the connection pool. More info: http://pris.ly/d/connection-pool (Current connection pool timeout: {timeout}, connection limit: {connection_limit}) + */ +export interface PoolTimeout extends Prisma.PrismaClientKnownRequestError { + code: "P2024"; + meta: { + connection_limit: number; + timeout: number; + }; +} +/** + * An operation failed because it depends on one or more records that were required but not found. {cause} + */ +export interface RecordRequiredButNotFound extends Prisma.PrismaClientKnownRequestError { + code: "P2025"; + meta: { + cause: string; + }; +} +/** + * The current database provider doesn't support a feature that the query used: {feature} + */ +export interface UnsupportedFeature extends Prisma.PrismaClientKnownRequestError { + code: "P2026"; + meta: { + feature: string; + }; +} +/** + * Multiple errors occurred on the database during query execution: {errors} + */ +export interface MultiError extends Prisma.PrismaClientKnownRequestError { + code: "P2027"; + meta: { + errors: string; + }; +} +/** + * Transaction API error: {error} + */ +export interface InteractiveTransactionError extends Prisma.PrismaClientKnownRequestError { + code: "P2028"; + meta: { + error: string; + }; +} +/** + * Query parameter limit exceeded error: {message}. + */ +export interface QueryParameterLimitExceeded extends Prisma.PrismaClientKnownRequestError { + code: "P2029"; + meta: { + message: string; + }; +} +/** + * Cannot find a fulltext index to use for the search, try adding a @@fulltext([Fields...]) to your schema + */ +export interface MissingFullTextSearchIndex extends Prisma.PrismaClientKnownRequestError { + code: "P2030"; + meta: Record; +} +/** + * Prisma needs to perform transactions, which requires your MongoDB server to be run as a replica set. https://pris.ly/d/mongodb-replica-set + */ +export interface MongoReplicaSetRequired extends Prisma.PrismaClientKnownRequestError { + code: "P2031"; + meta: Record; +} +/** + * Error converting field \"{field}\" of expected non-nullable type \"{expected_type}\", found incompatible value of \"{found}\". + */ +export interface MissingFieldsInModel extends Prisma.PrismaClientKnownRequestError { + code: "P2032"; + meta: { + field: string; + expected_type: string; + found: string; + }; +} +/** + * {details} + */ +export interface ValueFitError extends Prisma.PrismaClientKnownRequestError { + code: "P2033"; + meta: { + details: string; + }; +} + +export type PrismaErrors = + | InputValueTooLong + | RecordNotFound + | UniqueKeyViolation + | ForeignKeyViolation + | ConstraintViolation + | StoredValueIsInvalid + | TypeMismatch + | TypeMismatchInvalidCustomType + | QueryParsingFailed + | QueryValidationFailed + | RawQueryFailed + | NullConstraintViolation + | MissingRequiredValue + | MissingRequiredArgument + | RelationViolation + | RelatedRecordNotFound + | InterpretationError + | RecordsNotConnected + | ConnectedRecordsNotFound + | InputError + | ValueOutOfRange + | TableDoesNotExist + | ColumnDoesNotExist + | InconsistentColumnData + | PoolTimeout + | RecordRequiredButNotFound + | UnsupportedFeature + | MultiError + | InteractiveTransactionError + | QueryParameterLimitExceeded + | MissingFullTextSearchIndex + | MongoReplicaSetRequired + | MissingFieldsInModel + | ValueFitError; + +export enum PrismaErrorCode { + InputValueTooLong = "P2000", + RecordNotFound = "P2001", + UniqueKeyViolation = "P2002", + ForeignKeyViolation = "P2003", + ConstraintViolation = "P2004", + StoredValueIsInvalid = "P2005", + TypeMismatch = "P2006", + TypeMismatchInvalidCustomType = "P2007", + QueryParsingFailed = "P2008", + QueryValidationFailed = "P2009", + RawQueryFailed = "P2010", + NullConstraintViolation = "P2011", + MissingRequiredValue = "P2012", + MissingRequiredArgument = "P2013", + RelationViolation = "P2014", + RelatedRecordNotFound = "P2015", + InterpretationError = "P2016", + RecordsNotConnected = "P2017", + ConnectedRecordsNotFound = "P2018", + InputError = "P2019", + ValueOutOfRange = "P2020", + TableDoesNotExist = "P2021", + ColumnDoesNotExist = "P2022", + InconsistentColumnData = "P2023", + PoolTimeout = "P2024", + RecordRequiredButNotFound = "P2025", + UnsupportedFeature = "P2026", + MultiError = "P2027", + InteractiveTransactionError = "P2028", + QueryParameterLimitExceeded = "P2029", + MissingFullTextSearchIndex = "P2030", + MongoReplicaSetRequired = "P2031", + MissingFieldsInModel = "P2032", + ValueFitError = "P2033", +} diff --git a/apps/api/modules/db/prismaErrors.util.ts b/apps/api/modules/db/prismaErrors.util.ts new file mode 100644 index 00000000..a01b2b85 --- /dev/null +++ b/apps/api/modules/db/prismaErrors.util.ts @@ -0,0 +1,9 @@ +import { Prisma } from "@prisma/client"; +import type { PrismaErrors } from "./prismaErrors.js"; + +export const isPrismaError = (err: unknown): err is PrismaErrors => { + if (err && err instanceof Prisma.PrismaClientKnownRequestError) { + return true; + } + return false; +}; diff --git a/apps/api/modules/questions/questions.params.ts b/apps/api/modules/questions/questions.params.ts new file mode 100644 index 00000000..eb8fc0dd --- /dev/null +++ b/apps/api/modules/questions/questions.params.ts @@ -0,0 +1,34 @@ +import { Prisma } from "@prisma/client"; +import { kv } from "../../utils.js"; +import { GetQuestionsQuery } from "./questions.schemas.js"; + +export const getQuestionsPrismaParams = ( + { category, level, status = "accepted", limit, offset, order, orderBy }: GetQuestionsQuery, + userRole: string | undefined, +) => { + const levels = level?.split(","); + + return { + where: { + ...(category && { categoryId: category }), + ...(levels && { levelId: { in: levels } }), + ...(status && userRole === "admin" ? { statusId: status } : { statusId: "accepted" }), + }, + take: limit, + skip: offset, + ...(order && + orderBy && { + orderBy: { + ...(orderBy === "votesCount" + ? { + QuestionVote: { + _count: order, + }, + } + : orderBy === "level" + ? { levelId: order } + : kv(orderBy, order)), + }, + }), + } satisfies Prisma.QuestionFindManyArgs; +}; diff --git a/apps/api/modules/questions/questions.routes.ts b/apps/api/modules/questions/questions.routes.ts new file mode 100644 index 00000000..1afc0180 --- /dev/null +++ b/apps/api/modules/questions/questions.routes.ts @@ -0,0 +1,414 @@ +import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox"; +import { FastifyPluginAsync } from "fastify"; +import { isPrismaError } from "../db/prismaErrors.util.js"; +import { PrismaErrorCode } from "../db/prismaErrors.js"; +import { + deleteQuestionByIdSchema, + generateGetQuestionsSchema, + generatePatchQuestionByIdSchema, + generatePostQuestionsSchema, + generateGetQuestionByIdSchema, + upvoteQuestionSchema, + downvoteQuestionSchema, + generateGetQuestionsVotesSchema, + getQuestionVotesSchema, +} from "./questions.schemas.js"; +import { getQuestionsPrismaParams } from "./questions.params.js"; + +const questionsPlugin: FastifyPluginAsync = async (fastify) => { + await fastify.register(import("./questions.utils.js")); + + const [categories, levels, statuses] = await Promise.all([ + fastify.questionsGetCategories(), + fastify.questionsGetLevels(), + fastify.questionsGetStatuses(), + ]); + const args = { + categories, + levels, + statuses, + }; + + fastify.withTypeProvider().route({ + url: "/questions", + method: "GET", + schema: generateGetQuestionsSchema(args), + async handler(request, reply) { + const params = getQuestionsPrismaParams(request.query, request.session.data?._user._roleId); + + const [total, questions] = await Promise.all([ + fastify.db.question.count({ + where: params.where, + }), + fastify.db.question.findMany({ + ...params, + select: { + id: true, + question: true, + categoryId: true, + levelId: true, + statusId: true, + acceptedAt: true, + _count: { + select: { + QuestionVote: true, + }, + }, + }, + }), + ]); + + const data = questions.map((q) => { + return { + id: q.id, + question: q.question, + _categoryId: q.categoryId, + _levelId: q.levelId, + _statusId: q.statusId, + acceptedAt: q.acceptedAt?.toISOString(), + votesCount: q._count.QuestionVote, + }; + }); + + return { data, meta: { total } }; + }, + }); + + fastify.withTypeProvider().route({ + url: "/questions", + method: "POST", + schema: generatePostQuestionsSchema(args), + async handler(request, reply) { + const { + body: { question, level, category }, + session: { data: sessionData }, + } = request; + + if (!sessionData) { + throw fastify.httpErrors.unauthorized(); + } + + try { + const newQuestion = await fastify.db.question.create({ + data: { + question, + levelId: level, + categoryId: category, + statusId: "pending", + createdById: sessionData._user.id, + }, + }); + + const data = { + id: newQuestion.id, + question: newQuestion.question, + _categoryId: newQuestion.categoryId, + _levelId: newQuestion.levelId, + _statusId: newQuestion.statusId, + acceptedAt: newQuestion.acceptedAt?.toISOString(), + votesCount: 0, + }; + + return { data }; + } catch (err) { + if (isPrismaError(err) && err.code === PrismaErrorCode.UniqueKeyViolation) { + throw fastify.httpErrors.conflict(`Question with content: ${question} already exists!`); + } + + throw err; + } + }, + }); + + fastify.withTypeProvider().route({ + url: "/questions/votes", + method: "GET", + schema: generateGetQuestionsVotesSchema(args), + async handler(request, reply) { + const params = getQuestionsPrismaParams(request.query, request.session.data?._user._roleId); + + const questions = await fastify.db.question.findMany({ + ...params, + select: { + id: true, + _count: { + select: { + QuestionVote: true, + }, + }, + QuestionVote: { + where: { + userId: request.session.data?._user.id || 0, + }, + }, + }, + }); + + const data = questions.map((q) => ({ + id: q.id, + votesCount: q._count.QuestionVote, + currentUserVotedOn: q.QuestionVote.length > 0, + })); + + return { data }; + }, + }); + + fastify.withTypeProvider().route({ + url: "/questions/:id/votes", + method: "GET", + schema: getQuestionVotesSchema, + async handler(request, reply) { + const { id } = request.params; + + const question = await fastify.db.question.findFirst({ + where: { + id, + }, + select: { + id: true, + _count: { + select: { + QuestionVote: true, + }, + }, + QuestionVote: { + where: { + userId: request.session.data?._user.id || 0, + }, + }, + }, + }); + + if (!question) { + throw fastify.httpErrors.notFound(`Question with id: ${id} not found!`); + } + + return { + data: { + id: question.id, + votesCount: question._count.QuestionVote, + currentUserVotedOn: question.QuestionVote.length > 0, + }, + }; + }, + }); + + fastify.withTypeProvider().route({ + url: "/questions/:id", + method: "PATCH", + schema: generatePatchQuestionByIdSchema(args), + preValidation(request, reply, done) { + if (request.session.data?._user._roleId !== "admin") { + throw fastify.httpErrors.unauthorized(); + } + done(); + }, + async handler(request, reply) { + const { id } = request.params; + + const { question, level, category, status } = request.body; + + const selectedQuestion = await fastify.db.question.findUnique({ + where: { id }, + select: { acceptedAt: true }, + }); + + const q = await fastify.db.question.update({ + where: { id }, + data: { + question, + ...(level && { QuestionLevel: { connect: { id: level } } }), + ...(category && { QuestionCategory: { connect: { id: category } } }), + ...(status && { QuestionStatus: { connect: { id: status } } }), + ...(status === "accepted" && !selectedQuestion?.acceptedAt && { acceptedAt: new Date() }), + }, + select: { + id: true, + question: true, + categoryId: true, + levelId: true, + statusId: true, + acceptedAt: true, + _count: { + select: { + QuestionVote: true, + }, + }, + }, + }); + + const data = { + id: q.id, + question: q.question, + _categoryId: q.categoryId, + _levelId: q.levelId, + _statusId: q.statusId, + acceptedAt: q.acceptedAt?.toISOString(), + votesCount: q._count.QuestionVote, + }; + + return { data }; + }, + }); + + fastify.withTypeProvider().route({ + url: "/questions/:id", + method: "GET", + schema: generateGetQuestionByIdSchema(args), + async handler(request, reply) { + const { id } = request.params; + + const q = await fastify.db.question.findFirst({ + where: { + id, + statusId: "accepted", + }, + select: { + id: true, + question: true, + categoryId: true, + levelId: true, + statusId: true, + acceptedAt: true, + _count: { + select: { + QuestionVote: true, + }, + }, + }, + }); + + if (!q) { + return reply.notFound(); + } + + const data = { + id: q.id, + question: q.question, + _categoryId: q.categoryId, + _levelId: q.levelId, + _statusId: q.statusId, + acceptedAt: q.acceptedAt?.toISOString(), + votesCount: q._count.QuestionVote, + }; + + return { data }; + }, + }); + + fastify.withTypeProvider().route({ + url: "/questions/:id", + method: "DELETE", + schema: deleteQuestionByIdSchema, + preValidation(request, reply, done) { + if (request.session.data?._user._roleId !== "admin") { + throw fastify.httpErrors.unauthorized(); + } + done(); + }, + async handler(request, reply) { + const { id } = request.params; + + try { + await fastify.db.question.delete({ where: { id } }); + } catch (err) { + if (isPrismaError(err)) { + switch (err.code) { + case PrismaErrorCode.RecordRequiredButNotFound: + throw fastify.httpErrors.notFound(`Question with id: ${id} not found!`); + } + + throw err; + } + } + + return reply.status(204).send(); + }, + }); + + fastify.withTypeProvider().route({ + url: "/questions/:id/votes", + method: "POST", + schema: upvoteQuestionSchema, + async handler(request, reply) { + const { + params: { id }, + session: { data: sessionData }, + } = request; + + if (!sessionData) { + throw fastify.httpErrors.unauthorized(); + } + + try { + const questionVote = await fastify.db.questionVote.upsert({ + where: { + userId_questionId: { + userId: sessionData._user.id, + questionId: id, + }, + }, + update: {}, + create: { + userId: sessionData._user.id, + questionId: id, + }, + }); + + return { + data: { + userId: questionVote.userId, + questionId: questionVote.questionId, + }, + }; + } catch (err) { + if (isPrismaError(err)) { + switch (err.code) { + case PrismaErrorCode.ForeignKeyViolation: + throw fastify.httpErrors.notFound(`Question with id: ${id} not found!`); + } + } + + throw err; + } + }, + }); + + fastify.withTypeProvider().route({ + url: "/questions/:id/votes", + method: "DELETE", + schema: downvoteQuestionSchema, + async handler(request, reply) { + const { + params: { id }, + session: { data: sessionData }, + } = request; + + if (!sessionData) { + throw fastify.httpErrors.unauthorized(); + } + + const question = await fastify.db.question.findUnique({ + where: { + id, + }, + }); + + if (!question) { + throw fastify.httpErrors.notFound(`Question with id: ${id} not found!`); + } + + await fastify.db.questionVote.deleteMany({ + where: { + userId: sessionData._user.id, + questionId: id, + }, + }); + + return reply.status(204).send(); + }, + }); +}; + +export default questionsPlugin; diff --git a/apps/api/modules/questions/questions.schemas.ts b/apps/api/modules/questions/questions.schemas.ts new file mode 100644 index 00000000..c14fce6b --- /dev/null +++ b/apps/api/modules/questions/questions.schemas.ts @@ -0,0 +1,232 @@ +import { Type, Static } from "@sinclair/typebox"; + +const questionVotes = Type.Object({ + id: Type.Integer(), + votesCount: Type.Integer(), + currentUserVotedOn: Type.Boolean(), +}); + +const generateGetQuestionsQuerySchema = < + Categories extends readonly string[], + Levels extends readonly string[], + Statuses extends readonly string[], +>(args: { + categories: Categories; + levels: Levels; + statuses: Statuses; +}) => + Type.Partial( + Type.Object({ + category: Type.Union(args.categories.map((val) => Type.Literal(val))), + status: Type.Union(args.statuses.map((val) => Type.Literal(val))), + level: Type.String({ pattern: `^([${args.levels.join("|")}],?)+$` }), + limit: Type.Integer(), + offset: Type.Integer(), + orderBy: Type.Union([ + Type.Literal("acceptedAt"), + Type.Literal("level"), + Type.Literal("votesCount"), + ]), + order: Type.Union([Type.Literal("asc"), Type.Literal("desc")]), + }), + ); +export type GetQuestionsQuery = Static>; +export type GetQuestionsOrderBy = GetQuestionsQuery["orderBy"]; +export type GetQuestionsOrder = GetQuestionsQuery["order"]; + +const generateQuestionShape = < + Categories extends readonly string[], + Levels extends readonly string[], + Statuses extends readonly string[], +>(args: { + categories: Categories; + levels: Levels; + statuses: Statuses; +}) => { + return { + id: Type.Integer(), + question: Type.String(), + _categoryId: Type.Union(args.categories.map((val) => Type.Literal(val))), + _levelId: Type.Union(args.levels.map((val) => Type.Literal(val))), + _statusId: Type.Union(args.statuses.map((val) => Type.Literal(val))), + acceptedAt: Type.Optional(Type.String({ format: "date-time" })), + } as const; +}; + +const generateCreateQuestionShape = < + Categories extends readonly string[], + Levels extends readonly string[], + Statuses extends readonly string[], +>(args: { + categories: Categories; + levels: Levels; + statuses: Statuses; +}) => { + return { + question: Type.String(), + level: Type.Union(args.levels.map((val) => Type.Literal(val))), + category: Type.Union(args.categories.map((val) => Type.Literal(val))), + }; +}; + +const generateQuestionResponseSchema = < + Categories extends readonly string[], + Levels extends readonly string[], + Statuses extends readonly string[], +>(args: { + categories: Categories; + levels: Levels; + statuses: Statuses; +}) => + Type.Object({ + ...generateQuestionShape(args), + votesCount: Type.Integer(), + }); + +export const generateGetQuestionsSchema = < + Categories extends readonly string[], + Levels extends readonly string[], + Statuses extends readonly string[], +>(args: { + categories: Categories; + levels: Levels; + statuses: Statuses; +}) => { + return { + querystring: generateGetQuestionsQuerySchema(args), + response: { + 200: Type.Object({ + data: Type.Array(generateQuestionResponseSchema(args)), + meta: Type.Object({ + total: Type.Integer(), + }), + }), + }, + } as const; +}; + +export const generatePostQuestionsSchema = < + Categories extends readonly string[], + Levels extends readonly string[], + Statuses extends readonly string[], +>(args: { + categories: Categories; + levels: Levels; + statuses: Statuses; +}) => { + return { + body: Type.Object(generateCreateQuestionShape(args)), + response: { + 200: Type.Object({ + data: generateQuestionResponseSchema(args), + }), + }, + } as const; +}; + +export const generateGetQuestionsVotesSchema = < + Categories extends readonly string[], + Levels extends readonly string[], + Statuses extends readonly string[], +>(args: { + categories: Categories; + levels: Levels; + statuses: Statuses; +}) => { + return { + querystring: generateGetQuestionsQuerySchema(args), + response: { + 200: Type.Object({ + data: Type.Array(questionVotes), + }), + }, + }; +}; + +export const generatePatchQuestionByIdSchema = < + Categories extends readonly string[], + Levels extends readonly string[], + Statuses extends readonly string[], +>(args: { + categories: Categories; + levels: Levels; + statuses: Statuses; +}) => { + return { + params: Type.Object({ + id: Type.Integer(), + }), + body: Type.Partial( + Type.Object({ + ...generateCreateQuestionShape(args), + status: Type.Union(args.statuses.map((val) => Type.Literal(val))), + }), + ), + response: { + 200: Type.Object({ + data: generateQuestionResponseSchema(args), + }), + }, + }; +}; + +export const generateGetQuestionByIdSchema = < + Categories extends readonly string[], + Levels extends readonly string[], + Statuses extends readonly string[], +>(args: { + categories: Categories; + levels: Levels; + statuses: Statuses; +}) => { + return { + params: Type.Object({ + id: Type.Integer(), + }), + response: { + 200: Type.Object({ + data: generateQuestionResponseSchema(args), + }), + }, + }; +}; + +export const getQuestionVotesSchema = { + params: Type.Object({ + id: Type.Integer(), + }), + response: { + 200: Type.Object({ + data: questionVotes, + }), + }, +}; + +export const deleteQuestionByIdSchema = { + params: Type.Object({ + id: Type.Integer(), + }), +}; + +export const upvoteQuestionSchema = { + params: Type.Object({ + id: Type.Integer(), + }), + response: { + 200: Type.Object({ + data: Type.Object({ + userId: Type.Integer(), + questionId: Type.Integer(), + }), + }), + }, +}; + +export const downvoteQuestionSchema = { + params: Type.Object({ + id: Type.Integer(), + }), + response: { + 204: Type.Never(), + }, +}; diff --git a/apps/api/modules/questions/questions.utils.ts b/apps/api/modules/questions/questions.utils.ts new file mode 100644 index 00000000..2981ddaf --- /dev/null +++ b/apps/api/modules/questions/questions.utils.ts @@ -0,0 +1,46 @@ +import type { FastifyPluginAsync } from "fastify"; +import LRUCache from "lru-cache"; +import FP from "fastify-plugin"; +import ms from "ms"; + +type Keys = + | "questionsGetLevels" + | "questionsGetCategories" + | "questionsGetStatuses" + | "usersGetRoles"; +declare module "fastify" { + interface FastifyInstance { + questionsGetLevels: () => Promise; + questionsGetCategories: () => Promise; + questionsGetStatuses: () => Promise; + usersGetRoles: () => Promise; + } +} + +const questions: FastifyPluginAsync = async (fastify, options) => { + const cache = new LRUCache({ + ttl: ms("1 hour"), + allowStale: true, + async fetchMethod(key) { + switch (key) { + case "questionsGetLevels": + return (await fastify.db.questionLevel.findMany()).map((i) => i.id); + case "questionsGetCategories": + return (await fastify.db.questionCategory.findMany()).map((i) => i.id); + case "questionsGetStatuses": + return (await fastify.db.questionStatus.findMany()).map((i) => i.id); + case "usersGetRoles": + return (await fastify.db.userRole.findMany()).map((i) => i.id); + } + }, + }); + + fastify.decorate("questionsGetLevels", () => cache.fetch("questionsGetLevels")); + fastify.decorate("questionsGetCategories", () => cache.fetch("questionsGetCategories")); + fastify.decorate("questionsGetStatuses", () => cache.fetch("questionsGetStatuses")); + fastify.decorate("usersGetRoles", () => cache.fetch("usersGetRoles")); +}; + +const questionsUtilsPlugin = FP(questions); + +export default questionsUtilsPlugin; diff --git a/apps/api/nodemon.json b/apps/api/nodemon.json index 4c4f4c25..c9fcabb1 100644 --- a/apps/api/nodemon.json +++ b/apps/api/nodemon.json @@ -1,6 +1,9 @@ { - "watch": ["dist/src"], - "ext": "js", - "ignore": [".git", "node_modules", "dist/src/**/*.test.*"], - "exec": "node ./dist/src/index.js" + "restartable": "rs", + "ignore": [".git", "node_modules/**/node_modules"], + "watch": [".", ".env"], + "execMap": { + "ts": "node --loader ts-node/esm" + }, + "ext": "ts,js,json,env" } diff --git a/apps/api/package.json b/apps/api/package.json index 895a0461..d129c742 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,118 +1,48 @@ { - "name": "api", - "version": "5.3.0", - "author": "Michał Miszczyszyn - Type of Web (https://typeofweb.com/)", - "license": "AGPL-3.0-only", - "private": true, - "engines": { - "node": "12.x.x" - }, - "keywords": [], - "main": "dist/src/index.js", - "scripts": { - "start:db": "docker-compose up", - "dev": "concurrently --kill-others-on-fail 'yarn start:db' 'yarn dev_'", - "dev_": "wait-on tcp:5432 --interval 5000 && yarn db:migrate:up && ts-node-dev src/index.ts", - "test": "cross-env NODE_ENV=test ENV=test yarn test:all", - "test:integration": "cross-env NODE_ENV=test ENV=test yarn test:_integration", - "test:integration:single": "cross-env NODE_ENV=test ENV=test yarn test:prepare:integration && yarn mocha", - "test:unit": "cross-env NODE_ENV=test ENV=test yarn mocha src/**/*.test.ts --exclude src/**/*.integration.test.ts", - "test:unit:single": "cross-env NODE_ENV=test ENV=test yarn mocha", - "mocha": "cross-env ENV=test mocha --config test/.mocharc.js", - "eslint": "eslint . --ext .js,.jsx,.ts,.tsx --fix", - "tsc": "tsc --noEmit -p tsconfig.json", - "db:seed:up": "ts-node --transpile-only -e 'require(`./migrate.ts`).seedUp()'", - "db:seed:down": "ts-node --transpile-only -e 'require(`./migrate.ts`).seedDown()'", - "db:migrate:up": "ts-node --transpile-only -e 'require(`./migrate.ts`).migrateUp()'", - "db:migrate:down": "ts-node --transpile-only -e 'require(`./migrate.ts`).migrateDown()'", - "//1": "/*****************************************************************************", - "//2": "* Rest of those commands are used internally and should not be used directly!", - "//3": "*****************************************************************************/", - "db:test:create": "cross-env NODE_ENV=test ENV=test ts-node --transpile-only ./node_modules/.bin/sequelize --config='./src/config/database.js' db:create || true", - "db:test:drop": "cross-env NODE_ENV=test ENV=test ts-node --transpile-only ./node_modules/.bin/sequelize --config='./src/config/database.js' db:drop || true", - "test:all": "concurrently --kill-others-on-fail --names *typescript,*****eslint,*tests:unit,integration --prefix-colors blue.inverse,blue,yellow,green 'yarn tsc' 'yarn eslint' 'yarn test:unit' 'yarn test:integration'", - "test:prepare:integration": "yarn db:test:drop && yarn db:test:create && yarn db:migrate:up", - "test:_integration": "yarn test:prepare:integration && yarn mocha src/**/*.integration.test.ts", - "test:ci": "cross-env NODE_ENV=test ENV=test yarn mocha:ci", - "mocha:ci": "yarn test:prepare:integration && cross-env mocha 'src/**/*.test.ts'", - "clean": "rm -rf dist", - "build": "yarn clean && tsc && rsync -av --exclude='*.ts' src/** dist/src/", - "generate-api-types": "./node_modules/@manifoldco/swagger-to-ts/pkg/bin/cli.js http://localhost:3002/swagger.json --output apiTypes.ts --prettier-config .prettierrc", - "postinstall": "cd ./node_modules/@manifoldco/swagger-to-ts && yarn && yarn build" - }, - "dependencies": { - "@hapi/bell": "12.0.1", - "@hapi/boom": "9.1.0", - "@hapi/cookie": "11.0.1", - "@hapi/hapi": "19.1.1", - "@hapi/inert": "6.0.1", - "@hapi/joi": "17.1.1", - "@hapi/vision": "6.0.0", - "@sentry/node": "5.17.0", - "cls-hooked": "4.2.2", - "cls-proxify": "1.0.1", - "dotenv": "8.2.0", - "faker": "4.1.0", - "google-auth-library": "6.0.2", - "hapi-swagger": "13.0.2", - "lodash": "4.17.15", - "moment": "2.27.0", - "nanoid": "3.1.10", - "newrelic": "6.10.0", - "node-fetch": "2.6.0", - "pg": "8.2.1", - "pg-hstore": "2.3.3", - "reflect-metadata": "0.1.13", - "sequelize": "4.44.4", - "sequelize-typescript": "0.6.11", - "uuid": "8.1.0" - }, - "devDependencies": { - "@manifoldco/swagger-to-ts": "github:mmiszy/swagger-to-ts#develop", - "@types/autocannon": "4.1.0", - "@types/chai": "4.2.11", - "@types/chai-as-promised": "7.1.2", - "@types/chai-datetime": "0.0.33", - "@types/cls-hooked": "4.3.0", - "@types/dotenv": "8.2.0", - "@types/faker": "4.1.12", - "@types/hapi-pino": "8.0.0", - "@types/hapi__bell": "11.0.0", - "@types/hapi__boom": "9.0.1", - "@types/hapi__cookie": "10.1.0", - "@types/hapi__hapi": "19.0.3", - "@types/hapi__inert": "5.2.0", - "@types/hapi__joi": "17.1.2", - "@types/hapi__vision": "5.5.1", - "@types/inert": "5.1.2", - "@types/lodash": "4.14.155", - "@types/mocha": "7.0.2", - "@types/nanoid": "2.1.0", - "@types/node": "14.0.13", - "@types/node-fetch": "2.5.7", - "@types/sequelize": "4.28.9", - "@types/sinon": "9.0.4", - "@types/sinon-chai": "3.2.4", - "@types/uuid": "8.0.0", - "@types/vision": "5.3.7", - "autocannon": "5.0.1", - "chai": "4.2.0", - "chai-as-promised": "7.1.1", - "chai-datetime": "1.6.0", - "concurrently": "5.2.0", - "cross-env": "7.0.2", - "eslint": "7.5.0", - "mocha": "8.0.1", - "nodemon": "2.0.4", - "prettier": "2.0.5", - "pretty-quick": "2.0.1", - "sequelize-cli": "5.5.1", - "sinon": "9.0.2", - "sinon-chai": "3.5.0", - "ts-node": "8.10.2", - "ts-node-dev": "1.0.0-pre.49", - "typescript": "3.9.5", - "umzug": "3.0.0-beta.5", - "wait-on": "5.0.1" - } + "name": "api", + "version": "6.0.1", + "private": true, + "type": "module", + "scripts": { + "build": "pnpm tsc", + "start": "node index.js", + "dev": "pnpm run dev:ts-node", + "dev:ts-node": "wait-on tcp:54421 --interval 1000 && prisma generate && prisma migrate dev && nodemon index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix --quiet", + "check-types": "tsc --noEmit", + "postinstall": "prisma generate" + }, + "devDependencies": { + "@types/ms": "0.7.31", + "@types/node": "18.11.18", + "eslint-config-devfaq": "workspace:*", + "pino-pretty": "9.1.1", + "tsconfig": "workspace:*", + "typescript": "4.9.4" + }, + "dependencies": { + "@fastify/cookie": "8.3.0", + "@fastify/cors": "8.2.0", + "@fastify/oauth2": "7.0.0", + "@fastify/sensible": "5.2.0", + "@fastify/session": "10.1.1", + "@fastify/swagger": "8.2.1", + "@fastify/swagger-ui": "1.3.0", + "@fastify/type-provider-typebox": "2.4.0", + "@prisma/client": "4.8.0", + "@sinclair/typebox": "0.25.16", + "@swc/core": "1.3.24", + "concurrently": "7.6.0", + "eslint": "8.31.0", + "fastify": "4.11.0", + "fastify-plugin": "4.4.0", + "lru-cache": "7.14.1", + "ms": "2.1.3", + "nodemon": "2.0.20", + "prisma": "4.8.0", + "ts-node": "10.9.1", + "undici": "5.14.0", + "wait-on": "7.0.1" + } } diff --git a/apps/api/prisma/migrations/20220613111252_initial/migration.sql b/apps/api/prisma/migrations/20220613111252_initial/migration.sql new file mode 100644 index 00000000..1a6b2115 --- /dev/null +++ b/apps/api/prisma/migrations/20220613111252_initial/migration.sql @@ -0,0 +1,125 @@ +-- CreateTable +CREATE TABLE "Question" ( + "id" SERIAL NOT NULL, + "question" TEXT NOT NULL, + "acceptedAt" TIMESTAMPTZ(6), + "_categoryId" TEXT NOT NULL, + "_levelId" TEXT NOT NULL, + "_statusId" TEXT NOT NULL DEFAULT E'pending', + "createdAt" TIMESTAMPTZ(6) NOT NULL, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + "version" INTEGER DEFAULT 0, + + CONSTRAINT "Question_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "QuestionCategory" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + "version" INTEGER DEFAULT 0, + + CONSTRAINT "QuestionCategory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "QuestionLevel" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + "version" INTEGER DEFAULT 0, + + CONSTRAINT "QuestionLevel_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "QuestionStatus" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + "version" INTEGER DEFAULT 0, + + CONSTRAINT "QuestionStatus_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "QuestionVote" ( + "_userId" INTEGER NOT NULL, + "_questionId" INTEGER NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "QuestionVote_pkey" PRIMARY KEY ("_userId","_questionId") +); + +-- CreateTable +CREATE TABLE "SequelizeMeta" ( + "name" VARCHAR(255) NOT NULL, + + CONSTRAINT "SequelizeMeta_pkey" PRIMARY KEY ("name") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" VARCHAR(255) NOT NULL, + "keepMeSignedIn" BOOLEAN NOT NULL DEFAULT false, + "validUntil" TIMESTAMPTZ(6) NOT NULL, + "_userId" INTEGER NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + "version" INTEGER DEFAULT 0, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "email" TEXT NOT NULL, + "firstName" TEXT, + "lastName" TEXT, + "_roleId" TEXT NOT NULL DEFAULT E'user', + "createdAt" TIMESTAMPTZ(6) NOT NULL, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + "version" INTEGER DEFAULT 0, + "socialLogin" JSONB NOT NULL DEFAULT '{}', + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserRole" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + "version" INTEGER DEFAULT 0, + + CONSTRAINT "UserRole_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Question_question_key" ON "Question"("question"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- AddForeignKey +ALTER TABLE "Question" ADD CONSTRAINT "Question__categoryId_fkey" FOREIGN KEY ("_categoryId") REFERENCES "QuestionCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Question" ADD CONSTRAINT "Question__levelId_fkey" FOREIGN KEY ("_levelId") REFERENCES "QuestionLevel"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Question" ADD CONSTRAINT "Question__statusId_fkey" FOREIGN KEY ("_statusId") REFERENCES "QuestionStatus"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QuestionVote" ADD CONSTRAINT "QuestionVote__questionId_fkey" FOREIGN KEY ("_questionId") REFERENCES "Question"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "QuestionVote" ADD CONSTRAINT "QuestionVote__userId_fkey" FOREIGN KEY ("_userId") REFERENCES "User"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session__userId_fkey" FOREIGN KEY ("_userId") REFERENCES "User"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User__roleId_fkey" FOREIGN KEY ("_roleId") REFERENCES "UserRole"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20220613111701_drop_versions/migration.sql b/apps/api/prisma/migrations/20220613111701_drop_versions/migration.sql new file mode 100644 index 00000000..98975243 --- /dev/null +++ b/apps/api/prisma/migrations/20220613111701_drop_versions/migration.sql @@ -0,0 +1,42 @@ +/* + Warnings: + + - You are about to drop the column `version` on the `Question` table. All the data in the column will be lost. + - You are about to drop the column `version` on the `QuestionCategory` table. All the data in the column will be lost. + - You are about to drop the column `version` on the `QuestionLevel` table. All the data in the column will be lost. + - You are about to drop the column `version` on the `QuestionStatus` table. All the data in the column will be lost. + - You are about to drop the column `version` on the `Session` table. All the data in the column will be lost. + - You are about to drop the column `version` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `version` on the `UserRole` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Question" DROP COLUMN "version", +ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "QuestionCategory" DROP COLUMN "version", +ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "QuestionLevel" DROP COLUMN "version", +ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "QuestionStatus" DROP COLUMN "version", +ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "QuestionVote" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Session" DROP COLUMN "version", +ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "version", +ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "UserRole" DROP COLUMN "version", +ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; diff --git a/apps/api/prisma/migrations/20221029082055_add_roles/migration.sql b/apps/api/prisma/migrations/20221029082055_add_roles/migration.sql new file mode 100644 index 00000000..fb819c81 --- /dev/null +++ b/apps/api/prisma/migrations/20221029082055_add_roles/migration.sql @@ -0,0 +1,5 @@ +INSERT INTO "UserRole" (id, "createdAt", "updatedAt") +VALUES + ('user', NOW(), NOW()), + ('admin', NOW(), NOW()) +ON CONFLICT (id) DO NOTHING; diff --git a/apps/api/prisma/migrations/20221209102055_add_statuses/migration.sql b/apps/api/prisma/migrations/20221209102055_add_statuses/migration.sql new file mode 100644 index 00000000..ad63b3ba --- /dev/null +++ b/apps/api/prisma/migrations/20221209102055_add_statuses/migration.sql @@ -0,0 +1,23 @@ +INSERT INTO "QuestionStatus" (id, "createdAt", "updatedAt") +VALUES + ('pending', NOW(), NOW()), + ('accepted', NOW(), NOW()) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO "QuestionCategory" (id, "createdAt", "updatedAt") +VALUES + ('html', NOW(), NOW()), + ('css', NOW(), NOW()), + ('js', NOW(), NOW()), + ('angular', NOW(), NOW()), + ('react', NOW(), NOW()), + ('git', NOW(), NOW()), + ('other', NOW(), NOW()) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO "QuestionLevel" (id, "createdAt", "updatedAt") +VALUES + ('junior', NOW(), NOW()), + ('mid', NOW(), NOW()), + ('senior', NOW(), NOW()) +ON CONFLICT (id) DO NOTHING; diff --git a/apps/api/prisma/migrations/20221219175543_fix_question_vote_relation/migration.sql b/apps/api/prisma/migrations/20221219175543_fix_question_vote_relation/migration.sql new file mode 100644 index 00000000..f95c1ca6 --- /dev/null +++ b/apps/api/prisma/migrations/20221219175543_fix_question_vote_relation/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "QuestionVote" DROP CONSTRAINT "QuestionVote__questionId_fkey"; + +-- AddForeignKey +ALTER TABLE "QuestionVote" ADD CONSTRAINT "QuestionVote__questionId_fkey" FOREIGN KEY ("_questionId") REFERENCES "Question"("id") ON DELETE CASCADE ON UPDATE NO ACTION; diff --git a/apps/api/prisma/migrations/20221220132352_added_created_by_to_question/migration.sql b/apps/api/prisma/migrations/20221220132352_added_created_by_to_question/migration.sql new file mode 100644 index 00000000..833b4ca4 --- /dev/null +++ b/apps/api/prisma/migrations/20221220132352_added_created_by_to_question/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Question" ADD COLUMN "_createdById" INTEGER; + +-- AddForeignKey +ALTER TABLE "Question" ADD CONSTRAINT "Question__createdById_fkey" FOREIGN KEY ("_createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20221223084845_add_questions_answers/migration.sql b/apps/api/prisma/migrations/20221223084845_add_questions_answers/migration.sql new file mode 100644 index 00000000..477a5455 --- /dev/null +++ b/apps/api/prisma/migrations/20221223084845_add_questions_answers/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "QuestionAnswer" ( + "id" SERIAL NOT NULL, + "_createdById" INTEGER NOT NULL, + "_questionId" INTEGER NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "QuestionAnswer_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "QuestionAnswer" ADD CONSTRAINT "QuestionAnswer__createdById_fkey" FOREIGN KEY ("_createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QuestionAnswer" ADD CONSTRAINT "QuestionAnswer__questionId_fkey" FOREIGN KEY ("_questionId") REFERENCES "Question"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20221226192534_add_answer_sources/migration.sql b/apps/api/prisma/migrations/20221226192534_add_answer_sources/migration.sql new file mode 100644 index 00000000..47322c5a --- /dev/null +++ b/apps/api/prisma/migrations/20221226192534_add_answer_sources/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "QuestionAnswer" ADD COLUMN "sources" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..fbffa92c --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 00000000..b272eefb --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,106 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Question { + id Int @id @default(autoincrement()) + question String @unique + acceptedAt DateTime? @db.Timestamptz(6) + categoryId String @map("_categoryId") + levelId String @map("_levelId") + statusId String @default("pending") @map("_statusId") + createdById Int? @map("_createdById") + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt() @db.Timestamptz(6) + QuestionCategory QuestionCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) + QuestionLevel QuestionLevel @relation(fields: [levelId], references: [id], onDelete: Cascade) + QuestionStatus QuestionStatus @relation(fields: [statusId], references: [id], onDelete: Cascade) + CreatedBy User? @relation(fields: [createdById], references: [id], onDelete: Cascade) + QuestionVote QuestionVote[] + QuestionAnswer QuestionAnswer[] +} + +model QuestionCategory { + id String @id + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt() @db.Timestamptz(6) + Question Question[] +} + +model QuestionLevel { + id String @id + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt() @db.Timestamptz(6) + Question Question[] +} + +model QuestionStatus { + id String @id + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt() @db.Timestamptz(6) + Question Question[] +} + +model QuestionVote { + userId Int @map("_userId") + questionId Int @map("_questionId") + createdAt DateTime @default(now()) @db.Timestamptz(6) + Question Question @relation(fields: [questionId], references: [id], onDelete: Cascade, onUpdate: NoAction) + User User @relation(fields: [userId], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@id([userId, questionId]) +} + +model QuestionAnswer { + id Int @id @default(autoincrement()) + createdById Int @map("_createdById") + questionId Int @map("_questionId") + content String + sources String[] @default([]) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt() @db.Timestamptz(6) + CreatedBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + Question Question @relation(fields: [questionId], references: [id], onDelete: Cascade) +} + +model SequelizeMeta { + name String @id @db.VarChar(255) +} + +model Session { + id String @id @db.VarChar(255) + keepMeSignedIn Boolean @default(false) + validUntil DateTime @db.Timestamptz(6) + userId Int @map("_userId") + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt() @db.Timestamptz(6) + User User @relation(fields: [userId], references: [id], onDelete: NoAction, onUpdate: NoAction) +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + firstName String? + lastName String? + roleId String @default("user") @map("_roleId") + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt() @db.Timestamptz(6) + socialLogin Json @default("{}") + UserRole UserRole @relation(fields: [roleId], references: [id], onDelete: Cascade) + QuestionVote QuestionVote[] + Session Session[] + Question Question[] + QuestionAnswer QuestionAnswer[] +} + +model UserRole { + id String @id + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt() @db.Timestamptz(6) + User User[] +} diff --git a/apps/api/server.ts b/apps/api/server.ts new file mode 100644 index 00000000..d29958bb --- /dev/null +++ b/apps/api/server.ts @@ -0,0 +1,68 @@ +import Fastify from "fastify"; +import type { TypeBoxTypeProvider } from "@fastify/type-provider-typebox"; +import { Type } from "@sinclair/typebox"; +import { getConfig } from "./config/config.js"; + +const fastify = Fastify({ + logger: + process.env.NODE_ENV === "production" + ? true + : { + transport: { + target: "pino-pretty", + options: { + levelFirst: true, + ignore: "pid,hostname", + }, + }, + }, + ajv: { + customOptions: { + strict: "log", + keywords: ["kind", "modifier"], + }, + }, +}).withTypeProvider(); + +await fastify.register(import("@fastify/sensible")); + +await fastify.register(import("./modules/db/db.js")); + +await fastify.register(import("@fastify/cors"), { credentials: true, origin: true }); + +await fastify.register(import("@fastify/swagger"), { + mode: "dynamic", + openapi: { + info: { + title: `DevFAQ API ${getConfig("ENV")}`, + version: getConfig("VERSION"), + }, + }, +}); +await fastify.register(import("@fastify/swagger-ui"), { + routePrefix: "/documentation", + uiConfig: { + docExpansion: "full", + }, +}); + +await fastify.register(import("./modules/auth/auth.js")); +await fastify.register(import("./modules/questions/questions.routes.js")); +await fastify.register(import("./modules/answers/answers.routes.js")); + +fastify.get( + "/", + { + schema: { + response: { + 200: Type.String(), + }, + }, + }, + async () => { + console.log(getConfig("VERSION")); + return `Zostań na chwilę i posłuchaj`; + }, +); + +export { fastify }; diff --git a/apps/api/src/config/database.js b/apps/api/src/config/database.js deleted file mode 100644 index b2b55ec5..00000000 --- a/apps/api/src/config/database.js +++ /dev/null @@ -1,28 +0,0 @@ -const dotenv = require('dotenv'); - -const { getConfig } = require('./index'); - -if (getConfig('NODE_ENV') !== 'production') { - dotenv.config({ path: '.env.dev' }); -} else { - dotenv.config(); -} - -const config = { - username: getConfig('DB_USERNAME'), - password: getConfig('DB_PASSWORD'), - database: getConfig('DB_NAME'), - host: getConfig('DB_HOSTNAME'), - dialect: 'postgres', -}; - -module.exports = { - development: config, - test: { - ...config, - database: 'database_test', - host: '127.0.0.1', - }, - production: config, - staging: config, -}; diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts deleted file mode 100644 index 6e851966..00000000 --- a/apps/api/src/config/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import Fs from 'fs'; - -export function getConfig(name: 'ENV'): 'production' | 'staging' | 'development' | 'test'; -export function getConfig(name: 'NODE_ENV'): 'production' | 'development'; -export function getConfig(name: string): string; -export function getConfig(name: string): string { - const val = process.env[name]; - - switch (name) { - case 'NODE_ENV': - return val || 'development'; - case 'ENV': - return val || 'development'; - case 'PORT': - return val || '3009'; - case 'AWS_ACCESS_KEY_ID': - case 'AWS_SECRET_ACCESS_KEY': - case 'SENTRY_DSN': - case 'HARVEST_API_AUTH_TOKEN': - case 'HARVEST_API_USER_AGENT': - case 'GITHUB_CLIENT_ID': - case 'GITHUB_CLIENT_SECRET': - return val || ''; - case 'VERSION': - return Fs.existsSync('.version') ? Fs.readFileSync('.version', 'utf-8').trim() : 'dev'; - case 'SENTRY_VERSION': - return getConfig('VERSION').split(':').pop() || ''; - } - - if (!val) { - throw new Error(`Cannot find environmental variable: ${name}`); - } - - return val; -} - -export const isProd = () => getConfig('ENV') === 'production'; -export const isStaging = () => getConfig('ENV') === 'staging'; diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts deleted file mode 100644 index 038d9a5c..00000000 --- a/apps/api/src/db.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Sequelize, Model } from 'sequelize-typescript'; - -import { getConfig } from './config'; -import { SentryCLS, getContext, USER_CONTEXT_KEY } from './plugins/cls/context'; - -// tslint:disable-next-line:no-var-requires -const config = require('./config/database.js'); - -export interface AnyModel extends Model {} -export type RawModel = Pick> & { - id: number; -}; - -export const sequelizeConfig = { - ...config[getConfig('ENV')], - pool: { - max: 5, - min: 0, - acquire: 30000, - idle: 10000, - }, - // http://docs.sequelizejs.com/manual/tutorial/querying.html#operators - operatorsAliases: false, - // native: true, - logging: - getConfig('NODE_ENV') !== 'production' - ? // tslint:disable-next-line:no-any - (sql: string, model?: Model) => { - SentryCLS.addBreadcrumb({ - category: 'SQL', - message: sql, - level: SentryCLS.Severity.Info, - data: { - model: model?.toJSON?.() || undefined, - context: getContext(USER_CONTEXT_KEY), - }, - }); - } - : (sql: string, _model: unknown) => { - if (getConfig('ENV') !== 'test') { - console.log( - [ - getContext(USER_CONTEXT_KEY)?.currentRequestID, - 'user: ' + getContext(USER_CONTEXT_KEY)?.userEmail, - sql, - ].join('\t') - ); - } - }, -}; - -export const sequelize = new Sequelize(sequelizeConfig); - -export const initDb = async () => { - await sequelize.addModels([__dirname + '/models']); - await sequelize.authenticate(); - - console.log('Connection to the database has been established successfully.'); - - return sequelize; -}; - -export function getAllModels() { - return (sequelize._ as unknown) as Sequelize['models']; -} - -export function getModelByName(name: string) { - return getAllModels()[name]; -} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts deleted file mode 100644 index f14cde91..00000000 --- a/apps/api/src/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import dotenv from 'dotenv'; - -import { getConfig } from './config'; -import { initDb } from './db'; -import { SentryCLS } from './plugins/cls/context'; -import { getServerWithPlugins } from './server'; -import { handleException } from './utils/utils'; - -if (getConfig('NODE_ENV') !== 'production') { - dotenv.config({ path: '.env.dev' }); -} else { - dotenv.config(); -} - -if (!getConfig('SENTRY_DSN')) { - console.warn('SENTRY_DSN is missing. No errors will be reported!'); -} else { - SentryCLS.init({ - debug: false, - dsn: getConfig('SENTRY_DSN'), - environment: getConfig('ENV'), - release: getConfig('SENTRY_VERSION'), - }); -} - -// tslint:disable-next-line:no-floating-promises -(async () => { - try { - await initDb(); - const devfaqServer = await getServerWithPlugins(); - await devfaqServer.start(); - - console.info('Server running at:', devfaqServer.info.uri); - } catch (err) { - handleException(err, SentryCLS.Severity.Fatal); - - const client = SentryCLS.getCurrentHub().getClient(); - if (client) { - client - // tslint:disable-next-line:no-magic-numbers - .close(2000) - .then(() => process.exit(1)); - } else { - process.exit(1); - } - } -})(); diff --git a/apps/api/src/migrations/20180814074541-initial.ts b/apps/api/src/migrations/20180814074541-initial.ts deleted file mode 100644 index 792ffdd8..00000000 --- a/apps/api/src/migrations/20180814074541-initial.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { QueryInterface, SequelizeStatic } from 'sequelize'; - -module.exports = { - async up(queryInterface: QueryInterface, Sequelize: SequelizeStatic) { - return queryInterface.sequelize.transaction(async (_t) => { - await queryInterface.createTable('QuestionCategory', { - id: { - type: Sequelize.TEXT, - primaryKey: true, - allowNull: false, - unique: true, - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - }, - version: { - type: Sequelize.INTEGER, - defaultValue: 0, - }, - }); - - await queryInterface.createTable('QuestionLevel', { - id: { - type: Sequelize.TEXT, - primaryKey: true, - allowNull: false, - unique: true, - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - }, - version: { - type: Sequelize.INTEGER, - defaultValue: 0, - }, - }); - - await queryInterface.createTable('QuestionStatus', { - id: { - type: Sequelize.TEXT, - primaryKey: true, - allowNull: false, - unique: true, - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - }, - version: { - type: Sequelize.INTEGER, - defaultValue: 0, - }, - }); - - await queryInterface.createTable('UserRoleType', { - id: { - type: Sequelize.TEXT, - primaryKey: true, - allowNull: false, - unique: true, - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - }, - version: { - type: Sequelize.INTEGER, - defaultValue: 0, - }, - }); - - await queryInterface.createTable('User', { - id: { - type: Sequelize.INTEGER, - primaryKey: true, - autoIncrement: true, - allowNull: false, - }, - email: { - type: Sequelize.TEXT, - allowNull: false, - unique: true, - }, - firstName: { - type: Sequelize.TEXT, - allowNull: true, - }, - lastName: { - type: Sequelize.TEXT, - allowNull: true, - }, - _roleId: { - type: Sequelize.TEXT, - allowNull: false, - defaultValue: 'user', - references: { - model: 'UserRoleType', - key: 'id', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - }, - version: { - type: Sequelize.INTEGER, - defaultValue: 0, - }, - }); - - await queryInterface.createTable('Question', { - id: { - type: Sequelize.INTEGER, - primaryKey: true, - autoIncrement: true, - allowNull: false, - }, - question: { - type: Sequelize.TEXT, - allowNull: false, - unique: true, - }, - acceptedAt: { - type: Sequelize.DATE, - allowNull: true, - }, - _categoryId: { - type: Sequelize.TEXT, - allowNull: false, - references: { - model: 'QuestionCategory', - key: 'id', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - _levelId: { - type: Sequelize.TEXT, - allowNull: false, - references: { - model: 'QuestionLevel', - key: 'id', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - _statusId: { - type: Sequelize.TEXT, - allowNull: false, - defaultValue: 'pending', - references: { - model: 'QuestionStatus', - key: 'id', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - }, - version: { - type: Sequelize.INTEGER, - defaultValue: 0, - }, - }); - }); - }, - - async down(queryInterface: QueryInterface, _Sequelize: SequelizeStatic) { - await queryInterface.dropAllTables(); - }, -}; diff --git a/apps/api/src/migrations/20180924111648-initial-seed.ts b/apps/api/src/migrations/20180924111648-initial-seed.ts deleted file mode 100644 index 8d988e43..00000000 --- a/apps/api/src/migrations/20180924111648-initial-seed.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { QueryInterface, SequelizeStatic } from 'sequelize'; - -enum USER_ROLE { - USER = 'user', - ADMIN = 'admin', -} - -enum QUESTION_CATEGORY { - HTML = 'html', - CSS = 'css', - JS = 'js', - ANGULAR = 'angular', - REACT = 'react', - GIT = 'git', - OTHER = 'other', -} - -enum QUESTION_LEVEL { - JUNIOR = 'junior', - MID = 'mid', - SENIOR = 'senior', -} - -enum QUESTION_STATUS { - ACCEPTED = 'accepted', - PENDING = 'pending', -} - -// tslint:disable-next-line:no-any -function toEntities(enumerable: any): Array<{ id: string }> { - return Object.values(enumerable).map((t) => ({ - id: t, - createdAt: new Date(), - updatedAt: new Date(), - })); -} - -module.exports = { - async up(queryInterface: QueryInterface, _Sequelize: SequelizeStatic) { - const userRoles = toEntities(USER_ROLE); - const questionCategories = toEntities(QUESTION_CATEGORY); - const questionLevels = toEntities(QUESTION_LEVEL); - const questionStatuses = toEntities(QUESTION_STATUS); - - return Promise.all([ - queryInterface.bulkInsert('UserRoleType', userRoles), - queryInterface.bulkInsert('QuestionCategory', questionCategories), - queryInterface.bulkInsert('QuestionLevel', questionLevels), - queryInterface.bulkInsert('QuestionStatus', questionStatuses), - ]); - }, - - async down(queryInterface: QueryInterface, _Sequelize: SequelizeStatic) { - const userRoles = toEntities(USER_ROLE); - const questionCategories = toEntities(QUESTION_CATEGORY); - const questionLevels = toEntities(QUESTION_LEVEL); - const questionStatuses = toEntities(QUESTION_STATUS); - - return Promise.all([ - queryInterface.bulkDelete('UserRoleType', userRoles), - queryInterface.bulkDelete('QuestionCategory', questionCategories), - queryInterface.bulkDelete('QuestionLevel', questionLevels), - queryInterface.bulkDelete('QuestionStatus', questionStatuses), - ]); - }, -}; diff --git a/apps/api/src/migrations/20190411134800-add-social-login.ts b/apps/api/src/migrations/20190411134800-add-social-login.ts deleted file mode 100644 index e53e6fcc..00000000 --- a/apps/api/src/migrations/20190411134800-add-social-login.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { QueryInterface, SequelizeStatic } from 'sequelize'; - -module.exports = { - async up(queryInterface: QueryInterface, Sequelize: SequelizeStatic) { - return queryInterface.addColumn('User', 'socialLogin', { - type: Sequelize.JSONB, - allowNull: false, - defaultValue: {}, - }); - }, - - async down(queryInterface: QueryInterface, _Sequelize: SequelizeStatic) { - return queryInterface.removeColumn('User', 'socialLogin'); - }, -}; diff --git a/apps/api/src/migrations/20190411164500-add-session.ts b/apps/api/src/migrations/20190411164500-add-session.ts deleted file mode 100644 index b99a8311..00000000 --- a/apps/api/src/migrations/20190411164500-add-session.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { QueryInterface, SequelizeStatic } from 'sequelize'; - -module.exports = { - async up(queryInterface: QueryInterface, Sequelize: SequelizeStatic) { - return queryInterface.createTable('Session', { - id: { primaryKey: true, type: Sequelize.STRING, allowNull: false, unique: true }, - keepMeSignedIn: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, - validUntil: { type: Sequelize.DATE, allowNull: false }, - _userId: { - type: Sequelize.INTEGER, - allowNull: false, - references: { model: 'User', key: 'id' }, - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - }, - version: { - type: Sequelize.INTEGER, - defaultValue: 0, - }, - }); - }, - - async down(queryInterface: QueryInterface, _Sequelize: SequelizeStatic) { - return queryInterface.dropTable('Session'); - }, -}; diff --git a/apps/api/src/migrations/20190417172900-add-question-vote.ts b/apps/api/src/migrations/20190417172900-add-question-vote.ts deleted file mode 100644 index 2e53eb74..00000000 --- a/apps/api/src/migrations/20190417172900-add-question-vote.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { QueryInterface, SequelizeStatic } from 'sequelize'; - -module.exports = { - async up(queryInterface: QueryInterface, Sequelize: SequelizeStatic) { - return queryInterface.createTable('QuestionVote', { - _userId: { - primaryKey: true, - type: Sequelize.INTEGER, - references: { model: 'User', key: 'id' }, - allowNull: false, - }, - _questionId: { - primaryKey: true, - type: Sequelize.INTEGER, - references: { model: 'Question', key: 'id' }, - allowNull: false, - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - }); - }, - - async down(queryInterface: QueryInterface, _Sequelize: SequelizeStatic) { - return queryInterface.dropTable('QuestionVote'); - }, -}; diff --git a/apps/api/src/models-consts.ts b/apps/api/src/models-consts.ts deleted file mode 100644 index ffee7018..00000000 --- a/apps/api/src/models-consts.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const userRoles = ['user', 'admin'] as const; -export type UserRoleUnion = typeof userRoles[number]; - -export const questionCategories = [ - 'html', - 'css', - 'js', - 'angular', - 'react', - 'git', - 'other', -] as const; -export type QuestionCategoryUnion = typeof questionCategories[number]; - -export const questionLevels = ['junior', 'mid', 'senior'] as const; -export type QuestionLevelUnion = typeof questionLevels[number]; - -export const questionStatuses = ['accepted', 'pending'] as const; -export type QuestionStatusUnion = typeof questionStatuses[number]; diff --git a/apps/api/src/models/Question.ts b/apps/api/src/models/Question.ts deleted file mode 100644 index 9b296266..00000000 --- a/apps/api/src/models/Question.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { isArray } from 'util'; - -import { - Table, - Column, - Model, - DataType, - Unique, - ForeignKey, - AllowNull, - Default, - BeforeUpdate, - BeforeCreate, - BelongsToMany, - Scopes, - IFindOptions, - Sequelize, -} from 'sequelize-typescript'; - -import { sequelize } from '../db'; -import { QuestionLevelUnion, QuestionCategoryUnion, QuestionStatusUnion } from '../models-consts'; - -import { QuestionCategory } from './QuestionCategory'; -import { QuestionLevel } from './QuestionLevel'; -import { QuestionStatus } from './QuestionStatus'; -import { QuestionVote } from './QuestionVote'; -import { User } from './User'; - -function getQuestionsOrderQuery(orders: Array<[string, 'DESC' | 'ASC'] | [string]>): string { - if (!orders || !orders.length) { - return `"Question"."id" ASC`; - } - - return orders - .filter((o) => o.length > 0) - .filter(([colName]) => { - return colName in Question.rawAttributes; - }) - .map((o) => { - const [colName, order = ''] = o; - if (colName === 'votesCount') { - return `"votesCount" ${order}`.trim(); - } - - return `"Question"."${colName}" ${order}`.trim(); - }) - .join(',\n'); -} - -function getQuestionsWhereQuery( - where: { [P in keyof Question]?: number | string | boolean | number[] | string[] | boolean[] } -): string { - return Object.entries(where) - .map(([key, val]) => { - if (isArray(val)) { - return `"Question"."${key}" IN (:${key})`; - } - return `"Question"."${key}" = :${key}`; - }) - .join(' AND '); -} - -@Scopes({ - withVotes() { - return { - include: [ - { - model: User, - as: '_votes', - attributes: ['id'], - }, - ], - }; - }, -}) -@Table({ version: true, timestamps: true }) -export class Question extends Model { - @BeforeUpdate - @BeforeCreate - static setAcceptedAt(instance: Question) { - if (!instance.acceptedAt && instance._statusId === 'accepted') { - instance.acceptedAt = new Date(); - } - if (instance.acceptedAt && instance._statusId === 'pending') { - instance.acceptedAt = null; - } - } - - static async findAllWithVotes( - { limit, offset, order, where }: IFindOptions, - userId?: User['id'] - ): Promise { - // tslint:disable-next-line:no-any - const orders = order as any; - // tslint:disable-next-line:no-any - const whereQuery = getQuestionsWhereQuery(where as any); - - const didUserVoteOnQuery = userId - ? `COALESCE( (SELECT true FROM "QuestionVote" WHERE "_questionId" = "Question"."id" AND "_userId" = :userId), false)` - : `false`; - - return sequelize.query( - ` - SELECT - ${didUserVoteOnQuery} as "didUserVoteOn", - "Question"."id", - "Question"."question", - "Question"."_categoryId", - "Question"."_levelId", - "Question"."_statusId", - "Question"."acceptedAt", - count("_votes"."id") as "votesCount" - FROM "Question" - LEFT OUTER JOIN ( - "QuestionVote" INNER JOIN "User" AS "_votes" ON "_votes"."id" = "QuestionVote"."_userId" - ) ON "Question"."id" = "QuestionVote"."_questionId" - - ${whereQuery ? `WHERE ${whereQuery}` : ''} - - GROUP BY "Question".id - ORDER BY ${getQuestionsOrderQuery(orders)} - ${limit ? 'LIMIT :limit' : ''} - ${offset ? 'OFFSET :offset' : ''} - ; - `, - { - type: Sequelize.QueryTypes.SELECT, - nest: true, - // tslint:disable-next-line:no-any - replacements: { limit, offset, userId, ...(where as any) }, - // tslint:disable-next-line:no-any - model: Question as any, - } - ); - } - - static async didUserVoteOn(user: User, question: Question): Promise { - const vote = await QuestionVote.findOne({ - where: { - _userId: user.id, - _questionId: question.id, - }, - }); - - return Boolean(vote); - } - - @Unique - @AllowNull(false) - @Column(DataType.TEXT) - question!: string; - - @Unique - @AllowNull(true) - @Column(DataType.DATE) - acceptedAt?: Date | null; - - @ForeignKey(() => QuestionCategory) - @AllowNull(false) - @Column(DataType.STRING) - _categoryId!: QuestionCategoryUnion; - - @ForeignKey(() => QuestionLevel) - @AllowNull(false) - @Column(DataType.STRING) - _levelId!: QuestionLevelUnion; - - @ForeignKey(() => QuestionStatus) - @Default('pending') - @AllowNull(false) - @Column(DataType.STRING) - _statusId!: QuestionStatusUnion; - - @BelongsToMany(() => User, { - through: () => QuestionVote, - foreignKey: '_questionId', - otherKey: '_userId', - as: '_votes', - }) - _votes?: Array; - - @Column({ - type: new DataType.VIRTUAL(DataType.BOOLEAN), - get() { - return this.getDataValue('didUserVoteOn') || false; - }, - }) - didUserVoteOn?: boolean; - - @Column({ - type: new DataType.VIRTUAL(DataType.INTEGER), - get() { - if (this.getDataValue('votesCount')) { - return this.getDataValue('votesCount'); - } - - const votes = this.getDataValue('_votes') as Question['_votes']; - if (!votes) { - throw new Error('Include _votes if you need votesCount!'); - } - return votes.length; - }, - }) - votesCount!: number; -} diff --git a/apps/api/src/models/QuestionCategory.ts b/apps/api/src/models/QuestionCategory.ts deleted file mode 100644 index 7fac85a6..00000000 --- a/apps/api/src/models/QuestionCategory.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - Table, - Column, - Model, - DataType, - Unique, - AllowNull, - HasMany, - PrimaryKey, -} from 'sequelize-typescript'; - -import { Question } from './Question'; - -@Table({ version: true, timestamps: true }) -export class QuestionCategory extends Model { - @Unique - @AllowNull(false) - @PrimaryKey - @Column(DataType.TEXT) - readonly id!: string; - - @HasMany(() => Question, '_categoryId') - _questions?: Question[]; -} diff --git a/apps/api/src/models/QuestionLevel.ts b/apps/api/src/models/QuestionLevel.ts deleted file mode 100644 index cf74f449..00000000 --- a/apps/api/src/models/QuestionLevel.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - Table, - Column, - Model, - DataType, - Unique, - AllowNull, - HasMany, - PrimaryKey, -} from 'sequelize-typescript'; - -import { Question } from './Question'; - -@Table({ version: true, timestamps: true }) -export class QuestionLevel extends Model { - @Unique - @AllowNull(false) - @PrimaryKey - @Column(DataType.TEXT) - readonly id!: string; - - @HasMany(() => Question, '_levelId') - _questions?: Question[]; -} diff --git a/apps/api/src/models/QuestionStatus.ts b/apps/api/src/models/QuestionStatus.ts deleted file mode 100644 index 1a85522a..00000000 --- a/apps/api/src/models/QuestionStatus.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - Table, - Column, - Model, - DataType, - Unique, - AllowNull, - HasMany, - PrimaryKey, -} from 'sequelize-typescript'; - -import { Question } from './Question'; - -@Table({ version: true, timestamps: true }) -export class QuestionStatus extends Model { - @Unique - @AllowNull(false) - @PrimaryKey - @Column(DataType.TEXT) - readonly id!: string; - - @HasMany(() => Question, '_statusId') - _questions?: Question[]; -} diff --git a/apps/api/src/models/QuestionVote.ts b/apps/api/src/models/QuestionVote.ts deleted file mode 100644 index f7ed2417..00000000 --- a/apps/api/src/models/QuestionVote.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - Table, - Column, - Model, - DataType, - ForeignKey, - AllowNull, - BelongsTo, -} from 'sequelize-typescript'; - -import { Question } from './Question'; -import { User } from './User'; - -@Table({ timestamps: true, updatedAt: false }) -export class QuestionVote extends Model { - @ForeignKey(() => User) - @AllowNull(false) - @Column({ type: DataType.INTEGER, primaryKey: true }) - _userId!: number; - - @ForeignKey(() => Question) - @AllowNull(false) - @Column({ type: DataType.INTEGER, primaryKey: true }) - _questionId!: number; - - @BelongsTo(() => User, '_userId') - _user?: User; - - @BelongsTo(() => Question, '_questionId') - _question?: Question; -} diff --git a/apps/api/src/models/Session.ts b/apps/api/src/models/Session.ts deleted file mode 100644 index 82644c82..00000000 --- a/apps/api/src/models/Session.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { nanoid } from 'nanoid'; -import { - Table, - Column, - Model, - DataType, - AllowNull, - PrimaryKey, - Sequelize, - ForeignKey, - BelongsTo, -} from 'sequelize-typescript'; - -import { User } from './User'; - -@Table({ version: true, timestamps: true }) -export class Session extends Model { - @Column - readonly createdAt!: Date; - - @Column - readonly updatedAt!: Date; - - @Column - readonly version!: number; - - // tslint:disable-next-line:no-magic-numbers - @PrimaryKey - @AllowNull(false) - @Column({ - type: Sequelize.STRING, - defaultValue() { - const TOKEN_LENGTH = 36; - return nanoid(TOKEN_LENGTH); - }, - }) - readonly id!: string; - - @AllowNull(false) - @Column({ - type: DataType.BOOLEAN, - defaultValue: false, - }) - keepMeSignedIn!: boolean; - - @AllowNull(false) - @Column(DataType.DATE) - validUntil!: Date; - - @ForeignKey(() => User) - @AllowNull(false) - @Column(DataType.INTEGER) - _userId!: number; - - @BelongsTo(() => User, '_userId') - _user?: User; -} diff --git a/apps/api/src/models/User.ts b/apps/api/src/models/User.ts deleted file mode 100644 index 9cb253f1..00000000 --- a/apps/api/src/models/User.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { - Table, - Column, - Model, - DataType, - Unique, - DefaultScope, - ForeignKey, - AllowNull, - Default, - BelongsTo, - IFindOptions, - Scopes, - BelongsToMany, -} from 'sequelize-typescript'; - -import { UserRoleUnion } from '../models-consts'; - -import { Question } from './Question'; -import { QuestionVote } from './QuestionVote'; -import { UserRole } from './UserRole'; - -function withSensitiveData(): IFindOptions { - return { - attributes: ['createdAt', 'updatedAt', 'version', 'socialLogin'], - }; -} - -@DefaultScope({ - attributes: ['id', 'email', 'firstName', 'lastName', '_roleId'], -}) -@Scopes({ - withSensitiveData, -}) -@Table({ version: true, timestamps: true }) -export class User extends Model { - readonly id!: number; - readonly createdAt!: Date; - readonly updatedAt!: Date; - readonly version!: number; - - @Unique - @AllowNull(false) - @Column(DataType.TEXT) - email!: string; - - @AllowNull(true) - @Column(DataType.TEXT) - firstName?: string | null; - - @AllowNull(true) - @Column(DataType.TEXT) - lastName?: string | null; - - @AllowNull(false) - @Default({}) - @Column(DataType.JSONB) - socialLogin!: {}; - - @AllowNull(true) - @Column(DataType.TEXT) - avatarUrl?: string | null; - - @ForeignKey(() => UserRole) - @Default('user') - @AllowNull(false) - @Column(DataType.STRING) - _roleId!: UserRoleUnion; - - @BelongsTo(() => UserRole, '_roleId') - _role?: UserRole; - - @BelongsToMany(() => Question, { - through: () => QuestionVote, - foreignKey: '_userId', - otherKey: '_questionId', - as: '_votedOn', - }) - _votedOn?: Array; -} diff --git a/apps/api/src/models/UserRole.ts b/apps/api/src/models/UserRole.ts deleted file mode 100644 index ec92da6f..00000000 --- a/apps/api/src/models/UserRole.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - Table, - Column, - Model, - DataType, - Unique, - AllowNull, - HasMany, - PrimaryKey, -} from 'sequelize-typescript'; - -import { User } from './User'; - -@Table({ version: true, timestamps: true }) -export class UserRole extends Model { - @Unique - @AllowNull(false) - @PrimaryKey - @Column(DataType.TEXT) - readonly id!: string; - - @HasMany(() => User, '_roleId') - _users?: User[]; -} diff --git a/apps/api/src/modules/health-check/healthCheckRoutes.ts b/apps/api/src/modules/health-check/healthCheckRoutes.ts deleted file mode 100644 index 1e44ba5c..00000000 --- a/apps/api/src/modules/health-check/healthCheckRoutes.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Server } from '@hapi/hapi'; - -import { getConfig } from '../../config'; - -export const healthCheckRoute = { - init(server: Server) { - return server.route({ - method: 'GET', - path: '/health-check', - options: { - description: 'Health check endpoint', - tags: ['api'], - auth: false, - }, - handler() { - return { - ENV: getConfig('ENV'), - SENTRY_VERSION: getConfig('SENTRY_VERSION'), - }; - }, - }); - }, -}; diff --git a/apps/api/src/modules/hello-world/helloWorldRoute.ts b/apps/api/src/modules/hello-world/helloWorldRoute.ts deleted file mode 100644 index 03307165..00000000 --- a/apps/api/src/modules/hello-world/helloWorldRoute.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Server } from '@hapi/hapi'; - -export const helloWorldRoute = { - init(server: Server) { - return server.route({ - method: 'GET', - path: '/helloWorld', - options: { - description: 'Test endpoint', - tags: ['api'], - }, - handler() { - return 'Hello, world!'; - }, - }); - }, -}; diff --git a/apps/api/src/modules/question-votes/questionVotesRoutes.ts b/apps/api/src/modules/question-votes/questionVotesRoutes.ts deleted file mode 100644 index 8dbaf28e..00000000 --- a/apps/api/src/modules/question-votes/questionVotesRoutes.ts +++ /dev/null @@ -1,104 +0,0 @@ -import Boom from '@hapi/boom'; -import { Server } from '@hapi/hapi'; - -import { definitions } from '../../../apiTypes'; -import { Question } from '../../models/Question'; -import { QuestionVote } from '../../models/QuestionVote'; -import { User } from '../../models/User'; - -import { - CreateQuestionVoteRequestSchema, - CreateQuestionVoteResponseSchema, -} from './questionVotesSchemas'; - -export const questionVotesRoutes = { - async init(server: Server) { - await server.route({ - method: 'POST', - path: '/question-votes', - options: { - auth: { - mode: 'required', - access: { - scope: ['admin', 'user-{query._userId}'], - }, - }, - tags: ['api', 'questions', 'votes'], - validate: CreateQuestionVoteRequestSchema, - description: 'Votes on a question', - response: { - schema: CreateQuestionVoteResponseSchema, - }, - }, - async handler(request): Promise { - const { - _userId, - _questionId, - } = (request.query as unknown) as definitions['postQuestionVotesRequestQuery']; - - const question = await Question.findByPk(_questionId, { attributes: ['id'] }); - if (!question) { - throw Boom.badRequest(`Question with id=${_questionId} doesn't exist!`); - } - - const user = await User.findByPk(_userId, { attributes: ['id'] }); - if (!user) { - throw Boom.badRequest(`User with id=${_userId} doesn't exist!`); - } - - const [questionVote] = await QuestionVote.findOrCreate({ - raw: true, - where: { - _userId, - _questionId, - }, - defaults: { - _userId, - _questionId, - }, - }); - - return { - data: { - _userId: questionVote._userId, - _questionId: questionVote._questionId, - }, - }; - }, - }); - - await server.route({ - method: 'DELETE', - path: '/question-votes', - options: { - auth: { - mode: 'required', - access: { - scope: ['admin', 'user-{query._userId}'], - }, - }, - tags: ['api', 'questions', 'votes'], - validate: CreateQuestionVoteRequestSchema, - description: 'Votes on a question', - response: { - emptyStatusCode: 204, - }, - }, - async handler(request) { - const { - _userId, - _questionId, - } = (request.query as unknown) as definitions['deleteQuestionVotesRequestQuery']; - - await QuestionVote.destroy({ - where: { - _userId, - _questionId, - }, - }); - - return null; - }, - }); - }, -}; diff --git a/apps/api/src/modules/question-votes/questionVotesSchemas.ts b/apps/api/src/modules/question-votes/questionVotesSchemas.ts deleted file mode 100644 index 85aa76bc..00000000 --- a/apps/api/src/modules/question-votes/questionVotesSchemas.ts +++ /dev/null @@ -1,15 +0,0 @@ -import Joi from '@hapi/joi'; - -export const CreateQuestionVoteRequestSchema = { - query: Joi.object({ - _userId: Joi.number().integer().required(), - _questionId: Joi.number().integer().required(), - }).required(), -}; - -export const CreateQuestionVoteResponseSchema = Joi.object({ - data: Joi.object({ - _userId: Joi.number().integer().required(), - _questionId: Joi.number().integer().required(), - }).required(), -}); diff --git a/apps/api/src/modules/questions/questionRoutes.integration.test.ts b/apps/api/src/modules/questions/questionRoutes.integration.test.ts deleted file mode 100644 index 0fbac86c..00000000 --- a/apps/api/src/modules/questions/questionRoutes.integration.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Server } from '@hapi/hapi'; -import { expect } from 'chai'; -import faker = require('faker'); -import { uniqBy } from 'lodash'; - -import { questionCategories, questionLevels } from '../../models-consts'; -import { Question } from '../../models/Question'; -import { getServerWithPlugins } from '../../server'; -import { generateQuestions } from '../../tests/integrationTestsUtils'; - -describe('helloWorldRoute', async () => { - let devfaqServer: Server; - - beforeEach(async () => { - devfaqServer = await getServerWithPlugins(); - }); - - it('should allow empty response', async () => { - const res = await devfaqServer.inject({ - method: 'GET', - url: '/questions', - }); - - expect(res.result).to.eql({ data: [], meta: { total: 0 } }); - }); - - const assertAreAllAccepted = >(result: T) => { - const areAllAccepted = result.every((item) => item._statusId === 'accepted' && item.acceptedAt); - expect(areAllAccepted).to.eql(true); - }; - - const assertAreAllPending = >(result: T) => { - const areAllPending = result.every((item) => item._statusId === 'pending' && !item.acceptedAt); - expect(areAllPending).to.eql(true); - }; - - const assertAreAllUnique = >(result: T) => { - const areAllUnique = uniqBy(result, 'id').length === result.length; - expect(areAllUnique).to.eql(true); - }; - - const assertAllHaveValidCategory = >(category: any, result: T) => { - const allHaveValidCategory = result.every((item) => item._categoryId === category); - expect(allHaveValidCategory, category).to.eql(true); - }; - - const assertAllHaveValidLevel = >(level: any, result: T) => { - const allHaveValidLevel = result.every((item) => item._levelId === level); - expect(allHaveValidLevel, level).to.eql(true); - }; - - describe('GET /questions', async () => { - it('should return all questions in query', async () => { - await generateQuestions(20); - - const res = await devfaqServer.inject({ - method: 'GET', - url: '/questions', - }); - - const result = res.result as any; - expect(result).to.be.an('object'); - - assertAreAllAccepted(result!.data); - assertAreAllUnique(result!.data); - }); - - it('should return questions matching the query', async () => { - await generateQuestions(20); - - for (let i = 0; i < 5; ++i) { - const category = faker.random.arrayElement(questionCategories); - const level = faker.random.arrayElement(questionLevels); - - const res = await devfaqServer.inject({ - method: 'GET', - url: `/questions?category=${category}&level=${level}`, - }); - const result = res.result as any; - - assertAllHaveValidCategory(category, result!.data); - assertAllHaveValidLevel(level, result!.data); - assertAreAllAccepted(result!.data); - assertAreAllUnique(result!.data); - } - }); - }); - - describe('POST /questions', async () => { - it('should create questions with status=pending', async () => { - for (let i = 0; i < 20; ++i) { - const category = faker.random.arrayElement(questionCategories); - const level = faker.random.arrayElement(questionLevels); - - const payload = { - question: faker.lorem.sentence(), - category, - level, - }; - - await devfaqServer.inject({ - method: 'POST', - url: `/questions`, - payload, - }); - } - - const questions = await Question.findAll({ raw: true }); - assertAreAllPending(questions); - assertAreAllUnique(questions); - }); - }); -}); diff --git a/apps/api/src/modules/questions/questionRoutes.ts b/apps/api/src/modules/questions/questionRoutes.ts deleted file mode 100644 index 428e71ab..00000000 --- a/apps/api/src/modules/questions/questionRoutes.ts +++ /dev/null @@ -1,278 +0,0 @@ -import Boom from '@hapi/boom'; -import Hapi from '@hapi/hapi'; -import { IFindOptions } from 'sequelize-typescript'; - -import { definitions } from '../../../apiTypes'; -import { Question } from '../../models/Question'; -import { isAdmin, getCurrentUser } from '../../utils/utils'; - -import { - GetQuestionsRequestSchema, - GetQuestionsResponseSchema, - CreateQuestionRequestSchema, - CreateQuestionResponseSchema, - GetOneQuestionRequestSchema, - GetOneQuestionResponseSchema, - UpdateQuestionRequestSchema, - UpdateQuestionResponseSchema, -} from './questionSchemas'; - -function columnNameFromQuery( - orderBy: NonNullable -): string { - switch (orderBy) { - case 'level': - return '_levelId'; - default: - return orderBy; - } -} - -function getOrderFromQuery(request: Hapi.Request): IFindOptions['order'] { - const { order, orderBy } = request.query as definitions['getQuestionsRequestQuery']; - if (!order || !orderBy) { - return undefined; - } - - return [[columnNameFromQuery(orderBy), order], ['id']]; -} - -export const questionsRoutes = { - async init(server: Hapi.Server) { - await server.route({ - method: 'GET', - path: '/questions', - options: { - auth: { mode: 'try' }, - tags: ['api', 'questions'], - validate: GetQuestionsRequestSchema, - description: 'Returns questions', - response: { - schema: GetQuestionsResponseSchema, - }, - }, - async handler(request): Promise { - const { - category, - level, - status, - limit, - offset, - } = request.query as definitions['getQuestionsRequestQuery']; - const currentUser = getCurrentUser(request); - - const where = { - ...(category && { _categoryId: category }), - ...(level && { _levelId: level }), - ...(status && isAdmin(request) ? { _statusId: status } : { _statusId: 'accepted' }), - }; - - const total = await Question.count({ - where, - }); - - const order = getOrderFromQuery(request); - - const questions = await Question.findAllWithVotes( - { - where, - limit, - offset, - ...(order && { order }), - subQuery: false, - }, - currentUser && currentUser.id - ); - - const data = questions.map((q) => { - return { - id: q.id, - question: q.question, - _categoryId: q._categoryId, - _levelId: q._levelId, - _statusId: q._statusId, - acceptedAt: q.acceptedAt?.toISOString(), - votesCount: q.votesCount, - currentUserVotedOn: q.didUserVoteOn, - }; - }); - - return { data, meta: { total } }; - }, - }); - - await server.route({ - method: 'POST', - path: '/questions', - options: { - auth: { mode: 'try' }, - tags: ['api', 'questions'], - validate: CreateQuestionRequestSchema, - description: 'Creates a question', - notes: `When user is not an admin, it won't publish the question`, - response: { - schema: CreateQuestionResponseSchema, - }, - }, - async handler(request): Promise { - const { - question, - level, - category, - } = request.payload as definitions['postQuestionsRequestBody']; - - const newQuestion = await Question.create({ - question, - _levelId: level, - _categoryId: category, - _statusId: 'pending', - }); - - const data = { - id: newQuestion.id, - question: newQuestion.question, - _categoryId: newQuestion._categoryId, - _levelId: newQuestion._levelId, - _statusId: newQuestion._statusId, - acceptedAt: newQuestion.acceptedAt?.toISOString(), - currentUserVotedOn: false, - votesCount: 0, - }; - - return { data }; - }, - }); - - await server.route({ - method: 'PATCH', - path: '/questions/{id}', - options: { - auth: { - mode: 'required', - scope: ['admin'], - }, - tags: ['api', 'questions'], - validate: UpdateQuestionRequestSchema, - description: 'Updates a question', - response: { - schema: UpdateQuestionResponseSchema, - }, - }, - async handler(request): Promise { - const { - id, - } = (request.params as unknown) as definitions['patchQuestionsIdRequestPathParams']; - - const q = await Question.scope('withVotes').findByPk(id); - - if (!q) { - throw Boom.notFound(); - } - - const { - question, - level, - category, - status, - } = request.payload as definitions['patchQuestionsIdRequestBody']; - - const currentUser = getCurrentUser(request); - - q.question = question; - q._levelId = level; - q._categoryId = category; - q._statusId = status; - - await q.save(); - - const data = { - id: q.id, - question: q.question, - _categoryId: q._categoryId, - _levelId: q._levelId, - _statusId: q._statusId, - acceptedAt: q.acceptedAt?.toISOString(), - currentUserVotedOn: currentUser ? await Question.didUserVoteOn(currentUser, q) : false, - votesCount: q.votesCount, - }; - - return { data }; - }, - }); - - await server.route({ - method: 'GET', - path: '/questions/{id}', - options: { - auth: { mode: 'try' }, - tags: ['api', 'questions'], - validate: GetOneQuestionRequestSchema, - description: 'Returns one question', - response: { - schema: GetOneQuestionResponseSchema, - }, - }, - async handler(request): Promise { - const { - id, - } = (request.params as unknown) as definitions['getQuestionsIdRequestPathParams']; - - const question = await Question.scope('withVotes').findOne({ - where: { - id, - _statusId: 'accepted', - }, - }); - - if (!question) { - throw Boom.notFound(); - } - - const currentUser = getCurrentUser(request); - - const data = { - id: question.id, - question: question.question, - _categoryId: question._categoryId, - _levelId: question._levelId, - _statusId: question._statusId, - acceptedAt: question.acceptedAt?.toISOString(), - currentUserVotedOn: currentUser - ? await Question.didUserVoteOn(currentUser, question) - : false, - votesCount: question.votesCount, - }; - - return { data }; - }, - }); - - await server.route({ - method: 'DELETE', - path: '/questions/{id}', - options: { - auth: { - mode: 'required', - access: { - scope: ['admin'], - }, - }, - tags: ['api', 'questions'], - validate: GetOneQuestionRequestSchema, - description: 'Deletes one question', - response: { - emptyStatusCode: 204, - }, - }, - async handler(request) { - const { id } = request.params; - - await Question.destroy({ - where: { id }, - }); - - return null; - }, - }); - }, -}; diff --git a/apps/api/src/modules/questions/questionSchemas.ts b/apps/api/src/modules/questions/questionSchemas.ts deleted file mode 100644 index fa883c05..00000000 --- a/apps/api/src/modules/questions/questionSchemas.ts +++ /dev/null @@ -1,77 +0,0 @@ -import Joi from '@hapi/joi'; - -import { questionCategories, questionStatuses, questionLevels } from '../../models-consts'; - -export const QuestionCategorySchema = Joi.string().valid(...questionCategories); - -export const QuestionStatusSchema = Joi.string().valid(...questionStatuses); - -export const QuestionLevelSchema = Joi.string().valid(...questionLevels); - -export const QuestionSchema = Joi.object({ - id: Joi.number().integer().required(), - question: Joi.string().required(), - _categoryId: QuestionCategorySchema.required(), - _levelId: QuestionLevelSchema.required(), - _statusId: QuestionStatusSchema.required(), - acceptedAt: Joi.date().allow(null), -}); - -export const GetQuestionsRequestSchema = { - query: Joi.object({ - category: QuestionCategorySchema, - status: QuestionStatusSchema, - level: Joi.array().items(QuestionLevelSchema).single().optional(), - limit: Joi.number().integer().optional(), - offset: Joi.number().integer().optional(), - orderBy: Joi.string().valid('acceptedAt', 'level', 'votesCount'), - order: Joi.string().valid('asc', 'desc'), - }).required(), -}; - -export const QuestionResponseSchema = QuestionSchema.keys({ - votesCount: Joi.number().integer().required(), - currentUserVotedOn: Joi.bool(), -}); - -export const GetQuestionsResponseSchema = Joi.object({ - data: Joi.array().items(QuestionResponseSchema).required(), - meta: Joi.object({ - total: Joi.number().required(), - }).optional(), -}); - -export const GetOneQuestionRequestSchema = { - params: Joi.object({ - id: Joi.number().integer().required(), - }).required(), -}; - -export const GetOneQuestionResponseSchema = Joi.object({ - data: QuestionResponseSchema.required(), -}).required(); - -export const CreateQuestionRequestPayloadSchema = Joi.object({ - question: Joi.string().required(), - level: QuestionLevelSchema.required(), - category: QuestionCategorySchema.required(), -}); - -export const CreateQuestionRequestSchema = { - payload: CreateQuestionRequestPayloadSchema.required(), -}; - -export const CreateQuestionResponseSchema = Joi.object({ - data: QuestionResponseSchema.required(), -}).required(); - -export const UpdateQuestionRequestSchema = { - params: Joi.object({ - id: Joi.number().integer().required(), - }).required(), - payload: CreateQuestionRequestPayloadSchema.keys({ - status: QuestionStatusSchema.required(), - }).required(), -}; - -export const UpdateQuestionResponseSchema = CreateQuestionResponseSchema; diff --git a/apps/api/src/plugins/auth/github.ts b/apps/api/src/plugins/auth/github.ts deleted file mode 100644 index 436e8506..00000000 --- a/apps/api/src/plugins/auth/github.ts +++ /dev/null @@ -1,112 +0,0 @@ -import Bell from '@hapi/bell'; -import Boom from '@hapi/boom'; -import Hapi from '@hapi/hapi'; -import fetch from 'node-fetch'; - -import type { AuthProviderOptions } from '.'; - -export interface GitHubAuthPluginConfig { - githubClientId: string; - githubClientSecret: string; - githubPassword: string; - isProduction: boolean; -} - -interface GitHubCredentials { - token: string; - profile: { - id: number; - username: string; - displayName: string; - email: string | null; - raw: unknown; - }; -} - -const getNames = (credentials: GitHubCredentials): { firstName: string; lastName: string } => { - if (!credentials.profile || !credentials.profile.displayName) { - return { firstName: '', lastName: '' }; - } - - const [firstName, ...rest] = credentials.profile.displayName.split(' '); - return { - firstName, - lastName: rest.join(' '), - }; -}; - -const GitHubAuthPlugin: Hapi.Plugin = { - multiple: false, - name: 'DEVFAQ-API GitHub Auth Plugin', - version: '1.0.0', - async register(server, options) { - const bellOptions: Bell.BellOptions = { - provider: 'github', - password: options.githubPassword, - clientId: options.githubClientId, - clientSecret: options.githubClientSecret, - isSecure: options.isProduction, - forceHttps: options.isProduction, - }; - await server.auth.strategy('github', 'bell', bellOptions); - - await server.route({ - method: ['GET', 'POST'], - path: '/github', - options: { - auth: { - mode: 'try', - strategy: 'github', - }, - tags: ['api', 'oauth', 'github'], - }, - async handler(request, h) { - if (!request.auth.isAuthenticated) { - return request.auth.error.message; - } - - const gitHubCredentials = (request.auth.credentials as unknown) as GitHubCredentials; - - const token = gitHubCredentials.token; - - const res = await fetch('https://api.github.com/user/emails', { - headers: { - Authorization: `token ${token}`, - }, - }); - - if (!res.ok) { - throw Boom.serverUnavailable('GitHub responded with an error!'); - } - - const emails = (await res.json()) as Array<{ - email: string; - primary: boolean; - verified: boolean; - visibility: unknown; - }>; - const primaryEmail = emails.find((e) => e.primary && e.verified); - - if (!primaryEmail) { - throw Boom.unauthorized('Your primary email is not verified!'); - } - - const { firstName, lastName } = getNames(gitHubCredentials); - - return options.next( - { - serviceName: 'github', - externalServiceId: gitHubCredentials.profile.id, - email: primaryEmail.email, - firstName, - lastName, - }, - request, - h - ); - }, - }); - }, -}; - -export default GitHubAuthPlugin; diff --git a/apps/api/src/plugins/auth/index.ts b/apps/api/src/plugins/auth/index.ts deleted file mode 100644 index af5a6486..00000000 --- a/apps/api/src/plugins/auth/index.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { isString } from 'util'; - -import Bell from '@hapi/bell'; -import Boom from '@hapi/boom'; -import HapiAuthCookie from '@hapi/cookie'; -import Hapi from '@hapi/hapi'; -import Joi from '@hapi/joi'; -import { Op } from 'sequelize'; - -import { Session } from '../../models/Session'; -import { User } from '../../models/User'; - -import GitHubAuthPlugin from './github'; -import type { GitHubAuthPluginConfig } from './github'; -import { createNewSession, getNewSessionValidUntil } from './session'; - -declare module '@hapi/hapi' { - interface AuthCredentials { - session: Session; - } -} - -declare module '@hapi/boom' { - export function isBoom(err: any, statusCode?: number): err is Boom; -} - -interface RequiredOptions { - cookieDomain: string; - isProduction: boolean; - cookiePassword: string; -} - -type ProviderOptions = GitHubAuthPluginConfig | {}; -type AuthPluginOptions = RequiredOptions & ProviderOptions; - -interface AuthUserData { - serviceName: Bell.Provider; - externalServiceId: number | string; - email: string; - firstName?: string; - lastName?: string; -} - -type AuthProviderNext = ( - data: AuthUserData, - // tslint:disable-next-line:no-any - request: Hapi.Request, - h: Hapi.ResponseToolkit -) => Hapi.Lifecycle.ReturnValue; - -export interface AuthProviderOptions { - next: AuthProviderNext; -} - -const meAuthSchema = Joi.object({ - keepMeSignedIn: Joi.boolean().required(), - validUntil: Joi.date().required(), - createdAt: Joi.date().required(), - updatedAt: Joi.date().required(), - version: Joi.number().required(), - _userId: Joi.number().required(), - _user: Joi.object({ - id: Joi.number().required(), - email: Joi.string().required(), - createdAt: Joi.date().required(), - updatedAt: Joi.date().required(), - _roleId: Joi.string().required(), - firstName: Joi.string().allow('', null), - lastName: Joi.string().allow('', null), - // socialLogin: Joi.object({ - // github: Joi.alternatives(Joi.string(), Joi.number().integer()), - // }).allow(null), - socialLogin: Joi.any(), - }).required(), -}); - -async function maybeUpdateSessionValidity(session: Session) { - const validUntil = session.validUntil; - const newValidUntil = getNewSessionValidUntil(session.keepMeSignedIn); - - // tslint:disable-next-line:no-magic-numbers - const ONE_MINUTE = 1000 * 60; - if (newValidUntil.getTime() - validUntil.getTime() <= ONE_MINUTE) { - return; // update at most after 1 minute - } - - session.validUntil = newValidUntil; - await session.save(); -} - -const findOrCreateAccountFor = async ({ - serviceName, - externalServiceId, - email, - firstName, - lastName, -}: AuthUserData): Promise => { - const userWithSocialLogin = await User.findOne({ - where: { - socialLogin: { - [serviceName]: { - [Op.eq]: externalServiceId, - }, - }, - }, - }); - - if (userWithSocialLogin) { - return userWithSocialLogin; - } else { - const userWithEmail = await User.findOne({ - where: { - email, - }, - }); - if (userWithEmail) { - // @todo merge accounts - throw Boom.conflict('User with provided email already exists!'); - } else { - const user = await User.create({ - email, - socialLogin: { [serviceName]: externalServiceId }, - firstName, - lastName, - }); - - return user; - } - } -}; - -const next: AuthProviderNext = async (authData, request, _h) => { - const user = await findOrCreateAccountFor(authData); - const session = await createNewSession(user, false); - - request.cookieAuth.set({ id: session.id }); - - return ` - - - - - `.trim(); -}; - -const AuthPlugin: Hapi.Plugin = { - multiple: false, - name: 'DEVFAQ-API Auth Plugin', - version: '1.0.0', - async register(server, options) { - await server.register(Bell); - await server.register(HapiAuthCookie); - - const cookieOptions: HapiAuthCookie.Options = { - cookie: { - name: 'session', - password: options.cookiePassword, - // tslint:disable-next-line:no-magic-numbers - ttl: 365 * 24 * 60 * 60 * 1000, - encoding: 'iron' as 'iron', - isSecure: options.isProduction, - isHttpOnly: true, - clearInvalid: true, - strictHeader: true, - isSameSite: 'Lax' as 'Lax', - domain: options.cookieDomain, - path: '/', - }, - async validateFunc(request, session: { id?: string | number } | undefined) { - if (!session || !session.id) { - return { valid: false }; - } - - const sessionModel = await Session.findOne({ - where: { - id: session.id, - validUntil: { - [Op.gte]: new Date(), - }, - }, - include: [User.scope(['defaultScope', 'withSensitiveData'])], - }); - - if (!sessionModel) { - request?.cookieAuth.clear(); - return { valid: false }; - } - - await maybeUpdateSessionValidity(sessionModel); - - const roleId = sessionModel._user && sessionModel._user._roleId; - const userId = sessionModel._user && sessionModel._user.id; - const scope = ['user', `user-${userId}`, roleId].filter(isString); - - return { valid: true, credentials: { session: sessionModel, scope } }; - }, - }; - await server.auth.strategy('session', 'cookie', cookieOptions); - await server.auth.default('session'); - - if ('githubClientId' in options && options.githubClientId && options.githubClientSecret) { - const githubOptions: GitHubAuthPluginConfig & AuthProviderOptions = { - ...options, - next, - }; - await server.register({ - plugin: GitHubAuthPlugin, - options: githubOptions, - }); - } - - await server.route({ - method: 'POST', - path: '/logout', - options: { - tags: ['api', 'oauth'], - auth: { - mode: 'try', - strategy: 'session', - }, - }, - async handler(request) { - request.cookieAuth.clear(); - if (request.auth.credentials && request.auth.credentials.session) { - await Session.destroy({ - where: { - id: request.auth.credentials.session.id, - }, - }); - } - - return null; - }, - }); - - await server.route({ - method: 'GET', - path: '/me', - options: { - tags: ['api', 'oauth'], - auth: { - mode: 'try', - strategy: 'session', - }, - response: { - schema: Joi.object({ - data: meAuthSchema.required().allow(null), - }).required(), - }, - }, - async handler(request) { - if (request.auth.credentials && request.auth.credentials.session) { - return { data: request.auth.credentials.session.toJSON() }; - } - - return { data: null }; - }, - }); - }, -}; - -export default AuthPlugin; diff --git a/apps/api/src/plugins/auth/session.ts b/apps/api/src/plugins/auth/session.ts deleted file mode 100644 index 6189d79f..00000000 --- a/apps/api/src/plugins/auth/session.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Op } from 'sequelize'; - -import { Session } from '../../models/Session'; -import { User } from '../../models/User'; - -export function getNewSessionValidUntil(keepMeSignedIn: boolean): Date { - const validUntil = new Date(); - if (keepMeSignedIn) { - // tslint:disable-next-line:no-magic-numbers - validUntil.setHours(validUntil.getHours() + 24 * 7); - } else { - // tslint:disable-next-line:no-magic-numbers - validUntil.setHours(validUntil.getHours() + 2); - } - - return validUntil; -} - -export async function createNewSession(user: User, keepMeSignedIn = false) { - await Session.destroy({ - where: { - validUntil: { - [Op.lt]: new Date(), - }, - }, - }); - - const session = await Session.create({ - validUntil: getNewSessionValidUntil(keepMeSignedIn), - keepMeSignedIn, - _userId: user.id, - }); - - // @todo - // await user.update('lastLoginAt', new Date()); - - return session.reload({ - include: [{ model: User }], - }); -} diff --git a/apps/api/src/plugins/cls/cls.ts b/apps/api/src/plugins/cls/cls.ts deleted file mode 100644 index 96abec8e..00000000 --- a/apps/api/src/plugins/cls/cls.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Plugin } from '@hapi/hapi'; -import * as Sentry from '@sentry/node'; -import { setClsProxyValue } from 'cls-proxify'; -import uuid from 'uuid'; - -import { - contextNs, - setContext, - getContext, - updateContext, - USER_CONTEXT_KEY, - SENTRY_BREADCRUMBS_KEY, - SENTRY_KEY, -} from './context'; - -declare module '@hapi/hapi' { - interface PluginProperties { - cls: { - setContext: typeof setContext; - getContext: typeof getContext; - updateContext: typeof updateContext; - }; - } -} - -export const cls: Plugin<{}> = { - multiple: false, - name: 'cls', - version: '1.0.0', - - async register(server, _options) { - server.expose('setContext', setContext); - server.expose('getContext', getContext); - server.expose('updateContext', updateContext); - - server.ext('onRequest', (request, h) => { - contextNs.bindEmitter(request.raw.req); - contextNs.bindEmitter(request.raw.res); - return contextNs.runPromise(async () => { - request.server.plugins.cls.setContext(USER_CONTEXT_KEY, { currentRequestID: uuid.v4() }); - setClsProxyValue(SENTRY_KEY, { - addBreadcrumb(breadcrumb: Sentry.Breadcrumb) { - const breadcrumbs = getContext(SENTRY_BREADCRUMBS_KEY) || []; - breadcrumbs.push(breadcrumb); - setContext(SENTRY_BREADCRUMBS_KEY, breadcrumbs); - }, - Severity: Sentry.Severity, - withScope: Sentry.withScope, - captureException: Sentry.captureException, - getCurrentHub: Sentry.getCurrentHub, - }); - return h.continue; - }); - }); - - server.ext('onPostAuth', (request, h) => { - if (request.auth.credentials.session._user?.email) { - updateContext(USER_CONTEXT_KEY, { - userEmail: request.auth.credentials.session._user?.email, - }); - } - return h.continue; - }); - - server.events.on('response', () => { - // cleanup - setContext(SENTRY_BREADCRUMBS_KEY, undefined); - }); - }, -}; diff --git a/apps/api/src/plugins/cls/context.ts b/apps/api/src/plugins/cls/context.ts deleted file mode 100644 index e70727a5..00000000 --- a/apps/api/src/plugins/cls/context.ts +++ /dev/null @@ -1,45 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { clsProxifyNamespace, clsProxify } from 'cls-proxify'; - -export const USER_CONTEXT_KEY = 'USER_CONTEXT'; -type USER_CONTEXT_KEY = typeof USER_CONTEXT_KEY; -export const SENTRY_BREADCRUMBS_KEY = 'SENTRY_BREADCRUMBS'; -type SENTRY_BREADCRUMBS_KEY = typeof SENTRY_BREADCRUMBS_KEY; -export const contextNs = clsProxifyNamespace; - -export const SENTRY_KEY = 'SENTRY_KEY'; -export const SentryCLS = clsProxify(SENTRY_KEY, Sentry); - -interface ContextData { - currentRequestID?: string; - userEmail?: string; - key?: string; -} - -type KeyToValue = { - [SENTRY_BREADCRUMBS_KEY]: Sentry.Breadcrumb[] | undefined; - [USER_CONTEXT_KEY]: ContextData | undefined; -}; - -export function setContext( - name: N, - values: KeyToValue[N] -): KeyToValue[N] { - return contextNs.active ? contextNs.set(name, values) : undefined; -} - -export function updateContext( - name: N, - updates: KeyToValue[N] -): KeyToValue[N] { - const context = getContext(name) ?? setContext(name, updates); - if (!context) { - return undefined; - } - Object.assign(context, updates); - return context; -} - -export function getContext(name: N): KeyToValue[N] { - return contextNs.active ? contextNs.get(name) : undefined; -} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts deleted file mode 100644 index 8a30ad9f..00000000 --- a/apps/api/src/server.ts +++ /dev/null @@ -1,276 +0,0 @@ -import * as fs from 'fs'; - -import Boom from '@hapi/boom'; -import Hapi from '@hapi/hapi'; -import Inert from '@hapi/inert'; -import Joi from '@hapi/joi'; -import Vision from '@hapi/vision'; -import HapiSwagger from 'hapi-swagger'; - -import pkg from '../package.json'; - -import { getConfig, isProd, isStaging } from './config'; -import { healthCheckRoute } from './modules/health-check/healthCheckRoutes'; -import { helloWorldRoute } from './modules/hello-world/helloWorldRoute'; -import { questionVotesRoutes } from './modules/question-votes/questionVotesRoutes'; -import { questionsRoutes } from './modules/questions/questionRoutes'; -import AuthPlugin from './plugins/auth'; -import { SentryCLS } from './plugins/cls/context'; -import { handleException, routeToLabel } from './utils/utils'; - -const getServer = () => { - return new Hapi.Server({ - host: '0.0.0.0', - port: getConfig('PORT'), - routes: { - cors: { - origin: ['*'], - credentials: true, - }, - response: { - modify: true, - options: { - allowUnknown: false, - convert: true, - stripUnknown: { objects: true }, - }, - }, - validate: { - async failAction(_request, _h, err) { - if (isProd()) { - // In prod, log a limited error message and throw the default Bad Request error. - handleException(err, SentryCLS.Severity.Warning); - - throw Boom.badRequest(`Invalid request payload input`); - } else { - handleException(err, SentryCLS.Severity.Warning); - throw err; - } - }, - }, - }, - }); -}; - -export async function getServerWithPlugins() { - const server = getServer(); - server.validator(Joi); - - if (process.env.ENV !== 'test') { - /** - * @description Automatically generate labels for all endpoints with validation for the purpose of automatic types generation on the front-end - */ - const allLabels = new Map(); - server.events.on('route', (route) => { - if (route.path.startsWith('/swaggerui') || route.path.startsWith('/documentation')) { - return; // skip - } - - if (!route.settings.response?.schema) { - if (!isProd() && getConfig('ENV') !== 'test') { - console.warn(`Route without a response schema: ${route.method} ${route.path}`); - } - return; - } - - if (!Joi.isSchema(route.settings.response?.schema)) { - if (!isProd() && getConfig('ENV') !== 'test') { - console.warn(`Route response schema is not a Joi schema: ${route.method} ${route.path}`); - } - return; - } - - // tslint:disable-next-line: no-any - const maybeALabel = (route.settings.response.schema as any)._flags?.label; - if (maybeALabel) { - if (!isProd() && getConfig('ENV') !== 'test') { - console.log( - `Skipping route ${route.method} ${route.path} because it already has a response schema label: ${maybeALabel}` - ); - } - allLabels.set(maybeALabel, true); - return; - } - - const label = routeToLabel(route) + 'Response'; - - if (allLabels.has(label)) { - throw new Error(`Duplicate label: ${label} for ${route.method} ${route.path}`); - } - allLabels.set(label, true); - - // route.settings.response.schema = (route.settings.response.schema as Joi.Schema).label(label); - }); - } - - server.events.on({ name: 'request', channels: 'error' }, (request, event, _tags) => { - const baseUrl = `${server.info.protocol}://${request.info.host}`; - - SentryCLS.withScope((scope) => { - scope.setExtra('timestamp', request.info.received); - scope.setExtra('remoteAddress', request.info.remoteAddress); - - // const user = - // request.auth && - // request.auth.credentials && - // request.auth.credentials.session && - // request.auth.credentials.session._user; - // if (user) { - // scope.setUser({ - // id: user.id, - // username: user.email, - // email: user.email, - // json: user.toJSON(), - // }); - // } - - const extraData = { - method: request.method, - query_string: request.query, - headers: request.headers, - cookies: request.state, - url: baseUrl + request.path, - data: ['get', 'head'].includes(request.method) ? '' : request.payload, - }; - - scope.setExtra('request', extraData); - - handleException(event.error); - }); - }); - - const swaggerOptions: HapiSwagger.RegisterOptions = { - info: { - title: `${pkg.name} Documentation`, - version: - getConfig('ENV') + '-' + pkg.version + '-' + fs.readFileSync('.version', 'utf-8').trim(), - }, - auth: false, - }; - - await server.register([ - { plugin: Inert }, - { plugin: Vision }, - { - plugin: HapiSwagger, - options: swaggerOptions, - }, - ]); - - await server.register( - { - plugin: AuthPlugin, - options: { - cookieDomain: getConfig('COOKIE_DOMAIN'), - isProduction: isProd() || isStaging(), - cookiePassword: getConfig('COOKIE_PASSWORD'), - githubClientId: getConfig('GITHUB_CLIENT_ID'), - githubClientSecret: getConfig('GITHUB_CLIENT_SECRET'), - githubPassword: getConfig('GITHUB_PASSWORD'), - }, - }, - { - routes: { - prefix: '/oauth', - }, - } - ); - - await helloWorldRoute.init(server); - await healthCheckRoute.init(server); - await questionsRoutes.init(server); - await questionVotesRoutes.init(server); - - type CspReport = { - 'blocked-uri': string; - 'document-uri': string; - 'original-policy': string; - referrer: string; - 'violated-directive': string; - 'column-number'?: number; - 'line-number'?: number; - 'source-file': string; - }; - - server.route({ - path: '/csp', - method: 'POST', - options: { - tags: ['api'], - auth: { - mode: 'try', - }, - payload: { - override: 'application/json', - }, - }, - handler(request, h) { - if (typeof request.payload !== 'object' || !('csp-report' in request.payload)) { - return null; - } - const cspReport = request.payload as { - 'csp-report': CspReport; - }; - - SentryCLS.withScope((scope) => { - const { - info: { host, received, remoteAddress }, - method, - query, - headers, - state, - path, - auth, - } = request; - const baseUrl = `${server.info.protocol}://${host}`; - scope.setExtra('timestamp', received); - scope.setExtra('remoteAddress', remoteAddress); - - const user = auth?.credentials?.session?._user; - if (user) { - scope.setUser({ - id: String(user.id), - username: user.email, - email: user.email, - json: user.toJSON(), - }); - } - - const extraData = { - method, - query_string: query, - headers, - cookies: state, - url: baseUrl + path, - data: cspReport['csp-report'], - }; - - scope.setExtra('request', extraData); - - handleException(new Error('CSP Error')); - }); - - return null; - }, - }); - - await server.route({ - method: 'GET', - path: '/', - options: { - auth: { - mode: 'try', - strategy: 'session', - }, - }, - handler(request) { - if (request.auth.isAuthenticated) { - return request.auth.credentials; - } - - return `

Stay awhile and listen.

You're not logged in.

`; - }, - }); - - return server; -} diff --git a/apps/api/src/shim.d.ts b/apps/api/src/shim.d.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/tests/integrationTestsUtils.ts b/apps/api/src/tests/integrationTestsUtils.ts deleted file mode 100644 index 62533821..00000000 --- a/apps/api/src/tests/integrationTestsUtils.ts +++ /dev/null @@ -1,54 +0,0 @@ -import faker from 'faker'; - -import { initDb, sequelize, getAllModels } from '../db'; -import { questionCategories, questionLevels, questionStatuses } from '../models-consts'; -import { Question } from '../models/Question'; - -before(async () => { - await initDb(); - await sequelize.sync({ match: /_test$/, logging: false }); - await clearDB(); -}); - -afterEach(async () => { - await clearDB(); -}); - -async function clearDB() { - const TRUNCATE_BLACKLIST = [ - 'sequelize', - 'Sequelize', - 'SequelizeMeta', - 'QuestionCategory', - 'QuestionLevel', - 'QuestionStatus', - 'UserRole', - ]; - - const keys = Object.keys(getAllModels()).filter((key) => !TRUNCATE_BLACKLIST.includes(key)); - return Promise.all( - keys.map(async (key) => { - await sequelize.query(`TRUNCATE TABLE "${key}" RESTART IDENTITY CASCADE;`, { - raw: true, - }); - }) - ); -} - -export async function generateQuestions(num: number): Promise { - return Promise.all( - Array.from({ length: num }).map(async (_i) => { - const _categoryId = faker.random.arrayElement(questionCategories); - const _levelId = faker.random.arrayElement(questionLevels); - const _statusId = faker.random.arrayElement(questionStatuses); - - return Question.create({ - question: faker.lorem.sentence(), - acceptedAt: faker.date.past(), - _categoryId, - _levelId, - _statusId, - }); - }) - ); -} diff --git a/apps/api/src/tests/utils.test.ts b/apps/api/src/tests/utils.test.ts deleted file mode 100644 index 840d1767..00000000 --- a/apps/api/src/tests/utils.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect } from 'chai'; - -import { defaultToAny } from '../utils/utils'; - -describe('utils', () => { - describe('defaultToAny', () => { - it('should use first value if provided', () => { - expect(defaultToAny(1)).to.eql(1); - }); - - it('should use second value if first is undefined', () => { - expect(defaultToAny(undefined, 'aaa')).to.eql('aaa'); - }); - - it('should use second value if first is null', () => { - expect(defaultToAny(null, 'bbb')).to.eql('bbb'); - }); - - it('should use next values if previous are undefined or null', () => { - expect(defaultToAny(undefined, null, 123)).to.eql(123); - }); - }); -}); diff --git a/apps/api/src/utils/utils.ts b/apps/api/src/utils/utils.ts deleted file mode 100644 index df3f6825..00000000 --- a/apps/api/src/utils/utils.ts +++ /dev/null @@ -1,85 +0,0 @@ -import Hapi, { RequestRoute } from '@hapi/hapi'; -import type { Severity } from '@sentry/node'; -import { isUndefined, omitBy, upperFirst, camelCase } from 'lodash'; -import moment from 'moment'; -import { Model } from 'sequelize-typescript'; - -import { getConfig } from '../config'; -import { User } from '../models/User'; -import { SentryCLS } from '../plugins/cls/context'; - -type Nil = T | undefined | null; -export function defaultToAny(v1: T): T; -export function defaultToAny(v1: Nil, v2: T): T; -export function defaultToAny(v1: Nil, v2: boolean): boolean; -export function defaultToAny(v1: Nil, v2: Nil, v3: T): T; -export function defaultToAny(v1: Nil, v2: Nil, v3: boolean): boolean; -export function defaultToAny(...defaults: Array>) { - return defaults.reduce((prev, next) => { - if (prev === undefined || prev === null || Number.isNaN((prev as unknown) as number)) { - return next; - } - return prev; - }); -} - -// tslint:disable-next-line:no-any -export function handleException(err: any, level?: Severity) { - if (!getConfig('SENTRY_DSN')) { - return console.error(err, level); - } - - if (level) { - SentryCLS.withScope((scope) => { - scope.setLevel(level); - SentryCLS.captureException(err); - }); - } else { - SentryCLS.captureException(err); - } -} - -// tslint:disable-next-line:no-any -export const isEmptyES6 = (obj: any): obj is {} => { - return ( - obj && - Object.getOwnPropertyNames(obj).length === 0 && - Object.getOwnPropertySymbols(obj).length === 0 - ); -}; - -export const omitUndefined = (obj: T): Partial => - omitBy(obj, isUndefined) as Partial; - -export function getNewSessionValidUntil(keepMeSignedIn: boolean): Date { - const now = moment(); - - // tslint:disable-next-line:no-magic-numbers - return keepMeSignedIn ? now.add(7, 'days').toDate() : now.add(2, 'hours').toDate(); -} - -export const getCurrentUser = (request: Hapi.Request): User | undefined => { - return ( - request && - request.auth && - request.auth.credentials && - request.auth.credentials.session && - request.auth.credentials.session._user - ); -}; - -export const isAdmin = (request: Hapi.Request): boolean => { - const user = getCurrentUser(request); - return Boolean(user && user._roleId === 'admin'); -}; - -// tslint:disable-next-line:no-any -export const arrayToJSON = >(entities: Array>) => { - return entities.map((entity) => entity.toJSON()); -}; - -export const routeToLabel = ({ method, path }: RequestRoute): string => { - const prefix = method.toLowerCase(); - const label = upperFirst(camelCase(path)); - return prefix + label; -}; diff --git a/apps/api/test/.mocharc.js b/apps/api/test/.mocharc.js deleted file mode 100644 index 01d4d5f6..00000000 --- a/apps/api/test/.mocharc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - require: ['test/setup-env.js', 'source-map-support/register'], - timeout: 600000, - bail: true, - exit: true, -}; diff --git a/apps/api/test/setup-env.js b/apps/api/test/setup-env.js deleted file mode 100644 index 51214bf0..00000000 --- a/apps/api/test/setup-env.js +++ /dev/null @@ -1,7 +0,0 @@ -var chai = require('chai'); -chai.use(require('sinon-chai')); - -require('ts-node').register({ - project: './tsconfig.json', - transpileOnly: true, -}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 20b16b05..b5b26997 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -1,16 +1,7 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { - "baseUrl": ".", - "emitDecoratorMetadata": true, - "esModuleInterop": true, - "experimentalDecorators": true, - "lib": ["es2017"], - "module": "commonjs", - "outDir": "dist", - "rootDir": ".", - "target": "es2017", - "typeRoots": ["./node_modules/@types", "./typings"] - }, - "exclude": ["dist"] + "extends": "tsconfig/api.json", + "ts-node": { + "transpileOnly": true, + "transpiler": "ts-node/transpilers/swc-experimental" + } } diff --git a/apps/api/utils.ts b/apps/api/utils.ts new file mode 100644 index 00000000..502cff92 --- /dev/null +++ b/apps/api/utils.ts @@ -0,0 +1,10 @@ +/** + * Add typesafety to computed properties that are unions + * @param k union of keys `"a" | "b"` + * @param v value + * @returns a union of `{ [k]: v }` distributed over `k` but typed correctly + * @see https://tsplay.dev/m0bxDw + */ +export function kv(k: K, v: V): { [P in K]: { [Q in P]: V } }[K] { + return { [k]: v } as never; +} diff --git a/apps/app/.env b/apps/app/.env new file mode 100644 index 00000000..abfcb175 --- /dev/null +++ b/apps/app/.env @@ -0,0 +1 @@ +NEXT_PUBLIC_APP_URL=https://${VERCEL_URL} diff --git a/apps/app/.env.local-example b/apps/app/.env.local-example new file mode 100644 index 00000000..5e92dc3d --- /dev/null +++ b/apps/app/.env.local-example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_API_URL=http://api.devfaq.localhost:3002 +NEXT_PUBLIC_APP_URL=http://app.devfaq.localhost:3000 diff --git a/apps/app/.eslintignore b/apps/app/.eslintignore new file mode 100644 index 00000000..20687473 --- /dev/null +++ b/apps/app/.eslintignore @@ -0,0 +1 @@ +storybook-static diff --git a/apps/app/.eslintrc.js b/apps/app/.eslintrc.js new file mode 100644 index 00000000..2d5ed24b --- /dev/null +++ b/apps/app/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + root: true, + extends: ["devfaq", "plugin:storybook/recommended"], + parserOptions: { + tsconfigRootDir: __dirname, + }, + rules: {}, +}; diff --git a/apps/app/.gitignore b/apps/app/.gitignore new file mode 100644 index 00000000..3916044c --- /dev/null +++ b/apps/app/.gitignore @@ -0,0 +1,5 @@ +.vscode +.vercel +storybook-static +.env*.local +all-contributorsrc.json diff --git a/apps/app/.storybook/main.js b/apps/app/.storybook/main.js new file mode 100644 index 00000000..9dcc479e --- /dev/null +++ b/apps/app/.storybook/main.js @@ -0,0 +1,15 @@ +module.exports = { + stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], + addons: [ + "@storybook/addon-links", + "@storybook/addon-essentials", + "@storybook/addon-interactions", + ], + framework: { + name: "@storybook/nextjs", + options: {}, + }, + docs: { + docsPage: true, + }, +}; diff --git a/apps/app/.storybook/preview.js b/apps/app/.storybook/preview.js new file mode 100644 index 00000000..87600a61 --- /dev/null +++ b/apps/app/.storybook/preview.js @@ -0,0 +1,11 @@ +import "../src/styles/globals.css"; + +export const parameters = { + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, +}; diff --git a/apps/app/iconmoon.json b/apps/app/iconmoon.json new file mode 100644 index 00000000..860a2b83 --- /dev/null +++ b/apps/app/iconmoon.json @@ -0,0 +1,170 @@ +{ + "IcoMoonType": "selection", + "icons": [ + { + "icon": { + "paths": [ + "M384 832h640v128h-640zM384 448h640v128h-640zM384 64h640v128h-640zM192 0v256h-64v-192h-64v-64zM128 526v50h128v64h-192v-146l128-60v-50h-128v-64h192v146zM256 704v320h-192v-64h128v-64h-128v-64h128v-64h-128v-64z" + ], + "tags": ["list-numbered", "options"], + "defaultCode": 59833, + "grid": 16, + "attrs": [] + }, + "attrs": [], + "properties": { + "ligatures": "list-numbered, options", + "name": "list-numbered", + "order": 10, + "id": 186, + "prevSize": 32 + }, + "setIdx": 0, + "setId": 1, + "iconIdx": 185 + }, + { + "icon": { + "paths": [ + "M384 64h640v128h-640v-128zM384 448h640v128h-640v-128zM384 832h640v128h-640v-128zM0 128c0-70.692 57.308-128 128-128s128 57.308 128 128c0 70.692-57.308 128-128 128s-128-57.308-128-128zM0 512c0-70.692 57.308-128 128-128s128 57.308 128 128c0 70.692-57.308 128-128 128s-128-57.308-128-128zM0 896c0-70.692 57.308-128 128-128s128 57.308 128 128c0 70.692-57.308 128-128 128s-128-57.308-128-128z" + ], + "tags": ["list", "todo", "bullet", "menu", "options"], + "defaultCode": 59835, + "grid": 16, + "attrs": [] + }, + "attrs": [], + "properties": { + "ligatures": "list2, todo2", + "name": "list2", + "order": 11, + "id": 188, + "prevSize": 32 + }, + "setIdx": 0, + "setId": 1, + "iconIdx": 187 + }, + { + "icon": { + "paths": [ + "M512 192c-223.318 0-416.882 130.042-512 320 95.118 189.958 288.682 320 512 320 223.312 0 416.876-130.042 512-320-95.116-189.958-288.688-320-512-320zM764.45 361.704c60.162 38.374 111.142 89.774 149.434 150.296-38.292 60.522-89.274 111.922-149.436 150.296-75.594 48.218-162.89 73.704-252.448 73.704-89.56 0-176.858-25.486-252.452-73.704-60.158-38.372-111.138-89.772-149.432-150.296 38.292-60.524 89.274-111.924 149.434-150.296 3.918-2.5 7.876-4.922 11.86-7.3-9.96 27.328-15.41 56.822-15.41 87.596 0 141.382 114.616 256 256 256 141.382 0 256-114.618 256-256 0-30.774-5.452-60.268-15.408-87.598 3.978 2.378 7.938 4.802 11.858 7.302v0zM512 416c0 53.020-42.98 96-96 96s-96-42.98-96-96 42.98-96 96-96 96 42.982 96 96z" + ], + "tags": ["eye", "views", "vision", "visit"], + "defaultCode": 59854, + "grid": 16, + "attrs": [] + }, + "attrs": [], + "properties": { + "ligatures": "eye, views", + "name": "eye", + "order": 13, + "id": 207, + "prevSize": 32 + }, + "setIdx": 0, + "setId": 1, + "iconIdx": 206 + }, + { + "icon": { + "paths": [ + "M707.88 484.652c37.498-44.542 60.12-102.008 60.12-164.652 0-141.16-114.842-256-256-256h-320v896h384c141.158 0 256-114.842 256-256 0-92.956-49.798-174.496-124.12-219.348zM384 192h101.5c55.968 0 101.5 57.42 101.5 128s-45.532 128-101.5 128h-101.5v-256zM543 832h-159v-256h159c58.45 0 106 57.42 106 128s-47.55 128-106 128z" + ], + "tags": ["bold", "wysiwyg"], + "defaultCode": 60002, + "grid": 16, + "attrs": [] + }, + "attrs": [], + "properties": { + "ligatures": "bold, wysiwyg4", + "name": "bold", + "order": 8, + "id": 355, + "prevSize": 32 + }, + "setIdx": 0, + "setId": 1, + "iconIdx": 354 + }, + { + "icon": { + "paths": ["M896 64v64h-128l-320 768h128v64h-448v-64h128l320-768h-128v-64z"], + "tags": ["italic", "wysiwyg"], + "defaultCode": 60004, + "grid": 16, + "attrs": [] + }, + "attrs": [], + "properties": { + "ligatures": "italic, wysiwyg6", + "name": "italic", + "order": 9, + "id": 357, + "prevSize": 32 + }, + "setIdx": 0, + "setId": 1, + "iconIdx": 356 + }, + { + "icon": { + "paths": [ + "M576 736l96 96 320-320-320-320-96 96 224 224z", + "M448 288l-96-96-320 320 320 320 96-96-224-224z" + ], + "tags": ["embed", "code", "html", "xml"], + "defaultCode": 60031, + "grid": 16, + "attrs": [] + }, + "attrs": [], + "properties": { + "ligatures": "embed, code", + "name": "embed", + "order": 12, + "id": 384, + "prevSize": 32 + }, + "setIdx": 0, + "setId": 1, + "iconIdx": 383 + } + ], + "height": 1024, + "preferences": { + "showGlyphs": true, + "showQuickUse": true, + "showQuickUse2": true, + "showSVGs": true, + "fontPref": { + "prefix": "icon-", + "metadata": { + "fontFamily": "icomoon" + }, + "metrics": { + "emSize": 1024, + "baseline": 6.25, + "whitespace": 50 + }, + "embed": false + }, + "imagePref": { + "prefix": "icon-", + "png": false, + "useClassSelector": true, + "color": 0, + "bgColor": 16777215, + "classSelector": ".icon", + "name": "icomoon", + "height": 32, + "columns": 16, + "margin": 16 + }, + "historySize": 50, + "showCodes": true, + "gridSize": 16 + } +} diff --git a/apps/app/lib.d.ts b/apps/app/lib.d.ts new file mode 100644 index 00000000..f9797787 --- /dev/null +++ b/apps/app/lib.d.ts @@ -0,0 +1,7 @@ +interface Array { + includes(searchElement: unknown, fromIndex?: number): searchElement is T; +} + +interface ReadonlyArray { + includes(searchElement: unknown, fromIndex?: number): searchElement is T; +} diff --git a/apps/app/next-env.d.ts b/apps/app/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/apps/app/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/app/next.config.js b/apps/app/next.config.js new file mode 100644 index 00000000..87f0c9ba --- /dev/null +++ b/apps/app/next.config.js @@ -0,0 +1,78 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + swcMinify: true, + transpilePackages: [], + experimental: { + appDir: true, + esmExternals: true, + fontLoaders: [{ loader: "@next/font/google", options: { subsets: ["latin", "latin-ext"] } }], + serverComponentsExternalPackages: ["remark-gfm", "remark-prism"], + legacyBrowsers: false, + }, + images: { + remotePatterns: [ + ...["", 0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((number) => ({ + protocol: "https", + hostname: `avatars${number}.githubusercontent.com`, + })), + ], + }, + webpack(config) { + config.module.rules.push({ + test: /\.svg$/, + use: ["@svgr/webpack"], + }); + + return config; + }, + async rewrites() { + return [ + { + source: "/sitemap.xml", + destination: "/api/sitemap.xml", + }, + ]; + }, + async redirects() { + return [ + { + source: "/", + destination: "/questions/js/1", + permanent: false, + }, + { + source: "/questions", + destination: "/questions/js/1", + permanent: false, + }, + { + source: "/admin", + destination: "/admin/pending/1", + permanent: false, + }, + { + source: "/questions/:technology", + destination: "/questions/:technology/1", + permanent: false, + }, + { + source: "/authors", + destination: "/autorzy", + permanent: false, + }, + { + source: "/regulations", + destination: "/regulamin", + permanent: false, + }, + { + source: "/about", + destination: "/jak-korzystac", + permanent: false, + }, + ]; + }, +}; + +module.exports = nextConfig; diff --git a/apps/app/package.json b/apps/app/package.json new file mode 100644 index 00000000..d3cc638d --- /dev/null +++ b/apps/app/package.json @@ -0,0 +1,73 @@ +{ + "name": "app", + "version": "6.0.1", + "private": true, + "scripts": { + "dev": "next dev", + "build": "cp ../../.all-contributorsrc ./src/all-contributorsrc.json && next build", + "start": "next start", + "test": "vitest", + "lint": "next lint --dir .", + "lint:fix": "next lint --dir . --fix --quiet", + "check-types": "tsc --noEmit", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" + }, + "dependencies": { + "@headlessui/react": "1.7.7", + "@next/font": "13.1.1", + "@tanstack/react-query": "4.20.4", + "@tanstack/react-query-devtools": "4.20.4", + "client-only": "0.0.1", + "easymde": "2.18.0", + "next": "13.1.1", + "next-mdx-remote": "4.2.0", + "openapi-typescript-fetch": "1.1.3", + "prismjs": "1.29.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-focus-lock": "2.9.2", + "rehype-prism-plus": "1.5.0", + "remark": "14.0.2", + "strip-markdown": "5.0.0", + "tailwind-merge": "1.8.1" + }, + "devDependencies": { + "@csstools/postcss-oklab-function": "1.1.1", + "@storybook/addon-essentials": "^7.0.0-alpha.54", + "@storybook/addon-interactions": "^7.0.0-alpha.54", + "@storybook/addon-links": "^7.0.0-alpha.54", + "@storybook/nextjs": "^7.0.0-alpha.54", + "@storybook/react": "^7.0.0-alpha.54", + "@storybook/testing-library": "^0.0.13", + "@svgr/webpack": "6.5.1", + "@tailwindcss/typography": "0.5.8", + "@types/node": "18.11.18", + "@types/prismjs": "1.26.0", + "@types/react": "18.0.26", + "@types/react-dom": "18.0.10", + "@types/remark-prism": "1.3.4", + "@vercel/analytics": "0.1.6", + "@vitejs/plugin-react": "3.0.0", + "autoprefixer": "^10.4.13", + "css-loader": "^6.7.3", + "eslint": "8.31.0", + "eslint-config-devfaq": "workspace:*", + "eslint-plugin-storybook": "^0.6.8", + "jsdom": "20.0.3", + "openapi-types": "workspace:*", + "postcss": "^8.4.20", + "postcss-loader": "^7.0.2", + "storybook": "^7.0.0-alpha.54", + "style-loader": "^3.3.1", + "tailwindcss": "^3.2.4", + "tsconfig": "workspace:*", + "typescript": "4.9.4", + "vitest": "0.26.3" + }, + "nextBundleAnalysis": { + "budget": null, + "budgetPercentIncreaseRed": 20, + "showDetails": true + } +} diff --git a/apps/app/postcss.config.js b/apps/app/postcss.config.js new file mode 100644 index 00000000..5777a6dc --- /dev/null +++ b/apps/app/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + "@csstools/postcss-oklab-function": {}, + }, +}; diff --git a/apps/www/public/android-chrome-192x192.png b/apps/app/public/android-chrome-192x192.png similarity index 100% rename from apps/www/public/android-chrome-192x192.png rename to apps/app/public/android-chrome-192x192.png diff --git a/apps/www/public/android-chrome-512x512.png b/apps/app/public/android-chrome-512x512.png similarity index 100% rename from apps/www/public/android-chrome-512x512.png rename to apps/app/public/android-chrome-512x512.png diff --git a/apps/www/public/apple-touch-icon.png b/apps/app/public/apple-touch-icon.png similarity index 100% rename from apps/www/public/apple-touch-icon.png rename to apps/app/public/apple-touch-icon.png diff --git a/apps/www/public/browserconfig.xml b/apps/app/public/browserconfig.xml similarity index 100% rename from apps/www/public/browserconfig.xml rename to apps/app/public/browserconfig.xml diff --git a/apps/app/public/devfaq-logo.svg b/apps/app/public/devfaq-logo.svg new file mode 100644 index 00000000..f46b83f3 --- /dev/null +++ b/apps/app/public/devfaq-logo.svg @@ -0,0 +1 @@ + diff --git a/apps/www/public/favicon-16x16.png b/apps/app/public/favicon-16x16.png similarity index 100% rename from apps/www/public/favicon-16x16.png rename to apps/app/public/favicon-16x16.png diff --git a/apps/www/public/favicon-32x32.png b/apps/app/public/favicon-32x32.png similarity index 100% rename from apps/www/public/favicon-32x32.png rename to apps/app/public/favicon-32x32.png diff --git a/apps/www/public/favicon.ico b/apps/app/public/favicon.ico similarity index 100% rename from apps/www/public/favicon.ico rename to apps/app/public/favicon.ico diff --git a/apps/app/public/icons/action-icon-add.svg b/apps/app/public/icons/action-icon-add.svg new file mode 100644 index 00000000..e8750e2b --- /dev/null +++ b/apps/app/public/icons/action-icon-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/app/public/icons/action-icon-filter.svg b/apps/app/public/icons/action-icon-filter.svg new file mode 100644 index 00000000..c4f85c91 --- /dev/null +++ b/apps/app/public/icons/action-icon-filter.svg @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/apps/app/public/icons/add-icon.svg b/apps/app/public/icons/add-icon.svg new file mode 100644 index 00000000..7e99101a --- /dev/null +++ b/apps/app/public/icons/add-icon.svg @@ -0,0 +1,6 @@ + + + + diff --git a/apps/app/public/icons/angularjs-logo.svg b/apps/app/public/icons/angularjs-logo.svg new file mode 100644 index 00000000..ee1034dd --- /dev/null +++ b/apps/app/public/icons/angularjs-logo.svg @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/apps/app/public/icons/bin.svg b/apps/app/public/icons/bin.svg new file mode 100644 index 00000000..a10c6cbb --- /dev/null +++ b/apps/app/public/icons/bin.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/app/public/icons/check.svg b/apps/app/public/icons/check.svg new file mode 100644 index 00000000..25fd7f3c --- /dev/null +++ b/apps/app/public/icons/check.svg @@ -0,0 +1,47 @@ + + + + + + + + + diff --git a/apps/app/public/icons/css3-logo.svg b/apps/app/public/icons/css3-logo.svg new file mode 100644 index 00000000..f3134fa4 --- /dev/null +++ b/apps/app/public/icons/css3-logo.svg @@ -0,0 +1,18 @@ + + + + + + diff --git a/apps/app/public/icons/git-logo.svg b/apps/app/public/icons/git-logo.svg new file mode 100644 index 00000000..d6ce3873 --- /dev/null +++ b/apps/app/public/icons/git-logo.svg @@ -0,0 +1,9 @@ + + + diff --git a/apps/app/public/icons/github-logo.svg b/apps/app/public/icons/github-logo.svg new file mode 100644 index 00000000..0f8bba0c --- /dev/null +++ b/apps/app/public/icons/github-logo.svg @@ -0,0 +1,41 @@ + + + + + + diff --git a/apps/app/public/icons/html5-logo.svg b/apps/app/public/icons/html5-logo.svg new file mode 100644 index 00000000..4cb88dd4 --- /dev/null +++ b/apps/app/public/icons/html5-logo.svg @@ -0,0 +1,18 @@ + + + + + + diff --git a/apps/app/public/icons/javascript-logo.svg b/apps/app/public/icons/javascript-logo.svg new file mode 100644 index 00000000..616b5511 --- /dev/null +++ b/apps/app/public/icons/javascript-logo.svg @@ -0,0 +1,12 @@ + + + + diff --git a/apps/app/public/icons/other-logo.svg b/apps/app/public/icons/other-logo.svg new file mode 100644 index 00000000..3914c296 --- /dev/null +++ b/apps/app/public/icons/other-logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/app/public/icons/pencil.svg b/apps/app/public/icons/pencil.svg new file mode 100644 index 00000000..db0af3da --- /dev/null +++ b/apps/app/public/icons/pencil.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/app/public/icons/reactjs-logo.svg b/apps/app/public/icons/reactjs-logo.svg new file mode 100644 index 00000000..7c7cbb69 --- /dev/null +++ b/apps/app/public/icons/reactjs-logo.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/apps/app/public/icons/reject.svg b/apps/app/public/icons/reject.svg new file mode 100644 index 00000000..17340576 --- /dev/null +++ b/apps/app/public/icons/reject.svg @@ -0,0 +1,37 @@ + + + + + + + + + + diff --git a/apps/app/public/icons/toolbar-bold.svg b/apps/app/public/icons/toolbar-bold.svg new file mode 100644 index 00000000..6b7d7aa9 --- /dev/null +++ b/apps/app/public/icons/toolbar-bold.svg @@ -0,0 +1,5 @@ + + +bold + + diff --git a/apps/app/public/icons/toolbar-code.svg b/apps/app/public/icons/toolbar-code.svg new file mode 100644 index 00000000..cd590e71 --- /dev/null +++ b/apps/app/public/icons/toolbar-code.svg @@ -0,0 +1,6 @@ + + +embed + + + diff --git a/apps/app/public/icons/toolbar-eye.svg b/apps/app/public/icons/toolbar-eye.svg new file mode 100644 index 00000000..e0bb30b5 --- /dev/null +++ b/apps/app/public/icons/toolbar-eye.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/app/public/icons/toolbar-heading.svg b/apps/app/public/icons/toolbar-heading.svg new file mode 100644 index 00000000..3be2c4b6 --- /dev/null +++ b/apps/app/public/icons/toolbar-heading.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/app/public/icons/toolbar-italic.svg b/apps/app/public/icons/toolbar-italic.svg new file mode 100644 index 00000000..8762026b --- /dev/null +++ b/apps/app/public/icons/toolbar-italic.svg @@ -0,0 +1,5 @@ + + +italic + + diff --git a/apps/app/public/icons/toolbar-ol.svg b/apps/app/public/icons/toolbar-ol.svg new file mode 100644 index 00000000..ab1fa662 --- /dev/null +++ b/apps/app/public/icons/toolbar-ol.svg @@ -0,0 +1,5 @@ + + +list-numbered + + diff --git a/apps/app/public/icons/toolbar-ul.svg b/apps/app/public/icons/toolbar-ul.svg new file mode 100644 index 00000000..d706fe3b --- /dev/null +++ b/apps/app/public/icons/toolbar-ul.svg @@ -0,0 +1,5 @@ + + +list2 + + diff --git a/apps/app/public/icons/trash.svg b/apps/app/public/icons/trash.svg new file mode 100644 index 00000000..97c23561 --- /dev/null +++ b/apps/app/public/icons/trash.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/www/public/img/devfaq-cover-facebook.png b/apps/app/public/img/devfaq-cover-facebook.png similarity index 100% rename from apps/www/public/img/devfaq-cover-facebook.png rename to apps/app/public/img/devfaq-cover-facebook.png diff --git a/apps/www/public/img/fefaq-cover-facebook.png b/apps/app/public/img/fefaq-cover-facebook.png similarity index 100% rename from apps/www/public/img/fefaq-cover-facebook.png rename to apps/app/public/img/fefaq-cover-facebook.png diff --git a/apps/app/public/manifest.json b/apps/app/public/manifest.json new file mode 100644 index 00000000..b92c909c --- /dev/null +++ b/apps/app/public/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "DevFAQ.pl", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/apps/www/public/mstile-144x144.png b/apps/app/public/mstile-144x144.png similarity index 100% rename from apps/www/public/mstile-144x144.png rename to apps/app/public/mstile-144x144.png diff --git a/apps/www/public/mstile-150x150.png b/apps/app/public/mstile-150x150.png similarity index 100% rename from apps/www/public/mstile-150x150.png rename to apps/app/public/mstile-150x150.png diff --git a/apps/www/public/robots.txt b/apps/app/public/robots.txt similarity index 77% rename from apps/www/public/robots.txt rename to apps/app/public/robots.txt index afbb17e0..02186e51 100644 --- a/apps/www/public/robots.txt +++ b/apps/app/public/robots.txt @@ -1,4 +1,4 @@ -User-agent: * +User-agent: * Allow: / Sitemap: https://app.devfaq.pl/sitemap.xml diff --git a/apps/www/public/safari-pinned-tab.svg b/apps/app/public/safari-pinned-tab.svg similarity index 100% rename from apps/www/public/safari-pinned-tab.svg rename to apps/app/public/safari-pinned-tab.svg diff --git a/apps/www/public/images/select-purple.svg b/apps/app/public/select-purple.svg similarity index 100% rename from apps/www/public/images/select-purple.svg rename to apps/app/public/select-purple.svg diff --git a/apps/www/public/images/select.svg b/apps/app/public/select.svg similarity index 100% rename from apps/www/public/images/select.svg rename to apps/app/public/select.svg diff --git a/apps/www/public/images/typeofweb-logo.svg b/apps/app/public/typeofweb-logo.svg similarity index 100% rename from apps/www/public/images/typeofweb-logo.svg rename to apps/app/public/typeofweb-logo.svg diff --git a/apps/app/src/all-contributorsrc.json b/apps/app/src/all-contributorsrc.json new file mode 100644 index 00000000..e1dc8fa8 --- /dev/null +++ b/apps/app/src/all-contributorsrc.json @@ -0,0 +1,113 @@ +{ + "projectName": "devfaq", + "projectOwner": "typeofweb", + "repoType": "github", + "repoHost": "https://github.com", + "files": ["README.md"], + "imageSize": 70, + "commit": true, + "commitConvention": "gitmoji", + "contributors": [ + { + "login": "mmiszy", + "name": "Michał Miszczyszyn", + "avatar_url": "https://avatars0.githubusercontent.com/u/1338731?v=4", + "profile": "https://typeofweb.com", + "contributions": ["code", "maintenance", "platform", "ideas"] + }, + { + "login": "tomasznastaly", + "name": "Tomasz Nastały", + "avatar_url": "https://avatars2.githubusercontent.com/u/16205492?v=4", + "profile": "https://github.com/tomasznastaly", + "contributions": ["code", "ideas"] + }, + { + "login": "cytrowski", + "name": "Bartosz Cytrowski", + "avatar_url": "https://avatars0.githubusercontent.com/u/2965690?v=4", + "profile": "https://github.com/cytrowski", + "contributions": ["content"] + }, + { + "login": "pavveu", + "name": "Pawel Pawlowski", + "avatar_url": "https://avatars3.githubusercontent.com/u/108490?v=4", + "profile": "https://github.com/pavveu", + "contributions": ["design"] + }, + { + "login": "Survikrowa", + "name": "Survikrowa", + "avatar_url": "https://avatars2.githubusercontent.com/u/35381167?v=4", + "profile": "https://github.com/Survikrowa", + "contributions": ["code"] + }, + { + "login": "mczeplowski", + "name": "mczeplowski", + "avatar_url": "https://avatars3.githubusercontent.com/u/43904845?v=4", + "profile": "https://github.com/mczeplowski", + "contributions": ["code"] + }, + { + "login": "drillprop", + "name": "Bartosz Dryl", + "avatar_url": "https://avatars3.githubusercontent.com/u/51168865?v=4", + "profile": "https://github.com/drillprop", + "contributions": ["code"] + }, + { + "login": "D0man", + "name": "Kuba Domański", + "avatar_url": "https://avatars2.githubusercontent.com/u/22179216?v=4", + "profile": "https://github.com/D0man", + "contributions": ["review"] + }, + { + "login": "kbkk", + "name": "Jakub Kisielewski", + "avatar_url": "https://avatars3.githubusercontent.com/u/6276426?v=4", + "profile": "https://github.com/kbkk", + "contributions": ["review"] + }, + { + "login": "KonradNojman", + "name": "KonradNojman", + "avatar_url": "https://avatars2.githubusercontent.com/u/60238331?v=4", + "profile": "https://github.com/KonradNojman", + "contributions": ["review"] + }, + { + "login": "PatrykBuniX", + "name": "Patryk Górka", + "avatar_url": "https://avatars.githubusercontent.com/u/45733298?v=4", + "profile": "https://github.com/PatrykBuniX", + "contributions": ["doc"] + }, + { + "login": "AdiPol1359", + "name": "Adrian Polak", + "avatar_url": "https://avatars.githubusercontent.com/u/27779154?v=4", + "profile": "https://projectcode.pl/", + "contributions": ["code"] + }, + { + "login": "xStrixU", + "name": "xStrixU", + "avatar_url": "https://avatars.githubusercontent.com/u/41890821?v=4", + "profile": "https://github.com/xStrixU", + "contributions": ["code"] + }, + { + "login": "grzegorzpokorski", + "name": "Grzegorz Pokorski", + "avatar_url": "https://avatars.githubusercontent.com/u/27455716?v=4", + "profile": "https://github.com/grzegorzpokorski", + "contributions": ["doc", "bug", "code"] + } + ], + "contributorsPerLine": 3, + "skipCi": true, + "badgeTemplate": "[![All Contributors](https://img.shields.io/badge/Contributors-<%= contributors.length %>-673ab7.svg)](#contributors-)" +} diff --git a/apps/app/src/app/(main-layout)/(static)/autorzy/head.tsx b/apps/app/src/app/(main-layout)/(static)/autorzy/head.tsx new file mode 100644 index 00000000..2b702130 --- /dev/null +++ b/apps/app/src/app/(main-layout)/(static)/autorzy/head.tsx @@ -0,0 +1,5 @@ +import { HeadTags } from "../../../../components/HeadTags"; + +export default function Head() { + return ; +} diff --git a/apps/app/src/app/(main-layout)/(static)/autorzy/page.tsx b/apps/app/src/app/(main-layout)/(static)/autorzy/page.tsx new file mode 100644 index 00000000..5cc8e2cc --- /dev/null +++ b/apps/app/src/app/(main-layout)/(static)/autorzy/page.tsx @@ -0,0 +1,18 @@ +import { Author } from "../../../../components/Author/Author"; +import { StaticPageContainer } from "../../../../components/StaticPageContainer"; +import { getAllContributors } from "../../../../lib/contributors"; + +export default function AuthorsPage() { + return ( + +

Autorzy

+
    + {getAllContributors().map((contributor) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/apps/app/src/app/(main-layout)/(static)/jak-korzystac/head.tsx b/apps/app/src/app/(main-layout)/(static)/jak-korzystac/head.tsx new file mode 100644 index 00000000..0240fabf --- /dev/null +++ b/apps/app/src/app/(main-layout)/(static)/jak-korzystac/head.tsx @@ -0,0 +1,5 @@ +import { HeadTags } from "../../../../components/HeadTags"; + +export default function Head() { + return ; +} diff --git a/apps/app/src/app/(main-layout)/(static)/jak-korzystac/page.tsx b/apps/app/src/app/(main-layout)/(static)/jak-korzystac/page.tsx new file mode 100644 index 00000000..efbc381b --- /dev/null +++ b/apps/app/src/app/(main-layout)/(static)/jak-korzystac/page.tsx @@ -0,0 +1,73 @@ +import { StaticPageContainer } from "../../../../components/StaticPageContainer"; + +export default function AboutPage() { + return ( + +

Jak korzystać? FAQ

+

Co to jest DevFAQ.pl?

+

+ DevFAQ.pl jest serwisem internetowym służącym do udostępniania i wymiany pytań + rekrutacyjnych na stanowiska developerów oraz inne pokrewne. Został stworzony przez + programistów dla programistów, a jego celem jest wymiana wiedzy oraz możliwość przygotowania + się do rozmów rekrutacyjnych. +

+ +

Jak można dodać pytanie?

+

+ Każdy użytkownik DevFAQ może dodać treść pytania, przydzielić mu kategorię oraz poziom + trudności. Następnie po kliknięciu „Dodaj” pytanie trafia do moderacji. Po zaakceptowaniu + przez administratorów, pojawi się na stronie. Może to zająć kilka dni! +

+ +

Jakie są ogólne zasady korzystania z serwisu?

+
    +
  • W treści nie podawaj nazwy firmy, w której padło pytanie.
  • +
  • + Przed dodaniem pytania, upewnij się czy treść jest wolna od błędów ortograficznych, + zaoszczędzisz pracy moderacji. +
  • +
  • Pytanie niezwiązane z technologiami (np. miękkie) umieść w kategorii INNE
  • +
  • + Jeśli dodajesz kod do pytania, zadbaj o poprawne formatowanie (spacje, wcięcia itp.). +
  • +
  • + Wszystkie pytania są moderowane. Moderatorzy dbają również o to, aby pytania się nie + powtarzały. +
  • +
+ +

Czy mogę formatować jakoś treść dodawanych pytań?

+

Tak! Możesz skorzystać z powszechnie znanego Markdown:

+
    +
  • ``` Code block ```
  • +
  • `Inline code`
  • +
  • *Italic*
  • +
  • **Bold**
  • +
  • [Link](http://a.com)
  • +
  • * List
  • +
+ +

Przykład użycia

+

Przykładowe pytanie napisane w Markdown może wyglądać tak:

+ +
+				Czy funkcja `sayHello` zwraca **string**?{"\n"}
+				```javascript
+				{`
+function sayHello() {
+  return 'Hello World';
+}
+`}
+				```
+			
+ +

+ Więcej informacji na temat Markdown oraz kompletną dokumentację znajdziesz na stronie{" "} + + CommonMark + + . +

+ + ); +} diff --git a/apps/app/src/app/(main-layout)/(static)/regulamin/head.tsx b/apps/app/src/app/(main-layout)/(static)/regulamin/head.tsx new file mode 100644 index 00000000..0aeb5ad1 --- /dev/null +++ b/apps/app/src/app/(main-layout)/(static)/regulamin/head.tsx @@ -0,0 +1,10 @@ +import { HeadTags } from "../../../../components/HeadTags"; + +export default function Head() { + return ( + + ); +} diff --git a/apps/app/src/app/(main-layout)/(static)/regulamin/page.tsx b/apps/app/src/app/(main-layout)/(static)/regulamin/page.tsx new file mode 100644 index 00000000..c3117ab3 --- /dev/null +++ b/apps/app/src/app/(main-layout)/(static)/regulamin/page.tsx @@ -0,0 +1,223 @@ +import { StaticPageContainer } from "../../../../components/StaticPageContainer"; + +export default function RegulaminPage() { + return ( + +

Regulamin DevFAQ.pl

+

Wersja z dnia 3. czerwca 2020 r.

+
    +
  1. + Postanowienia ogólne +
      +
    1. + Niniejszy Regulamin (zwany dalej „Regulaminem”) określa zasady korzystania z serwisu + devfaq.pl (zwanego dalej: „Serwisem”) dla użytkowników indywidualnych (zwanych dalej + „Użytkownikami”). +
    2. +
    3. + Administratorem Serwisu jest Michał Miszczyszyn prowadzący działalność gospodarczą pod + firmą „Type of Web - Michał Miszczyszyn”, zarejestrowaną w Centralnej Ewidencji + i Informacji o Działalności Gospodarczej pod adresem ul. Stolema 6H/2, + 80-175 Gdańsk, NIP: 6040080451 (zwany dalej: „Usługodawcą”). Adres do korespondencji: + ul. Stolema 6H/2, 80-175 Gdańsk; adres e-mail: hi@typeofweb.com. +
    4. +
    +
  2. +
  3. + Zasady korzystania z Serwisu +
      +
    1. + Serwis służy do udostępniania pytań rekrutacyjnych dodawanych przez Użytkowników. + Usługodawca świadczy usługę polegającą na udostępnianiu infrastruktury + teleinformatycznej celem dodawania, przechowywania i udostępniania pytań + dodawanych przez Użytkowników. +
    2. +
    3. + Korzystanie z Serwisu jest bezpłatne i nie wymaga logowania Użytkownika. +
    4. +
    5. + Korzystanie z Serwisu wymaga przeglądarki internetowej z włączoną obsługą + języka JavaScript. Usługodawca zastrzega sobie prawo do wspierania tylko wybranych, + najpopularniejszych przeglądarek internetowych. +
    6. +
    7. + Każde pytanie dodawane do Serwisu, przed opublikowaniem podlega weryfikacji przez + Usługodawcę i może zostać zmodyfikowane lub usunięte. Okres od dodania pytania, + do jego opublikowania w Serwisie, może trwać kilka dni. +
    8. +
    9. + Dodając treść do Serwisu, Użytkownik: +
        +
      1. ma obowiązek przestrzegania zasad kultury osobistej i netykiety;
      2. +
      3. + ma obowiązek poprawnego formatowania tekstu z wykorzystaniem technologii + Markdown w standardzie CommonMark, a także do przestrzegania zasad + ortografii i gramatyki języka polskiego; +
      4. +
      5. + oświadcza, że przysługują mu prawa do publikacji dodawanych treści i wyraża + zgodę na ich wykorzystanie przez Usługodawcę w Serwisie, a także poza + nim, nie wyłączając celów komercyjnych; +
      6. +
      7. + ponosi pełną odpowiedzialność związaną z ewentualnym naruszeniem Regulaminu i + przepisów prawa, a w tym dóbr osobistych, praw własności intelektualnej, umów + o zachowaniu poufności i innych; +
      8. +
      9. + wyraża zgodę na modyfikowanie, moderowanie oraz odmowę publikacji treści przez + Usługodawcę, w celu utrzymania wysokiej jakości treści publikowanych w + Serwisie. +
      10. +
      +
    10. +
    11. + Użytkownik nie może: +
        +
      1. + wykorzystywać Serwisu, ani treści w nim zamieszczonych, do działalności + komercyjnej. Pod pojęciem działalności komercyjnej rozumie się jakąkolwiek + działalność marketingową, promocyjną lub wspomagającą te działania, na przykład + umieszczanie reklam w treściach dodawanych do Serwisu, lub dowolne inne + sposoby czerpania korzyści majątkowej z Serwisu; +
      2. +
      3. + dodawać do Serwisu treści o charakterze erotycznym, zawierających wulgaryzmy + lub z innych względów nieodpowiednich dla osób poniżej 18. roku życia; +
      4. +
      5. + dodawać do Serwisu treści nawołujących do nienawiści na tle etnicznym, rasowym, + religijnym lub jakimkolwiek innym oraz propagujących faszyzm, nazizm, komunizm + oraz inne zbrodnicze ideologie; +
      6. +
      7. w nadmierny sposób obciążać serwera, na którym znajduje się Serwis;
      8. +
      9. + dodawać do Serwisu treści zawierających jakiekolwiek dane osobowe lub oznaczenia + umożliwiające identyfikację konkretnych podmiotów. +
      10. +
      +
    12. +
    13. + Usługodawca ma prawo do usuwania treści z Serwisu bez podania przyczyny, a w + szczególności w przypadku powzięcia wiarygodnej informacji o naruszeniu + prawa lub Regulaminu. +
    14. +
    15. + Jeśli zdaniem Użytkownika opublikowana w Serwisie treść narusza prawo, Regulamin + lub zasady współżycia społecznego, Użytkownik może powiadomić Usługodawcę za + pośrednictwem poczty elektronicznej na adres e-mail: abuse@devfaq.pl. +
    16. +
    +
  4. +
  5. + Logowanie Użytkownika +
      +
    1. + Użytkownik ma możliwość zalogowania się w Serwisie za pośrednictwem GitHub. +
    2. +
    3. Logowanie jest bezpłatne.
    4. +
    5. + Zalogowany Użytkownik ma możliwość korzystania z następujących dodatkowych + funkcji Serwisu:{" "} +
        +
      1. głosowanie na poszczególne pytania opublikowane w Serwisie.
      2. +
      +
    6. +
    +
  6. +
  7. + Odpowiedzialność i sankcje +
      +
    1. + Użytkownik zobowiązuje się, że w momencie wystąpienia przez osoby trzecie z + roszczeniami w stosunku do Usługodawcy z tytułu naruszenia jakichkolwiek + praw tych osób trzecich przez treści zamieszczone przez Użytkownika, Użytkownik ten + wstąpi do sprawy w miejsce Usługodawcy oraz przejmie na siebie w całości + koszty ewentualnego postępowania sądowego, zasądzonych odszkodowań oraz inne. +
    2. +
    3. + Naruszenie Regulaminu przez Użytkownika może skutkować zablokowaniem mu dostępu do + Serwisu, a w przypadku gdy naruszone zostały przepisy prawa, również + zawiadomieniem odpowiednich organów lub skierowaniem sprawy do sądu. +
    4. +
    5. + Przez naruszenie Regulaminu rozumie się także namawianie innych osób do jego + naruszenia, a także ułatwianie jego obchodzenia, jak również czerpanie korzyści + majątkowych z tych czynności. +
    6. +
    +
  8. +
  9. + Wyłączenie odpowiedzialności i zastrzeżenia +
      +
    1. + W najszerszym zakresie dopuszczalnym przez prawo, wyłączona zostaje + odpowiedzialność Usługodawcy za:{" "} +
        +
      1. + szkody wynikające ze sposobu w jaki Użytkownicy korzystają z Serwisu, + niewłaściwego działania Serwisu, braku dostępu do Serwisu, jak również szkody + powstałe w wyniku działania siły wyższej; +
      2. +
      3. jakiekolwiek treści zamieszczone w Serwisie przez Użytkowników.
      4. +
      +
    2. +
    3. + W najszerszym dopuszczalnym zakresie, Usługodawca ma prawo do: +
        +
      1. zmiany parametrów oraz sposobu działania Serwisu;
      2. +
      3. wyłączania Serwisu bez wcześniejszego powiadomienia Użytkowników;
      4. +
      5. usuwania opublikowanych treści;
      6. +
      7. całkowitego zaprzestania świadczenia usług;
      8. +
      9. + przeniesienia wszelkich praw i obowiązków związanych ze świadczeniem usług + drogą elektroniczną w Serwisie na inny podmiot. +
      10. +
      +
    4. +
    +
  10. +
  11. + Informacje o zagrożeniach +
      +
    1. + Podstawowym zagrożeniem każdego użytkownika Internetu, w tym osób korzystających + z usług świadczonych drogą elektroniczną, jest możliwość zainfekowania systemu + teleinformatycznego przez złośliwe oprogramowanie tworzone głównie w celu + wyrządzania szkód, takie jak wirusy, robaki, czy konie trojańskie. Aby uniknąć + zagrożeń z tym związanych, a w tym pojawiających się w momencie + otwierania wiadomości e-mail, należy wyposażyć swoje urządzenia dostępowe + w programy antywirusowe i pamiętać o ich bieżącej aktualizacji do + najnowszych wersji. Korzystanie z usług świadczonych przez Internet wiąże się + z możliwą działalnością hackerów, zmierzających do włamania się zarówno do + Serwisu (np. ataki na witrynę lub serwer), jak i do systemu Użytkownika. Warto + pamiętać, że mimo stosowania przez Usługodawcę różnorodnych, nowoczesnych technologii + obronnych, nie istnieje w 100% skuteczne zabezpieczenie przed opisanymi wyżej + niepożądanymi działaniami. Więcej na temat stosowanych przez Usługodawcę zasad ochrony + danych znaleźć można w Polityce prywatności. +
    2. +
    +
  12. +
  13. + Ochrona danych osobowych +
      +
    1. + Administratorem danych osobowych Użytkowników, jest Michał Miszczyszyn prowadzący + działalność gospodarczą pod firmą „Type of Web - Michał Miszczyszyn”, zarejestrowaną + w Centralnej Ewidencji i Informacji o Działalności Gospodarczej pod + adresem ul. Stolema 6H/2, 80-175 Gdańsk, NIP: 6040080451 (zwany dalej + „Administratorem”). Adres do korespondencji: ul. Stolema 6H/2, 80-175 Gdańsk; adres + e-mail: hi@typeofweb.com +
    2. +
    3. + Korzystanie z Serwisu wiąże się z przetwarzaniem danych osobowych + Użytkowników w celu logowania Użytkownika - na podstawie art. 6 ust. 1 RODO, czyli + zgody Użytkownika, przetwarzane są adres e-mail, imię i nazwisko oraz ewentualnie + nazwa użytkownika. +
    4. +
    +
  14. +
+
+ ); +} diff --git a/apps/app/src/app/(main-layout)/admin/[status]/[page]/page.tsx b/apps/app/src/app/(main-layout)/admin/[status]/[page]/page.tsx new file mode 100644 index 00000000..7d57ec22 --- /dev/null +++ b/apps/app/src/app/(main-layout)/admin/[status]/[page]/page.tsx @@ -0,0 +1,37 @@ +import { redirect } from "next/navigation"; +import dynamic from "next/dynamic"; +import { PrivateRoute } from "../../../../../components/PrivateRoute"; +import { parseQueryLevels } from "../../../../../lib/level"; +import { statuses } from "../../../../../lib/question"; +import { parseTechnologyQuery } from "../../../../../lib/technologies"; +import { Params, SearchParams } from "../../../../../types"; + +const AdminPanel = dynamic( + () => + import( + /* webpackChunkName: "AdminPanel" */ "../../../../../components/AdminPanel/AdminPanel" + ).then((mod) => mod.AdminPanel), + { ssr: false }, +); + +export default function AdminPage({ + params, + searchParams, +}: { + params: Params<"status" | "page">; + searchParams?: SearchParams<"technology" | "level">; +}) { + const page = Number.parseInt(params.page); + const technology = parseTechnologyQuery(searchParams?.technology); + const levels = parseQueryLevels(searchParams?.level); + + if (Number.isNaN(page) || !statuses.includes(params.status)) { + return redirect("/admin"); + } + + return ( + + + + ); +} diff --git a/apps/app/src/app/(main-layout)/admin/head.tsx b/apps/app/src/app/(main-layout)/admin/head.tsx new file mode 100644 index 00000000..e279fb3a --- /dev/null +++ b/apps/app/src/app/(main-layout)/admin/head.tsx @@ -0,0 +1,5 @@ +import { HeadTags } from "../../../components/HeadTags"; + +export default function Head() { + return ; +} diff --git a/apps/app/src/app/(main-layout)/admin/layout.tsx b/apps/app/src/app/(main-layout)/admin/layout.tsx new file mode 100644 index 00000000..36cf50a0 --- /dev/null +++ b/apps/app/src/app/(main-layout)/admin/layout.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from "react"; +import { Container } from "../../../components/Container"; + +export default function AdminPageLayout({ children }: { readonly children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/app/src/app/(main-layout)/layout.tsx b/apps/app/src/app/(main-layout)/layout.tsx new file mode 100644 index 00000000..8421e119 --- /dev/null +++ b/apps/app/src/app/(main-layout)/layout.tsx @@ -0,0 +1,18 @@ +import { CtaHeader } from "../../components/CtaHeader/CtaHeader"; +import { Header } from "../../components/Header/Header"; +import { Footer } from "../../components/Footer"; +import { AppModals } from "../../components/AppModals"; +import { SkipToTheContent } from "../../components/SkipToTheContent"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + <> + + +
+ + {children} +