├── .babelrc ├── .dockerignore ├── .env.sample ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── build-and-push.yml │ └── ci.yml ├── .gitignore ├── .gitmodules ├── Dockerfile ├── LEGAL.md ├── LICENSE ├── README.md ├── docker-compose.yml ├── ecosystem.config.js ├── g0v.json ├── jsconfig.json ├── package-lock.json ├── package.json ├── process-dev.json ├── renovate.json ├── src ├── CookieStore.js ├── __fixtures__ │ ├── auth.js │ └── index.js ├── __tests__ │ ├── __snapshots__ │ │ └── auth.js.snap │ ├── auth.js │ └── index.js ├── adm │ ├── README.md │ ├── handlers │ │ ├── badge │ │ │ ├── __fixtures__ │ │ │ │ ├── awardBadge.ts │ │ │ │ └── revokeBadge.ts │ │ │ ├── __tests__ │ │ │ │ ├── awardBadge.js │ │ │ │ └── revokeBadge.js │ │ │ ├── awardBadge.ts │ │ │ └── revokeBadge.ts │ │ ├── moderation │ │ │ ├── __fixtures__ │ │ │ │ └── blockUser.js │ │ │ ├── __tests__ │ │ │ │ └── blockUser.js │ │ │ ├── blockUser.ts │ │ │ ├── genAIReply.ts │ │ │ └── replaceMedia.ts │ │ └── ping.ts │ ├── index.ts │ └── util.ts ├── auth.js ├── checkHeaders.js ├── contextFactory.ts ├── graphql │ ├── __fixtures__ │ │ ├── media-integration │ │ │ ├── replaced.jpg │ │ │ └── small.jpg │ │ └── util │ │ │ ├── audio-test.m4a │ │ │ ├── ocr-test.jpg │ │ │ ├── video-complex.mp4 │ │ │ └── video-subtitles-only.mp4 │ ├── __tests__ │ │ ├── media-integration.js │ │ └── util.js │ ├── dataLoaders │ │ ├── __fixtures__ │ │ │ ├── analyticsLoaderFactory.js │ │ │ ├── articleCategoriesByCategoryIdLoaderFactory.js │ │ │ ├── contributionsLoaderFactory.js │ │ │ └── userLoaderFactory.js │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ ├── analyticsLoaderFactory.js.snap │ │ │ │ ├── articleCategoriesByCategoryIdLoaderFactory.js.snap │ │ │ │ ├── contirubtionsLoaderFactory.js.snap │ │ │ │ └── userLoaderFactory.js.snap │ │ │ ├── analyticsLoaderFactory.js │ │ │ ├── articleCategoriesByCategoryIdLoaderFactory.js │ │ │ ├── contirubtionsLoaderFactory.js │ │ │ └── userLoaderFactory.js │ │ ├── analyticsLoaderFactory.js │ │ ├── articleCategoriesByCategoryIdLoaderFactory.js │ │ ├── articleCategoryFeedbacksLoaderFactory.js │ │ ├── articleRepliesByReplyIdLoaderFactory.js │ │ ├── articleReplyFeedbacksLoaderFactory.js │ │ ├── contributionsLoaderFactory.js │ │ ├── docLoaderFactory.js │ │ ├── index.ts │ │ ├── repliedArticleCountLoaderFactory.js │ │ ├── searchResultLoaderFactory.js │ │ ├── urlLoaderFactory.js │ │ ├── userLevelLoaderFactory.js │ │ ├── userLoaderFactory.js │ │ └── votedArticleReplyCountLoaderFactory.js │ ├── interfaces │ │ ├── Connection.js │ │ ├── Edge.js │ │ ├── Node.js │ │ └── PageInfo.js │ ├── models │ │ ├── AIResponse.js │ │ ├── AIResponseStatusEnum.js │ │ ├── AIResponseTypeEnum.js │ │ ├── Analytics.js │ │ ├── AnalyticsDocTypeEnum.js │ │ ├── Article.js │ │ ├── ArticleCategory.js │ │ ├── ArticleCategoryFeedback.js │ │ ├── ArticleCategoryStatusEnum.js │ │ ├── ArticleReference.js │ │ ├── ArticleReply.js │ │ ├── ArticleReplyFeedback.js │ │ ├── ArticleReplyFeedbackStatusEnum.js │ │ ├── ArticleReplyStatusEnum.js │ │ ├── ArticleStatusEnum.js │ │ ├── ArticleTypeEnum.js │ │ ├── AvatarTypeEnum.js │ │ ├── Badge.js │ │ ├── Category.js │ │ ├── Contribution.js │ │ ├── Contributor.js │ │ ├── Cooccurrence.js │ │ ├── CrawledDoc.js │ │ ├── FeedbackVote.js │ │ ├── Highlights.js │ │ ├── Hyperlink.js │ │ ├── MutationResult.js │ │ ├── Reply.js │ │ ├── ReplyRequest.js │ │ ├── ReplyRequestStatusEnum.js │ │ ├── ReplyTypeEnum.js │ │ ├── SlugErrorEnum.js │ │ ├── User.js │ │ ├── UserAwardedBadge.js │ │ ├── Ydoc.js │ │ ├── YdocVersion.js │ │ ├── __fixtures__ │ │ │ └── User.js │ │ └── __tests__ │ │ │ ├── User.js │ │ │ └── __snapshots__ │ │ │ └── User.js.snap │ ├── mutations │ │ ├── CreateAIReply.js │ │ ├── CreateArticle.js │ │ ├── CreateArticleCategory.js │ │ ├── CreateArticleReply.js │ │ ├── CreateCategory.js │ │ ├── CreateMediaArticle.js │ │ ├── CreateOrUpdateArticleCategoryFeedback.js │ │ ├── CreateOrUpdateArticleReplyFeedback.ts │ │ ├── CreateOrUpdateCooccurrence.js │ │ ├── CreateOrUpdateReplyRequest.js │ │ ├── CreateOrUpdateReplyRequestFeedback.js │ │ ├── CreateReply.js │ │ ├── UpdateArticleCategoryStatus.js │ │ ├── UpdateArticleReplyStatus.js │ │ ├── UpdateUser.js │ │ ├── __fixtures__ │ │ │ ├── CreateAIReply.js │ │ │ ├── CreateArticle.js │ │ │ ├── CreateArticleCategory.js │ │ │ ├── CreateArticleReply.js │ │ │ ├── CreateMediaArticle.js │ │ │ ├── CreateOrUpdateArticleCategoryFeedback.js │ │ │ ├── CreateOrUpdateArticleReplyFeedback.js │ │ │ ├── CreateOrUpdateCooccurrence.js │ │ │ ├── CreateOrUpdateReplyRequest.js │ │ │ ├── CreateOrUpdateReplyRequestFeedback.js │ │ │ ├── CreateReply.js │ │ │ ├── UpdateArticleCategoryStatus.js │ │ │ ├── UpdateArticleReplyStatus.js │ │ │ └── UpdateUser.js │ │ └── __tests__ │ │ │ ├── CreateAIReply.js │ │ │ ├── CreateArticle.js │ │ │ ├── CreateArticleCategory.js │ │ │ ├── CreateArticleReply.js │ │ │ ├── CreateCategory.js │ │ │ ├── CreateMediaArticle.js │ │ │ ├── CreateOrUpdateArticleCategoryFeedback.js │ │ │ ├── CreateOrUpdateArticleReplyFeedback.js │ │ │ ├── CreateOrUpdateCooccurrence.js │ │ │ ├── CreateOrUpdateReplyRequest.js │ │ │ ├── CreateOrUpdateReplyRequestFeedback.js │ │ │ ├── CreateReply.js │ │ │ ├── UpdateArticleCategoryStatus.js │ │ │ ├── UpdateArticleReplyStatus.js │ │ │ ├── UpdateUser.js │ │ │ └── __snapshots__ │ │ │ ├── CreateArticle.js.snap │ │ │ ├── CreateArticleCategory.js.snap │ │ │ ├── CreateArticleReply.js.snap │ │ │ ├── CreateCategory.js.snap │ │ │ ├── CreateOrUpdateArticleCategoryFeedback.js.snap │ │ │ ├── CreateOrUpdateArticleReplyFeedback.js.snap │ │ │ ├── CreateOrUpdateCooccurrence.js.snap │ │ │ ├── CreateOrUpdateReplyRequest.js.snap │ │ │ ├── CreateOrUpdateReplyRequestFeedback.js.snap │ │ │ ├── CreateReply.js.snap │ │ │ ├── UpdateArticleCategoryStatus.js.snap │ │ │ ├── UpdateArticleReplyStatus.js.snap │ │ │ └── UpdateUser.js.snap │ ├── queries │ │ ├── GetArticle.js │ │ ├── GetBadge.js │ │ ├── GetCategory.js │ │ ├── GetReply.js │ │ ├── GetUser.js │ │ ├── GetYdoc.js │ │ ├── ListAIResponses.js │ │ ├── ListAnalytics.js │ │ ├── ListArticleReplyFeedbacks.js │ │ ├── ListArticles.js │ │ ├── ListBlockedUsers.js │ │ ├── ListCategories.js │ │ ├── ListCooccurrences.js │ │ ├── ListReplies.js │ │ ├── ListReplyRequests.js │ │ ├── ValidateSlug.js │ │ ├── __fixtures__ │ │ │ ├── GetCategory.js │ │ │ ├── GetReplyAndArticle.js │ │ │ ├── GetUser.js │ │ │ ├── GetYdoc.js │ │ │ ├── ListAIResponses.js │ │ │ ├── ListArticleReplyFeedbacks.js │ │ │ ├── ListArticles.js │ │ │ ├── ListBlockedUsers.js │ │ │ ├── ListCategories.js │ │ │ ├── ListCooccurrences.js │ │ │ ├── ListReplies.js │ │ │ ├── ListReplyRequests.js │ │ │ └── ValidateSlug.js │ │ └── __tests__ │ │ │ ├── GetCategory.js │ │ │ ├── GetReplyAndGetArticle.js │ │ │ ├── GetUser.js │ │ │ ├── GetYdoc.js │ │ │ ├── ListAIResponses.js │ │ │ ├── ListAnalytics.js │ │ │ ├── ListArticleReplyFeedbacks.js │ │ │ ├── ListArticles.js │ │ │ ├── ListBlockedUsers.js │ │ │ ├── ListCategories.js │ │ │ ├── ListCooccurrences.js │ │ │ ├── ListReplies.js │ │ │ ├── ListReplyRequests.js │ │ │ ├── ValidateSlug.js │ │ │ └── __snapshots__ │ │ │ ├── GetCategory.js.snap │ │ │ ├── GetReplyAndGetArticle.js.snap │ │ │ ├── GetUser.js.snap │ │ │ ├── GetYdoc.js.snap │ │ │ ├── ListAIResponses.js.snap │ │ │ ├── ListAnalytics.js.snap │ │ │ ├── ListArticleReplyFeedbacks.js.snap │ │ │ ├── ListArticles.js.snap │ │ │ ├── ListBlockedUsers.js.snap │ │ │ ├── ListCategories.js.snap │ │ │ ├── ListCooccurrences.js.snap │ │ │ ├── ListReplies.js.snap │ │ │ └── ListReplyRequests.js.snap │ ├── schema.js │ └── util.js ├── index.js ├── jade │ └── index.jade ├── rollbarInstance.js ├── scripts │ ├── __fixtures__ │ │ ├── cleanupUrls.js │ │ ├── fetchStatsFromGA.js │ │ ├── genAIReply.js │ │ ├── genBERTInputArticles.js │ │ ├── genCategoryReview.js │ │ └── removeArticleReply.js │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── cleanupUrls.js.snap │ │ ├── cleanupUrls.js │ │ ├── fetchStatsFromGA.js │ │ ├── genAIReply.js │ │ ├── genBERTInputArticles.js │ │ ├── genCategoryReview.js │ │ └── removeArticleReply.js │ ├── cleanupUrls.js │ ├── experimentAVTranscript.ts │ ├── fetchStatsFromGA.js │ ├── genAIReply.js │ ├── genBERTInputArticles.js │ ├── genCategoryReview.js │ ├── migrations │ │ ├── __fixtures__ │ │ │ ├── createBackendUsers.js │ │ │ └── importFlowAnnotation.js │ │ ├── __tests__ │ │ │ ├── createBackendUsers.js │ │ │ └── importFlowAnnotation.js │ │ ├── createBackendUsers.js │ │ ├── fillAllHyperlinks.js │ │ └── importFlowAnnotation.js │ ├── removeArticleReply.js │ └── replaceMedia.js └── util │ ├── __fixtures__ │ ├── getAllDocs.js │ ├── scrapUrls.js │ └── user.js │ ├── __mocks__ │ └── grpc.js │ ├── __tests__ │ ├── __snapshots__ │ │ ├── scrapUrls.js.snap │ │ └── user.js.snap │ ├── archiveUrlsFromText.ts │ ├── getAllDocs.js │ ├── getInFactory.js │ ├── level.js │ ├── scrapUrls.js │ └── user.js │ ├── archiveUrlsFromText.ts │ ├── bulk.js │ ├── catchUnhandledRejection.js │ ├── client.ts │ ├── delayForMs.js │ ├── getAllDocs.ts │ ├── getInFactory.js │ ├── grpc.js │ ├── langfuse.ts │ ├── level.js │ ├── mediaManager.js │ ├── openPeepsOptions.js │ ├── openai.ts │ ├── protobuf │ ├── resolve_error.proto │ └── url_resolver.proto │ ├── pseudonymDict.js │ ├── scrapUrls.js │ └── user.ts ├── static ├── favicon.png └── graphiql.html ├── test ├── postTest.js ├── setup.js ├── testSequencer.js └── util │ ├── GraphQL.js │ └── fixtures.js └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/env", { 4 | "targets": { 5 | "node": "current" 6 | } 7 | }], 8 | "@babel/preset-typescript" 9 | ], 10 | "plugins": [ 11 | ["module-resolver", { 12 | "root": ["./src", "./test"], 13 | "extensions": [".js", ".ts"] 14 | }] 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !.babelrc 3 | !ecosystem.config.js 4 | !package.json 5 | !package-lock.json 6 | !static 7 | !src 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/rumors-db -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@babel/eslint-parser', 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:import/errors', 6 | 'plugin:import/warnings', 7 | 'prettier', 8 | ], 9 | env: { node: true, es6: true, jest: true }, 10 | plugins: ['prettier', 'import'], 11 | rules: { 12 | 'prettier/prettier': [ 13 | 'error', 14 | { 15 | trailingComma: 'es5', 16 | singleQuote: true, 17 | }, 18 | ], 19 | }, 20 | settings: { 21 | 'import/resolver': { 22 | 'babel-module': {}, 23 | typescript: {}, 24 | }, 25 | 'import/parsers': { 26 | '@typescript-eslint/parser': ['.ts'], 27 | }, 28 | }, 29 | overrides: [ 30 | { 31 | files: ['**/*.ts'], 32 | extends: ['plugin:@typescript-eslint/recommended'], 33 | plugins: ['@typescript-eslint'], 34 | }, 35 | ], 36 | }; 37 | -------------------------------------------------------------------------------- /.github/workflows/build-and-push.yml: -------------------------------------------------------------------------------- 1 | name: Build and push to docker 2 | 3 | # Controls when the action will run. 4 | on: 5 | # Triggers the workflow on push or pull request events but only for the dev branch 6 | push: 7 | tags: 8 | - release/* 9 | branches: 10 | - master 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 16 | jobs: 17 | # This workflow contains a single job called "build" 18 | build-and-push: 19 | # The type of runner that the job will run on 20 | runs-on: ubuntu-latest 21 | env: 22 | RELEASE_TAG: ${{ startsWith(github.ref, 'refs/tags/release') && 'latest' || 'dev' }} 23 | 24 | # Steps represent a sequence of tasks that will be executed as part of the job 25 | steps: 26 | - name: Echo RELEASE_TAG 27 | run: 'echo $RELEASE_TAG' 28 | 29 | - name: Checkout repo 30 | uses: actions/checkout@v4 31 | 32 | - name: Login to Docker Hub 33 | uses: docker/login-action@v1 34 | with: 35 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 36 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 37 | 38 | - name: Set up Docker Buildx 39 | id: buildx 40 | uses: docker/setup-buildx-action@v1 41 | 42 | - name: Build and push 43 | id: docker_build 44 | uses: docker/build-push-action@v2 45 | with: 46 | context: ./ 47 | file: ./Dockerfile 48 | push: true 49 | tags: cofacts/rumors-api:${{ env.RELEASE_TAG }} 50 | cache-from: type=gha 51 | cache-to: type=gha,mode=max 52 | 53 | - name: Image digest 54 | run: echo ${{ steps.docker_build.outputs.digest }} 55 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI test 2 | 3 | on: 4 | # Triggers the workflow on push or pull request events but only for the dev branch 5 | - pull_request 6 | - push 7 | # Allows you to run this workflow manually from the Actions tab 8 | - workflow_dispatch 9 | 10 | jobs: 11 | install-and-test: 12 | runs-on: ubuntu-latest 13 | services: 14 | rumors-test-db: 15 | image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.2 16 | ports: 17 | - 62223:9200 18 | 19 | permissions: # Required by google-github-actions/auth 20 | contents: 'read' 21 | id-token: 'write' 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | submodules: true 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version: '18' 30 | cache: 'npm' 31 | - uses: google-github-actions/auth@v1 32 | with: 33 | workload_identity_provider: ${{ secrets.GC_WORKLOAD_IDENTITY_PROVIDER }} 34 | service_account: ${{ secrets.GC_SERVICE_ACCOUNT }} 35 | - run: npm ci 36 | - run: npm run lint 37 | - run: npm run typecheck 38 | - run: npm run test -- --coverage 39 | env: 40 | GCS_CREDENTIALS: ${{ secrets.GCS_CREDENTIALS }} 41 | GCS_BUCKET_NAME: ${{ secrets.GCS_BUCKET_NAME }} 42 | GCS_MEDIA_FOLDER: github/${{ github.run_number }}_${{ github.run_attempt }}/ 43 | OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} 44 | TEST_DATASET: rumors_api_test_dataset 45 | - name: Update coveralls 46 | uses: coverallsapp/github-action@master 47 | with: 48 | github-token: ${{ secrets.GITHUB_TOKEN }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # nyc test coverage 20 | .nyc_output 21 | 22 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 23 | .grunt 24 | 25 | # node-waf configuration 26 | .lock-wscript 27 | 28 | # Compiled binary addons (http://nodejs.org/api/addons.html) 29 | build/Release 30 | 31 | # Dependency directories 32 | node_modules 33 | jspm_packages 34 | 35 | # Optional npm cache directory 36 | .npm 37 | 38 | # Optional REPL history 39 | .node_repl_history 40 | 41 | # docker-compose test data 42 | esdata 43 | 44 | # local config 45 | config/local-* 46 | .vscode/ 47 | .env 48 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test/rumors-db"] 2 | path = src/rumors-db 3 | url = https://github.com/MrOrz/rumors-db.git 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Builds production image for rumors-api. 2 | # 3 | FROM node:18-alpine3.18 AS builder 4 | WORKDIR /srv/www 5 | 6 | # make node_modules cached. 7 | # Src: https://nodesource.com/blog/8-protips-to-start-killing-it-when-dockerizing-node-js/ 8 | # 9 | COPY package.json package-lock.json ./ 10 | RUN npm install 11 | 12 | # Other files, so that other files do not interfere with node_modules cache 13 | # 14 | COPY . . 15 | 16 | RUN node_modules/.bin/babel src -d build --extensions ".ts,.js" 17 | RUN npm prune --production 18 | 19 | ######################################### 20 | FROM node:18-alpine3.18 21 | 22 | WORKDIR /srv/www 23 | EXPOSE 5000 5500 24 | ENTRYPOINT NODE_ENV=production npm start 25 | 26 | COPY --from=builder /srv/www/node_modules ./node_modules 27 | COPY --from=builder /srv/www/build ./build 28 | COPY src/jade ./build/jade 29 | COPY src/adm/README.md ./build/adm/README.md 30 | COPY src/util/protobuf ./build/util/protobuf 31 | COPY package.json package-lock.json ecosystem.config.js ./ 32 | COPY static ./static 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-2021 Cofacts message reporting chatbot and crowd-sourced fact-checking community (「Cofacts 真的假的」訊息回報機器人與查證協作社群) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Builds developing environment for rumors-api 2 | # 3 | version: '2' 4 | services: 5 | db: 6 | image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.2 7 | ports: 8 | - "62222:9200" 9 | volumes: 10 | - "./esdata:/usr/share/elasticsearch/data" 11 | environment: 12 | - "path.repo=/usr/share/elasticsearch/data" 13 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" # Prevent elasticsearch eating up too much memory 14 | 15 | kibana: 16 | image: docker.elastic.co/kibana/kibana-oss:6.3.2 17 | depends_on: 18 | - db 19 | environment: 20 | ELASTICSEARCH_URL: http://db:9200 # Through docker network, not exposed port 21 | ports: 22 | - "6222:5601" 23 | 24 | url-resolver: 25 | image: cofacts/url-resolver 26 | ports: 27 | - "4000:4000" 28 | 29 | api: 30 | image: node:18 31 | container_name: rumors-api 32 | depends_on: 33 | - db 34 | working_dir: "/srv/www" 35 | entrypoint: npm run dev 36 | volumes: 37 | - ".:/srv/www" 38 | environment: 39 | ELASTICSEARCH_URL: "http://db:9200" 40 | URL_RESOLVER_URL: "url-resolver:4000" 41 | ports: 42 | - "5000:5000" 43 | - "5500:5500" 44 | 45 | site: 46 | image: cofacts/rumors-site:latest-en 47 | ports: 48 | - "3000:3000" 49 | environment: 50 | PUBLIC_API_URL: http://localhost:5000 -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'rumors-api', 5 | script: 'build/index.js', 6 | env_production: { 7 | NODE_ENV: 'production', 8 | }, 9 | instances: process.env.WEB_CONCURRENCY ? +process.env.WEB_CONCURRENCY : 1, 10 | exec_mode: 'cluster', 11 | out_file: '/dev/null', 12 | error_file: '/dev/null', 13 | }, 14 | { 15 | name: 'rumors-admin-api', 16 | script: 'build/adm/index.js', 17 | env_production: { 18 | NODE_ENV: 'production', 19 | }, 20 | instances: 1, 21 | exec_mode: 'cluster', 22 | out_file: '/dev/null', 23 | error_file: '/dev/null', 24 | }, 25 | ], 26 | }; 27 | -------------------------------------------------------------------------------- /g0v.json: -------------------------------------------------------------------------------- 1 | { 2 | "repo": "cofacts/rumors-api", 3 | "author": "MrOrz", 4 | "status": "Beta", 5 | "name": "cofacts", 6 | "name_zh": "真的假的", 7 | "description": "A experimental platform that puts collaborative fact-checking to test. In the real world, in Taiwan.", 8 | "description_zh": "開放大眾編輯的闢謠與回應網路文章的試驗性平台。內容與程式碼本身均為開放授權,希望能讓第三方查證平台遍地開花、讓大眾可以更容易接觸到不同的聲音。", 9 | "homepage": "https://rumors.hacktabl.org/", 10 | "thumbnail": "https://avatars2.githubusercontent.com/u/26894329?s=400", 11 | "document": "http://beta.hackfoldr.org/cofacts", 12 | "repository": "https://github.com/cofacts", 13 | "licenses": [ 14 | "MIT - All source codes", 15 | "CC0 - Reply content" 16 | ], 17 | "keywords": [ 18 | "fact-checking", 19 | "crowd-sourcing", 20 | "collaborative fact-checking" 21 | ], 22 | "audience": [ 23 | "對網路訊息抱持懷疑,但不熟悉 google 而熟悉 LINE 轉傳功能的人(ex: 最近兩年才接觸網際網路的老年人)", 24 | "想查證訊息來源,但沒有時間慢慢 google 的人" 25 | ], 26 | "products": [ 27 | "https://rumors.hacktabl.org/", 28 | "https://cofacts-api.hacktabl.org/" 29 | ], 30 | "partOf": "", 31 | "contributors": [ 32 | "MrOrz", 33 | "kytu800", 34 | "sayuan", 35 | "quad", 36 | "linekin", 37 | "LucienLee", 38 | "godgunman", 39 | "darkbtf", 40 | "bil4444", 41 | "hazelwei" 42 | ], 43 | "needs": [ 44 | "txt 組 - 闢謠" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src" 4 | }, 5 | "exclude": ["node_modules"] 6 | } -------------------------------------------------------------------------------- /process-dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [{ 3 | "name": "rumors-api", 4 | "script": "src/index.js", 5 | "watch": ["src", "test"], 6 | "interpreter": "./node_modules/.bin/babel-node", 7 | "interpreter_args": "--extensions .ts,.js" 8 | }, { 9 | "name": "rumors-admin-api", 10 | "script": "src/adm/index.ts", 11 | "watch": ["src/adm"], 12 | "interpreter": "./node_modules/.bin/babel-node", 13 | "interpreter_args": "--extensions .ts,.js" 14 | }] 15 | } 16 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | "config:semverAllMonthly" 5 | ], 6 | "ignoreDeps": ["prettier"] 7 | } 8 | -------------------------------------------------------------------------------- /src/CookieStore.js: -------------------------------------------------------------------------------- 1 | import { Store } from 'koa-session2'; 2 | import crypto from 'crypto'; 3 | 4 | export default class CookieStore extends Store { 5 | constructor({ algorithm = 'aes-256-ctr', password = '' }) { 6 | super(); 7 | this.algorithm = algorithm; 8 | this.password = password; 9 | } 10 | 11 | get(sid) { 12 | const decipher = crypto.createDecipher(this.algorithm, this.password); 13 | try { 14 | let decrypted = decipher.update(sid, 'hex', 'utf8'); 15 | decrypted += decipher.final('utf8'); 16 | return JSON.parse(decrypted); 17 | } catch (e) { 18 | return {}; 19 | } 20 | } 21 | 22 | set(session) { 23 | const cipher = crypto.createCipher(this.algorithm, this.password); 24 | let crypted = cipher.update(JSON.stringify(session), 'utf8', 'hex'); 25 | crypted += cipher.final('hex'); 26 | return crypted; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/__fixtures__/auth.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/users/doc/test-user': { 3 | name: 'test user', 4 | email: 'secret@secret.com', 5 | facebookId: 'secret-fb-id', 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /src/__fixtures__/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/users/doc/6LOqD_3gpe4ZVaxRvemf7KNTfm6y3WNBu1hbs-5MRdSWiWVss': { 3 | name: 'test user 2', 4 | appUserId: 'testUser2', 5 | appId: 'TEST_BACKEND', 6 | }, 7 | '/users/doc/testUser1': { 8 | name: 'test user 1', 9 | appId: 'WEBSITE', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/auth.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`verifyProfile authenticates user via profile ID 1`] = ` 4 | Object { 5 | "_cursor": undefined, 6 | "_fields": undefined, 7 | "_score": 0.2876821, 8 | "email": "secret@secret.com", 9 | "facebookId": "secret-fb-id", 10 | "highlight": undefined, 11 | "id": "test-user", 12 | "inner_hits": undefined, 13 | "name": "test user", 14 | } 15 | `; 16 | 17 | exports[`verifyProfile authenticates user via same email of existing user; also updates profile ID 1`] = ` 18 | Object { 19 | "_cursor": undefined, 20 | "_fields": undefined, 21 | "_score": undefined, 22 | "email": "secret@secret.com", 23 | "facebookId": "secret-fb-id", 24 | "highlight": undefined, 25 | "id": "test-user", 26 | "inner_hits": undefined, 27 | "name": "test user", 28 | "twitterId": "secret-twitter-id", 29 | "updatedAt": "1989-06-04T00:00:00.000Z", 30 | } 31 | `; 32 | 33 | exports[`verifyProfile creates new user if user does not exist 1`] = ` 34 | Object { 35 | "_cursor": undefined, 36 | "_fields": undefined, 37 | "_score": undefined, 38 | "avatarUrl": "url-to-photo", 39 | "createdAt": "1989-06-04T00:00:00.000Z", 40 | "email": "another@user.com", 41 | "facebookId": "another-fb-id", 42 | "highlight": undefined, 43 | "inner_hits": undefined, 44 | "name": "New user", 45 | "updatedAt": "1989-06-04T00:00:00.000Z", 46 | } 47 | `; 48 | -------------------------------------------------------------------------------- /src/__tests__/auth.js: -------------------------------------------------------------------------------- 1 | import MockDate from 'mockdate'; 2 | import { loadFixtures, unloadFixtures } from 'util/fixtures'; 3 | import fixtures from '../__fixtures__/auth'; 4 | import { verifyProfile } from '../auth'; 5 | import client from 'util/client'; 6 | 7 | const FIXED_DATE = 612921600000; 8 | 9 | describe('verifyProfile', () => { 10 | beforeAll(() => loadFixtures(fixtures)); 11 | afterAll(() => unloadFixtures(fixtures)); 12 | 13 | it('authenticates user via profile ID', async () => { 14 | const passportProfile = { 15 | provider: 'facebook', 16 | id: 'secret-fb-id', 17 | }; 18 | 19 | expect( 20 | await verifyProfile(passportProfile, 'facebookId') 21 | ).toMatchSnapshot(); 22 | }); 23 | 24 | it('authenticates user via same email of existing user; also updates profile ID', async () => { 25 | const passportProfile = { 26 | provider: 'twitter', 27 | id: 'secret-twitter-id', 28 | emails: [{ type: 'office', value: 'secret@secret.com' }], 29 | }; 30 | 31 | MockDate.set(FIXED_DATE); 32 | expect(await verifyProfile(passportProfile, 'twitterId')).toMatchSnapshot(); 33 | MockDate.reset(); 34 | }); 35 | 36 | it('creates new user if user does not exist', async () => { 37 | const passportProfile = { 38 | provider: 'facebook', 39 | id: 'another-fb-id', 40 | emails: [{ type: 'home', value: 'another@user.com' }], 41 | photos: [{ value: 'url-to-photo' }], 42 | displayName: 'New user', 43 | }; 44 | 45 | MockDate.set(FIXED_DATE); 46 | // eslint-disable-next-line no-unused-vars 47 | const { id, ...newUser } = await verifyProfile( 48 | passportProfile, 49 | 'facebookId' 50 | ); 51 | MockDate.reset(); 52 | await client.delete({ 53 | index: 'users', 54 | type: 'doc', 55 | id: id, 56 | }); 57 | expect(newUser).toMatchSnapshot(); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/adm/README.md: -------------------------------------------------------------------------------- 1 | # Cofacts Admin API 2 | 3 | Welcome! You have been granted access to this API by the Cofacts Work Group. 4 | 5 | To access the API programmatically, you need to use [Service Tokens](https://developers.cloudflare.com/cloudflare-one/identity/service-tokens/#connect-your-service-to-access). 6 | Please contact Cofacts Work Group to get your `CLIENT_ID` and `CLIENT_SECRET`. 7 | 8 | Here are some examples with `curl` command (`` denotes the host name of this document page, i.e. URL without "/docs".): 9 | ```sh 10 | # Get OpenAPI schema via curl command 11 | curl -H "CF-Access-Client-Id: " -H "CF-Access-Client-Secret: " /openapi.json 12 | 13 | # Call POST /ping and get pong + echo 14 | curl -XPOST -H "CF-Access-Client-Id: " -H "CF-Access-Client-Secret: " -d '{"echo": "foo"}' /ping 15 | ``` 16 | 17 | The response would attach a cookie named `CF_Authorization` that you may use for [subsequent requests](https://developers.cloudflare.com/cloudflare-one/identity/service-tokens/#subsequent-requests). 18 | 19 | ## Sending request via Swagger UI 20 | 21 | You can send test requests in this Swagger UI in the browser using your current login session. 22 | 23 | However, since different APIs are managed by different Cloudflare Access Applications, your current 24 | login session may not yet be authorized to the API you want to call. In this case, you may see your 25 | request sent in Swagger UI being redirected to Cloudflare, and is then blocked by the browser. 26 | 27 | To authorize your current login session to an API, try visiting the API path directly. 28 | For example, in order to call `/moderation/blockUser`, you can first [visit `/moderation`](/moderation) directly in your browser. 29 | Cloudflare will do the authorization and redirect you to the 404 page. 30 | By that time your login session cookie should have been updated, and you can then call 31 | `/moderation/blockUser` in `/docs`'s Swagger UI. 32 | -------------------------------------------------------------------------------- /src/adm/handlers/badge/__fixtures__/awardBadge.ts: -------------------------------------------------------------------------------- 1 | import type { User } from 'rumors-db/schema/users'; 2 | import type { Badge } from 'rumors-db/schema/badges'; 3 | 4 | export default { 5 | '/users/doc/user-to-award-badge': { 6 | name: 'user-to-award-badge', 7 | createdAt: '2020-01-01T00:00:00.000Z', 8 | googleId: 'some-google-id', 9 | badges: [], 10 | } satisfies User, 11 | 12 | '/users/doc/user-already-award-badge': { 13 | name: 'user-already-award-badge', 14 | createdAt: '2020-01-01T00:00:00.000Z', 15 | googleId: 'some-google-id', 16 | badges: [ 17 | { 18 | badgeId: 'test-certification-001', 19 | badgeMetaData: '{"from":"some-orgnization"}', 20 | isDisplayed: false, 21 | createdAt: '2020-01-01T00:00:00.000Z', 22 | updatedAt: '2020-01-01T00:00:00.000Z', 23 | }, 24 | ], 25 | } satisfies User, 26 | 27 | '/badges/doc/test-certification-001': { 28 | name: 'Test Certification', 29 | displayName: 'Test Certification', 30 | description: 'A test certification badge', 31 | link: 'https://badge.source.com', 32 | icon: 'https://badge.source.com/icon.png', 33 | borderImage: 'https://badge.source.com/border.png', 34 | issuers: ['authorized-issuer@test.com', 'service-token-123'], 35 | createdAt: '2020-01-01T00:00:00.000Z', 36 | updatedAt: '2020-01-01T00:00:00.000Z', 37 | } satisfies Badge, 38 | }; 39 | -------------------------------------------------------------------------------- /src/adm/handlers/badge/__fixtures__/revokeBadge.ts: -------------------------------------------------------------------------------- 1 | import type { User } from 'rumors-db/schema/users'; 2 | import type { Badge } from 'rumors-db/schema/badges'; 3 | 4 | export default { 5 | '/users/doc/user-with-badge': { 6 | name: 'user-with-badge', 7 | createdAt: '2020-01-01T00:00:00.000Z', 8 | googleId: 'some-google-id', 9 | badges: [ 10 | { 11 | badgeId: 'test-certification-001', 12 | badgeMetaData: '{"from":"some-orgnization"}', 13 | isDisplayed: true, 14 | createdAt: '2020-01-01T00:00:00.000Z', 15 | updatedAt: '2020-01-01T00:00:00.000Z', 16 | }, 17 | { 18 | badgeId: 'test-certification-002', 19 | badgeMetaData: '{"from":"another-orgnization"}', 20 | isDisplayed: false, 21 | createdAt: '2020-01-02T00:00:00.000Z', 22 | updatedAt: '2020-01-02T00:00:00.000Z', 23 | }, 24 | ], 25 | } satisfies User, 26 | 27 | '/users/doc/user-without-badge': { 28 | name: 'user-without-badge', 29 | createdAt: '2020-01-01T00:00:00.000Z', 30 | googleId: 'some-google-id', 31 | badges: [], 32 | } satisfies User, 33 | 34 | '/users/doc/user-with-wrong-badge': { 35 | name: 'user-with-wrong-badge', 36 | createdAt: '2020-01-01T00:00:00.000Z', 37 | googleId: 'some-google-id', 38 | badges: [ 39 | { 40 | badgeId: 'test-certification-001', 41 | badgeMetaData: '{"from":"some-orgnization"}', 42 | isDisplayed: true, 43 | createdAt: '2020-01-01T00:00:00.000Z', 44 | updatedAt: '2020-01-01T00:00:00.000Z', 45 | }, 46 | ], 47 | } satisfies User, 48 | 49 | '/badges/doc/test-certification-001': { 50 | name: 'Test Certification', 51 | displayName: 'Test Certification', 52 | description: 'A test certification badge', 53 | link: 'https://badge.source.com', 54 | icon: 'https://badge.source.com/icon.png', 55 | borderImage: 'https://badge.source.com/border.png', 56 | issuers: ['authorized-issuer@test.com', 'service-token-123'], 57 | createdAt: '2020-01-01T00:00:00.000Z', 58 | updatedAt: '2020-01-01T00:00:00.000Z', 59 | } satisfies Badge, 60 | 61 | '/badges/doc/test-certification-002': { 62 | name: 'Another Test Certification', 63 | displayName: 'Another Test Certification', 64 | description: 'Another test certification badge', 65 | link: 'https://badge.source.com', 66 | icon: 'https://badge.source.com/icon2.png', 67 | borderImage: 'https://badge.source.com/border2.png', 68 | issuers: ['authorized-issuer@test.com', 'service-token-123'], 69 | createdAt: '2020-01-02T00:00:00.000Z', 70 | updatedAt: '2020-01-02T00:00:00.000Z', 71 | } satisfies Badge, 72 | }; 73 | -------------------------------------------------------------------------------- /src/adm/handlers/moderation/genAIReply.ts: -------------------------------------------------------------------------------- 1 | import { HTTPError } from 'fets'; 2 | import genAIReplyScript from 'scripts/genAIReply'; 3 | type GenAIReplyParams = { 4 | articleId: string; 5 | temperature?: number; 6 | }; 7 | 8 | /** 9 | * Calls the genAIReply script to generate a new AI reply for the specified article. 10 | * Returns { success: true } if the script runs without throwing an error. 11 | */ 12 | async function genAIReplyHandler({ 13 | articleId, 14 | temperature, 15 | }: GenAIReplyParams): Promise<{ success: boolean }> { 16 | try { 17 | // The script handles fetching the article and user creation internally. 18 | // It returns the AI response doc or null if content is insufficient. 19 | // We only care if it throws an error or not. 20 | await genAIReplyScript({ articleId, temperature }); 21 | 22 | // If the script completes without error, consider it a success. 23 | return { success: true }; 24 | } catch (e: any) { 25 | // Handle known errors, e.g., article not found by the script 26 | if (e.message.includes('Please specify articleId')) { 27 | // This specific error is unlikely if articleId is required by schema, but good practice 28 | throw new HTTPError(400, e.message); 29 | } 30 | if ( 31 | e.message.includes('document_missing_exception') || 32 | e.message.includes('not found') 33 | ) { 34 | // Error from client.get inside the script if article doesn't exist 35 | throw new HTTPError(404, `Article with ID=${articleId} not found.`); 36 | } 37 | 38 | // Log and re-throw unknown errors 39 | console.error('[genAIReplyHandler] Unexpected error:', e); 40 | throw new HTTPError( 41 | 500, 42 | `Error generating AI reply for article ${articleId}: ${ 43 | e.message || 'Unknown error' 44 | }` 45 | ); 46 | } 47 | } 48 | 49 | export default genAIReplyHandler; 50 | -------------------------------------------------------------------------------- /src/adm/handlers/moderation/replaceMedia.ts: -------------------------------------------------------------------------------- 1 | import { HTTPError } from 'fets'; 2 | import replaceMediaScript from 'scripts/replaceMedia'; 3 | import client from 'util/client'; // Moved to top 4 | 5 | type ReplaceMediaParams = { 6 | articleId: string; 7 | url: string; 8 | force?: boolean; 9 | }; 10 | 11 | async function replaceMediaHandler({ 12 | articleId, 13 | url, 14 | force = false, 15 | }: ReplaceMediaParams): Promise<{ attachmentHash: string }> { 16 | try { 17 | // The original script logs the new hash but doesn't return it. 18 | // We'll run the script and then fetch the article again to get the updated hash. 19 | await replaceMediaScript({ articleId, url, force }); 20 | 21 | const { 22 | body: { _source: updatedArticle }, 23 | } = await client.get({ index: 'articles', type: 'doc', id: articleId }); 24 | 25 | if (!updatedArticle) { 26 | // This case might happen if the article was deleted between the script run and the fetch 27 | throw new HTTPError( 28 | 404, 29 | `Article ${articleId} not found after update attempt.` 30 | ); 31 | } 32 | 33 | return { attachmentHash: updatedArticle.attachmentHash }; 34 | } catch (e: any) { 35 | // Handle known errors from the script or client 36 | if (e.message.includes('not found')) { 37 | // Could be article not found initially, or after update attempt 38 | throw new HTTPError(404, e.message); 39 | } 40 | if (e.message.includes('has no corresponding media entry') && !force) { 41 | // Error from replaceMediaScript if old media entry is missing and force=false 42 | throw new HTTPError(400, e.message); 43 | } 44 | // Re-throw unknown errors after logging 45 | console.error('[replaceMediaHandler] Unexpected error:', e); 46 | throw new HTTPError( 47 | 500, 48 | `Error replacing media for article ${articleId}: ${ 49 | e.message || 'Unknown error' 50 | }` 51 | ); 52 | } 53 | } 54 | 55 | export default replaceMediaHandler; 56 | -------------------------------------------------------------------------------- /src/adm/handlers/ping.ts: -------------------------------------------------------------------------------- 1 | /** Input and output types are manually aligned to the actual API */ 2 | export default function pingHandler({ echo }: { echo: string }): string { 3 | return `pong ${echo}`; 4 | } 5 | -------------------------------------------------------------------------------- /src/checkHeaders.js: -------------------------------------------------------------------------------- 1 | import cors from 'kcors'; 2 | 3 | async function checkSecret(ctx, next) { 4 | const secret = ctx.get(process.env.HTTP_HEADER_APP_SECRET); 5 | if (secret === process.env.RUMORS_LINE_BOT_SECRET) { 6 | // Shortcut for official rumors-line-bot -- no DB queries 7 | ctx.appId = 'RUMORS_LINE_BOT'; 8 | 9 | // else if(secret) { ... 10 | // TODO: Fill up ctx.appId with app id after looking up DB with secret 11 | } else { 12 | // secret do not match anything 13 | ctx.appId = 'DEVELOPMENT_BACKEND'; // FIXME: Remove this after developer key function rolls out. 14 | } 15 | 16 | return next(); 17 | } 18 | 19 | async function checkAppId(ctx, next) { 20 | const appId = ctx.get(process.env.HTTP_HEADER_APP_ID); 21 | let origin; 22 | 23 | if (ctx.method === 'OPTIONS') { 24 | // Pre-flight, no header available, just allow all 25 | origin = ctx.get('Origin'); 26 | } else if (appId === 'RUMORS_SITE') { 27 | // Shortcut for official rumors-site -- no DB queries 28 | origin = process.env.RUMORS_SITE_CORS_ORIGIN; 29 | ctx.appId = 'WEBSITE'; 30 | 31 | // else if(appId) { ... 32 | // ctx.appId = 'WEBSITE'; // other apps share the same "from" 33 | // // because ctx.user set by passport as well 34 | // 35 | // TODO: Fill up origin from DB according to appId 36 | } else if (appId === 'RUMORS_LINE_BOT') { 37 | // Shortcut for official rumors-line-bot LIFF -- no DB queries 38 | origin = process.env.RUMORS_LINE_BOT_CORS_ORIGIN; 39 | ctx.appId = 'RUMORS_LINE_BOT'; 40 | } else { 41 | // No header is given. Allow localhost access only. 42 | // FIXME: After developer key function rolls out, these kind of request 43 | // (have no secret nor appid) should be blocked, instead of given ctx.appId. 44 | // 45 | origin = 'http://localhost:3000'; 46 | ctx.appId = 'DEVELOPMENT_FRONTEND'; 47 | } 48 | 49 | return cors({ 50 | credentials: true, 51 | origin: (ctx) => { 52 | const allowedOrigins = origin.split(','); 53 | if (allowedOrigins.includes(ctx.get('Origin'))) return ctx.get('Origin'); 54 | 55 | // CORS check fails. 56 | // Since returning null is not recommended, we just return one valid origin here. 57 | // Ref: https://w3c.github.io/webappsec-cors-for-developers/#avoid-returning-access-control-allow-origin-null 58 | // 59 | return allowedOrigins[0]; 60 | }, 61 | })(ctx, next); 62 | } 63 | 64 | export default () => (ctx, next) => { 65 | if (ctx.get(process.env.HTTP_HEADER_APP_SECRET)) { 66 | return checkSecret(ctx, next); 67 | } 68 | 69 | return checkAppId(ctx, next); 70 | }; 71 | -------------------------------------------------------------------------------- /src/contextFactory.ts: -------------------------------------------------------------------------------- 1 | import DataLoaders from './graphql/dataLoaders'; 2 | import { createOrUpdateUser } from './util/user'; 3 | 4 | type ContextFactoryArgs = { 5 | ctx: { 6 | appId: string; 7 | query: { userId?: string }; 8 | state: { user?: { userId?: string } }; 9 | }; 10 | }; 11 | 12 | export default async function contextFactory({ ctx }: ContextFactoryArgs) { 13 | const { 14 | appId, 15 | query: { userId: queryUserId } = {}, 16 | state: { user: { userId: sessionUserId } = {} } = {}, 17 | } = ctx; 18 | 19 | const userId = queryUserId ?? sessionUserId; 20 | 21 | let currentUser = null; 22 | if (appId && userId) { 23 | ({ user: currentUser } = await createOrUpdateUser({ 24 | userId, 25 | appId, 26 | })); 27 | } 28 | 29 | return { 30 | loaders: new DataLoaders(), // new loaders per request 31 | user: currentUser, 32 | 33 | // userId-appId pair 34 | // 35 | userId: currentUser?.id, 36 | appUserId: userId, 37 | appId, 38 | }; 39 | } 40 | 41 | /** GraphQL resolver context */ 42 | export type ResolverContext = Awaited>; 43 | -------------------------------------------------------------------------------- /src/graphql/__fixtures__/media-integration/replaced.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cofacts/rumors-api/a59e593ef266454ee9cd497cd8746b7e85039a08/src/graphql/__fixtures__/media-integration/replaced.jpg -------------------------------------------------------------------------------- /src/graphql/__fixtures__/media-integration/small.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cofacts/rumors-api/a59e593ef266454ee9cd497cd8746b7e85039a08/src/graphql/__fixtures__/media-integration/small.jpg -------------------------------------------------------------------------------- /src/graphql/__fixtures__/util/audio-test.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cofacts/rumors-api/a59e593ef266454ee9cd497cd8746b7e85039a08/src/graphql/__fixtures__/util/audio-test.m4a -------------------------------------------------------------------------------- /src/graphql/__fixtures__/util/ocr-test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cofacts/rumors-api/a59e593ef266454ee9cd497cd8746b7e85039a08/src/graphql/__fixtures__/util/ocr-test.jpg -------------------------------------------------------------------------------- /src/graphql/__fixtures__/util/video-complex.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cofacts/rumors-api/a59e593ef266454ee9cd497cd8746b7e85039a08/src/graphql/__fixtures__/util/video-complex.mp4 -------------------------------------------------------------------------------- /src/graphql/__fixtures__/util/video-subtitles-only.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cofacts/rumors-api/a59e593ef266454ee9cd497cd8746b7e85039a08/src/graphql/__fixtures__/util/video-subtitles-only.mp4 -------------------------------------------------------------------------------- /src/graphql/dataLoaders/__fixtures__/articleCategoriesByCategoryIdLoaderFactory.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/articles/doc/a1': { 3 | text: 'Lorum ipsum', 4 | articleCategories: [ 5 | { 6 | categoryId: 'c1', 7 | status: 'NORMAL', 8 | }, 9 | { 10 | categoryId: 'c2', 11 | status: 'DELETED', 12 | }, 13 | ], 14 | normalArticleCategoryCount: 1, 15 | updatedAt: '2020-02-09T15:11:04.472Z', 16 | }, 17 | '/articles/doc/a2': { 18 | text: 'Lorum ipsum', 19 | articleCategories: [ 20 | { 21 | categoryId: 'c1', 22 | status: 'NORMAL', 23 | }, 24 | ], 25 | normalArticleCategoryCount: 1, 26 | updatedAt: '2020-02-09T15:11:05.472Z', 27 | }, 28 | '/articles/doc/a3': { 29 | text: 'Lorum ipsum', 30 | articleCategories: [ 31 | { 32 | categoryId: 'c1', 33 | status: 'NORMAL', 34 | }, 35 | ], 36 | normalArticleCategoryCount: 1, 37 | updatedAt: '2020-02-09T15:11:06.472Z', 38 | }, 39 | '/categories/doc/c1': { 40 | title: '性少數與愛滋病', 41 | description: '對同性婚姻的恐懼、愛滋病的誤解與的防疫相關釋疑。', 42 | }, 43 | '/categories/doc/c2': { 44 | title: '免費訊息詐騙', 45 | description: '詐騙貼圖、假行銷手法。', 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/__fixtures__/contributionsLoaderFactory.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/replyrequests/doc/replyrequest1': { 3 | userId: 'user1', 4 | createdAt: '2019-01-01T00:00:00.000+08:00', 5 | }, 6 | '/replyrequests/doc/replyrequest2': { 7 | userId: 'user1', 8 | createdAt: '2020-02-01T00:00:00.000+08:00', 9 | }, 10 | '/replyrequests/doc/replyrequest3': { 11 | userId: 'user2', 12 | createdAt: '2020-02-01T00:00:00.000+08:00', 13 | }, 14 | '/replyrequests/doc/replyrequest4': { 15 | userId: 'user1', 16 | createdAt: '2020-02-02T00:00:00.000+08:00', 17 | }, 18 | '/replyrequests/doc/replyrequest5': { 19 | userId: 'user1', 20 | createdAt: '2020-02-03T00:00:00.000+08:00', 21 | }, 22 | '/replyrequests/doc/replyrequest6': { 23 | userId: 'user1', 24 | createdAt: '2020-02-03T00:00:00.000+08:00', 25 | }, 26 | '/replyrequests/doc/replyrequest7': { 27 | userId: 'user1', 28 | createdAt: '2020-02-04T00:00:00.000+08:00', 29 | }, 30 | 31 | '/replies/doc/reply1': { 32 | userId: 'user1', 33 | createdAt: '2019-01-01T00:00:00.000+08:00', 34 | }, 35 | '/replies/doc/reply2': { 36 | userId: 'user2', 37 | createdAt: '2020-01-01T00:00:00.000+08:00', 38 | }, 39 | '/replies/doc/reply3': { 40 | userId: 'user2', 41 | createdAt: '2020-02-03T00:00:00.000+08:00', 42 | }, 43 | '/replies/doc/reply4': { 44 | userId: 'user1', 45 | createdAt: '2020-02-01T00:00:00.000+08:00', 46 | }, 47 | '/replies/doc/reply5': { 48 | userId: 'user1', 49 | createdAt: '2020-02-04T00:00:00.000+08:00', 50 | }, 51 | '/replies/doc/reply6': { 52 | userId: 'user1', 53 | createdAt: '2020-02-09T00:00:00.000+08:00', 54 | }, 55 | '/replies/doc/reply7': { 56 | userId: 'user1', 57 | createdAt: '2020-02-09T00:00:00.000+08:00', 58 | }, 59 | 60 | '/articlereplyfeedbacks/doc/f1': { 61 | userId: 'user1', 62 | createdAt: '2019-01-01T00:00:00.000+08:00', 63 | }, 64 | '/articlereplyfeedbacks/doc/f2': { 65 | userId: 'user2', 66 | createdAt: '2020-01-01T00:00:00.000+08:00', 67 | }, 68 | '/articlereplyfeedbacks/doc/f3': { 69 | userId: 'user2', 70 | createdAt: '2020-02-03T00:00:00.000+08:00', 71 | }, 72 | '/articlereplyfeedbacks/doc/f4': { 73 | userId: 'user1', 74 | createdAt: '2020-02-01T00:00:00.000+08:00', 75 | }, 76 | '/articlereplyfeedbacks/doc/f5': { 77 | userId: 'user1', 78 | createdAt: '2020-02-04T00:00:00.000+08:00', 79 | }, 80 | '/articlereplyfeedbacks/doc/f6': { 81 | userId: 'user1', 82 | createdAt: '2020-02-06T00:00:00.000+08:00', 83 | }, 84 | '/articlereplyfeedbacks/doc/f7': { 85 | userId: 'user1', 86 | createdAt: '2020-02-08T00:00:00.000+08:00', 87 | }, 88 | }; 89 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/__fixtures__/userLoaderFactory.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/users/doc/test-user': { 3 | slug: 'abc123', 4 | name: 'test user', 5 | email: 'secret@secret.com', 6 | }, 7 | '/users/doc/test-user2': { 8 | slug: 'def456', 9 | name: 'test user2', 10 | email: 'hi@me.com', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/__tests__/__snapshots__/articleCategoriesByCategoryIdLoaderFactory.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`articleCategoriesByCategoryIdLoaderFactory load id 1`] = ` 4 | Array [ 5 | Object { 6 | "articleId": "a1", 7 | "categoryId": "c1", 8 | "status": "NORMAL", 9 | }, 10 | Object { 11 | "articleId": "a2", 12 | "categoryId": "c1", 13 | "status": "NORMAL", 14 | }, 15 | Object { 16 | "articleId": "a3", 17 | "categoryId": "c1", 18 | "status": "NORMAL", 19 | }, 20 | ] 21 | `; 22 | 23 | exports[`articleCategoriesByCategoryIdLoaderFactory load id with after 1`] = ` 24 | Array [ 25 | Object { 26 | "articleId": "a3", 27 | "categoryId": "c1", 28 | "status": "NORMAL", 29 | }, 30 | ] 31 | `; 32 | 33 | exports[`articleCategoriesByCategoryIdLoaderFactory load id with before 1`] = ` 34 | Array [ 35 | Object { 36 | "articleId": "a1", 37 | "categoryId": "c1", 38 | "status": "NORMAL", 39 | }, 40 | ] 41 | `; 42 | 43 | exports[`articleCategoriesByCategoryIdLoaderFactory load id with first 1`] = ` 44 | Array [ 45 | Object { 46 | "articleId": "a1", 47 | "categoryId": "c1", 48 | "status": "NORMAL", 49 | }, 50 | Object { 51 | "articleId": "a2", 52 | "categoryId": "c1", 53 | "status": "NORMAL", 54 | }, 55 | ] 56 | `; 57 | 58 | exports[`articleCategoriesByCategoryIdLoaderFactory should fail if before and after both exist 1`] = `[Error: Use of before & after is prohibited.]`; 59 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/__tests__/__snapshots__/contirubtionsLoaderFactory.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`contributionsLoaderFactory should load data for the date range specified 1`] = ` 4 | Array [ 5 | Object { 6 | "count": 1, 7 | "date": "2020-02-02", 8 | }, 9 | Object { 10 | "count": 2, 11 | "date": "2020-02-03", 12 | }, 13 | Object { 14 | "count": 3, 15 | "date": "2020-02-04", 16 | }, 17 | Object { 18 | "count": 1, 19 | "date": "2020-02-06", 20 | }, 21 | Object { 22 | "count": 1, 23 | "date": "2020-02-08", 24 | }, 25 | Object { 26 | "count": 2, 27 | "date": "2020-02-09", 28 | }, 29 | ] 30 | `; 31 | 32 | exports[`contributionsLoaderFactory should load last year of data for given userId 1`] = ` 33 | Array [ 34 | Object { 35 | "count": 3, 36 | "date": "2020-02-01", 37 | }, 38 | Object { 39 | "count": 1, 40 | "date": "2020-02-02", 41 | }, 42 | Object { 43 | "count": 2, 44 | "date": "2020-02-03", 45 | }, 46 | Object { 47 | "count": 3, 48 | "date": "2020-02-04", 49 | }, 50 | Object { 51 | "count": 1, 52 | "date": "2020-02-06", 53 | }, 54 | Object { 55 | "count": 1, 56 | "date": "2020-02-08", 57 | }, 58 | Object { 59 | "count": 2, 60 | "date": "2020-02-09", 61 | }, 62 | ] 63 | `; 64 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/__tests__/__snapshots__/userLoaderFactory.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`userLoaderFactory should return correct user with given slug 1`] = ` 4 | Object { 5 | "_cursor": undefined, 6 | "_fields": undefined, 7 | "_score": 0.6931472, 8 | "email": "secret@secret.com", 9 | "highlight": undefined, 10 | "id": "test-user", 11 | "inner_hits": undefined, 12 | "name": "test user", 13 | "slug": "abc123", 14 | } 15 | `; 16 | 17 | exports[`userLoaderFactory should return correct user with given slug 2`] = ` 18 | Object { 19 | "_cursor": undefined, 20 | "_fields": undefined, 21 | "_score": 0.6931472, 22 | "email": "hi@me.com", 23 | "highlight": undefined, 24 | "id": "test-user2", 25 | "inner_hits": undefined, 26 | "name": "test user2", 27 | "slug": "def456", 28 | } 29 | `; 30 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/__tests__/analyticsLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import { loadFixtures, unloadFixtures } from 'util/fixtures'; 2 | import DataLoaders from '../index'; 3 | import fixtures from '../__fixtures__/analyticsLoaderFactory'; 4 | import MockDate from 'mockdate'; 5 | 6 | const loader = new DataLoaders(); 7 | MockDate.set(1578589200000); // 2020-01-10T01:00:00.000+08:00 8 | 9 | describe('analyticsLoaderFactory', () => { 10 | beforeAll(() => loadFixtures(fixtures)); 11 | 12 | it('should load last 31 days of data for given id', async () => { 13 | const res = await loader.analyticsLoader.load({ 14 | docId: 'article0', 15 | docType: 'article', 16 | }); 17 | expect(res).toMatchSnapshot(); 18 | expect(res.length).toBe(31); 19 | }); 20 | 21 | it('should load data for id with date range (start of day)', async () => { 22 | expect( 23 | await loader.analyticsLoader.load({ 24 | docId: 'article0', 25 | docType: 'article', 26 | dateRange: { 27 | gte: '2020-01-01T00:00:00.000Z', 28 | lte: '2020-01-04T00:00:00.000Z', 29 | }, 30 | }) 31 | ).toMatchSnapshot(); 32 | }); 33 | 34 | it('should load data for id with date range (not start of day)', async () => { 35 | expect( 36 | await loader.analyticsLoader.load({ 37 | docId: 'article0', 38 | docType: 'article', 39 | dateRange: { 40 | gte: '2020-01-01T08:00:00.000+08:00', 41 | lte: '2020-01-04T08:00:00.000+08:00', 42 | }, 43 | }) 44 | ).toMatchSnapshot(); 45 | }); 46 | 47 | it('should throw error if docId is not present', async () => { 48 | let error; 49 | try { 50 | await loader.analyticsLoader.load({ docType: 'article' }); 51 | } catch (e) { 52 | error = e.message; 53 | } 54 | expect(error).toBe('docId is required'); 55 | }); 56 | 57 | it('should throw error if docType is not present', async () => { 58 | let error; 59 | try { 60 | await loader.analyticsLoader.load({ docId: 'article0' }); 61 | } catch (e) { 62 | error = e.message; 63 | } 64 | expect(error).toBe('docType is required'); 65 | }); 66 | 67 | afterAll(() => unloadFixtures(fixtures)); 68 | }); 69 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/__tests__/articleCategoriesByCategoryIdLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import { loadFixtures, unloadFixtures } from 'util/fixtures'; 2 | import fixtures from '../__fixtures__/articleCategoriesByCategoryIdLoaderFactory'; 3 | 4 | import DataLoaders from '../index'; 5 | const loader = new DataLoaders(); 6 | 7 | describe('articleCategoriesByCategoryIdLoaderFactory', () => { 8 | beforeAll(() => loadFixtures(fixtures)); 9 | 10 | it('load id', async () => { 11 | expect( 12 | await loader.articleCategoriesByCategoryIdLoader.load({ 13 | id: 'c1', 14 | }) 15 | ).toMatchSnapshot(); 16 | }); 17 | 18 | it('load id with first', async () => { 19 | expect( 20 | await loader.articleCategoriesByCategoryIdLoader.load({ 21 | id: 'c1', 22 | first: 2, 23 | }) 24 | ).toMatchSnapshot(); 25 | }); 26 | 27 | it('load id with before', async () => { 28 | expect( 29 | await loader.articleCategoriesByCategoryIdLoader.load({ 30 | id: 'c1', 31 | before: [+new Date('2020-02-09T15:11:05.472Z')], 32 | }) 33 | ).toMatchSnapshot(); 34 | }); 35 | 36 | it('load id with after', async () => { 37 | expect( 38 | await loader.articleCategoriesByCategoryIdLoader.load({ 39 | id: 'c1', 40 | after: [+new Date('2020-02-09T15:11:05.472Z')], 41 | }) 42 | ).toMatchSnapshot(); 43 | }); 44 | 45 | it('should fail if before and after both exist', async () => { 46 | expect.assertions(1); 47 | try { 48 | await loader.articleCategoriesByCategoryIdLoader.load({ 49 | id: 'c1', 50 | after: [+new Date('2020-02-09T15:11:05.472Z')], 51 | before: [+new Date('2020-02-09T15:11:05.472Z')], 52 | }); 53 | } catch (e) { 54 | expect(e).toMatchSnapshot(); 55 | } 56 | }); 57 | 58 | afterAll(() => unloadFixtures(fixtures)); 59 | }); 60 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/__tests__/contirubtionsLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import { loadFixtures, unloadFixtures } from 'util/fixtures'; 2 | import DataLoaders from '../index'; 3 | import fixtures from '../__fixtures__/contributionsLoaderFactory'; 4 | import MockDate from 'mockdate'; 5 | 6 | const loader = new DataLoaders(); 7 | 8 | describe('contributionsLoaderFactory', () => { 9 | beforeAll(() => { 10 | MockDate.set(1609430400000); // 2021-01-01T00:00:00.000+08:00 11 | return loadFixtures(fixtures); 12 | }); 13 | 14 | it('should load last year of data for given userId', async () => { 15 | const res = await loader.contributionsLoader.load({ 16 | userId: 'user1', 17 | }); 18 | expect(res).toMatchSnapshot(); 19 | }); 20 | 21 | it('should load data for the date range specified', async () => { 22 | expect( 23 | await loader.contributionsLoader.load({ 24 | userId: 'user1', 25 | dateRange: { 26 | gte: '2020-02-01T00:00:00.000Z', 27 | lte: '2020-03-01T00:00:00.000Z', 28 | }, 29 | }) 30 | ).toMatchSnapshot(); 31 | }); 32 | 33 | it('should throw error if userId is not present', async () => { 34 | let error; 35 | try { 36 | await loader.contributionsLoader.load({}); 37 | } catch (e) { 38 | error = e.message; 39 | } 40 | expect(error).toBe('userId is required'); 41 | }); 42 | 43 | afterAll(() => { 44 | MockDate.reset(); 45 | return unloadFixtures(fixtures); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/__tests__/userLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import { loadFixtures, unloadFixtures } from 'util/fixtures'; 2 | import DataLoaders from '../index'; 3 | import fixtures from '../__fixtures__/userLoaderFactory'; 4 | 5 | const loader = new DataLoaders(); 6 | 7 | describe('userLoaderFactory', () => { 8 | beforeAll(() => loadFixtures(fixtures)); 9 | 10 | it('should return correct user with given slug', async () => { 11 | expect( 12 | await loader.userLoader.load({ 13 | slug: 'abc123', 14 | }) 15 | ).toMatchSnapshot(); 16 | expect( 17 | await loader.userLoader.load({ 18 | slug: 'def456', 19 | }) 20 | ).toMatchSnapshot(); 21 | }); 22 | 23 | it('should return null if slug does not exist', async () => { 24 | expect( 25 | await loader.userLoader.load({ 26 | slug: 'asdf', 27 | }) 28 | ).toBe(null); 29 | }); 30 | 31 | afterAll(() => unloadFixtures(fixtures)); 32 | }); 33 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/analyticsLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import { subDays } from 'date-fns'; 2 | import DataLoader from 'dataloader'; 3 | import client from 'util/client'; 4 | import { getRangeFieldParamFromArithmeticExpression } from 'graphql/util'; 5 | 6 | const defaultDuration = 31; 7 | 8 | export default () => 9 | new DataLoader( 10 | async (statsQueries) => { 11 | const body = []; 12 | const defaultEndDate = new Date(); 13 | const defaultStartDate = subDays(defaultEndDate, defaultDuration); 14 | const defaultDateRange = { 15 | gt: defaultStartDate, 16 | lte: defaultEndDate, 17 | }; 18 | statsQueries.forEach( 19 | ({ docId, docType, dateRange = defaultDateRange }) => { 20 | if (!docId) throw new Error('docId is required'); 21 | if (!docType) throw new Error('docType is required'); 22 | body.push({ index: 'analytics', type: 'doc' }); 23 | body.push({ 24 | query: { 25 | bool: { 26 | must: [ 27 | { match: { type: docType } }, 28 | { match: { docId: docId } }, 29 | { 30 | range: { 31 | date: getRangeFieldParamFromArithmeticExpression( 32 | dateRange 33 | ), 34 | }, 35 | }, 36 | ], 37 | }, 38 | }, 39 | sort: [{ date: 'asc' }], 40 | size: 90, 41 | }); 42 | } 43 | ); 44 | 45 | return ( 46 | await client.msearch({ 47 | body, 48 | }) 49 | ).body.responses.map(({ hits: { hits: analytics } }) => 50 | analytics.map((row) => row._source) 51 | ); 52 | }, 53 | { 54 | cacheKeyFn: ({ docId, docType, dateRange }) => 55 | `${docType}/${docId}/${JSON.stringify(dateRange)}`, 56 | } 57 | ); 58 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/articleCategoriesByCategoryIdLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import DataLoader from 'dataloader'; 2 | import client from 'util/client'; 3 | 4 | export default () => 5 | new DataLoader( 6 | async (categoryQueries) => { 7 | const body = []; 8 | 9 | categoryQueries.forEach(({ id, first, before, after }) => { 10 | // TODO error independently? 11 | if (before && after) { 12 | throw new Error('Use of before & after is prohibited.'); 13 | } 14 | 15 | body.push({ index: 'articles', type: 'doc' }); 16 | 17 | body.push({ 18 | query: { 19 | nested: { 20 | path: 'articleCategories', 21 | query: { 22 | term: { 23 | 'articleCategories.categoryId': id, 24 | }, 25 | }, 26 | }, 27 | }, 28 | size: first ? first : 10, 29 | sort: before ? [{ updatedAt: 'desc' }] : [{ updatedAt: 'asc' }], 30 | search_after: before || after ? before || after : undefined, 31 | }); 32 | }); 33 | 34 | return ( 35 | await client.msearch({ 36 | body, 37 | }) 38 | ).body.responses.map(({ hits }, idx) => { 39 | if (!hits || !hits.hits) return []; 40 | 41 | const categoryId = categoryQueries[idx].id; 42 | return hits.hits.map(({ _id, _source: { articleCategories } }) => { 43 | // Find corresponding articleCategory and insert articleId 44 | // 45 | const articleCategory = articleCategories.find( 46 | (articleCategory) => articleCategory.categoryId === categoryId 47 | ); 48 | articleCategory.articleId = _id; 49 | return articleCategory; 50 | }); 51 | }); 52 | }, 53 | { 54 | cacheKeyFn: ({ id, first, before, after }) => 55 | `/${id}/${first}/${before}/${after}`, 56 | } 57 | ); 58 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/articleCategoryFeedbacksLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import DataLoader from 'dataloader'; 2 | import client, { processMeta } from 'util/client'; 3 | 4 | export default () => 5 | new DataLoader( 6 | async (articleAndCategoryIds) => { 7 | const body = []; 8 | 9 | articleAndCategoryIds.forEach(({ articleId, categoryId }) => { 10 | body.push({ index: 'articlecategoryfeedbacks', type: 'doc' }); 11 | 12 | body.push({ 13 | query: { 14 | bool: { 15 | must: [{ term: { articleId } }, { term: { categoryId } }], 16 | }, 17 | }, 18 | size: 10000, 19 | }); 20 | }); 21 | 22 | return ( 23 | await client.msearch({ 24 | body, 25 | }) 26 | ).body.responses.map(({ hits }) => { 27 | if (!hits || !hits.hits) return []; 28 | 29 | return hits.hits.map(processMeta); 30 | }); 31 | }, 32 | { 33 | cacheKeyFn: ({ articleId, categoryId }) => `${articleId}__${categoryId}`, 34 | } 35 | ); 36 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/articleRepliesByReplyIdLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import DataLoader from 'dataloader'; 2 | import client from 'util/client'; 3 | 4 | export default () => 5 | new DataLoader(async (replyIds) => { 6 | const body = []; 7 | 8 | replyIds.forEach((id) => { 9 | body.push({ index: 'articles', type: 'doc' }); 10 | 11 | body.push({ 12 | query: { 13 | nested: { 14 | path: 'articleReplies', 15 | query: { 16 | term: { 17 | 'articleReplies.replyId': id, 18 | }, 19 | }, 20 | }, 21 | }, 22 | }); 23 | }); 24 | 25 | return ( 26 | await client.msearch({ 27 | body, 28 | }) 29 | ).body.responses.map(({ hits }, idx) => { 30 | if (!hits || !hits.hits) return []; 31 | 32 | const replyId = replyIds[idx]; 33 | return hits.hits.map(({ _id, _source: { articleReplies } }) => { 34 | // Find corresponding articleReply and insert articleId 35 | // 36 | const articleReply = articleReplies.find( 37 | (articleReply) => articleReply.replyId === replyId 38 | ); 39 | articleReply.articleId = _id; 40 | return articleReply; 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/articleReplyFeedbacksLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import DataLoader from 'dataloader'; 2 | import client, { processMeta } from 'util/client'; 3 | 4 | export default () => 5 | new DataLoader( 6 | async (articleAndReplyIds) => { 7 | const body = []; 8 | 9 | articleAndReplyIds.forEach(({ articleId, replyId }) => { 10 | body.push({ index: 'articlereplyfeedbacks', type: 'doc' }); 11 | 12 | body.push({ 13 | query: { 14 | bool: { 15 | must: [{ term: { articleId } }, { term: { replyId } }], 16 | }, 17 | }, 18 | size: 10000, 19 | }); 20 | }); 21 | 22 | return ( 23 | await client.msearch({ 24 | body, 25 | }) 26 | ).body.responses.map(({ hits }) => { 27 | if (!hits || !hits.hits) return []; 28 | 29 | return hits.hits.map(processMeta); 30 | }); 31 | }, 32 | { 33 | cacheKeyFn: ({ articleId, replyId }) => `${articleId}__${replyId}`, 34 | } 35 | ); 36 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/contributionsLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import { subDays, startOfWeek } from 'date-fns'; 2 | import DataLoader from 'dataloader'; 3 | import client from 'util/client'; 4 | import { getRangeFieldParamFromArithmeticExpression } from 'graphql/util'; 5 | 6 | const defaultDuration = 365; 7 | 8 | export default () => 9 | new DataLoader( 10 | async (statsQueries) => { 11 | const body = []; 12 | const defaultEndDate = new Date(); 13 | const defaultStartDate = startOfWeek( 14 | subDays(defaultEndDate, defaultDuration) 15 | ); 16 | const defaultDateRange = { 17 | gt: defaultStartDate, 18 | lte: defaultEndDate, 19 | }; 20 | statsQueries.forEach(({ userId, dateRange = defaultDateRange }) => { 21 | if (!userId) throw new Error('userId is required'); 22 | body.push({ 23 | index: ['replyrequests', 'replies', 'articlereplyfeedbacks'], 24 | type: 'doc', 25 | }); 26 | body.push({ 27 | query: { 28 | bool: { 29 | must: [ 30 | { term: { userId } }, 31 | { 32 | range: { 33 | createdAt: 34 | getRangeFieldParamFromArithmeticExpression(dateRange), 35 | }, 36 | }, 37 | ], 38 | }, 39 | }, 40 | size: 0, 41 | aggs: { 42 | contributions: { 43 | date_histogram: { 44 | field: 'createdAt', 45 | interval: 'day', 46 | min_doc_count: 1, 47 | format: 'yyyy-MM-dd', 48 | time_zone: process.env.TIMEZONE || '+08:00', 49 | }, 50 | }, 51 | }, 52 | }); 53 | }); 54 | 55 | return ( 56 | await client.msearch({ 57 | body, 58 | }) 59 | ).body.responses.map( 60 | ({ 61 | aggregations: { 62 | contributions: { buckets }, 63 | }, 64 | }) => 65 | buckets 66 | ? buckets.map((bucket) => ({ 67 | date: bucket.key_as_string, 68 | count: bucket.doc_count, 69 | })) 70 | : [] 71 | ); 72 | }, 73 | { 74 | cacheKeyFn: ({ userId, dateRange }) => 75 | `${userId}/${JSON.stringify(dateRange)}`, 76 | } 77 | ); 78 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/docLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import DataLoader from 'dataloader'; 2 | import client, { processMeta } from 'util/client'; 3 | 4 | // Given document {index, type, id} 5 | // returns the specified documents. 6 | // 7 | export default () => 8 | new DataLoader( 9 | async (indexTypeIds) => { 10 | const docs = indexTypeIds.map(({ index, id }) => ({ 11 | _index: index, 12 | _id: id, 13 | _type: 'doc', 14 | })); 15 | 16 | return ( 17 | await client.mget({ 18 | body: { docs }, 19 | }) 20 | ).body.docs.map(processMeta); 21 | }, 22 | { 23 | cacheKeyFn: ({ index, id }) => `/${index}/${id}`, 24 | } 25 | ); 26 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/repliedArticleCountLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import DataLoader from 'dataloader'; 2 | import client from 'util/client'; 3 | 4 | export default () => 5 | new DataLoader( 6 | /** 7 | * @param {string[]} userIds - list of userIds 8 | * @returns {Promise} - replied article count for the specified user 9 | */ 10 | async (userIds) => { 11 | const body = userIds.reduce( 12 | (commands, userId) => 13 | commands.concat( 14 | { 15 | index: 'articles', 16 | type: 'doc', 17 | }, 18 | { 19 | size: 0, 20 | query: { 21 | nested: { 22 | path: 'articleReplies', 23 | query: { 24 | bool: { 25 | must: [ 26 | { term: { 'articleReplies.userId': userId } }, 27 | { term: { 'articleReplies.appId': 'WEBSITE' } }, 28 | { term: { 'articleReplies.status': 'NORMAL' } }, 29 | ], 30 | }, 31 | }, 32 | }, 33 | }, 34 | } 35 | ), 36 | [] 37 | ); 38 | 39 | return (await client.msearch({ body })).body.responses.map( 40 | ({ hits: { total } }) => total 41 | ); 42 | } 43 | ); 44 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/searchResultLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import DataLoader from 'dataloader'; 2 | import client, { processMeta } from 'util/client'; 3 | import getIn from 'util/getInFactory'; 4 | 5 | export default () => 6 | new DataLoader( 7 | async (searchContexts) => { 8 | const mSearchBody = []; 9 | searchContexts.forEach(({ body, ...otherContext }) => { 10 | mSearchBody.push(otherContext); 11 | mSearchBody.push(body); 12 | }); 13 | 14 | return (await client.msearch({ body: mSearchBody })).body.responses.map( 15 | (resp) => { 16 | if (resp.error) throw new Error(JSON.stringify(resp.error)); 17 | return getIn(resp)(['hits', 'hits'], []).map(processMeta); 18 | } 19 | ); 20 | }, 21 | { 22 | cacheKeyFn: JSON.stringify, 23 | } 24 | ); 25 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/urlLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import DataLoader from 'dataloader'; 2 | 3 | /** 4 | * Given list of urls, return their latest fetch respectively 5 | */ 6 | export default (dataLoaders) => 7 | new DataLoader(async (urls) => { 8 | const data = await dataLoaders.searchResultLoader.loadMany( 9 | urls.map((url) => ({ 10 | index: 'urls', 11 | type: 'doc', 12 | body: { 13 | query: { 14 | bool: { 15 | should: [{ term: { url } }, { term: { canonical: url } }], 16 | }, 17 | }, 18 | sort: { 19 | fetchedAt: 'desc', 20 | }, 21 | size: 1, 22 | }, 23 | })) 24 | ); 25 | 26 | return data.map((result) => (result && result.length ? result[0] : null)); 27 | }); 28 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/userLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import DataLoader from 'dataloader'; 2 | import client, { processMeta } from 'util/client'; 3 | 4 | export default () => 5 | new DataLoader( 6 | async (slugs) => { 7 | const body = []; 8 | 9 | slugs.forEach(({ slug }) => { 10 | body.push({ index: 'users', type: 'doc' }); 11 | 12 | body.push({ 13 | query: { 14 | term: { slug }, 15 | }, 16 | size: 1, 17 | }); 18 | }); 19 | 20 | return ( 21 | await client.msearch({ 22 | body, 23 | }) 24 | ).body.responses.map(({ hits }) => { 25 | if (!hits || !hits.hits || hits.hits.length == 0) return null; 26 | return processMeta(hits.hits[0]); 27 | }); 28 | }, 29 | { 30 | cacheKeyFn: ({ slug }) => `/user/slug/${slug}`, 31 | } 32 | ); 33 | -------------------------------------------------------------------------------- /src/graphql/dataLoaders/votedArticleReplyCountLoaderFactory.js: -------------------------------------------------------------------------------- 1 | import DataLoader from 'dataloader'; 2 | import client from 'util/client'; 3 | 4 | export default () => 5 | new DataLoader( 6 | /** 7 | * @param {string[]} userIds - list of userIds 8 | * @returns {Promise} - number of article replies the specified user has voted 9 | */ 10 | async (userIds) => { 11 | const body = userIds.reduce( 12 | (commands, userId) => 13 | commands.concat( 14 | { 15 | index: 'articlereplyfeedbacks', 16 | type: 'doc', 17 | }, 18 | { 19 | size: 0, 20 | query: { term: { userId } }, 21 | } 22 | ), 23 | [] 24 | ); 25 | 26 | return (await client.msearch({ body })).body.responses.map( 27 | ({ hits: { total } }) => total 28 | ); 29 | } 30 | ); 31 | -------------------------------------------------------------------------------- /src/graphql/interfaces/Connection.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLInterfaceType, 3 | GraphQLNonNull, 4 | GraphQLList, 5 | GraphQLInt, 6 | } from 'graphql'; 7 | import Edge from './Edge'; 8 | import PageInfo from './PageInfo'; 9 | 10 | const Connection = new GraphQLInterfaceType({ 11 | name: 'Connection', 12 | description: 13 | "Connection model for a list of nodes. Modeled after Relay's GraphQL Server Specification.", 14 | fields: { 15 | edges: { 16 | type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Edge))), 17 | }, 18 | totalCount: { type: new GraphQLNonNull(GraphQLInt) }, 19 | pageInfo: { type: new GraphQLNonNull(PageInfo) }, 20 | }, 21 | }); 22 | 23 | export default Connection; 24 | -------------------------------------------------------------------------------- /src/graphql/interfaces/Edge.js: -------------------------------------------------------------------------------- 1 | import { GraphQLInterfaceType, GraphQLNonNull, GraphQLString } from 'graphql'; 2 | import Node from './Node'; 3 | 4 | const Edge = new GraphQLInterfaceType({ 5 | name: 'Edge', 6 | description: 'Edge in Connection. Modeled after GraphQL connection model.', 7 | fields: { 8 | node: { type: new GraphQLNonNull(Node) }, 9 | cursor: { type: new GraphQLNonNull(GraphQLString) }, 10 | }, 11 | }); 12 | 13 | export default Edge; 14 | -------------------------------------------------------------------------------- /src/graphql/interfaces/Node.js: -------------------------------------------------------------------------------- 1 | import { GraphQLInterfaceType, GraphQLID, GraphQLNonNull } from 'graphql'; 2 | 3 | const Node = new GraphQLInterfaceType({ 4 | name: 'Node', 5 | description: 6 | "Basic entity. Modeled after Relay's GraphQL Server Specification.", 7 | fields: { 8 | id: { type: new GraphQLNonNull(GraphQLID) }, 9 | }, 10 | }); 11 | 12 | export default Node; 13 | -------------------------------------------------------------------------------- /src/graphql/interfaces/PageInfo.js: -------------------------------------------------------------------------------- 1 | import { GraphQLInterfaceType, GraphQLString } from 'graphql'; 2 | 3 | const PageInfo = new GraphQLInterfaceType({ 4 | name: 'PageInfo', 5 | description: 6 | 'PageInfo in Connection. Modeled after GraphQL connection model.', 7 | fields: { 8 | firstCursor: { 9 | description: 10 | 'The cursor pointing to the first node of the entire collection, regardless of "before" and "after". Can be used to determine if is in the last page. Null when the collection is empty.', 11 | type: GraphQLString, 12 | }, 13 | lastCursor: { 14 | description: 15 | 'The cursor pointing to the last node of the entire collection, regardless of "before" and "after". Can be used to determine if is in the last page. Null when the collection is empty.', 16 | type: GraphQLString, 17 | }, 18 | }, 19 | }); 20 | 21 | export default PageInfo; 22 | -------------------------------------------------------------------------------- /src/graphql/models/AIResponseStatusEnum.js: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumType } from 'graphql'; 2 | 3 | export default new GraphQLEnumType({ 4 | name: 'AIResponseStatusEnum', 5 | values: { 6 | LOADING: { value: 'LOADING' }, 7 | SUCCESS: { value: 'SUCCESS' }, 8 | ERROR: { value: 'ERROR' }, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /src/graphql/models/AIResponseTypeEnum.js: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumType } from 'graphql'; 2 | 3 | export default new GraphQLEnumType({ 4 | name: 'AIResponseTypeEnum', 5 | values: { 6 | AI_REPLY: { 7 | description: 8 | 'The AI Response is an automated analysis / reply of an article.', 9 | value: 'AI_REPLY', 10 | }, 11 | 12 | TRANSCRIPT: { 13 | description: 'AI transcribed text of the specified article.', 14 | value: 'TRANSCRIPT', 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/graphql/models/AnalyticsDocTypeEnum.js: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumType } from 'graphql'; 2 | 3 | export default new GraphQLEnumType({ 4 | name: 'AnalyticsDocTypeEnum', 5 | values: { 6 | ARTICLE: { value: 'article' }, 7 | REPLY: { value: 'reply' }, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/graphql/models/ArticleCategoryFeedback.js: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLString } from 'graphql'; 2 | import FeedbackVote from './FeedbackVote'; 3 | 4 | import User, { userFieldResolver } from './User'; 5 | 6 | export default new GraphQLObjectType({ 7 | name: 'ArticleCategoryFeedback', 8 | description: 'User feedback to an ArticleCategory', 9 | fields: () => ({ 10 | id: { type: GraphQLString }, 11 | 12 | user: { 13 | type: User, 14 | resolve: userFieldResolver, 15 | }, 16 | 17 | comment: { type: GraphQLString }, 18 | 19 | vote: { 20 | description: "User's vote on the articleCategory", 21 | type: FeedbackVote, 22 | resolve: ({ score }) => score, 23 | }, 24 | 25 | createdAt: { type: GraphQLString }, 26 | updatedAt: { type: GraphQLString }, 27 | }), 28 | }); 29 | -------------------------------------------------------------------------------- /src/graphql/models/ArticleCategoryStatusEnum.js: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumType } from 'graphql'; 2 | 3 | export default new GraphQLEnumType({ 4 | name: 'ArticleCategoryStatusEnum', 5 | values: { 6 | NORMAL: { 7 | value: 'NORMAL', 8 | }, 9 | DELETED: { 10 | value: 'DELETED', 11 | }, 12 | BLOCKED: { 13 | value: 'BLOCKED', 14 | description: 'Created by a blocked user violating terms of use.', 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/graphql/models/ArticleReference.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLInputObjectType, 3 | GraphQLObjectType, 4 | GraphQLEnumType, 5 | GraphQLString, 6 | GraphQLNonNull, 7 | } from 'graphql'; 8 | 9 | const ArticleReferenceTypeEnum = new GraphQLEnumType({ 10 | name: 'ArticleReferenceTypeEnum', 11 | description: 'Where this article is collected from.', 12 | values: { 13 | URL: { 14 | value: 'URL', 15 | description: 16 | 'The article is collected from the Internet, with a link to the article available.', 17 | }, 18 | LINE: { 19 | value: 'LINE', 20 | description: 21 | 'The article is collected from conversations in LINE messengers.', 22 | }, 23 | }, 24 | }); 25 | 26 | export default new GraphQLObjectType({ 27 | name: 'ArticleReference', 28 | fields: () => ({ 29 | createdAt: { type: new GraphQLNonNull(GraphQLString) }, 30 | type: { type: new GraphQLNonNull(ArticleReferenceTypeEnum) }, 31 | permalink: { type: GraphQLString }, 32 | }), 33 | }); 34 | 35 | export const ArticleReferenceInput = new GraphQLInputObjectType({ 36 | name: 'ArticleReferenceInput', 37 | fields: () => ({ 38 | type: { type: new GraphQLNonNull(ArticleReferenceTypeEnum) }, 39 | permalink: { type: GraphQLString }, 40 | }), 41 | }); 42 | -------------------------------------------------------------------------------- /src/graphql/models/ArticleReplyFeedbackStatusEnum.js: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumType } from 'graphql'; 2 | 3 | export default new GraphQLEnumType({ 4 | name: 'ArticleReplyFeedbackStatusEnum', 5 | values: { 6 | NORMAL: { 7 | value: 'NORMAL', 8 | }, 9 | BLOCKED: { 10 | value: 'BLOCKED', 11 | description: 'Created by a blocked user violating terms of use.', 12 | }, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /src/graphql/models/ArticleReplyStatusEnum.js: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumType } from 'graphql'; 2 | 3 | export default new GraphQLEnumType({ 4 | name: 'ArticleReplyStatusEnum', 5 | values: { 6 | NORMAL: { 7 | value: 'NORMAL', 8 | }, 9 | DELETED: { 10 | value: 'DELETED', 11 | }, 12 | BLOCKED: { 13 | value: 'BLOCKED', 14 | description: 'Created by a blocked user violating terms of use.', 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/graphql/models/ArticleStatusEnum.js: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumType } from 'graphql'; 2 | 3 | export default new GraphQLEnumType({ 4 | name: 'ArticleStatusEnum', 5 | values: { 6 | NORMAL: { 7 | value: 'NORMAL', 8 | }, 9 | BLOCKED: { 10 | value: 'BLOCKED', 11 | description: 'Created by a blocked user violating terms of use.', 12 | }, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /src/graphql/models/ArticleTypeEnum.js: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumType } from 'graphql'; 2 | 3 | export default new GraphQLEnumType({ 4 | name: 'ArticleTypeEnum', 5 | values: { 6 | TEXT: { 7 | value: 'TEXT', 8 | }, 9 | IMAGE: { 10 | value: 'IMAGE', 11 | }, 12 | VIDEO: { 13 | value: 'VIDEO', 14 | }, 15 | AUDIO: { 16 | value: 'AUDIO', 17 | }, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /src/graphql/models/AvatarTypeEnum.js: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumType } from 'graphql'; 2 | import { AvatarTypes } from 'util/user'; 3 | 4 | export default new GraphQLEnumType({ 5 | name: 'AvatarTypeEnum', 6 | values: Object.fromEntries( 7 | Object.keys(AvatarTypes).map((k) => [k, { value: k }]) 8 | ), 9 | }); 10 | -------------------------------------------------------------------------------- /src/graphql/models/Badge.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLObjectType, 3 | GraphQLString, 4 | GraphQLNonNull, 5 | GraphQLID, 6 | GraphQLList, 7 | } from 'graphql'; 8 | 9 | export default new GraphQLObjectType({ 10 | name: 'Badge', 11 | fields: { 12 | id: { 13 | type: GraphQLID, 14 | }, 15 | name: { type: GraphQLString }, 16 | displayName: { type: GraphQLString }, 17 | description: { type: GraphQLString }, 18 | link: { type: GraphQLString }, 19 | icon: { type: GraphQLString }, 20 | borderImage: { type: GraphQLString }, 21 | createdAt: { type: GraphQLString }, 22 | updatedAt: { type: GraphQLString }, 23 | issuers: { 24 | type: new GraphQLNonNull( 25 | new GraphQLList(new GraphQLNonNull(GraphQLString)) 26 | ), 27 | }, 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /src/graphql/models/Category.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLObjectType, 3 | GraphQLString, 4 | GraphQLNonNull, 5 | GraphQLID, 6 | } from 'graphql'; 7 | 8 | import { createSortType, pagingArgs, getSortArgs } from 'graphql/util'; 9 | 10 | import Node from '../interfaces/Node'; 11 | import { ArticleCategoryConnection } from './ArticleCategory'; 12 | import ArticleCategoryStatusEnum from './ArticleCategoryStatusEnum'; 13 | 14 | const Category = new GraphQLObjectType({ 15 | name: 'Category', 16 | description: 'Category label for specific topic', 17 | interfaces: [Node], 18 | fields: () => ({ 19 | id: { type: new GraphQLNonNull(GraphQLID) }, 20 | title: { type: GraphQLString }, 21 | description: { type: GraphQLString }, 22 | createdAt: { type: GraphQLString }, 23 | updatedAt: { type: GraphQLString }, 24 | 25 | articleCategories: { 26 | type: ArticleCategoryConnection, 27 | args: { 28 | status: { 29 | type: ArticleCategoryStatusEnum, 30 | description: 31 | 'When specified, returns only article categories with the specified status', 32 | }, 33 | orderBy: { 34 | type: createSortType('CategoryArticleCategoriesOrderBy', [ 35 | 'createdAt', 36 | ]), 37 | }, 38 | ...pagingArgs, 39 | }, 40 | resolve: async ( 41 | { id }, 42 | { status = 'NORMAL', orderBy = [], ...otherParams } 43 | ) => { 44 | const body = { 45 | sort: getSortArgs(orderBy), 46 | query: { 47 | nested: { 48 | path: 'articleCategories', 49 | query: { 50 | bool: { 51 | must: [ 52 | { 53 | term: { 'articleCategories.categoryId': id }, 54 | }, 55 | { 56 | term: { 'articleCategories.status': status }, 57 | }, 58 | ], 59 | }, 60 | }, 61 | }, 62 | }, 63 | }; 64 | 65 | return { 66 | index: 'articles', 67 | type: 'doc', 68 | body, 69 | ...otherParams, 70 | }; 71 | }, 72 | }, 73 | }), 74 | }); 75 | 76 | export default Category; 77 | -------------------------------------------------------------------------------- /src/graphql/models/Contribution.js: -------------------------------------------------------------------------------- 1 | import { GraphQLInt, GraphQLObjectType, GraphQLString } from 'graphql'; 2 | 3 | export default new GraphQLObjectType({ 4 | name: 'Contribution', 5 | fields: () => ({ 6 | date: { type: GraphQLString }, 7 | count: { type: GraphQLInt }, 8 | }), 9 | }); 10 | -------------------------------------------------------------------------------- /src/graphql/models/Contributor.js: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLString, GraphQLNonNull } from 'graphql'; 2 | import User, { userFieldResolver } from './User'; 3 | 4 | export default new GraphQLObjectType({ 5 | name: 'Contributor', 6 | fields: () => ({ 7 | user: { 8 | type: User, 9 | description: 'The user who contributed to this article.', 10 | resolve: userFieldResolver, 11 | }, 12 | userId: { type: GraphQLNonNull(GraphQLString) }, 13 | appId: { type: GraphQLNonNull(GraphQLString) }, 14 | updatedAt: { type: GraphQLString }, 15 | }), 16 | }); 17 | -------------------------------------------------------------------------------- /src/graphql/models/Cooccurrence.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLObjectType, 3 | GraphQLString, 4 | GraphQLList, 5 | GraphQLNonNull, 6 | GraphQLID, 7 | } from 'graphql'; 8 | import Article from './Article'; 9 | import Node from '../interfaces/Node'; 10 | 11 | const Cooccurrence = new GraphQLObjectType({ 12 | name: 'Cooccurrence', 13 | interfaces: [Node], 14 | fields: () => ({ 15 | id: { type: new GraphQLNonNull(GraphQLID) }, 16 | userId: { type: GraphQLNonNull(GraphQLString) }, 17 | appId: { type: GraphQLNonNull(GraphQLString) }, 18 | articles: { 19 | type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Article))), 20 | resolve: async ({ articleIds }, args, { loaders }) => 21 | loaders.searchResultLoader.load({ 22 | index: 'articles', 23 | type: 'doc', 24 | body: { 25 | query: { 26 | ids: { 27 | values: articleIds, 28 | }, 29 | }, 30 | }, 31 | }), 32 | }, 33 | articleIds: { 34 | type: new GraphQLNonNull( 35 | new GraphQLList(new GraphQLNonNull(GraphQLString)) 36 | ), 37 | }, 38 | createdAt: { type: new GraphQLNonNull(GraphQLString) }, 39 | updatedAt: { type: new GraphQLNonNull(GraphQLString) }, 40 | }), 41 | }); 42 | 43 | export default Cooccurrence; 44 | -------------------------------------------------------------------------------- /src/graphql/models/CrawledDoc.js: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLString } from 'graphql'; 2 | 3 | const CrawledDoc = new GraphQLObjectType({ 4 | name: 'CrawledDoc', 5 | description: 'A document that is crawled from selected myth-busting websites', 6 | fields: { 7 | rumor: { type: GraphQLString }, 8 | answer: { type: GraphQLString }, 9 | url: { type: GraphQLString }, 10 | }, 11 | }); 12 | 13 | export default CrawledDoc; 14 | -------------------------------------------------------------------------------- /src/graphql/models/FeedbackVote.js: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumType } from 'graphql'; 2 | 3 | export default new GraphQLEnumType({ 4 | name: 'FeedbackVote', 5 | values: { 6 | UPVOTE: { value: 1 }, 7 | NEUTRAL: { value: 0 }, 8 | DOWNVOTE: { value: -1 }, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /src/graphql/models/Highlights.js: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLString, GraphQLList } from 'graphql'; 2 | import Hyperlink from './Hyperlink'; 3 | 4 | const Highlights = new GraphQLObjectType({ 5 | name: 'Highlights', 6 | fields: { 7 | text: { 8 | type: GraphQLString, 9 | description: 'Article or Reply text', 10 | }, 11 | reference: { 12 | type: GraphQLString, 13 | description: 'Reply reference', 14 | }, 15 | hyperlinks: { 16 | type: new GraphQLList(Hyperlink), 17 | description: 'Article or Reply hyperlinks', 18 | }, 19 | }, 20 | }); 21 | 22 | export default Highlights; 23 | -------------------------------------------------------------------------------- /src/graphql/models/Hyperlink.js: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLString } from 'graphql'; 2 | 3 | /** 4 | * 5 | * @param {string} field 6 | * @returns {resolveFn} Resolves URL entry using url or normalizedUrl 7 | */ 8 | function resolveUrl(field) { 9 | return async function ({ url, normalizedUrl }, args, { loaders }) { 10 | const urls = [url]; 11 | if (normalizedUrl) { 12 | // Only consider normalizedUrl when there is one 13 | urls.push(normalizedUrl); 14 | } 15 | const urlEnties = await loaders.urlLoader.loadMany(urls); 16 | const firstEntry = urlEnties.find((urlEntry) => urlEntry) || {}; 17 | return firstEntry[field]; 18 | }; 19 | } 20 | 21 | const Hyperlink = new GraphQLObjectType({ 22 | name: 'Hyperlink', 23 | description: 'Data behind a hyperlink', 24 | fields: { 25 | url: { type: GraphQLString, description: 'URL in text' }, 26 | normalizedUrl: { 27 | type: GraphQLString, 28 | description: 'URL normalized by scrapUrl', 29 | }, 30 | title: { type: GraphQLString }, 31 | summary: { type: GraphQLString }, 32 | topImageUrl: { type: GraphQLString, resolve: resolveUrl('topImageUrl') }, 33 | fetchedAt: { type: GraphQLString, resolve: resolveUrl('fetchedAt') }, 34 | status: { type: GraphQLString, resolve: resolveUrl('status') }, 35 | error: { type: GraphQLString, resolve: resolveUrl('error') }, 36 | }, 37 | }); 38 | 39 | export default Hyperlink; 40 | -------------------------------------------------------------------------------- /src/graphql/models/MutationResult.js: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLString } from 'graphql'; 2 | 3 | export default new GraphQLObjectType({ 4 | name: 'MutationResult', 5 | fields: () => ({ 6 | id: { type: GraphQLString }, 7 | }), 8 | }); 9 | -------------------------------------------------------------------------------- /src/graphql/models/ReplyRequest.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLObjectType, 3 | GraphQLString, 4 | GraphQLInt, 5 | GraphQLID, 6 | GraphQLNonNull, 7 | } from 'graphql'; 8 | import User, { userFieldResolver } from './User'; 9 | import Article from './Article'; 10 | import FeedbackVote from './FeedbackVote'; 11 | import Node from '../interfaces/Node'; 12 | import ReplyRequestStatusEnum from './ReplyRequestStatusEnum'; 13 | 14 | export default new GraphQLObjectType({ 15 | name: 'ReplyRequest', 16 | interfaces: [Node], 17 | fields: () => ({ 18 | id: { type: new GraphQLNonNull(GraphQLID) }, 19 | 20 | articleId: { 21 | type: new GraphQLNonNull(GraphQLID), 22 | }, 23 | userId: { type: GraphQLString }, 24 | appId: { type: GraphQLString }, 25 | 26 | user: { 27 | type: User, 28 | description: 'The author of reply request.', 29 | resolve: userFieldResolver, 30 | }, 31 | 32 | reason: { type: GraphQLString }, 33 | feedbackCount: { 34 | type: GraphQLInt, 35 | resolve({ feedbacks = [] }) { 36 | return feedbacks.length; 37 | }, 38 | }, 39 | positiveFeedbackCount: { 40 | type: GraphQLInt, 41 | resolve({ feedbacks = [] }) { 42 | return feedbacks.filter((fb) => fb.score === 1).length; 43 | }, 44 | }, 45 | negativeFeedbackCount: { 46 | type: GraphQLInt, 47 | resolve({ feedbacks = [] }) { 48 | return feedbacks.filter((fb) => fb.score === -1).length; 49 | }, 50 | }, 51 | createdAt: { type: GraphQLString }, 52 | updatedAt: { type: GraphQLString }, 53 | ownVote: { 54 | type: FeedbackVote, 55 | description: 56 | 'The feedback of current user. null when not logged in or not voted yet.', 57 | resolve({ feedbacks = [] }, args, { userId, appId }) { 58 | if (!userId || !appId) return null; 59 | const ownFeedback = feedbacks.find( 60 | (feedback) => feedback.userId === userId && feedback.appId === appId 61 | ); 62 | if (!ownFeedback) return null; 63 | return ownFeedback.score; 64 | }, 65 | }, 66 | article: { 67 | type: new GraphQLNonNull(Article), 68 | resolve: ({ articleId }, args, { loaders }) => 69 | loaders.docLoader.load({ index: 'articles', id: articleId }), 70 | }, 71 | status: { 72 | type: new GraphQLNonNull(ReplyRequestStatusEnum), 73 | }, 74 | }), 75 | }); 76 | -------------------------------------------------------------------------------- /src/graphql/models/ReplyRequestStatusEnum.js: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumType } from 'graphql'; 2 | 3 | export default new GraphQLEnumType({ 4 | name: 'ReplyRequestStatusEnum', 5 | values: { 6 | NORMAL: { 7 | value: 'NORMAL', 8 | }, 9 | BLOCKED: { 10 | value: 'BLOCKED', 11 | description: 'Created by a blocked user violating terms of use.', 12 | }, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /src/graphql/models/ReplyTypeEnum.js: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumType } from 'graphql'; 2 | 3 | export default new GraphQLEnumType({ 4 | name: 'ReplyTypeEnum', 5 | description: 'Reflects how the replier categories the replied article.', 6 | values: { 7 | RUMOR: { 8 | value: 'RUMOR', 9 | description: 10 | 'The replier thinks that the article contains false information.', 11 | }, 12 | NOT_RUMOR: { 13 | value: 'NOT_RUMOR', 14 | description: 15 | 'The replier thinks that the articles contains no false information.', 16 | }, 17 | NOT_ARTICLE: { 18 | value: 'NOT_ARTICLE', 19 | description: 20 | 'The replier thinks that the article is actually not a complete article on the internet or passed around in messengers.', 21 | }, 22 | OPINIONATED: { 23 | value: 'OPINIONATED', 24 | description: 25 | 'The replier thinks that the article contains personal viewpoint and is not objective.', 26 | }, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /src/graphql/models/SlugErrorEnum.js: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumType } from 'graphql'; 2 | 3 | export const errors = { 4 | EMPTY: 'EMPTY', 5 | NOT_TRIMMED: 'NOT_TRIMMED', 6 | HAS_URI_COMPONENT: 'HAS_URI_COMPONENT', 7 | TAKEN: 'TAKEN', 8 | }; 9 | 10 | export default new GraphQLEnumType({ 11 | name: 'SlugErrorEnum', 12 | description: 'Slug of canot', 13 | values: { 14 | [errors.EMPTY]: { 15 | value: errors.EMPTY, 16 | description: 'Slug is empty', 17 | }, 18 | [errors.NOT_TRIMMED]: { 19 | value: errors.NOT_TRIMMED, 20 | description: 'Slug have leading or trailing spaces or line ends', 21 | }, 22 | [errors.HAS_URI_COMPONENT]: { 23 | value: errors.HAS_URI_COMPONENT, 24 | description: 25 | 'Slug has URI component inside, which can be misleading to browsers', 26 | }, 27 | [errors.TAKEN]: { 28 | value: errors.TAKEN, 29 | description: 'Slug has already been taken by someone else', 30 | }, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/graphql/models/UserAwardedBadge.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLBoolean, 3 | GraphQLObjectType, 4 | GraphQLString, 5 | GraphQLNonNull, 6 | } from 'graphql'; 7 | 8 | export default new GraphQLObjectType({ 9 | name: 'UserAwardedBadge', 10 | fields: () => ({ 11 | badgeId: { type: new GraphQLNonNull(GraphQLString) }, 12 | badgeMetaData: { type: new GraphQLNonNull(GraphQLString) }, 13 | isDisplayed: { type: new GraphQLNonNull(GraphQLBoolean) }, 14 | createdAt: { type: new GraphQLNonNull(GraphQLString) }, 15 | updatedAt: { type: new GraphQLNonNull(GraphQLString) }, 16 | }), 17 | }); 18 | -------------------------------------------------------------------------------- /src/graphql/models/Ydoc.js: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLString, GraphQLList } from 'graphql'; 2 | 3 | import YdocVersion from './YdocVersion'; 4 | 5 | const Ydoc = new GraphQLObjectType({ 6 | name: 'Ydoc', 7 | fields: () => ({ 8 | data: { 9 | type: GraphQLString, 10 | // https://www.elastic.co/guide/en/elasticsearch/reference/current/binary.html 11 | description: 'Binary that stores as base64 encoded string', 12 | resolve: ({ ydoc }) => { 13 | return ydoc; 14 | }, 15 | }, 16 | versions: { 17 | type: new GraphQLList(YdocVersion), 18 | description: 19 | 'Ydoc snapshots which are used to restore to specific version', 20 | resolve: async ({ versions }) => { 21 | return versions || []; 22 | }, 23 | }, 24 | }), 25 | }); 26 | 27 | export default Ydoc; 28 | -------------------------------------------------------------------------------- /src/graphql/models/YdocVersion.js: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLString } from 'graphql'; 2 | 3 | export default new GraphQLObjectType({ 4 | name: 'YdocVersion', 5 | fields: () => ({ 6 | createdAt: { type: GraphQLString }, 7 | snapshot: { 8 | type: GraphQLString, 9 | // https://www.elastic.co/guide/en/elasticsearch/reference/current/binary.html 10 | description: 'Binary that stores as base64 encoded string', 11 | }, 12 | }), 13 | }); 14 | -------------------------------------------------------------------------------- /src/graphql/models/__fixtures__/User.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/users/doc/6LOqD_z5A8BwUr4gh1P2gw_2zFU3IIrSchTSl-vemod7CChMU': { 3 | name: 'test user 1', 4 | appUserId: 'userTest1', 5 | appId: 'TEST_BACKEND', 6 | }, 7 | 8 | '/users/doc/userTest2': { 9 | name: 'test user 2', 10 | appId: 'WEBSITE', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /src/graphql/models/__tests__/__snapshots__/User.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`User model userFieldResolver returns the right backend user given appUserId 1`] = ` 4 | Object { 5 | "_cursor": undefined, 6 | "_fields": undefined, 7 | "_score": undefined, 8 | "appId": "TEST_BACKEND", 9 | "appUserId": "userTest1", 10 | "highlight": undefined, 11 | "id": "6LOqD_z5A8BwUr4gh1P2gw_2zFU3IIrSchTSl-vemod7CChMU", 12 | "inner_hits": undefined, 13 | "name": "test user 1", 14 | } 15 | `; 16 | 17 | exports[`User model userFieldResolver returns the right backend user given db userId 1`] = ` 18 | Object { 19 | "_cursor": undefined, 20 | "_fields": undefined, 21 | "_score": undefined, 22 | "appId": "TEST_BACKEND", 23 | "appUserId": "userTest1", 24 | "highlight": undefined, 25 | "id": "6LOqD_z5A8BwUr4gh1P2gw_2zFU3IIrSchTSl-vemod7CChMU", 26 | "inner_hits": undefined, 27 | "name": "test user 1", 28 | } 29 | `; 30 | 31 | exports[`User model userFieldResolver returns the right web user given userId 1`] = ` 32 | Object { 33 | "_cursor": undefined, 34 | "_fields": undefined, 35 | "_score": undefined, 36 | "appId": "WEBSITE", 37 | "highlight": undefined, 38 | "id": "userTest2", 39 | "inner_hits": undefined, 40 | "name": "test user 2", 41 | } 42 | `; 43 | -------------------------------------------------------------------------------- /src/graphql/mutations/CreateCategory.js: -------------------------------------------------------------------------------- 1 | import { GraphQLString, GraphQLNonNull } from 'graphql'; 2 | 3 | import { assertUser } from 'util/user'; 4 | 5 | import client from 'util/client'; 6 | 7 | import MutationResult from 'graphql/models/MutationResult'; 8 | 9 | export default { 10 | type: MutationResult, 11 | description: 'Create a category', 12 | args: { 13 | title: { type: new GraphQLNonNull(GraphQLString) }, 14 | description: { type: new GraphQLNonNull(GraphQLString) }, 15 | }, 16 | async resolve(rootValue, { title, description }, { userId, appId }) { 17 | assertUser({ userId, appId }); 18 | 19 | const categoryBody = { 20 | userId, 21 | appId, 22 | title, 23 | description, 24 | updatedAt: new Date(), 25 | createdAt: new Date(), 26 | }; 27 | 28 | const { 29 | body: { result, _id: id }, 30 | } = await client.index({ 31 | index: 'categories', 32 | type: 'doc', 33 | body: categoryBody, 34 | }); 35 | 36 | if (result !== 'created') { 37 | throw new Error(`Cannot create reply: ${result}`); 38 | } 39 | 40 | return { id }; 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /src/graphql/mutations/CreateOrUpdateCooccurrence.js: -------------------------------------------------------------------------------- 1 | import { GraphQLString, GraphQLNonNull, GraphQLList } from 'graphql'; 2 | import { assertUser } from 'util/user'; 3 | 4 | import Cooccurrence from '../models/Cooccurrence'; 5 | 6 | import client from 'util/client'; 7 | 8 | export function getCooccurrenceId({ articleIds, userId, appId }) { 9 | const joinedArticleIds = articleIds.sort().join('_'); 10 | return `${userId}_${appId}_${joinedArticleIds}`; 11 | } 12 | 13 | /** 14 | * Updates the cooccurrence with specified `articleIds`. 15 | * 16 | * @param {string[]} articleIds 17 | * @returns {object} The updated coocurrence 18 | */ 19 | export async function createOrUpdateCooccurrence({ articleIds, user }) { 20 | assertUser(user); 21 | 22 | const now = new Date().toISOString(); 23 | const id = getCooccurrenceId({ 24 | articleIds, 25 | userId: user.id, 26 | appId: user.appId, 27 | }); 28 | 29 | const updatedDoc = { 30 | updatedAt: now, 31 | }; 32 | 33 | const { 34 | body: { 35 | result, 36 | get: { _source }, 37 | }, 38 | } = await client.update({ 39 | index: 'cooccurrences', 40 | type: 'doc', 41 | id, 42 | body: { 43 | doc: updatedDoc, 44 | upsert: { 45 | articleIds, 46 | userId: user.id, 47 | appId: user.appId, 48 | createdAt: now, 49 | updatedAt: now, 50 | }, 51 | _source: true, 52 | }, 53 | refresh: 'true', 54 | }); 55 | 56 | const isCreated = result === 'created'; 57 | 58 | return { 59 | cooccurrence: { id, ..._source }, 60 | isCreated, 61 | }; 62 | } 63 | 64 | export default { 65 | description: 'Create or update a cooccurrence for the given articles', 66 | type: Cooccurrence, 67 | args: { 68 | articleIds: { type: new GraphQLList(new GraphQLNonNull(GraphQLString)) }, 69 | }, 70 | async resolve(rootValue, { articleIds }, { user }) { 71 | const result = await createOrUpdateCooccurrence({ 72 | articleIds, 73 | user, 74 | }); 75 | return result.cooccurrence; 76 | }, 77 | }; 78 | -------------------------------------------------------------------------------- /src/graphql/mutations/UpdateUser.js: -------------------------------------------------------------------------------- 1 | import { GraphQLString } from 'graphql'; 2 | import client from 'util/client'; 3 | import User from 'graphql/models/User'; 4 | import { omit, omitBy } from 'lodash'; 5 | import { AvatarTypes } from 'util/user'; 6 | import { errors } from 'graphql/models/SlugErrorEnum'; 7 | 8 | import { assertSlugIsValid } from 'graphql/queries/ValidateSlug'; 9 | import AvatarTypeEnum from 'graphql/models/AvatarTypeEnum'; 10 | 11 | export default { 12 | type: User, 13 | description: 'Change attribute of a user', 14 | args: { 15 | name: { type: GraphQLString }, 16 | slug: { type: GraphQLString }, 17 | avatarType: { type: AvatarTypeEnum }, 18 | avatarData: { type: GraphQLString }, 19 | bio: { type: GraphQLString }, 20 | }, 21 | async resolve( 22 | rootValue, 23 | { name, slug, avatarType, avatarData, bio }, 24 | { userId } 25 | ) { 26 | let doc = omitBy( 27 | { 28 | updatedAt: new Date().toISOString(), 29 | name, 30 | slug, 31 | avatarType, 32 | avatarData, 33 | bio, 34 | }, 35 | (v) => v === undefined || v === null || v === '' 36 | ); 37 | 38 | if (Object.keys(doc).length === 1) 39 | throw new Error(`There's nothing to update`); 40 | 41 | // Ensure uniqueness of slug 42 | if (slug !== undefined && slug !== null) { 43 | try { 44 | await assertSlugIsValid(slug, userId); 45 | } catch (e) { 46 | if (e === errors.EMPTY) { 47 | // allow user to update slug to empty 48 | doc.slug = ''; 49 | } else { 50 | throw new Error(`Invalid slug: ${e}`); 51 | } 52 | } 53 | } 54 | 55 | if (avatarType && avatarType !== AvatarTypes.OpenPeeps) 56 | doc = omit(doc, ['avatarData']); 57 | 58 | const { 59 | body: { 60 | result, 61 | get: { _source }, 62 | }, 63 | } = await client.update({ 64 | index: 'users', 65 | type: 'doc', 66 | id: userId, 67 | body: { 68 | doc, 69 | _source: true, 70 | }, 71 | }); 72 | 73 | /* istanbul ignore if */ 74 | if (result === 'noop') { 75 | throw new Error(`Cannot update user`); 76 | } 77 | 78 | return { id: userId, ..._source }; 79 | }, 80 | }; 81 | -------------------------------------------------------------------------------- /src/graphql/mutations/__fixtures__/CreateArticle.js: -------------------------------------------------------------------------------- 1 | import { getArticleId } from '../CreateArticle'; 2 | 3 | export const fixture1Text = 'I think I am I exist'; 4 | export const fixture2Text = 'I think I exist I am'; 5 | 6 | export default { 7 | [`/articles/doc/${getArticleId(fixture2Text)}`]: { 8 | text: fixture2Text, 9 | replyRequestCount: 1, 10 | references: [{ type: 'LINE' }], 11 | }, 12 | [`/articles/doc/${getArticleId(fixture1Text)}`]: { 13 | text: fixture1Text, 14 | replyRequestCount: 1, 15 | references: [{ type: 'LINE' }], 16 | }, 17 | '/urls/doc/hyperlink1': { 18 | canonical: 'http://foo.com/article/1', 19 | title: 'FOO title', 20 | summary: 'FOO article content', 21 | url: 'http://foo.com/article/1-super-long-url-for-SEO', 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /src/graphql/mutations/__fixtures__/CreateArticleCategory.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/articles/doc/createArticleCategory1': { 3 | text: 'foofoo', 4 | articleCategories: [], 5 | references: [{ type: 'LINE' }], 6 | }, 7 | '/categories/doc/createArticleCategory2': { 8 | title: 'bar', 9 | description: 'RUMOR', 10 | }, 11 | '/articles/doc/articleHasDeletedArticleCategory': { 12 | text: 'foofoo', 13 | articleCategories: [ 14 | { 15 | appId: 'test', 16 | userId: 'test', 17 | categoryId: 'createArticleCategory2', 18 | negativeFeedbackCount: 0, 19 | positiveFeedbackCount: 0, 20 | status: 'DELETED', 21 | }, 22 | ], 23 | references: [{ type: 'LINE' }], 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/graphql/mutations/__fixtures__/CreateArticleReply.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/articles/doc/createArticleReply1': { 3 | text: 'foofoo', 4 | articleReplies: [], 5 | references: [{ type: 'LINE' }], 6 | }, 7 | '/replies/doc/createArticleReply2': { 8 | text: 'bar', 9 | type: 'RUMOR', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/graphql/mutations/__fixtures__/CreateMediaArticle.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/articles/doc/image1': { 3 | text: '', 4 | attachmentUrl: 'http://foo/image.jpeg', 5 | attachmentHash: 'ffff8000', 6 | replyRequestCount: 1, 7 | references: [{ type: 'LINE' }], 8 | }, 9 | '/airesponses/doc/ocr': { 10 | docId: 'mock_image_hash', 11 | type: 'TRANSCRIPT', 12 | text: 'OCR result of output image', 13 | status: 'SUCCESS', 14 | createdAt: '2020-01-01T00:00:00.000Z', 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/graphql/mutations/__fixtures__/CreateOrUpdateCooccurrence.js: -------------------------------------------------------------------------------- 1 | export default { 2 | [`/articles/doc/a1`]: { 3 | text: 'i am a1', 4 | replyRequestCount: 3, 5 | references: [{ type: 'LINE' }], 6 | }, 7 | [`/articles/doc/a2`]: { 8 | text: 'i am a2', 9 | replyRequestCount: 1, 10 | references: [{ type: 'LINE' }], 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /src/graphql/mutations/__fixtures__/CreateOrUpdateReplyRequest.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/articles/doc/createReplyRequestTest1': { 3 | text: 'foofoo', 4 | replyRequestCount: 1, 5 | lastRequestedAt: new Date(0).toISOString(), 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /src/graphql/mutations/__fixtures__/CreateOrUpdateReplyRequestFeedback.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/replyrequests/doc/foo': { 3 | articleId: 'foo-article', 4 | userId: 'user', 5 | appId: 'app', 6 | reason: 'foo-reason', 7 | feedbacks: [ 8 | { 9 | userId: 'fb-user-1', 10 | appId: 'app', 11 | score: 1, 12 | createdAt: '2017-01-01T00:00:00.000Z', 13 | updatedAt: '2017-01-01T00:00:00.000Z', 14 | }, 15 | ], 16 | positiveFeedbackCount: 1, 17 | negativeFeedbackCount: 0, 18 | createdAt: '2017-01-01T00:00:00.000Z', 19 | updatedAt: '2017-01-01T00:00:00.000Z', 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /src/graphql/mutations/__fixtures__/CreateReply.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/articles/doc/setReplyTest1': { 3 | text: 'foofoo', 4 | articleReplies: [], 5 | references: [{ type: 'LINE' }], 6 | }, 7 | '/urls/doc/hyperlink1': { 8 | canonical: 'http://google.com/', 9 | title: 'Google google', 10 | summary: 'Gooooooogle', 11 | url: 'http://google.com/', 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/graphql/mutations/__fixtures__/UpdateArticleCategoryStatus.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/articles/doc/others': { 3 | articleCategories: [ 4 | { 5 | categoryId: 'others', 6 | status: 'NORMAL', 7 | userId: 'not you', 8 | appId: 'not this app', 9 | }, 10 | ], 11 | }, 12 | '/articles/doc/normal': { 13 | articleCategories: [ 14 | { 15 | categoryId: 'category', 16 | userId: 'foo', 17 | appId: 'test', 18 | status: 'NORMAL', 19 | }, 20 | ], 21 | }, 22 | '/articles/doc/deleted': { 23 | articleCategories: [ 24 | { 25 | categoryId: 'category', 26 | userId: 'foo', 27 | appId: 'test', 28 | status: 'DELETED', 29 | }, 30 | ], 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /src/graphql/mutations/__fixtures__/UpdateArticleReplyStatus.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/articles/doc/others': { 3 | articleReplies: [ 4 | { 5 | replyId: 'others', 6 | status: 'NORMAL', 7 | userId: 'not you', 8 | appId: 'not this app', 9 | }, 10 | ], 11 | }, 12 | '/articles/doc/normal': { 13 | articleReplies: [ 14 | { 15 | replyId: 'reply', 16 | userId: 'foo', 17 | appId: 'test', 18 | status: 'NORMAL', 19 | updatedAt: 0, 20 | }, 21 | ], 22 | }, 23 | '/articles/doc/deleted': { 24 | articleReplies: [ 25 | { 26 | replyId: 'reply', 27 | userId: 'foo', 28 | appId: 'test', 29 | status: 'DELETED', 30 | updatedAt: 0, 31 | }, 32 | ], 33 | }, 34 | '/articles/doc/blocked': { 35 | articleReplies: [ 36 | { 37 | replyId: 'reply', 38 | userId: 'iAmBlocked', 39 | appId: 'test', 40 | status: 'DELETED', 41 | updatedAt: 0, 42 | }, 43 | ], 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /src/graphql/mutations/__fixtures__/UpdateUser.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/users/doc/error': { 3 | name: 'Bill', 4 | updatedAt: 0, 5 | }, 6 | '/users/doc/normal': { 7 | name: 'Bill', 8 | updatedAt: 0, 9 | }, 10 | '/users/doc/testUser1': { 11 | name: 'test user 1', 12 | facebookId: 'fbid123', 13 | githubId: 'githubId123', 14 | email: 'user1@example.com', 15 | }, 16 | '/users/doc/testUser2': { 17 | name: 'test user 2', 18 | githubId: 'githubId456', 19 | email: 'user2@example.com', 20 | slug: 'test-user-2', 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/graphql/mutations/__tests__/CreateCategory.js: -------------------------------------------------------------------------------- 1 | jest.mock('util/grpc'); 2 | 3 | import gql from 'util/GraphQL'; 4 | import client from 'util/client'; 5 | import MockDate from 'mockdate'; 6 | 7 | describe('CreateCategory', () => { 8 | it('creates a category', async () => { 9 | MockDate.set(1485593157011); 10 | const { data, errors } = await gql` 11 | mutation ($title: String!, $description: String!) { 12 | CreateCategory(title: $title, description: $description) { 13 | id 14 | } 15 | } 16 | `( 17 | { 18 | title: '保健秘訣、食品安全', 19 | description: 20 | '各種宣稱會抗癌、高血壓、糖尿病等等的偏方秘笈、十大恐怖美食、不要吃海帶、美粒果', 21 | }, 22 | { userId: 'test', appId: 'test' } 23 | ); 24 | 25 | expect(errors).toBeUndefined(); 26 | 27 | const categoryId = data.CreateCategory.id; 28 | const { body: category } = await client.get({ 29 | index: 'categories', 30 | type: 'doc', 31 | id: categoryId, 32 | }); 33 | 34 | expect(category._source).toMatchSnapshot('created category'); 35 | 36 | // Cleanup 37 | await client.delete({ index: 'categories', type: 'doc', id: categoryId }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/graphql/mutations/__tests__/CreateOrUpdateCooccurrence.js: -------------------------------------------------------------------------------- 1 | import gql from 'util/GraphQL'; 2 | import { loadFixtures, unloadFixtures } from 'util/fixtures'; 3 | import client from 'util/client'; 4 | import { getCooccurrenceId } from '../CreateOrUpdateCooccurrence'; 5 | import MockDate from 'mockdate'; 6 | import fixtures from '../__fixtures__/CreateOrUpdateCooccurrence'; 7 | 8 | describe('CreateOrUpdateCooccurrence', () => { 9 | beforeAll(() => loadFixtures(fixtures)); 10 | 11 | it('creates cooccurrence', async () => { 12 | MockDate.set(1485593157011); 13 | const userId = 'test'; 14 | const appId = 'test'; 15 | const articleIds = ['a1', 'a2']; 16 | 17 | const { data, errors } = await gql` 18 | mutation ($articleIds: [String!]) { 19 | CreateOrUpdateCooccurrence(articleIds: $articleIds) { 20 | id 21 | articles { 22 | text 23 | } 24 | articleIds 25 | userId 26 | appId 27 | } 28 | } 29 | `( 30 | { 31 | articleIds, 32 | }, 33 | { 34 | user: { id: userId, appId }, 35 | } 36 | ); 37 | MockDate.reset(); 38 | 39 | expect(errors).toBeUndefined(); 40 | expect(data.CreateOrUpdateCooccurrence).toMatchSnapshot(); 41 | 42 | const id = getCooccurrenceId({ 43 | articleIds, 44 | userId, 45 | appId, 46 | }); 47 | 48 | const { body: conn } = await client.get({ 49 | index: 'cooccurrences', 50 | type: 'doc', 51 | id, 52 | }); 53 | expect(conn._source).toMatchInlineSnapshot(` 54 | Object { 55 | "appId": "test", 56 | "articleIds": Array [ 57 | "a1", 58 | "a2", 59 | ], 60 | "createdAt": "2017-01-28T08:45:57.011Z", 61 | "updatedAt": "2017-01-28T08:45:57.011Z", 62 | "userId": "test", 63 | } 64 | `); 65 | 66 | // Cleanup 67 | await client.delete({ 68 | index: 'cooccurrences', 69 | type: 'doc', 70 | id, 71 | }); 72 | }); 73 | 74 | afterAll(() => unloadFixtures(fixtures)); 75 | }); 76 | -------------------------------------------------------------------------------- /src/graphql/mutations/__tests__/CreateOrUpdateReplyRequestFeedback.js: -------------------------------------------------------------------------------- 1 | import gql from 'util/GraphQL'; 2 | import { loadFixtures, unloadFixtures, resetFrom } from 'util/fixtures'; 3 | import client from 'util/client'; 4 | import MockDate from 'mockdate'; 5 | import fixtures from '../__fixtures__/CreateOrUpdateReplyRequestFeedback'; 6 | 7 | describe('CreateOrUpdateReplyRequestFeedback', () => { 8 | beforeAll(() => loadFixtures(fixtures)); 9 | 10 | it('creates new feedback', async () => { 11 | MockDate.set(1485593157011); 12 | const userId = 'fb-user-2'; 13 | const appId = 'app'; 14 | const replyRequestId = 'foo'; 15 | 16 | const { data, errors } = await gql` 17 | mutation ($replyRequestId: String!) { 18 | CreateOrUpdateReplyRequestFeedback( 19 | replyRequestId: $replyRequestId 20 | vote: UPVOTE 21 | ) { 22 | id 23 | feedbackCount 24 | positiveFeedbackCount 25 | negativeFeedbackCount 26 | ownVote 27 | } 28 | } 29 | `( 30 | { 31 | replyRequestId, 32 | }, 33 | { userId, appId } 34 | ); 35 | MockDate.reset(); 36 | 37 | expect(errors).toBeUndefined(); 38 | expect(data).toMatchSnapshot(); 39 | 40 | const { body: replyrequest } = await client.get({ 41 | index: 'replyrequests', 42 | type: 'doc', 43 | id: replyRequestId, 44 | }); 45 | expect(replyrequest._source).toMatchSnapshot(); 46 | 47 | // Cleanup 48 | await resetFrom(fixtures, '/replyrequests/doc/foo'); 49 | }); 50 | 51 | it('updates existing feedback', async () => { 52 | MockDate.set(1485593157011); 53 | const userId = 'fb-user-1'; 54 | const appId = 'app'; 55 | const replyRequestId = 'foo'; 56 | 57 | const { data, errors } = await gql` 58 | mutation ($replyRequestId: String!) { 59 | CreateOrUpdateReplyRequestFeedback( 60 | replyRequestId: $replyRequestId 61 | vote: DOWNVOTE 62 | ) { 63 | id 64 | feedbackCount 65 | positiveFeedbackCount 66 | negativeFeedbackCount 67 | ownVote 68 | } 69 | } 70 | `( 71 | { 72 | replyRequestId, 73 | }, 74 | { userId, appId } 75 | ); 76 | MockDate.reset(); 77 | 78 | expect(errors).toBeUndefined(); 79 | expect(data).toMatchSnapshot(); 80 | 81 | const { body: replyrequest } = await client.get({ 82 | index: 'replyrequests', 83 | type: 'doc', 84 | id: replyRequestId, 85 | }); 86 | expect(replyrequest._source).toMatchSnapshot(); 87 | 88 | // Cleanup 89 | await resetFrom(fixtures, '/replyrequests/doc/foo'); 90 | }); 91 | 92 | afterAll(() => unloadFixtures(fixtures)); 93 | }); 94 | -------------------------------------------------------------------------------- /src/graphql/mutations/__tests__/UpdateArticleCategoryStatus.js: -------------------------------------------------------------------------------- 1 | import gql from 'util/GraphQL'; 2 | import { loadFixtures, unloadFixtures, resetFrom } from 'util/fixtures'; 3 | import client from 'util/client'; 4 | import MockDate from 'mockdate'; 5 | import fixtures from '../__fixtures__/UpdateArticleCategoryStatus'; 6 | 7 | describe('UpdateArticleCategoryStatus', () => { 8 | beforeEach(() => { 9 | MockDate.set(1485593157011); 10 | return loadFixtures(fixtures); 11 | }); 12 | 13 | it("should not allow users to delete other's article categories", async () => { 14 | const userId = 'foo'; 15 | const appId = 'test'; 16 | 17 | const { errors } = await gql` 18 | mutation { 19 | UpdateArticleCategoryStatus( 20 | articleId: "others" 21 | categoryId: "others" 22 | status: DELETED 23 | ) { 24 | status 25 | updatedAt 26 | } 27 | } 28 | `({}, { userId, appId }); 29 | 30 | expect(errors).toMatchSnapshot(); 31 | }); 32 | 33 | it('should set article category fields correctly', async () => { 34 | const userId = 'foo'; 35 | const appId = 'test'; 36 | 37 | const { data, errors } = await gql` 38 | mutation { 39 | normal: UpdateArticleCategoryStatus( 40 | articleId: "normal" 41 | categoryId: "category" 42 | status: DELETED 43 | ) { 44 | articleId 45 | categoryId 46 | status 47 | updatedAt 48 | } 49 | deleted: UpdateArticleCategoryStatus( 50 | articleId: "deleted" 51 | categoryId: "category" 52 | status: NORMAL 53 | ) { 54 | articleId 55 | categoryId 56 | status 57 | updatedAt 58 | } 59 | } 60 | `({}, { userId, appId }); 61 | 62 | expect(errors).toBeUndefined(); 63 | expect(data).toMatchSnapshot(); 64 | 65 | const { 66 | body: { _source: normal }, 67 | } = await client.get({ 68 | index: 'articles', 69 | type: 'doc', 70 | id: 'normal', 71 | }); 72 | expect(normal.articleCategories).toMatchSnapshot(); 73 | expect(normal.normalArticleCategoryCount).toBe(0); 74 | 75 | const { 76 | body: { _source: deleted }, 77 | } = await client.get({ 78 | index: 'articles', 79 | type: 'doc', 80 | id: 'deleted', 81 | }); 82 | expect(deleted.articleCategories).toMatchSnapshot(); 83 | expect(deleted.normalArticleCategoryCount).toBe(1); 84 | 85 | // Cleanup 86 | await resetFrom(fixtures, '/articles/doc/normal'); 87 | await resetFrom(fixtures, '/articles/doc/deleted'); 88 | }); 89 | 90 | afterEach(() => { 91 | MockDate.reset(); 92 | return unloadFixtures(fixtures); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/graphql/mutations/__tests__/__snapshots__/CreateArticle.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`creation avoids creating duplicated articles and adds replyRequests automatically 1`] = ` 4 | Object { 5 | "lastRequestedAt": "2017-01-28T08:45:57.011Z", 6 | "references": Array [ 7 | Object { 8 | "type": "LINE", 9 | }, 10 | Object { 11 | "appId": "foo", 12 | "createdAt": "2017-01-28T08:45:57.011Z", 13 | "type": "LINE", 14 | "userId": "test", 15 | }, 16 | ], 17 | "replyRequestCount": 2, 18 | "text": "I think I am I exist", 19 | "updatedAt": "2017-01-28T08:45:57.011Z", 20 | } 21 | `; 22 | 23 | exports[`creation creates articles and a reply request and fills in URLs 1`] = ` 24 | Object { 25 | "appId": "foo", 26 | "articleCategories": Array [], 27 | "articleReplies": Array [], 28 | "articleType": "TEXT", 29 | "attachmentHash": "", 30 | "attachmentUrl": "", 31 | "contributors": Array [], 32 | "createdAt": "2017-01-28T08:45:57.011Z", 33 | "hyperlinks": Array [ 34 | Object { 35 | "normalizedUrl": "http://foo.com/article/1", 36 | "summary": "FOO article content", 37 | "title": "FOO title", 38 | "url": "http://foo.com/article/1", 39 | }, 40 | ], 41 | "lastRequestedAt": "2017-01-28T08:45:57.011Z", 42 | "normalArticleCategoryCount": 0, 43 | "normalArticleReplyCount": 0, 44 | "references": Array [ 45 | Object { 46 | "appId": "foo", 47 | "createdAt": "2017-01-28T08:45:57.011Z", 48 | "type": "LINE", 49 | "userId": "test", 50 | }, 51 | ], 52 | "replyRequestCount": 1, 53 | "status": "NORMAL", 54 | "text": "FOO FOO http://foo.com/article/1", 55 | "updatedAt": "2017-01-28T08:45:57.011Z", 56 | "userId": "test", 57 | } 58 | `; 59 | 60 | exports[`fails with an invalid app id 1`] = ` 61 | Array [ 62 | [GraphQLError: userId is set, but x-app-id or x-app-secret is not set accordingly.], 63 | ] 64 | `; 65 | 66 | exports[`fails with an invalid user id 1`] = ` 67 | Array [ 68 | [GraphQLError: userId is not set via query string.], 69 | ] 70 | `; 71 | -------------------------------------------------------------------------------- /src/graphql/mutations/__tests__/__snapshots__/CreateArticleCategory.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CreateArticleCategory connects article and category together 2`] = ` 4 | Object { 5 | "articleCategories": Array [ 6 | Object { 7 | "appId": "test", 8 | "categoryId": "createArticleCategory2", 9 | "createdAt": "2017-01-28T08:45:57.011Z", 10 | "negativeFeedbackCount": 0, 11 | "positiveFeedbackCount": 0, 12 | "status": "NORMAL", 13 | "updatedAt": "2017-01-28T08:45:57.011Z", 14 | "userId": "test", 15 | }, 16 | ], 17 | "normalArticleCategoryCount": 1, 18 | "references": Array [ 19 | Object { 20 | "type": "LINE", 21 | }, 22 | ], 23 | "text": "foofoo", 24 | } 25 | `; 26 | 27 | exports[`CreateArticleCategory connects article and category together by AI model 2`] = ` 28 | Object { 29 | "articleCategories": Array [ 30 | Object { 31 | "aiConfidence": 0.99, 32 | "aiModel": "aiModel1", 33 | "appId": "test", 34 | "categoryId": "createArticleCategory2", 35 | "createdAt": "2017-01-28T08:45:57.011Z", 36 | "negativeFeedbackCount": 0, 37 | "positiveFeedbackCount": 0, 38 | "status": "NORMAL", 39 | "updatedAt": "2017-01-28T08:45:57.011Z", 40 | "userId": "test", 41 | }, 42 | ], 43 | "normalArticleCategoryCount": 1, 44 | "references": Array [ 45 | Object { 46 | "type": "LINE", 47 | }, 48 | ], 49 | "text": "foofoo", 50 | } 51 | `; 52 | 53 | exports[`CreateArticleCategory update userId and appId when connecting with DELETED ArticleCategory 2`] = ` 54 | Object { 55 | "articleCategories": Array [ 56 | Object { 57 | "appId": "test2", 58 | "categoryId": "createArticleCategory2", 59 | "negativeFeedbackCount": 0, 60 | "positiveFeedbackCount": 0, 61 | "status": "NORMAL", 62 | "updatedAt": "2017-01-28T08:45:57.011Z", 63 | "userId": "test2", 64 | }, 65 | ], 66 | "references": Array [ 67 | Object { 68 | "type": "LINE", 69 | }, 70 | ], 71 | "text": "foofoo", 72 | } 73 | `; 74 | -------------------------------------------------------------------------------- /src/graphql/mutations/__tests__/__snapshots__/CreateArticleReply.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CreateArticleReply connects article and reply together 1`] = ` 4 | Array [ 5 | Object { 6 | "appId": "test", 7 | "article": Object { 8 | "id": "createArticleReply1", 9 | }, 10 | "negativeFeedbackCount": 0, 11 | "positiveFeedbackCount": 0, 12 | "reply": Object { 13 | "id": "createArticleReply2", 14 | }, 15 | "status": "NORMAL", 16 | "userId": "test", 17 | }, 18 | ] 19 | `; 20 | 21 | exports[`CreateArticleReply connects article and reply together 2`] = ` 22 | Object { 23 | "articleReplies": Array [ 24 | Object { 25 | "appId": "test", 26 | "createdAt": "2017-01-28T08:45:57.011Z", 27 | "negativeFeedbackCount": 0, 28 | "positiveFeedbackCount": 0, 29 | "replyId": "createArticleReply2", 30 | "replyType": "RUMOR", 31 | "status": "NORMAL", 32 | "updatedAt": "2017-01-28T08:45:57.011Z", 33 | "userId": "test", 34 | }, 35 | ], 36 | "normalArticleReplyCount": 1, 37 | "references": Array [ 38 | Object { 39 | "type": "LINE", 40 | }, 41 | ], 42 | "text": "foofoo", 43 | } 44 | `; 45 | 46 | exports[`CreateArticleReply inserts blocked article and reply without updating normal count 1`] = ` 47 | Object { 48 | "articleReplies": Array [ 49 | Object { 50 | "appId": "test", 51 | "createdAt": "2017-01-28T08:45:57.011Z", 52 | "negativeFeedbackCount": 0, 53 | "positiveFeedbackCount": 0, 54 | "replyId": "createArticleReply2", 55 | "replyType": "RUMOR", 56 | "status": "BLOCKED", 57 | "updatedAt": "2017-01-28T08:45:57.011Z", 58 | "userId": "iamBlocked", 59 | }, 60 | ], 61 | "normalArticleReplyCount": 0, 62 | "references": Array [ 63 | Object { 64 | "type": "LINE", 65 | }, 66 | ], 67 | "text": "foofoo", 68 | } 69 | `; 70 | -------------------------------------------------------------------------------- /src/graphql/mutations/__tests__/__snapshots__/CreateCategory.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CreateCategory creates a category: created category 1`] = ` 4 | Object { 5 | "appId": "test", 6 | "createdAt": "2017-01-28T08:45:57.011Z", 7 | "description": "各種宣稱會抗癌、高血壓、糖尿病等等的偏方秘笈、十大恐怖美食、不要吃海帶、美粒果", 8 | "title": "保健秘訣、食品安全", 9 | "updatedAt": "2017-01-28T08:45:57.011Z", 10 | "userId": "test", 11 | } 12 | `; 13 | -------------------------------------------------------------------------------- /src/graphql/mutations/__tests__/__snapshots__/CreateOrUpdateCooccurrence.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CreateOrUpdateCooccurrence creates cooccurrence 1`] = ` 4 | Object { 5 | "appId": "test", 6 | "articleIds": Array [ 7 | "a1", 8 | "a2", 9 | ], 10 | "articles": Array [ 11 | Object { 12 | "text": "i am a1", 13 | }, 14 | Object { 15 | "text": "i am a2", 16 | }, 17 | ], 18 | "id": "test_test_a1_a2", 19 | "userId": "test", 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /src/graphql/mutations/__tests__/__snapshots__/CreateOrUpdateReplyRequest.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CreateOrUpdateReplyRequest attaches a reply request to an article 1`] = ` 4 | Object { 5 | "CreateOrUpdateReplyRequest": Object { 6 | "id": "createReplyRequestTest1", 7 | "replyRequestCount": 2, 8 | "replyRequests": Array [ 9 | Object { 10 | "reason": "気になります", 11 | "userId": "test", 12 | }, 13 | ], 14 | "requestedForReply": true, 15 | }, 16 | } 17 | `; 18 | 19 | exports[`CreateOrUpdateReplyRequest can update reason of a previously submitted reply request 1`] = ` 20 | Object { 21 | "CreateOrUpdateReplyRequest": Object { 22 | "id": "createReplyRequestTest1", 23 | "replyRequestCount": 2, 24 | "replyRequests": Array [ 25 | Object { 26 | "reason": "New reason", 27 | "userId": "test", 28 | }, 29 | ], 30 | "requestedForReply": true, 31 | }, 32 | } 33 | `; 34 | 35 | exports[`CreateOrUpdateReplyRequest inserts blocked reply request without updating article count 1`] = ` 36 | Object { 37 | "CreateOrUpdateReplyRequest": Object { 38 | "id": "createReplyRequestTest1", 39 | "replyRequestCount": 1, 40 | "replyRequests": Array [ 41 | Object { 42 | "reason": "Some unwelcomed ads here", 43 | "userId": "iAmBlocked", 44 | }, 45 | ], 46 | "requestedForReply": true, 47 | }, 48 | } 49 | `; 50 | -------------------------------------------------------------------------------- /src/graphql/mutations/__tests__/__snapshots__/CreateOrUpdateReplyRequestFeedback.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CreateOrUpdateReplyRequestFeedback creates new feedback 1`] = ` 4 | Object { 5 | "CreateOrUpdateReplyRequestFeedback": Object { 6 | "feedbackCount": 2, 7 | "id": "foo", 8 | "negativeFeedbackCount": 0, 9 | "ownVote": "UPVOTE", 10 | "positiveFeedbackCount": 2, 11 | }, 12 | } 13 | `; 14 | 15 | exports[`CreateOrUpdateReplyRequestFeedback creates new feedback 2`] = ` 16 | Object { 17 | "appId": "app", 18 | "articleId": "foo-article", 19 | "createdAt": "2017-01-01T00:00:00.000Z", 20 | "feedbacks": Array [ 21 | Object { 22 | "appId": "app", 23 | "createdAt": "2017-01-01T00:00:00.000Z", 24 | "score": 1, 25 | "updatedAt": "2017-01-01T00:00:00.000Z", 26 | "userId": "fb-user-1", 27 | }, 28 | Object { 29 | "appId": "app", 30 | "createdAt": "2017-01-28T08:45:57.011Z", 31 | "score": 1, 32 | "updatedAt": "2017-01-28T08:45:57.011Z", 33 | "userId": "fb-user-2", 34 | }, 35 | ], 36 | "negativeFeedbackCount": 0, 37 | "positiveFeedbackCount": 2, 38 | "reason": "foo-reason", 39 | "updatedAt": "2017-01-01T00:00:00.000Z", 40 | "userId": "user", 41 | } 42 | `; 43 | 44 | exports[`CreateOrUpdateReplyRequestFeedback updates existing feedback 1`] = ` 45 | Object { 46 | "CreateOrUpdateReplyRequestFeedback": Object { 47 | "feedbackCount": 1, 48 | "id": "foo", 49 | "negativeFeedbackCount": 1, 50 | "ownVote": "DOWNVOTE", 51 | "positiveFeedbackCount": 0, 52 | }, 53 | } 54 | `; 55 | 56 | exports[`CreateOrUpdateReplyRequestFeedback updates existing feedback 2`] = ` 57 | Object { 58 | "appId": "app", 59 | "articleId": "foo-article", 60 | "createdAt": "2017-01-01T00:00:00.000Z", 61 | "feedbacks": Array [ 62 | Object { 63 | "appId": "app", 64 | "createdAt": "2017-01-01T00:00:00.000Z", 65 | "score": -1, 66 | "updatedAt": "2017-01-28T08:45:57.011Z", 67 | "userId": "fb-user-1", 68 | }, 69 | ], 70 | "negativeFeedbackCount": 1, 71 | "positiveFeedbackCount": 0, 72 | "reason": "foo-reason", 73 | "updatedAt": "2017-01-01T00:00:00.000Z", 74 | "userId": "user", 75 | } 76 | `; 77 | -------------------------------------------------------------------------------- /src/graphql/mutations/__tests__/__snapshots__/CreateReply.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CreateReply creates replies and associates itself with specified article: hyperlinks after fetch 1`] = ` 4 | Array [ 5 | Object { 6 | "normalizedUrl": "http://shouldscrap.com/", 7 | "title": "scrapped title", 8 | "url": "http://shouldscrap.com/", 9 | }, 10 | ] 11 | `; 12 | 13 | exports[`CreateReply creates replies and associates itself with specified article: reply without hyperlinks 1`] = ` 14 | Object { 15 | "appId": "test", 16 | "createdAt": "2017-01-28T08:45:57.011Z", 17 | "reference": "http://shouldscrap.com/", 18 | "text": "FOO FOO", 19 | "type": "RUMOR", 20 | "userId": "test", 21 | } 22 | `; 23 | 24 | exports[`CreateReply should support waitForHyperlinks 1`] = ` 25 | Object { 26 | "appId": "test", 27 | "createdAt": "2017-01-28T08:45:57.011Z", 28 | "hyperlinks": Array [ 29 | Object { 30 | "normalizedUrl": "http://google.com/", 31 | "summary": "Gooooooogle", 32 | "title": "Google google", 33 | "url": "http://google.com", 34 | }, 35 | ], 36 | "reference": "http://google.com", 37 | "text": "Bar Bar", 38 | "type": "RUMOR", 39 | "userId": "test", 40 | } 41 | `; 42 | 43 | exports[`CreateReply should throw error since a reference is required for type !== NOT_ARTICLE 1`] = ` 44 | Array [ 45 | [GraphQLError: reference is required for type !== NOT_ARTICLE], 46 | ] 47 | `; 48 | -------------------------------------------------------------------------------- /src/graphql/mutations/__tests__/__snapshots__/UpdateArticleCategoryStatus.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`UpdateArticleCategoryStatus should not allow users to delete other's article categories 1`] = ` 4 | Array [ 5 | [GraphQLError: Cannot change status for articleCategory(articleId=others, categoryId=others)], 6 | ] 7 | `; 8 | 9 | exports[`UpdateArticleCategoryStatus should set article category fields correctly 1`] = ` 10 | Object { 11 | "deleted": Array [ 12 | Object { 13 | "articleId": "deleted", 14 | "categoryId": "category", 15 | "status": "NORMAL", 16 | "updatedAt": "2017-01-28T08:45:57.011Z", 17 | }, 18 | ], 19 | "normal": Array [ 20 | Object { 21 | "articleId": "normal", 22 | "categoryId": "category", 23 | "status": "DELETED", 24 | "updatedAt": "2017-01-28T08:45:57.011Z", 25 | }, 26 | ], 27 | } 28 | `; 29 | 30 | exports[`UpdateArticleCategoryStatus should set article category fields correctly 2`] = ` 31 | Array [ 32 | Object { 33 | "appId": "test", 34 | "categoryId": "category", 35 | "status": "DELETED", 36 | "updatedAt": "2017-01-28T08:45:57.011Z", 37 | "userId": "foo", 38 | }, 39 | ] 40 | `; 41 | 42 | exports[`UpdateArticleCategoryStatus should set article category fields correctly 3`] = ` 43 | Array [ 44 | Object { 45 | "appId": "test", 46 | "categoryId": "category", 47 | "status": "NORMAL", 48 | "updatedAt": "2017-01-28T08:45:57.011Z", 49 | "userId": "foo", 50 | }, 51 | ] 52 | `; 53 | -------------------------------------------------------------------------------- /src/graphql/mutations/__tests__/__snapshots__/UpdateArticleReplyStatus.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`UpdateArticleReplyStatus restore delete state to blocked state for blocked users 1`] = ` 4 | Object { 5 | "UpdateArticleReplyStatus": Array [ 6 | Object { 7 | "articleId": "blocked", 8 | "replyId": "reply", 9 | "status": "BLOCKED", 10 | "updatedAt": "2017-01-28T08:45:57.011Z", 11 | }, 12 | ], 13 | } 14 | `; 15 | 16 | exports[`UpdateArticleReplyStatus should not allow users to delete other's article replies 1`] = ` 17 | Array [ 18 | [GraphQLError: Cannot change status for articleReply(articleId=others, replyId=others)], 19 | ] 20 | `; 21 | 22 | exports[`UpdateArticleReplyStatus should set article reply fields correctly 1`] = ` 23 | Object { 24 | "deleted": Array [ 25 | Object { 26 | "articleId": "deleted", 27 | "replyId": "reply", 28 | "status": "NORMAL", 29 | "updatedAt": "2017-01-28T08:45:57.011Z", 30 | }, 31 | ], 32 | "normal": Array [ 33 | Object { 34 | "articleId": "normal", 35 | "replyId": "reply", 36 | "status": "DELETED", 37 | "updatedAt": "2017-01-28T08:45:57.011Z", 38 | }, 39 | ], 40 | } 41 | `; 42 | -------------------------------------------------------------------------------- /src/graphql/queries/GetArticle.js: -------------------------------------------------------------------------------- 1 | import { GraphQLString } from 'graphql'; 2 | 3 | import Article from 'graphql/models/Article'; 4 | 5 | export default { 6 | type: Article, 7 | args: { 8 | id: { type: GraphQLString }, 9 | }, 10 | resolve: async (rootValue, { id }, { loaders }) => 11 | loaders.docLoader.load({ index: 'articles', id }), 12 | }; 13 | -------------------------------------------------------------------------------- /src/graphql/queries/GetBadge.js: -------------------------------------------------------------------------------- 1 | import { GraphQLString } from 'graphql'; 2 | import Badge from '../models/Badge'; 3 | 4 | export default { 5 | type: Badge, 6 | args: { 7 | id: { type: GraphQLString }, 8 | }, 9 | resolve: async (rootValue, { id }, { loaders }) => { 10 | const result = await loaders.docLoader.load({ 11 | index: 'badges', 12 | id, 13 | }); 14 | return result; 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/graphql/queries/GetCategory.js: -------------------------------------------------------------------------------- 1 | import { GraphQLString } from 'graphql'; 2 | 3 | import Category from 'graphql/models/Category'; 4 | 5 | export default { 6 | type: Category, 7 | args: { 8 | id: { type: GraphQLString }, 9 | }, 10 | resolve: async (rootValue, { id }, { loaders }) => 11 | loaders.docLoader.load({ index: 'categories', id }), 12 | }; 13 | -------------------------------------------------------------------------------- /src/graphql/queries/GetReply.js: -------------------------------------------------------------------------------- 1 | import { GraphQLString } from 'graphql'; 2 | 3 | import Reply from 'graphql/models/Reply'; 4 | 5 | export default { 6 | type: Reply, 7 | args: { 8 | id: { type: GraphQLString }, 9 | }, 10 | resolve: async (rootValue, { id }, { loaders }) => 11 | loaders.docLoader.load({ index: 'replies', id }), 12 | }; 13 | -------------------------------------------------------------------------------- /src/graphql/queries/GetUser.js: -------------------------------------------------------------------------------- 1 | import { GraphQLString } from 'graphql'; 2 | 3 | import User from 'graphql/models/User'; 4 | 5 | export default { 6 | type: User, 7 | description: ` 8 | Gets specified user. If id is not given, returns the currently logged-in user. 9 | Note that some fields like email is not visible to other users. 10 | `, 11 | args: { 12 | id: { type: GraphQLString }, 13 | slug: { type: GraphQLString }, 14 | }, 15 | resolve(rootValue, { id, slug }, { user = null, loaders }) { 16 | if (!id && !slug) return user; 17 | if (id && slug) throw new Error('cannot search by both id and slug'); 18 | 19 | if (id) return loaders.docLoader.load({ index: 'users', id }); 20 | else return loaders.userLoader.load({ slug }); 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/graphql/queries/GetYdoc.js: -------------------------------------------------------------------------------- 1 | import { GraphQLString, GraphQLNonNull } from 'graphql'; 2 | 3 | import Ydoc from 'graphql/models/Ydoc'; 4 | 5 | export default { 6 | type: Ydoc, 7 | args: { 8 | id: { type: new GraphQLNonNull(GraphQLString) }, 9 | }, 10 | resolve: async (rootValue, { id }, { loaders }) => 11 | loaders.docLoader.load({ index: 'ydocs', id }), 12 | }; 13 | -------------------------------------------------------------------------------- /src/graphql/queries/ListAnalytics.js: -------------------------------------------------------------------------------- 1 | import { GraphQLNonNull, GraphQLID } from 'graphql'; 2 | import { 3 | createFilterType, 4 | createSortType, 5 | getSortArgs, 6 | pagingArgs, 7 | timeRangeInput, 8 | getRangeFieldParamFromArithmeticExpression, 9 | } from 'graphql/util'; 10 | 11 | import { AnalyticsConnection } from 'graphql/models/Analytics'; 12 | import AnalyticsDocTypeEnum from 'graphql/models/AnalyticsDocTypeEnum'; 13 | 14 | export default { 15 | args: { 16 | filter: { 17 | type: createFilterType('ListAnalyticsFilter', { 18 | date: { 19 | type: timeRangeInput, 20 | description: 21 | 'List only the activities between the specific time range.', 22 | }, 23 | type: { type: AnalyticsDocTypeEnum }, 24 | docId: { type: GraphQLID }, 25 | docUserId: { type: GraphQLID }, 26 | docAppId: { type: GraphQLID }, 27 | }), 28 | }, 29 | orderBy: { 30 | type: createSortType('ListAnalyticsOrderBy', ['date']), 31 | }, 32 | ...pagingArgs, 33 | }, 34 | type: new GraphQLNonNull(AnalyticsConnection), 35 | async resolve(rootValue, { filter = {}, orderBy = [], ...otherParams }) { 36 | const body = { 37 | sort: getSortArgs(orderBy), 38 | query: { 39 | bool: { 40 | filter: [], 41 | }, 42 | }, 43 | }; 44 | 45 | if (filter.date) { 46 | body.query.bool.filter.push({ 47 | range: { 48 | date: getRangeFieldParamFromArithmeticExpression(filter.date), 49 | }, 50 | }); 51 | } 52 | 53 | ['type', 'docId', 'docUserId', 'docAppId'].forEach((field) => { 54 | if (!filter[field]) return; 55 | body.query.bool.filter.push({ term: { [`${field}`]: filter[field] } }); 56 | }); 57 | 58 | return { 59 | index: 'analytics', 60 | type: 'doc', 61 | body, 62 | ...otherParams, 63 | }; 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /src/graphql/queries/ListBlockedUsers.js: -------------------------------------------------------------------------------- 1 | import { GraphQLNonNull } from 'graphql'; 2 | import { 3 | createFilterType, 4 | createSortType, 5 | getSortArgs, 6 | pagingArgs, 7 | timeRangeInput, 8 | getRangeFieldParamFromArithmeticExpression, 9 | } from 'graphql/util'; 10 | 11 | import { UserConnection } from 'graphql/models/User'; 12 | 13 | export default { 14 | args: { 15 | filter: { 16 | type: createFilterType('ListBlockedUsersFilter', { 17 | createdAt: { 18 | type: timeRangeInput, 19 | description: 20 | 'List only the blocked users that were registered between the specific time range.', 21 | }, 22 | }), 23 | }, 24 | orderBy: { 25 | type: createSortType('ListBlockedUsersOrderBy', ['createdAt']), 26 | }, 27 | ...pagingArgs, 28 | }, 29 | type: new GraphQLNonNull(UserConnection), 30 | async resolve(rootValue, { filter = {}, orderBy = [], ...otherParams }) { 31 | const body = { 32 | sort: getSortArgs(orderBy), 33 | query: { 34 | bool: { 35 | must: [ 36 | { 37 | exists: { 38 | field: 'blockedReason', 39 | }, 40 | }, 41 | ], 42 | filter: [], 43 | }, 44 | }, 45 | }; 46 | 47 | if (filter.createdAt) { 48 | body.query.bool.filter.push({ 49 | range: { 50 | createdAt: getRangeFieldParamFromArithmeticExpression( 51 | filter.createdAt 52 | ), 53 | }, 54 | }); 55 | } 56 | 57 | return { 58 | index: 'users', 59 | type: 'doc', 60 | body, 61 | ...otherParams, 62 | }; 63 | }, 64 | }; 65 | -------------------------------------------------------------------------------- /src/graphql/queries/ListCategories.js: -------------------------------------------------------------------------------- 1 | import { 2 | createSortType, 3 | createConnectionType, 4 | pagingArgs, 5 | getSortArgs, 6 | } from 'graphql/util'; 7 | import Category from 'graphql/models/Category'; 8 | 9 | export default { 10 | args: { 11 | orderBy: { 12 | type: createSortType('ListCategoryOrderBy', ['createdAt']), 13 | }, 14 | ...pagingArgs, 15 | }, 16 | async resolve(rootValue, { orderBy = [], ...otherParams }) { 17 | const body = { 18 | sort: getSortArgs(orderBy), 19 | }; 20 | 21 | // should return search context for resolveEdges & resolvePageInfo 22 | return { 23 | index: 'categories', 24 | type: 'doc', 25 | body, 26 | ...otherParams, 27 | }; 28 | }, 29 | type: createConnectionType('ListCategoryConnection', Category), 30 | }; 31 | -------------------------------------------------------------------------------- /src/graphql/queries/ListCooccurrences.js: -------------------------------------------------------------------------------- 1 | import { 2 | createFilterType, 3 | createSortType, 4 | createConnectionType, 5 | timeRangeInput, 6 | pagingArgs, 7 | getSortArgs, 8 | getRangeFieldParamFromArithmeticExpression, 9 | } from 'graphql/util'; 10 | import Cooccurrence from 'graphql/models/Cooccurrence'; 11 | 12 | export default { 13 | args: { 14 | filter: { 15 | type: createFilterType('ListCooccurrenceFilter', { 16 | updatedAt: { 17 | type: timeRangeInput, 18 | description: 19 | 'List only the cooccurrence that were last updated within the specific time range.', 20 | }, 21 | }), 22 | }, 23 | orderBy: { 24 | type: createSortType('ListCooccurrenceOrderBy', [ 25 | 'createdAt', 26 | 'updatedAt', 27 | ]), 28 | }, 29 | ...pagingArgs, 30 | }, 31 | async resolve(rootValue, { orderBy = [], filter = {}, ...otherParams }) { 32 | const body = { 33 | sort: getSortArgs(orderBy), 34 | }; 35 | 36 | const filterQueries = []; 37 | 38 | if (filter.updatedAt) { 39 | filterQueries.push({ 40 | range: { 41 | updatedAt: getRangeFieldParamFromArithmeticExpression( 42 | filter.updatedAt 43 | ), 44 | }, 45 | }); 46 | } 47 | 48 | body.query = { 49 | bool: { 50 | filter: filterQueries, 51 | }, 52 | }; 53 | 54 | // should return search context for resolveEdges & resolvePageInfo 55 | return { 56 | index: 'cooccurrences', 57 | type: 'doc', 58 | body, 59 | ...otherParams, 60 | }; 61 | }, 62 | type: createConnectionType('ListCooccurrenceConnection', Cooccurrence), 63 | }; 64 | -------------------------------------------------------------------------------- /src/graphql/queries/ListReplyRequests.js: -------------------------------------------------------------------------------- 1 | import { GraphQLList, GraphQLNonNull, GraphQLString } from 'graphql'; 2 | import { 3 | createFilterType, 4 | createSortType, 5 | createConnectionType, 6 | createCommonListFilter, 7 | attachCommonListFilter, 8 | pagingArgs, 9 | getSortArgs, 10 | DEFAULT_REPLY_REQUEST_STATUSES, 11 | } from 'graphql/util'; 12 | import ReplyRequest from 'graphql/models/ReplyRequest'; 13 | import ReplyRequestStatusEnum from 'graphql/models/ReplyRequestStatusEnum'; 14 | 15 | export default { 16 | args: { 17 | filter: { 18 | type: createFilterType('ListReplyRequestFilter', { 19 | ...createCommonListFilter('reply requests'), 20 | articleId: { 21 | type: GraphQLString, 22 | }, 23 | statuses: { 24 | type: new GraphQLList(new GraphQLNonNull(ReplyRequestStatusEnum)), 25 | defaultValue: DEFAULT_REPLY_REQUEST_STATUSES, 26 | description: 'List only reply requests with specified statuses', 27 | }, 28 | }), 29 | }, 30 | orderBy: { 31 | type: createSortType('ListReplyRequestOrderBy', ['createdAt', 'vote']), 32 | }, 33 | ...pagingArgs, 34 | }, 35 | async resolve( 36 | rootValue, 37 | { orderBy = [], filter = {}, ...otherParams }, 38 | { userId, appId } 39 | ) { 40 | const filterQueries = [ 41 | { 42 | terms: { 43 | status: filter.statuses || DEFAULT_REPLY_REQUEST_STATUSES, 44 | }, 45 | }, 46 | ]; 47 | 48 | attachCommonListFilter(filterQueries, filter, userId, appId); 49 | 50 | if (filter.articleId) { 51 | filterQueries.push({ term: { articleId: filter.articleId } }); 52 | } 53 | 54 | const body = { 55 | sort: getSortArgs(orderBy, { 56 | vote: (o) => ({ 57 | 'feedbacks.score': { 58 | order: o, 59 | mode: 'sum', 60 | nested: { 61 | path: 'feedbacks', 62 | }, 63 | }, 64 | }), 65 | }), 66 | query: { 67 | bool: { 68 | filter: filterQueries, 69 | }, 70 | }, 71 | }; 72 | 73 | return { 74 | index: 'replyrequests', 75 | type: 'doc', 76 | body, 77 | ...otherParams, 78 | }; 79 | }, 80 | type: createConnectionType('ListReplyRequestConnection', ReplyRequest), 81 | }; 82 | -------------------------------------------------------------------------------- /src/graphql/queries/ValidateSlug.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLObjectType, 3 | GraphQLNonNull, 4 | GraphQLString, 5 | GraphQLBoolean, 6 | } from 'graphql'; 7 | 8 | import { assertUser } from 'util/user'; 9 | import client from 'util/client'; 10 | import SlugErrorEnum, { errors } from 'graphql/models/SlugErrorEnum'; 11 | 12 | /** 13 | * Throws error if slug is not valid 14 | * 15 | * @param {string} slug - The string to check as slug 16 | * @param {string} userId - The user that want to use this slug 17 | */ 18 | export async function assertSlugIsValid(slug, userId) { 19 | const trimmedSlug = slug.trim(); 20 | 21 | if (!trimmedSlug) { 22 | throw errors.EMPTY; 23 | } 24 | 25 | if (slug !== trimmedSlug) { 26 | throw errors.NOT_TRIMMED; 27 | } 28 | 29 | if (encodeURI(slug) !== encodeURIComponent(slug)) { 30 | throw errors.HAS_URI_COMPONENT; 31 | } 32 | 33 | const { 34 | body: { count }, 35 | } = await client.count({ 36 | index: 'users', 37 | type: 'doc', 38 | body: { 39 | query: { 40 | bool: { 41 | must: [{ term: { slug } }], 42 | must_not: [{ ids: { values: [userId] } }], 43 | }, 44 | }, 45 | }, 46 | }); 47 | 48 | if (count > 0) throw errors.TAKEN; 49 | } 50 | 51 | export default { 52 | args: { 53 | slug: { 54 | type: new GraphQLNonNull(GraphQLString), 55 | }, 56 | }, 57 | type: new GraphQLObjectType({ 58 | name: 'ValidationResult', 59 | fields: { 60 | success: { type: new GraphQLNonNull(GraphQLBoolean) }, 61 | error: { type: SlugErrorEnum }, 62 | }, 63 | }), 64 | async resolve(rootValue, { slug }, { userId, appId }) { 65 | assertUser({ userId, appId }); 66 | try { 67 | await assertSlugIsValid(slug, userId); 68 | } catch (e) { 69 | /* istanbul ignore else */ 70 | if (e in errors) { 71 | return { 72 | success: false, 73 | error: e, 74 | }; 75 | } 76 | 77 | // Re-throw unexpected errors 78 | throw e; 79 | } 80 | 81 | return { 82 | success: true, 83 | }; 84 | }, 85 | }; 86 | -------------------------------------------------------------------------------- /src/graphql/queries/__fixtures__/GetCategory.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/categories/doc/c1': { 3 | title: '性少數與愛滋病', 4 | description: '對同性婚姻的恐懼、愛滋病的誤解與的防疫相關釋疑。', 5 | }, 6 | '/categories/doc/c2': { 7 | title: '免費訊息詐騙', 8 | description: '詐騙貼圖、假行銷手法。', 9 | }, 10 | '/articles/doc/GetCategory1': { 11 | text: 'Lorum ipsum', 12 | articleCategories: [ 13 | { 14 | categoryId: 'c1', 15 | status: 'NORMAL', 16 | }, 17 | { 18 | categoryId: 'c2', 19 | status: 'DELETED', 20 | }, 21 | ], 22 | normalArticleCategoryCount: 1, 23 | }, 24 | '/articles/doc/GetCategory2': { 25 | text: 'Lorum ipsum', 26 | articleCategories: [ 27 | { 28 | categoryId: 'c1', 29 | status: 'DELETED', 30 | }, 31 | { 32 | categoryId: 'c2', 33 | status: 'NORMAL', 34 | }, 35 | ], 36 | normalArticleCategoryCount: 1, 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/graphql/queries/__fixtures__/GetYdoc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/ydocs/doc/foo': { 3 | ydoc: 'mock data1', 4 | versions: [ 5 | { 6 | createdAt: '2023-09-07T08:14:14.005Z', 7 | snapshot: 'mock snapshot1', 8 | }, 9 | { 10 | createdAt: '2023-09-07T08:16:45.613Z', 11 | snapshot: 'mock snapshot2', 12 | }, 13 | { 14 | createdAt: '2023-09-07T08:18:32.467Z', 15 | snapshot: 'mock snapshot3', 16 | }, 17 | { 18 | createdAt: '2023-09-07T08:18:49.500Z', 19 | snapshot: 'mock snapshot4', 20 | }, 21 | ], 22 | }, 23 | '/ydocs/doc/foo2': { 24 | ydoc: 'mock data2', 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /src/graphql/queries/__fixtures__/ListAIResponses.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/airesponses/doc/ai-reply-old': { 3 | docId: 'ai-replied-article', 4 | type: 'AI_REPLY', 5 | status: 'SUCCESS', 6 | createdAt: '2020-01-01T00:00:00.000Z', 7 | updatedAt: '2020-01-01T00:00:10.000Z', 8 | }, 9 | '/airesponses/doc/ai-reply-latest': { 10 | docId: 'ai-replied-article', 11 | type: 'AI_REPLY', 12 | status: 'SUCCESS', 13 | createdAt: '2020-01-02T00:00:00.000Z', 14 | updatedAt: '2020-01-02T00:00:15.000Z', 15 | }, 16 | '/airesponses/doc/expired-loading': { 17 | docId: 'ai-replied-article', 18 | type: 'AI_REPLY', 19 | status: 'ERROR', 20 | createdAt: '2020-01-04T00:00:00.000Z', 21 | }, 22 | '/airesponses/doc/loading': { 23 | docId: 'some-article', 24 | type: 'AI_REPLY', 25 | status: 'LOADING', 26 | createdAt: '2020-01-01T00:00:00.000Z', 27 | }, 28 | '/airesponses/doc/transcript': { 29 | docId: 'some-article', 30 | type: 'TRANSCRIPT', 31 | status: 'SUCCESS', 32 | createdAt: '2020-01-01T00:00:00.000Z', 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /src/graphql/queries/__fixtures__/ListArticleReplyFeedbacks.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/articles/doc/a1': { 3 | text: 'Article 1', 4 | articleReplies: [{ replyId: 'r1' }, { replyId: 'r2' }], 5 | }, 6 | '/articles/doc/a2': { 7 | text: 'Article 2', 8 | articleReplies: [{ replyId: 'r2' }], 9 | }, 10 | '/replies/doc/r1': { 11 | text: 'Reply 1', 12 | }, 13 | '/replies/doc/r2': { 14 | text: 'Reply 2', 15 | }, 16 | '/users/doc/user1': { 17 | name: 'User 1', 18 | }, 19 | '/users/doc/user2': { 20 | name: 'User 2', 21 | }, 22 | '/articlereplyfeedbacks/doc/f1': { 23 | userId: 'user1', 24 | appId: 'app1', 25 | articleId: 'a1', 26 | replyId: 'r1', 27 | score: 1, 28 | status: 'NORMAL', 29 | createdAt: '2020-03-06T00:00:00.000Z', 30 | updatedAt: '2020-04-06T00:00:00.000Z', 31 | replyUserId: 'user2', 32 | articleReplyUserId: 'user3', 33 | }, 34 | '/articlereplyfeedbacks/doc/f2': { 35 | userId: 'user1', 36 | appId: 'app2', 37 | articleId: 'a1', 38 | replyId: 'r2', 39 | score: -1, 40 | status: 'NORMAL', 41 | comment: '武漢肺炎', 42 | createdAt: '2020-02-06T00:00:00.000Z', 43 | updatedAt: '2020-05-06T00:00:00.000Z', 44 | replyUserId: 'user3', 45 | articleReplyUserId: 'user2', 46 | }, 47 | '/articlereplyfeedbacks/doc/f3': { 48 | userId: 'user2', 49 | appId: 'app2', 50 | articleId: 'a2', 51 | replyId: 'r2', 52 | score: 1, 53 | status: 'NORMAL', 54 | comment: 'Thank you for info regarding COVID19.', 55 | createdAt: '2020-04-06T00:00:00.000Z', 56 | updatedAt: '2020-06-06T00:00:00.000Z', 57 | replyUserId: 'user4', 58 | articleReplyUserId: 'user4', 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /src/graphql/queries/__fixtures__/ListBlockedUsers.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/users/doc/normalUser': { 3 | name: 'normal user', 4 | createdAt: '2017-01-01T00:00:00.000Z', 5 | }, 6 | 7 | '/users/doc/unblockedUser': { 8 | name: 'unblocked user', 9 | createdAt: '2017-01-06T00:00:00.000Z', 10 | blockedReason: null, 11 | }, 12 | 13 | '/users/doc/blockedUser1': { 14 | name: 'Blocked spammer 1', 15 | blockedReason: 'Some announcement', 16 | createdAt: '2017-01-05T00:00:00.000Z', 17 | }, 18 | 19 | '/users/doc/blockedUser2': { 20 | name: 'Blocked spammer 2', 21 | blockedReason: 'Some announcement', 22 | createdAt: '2017-01-04T00:00:00.000Z', 23 | }, 24 | '/users/doc/blockedUser3': { 25 | name: 'Blocked spammer 3', 26 | blockedReason: 'Some announcement', 27 | createdAt: '2017-01-03T00:00:00.000Z', 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /src/graphql/queries/__fixtures__/ListCategories.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/categories/doc/c1': { 3 | title: '性少數與愛滋病', 4 | description: '對同性婚姻的恐懼、愛滋病的誤解與的防疫相關釋疑。', 5 | createdAt: '2020-02-06T05:34:45.862Z', 6 | updatedAt: '2020-02-06T05:34:45.862Z', 7 | }, 8 | '/categories/doc/c2': { 9 | title: '女權與性別刻板印象', 10 | description: '對性別平等教育的恐慌與抹黑', 11 | createdAt: '2020-02-06T05:34:46.862Z', 12 | updatedAt: '2020-02-06T05:34:46.862Z', 13 | }, 14 | '/categories/doc/c3': { 15 | title: '保健秘訣、食品安全', 16 | description: 17 | '各種宣稱會抗癌、高血壓、糖尿病等等的偏方秘笈、十大恐怖美食、不要吃海帶、美粒果', 18 | createdAt: '2020-02-06T05:34:43.862Z', 19 | updatedAt: '2020-02-06T05:34:43.862Z', 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /src/graphql/queries/__fixtures__/ListCooccurrences.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/cooccurrences/doc/listCooccurrenceTest1': { 3 | userId: 'user1', 4 | appId: 'app1', 5 | articleIds: ['a1', 'a2'], 6 | updatedAt: '2020-02-03T00:01:00.000Z', 7 | createdAt: '2020-02-03T00:00:00.000Z', 8 | }, 9 | '/cooccurrences/doc/listCooccurrenceTest2': { 10 | userId: 'user1', 11 | appId: 'app1', 12 | articleIds: ['a1', 'a3'], 13 | updatedAt: '2020-02-03T00:02:00.000Z', 14 | createdAt: '2020-02-03T00:00:00.000Z', 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/graphql/queries/__fixtures__/ListReplies.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/replies/doc/moreLikeThis1': { 3 | text: 'foo foo', 4 | reference: 'bar bar', 5 | type: 'NOT_ARTICLE', 6 | createdAt: '2020-02-06T00:00:00.000Z', 7 | }, 8 | '/replies/doc/moreLikeThis2': { 9 | text: 'bar bar bar', 10 | reference: 'foo foo foo', 11 | type: 'NOT_ARTICLE', 12 | createdAt: '2020-02-05T00:00:00.000Z', 13 | }, 14 | '/replies/doc/userFoo': { 15 | text: 'bar', 16 | reference: 'barbar', 17 | type: 'NOT_ARTICLE', 18 | userId: 'foo', 19 | appId: 'test', 20 | createdAt: '2020-02-07T00:00:00.000Z', 21 | }, 22 | '/replies/doc/rumor': { 23 | text: 'bar', 24 | reference: 'barbar', 25 | type: 'RUMOR', 26 | createdAt: '2020-02-04T00:00:00.000Z', 27 | }, 28 | '/replies/doc/referenceUrl': { 29 | text: '國文課本', 30 | reference: 'http://gohome.com', 31 | hyperlinks: [ 32 | { 33 | url: 'http://gohome.com', 34 | normalizedUrl: 'http://gohome.com/', 35 | title: '馮諼很餓', 36 | summary: 37 | '居有頃,倚柱彈其劍,歌曰:「長鋏歸來乎!食無魚。」左右以告。孟嘗君曰:「食之,比門下之客。」', 38 | }, 39 | ], 40 | type: 'NOT_RUMOR', 41 | createdAt: '2020-02-04T00:00:00.000Z', 42 | }, 43 | '/urls/doc/gohome': { 44 | url: 'http://gohome.com/', 45 | title: '馮諼很餓', 46 | summary: 47 | '居有頃,倚柱彈其劍,歌曰:「長鋏歸來乎!食無魚。」左右以告。孟嘗君曰:「食之,比門下之客。」', 48 | topImageUrl: 'http://gohome.com/image.jpg', 49 | }, 50 | '/urls/doc/foobar': { 51 | url: 'http://foo.com/', 52 | title: 'bar', 53 | summary: 'bar', 54 | topImageUrl: 'http://foo.com/image.jpg', 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /src/graphql/queries/__fixtures__/ListReplyRequests.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/replyrequests/doc/replyrequests1': { 3 | articleId: 'article1', 4 | userId: 'user1', 5 | appId: 'WEBSITE', 6 | reason: 'blahblah', 7 | feedbacks: [ 8 | { 9 | userId: 'user2', 10 | appId: 'WEBSITE', 11 | score: 1, 12 | }, 13 | { 14 | userId: 'user3', 15 | appId: 'WEBSITE', 16 | score: -1, 17 | }, 18 | { 19 | userId: 'user4', 20 | appId: 'WEBSITE', 21 | score: 1, 22 | }, 23 | { 24 | userId: 'user5', 25 | appId: 'WEBSITE', 26 | score: 1, 27 | }, 28 | ], 29 | status: 'NORMAL', 30 | createdAt: '2020-01-01T00:00:00.000Z', 31 | updatedAt: '2020-01-01T00:00:00.000Z', 32 | }, 33 | '/replyrequests/doc/replyrequests2': { 34 | articleId: 'article2', 35 | userId: 'user1', 36 | appId: 'WEBSITE', 37 | reason: 'blahblah', 38 | feedbacks: [ 39 | { 40 | userId: 'user2', 41 | appId: 'WEBSITE', 42 | score: -1, 43 | }, 44 | { 45 | userId: 'user3', 46 | appId: 'WEBSITE', 47 | score: -1, 48 | }, 49 | { 50 | userId: 'user4', 51 | appId: 'WEBSITE', 52 | score: -1, 53 | }, 54 | { 55 | userId: 'user5', 56 | appId: 'WEBSITE', 57 | score: -1, 58 | }, 59 | ], 60 | status: 'NORMAL', 61 | createdAt: '2020-02-03T00:00:00.000Z', 62 | updatedAt: '2020-01-01T00:00:00.000Z', 63 | }, 64 | '/replyrequests/doc/replyrequests3': { 65 | articleId: 'article1', 66 | userId: 'user1', 67 | appId: 'WEBSITE', 68 | reason: 'blahblah', 69 | feedbacks: [ 70 | { 71 | userId: 'user2', 72 | appId: 'WEBSITE', 73 | score: 1, 74 | }, 75 | ], 76 | status: 'NORMAL', 77 | createdAt: '2020-02-01T00:00:00.000Z', 78 | updatedAt: '2020-01-01T00:00:00.000Z', 79 | }, 80 | '/replyrequests/doc/replyrequests4': { 81 | articleId: 'article2', 82 | userId: 'user2', 83 | appId: 'WEBSITE', 84 | reason: 'blahblah', 85 | feedbacks: [ 86 | { 87 | userId: 'user1', 88 | appId: 'WEBSITE', 89 | score: -1, 90 | }, 91 | ], 92 | status: 'NORMAL', 93 | createdAt: '2020-02-02T00:00:00.000Z', 94 | updatedAt: '2020-01-01T00:00:00.000Z', 95 | }, 96 | '/users/doc/user1': { 97 | appId: 'WEBSITE', 98 | name: 'user 1', 99 | }, 100 | '/users/doc/user2': { 101 | appId: 'WEBSITE', 102 | name: 'user 2', 103 | }, 104 | }; 105 | -------------------------------------------------------------------------------- /src/graphql/queries/__fixtures__/ValidateSlug.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/users/doc/test-user': { 3 | slug: 'taken', 4 | name: 'test user', 5 | email: 'secret@secret.com', 6 | avatarType: 'Facebook', 7 | facebookId: 123456, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /src/graphql/queries/__tests__/GetCategory.js: -------------------------------------------------------------------------------- 1 | import gql from 'util/GraphQL'; 2 | import { loadFixtures, unloadFixtures } from 'util/fixtures'; 3 | import fixtures from '../__fixtures__/GetCategory'; 4 | 5 | describe('GetCategory', () => { 6 | beforeAll(() => loadFixtures(fixtures)); 7 | 8 | it('Get specified category and articleCategories with NORMAL status', async () => { 9 | expect( 10 | await gql` 11 | { 12 | GetCategory(id: "c1") { 13 | title 14 | articleCategories(status: NORMAL) { 15 | totalCount 16 | edges { 17 | node { 18 | articleId 19 | category { 20 | id 21 | title 22 | } 23 | status 24 | } 25 | cursor 26 | score 27 | } 28 | pageInfo { 29 | firstCursor 30 | lastCursor 31 | } 32 | } 33 | } 34 | } 35 | `() 36 | ).toMatchSnapshot(); 37 | }); 38 | 39 | it('Get specified category and articleCategories with DELETED status', async () => { 40 | expect( 41 | await gql` 42 | { 43 | GetCategory(id: "c1") { 44 | title 45 | articleCategories(status: DELETED) { 46 | totalCount 47 | edges { 48 | node { 49 | articleId 50 | category { 51 | id 52 | title 53 | } 54 | status 55 | } 56 | cursor 57 | score 58 | } 59 | pageInfo { 60 | firstCursor 61 | lastCursor 62 | } 63 | } 64 | } 65 | } 66 | `() 67 | ).toMatchSnapshot(); 68 | }); 69 | 70 | afterAll(() => unloadFixtures(fixtures)); 71 | }); 72 | -------------------------------------------------------------------------------- /src/graphql/queries/__tests__/GetYdoc.js: -------------------------------------------------------------------------------- 1 | import gql from 'util/GraphQL'; 2 | import { loadFixtures, unloadFixtures } from 'util/fixtures'; 3 | import fixtures from '../__fixtures__/GetYdoc'; 4 | 5 | describe('GetYdoc', () => { 6 | beforeAll(() => loadFixtures(fixtures)); 7 | afterAll(() => unloadFixtures(fixtures)); 8 | 9 | it('should get the specified doc', async () => { 10 | expect( 11 | await gql` 12 | { 13 | GetYdoc(id: "foo") { 14 | data 15 | versions { 16 | createdAt 17 | snapshot 18 | } 19 | } 20 | } 21 | `({}, { user: { id: 'test', appId: 'test' } }) 22 | ).toMatchSnapshot(); 23 | }); 24 | 25 | it('should return empty versions when there is none', async () => { 26 | expect( 27 | await gql` 28 | { 29 | GetYdoc(id: "foo2") { 30 | data 31 | versions { 32 | createdAt 33 | snapshot 34 | } 35 | } 36 | } 37 | `({}, { user: { id: 'test', appId: 'test' } }) 38 | ).toMatchSnapshot(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/graphql/queries/__tests__/ListCategories.js: -------------------------------------------------------------------------------- 1 | import gql from 'util/GraphQL'; 2 | import { loadFixtures, unloadFixtures } from 'util/fixtures'; 3 | import { getCursor } from 'graphql/util'; 4 | import fixtures from '../__fixtures__/ListCategories'; 5 | 6 | describe('ListCategories', () => { 7 | beforeAll(() => loadFixtures(fixtures)); 8 | 9 | it('lists all Categories', async () => { 10 | expect( 11 | await gql` 12 | { 13 | ListCategories { 14 | totalCount 15 | edges { 16 | node { 17 | id 18 | } 19 | cursor 20 | } 21 | pageInfo { 22 | firstCursor 23 | lastCursor 24 | } 25 | } 26 | } 27 | `() 28 | ).toMatchSnapshot(); 29 | }); 30 | 31 | it('sorts', async () => { 32 | expect( 33 | await gql` 34 | { 35 | ListCategories(orderBy: [{ createdAt: DESC }]) { 36 | edges { 37 | node { 38 | id 39 | } 40 | } 41 | totalCount 42 | pageInfo { 43 | firstCursor 44 | lastCursor 45 | } 46 | } 47 | } 48 | `() 49 | ).toMatchSnapshot(); 50 | }); 51 | 52 | it('supports after', async () => { 53 | expect( 54 | await gql` 55 | query ($cursor: String) { 56 | ListCategories(after: $cursor) { 57 | edges { 58 | node { 59 | id 60 | } 61 | } 62 | totalCount 63 | pageInfo { 64 | firstCursor 65 | lastCursor 66 | } 67 | } 68 | } 69 | `({ cursor: getCursor(['c2']) }) 70 | ).toMatchSnapshot(); 71 | }); 72 | 73 | it('supports before', async () => { 74 | expect( 75 | await gql` 76 | query ($cursor: String) { 77 | ListCategories(before: $cursor) { 78 | edges { 79 | node { 80 | id 81 | } 82 | } 83 | totalCount 84 | pageInfo { 85 | firstCursor 86 | lastCursor 87 | } 88 | } 89 | } 90 | `({ cursor: getCursor(['c2']) }) 91 | ).toMatchSnapshot(); 92 | }); 93 | 94 | afterAll(() => unloadFixtures(fixtures)); 95 | }); 96 | -------------------------------------------------------------------------------- /src/graphql/queries/__tests__/ListCooccurrences.js: -------------------------------------------------------------------------------- 1 | import gql from 'util/GraphQL'; 2 | import { loadFixtures, unloadFixtures } from 'util/fixtures'; 3 | import fixtures from '../__fixtures__/ListCooccurrences'; 4 | 5 | describe('ListCooccurrences', () => { 6 | beforeAll(() => loadFixtures(fixtures)); 7 | 8 | it('lists all cooccurrences', async () => { 9 | expect( 10 | await gql` 11 | { 12 | ListCooccurrences { 13 | totalCount 14 | edges { 15 | node { 16 | id 17 | } 18 | cursor 19 | } 20 | pageInfo { 21 | firstCursor 22 | lastCursor 23 | } 24 | } 25 | } 26 | `() 27 | ).toMatchSnapshot(); 28 | }); 29 | 30 | it('sorts', async () => { 31 | expect( 32 | await gql` 33 | { 34 | ListCooccurrences(orderBy: [{ updatedAt: DESC }]) { 35 | edges { 36 | node { 37 | id 38 | updatedAt 39 | } 40 | } 41 | totalCount 42 | pageInfo { 43 | firstCursor 44 | lastCursor 45 | } 46 | } 47 | } 48 | `() 49 | ).toMatchSnapshot('by updatedAt DESC'); 50 | }); 51 | 52 | it('filters', async () => { 53 | expect( 54 | await gql` 55 | { 56 | ListCooccurrences( 57 | filter: { updatedAt: { GT: "2020-02-03T00:01:05.000Z" } } 58 | ) { 59 | edges { 60 | node { 61 | id 62 | updatedAt 63 | } 64 | } 65 | totalCount 66 | } 67 | } 68 | `() 69 | ).toMatchInlineSnapshot(` 70 | Object { 71 | "data": Object { 72 | "ListCooccurrences": Object { 73 | "edges": Array [ 74 | Object { 75 | "node": Object { 76 | "id": "listCooccurrenceTest2", 77 | "updatedAt": "2020-02-03T00:02:00.000Z", 78 | }, 79 | }, 80 | ], 81 | "totalCount": 1, 82 | }, 83 | }, 84 | } 85 | `); 86 | }); 87 | 88 | afterAll(() => unloadFixtures(fixtures)); 89 | }); 90 | -------------------------------------------------------------------------------- /src/graphql/queries/__tests__/__snapshots__/GetCategory.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`GetCategory Get specified category and articleCategories with DELETED status 1`] = ` 4 | Object { 5 | "data": Object { 6 | "GetCategory": Object { 7 | "articleCategories": Object { 8 | "edges": Array [ 9 | Object { 10 | "cursor": "WyJHZXRDYXRlZ29yeTIiXQ==", 11 | "node": Object { 12 | "articleId": "GetCategory2", 13 | "category": Object { 14 | "id": "c1", 15 | "title": "性少數與愛滋病", 16 | }, 17 | "status": "DELETED", 18 | }, 19 | "score": null, 20 | }, 21 | ], 22 | "pageInfo": Object { 23 | "firstCursor": "WyJHZXRDYXRlZ29yeTIiXQ==", 24 | "lastCursor": "WyJHZXRDYXRlZ29yeTIiXQ==", 25 | }, 26 | "totalCount": 1, 27 | }, 28 | "title": "性少數與愛滋病", 29 | }, 30 | }, 31 | } 32 | `; 33 | 34 | exports[`GetCategory Get specified category and articleCategories with NORMAL status 1`] = ` 35 | Object { 36 | "data": Object { 37 | "GetCategory": Object { 38 | "articleCategories": Object { 39 | "edges": Array [ 40 | Object { 41 | "cursor": "WyJHZXRDYXRlZ29yeTEiXQ==", 42 | "node": Object { 43 | "articleId": "GetCategory1", 44 | "category": Object { 45 | "id": "c1", 46 | "title": "性少數與愛滋病", 47 | }, 48 | "status": "NORMAL", 49 | }, 50 | "score": null, 51 | }, 52 | ], 53 | "pageInfo": Object { 54 | "firstCursor": "WyJHZXRDYXRlZ29yeTEiXQ==", 55 | "lastCursor": "WyJHZXRDYXRlZ29yeTEiXQ==", 56 | }, 57 | "totalCount": 1, 58 | }, 59 | "title": "性少數與愛滋病", 60 | }, 61 | }, 62 | } 63 | `; 64 | -------------------------------------------------------------------------------- /src/graphql/queries/__tests__/__snapshots__/GetYdoc.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`GetYdoc should get the specified doc 1`] = ` 4 | Object { 5 | "data": Object { 6 | "GetYdoc": Object { 7 | "data": "mock data1", 8 | "versions": Array [ 9 | Object { 10 | "createdAt": "2023-09-07T08:14:14.005Z", 11 | "snapshot": "mock snapshot1", 12 | }, 13 | Object { 14 | "createdAt": "2023-09-07T08:16:45.613Z", 15 | "snapshot": "mock snapshot2", 16 | }, 17 | Object { 18 | "createdAt": "2023-09-07T08:18:32.467Z", 19 | "snapshot": "mock snapshot3", 20 | }, 21 | Object { 22 | "createdAt": "2023-09-07T08:18:49.500Z", 23 | "snapshot": "mock snapshot4", 24 | }, 25 | ], 26 | }, 27 | }, 28 | } 29 | `; 30 | 31 | exports[`GetYdoc should return empty versions when there is none 1`] = ` 32 | Object { 33 | "data": Object { 34 | "GetYdoc": Object { 35 | "data": "mock data2", 36 | "versions": Array [], 37 | }, 38 | }, 39 | } 40 | `; 41 | -------------------------------------------------------------------------------- /src/graphql/queries/__tests__/__snapshots__/ListCategories.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ListCategories lists all Categories 1`] = ` 4 | Object { 5 | "data": Object { 6 | "ListCategories": Object { 7 | "edges": Array [ 8 | Object { 9 | "cursor": "WyJjMyJd", 10 | "node": Object { 11 | "id": "c3", 12 | }, 13 | }, 14 | Object { 15 | "cursor": "WyJjMiJd", 16 | "node": Object { 17 | "id": "c2", 18 | }, 19 | }, 20 | Object { 21 | "cursor": "WyJjMSJd", 22 | "node": Object { 23 | "id": "c1", 24 | }, 25 | }, 26 | ], 27 | "pageInfo": Object { 28 | "firstCursor": "WyJjMyJd", 29 | "lastCursor": "WyJjMSJd", 30 | }, 31 | "totalCount": 3, 32 | }, 33 | }, 34 | } 35 | `; 36 | 37 | exports[`ListCategories sorts 1`] = ` 38 | Object { 39 | "data": Object { 40 | "ListCategories": Object { 41 | "edges": Array [ 42 | Object { 43 | "node": Object { 44 | "id": "c2", 45 | }, 46 | }, 47 | Object { 48 | "node": Object { 49 | "id": "c1", 50 | }, 51 | }, 52 | Object { 53 | "node": Object { 54 | "id": "c3", 55 | }, 56 | }, 57 | ], 58 | "pageInfo": Object { 59 | "firstCursor": "WzE1ODA5NjcyODY4NjIsImMyIl0=", 60 | "lastCursor": "WzE1ODA5NjcyODM4NjIsImMzIl0=", 61 | }, 62 | "totalCount": 3, 63 | }, 64 | }, 65 | } 66 | `; 67 | 68 | exports[`ListCategories supports after 1`] = ` 69 | Object { 70 | "data": Object { 71 | "ListCategories": Object { 72 | "edges": Array [ 73 | Object { 74 | "node": Object { 75 | "id": "c1", 76 | }, 77 | }, 78 | ], 79 | "pageInfo": Object { 80 | "firstCursor": "WyJjMyJd", 81 | "lastCursor": "WyJjMSJd", 82 | }, 83 | "totalCount": 3, 84 | }, 85 | }, 86 | } 87 | `; 88 | 89 | exports[`ListCategories supports before 1`] = ` 90 | Object { 91 | "data": Object { 92 | "ListCategories": Object { 93 | "edges": Array [ 94 | Object { 95 | "node": Object { 96 | "id": "c3", 97 | }, 98 | }, 99 | ], 100 | "pageInfo": Object { 101 | "firstCursor": "WyJjMyJd", 102 | "lastCursor": "WyJjMSJd", 103 | }, 104 | "totalCount": 3, 105 | }, 106 | }, 107 | } 108 | `; 109 | -------------------------------------------------------------------------------- /src/graphql/queries/__tests__/__snapshots__/ListCooccurrences.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ListCooccurrences lists all cooccurrences 1`] = ` 4 | Object { 5 | "data": Object { 6 | "ListCooccurrences": Object { 7 | "edges": Array [ 8 | Object { 9 | "cursor": "WyJsaXN0Q29vY2N1cnJlbmNlVGVzdDIiXQ==", 10 | "node": Object { 11 | "id": "listCooccurrenceTest2", 12 | }, 13 | }, 14 | Object { 15 | "cursor": "WyJsaXN0Q29vY2N1cnJlbmNlVGVzdDEiXQ==", 16 | "node": Object { 17 | "id": "listCooccurrenceTest1", 18 | }, 19 | }, 20 | ], 21 | "pageInfo": Object { 22 | "firstCursor": "WyJsaXN0Q29vY2N1cnJlbmNlVGVzdDIiXQ==", 23 | "lastCursor": "WyJsaXN0Q29vY2N1cnJlbmNlVGVzdDEiXQ==", 24 | }, 25 | "totalCount": 2, 26 | }, 27 | }, 28 | } 29 | `; 30 | 31 | exports[`ListCooccurrences sorts: by updatedAt DESC 1`] = ` 32 | Object { 33 | "data": Object { 34 | "ListCooccurrences": Object { 35 | "edges": Array [ 36 | Object { 37 | "node": Object { 38 | "id": "listCooccurrenceTest2", 39 | "updatedAt": "2020-02-03T00:02:00.000Z", 40 | }, 41 | }, 42 | Object { 43 | "node": Object { 44 | "id": "listCooccurrenceTest1", 45 | "updatedAt": "2020-02-03T00:01:00.000Z", 46 | }, 47 | }, 48 | ], 49 | "pageInfo": Object { 50 | "firstCursor": "WzE1ODA2ODgxMjAwMDAsImxpc3RDb29jY3VycmVuY2VUZXN0MiJd", 51 | "lastCursor": "WzE1ODA2ODgwNjAwMDAsImxpc3RDb29jY3VycmVuY2VUZXN0MSJd", 52 | }, 53 | "totalCount": 2, 54 | }, 55 | }, 56 | } 57 | `; 58 | -------------------------------------------------------------------------------- /src/jade/index.jade: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang="en") 3 | head 4 | meta(charset="utf-8") 5 | link(rel="shortcut icon" href="/favicon.png" type="image/png" sizes="32x32") 6 | title 真的假的 API 7 | body 8 | h1 Cofacts 真的假的 API server 9 | p 10 | a(href="/graphql") graphiql & Docs 11 | | ・ 12 | a(href="https://beta.hackfoldr.org/1yXwRJwFNFHNJibKENnLCAV5xB8jnUvEwY_oUq-KcETU/https%253A%252F%252Fhackmd.io%252Fs%252Fr1nfwTrgM") Developer quickstart 13 | | ・ 14 | a(href="https://github.com/cofacts") Github 15 | | ・ 16 | a(href="http://cofacts.org") Website 17 | 18 | 19 | h2 Cofacts Data User Agreement 20 | p 21 | | Access data via this API means that you accept #[a(href="https://github.com/cofacts/rumors-api/blob/master/LEGAL.md") Cofacts data user agreement]. 22 | p 23 | | Please contact Cofacts WG to express your consent and apply for a client application in order to get #[code app-id] and #[code app-secret]. 24 | | See #[a(href="https://github.com/cofacts/rumors-api/blob/master/LEGAL.md") Cofacts data user agreement] for the detail of application. 25 | 26 | h2 Using this API 27 | p 28 | | Cofacst API is a GraphQL server. 29 | | You can visit #[a(href="/graphql") #[code /graphql]] endpoint for GraphQL playground. 30 | | Send #[code POST] requests to #[code /graphql] endpoint to use the API. 31 | | You can refer to #[a(href="https://www.apollographql.com/blog/4-simple-ways-to-call-a-graphql-api-a6807bcdb355/") this blog post by Apollo] if you are new to GraphQL. 32 | 33 | p After getting your #[code app-secret] you can test out Cofacts API in CLI using this command: 34 | pre. 35 | curl -X POST -H "Content-Type: application/json" \ 36 | -H "x-app-secret: <Your App Secret>" \ 37 | --data '{ "query": "{ ListArticles { totalCount } } " }' \ 38 | #{hostName}/graphql 39 | 40 | p The command above should give you the total number of articles (reported messages) currently in the database, in JSON format: 41 | pre. 42 | {"data":{"ListArticles":{"totalCount":43835}}} 43 | 44 | h2 GraphQL Schema 45 | code 46 | pre= schemaStr 47 | -------------------------------------------------------------------------------- /src/rollbarInstance.js: -------------------------------------------------------------------------------- 1 | import Rollbar from 'rollbar'; 2 | 3 | const rollbar = new Rollbar({ 4 | enabled: !!(process.env.ROLLBAR_TOKEN && process.env.ROLLBAR_ENV), 5 | verbose: true, 6 | accessToken: process.env.ROLLBAR_TOKEN, 7 | captureUncaught: true, 8 | captureUnhandledRejections: true, 9 | environment: process.env.ROLLBAR_ENV, 10 | }); 11 | 12 | export default rollbar; 13 | -------------------------------------------------------------------------------- /src/scripts/__fixtures__/cleanupUrls.js: -------------------------------------------------------------------------------- 1 | import { FLAG_FIELD } from '../cleanupUrls'; 2 | 3 | export default { 4 | '/articles/doc/a1': { 5 | userId: 'user1', 6 | appId: 'app1', 7 | hyperlinks: [{ url: 'foo.com' }], 8 | }, 9 | '/replies/doc/r1': { 10 | userId: 'user1', 11 | appId: 'app1', 12 | hyperlinks: [{ url: 'bar.com' }], 13 | }, 14 | '/urls/doc/url-to-delete': { 15 | url: 'm.not-exist.com', 16 | canonical: 'not-exist.com', 17 | fetchedAt: '2018-01-01T00:00:00Z', // more than 1 day ago 18 | }, 19 | '/urls/doc/new-url': { 20 | url: 'm.not-exist-yet.com', 21 | canonical: 'not-exist-yet.com', 22 | fetchedAt: new Date().toISOString(), // most recent 23 | }, 24 | '/urls/doc/url-processed': { 25 | url: 'processed.com', 26 | canonical: 'processed.com', 27 | [FLAG_FIELD]: true, 28 | fetchedAt: '2018-01-01T00:00:00Z', // more than 1 day ago 29 | }, 30 | '/urls/doc/url-foo': { 31 | url: 'foo.com', 32 | canonical: 'foo2.com', 33 | fetchedAt: '2018-01-01T00:00:00Z', // more than 1 day ago 34 | }, 35 | '/urls/doc/curl-foo': { 36 | url: 'foo2.com', 37 | canonical: 'foo.com', 38 | fetchedAt: '2018-01-01T00:00:00Z', // more than 1 day ago 39 | }, 40 | '/urls/doc/url-bar': { 41 | url: 'bar.com', 42 | canonical: 'bar2.com', 43 | fetchedAt: '2018-01-01T00:00:00Z', // more than 1 day ago 44 | }, 45 | '/urls/doc/curl-bar': { 46 | url: 'bar2.com', 47 | canonical: 'bar.com', 48 | fetchedAt: '2018-01-01T00:00:00Z', // more than 1 day ago 49 | }, 50 | ...Array.from(Array(100)).reduce((fixtures, _, i) => { 51 | fixtures[`/urls/doc/quantityTest${i}`] = { 52 | url: `not-exist${i}.com`, 53 | canonical: `not-exist${i}.com`, 54 | fetchedAt: '2018-01-01T00:00:00Z', // more than 1 day ago 55 | }; 56 | return fixtures; 57 | }, {}), 58 | }; 59 | -------------------------------------------------------------------------------- /src/scripts/__fixtures__/genAIReply.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/articles/doc/some-article': { 3 | text: 'Some article', 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /src/scripts/__fixtures__/removeArticleReply.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/articles/doc/article1': { 3 | articleReplies: [ 4 | { 5 | status: 'NORMAL', 6 | appId: 'WEBSITE', 7 | userId: 'current-user', 8 | replyId: 'foo', 9 | }, 10 | { 11 | status: 'NORMAL', 12 | appId: 'WEBSITE', 13 | userId: 'current-user', 14 | replyId: 'bar', 15 | }, 16 | { 17 | status: 'NORMAL', 18 | appId: 'WEBSITE', 19 | userId: 'other-user', 20 | replyId: 'foo2', 21 | }, 22 | ], 23 | normalArticleReplyCount: 3, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/scripts/__tests__/__snapshots__/cleanupUrls.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should clean up urls 1`] = ` 4 | Array [ 5 | Object { 6 | "_cursor": undefined, 7 | "_fields": undefined, 8 | "_score": 1, 9 | "highlight": undefined, 10 | "id": "new-url", 11 | "inner_hits": undefined, 12 | }, 13 | Object { 14 | "_cursor": undefined, 15 | "_fields": undefined, 16 | "_score": 1, 17 | "highlight": undefined, 18 | "id": "url-processed", 19 | "inner_hits": undefined, 20 | "isReferenced": true, 21 | }, 22 | Object { 23 | "_cursor": undefined, 24 | "_fields": undefined, 25 | "_score": 1, 26 | "highlight": undefined, 27 | "id": "url-foo", 28 | "inner_hits": undefined, 29 | "isReferenced": true, 30 | }, 31 | Object { 32 | "_cursor": undefined, 33 | "_fields": undefined, 34 | "_score": 1, 35 | "highlight": undefined, 36 | "id": "curl-foo", 37 | "inner_hits": undefined, 38 | "isReferenced": true, 39 | }, 40 | Object { 41 | "_cursor": undefined, 42 | "_fields": undefined, 43 | "_score": 1, 44 | "highlight": undefined, 45 | "id": "url-bar", 46 | "inner_hits": undefined, 47 | "isReferenced": true, 48 | }, 49 | Object { 50 | "_cursor": undefined, 51 | "_fields": undefined, 52 | "_score": 1, 53 | "highlight": undefined, 54 | "id": "curl-bar", 55 | "inner_hits": undefined, 56 | "isReferenced": true, 57 | }, 58 | ] 59 | `; 60 | -------------------------------------------------------------------------------- /src/scripts/__tests__/cleanupUrls.js: -------------------------------------------------------------------------------- 1 | import { loadFixtures, unloadFixtures } from 'util/fixtures'; 2 | import client, { processMeta } from 'util/client'; 3 | import cleanupUrls, { FLAG_FIELD } from '../cleanupUrls'; 4 | import fixtures from '../__fixtures__/cleanupUrls'; 5 | 6 | it('should clean up urls', async () => { 7 | await loadFixtures(fixtures); 8 | 9 | await cleanupUrls(); 10 | 11 | const urlsResult = ( 12 | await client.search({ 13 | index: 'urls', 14 | body: { 15 | query: { 16 | match_all: {}, 17 | }, 18 | _source: [FLAG_FIELD], 19 | }, 20 | }) 21 | ).body.hits.hits.map(processMeta); 22 | 23 | expect(urlsResult).toMatchSnapshot(); 24 | 25 | await unloadFixtures(fixtures); 26 | }); 27 | -------------------------------------------------------------------------------- /src/scripts/__tests__/genAIReply.js: -------------------------------------------------------------------------------- 1 | jest.mock('graphql/mutations/CreateAIReply'); 2 | 3 | import client from 'util/client'; 4 | import { loadFixtures, unloadFixtures } from 'util/fixtures'; 5 | import { createNewAIReply } from 'graphql/mutations/CreateAIReply'; 6 | import fixtures from '../__fixtures__/genAIReply'; 7 | import genAIReply, { GENERATOR_USER_ID } from '../genAIReply'; 8 | 9 | beforeEach(() => loadFixtures(fixtures)); 10 | 11 | it('rejects when articleId is not provided', async () => { 12 | await expect( 13 | genAIReply({ articleId: undefined }) 14 | ).rejects.toThrowErrorMatchingInlineSnapshot(`"Please specify articleId"`); 15 | }); 16 | 17 | it('calls AI reply generation as expected', async () => { 18 | createNewAIReply.mockImplementationOnce(async () => undefined); 19 | 20 | await genAIReply({ articleId: 'some-article' }); 21 | 22 | expect(createNewAIReply).toHaveBeenCalledTimes(1); 23 | expect(createNewAIReply.mock.calls[0][0].article).toMatchInlineSnapshot(` 24 | Object { 25 | "id": "some-article", 26 | "text": "Some article", 27 | } 28 | `); 29 | expect(createNewAIReply.mock.calls[0][0].user.appUserId).toBe( 30 | GENERATOR_USER_ID 31 | ); 32 | 33 | // Cleanup generated reviewer user before invoking the mocked createNewAIReply 34 | await client.delete({ 35 | index: 'users', 36 | type: 'doc', 37 | id: createNewAIReply.mock.calls[0][0].user.id, 38 | }); 39 | }); 40 | 41 | afterEach(() => unloadFixtures(fixtures)); 42 | -------------------------------------------------------------------------------- /src/scripts/genAIReply.js: -------------------------------------------------------------------------------- 1 | // eslint-disable no-console 2 | /* 3 | A script that generates AI reply for an article 4 | */ 5 | 6 | import yargs from 'yargs'; 7 | 8 | import client from 'util/client'; 9 | import { createNewAIReply } from 'graphql/mutations/CreateAIReply'; 10 | import { createOrUpdateUser } from 'util/user'; 11 | 12 | // The identify we use to generate AI reply 13 | const GENERATOR_APP_ID = 'RUMORS_AI'; 14 | export const GENERATOR_USER_ID = 'ai-reply-reviewer'; 15 | 16 | async function main({ articleId, temperature } = {}) { 17 | if (!articleId) throw new Error('Please specify articleId'); 18 | 19 | const { 20 | body: { _source: article }, 21 | } = await client.get({ 22 | index: 'articles', 23 | type: 'doc', 24 | id: articleId, 25 | }); 26 | 27 | const { user } = await createOrUpdateUser({ 28 | userId: GENERATOR_USER_ID, 29 | appId: GENERATOR_APP_ID, 30 | }); 31 | 32 | return createNewAIReply({ 33 | article: { 34 | ...article, 35 | id: articleId, 36 | }, 37 | user, 38 | completionOptions: { 39 | temperature, 40 | }, 41 | }); 42 | } 43 | 44 | export default main; 45 | 46 | /* istanbul ignore if */ 47 | if (require.main === module) { 48 | const argv = yargs 49 | .options({ 50 | articleId: { 51 | alias: 'a', 52 | description: 'Article ID to generate AI reply', 53 | type: 'string', 54 | demandOption: true, 55 | }, 56 | temperature: { 57 | description: 'Open AI chat completion param', 58 | type: 'number', 59 | demandOption: false, 60 | }, 61 | }) 62 | .help('help').argv; 63 | 64 | main(argv).catch(console.error); 65 | } 66 | -------------------------------------------------------------------------------- /src/scripts/migrations/__fixtures__/importFlowAnnotation.js: -------------------------------------------------------------------------------- 1 | import { convertAppUserIdToUserId } from 'util/user'; 2 | import { getArticleCategoryFeedbackId } from 'graphql/mutations/CreateOrUpdateArticleCategoryFeedback'; 3 | 4 | const reviewerUserId = convertAppUserIdToUserId({ 5 | appUserId: 'category-reviewer', 6 | appId: 'RUMORS_AI', 7 | }); 8 | 9 | export default { 10 | '/articles/doc/a1': { 11 | text: 'Article text', 12 | normalArticleCategoryCount: 2, 13 | articleCategories: [ 14 | // Already existed, not commented 15 | { 16 | categoryId: 'kj287XEBrIRcahlYvQoS', 17 | positiveFeedbackCount: 0, 18 | negativeFeedbackCount: 0, 19 | status: 'NORMAL', 20 | }, 21 | 22 | // Already existed and commented previously 23 | { 24 | categoryId: 'kz3c7XEBrIRcahlYxAp6', 25 | positiveFeedbackCount: 1, 26 | negativeFeedbackCount: 1, 27 | status: 'NORMAL', 28 | }, 29 | 30 | // Previously tagged and then deleted 31 | { 32 | categoryId: 'lD3h7XEBrIRcahlYeQqS', 33 | positiveFeedbackCount: 0, 34 | negativeFeedbackCount: 0, 35 | status: 'DELETED', 36 | }, 37 | ], 38 | }, 39 | [`/articlecategoryfeedbacks/doc/${getArticleCategoryFeedbackId({ 40 | articleId: 'a1', 41 | categoryId: 'kz3c7XEBrIRcahlYxAp6', 42 | userId: 'non-reviewer', 43 | appId: 'WEBSITE', 44 | })}`]: { 45 | articleId: 'a1', 46 | categoryId: 'kz3c7XEBrIRcahlYxAp6', 47 | appId: 'WEBSITE', 48 | userId: 'non-reviewer', 49 | score: 1, 50 | status: 'NORMAL', 51 | }, 52 | [`/articlecategoryfeedbacks/doc/${getArticleCategoryFeedbackId({ 53 | articleId: 'a1', 54 | categoryId: 'kz3c7XEBrIRcahlYxAp6', 55 | userId: reviewerUserId, 56 | appId: 'RUMORS_AI', 57 | })}`]: { 58 | articleId: 'a1', 59 | categoryId: 'kz3c7XEBrIRcahlYxAp6', 60 | appId: 'RUMORS_AI', 61 | userId: reviewerUserId, 62 | score: -1, 63 | status: 'NORMAL', 64 | }, 65 | '/categories/doc/kj287XEBrIRcahlYvQoS': { 66 | title: 'Category 1', 67 | description: 'Description for category 1', 68 | }, 69 | '/categories/doc/kz3c7XEBrIRcahlYxAp6': { 70 | title: 'Category 2', 71 | description: 'Description for category 2', 72 | }, 73 | '/categories/doc/lD3h7XEBrIRcahlYeQqS': { 74 | title: 'Category 3', 75 | description: 'Description for category 3', 76 | }, 77 | '/categories/doc/lT3h7XEBrIRcahlYugqq': { 78 | title: 'Category 4', 79 | description: 'Description for category 4', 80 | }, 81 | }; 82 | -------------------------------------------------------------------------------- /src/scripts/migrations/__tests__/createBackendUsers.js: -------------------------------------------------------------------------------- 1 | import { loadFixtures } from 'util/fixtures'; 2 | import client from 'util/client'; 3 | import CreateBackendUsers from '../createBackendUsers'; 4 | import fixtures from '../__fixtures__/createBackendUsers'; 5 | import { sortBy } from 'lodash'; 6 | 7 | const checkAllDocsForIndex = async (index) => { 8 | let res = {}; 9 | const { 10 | body: { 11 | hits: { hits: docs }, 12 | }, 13 | } = await client.search({ 14 | index, 15 | body: { 16 | query: { 17 | match_all: {}, 18 | }, 19 | size: 10000, 20 | sort: [{ _id: 'asc' }], 21 | }, 22 | }); 23 | 24 | docs.forEach( 25 | (doc) => (res[`/${index}/${doc._type}/${doc._id}`] = doc._source) 26 | ); 27 | 28 | const expected = fixtures.expectedResults[index]; 29 | expect(sortBy(Object.keys(res))).toStrictEqual(sortBy(Object.keys(expected))); 30 | 31 | expect(res).toMatchObject(expected); 32 | }; 33 | 34 | const indices = [ 35 | 'users', 36 | 'articlecategoryfeedbacks', 37 | 'articlereplyfeedbacks', 38 | 'articles', 39 | 'replies', 40 | 'replyrequests', 41 | 'analytics', 42 | ]; 43 | 44 | // We have made DB schema strictly prohibit extra fields, thus we can no longer insert the fixture 45 | // for this test into the database. We have no choice but to skip this test. 46 | // 47 | describe.skip('createBackendUsers', () => { 48 | beforeAll(async () => { 49 | await loadFixtures(fixtures.fixturesToLoad); 50 | 51 | await new CreateBackendUsers({ 52 | batchSize: 50, 53 | aggBatchSize: 10, 54 | analyticsBatchSize: 100, 55 | }).execute(); 56 | 57 | // refreshing all indices to ensure test consistency 58 | for (const index of indices) { 59 | await client.indices.refresh({ index }); 60 | } 61 | }); 62 | 63 | afterAll(async () => { 64 | for (const index of indices) { 65 | await client.deleteByQuery({ 66 | index, 67 | body: { 68 | query: { 69 | match_all: {}, 70 | }, 71 | }, 72 | refresh: 'true', 73 | }); 74 | } 75 | }); 76 | 77 | for (const index of indices) { 78 | it(`All ${index} docs have been created/updated accordingly`, async () => { 79 | await checkAllDocsForIndex(index); 80 | }); 81 | } 82 | }); 83 | -------------------------------------------------------------------------------- /src/scripts/removeArticleReply.js: -------------------------------------------------------------------------------- 1 | // eslint-disable no-console 2 | /* 3 | A script that deletes an article reply 4 | */ 5 | 6 | import client from 'util/client'; 7 | import { updateArticleReplyStatus } from 'graphql/mutations/UpdateArticleReplyStatus'; 8 | import yargs from 'yargs'; 9 | 10 | async function main({ articleId, replyId, userId, replacedText } = {}) { 11 | if (!articleId || !replyId || !userId) 12 | throw new Error('Please provide all of articleId, replyId and userId'); 13 | 14 | if (replacedText) { 15 | const replyBody = { 16 | text: replacedText, 17 | }; 18 | 19 | await client.update({ 20 | index: 'replies', 21 | type: 'doc', 22 | body: replyBody, 23 | }); 24 | } 25 | return updateArticleReplyStatus({ 26 | articleId, 27 | replyId, 28 | userId, 29 | appId: 'WEBSITE', 30 | status: 'DELETED', 31 | }); 32 | } 33 | 34 | export default main; 35 | 36 | /* istanbul ignore if */ 37 | if (require.main === module) { 38 | const argv = yargs 39 | .options({ 40 | userId: { 41 | alias: 'u', 42 | description: 'User ID of the reply.', 43 | type: 'string', 44 | demandOption: true, 45 | }, 46 | articleId: { 47 | alias: 'a', 48 | description: 'Article ID of the articleReply', 49 | type: 'string', 50 | demandOption: true, 51 | }, 52 | replyId: { 53 | alias: 'r', 54 | description: 'Reply ID of the articleReply', 55 | type: 'string', 56 | demandOption: true, 57 | }, 58 | replacedText: { 59 | alias: 'rt', 60 | description: 'Replaced text for spammer reply', 61 | type: 'string', 62 | demandOption: false, 63 | }, 64 | }) 65 | .help('help').argv; 66 | 67 | main(argv).catch(console.error); 68 | } 69 | -------------------------------------------------------------------------------- /src/scripts/replaceMedia.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Given articleId and URL, replace the media with the content the URL points to. 3 | */ 4 | 5 | import 'dotenv/config'; 6 | import yargs from 'yargs'; 7 | import client from 'util/client'; 8 | import mediaManager from 'util/mediaManager'; 9 | import { uploadMedia } from 'graphql/util'; 10 | 11 | /** 12 | * @param {object} args 13 | */ 14 | async function replaceMedia({ articleId, url, force = false } = {}) { 15 | const { 16 | body: { _source: article }, 17 | } = await client.get({ index: 'articles', type: 'doc', id: articleId }); 18 | 19 | /* istanbul ignore if */ 20 | if (!article) throw new Error(`Article ${articleId} is not found`); 21 | 22 | const oldMediaEntry = await mediaManager.get(article.attachmentHash); 23 | 24 | /* istanbul ignore if */ 25 | if (!force && !oldMediaEntry) 26 | throw new Error( 27 | `Article ${articleId}'s attachment hash "${article.attachmentHash}" has no corresponding media entry` 28 | ); 29 | 30 | // Delete old media first, so that new one can be written without worring overwriting existing files 31 | // 32 | if (oldMediaEntry) { 33 | await Promise.all( 34 | oldMediaEntry.variants.map((variant) => 35 | oldMediaEntry 36 | .getFile(variant) 37 | .delete() 38 | .then(() => { 39 | console.info(`Old media entry variant=${variant} deleted`); 40 | }) 41 | ) 42 | ); 43 | } 44 | 45 | const newMediaEntry = await uploadMedia({ 46 | mediaUrl: url, 47 | articleType: article.articleType, 48 | }); 49 | 50 | console.info( 51 | `Article ${articleId} attachment hash: ${ 52 | oldMediaEntry?.id ?? article.attachmentHash 53 | } --> ${newMediaEntry.id}` 54 | ); 55 | await client.update({ 56 | index: 'articles', 57 | type: 'doc', 58 | id: articleId, 59 | body: { 60 | doc: { 61 | attachmentHash: newMediaEntry.id, 62 | }, 63 | }, 64 | }); 65 | } 66 | 67 | export default replaceMedia; 68 | 69 | /* istanbul ignore if */ 70 | if (require.main === module) { 71 | const argv = yargs 72 | .options({ 73 | articleId: { 74 | alias: 'a', 75 | description: 'The article ID', 76 | type: 'string', 77 | demandOption: true, 78 | }, 79 | url: { 80 | alias: 'u', 81 | description: 'The URL to the content to replace', 82 | type: 'string', 83 | demandOption: true, 84 | }, 85 | force: { 86 | alias: 'f', 87 | description: 'Skip old media entry check', 88 | type: 'boolean', 89 | }, 90 | }) 91 | .help('help').argv; 92 | 93 | replaceMedia(argv).catch(console.error); 94 | } 95 | -------------------------------------------------------------------------------- /src/util/__fixtures__/getAllDocs.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/myindex/doc/bardoc1': { 3 | foo: 'bar', 4 | }, 5 | '/myindex/doc/bardoc2': { 6 | foo: 'bar', 7 | }, 8 | '/myindex/doc/notbar': { 9 | foo: 'something else', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/util/__fixtures__/scrapUrls.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/urls/doc/someUrl': { 3 | url: 'http://example.com/article/1111-aaaaa-bbb-ccc', 4 | canonical: 'http://example.com/article/1111', 5 | title: 'Example title', 6 | summary: 'Extracted summary', 7 | fetchedAt: new Date('2017-01-01'), 8 | }, 9 | '/urls/doc/someUrl-2nd-fetch': { 10 | url: 'http://example.com/article/1111-aaaaa-bbb-ccc', 11 | canonical: 'http://example.com/article/1111', 12 | title: 'Changed title', 13 | summary: 'Changed summary', 14 | fetchedAt: new Date('2017-02-01'), 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/util/__fixtures__/user.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/users/doc/6LOqD_QabTT7XXTrsz7ybEa5PLfc0GfXlV578HYPhODPfSWc8': { 3 | name: 'test user 1', 4 | appUserId: 'testUser1', 5 | appId: 'TEST_BACKEND', 6 | }, 7 | '/users/doc/web-user': { 8 | name: 'Web user', 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /src/util/__mocks__/grpc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Usage: 3 | * 4 | * jest.mock('path-to-grpc/grpc'); 5 | * import resolveUrl from 'path-to-grpc/grpc'; 6 | * 7 | * it('some test', async () => { 8 | * resolveUrl.__addMockResponse([{url, ...}, ...]) 9 | * 10 | * const result = await functionUnderTestThatInvokesresolveUrl(); 11 | * 12 | * expect(resolveUrl.__getRequests).toMatchSnapshot(); // assert request sent 13 | * expect(result).toMatchSnapshot(); 14 | * 15 | * resolveUrl.__reset() // Must reset to reset mock responses & call sequence number! 16 | * }) 17 | */ 18 | 19 | import delayForMs from '../delayForMs'; 20 | 21 | let seq = 0; 22 | let mockResponses = []; 23 | let requests = []; 24 | let delayMs = 0; // milliseconds 25 | 26 | function resolveUrl(urls) { 27 | if (!mockResponses[seq]) { 28 | throw Error( 29 | `resolveUrl Mock error: No response found for request #${seq}. Please add mock response first.` 30 | ); 31 | } 32 | requests.push(urls); 33 | return delayForMs(delayMs).then(() => mockResponses[seq++]); 34 | } 35 | 36 | resolveUrl.__addMockResponse = (resp) => mockResponses.push(resp); 37 | resolveUrl.__setDelay = (delay) => (delayMs = delay); 38 | resolveUrl.__getRequests = () => requests; 39 | resolveUrl.__reset = () => { 40 | seq = 0; 41 | mockResponses = []; 42 | requests = []; 43 | delayMs = 0; 44 | }; 45 | 46 | export default resolveUrl; 47 | -------------------------------------------------------------------------------- /src/util/__tests__/getAllDocs.js: -------------------------------------------------------------------------------- 1 | import { loadFixtures, unloadFixtures } from 'util/fixtures'; 2 | import fixtures from '../__fixtures__/getAllDocs'; 3 | import getAllDocs from '../getAllDocs'; 4 | 5 | beforeAll(async () => { 6 | await loadFixtures(fixtures); 7 | }); 8 | 9 | afterAll(() => unloadFixtures(fixtures)); 10 | 11 | it('fetches all docs in the index by default', async () => { 12 | const ids = []; 13 | for await (const { _id } of getAllDocs('myindex')) { 14 | ids.push(_id); 15 | } 16 | 17 | expect(ids).toEqual(expect.arrayContaining(['bardoc1', 'bardoc2', 'notbar'])); 18 | }); 19 | 20 | it('fetches only the doc that matches the query', async () => { 21 | const ids = []; 22 | for await (const { _id } of getAllDocs('myindex', { 23 | term: { foo: 'bar' }, 24 | })) { 25 | ids.push(_id); 26 | } 27 | 28 | expect(ids).toEqual(expect.arrayContaining(['bardoc1', 'bardoc2'])); 29 | expect(ids).not.toEqual(expect.arrayContaining(['notbar'])); 30 | }); 31 | -------------------------------------------------------------------------------- /src/util/__tests__/getInFactory.js: -------------------------------------------------------------------------------- 1 | import getInFactory from '../getInFactory'; 2 | 3 | describe('getInFactory', () => { 4 | it('should get nested data', () => { 5 | expect( 6 | getInFactory({ 7 | key: { key2: [{ key3: 'value' }] }, 8 | })(['key', 'key2', 0, 'key3']) 9 | ).toBe('value'); 10 | }); 11 | 12 | it('should stop on non-existing key and provides default value', () => { 13 | expect(getInFactory({})(['not-exist-key'])).toBe(undefined); 14 | expect(getInFactory({})(['not-exist-key'], 42)).toBe(42); 15 | 16 | expect( 17 | getInFactory({ 18 | key: { key2: [{ key3: 'value' }] }, 19 | })(['key', 'key3']) 20 | ).toBe(undefined); 21 | 22 | // Edge cases for root data 23 | // 24 | expect(getInFactory(0)(['key', 'key2'])).toBe(undefined); 25 | expect(getInFactory(null)(['key', 'key2'])).toBe(undefined); 26 | expect(getInFactory(undefined)(['key', 'key2'])).toBe(undefined); 27 | expect(getInFactory(false)(['key', 'key2'])).toBe(undefined); 28 | expect(getInFactory(NaN)(['key', 'key2'])).toBe(undefined); 29 | 30 | // Edge cases for keys 31 | // 32 | expect(getInFactory(100)([])).toBe(100); 33 | expect(getInFactory(false)([])).toBe(false); 34 | }); 35 | 36 | it('should not give default value when data exists', () => { 37 | expect( 38 | getInFactory({ 39 | key: 0, 40 | })(['key'], 100) 41 | ).toBe(0); 42 | 43 | expect( 44 | getInFactory({ 45 | key: false, 46 | })(['key'], 100) 47 | ).toBe(false); 48 | 49 | expect( 50 | getInFactory({ 51 | key: null, 52 | })(['key'], 100) 53 | ).toBe(null); 54 | 55 | expect( 56 | getInFactory({ 57 | key: NaN, 58 | })(['key'], 100) 59 | ).toBeNaN(); 60 | }); 61 | 62 | it('handles object without prototype', () => { 63 | const a = Object.create(null); 64 | a.foo = Object.create(null); 65 | a.foo.bar = 123; 66 | expect(getInFactory(a)(['foo', 'bar'])).toBe(123); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/util/__tests__/level.js: -------------------------------------------------------------------------------- 1 | import { getPointsRequired, getLevel } from '../level'; 2 | 3 | describe('getPointsRequired', () => { 4 | it('returns correct point for ordinary level', () => { 5 | expect(getPointsRequired(0)).toEqual(0); 6 | expect(getPointsRequired(1)).toEqual(1); 7 | expect(getPointsRequired(2)).toEqual(2); 8 | expect(getPointsRequired(5)).toEqual(8); 9 | }); 10 | it('returns Infinity for level out of bound', () => { 11 | expect(getPointsRequired(100000)).toEqual(Infinity); 12 | }); 13 | }); 14 | 15 | describe('getLevel', () => { 16 | it('returns correct level for points in ladder', () => { 17 | expect(getLevel(0)).toEqual(0); 18 | expect(getLevel(1)).toEqual(1); 19 | expect(getLevel(2)).toEqual(2); 20 | expect(getLevel(3)).toEqual(3); 21 | expect(getLevel(4)).toEqual(3); 22 | expect(getLevel(5)).toEqual(4); 23 | expect(getLevel(7)).toEqual(4); 24 | expect(getLevel(8)).toEqual(5); 25 | expect(getLevel(121392)).toEqual(24); 26 | expect(getLevel(121393)).toEqual(25); 27 | }); 28 | 29 | it('returns the max level for out-of-scope points', () => { 30 | expect(getLevel(1e9)).toEqual(25); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/util/archiveUrlsFromText.ts: -------------------------------------------------------------------------------- 1 | /** Extract URLs from text and send to Internet Archive Wayback Machine */ 2 | 3 | import urlRegex from 'url-regex'; 4 | import { removeFBCLIDIfExist } from './scrapUrls'; 5 | 6 | export default async function archiveUrlsFromText(text: string) { 7 | const originalUrls = text.match(urlRegex()) || []; 8 | if (originalUrls.length === 0) return []; 9 | 10 | // Normalize URLs before sending to cache or scrapper to increase cache hit 11 | // 12 | const normalizedUrls = removeFBCLIDIfExist(originalUrls); 13 | 14 | const results = await Promise.all( 15 | normalizedUrls.map(async (url) => { 16 | const formData = new FormData(); 17 | formData.append('url', url); 18 | formData.append('capture_screenshot', '1'); 19 | formData.append('skip_first_archive', '1'); 20 | formData.append('delay_wb_availability', '1'); // Help reduce load on IA servers 21 | 22 | return ( 23 | await fetch('https://web.archive.org/save', { 24 | method: 'POST', 25 | headers: { 26 | Accept: 'application/json', 27 | Authorization: `LOW ${process.env.INTERNET_ARCHIVE_S3_ACCESS_KEY}:${process.env.INTERNET_ARCHIVE_S3_SECRET_KEY}`, 28 | }, 29 | body: formData, 30 | }) 31 | ).json(); 32 | }) 33 | ); 34 | 35 | console.info(`[archiveUrlsFromText] Archiving ${results.length} URLs`); 36 | results.forEach((result, i) => { 37 | if (result.job_id) { 38 | console.info(`[archiveUrlsFromText] [ ${result.url} ]: ${result.job_id}`); 39 | } else { 40 | console.error( 41 | `[archiveUrlsFromText] [ ${normalizedUrls[i]} ]: ${JSON.stringify( 42 | result 43 | )}` 44 | ); 45 | } 46 | }); 47 | return results; 48 | } 49 | -------------------------------------------------------------------------------- /src/util/bulk.js: -------------------------------------------------------------------------------- 1 | const BATCH_SIZE = 100; 2 | 3 | export default class Bulk { 4 | constructor(client, batchSize = BATCH_SIZE) { 5 | this.client = client; 6 | this.batchSize = batchSize; 7 | this._operations = []; 8 | this.actionsCount = 0; 9 | } 10 | 11 | async push(items, count = 1) { 12 | this._operations.push(...items); 13 | this.actionsCount += count; 14 | if ( 15 | this.actionCounts > this.batchSize || 16 | this._operations.length > 3 * this.batchSize 17 | ) { 18 | await this.flush(); 19 | } 20 | return this; 21 | } 22 | 23 | async flush() { 24 | if (this._operations.length === 0) { 25 | return this; 26 | } 27 | 28 | // Process entires queued up 29 | const op = this._operations.splice(0); 30 | this.actionCounts = 0; 31 | const { body } = await this.client.bulk({ 32 | body: op, 33 | refresh: 'true', 34 | }); 35 | if (body.errors) { 36 | console.error(body.errors); 37 | } else { 38 | console.info(`${body.items.length} items processed`); 39 | } 40 | 41 | return this; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/util/catchUnhandledRejection.js: -------------------------------------------------------------------------------- 1 | process.on('unhandledRejection', (reason, p) => { 2 | // eslint-disable-next-line no-console 3 | console.log( 4 | 'Possibly Unhandled Rejection at: Promise ', 5 | p, 6 | ' reason: ', 7 | reason 8 | ); 9 | // application specific logging here 10 | }); 11 | -------------------------------------------------------------------------------- /src/util/client.ts: -------------------------------------------------------------------------------- 1 | import elasticsearch from '@elastic/elasticsearch'; 2 | 3 | export default new elasticsearch.Client({ 4 | node: process.env.ELASTICSEARCH_URL, 5 | }); 6 | 7 | type ProcessMetaArgs = { 8 | _id: string; 9 | _source: T; 10 | found: boolean; 11 | _score: number; 12 | highlight: object; // FIXME: use ES type 13 | inner_hits: object; // FIXME: use ES type 14 | sort: string; 15 | fields: object; 16 | }; 17 | 18 | // Processes {_id, _version, found, _source: {...}} to 19 | // {id, ..._source}. 20 | // 21 | export function processMeta({ 22 | _id: id, 23 | _source: source, 24 | 25 | found, // for mget queries 26 | _score, // for search queries 27 | 28 | highlight, // search result highlighting. Should be {: ['...']} 29 | 30 | inner_hits, // nested query search result highlighting. 31 | 32 | sort, // cursor when sorted 33 | 34 | fields, // scripted fields (if any) 35 | }: ProcessMetaArgs) { 36 | if (found || _score !== undefined) { 37 | return { 38 | id, 39 | ...source, 40 | _cursor: sort, 41 | _score, 42 | highlight, 43 | inner_hits, 44 | _fields: fields, 45 | }; 46 | } 47 | return null; // not found 48 | } 49 | -------------------------------------------------------------------------------- /src/util/delayForMs.js: -------------------------------------------------------------------------------- 1 | function delayForMs(delayMs) { 2 | return new Promise((resolve) => { 3 | setTimeout(() => resolve(), delayMs); 4 | }); 5 | } 6 | 7 | export default delayForMs; 8 | -------------------------------------------------------------------------------- /src/util/getAllDocs.ts: -------------------------------------------------------------------------------- 1 | import client from 'util/client'; 2 | 3 | /** 4 | * A generator that fetches all docs in the specified index and yield 1 doc at a time. 5 | * 6 | * @param index The name of the index to fetch 7 | * @param query The elasticsearch query. Fetches all doc in the specified index if not given. 8 | * @param settings Query param settings in ES. 9 | * @param settings.size Number of docs per batch. Default to 1000. 10 | * @param settings.scroll The scroll timeout setting. Default to 30s. 11 | * @yields {Object} the document 12 | */ 13 | async function* getAllDocs( 14 | index: string, 15 | query: object = { match_all: {} }, 16 | { scroll = '30s', size = 1000 }: { size?: number; scroll?: string } = {} 17 | ): AsyncGenerator<{ _id: string; _source: T }> { 18 | let resp = await client.search({ 19 | index, 20 | scroll, 21 | size, 22 | body: { 23 | query, 24 | }, 25 | }); 26 | 27 | while (true) { 28 | const docs = resp.body.hits.hits; 29 | if (docs.length === 0) break; 30 | for (const doc of docs) { 31 | yield doc; 32 | } 33 | 34 | if (!resp.body._scroll_id) { 35 | break; 36 | } 37 | 38 | resp = await client.scroll({ 39 | scroll, 40 | scroll_id: resp.body._scroll_id, 41 | }); 42 | } 43 | } 44 | 45 | export default getAllDocs; 46 | -------------------------------------------------------------------------------- /src/util/getInFactory.js: -------------------------------------------------------------------------------- 1 | export default (data) => (path, defaultValue) => { 2 | const result = (path || []).reduce((res, d) => { 3 | if (res !== null && typeof res === 'object') { 4 | return res[d]; 5 | } 6 | return undefined; 7 | }, data); 8 | return result === undefined ? defaultValue : result; 9 | }; 10 | -------------------------------------------------------------------------------- /src/util/grpc.js: -------------------------------------------------------------------------------- 1 | import rollbar from '../rollbarInstance'; 2 | const grpc = require('@grpc/grpc-js'); 3 | const protoLoader = require('@grpc/proto-loader'); 4 | 5 | const PROTO_PATH = __dirname + '/protobuf/url_resolver.proto'; 6 | const packageDefinition = protoLoader.loadSync(PROTO_PATH, { 7 | keepCase: true, 8 | longs: String, 9 | enums: String, 10 | defaults: true, 11 | oneofs: true, 12 | }); 13 | 14 | const urlResolverProto = 15 | grpc.loadPackageDefinition(packageDefinition).url_resolver; 16 | 17 | const URL_RESOLVER_URL = process.env.URL_RESOLVER_URL || 'localhost:4000'; 18 | const client = new urlResolverProto.UrlResolver( 19 | URL_RESOLVER_URL, 20 | grpc.credentials.createInsecure() 21 | ); 22 | 23 | // Receiving stream response from resolver using gRPC 24 | export default (urls) => 25 | new Promise((resolve, reject) => { 26 | const call = client.ResolveUrl({ urls }); 27 | const responses = []; 28 | call.on('data', (response) => { 29 | responses.push(response); 30 | }); 31 | call.on('error', (err) => { 32 | // eslint-disable-next-line no-console 33 | console.error('gRPC operation contains error:', err); 34 | rollbar.error( 35 | 'gRPC error', 36 | { 37 | body: JSON.stringify({ urls }), 38 | url: URL_RESOLVER_URL, 39 | }, 40 | { err } 41 | ); 42 | reject(err); 43 | }); 44 | call.on('end', () => resolve(responses)); 45 | }); 46 | -------------------------------------------------------------------------------- /src/util/langfuse.ts: -------------------------------------------------------------------------------- 1 | import { Langfuse } from 'langfuse'; 2 | 3 | const langfuse = new Langfuse(); 4 | 5 | export const CURRENT_ENV = process.env.ROLLBAR_ENV ?? 'dev'; 6 | 7 | const originalTrace = langfuse.trace; 8 | langfuse.trace = (body) => 9 | originalTrace.call(langfuse, { 10 | ...body, 11 | tags: [...(body?.tags ?? []), CURRENT_ENV], 12 | }); 13 | 14 | export default langfuse; 15 | -------------------------------------------------------------------------------- /src/util/level.js: -------------------------------------------------------------------------------- 1 | const LEVEL_POINTS = [ 2 | 0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1587, 2584, 3 | 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 4 | ]; 5 | 6 | /** 7 | * @param {number} level 8 | * @returns {number} points required to reach the level 9 | */ 10 | export function getPointsRequired(level) { 11 | if (level >= LEVEL_POINTS.length) return Infinity; 12 | return LEVEL_POINTS[level]; 13 | } 14 | 15 | /** 16 | * @param {number[]} sortedArr 17 | * @param {number} item 18 | * @returns {number} the index in the sortedArr, whose value is the max value in sortedArr <= item 19 | */ 20 | function binarySearchIdx(sortedArr, item) { 21 | let start = 0; 22 | let end = sortedArr.length; 23 | while (end - start > 1) { 24 | const middleIdx = Math.floor((start + end) / 2); 25 | if (item === sortedArr[middleIdx]) return middleIdx; 26 | if (item < sortedArr[middleIdx]) end = middleIdx; 27 | else start = middleIdx; 28 | } 29 | 30 | return start; 31 | } 32 | 33 | /** 34 | * @param {number} point 35 | * @returns {number} level of the given point 36 | */ 37 | export function getLevel(point) { 38 | return binarySearchIdx(LEVEL_POINTS, point); 39 | } 40 | -------------------------------------------------------------------------------- /src/util/mediaManager.js: -------------------------------------------------------------------------------- 1 | import { MediaManager } from '@cofacts/media-manager'; 2 | 3 | export const IMAGE_PREVIEW = 'webp600w'; 4 | export const IMAGE_THUMBNAIL = 'jpg240h'; 5 | 6 | const mediaManager = new MediaManager({ 7 | bucketName: process.env.GCS_BUCKET_NAME, 8 | credentialsJSON: process.env.GCS_CREDENTIALS, 9 | prefix: process.env.GCS_MEDIA_FOLDER, 10 | }); 11 | 12 | export default mediaManager; 13 | -------------------------------------------------------------------------------- /src/util/openai.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai'; 2 | import { observeOpenAI, type LangfuseConfig } from 'langfuse'; 3 | import { CURRENT_ENV } from './langfuse'; 4 | 5 | const getOpenAI = (langfuseConfig: LangfuseConfig = {}) => 6 | observeOpenAI( 7 | new OpenAI({ 8 | apiKey: process.env.OPENAI_API_KEY, 9 | }), 10 | { 11 | ...langfuseConfig, 12 | tags: [ 13 | ...(('tags' in langfuseConfig && langfuseConfig.tags) || []), 14 | CURRENT_ENV, 15 | ], 16 | } 17 | ); 18 | 19 | export default getOpenAI; 20 | -------------------------------------------------------------------------------- /src/util/protobuf/resolve_error.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package resolve_error; 4 | 5 | enum ResolveError { 6 | UNKNOWN_ERROR = 0; 7 | NAME_NOT_RESOLVED = 1; // DNS cannot resolve the given URL 8 | INVALID_URL = 2; // Malformed URL 9 | NOT_REACHABLE = 3; // The target URL cannot be reached at all 10 | UNSUPPORTED = 4; // File download, etc 11 | HTTPS_ERROR = 5; // HTTPS related error, such as invalid certificates 12 | UNKNOWN_SCRAP_ERROR = 6; 13 | UNKNOWN_UNFURL_ERROR = 7; 14 | } -------------------------------------------------------------------------------- /src/util/protobuf/url_resolver.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "resolve_error.proto"; 4 | 5 | package url_resolver; 6 | 7 | service UrlResolver { 8 | rpc ResolveUrl (UrlsRequest) returns (stream UrlReply) {} 9 | } 10 | 11 | message UrlsRequest { 12 | repeated string urls = 1; 13 | } 14 | 15 | message UrlsReply { 16 | repeated UrlReply reply = 1; 17 | } 18 | 19 | message UrlReply { 20 | string url = 1; 21 | string canonical = 2; 22 | string title = 3; 23 | string summary = 4; 24 | string top_image_url = 5; 25 | string html = 6; 26 | int32 status = 7; 27 | oneof result { 28 | ResolveError error = 8; 29 | bool successfully_resolved = 9; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cofacts/rumors-api/a59e593ef266454ee9cd497cd8746b7e85039a08/static/favicon.png -------------------------------------------------------------------------------- /static/graphiql.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | GraphiQL 13 | 25 | 32 | 33 | 34 | 39 | 40 | 41 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
Loading...
51 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /test/postTest.js: -------------------------------------------------------------------------------- 1 | import main from 'scripts/cleanupUrls'; 2 | import client from 'util/client'; 3 | 4 | 5 | const checkDocs = async () => { 6 | 7 | const {body: aliases} = await client.cat.aliases({ 8 | format: 'JSON' 9 | }) 10 | 11 | const indices = aliases.map(i => i.alias); 12 | 13 | const { body: { hits: { total, hits } } } = await client.search({ 14 | index: indices, 15 | _source: 'false' 16 | }) 17 | 18 | if (total > 0) { 19 | console.log('\x1b[33m'); 20 | console.log('WARNING: test db is not cleaned up properly.'); 21 | const docs = hits.map(d => `/${d._index}/${d._type}/${d._id}`) 22 | console.log(JSON.stringify(docs, null, 2)); 23 | console.log('\x1b[0m'); 24 | 25 | for (const d of hits) { 26 | await client.delete({index: d._index, type: d._type, id: d._id}) 27 | } 28 | process.exit(1) 29 | } 30 | } 31 | 32 | checkDocs() 33 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | jest.mock(__dirname + '../../src/util/grpc'); 4 | 5 | jest.mock(__dirname + '../../src/rollbarInstance', () => ({ 6 | __esModule: true, 7 | default: { error: jest.fn() }, 8 | })); 9 | 10 | jest.setTimeout(process.env.JEST_TIMEOUT || 5000); 11 | 12 | 13 | expect.extend({ 14 | toBeNaN(received) { 15 | const pass = isNaN(received); 16 | return { 17 | pass, 18 | message: `expected ${received} ${pass ? 'not ' : ''}to be NaN`, 19 | }; 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /test/testSequencer.js: -------------------------------------------------------------------------------- 1 | const Sequencer = require('@jest/test-sequencer').default; 2 | 3 | class TestSequencer extends Sequencer { 4 | sort(tests) { 5 | // Test structure information 6 | // https://github.com/facebook/jest/blob/6b8b1404a1d9254e7d5d90a8934087a9c9899dab/packages/jest-runner/src/types.ts#L17-L21 7 | const copyTests = Array.from(tests); 8 | return copyTests.sort((testA, testB) => (testA.path > testB.path ? 1 : -1)); 9 | } 10 | } 11 | 12 | 13 | module.exports = TestSequencer; -------------------------------------------------------------------------------- /test/util/GraphQL.js: -------------------------------------------------------------------------------- 1 | import { graphql } from 'graphql'; 2 | import schema from 'graphql/schema'; 3 | import DataLoaders from 'graphql/dataLoaders'; 4 | 5 | // Usage: 6 | // 7 | // import gql from './util/GraphQL'; 8 | // gql`query($var: Type) { foo }`({var: 123}).then(...) 9 | // 10 | // We use template string here so that Atom's language-babel does syntax highlight 11 | // for us. 12 | // 13 | export default (query, ...substitutes) => (variables = {}, context = {}) => 14 | graphql( 15 | schema, 16 | String.raw(query, ...substitutes), 17 | null, 18 | { 19 | loaders: new DataLoaders(), // new loaders per request 20 | ...context, 21 | }, 22 | variables 23 | ); 24 | -------------------------------------------------------------------------------- /test/util/fixtures.js: -------------------------------------------------------------------------------- 1 | import client from 'util/client'; 2 | 3 | // fixtureMap: 4 | // { 5 | // '/articles/basic/1': { ... body of article 1 ... }, 6 | // '/replies/basic/2': { ... body of reply 2 ... }, 7 | // } 8 | // 9 | 10 | export async function loadFixtures(fixtureMap) { 11 | const body = []; 12 | Object.keys(fixtureMap).forEach(key => { 13 | const [, _index, _type, _id] = key.split('/'); 14 | body.push({ index: { _index, _type, _id } }); 15 | body.push(fixtureMap[key]); 16 | }); 17 | 18 | // refresh() should be invoked after bulk insert, or re-index happens every 1 seconds 19 | // 20 | // ref: https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-update-settings.html#bulk 21 | // https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-refresh.html 22 | // 23 | const { body: result } = await client.bulk({ body, refresh: 'true' }); 24 | 25 | /* istanbul ignore if */ 26 | if (result.errors) { 27 | throw new Error( 28 | `Fixture load failed : ${JSON.stringify(result, null, ' ')}` 29 | ); 30 | } 31 | } 32 | 33 | export async function unloadFixtures(fixtureMap) { 34 | const body = Object.keys(fixtureMap).map(key => { 35 | const [, _index, _type, _id] = key.split('/'); 36 | return { delete: { _index, _type, _id } }; 37 | }); 38 | 39 | const { body: result } = await client.bulk({ body, refresh: 'true' }); 40 | 41 | /* istanbul ignore if */ 42 | if (result.errors) { 43 | throw new Error( 44 | `Fixture unload failed : ${JSON.stringify(result, null, ' ')}` 45 | ); 46 | } 47 | } 48 | 49 | // Reset a document to fixture 50 | // 51 | export async function resetFrom(fixtureMap, key) { 52 | const [, index, type, id] = key.split('/'); 53 | await client.update({ 54 | index, 55 | type, 56 | id, 57 | body: { 58 | doc: fixtureMap[key], 59 | }, 60 | refresh: 'true', 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "*": ["src/*", "test/*"] 6 | }, 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "target": "es2018" 17 | }, 18 | "include": [ 19 | "src/**/*" 20 | ] 21 | } --------------------------------------------------------------------------------