diff --git a/.env.example b/.env.example index ded2d82be..068c88f7a 100644 --- a/.env.example +++ b/.env.example @@ -45,4 +45,7 @@ AWS_ENDPOINT=http://localhost:4566 MONGO_MIGRATION_URL= -OPENAI_API_KEY= \ No newline at end of file +OPENAI_API_KEY= + +ZENVIA_API_URL= +ZENVIA_API_TOKEN= \ No newline at end of file diff --git a/.github/workflows/aws.yml b/.github/workflows/aws.yml index 7315eb6b3..04b73c44a 100644 --- a/.github/workflows/aws.yml +++ b/.github/workflows/aws.yml @@ -38,6 +38,8 @@ env: NEXT_PUBLIC_RECAPTCHA_SITEKEY: ${{ secrets.RECAPTCHA_SITEKEY }} AGENTS_API_URL: ${{ secrets.DEVELOPMENT_AGENTS_API_URL }} OPENAI_API_KEY: ${{ secrets.DEVELOPMENT_OPENAI_API_KEY }} + ZENVIA_API_URL: ${{ secrets.DEVELOPMENT_ZENVIA_API_URL }} + ZENVIA_API_TOKEN: ${{ secrets.DEVELOPMENT_ZENVIA_API_URL }} jobs: setup-build-publish: @@ -71,6 +73,8 @@ jobs: echo "NEXT_PUBLIC_ORY_SDK_URL=${{ secrets.ORY_SDK_URL }}" >> $GITHUB_ENV echo "AGENTS_API_URL=${{ secrets.PRODUCTION_AGENTS_API_URL }}" >> $GITHUB_ENV echo "OPENAI_API_KEY=${{ secrets.PRODUCTION_OPENAI_API_KEY }}" >> $GITHUB_ENV + echo "ZENVIA_API_URL=${{ secrets.PRODUCTION_ZENVIA_API_URL }}" >> $GITHUB_ENV + echo "ZENVIA_API_TOKEN=${{ secrets.PRODUCTION_ZENVIA_API_TOKEN }}" >> $GITHUB_ENV - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v1 @@ -86,48 +90,50 @@ jobs: # Setting up config.yaml based on environment - name: Set config.yaml env: - RECAPTCHA_SECRET: ${{ secrets.RECAPTCHA_SECRETKEY }} + RECAPTCHA_SECRET: ${{ secrets.RECAPTCHA_SECRETKEY }} run: | - sed -i "s/ENV/$ENVIRONMENT/g" config.$ENVIRONMENT.yaml - sed -i "s%RECAPTCHA_SECRET%$RECAPTCHA_SECRET%g" config.$ENVIRONMENT.yaml - sed -i "s%MONGODB_URI%$MONGODB_URI%g" config.$ENVIRONMENT.yaml - sed -i "s%ORY_SDK_URL%$ORY_SDK_URL%g" config.$ENVIRONMENT.yaml - sed -i "s%GITLAB_FEATURE_FLAG_URL%$GITLAB_FEATURE_FLAG_URL%g" config.$ENVIRONMENT.yaml - sed -i "s%GITLAB_FEATURE_FLAG_INSTANCE_ID%$GITLAB_FEATURE_FLAG_INSTANCE_ID%g" config.$ENVIRONMENT.yaml - sed -i "s/ORY_ACCESS_TOKEN/$ORY_ACCESS_TOKEN/g" config.$ENVIRONMENT.yaml - sed -i "s/ALETHEIA_SCHEMA_ID/$ALETHEIA_SCHEMA_ID/g" config.$ENVIRONMENT.yaml - sed -i "s%AWS_SDK_BUCKET%$AWS_SDK_BUCKET%g" config.$ENVIRONMENT.yaml - sed -i "s%AWS_ACCESS_KEY_ID%$AWS_ACCESS_KEY_ID%g" config.$ENVIRONMENT.yaml - sed -i "s%AWS_SECRET_ACCESS_KEY%$AWS_SECRET_ACCESS_KEY%g" config.$ENVIRONMENT.yaml - sed -i "s%NOVU_API_KEY%$NOVU_API_KEY%g" config.$ENVIRONMENT.yaml - sed -i "s%NOVU_APPLICATION_IDENTIFIER%$NOVU_APPLICATION_IDENTIFIER%g" config.$ENVIRONMENT.yaml - sed -i "s%AGENTS_API_URL%$AGENTS_API_URL%g" config.$ENVIRONMENT.yaml - sed -i "s%OPENAI_API_KEY%$OPENAI_API_KEY%g" config.$ENVIRONMENT.yaml + sed -i "s/ENV/$ENVIRONMENT/g" config.$ENVIRONMENT.yaml + sed -i "s%RECAPTCHA_SECRET%$RECAPTCHA_SECRET%g" config.$ENVIRONMENT.yaml + sed -i "s%MONGODB_URI%$MONGODB_URI%g" config.$ENVIRONMENT.yaml + sed -i "s%ORY_SDK_URL%$ORY_SDK_URL%g" config.$ENVIRONMENT.yaml + sed -i "s%GITLAB_FEATURE_FLAG_URL%$GITLAB_FEATURE_FLAG_URL%g" config.$ENVIRONMENT.yaml + sed -i "s%GITLAB_FEATURE_FLAG_INSTANCE_ID%$GITLAB_FEATURE_FLAG_INSTANCE_ID%g" config.$ENVIRONMENT.yaml + sed -i "s/ORY_ACCESS_TOKEN/$ORY_ACCESS_TOKEN/g" config.$ENVIRONMENT.yaml + sed -i "s/ALETHEIA_SCHEMA_ID/$ALETHEIA_SCHEMA_ID/g" config.$ENVIRONMENT.yaml + sed -i "s%AWS_SDK_BUCKET%$AWS_SDK_BUCKET%g" config.$ENVIRONMENT.yaml + sed -i "s%AWS_ACCESS_KEY_ID%$AWS_ACCESS_KEY_ID%g" config.$ENVIRONMENT.yaml + sed -i "s%AWS_SECRET_ACCESS_KEY%$AWS_SECRET_ACCESS_KEY%g" config.$ENVIRONMENT.yaml + sed -i "s%NOVU_API_KEY%$NOVU_API_KEY%g" config.$ENVIRONMENT.yaml + sed -i "s%NOVU_APPLICATION_IDENTIFIER%$NOVU_APPLICATION_IDENTIFIER%g" config.$ENVIRONMENT.yaml + sed -i "s%AGENTS_API_URL%$AGENTS_API_URL%g" config.$ENVIRONMENT.yaml + sed -i "s%OPENAI_API_KEY%$OPENAI_API_KEY%g" config.$ENVIRONMENT.yaml + sed -i "s%ZENVIA_API_URL%$ZENVIA_API_URL%g" config.$ENVIRONMENT.yaml + sed -i "s%ZENVIA_API_TOKEN%$ZENVIA_API_TOKEN%g" config.$ENVIRONMENT.yaml - name: Set migrate-mongo-config.ts run: | - sed -i "s%MONGODB_URI%$MONGODB_URI%g" migrate-mongo-config-example.ts - sed -i "s%MONGODB_NAME%$MONGODB_NAME%g" migrate-mongo-config-example.ts + sed -i "s%MONGODB_URI%$MONGODB_URI%g" migrate-mongo-config-example.ts + sed -i "s%MONGODB_NAME%$MONGODB_NAME%g" migrate-mongo-config-example.ts # Setting user seed config - name: Set config.seed.example.yaml env: - SMTP_HOST: ${{ secrets.SMTP_HOST }} - SMTP_PORT: ${{ secrets.SMTP_PORT }} - SMTP_EMAIL_USER: ${{ secrets.SMTP_EMAIL_USER }} - SMTP_EMAIL_PASS: ${{ secrets.SMTP_EMAIL_PASS }} - TEST_USER_PASS: ${{ secrets.TEST_USER_PASS }} + SMTP_HOST: ${{ secrets.SMTP_HOST }} + SMTP_PORT: ${{ secrets.SMTP_PORT }} + SMTP_EMAIL_USER: ${{ secrets.SMTP_EMAIL_USER }} + SMTP_EMAIL_PASS: ${{ secrets.SMTP_EMAIL_PASS }} + TEST_USER_PASS: ${{ secrets.TEST_USER_PASS }} run: | - sed -i "s%SMTP_HOST%$SMTP_HOST%g" config.seed.example.yaml - sed -i "s%SMTP_PORT%$SMTP_PORT%g" config.seed.example.yaml - sed -i "s%SMTP_EMAIL_USER%$SMTP_EMAIL_USER%g" config.seed.example.yaml - sed -i "s%SMTP_EMAIL_PASS%$SMTP_EMAIL_PASS%g" config.seed.example.yaml - sed -i "s/TEST_USER_PASS/$TEST_USER_PASS/g" config.seed.example.yaml - sed -i "s%MONGODB_URI%$MONGODB_URI%g" config.seed.example.yaml - sed -i "s%ORY_SDK_URL%$ORY_SDK_URL%g" config.seed.example.yaml - sed -i "s/ORY_ACCESS_TOKEN/$ORY_ACCESS_TOKEN/g" config.seed.example.yaml - sed -i "s/ALETHEIA_SCHEMA_ID/$ALETHEIA_SCHEMA_ID/g" config.seed.example.yaml + sed -i "s%SMTP_HOST%$SMTP_HOST%g" config.seed.example.yaml + sed -i "s%SMTP_PORT%$SMTP_PORT%g" config.seed.example.yaml + sed -i "s%SMTP_EMAIL_USER%$SMTP_EMAIL_USER%g" config.seed.example.yaml + sed -i "s%SMTP_EMAIL_PASS%$SMTP_EMAIL_PASS%g" config.seed.example.yaml + sed -i "s/TEST_USER_PASS/$TEST_USER_PASS/g" config.seed.example.yaml + sed -i "s%MONGODB_URI%$MONGODB_URI%g" config.seed.example.yaml + sed -i "s%ORY_SDK_URL%$ORY_SDK_URL%g" config.seed.example.yaml + sed -i "s/ORY_ACCESS_TOKEN/$ORY_ACCESS_TOKEN/g" config.seed.example.yaml + sed -i "s/ALETHEIA_SCHEMA_ID/$ALETHEIA_SCHEMA_ID/g" config.seed.example.yaml # Build the Docker image - name: Build @@ -174,6 +180,8 @@ jobs: echo "NOVU_API_KEY=${{ secrets.PRODUCTION_NOVU_API_KEY }}" >> $GITHUB_ENV echo "NOVU_APPLICATION_IDENTIFIER=${{ secrets.PRODUCTION_NOVU_APPLICATION_IDENTIFIER }}" >> $GITHUB_ENV echo "OPENAI_API_KEY=${{ secrets.PRODUCTION_OPENAI_API_KEY }}" >> $GITHUB_ENV + echo "ZENVIA_API_URL=${{ secrets.PRODUCTION_ZENVIA_API_URL }}" >> $GITHUB_ENV + echo "ZENVIA_API_TOKEN=${{ secrets.PRODUCTION_ZENVIA_API_TOKEN }}" >> $GITHUB_ENV - name: Set environment run: | @@ -199,8 +207,8 @@ jobs: with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: 'us-east-1' - cluster-name: 'production' + aws-region: "us-east-1" + cluster-name: "production" command: kubectl apply -f ./deployment/ - name: Validation @@ -208,8 +216,8 @@ jobs: with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: 'us-east-1' - cluster-name: 'production' + aws-region: "us-east-1" + cluster-name: "production" command: kubectl rollout status deployments/aletheia -n ${{ env.ENVIRONMENT }} --timeout=360s if: success() @@ -218,7 +226,7 @@ jobs: with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: 'us-east-1' - cluster-name: 'production' + aws-region: "us-east-1" + cluster-name: "production" command: kubectl rollout undo deployments/aletheia -n ${{ env.ENVIRONMENT }} if: failure() diff --git a/.gitignore b/.gitignore index 8c762e922..ff1c1df9f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ # dependencies /node_modules +.yarn/install-state.gz + # testing /coverage /cypress.env.json @@ -65,4 +67,4 @@ data # Auto-generated documentation docs/compodoc/**/* -docs/storybook/**/* +docs/storybook/**/* \ No newline at end of file diff --git a/.yarn/cache/@grpc-grpc-js-npm-1.10.9-fc7c3de4a3-88d91c2271.zip b/.yarn/cache/@grpc-grpc-js-npm-1.10.9-fc7c3de4a3-88d91c2271.zip new file mode 100644 index 000000000..27bfc9b14 Binary files /dev/null and b/.yarn/cache/@grpc-grpc-js-npm-1.10.9-fc7c3de4a3-88d91c2271.zip differ diff --git a/.yarn/cache/@grpc-grpc-js-npm-1.9.9-5641bba424-71183a483b.zip b/.yarn/cache/@grpc-grpc-js-npm-1.9.9-5641bba424-71183a483b.zip deleted file mode 100644 index 9fea62e96..000000000 Binary files a/.yarn/cache/@grpc-grpc-js-npm-1.9.9-5641bba424-71183a483b.zip and /dev/null differ diff --git a/.yarn/cache/@grpc-proto-loader-npm-0.7.13-be5b6af1c1-399c1b8a46.zip b/.yarn/cache/@grpc-proto-loader-npm-0.7.13-be5b6af1c1-399c1b8a46.zip new file mode 100644 index 000000000..db353380c Binary files /dev/null and b/.yarn/cache/@grpc-proto-loader-npm-0.7.13-be5b6af1c1-399c1b8a46.zip differ diff --git a/.yarn/cache/@js-sdsl-ordered-map-npm-4.4.2-158f6c6b74-a927ae4ff8.zip b/.yarn/cache/@js-sdsl-ordered-map-npm-4.4.2-158f6c6b74-a927ae4ff8.zip new file mode 100644 index 000000000..19bdb23ad Binary files /dev/null and b/.yarn/cache/@js-sdsl-ordered-map-npm-4.4.2-158f6c6b74-a927ae4ff8.zip differ diff --git a/.yarn/cache/async-limiter-npm-1.0.1-7e6819bcdb-2b849695b4.zip b/.yarn/cache/async-limiter-npm-1.0.1-7e6819bcdb-2b849695b4.zip deleted file mode 100644 index 38dfc9d4b..000000000 Binary files a/.yarn/cache/async-limiter-npm-1.0.1-7e6819bcdb-2b849695b4.zip and /dev/null differ diff --git a/.yarn/cache/axios-npm-1.7.2-c89264f6f7-e457e2b0ab.zip b/.yarn/cache/axios-npm-1.7.2-c89264f6f7-e457e2b0ab.zip new file mode 100644 index 000000000..5d20f72e9 Binary files /dev/null and b/.yarn/cache/axios-npm-1.7.2-c89264f6f7-e457e2b0ab.zip differ diff --git a/.yarn/cache/braces-npm-3.0.2-782240b28a-e2a8e769a8.zip b/.yarn/cache/braces-npm-3.0.2-782240b28a-e2a8e769a8.zip deleted file mode 100644 index 92998e3cc..000000000 Binary files a/.yarn/cache/braces-npm-3.0.2-782240b28a-e2a8e769a8.zip and /dev/null differ diff --git a/.yarn/cache/braces-npm-3.0.3-582c14023c-b95aa0b3bd.zip b/.yarn/cache/braces-npm-3.0.3-582c14023c-b95aa0b3bd.zip new file mode 100644 index 000000000..b4e6ac897 Binary files /dev/null and b/.yarn/cache/braces-npm-3.0.3-582c14023c-b95aa0b3bd.zip differ diff --git a/.yarn/cache/decompress-response-npm-6.0.0-359de2878c-d377cf47e0.zip b/.yarn/cache/decompress-response-npm-6.0.0-359de2878c-d377cf47e0.zip new file mode 100644 index 000000000..bbc1db518 Binary files /dev/null and b/.yarn/cache/decompress-response-npm-6.0.0-359de2878c-d377cf47e0.zip differ diff --git a/.yarn/cache/deep-extend-npm-0.6.0-e182924219-7be7e5a8d4.zip b/.yarn/cache/deep-extend-npm-0.6.0-e182924219-7be7e5a8d4.zip new file mode 100644 index 000000000..87f0270ec Binary files /dev/null and b/.yarn/cache/deep-extend-npm-0.6.0-e182924219-7be7e5a8d4.zip differ diff --git a/.yarn/cache/ejs-npm-3.1.10-4e8cf4bdc1-ce90637e9c.zip b/.yarn/cache/ejs-npm-3.1.10-4e8cf4bdc1-ce90637e9c.zip new file mode 100644 index 000000000..2771e3267 Binary files /dev/null and b/.yarn/cache/ejs-npm-3.1.10-4e8cf4bdc1-ce90637e9c.zip differ diff --git a/.yarn/cache/ejs-npm-3.1.9-e201b2088c-af6f10eb81.zip b/.yarn/cache/ejs-npm-3.1.9-e201b2088c-af6f10eb81.zip deleted file mode 100644 index 3f5e3828e..000000000 Binary files a/.yarn/cache/ejs-npm-3.1.9-e201b2088c-af6f10eb81.zip and /dev/null differ diff --git a/.yarn/cache/fill-range-npm-7.0.1-b8b1817caa-cc283f4e65.zip b/.yarn/cache/fill-range-npm-7.0.1-b8b1817caa-cc283f4e65.zip deleted file mode 100644 index 1da4a361d..000000000 Binary files a/.yarn/cache/fill-range-npm-7.0.1-b8b1817caa-cc283f4e65.zip and /dev/null differ diff --git a/.yarn/cache/fill-range-npm-7.1.1-bf491486db-b4abfbca38.zip b/.yarn/cache/fill-range-npm-7.1.1-bf491486db-b4abfbca38.zip new file mode 100644 index 000000000..399180922 Binary files /dev/null and b/.yarn/cache/fill-range-npm-7.1.1-bf491486db-b4abfbca38.zip differ diff --git a/.yarn/cache/ini-npm-1.3.8-fb5040b4c0-dfd98b0ca3.zip b/.yarn/cache/ini-npm-1.3.8-fb5040b4c0-dfd98b0ca3.zip new file mode 100644 index 000000000..ee9245b9c Binary files /dev/null and b/.yarn/cache/ini-npm-1.3.8-fb5040b4c0-dfd98b0ca3.zip differ diff --git a/.yarn/cache/mimic-response-npm-3.1.0-a4a24b4e96-25739fee32.zip b/.yarn/cache/mimic-response-npm-3.1.0-a4a24b4e96-25739fee32.zip new file mode 100644 index 000000000..a47a9a623 Binary files /dev/null and b/.yarn/cache/mimic-response-npm-3.1.0-a4a24b4e96-25739fee32.zip differ diff --git a/.yarn/cache/packument-npm-2.0.0-1a3f6fd340-ac6882ce3b.zip b/.yarn/cache/packument-npm-2.0.0-1a3f6fd340-ac6882ce3b.zip new file mode 100644 index 000000000..0b775f4e4 Binary files /dev/null and b/.yarn/cache/packument-npm-2.0.0-1a3f6fd340-ac6882ce3b.zip differ diff --git a/.yarn/cache/protobufjs-npm-7.3.1-22f14d5229-81f5543a59.zip b/.yarn/cache/protobufjs-npm-7.3.1-22f14d5229-81f5543a59.zip new file mode 100644 index 000000000..f613a1936 Binary files /dev/null and b/.yarn/cache/protobufjs-npm-7.3.1-22f14d5229-81f5543a59.zip differ diff --git a/.yarn/cache/rc-npm-1.2.8-d6768ac936-2e26e052f8.zip b/.yarn/cache/rc-npm-1.2.8-d6768ac936-2e26e052f8.zip new file mode 100644 index 000000000..f7372f98e Binary files /dev/null and b/.yarn/cache/rc-npm-1.2.8-d6768ac936-2e26e052f8.zip differ diff --git a/.yarn/cache/registry-auth-token-npm-4.2.2-ffd70a9849-c503019854.zip b/.yarn/cache/registry-auth-token-npm-4.2.2-ffd70a9849-c503019854.zip new file mode 100644 index 000000000..aac4909a0 Binary files /dev/null and b/.yarn/cache/registry-auth-token-npm-4.2.2-ffd70a9849-c503019854.zip differ diff --git a/.yarn/cache/registry-url-npm-5.1.0-f58d0ca7ff-bcea86c84a.zip b/.yarn/cache/registry-url-npm-5.1.0-f58d0ca7ff-bcea86c84a.zip new file mode 100644 index 000000000..de1542129 Binary files /dev/null and b/.yarn/cache/registry-url-npm-5.1.0-f58d0ca7ff-bcea86c84a.zip differ diff --git a/.yarn/cache/simple-concat-npm-1.0.1-48df70de29-4d211042cc.zip b/.yarn/cache/simple-concat-npm-1.0.1-48df70de29-4d211042cc.zip new file mode 100644 index 000000000..6b694bed9 Binary files /dev/null and b/.yarn/cache/simple-concat-npm-1.0.1-48df70de29-4d211042cc.zip differ diff --git a/.yarn/cache/simple-get-npm-4.0.1-fa2a97645d-e4132fd27c.zip b/.yarn/cache/simple-get-npm-4.0.1-fa2a97645d-e4132fd27c.zip new file mode 100644 index 000000000..95cce5fb2 Binary files /dev/null and b/.yarn/cache/simple-get-npm-4.0.1-fa2a97645d-e4132fd27c.zip differ diff --git a/.yarn/cache/strip-json-comments-npm-2.0.1-e7883b2d04-1074ccb632.zip b/.yarn/cache/strip-json-comments-npm-2.0.1-e7883b2d04-1074ccb632.zip new file mode 100644 index 000000000..9c537fe05 Binary files /dev/null and b/.yarn/cache/strip-json-comments-npm-2.0.1-e7883b2d04-1074ccb632.zip differ diff --git a/.yarn/cache/ws-npm-6.2.2-ca62a10fa0-aec3154ec5.zip b/.yarn/cache/ws-npm-6.2.2-ca62a10fa0-aec3154ec5.zip deleted file mode 100644 index 3ce33965a..000000000 Binary files a/.yarn/cache/ws-npm-6.2.2-ca62a10fa0-aec3154ec5.zip and /dev/null differ diff --git a/.yarn/cache/ws-npm-7.5.9-26f12a5ed6-c3c100a181.zip b/.yarn/cache/ws-npm-7.5.9-26f12a5ed6-c3c100a181.zip deleted file mode 100644 index 5e9490b85..000000000 Binary files a/.yarn/cache/ws-npm-7.5.9-26f12a5ed6-c3c100a181.zip and /dev/null differ diff --git a/.yarn/cache/ws-npm-8.11.0-ab72116a01-316b33aba3.zip b/.yarn/cache/ws-npm-8.11.0-ab72116a01-316b33aba3.zip deleted file mode 100644 index dbd70f152..000000000 Binary files a/.yarn/cache/ws-npm-8.11.0-ab72116a01-316b33aba3.zip and /dev/null differ diff --git a/.yarn/cache/ws-npm-8.13.0-26ffa3016a-53e991bbf9.zip b/.yarn/cache/ws-npm-8.13.0-26ffa3016a-53e991bbf9.zip deleted file mode 100644 index 74e59aab9..000000000 Binary files a/.yarn/cache/ws-npm-8.13.0-26ffa3016a-53e991bbf9.zip and /dev/null differ diff --git a/.yarn/cache/ws-npm-8.14.2-b339ac47a2-3ca0dad26e.zip b/.yarn/cache/ws-npm-8.14.2-b339ac47a2-3ca0dad26e.zip deleted file mode 100644 index 3b175343d..000000000 Binary files a/.yarn/cache/ws-npm-8.14.2-b339ac47a2-3ca0dad26e.zip and /dev/null differ diff --git a/.yarn/cache/ws-npm-8.17.1-f57fb24a2c-442badcce1.zip b/.yarn/cache/ws-npm-8.17.1-f57fb24a2c-442badcce1.zip new file mode 100644 index 000000000..a0ebe465f Binary files /dev/null and b/.yarn/cache/ws-npm-8.17.1-f57fb24a2c-442badcce1.zip differ diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz deleted file mode 100644 index 5bde176e4..000000000 Binary files a/.yarn/install-state.gz and /dev/null differ diff --git a/config.development.yaml b/config.development.yaml index 966a6d173..459b1d0cd 100644 --- a/config.development.yaml +++ b/config.development.yaml @@ -41,4 +41,7 @@ services: application_identifier: NOVU_APPLICATION_IDENTIFIER openai: api_key: OPENAI_API_KEY + zenvia: + api_url: ZENVIA_API_URL + api_token: ZENVIA_API_TOKEN diff --git a/config.example.yaml b/config.example.yaml index 79de44edc..0c2cc0d0c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -43,4 +43,7 @@ services: api_key: NOVU_API_KEY openai: api_key: OPENAI_API_KEY + zenvia: + api_url: ZENVIA_API_URL + api_token: ZENVIA_API_TOKEN diff --git a/config.production.yaml b/config.production.yaml index d6a5445b7..d592b4f54 100644 --- a/config.production.yaml +++ b/config.production.yaml @@ -39,3 +39,6 @@ services: application_identifier: NOVU_APPLICATION_IDENTIFIER openai: api_key: OPENAI_API_KEY + zenvia: + api_url: ZENVIA_API_URL + api_token: ZENVIA_API_TOKEN diff --git a/config.seed.example.yaml b/config.seed.example.yaml index 1c13d4861..816c964df 100644 --- a/config.seed.example.yaml +++ b/config.seed.example.yaml @@ -37,3 +37,6 @@ services: api_key: NOVU_API_KEY openai: api_key: OPENAI_API_KEY + zenvia: + api_url: ZENVIA_API_URL + api_token: ZENVIA_API_TOKEN diff --git a/config.seed.test.ci.yaml b/config.seed.test.ci.yaml index 7785ca01a..c4e6c8935 100644 --- a/config.seed.test.ci.yaml +++ b/config.seed.test.ci.yaml @@ -36,3 +36,6 @@ services: instanceId: {env(GITLAB_FEATURE_FLAG_INSTANCE_ID)} openai: api_key: {env(OPENAI_API_KEY)} + zenvia: + api_url: {env(ZENVIA_API_URL)} + api_token: {env(ZENVIA_API_TOKEN)} diff --git a/config.test.ci.yaml b/config.test.ci.yaml index 429d667f6..51b17baa5 100644 --- a/config.test.ci.yaml +++ b/config.test.ci.yaml @@ -35,3 +35,6 @@ services: secretAccessKey: {env(AWS_SECRET_ACCESS_KEY)} openai: api_key: {env(OPENAI_API_KEY)} + zenvia: + api_url: {env(ZENVIA_API_URL)} + api_token: {env(ZENVIA_API_TOKEN)} diff --git a/cypress/e2e/tests/header.cy.ts b/cypress/e2e/tests/header.cy.ts index 0265ae351..b7351b02f 100644 --- a/cypress/e2e/tests/header.cy.ts +++ b/cypress/e2e/tests/header.cy.ts @@ -23,10 +23,16 @@ describe("Test the header menus", () => { cy.url().should("contains", "claim"); }); - it("Open side bar and click sources", () => { + it("Open side bar and click source", () => { cy.get(locators.menu.SIDE_MENU).click(); - cy.get("[data-cy=testSourcestItem]").click(); - cy.url().should("contains", "sources"); + cy.get("[data-cy=testSourcetItem]").click(); + cy.url().should("contains", "source"); + }); + + it("Open side bar and click source", () => { + cy.get(locators.menu.SIDE_MENU).click(); + cy.get("[data-cy=testVerificationRequestItem]").click(); + cy.url().should("contains", "verification-request"); }); it("Open side bar and click about", () => { diff --git a/cypress/e2e/tests/image.cy.ts b/cypress/e2e/tests/image.cy.ts index a1a35503f..539ce3a89 100644 --- a/cypress/e2e/tests/image.cy.ts +++ b/cypress/e2e/tests/image.cy.ts @@ -23,9 +23,9 @@ describe("Create image claim", () => { cy.get(locators.claim.INPUT_SOURCE) .should("be.visible") - .type(claim.source); + .type(claim.imageSource); cy.get(locators.claim.BTN_UPLOAD_IMAGE).should("be.visible"); - cy.get('input[type="file"]').selectFile(claim.imageSource, { + cy.get('input[type="file"]').selectFile(claim.imageSourceFile, { force: true, }); cy.get("[data-cy=testCheckboxAcceptTerms]").click(); diff --git a/cypress/e2e/tests/review.cy.ts b/cypress/e2e/tests/review.cy.ts index d22939c77..661cc1e5e 100644 --- a/cypress/e2e/tests/review.cy.ts +++ b/cypress/e2e/tests/review.cy.ts @@ -23,6 +23,31 @@ const goToClaimReviewPage = () => { cy.get(locators.claim.BTN_SEE_FULL_REVIEW).should("exist"); }; +const assignUser = () => { + cy.get(locators.claimReview.BTN_START_CLAIM_REVIEW).should("exist").click(); + cy.get(locators.claimReview.INPUT_USER) + .should("exist") + .type(review.username, { delay: 200 }); + cy.get(".ant-select-item-option-active").click(); + cy.get('[title="reCAPTCHA"]').should("exist"); + cy.get(locators.claimReview.BTN_ASSIGN_USER).should("be.disabled"); + cy.checkRecaptcha(); + cy.get(locators.claimReview.BTN_ASSIGN_USER).should("be.enabled").click(); + cy.get(locators.claimReview.INPUT_CLASSIFICATION).should("exist"); +}; + +const blockAssignedUserReview = () => { + cy.checkRecaptcha(); + cy.get(locators.claimReview.BTN_SELECTED_REVIEW).should("exist").click(); + cy.get(locators.claimReview.INPUT_REVIEWER) + .should("exist") + .type(review.username, { delay: 200 }); + cy.get(".ant-select-item-option-active").click(); + cy.checkRecaptcha(); + cy.get(locators.claimReview.BTN_SUBMIT).should("be.enabled").click(); + cy.get(locators.claimReview.TEXT_REVIEWER_ERROR).should("exist"); +}; + describe("Test claim review", () => { it("should not show start review when not logged in", () => { cy.visit( @@ -36,25 +61,12 @@ describe("Test claim review", () => { it("should be able to assign a user", () => { cy.login(); goToClaimReviewPage(); - cy.get(locators.claimReview.BTN_START_CLAIM_REVIEW) - .should("exist") - .click(); - cy.get(locators.claimReview.INPUT_USER) - .should("exist") - .type(review.username, { delay: 200 }); - cy.get(".ant-select-item-option-active").click(); - cy.get('[title="reCAPTCHA"]').should("exist"); - cy.get(locators.claimReview.BTN_ASSIGN_USER).should("be.disabled"); - cy.checkRecaptcha(); - cy.get(locators.claimReview.BTN_ASSIGN_USER) - .should("be.enabled") - .click(); - cy.get(locators.claimReview.INPUT_CLASSIFICATION).should("exist"); + assignUser(); }); it("should be able to submit full review fields", () => { cy.login(); - cy.intercept("GET", "/api/claimreviewtask/editor-content/*", (req) => { + cy.intercept("GET", "/api/reviewtask/editor-content/*", (req) => { req.reply({ statusCode: 200, body: review.editorContent, @@ -102,16 +114,6 @@ describe("Test claim review", () => { it("should not be able submit after choosing assigned user as reviewer", () => { cy.login(); goToClaimReviewPage(); - cy.checkRecaptcha(); - cy.get(locators.claimReview.BTN_SELECTED_REVIEW) - .should("exist") - .click(); - cy.get(locators.claimReview.INPUT_REVIEWER) - .should("exist") - .type(review.username, { delay: 200 }); - cy.get(".ant-select-item-option-active").click(); - cy.checkRecaptcha(); - cy.get(locators.claimReview.BTN_SUBMIT).should("be.enabled").click(); - cy.get(locators.claimReview.TEXT_REVIEWER_ERROR).should("exist"); + blockAssignedUserReview(); }); }); diff --git a/cypress/e2e/tests/source.cy.ts b/cypress/e2e/tests/source.cy.ts new file mode 100644 index 000000000..e211d72c4 --- /dev/null +++ b/cypress/e2e/tests/source.cy.ts @@ -0,0 +1,82 @@ +/* eslint-disable no-undef */ +/// + +import locators from "../../support/locators"; +import source from "../../fixtures/source"; +import review from "../../fixtures/review"; + +const goToSourceReviewPage = () => { + cy.visit(`http://localhost:3000/source/${source.data_hash}`); +}; + +describe("Create source and source review", () => { + beforeEach("login", () => cy.login()); + + it("Should create a new Source", () => { + cy.get(locators.floatButton.FLOAT_BUTTON).should("be.visible").click(); + cy.get(locators.floatButton.ADD_SOURCE).should("be.visible").click(); + + cy.url().should("contains", "source"); + cy.get(locators.source.INPUT_SOURCE).type(source.href); + cy.checkRecaptcha(); + cy.get(`${locators.source.BTN_SUBMIT_SOURCE}`).click(); + }); + + it("should not show start source review when not logged in", () => { + goToSourceReviewPage(); + cy.get(locators.claimReview.BTN_START_CLAIM_REVIEW).should("not.exist"); + }); + + it("should be able to assign a user", () => { + goToSourceReviewPage(); + cy.get(locators.claimReview.BTN_START_CLAIM_REVIEW) + .should("exist") + .click(); + cy.get(locators.claimReview.INPUT_USER) + .should("exist") + .type(review.username, { delay: 200 }); + cy.get(".ant-select-item-option-active").click(); + cy.get('[title="reCAPTCHA"]').should("exist"); + cy.get(locators.claimReview.BTN_ASSIGN_USER).should("be.disabled"); + cy.checkRecaptcha(); + cy.get(locators.claimReview.BTN_ASSIGN_USER) + .should("be.enabled") + .click(); + cy.get(locators.claimReview.INPUT_CLASSIFICATION).should("exist"); + }); + + it("should be able to submit source review fields", () => { + goToSourceReviewPage(); + cy.get(locators.claimReview.INPUT_CLASSIFICATION) + .should("exist") + .click(); + cy.get(`[data-cy=${review.classification}]`) + .should("be.visible") + .click(); + + cy.get(locators.claimReview.INPUT_SUMMARY) + .should("exist") + .type(review.summary); + + cy.checkRecaptcha(); + cy.get(locators.claimReview.BTN_FINISH_REPORT) + .should("be.enabled") + .click(); + cy.get(locators.claimReview.BTN_SELECTED_REVIEW).should("exist"); + }); + + it("should not be able submit after choosing assigned user as reviewer", () => { + goToSourceReviewPage(); + cy.checkRecaptcha(); + cy.get(locators.claimReview.BTN_SELECTED_REVIEW) + .should("exist") + .click(); + cy.get(locators.claimReview.INPUT_REVIEWER) + .should("exist") + .type(review.username, { delay: 200 }); + cy.get(".ant-select-item-option-active").click(); + cy.checkRecaptcha(); + cy.get(locators.claimReview.BTN_SUBMIT).should("be.enabled").click(); + cy.get(locators.claimReview.TEXT_REVIEWER_ERROR).should("exist"); + }); +}); diff --git a/cypress/fixtures/claim.ts b/cypress/fixtures/claim.ts index 1ac7c0987..fc04e20b5 100644 --- a/cypress/fixtures/claim.ts +++ b/cypress/fixtures/claim.ts @@ -1,10 +1,11 @@ const claim = { title: "Speech Claim Title", content: "Speech Claim Content Lorem Ipsum Dolor Sit Amet...", - source: "http://wikipedia.org", + source: "https://wikimedia.org", + imageSource: "https://aletheiafact.org", slug: "speech-claim-title", imageTitle: "Image Claim Title", - imageSource: "cypress/fixtures/imageTest1.png", + imageSourceFile: "cypress/fixtures/imageTest1.png", imagePersonalitySource: "cypress/fixtures/imageTest2.png", imageSlug: "image-claim-title", }; diff --git a/cypress/fixtures/review.ts b/cypress/fixtures/review.ts index eb3ebaf06..66b3bb2bb 100644 --- a/cypress/fixtures/review.ts +++ b/cypress/fixtures/review.ts @@ -5,7 +5,7 @@ const review = { question2: "Question 2 content", report: "Verification Report content", process: "Verification process content", - source1: "https://wikipedia.org", + source1: "https://wikidata.org", source2: "https://google.com", classification: "arguable", editorContent: { @@ -32,7 +32,7 @@ const review = { { type: "link", attrs: { - href: "https://wikipedia.org", + href: "https://wikidata.org", target: null, auto: false, id: "lovjnfw8", diff --git a/cypress/fixtures/source.ts b/cypress/fixtures/source.ts new file mode 100644 index 000000000..d7cfd9529 --- /dev/null +++ b/cypress/fixtures/source.ts @@ -0,0 +1,6 @@ +const source = { + href: "https://wikipedia.org", + data_hash: "bb2c466041b713fdd03e28917286baa2", +}; + +export default source; diff --git a/cypress/support/locators.ts b/cypress/support/locators.ts index 95ae60425..f6265c57a 100644 --- a/cypress/support/locators.ts +++ b/cypress/support/locators.ts @@ -30,10 +30,16 @@ const locators = { INPUT_SOURCE: "[data-cy=testSource1]", }, + source: { + INPUT_SOURCE: "[data-cy=testClaimReviewsource]", + BTN_SUBMIT_SOURCE: "[data-cy=testSaveButton]", + }, + floatButton: { FLOAT_BUTTON: "[data-cy=testFloatButton]", ADD_CLAIM: "[data-cy=testFloatButtonAddClaim]", ADD_PERSONALITY: "[data-cy=testFloatButtonAddPersonality]", + ADD_SOURCE: "[data-cy=testFloatButtonAddSources]", }, claimReview: { diff --git a/lib/editor-parser.ts b/lib/editor-parser.ts index d4ef429e4..99650ae9d 100644 --- a/lib/editor-parser.ts +++ b/lib/editor-parser.ts @@ -1,19 +1,39 @@ import { ObjectMark, RemirrorJSON } from "remirror"; -import { ReviewTaskMachineContextReviewData } from "../server/claim-review-task/dto/create-claim-review-task.dto"; -import { ReportModelEnum } from "../server/types/enums"; +import { ReviewTaskMachineContextReviewData } from "../server/review-task/dto/create-review-task.dto"; +import { ReportModelEnum, ReviewTaskTypeEnum } from "../server/types/enums"; -type SchemaType = { - summary: string; +type ClaimReviewSchemaType = { + summary?: string; verification?: string; report?: string; - sources: any[]; - questions?: any[]; + sources?: any[]; + questions?: string[]; }; -const getEditorSchemaArray = (reportModel = ReportModelEnum.FactChecking) => - reportModel === ReportModelEnum.FactChecking - ? ["summary", "report", "verification", "questions", "paragraph"] - : ["summary", "paragraph"]; +type ReviewSchemaType = ClaimReviewSchemaType; + +const getEditorSchemaArray = (reportModel = ReportModelEnum.FactChecking) => { + if (!reportModel) { + return []; + } + + if (!Object.values(ReportModelEnum).includes(reportModel)) { + return []; + } + + const editorFields = { + [ReportModelEnum.FactChecking]: [ + "summary", + "report", + "verification", + "questions", + "paragraph", + ], + [ReportModelEnum.InformativeNews]: ["summary", "paragraph"], + }; + + return editorFields[reportModel]; +}; const MarkupCleanerRegex = /{{[^|]+\|([^}]+)}}/; @@ -24,7 +44,7 @@ const createParagraphBlock = ( content: [{ type: "paragraph" }], }); -const getDefaultDoc = (reportModel: string): RemirrorJSON => { +const getDefaultDoc = (reviewTaskType, reportModel: string): RemirrorJSON => { const baseContent = [ createParagraphBlock("summary"), createParagraphBlock("questions"), @@ -32,7 +52,10 @@ const getDefaultDoc = (reportModel: string): RemirrorJSON => { createParagraphBlock("verification"), ]; - if (reportModel === ReportModelEnum.InformativeNews) { + if ( + reportModel === ReportModelEnum.InformativeNews || + reviewTaskType === ReviewTaskTypeEnum.Source + ) { return { type: "doc", content: [createParagraphBlock("summary")], @@ -47,7 +70,7 @@ const getDefaultDoc = (reportModel: string): RemirrorJSON => { export class EditorParser { hasSources(sources): boolean { - return sources.length > 0; + return sources?.length > 0; } getSourceByProperty(sources, property) { @@ -164,13 +187,25 @@ export class EditorParser { return newSchema; } - editor2schema(data: RemirrorJSON): ReviewTaskMachineContextReviewData { - const schema: SchemaType = { - summary: "", - sources: [], - }; + editor2schema({ + content, + attrs = { reviewTaskType: ReviewTaskTypeEnum.Claim }, + }: RemirrorJSON): ReviewTaskMachineContextReviewData & { + summary?: string; + source?: string; + } { + let schema: Partial; + switch (attrs.reviewTaskType) { + case ReviewTaskTypeEnum.Claim: + schema = { summary: "", sources: [] }; + break; + default: + schema = {}; + break; + } + const questions = []; - for (const cardContent of data?.content) { + for (const cardContent of content) { if (getEditorSchemaArray().includes(cardContent?.type)) { if (cardContent?.type === "questions") { for (const { content } of cardContent.content) { @@ -203,10 +238,13 @@ export class EditorParser { * Needed to do this conditional because the form validation when the reportModel * is equal to Informative news requires the questions field. */ - if (schema.report || schema.verification) { + if ("report" in schema || "verification" in schema) { schema.questions = questions; } - schema.sources = this.replaceSourceContentToTextRange(schema); + + if ("sources" in schema) { + schema.sources = this.replaceSourceContentToTextRange(schema); + } return schema; } @@ -293,10 +331,11 @@ export class EditorParser { async schema2editor( schema: ReviewTaskMachineContextReviewData, - reportModel = ReportModelEnum.FactChecking + reportModel = ReportModelEnum.FactChecking, + reviewTaskType: string = ReviewTaskTypeEnum.Claim ): Promise { if (!schema) { - return getDefaultDoc(reportModel); + return getDefaultDoc(reviewTaskType, reportModel); } const doc: RemirrorJSON = { diff --git a/migrations/20240610193252-add-claimreviewid-on-sentences.ts b/migrations/20240610193252-add-claimreviewid-on-sentences.ts new file mode 100644 index 000000000..0d00fdc1a --- /dev/null +++ b/migrations/20240610193252-add-claimreviewid-on-sentences.ts @@ -0,0 +1,66 @@ +import { Db } from "mongodb"; + +export async function up(db: Db) { + const collection = db.collection("sentences"); + + const pipeline = [ + { + $lookup: { + from: "paragraphs", + localField: "_id", + foreignField: "content", + as: "paragraphs", + }, + }, + { + $unwind: "$paragraphs", + }, + { + $lookup: { + from: "speeches", + localField: "paragraphs._id", + foreignField: "content", + as: "speeches", + }, + }, + { + $unwind: "$speeches", + }, + { + $lookup: { + from: "claimrevisions", + localField: "speeches._id", + foreignField: "contentId", + as: "claimRevisions", + }, + }, + { + $unwind: "$claimRevisions", + }, + { + $addFields: { + claimRevisionId: "$claimRevisions._id", + }, + }, + ]; + + const sentencesWithClaimRevision = await collection + .aggregate(pipeline) + .toArray(); + + for (const sentence of sentencesWithClaimRevision) { + await collection.updateOne( + { _id: sentence._id }, + { $set: { claimRevisionId: sentence.claimRevisionId } } + ); + } +} + +export async function down(db: Db) { + const collection = db.collection("sentences"); + + await collection.updateMany( + { claimRevisionId: { $exists: true } }, + { $unset: { claimRevisionId: "" } } + ); +} diff --git a/migrations/20240610195130-add-claimreviewid-on-debates-sentences.ts b/migrations/20240610195130-add-claimreviewid-on-debates-sentences.ts new file mode 100644 index 000000000..ab2ff2ed7 --- /dev/null +++ b/migrations/20240610195130-add-claimreviewid-on-debates-sentences.ts @@ -0,0 +1,74 @@ +import { Db } from "mongodb"; + +export async function up(db: Db) { + const collection = db.collection("sentences"); + + const pipeline = [ + { + $lookup: { + from: "paragraphs", + localField: "_id", + foreignField: "content", + as: "paragraphs", + }, + }, + { + $unwind: "$paragraphs", + }, + { + $lookup: { + from: "speeches", + localField: "paragraphs._id", + foreignField: "content", + as: "speeches", + }, + }, + { + $unwind: "$speeches", + }, + { + $lookup: { + from: "debates", + localField: "speeches._id", + foreignField: "content", + as: "debate", + }, + }, + { + $lookup: { + from: "claimrevisions", + localField: "debate._id", + foreignField: "contentId", + as: "claimRevisions", + }, + }, + { + $unwind: "$claimRevisions", + }, + { + $addFields: { + claimRevisionId: "$claimRevisions._id", + }, + }, + ]; + + const sentencesWithClaimRevision = await collection + .aggregate(pipeline) + .toArray(); + + for (const sentence of sentencesWithClaimRevision) { + await collection.updateOne( + { _id: sentence._id }, + { $set: { claimRevisionId: sentence.claimRevisionId } } + ); + } +} + +export async function down(db: Db) { + const collection = db.collection("sentences"); + + await collection.updateMany( + { claimRevisionId: { $exists: true } }, + { $unset: { claimRevisionId: "" } } + ); +} diff --git a/migrations/20240610195623-add-claimreviewid-on-unattributed-sentences.ts b/migrations/20240610195623-add-claimreviewid-on-unattributed-sentences.ts new file mode 100644 index 000000000..8fb41186a --- /dev/null +++ b/migrations/20240610195623-add-claimreviewid-on-unattributed-sentences.ts @@ -0,0 +1,63 @@ +import { Db } from "mongodb"; + +export async function up(db: Db) { + const collection = db.collection("sentences"); + + const pipeline = [ + { + $lookup: { + from: "paragraphs", + localField: "_id", + foreignField: "content", + as: "paragraphs", + }, + }, + { + $unwind: "$paragraphs", + }, + { + $lookup: { + from: "unattributeds", + localField: "paragraphs._id", + foreignField: "content", + as: "unattributed", + }, + }, + { + $lookup: { + from: "claimrevisions", + localField: "unattributed._id", + foreignField: "contentId", + as: "claimRevisions", + }, + }, + { + $unwind: "$claimRevisions", + }, + { + $addFields: { + claimRevisionId: "$claimRevisions._id", + }, + }, + ]; + + const sentencesWithClaimRevision = await collection + .aggregate(pipeline) + .toArray(); + + for (const sentence of sentencesWithClaimRevision) { + await collection.updateOne( + { _id: sentence._id }, + { $set: { claimRevisionId: sentence.claimRevisionId } } + ); + } +} + +export async function down(db: Db) { + const collection = db.collection("sentences"); + + await collection.updateMany( + { claimRevisionId: { $exists: true } }, + { $unset: { claimRevisionId: "" } } + ); +} diff --git a/migrations/20240610202706-add-claimreviewid-on-debates.ts b/migrations/20240610202706-add-claimreviewid-on-debates.ts new file mode 100644 index 000000000..506b67394 --- /dev/null +++ b/migrations/20240610202706-add-claimreviewid-on-debates.ts @@ -0,0 +1,44 @@ +import { Db } from "mongodb"; + +export async function up(db: Db) { + const collection = db.collection("debates"); + + const pipeline = [ + { + $lookup: { + from: "claimrevisions", + localField: "_id", + foreignField: "contentId", + as: "claimRevisions", + }, + }, + { + $unwind: "$claimRevisions", + }, + { + $addFields: { + claimRevisionId: "$claimRevisions._id", + }, + }, + ]; + + const debatesWithClaimRevision = await collection + .aggregate(pipeline) + .toArray(); + + for (const debates of debatesWithClaimRevision) { + await collection.updateOne( + { _id: debates._id }, + { $set: { claimRevisionId: debates.claimRevisionId } } + ); + } +} + +export async function down(db: Db) { + const collection = db.collection("debates"); + + await collection.updateMany( + { claimRevisionId: { $exists: true } }, + { $unset: { claimRevisionId: "" } } + ); +} diff --git a/migrations/20240610204108-add-claimreviewid-on-images.ts b/migrations/20240610204108-add-claimreviewid-on-images.ts new file mode 100644 index 000000000..3793d335e --- /dev/null +++ b/migrations/20240610204108-add-claimreviewid-on-images.ts @@ -0,0 +1,44 @@ +import { Db } from "mongodb"; + +export async function up(db: Db) { + const collection = db.collection("images"); + + const pipeline = [ + { + $lookup: { + from: "claimrevisions", + localField: "_id", + foreignField: "contentId", + as: "claimRevisions", + }, + }, + { + $unwind: "$claimRevisions", + }, + { + $addFields: { + claimRevisionId: "$claimRevisions._id", + }, + }, + ]; + + const imagesWithClaimRevision = await collection + .aggregate(pipeline) + .toArray(); + + for (const images of imagesWithClaimRevision) { + await collection.updateOne( + { _id: images._id }, + { $set: { claimRevisionId: images.claimRevisionId } } + ); + } +} + +export async function down(db: Db) { + const collection = db.collection("images"); + + await collection.updateMany( + { claimRevisionId: { $exists: true } }, + { $unset: { claimRevisionId: "" } } + ); +} diff --git a/migrations/20240611185358-add-claimreviewid-on-paragraphs.ts b/migrations/20240611185358-add-claimreviewid-on-paragraphs.ts new file mode 100644 index 000000000..65cca447a --- /dev/null +++ b/migrations/20240611185358-add-claimreviewid-on-paragraphs.ts @@ -0,0 +1,55 @@ +import { Db } from "mongodb"; + +export async function up(db: Db) { + const collection = db.collection("paragraphs"); + + const pipeline = [ + { + $lookup: { + from: "speeches", + localField: "_id", + foreignField: "content", + as: "speeches", + }, + }, + { + $unwind: "$speeches", + }, + { + $lookup: { + from: "claimrevisions", + localField: "speeches._id", + foreignField: "contentId", + as: "claimRevisions", + }, + }, + { + $unwind: "$claimRevisions", + }, + { + $addFields: { + claimRevisionId: "$claimRevisions._id", + }, + }, + ]; + + const paragraphsWithClaimRevision = await collection + .aggregate(pipeline) + .toArray(); + + for (const paragraph of paragraphsWithClaimRevision) { + await collection.updateOne( + { _id: paragraph._id }, + { $set: { claimRevisionId: paragraph.claimRevisionId } } + ); + } +} + +export async function down(db: Db) { + const collection = db.collection("paragraphs"); + + await collection.updateMany( + { claimRevisionId: { $exists: true } }, + { $unset: { claimRevisionId: "" } } + ); +} diff --git a/migrations/20240611185513-add-claimreviewid-on-unattributeds-paragraphs.ts b/migrations/20240611185513-add-claimreviewid-on-unattributeds-paragraphs.ts new file mode 100644 index 000000000..9acd7adc4 --- /dev/null +++ b/migrations/20240611185513-add-claimreviewid-on-unattributeds-paragraphs.ts @@ -0,0 +1,52 @@ +import { Db } from "mongodb"; + +export async function up(db: Db) { + const collection = db.collection("paragraphs"); + + const pipeline = [ + { + $lookup: { + from: "unattributeds", + localField: "_id", + foreignField: "content", + as: "unattributed", + }, + }, + { + $lookup: { + from: "claimrevisions", + localField: "unattributed._id", + foreignField: "contentId", + as: "claimRevisions", + }, + }, + { + $unwind: "$claimRevisions", + }, + { + $addFields: { + claimRevisionId: "$claimRevisions._id", + }, + }, + ]; + + const paragraphsWithClaimRevision = await collection + .aggregate(pipeline) + .toArray(); + + for (const paragraph of paragraphsWithClaimRevision) { + await collection.updateOne( + { _id: paragraph._id }, + { $set: { claimRevisionId: paragraph.claimRevisionId } } + ); + } +} + +export async function down(db: Db) { + const collection = db.collection("paragraphs"); + + await collection.updateMany( + { claimRevisionId: { $exists: true } }, + { $unset: { claimRevisionId: "" } } + ); +} diff --git a/migrations/20240611185733-add-claimreviewid-on-debates-paragraphs.ts b/migrations/20240611185733-add-claimreviewid-on-debates-paragraphs.ts new file mode 100644 index 000000000..17ed18ae8 --- /dev/null +++ b/migrations/20240611185733-add-claimreviewid-on-debates-paragraphs.ts @@ -0,0 +1,63 @@ +import { Db } from "mongodb"; + +export async function up(db: Db) { + const collection = db.collection("paragraphs"); + + const pipeline = [ + { + $lookup: { + from: "speeches", + localField: "_id", + foreignField: "content", + as: "speeches", + }, + }, + { + $unwind: "$speeches", + }, + { + $lookup: { + from: "debates", + localField: "speeches._id", + foreignField: "content", + as: "debate", + }, + }, + { + $lookup: { + from: "claimrevisions", + localField: "debate._id", + foreignField: "contentId", + as: "claimRevisions", + }, + }, + { + $unwind: "$claimRevisions", + }, + { + $addFields: { + claimRevisionId: "$claimRevisions._id", + }, + }, + ]; + + const paragraphsWithClaimRevision = await collection + .aggregate(pipeline) + .toArray(); + + for (const paragraph of paragraphsWithClaimRevision) { + await collection.updateOne( + { _id: paragraph._id }, + { $set: { claimRevisionId: paragraph.claimRevisionId } } + ); + } +} + +export async function down(db: Db) { + const collection = db.collection("paragraphs"); + + await collection.updateMany( + { claimRevisionId: { $exists: true } }, + { $unset: { claimRevisionId: "" } } + ); +} diff --git a/migrations/20240611191417-add-claimreviewid-on-debates-speeches.ts b/migrations/20240611191417-add-claimreviewid-on-debates-speeches.ts new file mode 100644 index 000000000..8cf598b11 --- /dev/null +++ b/migrations/20240611191417-add-claimreviewid-on-debates-speeches.ts @@ -0,0 +1,52 @@ +import { Db } from "mongodb"; + +export async function up(db: Db) { + const collection = db.collection("speeches"); + + const pipeline = [ + { + $lookup: { + from: "debates", + localField: "_id", + foreignField: "content", + as: "debate", + }, + }, + { + $lookup: { + from: "claimrevisions", + localField: "debate._id", + foreignField: "contentId", + as: "claimRevisions", + }, + }, + { + $unwind: "$claimRevisions", + }, + { + $addFields: { + claimRevisionId: "$claimRevisions._id", + }, + }, + ]; + + const speechesWithClaimRevision = await collection + .aggregate(pipeline) + .toArray(); + + for (const speeche of speechesWithClaimRevision) { + await collection.updateOne( + { _id: speeche._id }, + { $set: { claimRevisionId: speeche.claimRevisionId } } + ); + } +} + +export async function down(db: Db) { + const collection = db.collection("speeches"); + + await collection.updateMany( + { claimRevisionId: { $exists: true } }, + { $unset: { claimRevisionId: "" } } + ); +} diff --git a/migrations/20240611191431-add-claimreviewid-on-speeches.ts b/migrations/20240611191431-add-claimreviewid-on-speeches.ts new file mode 100644 index 000000000..9aaaa9612 --- /dev/null +++ b/migrations/20240611191431-add-claimreviewid-on-speeches.ts @@ -0,0 +1,44 @@ +import { Db } from "mongodb"; + +export async function up(db: Db) { + const collection = db.collection("speeches"); + + const pipeline = [ + { + $lookup: { + from: "claimrevisions", + localField: "_id", + foreignField: "contentId", + as: "claimRevisions", + }, + }, + { + $unwind: "$claimRevisions", + }, + { + $addFields: { + claimRevisionId: "$claimRevisions._id", + }, + }, + ]; + + const speechesWithClaimRevision = await collection + .aggregate(pipeline) + .toArray(); + + for (const speeche of speechesWithClaimRevision) { + await collection.updateOne( + { _id: speeche._id }, + { $set: { claimRevisionId: speeche.claimRevisionId } } + ); + } +} + +export async function down(db: Db) { + const collection = db.collection("speeches"); + + await collection.updateMany( + { claimRevisionId: { $exists: true } }, + { $unset: { claimRevisionId: "" } } + ); +} diff --git a/migrations/20240618124843-change-review-task-history-target-model-field.ts b/migrations/20240618124843-change-review-task-history-target-model-field.ts new file mode 100644 index 000000000..409cf0636 --- /dev/null +++ b/migrations/20240618124843-change-review-task-history-target-model-field.ts @@ -0,0 +1,19 @@ +import { Db } from "mongodb"; + +export async function up(db: Db) { + const collection = db.collection("histories"); + + await collection.updateMany( + { targetModel: "ClaimReviewTask" }, + { $set: { targetModel: "ReviewTask" } } + ); +} + +export async function down(db: Db) { + const collection = db.collection("histories"); + + await collection.updateMany( + { targetModel: "ReviewTask" }, + { $set: { targetModel: "ClaimReviewTask" } } + ); +} diff --git a/migrations/20240627115131-rename-review-task-collection.ts b/migrations/20240627115131-rename-review-task-collection.ts new file mode 100644 index 000000000..d1ca0ef9c --- /dev/null +++ b/migrations/20240627115131-rename-review-task-collection.ts @@ -0,0 +1,13 @@ +import { Db } from "mongodb"; + +export async function up(db: Db) { + const collection = db.collection("claimreviewtasks"); + + await collection.rename("reviewtasks"); +} + +export async function down(db: Db) { + const collection = db.collection("reviewtasks"); + + await collection.rename("claimreviewtasks"); +} diff --git a/migrations/20240701184655-add-namespace-in-review-task-schemae.ts b/migrations/20240701184655-add-namespace-in-review-task-schemae.ts new file mode 100644 index 000000000..6af8d29aa --- /dev/null +++ b/migrations/20240701184655-add-namespace-in-review-task-schemae.ts @@ -0,0 +1,51 @@ +import { Db } from "mongodb"; +const ObjectId = require("mongodb").ObjectID; + +export async function up(db: Db) { + const reviewTasksCursor = await db.collection("reviewtasks").find(); + + while (await reviewTasksCursor.hasNext()) { + const reviewTask = await reviewTasksCursor.next(); + if (reviewTask.machine.context.claimReview.claim) { + const claim = await db.collection("claims").findOne({ + _id: ObjectId(reviewTask.machine.context.claimReview.claim), + }); + + await db + .collection("reviewtasks") + .updateOne( + { _id: reviewTask._id }, + { + $set: { + nameSpace: claim?.nameSpace, + reviewTaskType: "Claim", + }, + } + ); + } + + if (reviewTask.machine.context.claimReview.source) { + const source = await db.collection("sources").findOne({ + _id: ObjectId(reviewTask.machine.context.claimReview.source), + }); + + await db + .collection("reviewtasks") + .updateOne( + { _id: reviewTask._id }, + { + $set: { + nameSpace: source?.nameSpace, + reviewTaskType: "Source", + }, + } + ); + } + } +} + +export async function down(db: Db) { + await db + .collection("reviewtasks") + .updateMany({}, { $unset: { nameSpace: "", reviewTaskType: "" } }); +} diff --git a/migrations/20240701191347-add-target-field-in-machine-context.ts b/migrations/20240701191347-add-target-field-in-machine-context.ts new file mode 100644 index 000000000..a479081b5 --- /dev/null +++ b/migrations/20240701191347-add-target-field-in-machine-context.ts @@ -0,0 +1,36 @@ +import { Db } from "mongodb"; + +export async function up(db: Db) { + const reviewTasksCursor = await db.collection("reviewtasks").find(); + + while (await reviewTasksCursor.hasNext()) { + const reviewTask = await reviewTasksCursor.next(); + if (reviewTask.machine.context.claimReview.claim) { + await db.collection("reviewtasks").updateOne( + { _id: reviewTask._id }, + { + $set: { + target: reviewTask.machine.context.claimReview.claim, + }, + $unset: { + "machine.context.claimReview.claim": undefined, + }, + } + ); + } + + if (reviewTask.machine.context.claimReview.source) { + await db.collection("reviewtasks").updateOne( + { _id: reviewTask._id }, + { + $set: { + target: reviewTask.machine.context.claimReview.source, + }, + $unset: { + "machine.context.claimReview.source": undefined, + }, + } + ); + } + } +} diff --git a/migrations/20240701221810-rename-claim-review-field-in-review-task-schema.ts b/migrations/20240701221810-rename-claim-review-field-in-review-task-schema.ts new file mode 100644 index 000000000..1d0fa8c77 --- /dev/null +++ b/migrations/20240701221810-rename-claim-review-field-in-review-task-schema.ts @@ -0,0 +1,39 @@ +import { Db } from "mongodb"; + +export async function up(db: Db) { + const reviewTasksCursor = await db.collection("reviewtasks").find(); + + while (await reviewTasksCursor.hasNext()) { + const reviewTask = await reviewTasksCursor.next(); + + await db + .collection("reviewtasks") + .updateOne( + { _id: reviewTask._id }, + { + $rename: { + "machine.context.claimReview": "machine.context.review", + }, + } + ); + } +} + +export async function down(db: Db) { + const reviewTasksCursor = await db.collection("reviewtasks").find(); + + while (await reviewTasksCursor.hasNext()) { + const reviewTask = await reviewTasksCursor.next(); + + await db + .collection("reviewtasks") + .updateOne( + { _id: reviewTask._id }, + { + $rename: { + "machine.context.review": "machine.context.claimReview", + }, + } + ); + } +} diff --git a/migrations/20240717100604-add-target-and-target-model-fields-in-claim-review-schema.ts b/migrations/20240717100604-add-target-and-target-model-fields-in-claim-review-schema.ts new file mode 100644 index 000000000..8962fb242 --- /dev/null +++ b/migrations/20240717100604-add-target-and-target-model-fields-in-claim-review-schema.ts @@ -0,0 +1,38 @@ +import { Db } from "mongodb"; + +export async function up(db: Db) { + const claimReviewCursor = await db.collection("claimreviews").find(); + + while (await claimReviewCursor.hasNext()) { + const claimReview = await claimReviewCursor.next(); + if (claimReview.claim) { + await db.collection("claimreviews").updateOne( + { _id: claimReview._id }, + { + $set: { + target: claimReview.claim, + targetModel: "Claim", + }, + $unset: { + claim: undefined, + }, + } + ); + } + + if (claimReview.source) { + await db.collection("claimreviews").updateOne( + { _id: claimReview._id }, + { + $set: { + target: claimReview.source, + targetModel: "Source", + }, + $unset: { + source: undefined, + }, + } + ); + } + } +} diff --git a/package.json b/package.json index d4960dd62..7568a751d 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "ory-kratos:cy": "docker compose up -d --build --force-recreate kratos kratos-migrate", "test:e2e:cy": "env-cmd --silent concurrently -p \"[{name}]\" -n \"MongoDB,Ory,Server\" \"yarn test:e2e:mongo-server\" \"yarn ory-kratos:cy\" \"wait-on tcp:127.0.0.1:35025 tcp:127.0.0.1:4433 tcp:127.0.0.1:4434 && yarn test:e2e:app-server\"", "test:e2e:mongo-server": "node dist/server/mongodb.server.js", - "test:e2e:app-server": "yarn seed:ci && yarn start -c config.test.ci.yaml" + "test:e2e:app-server": "yarn seed:ci && yarn start -c config.test.ci.yaml", + "create-novu-subscribers": "env-cmd ts-node scripts/createNovuSubscribers.ts" }, "nodemonConfig": { "ignore": [ @@ -107,7 +108,7 @@ "ai": "^3.1.1", "antd": "^4.18.5", "aws-sdk": "^2.1154.0", - "axios": "^1.5.0", + "axios": "^1.6.0", "babel-jest": "^29.7.0", "babel-plugin-styled-components": "^1.13.2", "class-transformer": "^0.5.1", @@ -259,7 +260,11 @@ "ip": "1.1.9", "tar": "6.2.1", "@types/react": "^17.0.38", - "@types/react-dom": "17.0.2" + "@types/react-dom": "17.0.2", + "ejs": "3.1.10", + "@grpc/grpc-js": "1.10.9", + "braces": "3.0.3", + "ws": "8.17.1" }, "packageManager": "yarn@3.6.3" } diff --git a/public/locales/en/claimReview.json b/public/locales/en/claimReview.json index 61bec75c8..edd9167cc 100644 --- a/public/locales/en/claimReview.json +++ b/public/locales/en/claimReview.json @@ -1,7 +1,8 @@ { "cardAuthor": "{{name}} reviewed this as:", "anonymousUserName": "Anonymous", - "claimReview": "Claim reviewed as ", + "titleClaimReview": "Claim reviewed as: ", + "titleSourceReview": "Source reviewed as: ", "listTitle": "Reviews", "summarySectionTitle": "Summary of conclusion", "questionsSectionTitle": "What questions should the verification answer?", diff --git a/public/locales/en/claimReviewForm.json b/public/locales/en/claimReviewForm.json index 4ce954288..7c3f72aa2 100644 --- a/public/locales/en/claimReviewForm.json +++ b/public/locales/en/claimReviewForm.json @@ -2,8 +2,10 @@ "titleEmpty": "Choose a sentence to review", "title": "Review the sentence", "classificationLabel": "Review this claim", - "addReviewButton": "Start a fact-check", + "addFactCheckingReviewButton": "Start a fact-check", "addInformativeNewsButton": "Start an informative news", + "addSourceReviewButton": "Start a source review", + "addVerificationRequestButton": "Start a verification request review", "cancelButton": "Cancel", "classificationPlaceholder": "Choose a classification", "not-fact": "Not fact", @@ -29,12 +31,13 @@ "addQuestionLabel": "Add another question", "reportLabel": "Verification report", "reportPlaceholder": "Insert report content here", + "sourceEditorLabel": "Create source review", "verificationLabel": "How it was verified", "verificationPlaceholder": "Describe how the verification was done", - "collaborativeEditorLabel": "Report", - "collaborativeEditorPlaceholder": "Insert report fields", - "sourcesLabel": "Provide at least one link to a reliable source", - "sourcesPlaceholder": "Paste URL", + "visualEditorLabel": "Report", + "visualEditorPlaceholder": "Insert report fields", + "sourceLabel": "Provide one link to a reliable source", + "sourcePlaceholder": "Paste URL", "addSourceLabel": "Add another source", "reviewerLabel": "Select a reviewer for review", "reviewerPlaceholder": "Select a user", @@ -47,5 +50,9 @@ "rejectionCommentLabel": "Rejection comment", "rejectionCommentPlaceholder": "Describe what needs to be changed", "loginButton": "Login to continue", - "notReviewed": "Not reviewed" + "notReviewed": "Not reviewed", + "groupLabel": "Group verification requests", + "groupPlaceholder": "Select verification requests", + "isSensitiveLabel": "Sensitve content", + "isSensitive": "This verification request contains sensitve content" } diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 3dfd87af0..697e7df90 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -8,6 +8,7 @@ "questionsRequiredFieldError": "Questions field is required", "verificationRequiredFieldError": "Verification field is required", "sourcesRequiredFieldError": "Source is required", + "sourceRequiredFieldError": "Source is required", "supportEmail": "support@aletheifact.org", "contactEmail": "contact@aletheiafact.org", "captchaError": "There was an error validating the captcha", diff --git a/public/locales/en/kanban.json b/public/locales/en/kanban.json index 55e12c968..95e41daed 100644 --- a/public/locales/en/kanban.json +++ b/public/locales/en/kanban.json @@ -1,7 +1,10 @@ { "claimBy": "Claim by", "assignedTo": "Fact-checkers", - "myTasks": "My tasks only", - "myCrossChecks": "My cross-checks only", - "myReviews": "My reviews only" + "myAssigned": "My tasks only", + "myCrossChecked": "My cross-checks only", + "myReviewed": "My reviews only", + "tabClaimTitle": "Claims", + "tabSourceTitle": "Sources", + "tabVerificationRequestTitle": "Verification Requests" } diff --git a/public/locales/en/menu.json b/public/locales/en/menu.json index 6ca03ac9b..b3c74c738 100644 --- a/public/locales/en/menu.json +++ b/public/locales/en/menu.json @@ -12,5 +12,6 @@ "kanbanItem": "Kanban", "nameSpaceItem": "Namespace", "supportiveMaterials": "Supportive Materials", - "sourcesItem": "Sources" + "sourcesItem": "Sources", + "verificationRequestItem": "Verification Requests" } diff --git a/public/locales/en/claimReviewTask.json b/public/locales/en/reviewTask.json similarity index 85% rename from public/locales/en/claimReviewTask.json rename to public/locales/en/reviewTask.json index 8bb5670a0..67eb77a7c 100644 --- a/public/locales/en/claimReviewTask.json +++ b/public/locales/en/reviewTask.json @@ -1,6 +1,8 @@ { "ASSIGN_USER_SUCCESS": "User assigned successfully", "ASSIGN_USER_ERROR": "Error when assigning the user", + "ASSIGN_REQUEST_SUCCESS": "User assigned successfully", + "ASSIGN_REQUEST_ERROR": "Error when assigning the user", "FINISH_REPORT_SUCCESS": "Report saved succesfully", "FINISH_REPORT_ERROR": "Error when saving the report", "ADD_COMMENT_SUCCESS": "Successfully advanced step", @@ -25,9 +27,14 @@ "FULL_REVIEW_ERROR": "Error when saving the summary", "GO_BACK_SUCCESS": "Successfully turned step", "GO_BACK_ERROR": "It was not possible to return", + "REJECT_SUCCESS": "Successfully rejected verification request", + "REJECT_ERROR": "It was not possible to reject", + "RESET_SUCCESS": "Successfully reset workflow", + "RESET_ERROR": "It was not possible to reset", "ADD_REJECTION_COMMENT": "Send comment", "ASSIGN_USER": "Assign", + "ASSIGN_REQUEST": "Assign", "FINISH_REPORT": "Finish Report", "SELECTED_CROSS_CHECKING": "Select cross-checker", "SELECTED_REVIEW": "Select reviewer", @@ -42,6 +49,8 @@ "FULL_REVIEW": "Complete report", "GO_BACK": "Go Back", "SAVE_DRAFT": "Save Draft", + "REJECT_REQUEST": "Reject", + "RESET": "Reset", "errorWhileFetching": "Error while fetching machine", "assigned": "User assigned", @@ -50,6 +59,8 @@ "submitted": "In review", "published": "Published", "draft": "Draft", + "assignedRequest": "User assigned", + "rejectedRequest": "Rejected", "crossCheckingAlertTitle": "This report is beeing cross-checked", "reviewingAlertTitle": "This report is beeing reviewed", "rejectionAlertTitle": "This review was rejected with the following comment", diff --git a/public/locales/en/seo.json b/public/locales/en/seo.json index 17793fe66..319b16c3d 100644 --- a/public/locales/en/seo.json +++ b/public/locales/en/seo.json @@ -13,5 +13,7 @@ "claimListTitle": "Claims", "claimListDescription": "See claims on AletheiaFact.org", "createSourceTitle": "Create source", - "createSourceDescription": "Create sources to AletheiaFact.org" + "createSourceDescription": "Create sources to AletheiaFact.org", + "verificationRequestTitle": "Verification Requests", + "verificationRequestDescription": "See verification requests on AletheiaFact.org" } diff --git a/public/locales/en/sourceForm.json b/public/locales/en/sourceForm.json index 82df6ee45..06f85094e 100644 --- a/public/locales/en/sourceForm.json +++ b/public/locales/en/sourceForm.json @@ -14,5 +14,6 @@ "summaryLabel": "Summary", "summaryPlaceholder": "Insert the source summary", "classificationLabel": "Review this source", - "classificationPlaceholder": "Choose a classification" + "classificationPlaceholder": "Choose a classification", + "summarizeSouce": "Summarize source automatic" } diff --git a/public/locales/en/verificationRequest.json b/public/locales/en/verificationRequest.json new file mode 100644 index 000000000..5bce0292a --- /dev/null +++ b/public/locales/en/verificationRequest.json @@ -0,0 +1,10 @@ +{ + "verificationRequestListHeader": "Verification Requests", + "verificationRequestTitle": "Verification Request", + "manageVerificationRequests": "Manage selected verification requests", + "createClaimFromVerificationRequest": "No claim was created with this verification request", + "openVerificationRequestClaimLabel": "A claim created was related to this verification request", + "openVerificationRequestClaimButton": "Open claim", + "agroupVerificationRequest": "Related verification requests", + "openVerificationRequest": "Open" +} \ No newline at end of file diff --git a/public/locales/pt/claimReview.json b/public/locales/pt/claimReview.json index ca798771f..1870d3cbe 100644 --- a/public/locales/pt/claimReview.json +++ b/public/locales/pt/claimReview.json @@ -1,7 +1,8 @@ { "cardAuthor": "{{name}} avaliou essa afirmação como:", "anonymousUserName": "Anônimo", - "claimReview": "Afirmação avaliada como ", + "titleClaimReview": "Afirmação avaliada como: ", + "titleSourceReview": "Fonte avaliada como: ", "listTitle": "Avaliações", "summarySectionTitle": "Resumo da conclusão", "questionsSectionTitle": "Quais perguntas a verificação deverá responder?", diff --git a/public/locales/pt/claimReviewForm.json b/public/locales/pt/claimReviewForm.json index cb2e7101f..9f992f60d 100644 --- a/public/locales/pt/claimReviewForm.json +++ b/public/locales/pt/claimReviewForm.json @@ -2,8 +2,10 @@ "titleEmpty": "Escolha uma frase para revisar", "title": "Classifique a frase", "classificationLabel": "Revise essa frase", - "addReviewButton": "Comece uma checagem de fatos", + "addFactCheckingReviewButton": "Comece uma checagem de fatos", "addInformativeNewsButton": "Comece uma noticia informativa", + "addSourceReviewButton": "Comece uma checagem de fonte", + "addVerificationRequestButton": "Comece uma revisão de denúncia", "cancelButton": "Cancelar", "classificationPlaceholder": "Selecione uma classificação", "not-fact": "Não é fato", @@ -31,10 +33,11 @@ "reportPlaceholder": "Insira o relatório da verificação", "verificationLabel": "Como verificamos?", "verificationPlaceholder": "Insira como a verificação foi feita", - "collaborativeEditorLabel": "Relatório", - "collaborativeEditorPlaceholder": "Insira campos do relatório", - "sourcesLabel": "Forneça ao menos um link de uma fonte confiável", - "sourcesPlaceholder": "Cole uma URL", + "visualEditorLabel": "Relatório", + "visualEditorPlaceholder": "Insira campos do relatório", + "sourceLabel": "Forneça um link de uma fonte confiável", + "sourceEditorLabel": "Criar revisão da fonte", + "sourcePlaceholder": "Cole uma URL", "addSourceLabel": "Incluir outra fonte", "reviewerLabel": "Selecione um revisor para a revisão", "reviewerPlaceholder": "Selecione um usuário", @@ -47,5 +50,9 @@ "rejectionCommentLabel": "Comentário de rejeição", "rejectionCommentPlaceholder": "Descreva o que precisa ser alterado", "loginButton": "Faça login para continuar", - "notReviewed": "Não revisado" + "notReviewed": "Não revisado", + "groupLabel": "Agrupar denúncias", + "groupPlaceholder": "Selecione denúncias", + "isSensitiveLabel": "Conteúdo confidencial", + "isSensitive": "Esta denúncia contém conteúdo confidencial" } diff --git a/public/locales/pt/common.json b/public/locales/pt/common.json index 7bc8f6b54..8cb1a8881 100644 --- a/public/locales/pt/common.json +++ b/public/locales/pt/common.json @@ -8,6 +8,7 @@ "questionsRequiredFieldError": "Campo de perguntas obrigatório", "verificationRequiredFieldError": "Campo de verificação obrigatório", "sourcesRequiredFieldError": "Fonte obrigatória", + "sourceRequiredFieldError": "Fonte obrigatória", "supportEmail": "support@aletheifact.org", "contactEmail": "contato@aletheiafact.org", "captchaError": "Erro na validação do captcha", diff --git a/public/locales/pt/kanban.json b/public/locales/pt/kanban.json index cd32b90fd..e4cbe3585 100644 --- a/public/locales/pt/kanban.json +++ b/public/locales/pt/kanban.json @@ -1,7 +1,10 @@ { "claimBy": "Afirmação de", "assignedTo": "Checadores", - "myTasks": "Apenas minhas tarefas", - "myCrossChecks": "Apenas meus cross-checkings", - "myReviews": "Minhas revisões" + "myAssigned": "Apenas minhas tarefas", + "myCrossChecked": "Apenas meus cross-checkings", + "myReviewed": "Minhas revisões", + "tabClaimTitle": "Afirmações", + "tabSourceTitle": "Fontes", + "tabVerificationRequestTitle": "Denúncias" } diff --git a/public/locales/pt/menu.json b/public/locales/pt/menu.json index 9956f2a13..aa80d2e31 100644 --- a/public/locales/pt/menu.json +++ b/public/locales/pt/menu.json @@ -12,5 +12,6 @@ "kanbanItem": "Meu Trabalho", "nameSpaceItem": "Namespace", "supportiveMaterials": "Materiais de Apoio", - "sourcesItem": "Fontes" + "sourcesItem": "Fontes", + "verificationRequestItem": "Denúncias" } diff --git a/public/locales/pt/claimReviewTask.json b/public/locales/pt/reviewTask.json similarity index 85% rename from public/locales/pt/claimReviewTask.json rename to public/locales/pt/reviewTask.json index 39e8535b8..948b03800 100644 --- a/public/locales/pt/claimReviewTask.json +++ b/public/locales/pt/reviewTask.json @@ -1,6 +1,8 @@ { "ASSIGN_USER_SUCCESS": "Usuário atribuído com sucesso", "ASSIGN_USER_ERROR": "Erro ao atribuir usuário", + "ASSIGN_REQUEST_SUCCESS": "Usuário atribuído com sucesso", + "ASSIGN_REQUEST_ERROR": "Erro ao atribuir usuário", "FINISH_REPORT_SUCCESS": "Relatório salvo com sucesso", "FINISH_REPORT_ERROR": "Erro ao salvar o relatório", "ADD_COMMENT_SUCCESS": "Passo avançado com sucesso", @@ -25,9 +27,14 @@ "FULL_REVIEW_ERROR": "Erro ao salvar resumo", "GO_BACK_SUCCESS": "Passo voltado com sucesso", "GO_BACK_ERROR": "Não foi possível voltar", + "REJECT_SUCCESS": "Denúncia rejeitada com sucesso", + "REJECT_ERROR": "Não foi possível rejeitar a denúncia", + "RESET_SUCCESS": "Fluxo reiniciado com sucesso", + "RESET_ERROR": "Não foi possível reiniciar o fluxo", "ADD_REJECTION_COMMENT": "Enviar comentário", "ASSIGN_USER": "Atribuir", + "ASSIGN_REQUEST": "Atribuir", "FINISH_REPORT": "Concluir Relatório", "SELECTED_CROSS_CHECKING": "Selecione um cross-checker", "SELECTED_REVIEW": "Selecione um revisor", @@ -42,6 +49,8 @@ "FULL_REVIEW": "Completar relatório", "GO_BACK": "Voltar", "SAVE_DRAFT": "Salvar rascunho", + "REJECT_REQUEST": "Rejeitar", + "RESET": "Resetar", "errorWhileFetching": "Erro ao buscar a máquina", "assigned": "Usuário atribuído", @@ -50,6 +59,8 @@ "submitted": "Em revisão", "published": "Publicado", "draft": "Rascunho", + "assignedRequest": "Usuário atribuído", + "rejectedRequest": "Rejeitado", "crossCheckingAlertTitle": "Este relatório está em processo de cross-checking", "reviewingAlertTitle": "Este relatório está em processo de revisão", "rejectionAlertTitle": "A revisão foi rejeitada com o seguinte comentário", diff --git a/public/locales/pt/seo.json b/public/locales/pt/seo.json index c31d76a7c..c0331071d 100644 --- a/public/locales/pt/seo.json +++ b/public/locales/pt/seo.json @@ -13,5 +13,7 @@ "claimListTitle": "Declarações", "claimListDescription": "Veja declarações na AletheiaFact.org", "createSourceTitle": "Adicione uma fonte", - "createSourceDescription": "Adiciona uma fonte na AletheiaFact.org" + "createSourceDescription": "Adiciona uma fonte na AletheiaFact.org", + "verificationRequestTitle": "Denúncias", + "verificationRequestDescription": "Veja as denúncias na AletheiaFact.org" } diff --git a/public/locales/pt/sourceForm.json b/public/locales/pt/sourceForm.json index 02d352624..861af562c 100644 --- a/public/locales/pt/sourceForm.json +++ b/public/locales/pt/sourceForm.json @@ -14,5 +14,6 @@ "summaryLabel": "Resumo", "summaryPlaceholder": "Insira o resumo da fonte", "classificationLabel": "Revise essa fonte", - "classificationPlaceholder": "Selecione uma classificação" + "classificationPlaceholder": "Selecione uma classificação", + "summarizeSouce": "Resumo automático" } diff --git a/public/locales/pt/verificationRequest.json b/public/locales/pt/verificationRequest.json new file mode 100644 index 000000000..76d7c0f3a --- /dev/null +++ b/public/locales/pt/verificationRequest.json @@ -0,0 +1,10 @@ +{ + "verificationRequestListHeader": "Denúncias", + "verificationRequestTitle": "Denúncia", + "manageVerificationRequests": "Gerenciar denúncias selecionadas", + "createClaimFromVerificationRequest": "Nenhuma afirmação foi criada com esta denúncia", + "openVerificationRequestClaimLabel": "Uma afirmação criada foi relacionada com essa denúncia", + "openVerificationRequestClaimButton": "Abrir afirmação", + "agroupVerificationRequest": "Denúncias relacionadas", + "openVerificationRequest": "Abrir" +} \ No newline at end of file diff --git a/scripts/createNovuSubscribers.ts b/scripts/createNovuSubscribers.ts new file mode 100644 index 000000000..20d628e42 --- /dev/null +++ b/scripts/createNovuSubscribers.ts @@ -0,0 +1,76 @@ +/** + * Script to create Novu subscribers and add them to a topic. + * + * Usage: + * Run the following command to create subscribers and add them to a specified topic: + * yarn create-novu-subscribers "" + */ +import { Novu } from "@novu/node"; +import { v4 as uuidv4 } from "uuid"; + +if (!process.env.NOVU_API_KEY) { + console.error("NOVU_API_KEY is not set in the environment variables."); + process.exit(1); +} + +const novu = new Novu(`${process.env.NOVU_API_KEY}`); + +const args = process.argv.slice(2); + +if (args.length < 2) { + console.error( + "Usage: yarn create-novu-subscribers " + ); + process.exit(1); +} + +const topicKey = args[0]; +const emails = args[1].split(","); + +const subscriberIds: string[] = []; + +const createSubscribers = async (emails: string[]) => { + for (const email of emails) { + const [firstName, domain] = email.split("@"); + const subscriberId = uuidv4(); + subscriberIds.push(subscriberId); + + try { + await novu.subscribers.identify(subscriberId, { + firstName, + email, + }); + console.log( + `Subscriber created: ${firstName} (${email}) with ID: ${subscriberId}` + ); + } catch (error) { + console.error(`Failed to create subscriber for ${email}:`, error); + } + } +}; + +const addSubscribersToTopic = async ( + topicKey: string, + subscribers: string[] +) => { + try { + const result = await novu.topics.addSubscribers(topicKey, { + subscribers, + }); + console.log(`Subscribers added to topic ${topicKey}:`, result); + } catch (error) { + console.error(`Failed to add subscribers to topic ${topicKey}:`, error); + } +}; + +const main = async () => { + try { + await createSubscribers(emails); + + await addSubscribersToTopic(topicKey, subscriberIds); + } catch (error) { + console.error("Error:", error); + } +}; + +main(); diff --git a/server/app.module.ts b/server/app.module.ts index e278f989e..a3de297bd 100644 --- a/server/app.module.ts +++ b/server/app.module.ts @@ -23,7 +23,7 @@ import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler"; import { SitemapModule } from "./sitemap/sitemap.module"; import { ClaimRevisionModule } from "./claim/claim-revision/claim-revision.module"; import { HistoryModule } from "./history/history.module"; -import { ClaimReviewTaskModule } from "./claim-review-task/claim-review-task.module"; +import { ReviewTaskModule } from "./review-task/review-task.module"; import { LoggerMiddleware } from "./middleware/logger.middleware"; import { ReportModule } from "./report/report.module"; import OryModule from "./auth/ory/ory.module"; @@ -48,13 +48,18 @@ import { EditorModule } from "./editor/editor.module"; import { BadgeModule } from "./badge/badge.module"; import { EditorParseModule } from "./editor-parse/editor-parse.module"; import { NotificationModule } from "./notifications/notifications.module"; -import { CommentModule } from "./claim-review-task/comment/comment.module"; +import { CommentModule } from "./review-task/comment/comment.module"; import { NameSpaceModule } from "./auth/name-space/name-space.module"; import { NameSpaceGuard } from "./auth/name-space/name-space.guard"; import { AutomatedFactCheckingModule } from "./automated-fact-checking/automated-fact-checking.module"; import { CopilotChatModule } from "./copilot/copilot-chat.module"; import { UnattributedModule } from "./claim/types/unattributed/unattributed.module"; import { DailyReportModule } from "./daily-report/daily-report.module"; +import { SummarizationCrawlerModule } from "./summarization/summarization-crawler.module"; +import { ChatbotModule } from "./chat-bot/chat-bot.module"; +import { VerificationRequestModule } from "./verification-request/verification-request.module"; +import { FeatureFlagModule } from "./feature-flag/feature-flag.module"; +import { GroupModule } from "./group/group.module"; @Module({}) export class AppModule implements NestModule { @@ -84,7 +89,7 @@ export class AppModule implements NestModule { PersonalityModule, ClaimModule, ClaimReviewModule, - ClaimReviewTaskModule, + ReviewTaskModule, ClaimRevisionModule, HistoryModule, StateEventModule, @@ -115,6 +120,11 @@ export class AppModule implements NestModule { CopilotChatModule, UnattributedModule, DailyReportModule, + SummarizationCrawlerModule, + ChatbotModule, + VerificationRequestModule, + FeatureFlagModule, + GroupModule, ]; if (options.feature_flag) { imports.push( diff --git a/server/auth/name-space/dto/create-namespace.dto.ts b/server/auth/name-space/dto/create-namespace.dto.ts index 89b3ab393..f48f88712 100644 --- a/server/auth/name-space/dto/create-namespace.dto.ts +++ b/server/auth/name-space/dto/create-namespace.dto.ts @@ -12,4 +12,9 @@ export class CreateNameSpaceDTO { @IsOptional() @ApiProperty() users: User[]; + + @IsString() + @IsOptional() + @ApiProperty() + slug: string; } diff --git a/server/auth/name-space/name-space.controller.ts b/server/auth/name-space/name-space.controller.ts index 1e4e3edd5..5baae275d 100644 --- a/server/auth/name-space/name-space.controller.ts +++ b/server/auth/name-space/name-space.controller.ts @@ -22,13 +22,18 @@ import { CheckAbilities, } from "../../auth/ability/ability.decorator"; import { AbilitiesGuard } from "../../auth/ability/abilities.guard"; +import { Types } from "mongoose"; +import { Roles } from "../../auth/ability/ability.factory"; +import { NotificationService } from "../../notifications/notifications.service"; +import slugify from "slugify"; @Controller() export class NameSpaceController { constructor( private nameSpaceService: NameSpaceService, private usersService: UsersService, - private viewService: ViewService + private viewService: ViewService, + private notificationService: NotificationService ) {} @ApiTags("name-space") @@ -36,6 +41,16 @@ export class NameSpaceController { @UseGuards(AbilitiesGuard) @CheckAbilities(new AdminUserAbility()) async create(@Body() namespace: CreateNameSpaceDTO) { + namespace.slug = slugify(namespace.name, { + lower: true, + strict: true, + }); + + namespace.users = await this.updateNameSpaceUsers( + namespace.users, + namespace.slug + ); + return await this.nameSpaceService.create(namespace); } @@ -43,8 +58,31 @@ export class NameSpaceController { @Put("api/name-space/:id") @UseGuards(AbilitiesGuard) @CheckAbilities(new AdminUserAbility()) - async update(@Param("id") id, @Body() namespace: UpdateNameSpaceDTO) { - return await this.nameSpaceService.update(id, namespace); + async update(@Param("id") id, @Body() namespaceBody: UpdateNameSpaceDTO) { + const nameSpace = await this.nameSpaceService.getById(id); + const newNameSpace = { + ...nameSpace.toObject(), + ...namespaceBody, + }; + + newNameSpace.slug = slugify(nameSpace.name, { + lower: true, + strict: true, + }); + + newNameSpace.users = await this.updateNameSpaceUsers( + newNameSpace.users, + nameSpace.slug + ); + + await this.findNameSpaceUsersAndDelete( + id, + nameSpace.slug, + newNameSpace.users, + nameSpace.users + ); + + return await this.nameSpaceService.update(id, newNameSpace); } @ApiTags("name-space") @@ -62,4 +100,58 @@ export class NameSpaceController { Object.assign(parsedUrl.query, { nameSpaces, users }) ); } + + private async updateNameSpaceUsers(users, key) { + const promises = users.map(async (user) => { + const userId = Types.ObjectId(user._id); + const existingUser = await this.usersService.getById(userId); + + if (!existingUser.role[key]) { + await this.usersService.updateUser(existingUser._id, { + role: { + ...existingUser.role, + [key]: Roles.Regular, + }, + }); + } + + return userId; + }); + + return await Promise.all(promises); + } + + private async findNameSpaceUsersAndDelete( + id, + nameSpaceSlug, + users, + previousUsersId + ) { + const usersIdSet = new Set(users.map((user) => user.toString())); + const nameSpaceUsersTodelete = previousUsersId.filter( + (previousUserId) => !usersIdSet.has(previousUserId.toString()) + ); + + if (nameSpaceUsersTodelete.length > 0) { + this.notificationService.removeTopicSubscriber( + id, + nameSpaceUsersTodelete + ); + return await this.deleteUsersNameSpace( + nameSpaceUsersTodelete, + nameSpaceSlug + ); + } + } + + private async deleteUsersNameSpace(usersId, key) { + const updatePromises = usersId.map(async (userId) => { + const id = Types.ObjectId(userId); + const user = await this.usersService.getById(id); + delete user.role[key]; + return this.usersService.updateUser(user._id, { role: user.role }); + }); + + await Promise.all(updatePromises); + } } diff --git a/server/auth/name-space/name-space.service.ts b/server/auth/name-space/name-space.service.ts index b6750771e..10bdb06af 100644 --- a/server/auth/name-space/name-space.service.ts +++ b/server/auth/name-space/name-space.service.ts @@ -1,10 +1,7 @@ import { Injectable } from "@nestjs/common"; -import { Model, Types } from "mongoose"; +import { Model } from "mongoose"; import { NameSpaceDocument, NameSpace } from "./schemas/name-space.schema"; import { InjectModel } from "@nestjs/mongoose"; -import slugify from "slugify"; -import { UsersService } from "../../users/users.service"; -import { Roles } from "../../auth/ability/ability.factory"; import { UpdateNameSpaceDTO } from "./dto/update-name-space.dto"; import { NotificationService } from "../../notifications/notifications.service"; @@ -13,8 +10,7 @@ export class NameSpaceService { constructor( @InjectModel(NameSpace.name) private NameSpaceModel: Model, - private notificationService: NotificationService, - private usersService: UsersService + private notificationService: NotificationService ) {} listAll() { @@ -22,16 +18,6 @@ export class NameSpaceService { } async create(nameSpace) { - nameSpace.slug = slugify(nameSpace.name, { - lower: true, - strict: true, - }); - - nameSpace.users = await this.updateNameSpaceUsers( - nameSpace.users, - nameSpace.slug - ); - const newNameSpace = await new this.NameSpaceModel(nameSpace).save(); await this.notificationService.createTopic( @@ -47,27 +33,11 @@ export class NameSpaceService { return newNameSpace; } - async update(id, nameSpaceBody: UpdateNameSpaceDTO) { - const nameSpace = await this.getById(id); - const newNameSpace = { - ...nameSpace.toObject(), - ...nameSpaceBody, - }; - - newNameSpace.slug = slugify(newNameSpace.name, { - lower: true, - strict: true, - }); - + async update(id, newNameSpace: UpdateNameSpaceDTO) { const isNameSpaceTopic = await this.notificationService.getTopic( newNameSpace._id ); - newNameSpace.users = await this.updateNameSpaceUsers( - newNameSpace.users, - nameSpace.slug - ); - await this.ensureTopicAndSubscribers( id, newNameSpace.name, @@ -75,15 +45,8 @@ export class NameSpaceService { isNameSpaceTopic ); - await this.findNameSpaceUsersAndDelete( - id, - nameSpace.slug, - newNameSpace.users, - nameSpace.users - ); - return await this.NameSpaceModel.updateOne( - { _id: nameSpace._id }, + { _id: newNameSpace._id }, newNameSpace ); } @@ -103,59 +66,6 @@ export class NameSpaceService { await this.notificationService.addTopicSubscriber(namespaceId, users); } - async updateNameSpaceUsers(users, key) { - const promises = users.map(async (user) => { - const userId = Types.ObjectId(user._id); - const existingUser = await this.usersService.getById(userId); - - if (!user.role[key]) { - await this.usersService.updateUser(existingUser._id, { - role: { - ...existingUser.role, - [key]: Roles.Regular, - }, - }); - } - - return userId; - }); - - return await Promise.all(promises); - } - - async deleteUsersNameSpace(usersId, key) { - const updatePromises = usersId.map(async (userId) => { - const id = Types.ObjectId(userId); - const user = await this.usersService.getById(id); - delete user.role[key]; - return this.usersService.updateUser(user._id, { role: user.role }); - }); - - await Promise.all(updatePromises); - } - - async findNameSpaceUsersAndDelete( - id, - nameSpaceSlug, - users, - previousUsersId - ) { - const usersIdSet = new Set(users.map((user) => user.toString())); - const nameSpaceUsersTodelete = previousUsersId.filter( - (previousUserId) => !usersIdSet.has(previousUserId.toString()) - ); - if (nameSpaceUsersTodelete.length > 0) { - this.notificationService.removeTopicSubscriber( - id, - nameSpaceUsersTodelete - ); - return await this.deleteUsersNameSpace( - nameSpaceUsersTodelete, - nameSpaceSlug - ); - } - } - findOne(match) { return this.NameSpaceModel.findOne(match); } diff --git a/server/automated-fact-checking/automated-fact-checking.module.ts b/server/automated-fact-checking/automated-fact-checking.module.ts index f53a82e21..96678aca6 100644 --- a/server/automated-fact-checking/automated-fact-checking.module.ts +++ b/server/automated-fact-checking/automated-fact-checking.module.ts @@ -1,11 +1,11 @@ import { Module } from "@nestjs/common"; -import { ClaimReviewTaskModule } from "../claim-review-task/claim-review-task.module"; +import { ReviewTaskModule } from "../review-task/review-task.module"; import { AutomatedFactCheckingService } from "./automated-fact-checking.service"; import { AutomatedFactCheckingController } from "./automated-fact-checking.controller"; import { ConfigModule } from "@nestjs/config"; @Module({ - imports: [ClaimReviewTaskModule, ConfigModule], + imports: [ReviewTaskModule, ConfigModule], providers: [AutomatedFactCheckingService], exports: [AutomatedFactCheckingService], controllers: [AutomatedFactCheckingController], diff --git a/server/automated-fact-checking/automated-fact-checking.service.ts b/server/automated-fact-checking/automated-fact-checking.service.ts index 5490152de..1ce95eaff 100644 --- a/server/automated-fact-checking/automated-fact-checking.service.ts +++ b/server/automated-fact-checking/automated-fact-checking.service.ts @@ -12,7 +12,6 @@ export class AutomatedFactCheckingService { } async getResponseFromAgents(data): Promise<{ stream: string; json: any }> { - console.log(this.agenciaURL); const params = { input: { claim: data.claim, @@ -50,8 +49,13 @@ export class AutomatedFactCheckingService { .map((line) => JSON.parse(line.substring(5))) .reduce((acc, data) => ({ ...acc, ...data }), {}); - jsonEvents.__end__.messages = JSON.parse(jsonEvents.__end__.messages); + if (jsonEvents.start_fact_checking) { + const report = JSON.parse(jsonEvents.start_fact_checking.messages); + return { stream: streamResponse, json: { messages: report } }; + } + + const report = JSON.parse(jsonEvents.create_report.messages); - return { stream: streamResponse, json: jsonEvents.__end__ }; + return { stream: streamResponse, json: { messages: report } }; } } diff --git a/server/chat-bot/chat-bot-actions.ts b/server/chat-bot/chat-bot-actions.ts new file mode 100644 index 000000000..53b5e5844 --- /dev/null +++ b/server/chat-bot/chat-bot-actions.ts @@ -0,0 +1,74 @@ +import { EventObject, assign } from "xstate"; +import { ChatBotContext } from "./chat-bot.machine"; + +interface VerificationRequestEvent extends EventObject { + verificationRequest: string; +} + +const MESSAGES = { + greeting: + "Olá! Sou o assistente virtual da AletheiaFact.org, estou aqui para ajudá-lo(a) a combater desinformações 🙂 Você gostaria de fazer uma denúncia agora?\n\nResponda SIM para continuar ou NÃO se não deseja denunciar.", + noMessage: + "Entendi. Nosso trabalho é verificar informações falsas.\n\nSe quiser saber mais sobre o que fazemos, visite: https://aletheiafact.org. Se mudar de ideia e desejar fazer uma denúncia, basta digitar DENÚNCIA a qualquer momento.", + notUnderstood: + "Desculpe, não entendi sua resposta. Para continuar, preciso que você digite SIM se deseja fazer uma denúncia, ou NÃO se não deseja.\n\nVocê gostaria de fazer uma denúncia agora?", + askForVerificationRequest: + "Por favor, me conte com detalhes o que você gostaria de denunciar.\n\nPor favor, inclua todas as informações que considerar relevantes para que possamos verificar a denúncia de forma eficiente 👀", + thanks: "Muito obrigada por sua contribuição!\n\nSua informação será analisada pela nossa equipe ✅Para saber mais, visite nosso site: https://aletheiafact.org.\n\nDeseja relatar outra denúncia? Responda SIM para continuar ou NÃO para encerrar.", + noTextMessageGreeting: + "Desculpe, só podemos processar mensagens de texto. Por favor, envie sua mensagem em formato de texto.\n\nOlá! Sou o assistente virtual da AletheiaFact.org, estou aqui para ajudá-lo(a) a combater desinformações 🙂 Você gostaria de fazer uma denúncia agora?\n\nResponda SIM para continuar ou NÃO se não deseja denunciar.", + noTextMessageAskForVerificationRequest: + "Desculpe, só podemos processar mensagens de texto. Por favor, envie sua mensagem em formato de texto para que possamos entender e verificar sua denúncia de forma eficiente.\n\nPor favor, me conte com detalhes o que você gostaria de denunciar.\n\nPor favor, inclua todas as informações que considerar relevantes para que possamos verificar a denúncia de forma eficiente 👀", + noTextMessageNoMessage: + "Desculpe, só podemos processar mensagens de texto. Por favor, envie sua mensagem em formato de texto.\n\nNosso trabalho é verificar informações falsas.\n\nSe quiser saber mais sobre o que fazemos, visite: https://aletheiafact.org. Se mudar de ideia e desejar fazer uma denúncia, basta digitar DENÚNCIA a qualquer momento.", + noTextMessageAskIfForVerificationRequest: + "Desculpe, só podemos processar mensagens de texto. Por favor, envie sua mensagem em formato de texto.\n\nVocê gostaria de fazer uma denúncia agora? Responda SIM para continuar ou NÃO se não deseja.", +}; + +export const sendGreeting = assign({ + responseMessage: () => MESSAGES.greeting, +}); + +export const sendNoMessage = assign({ + responseMessage: () => MESSAGES.noMessage, +}); + +export const sendNotUnderstoodMessage = assign({ + responseMessage: () => MESSAGES.notUnderstood, +}); + +export const askForVerificationRequest = assign({ + responseMessage: () => MESSAGES.askForVerificationRequest, +}); + +export const saveVerificationRequest = assign({ + verificationRequest: (context, event) => + (event as VerificationRequestEvent).verificationRequest, +}); + +export const sendThanks = assign({ + responseMessage: () => MESSAGES.thanks, +}); + +export const sendNoTextMessageGreeting = assign({ + responseMessage: () => MESSAGES.noTextMessageGreeting, +}); + +export const sendNoTextMessageAskForVerificationRequest = + assign({ + responseMessage: () => MESSAGES.noTextMessageAskForVerificationRequest, + }); + +export const sendNoTextMessageNoMessage = assign({ + responseMessage: () => MESSAGES.noTextMessageNoMessage, +}); + +export const sendNoTextMessageAskIfForVerificationRequest = + assign({ + responseMessage: () => + MESSAGES.noTextMessageAskIfForVerificationRequest, + }); + +export const setResponseMessage = assign({ + responseMessage: (context) => context.responseMessage, +}); diff --git a/server/chat-bot/chat-bot.controller.ts b/server/chat-bot/chat-bot.controller.ts new file mode 100644 index 000000000..e4fc8e152 --- /dev/null +++ b/server/chat-bot/chat-bot.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Post, Req, Res } from "@nestjs/common"; +import { ChatbotService } from "./chat-bot.service"; +import type { Request, Response } from "express"; + +@Controller() +export class ChatbotController { + constructor(private readonly chatBotService: ChatbotService) {} + + @Post("api/chatbot/hook") + handleHook(@Req() req: Request, @Res() res: Response) { + const response = this.chatBotService.sendMessage(req.body.message); + + res.status(200).json({ message: response }); + return response; + } +} diff --git a/server/chat-bot/chat-bot.machine.ts b/server/chat-bot/chat-bot.machine.ts new file mode 100644 index 000000000..d9e921597 --- /dev/null +++ b/server/chat-bot/chat-bot.machine.ts @@ -0,0 +1,128 @@ +import { createMachine, interpret } from "xstate"; +import * as actions from "./chat-bot-actions"; +import { VerificationRequestService } from "../verification-request/verification-request.service"; + +export interface ChatBotContext { + verificationRequest: string; + responseMessage: string; +} + +export const createChatBotMachine = ( + verificationRequestService: VerificationRequestService +) => { + const chatBotMachine = createMachine( + { + id: "chatBot", + initial: "greeting", + context: { + verificationRequest: "", + responseMessage: "", + }, + states: { + greeting: { + on: { + ASK_IF_VERIFICATION_REQUEST: { + target: "askingIfVerificationRequest", + actions: ["sendGreeting", "setResponseMessage"], + }, + NON_TEXT_MESSAGE: { + target: "askingIfVerificationRequest", + actions: [ + "sendNoTextMessageGreeting", + "setResponseMessage", + ], + }, + }, + }, + askingIfVerificationRequest: { + on: { + RECEIVE_YES: { + target: "askingForVerificationRequest", + actions: [ + "askForVerificationRequest", + "setResponseMessage", + ], + }, + RECEIVE_NO: { + target: "sendingNoMessage", + actions: ["sendNoMessage", "setResponseMessage"], + }, + NOT_UNDERSTOOD: { + target: "askingIfVerificationRequest", + actions: [ + "sendNotUnderstoodMessage", + "setResponseMessage", + ], + }, + NON_TEXT_MESSAGE: { + target: "askingIfVerificationRequest", + actions: [ + "sendNoTextMessageAskIfForVerificationRequest", + "setResponseMessage", + ], + }, + }, + }, + askingForVerificationRequest: { + on: { + RECEIVE_REPORT: { + target: "askingIfVerificationRequest", + actions: [ + "saveVerificationRequest", + "sendThanks", + "setResponseMessage", + "saveVerificationRequestToDB", + ], + }, + NON_TEXT_MESSAGE: { + target: "askingForVerificationRequest", + actions: [ + "sendNoTextMessageAskForVerificationRequest", + "setResponseMessage", + ], + }, + }, + }, + sendingNoMessage: { + on: { + ASK_TO_REPORT: { + target: "askingForVerificationRequest", + actions: [ + "askForVerificationRequest", + "setResponseMessage", + ], + }, + RECEIVE_NO: { + target: "sendingNoMessage", + actions: ["sendNoMessage", "setResponseMessage"], + }, + NON_TEXT_MESSAGE: { + target: "sendingNoMessage", + actions: [ + "sendNoTextMessageNoMessage", + "setResponseMessage", + ], + }, + }, + }, + }, + }, + { + actions: { + ...actions, + saveVerificationRequestToDB: (context) => { + const verificationRequestBody = { + content: context.verificationRequest, + date: new Date(), + sources: [], + data_hash: "", + }; + + verificationRequestService.create(verificationRequestBody); + }, + }, + } + ); + + return interpret(chatBotMachine); +}; diff --git a/server/chat-bot/chat-bot.module.ts b/server/chat-bot/chat-bot.module.ts new file mode 100644 index 000000000..95487d163 --- /dev/null +++ b/server/chat-bot/chat-bot.module.ts @@ -0,0 +1,26 @@ +import { + MiddlewareConsumer, + Module, + NestModule, + RequestMethod, +} from "@nestjs/common"; +import { ChatbotService } from "./chat-bot.service"; +import { ChatbotController } from "./chat-bot.controller"; +import { HttpModule } from "@nestjs/axios"; +import { VerificationRequestModule } from "../verification-request/verification-request.module"; +import { ConfigModule } from "@nestjs/config"; +import { AuthZenviaWebHookMiddleware } from "../middleware/auth-zenvia-webhook.middleware"; + +@Module({ + imports: [HttpModule, VerificationRequestModule, ConfigModule], + providers: [ChatbotService], + controllers: [ChatbotController], +}) +export class ChatbotModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(AuthZenviaWebHookMiddleware).forRoutes({ + path: "api/chatbot/hook", + method: RequestMethod.POST, + }); + } +} diff --git a/server/chat-bot/chat-bot.service.ts b/server/chat-bot/chat-bot.service.ts new file mode 100644 index 000000000..aaf8b9bed --- /dev/null +++ b/server/chat-bot/chat-bot.service.ts @@ -0,0 +1,112 @@ +import { Injectable } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { AxiosResponse } from "axios"; +import { catchError, map } from "rxjs/operators"; +import { Observable, throwError } from "rxjs"; +import { createChatBotMachine } from "./chat-bot.machine"; +import { VerificationRequestService } from "../verification-request/verification-request.service"; +import { ConfigService } from "@nestjs/config"; + +const diacriticsRegex = /[\u0300-\u036f]/g; +const MESSAGE_MAP = { + sim: "RECEIVE_YES", + nao: "RECEIVE_NO", +}; + +@Injectable() +export class ChatbotService { + private chatBotMachineService; + + constructor( + private configService: ConfigService, + private readonly httpService: HttpService, + private verificationService: VerificationRequestService + ) {} + + onModuleInit() { + this.initializeChatBotMachine(); + } + + private initializeChatBotMachine() { + this.chatBotMachineService = createChatBotMachine( + this.verificationService + ); + this.chatBotMachineService.start(); + } + + //TODO: Find a better way to interpret the user's message. + private normalizeAndLowercase(message: string): string { + return message + .normalize("NFD") + .replace(diacriticsRegex, "") + .toLowerCase(); + } + + private handleMachineEventSend(parsedMessage: string): void { + this.chatBotMachineService.send( + MESSAGE_MAP[parsedMessage] || "NOT_UNDERSTOOD" + ); + } + + private handleSendingNoMessage(parsedMessage: string): void { + this.chatBotMachineService.send( + parsedMessage === "denuncia" ? "ASK_TO_REPORT" : "RECEIVE_NO" + ); + } + + private handleUserMessage(message): void { + const messageType = message.contents[0].type; + const userMessage = message.contents[0].text; + + if (messageType !== "text") { + this.chatBotMachineService.send("NON_TEXT_MESSAGE"); + return; + } + + const parsedMessage = this.normalizeAndLowercase(userMessage); + const currentState = this.chatBotMachineService.getSnapshot().value; + + switch (currentState) { + case "greeting": + this.chatBotMachineService.send("ASK_IF_VERIFICATION_REQUEST"); + break; + case "askingIfVerificationRequest": + this.handleMachineEventSend(parsedMessage); + break; + case "askingForVerificationRequest": + this.chatBotMachineService.send({ + type: "RECEIVE_REPORT", + verificationRequest: userMessage, + }); + break; + case "sendingNoMessage": + this.handleSendingNoMessage(parsedMessage); + break; + default: + console.warn(`Unhandled state: ${currentState}`); + } + } + + public sendMessage(message): Observable> { + const { api_url, api_token } = this.configService.get("zenvia"); + this.handleUserMessage(message); + + const snapshot = this.chatBotMachineService.getSnapshot(); + const responseMessage = snapshot.context.responseMessage; + + const body = { + from: message.to, + to: message.from, + contents: [{ type: "text", text: responseMessage }], + }; + + return this.httpService + .post(api_url, body, { + headers: { "X-API-TOKEN": api_token }, + }) + .pipe( + map((response) => response), + catchError((error) => throwError(() => new Error(error))) + ); + } +} diff --git a/server/claim-review-task/claim-review-task.controller.ts b/server/claim-review-task/claim-review-task.controller.ts deleted file mode 100644 index e28e3962d..000000000 --- a/server/claim-review-task/claim-review-task.controller.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { - Body, - Controller, - Post, - Param, - Get, - Put, - Query, - Req, - Res, - Header, - Optional, -} from "@nestjs/common"; -import { ClaimReviewTaskService } from "./claim-review-task.service"; -import { CreateClaimReviewTaskDTO } from "./dto/create-claim-review-task.dto"; -import { UpdateClaimReviewTaskDTO } from "./dto/update-claim-review-task.dto"; -import { CaptchaService } from "../captcha/captcha.service"; -import { parse } from "url"; -import type { Request, Response } from "express"; -import { ViewService } from "../view/view.service"; -import { GetTasksDTO } from "./dto/get-tasks.dto"; -import { getQueryMatchForMachineValue } from "./mongo-utils"; -import { ConfigService } from "@nestjs/config"; -import { UnleashService } from "nestjs-unleash"; -import { ApiTags } from "@nestjs/swagger"; -import { NameSpaceEnum } from "../auth/name-space/schemas/name-space.schema"; - -@Controller(":namespace?") -export class ClaimReviewController { - constructor( - private claimReviewTaskService: ClaimReviewTaskService, - private captchaService: CaptchaService, - private viewService: ViewService, - private configService: ConfigService, - @Optional() private readonly unleash: UnleashService - ) {} - - @ApiTags("claim-review-task") - @Get("api/claimreviewtask") - @Header("Cache-Control", "no-cache") - public async getByMachineValue(@Query() getTasksDTO: GetTasksDTO) { - const { - page = 0, - pageSize = 10, - order = 1, - value, - filterUser, - nameSpace = NameSpaceEnum.Main, - } = getTasksDTO; - return Promise.all([ - this.claimReviewTaskService.listAll( - page, - pageSize, - order, - value, - filterUser, - nameSpace - ), - this.claimReviewTaskService.countReviewTasksNotDeleted( - getQueryMatchForMachineValue(value), - filterUser, - nameSpace - ), - ]).then(([tasks, totalTasks]) => { - const totalPages = Math.ceil(totalTasks / pageSize); - - return { - tasks, - totalTasks, - totalPages, - page, - pageSize, - }; - }); - } - - @ApiTags("claim-review-task") - @Get("api/claimreviewtask/:id") - @Header("Cache-Control", "no-cache") - async getById(@Param("id") id: string) { - return this.claimReviewTaskService.getById(id); - } - - @ApiTags("claim-review-task") - @Post("api/claimreviewtask") - @Header("Cache-Control", "no-cache") - async create(@Body() createClaimReviewTask: CreateClaimReviewTaskDTO) { - const validateCaptcha = await this.captchaService.validate( - createClaimReviewTask.recaptcha - ); - if (!validateCaptcha) { - throw new Error("Error validating captcha"); - } - return this.claimReviewTaskService.create(createClaimReviewTask); - } - - @ApiTags("claim-review-task") - @Put("api/claimreviewtask/:data_hash") - @Header("Cache-Control", "no-cache") - async autoSaveDraft( - @Param("data_hash") data_hash, - @Body() claimReviewTaskBody: UpdateClaimReviewTaskDTO - ) { - const history = false; - return this.claimReviewTaskService - .getClaimReviewTaskByDataHash(data_hash) - .then((review) => { - if (review) { - return this.claimReviewTaskService.update( - data_hash, - claimReviewTaskBody, - claimReviewTaskBody.nameSpace, - claimReviewTaskBody.reportModel, - history - ); - } - }); - } - - // TODO: remove hash from the url - @ApiTags("claim-review-task") - @Get("api/claimreviewtask/hash/:data_hash") - @Header("Cache-Control", "no-cache") - async getByDataHash(@Param("data_hash") data_hash: string) { - return this.claimReviewTaskService.getClaimReviewTaskByDataHash( - data_hash - ); - } - - @ApiTags("claim-review-task") - @Get("api/claimreviewtask/editor-content/:data_hash") - @Header("Cache-Control", "no-cache") - async getEditorContentByDataHash( - @Param("data_hash") data_hash: string, - @Query() query: { reportModel: string } - ) { - const claimReviewTask = - await this.claimReviewTaskService.getClaimReviewTaskByDataHash( - data_hash - ); - - return this.claimReviewTaskService.getEditorContentObject( - claimReviewTask?.machine?.context?.reviewData, - query.reportModel - ); - } - - @ApiTags("claim-review-task") - @Put("api/claimreviewtask/add-comment/:data_hash") - @Header("Cache-Control", "no-cache") - async addComment(@Param("data_hash") data_hash: string, @Body() body) { - return this.claimReviewTaskService.addComment(data_hash, body.comment); - } - - @ApiTags("claim-review-task") - @Put("api/claimreviewtask/delete-comment/:data_hash") - @Header("Cache-Control", "no-cache") - async deleteComment(@Param("data_hash") data_hash: string, @Body() body) { - return this.claimReviewTaskService.deleteComment( - data_hash, - body.commentId - ); - } - - @ApiTags("pages") - @Get("kanban") - @Header("Cache-Control", "no-cache") - public async personalityList(@Req() req: Request, @Res() res: Response) { - const parsedUrl = parse(req.url, true); - const enableCollaborativeEditor = this.isEnableCollaborativeEditor(); - const enableCopilotChatBot = this.isEnableCopilotChatBot(); - const enableEditorAnnotations = this.isEnableEditorAnnotations(); - const enableAddEditorSourcesWithoutSelecting = - this.isEnableAddEditorSourcesWithoutSelecting(); - - await this.viewService.getNextServer().render( - req, - res, - "/kanban-page", - Object.assign(parsedUrl.query, { - sitekey: this.configService.get("recaptcha_sitekey"), - enableCollaborativeEditor, - enableEditorAnnotations, - enableCopilotChatBot, - enableAddEditorSourcesWithoutSelecting, - websocketUrl: this.configService.get("websocketUrl"), - nameSpace: req.params.namespace, - }) - ); - } - - private isEnableCollaborativeEditor() { - const config = this.configService.get("feature_flag"); - - return config - ? this.unleash.isEnabled("enable_collaborative_editor") - : false; - } - - private isEnableCopilotChatBot() { - const config = this.configService.get("feature_flag"); - - return config ? this.unleash.isEnabled("copilot_chat_bot") : false; - } - - private isEnableEditorAnnotations() { - const config = this.configService.get("feature_flag"); - - return config - ? this.unleash.isEnabled("enable_editor_annotations") - : false; - } - - private isEnableAddEditorSourcesWithoutSelecting() { - const config = this.configService.get("feature_flag"); - - return config - ? this.unleash.isEnabled( - "enable_add_editor_sources_without_selecting" - ) - : false; - } -} diff --git a/server/claim-review-task/claim-review-task.service.ts b/server/claim-review-task/claim-review-task.service.ts deleted file mode 100644 index 3ef77ffc2..000000000 --- a/server/claim-review-task/claim-review-task.service.ts +++ /dev/null @@ -1,635 +0,0 @@ -import { ForbiddenException, Inject, Injectable, Scope } from "@nestjs/common"; -import { Model, Types } from "mongoose"; -import { - ClaimReviewTask, - ClaimReviewTaskDocument, -} from "./schemas/claim-review-task.schema"; -import { InjectModel } from "@nestjs/mongoose"; -import { CreateClaimReviewTaskDTO } from "./dto/create-claim-review-task.dto"; -import { UpdateClaimReviewTaskDTO } from "./dto/update-claim-review-task.dto"; -import { ClaimReviewService } from "../claim-review/claim-review.service"; -import { ReportService } from "../report/report.service"; -import { HistoryType, TargetModel } from "../history/schema/history.schema"; -import { HistoryService } from "../history/history.service"; -import { StateEventService } from "../state-event/state-event.service"; -import { TypeModel } from "../state-event/schema/state-event.schema"; -import { REQUEST } from "@nestjs/core"; -import type { BaseRequest } from "../types"; -import { SentenceService } from "../claim/types/sentence/sentence.service"; -import { getQueryMatchForMachineValue } from "./mongo-utils"; -import { Roles } from "../auth/ability/ability.factory"; -import { ImageService } from "../claim/types/image/image.service"; -import { ContentModelEnum } from "../types/enums"; -import lookupUsers from "../mongo-pipelines/lookupUsers"; -import lookUpPersonalityties from "../mongo-pipelines/lookUpPersonalityties"; -import lookupClaims from "../mongo-pipelines/lookupClaims"; -import lookupClaimReviews from "../mongo-pipelines/lookupClaimReviews"; -import lookupClaimRevisions from "../mongo-pipelines/lookupClaimRevisions"; -import { EditorParseService } from "../editor-parse/editor-parse.service"; -import { CommentService } from "./comment/comment.service"; -import { NameSpaceEnum } from "../auth/name-space/schemas/name-space.schema"; -import { CommentEnum } from "./comment/schema/comment.schema"; - -@Injectable({ scope: Scope.REQUEST }) -export class ClaimReviewTaskService { - constructor( - @Inject(REQUEST) private req: BaseRequest, - @InjectModel(ClaimReviewTask.name) - private ClaimReviewTaskModel: Model, - private claimReviewService: ClaimReviewService, - private reportService: ReportService, - private historyService: HistoryService, - private stateEventService: StateEventService, - private sentenceService: SentenceService, - private imageService: ImageService, - private editorParseService: EditorParseService, - private commentService: CommentService - ) {} - - _verifyMachineValueAndAddMatchPipeline(pipeline, value) { - if (value === "published") { - pipeline.push( - lookupClaimReviews({ - as: "machine.context.claimReview.claimReview", - }), - { $unwind: "$machine.context.claimReview.claimReview" }, - { - $match: { - "machine.context.claimReview.claimReview.isDeleted": - false, - "machine.context.claimReview.claim.isDeleted": false, - }, - } - ); - } else { - pipeline.push({ - $match: { - "machine.context.claimReview.claim.isDeleted": false, - }, - }); - } - } - - _buildPipeline(value, filterUser, nameSpace) { - const pipeline = []; - const query = getQueryMatchForMachineValue(value); - const fieldMap = { - assigned: "machine.context.reviewData.usersId", - crossChecked: "machine.context.reviewData.crossCheckerId", - reviewed: "machine.context.reviewData.reviewerId", - }; - - Object.keys(filterUser).forEach((key) => { - const value = filterUser[key]; - if (value === true || value === "true") { - const queryPath = fieldMap[key]; - query[queryPath] = Types.ObjectId(this.req.user._id); - } - }); - - pipeline.push( - { $match: query }, - lookupUsers(), - lookUpPersonalityties(TargetModel.ClaimReviewTask), - lookupClaims(TargetModel.ClaimReviewTask, { - pipeline: [ - { $match: { $expr: { $eq: ["$_id", "$$claimId"] } } }, - lookupClaimRevisions(TargetModel.ClaimReviewTask), - { $unwind: "$latestRevision" }, - ], - as: "machine.context.claimReview.claim", - }), - { - $match: { - "machine.context.claimReview.claim.nameSpace": nameSpace, - }, - } - ); - - this._verifyMachineValueAndAddMatchPipeline(pipeline, value); - - return pipeline; - } - - async listAll(page, pageSize, order, value, filterUser, nameSpace) { - const pipeline = this._buildPipeline(value, filterUser, nameSpace); - pipeline.push( - { $sort: { _id: order === "asc" ? 1 : -1 } }, - { $skip: page * pageSize }, - { $limit: pageSize } - ); - - const reviewTasks = await this.ClaimReviewTaskModel.aggregate( - pipeline - ).exec(); - - return Promise.all( - reviewTasks?.map(async ({ data_hash, machine }) => { - const { - personality: [personality], - claim: [claim], - }: any = machine.context.claimReview; - const { title, contentModel } = claim.latestRevision; - const isContentImage = contentModel === ContentModelEnum.Image; - - const personalityPath = `/personality/${personality?.slug}`; - - const contentModelPathMap = { - [ContentModelEnum.Debate]: `/claim/${claim?._id}/debate`, - [ContentModelEnum.Image]: personality - ? `${personalityPath}/claim/${claim?.slug}/${claim?._id}` - : `/claim/${claim?._id}`, - [ContentModelEnum.Speech]: `${personalityPath}/claim/${claim?.slug}/sentence/${data_hash}`, - }; - - let reviewHref = - nameSpace !== NameSpaceEnum.Main - ? `/${nameSpace}${contentModelPathMap[contentModel]}` - : contentModelPathMap[contentModel]; - - const usersName = machine.context.reviewData.users.map( - (user) => { - return user.name; - } - ); - - const content = isContentImage - ? await this.imageService.getByDataHash(data_hash) - : await this.sentenceService.getByDataHash(data_hash); - - return { - content, - usersName, - value: machine.value, - personalityName: personality?.name, - claimTitle: title, - claimId: claim._id, - personalityId: personality?._id, - reviewHref, - contentModel, - }; - }) - ); - } - - getById(claimReviewTaskId: string) { - return this.ClaimReviewTaskModel.findById(claimReviewTaskId); - } - - _createReviewTaskHistory( - newClaimReviewTask, - previousClaimReviewTask = null - ) { - let historyType; - - if (typeof newClaimReviewTask.machine.value === "object") { - historyType = - newClaimReviewTask.machine.value?.[ - Object.keys(newClaimReviewTask.machine.value)[0] - ] === "draft" - ? HistoryType.Draft - : Object.keys(newClaimReviewTask.machine.value)[0]; - } - - const user = this.req.user; - - const history = this.historyService.getHistoryParams( - newClaimReviewTask._id, - TargetModel.ClaimReviewTask, - user, - historyType || HistoryType.Published, - { - ...newClaimReviewTask.machine.context.reviewData, - ...newClaimReviewTask.machine.context.claimReview.claim, - value: newClaimReviewTask.machine.value, - }, - previousClaimReviewTask && { - ...previousClaimReviewTask.machine.context.reviewData, - ...previousClaimReviewTask.machine.context.claimReview.claim, - value: previousClaimReviewTask.machine.value, - } - ); - - this.historyService.createHistory(history); - } - - _createStateEvent(newClaimReviewTask) { - let typeModel; - let draft = false; - - if (typeof newClaimReviewTask.machine.value === "object") { - draft = - newClaimReviewTask.machine.value?.[ - Object.keys(newClaimReviewTask.machine.value)[0] - ] === "draft" - ? true - : false; - - typeModel = Object.keys(newClaimReviewTask.machine.value)[0]; - } - - const stateEvent = this.stateEventService.getStateEventParams( - Types.ObjectId( - newClaimReviewTask.machine.context.claimReview.claim - ), - typeModel || TypeModel.Published, - draft, - newClaimReviewTask._id - ); - - this.stateEventService.createStateEvent(stateEvent); - } - - async _createReportAndClaimReview( - data_hash, - machine, - reportModel, - nameSpace - ) { - const claimReviewData = machine.context.claimReview; - - const newReport = Object.assign(machine.context.reviewData, { - data_hash, - reportModel, - }); - - const report = await this.reportService.create(newReport); - - this.claimReviewService.create( - { - ...claimReviewData, - report, - nameSpace, - }, - data_hash, - reportModel - ); - } - - _returnObjectId(data): any { - if (Array.isArray(data)) { - return data.map((item) => - item._id ? Types.ObjectId(item._id) || "" : Types.ObjectId(item) - ); - } - } - - _createCrossCheckingComment(comment, text, targetId) { - const newCrossCheckingComment = { - comment, - text, - type: CommentEnum.crossChecking, - targetId, - user: this.req.user._id, - }; - return this.commentService.create(newCrossCheckingComment); - } - - async create(claimReviewTaskBody: CreateClaimReviewTaskDTO) { - const reviewDataBody = claimReviewTaskBody.machine.context.reviewData; - const claimReviewTask = await this.getClaimReviewTaskByDataHash( - claimReviewTaskBody.data_hash - ); - - const createCrossCheckingComment = - claimReviewTask?.machine?.value === "addCommentCrossChecking" && - reviewDataBody.crossCheckingClassification && - reviewDataBody.crossCheckingComment; - - claimReviewTaskBody.machine.context.reviewData.usersId = - this._returnObjectId(reviewDataBody.usersId); - - if (reviewDataBody.reviewerId) { - claimReviewTaskBody.machine.context.reviewData.reviewerId = - Types.ObjectId(reviewDataBody.reviewerId) || ""; - } - - if (reviewDataBody.crossCheckerId) { - claimReviewTaskBody.machine.context.reviewData.crossCheckerId = - Types.ObjectId(reviewDataBody.crossCheckerId) || ""; - } - - if (reviewDataBody.reviewComments) { - this.commentService.updateManyComments( - reviewDataBody.reviewComments - ); - claimReviewTaskBody.machine.context.reviewData.reviewComments = - this._returnObjectId(reviewDataBody.reviewComments); - } - - if (reviewDataBody.crossCheckingComments) { - claimReviewTaskBody.machine.context.reviewData.crossCheckingComments = - this._returnObjectId(reviewDataBody.crossCheckingComments); - } - - if (createCrossCheckingComment) { - const crossCheckingComment = await this._createCrossCheckingComment( - reviewDataBody.crossCheckingComment, - reviewDataBody.crossCheckingClassification, - claimReviewTask._id - ); - claimReviewTaskBody.machine.context.reviewData.crossCheckingComments.push( - crossCheckingComment._id - ); - } - - if (claimReviewTask) { - return this.update( - claimReviewTaskBody.data_hash, - claimReviewTaskBody, - claimReviewTaskBody.nameSpace, - claimReviewTask.reportModel - ); - } else { - const newClaimReviewTask = new this.ClaimReviewTaskModel( - claimReviewTaskBody - ); - newClaimReviewTask.save(); - this._createReviewTaskHistory(newClaimReviewTask); - this._createStateEvent(newClaimReviewTask); - return newClaimReviewTask; - } - } - - async update( - data_hash: string, - { machine }: UpdateClaimReviewTaskDTO, - nameSpace: string, - reportModel: string, - history: boolean = true - ) { - const loggedInUser = this.req.user; - // This line may cause a false positive in sonarCloud because if we remove the await, we cannot iterate through the results - const claimReviewTask = await this.getClaimReviewTaskByDataHash( - data_hash - ); - - const newClaimReviewTaskMachine = { - ...claimReviewTask.machine, - ...machine, - }; - - const newClaimReviewTask = { - ...claimReviewTask.toObject(), - machine: newClaimReviewTaskMachine, - }; - - if (newClaimReviewTaskMachine.value === "published") { - if ( - loggedInUser.role[nameSpace] !== Roles.Admin && - loggedInUser.role[nameSpace] !== Roles.SuperAdmin && - loggedInUser._id !== - machine.context.reviewData.reviewerId.toString() - ) { - throw new ForbiddenException( - "This user does not have permission to publish the report" - ); - } - this._createReportAndClaimReview( - data_hash, - newClaimReviewTask.machine, - reportModel, - nameSpace - ); - } - - if (history) { - this._createReviewTaskHistory(newClaimReviewTask, claimReviewTask); - this._createStateEvent(newClaimReviewTask); - } - - return this.ClaimReviewTaskModel.updateOne( - { _id: newClaimReviewTask._id }, - newClaimReviewTask - ); - } - - getClaimReviewTaskByDataHash(data_hash: string) { - const commentPopulation = [ - { - path: "user", - select: "name", - }, - { - path: "replies", - populate: { - path: "user", - select: "name", - }, - }, - ]; - - return this.ClaimReviewTaskModel.findOne({ data_hash }) - .populate({ - path: "machine.context.reviewData.reviewComments", - model: "Comment", - populate: commentPopulation, - }) - .populate({ - path: "machine.context.reviewData.crossCheckingComments", - model: "Comment", - populate: commentPopulation, - }); - } - - async getReviewTasksByClaimId(claimId: string) { - return await this.ClaimReviewTaskModel.aggregate([ - { - $match: { - "machine.context.claimReview.claim": claimId.toString(), - "machine.value": { $ne: "published" }, - }, - }, - ]); - } - - async getClaimReviewTaskByDataHashWithUsernames(data_hash: string) { - // This may cause a false positive in sonarCloud - const claimReviewTask = await this.getClaimReviewTaskByDataHash( - data_hash - ) - .populate({ - path: "machine.context.reviewData.usersId", - model: "User", - select: "name", - }) - .populate({ - path: "machine.context.reviewData.crossCheckerId", - model: "User", - select: "name", - }) - .populate({ - path: "machine.context.reviewData.reviewerId", - model: "User", - select: "name", - }); - - if (claimReviewTask) { - const preloadedAsignees = []; - const usersId = []; - claimReviewTask.machine.context.reviewData.usersId.forEach( - (assignee) => { - preloadedAsignees.push({ - value: assignee._id, - label: assignee.name, - }); - usersId.push(assignee._id); - } - ); - claimReviewTask.machine.context.reviewData.usersId = usersId; - claimReviewTask.machine.context.preloadedOptions = { - usersId: preloadedAsignees, - }; - - if (claimReviewTask.machine.context.reviewData.crossCheckerId) { - const crossCheckerUser = - claimReviewTask.machine.context.reviewData.crossCheckerId; - claimReviewTask.machine.context.preloadedOptions.crossCheckerId = - [ - { - value: crossCheckerUser._id, - label: crossCheckerUser.name, - }, - ]; - claimReviewTask.machine.context.reviewData.crossCheckerId = - crossCheckerUser._id; - } - - if (claimReviewTask.machine.context.reviewData.reviewerId) { - const reviewerUser = - claimReviewTask.machine.context.reviewData.reviewerId; - claimReviewTask.machine.context.preloadedOptions.reviewerId = [ - { - value: reviewerUser._id, - label: reviewerUser.name, - }, - ]; - claimReviewTask.machine.context.reviewData.reviewerId = - reviewerUser._id; - } - } - - return claimReviewTask; - } - - count(query: any = {}) { - return this.ClaimReviewTaskModel.countDocuments().where(query); - } - - async countReviewTasksNotDeleted(query, filterUser, nameSpace) { - try { - const fieldMap = { - assigned: "machine.context.reviewData.usersId", - crossChecked: "machine.context.reviewData.crossCheckerId", - reviewed: "machine.context.reviewData.reviewerId", - }; - - Object.keys(filterUser).forEach((key) => { - const value = filterUser[key]; - if (value === true || value === "true") { - const queryPath = fieldMap[key]; - query[queryPath] = Types.ObjectId(this.req.user._id); - } - }); - - const pipeline = [ - { $match: query }, - lookupClaimReviews({ - as: "machine.context.claimReview.claimReview", - }), - lookupClaims(TargetModel.ClaimReviewTask, { - pipeline: [ - { $match: { $expr: { $eq: ["$_id", "$$claimId"] } } }, - { $project: { isDeleted: 1, nameSpace: 1 } }, - ], - as: "machine.context.claimReview.claim", - }), - { - $match: { - "machine.context.claimReview.claim.nameSpace": - nameSpace, - "machine.context.claimReview.claimReview.isDeleted": { - $ne: true, - }, - "machine.context.claimReview.claim.isDeleted": { - $ne: true, - }, - }, - }, - { $count: "count" }, - ]; - - const result = await this.ClaimReviewTaskModel.aggregate( - pipeline - ).exec(); - - if (result.length > 0) { - return result[0].count; - } - - return 0; - } catch (error) { - console.error("Error in countReviewTasksNotDeleted:", error); - throw error; - } - } - - getEditorContentObject(schema, reportModel) { - return this.editorParseService.schema2editor(schema, reportModel); - } - - async addComment(data_hash, comment) { - const claimReviewTask = await this.getClaimReviewTaskByDataHash( - data_hash - ); - const reviewData = claimReviewTask.machine.context.reviewData; - const newComment = await this.commentService.create({ - ...comment, - targetId: claimReviewTask._id, - }); - - if (!reviewData.reviewComments) { - reviewData.reviewComments = []; - } - - reviewData.reviewComments.push(Types.ObjectId(newComment?._id)); - - const { machine } = await this.ClaimReviewTaskModel.findOneAndUpdate( - { _id: claimReviewTask._id }, - { "machine.context.reviewData": reviewData }, - { new: true } - ); - - return { - reviewData: machine.context.reviewData, - comment: newComment, - }; - } - - async deleteComment(data_hash, commentId) { - const commentIdObject = Types.ObjectId(commentId); - const claimReviewTask = await this.getClaimReviewTaskByDataHash( - data_hash - ); - const reviewData = claimReviewTask.machine.context.reviewData; - reviewData.reviewComments = reviewData.reviewComments.filter( - (comment) => !comment._id.equals(commentIdObject) - ); - reviewData.reviewComments = reviewData.crossCheckingComments.filter( - (comment) => !comment._id.equals(commentIdObject) - ); - - return this.ClaimReviewTaskModel.findByIdAndUpdate( - claimReviewTask._id, - { "machine.context.reviewData": reviewData } - ); - } - - async getHtmlFromSchema(schema) { - const htmlContent = this.editorParseService.schema2html(schema); - return { - ...schema, - ...htmlContent, - }; - } -} diff --git a/server/claim-review-task/dto/update-claim-review-task.dto.ts b/server/claim-review-task/dto/update-claim-review-task.dto.ts deleted file mode 100644 index 984b8b46f..000000000 --- a/server/claim-review-task/dto/update-claim-review-task.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { PartialType } from "@nestjs/mapped-types"; -import { CreateClaimReviewTaskDTO } from "./create-claim-review-task.dto"; - -export class UpdateClaimReviewTaskDTO extends PartialType( - CreateClaimReviewTaskDTO -) {} diff --git a/server/claim-review-task/schemas/claim-review-task.schema.ts b/server/claim-review-task/schemas/claim-review-task.schema.ts deleted file mode 100644 index 7d14c97ba..000000000 --- a/server/claim-review-task/schemas/claim-review-task.schema.ts +++ /dev/null @@ -1,29 +0,0 @@ -import * as mongoose from "mongoose"; -import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; -import type { Machine } from "../dto/create-claim-review-task.dto"; -import { ReportModelEnum } from "../../types/enums"; - -export type ClaimReviewTaskDocument = ClaimReviewTask & mongoose.Document; - -@Schema({ toObject: { virtuals: true }, toJSON: { virtuals: true } }) -export class ClaimReviewTask { - @Prop({ type: Object, required: true }) - machine: Machine; - - @Prop({ unique: true, required: true }) - data_hash: string; - - @Prop({ - required: true, - validate: { - validator: (v) => { - return Object.values(ReportModelEnum).includes(v); - }, - }, - message: (tag) => `${tag} is not a valid report type.`, - }) - reportModel: ReportModelEnum; -} - -export const ClaimReviewTaskSchema = - SchemaFactory.createForClass(ClaimReviewTask); diff --git a/server/claim-review/claim-review.controller.ts b/server/claim-review/claim-review.controller.ts index a7fd88e9b..3bd5a836d 100644 --- a/server/claim-review/claim-review.controller.ts +++ b/server/claim-review/claim-review.controller.ts @@ -46,13 +46,13 @@ export class ClaimReviewController { } = getClaimReviewsDto; return Promise.all([ - this.claimReviewService.listAll( + this.claimReviewService.listAll({ page, pageSize, order, - { isHidden, isDeleted: false, nameSpace }, - latest - ), + query: { isHidden, isDeleted: false, nameSpace }, + latest, + }), this.claimReviewService.count({ isHidden, isDeleted: false }), ]).then(([reviews, totalReviews]) => { const totalPages = Math.ceil(totalReviews / pageSize); diff --git a/server/claim-review/claim-review.module.ts b/server/claim-review/claim-review.module.ts index 716767553..3df980cc4 100644 --- a/server/claim-review/claim-review.module.ts +++ b/server/claim-review/claim-review.module.ts @@ -1,10 +1,9 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { ClaimReview, ClaimReviewSchema } from "./schemas/claim-review.schema"; import { ClaimReviewService } from "./claim-review.service"; import { MongooseModule } from "@nestjs/mongoose"; import { ClaimReviewController } from "./claim-review.controller"; import { UtilService } from "../util"; -import { SourceModule } from "../source/source.module"; import { ConfigModule } from "@nestjs/config"; import { HistoryModule } from "../history/history.module"; import { SentenceModule } from "../claim/types/sentence/sentence.module"; @@ -12,6 +11,7 @@ import { CaptchaModule } from "../captcha/captcha.module"; import { AbilityModule } from "../auth/ability/ability.module"; import { ImageModule } from "../claim/types/image/image.module"; import { EditorParseModule } from "../editor-parse/editor-parse.module"; +import { WikidataModule } from "../wikidata/wikidata.module"; export const ClaimReviewModel = MongooseModule.forFeature([ { @@ -24,13 +24,13 @@ export const ClaimReviewModel = MongooseModule.forFeature([ imports: [ ClaimReviewModel, HistoryModule, - SourceModule, ConfigModule, - SentenceModule, + forwardRef(() => SentenceModule), CaptchaModule, AbilityModule, ImageModule, EditorParseModule, + WikidataModule, ], providers: [UtilService, ClaimReviewService], exports: [ClaimReviewService], diff --git a/server/claim-review/claim-review.service.ts b/server/claim-review/claim-review.service.ts index c8d54bb38..5eca3f3ef 100644 --- a/server/claim-review/claim-review.service.ts +++ b/server/claim-review/claim-review.service.ts @@ -14,11 +14,10 @@ import { SentenceService } from "../claim/types/sentence/sentence.service"; import { REQUEST } from "@nestjs/core"; import type { BaseRequest } from "../types"; import { ImageService } from "../claim/types/image/image.service"; -import { ContentModelEnum } from "../types/enums"; -import lookUpPersonalityties from "../mongo-pipelines/lookUpPersonalityties"; -import lookupClaims from "../mongo-pipelines/lookupClaims"; +import { ContentModelEnum, ReviewTaskTypeEnum } from "../types/enums"; import { NameSpaceEnum } from "../auth/name-space/schemas/name-space.schema"; import { EditorParseService } from "../editor-parse/editor-parse.service"; +import { WikidataService } from "../wikidata/wikidata.service"; @Injectable({ scope: Scope.REQUEST }) export class ClaimReviewService { @@ -31,14 +30,19 @@ export class ClaimReviewService { private util: UtilService, private sentenceService: SentenceService, private imageService: ImageService, - private editorParseService: EditorParseService + private editorParseService: EditorParseService, + private wikidata: WikidataService ) {} - async listAll(page, pageSize, order, query, latest = false) { - const pipeline = this.ClaimReviewModel.find(query) + async listAll({ page, pageSize, order, query, latest = false }) { + // Currently only list claim reviews + const pipeline = this.ClaimReviewModel.find({ + ...query, + targetModel: ReviewTaskTypeEnum.Claim, + }) .sort(latest ? { date: -1 } : { _id: order === "asc" ? 1 : -1 }) .populate({ - path: "claim", + path: "target", model: "Claim", populate: { path: "latestRevision", @@ -78,10 +82,13 @@ export class ClaimReviewService { } async listDailyClaimReviews(query) { - const pipeline = this.ClaimReviewModel.find(query) + const pipeline = this.ClaimReviewModel.find({ + ...query, + targetModel: ReviewTaskTypeEnum.Claim, + }) .sort({ _id: 1 }) .populate({ - path: "claim", + path: "target", model: "Claim", populate: { path: "latestRevision", @@ -127,7 +134,7 @@ export class ClaimReviewService { async agreggateClassification(match: any) { const claimReviews = await this.ClaimReviewModel.find(match).populate({ - path: "claim", + path: "target", model: "Claim", match: { "claim.isHidden": false, @@ -143,14 +150,28 @@ export class ClaimReviewService { aggregation.push( { $match: query }, - lookUpPersonalityties(TargetModel.ClaimReview), - lookupClaims(TargetModel.ClaimReview), - { $unwind: "$claim" }, + { + $lookup: { + from: "personalities", + localField: "personality", + foreignField: "_id", + as: "personality", + }, + }, + { + $lookup: { + from: "claims", + localField: "target", + foreignField: "_id", + as: "target", + }, + }, + { $unwind: "$target" }, { $match: { "personality.isDeleted": false, - "claim.isHidden": query.isHidden, - "claim.isDeleted": false, + "target.isHidden": query.isHidden, + "target.isDeleted": false, }, } ); @@ -174,7 +195,7 @@ export class ClaimReviewService { async getReviewStatsByClaimId(claimId) { const reviews = await this.ClaimReviewModel.find({ - claim: claimId, + target: claimId, isDeleted: false, isPublished: true, isHidden: false, @@ -192,7 +213,7 @@ export class ClaimReviewService { async getReviewsByClaimId(claimId) { const classificationCounts = {}; const claimReviews = await this.ClaimReviewModel.find({ - claim: claimId, + target: claimId, isDeleted: false, isPublished: true, isHidden: false, @@ -220,44 +241,41 @@ export class ClaimReviewService { * This function creates a new claim review. * Also creates a History Module that tracks creation of claim reviews. * @param claimReview ClaimReviewBody received of the client. + * @param data_hash unique claim review task hash + * @param reportModel FactChecking or InformativeNews * @returns Return a new claim review object. */ async create(claimReview, data_hash, reportModel) { - // This line may cause a false positive in sonarCloud because if we remove the await, we cannot iterate through the results - const review = await this.getReviewByDataHash(data_hash); - - if (review) { - throw new Error("This Claim already has a review"); - //TODO: verify if already start a review and isn't published - } else { - // Cast ObjectId - claimReview.personality = claimReview.personality - ? Types.ObjectId(claimReview.personality) - : null; - claimReview.claim = Types.ObjectId(claimReview.claim); - claimReview.usersId = claimReview.report.usersId.map((userId) => { - return Types.ObjectId(userId); - }); - claimReview.report = Types.ObjectId(claimReview.report._id); - claimReview.data_hash = data_hash; - claimReview.reportModel = reportModel; - claimReview.date = new Date(); - const newClaimReview = new this.ClaimReviewModel(claimReview); - newClaimReview.isPublished = true; - newClaimReview.isPartialReview = claimReview.isPartialReview; - - const history = this.historyService.getHistoryParams( - newClaimReview._id, - TargetModel.ClaimReview, - claimReview.usersId, - HistoryType.Create, - newClaimReview - ); - - this.historyService.createHistory(history); + if (claimReview.personality) { + claimReview.personality = Types.ObjectId(claimReview.personality); + } - return newClaimReview.save(); + if (claimReview.target) { + claimReview.target = Types.ObjectId(claimReview.target); } + + claimReview.usersId = claimReview.report.usersId.map((userId) => { + return Types.ObjectId(userId); + }); + claimReview.report = Types.ObjectId(claimReview.report._id); + claimReview.data_hash = data_hash; + claimReview.reportModel = reportModel; + claimReview.date = new Date(); + const newClaimReview = new this.ClaimReviewModel(claimReview); + newClaimReview.isPublished = true; + newClaimReview.isPartialReview = claimReview.isPartialReview; + + const history = this.historyService.getHistoryParams( + newClaimReview._id, + TargetModel.ClaimReview, + claimReview.usersId, + HistoryType.Create, + newClaimReview + ); + + this.historyService.createHistory(history); + + return newClaimReview.save(); } getById(claimReviewId) { @@ -340,14 +358,20 @@ export class ClaimReviewService { } private async postProcess(review) { - const { personality, data_hash, report } = review; + const { data_hash, report } = review; + const personality = await this.personalityPostProcess( + review.personality + ); + const nameSpace = this.req.params.namespace || this.req.query.nameSpace || NameSpaceEnum.Main; const claim = { - contentModel: review.claim.latestRevision.contentModel, - date: review.claim.latestRevision.date, + contentModel: review.target.latestRevision.contentModel, + date: review.target.latestRevision.date, + slug: review.target.latestRevision.slug, + title: review.target.latestRevision.title, }; const isContentImage = claim.contentModel === ContentModelEnum.Image; @@ -361,21 +385,21 @@ export class ClaimReviewService { let reviewHref = nameSpace !== NameSpaceEnum.Main - ? `/${nameSpace}/claim/${review.claim.latestRevision.claimId}` - : `/claim/${review.claim.latestRevision.claimId}`; + ? `/${nameSpace}/claim/${review.target.latestRevision.claimId}` + : `/claim/${review.target.latestRevision.claimId}`; if (isContentInformativeNews) { reviewHref = nameSpace !== NameSpaceEnum.Main - ? `/${nameSpace}/claim/${review.claim.slug}` - : `/claim/${review.claim.slug}`; + ? `/${nameSpace}/claim/${review.target.slug}` + : `/claim/${review.target.slug}`; } if (personality) { reviewHref = nameSpace !== NameSpaceEnum.Main - ? `/${nameSpace}/personality/${personality?.slug}/claim/${review.claim.slug}` - : `/personality/${personality?.slug}/claim/${review.claim.slug}`; + ? `/${nameSpace}/personality/${personality?.slug}/claim/${review.target.slug}` + : `/personality/${personality?.slug}/claim/${review.target.slug}`; } reviewHref += isContentImage @@ -384,8 +408,8 @@ export class ClaimReviewService { if (isContentDebate) { reviewHref = nameSpace !== NameSpaceEnum.Main - ? `/${nameSpace}/claim/${review.claim.latestRevision.claimId}/debate` - : `/claim/${review.claim.latestRevision.claimId}/debate`; + ? `/${nameSpace}/claim/${review.target.latestRevision.claimId}/debate` + : `/claim/${review.target.latestRevision.claimId}/debate`; } return { @@ -421,4 +445,23 @@ export class ClaimReviewService { ...htmlContent, }; } + + async personalityPostProcess(personality) { + if (personality) { + const wikidataExtract = await this.wikidata.fetchProperties({ + wikidataId: personality.wikidata, + language: this.req.language || "en", + }); + + if (wikidataExtract.isAllowedProp === false) { + return; + } + + return { + ...personality.toObject(), + ...wikidataExtract, + }; + } + return personality; + } } diff --git a/server/claim-review/schemas/claim-review.schema.ts b/server/claim-review/schemas/claim-review.schema.ts index f2155410b..db0cd6048 100644 --- a/server/claim-review/schemas/claim-review.schema.ts +++ b/server/claim-review/schemas/claim-review.schema.ts @@ -7,6 +7,7 @@ import type { ReportDocument } from "../../report/schemas/report.schema"; import { User } from "../../users/schemas/user.schema"; import { ReportModelEnum } from "../../types/enums"; import { NameSpaceEnum } from "../../auth/name-space/schemas/name-space.schema"; +import { Source } from "../../source/schemas/source.schema"; export type ClaimReviewDocument = ClaimReview & mongoose.Document; @@ -14,17 +15,17 @@ export type ClaimReviewDocument = ClaimReview & mongoose.Document; export class ClaimReview { @Prop({ type: mongoose.Types.ObjectId, - required: true, - ref: "Claim", + required: false, + ref: "Personality", }) - claim: Claim; + personality: Personality; @Prop({ type: mongoose.Types.ObjectId, required: false, - ref: "Personality", + ref: "Source", }) - personality: Personality; + target: mongoose.Types.ObjectId; @Prop({ type: mongoose.Types.ObjectId, @@ -68,6 +69,9 @@ export class ClaimReview { @Prop({ default: NameSpaceEnum.Main, required: true }) nameSpace: string; + + @Prop({ required: true, type: String }) + targetModel: string; } const ClaimReviewSchemaRaw = SchemaFactory.createForClass(ClaimReview); diff --git a/server/claim/claim-revision/claim-revision.service.ts b/server/claim/claim-revision/claim-revision.service.ts index 61fbb6467..fc76763f3 100644 --- a/server/claim/claim-revision/claim-revision.service.ts +++ b/server/claim/claim-revision/claim-revision.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { InjectModel } from "@nestjs/mongoose"; -import { Model } from "mongoose"; +import { Model, Types } from "mongoose"; import slugify from "slugify"; import { ParserService } from "../parser/parser.service"; import { SourceService } from "../../source/source.service"; @@ -69,10 +69,14 @@ export class ClaimRevisionService { lower: true, // convert to lower case, defaults to `false` strict: true, // strip special characters except replacement, defaults to `false` }); + const newClaimRevision = new this.ClaimRevisionModel(claim); + const newclaimRevisionId = Types.ObjectId(newClaimRevision._id); - claim.contentId = await this._createContentModel(claim); + newClaimRevision.contentId = await this._createContentModel( + claim, + newclaimRevisionId + ); - const newClaimRevision = new this.ClaimRevisionModel(claim); await this._createSources(claim.sources, claimId); return newClaimRevision.save(); } @@ -91,7 +95,7 @@ export class ClaimRevisionService { query: searchText, path: "title", fuzzy: { - maxEdits: 2, + maxEdits: 1, // Using maxEdits: 1 to allow minor typos or spelling errors in search queries. }, }, }, @@ -165,18 +169,30 @@ export class ClaimRevisionService { return this.ClaimRevisionModel.findOne({ contentId }); } - private async _createContentModel(claim) { + private async _createContentModel(claim, claimRevisionId) { switch (claim.contentModel) { case ContentModelEnum.Speech: - return (await this.parserService.parse(claim.content))._id; + return ( + await this.parserService.parse( + claim.content, + claimRevisionId + ) + )._id; case ContentModelEnum.Image: - return (await this.imageService.create(claim.content))._id; + return ( + await this.imageService.create( + claim.content, + claimRevisionId + ) + )._id; case ContentModelEnum.Debate: - return (await this.debateService.create(claim))._id; + return (await this.debateService.create(claim, claimRevisionId)) + ._id; case ContentModelEnum.Unattributed: return ( await this.parserService.parse( claim.content, + claimRevisionId, null, claim.contentModel ) diff --git a/server/claim/claim.controller.ts b/server/claim/claim.controller.ts index 2ab76da44..cc7e402ce 100644 --- a/server/claim/claim.controller.ts +++ b/server/claim/claim.controller.ts @@ -12,7 +12,6 @@ import { Req, Res, Header, - Optional, UseGuards, } from "@nestjs/common"; import { ClaimReviewService } from "../claim-review/claim-review.service"; @@ -28,12 +27,11 @@ import { GetClaimsDTO } from "./dto/get-claims.dto"; import { UpdateClaimDTO } from "./dto/update-claim.dto"; import { IsPublic } from "../auth/decorators/is-public.decorator"; import { CaptchaService } from "../captcha/captcha.service"; -import { ClaimReviewTaskService } from "../claim-review-task/claim-review-task.service"; +import { ReviewTaskService } from "../review-task/review-task.service"; import { TargetModel } from "../history/schema/history.schema"; import { SentenceService } from "./types/sentence/sentence.service"; import type { BaseRequest } from "../types"; import slugify from "slugify"; -import { UnleashService } from "nestjs-unleash"; import { SentenceDocument } from "./types/sentence/schemas/sentence.schema"; import { ImageService } from "./types/image/image.service"; import { ImageDocument } from "./types/image/schemas/image.schema"; @@ -51,13 +49,17 @@ import { Roles } from "../auth/ability/ability.factory"; import { ApiTags } from "@nestjs/swagger"; import { HistoryService } from "../history/history.service"; import { NameSpaceEnum } from "../auth/name-space/schemas/name-space.schema"; +import { ClaimRevisionService } from "./claim-revision/claim-revision.service"; +import { FeatureFlagService } from "../feature-flag/feature-flag.service"; +import { Types } from "mongoose"; +import { GroupService } from "../group/group.service"; @Controller(":namespace?") export class ClaimController { private readonly logger = new Logger("ClaimController"); constructor( private claimReviewService: ClaimReviewService, - private claimReviewTaskService: ClaimReviewTaskService, + private reviewTaskService: ReviewTaskService, private personalityService: PersonalityService, private claimService: ClaimService, private sentenceService: SentenceService, @@ -69,7 +71,9 @@ export class ClaimController { private editorService: EditorService, private parserService: ParserService, private historyService: HistoryService, - @Optional() private readonly unleash: UnleashService + private claimRevisionService: ClaimRevisionService, + private featureFlagService: FeatureFlagService, + private groupService: GroupService ) {} _verifyInputsQuery(query) { @@ -220,9 +224,17 @@ export class ClaimController { ) { const { content, personality, isLive } = updateClaimDebateDto; let newSpeech; + + const claimRevision = await this.claimRevisionService.getByContentId( + Types.ObjectId(debateId) + ); + + const claimRevisionId = Types.ObjectId(claimRevision._id); + if (content && personality) { newSpeech = await this.parserService.parse( updateClaimDebateDto.content, + claimRevisionId, updateClaimDebateDto.personality ); @@ -330,8 +342,8 @@ export class ClaimController { ) { const hideDescriptions = {}; - const claimReviewTask = - await this.claimReviewTaskService.getClaimReviewTaskByDataHashWithUsernames( + const reviewTask = + await this.reviewTaskService.getReviewTaskByDataHashWithUsernames( data_hash ); @@ -339,11 +351,14 @@ export class ClaimController { data_hash ); - const enableCollaborativeEditor = this.isEnableCollaborativeEditor(); - const enableCopilotChatBot = this.isEnableCopilotChatBot(); - const enableEditorAnnotations = this.isEnableEditorAnnotations(); + const enableCollaborativeEditor = + this.featureFlagService.isEnableCollaborativeEditor(); + const enableCopilotChatBot = + this.featureFlagService.isEnableCopilotChatBot(); + const enableEditorAnnotations = + this.featureFlagService.isEnableEditorAnnotations(); const enableAddEditorSourcesWithoutSelecting = - this.isEnableAddEditorSourcesWithoutSelecting(); + this.featureFlagService.isEnableAddEditorSourcesWithoutSelecting(); hideDescriptions[TargetModel.Claim] = await this.historyService.getDescriptionForHide( @@ -367,7 +382,7 @@ export class ClaimController { personality, claim, content, - claimReviewTask, + reviewTask, claimReview, sitekey: this.configService.get("recaptcha_sitekey"), hideDescriptions, @@ -494,12 +509,16 @@ export class ClaimController { @ApiTags("pages") @Get("claim/create") public async claimCreatePage( - @Query() query: { personality?: string }, + @Query() query: { personality?: string; verificationRequest?: string }, @Req() req: BaseRequest, @Res() res: Response ) { const parsedUrl = parse(req.url, true); + const verificationRequestGroup = query.verificationRequest + ? await this.groupService.getByContentId(query.verificationRequest) + : null; + const personality = query.personality ? await this.personalityService.getClaimsByPersonalitySlug( { @@ -518,6 +537,7 @@ export class ClaimController { personality, sitekey: this.configService.get("recaptcha_sitekey"), nameSpace: req.params.namespace, + verificationRequestGroup, }) ); } @@ -551,11 +571,14 @@ export class ClaimController { const { claimSlug, namespace = NameSpaceEnum.Main } = req.params; const parsedUrl = parse(req.url, true); const claim = await this.claimService.getByClaimSlug(claimSlug); - const enableCollaborativeEditor = this.isEnableCollaborativeEditor(); - const enableCopilotChatBot = this.isEnableCopilotChatBot(); - const enableEditorAnnotations = this.isEnableEditorAnnotations(); + const enableCollaborativeEditor = + this.featureFlagService.isEnableCollaborativeEditor(); + const enableCopilotChatBot = + this.featureFlagService.isEnableCopilotChatBot(); + const enableEditorAnnotations = + this.featureFlagService.isEnableEditorAnnotations(); const enableAddEditorSourcesWithoutSelecting = - this.isEnableAddEditorSourcesWithoutSelecting(); + this.featureFlagService.isEnableAddEditorSourcesWithoutSelecting(); this.redirectBasedOnPersonality(res, claim, namespace); @@ -585,11 +608,14 @@ export class ClaimController { const { claimSlug, revisionId, namespace } = req.params; const parsedUrl = parse(req.url, true); - const enableCollaborativeEditor = this.isEnableCollaborativeEditor(); - const enableCopilotChatBot = this.isEnableCopilotChatBot(); - const enableEditorAnnotations = this.isEnableEditorAnnotations(); + const enableCollaborativeEditor = + this.featureFlagService.isEnableCollaborativeEditor(); + const enableCopilotChatBot = + this.featureFlagService.isEnableCopilotChatBot(); + const enableEditorAnnotations = + this.featureFlagService.isEnableEditorAnnotations(); const enableAddEditorSourcesWithoutSelecting = - this.isEnableAddEditorSourcesWithoutSelecting(); + this.featureFlagService.isEnableAddEditorSourcesWithoutSelecting(); const claim = await this.claimService.getByClaimSlug( claimSlug, @@ -625,11 +651,14 @@ export class ClaimController { const { personalitySlug, claimSlug, namespace } = req.params; const parsedUrl = parse(req.url, true); - const enableCollaborativeEditor = this.isEnableCollaborativeEditor(); - const enableCopilotChatBot = this.isEnableCopilotChatBot(); - const enableEditorAnnotations = this.isEnableEditorAnnotations(); + const enableCollaborativeEditor = + this.featureFlagService.isEnableCollaborativeEditor(); + const enableCopilotChatBot = + this.featureFlagService.isEnableCopilotChatBot(); + const enableEditorAnnotations = + this.featureFlagService.isEnableEditorAnnotations(); const enableAddEditorSourcesWithoutSelecting = - this.isEnableAddEditorSourcesWithoutSelecting(); + this.featureFlagService.isEnableAddEditorSourcesWithoutSelecting(); const personality = await this.personalityService.getClaimsByPersonalitySlug( @@ -688,11 +717,14 @@ export class ClaimController { req.language ); - const enableCollaborativeEditor = this.isEnableCollaborativeEditor(); - const enableCopilotChatBot = this.isEnableCopilotChatBot(); - const enableEditorAnnotations = this.isEnableEditorAnnotations(); + const enableCollaborativeEditor = + this.featureFlagService.isEnableCollaborativeEditor(); + const enableCopilotChatBot = + this.featureFlagService.isEnableCopilotChatBot(); + const enableEditorAnnotations = + this.featureFlagService.isEnableEditorAnnotations(); const enableAddEditorSourcesWithoutSelecting = - this.isEnableAddEditorSourcesWithoutSelecting(); + this.featureFlagService.isEnableAddEditorSourcesWithoutSelecting(); const claim = await this.claimService.getByPersonalityIdAndClaimSlug( personality._id, @@ -878,18 +910,17 @@ export class ClaimController { const { data_hash } = req.params; const parsedUrl = parse(req.url, true); - const claimReviewTask = - await this.claimReviewTaskService.getClaimReviewTaskByDataHash( - data_hash - ); + const reviewTask = await this.reviewTaskService.getReviewTaskByDataHash( + data_hash + ); await this.viewService.getNextServer().render( req, res, "/history-page", Object.assign(parsedUrl.query, { - targetId: claimReviewTask._id, - targetModel: TargetModel.ClaimReviewTask, + targetId: reviewTask._id, + targetModel: TargetModel.ReviewTask, nameSpace: req.params.namespace, }) ); @@ -921,38 +952,6 @@ export class ClaimController { await this.returnClaimReviewPage(data_hash, req, res, claim, sentence); } - private isEnableCollaborativeEditor() { - const config = this.configService.get("feature_flag"); - - return config - ? this.unleash.isEnabled("enable_collaborative_editor") - : false; - } - - private isEnableCopilotChatBot() { - const config = this.configService.get("feature_flag"); - - return config ? this.unleash.isEnabled("copilot_chat_bot") : false; - } - - private isEnableEditorAnnotations() { - const config = this.configService.get("feature_flag"); - - return config - ? this.unleash.isEnabled("enable_editor_annotations") - : false; - } - - private isEnableAddEditorSourcesWithoutSelecting() { - const config = this.configService.get("feature_flag"); - - return config - ? this.unleash.isEnabled( - "enable_add_editor_sources_without_selecting" - ) - : false; - } - private redirectBasedOnPersonality( res, claim, diff --git a/server/claim/claim.module.ts b/server/claim/claim.module.ts index 6cdd6c17d..62f2f0b07 100644 --- a/server/claim/claim.module.ts +++ b/server/claim/claim.module.ts @@ -8,11 +8,10 @@ import { ParserModule } from "./parser/parser.module"; import { PersonalityModule } from "../personality/personality.module"; import { ConfigModule } from "@nestjs/config"; import { ViewModule } from "../view/view.module"; -import { SourceModule } from "../source/source.module"; import { ClaimRevisionModule } from "./claim-revision/claim-revision.module"; import { HistoryModule } from "../history/history.module"; import { CaptchaModule } from "../captcha/captcha.module"; -import { ClaimReviewTaskModule } from "../claim-review-task/claim-review-task.module"; +import { ReviewTaskModule } from "../review-task/review-task.module"; import { SentenceModule } from "./types/sentence/sentence.module"; import { StateEventModule } from "../state-event/state-event.module"; import { ImageModule } from "./types/image/image.module"; @@ -20,6 +19,8 @@ import { DebateModule } from "./types/debate/debate.module"; import { EditorModule } from "../editor/editor.module"; import { AbilityModule } from "../auth/ability/ability.module"; import { UtilService } from "../util"; +import { FeatureFlagModule } from "../feature-flag/feature-flag.module"; +import { GroupModule } from "../group/group.module"; const ClaimModel = MongooseModule.forFeature([ { @@ -32,7 +33,7 @@ const ClaimModel = MongooseModule.forFeature([ imports: [ ClaimModel, ClaimReviewModule, - ClaimReviewTaskModule, + ReviewTaskModule, ClaimRevisionModule, SentenceModule, ParserModule, @@ -41,12 +42,13 @@ const ClaimModel = MongooseModule.forFeature([ StateEventModule, ConfigModule, ViewModule, - SourceModule, CaptchaModule, ImageModule, DebateModule, EditorModule, AbilityModule, + FeatureFlagModule, + GroupModule, ], exports: [ClaimService], providers: [UtilService, ClaimService], diff --git a/server/claim/claim.service.ts b/server/claim/claim.service.ts index 125fe1e80..115740102 100644 --- a/server/claim/claim.service.ts +++ b/server/claim/claim.service.ts @@ -12,9 +12,10 @@ import { ISoftDeletedModel } from "mongoose-softdelete-typescript"; import { REQUEST } from "@nestjs/core"; import type { BaseRequest } from "../types"; import { ContentModelEnum } from "../types/enums"; -import { ClaimReviewTaskService } from "../claim-review-task/claim-review-task.service"; +import { ReviewTaskService } from "../review-task/review-task.service"; import { UtilService } from "../util"; import { NameSpaceEnum } from "../auth/name-space/schemas/name-space.schema"; +import { GroupService } from "../group/group.service"; type ClaimMatchParameters = ( | { _id: string; isHidden?: boolean; nameSpace?: string } @@ -38,8 +39,9 @@ export class ClaimService { private historyService: HistoryService, private stateEventService: StateEventService, private claimRevisionService: ClaimRevisionService, - private claimReviewTaskService: ClaimReviewTaskService, - private util: UtilService + private reviewTaskService: ReviewTaskService, + private util: UtilService, + private groupService: GroupService ) {} async listAll(page, pageSize, order, query) { @@ -126,6 +128,10 @@ export class ClaimService { return Types.ObjectId(personality); }); + if (claim.group) { + claim.group = Types.ObjectId(claim.group); + } + const newClaim = new this.ClaimModel(claim); const newClaimRevision = await this.claimRevisionService.create( newClaim._id, @@ -150,6 +156,9 @@ export class ClaimService { this.historyService.createHistory(history); this.stateEventService.createStateEvent(stateEvent); + if (claim.group) { + this.groupService.updateWithTargetId(claim.group, newClaim._id); + } newClaim.save(); return { @@ -383,10 +392,8 @@ export class ClaimService { const reviews = await this.claimReviewService.getReviewsByClaimId( claim._id ); - const claimReviewTasks = - await this.claimReviewTaskService.getReviewTasksByClaimId( - claim._id - ); + const reviewTasks = + await this.reviewTaskService.getReviewTasksByClaimId(claim._id); processedClaim.content = this.getClaimContent(processedClaim); @@ -397,7 +404,7 @@ export class ClaimService { const content = this.transformContentObject( speech.content, reviews, - claimReviewTasks + reviewTasks ); return { ...speech, content }; }); @@ -405,7 +412,7 @@ export class ClaimService { processedClaim.content = this.transformContentObject( processedClaim.content, reviews, - claimReviewTasks + reviewTasks ); } } @@ -447,11 +454,8 @@ export class ClaimService { }; } - private transformContentObject(claimContent, reviews, claimReviewTasks) { - if ( - !claimContent || - (reviews.length <= 0 && claimReviewTasks.length <= 0) - ) { + private transformContentObject(claimContent, reviews, reviewTasks) { + if (!claimContent || (reviews.length <= 0 && reviewTasks.length <= 0)) { return claimContent; } @@ -490,11 +494,11 @@ export class ClaimService { ); } - const claimReviewTask = claimReviewTasks.find( + const reviewTask = reviewTasks.find( (task) => task?.data_hash === sentence.data_hash ); - if (claimReviewTask) { + if (reviewTask) { return processReview(sentence, "in-progress"); } diff --git a/server/claim/dto/create-claim.dto.ts b/server/claim/dto/create-claim.dto.ts index 4674fe740..c8a90402a 100644 --- a/server/claim/dto/create-claim.dto.ts +++ b/server/claim/dto/create-claim.dto.ts @@ -10,6 +10,7 @@ import { import { ContentModelEnum } from "../../types/enums"; import { Personality } from "../../personality/schemas/personality.schema"; import { ApiProperty } from "@nestjs/swagger"; +import { Group } from "../../group/schemas/group.schema"; export class CreateClaimDTO { @IsNotEmpty() @@ -52,4 +53,9 @@ export class CreateClaimDTO { @IsString() @ApiProperty() nameSpace: string; + + @IsString() + @IsOptional() + @ApiProperty() + group: Group; } diff --git a/server/claim/parser/parser.service.spec.ts b/server/claim/parser/parser.service.spec.ts index be0cfbee6..5d6b40694 100644 --- a/server/claim/parser/parser.service.spec.ts +++ b/server/claim/parser/parser.service.spec.ts @@ -1,37 +1,26 @@ import { Test, TestingModule } from "@nestjs/testing"; import { ParserService } from "./parser.service"; import * as fs from "fs"; -import { SpeechModule } from "../types/speech/speech.module"; -import { ParagraphModule } from "../types/paragraph/paragraph.module"; -import { SentenceModule } from "../types/sentence/sentence.module"; -import { MongooseModule } from "@nestjs/mongoose"; import { TestConfigOptions } from "../../tests/utils/TestConfigOptions"; import { MongoMemoryServer } from "mongodb-memory-server"; -import { UnattributedModule } from "../types/unattributed/unattributed.module"; +import { Types } from "mongoose"; +import { AppModule } from "../../app.module"; describe("ParserService", () => { let parserService: ParserService; let db: any; + const claimRevisionIdMock = new Types.ObjectId("66684a2a763175559d119818"); beforeAll(async () => { db = await MongoMemoryServer.create({ instance: { port: 35025 } }); }); beforeEach(async () => { - const testingModule: TestingModule = await Test.createTestingModule({ - imports: [ - MongooseModule.forRoot( - TestConfigOptions.config.db.connection_uri, - TestConfigOptions.config.db.options - ), - SpeechModule, - ParagraphModule, - SentenceModule, - UnattributedModule, - ], - providers: [ParserService], + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule.register(TestConfigOptions.config)], }).compile(); - parserService = testingModule.get(ParserService); + + parserService = moduleFixture.get(ParserService); }); describe("parse()", () => { @@ -40,7 +29,7 @@ describe("ParserService", () => { "Pellentesque auctor neque nec urna. Nulla facilisi. Praesent nec nisl a purus blandit viverra." + "\n\nNam at tortor in tellus interdum sagittis. Ut leo. Praesent adipiscing. Curabitur nisi."; const parseOutput = await ( - await parserService.parse(claimText) + await parserService.parse(claimText, claimRevisionIdMock) ) .populate({ path: "content", @@ -64,7 +53,7 @@ describe("ParserService", () => { ); const parseOutput = ( await ( - await parserService.parse(claimText) + await parserService.parse(claimText, claimRevisionIdMock) ) .populate({ path: "content", @@ -83,7 +72,7 @@ describe("ParserService", () => { const claimText = "Nulla facilisi.\n\nUt leo."; const parseOutput = ( await ( - await parserService.parse(claimText) + await parserService.parse(claimText, claimRevisionIdMock) ) .populate({ path: "content", @@ -102,7 +91,7 @@ describe("ParserService", () => { it("Ph.D word is not confused with end of sentence", async () => { const claimText = "Jose is Ph.D. and Maria is a Ph.D."; const parseOutput = await ( - await parserService.parse(claimText) + await parserService.parse(claimText, claimRevisionIdMock) ) .populate({ path: "content", @@ -120,7 +109,7 @@ describe("ParserService", () => { const claimText = "Mr. Jose and Mrs. Maria lives in St. Monica with Ms. Butterfly their Dr. of the year"; const parseOutput = await ( - await parserService.parse(claimText) + await parserService.parse(claimText, claimRevisionIdMock) ) .populate({ path: "content", diff --git a/server/claim/parser/parser.service.ts b/server/claim/parser/parser.service.ts index 0becbc9d8..fa91b83eb 100644 --- a/server/claim/parser/parser.service.ts +++ b/server/claim/parser/parser.service.ts @@ -26,6 +26,7 @@ export class ParserService { async parse( content: string, + claimRevisionId: object, personality = null, contentModel = ContentModelEnum.Speech ): Promise { @@ -51,8 +52,13 @@ export class ParserService { props: { id: paragraphId, }, + claimRevisionId: claimRevisionId, content: sentences.map((sentence) => - this.parseSentence(sentence, paragraphDataHash) + this.parseSentence( + sentence, + paragraphDataHash, + claimRevisionId + ) ), }) ); @@ -73,6 +79,7 @@ export class ParserService { return this.speechService.create({ content: object, + claimRevisionId: claimRevisionId, personality, }); } @@ -99,7 +106,7 @@ export class ParserService { return newSentences; } - parseSentence(sentenceContent, paragraphDataHash) { + parseSentence(sentenceContent, paragraphDataHash, claimRevisionId) { const sentenceId = this.createSentenceId(); const sentenceDataHash = md5( `${paragraphDataHash}${this.sentenceSequence}${sentenceContent}` @@ -111,6 +118,7 @@ export class ParserService { id: sentenceId, }, content: sentenceContent, + claimRevisionId: claimRevisionId, }); } diff --git a/server/claim/schemas/claim.schema.ts b/server/claim/schemas/claim.schema.ts index a722f053c..33f7586a3 100644 --- a/server/claim/schemas/claim.schema.ts +++ b/server/claim/schemas/claim.schema.ts @@ -4,6 +4,7 @@ import { Personality } from "../../personality/schemas/personality.schema"; import { ClaimRevision } from "../claim-revision/schema/claim-revision.schema"; import { softDeletePlugin } from "mongoose-softdelete-typescript"; import { NameSpaceEnum } from "../../auth/name-space/schemas/name-space.schema"; +import { Group } from "../../group/schemas/group.schema"; export type ClaimDocument = Claim & mongoose.Document & { revisions: any }; @@ -35,6 +36,13 @@ export class Claim { @Prop({ default: NameSpaceEnum.Main, required: true }) nameSpace: string; + + @Prop({ + type: mongoose.Types.ObjectId, + required: false, + ref: "Group", + }) + group: Group; } const ClaimSchemaRaw = SchemaFactory.createForClass(Claim); diff --git a/server/claim/types/README.md b/server/claim/types/README.md index cb7486d6e..c2f824798 100644 --- a/server/claim/types/README.md +++ b/server/claim/types/README.md @@ -28,6 +28,10 @@ interface ClaimSpec { * Any structed data that allows the platform to retrieve a kind of media */ content: any; + /** + * The unique identifier of the claim revision. + */ + claimRevisionId: objectId; } ``` diff --git a/server/claim/types/debate/debate.service.ts b/server/claim/types/debate/debate.service.ts index f8c0c1e42..3bfeff82d 100644 --- a/server/claim/types/debate/debate.service.ts +++ b/server/claim/types/debate/debate.service.ts @@ -32,7 +32,7 @@ export class DebateService { .lean(); } - async create(claim) { + async create(claim, claimRevisionId) { let hashString = claim.personalities.join(" "); hashString += ` ${claim.title} ${claim.date.toString()}`; const data_hash = md5(hashString); @@ -40,6 +40,7 @@ export class DebateService { content: [], isLive: false, data_hash, + claimRevisionId: claimRevisionId, }; const debateCreated = await this.DebateModel.create(debate); await this.editorService.create(debateCreated._id); diff --git a/server/claim/types/debate/schemas/debate.schema.ts b/server/claim/types/debate/schemas/debate.schema.ts index 7bfe37ac9..d7ab67248 100644 --- a/server/claim/types/debate/schemas/debate.schema.ts +++ b/server/claim/types/debate/schemas/debate.schema.ts @@ -1,6 +1,7 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Speech } from "../../speech/schemas/speech.schema"; import * as mongoose from "mongoose"; +import { ClaimRevision } from "../../../../claim/claim-revision/schema/claim-revision.schema"; export type DebateDocument = Debate & mongoose.Document; @@ -35,6 +36,13 @@ export class Debate { @Prop({ type: Date }) updatedAt: Date; + + @Prop({ + type: mongoose.Types.ObjectId, + required: true, + ref: "ClaimRevision", + }) + claimRevisionId: ClaimRevision; } const DebateSchemaRaw = SchemaFactory.createForClass(Debate); diff --git a/server/claim/types/image/image.module.ts b/server/claim/types/image/image.module.ts index e2fd61b0c..7323020d2 100644 --- a/server/claim/types/image/image.module.ts +++ b/server/claim/types/image/image.module.ts @@ -1,4 +1,4 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { MongooseModule } from "@nestjs/mongoose"; import { ReportModule } from "../../../report/report.module"; import { HistoryModule } from "../../../history/history.module"; @@ -14,7 +14,7 @@ const ImageModel = MongooseModule.forFeature([ ]); @Module({ - imports: [ImageModel, HistoryModule, ReportModule], + imports: [ImageModel, HistoryModule, forwardRef(() => ReportModule)], controllers: [ImageController], providers: [ImageService], exports: [ImageService], diff --git a/server/claim/types/image/image.service.ts b/server/claim/types/image/image.service.ts index 37607a855..000b5ab24 100644 --- a/server/claim/types/image/image.service.ts +++ b/server/claim/types/image/image.service.ts @@ -21,7 +21,7 @@ export class ImageService { private reportService: ReportService ) {} - async create(image) { + async create(image, claimRevisionId = null) { const imageSchema = { data_hash: image.DataHash, props: { @@ -29,6 +29,7 @@ export class ImageService { extension: image.Extension, }, content: image.FileURL, + claimRevisionId: claimRevisionId, }; const newImage = await new this.ImageModel(imageSchema).save(); diff --git a/server/claim/types/image/schemas/image.schema.ts b/server/claim/types/image/schemas/image.schema.ts index 6beb6110a..3e58fee62 100644 --- a/server/claim/types/image/schemas/image.schema.ts +++ b/server/claim/types/image/schemas/image.schema.ts @@ -2,6 +2,7 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { ContentModelEnum } from "../../../../types/enums"; import * as mongoose from "mongoose"; import { Topic } from "../../../../topic/schemas/topic.schema"; +import { ClaimRevision } from "../../../../claim/claim-revision/schema/claim-revision.schema"; export type ImageDocument = Image & mongoose.Document; @@ -24,6 +25,13 @@ export class Image { @Prop({ required: false }) topics: Topic[]; + + @Prop({ + type: mongoose.Types.ObjectId, + required: false, + ref: "ClaimRevision", + }) + claimRevisionId: ClaimRevision; } const ImageSchemaRaw = SchemaFactory.createForClass(Image); diff --git a/server/claim/types/paragraph/schemas/paragraph.schema.ts b/server/claim/types/paragraph/schemas/paragraph.schema.ts index 3a7640987..72a6ff05f 100644 --- a/server/claim/types/paragraph/schemas/paragraph.schema.ts +++ b/server/claim/types/paragraph/schemas/paragraph.schema.ts @@ -1,6 +1,7 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import * as mongoose from "mongoose"; import { Sentence } from "../../sentence/schemas/sentence.schema"; +import { ClaimRevision } from "../../../../claim/claim-revision/schema/claim-revision.schema"; export type ParagraphDocument = Paragraph & mongoose.Document; @@ -29,6 +30,13 @@ export class Paragraph { ], }) content: Sentence[]; + + @Prop({ + type: mongoose.Types.ObjectId, + required: true, + ref: "ClaimRevision", + }) + claimRevisionId: ClaimRevision; } const ParagraphSchemaRaw = SchemaFactory.createForClass(Paragraph); diff --git a/server/claim/types/sentence/schemas/sentence.schema.ts b/server/claim/types/sentence/schemas/sentence.schema.ts index 0c70701f5..c2ab00505 100644 --- a/server/claim/types/sentence/schemas/sentence.schema.ts +++ b/server/claim/types/sentence/schemas/sentence.schema.ts @@ -1,6 +1,7 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import * as mongoose from "mongoose"; import { Topic } from "../../../../topic/schemas/topic.schema"; +import { ClaimRevision } from "../../../../claim/claim-revision/schema/claim-revision.schema"; export type SentenceDocument = Sentence & mongoose.Document; @@ -24,6 +25,13 @@ export class Sentence { @Prop({ required: false }) topics: Topic[]; + + @Prop({ + type: mongoose.Types.ObjectId, + required: true, + ref: "ClaimRevision", + }) + claimRevisionId: ClaimRevision; } const SentenceSchemaRaw = SchemaFactory.createForClass(Sentence); diff --git a/server/claim/types/sentence/sentence.service.ts b/server/claim/types/sentence/sentence.service.ts index 4dab43f67..207467173 100644 --- a/server/claim/types/sentence/sentence.service.ts +++ b/server/claim/types/sentence/sentence.service.ts @@ -69,7 +69,7 @@ export class SentenceService { query: searchText, path: "content", fuzzy: { - maxEdits: 2, + maxEdits: 1, // Using maxEdits: 1 to allow minor typos or spelling errors in search queries. }, }, }, @@ -98,27 +98,11 @@ export class SentenceService { } pipeline.push( - { - $lookup: { - from: "paragraphs", - localField: "_id", - foreignField: "content", - as: "claim", - }, - }, - { - $lookup: { - from: "speeches", - localField: "claim._id", - foreignField: "content", - as: "claim", - }, - }, { $lookup: { from: "claimrevisions", - localField: "claim._id", - foreignField: "contentId", + localField: "claimRevisionId", + foreignField: "_id", as: "claim", }, }, @@ -139,13 +123,6 @@ export class SentenceService { }, }, this.util.getVisibilityMatch(nameSpace), - // Logic made to filter sentences from debates - //TODO: Remove this when claim schema is changed - { - $match: { - claim: { $ne: [] }, - }, - }, { $project: { content: 1, diff --git a/server/claim/types/speech/schemas/speech.schema.ts b/server/claim/types/speech/schemas/speech.schema.ts index 3cba011cb..0d7577814 100644 --- a/server/claim/types/speech/schemas/speech.schema.ts +++ b/server/claim/types/speech/schemas/speech.schema.ts @@ -2,6 +2,7 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import * as mongoose from "mongoose"; import { Personality } from "../../../../personality/schemas/personality.schema"; import { Paragraph } from "../../paragraph/schemas/paragraph.schema"; +import { ClaimRevision } from "../../../../claim/claim-revision/schema/claim-revision.schema"; export type SpeechDocument = Speech & mongoose.Document; @Schema({ toObject: { virtuals: true }, toJSON: { virtuals: true } }) @@ -29,6 +30,13 @@ export class Speech { ref: "personality", }) personality: Personality; + + @Prop({ + type: mongoose.Types.ObjectId, + required: true, + ref: "ClaimRevision", + }) + claimRevisionId: ClaimRevision; } const SpeechSchemaRaw = SchemaFactory.createForClass(Speech); diff --git a/server/copilot/copilot-chat.service.ts b/server/copilot/copilot-chat.service.ts index 564f05094..715f1781b 100644 --- a/server/copilot/copilot-chat.service.ts +++ b/server/copilot/copilot-chat.service.ts @@ -118,35 +118,32 @@ export class CopilotChatService { ]; const prompt = ChatPromptTemplate.fromMessages([ - //TODO: Ensure that the agent only fack-checks the claim from the context [ "system", ` - You are helpful assistant, your objective is to gather relevant informations from the user about the {claim} that has to be fact-checked. + You are the Fact-checker Aletheia's Assistant, working with a fact-checker who requires assistance. + Your primary goal is to gather all relevant information from the user about the claim: {claim} that needs to be fact-checked. + + Please follow these steps carefully - A fact-checker is interacting with you because he needs assistance with his fact-check report. + 1. Confirm the claim for fact-checking: + - If the user requests assistance with fact-checking, ask the user to confirm the claim that he wants to review is the claim: {claim} stated by {personality}, assure to always compose this specific question using these values {claim} and {personality}. - Follow these steps carefully: + 2. Analyze the {claim}: + - If your analysis indicates that the claim pertains to Brazilian municipalities or states, ask the following questions sequentially: + - "In which Brazilian city or state was the claim made?" + - "Do you have a specific time period during which we should search in the public gazettes (e.g. January 2022 to December 2022), or should we search up to the date the claim was stated: {date}?" - 1. Confirm with the user the claim to be fack-checked: - - If the user requests assistance with the fact-check, ask the user to confirm the claim that he wants to review is the claim:{claim} stated by {personality}, assure to always compose this specific question using these values {claim} and {personality}. + - If the claim is unrelated to Brazilian municipalities or concerns a different topic, ask: + - "Do you have any specific sources you suggest we consult for verifying this claim?" - 2. Analyze the {claim}: - - Based on your analyze assure if the claim is related to Brazilian municipalities or states proceed with the following questions strictly one at a time: - - Ask the user Which Brazilian city or state was the claim made in? - - Ask: Do you have any time expecific time period we should search in the public gazettes? (e.g., January 2022 to December 2022), or we should search until statement of the claim date:{date}? + If any information provided is ambiguous or incomplete, request clarification from the user without making assumptions. + Always pose your questions one at a time and in the specified order. - - Based on your analyze if the claim is unrelated to Brazilian municipalities or is a totally different topic proceed with the following question: - - Ask the user if he has any suggestion of sources that we should consult? - - If any information is ambiguous or missing, request clarification from the user without making assumptions. - Always ask one question at a time and in the specified order. - - You need to make all possible questions even if the user tries to rush the review. - Compose your responses using formal language and you MUST provide you answer in {language}. - Always pass the {claim} specifically to the tool without translating. - Only when you have made all questions and followed all steps to extracted information from the user then proceed to use the get-fact-checking-report tool, Do not proceed until you have asked all the questions. - `, + Persist in asking all necessary questions, even if the user attempts to expedite the process. + Do not advance to this tool until you have thoroughly completed all preceding steps. + Maintain the use of formal language in your responses, ensuring that all communication is conducted in {language}. + Only after all questions have been addressed and all relevant information has been gathered from the user should you proceed to use the get-fact-checking-report tool.`, ], new MessagesPlaceholder({ variableName: "chat_history" }), ["user", "{input}"], diff --git a/server/daily-report/daily-report.module.ts b/server/daily-report/daily-report.module.ts index 6da0c6ec1..c501aadb5 100644 --- a/server/daily-report/daily-report.module.ts +++ b/server/daily-report/daily-report.module.ts @@ -3,7 +3,7 @@ import { DailyReport, DailyReportSchema } from "./schemas/daily-report.schema"; import { DailyReportService } from "./daily-report.service"; import { MongooseModule } from "@nestjs/mongoose"; import { AbilityModule } from "../auth/ability/ability.module"; -import { SummarizationModule } from "../summarization/summarization.module"; +import { SummarizationCrawlerModule } from "../summarization/summarization-crawler.module"; import { ClaimReviewModule } from "../claim-review/claim-review.module"; import { NotificationModule } from "../notifications/notifications.module"; import { DailyReportController } from "./daily-report.controller"; @@ -22,7 +22,7 @@ export const DailyReportModel = MongooseModule.forFeature([ DailyReportModel, ClaimReviewModule, SourceModule, - SummarizationModule, + SummarizationCrawlerModule, AbilityModule, NotificationModule, ConfigModule, diff --git a/server/daily-report/daily-report.service.ts b/server/daily-report/daily-report.service.ts index f39fb717f..fdedec69a 100644 --- a/server/daily-report/daily-report.service.ts +++ b/server/daily-report/daily-report.service.ts @@ -5,7 +5,7 @@ import { DailyReportDocument, } from "./schemas/daily-report.schema"; import { InjectModel } from "@nestjs/mongoose"; -import { SummarizationService } from "../summarization/summarization.service"; +import { SummarizationCrawlerService } from "../summarization/summarization-crawler.service"; @Injectable({ scope: Scope.REQUEST }) export class DailyReportService { @@ -13,7 +13,7 @@ export class DailyReportService { constructor( @InjectModel(DailyReport.name) private DailyReportModel: Model, - private summarizationService: SummarizationService + private summarizationCrawlerService: SummarizationCrawlerService ) {} async create(dailyReportBody: DailyReport): Promise { @@ -32,11 +32,11 @@ export class DailyReportService { ): Promise { try { const summarizedReviews = - await this.summarizationService.getSummarizedReviews( + await this.summarizationCrawlerService.getSummarizedReviews( dailyReviews ); - return this.summarizationService.generateHTMLReport( + return this.summarizationCrawlerService.generateHTMLReport( summarizedReviews, nameSpace ); diff --git a/server/editor-parse/editor-parse.service.spec.ts b/server/editor-parse/editor-parse.service.spec.ts index aea204a74..4600bda21 100644 --- a/server/editor-parse/editor-parse.service.spec.ts +++ b/server/editor-parse/editor-parse.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from "@nestjs/testing"; import { EditorParseModule } from "./editor-parse.module"; import { EditorParseService } from "./editor-parse.service"; -import { ReviewTaskMachineContextReviewData } from "../claim-review-task/dto/create-claim-review-task.dto"; +import { ReviewTaskMachineContextReviewData } from "../review-task/dto/create-review-task.dto"; import { RemirrorJSON } from "remirror"; describe("ParserService", () => { diff --git a/server/feature-flag/feature-flag.module.ts b/server/feature-flag/feature-flag.module.ts new file mode 100644 index 000000000..41cd1af2d --- /dev/null +++ b/server/feature-flag/feature-flag.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { FeatureFlagService } from "./feature-flag.service"; + +@Module({ + imports: [ConfigModule], + exports: [FeatureFlagService], + providers: [FeatureFlagService], +}) +export class FeatureFlagModule {} diff --git a/server/feature-flag/feature-flag.service.ts b/server/feature-flag/feature-flag.service.ts new file mode 100644 index 000000000..bf0d1939d --- /dev/null +++ b/server/feature-flag/feature-flag.service.ts @@ -0,0 +1,43 @@ +import { Injectable, Optional } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { UnleashService } from "nestjs-unleash"; + +@Injectable() +export class FeatureFlagService { + constructor( + private configService: ConfigService, + @Optional() private readonly unleash: UnleashService + ) {} + + isEnableCollaborativeEditor() { + const config = this.configService.get("feature_flag"); + + return config + ? this.unleash.isEnabled("enable_collaborative_editor") + : false; + } + + isEnableCopilotChatBot() { + const config = this.configService.get("feature_flag"); + + return config ? this.unleash.isEnabled("copilot_chat_bot") : false; + } + + isEnableEditorAnnotations() { + const config = this.configService.get("feature_flag"); + + return config + ? this.unleash.isEnabled("enable_editor_annotations") + : false; + } + + isEnableAddEditorSourcesWithoutSelecting() { + const config = this.configService.get("feature_flag"); + + return config + ? this.unleash.isEnabled( + "enable_add_editor_sources_without_selecting" + ) + : false; + } +} diff --git a/server/group/group.module.ts b/server/group/group.module.ts new file mode 100644 index 000000000..00d08f009 --- /dev/null +++ b/server/group/group.module.ts @@ -0,0 +1,18 @@ +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; +import { Group, GroupSchema } from "./schemas/group.schema"; +import { GroupService } from "./group.service"; + +const GroupModel = MongooseModule.forFeature([ + { + name: Group.name, + schema: GroupSchema, + }, +]); + +@Module({ + imports: [GroupModel], + exports: [GroupService], + providers: [GroupService], +}) +export class GroupModule {} diff --git a/server/group/group.service.ts b/server/group/group.service.ts new file mode 100644 index 000000000..0095dfb55 --- /dev/null +++ b/server/group/group.service.ts @@ -0,0 +1,114 @@ +import { Injectable } from "@nestjs/common"; +import { Model, Types } from "mongoose"; +import { InjectModel } from "@nestjs/mongoose"; +import { Group, GroupDocument } from "./schemas/group.schema"; + +@Injectable() +export class GroupService { + constructor( + @InjectModel(Group.name) + private GroupModel: Model + ) {} + + /** + * gets the group document by an ID + * @param groupId group id typeof string + * @returns + */ + async getById(groupId: string): Promise { + return this.GroupModel.findById(groupId); + } + + /** + * gets the group by querying the content ID + * @param contentId content id string + * @returns the group document + */ + async getByContentId(contentId): Promise { + return this.GroupModel.findOne({ content: contentId }).populate( + "content" + ); + } + + /** + * Creates or updates a group document + * @param group Group document data + * @returns the group document + */ + async create(group: Partial): Promise { + try { + const existingGroup = await this.GroupModel.findOne({ + content: { $in: group.content }, + }); + + if (existingGroup) { + return await this.GroupModel.findByIdAndUpdate( + existingGroup._id, + group, + { new: true } + ); + } + + return await new this.GroupModel(group).save(); + } catch (error) { + console.error("Failed to create or update group:", error); + throw error; + } + } + + /** + * Updates the group document with the claim target ID + * @param groupId specific group which will be updated + * @param targetId claim target id + * @returns the group document updated + */ + async updateWithTargetId( + groupId: string, + targetId: string + ): Promise { + const group = await this.GroupModel.findById(groupId); + + return this.GroupModel.findByIdAndUpdate( + group._id, + { targetId: Types.ObjectId(targetId) }, + { new: true } + ); + } + + /** + * Removes a verification request from content group. + * In case the content is an empty array, deletes the group document + * @param groupId group id + * @param contentId verification request id + * @returns the updated or deleted group document + */ + async removeContent( + groupId: string, + contentId: string + ): Promise< + | GroupDocument + | ({ ok?: number; n?: number } & { deletedCount?: number }) + > { + try { + const group = await this.GroupModel.findById(groupId); + const newContent = group.content.filter( + (content: any) => content.toString() !== contentId.toString() + ); + + if (!newContent.length) { + return await this.GroupModel.deleteOne({ _id: group._id }); + } + + if (group) { + return await this.GroupModel.findByIdAndUpdate( + group._id, + { content: newContent }, + { new: true } + ); + } + } catch (error) { + console.error("Failed to remove content:", error); + throw error; + } + } +} diff --git a/server/group/schemas/group.schema.ts b/server/group/schemas/group.schema.ts new file mode 100644 index 000000000..fc4820cd3 --- /dev/null +++ b/server/group/schemas/group.schema.ts @@ -0,0 +1,38 @@ +import * as mongoose from "mongoose"; +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { VerificationRequest } from "../../verification-request/schemas/verification-request.schema"; + +export type GroupDocument = Group & mongoose.Document; + +@Schema({ toObject: { virtuals: true }, toJSON: { virtuals: true } }) +export class Group { + @Prop({ + type: [ + { + type: mongoose.Types.ObjectId, + required: false, + ref: "VerificationRequest", + }, + ], + }) + content: VerificationRequest[]; + + @Prop({ + type: mongoose.Types.ObjectId, + required: false, + refPath: "onModel", + }) + targetId: mongoose.Types.ObjectId; +} + +const GroupSchemaRaw = SchemaFactory.createForClass(Group); + +GroupSchemaRaw.pre("find", function () { + this.populate("content"); + this.populate({ + path: "targetId", + model: "Claim", + }); +}); + +export const GroupSchema = GroupSchemaRaw; diff --git a/server/history/schema/history.schema.ts b/server/history/schema/history.schema.ts index a865257b5..a8de88dca 100644 --- a/server/history/schema/history.schema.ts +++ b/server/history/schema/history.schema.ts @@ -9,8 +9,9 @@ export enum TargetModel { Debate = "Debate", Personality = "Personality", ClaimReview = "ClaimReview", - ClaimReviewTask = "ClaimReviewTask", + ReviewTask = "ReviewTask", Image = "Image", + Source = "Source", } export enum HistoryType { diff --git a/server/home/home.controller.ts b/server/home/home.controller.ts index 61e2f82c2..38dfaf739 100644 --- a/server/home/home.controller.ts +++ b/server/home/home.controller.ts @@ -9,6 +9,8 @@ import type { BaseRequest } from "../types"; import { DebateService } from "../claim/types/debate/debate.service"; import { ClaimRevisionService } from "../claim/claim-revision/claim-revision.service"; import { ApiTags } from "@nestjs/swagger"; +import { ClaimReviewService } from "../claim-review/claim-review.service"; +import { NameSpaceEnum } from "../auth/name-space/schemas/name-space.schema"; @Controller("/") export class HomeController { @@ -17,7 +19,8 @@ export class HomeController { private personalityService: PersonalityService, private statsService: StatsService, private debateService: DebateService, - private claimRevisionService: ClaimRevisionService + private claimRevisionService: ClaimRevisionService, + private claimReviewService: ClaimReviewService ) {} @ApiTags("pages") @@ -33,6 +36,18 @@ export class HomeController { @Header("Cache-Control", "max-age=60, must-revalidate") public async showHome(@Req() req: BaseRequest, @Res() res: Response) { const parsedUrl = parse(req.url, true); + const reviews = await this.claimReviewService.listAll({ + page: 0, + pageSize: 6, + order: "asc", + query: { + isHidden: false, + isDeleted: false, + nameSpace: req.params.namespace || NameSpaceEnum.Main, + }, + latest: true, + }); + const { personalities } = await this.personalityService.combinedListAll( { language: req.language, @@ -78,6 +93,7 @@ export class HomeController { personalities, stats, claims, + reviews, nameSpace: req.params.namespace, }) ); diff --git a/server/home/home.module.ts b/server/home/home.module.ts index c1f524e05..6117323d4 100644 --- a/server/home/home.module.ts +++ b/server/home/home.module.ts @@ -5,6 +5,7 @@ import { StatsModule } from "../stats/stats.module"; import { ViewModule } from "../view/view.module"; import { DebateModule } from "../claim/types/debate/debate.module"; import { ClaimRevisionModule } from "../claim/claim-revision/claim-revision.module"; +import { ClaimReviewModule } from "../claim-review/claim-review.module"; @Module({ imports: [ @@ -13,6 +14,7 @@ import { ClaimRevisionModule } from "../claim/claim-revision/claim-revision.modu ViewModule, DebateModule, ClaimRevisionModule, + ClaimReviewModule, ], providers: [], controllers: [HomeController], diff --git a/server/jest.config.json b/server/jest.config.json index 561581b21..121f06f93 100644 --- a/server/jest.config.json +++ b/server/jest.config.json @@ -1,5 +1,4 @@ { - "name": "server", "displayName": "server", "moduleFileExtensions": [ "js", diff --git a/server/middleware/auth-zenvia-webhook.middleware.ts b/server/middleware/auth-zenvia-webhook.middleware.ts new file mode 100644 index 000000000..749f8b2a6 --- /dev/null +++ b/server/middleware/auth-zenvia-webhook.middleware.ts @@ -0,0 +1,49 @@ +import { + Injectable, + Logger, + NestMiddleware, + UnauthorizedException, +} from "@nestjs/common"; +import { Request, Response, NextFunction } from "express"; +import { ConfigService } from "@nestjs/config"; + +@Injectable() +export class AuthZenviaWebHookMiddleware implements NestMiddleware { + constructor(private configService: ConfigService) {} + private logger = new Logger("HTTP"); + + async use( + request: Request, + response: Response, + next: NextFunction + ): Promise { + const { ip, method, originalUrl } = request; + const { api_token } = this.configService.get("zenvia"); + const userAgent = request.get("user-agent") || ""; + + const authHeader = request.headers["authorization"]; + if (!authHeader) { + throw new UnauthorizedException("Authorization header is missing"); + } + + const token = authHeader.split(" ")[1]; + if (!token) { + throw new UnauthorizedException("Token is missing"); + } + + if (token !== api_token) { + throw new UnauthorizedException("Invalid token"); + } + + response.on("finish", () => { + const { statusCode } = response; + const contentLength = response.get("content-length"); + + this.logger.log( + `${method} ${originalUrl} ${statusCode} ${contentLength} - ${userAgent} ${ip}` + ); + }); + + next(); + } +} diff --git a/server/mongo-pipelines/lookUpPersonalityties.ts b/server/mongo-pipelines/lookUpPersonalityties.ts deleted file mode 100644 index 4faa93fa6..000000000 --- a/server/mongo-pipelines/lookUpPersonalityties.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { TargetModel } from "../history/schema/history.schema"; - -export default function lookupPersonalities(target) { - if (target === TargetModel.ClaimReviewTask) { - return { - $lookup: { - from: "personalities", - let: { - personalityId: { - $toObjectId: "$machine.context.claimReview.personality", - }, - }, - pipeline: [ - { $match: { $expr: { $eq: ["$_id", "$$personalityId"] } } }, - { $project: { slug: 1, name: 1, _id: 1 } }, - ], - as: "machine.context.claimReview.personality", - }, - }; - } else if (target === TargetModel.ClaimReview) { - return { - $lookup: { - from: "personalities", - localField: "personality", - foreignField: "_id", - as: "personality", - }, - }; - } -} diff --git a/server/mongo-pipelines/lookupClaimReviews.ts b/server/mongo-pipelines/lookupClaimReviews.ts deleted file mode 100644 index 3a22ba5ee..000000000 --- a/server/mongo-pipelines/lookupClaimReviews.ts +++ /dev/null @@ -1,10 +0,0 @@ -export default function ({ localField = "data_hash", as }) { - return { - $lookup: { - from: "claimreviews", - localField, - foreignField: "data_hash", - as, - }, - }; -} diff --git a/server/mongo-pipelines/lookupClaimRevisions.ts b/server/mongo-pipelines/lookupClaimRevisions.ts deleted file mode 100644 index 9c290de1d..000000000 --- a/server/mongo-pipelines/lookupClaimRevisions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { TargetModel } from "../history/schema/history.schema"; - -export default function lookupClaimRevisions(target) { - if (target === TargetModel.ClaimReviewTask) { - return { - $lookup: { - from: "claimrevisions", - localField: "latestRevision", - foreignField: "_id", - as: "latestRevision", - }, - }; - } else if (target === TargetModel.ClaimReview) { - return { - $lookup: { - from: "claimrevisions", - localField: "claim.latestRevision", - foreignField: "_id", - as: "claim.latestRevision", - }, - }; - } -} diff --git a/server/mongo-pipelines/lookupClaims.ts b/server/mongo-pipelines/lookupClaims.ts deleted file mode 100644 index f8ef38467..000000000 --- a/server/mongo-pipelines/lookupClaims.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { TargetModel } from "../history/schema/history.schema"; - -export default function lookupClaims(target, params: any = {}) { - if (target === TargetModel.ClaimReviewTask) { - const { pipeline, as } = params; - return { - $lookup: { - from: "claims", - let: { - claimId: { - $toObjectId: "$machine.context.claimReview.claim", - }, - }, - pipeline, - as, - }, - }; - } else if (target === TargetModel.ClaimReview) { - return { - $lookup: { - from: "claims", - localField: "claim", - foreignField: "_id", - as: "claim", - }, - }; - } -} diff --git a/server/mongo-pipelines/lookupUsers.ts b/server/mongo-pipelines/lookupUsers.ts deleted file mode 100644 index f45307f21..000000000 --- a/server/mongo-pipelines/lookupUsers.ts +++ /dev/null @@ -1,13 +0,0 @@ -export default function () { - return { - $lookup: { - from: "users", - let: { usersId: "$machine.context.reviewData.usersId" }, - pipeline: [ - { $match: { $expr: { $in: ["$_id", "$$usersId"] } } }, - { $project: { name: 1 } }, - ], - as: "machine.context.reviewData.users", - }, - }; -} diff --git a/server/personality/personality.service.ts b/server/personality/personality.service.ts index 40f1ffbf3..302dbafa4 100644 --- a/server/personality/personality.service.ts +++ b/server/personality/personality.service.ts @@ -445,7 +445,7 @@ export class PersonalityService { query: searchText, path: "name", fuzzy: { - maxEdits: 2, + maxEdits: 1, // Using maxEdits: 1 to allow minor typos or spelling errors in search queries. }, }, }, diff --git a/server/report/report.module.ts b/server/report/report.module.ts index 1ee8f023b..fc9d2dc6a 100644 --- a/server/report/report.module.ts +++ b/server/report/report.module.ts @@ -1,4 +1,4 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { MongooseModule } from "@nestjs/mongoose"; import { ConfigModule } from "@nestjs/config"; import { Report, ReportSchema } from "./schemas/report.schema"; @@ -14,14 +14,9 @@ const ReportModel = MongooseModule.forFeature([ ]); @Module({ - imports: [ - ReportModel, - ConfigModule, - SourceModule, - ], + imports: [ReportModel, ConfigModule, forwardRef(() => SourceModule)], exports: [ReportService], providers: [ReportService], - controllers: [ReportController] + controllers: [ReportController], }) - export class ReportModule {} diff --git a/server/report/report.service.ts b/server/report/report.service.ts index 54d62ca36..bf2d3859c 100644 --- a/server/report/report.service.ts +++ b/server/report/report.service.ts @@ -13,7 +13,7 @@ export class ReportService { private sourceService: SourceService ) {} - async create(report) { + create(report) { if ( !Object.values(ClassificationEnum).includes(report.classification) ) { @@ -21,16 +21,37 @@ export class ReportService { } const newReport = new this.ReportModel(report); - for (const source of report.sources) { - await this.sourceService.create({ + if (report.sources) { + this.createReportSources(report.sources, newReport.id); + } else { + this.updateReportSource(report, newReport.id); + } + + newReport.save(); + return newReport; + } + + createReportSources(sources, targetId) { + for (const source of sources) { + this.sourceService.create({ href: source.href, props: source?.props, - targetId: newReport.id, + targetId, }); } + } - newReport.save(); - return newReport; + updateReportSource({ classification, summary, data_hash }, targetId) { + const newSourceBody = { + props: { + classification: classification, + summary: summary, + date: new Date(), + }, + targetId, + }; + + return this.sourceService.update(data_hash, newSourceBody); } findByDataHash(data_hash) { diff --git a/server/claim-review-task/comment/comment.controller.ts b/server/review-task/comment/comment.controller.ts similarity index 100% rename from server/claim-review-task/comment/comment.controller.ts rename to server/review-task/comment/comment.controller.ts diff --git a/server/claim-review-task/comment/comment.module.ts b/server/review-task/comment/comment.module.ts similarity index 100% rename from server/claim-review-task/comment/comment.module.ts rename to server/review-task/comment/comment.module.ts diff --git a/server/claim-review-task/comment/comment.service.ts b/server/review-task/comment/comment.service.ts similarity index 100% rename from server/claim-review-task/comment/comment.service.ts rename to server/review-task/comment/comment.service.ts diff --git a/server/claim-review-task/comment/schema/comment.schema.ts b/server/review-task/comment/schema/comment.schema.ts similarity index 100% rename from server/claim-review-task/comment/schema/comment.schema.ts rename to server/review-task/comment/schema/comment.schema.ts diff --git a/server/claim-review-task/dto/create-claim-review-task.dto.ts b/server/review-task/dto/create-review-task.dto.ts similarity index 83% rename from server/claim-review-task/dto/create-claim-review-task.dto.ts rename to server/review-task/dto/create-review-task.dto.ts index 092578f18..15f1c8940 100644 --- a/server/claim-review-task/dto/create-claim-review-task.dto.ts +++ b/server/review-task/dto/create-review-task.dto.ts @@ -1,11 +1,10 @@ import { IsEnum, IsNotEmpty, IsObject, IsString } from "class-validator"; import { ClassificationEnum } from "../../claim-review/dto/create-claim-review.dto"; -import { Claim } from "../../claim/schemas/claim.schema"; import { Personality } from "../../personality/schemas/personality.schema"; import { User } from "../../users/schemas/user.schema"; import { ApiProperty } from "@nestjs/swagger"; -import { ReportModelEnum } from "../../types/enums"; +import { ReportModelEnum, ReviewTaskTypeEnum } from "../../types/enums"; export type ReviewTaskMachineContextReviewData = { usersId?: any[]; @@ -24,16 +23,16 @@ export type ReviewTaskMachineContextReviewData = { crossCheckingClassification?: string; crossCheckingComments?: any[]; crossCheckerId?: any; + group?: any[]; }; export type ReviewTaskMachineContext = { reviewData: ReviewTaskMachineContextReviewData; - claimReview: { + review: { usersId?: User[]; - data_hash: string; personality: Personality; - claim: Claim; isPartialReview: boolean; + targetId: string; }; preloadedOptions: { usersId?: any[]; @@ -47,7 +46,7 @@ export type Machine = { value: string; }; -export class CreateClaimReviewTaskDTO { +export class CreateReviewTaskDTO { @IsNotEmpty() @IsObject() @ApiProperty() @@ -72,4 +71,14 @@ export class CreateClaimReviewTaskDTO { @IsString() @ApiProperty() nameSpace: string; + + @IsString() + @ApiProperty() + target: string; + + @IsNotEmpty() + @IsEnum(ReviewTaskTypeEnum) + @IsString() + @ApiProperty() + reviewTaskType: string; } diff --git a/server/claim-review-task/dto/get-tasks.dto.ts b/server/review-task/dto/get-tasks.dto.ts similarity index 65% rename from server/claim-review-task/dto/get-tasks.dto.ts rename to server/review-task/dto/get-tasks.dto.ts index 6f20e5841..55ce669f6 100644 --- a/server/claim-review-task/dto/get-tasks.dto.ts +++ b/server/review-task/dto/get-tasks.dto.ts @@ -1,11 +1,15 @@ +import { ApiProperty } from "@nestjs/swagger"; import { + IsEnum, IsInt, + IsNotEmpty, IsNumber, IsObject, IsOptional, IsString, Min, } from "class-validator"; +import { ReviewTaskTypeEnum } from "../../types/enums"; export class GetTasksDTO { @IsNumber() @@ -28,10 +32,15 @@ export class GetTasksDTO { filterUser?: { assigned: boolean; crossChecked: boolean; - reviewerd: boolean; + reviewed: boolean; }; @IsString() @IsOptional() nameSpace?: string; + + @IsNotEmpty() + @IsEnum(ReviewTaskTypeEnum) + @ApiProperty() + reviewTaskType: ReviewTaskTypeEnum; } diff --git a/server/review-task/dto/update-review-task.dto.ts b/server/review-task/dto/update-review-task.dto.ts new file mode 100644 index 000000000..015eb60b5 --- /dev/null +++ b/server/review-task/dto/update-review-task.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/mapped-types"; +import { CreateReviewTaskDTO } from "./create-review-task.dto"; + +export class UpdateReviewTaskDTO extends PartialType(CreateReviewTaskDTO) {} diff --git a/server/claim-review-task/mongo-utils.ts b/server/review-task/mongo-utils.ts similarity index 76% rename from server/claim-review-task/mongo-utils.ts rename to server/review-task/mongo-utils.ts index 8a6c8c012..e88b420f4 100644 --- a/server/claim-review-task/mongo-utils.ts +++ b/server/review-task/mongo-utils.ts @@ -12,4 +12,10 @@ export const getQueryMatchForMachineValue = (value) => { : { [`machine.value.${value}`]: { $exists: true } }; }; -const plainValues = ["published", "submitted", "reported"]; +const plainValues = [ + "published", + "submitted", + "reported", + "assignedRequest", + "rejectedRequest", +]; diff --git a/server/review-task/review-task.controller.ts b/server/review-task/review-task.controller.ts new file mode 100644 index 000000000..29a2a2a6f --- /dev/null +++ b/server/review-task/review-task.controller.ts @@ -0,0 +1,190 @@ +import { + Body, + Controller, + Post, + Param, + Get, + Put, + Query, + Req, + Res, + Header, +} from "@nestjs/common"; +import { ReviewTaskService } from "./review-task.service"; +import { CreateReviewTaskDTO } from "./dto/create-review-task.dto"; +import { UpdateReviewTaskDTO } from "./dto/update-review-task.dto"; +import { CaptchaService } from "../captcha/captcha.service"; +import { parse } from "url"; +import type { Request, Response } from "express"; +import { ViewService } from "../view/view.service"; +import { GetTasksDTO } from "./dto/get-tasks.dto"; +import { ConfigService } from "@nestjs/config"; +import { FeatureFlagService } from "../feature-flag/feature-flag.service"; +import { ApiTags } from "@nestjs/swagger"; +import { NameSpaceEnum } from "../auth/name-space/schemas/name-space.schema"; + +@Controller(":namespace?") +export class ReviewTaskController { + constructor( + private reviewTaskService: ReviewTaskService, + private captchaService: CaptchaService, + private viewService: ViewService, + private configService: ConfigService, + private featureFlagService: FeatureFlagService + ) {} + + @ApiTags("review-task") + @Get("api/reviewtask") + @Header("Cache-Control", "no-cache") + public async getByMachineValue(@Query() getTasksDTO: GetTasksDTO) { + const { + page = 0, + pageSize = 10, + order = 1, + value, + filterUser, + reviewTaskType, + nameSpace = NameSpaceEnum.Main, + } = getTasksDTO; + return Promise.all([ + this.reviewTaskService.listAll({ + page, + pageSize, + order, + value, + filterUser, + nameSpace, + reviewTaskType, + }), + this.reviewTaskService.countReviewTasksNotDeleted( + value, + filterUser, + nameSpace, + reviewTaskType + ), + ]).then(([tasks, totalTasks]) => { + const totalPages = Math.ceil(totalTasks / pageSize); + + return { + tasks, + totalTasks, + totalPages, + page, + pageSize, + }; + }); + } + + @ApiTags("review-task") + @Get("api/reviewtask/:id") + @Header("Cache-Control", "no-cache") + async getById(@Param("id") id: string) { + return this.reviewTaskService.getById(id); + } + + @ApiTags("review-task") + @Post("api/reviewtask") + @Header("Cache-Control", "no-cache") + async create(@Body() createReviewTask: CreateReviewTaskDTO) { + const validateCaptcha = await this.captchaService.validate( + createReviewTask.recaptcha + ); + if (!validateCaptcha) { + throw new Error("Error validating captcha"); + } + return this.reviewTaskService.create(createReviewTask); + } + + @ApiTags("review-task") + @Put("api/reviewtask/:data_hash") + @Header("Cache-Control", "no-cache") + async autoSaveDraft( + @Param("data_hash") data_hash, + @Body() reviewTaskBody: UpdateReviewTaskDTO + ) { + const history = false; + return this.reviewTaskService + .getReviewTaskByDataHash(data_hash) + .then((review) => { + if (review) { + return this.reviewTaskService.update( + data_hash, + reviewTaskBody, + reviewTaskBody.nameSpace, + reviewTaskBody.reportModel, + history + ); + } + }); + } + + // TODO: remove hash from the url + @ApiTags("review-task") + @Get("api/reviewtask/hash/:data_hash") + @Header("Cache-Control", "no-cache") + async getByDataHash(@Param("data_hash") data_hash: string) { + return this.reviewTaskService.getReviewTaskByDataHash(data_hash); + } + + @ApiTags("review-task") + @Get("api/reviewtask/editor-content/:data_hash") + @Header("Cache-Control", "no-cache") + async getEditorContentByDataHash( + @Param("data_hash") data_hash: string, + @Query() query: { reportModel: string; reviewTaskType: string } + ) { + const reviewTask = await this.reviewTaskService.getReviewTaskByDataHash( + data_hash + ); + + return this.reviewTaskService.getEditorContentObject( + reviewTask?.machine?.context?.reviewData, + query.reportModel, + query.reviewTaskType + ); + } + + @ApiTags("review-task") + @Put("api/reviewtask/add-comment/:data_hash") + @Header("Cache-Control", "no-cache") + async addComment(@Param("data_hash") data_hash: string, @Body() body) { + return this.reviewTaskService.addComment(data_hash, body.comment); + } + + @ApiTags("review-task") + @Put("api/reviewtask/delete-comment/:data_hash") + @Header("Cache-Control", "no-cache") + async deleteComment(@Param("data_hash") data_hash: string, @Body() body) { + return this.reviewTaskService.deleteComment(data_hash, body.commentId); + } + + @ApiTags("pages") + @Get("kanban") + @Header("Cache-Control", "no-cache") + public async kanbanList(@Req() req: Request, @Res() res: Response) { + const parsedUrl = parse(req.url, true); + const enableCollaborativeEditor = + this.featureFlagService.isEnableCollaborativeEditor(); + const enableCopilotChatBot = + this.featureFlagService.isEnableCopilotChatBot(); + const enableEditorAnnotations = + this.featureFlagService.isEnableEditorAnnotations(); + const enableAddEditorSourcesWithoutSelecting = + this.featureFlagService.isEnableAddEditorSourcesWithoutSelecting(); + + await this.viewService.getNextServer().render( + req, + res, + "/kanban-page", + Object.assign(parsedUrl.query, { + sitekey: this.configService.get("recaptcha_sitekey"), + enableCollaborativeEditor, + enableEditorAnnotations, + enableCopilotChatBot, + enableAddEditorSourcesWithoutSelecting, + websocketUrl: this.configService.get("websocketUrl"), + nameSpace: req.params.namespace, + }) + ); + } +} diff --git a/server/claim-review-task/claim-review-task.module.ts b/server/review-task/review-task.module.ts similarity index 53% rename from server/claim-review-task/claim-review-task.module.ts rename to server/review-task/review-task.module.ts index 2d93c402c..823d9daf1 100644 --- a/server/claim-review-task/claim-review-task.module.ts +++ b/server/review-task/review-task.module.ts @@ -1,11 +1,8 @@ -import { Module } from "@nestjs/common"; -import { - ClaimReviewTask, - ClaimReviewTaskSchema, -} from "./schemas/claim-review-task.schema"; -import { ClaimReviewTaskService } from "./claim-review-task.service"; +import { Module, forwardRef } from "@nestjs/common"; +import { ReviewTask, ReviewTaskSchema } from "./schemas/review-task.schema"; +import { ReviewTaskService } from "./review-task.service"; import { MongooseModule } from "@nestjs/mongoose"; -import { ClaimReviewController } from "./claim-review-task.controller"; +import { ReviewTaskController } from "./review-task.controller"; import { ClaimReviewModule } from "../claim-review/claim-review.module"; import { ReportModule } from "../report/report.module"; import { CaptchaModule } from "../captcha/captcha.module"; @@ -17,31 +14,35 @@ import { ConfigModule } from "@nestjs/config"; import { ImageModule } from "../claim/types/image/image.module"; import { EditorParseModule } from "../editor-parse/editor-parse.module"; import { CommentModule } from "./comment/comment.module"; +import { FeatureFlagModule } from "../feature-flag/feature-flag.module"; +import { GroupModule } from "../group/group.module"; -export const ClaimReviewTaskModel = MongooseModule.forFeature([ +export const ReviewTaskModel = MongooseModule.forFeature([ { - name: ClaimReviewTask.name, - schema: ClaimReviewTaskSchema, + name: ReviewTask.name, + schema: ReviewTaskSchema, }, ]); @Module({ imports: [ - ClaimReviewTaskModel, - ClaimReviewModule, - ReportModule, + ReviewTaskModel, + forwardRef(() => ClaimReviewModule), + forwardRef(() => ReportModule), HistoryModule, StateEventModule, CaptchaModule, ViewModule, - SentenceModule, + forwardRef(() => SentenceModule), ConfigModule, ImageModule, EditorParseModule, CommentModule, + FeatureFlagModule, + GroupModule, ], - providers: [ClaimReviewTaskService], - exports: [ClaimReviewTaskService], - controllers: [ClaimReviewController], + providers: [ReviewTaskService], + exports: [ReviewTaskService], + controllers: [ReviewTaskController], }) -export class ClaimReviewTaskModule {} +export class ReviewTaskModule {} diff --git a/server/review-task/review-task.service.ts b/server/review-task/review-task.service.ts new file mode 100644 index 000000000..24e588f93 --- /dev/null +++ b/server/review-task/review-task.service.ts @@ -0,0 +1,757 @@ +import { ForbiddenException, Inject, Injectable, Scope } from "@nestjs/common"; +import { Model, Types } from "mongoose"; +import { ReviewTask, ReviewTaskDocument } from "./schemas/review-task.schema"; +import { InjectModel } from "@nestjs/mongoose"; +import { CreateReviewTaskDTO, Machine } from "./dto/create-review-task.dto"; +import { UpdateReviewTaskDTO } from "./dto/update-review-task.dto"; +import { ClaimReviewService } from "../claim-review/claim-review.service"; +import { ReportService } from "../report/report.service"; +import { HistoryType, TargetModel } from "../history/schema/history.schema"; +import { HistoryService } from "../history/history.service"; +import { StateEventService } from "../state-event/state-event.service"; +import { TypeModel } from "../state-event/schema/state-event.schema"; +import { REQUEST } from "@nestjs/core"; +import type { BaseRequest } from "../types"; +import { SentenceService } from "../claim/types/sentence/sentence.service"; +import { getQueryMatchForMachineValue } from "./mongo-utils"; +import { Roles } from "../auth/ability/ability.factory"; +import { ImageService } from "../claim/types/image/image.service"; +import { + ContentModelEnum, + ReportModelEnum, + ReviewTaskTypeEnum, +} from "../types/enums"; +import { EditorParseService } from "../editor-parse/editor-parse.service"; +import { CommentService } from "./comment/comment.service"; +import { CommentEnum } from "./comment/schema/comment.schema"; +import { User } from "../users/schemas/user.schema"; +import { Image } from "../claim/types/image/schemas/image.schema"; +import { Sentence } from "../claim/types/sentence/schemas/sentence.schema"; +import { Source } from "../source/schemas/source.schema"; + +interface IListAllQuery { + value: any; + filterUser: { assigned: boolean; crossChecked: boolean; reviewed: boolean }; + nameSpace: string; + reviewTaskType: ReviewTaskTypeEnum; + page: number; + pageSize: number; + order: string | number; +} + +interface IPostProcess { + data_hash: string; + machine: Machine; + target: any; + reviewTaskType: string; +} + +export interface IReviewTask { + content: Source | Sentence | Image; + usersName: string[]; + value: string; + personalityName: string; + claimTitle: string; + targetId: string; + personalityId: string; + contentModel: string; +} + +@Injectable({ scope: Scope.REQUEST }) +export class ReviewTaskService { + fieldMap: { assigned: string; crossChecked: string; reviewed: string }; + constructor( + @Inject(REQUEST) private req: BaseRequest, + @InjectModel(ReviewTask.name) + private ReviewTaskModel: Model, + private claimReviewService: ClaimReviewService, + private reportService: ReportService, + private historyService: HistoryService, + private stateEventService: StateEventService, + private sentenceService: SentenceService, + private imageService: ImageService, + private editorParseService: EditorParseService, + private commentService: CommentService + ) { + this.fieldMap = { + assigned: "machine.context.reviewData.usersId", + crossChecked: "machine.context.reviewData.crossCheckerId", + reviewed: "machine.context.reviewData.reviewerId", + }; + } + + getQueryObject(value, filterUser) { + const query = getQueryMatchForMachineValue(value); + + Object.keys(filterUser).forEach((key) => { + const value = filterUser[key]; + if (value === true || value === "true") { + const queryPath = this.fieldMap[key]; + query[queryPath] = Types.ObjectId(this.req.user._id); + } + }); + + return query; + } + + _verifyMachineValueAndAddMatchPipeline(pipeline, value, reviewTaskType) { + if ( + value === "published" && + reviewTaskType !== ReviewTaskTypeEnum.VerificationRequest + ) { + return pipeline.push( + { + $lookup: { + from: "claimreviews", + localField: "data_hash", + foreignField: "data_hash", + as: "machine.context.claimReview.claimReview", + }, + }, + { $unwind: "$machine.context.claimReview.claimReview" }, + { + $match: { + "machine.context.claimReview.claimReview.isDeleted": + false, + $or: [ + { "target.isDeleted": false }, + { "target.isDeleted": { $exists: false } }, + ], + }, + } + ); + } + + return pipeline.push({ + $match: { + $or: [ + { "target.isDeleted": false }, + { "target.isDeleted": { $exists: false } }, + ], + }, + }); + } + + buildLookupPipeline(reviewTaskType) { + let pipeline: any = [ + { $match: { $expr: { $eq: ["$_id", "$$targetId"] } } }, + ]; + + if (reviewTaskType === ReviewTaskTypeEnum.Claim) { + pipeline.push( + { + $lookup: { + from: "claimrevisions", + localField: "latestRevision", + foreignField: "_id", + as: "latestRevision", + }, + }, + { $unwind: "$latestRevision" } + ); + } + + return pipeline; + } + + _buildPipeline({ + value, + filterUser, + nameSpace, + reviewTaskType, + }: IListAllQuery) { + const pipeline = []; + const query = this.getQueryObject(value, filterUser); + + pipeline.push( + { $match: { ...query, reviewTaskType, nameSpace } }, + { + $lookup: { + from: "users", + let: { usersId: "$machine.context.reviewData.usersId" }, + pipeline: [ + { $match: { $expr: { $in: ["$_id", "$$usersId"] } } }, + { $project: { name: 1 } }, + ], + as: "machine.context.reviewData.usersId", + }, + }, + { + $lookup: { + from: "personalities", + let: { + personalityId: { + $toObjectId: "$machine.context.review.personality", + }, + }, + pipeline: [ + { + $match: { + $expr: { $eq: ["$_id", "$$personalityId"] }, + }, + }, + { $project: { slug: 1, name: 1, _id: 1 } }, + ], + as: "machine.context.review.personality", + }, + }, + { + $unwind: { + path: "$machine.context.review.personality", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: `${reviewTaskType.toLowerCase()}s`, + let: { targetId: { $toObjectId: "$target" } }, + pipeline: this.buildLookupPipeline(reviewTaskType), + as: "target", + }, + }, + { $unwind: { path: "$target", preserveNullAndEmptyArrays: true } } + ); + + this._verifyMachineValueAndAddMatchPipeline( + pipeline, + value, + reviewTaskType + ); + + return pipeline; + } + + async listAll(query: IListAllQuery): Promise { + const pipeline = this._buildPipeline(query); + pipeline.push( + { $sort: { _id: query.order === "asc" ? 1 : -1 } }, + { $skip: query.page * query.pageSize }, + { $limit: query.pageSize } + ); + + const reviewTasks = await this.ReviewTaskModel.aggregate( + pipeline + ).exec(); + + return Promise.all( + reviewTasks?.map((reviewTask) => this.postProcess(reviewTask)) + ); + } + + async postProcess({ + data_hash, + machine, + target, + reviewTaskType, + }: IPostProcess): Promise { + let content: Source | Sentence | Image = target; + const personality: { _id?: string; name?: string } = + machine.context.review.personality; + const contentModel: string = target?.latestRevision?.contentModel; + const claimTitle: string = target?.latestRevision?.title; + const usersId: User[] = machine.context.reviewData.usersId; + const isContentImage: boolean = contentModel === ContentModelEnum.Image; + const usersName: string[] = usersId.map((user) => user.name); + + if (reviewTaskType === ReviewTaskTypeEnum.Claim) { + if (isContentImage) { + content = await this.imageService.getByDataHash(data_hash); + } + + content = await this.sentenceService.getByDataHash(data_hash); + } + + return { + content, + usersName, + value: machine.value, + personalityName: personality?.name, + claimTitle, + targetId: target._id, + personalityId: personality?._id, + contentModel, + }; + } + + getById(reviewTaskId: string) { + return this.ReviewTaskModel.findById(reviewTaskId); + } + + _createReviewTaskHistory(newReviewTask, previousReviewTask = null) { + let historyType; + + if (typeof newReviewTask.machine.value === "object") { + historyType = + newReviewTask.machine.value?.[ + Object.keys(newReviewTask.machine.value)[0] + ] === "draft" + ? HistoryType.Draft + : Object.keys(newReviewTask.machine.value)[0]; + } + + const user = this.req.user; + + const history = this.historyService.getHistoryParams( + newReviewTask._id, + TargetModel.ReviewTask, + user, + historyType || HistoryType.Published, + { + ...newReviewTask.machine.context.reviewData, + ...newReviewTask.machine.context.review.target, + value: newReviewTask.machine.value, + }, + previousReviewTask && { + ...previousReviewTask.machine.context.reviewData, + ...previousReviewTask.machine.context.review.target, + value: previousReviewTask.machine.value, + } + ); + + this.historyService.createHistory(history); + } + + _createStateEvent(newReviewTask) { + let typeModel; + let draft = false; + + if (typeof newReviewTask.machine.value === "object") { + draft = + newReviewTask.machine.value?.[ + Object.keys(newReviewTask.machine.value)[0] + ] === "draft" + ? true + : false; + + typeModel = Object.keys(newReviewTask.machine.value)[0]; + } + + const stateEvent = this.stateEventService.getStateEventParams( + Types.ObjectId(newReviewTask.machine.context.review.target), + typeModel || TypeModel.Published, + draft, + newReviewTask._id + ); + + this.stateEventService.createStateEvent(stateEvent); + } + + async _createReportAndClaimReview( + data_hash, + machine, + reportModel, + nameSpace, + target, + targetModel + ) { + const reviewData = machine.context.review; + + const newReport = Object.assign(machine.context.reviewData, { + data_hash, + reportModel, + }); + + const report = await this.reportService.create(newReport); + + this.claimReviewService.create( + { + ...reviewData, + report, + nameSpace, + target, + targetModel, + }, + data_hash, + reportModel + ); + } + + _returnObjectId(data): any { + if (Array.isArray(data)) { + return data.map((item) => + item._id ? Types.ObjectId(item._id) || "" : Types.ObjectId(item) + ); + } + } + + _createCrossCheckingComment(comment, text, targetId) { + const newCrossCheckingComment = { + comment, + text, + type: CommentEnum.crossChecking, + targetId, + user: this.req.user._id, + }; + return this.commentService.create(newCrossCheckingComment); + } + + async create(reviewTaskBody: CreateReviewTaskDTO) { + const reviewDataBody = reviewTaskBody.machine.context.reviewData; + const reviewTask = await this.getReviewTaskByDataHash( + reviewTaskBody.data_hash + ); + + const createCrossCheckingComment = + reviewTask?.machine?.value === "addCommentCrossChecking" && + reviewDataBody.crossCheckingClassification && + reviewDataBody.crossCheckingComment; + + reviewTaskBody.machine.context.reviewData.usersId = + this._returnObjectId(reviewDataBody.usersId); + + reviewTaskBody.machine.context.reviewData.group = this._returnObjectId( + reviewDataBody.group + ); + + if (reviewDataBody.reviewerId) { + reviewTaskBody.machine.context.reviewData.reviewerId = + Types.ObjectId(reviewDataBody.reviewerId) || ""; + } + + if (reviewDataBody.crossCheckerId) { + reviewTaskBody.machine.context.reviewData.crossCheckerId = + Types.ObjectId(reviewDataBody.crossCheckerId) || ""; + } + + if (reviewDataBody.reviewComments) { + this.commentService.updateManyComments( + reviewDataBody.reviewComments + ); + reviewTaskBody.machine.context.reviewData.reviewComments = + this._returnObjectId(reviewDataBody.reviewComments); + } + + if (reviewDataBody.crossCheckingComments) { + reviewTaskBody.machine.context.reviewData.crossCheckingComments = + this._returnObjectId(reviewDataBody.crossCheckingComments); + } + + if (createCrossCheckingComment) { + const crossCheckingComment = await this._createCrossCheckingComment( + reviewDataBody.crossCheckingComment, + reviewDataBody.crossCheckingClassification, + reviewTask._id + ); + reviewTaskBody.machine.context.reviewData.crossCheckingComments.push( + crossCheckingComment._id + ); + } + + if (reviewTask) { + return this.update( + reviewTaskBody.data_hash, + reviewTaskBody, + reviewTaskBody.nameSpace, + reviewTask.reportModel + ); + } else { + const newReviewTask = new this.ReviewTaskModel(reviewTaskBody); + newReviewTask.save(); + this._createReviewTaskHistory(newReviewTask); + this._createStateEvent(newReviewTask); + return newReviewTask; + } + } + + async update( + data_hash: string, + { machine }: UpdateReviewTaskDTO, + nameSpace: string, + reportModel: string, + history: boolean = true + ) { + // This line may cause a false positive in sonarCloud because if we remove the await, we cannot iterate through the results + const reviewTask = await this.getReviewTaskByDataHash(data_hash); + + const newReviewTaskMachine = { + ...reviewTask.machine, + ...machine, + }; + + const newReviewTask = { + ...reviewTask.toObject(), + machine: newReviewTaskMachine, + }; + + this._publishReviewTask( + newReviewTask, + nameSpace, + machine, + data_hash, + reportModel + ); + + if (history) { + this._createReviewTaskHistory(newReviewTask, reviewTask); + this._createStateEvent(newReviewTask); + } + + return this.ReviewTaskModel.updateOne( + { _id: newReviewTask._id }, + newReviewTask + ); + } + + getReviewTaskByDataHash(data_hash: string) { + const commentPopulation = [ + { + path: "user", + select: "name", + }, + { + path: "replies", + populate: { + path: "user", + select: "name", + }, + }, + ]; + + return this.ReviewTaskModel.findOne({ data_hash }) + .populate({ + path: "machine.context.reviewData.reviewComments", + model: "Comment", + populate: commentPopulation, + }) + .populate({ + path: "machine.context.reviewData.crossCheckingComments", + model: "Comment", + populate: commentPopulation, + }); + } + + async getReviewTasksByClaimId(targetId: string) { + return await this.ReviewTaskModel.find({ + "machine.context.review.target": targetId.toString(), + "machine.value": { $ne: "published" }, + }); + } + + async getReviewTaskByDataHashWithUsernames(data_hash: string) { + // This may cause a false positive in sonarCloud + const reviewTask = await this.getReviewTaskByDataHash(data_hash) + .populate({ + path: "machine.context.reviewData.usersId", + model: "User", + select: "name", + }) + .populate({ + path: "machine.context.reviewData.crossCheckerId", + model: "User", + select: "name", + }) + .populate({ + path: "machine.context.reviewData.reviewerId", + model: "User", + select: "name", + }); + + if (reviewTask) { + const preloadedAsignees = []; + const usersId = []; + reviewTask.machine.context.reviewData.usersId.forEach( + (assignee) => { + preloadedAsignees.push({ + value: assignee._id, + label: assignee.name, + }); + usersId.push(assignee._id); + } + ); + reviewTask.machine.context.reviewData.usersId = usersId; + reviewTask.machine.context.preloadedOptions = { + usersId: preloadedAsignees, + }; + + if (reviewTask.machine.context.reviewData.crossCheckerId) { + const crossCheckerUser = + reviewTask.machine.context.reviewData.crossCheckerId; + reviewTask.machine.context.preloadedOptions.crossCheckerId = [ + { + value: crossCheckerUser._id, + label: crossCheckerUser.name, + }, + ]; + reviewTask.machine.context.reviewData.crossCheckerId = + crossCheckerUser._id; + } + + if (reviewTask.machine.context.reviewData.reviewerId) { + const reviewerUser = + reviewTask.machine.context.reviewData.reviewerId; + reviewTask.machine.context.preloadedOptions.reviewerId = [ + { + value: reviewerUser._id, + label: reviewerUser.name, + }, + ]; + reviewTask.machine.context.reviewData.reviewerId = + reviewerUser._id; + } + } + + return reviewTask; + } + + count(query: any = {}) { + return this.ReviewTaskModel.countDocuments().where(query); + } + + async countReviewTasksNotDeleted( + value, + filterUser, + nameSpace, + reviewTaskType + ) { + try { + const query: any = this.getQueryObject(value, filterUser); + + const pipeline = [ + { $match: { ...query, nameSpace, reviewTaskType } }, + { + $lookup: { + from: "claimreviews", + localField: "data_hash", + foreignField: "data_hash", + as: "machine.context.review.claimReview", + }, + }, + { + $lookup: { + from: + reviewTaskType === ReviewTaskTypeEnum.Claim + ? "claims" + : "sources", + let: { targetId: { $toObjectId: "$target" } }, + pipeline: [ + { + $match: { + $expr: { $eq: ["$_id", "$$targetId"] }, + }, + }, + { $project: { isDeleted: 1 } }, + ], + as: "target", + }, + }, + { + $match: { + "machine.context.review.claimReview.isDeleted": { + $ne: true, + }, + $or: [ + { "target.isDeleted": false }, + { "target.isDeleted": { $exists: false } }, + ], + }, + }, + { $count: "count" }, + ]; + + const result = await this.ReviewTaskModel.aggregate( + pipeline + ).exec(); + + if (result.length > 0) { + return result[0].count; + } + + return 0; + } catch (error) { + console.error("Error in countReviewTasksNotDeleted:", error); + throw error; + } + } + + getEditorContentObject(schema, reportModel, reviewTaskType) { + return this.editorParseService.schema2editor( + schema, + reportModel, + reviewTaskType + ); + } + + async addComment(data_hash, comment) { + const reviewTask = await this.getReviewTaskByDataHash(data_hash); + const reviewData = reviewTask.machine.context.reviewData; + const newComment = await this.commentService.create({ + ...comment, + targetId: reviewTask._id, + }); + + if (!reviewData.reviewComments) { + reviewData.reviewComments = []; + } + + reviewData.reviewComments.push(Types.ObjectId(newComment?._id)); + + const { machine } = await this.ReviewTaskModel.findOneAndUpdate( + { _id: reviewTask._id }, + { "machine.context.reviewData": reviewData }, + { new: true } + ); + + return { + reviewData: machine.context.reviewData, + comment: newComment, + }; + } + + async deleteComment(data_hash, commentId) { + const commentIdObject = Types.ObjectId(commentId); + const reviewTask = await this.getReviewTaskByDataHash(data_hash); + const reviewData = reviewTask.machine.context.reviewData; + reviewData.reviewComments = reviewData.reviewComments.filter( + (comment) => !comment._id.equals(commentIdObject) + ); + reviewData.reviewComments = reviewData.crossCheckingComments.filter( + (comment) => !comment._id.equals(commentIdObject) + ); + + return this.ReviewTaskModel.findByIdAndUpdate(reviewTask._id, { + "machine.context.reviewData": reviewData, + }); + } + + async getHtmlFromSchema(schema) { + const htmlContent = this.editorParseService.schema2html(schema); + return { + ...schema, + ...htmlContent, + }; + } + + private _publishReviewTask( + reviewTaskMachine, + nameSpace, + machine, + data_hash, + reportModel + ) { + const loggedInUser = this.req.user; + + if ( + reviewTaskMachine.machine.value === "published" && + reportModel !== ReportModelEnum.Request + ) { + if ( + loggedInUser.role[nameSpace] !== Roles.Admin && + loggedInUser.role[nameSpace] !== Roles.SuperAdmin && + loggedInUser._id !== + machine.context.reviewData.reviewerId.toString() + ) { + throw new ForbiddenException( + "This user does not have permission to publish the report" + ); + } + this._createReportAndClaimReview( + data_hash, + reviewTaskMachine.machine, + reportModel, + nameSpace, + reviewTaskMachine.target, + reviewTaskMachine.reviewTaskType + ); + } + } +} diff --git a/server/review-task/schemas/review-task.schema.ts b/server/review-task/schemas/review-task.schema.ts new file mode 100644 index 000000000..e99e5cc42 --- /dev/null +++ b/server/review-task/schemas/review-task.schema.ts @@ -0,0 +1,65 @@ +import * as mongoose from "mongoose"; +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import type { Machine } from "../dto/create-review-task.dto"; +import { ReportModelEnum, ReviewTaskTypeEnum } from "../../types/enums"; +import { NameSpaceEnum } from "../../auth/name-space/schemas/name-space.schema"; + +export type ReviewTaskDocument = ReviewTask & + mongoose.Document & { content: any }; + +@Schema({ toObject: { virtuals: true }, toJSON: { virtuals: true } }) +export class ReviewTask { + @Prop({ type: Object, required: true }) + machine: Machine; + + @Prop({ unique: true, required: true }) + data_hash: string; + + @Prop({ + required: true, + validate: { + validator: (v) => { + return Object.values(ReportModelEnum).includes(v); + }, + }, + message: (tag) => `${tag} is not a valid report type.`, + }) + reportModel: ReportModelEnum; + + @Prop({ default: NameSpaceEnum.Main, required: true }) + nameSpace: string; + + @Prop({ + type: mongoose.Types.ObjectId, + required: true, + refPath: "onModel", + }) + target: mongoose.Types.ObjectId; + + @Prop({ + required: true, + validate: { + validator: (v) => { + return Object.values(ReviewTaskTypeEnum).includes(v); + }, + }, + message: (tag) => `${tag} is not a valid review task type.`, + }) + reviewTaskType: ReviewTaskTypeEnum; +} + +const ReviewTaskSchemaRaw = SchemaFactory.createForClass(ReviewTask); + +ReviewTaskSchemaRaw.virtual("content", { + ref: () => ["Claim", "Source"], + localField: "target", + foreignField: "_id", +}); + +ReviewTaskSchemaRaw.virtual("reviews", { + ref: "ClaimReview", + localField: "data_hash", + foreignField: "data_hash", +}); + +export const ReviewTaskSchema = ReviewTaskSchemaRaw; diff --git a/server/source/dto/create-source.dto.ts b/server/source/dto/create-source.dto.ts index 9eee1d62d..d42cf5397 100644 --- a/server/source/dto/create-source.dto.ts +++ b/server/source/dto/create-source.dto.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsString, IsObject } from "class-validator"; +import { IsNotEmpty, IsString, IsObject, IsOptional } from "class-validator"; import { ApiProperty } from "@nestjs/swagger"; import { User } from "../../users/schemas/user.schema"; @@ -18,8 +18,8 @@ export class CreateSourceDTO { @ApiProperty() href: string; - @IsNotEmpty() @IsObject() + @IsOptional() @ApiProperty() props: SourceProps; @@ -35,4 +35,9 @@ export class CreateSourceDTO { @IsString() @ApiProperty() recaptcha: string; + + @IsString() + @IsOptional() + @ApiProperty() + targetId: string; } diff --git a/server/source/schemas/source.schema.ts b/server/source/schemas/source.schema.ts index 1c62563ea..05f6fe416 100644 --- a/server/source/schemas/source.schema.ts +++ b/server/source/schemas/source.schema.ts @@ -38,6 +38,9 @@ export class Source { }) user: User; + @Prop({ unique: true, required: true }) + data_hash: string; + @Prop({ default: NameSpaceEnum.Main, required: false }) nameSpace: string; } diff --git a/server/source/source.controller.ts b/server/source/source.controller.ts index 959d101bf..1c37f9ed4 100644 --- a/server/source/source.controller.ts +++ b/server/source/source.controller.ts @@ -4,6 +4,7 @@ import { Get, Header, Logger, + Optional, Param, Post, Query, @@ -20,6 +21,11 @@ import type { Response } from "express"; import { IsPublic } from "../auth/decorators/is-public.decorator"; import { CreateSourceDTO } from "./dto/create-source.dto"; import { CaptchaService } from "../captcha/captcha.service"; +import { UnleashService } from "nestjs-unleash"; +import { TargetModel } from "../history/schema/history.schema"; +import { HistoryService } from "../history/history.service"; +import { ReviewTaskService } from "../review-task/review-task.service"; +import { ClaimReviewService } from "../claim-review/claim-review.service"; @Controller(":namespace?") export class SourceController { @@ -28,12 +34,25 @@ export class SourceController { private sourceService: SourceService, private viewService: ViewService, private configService: ConfigService, - private captchaService: CaptchaService + private captchaService: CaptchaService, + private claimReviewService: ClaimReviewService, + private reviewTaskService: ReviewTaskService, + private historyService: HistoryService, + @Optional() private readonly unleash: UnleashService ) {} @ApiTags("source") - @Get("api/source/:targetId") - public async getSourcesClaim(@Param() params, @Query() getSources: any) { + @Get("api/source/:id") + async getById(@Param("id") sourceId: string) { + return this.sourceService.getById(sourceId); + } + + @ApiTags("source") + @Get("api/source/target/:targetId") + public async getSourcesByTargetId( + @Param() params, + @Query() getSources: any + ) { const { targetId } = params; const { page, order } = getSources; const pageSize = parseInt(getSources.pageSize, 10); @@ -64,7 +83,7 @@ export class SourceController { } @ApiTags("pages") - @Get("sources/create") + @Get("source/create") public async sourceCreatePage( @Req() req: BaseRequest, @Res() res: Response @@ -109,7 +128,8 @@ export class SourceController { @IsPublic() @ApiTags("pages") - @Get("sources") + @Get("source") + @Header("Cache-Control", "max-age=60, must-revalidate") public async sourcesPage(@Req() req: BaseRequest, @Res() res: Response) { const parsedUrl = parse(req.url, true); @@ -122,4 +142,99 @@ export class SourceController { }) ); } + + @IsPublic() + @ApiTags("pages") + @Get("source/:dataHash") + @Header("Cache-Control", "max-age=60, must-revalidate") + public async sourceReviewPage( + @Req() req: BaseRequest, + @Res() res: Response + ) { + const source = await this.sourceService.getByDataHash( + req.params.dataHash + ); + + const reviewTask = + await this.reviewTaskService.getReviewTaskByDataHashWithUsernames( + source.data_hash + ); + const claimReview = await this.claimReviewService.getReviewByDataHash( + source.data_hash + ); + + const enableCollaborativeEditor = this.isEnableCollaborativeEditor(); + const enableCopilotChatBot = this.isEnableCopilotChatBot(); + const enableEditorAnnotations = this.isEnableEditorAnnotations(); + const enableAddEditorSourcesWithoutSelecting = + this.isEnableAddEditorSourcesWithoutSelecting(); + + const hideDescriptions = {}; + + hideDescriptions[TargetModel.Source] = + await this.historyService.getDescriptionForHide( + source, + TargetModel.Claim + ); + + hideDescriptions[TargetModel.ClaimReview] = + await this.historyService.getDescriptionForHide( + claimReview, + TargetModel.ClaimReview + ); + + const parsedUrl = parse(req.url, true); + + await this.viewService.getNextServer().render( + req, + res, + "/source-review", + Object.assign(parsedUrl.query, { + source, + reviewTask, + claimReview, + sitekey: this.configService.get("recaptcha_sitekey"), + hideDescriptions, + enableCollaborativeEditor, + enableEditorAnnotations, + enableCopilotChatBot, + enableAddEditorSourcesWithoutSelecting, + websocketUrl: this.configService.get("websocketUrl"), + nameSpace: req.params.namespace, + }) + ); + } + + //TODO: Create service to get feature flags config + private isEnableCollaborativeEditor() { + const config = this.configService.get("feature_flag"); + + return config + ? this.unleash.isEnabled("enable_collaborative_editor") + : false; + } + + private isEnableCopilotChatBot() { + const config = this.configService.get("feature_flag"); + + return config ? this.unleash.isEnabled("copilot_chat_bot") : false; + } + + private isEnableEditorAnnotations() { + const config = this.configService.get("feature_flag"); + + return config + ? this.unleash.isEnabled("enable_editor_annotations") + : false; + } + + private isEnableAddEditorSourcesWithoutSelecting() { + const config = this.configService.get("feature_flag"); + + return config + ? this.unleash.isEnabled( + "enable_add_editor_sources_without_selecting" + ) + : false; + } } diff --git a/server/source/source.module.ts b/server/source/source.module.ts index eb88cc83f..4bd568448 100644 --- a/server/source/source.module.ts +++ b/server/source/source.module.ts @@ -1,4 +1,4 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { MongooseModule } from "@nestjs/mongoose"; import { Source, SourceSchema } from "./schemas/source.schema"; import { SourceController } from "./source.controller"; @@ -6,6 +6,9 @@ import { SourceService } from "./source.service"; import { ViewModule } from "../view/view.module"; import { ConfigModule } from "@nestjs/config"; import { CaptchaModule } from "../captcha/captcha.module"; +import { HistoryModule } from "../history/history.module"; +import { ClaimReviewModule } from "../claim-review/claim-review.module"; +import { ReviewTaskModule } from "../review-task/review-task.module"; const SourceModel = MongooseModule.forFeature([ { @@ -15,7 +18,15 @@ const SourceModel = MongooseModule.forFeature([ ]); @Module({ - imports: [SourceModel, ViewModule, ConfigModule, CaptchaModule], + imports: [ + SourceModel, + ViewModule, + ConfigModule, + CaptchaModule, + HistoryModule, + forwardRef(() => ClaimReviewModule), + ReviewTaskModule, + ], providers: [SourceService], exports: [SourceService], controllers: [SourceController], diff --git a/server/source/source.service.ts b/server/source/source.service.ts index da72c77bc..8b72fe2fe 100644 --- a/server/source/source.service.ts +++ b/server/source/source.service.ts @@ -1,7 +1,8 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, NotFoundException } from "@nestjs/common"; import { Model, Types } from "mongoose"; import { SourceDocument, Source } from "./schemas/source.schema"; import { InjectModel } from "@nestjs/mongoose"; +const md5 = require("md5"); @Injectable() export class SourceService { @@ -35,6 +36,7 @@ export class SourceService { if (data?.props?.date) { data.props.date = new Date(data.props.date); } + data.data_hash = md5(data.href); data.user = Types.ObjectId(data.user); //TODO: don't create duplicate sources in one claim review task return await new this.SourceModel(data).save(); @@ -62,7 +64,33 @@ export class SourceService { } getById(_id) { - return this.SourceModel.findById(_id, { _id: 1, href: 1 }); + return this.SourceModel.findById(_id); + } + + async getByDataHash(data_hash) { + const source = await this.SourceModel.findOne({ data_hash }); + + if (source) { + return source; + } else { + throw new NotFoundException(); + } + } + + async update(data_hash, sourceBodyUpdate) { + const source = await this.getByDataHash(data_hash); + + const newSource = Object.assign(source, sourceBodyUpdate); + const sourceUpdated = await this.SourceModel.findByIdAndUpdate( + source._id, + newSource, + { + new: true, + upsert: true, + } + ); + + return sourceUpdated; } count(query) { diff --git a/server/state-event/schema/state-event.schema.ts b/server/state-event/schema/state-event.schema.ts index efd650176..0becde79a 100644 --- a/server/state-event/schema/state-event.schema.ts +++ b/server/state-event/schema/state-event.schema.ts @@ -1,7 +1,7 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import * as mongoose from "mongoose"; import { Claim } from "../../claim/schemas/claim.schema"; -import { ClaimReviewTask } from "../../claim-review-task/schemas/claim-review-task.schema"; +import { ReviewTask } from "../../review-task/schemas/review-task.schema"; export type StateEventDocument = StateEvent & mongoose.Document; @@ -29,9 +29,9 @@ export class StateEvent { @Prop({ type: mongoose.Types.ObjectId, required: false, - ref: "ClaimReviewTask", + ref: "ReviewTask", }) - taskId: ClaimReviewTask; + taskId: ReviewTask; @Prop({ type: Boolean, diff --git a/server/summarization/summarization-chain.service.ts b/server/summarization/summarization-crawler-chain.service.ts similarity index 98% rename from server/summarization/summarization-chain.service.ts rename to server/summarization/summarization-crawler-chain.service.ts index 30d42d4de..379c9196d 100644 --- a/server/summarization/summarization-chain.service.ts +++ b/server/summarization/summarization-crawler-chain.service.ts @@ -7,7 +7,7 @@ import { RecursiveCharacterTextSplitter } from "langchain/text_splitter"; import { ConfigService } from "@nestjs/config"; @Injectable() -export class SummarizationChainService { +export class SummarizationCrawlerChainService { private readonly logger = new Logger("SummarizationChainLogger"); constructor(private configService: ConfigService) {} diff --git a/server/summarization/summarization-crawler.controller.ts b/server/summarization/summarization-crawler.controller.ts new file mode 100644 index 000000000..a02b0c26e --- /dev/null +++ b/server/summarization/summarization-crawler.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Query, Req, UseGuards } from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { SummarizationCrawlerService } from "./summarization-crawler.service"; +import type { BaseRequest } from "../types"; +import { AbilitiesGuard } from "../auth/ability/abilities.guard"; +import { + CheckAbilities, + FactCheckerUserAbility, +} from "../auth/ability/ability.decorator"; + +@Controller() +export class SummarizationCrawlerController { + constructor( + private summarizationCrawlerService: SummarizationCrawlerService + ) {} + + @ApiTags("source") + @Get("api/summarization") + @UseGuards(AbilitiesGuard) + @CheckAbilities(new FactCheckerUserAbility()) + create(@Req() req: BaseRequest, @Query() query) { + return this.summarizationCrawlerService.summarizePage( + query.source, + req.language + ); + } +} diff --git a/server/summarization/summarization-crawler.module.ts b/server/summarization/summarization-crawler.module.ts new file mode 100644 index 000000000..591dd91e6 --- /dev/null +++ b/server/summarization/summarization-crawler.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { SummarizationCrawlerChainService } from "./summarization-crawler-chain.service"; +import { SummarizationCrawlerService } from "./summarization-crawler.service"; +import { ClaimReviewModule } from "../claim-review/claim-review.module"; +import { AbilityModule } from "../auth/ability/ability.module"; +import { ConfigModule } from "@nestjs/config"; +import { SummarizationCrawlerController } from "./summarization-crawler.controller"; + +@Module({ + imports: [ClaimReviewModule, AbilityModule, ConfigModule], + providers: [SummarizationCrawlerChainService, SummarizationCrawlerService], + exports: [SummarizationCrawlerService], + controllers: [SummarizationCrawlerController], +}) +export class SummarizationCrawlerModule {} diff --git a/server/summarization/summarization.service.ts b/server/summarization/summarization-crawler.service.ts similarity index 67% rename from server/summarization/summarization.service.ts rename to server/summarization/summarization-crawler.service.ts index e24ebb0ce..1ff03a967 100644 --- a/server/summarization/summarization.service.ts +++ b/server/summarization/summarization-crawler.service.ts @@ -1,10 +1,22 @@ import { Injectable, Logger } from "@nestjs/common"; -import { SummarizationChainService } from "./summarization-chain.service"; +import { SummarizationCrawlerChainService } from "./summarization-crawler-chain.service"; +import { WebBrowser } from "langchain/tools/webbrowser"; +import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai"; +import { openAI } from "../copilot/openAI.constants"; +import { AgentExecutor, createOpenAIToolsAgent } from "langchain/agents"; +import { ConfigService } from "@nestjs/config"; +import { + ChatPromptTemplate, + MessagesPlaceholder, +} from "@langchain/core/prompts"; @Injectable() -export class SummarizationService { +export class SummarizationCrawlerService { private readonly logger = new Logger("SummarizationLogger"); - constructor(private chainService: SummarizationChainService) {} + constructor( + private chainService: SummarizationCrawlerChainService, + private configService: ConfigService + ) {} async getSummarizedReviews(dailyReviews: any[]): Promise { try { @@ -116,4 +128,39 @@ export class SummarizationService { const stuffChain = this.chainService.createBulletPointsChain(); return await this.chainService.generateAnswer(stuffChain, content); } + + async summarizePage(source, language = "pt") { + language = language === "pt" ? "Portuguese" : "English"; + const prompt = ChatPromptTemplate.fromMessages([ + [ + "system", + `Summarize the content found at the provided URL {source}. Please provide a concise and accurate summary, + utilizing your understanding of the content's main points, key information, and essential details. Consider summarizing + in 1-2 paragraphs. Compose your responses using formal language and you MUST provide you answer in {language}.`, + ], + new MessagesPlaceholder({ variableName: "agent_scratchpad" }), + ]); + + const llm = new ChatOpenAI({ + temperature: +openAI.BASIC_CHAT_OPENAI_TEMPERATURE, + modelName: openAI.GPT_3_5_TURBO_1106.toString(), + apiKey: this.configService.get("openai.api_key"), + }); + + const embeddings = new OpenAIEmbeddings(); + const tools = [new WebBrowser({ model: llm, embeddings })]; + + const agent = await createOpenAIToolsAgent({ + llm, + tools, + prompt, + }); + + const agentExecutor = new AgentExecutor({ + agent, + tools, + }); + + return agentExecutor.invoke({ source, language }); + } } diff --git a/server/summarization/summarization.module.ts b/server/summarization/summarization.module.ts deleted file mode 100644 index c979a871b..000000000 --- a/server/summarization/summarization.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from "@nestjs/common"; -import { SummarizationChainService } from "./summarization-chain.service"; -import { SummarizationService } from "./summarization.service"; -import { ClaimReviewModule } from "../claim-review/claim-review.module"; -import { AbilityModule } from "../auth/ability/ability.module"; -import { ConfigModule } from "@nestjs/config"; - -@Module({ - imports: [ClaimReviewModule, AbilityModule, ConfigModule], - providers: [SummarizationChainService, SummarizationService], - exports: [SummarizationService], -}) -export class SummarizationModule {} diff --git a/server/tests/claim-review.e2e.spec.ts b/server/tests/claim-review.e2e.spec.ts index 6bf45b180..938f1ce09 100644 --- a/server/tests/claim-review.e2e.spec.ts +++ b/server/tests/claim-review.e2e.spec.ts @@ -134,7 +134,7 @@ describe("ClaimReviewController (e2e)", () => { .expect(200) .expect(({ body }) => { claimReviewId = body.review._id; - expect(body.review.claim).toEqual(claimId.toString()); + expect(body.review.target).toEqual(claimId.toString()); expect(body.review.personality).toEqual( personalitiesId[0].toString() ); diff --git a/server/tests/claim.e2e.spec.ts b/server/tests/claim.e2e.spec.ts index c22e9f134..54a11187a 100644 --- a/server/tests/claim.e2e.spec.ts +++ b/server/tests/claim.e2e.spec.ts @@ -19,7 +19,10 @@ describe("ClaimController (e2e)", () => { let db: any; let personalitiesId: string[]; let claimId: string; - const sources: string[] = ["http://wikipedia.org"]; + const speechSources: string[] = ["http://wikipedia.org"]; + const imageSources1: string[] = ["http://wikimedia.org"]; + const imageSources2: string[] = ["http://wikidata.org"]; + const debateSources: string[] = ["http://aletheiafact.org"]; const date: string = "2023-11-25T14:49:30.992Z"; beforeAll(async () => { @@ -69,7 +72,7 @@ describe("ClaimController (e2e)", () => { content: "Speech Claim Content Lorem Ipsum Dolor Sit Amet...", slug: "speech-claim-title", date, - sources, + speechSources, }) .expect(201) .expect(({ body }) => { @@ -102,7 +105,7 @@ describe("ClaimController (e2e)", () => { nameSpace: NameSpaceEnum.Main, title: "Image Claim Title With Personality", date, - sources, + imageSources1, content: { FileURL: "http://localhost:4566/aletheia/imageTest1.png", Key: "imageTest1.png", @@ -127,7 +130,7 @@ describe("ClaimController (e2e)", () => { nameSpace: NameSpaceEnum.Main, title: "Image Claim Title Without Personality", date, - sources, + imageSources2, content: { FileURL: "http://localhost:4566/aletheia/imageTest2.png", Key: "imageTest2.png", @@ -152,7 +155,7 @@ describe("ClaimController (e2e)", () => { nameSpace: NameSpaceEnum.Main, title: "Debate Claim Title", date, - sources, + debateSources, }) .expect(201) .expect(({ body }) => diff --git a/server/tests/source.e2e.spec.ts b/server/tests/source.e2e.spec.ts new file mode 100644 index 000000000..dd9f7ad21 --- /dev/null +++ b/server/tests/source.e2e.spec.ts @@ -0,0 +1,133 @@ +import { MongoMemoryServer } from "mongodb-memory-server"; +import * as request from "supertest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { AppModule } from "../app.module"; +import { SessionGuard } from "../auth/session.guard"; +import { SessionGuardMock } from "./mocks/SessionGuardMock"; +import { TestConfigOptions } from "./utils/TestConfigOptions"; +import { SeedTestUser } from "./utils/SeedTestUser"; +import { AbilitiesGuard } from "../auth/ability/abilities.guard"; +import { AbilitiesGuardMock } from "./mocks/AbilitiesGuardMock"; +import { NameSpaceEnum } from "../auth/name-space/schemas/name-space.schema"; +import { SeedTestPersonality } from "./utils/SeedTestPersonality"; +import { SeedTestClaim } from "./utils/SeedTestClaim"; +const ObjectId = require("mongodb").ObjectID; + +jest.setTimeout(10000); + +describe("SourceController (e2e)", () => { + let app: any; + let db: any; + let userId: string; + let personalitiesId: string[]; + let claimId: string; + let targetId: string; + + beforeAll(async () => { + db = await MongoMemoryServer.create({ instance: { port: 35025 } }); + const user = await SeedTestUser( + TestConfigOptions.config.db.connection_uri + ); + userId = user.insertedId; + + const { insertedIds } = await SeedTestPersonality( + TestConfigOptions.config.db.connection_uri + ); + personalitiesId = [insertedIds["0"], insertedIds["1"]]; + + const claimRevisionId = new ObjectId(); + const claim = await SeedTestClaim( + TestConfigOptions.config.db.connection_uri, + personalitiesId, + claimRevisionId + ); + claimId = claim.insertedId; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule.register(TestConfigOptions.config)], + }) + .overrideProvider(SessionGuard) + .useValue(SessionGuardMock) + .overrideGuard(AbilitiesGuard) + .useValue(AbilitiesGuardMock) + .compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it("/api/source (GET) - sources should be empty", () => { + return request(app.getHttpServer()) + .get("/api/source") + .query({ + page: 0, + order: "asc", + pageSize: 5, + language: "pt", + nameSpace: NameSpaceEnum.Main, + }) + .expect(200) + .expect(({ body }) => expect(body?.sources.length).toEqual(0)); + }); + + it("/api/source (POST) - should create a new source with valid captcha", async () => { + const sourceData = { + href: "https://www.wikipedia.org/", + props: { + summary: "mock_summary", + classification: "mock_classification", + }, + user: ObjectId(userId), + nameSpace: NameSpaceEnum.Main, + targetId: claimId, + recaptcha: "valid_recaptcha_token", + }; + + return request(app.getHttpServer()) + .post(`/api/source`) + .send(sourceData) + .expect(201) + .expect(({ body }) => { + targetId = body.targetId; + expect(body.href).toEqual("https://www.wikipedia.org/"); + }); + }); + + it("/api/source/target/:targetId (GET) - Should get sources by targetId", () => { + return request(app.getHttpServer()) + .get(`/api/source/target/${targetId}`) + .query({ + page: 0, + order: "asc", + pageSize: 5, + language: "pt", + nameSpace: NameSpaceEnum.Main, + }) + .expect(200) + .expect(({ body }) => { + expect(body.sources[0].targetId).toEqual(targetId); + }); + }); + + it("/api/source (GET) - should findAll sources", () => { + return request(app.getHttpServer()) + .get("/api/source") + .query({ + page: 0, + order: "asc", + pageSize: 5, + language: "pt", + nameSpace: NameSpaceEnum.Main, + }) + .expect(200) + .expect(({ body }) => { + expect(body?.sources.length).toEqual(1); + }); + }); + + afterAll(async () => { + jest.restoreAllMocks(); + await db.stop(); + app.close(); + }); +}); diff --git a/server/tests/utils/ClaimReviewMock.ts b/server/tests/utils/ClaimReviewMock.ts index f9b6ee6df..677298429 100644 --- a/server/tests/utils/ClaimReviewMock.ts +++ b/server/tests/utils/ClaimReviewMock.ts @@ -8,7 +8,8 @@ export const ReviewMock = (claimId, personalitiesId, reportId, userId) => ({ isDeleted: false, deletedAt: null, personality: ObjectId(personalitiesId[0]), - claim: ObjectId(claimId), + target: ObjectId(claimId), + targetModel: "Claim", usersId: [ObjectId(userId)], report: ObjectId(reportId), data_hash: "4be75d25957a3cc0dbc6975a6939a385", diff --git a/server/tests/utils/ClaimRevisionMock.ts b/server/tests/utils/ClaimRevisionMock.ts index 3c28e7de9..368508f8a 100644 --- a/server/tests/utils/ClaimRevisionMock.ts +++ b/server/tests/utils/ClaimRevisionMock.ts @@ -9,7 +9,7 @@ export const ClaimRevisionMock = ( _id: claimRevisionId, personalities: [ObjectId(personalitiesId[0])], contentModel: "Speech", - tittle: "Mock Tittle", + title: "Mock Tittle", date: "2024-04-18T16:31:16.372+00:00", claimId: ObjectId(claimId), slug: "test", diff --git a/server/types/enums.ts b/server/types/enums.ts index 708169325..fa6405a64 100644 --- a/server/types/enums.ts +++ b/server/types/enums.ts @@ -8,6 +8,13 @@ enum ContentModelEnum { enum ReportModelEnum { FactChecking = "Fact-checking", InformativeNews = "Informative News", + Request = "Request", } -export { ContentModelEnum, ReportModelEnum }; +enum ReviewTaskTypeEnum { + Claim = "Claim", + Source = "Source", + VerificationRequest = "VerificationRequest", +} + +export { ContentModelEnum, ReportModelEnum, ReviewTaskTypeEnum }; diff --git a/server/verification-request/dto/create-verification-request-dto.ts b/server/verification-request/dto/create-verification-request-dto.ts new file mode 100644 index 000000000..0dae5221d --- /dev/null +++ b/server/verification-request/dto/create-verification-request-dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsDate, IsOptional, IsString } from "class-validator"; + +export class CreateVerificationRequestDTO { + @IsString() + @ApiProperty() + content: string; + + @IsDate() + @ApiProperty() + @IsOptional() + date: Date; + + @IsArray() + @ApiProperty() + @IsOptional() + sources: string[]; + + @IsString() + @IsOptional() + @ApiProperty() + data_hash: string; +} diff --git a/server/verification-request/dto/update-verification-request.dto.ts b/server/verification-request/dto/update-verification-request.dto.ts new file mode 100644 index 000000000..4882166e9 --- /dev/null +++ b/server/verification-request/dto/update-verification-request.dto.ts @@ -0,0 +1,36 @@ +import { PartialType } from "@nestjs/mapped-types"; +import { CreateVerificationRequestDTO } from "./create-verification-request-dto"; +import { IsArray, IsBoolean, IsOptional, IsString } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Group } from "../../group/schemas/group.schema"; +import { Transform } from "class-transformer"; + +export class UpdateVerificationRequestDTO extends PartialType( + CreateVerificationRequestDTO +) { + @IsString() + @IsOptional() + @ApiProperty() + targetId: string; + + @IsArray() + @IsOptional() + @ApiProperty() + group: Group; + + @IsOptional() + @ApiProperty() + usersId: string[]; + + @IsBoolean() + @IsOptional() + @ApiProperty() + isSensitive: boolean; + + @ApiProperty() + @IsOptional() + @Transform(({ value }) => { + return [true, "enabled", "true", 1, "1"].indexOf(value) > -1; + }) + rejected?: boolean; +} diff --git a/server/verification-request/schemas/verification-request.schema.ts b/server/verification-request/schemas/verification-request.schema.ts new file mode 100644 index 000000000..c4f7f3d63 --- /dev/null +++ b/server/verification-request/schemas/verification-request.schema.ts @@ -0,0 +1,34 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import * as mongoose from "mongoose"; +import { Group } from "../../group/schemas/group.schema"; + +export type VerificationRequestDocument = VerificationRequest & + mongoose.Document; + +@Schema() +export class VerificationRequest { + @Prop({ required: true, unique: true }) + data_hash: string; + + @Prop({ required: true, type: String }) + content: string; + + @Prop({ required: false, default: new Date() }) + date: Date; + + @Prop({ + type: mongoose.Types.ObjectId, + required: false, + ref: "Group", + }) + group: Group; + + @Prop({ required: false, type: Boolean }) + rejected: boolean; + + @Prop({ required: false, type: Boolean }) + isSensitive: boolean; +} + +export const VerificationRequestSchema = + SchemaFactory.createForClass(VerificationRequest); diff --git a/server/verification-request/verification-request.controller.ts b/server/verification-request/verification-request.controller.ts new file mode 100644 index 000000000..61bfd7ea7 --- /dev/null +++ b/server/verification-request/verification-request.controller.ts @@ -0,0 +1,153 @@ +import { + Controller, + Post, + Body, + Get, + Res, + Req, + Header, + Query, + Param, + Put, +} from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { VerificationRequestService } from "./verification-request.service"; +import type { BaseRequest } from "../types"; +import { parse } from "url"; +import { ConfigService } from "@nestjs/config"; +import { ViewService } from "../view/view.service"; +import type { Response } from "express"; +import { ReviewTaskService } from "../review-task/review-task.service"; +import { CreateVerificationRequestDTO } from "./dto/create-verification-request-dto"; +import { UpdateVerificationRequestDTO } from "./dto/update-verification-request.dto"; +import { IsPublic } from "../auth/decorators/is-public.decorator"; + +@Controller(":namespace?") +export class VerificationRequestController { + constructor( + private verificationRequestService: VerificationRequestService, + private configService: ConfigService, + private viewService: ViewService, + private reviewTaskService: ReviewTaskService + ) {} + + @ApiTags("verification-request") + @Get("api/verification-request") + @Header("Cache-Control", "max-age=60, must-revalidate") + public async listAll(@Query() getVerificationRequest) { + const { pageSize, page } = getVerificationRequest; + + return Promise.all([ + this.verificationRequestService.listAll(getVerificationRequest), + this.verificationRequestService.count({}), + ]).then(([verificationRequests, totalVerificationRequests]) => { + const totalPages = Math.ceil(totalVerificationRequests / pageSize); + + return { + verificationRequests, + totalVerificationRequests, + totalPages, + page: page, + pageSize: pageSize, + }; + }); + } + + @ApiTags("verification-request") + @Get("api/verification-request/search") + @Header("Cache-Control", "max-age=60, must-revalidate") + public async getAll(@Query() getVerificationRequest) { + return this.verificationRequestService.findAll(getVerificationRequest); + } + + @ApiTags("verification-request") + @Get("api/verification-request/:id") + @Header("Cache-Control", "max-age=60, must-revalidate") + public async getById(@Param("id") verificationRequestId: string) { + return this.verificationRequestService.getById(verificationRequestId); + } + + @ApiTags("verification-request") + @Post("api/verification-request") + create(@Body() verificationRequestBody: CreateVerificationRequestDTO) { + return this.verificationRequestService.create(verificationRequestBody); + } + + @ApiTags("verification-request") + @Put("api/verification-request/:verificationRequestId") + async updateVerificationRequest( + @Param("verificationRequestId") verificationRequestId: string, + @Body() updateVerificationRequestDto: UpdateVerificationRequestDTO + ) { + return this.verificationRequestService.update( + verificationRequestId, + updateVerificationRequestDto + ); + } + + @ApiTags("verification-request") + @Put("api/verification-request/:verificationRequestId/group") + async removeVerificationRequestFromGroup( + @Param("verificationRequestId") verificationRequestId: string, + @Body() { group }: { group: string } + ) { + return this.verificationRequestService.removeVerificationRequestFromGroup( + verificationRequestId, + group + ); + } + + @IsPublic() + @ApiTags("pages") + @Get("verification-request") + @Header("Cache-Control", "max-age=60, must-revalidate") + public async verificationRequestPage( + @Req() req: BaseRequest, + @Res() res: Response + ) { + const parsedUrl = parse(req.url, true); + + await this.viewService.getNextServer().render( + req, + res, + "/verification-request-page", + Object.assign(parsedUrl.query, { + nameSpace: req.params.namespace, + }) + ); + } + + @IsPublic() + @ApiTags("pages") + @Get("verification-request/:dataHash") + @Header("Cache-Control", "max-age=60, must-revalidate") + public async verificationRequestReviewPage( + @Req() req: BaseRequest, + @Res() res: Response + ) { + const parsedUrl = parse(req.url, true); + const { dataHash } = req.params; + + const verificationRequest = + await this.verificationRequestService.findByDataHash(dataHash); + + const reviewTask = + await this.reviewTaskService.getReviewTaskByDataHashWithUsernames( + dataHash + ); + + await this.viewService.getNextServer().render( + req, + res, + "/verification-request-review-page", + Object.assign(parsedUrl.query, { + reviewTask, + sitekey: this.configService.get("recaptcha_sitekey"), + hideDescriptions: {}, + websocketUrl: this.configService.get("websocketUrl"), + nameSpace: req.params.namespace, + verificationRequest, + }) + ); + } +} diff --git a/server/verification-request/verification-request.module.ts b/server/verification-request/verification-request.module.ts new file mode 100644 index 000000000..ebc62c6e0 --- /dev/null +++ b/server/verification-request/verification-request.module.ts @@ -0,0 +1,37 @@ +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; +import { + VerificationRequestSchema, + VerificationRequest, +} from "./schemas/verification-request.schema"; +import { VerificationRequestService } from "./verification-request.service"; +import { VerificationRequestController } from "./verification-request.controller"; +import { SourceModule } from "../source/source.module"; +import { ViewModule } from "../view/view.module"; +import { ConfigModule } from "@nestjs/config"; +import { ReviewTaskModule } from "../review-task/review-task.module"; +import { HistoryModule } from "../history/history.module"; +import { GroupModule } from "../group/group.module"; + +const VerificationRequestModel = MongooseModule.forFeature([ + { + name: VerificationRequest.name, + schema: VerificationRequestSchema, + }, +]); + +@Module({ + imports: [ + VerificationRequestModel, + SourceModule, + ViewModule, + ConfigModule, + ReviewTaskModule, + HistoryModule, + GroupModule, + ], + exports: [VerificationRequestService], + providers: [VerificationRequestService], + controllers: [VerificationRequestController], +}) +export class VerificationRequestModule {} diff --git a/server/verification-request/verification-request.service.ts b/server/verification-request/verification-request.service.ts new file mode 100644 index 000000000..28d39e80c --- /dev/null +++ b/server/verification-request/verification-request.service.ts @@ -0,0 +1,285 @@ +import { Injectable } from "@nestjs/common"; +import { Model, Types } from "mongoose"; +import { SourceService } from "../source/source.service"; +import { + VerificationRequest, + VerificationRequestDocument, +} from "./schemas/verification-request.schema"; +import { InjectModel } from "@nestjs/mongoose"; +import { GroupService } from "../group/group.service"; +import { CreateVerificationRequestDTO } from "./dto/create-verification-request-dto"; +import { UpdateVerificationRequestDTO } from "./dto/update-verification-request.dto"; +const md5 = require("md5"); + +@Injectable() +export class VerificationRequestService { + constructor( + @InjectModel(VerificationRequest.name) + private VerificationRequestModel: Model, + private sourceService: SourceService, + private groupService: GroupService + ) {} + + async listAll({ page, pageSize, order }): Promise { + return this.VerificationRequestModel.find({}) + .skip(page * parseInt(pageSize, 10)) + .limit(parseInt(pageSize, 10)) + .sort({ _id: order }) + .lean(); + } + + /** + * Find all verification requests that query matches + * @param verifiedRequestQuery query parameter + * @returns an array of verification request documents + */ + async findAll(verifiedRequestQuery: { + searchContent: string; + }): Promise { + return this.VerificationRequestModel.find({ + content: { + $regex: verifiedRequestQuery.searchContent || "", + $options: "i", + }, + }); + } + + /** + * Finds a document by an id parameter + * @param verificationRequestId verification request ID string + * @returns the verification request document + */ + async getById(verificationRequestId: string): Promise { + return this.VerificationRequestModel.findById( + verificationRequestId + ).populate("group"); + } + + /** + * Creates a new verification request document + * For each sources in verification request, creates a new source document + * @param verificationRequest verificationRequestBody + * @returns the verification request document + */ + create( + verificationRequest: CreateVerificationRequestDTO + ): Promise { + try { + verificationRequest.data_hash = md5(verificationRequest.content); + const newVerificationRequest = new this.VerificationRequestModel( + verificationRequest + ); + if (verificationRequest.sources.length) { + for (const source of verificationRequest.sources) { + this.sourceService.create({ + href: source, + targetId: newVerificationRequest.id, + }); + } + } + + return newVerificationRequest.save(); + } catch (e) { + console.error("Failed to create verification request", e); + throw new Error(e); + } + } + + /** + * Finds a document by an data_hash parameter + * @param data_hash encrypted hash + * @returns the verification request document + */ + async findByDataHash( + data_hash: string + ): Promise { + return this.VerificationRequestModel.findOne({ data_hash }).populate( + "group" + ); + } + + /** + * Find the removed ids based on an initial object and an updated object + * @param initial initial object + * @param updated updated object + * @returns the removed ids + */ + findRemovedIds(initial, updated): string[] { + return initial.content.filter( + (id) => !updated.content.includes(id.toString()) + ); + } + + /** + * Removes a specific verification request document in the group content document + * @param verificationRequestId verification request ID + * @param groupId group ID + * @returns updated verification request document + */ + async removeVerificationRequestFromGroup( + verificationRequestId: string, + groupId: string + ): Promise { + try { + const verificationRequest = + await this.VerificationRequestModel.findById( + verificationRequestId + ); + + await this.groupService.removeContent( + groupId, + verificationRequest._id + ); + + return await this.VerificationRequestModel.findByIdAndUpdate( + verificationRequest._id, + { $unset: { group: null } }, + { new: true, upsert: true } + ); + } catch (error) { + console.error( + "Failed to remove verification request from group:", + error + ); + throw error; + } + } + + /** + * Updates the verification request document + * @param verificationRequestId verification request id + * @param verificationRequestBodyUpdate verification request updated object + * @param postProcess boolean + * @returns the updated verification request document + */ + async update( + verificationRequestId: string, + verificationRequestBodyUpdate: Partial, + postProcess: boolean = true + ): Promise { + try { + const verificationRequest = + await this.VerificationRequestModel.findById( + verificationRequestId + ).populate("group"); + + if (!verificationRequest) { + throw new Error("Verification request not found"); + } + + const updatedVerificationRequestData = { + ...verificationRequest.toObject(), + ...verificationRequestBodyUpdate, + }; + + if ( + postProcess && + Array.isArray(updatedVerificationRequestData.group) + ) { + updatedVerificationRequestData.group = + await this.handleGroupPostProcessing( + verificationRequest, + updatedVerificationRequestData + ); + } + + return await this.VerificationRequestModel.findByIdAndUpdate( + verificationRequest._id, + updatedVerificationRequestData, + { new: true, upsert: true } + ); + } catch (error) { + console.error("Failed to update verification request:", error); + throw error; + } + } + + /** + * Finds the removed group ids and update their document + * Creates a new group document and updates all verification requests that are part of this group with the group ID + * @param originalVerificationRequest verification request original object + * @param updatedVerificationRequest verification request updated object + * @returns the group id + */ + private async handleGroupPostProcessing( + originalVerificationRequest, + updatedVerificationRequest + ) { + if (originalVerificationRequest?.group) { + await this.delete( + originalVerificationRequest, + updatedVerificationRequest + ); + } + + return await this.createGroupAndUpdateVerificationRequests( + originalVerificationRequest, + updatedVerificationRequest + ); + } + + /** + * Finds the removed group ids and update their document + * @param originalVerificationRequest verification request original object + * @param updatedVerificationRequest verification request updated object + */ + private async delete( + originalVerificationRequest, + updatedVerificationRequest + ) { + const removedRequestIds = await Promise.all( + this.findRemovedIds(originalVerificationRequest?.group, { + content: [ + originalVerificationRequest._id.toString(), + ...updatedVerificationRequest.group, + ], + }) + ); + + const flattenedRemovedIds = removedRequestIds.flat(); + + if (flattenedRemovedIds?.length) { + await Promise.all( + flattenedRemovedIds.map((id) => + this.update(id, { group: null }, false) + ) + ); + } + } + + /** + * Creates a new group document and updates all verification requests that are part of this group with the group ID + * @param originalVerificationRequest verification request original object + * @param updatedVerificationRequest verification request updated object + * @returns the group id + */ + private async createGroupAndUpdateVerificationRequests( + originalVerificationRequest, + updatedVerificationRequest + ) { + const contentIds = + updatedVerificationRequest?.group?.map((item) => + Types.ObjectId(item?._id || item) + ) || []; + + const groupId = ( + await this.groupService.create({ + content: [originalVerificationRequest._id, ...contentIds], + }) + )._id; + + if (contentIds.length) { + await Promise.all( + contentIds.map((itemId) => + this.update(itemId, { group: groupId }, false) + ) + ); + } + + return groupId; + } + + count(query) { + return this.VerificationRequestModel.countDocuments().where(query); + } +} diff --git a/src/api/AutomatedFactCheckingApi.ts b/src/api/AutomatedFactCheckingApi.ts index 5dfc63afe..0ba31b5fd 100644 --- a/src/api/AutomatedFactCheckingApi.ts +++ b/src/api/AutomatedFactCheckingApi.ts @@ -5,7 +5,7 @@ const request = axios.create({ baseURL: `/api/ai-fact-checking`, }); -const createClaimReviewTaskUsingAIAgents = (params) => { +const createReviewTaskUsingAIAgents = (params) => { return request .post("/", { ...params }) .then((response) => { @@ -17,7 +17,7 @@ const createClaimReviewTaskUsingAIAgents = (params) => { }; const AutomatedFactCheckingApi = { - createClaimReviewTaskUsingAIAgents, + createReviewTaskUsingAIAgents, }; export default AutomatedFactCheckingApi; diff --git a/src/api/ClaimReviewTaskApi.ts b/src/api/reviewTaskApi.ts similarity index 80% rename from src/api/ClaimReviewTaskApi.ts rename to src/api/reviewTaskApi.ts index cf98334a3..e4d54829e 100644 --- a/src/api/ClaimReviewTaskApi.ts +++ b/src/api/reviewTaskApi.ts @@ -4,16 +4,17 @@ import { NameSpaceEnum } from "../types/Namespace"; const request = axios.create({ withCredentials: true, - baseURL: `/api/claimreviewtask`, + baseURL: `/api/reviewtask`, }); -const getClaimReviewTasks = (options) => { +const getReviewTasks = (options) => { const params = { page: options.page ? options.page - 1 : 0, order: options.order || "asc", pageSize: options.pageSize ? options.pageSize : 5, value: options.value, filterUser: options.filterUser, + reviewTaskType: options.reviewTaskType, nameSpace: options.nameSpace ? options.nameSpace : NameSpaceEnum.Main, }; return request @@ -43,15 +44,15 @@ const getMachineByDataHash = (params) => { }); }; -const createClaimReviewTask = (params, t, type) => { +const createReviewTask = (params, t, type) => { return request .post("/", { ...params }) .then((response) => { - message.success(t(`claimReviewTask:${type}_SUCCESS`)); + message.success(t(`reviewTask:${type}_SUCCESS`)); return response.data; }) .catch((err) => { - message.error(t(`claimReviewTask:${type}_ERROR`)); + message.error(t(`reviewTask:${type}_ERROR`)); throw err; }); }; @@ -60,7 +61,7 @@ const autoSaveDraft = (params, t) => { return request .put(`/${params.data_hash}`, { ...params }) .then((response) => { - message.success(t(`claimReviewTask:SAVE_DRAFT_SUCCESS`)); + message.success(t(`reviewTask:SAVE_DRAFT_SUCCESS`)); return response.data; }) .catch((err) => { @@ -68,9 +69,9 @@ const autoSaveDraft = (params, t) => { }); }; -const getEditorContentObject = (data_hash, reportModel) => { +const getEditorContentObject = (data_hash, params) => { return request - .get(`/editor-content/${data_hash}`, { params: { reportModel } }) + .get(`/editor-content/${data_hash}`, { params }) .then((response) => { return response.data; }) @@ -101,14 +102,14 @@ const deleteComment = (hash, commentId) => { }); }; -const ClaimReviewTaskApi = { +const ReviewTaskApi = { getMachineByDataHash, - createClaimReviewTask, - getClaimReviewTasks, + createReviewTask, + getReviewTasks, autoSaveDraft, getEditorContentObject, addComment, deleteComment, }; -export default ClaimReviewTaskApi; +export default ReviewTaskApi; diff --git a/src/api/sourceApi.ts b/src/api/sourceApi.ts index c484488a7..235f965c1 100644 --- a/src/api/sourceApi.ts +++ b/src/api/sourceApi.ts @@ -23,7 +23,7 @@ const getByTargetId = (options: optionsType) => { language: options?.i18n?.languages[0], }; return request - .get(`${options.targetId}`, { params }) + .get(`/target/${options.targetId}`, { params }) .then((response) => { const { sources, totalPages, totalSources } = response.data; return { @@ -82,10 +82,22 @@ const createSource = (t, router, source: any = {}) => { }); }; +const getById = (id, t, params = {}) => { + return request + .get(`/${id}`, { params }) + .then((response) => { + return response.data; + }) + .catch(() => { + message.error("error"); //TODO: Improve feedback message + }); +}; + const SourceApi = { getByTargetId, createSource, get, + getById, }; export default SourceApi; diff --git a/src/api/summarizationApi.ts b/src/api/summarizationApi.ts new file mode 100644 index 000000000..e2a0cd75c --- /dev/null +++ b/src/api/summarizationApi.ts @@ -0,0 +1,23 @@ +import axios from "axios"; + +const request = axios.create({ + withCredentials: true, + baseURL: `/api/summarization`, +}); + +const summarizeSource = (source) => { + return request + .get("/", { params: { source } }) + .then((response) => { + return response.data; + }) + .catch((err) => { + throw err; + }); +}; + +const SummarizationApi = { + summarizeSource, +}; + +export default SummarizationApi; diff --git a/src/api/verificationRequestApi.ts b/src/api/verificationRequestApi.ts new file mode 100644 index 000000000..61e3d19d7 --- /dev/null +++ b/src/api/verificationRequestApi.ts @@ -0,0 +1,89 @@ +import axios from "axios"; +import { NameSpaceEnum } from "../types/Namespace"; + +const request = axios.create({ + withCredentials: true, + baseURL: `/api/verification-request`, +}); + +const get = (options) => { + const params = { + page: options.page ? options.page - 1 : 0, + order: options.order || "asc", + pageSize: options.pageSize ? options.pageSize : 10, + nameSpace: options?.nameSpace || NameSpaceEnum.Main, + }; + + return request + .get(`/`, { params }) + .then((response) => { + const { + verificationRequests, + totalPages, + totalVerificationRequests, + } = response.data; + + return { + data: verificationRequests, + total: totalVerificationRequests, + totalPages, + }; + }) + .catch((err) => { + console.log(err); + }); +}; + +const getVerificationRequests = (params) => { + return request + .get(`/search`, { params }) + .then((response) => { + return response?.data; + }) + .catch((e) => { + console.error("error while getting verification requests", e); + }); +}; + +const getById = (id, _t = null, params = {}) => { + return request + .get(`/${id}`, { params }) + .then((response) => { + return response.data; + }) + .catch((e) => { + console.error("error while getting verification request", e); + }); +}; + +const updateVerificationRequest = (id, params) => { + return request + .put(`/${id}`, params) + .then((response) => { + return response.data; + }) + .catch((e) => { + console.error("error while updating verification request", e); + }); +}; + +const removeVerificationRequestFromGroup = (id, params) => { + return request + .put(`/${id}/group`, params) + .then((response) => { + return response.data; + }) + .catch((e) => { + console.error("error while removing verification request", e); + }); +}; + +const verificationRequestApi = { + get, + getVerificationRequests, + getById, + updateVerificationRequest, + removeVerificationRequestFromGroup, +}; + +export default verificationRequestApi; diff --git a/src/components/AffixButton/AffixButton.tsx b/src/components/AffixButton/AffixButton.tsx index cb6dd465b..f4cd22fba 100644 --- a/src/components/AffixButton/AffixButton.tsx +++ b/src/components/AffixButton/AffixButton.tsx @@ -73,9 +73,9 @@ const AffixButton = ({ personalitySlug }: AffixButtonProps) => { tooltip: t("affix:affixButtonCreateVerifiedSources"), href: nameSpace !== NameSpaceEnum.Main - ? `/${nameSpace}/sources/create` - : `/sources/create`, - dataCy: "testFloatButtonAddVerifiedSources", + ? `/${nameSpace}/source/create` + : `/source/create`, + dataCy: "testFloatButtonAddSources", } ); diff --git a/src/components/AletheiaMenu.tsx b/src/components/AletheiaMenu.tsx index ea9814402..ee01ea913 100644 --- a/src/components/AletheiaMenu.tsx +++ b/src/components/AletheiaMenu.tsx @@ -67,15 +67,27 @@ const AletheiaMenu = () => { {t("menu:sourcesItem")} + + {t("menu:verificationRequestItem")} + + {role !== Roles.Regular && ( { const { t } = useTranslation(); const dispatch = useDispatch(); - const { selectedClaim } = useAppSelector((state) => state); + const { selectedTarget } = useAppSelector((state) => state); const review = claim?.stats?.reviews[0]; const paragraphs = content || claim.content; const [claimContent, setClaimContent] = useState(""); @@ -45,7 +45,7 @@ const ClaimCard = ({ const isUnattributed = claim?.contentModel === ContentModelEnum.Unattributed; const isInsideDebate = - selectedClaim?.contentModel === ContentModelEnum.Debate; + selectedTarget?.contentModel === ContentModelEnum.Debate; const shouldCreateFirstParagraph = isSpeech || isUnattributed; const dispatchPersonalityAndClaim = () => { @@ -53,7 +53,7 @@ const ClaimCard = ({ // when selecting a claim from the debate page to review or to read, // we don't want to change the selected claim // se we can keep reference to the debate - dispatch(actions.setSelectClaim(claim)); + dispatch(actions.setSelectTarget(claim)); } dispatch(actions.setSelectPersonality(personality)); }; diff --git a/src/components/Claim/ClaimInfo.tsx b/src/components/Claim/ClaimInfo.tsx index cf678b2b0..208577fc4 100644 --- a/src/components/Claim/ClaimInfo.tsx +++ b/src/components/Claim/ClaimInfo.tsx @@ -17,21 +17,26 @@ const ClaimInfoParagraph = styled(Paragraph)` color: ${colors.blackSecondary}; `; -const ClaimInfo = ({ isImage, date }) => { +const ClaimInfo = ({ + isImage, + date, + speechTypeTranslation = "claim:typeSpeech", + style = {}, +}) => { const { t } = useTranslation(); return ( <> {!isImage ? ( - + {t("claim:cardHeader1")}    {t("claim:cardHeader2")}  - {t("claim:typeSpeech")} + {t(speechTypeTranslation)} ) : ( - + {t("claim:cardHeader3")}  diff --git a/src/components/Claim/ClaimSummaryContent.tsx b/src/components/Claim/ClaimSummaryContent.tsx index 93f0a7337..e197f23a8 100644 --- a/src/components/Claim/ClaimSummaryContent.tsx +++ b/src/components/Claim/ClaimSummaryContent.tsx @@ -1,13 +1,9 @@ import colors from "../../styles/colors"; -import { Col, Typography } from "antd"; +import { Col } from "antd"; import React from "react"; import { useTranslation } from "next-i18next"; -import ImageClaim from "../ImageClaim"; import { ContentModelEnum } from "../../types/enums"; -import { NameSpaceEnum } from "../../types/Namespace"; -import { useAtom } from "jotai"; -import { currentNameSpace } from "../../atoms/namespace"; -const { Paragraph } = Typography; +import ReviewContent from "../ClaimReview/ReviewContent"; interface ClaimSummaryContentProps { claimContent: any; @@ -25,7 +21,6 @@ const ClaimSummaryContent = ({ contentModel, }: ClaimSummaryContentProps) => { const { t } = useTranslation(); - const [nameSpace] = useAtom(currentNameSpace); const isImage = contentModel === ContentModelEnum.Image; const contentProps = { [ContentModelEnum.Speech]: { @@ -62,47 +57,32 @@ const ClaimSummaryContent = ({ return ( - - -

- {title} -

- {isImage && ( - - )} -
-
- - {t(linkText)} - + + {title} +

+ ) + } + content={claimContent.content} + isImage={isImage} + contentPath={href} + linkText={t(linkText)} + ellipsis={true} + /> ); }; diff --git a/src/components/Claim/ClaimView.tsx b/src/components/Claim/ClaimView.tsx index 31e6977c0..c5ee93ecd 100644 --- a/src/components/Claim/ClaimView.tsx +++ b/src/components/Claim/ClaimView.tsx @@ -29,14 +29,14 @@ const ClaimView = ({ personality, claim, href, hideDescriptions }) => { const sources = claim?.sources?.map((source) => source.href); const dispatchPersonalityAndClaim = () => { - dispatch(actions.setSelectClaim(claim)); + dispatch(actions.setSelectTarget(claim)); dispatch(actions.setSelectPersonality(personality)); }; const [showHighlights, setShowHighlights] = useState(true); useEffect(() => { - dispatch(actions.setSelectClaim(claim)); + dispatch(actions.setSelectTarget(claim)); dispatch(actions.setSelectPersonality(personality)); if (isImage) { dispatch(actions.setSelectContent(claimContent)); diff --git a/src/components/Claim/CreateClaim/ClaimSelectPersonality.tsx b/src/components/Claim/CreateClaim/ClaimSelectPersonality.tsx index 2c93601c7..661545a33 100644 --- a/src/components/Claim/CreateClaim/ClaimSelectPersonality.tsx +++ b/src/components/Claim/CreateClaim/ClaimSelectPersonality.tsx @@ -113,7 +113,12 @@ const ClaimSelectPersonality = () => { personalities.map((personality) => ( - + diff --git a/src/components/Claim/CreateClaim/CreateClaimView.tsx b/src/components/Claim/CreateClaim/CreateClaimView.tsx index 4435da8dc..73cf1b696 100644 --- a/src/components/Claim/CreateClaim/CreateClaimView.tsx +++ b/src/components/Claim/CreateClaim/CreateClaimView.tsx @@ -1,6 +1,6 @@ import { Col, Row } from "antd"; import { useAtom } from "jotai"; -import React from "react"; +import React, { useState } from "react"; import { createClaimMachineAtom } from "../../../machines/createClaim/provider"; import { @@ -17,9 +17,18 @@ import ClaimSelectPersonality from "./ClaimSelectPersonality"; import ClaimSelectType from "./ClaimSelectType"; import ClaimUploadImage from "./ClaimUploadImage"; import { CreateClaimHeader } from "./CreateClaimHeader"; +import colors from "../../../styles/colors"; +import LargeDrawer from "../../LargeDrawer"; +import VerificationRequestCard from "../../VerificationRequest/VerificationRequestCard"; +import AletheiaButton from "../../Button"; +import { DeleteOutlined } from "@ant-design/icons"; +import { CreateClaimEvents } from "../../../machines/createClaim/types"; +import verificationRequestApi from "../../../api/verificationRequestApi"; +import { useTranslation } from "next-i18next"; const CreateClaimView = () => { - const [state] = useAtom(createClaimMachineAtom); + const { t } = useTranslation(); + const [state, send] = useAtom(createClaimMachineAtom); const setupImage = stateSelector(state, "setupImage"); const notStarted = stateSelector(state, "notStarted"); const setupSpeech = stateSelector(state, "setupSpeech"); @@ -42,9 +51,58 @@ const CreateClaimView = () => { addUnattributed ); + const [open, setOpen] = useState(false); + const onCloseDrawer = () => { + setOpen(false); + }; + + const onRemove = (id) => { + //TODO: Show confirmation dialog + const contentGroup = claimData.group.content.filter( + (verificationRequest) => verificationRequest?._id !== id + ); + verificationRequestApi + .removeVerificationRequestFromGroup(id, { + group: claimData.group._id, + }) + .then(() => { + send({ + type: CreateClaimEvents.updateGroup, + claimData: { + group: { ...claimData.group, content: contentGroup }, + }, + }); + }); + }; + return ( - + + {!isLoading && + claimData?.group && + claimData?.group?.content?.length > 0 && ( + setOpen(true)} + role="button" + aria-pressed="false" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === "Space") { + setOpen(true); + e.preventDefault(); + } + }} + style={{ + color: colors.lightBlueMain, + textDecoration: "underline", + cursor: "pointer", + }} + > + {t( + "verificationRequest:manageVerificationRequests" + )} + + )} {showPersonality && !!claimData.personalities?.length && ( )} @@ -58,6 +116,35 @@ const CreateClaimView = () => { {isLoading && } {addUnattributed && } + + + +

{t("verificationRequest:verificationRequestTitle")}

+ {claimData?.group ? ( + claimData.group.content.map(({ _id, content }) => ( + onRemove(_id)} + loading={isLoading} + > + + , + ]} + /> + )) + ) : ( + <> + )} + +
); }; diff --git a/src/components/Claim/CreateSource/BaseSourceForm.tsx b/src/components/Claim/CreateSource/BaseSourceForm.tsx deleted file mode 100644 index dc61936b6..000000000 --- a/src/components/Claim/CreateSource/BaseSourceForm.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import React, { useState } from "react"; -import { Checkbox, Col, Form, Row } from "antd"; -import { useTranslation } from "next-i18next"; -import { useRouter } from "next/router"; - -import colors from "../../../styles/colors"; -import AletheiaCaptcha from "../../AletheiaCaptcha"; -import Input from "../../AletheiaInput"; -import Button, { ButtonType } from "../../Button"; -import TextArea from "../../TextArea"; -import { SelectInput } from "../../Form/ClaimReviewSelect"; -import { Option } from "antd/lib/mentions"; -import ClassificationText from "../../ClassificationText"; - -interface BaseSourceFormProps { - handleSubmit: (values: any) => void; - disableFutureDates?: boolean; - isLoading: boolean; - disclaimer?: string; - dateExtraText: string; -} - -const BaseSourceForm = ({ - handleSubmit, - isLoading, - disclaimer, -}: BaseSourceFormProps) => { - const { t } = useTranslation(); - const router = useRouter(); - const [disableSubmit, setDisableSubmit] = useState(true); - const [recaptcha, setRecaptcha] = useState(""); - - const onChangeCaptcha = (captchaString) => { - setRecaptcha(captchaString); - const hasRecaptcha = !!captchaString; - setDisableSubmit(!hasRecaptcha); - }; - - const onFinish = (values) => { - handleSubmit({ ...values, recaptcha }); - }; - - return ( -
- - - - - - - - - -