diff --git a/.github/workflows/back.yml b/.github/workflows/back.yml index f9994cc..a4740dd 100644 --- a/.github/workflows/back.yml +++ b/.github/workflows/back.yml @@ -5,6 +5,7 @@ on: branches: - main - dev + - AW-42-documentation-tests-cicd paths: - "api/**" pull_request: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d62d8d5..0237467 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,7 +4,6 @@ on: push: branches: - main - - AW-35-Tests-unitaires jobs: deploy: diff --git a/.github/workflows/front.yml b/.github/workflows/front.yml index 0a989c2..6e440ed 100644 --- a/.github/workflows/front.yml +++ b/.github/workflows/front.yml @@ -5,6 +5,7 @@ on: branches: - main - dev + - AW-42-documentation-tests-cicd paths: - "flutter_area/**" tags: @@ -35,8 +36,13 @@ jobs: - name: Build APK run: | + touch .env + echo GITHUB_CLIENT_ID='${{ secrets.GTHUB_CLIENT_ID }}' >> .env + echo GITHUB_CLIENT_SECRET='${{ secrets.GTHUB_CLIENT_SECRET }}' >> .env + cat .env flutter build apk --release mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/maker.apk + keytool -printcert -jarfile build/app/outputs/flutter-apk/maker.apk > build/app/outputs/flutter-apk/sha_key_google.txt working-directory: ./flutter_area - name: Upload APK @@ -45,6 +51,12 @@ jobs: name: maker.apk path: ./flutter_area/build/app/outputs/flutter-apk/maker.apk + - name: Upload key + uses: actions/upload-artifact@v3.1.3 + with: + name: sha_key.txt + path: ./flutter_area/build/app/outputs/flutter-apk/sha_key_google.txt + - name: Publish Tagged Release uses: softprops/action-gh-release@v1 if: ${{ startsWith(github.ref, 'refs/tags/') }} diff --git a/README.md b/README.md index bb00261..0ba6726 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,21 @@ - [Installation from source](#installation-from-source) - [Using the application](#using-the-application) - [Troubleshooting](#troubleshooting) +- [Documentation](#documentation) - [Credits](#credits) - [License](#license) ## Requirements -- Node.js -- Npm -- Flutter +| Language / Tools | Link | Description | +|:---:|:---:|:---:| +| [![My Skills](https://skillicons.dev/icons?i=docker)](Docker) | [**Docker**](https://www.docker.com/) | Docker is used to containerize the application, the api and the database. | +| [![My Skills](https://skillicons.dev/icons?i=mongodb)](MongoDB) | [**MongoDB**](https://www.mongodb.com/) | MongoDB is used as the database for the application. (not mandatory if using Docker) | +| [![My Skills](https://skillicons.dev/icons?i=nodejs)](NodeJS) | [**NodeJS**](https://nodejs.org/en/) | NodeJS is used to run the application. (not mandatory if using Docker) | +| [![My Skills](https://skillicons.dev/icons?i=typescript)](TypeScript) | [**TypeScript**](https://www.typescriptlang.org/) | TypeScript is used to write the application. | +| [![My Skills](https://skillicons.dev/icons?i=flutter)](Flutter) | [**Flutter**](https://flutter.dev/) | Flutter is used to write the mobile application. | +| [![My Skills](https://skillicons.dev/icons?i=git)](Git) | [**Git**](https://git-scm.com/) | Git is used for version control. | +--- ## Communication @@ -23,10 +30,10 @@ ## Services -- [x] Timer -> 2 actions -> Recurring / One time -- [x] Weather -> 1 action -> OnGetWeather -- [x] Nasa -> 1 action -> OnGetPicture -- [x] GMail -> 2 actions -> OnNewMail / OnNewMailFrom / WriteMail +- [x] Timer +- [x] Weather +- [x] Nasa +- [x] GMail - [x] Github - [x] Slack @@ -40,15 +47,24 @@ git clone git@github.com:UwUClub/AwArea.git ## Using the application -### Server +### Docker + +- use in the root directory +```bash +docker-compose up --build +``` + +### Manual + +#### Server First start the server ```bash -cd api && npm install && npmstart +cd api && npm install && npm start ``` -### Client +#### Client Then start the client @@ -66,13 +82,18 @@ rm {your_flutter_directory}/bin/cache/flutter_tools.stamp And add `'--disable-web-security'` under `'--disable-extensions'` in `{your_flutter_directory}/packages/flutter_tools/lib/src/web/chrome.dart` +## Documentation + +- [User documentation to use Slack](doc/user/HowToMakeSlackApp.md) +- [Developer](doc/developer/Introduction.md) + ## Credits - Valentin Gegoux -- Baptiste Laran - Quentin Challon - Luca Deltort - Maxence Labourel +- Baptiste Laran ## License diff --git a/api/.eslintrc.js b/api/.eslintrc.js index 259de13..1841b8b 100644 --- a/api/.eslintrc.js +++ b/api/.eslintrc.js @@ -6,10 +6,7 @@ module.exports = { sourceType: 'module', }, plugins: ['@typescript-eslint/eslint-plugin'], - extends: [ - 'plugin:@typescript-eslint/recommended', - 'plugin:prettier/recommended', - ], + extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], root: true, env: { node: true, diff --git a/api/.prettierrc b/api/.prettierrc index e1f57d1..90412f8 100644 --- a/api/.prettierrc +++ b/api/.prettierrc @@ -1,6 +1,7 @@ { - "singleQuote": true, - "trailingComma": "all", - "tabWidth": 4, - "max_line_length": 120 -} \ No newline at end of file + "singleQuote": true, + "trailingComma": "all", + "tabWidth": 2, + "max_line_length": 120, + "printWidth": 120 +} diff --git a/api/package-lock.json b/api/package-lock.json index a959486..6c90008 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -20,6 +20,7 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.1.16", + "@slack/web-api": "^6.11.2", "axios": "^1.6.2", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", @@ -1489,11 +1490,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jonkemp/package-utils": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@jonkemp/package-utils/-/package-utils-1.0.8.tgz", - "integrity": "sha512-bIcKnH5YmtTYr7S6J3J86dn/rFiklwRpOqbTOQ9C0WMmR9FKHVb3bxs2UYfqEmNb93O4nbA97sb6rtz33i9SyA==" - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", @@ -1621,74 +1617,31 @@ } }, "node_modules/@nestjs-modules/mailer": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@nestjs-modules/mailer/-/mailer-1.9.1.tgz", - "integrity": "sha512-9kSDgg4qA6+2BXOzfY4IltL70uMGXDeE8u/dhkzM2gnCCOKu8Y+wIxWmh8xyLGYcrFHQ3Mke+ap0O1T98Tyjaw==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@nestjs-modules/mailer/-/mailer-1.10.3.tgz", + "integrity": "sha512-k2gs2NH8Ygq4JnETX+EDBXixLAS8DDZEI/Wbr9LGL3HwO3Qz8zVh8dBJ4ESpySuWniW+a8rARzGXtTUHC4KFlw==", "dependencies": { - "glob": "10.3.3", - "inline-css": "4.0.2", - "mjml": "^4.14.1", + "css-inline": "0.11.2", + "glob": "10.3.10", + "mjml": "4.14.1", "preview-email": "3.0.19" }, "optionalDependencies": { - "@types/ejs": "^3.1.2", - "@types/pug": "2.0.6", + "@types/ejs": "^3.1.5", + "@types/pug": "^2.0.10", "ejs": "^3.1.9", - "handlebars": "^4.7.7", + "handlebars": "^4.7.8", "pug": "^3.0.2" }, "peerDependencies": { - "@nestjs/common": "^7.0.9 || ^8.0.0 || ^9.0.0 || ^10.0.0", - "@nestjs/core": "^7.0.9 || ^8.0.0 || ^9.0.0 || ^10.0.0", - "@types/ejs": "^3.0.3", - "@types/pug": "2.0.6", - "ejs": "^3.1.2", - "handlebars": "^4.7.6", - "nodemailer": "^6.4.6", - "pug": "^3.0.1" - } - }, - "node_modules/@nestjs-modules/mailer/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@nestjs-modules/mailer/node_modules/glob": { - "version": "10.3.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.3.tgz", - "integrity": "sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.0.3", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "bin": { - "glob": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@nestjs-modules/mailer/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "@nestjs/common": ">=7.0.9", + "@nestjs/core": ">=7.0.9", + "@types/ejs": ">=3.0.3", + "@types/pug": ">=2.0.6", + "ejs": ">=3.1.2", + "handlebars": ">=4.7.6", + "nodemailer": ">=6.4.6", + "pug": ">=3.0.1" } }, "node_modules/@nestjs/axios": { @@ -2138,12 +2091,68 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "node_modules/@slack/logger": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-3.0.0.tgz", + "integrity": "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA==", + "dependencies": { + "@types/node": ">=12.0.0" + }, "engines": { - "node": ">= 6" + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/types": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.11.0.tgz", + "integrity": "sha512-UlIrDWvuLaDly3QZhCPnwUSI/KYmV1N9LyhuH6EDKCRS1HWZhyTG3Ja46T3D0rYfqdltKYFXbJSSRPwZpwO0cQ==", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-6.11.2.tgz", + "integrity": "sha512-s4qCQGXasr8jpCf/+6+V/smq+Z2RX7EIBnJeO/xe7Luie1nyBihFMgjICNyvzWoWBdaGntSnn5CcZdFm4ItBWg==", + "dependencies": { + "@slack/logger": "^3.0.0", + "@slack/types": "^2.11.0", + "@types/is-stream": "^1.1.0", + "@types/node": ">=12.0.0", + "axios": "^1.6.5", + "eventemitter3": "^3.1.0", + "form-data": "^2.5.0", + "is-electron": "2.2.2", + "is-stream": "^1.1.0", + "p-queue": "^6.6.1", + "p-retry": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api/node_modules/form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/@slack/web-api/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "engines": { + "node": ">=0.10.0" } }, "node_modules/@tsconfig/node10": { @@ -2326,6 +2335,14 @@ "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", "dev": true }, + "node_modules/@types/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2444,9 +2461,9 @@ } }, "node_modules/@types/pug": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.6.tgz", - "integrity": "sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", + "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", "optional": true }, "node_modules/@types/qs": { @@ -2461,6 +2478,11 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" + }, "node_modules/@types/semver": { "version": "7.5.6", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", @@ -2921,6 +2943,7 @@ "version": "8.11.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -2950,6 +2973,7 @@ "version": "8.3.1", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz", "integrity": "sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==", + "dev": true, "engines": { "node": ">=0.4.0" } @@ -3175,17 +3199,6 @@ "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.2.1.tgz", "integrity": "sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw==" }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/async": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", @@ -3198,11 +3211,11 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", + "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.4", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -3366,11 +3379,6 @@ "node": ">=6.0.0" } }, - "node_modules/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==" - }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -4035,6 +4043,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -4127,7 +4136,8 @@ "node_modules/cookiejar": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==" + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true }, "node_modules/core-util-is": { "version": "1.0.3", @@ -4221,13 +4231,10 @@ "node": ">= 8" } }, - "node_modules/css-rules": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/css-rules/-/css-rules-1.1.0.tgz", - "integrity": "sha512-7L6krLIRwAEVCaVKyCEL6PQjQXUmf8DM9bWYKutlZd0DqOe0SiKIGQOkFb59AjDBb+3If7SDp3X8UlzDAgYSow==", - "dependencies": { - "cssom": "^0.5.0" - } + "node_modules/css-inline": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/css-inline/-/css-inline-0.11.2.tgz", + "integrity": "sha512-c/oie5Yqa2lVRwUO7A8nd3c3r0x7yE6MQH2PPB/R1LaUb6ohZD7vNXj23fod5y4QNsNhsQi98/AWfUwo1K6R7g==" }, "node_modules/css-select": { "version": "5.1.0", @@ -4255,19 +4262,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==" - }, - "node_modules/data-uri-to-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz", - "integrity": "sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==", - "engines": { - "node": ">= 6" - } - }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -4309,7 +4303,8 @@ "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true }, "node_modules/deepmerge": { "version": "4.3.1", @@ -4506,20 +4501,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/degenerator": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-3.0.4.tgz", - "integrity": "sha512-Z66uPeBfHZAHVmue3HPfyKu2Q0rC2cRxbTOsvmU/po5fvvcx27W4mIu9n0PUlQih4oUYvcG1BsbtVv8x7KDOSw==", - "dependencies": { - "ast-types": "^0.13.2", - "escodegen": "^1.8.1", - "esprima": "^4.0.0", - "vm2": "^3.9.17" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -4583,6 +4564,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, "dependencies": { "asap": "^2.0.0", "wrappy": "1" @@ -5049,91 +5031,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", - "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^4.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=4.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/escodegen/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/escodegen/node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dependencies": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/escodegen/node_modules/type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "dependencies": { - "prelude-ls": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/eslint": { "version": "8.55.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.55.0.tgz", @@ -5313,6 +5210,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -5358,6 +5256,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -5370,6 +5269,11 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -5553,17 +5457,6 @@ "node": ">=4" } }, - "node_modules/extract-css": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/extract-css/-/extract-css-3.0.1.tgz", - "integrity": "sha512-mLNcMxYX7JVPcGUw7pgjczasLnvimYGlXFWuSx2YQ421sZDlBq4Dh0UzsSeXutf80Z0P2BtV5ZZt0FbaWTOxsQ==", - "dependencies": { - "batch": "^0.6.1", - "href-content": "^2.0.2", - "list-stylesheets": "^2.0.1", - "style-data": "^2.0.1" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5601,7 +5494,8 @@ "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true }, "node_modules/fast-safe-stringify": { "version": "2.1.1", @@ -5662,14 +5556,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-2.0.0.tgz", - "integrity": "sha512-hjPFI8oE/2iQPVe4gbrJ73Pp+Xfub2+WI2LlXDbsaJBwT5wuMh35WNWVYYTpnz895shtwfyutMFLFywpQAFdLg==", - "engines": { - "node": ">= 6" - } - }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -5834,11 +5720,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/flat-util": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/flat-util/-/flat-util-1.1.9.tgz", - "integrity": "sha512-BOTMw/6rbbxVjv5JQvwgGMc2/6wWGd2VeyTvnzvvE49VRjS0tTxLbry/QVP1yPw8SaAOBYsnixmzruXoqjdUHA==" - }, "node_modules/flatted": { "version": "3.2.9", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", @@ -5846,9 +5727,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", "funding": [ { "type": "individual", @@ -5924,6 +5805,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==", + "dev": true, "dependencies": { "dezalgo": "^1.0.4", "hexoid": "^1.0.0", @@ -6015,39 +5897,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/ftp": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz", - "integrity": "sha512-faFVML1aBx2UoDStmLwv2Wptt4vw5x03xxX172nhA5Y5HBshW5JweqQ2W4xL4dezQTG8inJsuYcpPHHU3X5OTQ==", - "dependencies": { - "readable-stream": "1.1.x", - "xregexp": "2.0.0" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/ftp/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" - }, - "node_modules/ftp/node_modules/readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "node_modules/ftp/node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6210,51 +6059,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-uri": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-3.0.2.tgz", - "integrity": "sha512-+5s0SJbGoyiJTZZ2JTpFPLMPSch72KEqGOTvQsBqg0RBWvwhWUSYZFAtz3TPW0GXJuLBJPts1E241iHg+VRfhg==", - "dependencies": { - "@tootallnate/once": "1", - "data-uri-to-buffer": "3", - "debug": "4", - "file-uri-to-path": "2", - "fs-extra": "^8.1.0", - "ftp": "^0.3.10" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/get-uri/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/get-uri/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/get-uri/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/glob": { "version": "10.3.10", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", @@ -6439,7 +6243,8 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true }, "node_modules/graphemer": { "version": "1.4.0", @@ -6600,18 +6405,11 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/href-content": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/href-content/-/href-content-2.0.2.tgz", - "integrity": "sha512-f/e40VYI+KciPGfFzfdw1wu8dptpUA9rYQJNbpYVRI217lyuo7nBNO7BjYfTiQMhU/AthfvPDMvj46uAgzUccQ==", - "dependencies": { - "remote-content": "^3.0.1" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -6691,19 +6489,6 @@ "node": ">= 0.8" } }, - "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -6828,23 +6613,6 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, - "node_modules/inline-css": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/inline-css/-/inline-css-4.0.2.tgz", - "integrity": "sha512-o8iZBpVRCs+v8RyEWKxB+4JRi6A4Wop6f3zzqEi0xVx2eIevbgcjXIKYDmQR2ZZ+DD5IVZ6JII0dt2GhJh8etw==", - "dependencies": { - "cheerio": "^1.0.0-rc.12", - "css-rules": "^1.1.0", - "extract-css": "^3.0.1", - "flat-util": "^1.1.9", - "pick-util": "^1.1.5", - "slick": "^1.12.2", - "specificity": "^0.4.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/inquirer": { "version": "8.2.6", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", @@ -6880,11 +6648,6 @@ "node": ">= 0.10" } }, - "node_modules/ip": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.8.tgz", - "integrity": "sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==" - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -6936,6 +6699,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==" + }, "node_modules/is-expression": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", @@ -8180,15 +7948,6 @@ "uc.micro": "^1.0.1" } }, - "node_modules/list-stylesheets": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/list-stylesheets/-/list-stylesheets-2.0.1.tgz", - "integrity": "sha512-UUEFowqvgRKT1+OJ59Ga5gTfVOP3hkbFo7DwNIZcMuXzJRWndYMHyDYbuqKe6lrw8KCY7c/GN5mEoLx0c54HAw==", - "dependencies": { - "cheerio": "1.0.0-rc.12", - "pick-util": "^1.1.5" - } - }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -8290,6 +8049,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "dependencies": { "yallist": "^3.0.2" } @@ -8438,14 +8198,6 @@ "node": ">= 0.6" } }, - "node_modules/mediaquery-text": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mediaquery-text/-/mediaquery-text-1.2.0.tgz", - "integrity": "sha512-cJyRqgYQi+hsYhRkyd5le0s4LsEPvOB7r+6X3jdEELNqVlM9mRIgyUPg9BzF+PuTqQH1ZekgIjYVOeWSXWq35Q==", - "dependencies": { - "cssom": "^0.5.0" - } - }, "node_modules/memfs": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", @@ -9260,14 +9012,6 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "devOptional": true }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -9605,6 +9349,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-timeout": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", @@ -9639,38 +9415,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pac-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-5.0.0.tgz", - "integrity": "sha512-CcFG3ZtnxO8McDigozwE3AqAw15zDvGH+OjXO4kzf7IkEKkQ4gxQ+3sdF50WmhQ4P/bVusXcqNE2S3XrNURwzQ==", - "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4", - "get-uri": "3", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "5", - "pac-resolver": "^5.0.0", - "raw-body": "^2.2.0", - "socks-proxy-agent": "5" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/pac-resolver": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-5.0.1.tgz", - "integrity": "sha512-cy7u00ko2KVgBAjuhevqpPeHIkCIqPe1v24cydhWjmeuzaBfmUWFCZJ1iAh5TuVzVZoUzXIW7K8sMYOZ84uZ9Q==", - "dependencies": { - "degenerator": "^3.0.2", - "ip": "^1.1.5", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/param-case": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz", @@ -9893,14 +9637,6 @@ "url": "https://ko-fi.com/killymxi" } }, - "node_modules/pick-util": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/pick-util/-/pick-util-1.1.5.tgz", - "integrity": "sha512-H0MaM8T7wpQ/azvB12ChZw7kpSFzjsgv3Z+N7fUWnL1McTGSEeroCngcK4eOPiFQq08rAyKX3hadcAB1kUqfXA==", - "dependencies": { - "@jonkemp/package-utils": "^1.0.8" - } - }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -10155,24 +9891,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-5.0.0.tgz", - "integrity": "sha512-gkH7BkvLVkSfX9Dk27W6TyNOWWZWRilRfk1XxGNWOYJ2TuedAv1yFpCaU9QSBmBe716XOTNpYNOzhysyw8xn7g==", - "dependencies": { - "agent-base": "^6.0.0", - "debug": "4", - "http-proxy-agent": "^4.0.0", - "https-proxy-agent": "^5.0.0", - "lru-cache": "^5.1.1", - "pac-proxy-agent": "^5.0.0", - "proxy-from-env": "^1.0.0", - "socks-proxy-agent": "^5.0.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -10477,16 +10195,6 @@ "node": ">= 0.10" } }, - "node_modules/remote-content": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/remote-content/-/remote-content-3.0.1.tgz", - "integrity": "sha512-zEMsvb4GgxVKBBTHgy2tte67RYBZx2Kyg9mTYpg+JfATHDqYJqhuC3zG1VoiYhDVP5JaB5+mPKcAvdnT0n3jxA==", - "dependencies": { - "proxy-from-env": "^1.1.0", - "superagent": "^8.0.9", - "superagent-proxy": "^3.0.0" - } - }, "node_modules/repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", @@ -10587,6 +10295,14 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -11035,6 +10751,8 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "optional": true, + "peer": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -11044,6 +10762,8 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "optional": true, + "peer": true, "dependencies": { "ip": "^2.0.0", "smart-buffer": "^4.2.0" @@ -11053,23 +10773,12 @@ "npm": ">= 3.0.0" } }, - "node_modules/socks-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-5.0.1.tgz", - "integrity": "sha512-vZdmnjb9a2Tz6WEQVIurybSwElwPxMZaIc7PzqbJTrezcKNznv6giT7J7tZDZ1BojVaa1jvO/UiUdhDVB0ACoQ==", - "dependencies": { - "agent-base": "^6.0.2", - "debug": "4", - "socks": "^2.3.3" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/socks/node_modules/ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" + "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "optional": true, + "peer": true }, "node_modules/source-map": { "version": "0.7.4", @@ -11107,14 +10816,6 @@ "memory-pager": "^1.0.2" } }, - "node_modules/specificity": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/specificity/-/specificity-0.4.1.tgz", - "integrity": "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg==", - "bin": { - "specificity": "bin/specificity" - } - }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -11272,20 +10973,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/style-data": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/style-data/-/style-data-2.0.1.tgz", - "integrity": "sha512-frUbteLGDoNEJhbMIWtyNE1VRduZXmZozhct4F+qN++OzIQZNZJ8KToZlDEl3eaedRYlDfKvUoMFMyrZj4x/sg==", - "dependencies": { - "cheerio": "^1.0.0-rc.12", - "mediaquery-text": "^1.2.0", - "pick-util": "^1.1.5" - } - }, "node_modules/superagent": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", + "dev": true, "dependencies": { "component-emitter": "^1.3.0", "cookiejar": "^2.1.4", @@ -11302,25 +10994,11 @@ "node": ">=6.4.0 <13 || >=14" } }, - "node_modules/superagent-proxy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/superagent-proxy/-/superagent-proxy-3.0.0.tgz", - "integrity": "sha512-wAlRInOeDFyd9pyonrkJspdRAxdLrcsZ6aSnS+8+nu4x1aXbz6FWSTT9M6Ibze+eG60szlL7JA8wEIV7bPWuyQ==", - "dependencies": { - "debug": "^4.3.2", - "proxy-agent": "^5.0.0" - }, - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "superagent": ">= 0.15.4 || 1 || 2 || 3" - } - }, "node_modules/superagent/node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, "bin": { "mime": "cli.js" }, @@ -12065,22 +11743,6 @@ "node": ">= 0.8" } }, - "node_modules/vm2": { - "version": "3.9.19", - "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.19.tgz", - "integrity": "sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==", - "deprecated": "The library contains critical security issues and should not be used for production! The maintenance of the project has been discontinued. Consider migrating your code to isolated-vm.", - "dependencies": { - "acorn": "^8.7.0", - "acorn-walk": "^8.2.0" - }, - "bin": { - "vm2": "bin/vm2" - }, - "engines": { - "node": ">=6.0" - } - }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -12448,14 +12110,6 @@ "node": ">= 10.0.0" } }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -12517,14 +12171,6 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, - "node_modules/xregexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz", - "integrity": "sha512-xl/50/Cf32VsGq/1R8jJE5ajH1yMCQkpmoS10QbFZWl2Oor4H0Me64Pu2yxvsRWK3m6soJbmGfzSR7BYmDcWAA==", - "engines": { - "node": "*" - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -12544,7 +12190,8 @@ "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true }, "node_modules/yargs": { "version": "17.7.2", diff --git a/api/package.json b/api/package.json index 98284ca..07eca45 100644 --- a/api/package.json +++ b/api/package.json @@ -31,6 +31,7 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.1.16", + "@slack/web-api": "^6.11.2", "axios": "^1.6.2", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", diff --git a/api/src/_utils/config.ts b/api/src/_utils/config.ts index 94209ca..2c8db1b 100644 --- a/api/src/_utils/config.ts +++ b/api/src/_utils/config.ts @@ -4,45 +4,48 @@ import { Logger } from '@nestjs/common'; import { exit } from 'process'; export class EnvironmentVariables { - @IsString() - JWT_SECRET: string = 'secret'; + @IsString() + JWT_SECRET: string = 'secret'; - @IsString() - MONGO_URI: string = 'mongodb://mongo/awarea'; + @IsString() + MONGO_URI: string = 'mongodb://mongo/awarea'; - @IsString() - WEATHER_KEY: string = 'c4b'; + @IsString() + WEATHER_KEY: string = 'c4b'; - @IsString() - NASA_API_KEY: string = 'c4b'; + @IsString() + NASA_API_KEY: string = 'c4b'; - @IsString() - GITHUB_CLIENT_ID: string = 'c4b'; + @IsString() + GITHUB_CLIENT_ID: string = 'c4b'; - @IsString() - GITHUB_CLIENT_SECRET: string = 'c4b'; + @IsString() + GITHUB_CLIENT_SECRET: string = 'c4b'; - @IsString() - GOOGLE_CLIENT_ID: string = 'c4b'; + @IsString() + GOOGLE_CLIENT_ID: string = 'c4b'; - @IsString() - GOOGLE_CLIENT_SECRET: string = 'c4b'; + @IsString() + GOOGLE_CLIENT_SECRET: string = 'c4b'; - @IsString() - GOOGLE_CALLBACK_URL: string = 'c4b'; + @IsString() + GOOGLE_CALLBACK_URL: string = 'c4b'; + + @IsString() + GITHUB_CALLBACK_URL: string = 'c4b'; } export function validateEnv(config: Record) { - const validatedConfig = plainToInstance(EnvironmentVariables, config, { - enableImplicitConversion: true, - }); - const errors = validateSync(validatedConfig, { - skipMissingProperties: false, - }); - - if (errors.length) { - new Logger(validateEnv.name).error(errors.toString()); - exit(); - } - return validatedConfig; + const validatedConfig = plainToInstance(EnvironmentVariables, config, { + enableImplicitConversion: true, + }); + const errors = validateSync(validatedConfig, { + skipMissingProperties: false, + }); + + if (errors.length) { + new Logger(validateEnv.name).error(errors.toString()); + exit(); + } + return validatedConfig; } diff --git a/api/src/_utils/decorators/is-secure-password.decorator.ts b/api/src/_utils/decorators/is-secure-password.decorator.ts index 63800c9..cc8b6ac 100644 --- a/api/src/_utils/decorators/is-secure-password.decorator.ts +++ b/api/src/_utils/decorators/is-secure-password.decorator.ts @@ -3,18 +3,17 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsStrongPassword } from 'class-validator'; export function IsSecurePassword() { - return applyDecorators( - ApiProperty({ - example: 'Test1234**', - description: - 'Minimum ten characters, at least one letter, two numbers and one special character.', - }), - IsStrongPassword({ - minLowercase: 1, - minLength: 10, - minNumbers: 2, - minUppercase: 1, - minSymbols: 1, - }), - ); + return applyDecorators( + ApiProperty({ + example: 'Test1234**', + description: 'Minimum ten characters, at least one letter, two numbers and one special character.', + }), + IsStrongPassword({ + minLowercase: 1, + minLength: 10, + minNumbers: 2, + minUppercase: 1, + minSymbols: 1, + }), + ); } diff --git a/api/src/about/about.controller.ts b/api/src/about/about.controller.ts index bb7ca5f..b84cabd 100644 --- a/api/src/about/about.controller.ts +++ b/api/src/about/about.controller.ts @@ -4,13 +4,13 @@ import { ApiTags } from '@nestjs/swagger'; import { Get } from '@nestjs/common'; import { Request } from 'express'; -@ApiTags('about') +@ApiTags('About') @Controller('about.json') export class AboutController { constructor(private readonly aboutService: AboutService) {} @Get() async getAbout(@Req() request: Request) { - return this.aboutService.getAbout(request.ip); + return this.aboutService.getAbout(request.headers.host); } } diff --git a/api/src/about/about.service.ts b/api/src/about/about.service.ts index 3435173..f0d3f26 100644 --- a/api/src/about/about.service.ts +++ b/api/src/about/about.service.ts @@ -2,35 +2,110 @@ import { Injectable } from '@nestjs/common'; @Injectable() export class AboutService { - getAbout(ipAddress: string) { - return { - client: { - host: ipAddress, - }, - server: { - current_time: new Date().getTime(), - services: [ - { - name: 'weather', - actions: [ - { - name: 'Get Meteo', - description: 'Get the meteo of the location', - }, - ], - }, - { - name: 'Naza', - actions: [ - { - name: 'Get photo of the day', - description: - 'Get the photo of the day posted every day by Naza', - }, - ], - }, - ], - }, - }; - } + getAbout(ipAddress: string) { + return { + client: { + host: ipAddress, + }, + server: { + current_time: new Date().getTime(), + services: [ + { + name: 'weather', + actions: [ + { + name: 'Get Meteo', + description: 'Get the meteo of the location', + }, + ], + }, + { + name: 'Naza', + actions: [ + { + name: 'Get photo of the day', + description: 'Get the photo of the day posted every day by Naza', + }, + ], + }, + { + name: 'Github', + actions: [ + { + name: 'Pull request creation', + description: 'Create a pull request on a specific repository', + }, + { + name: 'Issue creation', + description: 'Create an issue on a specific repository', + }, + { + name: 'Star added', + description: 'Add a star on a specific repository', + }, + { + name: 'Star removed', + description: 'Trigger when a star is remove on a specific repository', + }, + { + name: 'branch merged', + description: 'Trigger when a branch is merge on a specific repository', + }, + { + name: 'Pull request review requested', + description: 'Trigger when a pull request is review requested on a specific repository', + }, + { + name: 'Pull request review removed', + description: 'Trigger when a pull request is review removed on a specific repository', + }, + { + name: 'Branch created', + description: 'Trigger when a branch is created on a specific repository', + }, + { + name: 'Branch deleted', + description: 'Trigger when a branch is deleted on a specific repository', + }, + ], + }, + { + name: 'Timer', + actions: [ + { + name: 'On this date', + description: 'Trigger when the date is reached', + }, + ], + }, + { + name: 'slack', + reactions: [ + { + name: 'Send message', + description: 'Send a message on slack', + }, + { + name: 'Create channel', + description: 'Create a channel on slack', + }, + ], + }, + { + name: 'Google', + reactions: [ + { + name: 'Create draft', + description: 'Create a draft on gmail', + }, + { + name: 'Send email', + description: 'Create and send an email with gmail', + }, + ], + }, + ], + }, + }; + } } diff --git a/api/src/action-reaction/_utils/dto/request/create-action-reaction.dto.ts b/api/src/action-reaction/_utils/dto/request/create-action-reaction.dto.ts index 70b18e6..a6479af 100644 --- a/api/src/action-reaction/_utils/dto/request/create-action-reaction.dto.ts +++ b/api/src/action-reaction/_utils/dto/request/create-action-reaction.dto.ts @@ -1,6 +1,6 @@ import { IsString } from 'class-validator'; export class CreateActionReactionDto { - @IsString() - name: string; + @IsString() + name: string; } diff --git a/api/src/action-reaction/_utils/dto/request/create-mvp-action-reaction.dto.ts b/api/src/action-reaction/_utils/dto/request/create-mvp-action-reaction.dto.ts index d1b37a5..e1ce83f 100644 --- a/api/src/action-reaction/_utils/dto/request/create-mvp-action-reaction.dto.ts +++ b/api/src/action-reaction/_utils/dto/request/create-mvp-action-reaction.dto.ts @@ -1,9 +1,9 @@ import { IsEmail, IsString } from 'class-validator'; export class CreateMvpActionReactionDto { - @IsString() - name: string; + @IsString() + name: string; - @IsEmail() - email: string; + @IsEmail() + email: string; } diff --git a/api/src/action-reaction/_utils/dto/request/update-action-reaction.dto.ts b/api/src/action-reaction/_utils/dto/request/update-action-reaction.dto.ts index 41d76e7..773df90 100644 --- a/api/src/action-reaction/_utils/dto/request/update-action-reaction.dto.ts +++ b/api/src/action-reaction/_utils/dto/request/update-action-reaction.dto.ts @@ -1,12 +1,15 @@ -import { IsOptional, IsString } from 'class-validator'; -import { Optional } from '@nestjs/common'; +import { IsBoolean, IsOptional, IsString } from 'class-validator'; export class UpdateActionReactionDto { - @IsOptional() - @IsString() - actionId?: string; + @IsOptional() + @IsString() + actionId?: string; - @IsOptional() - @IsString() - reactionId?: string; + @IsOptional() + @IsString() + reactionId?: string; + + @IsOptional() + @IsBoolean() + isActivated?: boolean; } diff --git a/api/src/action-reaction/_utils/dto/response/get-action-reaction.dto.ts b/api/src/action-reaction/_utils/dto/response/get-action-reaction.dto.ts index 8d852bf..fd9829a 100644 --- a/api/src/action-reaction/_utils/dto/response/get-action-reaction.dto.ts +++ b/api/src/action-reaction/_utils/dto/response/get-action-reaction.dto.ts @@ -1,6 +1,6 @@ export class GetActionReactionDto { - id: string; - name: string; - action: any; - reaction: any; + id: string; + name: string; + action: any; + reaction: any; } diff --git a/api/src/action-reaction/_utils/pipes/get-action-reaction.pipe.ts b/api/src/action-reaction/_utils/pipes/get-action-reaction.pipe.ts new file mode 100644 index 0000000..08f79d7 --- /dev/null +++ b/api/src/action-reaction/_utils/pipes/get-action-reaction.pipe.ts @@ -0,0 +1,14 @@ +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; +import { ActionReactionRepository } from '../../action-reaction.repository'; + +@Injectable() +export class GetActionReaction implements PipeTransform { + constructor(private readonly actionReactionRepository: ActionReactionRepository) {} + + async transform(actionReactionId: string) { + const actionReaction = await this.actionReactionRepository.getActionReactionByIdNotConnected(actionReactionId); + if (!actionReaction) throw new BadRequestException('Action reaction does not exist'); + + return actionReaction; + } +} diff --git a/api/src/action-reaction/action-reaction.controller.ts b/api/src/action-reaction/action-reaction.controller.ts index 261301d..f22d50f 100644 --- a/api/src/action-reaction/action-reaction.controller.ts +++ b/api/src/action-reaction/action-reaction.controller.ts @@ -1,60 +1,77 @@ -import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, NotImplementedException, Param, Patch, Post } from '@nestjs/common'; import { ActionReactionService } from './action-reaction.service'; import { Protect } from '../auth/_utils/decorators/protect.decorator'; import { ConnectedUser } from '../auth/_utils/decorators/connected-user.decorator'; import { UserDocument } from '../users/users.schema'; import { CreateActionReactionDto } from './_utils/dto/request/create-action-reaction.dto'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiParam, ApiTags } from '@nestjs/swagger'; import { UpdateActionReactionDto } from './_utils/dto/request/update-action-reaction.dto'; -import { CreateMvpActionReactionDto } from './_utils/dto/request/create-mvp-action-reaction.dto'; +import { CreateActionDto } from '../actions/_utils/dto/request/create-action.dto'; +import { GetActionReaction } from './_utils/pipes/get-action-reaction.pipe'; +import { ActionReactionDocument } from './action-reaction.schema'; +import { CreateReactionDto } from '../reactions/_utils/dto/request/create-reaction.dto'; @ApiTags('Action Reaction') @Controller('action-reaction') export class ActionReactionController { - constructor( - private readonly actionReactionService: ActionReactionService, - ) {} - - @Protect() - @Get() - getActionReactions(@ConnectedUser() user: UserDocument) { - return this.actionReactionService.getActionReactions(user); - } - - @Protect() - @Post() - createActionReaction( - @ConnectedUser() user: UserDocument, - @Body() body: CreateActionReactionDto, - ) { - return this.actionReactionService.createActionReaction(user, body); - } - - @Protect() - @Post('mvp') - createMvpActionReaction( - @ConnectedUser() user: UserDocument, - @Body() body: CreateMvpActionReactionDto, - ) { - return this.actionReactionService.createMvpActionReaction(user, body); - } - - @Protect() - @Get(':action_reaction_id') - getActionReaction( - @Param('action_reaction_id') id: string, - @ConnectedUser() user: UserDocument, - ) { - return this.actionReactionService.getActionReactionById(id, user); - } - - @Protect() - @Patch(':action_reaction_id') - updateActionReaction( - @Param('action_reaction_id') id: string, - @Body() body: UpdateActionReactionDto, - @ConnectedUser() user: UserDocument, - ) { - return this.actionReactionService.updateActionReaction(user, id, body); - } + constructor(private readonly actionReactionService: ActionReactionService) {} + + @Protect() + @Get() + getActionReactions(@ConnectedUser() user: UserDocument) { + return this.actionReactionService.getActionReactions(user); + } + + @Protect() + @Post() + createActionReaction(@ConnectedUser() user: UserDocument, @Body() body: CreateActionReactionDto) { + return this.actionReactionService.createActionReaction(user, body); + } + + @Protect() + @Get(':action_reaction_id') + @ApiParam({ name: 'action_reaction_id', type: String }) + getActionReaction(@Param('action_reaction_id') id: string, @ConnectedUser() user: UserDocument) { + return this.actionReactionService.getActionReactionById(id, user); + } + + @Protect() + @Post(':action_reaction_id/action') + @ApiParam({ name: 'action_reaction_id', type: String }) + createAction( + @Param('action_reaction_id', GetActionReaction) + actionReaction: ActionReactionDocument, + @ConnectedUser() user: UserDocument, + @Body() body: CreateActionDto, + ) { + return this.actionReactionService.createAction(user, body, actionReaction); + } + + @Protect() + @Post(':action_reaction_id/reaction') + @ApiParam({ name: 'action_reaction_id', type: String }) + createReaction( + @Param('action_reaction_id', GetActionReaction) + actionReaction: ActionReactionDocument, + @ConnectedUser() user: UserDocument, + @Body() body: CreateReactionDto, + ) { + return this.actionReactionService.createReaction(user, body, actionReaction); + } + + @Protect() + @Patch(':action_reaction_id') + updateActionReaction( + @Param('action_reaction_id') id: string, + @Body() body: UpdateActionReactionDto, + @ConnectedUser() user: UserDocument, + ) { + return this.actionReactionService.updateActionReaction(user, id, body); + } + + @Protect() + @Delete(':action_reaction_id') + deleteActionReaction(@Param('action_reaction_id') id: string, @ConnectedUser() user: UserDocument) { + return this.actionReactionService.removeActionReaction(id, user); + } } diff --git a/api/src/action-reaction/action-reaction.mapper.ts b/api/src/action-reaction/action-reaction.mapper.ts index 0f5b953..b716da4 100644 --- a/api/src/action-reaction/action-reaction.mapper.ts +++ b/api/src/action-reaction/action-reaction.mapper.ts @@ -4,12 +4,10 @@ import { GetActionReactionDto } from './_utils/dto/response/get-action-reaction. @Injectable() export class ActionReactionMapper { - toGetActionReactionDto = ( - actionReaction: ActionReactionDocument, - ): GetActionReactionDto => ({ - id: actionReaction._id.toString(), - name: actionReaction.actionReactionName, - action: actionReaction.action, - reaction: actionReaction.reaction, - }); + toGetActionReactionDto = (actionReaction: ActionReactionDocument): GetActionReactionDto => ({ + id: actionReaction._id.toString(), + name: actionReaction.actionReactionName, + action: actionReaction.action, + reaction: actionReaction.reaction, + }); } diff --git a/api/src/action-reaction/action-reaction.module.ts b/api/src/action-reaction/action-reaction.module.ts index feb8459..67e8e43 100644 --- a/api/src/action-reaction/action-reaction.module.ts +++ b/api/src/action-reaction/action-reaction.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { ActionReactionService } from './action-reaction.service'; import { ActionReactionController } from './action-reaction.controller'; import { MongooseModule } from '@nestjs/mongoose'; @@ -9,22 +9,21 @@ import { ActionsModule } from '../actions/actions.module'; import { ReactionsModule } from '../reactions/reactions.module'; import { WeatherModule } from '../weather/weather.module'; import { GoogleApiModule } from '../google-api/google-api.module'; +import { GithubApiModule } from '../github-api/github-api.module'; +import { TimerModule } from '../timer/timer.module'; @Module({ - imports: [ - MongooseModule.forFeature([ - { name: ActionReaction.name, schema: ActionReactionSchema }, - ]), - ActionsModule, - ReactionsModule, - WeatherModule, - GoogleApiModule, - ], - controllers: [ActionReactionController], - providers: [ - ActionReactionService, - ActionReactionRepository, - ActionReactionMapper, - ], + imports: [ + MongooseModule.forFeature([{ name: ActionReaction.name, schema: ActionReactionSchema }]), + ActionsModule, + ReactionsModule, + WeatherModule, + GoogleApiModule, + TimerModule, + forwardRef(() => GithubApiModule), + ], + controllers: [ActionReactionController], + providers: [ActionReactionService, ActionReactionRepository, ActionReactionMapper], + exports: [ActionReactionRepository], }) export class ActionReactionModule {} diff --git a/api/src/action-reaction/action-reaction.repository.ts b/api/src/action-reaction/action-reaction.repository.ts index a199a06..0dbac85 100644 --- a/api/src/action-reaction/action-reaction.repository.ts +++ b/api/src/action-reaction/action-reaction.repository.ts @@ -1,13 +1,6 @@ -import { - ConflictException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { - ActionReaction, - ActionReactionDocument, -} from './action-reaction.schema'; -import { Model, Types } from 'mongoose'; +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { ActionReaction, ActionReactionDocument } from './action-reaction.schema'; +import { FilterQuery, Model, Types } from 'mongoose'; import { UserDocument } from '../users/users.schema'; import { CreateActionReactionDto } from './_utils/dto/request/create-action-reaction.dto'; import { InjectModel } from '@nestjs/mongoose'; @@ -16,47 +9,70 @@ import { ReactionDocument } from '../reactions/schemas/reactions.schema'; @Injectable() export class ActionReactionRepository { - constructor( - @InjectModel(ActionReaction.name) - private actionReactionModel: Model, - ) {} - - createActionReaction = ( - user: UserDocument, - queryDto: CreateActionReactionDto, - ) => - this.actionReactionModel - .create({ - user: user, - actionReactionName: queryDto.name, - }) - .catch((err) => { - throw new ConflictException(err.message); - }); - - getActionReactions = (userId: Types.ObjectId) => - this.actionReactionModel.find({ user: userId }).exec(); - - getActionReactionByName = (name: string) => - this.actionReactionModel.findOne({ actionReactionName: name }).exec(); - - updateActionReaction = ( - id: Types.ObjectId, - action: ActionDocument | null, - reaction: ReactionDocument | null, - ) => - this.actionReactionModel - .findByIdAndUpdate( - id, - { - ...(action ? { action: action } : {}), - ...(reaction ? { reaction: reaction } : {}), - }, - { new: true }, - ) - .populate('user') - .exec(); - - getActionReactionById = (id: Types.ObjectId, userId: Types.ObjectId) => - this.actionReactionModel.findOne({ _id: id, user: userId }).exec(); + constructor( + @InjectModel(ActionReaction.name) + private actionReactionModel: Model, + ) {} + + createActionReaction = (user: UserDocument, queryDto: CreateActionReactionDto) => + this.actionReactionModel + .create({ + user: user, + actionReactionName: queryDto.name, + }) + .catch((err) => { + throw new ConflictException(err.message); + }); + + getActionReactions = (userId: Types.ObjectId) => this.actionReactionModel.find({ user: userId }).exec(); + + getActionReactionByName = (name: string) => this.actionReactionModel.findOne({ actionReactionName: name }).exec(); + + getActionReactionByFilter = (filter: FilterQuery) => + this.actionReactionModel.aggregate([ + { + $lookup: { + from: 'users', + localField: 'user', + foreignField: '_id', + as: 'user', + }, + }, + { + $match: { isActive: true }, + }, + { + $match: filter, + }, + ]); + + updateActionReaction = ( + id: Types.ObjectId, + action: ActionDocument | null, + reaction: ReactionDocument | null, + isActive: boolean | null, + ) => + this.actionReactionModel + .findByIdAndUpdate( + id, + { + ...(action ? { action: action } : {}), + ...(reaction ? { reaction: reaction } : {}), + ...(isActive !== null ? { isActive: isActive } : {}), + }, + { new: true }, + ) + .populate('user', 'action') + .exec(); + + getActionReactionById = (id: Types.ObjectId, userId: Types.ObjectId) => + this.actionReactionModel.findOne({ _id: id, user: userId }).exec(); + + getActionReactionByIdNotConnected = (id: string) => this.actionReactionModel.findOne({ _id: id }).exec(); + + removeActionReactionById = (id: Types.ObjectId, user: UserDocument) => + this.actionReactionModel + .findOneAndDelete({ _id: id, user: user._id }) + .orFail(new NotFoundException('Action reaction not found')) + .exec(); } diff --git a/api/src/action-reaction/action-reaction.schema.ts b/api/src/action-reaction/action-reaction.schema.ts index 1f16c60..5c08c85 100644 --- a/api/src/action-reaction/action-reaction.schema.ts +++ b/api/src/action-reaction/action-reaction.schema.ts @@ -2,30 +2,33 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { HydratedDocument, Types } from 'mongoose'; import { User, UserDocument } from '../users/users.schema'; import { Action, ActionDocument } from '../actions/schemas/actions.schema'; +import { Reaction, ReactionDocument } from '../reactions/schemas/reactions.schema'; export type ActionReactionDocument = HydratedDocument; @Schema() export class ActionReaction { - @Prop({ - default: null, - ref: Action.name, - type: Types.ObjectId, - }) - action: Types.ObjectId | ActionDocument | null; + @Prop({ + default: null, + ref: Action.name, + type: Types.ObjectId, + }) + action: Types.ObjectId | ActionDocument | null; - @Prop({ default: null }) - reaction: Types.ObjectId | null; + @Prop({ default: null, ref: Reaction.name, type: Types.ObjectId }) + reaction: Types.ObjectId | ReactionDocument | null; - @Prop({ required: true, ref: User.name, type: Types.ObjectId }) - user: Types.ObjectId | UserDocument; + @Prop({ required: true, ref: User.name, type: Types.ObjectId }) + user: Types.ObjectId | UserDocument; - @Prop({ required: true, unique: true }) - actionReactionName: string; + @Prop({ required: true, unique: true }) + actionReactionName: string; - @Prop({ default: null }) - actionReactionSchedule: string | null; + @Prop({ default: null }) + actionReactionSchedule: string | null; + + @Prop({ default: false }) + isActive: boolean; } -export const ActionReactionSchema = - SchemaFactory.createForClass(ActionReaction); +export const ActionReactionSchema = SchemaFactory.createForClass(ActionReaction); diff --git a/api/src/action-reaction/action-reaction.service.ts b/api/src/action-reaction/action-reaction.service.ts index 9e4b5b8..eafcf44 100644 --- a/api/src/action-reaction/action-reaction.service.ts +++ b/api/src/action-reaction/action-reaction.service.ts @@ -1,183 +1,124 @@ -import { - BadRequestException, - ConflictException, - Injectable, - InternalServerErrorException, -} from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, InternalServerErrorException } from '@nestjs/common'; import { UserDocument } from '../users/users.schema'; import { CreateActionReactionDto } from './_utils/dto/request/create-action-reaction.dto'; import { ActionReactionRepository } from './action-reaction.repository'; import { ActionReactionMapper } from './action-reaction.mapper'; import { Types } from 'mongoose'; -import { SchedulerRegistry } from '@nestjs/schedule'; -import { CronJob } from 'cron'; import { UpdateActionReactionDto } from './_utils/dto/request/update-action-reaction.dto'; import { ActionsRepository } from '../actions/actions.repository'; import { ReactionRepository } from '../reactions/reaction.repository'; import { ActionReactionDocument } from './action-reaction.schema'; -import { WeatherService } from '../weather/weather.service'; -import { GoogleApiService } from '../google-api/google-api.service'; +import { CreateActionDto } from '../actions/_utils/dto/request/create-action.dto'; +import { ActionsService } from '../actions/actions.service'; +import { ReactionsService } from '../reactions/reactions.service'; +import { CreateReactionDto } from '../reactions/_utils/dto/request/create-reaction.dto'; +import { GithubApiDocument } from '../actions/schemas/github-api.schema'; +import { WebhookService } from '../github-api/services/webhook.service'; +import { TimerService } from '../timer/timer.service'; +import { ActionDocumentType } from '../actions/schemas/actions.schema'; @Injectable() export class ActionReactionService { - constructor( - private readonly actionReactionRepository: ActionReactionRepository, - private readonly actionReactionMapper: ActionReactionMapper, - private readonly actionRepository: ActionsRepository, - private readonly reactionRepository: ReactionRepository, - private readonly weatherService: WeatherService, - private readonly googleService: GoogleApiService, - private schedulerRegistry: SchedulerRegistry, - ) {} - - async getActionReactions(user: UserDocument) { - const actionReactions = - await this.actionReactionRepository.getActionReactions(user._id); - - return actionReactions.map((actionReaction) => - this.actionReactionMapper.toGetActionReactionDto(actionReaction), - ); - } - - async getActionReactionById(actionReactionId: string, user: UserDocument) { - const actionReaction = - await this.actionReactionRepository.getActionReactionById( - new Types.ObjectId(actionReactionId), - user._id, - ); - if (!actionReaction) - throw new BadRequestException('Action reaction does not exist'); - - return this.actionReactionMapper.toGetActionReactionDto(actionReaction); - } - - async createActionReaction( - user: UserDocument, - queryDto: CreateActionReactionDto, - ) { - let actionReaction = - await this.actionReactionRepository.getActionReactionByName( - queryDto.name, - ); - - if (actionReaction) - throw new ConflictException('Action reaction already exists'); - actionReaction = - await this.actionReactionRepository.createActionReaction( - user, - queryDto, - ); - - return this.actionReactionMapper.toGetActionReactionDto(actionReaction); - } - - async createMvpActionReaction( - user: UserDocument, - queryDto: CreateActionReactionDto, + constructor( + private readonly actionReactionRepository: ActionReactionRepository, + private readonly actionReactionMapper: ActionReactionMapper, + private readonly actionRepository: ActionsRepository, + private readonly actionService: ActionsService, + private readonly reactionRepository: ReactionRepository, + private readonly reactionService: ReactionsService, + private readonly webhookService: WebhookService, + private readonly timerService: TimerService, + ) {} + + async getActionReactions(user: UserDocument) { + const actionReactions = await this.actionReactionRepository.getActionReactions(user._id); + + return actionReactions.map((actionReaction) => this.actionReactionMapper.toGetActionReactionDto(actionReaction)); + } + + async getActionReactionById(actionReactionId: string, user: UserDocument) { + const actionReaction = await this.actionReactionRepository.getActionReactionById( + new Types.ObjectId(actionReactionId), + user._id, + ); + if (!actionReaction) throw new BadRequestException('Action reaction does not exist'); + + return this.actionReactionMapper.toGetActionReactionDto(actionReaction); + } + + async createActionReaction(user: UserDocument, queryDto: CreateActionReactionDto) { + let actionReaction = await this.actionReactionRepository.getActionReactionByName(queryDto.name); + + if (actionReaction) throw new ConflictException('Action reaction already exists'); + actionReaction = await this.actionReactionRepository.createActionReaction(user, queryDto); + + return this.actionReactionMapper.toGetActionReactionDto(actionReaction); + } + + async createAction(user: UserDocument, queryDto: CreateActionDto, actionReaction: ActionReactionDocument) { + if (actionReaction.user._id.toString() !== user._id.toString()) + throw new BadRequestException('You are not the owner of this action reaction'); + const action = await this.actionService.createAction(user, queryDto); + if (actionReaction.action !== null) await this.actionRepository.removeActionById(actionReaction.action._id); + await this.actionReactionRepository.updateActionReaction(actionReaction._id, action, null, null); + return this.actionReactionMapper.toGetActionReactionDto(actionReaction); + } + + async createReaction(user: UserDocument, queryDto: CreateReactionDto, actionReaction: ActionReactionDocument) { + if (actionReaction.user._id.toString() !== user._id.toString()) + throw new BadRequestException('You are not the owner of this action reaction'); + const reaction = await this.reactionService.createReaction(user, queryDto); + if (actionReaction.reaction !== null) await this.reactionRepository.removeReactionById(actionReaction.reaction._id); + actionReaction = await this.actionReactionRepository.updateActionReaction(actionReaction._id, null, reaction, null); + return this.actionReactionMapper.toGetActionReactionDto(actionReaction); + } + + async updateActionReaction(user: UserDocument, actionId: string, queryDto: UpdateActionReactionDto) { + let actionReaction = await this.actionReactionRepository.getActionReactionById( + new Types.ObjectId(actionId), + user._id, + ); + + if (!actionReaction) throw new BadRequestException('Action reaction does not exist'); + + actionReaction = await this.actionReactionRepository.updateActionReaction( + actionReaction._id, + null, + null, + queryDto.isActivated, + ); + if (actionReaction.action instanceof Types.ObjectId) + throw new InternalServerErrorException('Action is not populated'); + + if ( + actionReaction.action.actionType !== 'NASA_GET_APOD' && + actionReaction.action.actionType !== 'WEATHER_GET_CURRENT' && + actionReaction.action.actionType !== 'TIMER' && + queryDto.isActivated ) { - let actionReaction = - await this.actionReactionRepository.createActionReaction(user, { - name: queryDto.name, - }); - const action = - await this.actionRepository.createWeatherAction('Toulouse'); - const reaction = await this.reactionRepository.createDraft( - queryDto.name, - ); - actionReaction = - await this.actionReactionRepository.updateActionReaction( - actionReaction._id, - action, - reaction, - ); - const newJob = new CronJob('* * * * *', async () => { - console.log('You will see this message every second'); - await this.applyActionReactionWeatherDraft(actionReaction); - }); - - console.log('newJob', newJob); - this.schedulerRegistry.addCronJob( - actionReaction.actionReactionName, - newJob, - ); - newJob.start(); - console.log('newJob', newJob); - return this.actionReactionMapper.toGetActionReactionDto(actionReaction); + try { + const githubAction = actionReaction.action as GithubApiDocument; + await this.webhookService.createWebhook(user, githubAction.repoName); + } catch (e) { + throw new BadRequestException(e.message); + } } - - private async applyActionReactionWeatherDraft( - actionReaction: ActionReactionDocument, - ) { - if (actionReaction.user instanceof Types.ObjectId) - throw new InternalServerErrorException('USER_IS_NOT_POPULATED'); - const weather = await this.weatherService.getWeather('Toulouse'); - - console.log('weather', weather); - return this.googleService.createDraft( - actionReaction.user, - "it's " + weather.weather[0].main, - ); + if (actionReaction.action.actionType === 'TIMER' && queryDto.isActivated) { + await this.timerService.waitForThisDate(actionReaction.action as ActionDocumentType); } - async updateActionReaction( - user: UserDocument, - actionId: string, - queryDto: UpdateActionReactionDto, - ) { - let actionReaction = - await this.actionReactionRepository.getActionReactionById( - new Types.ObjectId(actionId), - user._id, - ); - - if (!actionReaction) - throw new BadRequestException('Action reaction does not exist'); - const action = queryDto.actionId - ? await this.actionRepository.getActionById( - new Types.ObjectId(queryDto.actionId), - ) - : null; - - const reaction = queryDto.reactionId - ? await this.reactionRepository.getReactionById(queryDto.reactionId) - : null; - - actionReaction = - await this.actionReactionRepository.updateActionReaction( - actionReaction._id, - action, - reaction, - ); - - return this.actionReactionMapper.toGetActionReactionDto(actionReaction); - } - - async addNewJob(actionReactionId: Types.ObjectId, user: UserDocument) { - const actionReaction = - await this.actionReactionRepository.getActionReactionById( - actionReactionId, - user._id, - ); - - if (!actionReaction.actionReactionSchedule) - throw new BadRequestException( - 'Action reaction does not have a schedule', - ); - - try { - const newJob = new CronJob( - actionReaction.actionReactionSchedule, - () => console.log('Hello World'), - ); - - this.schedulerRegistry.addCronJob( - actionReaction.actionReactionName, - newJob, - ); - newJob.start(); - } catch (error) { - throw new BadRequestException('Invalid schedule'); - } - return this.actionReactionMapper.toGetActionReactionDto(actionReaction); - } + return this.actionReactionMapper.toGetActionReactionDto(actionReaction); + } + + async removeActionReaction(actionReactionId: string, user: UserDocument) { + const actionReaction = await this.actionReactionRepository.getActionReactionById( + new Types.ObjectId(actionReactionId), + user._id, + ); + if (!actionReaction) throw new BadRequestException('Action reaction does not exist'); + if (actionReaction.action) await this.actionRepository.removeActionById(actionReaction.action._id); + if (actionReaction.reaction) await this.reactionRepository.removeReactionById(actionReaction.reaction._id); + await this.actionReactionRepository.removeActionReactionById(actionReaction._id, user); + return { message: 'Action reaction successfully deleted' }; + } } diff --git a/api/src/actions/_utils/dto/request/create-action.dto.ts b/api/src/actions/_utils/dto/request/create-action.dto.ts new file mode 100644 index 0000000..c4836ac --- /dev/null +++ b/api/src/actions/_utils/dto/request/create-action.dto.ts @@ -0,0 +1,21 @@ +import { ActionTypeEnum } from '../../enums/action-type.enum'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; + +export class CreateActionDto { + @ApiProperty({ enum: ActionTypeEnum }) + @IsEnum(ActionTypeEnum) + actionType: ActionTypeEnum; + + @IsString() + @IsOptional() + location?: string; + + @IsString() + @IsOptional() + githubRepoName?: string; + + @IsString() + @IsOptional() + date?: Date; +} diff --git a/api/src/actions/_utils/enums/action-type.enum.ts b/api/src/actions/_utils/enums/action-type.enum.ts index 72c1990..31b48e3 100644 --- a/api/src/actions/_utils/enums/action-type.enum.ts +++ b/api/src/actions/_utils/enums/action-type.enum.ts @@ -1,4 +1,14 @@ export enum ActionTypeEnum { - NASA_GET_APOD = 'NASA_GET_APOD', - WEATHER_GET_CURRENT = 'WEATHER_GET_CURRENT', + NASA_GET_APOD = 'NASA_GET_APOD', + WEATHER_GET_CURRENT = 'WEATHER_GET_CURRENT', + TIMER = 'TIMER', + PULL_REQUEST_CREATED = 'PULL_REQUEST_CREATED', + ISSUE_OPENED = 'ISSUE_OPENED', + BRANCH_MERGED = 'BRANCH_MERGED', + PULL_REQUEST_REVIEW_REQUESTED = 'PULL_REQUEST_REVIEW_REQUESTED', + PULL_REQUEST_REVIEW_REQUEST_REMOVED = 'PULL_REQUEST_REVIEW_REQUEST_REMOVED', + BRANCH_CREATED = 'BRANCH_CREATED', + BRANCH_DELETED = 'BRANCH_DELETED', + STAR_ADDED = 'STAR_ADDED', + STAR_REMOVED = 'STAR_REMOVED', } diff --git a/api/src/actions/actions.controller.ts b/api/src/actions/actions.controller.ts index b68a31c..55aef03 100644 --- a/api/src/actions/actions.controller.ts +++ b/api/src/actions/actions.controller.ts @@ -1,7 +1,4 @@ import { Controller } from '@nestjs/common'; -import { ActionsService } from './actions.service'; @Controller('actions') -export class ActionsController { - constructor(private readonly actionsService: ActionsService) {} -} +export class ActionsController {} diff --git a/api/src/actions/actions.module.ts b/api/src/actions/actions.module.ts index 7204c54..ab06704 100644 --- a/api/src/actions/actions.module.ts +++ b/api/src/actions/actions.module.ts @@ -7,28 +7,43 @@ import { ActionTypeEnum } from './_utils/enums/action-type.enum'; import { NasaApodActionSchema } from './schemas/nasa-apod-action.schema'; import { WeatherActionSchema } from './schemas/weather-action.schema'; import { ActionsRepository } from './actions.repository'; +import { WeatherModule } from '../weather/weather.module'; +import { NasaModule } from '../nasa/nasa.module'; +import { GithubApiSchema } from './schemas/github-api.schema'; +import { TimerModule } from '../timer/timer.module'; +import { TimerActionSchema } from './schemas/timer.schema'; @Module({ - imports: [ - MongooseModule.forFeature([ - { - name: Action.name, - schema: ActionSchema, - discriminators: [ - { - name: ActionTypeEnum.NASA_GET_APOD, - schema: NasaApodActionSchema, - }, - { - name: ActionTypeEnum.WEATHER_GET_CURRENT, - schema: WeatherActionSchema, - }, - ], - }, - ]), - ], - controllers: [ActionsController], - providers: [ActionsService, ActionsRepository], - exports: [ActionsRepository], + imports: [ + MongooseModule.forFeature([ + { + name: Action.name, + schema: ActionSchema, + discriminators: Object.entries({ + [ActionTypeEnum.TIMER]: TimerActionSchema, + [ActionTypeEnum.NASA_GET_APOD]: NasaApodActionSchema, + [ActionTypeEnum.WEATHER_GET_CURRENT]: WeatherActionSchema, + [ActionTypeEnum.BRANCH_CREATED]: GithubApiSchema, + [ActionTypeEnum.BRANCH_DELETED]: GithubApiSchema, + [ActionTypeEnum.BRANCH_MERGED]: GithubApiSchema, + [ActionTypeEnum.ISSUE_OPENED]: GithubApiSchema, + [ActionTypeEnum.PULL_REQUEST_CREATED]: GithubApiSchema, + [ActionTypeEnum.PULL_REQUEST_REVIEW_REQUEST_REMOVED]: GithubApiSchema, + [ActionTypeEnum.PULL_REQUEST_REVIEW_REQUESTED]: GithubApiSchema, + [ActionTypeEnum.STAR_ADDED]: GithubApiSchema, + [ActionTypeEnum.STAR_REMOVED]: GithubApiSchema, + }).map(([name, schema]) => ({ + name, + schema, + })), + }, + ]), + WeatherModule, + NasaModule, + TimerModule, + ], + controllers: [ActionsController], + providers: [ActionsService, ActionsRepository], + exports: [ActionsRepository, ActionsService], }) export class ActionsModule {} diff --git a/api/src/actions/actions.repository.ts b/api/src/actions/actions.repository.ts index 550f700..ce4dec2 100644 --- a/api/src/actions/actions.repository.ts +++ b/api/src/actions/actions.repository.ts @@ -1,29 +1,92 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { ActionTypeEnum } from './_utils/enums/action-type.enum'; import { Model, Types } from 'mongoose'; import { NasaApodAction } from './schemas/nasa-apod-action.schema'; import { WeatherAction } from './schemas/weather-action.schema'; import { Action } from './schemas/actions.schema'; +import { GithubApi } from './schemas/github-api.schema'; +import { TimerAction } from './schemas/timer.schema'; @Injectable() export class ActionsRepository { - constructor( - @InjectModel(Action.name) private actionModel: Model, - @InjectModel(ActionTypeEnum.NASA_GET_APOD) - private nasaModel: Model, - @InjectModel(ActionTypeEnum.WEATHER_GET_CURRENT) - private weatherModel: Model, - ) {} - - createNasaAction = () => this.nasaModel.create(); - - createWeatherAction = (city: string) => - this.weatherModel.create({ city: city }); - - getActionById = (id: Types.ObjectId) => - this.actionModel - .findById(id) - .orFail(new Error('Action not found')) - .exec(); + private githubModel: { + [ActionTypeEnum.BRANCH_DELETED]: Model; + [ActionTypeEnum.STAR_REMOVED]: Model; + [ActionTypeEnum.ISSUE_OPENED]: Model; + [ActionTypeEnum.PULL_REQUEST_CREATED]: Model; + [ActionTypeEnum.PULL_REQUEST_REVIEW_REQUESTED]: Model; + [ActionTypeEnum.BRANCH_CREATED]: Model; + [ActionTypeEnum.PULL_REQUEST_REVIEW_REQUEST_REMOVED]: Model; + [ActionTypeEnum.STAR_ADDED]: Model; + [ActionTypeEnum.BRANCH_MERGED]: Model; + }; + constructor( + @InjectModel(Action.name) private actionModel: Model, + @InjectModel(ActionTypeEnum.NASA_GET_APOD) + private nasaModel: Model, + @InjectModel(ActionTypeEnum.WEATHER_GET_CURRENT) + private weatherModel: Model, + @InjectModel(ActionTypeEnum.BRANCH_CREATED) + private githubBranchCreationModel: Model, + @InjectModel(ActionTypeEnum.BRANCH_DELETED) + private githubBranchDeletionModel: Model, + @InjectModel(ActionTypeEnum.BRANCH_MERGED) + private githubBranchMergeModel: Model, + @InjectModel(ActionTypeEnum.ISSUE_OPENED) + private githubIssueOpenedModel: Model, + @InjectModel(ActionTypeEnum.PULL_REQUEST_CREATED) + private githubPullRequestCreatedModel: Model, + @InjectModel(ActionTypeEnum.PULL_REQUEST_REVIEW_REQUESTED) + private githubPullRequestReviewRequestedModel: Model, + @InjectModel(ActionTypeEnum.PULL_REQUEST_REVIEW_REQUEST_REMOVED) + private githubPullRequestReviewRequestRemovedModel: Model, + @InjectModel(ActionTypeEnum.STAR_ADDED) + private githubStarAddedModel: Model, + @InjectModel(ActionTypeEnum.STAR_REMOVED) + private githubStarRemovedModel: Model, + @InjectModel(ActionTypeEnum.TIMER) + private timerModel: Model, + ) { + this.githubModel = { + [ActionTypeEnum.BRANCH_CREATED]: this.githubBranchCreationModel, + [ActionTypeEnum.BRANCH_DELETED]: this.githubBranchDeletionModel, + [ActionTypeEnum.BRANCH_MERGED]: this.githubBranchMergeModel, + [ActionTypeEnum.ISSUE_OPENED]: this.githubIssueOpenedModel, + [ActionTypeEnum.PULL_REQUEST_CREATED]: this.githubPullRequestCreatedModel, + [ActionTypeEnum.PULL_REQUEST_REVIEW_REQUESTED]: this.githubPullRequestReviewRequestedModel, + [ActionTypeEnum.PULL_REQUEST_REVIEW_REQUEST_REMOVED]: this.githubPullRequestReviewRequestRemovedModel, + [ActionTypeEnum.STAR_ADDED]: this.githubStarAddedModel, + [ActionTypeEnum.STAR_REMOVED]: this.githubStarRemovedModel, + }; + } + + createNasaAction = () => + this.nasaModel.create({ + actionType: ActionTypeEnum.NASA_GET_APOD, + }); + + createWeatherAction = (city: string) => this.weatherModel.create({ city: city }); + + createTimerAction = (date: Date) => this.timerModel.create({ date: date }); + + createGithubAction = (repoName: string, actionType: ActionTypeEnum) => + this.githubModel[actionType].create({ + repoName, + actionType, + }); + + getActionById = (id: Types.ObjectId) => this.actionModel.findById(id).orFail(new Error('Action not found')).exec(); + + updateWebhookId = (webhookId: string | null, actionId: Types.ObjectId) => + this.actionModel + .findByIdAndUpdate(actionId, { webhookId: webhookId }, { new: true }) + .orFail(new NotFoundException(`Action ${actionId} not found`)) + .exec(); + + removeActionById = (id: Types.ObjectId) => + this.actionModel + .findByIdAndDelete(id) + .orFail(new NotFoundException(`Action ${id} not found`)) + .exec(); } diff --git a/api/src/actions/actions.service.ts b/api/src/actions/actions.service.ts index 0422281..2f5825e 100644 --- a/api/src/actions/actions.service.ts +++ b/api/src/actions/actions.service.ts @@ -1,4 +1,39 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { UserDocument } from '../users/users.schema'; +import { CreateActionDto } from './_utils/dto/request/create-action.dto'; +import { ActionsRepository } from './actions.repository'; +import { ActionTypeEnum } from './_utils/enums/action-type.enum'; @Injectable() -export class ActionsService {} +export class ActionsService { + private readonly bodyNeeded: Map; + + constructor(private readonly actionsRepository: ActionsRepository) { + this.bodyNeeded = new Map([ + [ActionTypeEnum.NASA_GET_APOD, []], + [ActionTypeEnum.WEATHER_GET_CURRENT, ['location']], + [ActionTypeEnum.TIMER, ['date']], + ]); + for (const key in ActionTypeEnum) { + if (!this.bodyNeeded.has(ActionTypeEnum[key])) this.bodyNeeded.set(ActionTypeEnum[key], ['githubRepoName']); + } + } + + createAction(user: UserDocument, data: CreateActionDto) { + if (this.bodyNeeded.get(data.actionType).some((key) => !data[key])) { + throw new BadRequestException( + `You need to provide ${this.bodyNeeded[data.actionType].join(', ')} to create this action`, + ); + } + switch (data.actionType) { + case 'NASA_GET_APOD': + return this.actionsRepository.createNasaAction(); + case 'WEATHER_GET_CURRENT': + return this.actionsRepository.createWeatherAction(data.location); + case 'TIMER': + return this.actionsRepository.createTimerAction(data.date); + default: + return this.actionsRepository.createGithubAction(data.githubRepoName, data.actionType); + } + } +} diff --git a/api/src/actions/schemas/actions.schema.ts b/api/src/actions/schemas/actions.schema.ts index f4d2909..f06c278 100644 --- a/api/src/actions/schemas/actions.schema.ts +++ b/api/src/actions/schemas/actions.schema.ts @@ -1,13 +1,18 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { ActionTypeEnum } from '../_utils/enums/action-type.enum'; import { HydratedDocument } from 'mongoose'; +import { NasaApodActionDocument } from './nasa-apod-action.schema'; +import { WeatherActionDocument } from './weather-action.schema'; +import { TimerActionDocument } from './timer.schema'; export type ActionDocument = HydratedDocument; +export type ActionDocumentType = NasaApodActionDocument | WeatherActionDocument | TimerActionDocument; + @Schema({ discriminatorKey: 'actionType' }) export class Action { - @Prop({ required: true, enum: ActionTypeEnum, type: String }) - actionType: ActionTypeEnum; + @Prop({ required: true, enum: ActionTypeEnum, type: String }) + actionType: ActionTypeEnum; } export const ActionSchema = SchemaFactory.createForClass(Action); diff --git a/api/src/actions/schemas/github-api.schema.ts b/api/src/actions/schemas/github-api.schema.ts new file mode 100644 index 0000000..ed3100e --- /dev/null +++ b/api/src/actions/schemas/github-api.schema.ts @@ -0,0 +1,26 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { OmitType } from '@nestjs/swagger'; +import { Action } from './actions.schema'; +import { ActionTypeEnum } from '../_utils/enums/action-type.enum'; +import { HydratedDocument } from 'mongoose'; + +export type GithubApiDocument = HydratedDocument; + +@Schema() +export class GithubApi extends OmitType(Action, ['actionType'] as const) { + actionType: + | ActionTypeEnum.BRANCH_CREATED + | ActionTypeEnum.BRANCH_DELETED + | ActionTypeEnum.BRANCH_MERGED + | ActionTypeEnum.ISSUE_OPENED + | ActionTypeEnum.PULL_REQUEST_CREATED + | ActionTypeEnum.PULL_REQUEST_REVIEW_REQUESTED + | ActionTypeEnum.PULL_REQUEST_REVIEW_REQUEST_REMOVED + | ActionTypeEnum.STAR_ADDED + | ActionTypeEnum.STAR_REMOVED; + + @Prop({ required: true }) + repoName: string; +} + +export const GithubApiSchema = SchemaFactory.createForClass(GithubApi); diff --git a/api/src/actions/schemas/nasa-apod-action.schema.ts b/api/src/actions/schemas/nasa-apod-action.schema.ts index 305b0ef..11af312 100644 --- a/api/src/actions/schemas/nasa-apod-action.schema.ts +++ b/api/src/actions/schemas/nasa-apod-action.schema.ts @@ -8,8 +8,7 @@ export type NasaApodActionDocument = HydratedDocument; @Schema() export class NasaApodAction extends OmitType(Action, ['actionType'] as const) { - actionType: ActionTypeEnum.NASA_GET_APOD; + actionType: ActionTypeEnum.NASA_GET_APOD; } -export const NasaApodActionSchema = - SchemaFactory.createForClass(NasaApodAction); +export const NasaApodActionSchema = SchemaFactory.createForClass(NasaApodAction); diff --git a/api/src/actions/schemas/timer.schema.ts b/api/src/actions/schemas/timer.schema.ts new file mode 100644 index 0000000..3725932 --- /dev/null +++ b/api/src/actions/schemas/timer.schema.ts @@ -0,0 +1,17 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { OmitType } from '@nestjs/swagger'; +import { ActionTypeEnum } from '../_utils/enums/action-type.enum'; +import { Action } from './actions.schema'; +import { HydratedDocument } from 'mongoose'; + +export type TimerActionDocument = HydratedDocument; + +@Schema() +export class TimerAction extends OmitType(Action, ['actionType'] as const) { + actionType: ActionTypeEnum.TIMER; + + @Prop({ required: true, type: Date }) + date: Date; +} + +export const TimerActionSchema = SchemaFactory.createForClass(TimerAction); diff --git a/api/src/actions/schemas/weather-action.schema.ts b/api/src/actions/schemas/weather-action.schema.ts index 4a10046..9777801 100644 --- a/api/src/actions/schemas/weather-action.schema.ts +++ b/api/src/actions/schemas/weather-action.schema.ts @@ -8,10 +8,10 @@ export type WeatherActionDocument = HydratedDocument; @Schema() export class WeatherAction extends OmitType(Action, ['actionType'] as const) { - actionType: ActionTypeEnum.WEATHER_GET_CURRENT; + actionType: ActionTypeEnum.WEATHER_GET_CURRENT; - @Prop({ required: true }) - city: string; + @Prop({ required: true }) + city: string; } export const WeatherActionSchema = SchemaFactory.createForClass(WeatherAction); diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 704b90f..08b67ea 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -1,5 +1,4 @@ import { Module } from '@nestjs/common'; -import { TasksService } from './task.service'; import { UsersModule } from './users/users.module'; import { MongooseModule } from '@nestjs/mongoose'; import { AuthModule } from './auth/auth.module'; @@ -13,29 +12,32 @@ import { ActionReactionModule } from './action-reaction/action-reaction.module'; import { ActionsModule } from './actions/actions.module'; import { ReactionsModule } from './reactions/reactions.module'; import { GoogleApiModule } from './google-api/google-api.module'; +import { SlackModule } from './slack/slack.module'; +import { GithubApiModule } from './github-api/github-api.module'; +import { TimerModule } from './timer/timer.module'; @Module({ - imports: [ - ScheduleModule.forRoot(), - MongooseModule.forRootAsync({ - useFactory: async ( - configService: ConfigService, - ) => ({ - uri: configService.get('MONGO_URI'), - }), - inject: [ConfigService], - }), - UsersModule, - AuthModule, - ConfigModule.forRoot({ validate: validateEnv, isGlobal: true }), - WeatherModule, - NasaModule, - AboutModule, - ActionReactionModule, - ActionsModule, - ReactionsModule, - GoogleApiModule, - ], - providers: [TasksService], + imports: [ + ScheduleModule.forRoot(), + MongooseModule.forRootAsync({ + useFactory: async (configService: ConfigService) => ({ + uri: configService.get('MONGO_URI'), + }), + inject: [ConfigService], + }), + UsersModule, + AuthModule, + ConfigModule.forRoot({ validate: validateEnv, isGlobal: true }), + WeatherModule, + NasaModule, + AboutModule, + ActionReactionModule, + ActionsModule, + ReactionsModule, + GoogleApiModule, + SlackModule, + GithubApiModule, + TimerModule, + ], }) export class AppModule {} diff --git a/api/src/auth/_utils/decorators/connected-user.decorator.ts b/api/src/auth/_utils/decorators/connected-user.decorator.ts index fb376a1..b9b5ea4 100644 --- a/api/src/auth/_utils/decorators/connected-user.decorator.ts +++ b/api/src/auth/_utils/decorators/connected-user.decorator.ts @@ -1,8 +1,6 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -export const ConnectedUser = createParamDecorator( - (_, ctx: ExecutionContext) => { - const request = ctx.switchToHttp().getRequest(); - return request.user; - }, -); +export const ConnectedUser = createParamDecorator((_, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user; +}); diff --git a/api/src/auth/_utils/decorators/protect.decorator.ts b/api/src/auth/_utils/decorators/protect.decorator.ts index f7a1608..54c63ae 100644 --- a/api/src/auth/_utils/decorators/protect.decorator.ts +++ b/api/src/auth/_utils/decorators/protect.decorator.ts @@ -3,5 +3,5 @@ import { ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../../jwt/jwt-auth.guard'; export function Protect() { - return applyDecorators(ApiBearerAuth(), UseGuards(JwtAuthGuard)); + return applyDecorators(ApiBearerAuth(), UseGuards(JwtAuthGuard)); } diff --git a/api/src/auth/_utils/dto/request/google-login.dto.ts b/api/src/auth/_utils/dto/request/google-login.dto.ts index 07e271e..8e3fb1f 100644 --- a/api/src/auth/_utils/dto/request/google-login.dto.ts +++ b/api/src/auth/_utils/dto/request/google-login.dto.ts @@ -1,12 +1,12 @@ import { IsString } from 'class-validator'; export class GoogleLoginDto { - @IsString() - accessToken: string; + @IsString() + accessToken: string; - @IsString() - completeName: string; + @IsString() + completeName: string; - @IsString() - email: string; + @IsString() + email: string; } diff --git a/api/src/auth/_utils/dto/request/sign-in.dto.ts b/api/src/auth/_utils/dto/request/sign-in.dto.ts index 3e067ab..c19be3c 100644 --- a/api/src/auth/_utils/dto/request/sign-in.dto.ts +++ b/api/src/auth/_utils/dto/request/sign-in.dto.ts @@ -2,11 +2,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString } from 'class-validator'; export class SignInDto { - @IsString() - @ApiProperty({ example: 'moi' }) - usernameOrEmail: string; + @IsString() + @ApiProperty({ example: 'moi' }) + usernameOrEmail: string; - @IsString() - @ApiProperty({ example: 'Test1234**' }) - password: string; + @IsString() + @ApiProperty({ example: 'Test1234**' }) + password: string; } diff --git a/api/src/auth/_utils/dto/response/succes-login.dto.ts b/api/src/auth/_utils/dto/response/succes-login.dto.ts index 4b003f6..12f8439 100644 --- a/api/src/auth/_utils/dto/response/succes-login.dto.ts +++ b/api/src/auth/_utils/dto/response/succes-login.dto.ts @@ -1,6 +1,6 @@ import { GetUserDto } from '../../../../users/_utils/dto/response/get-user.dto'; export class SuccesLoginDto { - user: GetUserDto; - accessToken: string; + user: GetUserDto; + accessToken: string; } diff --git a/api/src/auth/auth.controller.spec.ts b/api/src/auth/auth.controller.spec.ts index 56aae83..22d2ebf 100644 --- a/api/src/auth/auth.controller.spec.ts +++ b/api/src/auth/auth.controller.spec.ts @@ -8,28 +8,28 @@ import { UsersService } from '../users/users.service'; import { Model } from 'mongoose'; describe('AuthController', () => { - let controller: AuthController; + let controller: AuthController; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [AuthController], - providers: [ - AuthService, - UsersRepository, - UsersMapper, - JwtService, - UsersService, - { - provide: 'UserModel', - useValue: Model, - }, - ], - }).compile(); + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + AuthService, + UsersRepository, + UsersMapper, + JwtService, + UsersService, + { + provide: 'UserModel', + useValue: Model, + }, + ], + }).compile(); - controller = module.get(AuthController); - }); + controller = module.get(AuthController); + }); - it('should be defined', () => { - expect(controller).toBeDefined(); - }); + it('should be defined', () => { + expect(controller).toBeDefined(); + }); }); diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index b98e8b9..c30f75b 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -1,32 +1,50 @@ -import { Body, Controller, Post } from '@nestjs/common'; +import { Body, Controller, Post, Headers, UnauthorizedException } from '@nestjs/common'; import { AuthService } from './auth.service'; import { SignInDto } from './_utils/dto/request/sign-in.dto'; import { CreateUserDto } from '../users/_utils/dto/request/create-user.dto'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiHeader, ApiTags } from '@nestjs/swagger'; import { GoogleLoginDto } from './_utils/dto/request/google-login.dto'; +import { UsersService } from '../users/users.service'; +import * as jwt from 'jsonwebtoken'; +import { ConfigService } from '@nestjs/config'; +import { EnvironmentVariables } from '../_utils/config'; +import { JwtStrategy } from './jwt/jwt.startegy'; @ApiTags('Auth') @Controller('auth') export class AuthController { - constructor(private readonly authService: AuthService) {} + constructor( + private readonly authService: AuthService, + private readonly usersService: UsersService, + private readonly configService: ConfigService, + private readonly jwtStrategy: JwtStrategy, + ) {} - /** - * Endpoint for user login. - * @param signInDto - The sign-in data transfer object. - * @returns The result of the sign-in operation. - */ - @Post('login') - signIn(@Body() signInDto: SignInDto) { - return this.authService.signIn(signInDto); - } + /** + * Endpoint for user login. + * @param signInDto - The sign-in data transfer object. + * @returns The result of the sign-in operation. + */ + @Post('login') + signIn(@Body() signInDto: SignInDto) { + return this.authService.signIn(signInDto); + } - @Post('register') - signUp(@Body() signUpDto: CreateUserDto) { - return this.authService.signUp(signUpDto); - } + @Post('register') + signUp(@Body() signUpDto: CreateUserDto) { + return this.authService.signUp(signUpDto); + } - @Post('google-login') - googleLogin(@Body() googleLoginDto: GoogleLoginDto) { - return this.authService.connectWithGoogle(googleLoginDto); + @Post('google-login') + async googleLogin(@Headers('Authorization') authorizationHeader: string, @Body() googleLoginDto: GoogleLoginDto) { + if (authorizationHeader) { + const token = authorizationHeader.split(' ')[1]; + const decodeToken = jwt.verify(token, this.configService.get('JWT_SECRET')); + if (!decodeToken) throw new UnauthorizedException('Bad token'); + const user = await this.jwtStrategy.validate(decodeToken); + console.log(user); + return this.usersService.updateGoogleToken(user, googleLoginDto.accessToken); } + return this.authService.connectWithGoogle(googleLoginDto); + } } diff --git a/api/src/auth/auth.module.ts b/api/src/auth/auth.module.ts index caaaf12..71f13f1 100644 --- a/api/src/auth/auth.module.ts +++ b/api/src/auth/auth.module.ts @@ -10,22 +10,20 @@ import { EnvironmentVariables } from 'src/_utils/config'; import { GoogleApiModule } from '../google-api/google-api.module'; @Module({ - imports: [ - UsersModule, - PassportModule, - GoogleApiModule, - JwtModule.registerAsync({ - useFactory: async ( - configService: ConfigService, - ) => ({ - global: true, - secret: configService.get('JWT_SECRET'), - signOptions: { expiresIn: '1d' }, - }), - inject: [ConfigService], - }), - ], - controllers: [AuthController], - providers: [AuthService, JwtStrategy], + imports: [ + UsersModule, + PassportModule, + GoogleApiModule, + JwtModule.registerAsync({ + useFactory: async (configService: ConfigService) => ({ + global: true, + secret: configService.get('JWT_SECRET'), + signOptions: { expiresIn: '1d' }, + }), + inject: [ConfigService], + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], }) export class AuthModule {} diff --git a/api/src/auth/auth.service.spec.ts b/api/src/auth/auth.service.spec.ts index 076c8a9..45026fb 100644 --- a/api/src/auth/auth.service.spec.ts +++ b/api/src/auth/auth.service.spec.ts @@ -7,27 +7,27 @@ import { UsersService } from '../users/users.service'; import { Model } from 'mongoose'; describe('AuthService', () => { - let service: AuthService; + let service: AuthService; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AuthService, - UsersRepository, - { - provide: 'UserModel', - useValue: Model, - }, - UsersMapper, - JwtService, - UsersService, - ], - }).compile(); + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + UsersRepository, + { + provide: 'UserModel', + useValue: Model, + }, + UsersMapper, + JwtService, + UsersService, + ], + }).compile(); - service = module.get(AuthService); - }); + service = module.get(AuthService); + }); - it('should be defined', () => { - expect(service).toBeDefined(); - }); + it('should be defined', () => { + expect(service).toBeDefined(); + }); }); diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index b217870..4247604 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -15,67 +15,60 @@ import { GoogleApiService } from '../google-api/google-api.service'; @Injectable() export class AuthService { - private oAuth2Client: OAuth2Client; + constructor( + private readonly usersService: UsersService, + private readonly usersRepository: UsersRepository, + private readonly usersMapper: UsersMapper, + private readonly jwtService: JwtService, + private readonly googleApiService: GoogleApiService, + ) {} - constructor( - private readonly usersService: UsersService, - private readonly usersRepository: UsersRepository, - private readonly usersMapper: UsersMapper, - private readonly jwtService: JwtService, - private readonly googleApiService: GoogleApiService, - ) {} + async signUp(createUserDto: CreateUserDto): Promise { + const user = await this.usersService.create(createUserDto); - async signUp(createUserDto: CreateUserDto): Promise { - const user = await this.usersService.create(createUserDto); + return { + user: await this.usersMapper.toGetUserDto(user), + accessToken: await this.createToken(user), + }; + } - return { - user: this.usersMapper.toGetUserDto(user), - accessToken: await this.createToken(user), - }; - } - - async signIn(signInDto: SignInDto): Promise { - let user: UserDocument; + async signIn(signInDto: SignInDto): Promise { + let user: UserDocument; - if (isEmail(signInDto.usernameOrEmail)) { - user = await this.usersRepository.findOneByEmail( - signInDto.usernameOrEmail, - ); - } else { - user = await this.usersRepository.findOneByUsername( - signInDto.usernameOrEmail, - ); - } - if (!user || !compareSync(signInDto.password, user.password)) - throw new UnauthorizedException('Invalid credentials'); - - return { - user: this.usersMapper.toGetUserDto(user), - accessToken: await this.createToken(user), - }; + if (isEmail(signInDto.usernameOrEmail)) { + user = await this.usersRepository.findOneByEmail(signInDto.usernameOrEmail); + } else { + user = await this.usersRepository.findOneByUsername(signInDto.usernameOrEmail); } + if (!user || !compareSync(signInDto.password, user.password)) + throw new UnauthorizedException('Invalid credentials'); + + return { + user: await this.usersMapper.toGetUserDto(user), + accessToken: await this.createToken(user), + }; + } - async connectWithGoogle(googleUser: GoogleLoginDto) { - const info = await this.googleApiService.loginWithGoogle(googleUser); - let user = await this.usersRepository.findOneByGoogleId(info.data.id); - if (!user) { - user = await this.usersRepository.createOAuthUser({ - fullName: info.data.name, - email: info.data.email, - googleId: info.data.id, - googleAccessToken: googleUser.accessToken, - }); - } else { - user = await this.usersRepository.updateOneById(user._id, { - googleAccessToken: googleUser.accessToken, - }); - } - return { - user: this.usersMapper.toGetUserDto(user), - accessToken: await this.createToken(user), - }; + async connectWithGoogle(googleUser: GoogleLoginDto) { + const info = await this.googleApiService.loginWithGoogle(googleUser); + let user = await this.usersRepository.findOneByGoogleId(info.data.id); + if (!user) { + user = await this.usersRepository.createOAuthUser({ + fullName: info.data.name, + email: info.data.email, + googleId: info.data.id, + googleAccessToken: googleUser.accessToken, + }); + } else { + user = await this.usersRepository.updateOneById(user._id, { + googleAccessToken: googleUser.accessToken, + }); } + return { + user: await this.usersMapper.toGetUserDto(user), + accessToken: await this.createToken(user), + }; + } - private createToken = (user: UserDocument) => - this.jwtService.signAsync({ sub: user._id.toString() }); + private createToken = (user: UserDocument) => this.jwtService.signAsync({ sub: user._id.toString() }); } diff --git a/api/src/auth/jwt/jwt.startegy.ts b/api/src/auth/jwt/jwt.startegy.ts index 322d4b6..3131c62 100644 --- a/api/src/auth/jwt/jwt.startegy.ts +++ b/api/src/auth/jwt/jwt.startegy.ts @@ -7,21 +7,18 @@ import { EnvironmentVariables } from '../../_utils/config'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor( - private readonly usersRepository: UsersRepository, - private readonly configService: ConfigService< - EnvironmentVariables, - true - >, - ) { - super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - ignoreExpiration: false, - secretOrKey: configService.get('JWT_SECRET'), - }); - } + constructor( + private readonly usersRepository: UsersRepository, + private readonly configService: ConfigService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_SECRET'), + }); + } - async validate(payload: any) { - return this.usersRepository.findById(payload.sub); - } + async validate(payload: any) { + return this.usersRepository.findById(payload.sub); + } } diff --git a/api/src/github-api/_utils/dto/request/desactivate-webhook.dto.ts b/api/src/github-api/_utils/dto/request/desactivate-webhook.dto.ts new file mode 100644 index 0000000..8cf4003 --- /dev/null +++ b/api/src/github-api/_utils/dto/request/desactivate-webhook.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class DeactivateWebhookDto { + @ApiProperty() + @IsString() + webhookId: string; +} diff --git a/api/src/github-api/_utils/functions/to-github-interface.function.ts b/api/src/github-api/_utils/functions/to-github-interface.function.ts new file mode 100644 index 0000000..b68b776 --- /dev/null +++ b/api/src/github-api/_utils/functions/to-github-interface.function.ts @@ -0,0 +1,25 @@ +import { ActionTypeEnum } from '../../../actions/_utils/enums/action-type.enum'; +import { toBranchActionInterface } from '../interfaces/branch-action.interface'; +import { toIssueInterface } from '../interfaces/issue.interface'; +import { toPullRequestInterface } from '../interfaces/pull-request.interface'; +import { toStarInterface } from '../interfaces/star.interface'; + +export function toGithubInterface(githubAction: any, type: ActionTypeEnum) { + switch (type) { + case ActionTypeEnum.BRANCH_CREATED: + case ActionTypeEnum.BRANCH_DELETED: + case ActionTypeEnum.BRANCH_MERGED: + return toBranchActionInterface(githubAction); + case ActionTypeEnum.ISSUE_OPENED: + return toIssueInterface(githubAction); + case ActionTypeEnum.PULL_REQUEST_CREATED: + case ActionTypeEnum.PULL_REQUEST_REVIEW_REQUESTED: + case ActionTypeEnum.PULL_REQUEST_REVIEW_REQUEST_REMOVED: + return toPullRequestInterface(githubAction); + case ActionTypeEnum.STAR_ADDED: + case ActionTypeEnum.STAR_REMOVED: + return toStarInterface(githubAction); + default: + return null; + } +} diff --git a/api/src/github-api/_utils/interfaces/branch-action.interface.ts b/api/src/github-api/_utils/interfaces/branch-action.interface.ts new file mode 100644 index 0000000..7d480c3 --- /dev/null +++ b/api/src/github-api/_utils/interfaces/branch-action.interface.ts @@ -0,0 +1,23 @@ +export interface BranchActionInterface { + branchName: string; + repoName: string; + repoOwner: string; + repoUrl: string; + ownerName: string; +} + +export function toBranchActionInterface(githubResponse: any): BranchActionInterface { + const branchName = githubResponse.ref.replace('refs/heads/', ''); + const repoName = githubResponse.repository.name; + const repoOwner = githubResponse.repository.owner.login; + const repoUrl = githubResponse.repository.html_url; + const ownerName = githubResponse.sender.login; + + return { + branchName, + repoName, + repoOwner, + repoUrl, + ownerName, + }; +} diff --git a/api/src/github-api/_utils/interfaces/issue.interface.ts b/api/src/github-api/_utils/interfaces/issue.interface.ts new file mode 100644 index 0000000..8faa570 --- /dev/null +++ b/api/src/github-api/_utils/interfaces/issue.interface.ts @@ -0,0 +1,29 @@ +export interface IssueInterface { + repoName: string; + repoUrl: string; + number: number; + title: string; + body: string; + created_at: string; + creator: string; +} + +export function toIssueInterface(githubResponse: any): IssueInterface { + const repoName = githubResponse.repository.name; + const repoUrl = githubResponse.repository.html_url; + const number = githubResponse.issue.number; + const title = githubResponse.issue.title; + const body = githubResponse.issue.body; + const created_at = githubResponse.issue.created_at; + const creator = githubResponse.sender.login; + + return { + repoName, + repoUrl, + number, + title, + body, + created_at, + creator, + }; +} diff --git a/api/src/github-api/_utils/interfaces/pull-request.interface.ts b/api/src/github-api/_utils/interfaces/pull-request.interface.ts new file mode 100644 index 0000000..cf456aa --- /dev/null +++ b/api/src/github-api/_utils/interfaces/pull-request.interface.ts @@ -0,0 +1,23 @@ +export interface PullRequestInterface { + number: number; + title: string; + body: string; + created_at: string; + creator: string; +} + +export function toPullRequestInterface(githubResponse: any): PullRequestInterface { + const number = githubResponse.pull_request.number; + const title = githubResponse.pull_request.title; + const body = githubResponse.pull_request.body; + const created_at = githubResponse.pull_request.created_at; + const creator = githubResponse.sender.login; + + return { + number, + title, + body, + created_at, + creator, + }; +} diff --git a/api/src/github-api/_utils/interfaces/star.interface.ts b/api/src/github-api/_utils/interfaces/star.interface.ts new file mode 100644 index 0000000..2126d15 --- /dev/null +++ b/api/src/github-api/_utils/interfaces/star.interface.ts @@ -0,0 +1,29 @@ +export interface StarInterface { + starred_at: string; + repoName: string; + repoOwner: string; + repoUrl: string; + ownerName: string; + starCount: number; + starUserName: string; +} + +export function toStarInterface(githubResponse: any): StarInterface { + const starred_at = githubResponse.starred_at ?? 'No date'; + const repoName = githubResponse.repository.name; + const repoOwner = githubResponse.repository.owner.login; + const repoUrl = githubResponse.repository.html_url; + const ownerName = githubResponse.sender.login; + const starCount = githubResponse.repository.stargazers_count; + const starUserName = githubResponse.sender.login; + + return { + starred_at, + repoName, + repoOwner, + repoUrl, + ownerName, + starCount, + starUserName, + }; +} diff --git a/api/src/github-api/github-api.controller.spec.ts b/api/src/github-api/github-api.controller.spec.ts new file mode 100644 index 0000000..09cba5e --- /dev/null +++ b/api/src/github-api/github-api.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GithubApiController } from './github-api.controller'; +import { GithubApiService } from './services/github-api.service'; + +describe('GithubApiController', () => { + let controller: GithubApiController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [GithubApiController], + providers: [GithubApiService], + }).compile(); + + controller = module.get(GithubApiController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/api/src/github-api/github-api.controller.ts b/api/src/github-api/github-api.controller.ts new file mode 100644 index 0000000..8dcf860 --- /dev/null +++ b/api/src/github-api/github-api.controller.ts @@ -0,0 +1,25 @@ +import { Body, Controller, Get, Headers, Post } from '@nestjs/common'; +import { GithubApiService } from './services/github-api.service'; +import { Protect } from '../auth/_utils/decorators/protect.decorator'; +import { ConnectedUser } from '../auth/_utils/decorators/connected-user.decorator'; +import { UserDocument } from '../users/users.schema'; + +@Controller('github-api') +export class GithubApiController { + constructor(private readonly githubApiService: GithubApiService) {} + + @Protect() + @Get() + async getGithubWebhooks(@ConnectedUser() user: UserDocument) { + return this.githubApiService.getGithubWebhooks('global-game-jam', user.githubName, user.githubAccessToken); + } + + @Post('callback') + handleWebhook( + @Headers('x-hub-signature') signature: string, + @Headers('x-github-event') event: string, + @Body() payload: any, + ) { + this.githubApiService.handleWebhook(payload, signature, event); + } +} diff --git a/api/src/github-api/github-api.module.ts b/api/src/github-api/github-api.module.ts new file mode 100644 index 0000000..2663044 --- /dev/null +++ b/api/src/github-api/github-api.module.ts @@ -0,0 +1,23 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { GithubApiService } from './services/github-api.service'; +import { GithubApiController } from './github-api.controller'; +import { ActionReactionModule } from '../action-reaction/action-reaction.module'; +import { ReactionsModule } from '../reactions/reactions.module'; +import { UsersModule } from '../users/users.module'; +import { MongooseModule } from '@nestjs/mongoose'; +import { Webhook, WebhookSchema } from './webhook.schema'; +import { WebhookService } from './services/webhook.service'; +import { GithubWebhookRepository } from './github-webhook.repository'; + +@Module({ + imports: [ + ReactionsModule, + forwardRef(() => ActionReactionModule), + forwardRef(() => UsersModule), + MongooseModule.forFeature([{ name: Webhook.name, schema: WebhookSchema }]), + ], + controllers: [GithubApiController], + providers: [GithubApiService, WebhookService, GithubWebhookRepository], + exports: [GithubApiService, WebhookService], +}) +export class GithubApiModule {} diff --git a/api/src/github-api/github-webhook.repository.ts b/api/src/github-api/github-webhook.repository.ts new file mode 100644 index 0000000..6d2daf0 --- /dev/null +++ b/api/src/github-api/github-webhook.repository.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Webhook } from './webhook.schema'; +import { Model } from 'mongoose'; + +@Injectable() +export class GithubWebhookRepository { + constructor(@InjectModel(Webhook.name) private webhookModel: Model) {} + + getWebhookByParams = (params: { repoName: string; userName: string }) => this.webhookModel.findOne(params).exec(); + + createWebhook = (params: { repoName: string; userName: string; repoId: string }) => + this.webhookModel.create(params).catch((err) => { + throw new Error(err.message); + }); + + removeWebhook = (params: { repoName: string; userName: string }) => this.webhookModel.deleteOne(params).exec(); +} diff --git a/api/src/github-api/services/github-api.service.spec.ts b/api/src/github-api/services/github-api.service.spec.ts new file mode 100644 index 0000000..afb83bc --- /dev/null +++ b/api/src/github-api/services/github-api.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GithubApiService } from './github-api.service'; + +describe('GithubApiService', () => { + let service: GithubApiService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [GithubApiService], + }).compile(); + + service = module.get(GithubApiService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/api/src/github-api/services/github-api.service.ts b/api/src/github-api/services/github-api.service.ts new file mode 100644 index 0000000..a1ccbb0 --- /dev/null +++ b/api/src/github-api/services/github-api.service.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { ActionTypeEnum } from '../../actions/_utils/enums/action-type.enum'; +import { ActionReactionRepository } from '../../action-reaction/action-reaction.repository'; +import { ReactionsService } from '../../reactions/reactions.service'; +import { UsersRepository } from '../../users/users.repository'; +import { toGithubInterface } from '../_utils/functions/to-github-interface.function'; + +@Injectable() +export class GithubApiService { + constructor( + private readonly actionReactionRepository: ActionReactionRepository, + private readonly reactionService: ReactionsService, + private readonly usersRepository: UsersRepository, + ) {} + + async getGithubUser(token: string) { + const url = 'https://api.github.com/user'; + const config = { + headers: { + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }; + const response = await axios.get(url, config); + return response.data; + } + + async getGithubWebhooks(repoName: string, userName: string, token: string) { + const url = `https://api.github.com/repos/${userName}/${repoName}/hooks`; + const config = { + headers: { + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }; + const response = await axios.get(url, config); + return response.data; + } + + defineActionType(event: string, action: any): ActionTypeEnum | null { + switch (event) { + case 'push': + if (action?.created === true) return ActionTypeEnum.BRANCH_CREATED; + if (action?.deleted === true) return ActionTypeEnum.BRANCH_DELETED; + return null; + case 'issues': + if (action?.action === 'opened') return ActionTypeEnum.ISSUE_OPENED; + return null; + case 'pull_request': + if (action?.action === 'opened') return ActionTypeEnum.PULL_REQUEST_CREATED; + if (action?.action === 'review_requested') return ActionTypeEnum.PULL_REQUEST_REVIEW_REQUESTED; + if (action?.action === 'review_request_removed') return ActionTypeEnum.PULL_REQUEST_REVIEW_REQUEST_REMOVED; + if (action?.action === 'closed' && action?.merged === true) return ActionTypeEnum.BRANCH_MERGED; + return null; + case 'star': + if (action?.action === 'created') return ActionTypeEnum.STAR_ADDED; + if (action?.action === 'deleted') return ActionTypeEnum.STAR_REMOVED; + return null; + default: + return null; + } + } + + async handleWebhook(payload: any, signature: string, event: string) { + const { sender, repository, ...rest } = payload; + const actionType = this.defineActionType(event, rest); + if (!actionType) return; + + const users = await this.usersRepository.findOneByGithubId(sender.id); + const action = toGithubInterface(payload, actionType); + const actionReactions = await this.actionReactionRepository.getActionReactionByFilter({ + 'user._id': { $in: users.map((user) => user._id) }, + 'action.actionType': actionType, + 'action.repoName': repository.name, + }); + for (const actionReaction of actionReactions) { + for (const user of users) + if (actionReaction.user[0]._id.toString() === user._id.toString()) + await this.reactionService + .executeReaction(user, actionReaction.reaction, action) + .catch((err) => console.log(err)); + } + } +} diff --git a/api/src/github-api/services/webhook.service.ts b/api/src/github-api/services/webhook.service.ts new file mode 100644 index 0000000..4b2b053 --- /dev/null +++ b/api/src/github-api/services/webhook.service.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@nestjs/common'; +import { GithubWebhookRepository } from '../github-webhook.repository'; +import axios from 'axios'; +import { EnvironmentVariables } from '../../_utils/config'; +import { ConfigService } from '@nestjs/config'; +import { UserDocument } from '../../users/users.schema'; + +@Injectable() +export class WebhookService { + constructor( + private readonly webhookRepo: GithubWebhookRepository, + private readonly configService: ConfigService, + ) {} + + async createWebhook(user: UserDocument, repoName: string) { + const repo = await this.webhookRepo.getWebhookByParams({ + repoName, + userName: user.githubName, + }); + if (repo) return false; + + const url = `https://api.github.com/repos/${user.githubName}/${repoName}/hooks`; + const config = { + headers: { + Authorization: `Bearer ${user.githubAccessToken}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }; + const data = { + name: 'web', + active: true, + events: ['*'], + config: { + url: this.configService.get('GITHUB_CALLBACK_URL'), + content_type: 'json', + insecure_ssl: '0', + }, + }; + const response = await axios.post(url, data, config); + + if (response.status !== 201) return false; + await this.webhookRepo.createWebhook({ + repoId: response.data.id, + repoName, + userName: user.githubName, + }); + return true; + } + + getWebhook = (userName: string, repoName: string) => + this.webhookRepo.getWebhookByParams({ + repoName, + userName, + }); + + async removeWebhook(user: UserDocument, repoName: string) { + const webhook = await this.webhookRepo.getWebhookByParams({ + repoName, + userName: user.githubName, + }); + + if (!webhook) return false; + const url = `https://api.github.com/repos/${user.githubName}/${repoName}/hooks/${webhook.repoId}`; + const config = { + headers: { + Authorization: `Bearer ${user.githubAccessToken}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }; + const deletedWebhook = await axios.delete(url, config); + if (deletedWebhook.status !== 204) return false; + await this.webhookRepo.removeWebhook({ + repoName, + userName: user.githubName, + }); + return true; + } +} diff --git a/api/src/github-api/webhook.schema.ts b/api/src/github-api/webhook.schema.ts new file mode 100644 index 0000000..a314a49 --- /dev/null +++ b/api/src/github-api/webhook.schema.ts @@ -0,0 +1,18 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument } from 'mongoose'; + +export type WebhookDocument = HydratedDocument; + +@Schema() +export class Webhook { + @Prop({ required: true }) + repoName: string; + + @Prop({ required: true }) + userName: string; + + @Prop({ required: true }) + repoId: string; +} + +export const WebhookSchema = SchemaFactory.createForClass(Webhook); diff --git a/api/src/google-api/google-api.controller.ts b/api/src/google-api/google-api.controller.ts index 6778bed..3fbf01d 100644 --- a/api/src/google-api/google-api.controller.ts +++ b/api/src/google-api/google-api.controller.ts @@ -1,16 +1,4 @@ -import { Controller, Get } from '@nestjs/common'; -import { Protect } from '../auth/_utils/decorators/protect.decorator'; -import { ConnectedUser } from '../auth/_utils/decorators/connected-user.decorator'; -import { UserDocument } from '../users/users.schema'; -import { GoogleApiService } from './google-api.service'; +import { Controller } from '@nestjs/common'; @Controller('google') -export class GoogleApiController { - constructor(private readonly googleService: GoogleApiService) {} - - @Protect() - @Get() - generate(@ConnectedUser() user: UserDocument) { - return this.googleService.createDraft(user, 'test'); - } -} +export class GoogleApiController {} diff --git a/api/src/google-api/google-api.module.ts b/api/src/google-api/google-api.module.ts index 7459ccc..2df882e 100644 --- a/api/src/google-api/google-api.module.ts +++ b/api/src/google-api/google-api.module.ts @@ -3,8 +3,8 @@ import { GoogleApiService } from './google-api.service'; import { GoogleApiController } from './google-api.controller'; @Module({ - controllers: [GoogleApiController], - providers: [GoogleApiService], - exports: [GoogleApiService], + controllers: [GoogleApiController], + providers: [GoogleApiService], + exports: [GoogleApiService], }) export class GoogleApiModule {} diff --git a/api/src/google-api/google-api.service.spec.ts b/api/src/google-api/google-api.service.spec.ts index c3a864e..c592940 100644 --- a/api/src/google-api/google-api.service.spec.ts +++ b/api/src/google-api/google-api.service.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; + import { GoogleApiService } from './google-api.service'; describe('GoogleApiService', () => { diff --git a/api/src/google-api/google-api.service.ts b/api/src/google-api/google-api.service.ts index b1d8bd1..f462bc0 100644 --- a/api/src/google-api/google-api.service.ts +++ b/api/src/google-api/google-api.service.ts @@ -5,75 +5,90 @@ import { EnvironmentVariables } from '../_utils/config'; import { google } from 'googleapis'; import { GoogleLoginDto } from '../auth/_utils/dto/request/google-login.dto'; import { UserDocument } from '../users/users.schema'; +import { ReactionDocumentType } from '../reactions/schemas/reactions.schema'; @Injectable() export class GoogleApiService { - private readonly oAuth2Client: OAuth2Client; - constructor( - private readonly configService: ConfigService< - EnvironmentVariables, - true - >, - ) { - this.oAuth2Client = new google.auth.OAuth2( - this.configService.get('GOOGLE_CLIENT_ID'), - this.configService.get('GOOGLE_CLIENT_SECRET'), - this.configService.get('GOOGLE_CALLBACK_URL'), - ); - } + private readonly oAuth2Client: OAuth2Client; + constructor(private readonly configService: ConfigService) { + this.oAuth2Client = new google.auth.OAuth2( + this.configService.get('GOOGLE_CLIENT_ID'), + this.configService.get('GOOGLE_CLIENT_SECRET'), + this.configService.get('GOOGLE_CALLBACK_URL'), + ); + } - private setAccessToken(accessToken: string) { - this.oAuth2Client.setCredentials({ access_token: accessToken }); - } + private setAccessToken(accessToken: string) { + this.oAuth2Client.setCredentials({ access_token: accessToken }); + } - private getGoogleProfile(accessToken: string) { - this.setAccessToken(accessToken); - return google - .oauth2({ - auth: this.oAuth2Client, - version: 'v2', - }) - .userinfo.get() - .catch(() => { - throw new UnauthorizedException('Bad access token'); - }); + async testConnection(accessToken: string) { + try { + await this.getGoogleProfile(accessToken); + } catch (e) { + return false; } + return true; + } - private createMailFromInfo(to: string, subject: string, body: string) { - const message = - '----\n' + - `To: ${to}\n` + - `Subject: ${subject}\n` + - '\n' + - `${body}\n`; - return Buffer.from(message).toString('base64'); + private async getGoogleProfile(accessToken: string) { + this.setAccessToken(accessToken); + try { + return await google + .oauth2({ + auth: this.oAuth2Client, + version: 'v2', + }) + .userinfo.get(); + } catch { + throw new UnauthorizedException('Bad access token'); } + } - async createDraft(user: UserDocument, body: string) { - if (!user.googleAccessToken) - throw new UnauthorizedException('Not connected with google'); - this.setAccessToken(user.googleAccessToken); - const gmail = google.gmail({ version: 'v1', auth: this.oAuth2Client }); - return gmail.users.drafts - .create({ - userId: 'me', - requestBody: { - id: '', - message: { - raw: this.createMailFromInfo( - 'challon.quentin64@gmail.com', - 'Weather at: ' + new Date().toLocaleString('fr-FR'), - body, - ), - }, - }, - }) - .catch(() => { - throw new UnauthorizedException('Bad access token'); - }); - } + private createMailFromInfo(to: string, subject: string, body: string) { + const message = '----\n' + `To: ${to}\n` + `Subject: ${subject}\n` + '\n' + `${body}\n`; + return Buffer.from(message).toString('base64'); + } - async loginWithGoogle(googleLogin: GoogleLoginDto) { - return this.getGoogleProfile(googleLogin.accessToken); - } + async createDraft(user: UserDocument, reaction: ReactionDocumentType) { + if (reaction.reactionType !== 'CREATE_DRAFT') throw new UnauthorizedException('Bad reaction type'); + if (!user.googleAccessToken) throw new UnauthorizedException('Not connected with google'); + this.setAccessToken(user.googleAccessToken); + const gmail = google.gmail({ version: 'v1', auth: this.oAuth2Client }); + return gmail.users.drafts + .create({ + userId: 'me', + requestBody: { + id: '', + message: { + raw: this.createMailFromInfo(reaction.email, reaction.subject, reaction.body), + }, + }, + }) + .catch(() => { + throw new UnauthorizedException('Bad access token'); + }); + } + + async sendMail(user: UserDocument, reaction: ReactionDocumentType) { + if (reaction.reactionType !== 'SEND_EMAIL') throw new UnauthorizedException('Bad reaction type'); + if (!user.googleAccessToken) throw new UnauthorizedException('Not connected with google'); + this.setAccessToken(user.googleAccessToken); + const gmail = google.gmail({ version: 'v1', auth: this.oAuth2Client }); + return gmail.users.messages + .send({ + userId: 'me', + requestBody: { + id: '', + raw: this.createMailFromInfo(reaction.email, reaction.subject, reaction.body), + }, + }) + .catch(() => { + throw new UnauthorizedException('Bad access token'); + }); + } + + async loginWithGoogle(googleLogin: GoogleLoginDto) { + return this.getGoogleProfile(googleLogin.accessToken); + } } diff --git a/api/src/main.ts b/api/src/main.ts index 5a16819..32ca7b7 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -4,21 +4,21 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { ValidationPipe } from '@nestjs/common'; async function main() { - const app = await NestFactory.create(AppModule); - app.useGlobalPipes(new ValidationPipe()); - app.enableCors(); + const app = await NestFactory.create(AppModule); + app.useGlobalPipes(new ValidationPipe()); + app.enableCors(); - const config = new DocumentBuilder() - .setTitle('MakerAPI') - .setDescription( - 'This is the API used by the server of Maker AREA project. It should show you all the endpoints available to use.', - ) - .setVersion('0.2') - .addBearerAuth() - .build(); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api', app, document); - await app.listen(8080); + const config = new DocumentBuilder() + .setTitle('MakerAPI') + .setDescription( + 'This is the API used by the server of Maker AREA project. It should show you all the endpoints available to use.', + ) + .setVersion('0.2') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api', app, document); + await app.listen(8080); } main(); diff --git a/api/src/nasa/_utils/dto/response/photo-of-the-day.dto.ts b/api/src/nasa/_utils/dto/response/photo-of-the-day.dto.ts index c60603a..3ac014a 100644 --- a/api/src/nasa/_utils/dto/response/photo-of-the-day.dto.ts +++ b/api/src/nasa/_utils/dto/response/photo-of-the-day.dto.ts @@ -1,11 +1,11 @@ import { Url } from 'url'; export class NasaPhoto { - date: Date; - explanation: string; - hdurl: string | null; - media_type: string; - title: string; - url: Url | null; - thumbnail_url: Url | null; + date: Date; + explanation: string; + hdurl: string | null; + media_type: string; + title: string; + url: Url | null; + thumbnail_url: Url | null; } diff --git a/api/src/nasa/nasa.mapper.ts b/api/src/nasa/nasa.mapper.ts index 3ff72dc..acf3c9f 100644 --- a/api/src/nasa/nasa.mapper.ts +++ b/api/src/nasa/nasa.mapper.ts @@ -3,19 +3,17 @@ import { NasaPhoto } from './_utils/dto/response/photo-of-the-day.dto'; @Injectable() export class NasaMapper { - constructor() {} + constructor() {} - toNasaPhotoDto(nasaPhoto): NasaPhoto { - return { - date: new Date(nasaPhoto.date), - explanation: nasaPhoto.explanation, - hdurl: nasaPhoto.hdurl ? nasaPhoto.hdurl : null, - media_type: nasaPhoto.media_type, - title: nasaPhoto.title, - url: nasaPhoto.url ? nasaPhoto.url : null, - thumbnail_url: nasaPhoto.thumbnail_url - ? nasaPhoto.thumbnail_url - : null, - }; - } + toNasaPhotoDto(nasaPhoto): NasaPhoto { + return { + date: new Date(nasaPhoto.date), + explanation: nasaPhoto.explanation, + hdurl: nasaPhoto.hdurl ? nasaPhoto.hdurl : null, + media_type: nasaPhoto.media_type, + title: nasaPhoto.title, + url: nasaPhoto.url ? nasaPhoto.url : null, + thumbnail_url: nasaPhoto.thumbnail_url ? nasaPhoto.thumbnail_url : null, + }; + } } diff --git a/api/src/nasa/nasa.module.ts b/api/src/nasa/nasa.module.ts index 99bdf32..68a9683 100644 --- a/api/src/nasa/nasa.module.ts +++ b/api/src/nasa/nasa.module.ts @@ -1,11 +1,13 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { NasaService } from './nasa.service'; import { NasaMapper } from './nasa.mapper'; import { HttpModule } from '@nestjs/axios'; +import { ActionReactionModule } from '../action-reaction/action-reaction.module'; +import { ReactionsModule } from '../reactions/reactions.module'; @Module({ - imports: [HttpModule], - providers: [NasaService, NasaMapper], - exports: [NasaService], + imports: [HttpModule, forwardRef(() => ActionReactionModule), ReactionsModule], + providers: [NasaService, NasaMapper], + exports: [NasaService], }) export class NasaModule {} diff --git a/api/src/nasa/nasa.service.ts b/api/src/nasa/nasa.service.ts index d1b7aa0..06f0439 100644 --- a/api/src/nasa/nasa.service.ts +++ b/api/src/nasa/nasa.service.ts @@ -6,42 +6,58 @@ import { ConfigService } from '@nestjs/config'; import { EnvironmentVariables } from '../_utils/config'; import { Injectable } from '@nestjs/common'; import { NasaMapper } from './nasa.mapper'; +import { ActionDocument } from '../actions/schemas/actions.schema'; +import { Cron } from '@nestjs/schedule'; +import { ActionReactionRepository } from '../action-reaction/action-reaction.repository'; +import { ReactionsService } from '../reactions/reactions.service'; @Injectable() export class NasaService { - private readonly apiKey: string; - private latestPhoto: NasaPhoto; + private readonly apiKey: string; + private latestPhoto: NasaPhoto; - constructor( - private httpService: HttpService, - private readonly configService: ConfigService< - EnvironmentVariables, - true - >, - private readonly nasaMapper: NasaMapper, - ) { - this.apiKey = this.configService.get('NASA_API_KEY'); - } + constructor( + private httpService: HttpService, + private readonly configService: ConfigService, + private readonly nasaMapper: NasaMapper, + private readonly actionReactionRepository: ActionReactionRepository, + private readonly reactionService: ReactionsService, + ) { + this.apiKey = this.configService.get('NASA_API_KEY'); + } - private getPhotoOfTheDay(): Observable> { - const url = `https://api.nasa.gov/planetary/apod?api_key=${this.apiKey}&thumbs=true`; - return this.httpService.get(url); - } + private getPhotoOfTheDay(): Observable> { + const url = `https://api.nasa.gov/planetary/apod?api_key=${this.apiKey}&thumbs=true`; + return this.httpService.get(url); + } - async fetchPhotoOfTheDay() { - try { - if (this.latestPhoto) { - const today = new Date(); - if (today.getDate() === this.latestPhoto.date.getDate()) { - return this.latestPhoto; - } - } - const request = this.getPhotoOfTheDay(); - const photo = await firstValueFrom(request); - this.latestPhoto = this.nasaMapper.toNasaPhotoDto(photo.data); - return this.latestPhoto; - } catch (error) { - throw new Error(error); + async fetchPhotoOfTheDay(action: ActionDocument) { + try { + if (this.latestPhoto) { + const today = new Date(); + if (today.getDate() === this.latestPhoto.date.getDate()) { + return { photo: this.latestPhoto, hasChanged: false }; } + } + const request = this.getPhotoOfTheDay(); + const photo = await firstValueFrom(request); + this.latestPhoto = this.nasaMapper.toNasaPhotoDto(photo.data); + return { photo: this.latestPhoto, hasChanged: true }; + } catch (error) { + throw new Error(error); + } + } + + @Cron('0 * * * *') + async fetchPhotoOfTheDayCron() { + const photo = await this.fetchPhotoOfTheDay(null); + if (!photo.hasChanged) return; + const actions = await this.actionReactionRepository.getActionReactionByFilter({ + 'action.actionType': 'NASA_GET_APOD', + }); + for (const action of actions) { + const user = action.user; + await this.reactionService.executeReaction(user[0], action.reaction, photo).catch((err) => console.log(err)); } + } } diff --git a/api/src/reactions/_utils/dto/request/create-reaction.dto.ts b/api/src/reactions/_utils/dto/request/create-reaction.dto.ts new file mode 100644 index 0000000..90b5a7e --- /dev/null +++ b/api/src/reactions/_utils/dto/request/create-reaction.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { ReactionTypeEnum } from '../../enum/reaction-type.enum'; + +export class CreateReactionDto { + @ApiProperty({ enum: ReactionTypeEnum }) + @IsEnum(ReactionTypeEnum) + reactionType: ReactionTypeEnum; + + /* + * For Gmail draft + */ + @IsOptional() + @IsString() + destinationEmail?: string; + + /* + * For Gmail draft + */ + @IsOptional() + @IsString() + subject?: string; + + /* + * For Gmail draft + */ + @IsOptional() + @IsString() + body?: string; + + /* + * For Slack channel creation and message + */ + @IsOptional() + @IsString() + channelName?: string; + + /* + * For Slack message + */ + @IsOptional() + @IsString() + message?: string; +} diff --git a/api/src/reactions/_utils/enum/reaction-type.enum.ts b/api/src/reactions/_utils/enum/reaction-type.enum.ts index 96ba4d7..d104dd7 100644 --- a/api/src/reactions/_utils/enum/reaction-type.enum.ts +++ b/api/src/reactions/_utils/enum/reaction-type.enum.ts @@ -1,3 +1,6 @@ export enum ReactionTypeEnum { - CREATE_DRAFT = 'CREATE_DRAFT', + CREATE_DRAFT = 'CREATE_DRAFT', + SEND_SLACK_MESSAGE = 'SEND_SLACK_MESSAGE', + CREATE_SLACK_CHANNEL = 'CREATE_SLACK_CHANNEL', + SEND_EMAIL = 'SEND_EMAIL', } diff --git a/api/src/reactions/_utils/functions/use-variable-in-reaction.function.ts b/api/src/reactions/_utils/functions/use-variable-in-reaction.function.ts new file mode 100644 index 0000000..b67ad31 --- /dev/null +++ b/api/src/reactions/_utils/functions/use-variable-in-reaction.function.ts @@ -0,0 +1,21 @@ +import { ReactionDocumentType } from '../../schemas/reactions.schema'; + +export function mergeObjects(obj1, obj2) { + if (typeof obj1 !== 'object' || typeof obj2 !== 'object') { + throw new Error('Les deux arguments doivent être des objets.'); + } + const result = { ...obj2 }; + + for (const key in result) { + if (typeof result[key] !== 'string') continue; + if (result[key].includes('${')) { + const regex = /\${(.*?)}/g; + result[key] = result[key].replace(regex, (match, capture) => { + const value = obj1[capture.trim()]; + return value !== undefined ? value : match; + }); + } + } + + return result; +} diff --git a/api/src/reactions/reaction.repository.ts b/api/src/reactions/reaction.repository.ts index 37e8cfd..cd61e9c 100644 --- a/api/src/reactions/reaction.repository.ts +++ b/api/src/reactions/reaction.repository.ts @@ -1,27 +1,56 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { ReactionTypeEnum } from './_utils/enum/reaction-type.enum'; -import { Model } from 'mongoose'; +import { Model, Types } from 'mongoose'; import { CreateDraft } from './schemas/create-draft.schema'; import { Reaction } from './schemas/reactions.schema'; +import { UserDocument } from 'src/users/users.schema'; +import { SendSlackMessage } from './schemas/send-slack-message.schema'; +import { CreateSlackChannel } from './schemas/create-slack-channel.schema'; +import { SendEmail } from './schemas/send-email.schema'; @Injectable() export class ReactionRepository { - constructor( - @InjectModel(Reaction.name) private reactionModel: Model, - @InjectModel(ReactionTypeEnum.CREATE_DRAFT) - private createDraftModel: Model, - ) {} + constructor( + @InjectModel(Reaction.name) private reactionModel: Model, + @InjectModel(ReactionTypeEnum.CREATE_DRAFT) + private createDraftModel: Model, + @InjectModel(ReactionTypeEnum.SEND_SLACK_MESSAGE) + private sendSlackMessageModel: Model, + @InjectModel(ReactionTypeEnum.CREATE_SLACK_CHANNEL) + private createSlackChannelModel: Model, + @InjectModel(ReactionTypeEnum.SEND_EMAIL) + private sendEmailModel: Model, + ) {} - createDraft = (email: string) => - this.createDraftModel.create({ - email: email, - reactionType: ReactionTypeEnum.CREATE_DRAFT, - }); + createDraft = (email: string, body: string, subject: string) => + this.createDraftModel.create({ + email: email, + body: body, + subject: subject, + }); - getReactionById = (id: string) => - this.reactionModel - .findById(id) - .orFail(new NotFoundException('Reaction not found')) - .exec(); + createSlackChannel = (channelName: string) => + this.createSlackChannelModel.create({ + channelName: channelName, + }); + + sendSlackMessage = (channelName: string, message: string) => + this.sendSlackMessageModel.create({ + channelName: channelName, + message: message, + }); + + sendEmail = (email: string, body: string, subject: string) => + this.sendEmailModel.create({ + email: email, + body: body, + subject: subject, + }); + + getReactionById = (id: string, user: UserDocument) => + this.reactionModel.findOne({ _id: id }).orFail(new NotFoundException('Reaction not found')).exec(); + + removeReactionById = (id: Types.ObjectId) => + this.reactionModel.findOneAndDelete({ _id: id }).orFail(new NotFoundException('Reaction not found')).exec(); } diff --git a/api/src/reactions/reactions.controller.ts b/api/src/reactions/reactions.controller.ts index 9bb61a6..83d452a 100644 --- a/api/src/reactions/reactions.controller.ts +++ b/api/src/reactions/reactions.controller.ts @@ -1,7 +1,4 @@ import { Controller } from '@nestjs/common'; -import { ReactionsService } from './reactions.service'; @Controller('reactions') -export class ReactionsController { - constructor(private readonly reactionsService: ReactionsService) {} -} +export class ReactionsController {} diff --git a/api/src/reactions/reactions.module.ts b/api/src/reactions/reactions.module.ts index f478adc..962b5cb 100644 --- a/api/src/reactions/reactions.module.ts +++ b/api/src/reactions/reactions.module.ts @@ -6,24 +6,43 @@ import { Reaction, ReactionSchema } from './schemas/reactions.schema'; import { ReactionTypeEnum } from './_utils/enum/reaction-type.enum'; import { CreateDraftSchema } from './schemas/create-draft.schema'; import { ReactionRepository } from './reaction.repository'; +import { GoogleApiModule } from '../google-api/google-api.module'; +import { SendSlackMessageSchema } from './schemas/send-slack-message.schema'; +import { CreateSlackChannelSchema } from './schemas/create-slack-channel.schema'; +import { SlackModule } from '../slack/slack.module'; +import { SendEmailSchema } from './schemas/send-email.schema'; @Module({ - imports: [ - MongooseModule.forFeature([ - { - name: Reaction.name, - schema: ReactionSchema, - discriminators: [ - { - name: ReactionTypeEnum.CREATE_DRAFT, - schema: CreateDraftSchema, - }, - ], - }, - ]), - ], - controllers: [ReactionsController], - providers: [ReactionsService, ReactionRepository], - exports: [ReactionRepository], + imports: [ + MongooseModule.forFeature([ + { + name: Reaction.name, + schema: ReactionSchema, + discriminators: [ + { + name: ReactionTypeEnum.CREATE_DRAFT, + schema: CreateDraftSchema, + }, + { + name: ReactionTypeEnum.SEND_SLACK_MESSAGE, + schema: SendSlackMessageSchema, + }, + { + name: ReactionTypeEnum.CREATE_SLACK_CHANNEL, + schema: CreateSlackChannelSchema, + }, + { + name: ReactionTypeEnum.SEND_EMAIL, + schema: SendEmailSchema, + }, + ], + }, + ]), + GoogleApiModule, + SlackModule, + ], + controllers: [ReactionsController], + providers: [ReactionsService, ReactionRepository], + exports: [ReactionRepository, ReactionsService], }) export class ReactionsModule {} diff --git a/api/src/reactions/reactions.service.ts b/api/src/reactions/reactions.service.ts index c3d1f17..4835b4b 100644 --- a/api/src/reactions/reactions.service.ts +++ b/api/src/reactions/reactions.service.ts @@ -1,4 +1,78 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ReactionDocumentType } from './schemas/reactions.schema'; +import { UserDocument } from '../users/users.schema'; +import { GoogleApiService } from '../google-api/google-api.service'; +import { CreateReactionDto } from './_utils/dto/request/create-reaction.dto'; +import { ReactionRepository } from './reaction.repository'; +import { mergeObjects } from './_utils/functions/use-variable-in-reaction.function'; +import { SlackService } from '../slack/slack.service'; @Injectable() -export class ReactionsService {} +export class ReactionsService { + private readonly reactions: { + CREATE_DRAFT: (user: UserDocument, reaction: ReactionDocumentType) => Promise; + CREATE_SLACK_CHANNEL: (user: UserDocument, reaction: ReactionDocumentType) => Promise; + SEND_SLACK_MESSAGE: (user: UserDocument, reaction: ReactionDocumentType) => Promise; + SEND_EMAIL: (user: UserDocument, reaction: ReactionDocumentType) => Promise; + }; + + private readonly bodyNeeded: { + CREATE_DRAFT: string[]; + CREATE_SLACK_CHANNEL: string[]; + SEND_SLACK_MESSAGE: string[]; + SEND_EMAIL: string[]; + }; + + constructor( + private readonly googleApiService: GoogleApiService, + private readonly reactionsRepository: ReactionRepository, + private readonly slackService: SlackService, + ) { + this.reactions = { + CREATE_DRAFT: async (user: UserDocument, reaction: ReactionDocumentType) => { + return await this.googleApiService.createDraft(user, reaction); + }, + CREATE_SLACK_CHANNEL: async (user: UserDocument, reaction: ReactionDocumentType) => { + return await this.slackService.createChannel(user, reaction); + }, + SEND_SLACK_MESSAGE: async (user: UserDocument, reaction: ReactionDocumentType) => { + return await this.slackService.sendMessage(user, reaction); + }, + SEND_EMAIL: async (user: UserDocument, reaction: ReactionDocumentType) => { + return await this.googleApiService.sendMail(user, reaction); + }, + }; + this.bodyNeeded = { + CREATE_DRAFT: ['destinationEmail', 'subject', 'body'], + CREATE_SLACK_CHANNEL: ['channelName'], + SEND_SLACK_MESSAGE: ['channelName', 'message'], + SEND_EMAIL: ['destinationEmail', 'subject', 'body'], + }; + } + + async executeReaction(user: UserDocument, reaction: ReactionDocumentType, actionVar: any) { + if (!reaction) return; + console.log("c'est passe"); + return this.reactions[reaction.reactionType](user, mergeObjects(actionVar, reaction)); + } + + async createReaction(user: UserDocument, data: CreateReactionDto) { + for (const key of this.bodyNeeded[data.reactionType]) { + if (!data[key]) { + throw new BadRequestException(`You need to provide ${key} to create this reaction`); + } + } + switch (data.reactionType) { + case 'CREATE_DRAFT': + return this.reactionsRepository.createDraft(data.destinationEmail, data.body, data.subject); + case 'SEND_SLACK_MESSAGE': + return this.reactionsRepository.sendSlackMessage(data.channelName, data.message); + case 'CREATE_SLACK_CHANNEL': + return this.reactionsRepository.createSlackChannel(data.channelName); + case 'SEND_EMAIL': + return this.reactionsRepository.sendEmail(data.destinationEmail, data.body, data.subject); + default: + throw new BadRequestException('Y a pas ca ici'); + } + } +} diff --git a/api/src/reactions/schemas/create-draft.schema.ts b/api/src/reactions/schemas/create-draft.schema.ts index 3c5ad68..9be2a73 100644 --- a/api/src/reactions/schemas/create-draft.schema.ts +++ b/api/src/reactions/schemas/create-draft.schema.ts @@ -8,10 +8,16 @@ export type CreateDraftDocument = HydratedDocument; @Schema() export class CreateDraft extends OmitType(Reaction, ['reactionType'] as const) { - reactionType: ReactionTypeEnum.CREATE_DRAFT; + reactionType: ReactionTypeEnum.CREATE_DRAFT; - @Prop({ required: true }) - email: string; + @Prop({ required: true }) + email: string; + + @Prop({ required: true }) + subject: string; + + @Prop({ required: true }) + body: string; } export const CreateDraftSchema = SchemaFactory.createForClass(CreateDraft); diff --git a/api/src/reactions/schemas/create-slack-channel.schema.ts b/api/src/reactions/schemas/create-slack-channel.schema.ts new file mode 100644 index 0000000..9ef99dc --- /dev/null +++ b/api/src/reactions/schemas/create-slack-channel.schema.ts @@ -0,0 +1,17 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { OmitType } from '@nestjs/swagger'; +import { ReactionTypeEnum } from '../_utils/enum/reaction-type.enum'; +import { Reaction } from './reactions.schema'; +import { HydratedDocument } from 'mongoose'; + +export type CreateSlackChannelDocument = HydratedDocument; + +@Schema() +export class CreateSlackChannel extends OmitType(Reaction, ['reactionType'] as const) { + reactionType: ReactionTypeEnum.CREATE_SLACK_CHANNEL; + + @Prop({ required: true }) + channelName: string; +} + +export const CreateSlackChannelSchema = SchemaFactory.createForClass(CreateSlackChannel); diff --git a/api/src/reactions/schemas/reactions.schema.ts b/api/src/reactions/schemas/reactions.schema.ts index e47ac72..e51681d 100644 --- a/api/src/reactions/schemas/reactions.schema.ts +++ b/api/src/reactions/schemas/reactions.schema.ts @@ -1,13 +1,23 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { ReactionTypeEnum } from '../_utils/enum/reaction-type.enum'; import { HydratedDocument } from 'mongoose'; +import { CreateDraftDocument } from './create-draft.schema'; +import { SendSlackMessageDocument } from './send-slack-message.schema'; +import { CreateSlackChannelDocument } from './create-slack-channel.schema'; +import { SendEmailDocument } from './send-email.schema'; export type ReactionDocument = HydratedDocument; -@Schema({ discriminatorKey: 'actionType' }) +export type ReactionDocumentType = + | CreateDraftDocument + | SendSlackMessageDocument + | CreateSlackChannelDocument + | SendEmailDocument; + +@Schema({ discriminatorKey: 'reactionType' }) export class Reaction { - @Prop({ required: true, enum: ReactionTypeEnum, type: String }) - reactionType: ReactionTypeEnum; + @Prop({ required: true, enum: ReactionTypeEnum, type: String }) + reactionType: ReactionTypeEnum; } export const ReactionSchema = SchemaFactory.createForClass(Reaction); diff --git a/api/src/reactions/schemas/send-email.schema.ts b/api/src/reactions/schemas/send-email.schema.ts new file mode 100644 index 0000000..5aa77b4 --- /dev/null +++ b/api/src/reactions/schemas/send-email.schema.ts @@ -0,0 +1,23 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { OmitType } from '@nestjs/swagger'; +import { Reaction } from './reactions.schema'; +import { ReactionTypeEnum } from '../_utils/enum/reaction-type.enum'; +import { HydratedDocument } from 'mongoose'; + +export type SendEmailDocument = HydratedDocument; + +@Schema() +export class SendEmail extends OmitType(Reaction, ['reactionType'] as const) { + reactionType: ReactionTypeEnum.SEND_EMAIL; + + @Prop({ required: true }) + email: string; + + @Prop({ required: true }) + subject: string; + + @Prop({ required: true }) + body: string; +} + +export const SendEmailSchema = SchemaFactory.createForClass(SendEmail); diff --git a/api/src/reactions/schemas/send-slack-message.schema.ts b/api/src/reactions/schemas/send-slack-message.schema.ts new file mode 100644 index 0000000..2d29a37 --- /dev/null +++ b/api/src/reactions/schemas/send-slack-message.schema.ts @@ -0,0 +1,20 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { OmitType } from '@nestjs/swagger'; +import { Reaction } from './reactions.schema'; +import { ReactionTypeEnum } from '../_utils/enum/reaction-type.enum'; +import { HydratedDocument } from 'mongoose'; + +export type SendSlackMessageDocument = HydratedDocument; + +@Schema() +export class SendSlackMessage extends OmitType(Reaction, ['reactionType'] as const) { + reactionType: ReactionTypeEnum.SEND_SLACK_MESSAGE; + + @Prop({ required: true }) + channelName: string; + + @Prop({ required: true }) + message: string; +} + +export const SendSlackMessageSchema = SchemaFactory.createForClass(SendSlackMessage); diff --git a/api/src/slack/_utils/dto/update-slack-bot.dto.ts b/api/src/slack/_utils/dto/update-slack-bot.dto.ts new file mode 100644 index 0000000..30a78e1 --- /dev/null +++ b/api/src/slack/_utils/dto/update-slack-bot.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class UpdateSlackBotDto { + @IsString() + botToken: string; +} diff --git a/api/src/slack/slack.controller.ts b/api/src/slack/slack.controller.ts new file mode 100644 index 0000000..c5087f9 --- /dev/null +++ b/api/src/slack/slack.controller.ts @@ -0,0 +1,20 @@ +import { Body, Controller, Patch } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ConnectedUser } from 'src/auth/_utils/decorators/connected-user.decorator'; +import { Protect } from 'src/auth/_utils/decorators/protect.decorator'; +import { UpdateSlackBotDto } from 'src/slack/_utils/dto/update-slack-bot.dto'; +import { UsersRepository } from 'src/users/users.repository'; +import { UserDocument } from 'src/users/users.schema'; + +@ApiTags('Slack') +@Controller('slack') +export class SlackController { + constructor(private readonly usersRepository: UsersRepository) {} + + @Protect() + @Patch('update-slack-bot') + updateSlackBot(@ConnectedUser() user: UserDocument, @Body() updateSlackBotDto: UpdateSlackBotDto) { + this.usersRepository.updateOneById(user._id, { slackBotToken: updateSlackBotDto.botToken }); + return { message: 'Slack bot token updated successfully' }; + } +} diff --git a/api/src/slack/slack.module.ts b/api/src/slack/slack.module.ts new file mode 100644 index 0000000..1db16cc --- /dev/null +++ b/api/src/slack/slack.module.ts @@ -0,0 +1,12 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { SlackController } from './slack.controller'; +import { UsersModule } from 'src/users/users.module'; +import { SlackService } from './slack.service'; + +@Module({ + imports: [forwardRef(() => UsersModule)], + controllers: [SlackController], + providers: [SlackService], + exports: [SlackService], +}) +export class SlackModule {} diff --git a/api/src/slack/slack.service.ts b/api/src/slack/slack.service.ts new file mode 100644 index 0000000..de88636 --- /dev/null +++ b/api/src/slack/slack.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { UserDocument } from '../users/users.schema'; +import { ReactionDocumentType } from '../reactions/schemas/reactions.schema'; +import { WebClient } from '@slack/web-api'; + +@Injectable() +export class SlackService { + constructor() {} + + async sendMessage(user: UserDocument, reaction: ReactionDocumentType) { + if (reaction.reactionType !== 'SEND_SLACK_MESSAGE') return; + const token = user.slackBotToken; + const web = new WebClient(token); + try { + await web.chat.postMessage({ + channel: reaction.channelName, + text: reaction.message, + }); + } catch (e) { + console.log(e); + } + } + + async createChannel(user: UserDocument, reaction: ReactionDocumentType) { + if (reaction.reactionType !== 'CREATE_SLACK_CHANNEL') return; + const token = user.slackBotToken; + const web = new WebClient(token); + try { + await web.conversations.create({ + name: reaction.channelName, + }); + } catch (e) { + console.log(e); + } + } +} diff --git a/api/src/task.service.ts b/api/src/task.service.ts deleted file mode 100644 index 762cb4b..0000000 --- a/api/src/task.service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; - -@Injectable() -export class TasksService { - private readonly logger = new Logger(TasksService.name); - - @Cron('10 * * * * *') - handleCron() { - this.logger.debug('Called when the current second is 10'); - } -} diff --git a/api/src/timer/timer.controller.spec.ts b/api/src/timer/timer.controller.spec.ts new file mode 100644 index 0000000..506aeb8 --- /dev/null +++ b/api/src/timer/timer.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TimerController } from './timer.controller'; +import { TimerService } from './timer.service'; + +describe('TimerController', () => { + let controller: TimerController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TimerController], + providers: [TimerService], + }).compile(); + + controller = module.get(TimerController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/api/src/timer/timer.controller.ts b/api/src/timer/timer.controller.ts new file mode 100644 index 0000000..b9eb21f --- /dev/null +++ b/api/src/timer/timer.controller.ts @@ -0,0 +1,4 @@ +import { Controller } from '@nestjs/common'; + +@Controller('timer') +export class TimerController {} diff --git a/api/src/timer/timer.module.ts b/api/src/timer/timer.module.ts new file mode 100644 index 0000000..3536f23 --- /dev/null +++ b/api/src/timer/timer.module.ts @@ -0,0 +1,13 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { TimerService } from './timer.service'; +import { TimerController } from './timer.controller'; +import { ActionReactionModule } from '../action-reaction/action-reaction.module'; +import { ReactionsModule } from '../reactions/reactions.module'; + +@Module({ + imports: [forwardRef(() => ActionReactionModule), ReactionsModule], + controllers: [TimerController], + providers: [TimerService], + exports: [TimerService], +}) +export class TimerModule {} diff --git a/api/src/timer/timer.service.spec.ts b/api/src/timer/timer.service.spec.ts new file mode 100644 index 0000000..bbb7547 --- /dev/null +++ b/api/src/timer/timer.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TimerService } from './timer.service'; + +describe('TimerService', () => { + let service: TimerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TimerService], + }).compile(); + + service = module.get(TimerService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/api/src/timer/timer.service.ts b/api/src/timer/timer.service.ts new file mode 100644 index 0000000..7d131dd --- /dev/null +++ b/api/src/timer/timer.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { CronJob } from 'cron'; +import { ActionDocumentType } from '../actions/schemas/actions.schema'; +import { ActionReactionRepository } from '../action-reaction/action-reaction.repository'; +import { ReactionsService } from '../reactions/reactions.service'; + +@Injectable() +export class TimerService { + constructor( + private schedulerRegistry: SchedulerRegistry, + private readonly actionReactionRepository: ActionReactionRepository, + private readonly reactionService: ReactionsService, + ) {} + + private dateToCron(date: Date) { + return `${date.getSeconds()} ${date.getMinutes()} ${date.getHours() - 1} ${date.getDate()} ${ + date.getMonth() + 1 + } *`; + } + + private executeCron(cronName: string, date: Date, callback: () => void) { + const job = this.schedulerRegistry.getCronJob(cronName); + if (!job) return; + if (new Date().getFullYear() === date.getFullYear()) { + callback(); + job.stop(); + this.schedulerRegistry.deleteCronJob(cronName); + } + } + + async waitForThisDate(action: ActionDocumentType) { + if (action.actionType !== 'TIMER') return; + const cronName = `timer-${action._id}`; + + console.log(this.dateToCron(action.date)); + const job = new CronJob(this.dateToCron(action.date), () => { + this.executeCron(cronName, action.date, async () => { + const actionsReactions = await this.actionReactionRepository.getActionReactionByFilter({ + 'action._id': action._id, + }); + for (const actionReaction of actionsReactions) + await this.reactionService.executeReaction( + actionReaction.user[0], + actionReaction.reaction, + actionReaction.action, + ); + }); + }); + this.schedulerRegistry.addCronJob(cronName, job); + job.start(); + } +} diff --git a/api/src/users/_utils/dto/request/add-github-token.dto.ts b/api/src/users/_utils/dto/request/add-github-token.dto.ts new file mode 100644 index 0000000..3354a6b --- /dev/null +++ b/api/src/users/_utils/dto/request/add-github-token.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AddGithubTokenDto { + @ApiProperty() + githubToken: string; +} diff --git a/api/src/users/_utils/dto/request/create-user.dto.ts b/api/src/users/_utils/dto/request/create-user.dto.ts index a593cc8..810ac59 100644 --- a/api/src/users/_utils/dto/request/create-user.dto.ts +++ b/api/src/users/_utils/dto/request/create-user.dto.ts @@ -3,19 +3,19 @@ import { IsSecurePassword } from '../../../../_utils/decorators/is-secure-passwo import { ApiProperty } from '@nestjs/swagger'; export class CreateUserDto { - @IsString() - @IsNotEmpty() - username: string; + @IsString() + @IsNotEmpty() + username: string; - @IsString() - @IsNotEmpty() - fullName: string; + @IsString() + @IsNotEmpty() + fullName: string; - @ApiProperty({ example: 'email@person.com' }) - @IsEmail() - @IsNotEmpty() - email: string; + @ApiProperty({ example: 'email@person.com' }) + @IsEmail() + @IsNotEmpty() + email: string; - @IsSecurePassword() - password: string; + @IsSecurePassword() + password: string; } diff --git a/api/src/users/_utils/dto/request/update-google-token.dto.ts b/api/src/users/_utils/dto/request/update-google-token.dto.ts new file mode 100644 index 0000000..f062a71 --- /dev/null +++ b/api/src/users/_utils/dto/request/update-google-token.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class UpdateGoogleTokenDto { + @IsString() + googleToken: string; +} diff --git a/api/src/users/_utils/dto/request/update-user.dto.ts b/api/src/users/_utils/dto/request/update-user.dto.ts new file mode 100644 index 0000000..359abf4 --- /dev/null +++ b/api/src/users/_utils/dto/request/update-user.dto.ts @@ -0,0 +1,17 @@ +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateUserDto { + @IsString() + @IsNotEmpty() + username: string; + + @IsString() + @IsNotEmpty() + fullName: string; + + @ApiProperty({ example: 'mail@test.com' }) + @IsEmail() + @IsNotEmpty() + email: string; +} diff --git a/api/src/users/_utils/dto/response/get-user.dto.ts b/api/src/users/_utils/dto/response/get-user.dto.ts index 389a1d7..1926e24 100644 --- a/api/src/users/_utils/dto/response/get-user.dto.ts +++ b/api/src/users/_utils/dto/response/get-user.dto.ts @@ -1,6 +1,9 @@ export class GetUserDto { - id: string; - username: string; - fullName?: string; - email: string; + id: string; + username: string; + fullName?: string; + email: string; + isLoggedInGoogle: boolean; + isLoggedInGithub: boolean; + slackConnected: boolean; } diff --git a/api/src/users/_utils/google-user-creation.interface.ts b/api/src/users/_utils/google-user-creation.interface.ts index e16ff38..a1d1d96 100644 --- a/api/src/users/_utils/google-user-creation.interface.ts +++ b/api/src/users/_utils/google-user-creation.interface.ts @@ -1,6 +1,6 @@ export interface GoogleUserCreationInterface { - fullName: string; - email: string; - googleAccessToken: string; - googleId: string; + fullName: string; + email: string; + googleAccessToken: string; + googleId: string; } diff --git a/api/src/users/users.controller.spec.ts b/api/src/users/users.controller.spec.ts index 24e887c..37e37ec 100644 --- a/api/src/users/users.controller.spec.ts +++ b/api/src/users/users.controller.spec.ts @@ -6,29 +6,29 @@ import { Model } from 'mongoose'; import { UsersMapper } from './users.mapper'; describe('UsersController', () => { - let controller: UsersController; - let service: UsersService; + let controller: UsersController; + let service: UsersService; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [UsersController], - providers: [ - UsersService, - UsersMapper, - UsersRepository, - { - provide: 'UserModel', - useValue: Model, - }, - ], - }).compile(); + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UsersController], + providers: [ + UsersService, + UsersMapper, + UsersRepository, + { + provide: 'UserModel', + useValue: Model, + }, + ], + }).compile(); - controller = module.get(UsersController); - service = module.get(UsersService); - }); + controller = module.get(UsersController); + service = module.get(UsersService); + }); - it('should be defined', () => { - expect(controller).toBeDefined(); - expect(service).toBeDefined(); - }); + it('should be defined', () => { + expect(controller).toBeDefined(); + expect(service).toBeDefined(); + }); }); diff --git a/api/src/users/users.controller.ts b/api/src/users/users.controller.ts index d5fdb90..a1150b5 100644 --- a/api/src/users/users.controller.ts +++ b/api/src/users/users.controller.ts @@ -1,16 +1,48 @@ -import { Controller, Get } from '@nestjs/common'; +import { Body, Controller, Get, Post, Put } from '@nestjs/common'; import { UsersMapper } from './users.mapper'; import { Protect } from '../auth/_utils/decorators/protect.decorator'; import { ConnectedUser } from '../auth/_utils/decorators/connected-user.decorator'; import { UserDocument } from './users.schema'; +import { UsersService } from './users.service'; +import { ApiTags } from '@nestjs/swagger'; +import { AddGithubTokenDto } from './_utils/dto/request/add-github-token.dto'; +import { UpdateUserDto } from './_utils/dto/request/update-user.dto'; +import { UpdateGoogleTokenDto } from './_utils/dto/request/update-google-token.dto'; +@ApiTags('users') @Controller('users') export class UsersController { - constructor(private readonly usersMapper: UsersMapper) {} + constructor( + private readonly usersMapper: UsersMapper, + private readonly usersService: UsersService, + ) {} - @Protect() - @Get('me') - getMe(@ConnectedUser() user: UserDocument) { - return this.usersMapper.toGetUserDto(user); + @Protect() + @Get('me') + getMe(@ConnectedUser() user: UserDocument) { + return this.usersMapper.toGetUserDto(user); + } + + @Protect() + @Post('github-token') + async updateGithubToken(@ConnectedUser() user: UserDocument, @Body() body: AddGithubTokenDto) { + if (body.githubToken == 'none') { + await this.usersService.removeGithubToken(user); + return this.usersMapper.toGetUserDto(user); } + const updatedUser = await this.usersService.updateGithubToken(user, body.githubToken); + return this.usersMapper.toGetUserDto(updatedUser); + } + + @Protect() + @Post('google-token') + updateGoogleToken(@ConnectedUser() user: UserDocument, @Body() body: UpdateGoogleTokenDto) { + return this.usersService.updateGoogleToken(user, body.googleToken); + } + + @Protect() + @Put() + update(@ConnectedUser() user: UserDocument, @Body() body: UpdateUserDto) { + return this.usersService.update(user, body); + } } diff --git a/api/src/users/users.mapper.ts b/api/src/users/users.mapper.ts index de6c36b..72edfe3 100644 --- a/api/src/users/users.mapper.ts +++ b/api/src/users/users.mapper.ts @@ -1,13 +1,24 @@ import { Injectable } from '@nestjs/common'; import { UserDocument } from './users.schema'; import { GetUserDto } from './_utils/dto/response/get-user.dto'; +import { GoogleApiService } from '../google-api/google-api.service'; @Injectable() export class UsersMapper { - toGetUserDto = (user: UserDocument): GetUserDto => ({ - id: user._id.toString(), - username: user.username, - fullName: user.fullName ?? undefined, - email: user.email, - }); + constructor(private readonly googleApiService: GoogleApiService) {} + + async toGetUserDto(user: UserDocument): Promise { + const googleConnected = + user.googleAccessToken !== null ? await this.googleApiService.testConnection(user.googleAccessToken) : false; + const slackConnected = user.slackBotToken !== null; + return { + id: user._id.toString(), + username: user.username ?? '', + fullName: user.fullName ?? undefined, + email: user.email, + isLoggedInGoogle: googleConnected, + isLoggedInGithub: user.githubAccessToken != null, + slackConnected, + }; + } } diff --git a/api/src/users/users.module.ts b/api/src/users/users.module.ts index 51b0ce1..b1f101b 100644 --- a/api/src/users/users.module.ts +++ b/api/src/users/users.module.ts @@ -5,13 +5,13 @@ import { MongooseModule } from '@nestjs/mongoose'; import { User, UserSchema } from './users.schema'; import { UsersRepository } from './users.repository'; import { UsersMapper } from './users.mapper'; +import { GithubApiModule } from '../github-api/github-api.module'; +import { GoogleApiModule } from '../google-api/google-api.module'; @Module({ - imports: [ - MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), - ], - controllers: [UsersController], - providers: [UsersService, UsersRepository, UsersMapper], - exports: [UsersService, UsersRepository, UsersMapper], + imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), GithubApiModule, GoogleApiModule], + controllers: [UsersController], + providers: [UsersService, UsersRepository, UsersMapper], + exports: [UsersService, UsersRepository, UsersMapper], }) export class UsersModule {} diff --git a/api/src/users/users.repository.ts b/api/src/users/users.repository.ts index 44f2178..b836442 100644 --- a/api/src/users/users.repository.ts +++ b/api/src/users/users.repository.ts @@ -8,43 +8,36 @@ import { GoogleUserCreationInterface } from './_utils/google-user-creation.inter @Injectable() export class UsersRepository { - constructor( - @InjectModel(User.name) private userModel: Model, - ) {} - - create = (userDto: CreateUserDto) => - this.userModel.create({ - username: userDto.username, - fullName: userDto.fullName, - email: userDto.email, - password: hashSync(userDto.password, 10), - }); - - createOAuthUser = (userInfo: GoogleUserCreationInterface) => - this.userModel.create({ - fullName: userInfo.fullName, - email: userInfo.email, - password: null, - username: null, - googleId: userInfo.googleId, - googleAccessToken: userInfo.googleAccessToken, - }); - - findOneByGoogleId = (googleId: string) => - this.userModel.findOne({ googleId: googleId }).exec(); - - findOneByEmail = (email: string) => - this.userModel.findOne({ email: email }).exec(); - - findOneByUsername = (username: string) => - this.userModel.findOne({ username: username }).exec(); - - findById = (id: string) => - this.userModel - .findById(id) - .orFail(new NotFoundException('User not found')) - .exec(); - - updateOneById = (id: Types.ObjectId, update: Partial) => - this.userModel.findByIdAndUpdate(id, update, { new: true }).exec(); + constructor(@InjectModel(User.name) private userModel: Model) {} + + create = (userDto: CreateUserDto) => + this.userModel.create({ + username: userDto.username, + fullName: userDto.fullName, + email: userDto.email, + password: hashSync(userDto.password, 10), + }); + + createOAuthUser = (userInfo: GoogleUserCreationInterface) => + this.userModel.create({ + fullName: userInfo.fullName, + email: userInfo.email, + password: null, + username: null, + googleId: userInfo.googleId, + googleAccessToken: userInfo.googleAccessToken, + }); + + findOneByGoogleId = (googleId: string) => this.userModel.findOne({ googleId: googleId }).exec(); + + findOneByGithubId = (githubId: string) => this.userModel.find({ githubId: githubId }).exec(); + + findOneByEmail = (email: string) => this.userModel.findOne({ email: email }).exec(); + + findOneByUsername = (username: string) => this.userModel.findOne({ username: username }).exec(); + + findById = (id: string) => this.userModel.findById(id).orFail(new NotFoundException('User not found')).exec(); + + updateOneById = (id: Types.ObjectId, update: Partial) => + this.userModel.findByIdAndUpdate(id, update, { new: true }).exec(); } diff --git a/api/src/users/users.schema.ts b/api/src/users/users.schema.ts index 20842f0..66a769b 100644 --- a/api/src/users/users.schema.ts +++ b/api/src/users/users.schema.ts @@ -5,26 +5,38 @@ export type UserDocument = HydratedDocument; @Schema() export class User { - @Prop({ default: null }) - username: string | null; + @Prop({ default: null }) + username: string | null; - @Prop({ required: true }) - fullName: string; + @Prop({ required: true }) + fullName: string; - @Prop({ required: true }) - email: string; + @Prop({ required: true }) + email: string; - @Prop({ default: null }) - password: string | null; + @Prop({ default: null }) + password: string | null; - @Prop({ default: null }) - googleId: string | null; + @Prop({ default: null }) + googleId: string | null; - @Prop({ default: null }) - googleAccessToken: string | null; + @Prop({ default: null }) + googleAccessToken: string | null; - @Prop({ default: null }) - googleRefreshToken: string | null; + @Prop({ default: null }) + googleRefreshToken: string | null; + + @Prop({ default: null }) + slackBotToken: string | null; + + @Prop({ default: null }) + githubId: string | null; + + @Prop({ default: null }) + githubAccessToken: string | null; + + @Prop({ default: null }) + githubName: string | null; } export const UserSchema = SchemaFactory.createForClass(User); diff --git a/api/src/users/users.service.spec.ts b/api/src/users/users.service.spec.ts index e18890c..194f82c 100644 --- a/api/src/users/users.service.spec.ts +++ b/api/src/users/users.service.spec.ts @@ -4,24 +4,24 @@ import { UsersRepository } from './users.repository'; import { Model } from 'mongoose'; describe('UsersService', () => { - let service: UsersService; + let service: UsersService; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - UsersService, - UsersRepository, - { - provide: 'UserModel', - useValue: Model, - }, - ], - }).compile(); + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + UsersRepository, + { + provide: 'UserModel', + useValue: Model, + }, + ], + }).compile(); - service = module.get(UsersService); - }); + service = module.get(UsersService); + }); - it('should be defined', () => { - expect(service).toBeDefined(); - }); + it('should be defined', () => { + expect(service).toBeDefined(); + }); }); diff --git a/api/src/users/users.service.ts b/api/src/users/users.service.ts index b469632..9c2e582 100644 --- a/api/src/users/users.service.ts +++ b/api/src/users/users.service.ts @@ -1,17 +1,62 @@ -import { ConflictException, Injectable } from '@nestjs/common'; +import { ConflictException, Injectable, InternalServerErrorException, UnauthorizedException } from '@nestjs/common'; import { UsersRepository } from './users.repository'; import { CreateUserDto } from './_utils/dto/request/create-user.dto'; +import { UserDocument } from './users.schema'; +import { GithubApiService } from '../github-api/services/github-api.service'; +import { UpdateUserDto } from './_utils/dto/request/update-user.dto'; +import { UsersMapper } from './users.mapper'; +import { GoogleApiService } from '../google-api/google-api.service'; @Injectable() export class UsersService { - constructor(private readonly usersRepository: UsersRepository) {} + constructor( + private readonly usersRepository: UsersRepository, + private readonly githubApiService: GithubApiService, + private readonly userMapper: UsersMapper, + private readonly googleService: GoogleApiService, + ) {} - async create(userDto: CreateUserDto) { - if (await this.usersRepository.findOneByEmail(userDto.email)) - throw new ConflictException('Email already exists'); - if (await this.usersRepository.findOneByUsername(userDto.username)) - throw new ConflictException('Username already exists'); + async create(userDto: CreateUserDto) { + if (await this.usersRepository.findOneByEmail(userDto.email)) throw new ConflictException('Email already exists'); + if (await this.usersRepository.findOneByUsername(userDto.username)) + throw new ConflictException('Username already exists'); - return this.usersRepository.create(userDto); - } + return this.usersRepository.create(userDto); + } + + async updateGithubToken(user: UserDocument, githubToken: string) { + const myGithubProfile = await this.githubApiService.getGithubUser(githubToken); + + return this.usersRepository.updateOneById(user._id, { + githubAccessToken: githubToken, + githubId: myGithubProfile.id, + githubName: myGithubProfile.login, + }); + } + + async updateGoogleToken(user: UserDocument, googleToken: string) { + if (googleToken === 'none') + return await this.usersRepository + .updateOneById(user._id, { googleAccessToken: null }) + .then(this.userMapper.toGetUserDto); + + if (!(await this.googleService.testConnection(googleToken))) throw new UnauthorizedException('Bad access token'); + const newUser = await this.usersRepository.updateOneById(user._id, { googleAccessToken: googleToken }); + if (!newUser) throw new InternalServerErrorException('Fail to update user'); + return this.userMapper.toGetUserDto(newUser); + } + + async removeGithubToken(user: UserDocument) { + return this.usersRepository.updateOneById(user._id, { + githubAccessToken: null, + githubId: null, + githubName: null, + }); + } + + async update(user: UserDocument, body: UpdateUserDto) { + user = await this.usersRepository.updateOneById(user._id, body); + + return this.userMapper.toGetUserDto(user); + } } diff --git a/api/src/weather/_utils/create-interface.function.ts b/api/src/weather/_utils/create-interface.function.ts new file mode 100644 index 0000000..140d4fb --- /dev/null +++ b/api/src/weather/_utils/create-interface.function.ts @@ -0,0 +1,47 @@ +import { WeatherInterface } from './intefaces/weather.interface'; + +function translateWeather(weather: string): string { + switch (weather) { + case 'Clouds': + return 'Nuageux'; + case 'Clear': + return 'Dégagé'; + case 'Rain': + return 'Pluvieux'; + case 'Snow': + return 'Neigeux'; + case 'Drizzle': + return 'Bruineux'; + case 'Thunderstorm': + return 'Orageux'; + case 'Mist': + return 'Brumeux'; + case 'Smoke': + return 'Fumeux'; + case 'Haze': + return 'Brumeux'; + case 'Dust': + return 'Poussiéreux'; + case 'Fog': + return 'Brumeux'; + case 'Sand': + return 'Sableux'; + case 'Ash': + return 'Cendreux'; + case 'Squall': + return 'Rafaleux'; + case 'Tornado': + return 'Tornade'; + default: + return weather; + } +} + +export function createInterface(weather: any): WeatherInterface { + return { + type: translateWeather(weather.weather[0].main), + description: weather.weather[0].description, + localisation: weather.name, + temperature: weather.main.temp, + }; +} diff --git a/api/src/weather/_utils/intefaces/weather.interface.ts b/api/src/weather/_utils/intefaces/weather.interface.ts new file mode 100644 index 0000000..e3edf50 --- /dev/null +++ b/api/src/weather/_utils/intefaces/weather.interface.ts @@ -0,0 +1,6 @@ +export interface WeatherInterface { + type: string; + localisation: string; + temperature: number; + description: string; +} diff --git a/api/src/weather/weather.module.ts b/api/src/weather/weather.module.ts index 4e31409..953d417 100644 --- a/api/src/weather/weather.module.ts +++ b/api/src/weather/weather.module.ts @@ -1,10 +1,12 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { WeatherService } from './weather.service'; import { HttpModule } from '@nestjs/axios'; +import { ActionReactionModule } from '../action-reaction/action-reaction.module'; +import { ReactionsModule } from '../reactions/reactions.module'; @Module({ - imports: [HttpModule], - providers: [WeatherService], - exports: [WeatherService], + imports: [HttpModule, forwardRef(() => ActionReactionModule), ReactionsModule], + providers: [WeatherService], + exports: [WeatherService], }) export class WeatherModule {} diff --git a/api/src/weather/weather.service.ts b/api/src/weather/weather.service.ts index ce057c9..7da928f 100644 --- a/api/src/weather/weather.service.ts +++ b/api/src/weather/weather.service.ts @@ -2,33 +2,53 @@ import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { firstValueFrom } from 'rxjs'; import { EnvironmentVariables } from 'src/_utils/config'; -import { Injectable } from '@nestjs/common'; +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { ActionDocument, ActionDocumentType } from '../actions/schemas/actions.schema'; +import { WeatherActionDocument } from '../actions/schemas/weather-action.schema'; +import { Cron } from '@nestjs/schedule'; +import { ActionReactionRepository } from '../action-reaction/action-reaction.repository'; +import { createInterface } from './_utils/create-interface.function'; +import { ReactionsService } from '../reactions/reactions.service'; @Injectable() export class WeatherService { - private readonly apiKey: string; + private readonly apiKey: string; - constructor( - private httpService: HttpService, - private readonly configService: ConfigService< - EnvironmentVariables, - true - >, - ) { - this.apiKey = configService.get('WEATHER_KEY'); - } + constructor( + private httpService: HttpService, + private readonly configService: ConfigService, + private readonly actionReactionRepository: ActionReactionRepository, + private readonly reactionService: ReactionsService, + ) { + this.apiKey = configService.get('WEATHER_KEY'); + } + + async getWeather(action: ActionDocumentType): Promise { + if (action.actionType !== 'WEATHER_GET_CURRENT') + throw new InternalServerErrorException('Action is not a weather action'); + const url = `https://api.openweathermap.org/data/2.5/weather?q=${action.city}&appid=${this.apiKey}&units=metric&lang=fr`; - async getWeather(city: string): Promise { - const url = `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${this.apiKey}&units=metric&lang=fr`; + try { + return await firstValueFrom(this.httpService.get(url)) + .then((res) => res.data) + .catch((err) => { + throw new Error(err); + }); + } catch (error) { + throw new Error(error); + } + } - try { - return await firstValueFrom(this.httpService.get(url)) - .then((res) => res.data) - .catch((err) => { - throw new Error(err); - }); - } catch (error) { - throw new Error(error); - } + @Cron('0 * * * *') + async onWeatherChange() { + const actionsReactions = await this.actionReactionRepository.getActionReactionByFilter({ + 'action.actionType': 'WEATHER_GET_CURRENT', + }); + for (const actionReaction of actionsReactions) { + const weather = createInterface(await this.getWeather(actionReaction.action)); + await this.reactionService + .executeReaction(actionReaction.user[0], actionReaction.reaction, weather) + .catch((err) => console.log(err)); } + } } diff --git a/api/test/app.e2e-spec.ts b/api/test/app.e2e-spec.ts index 381a2eb..a5722af 100644 --- a/api/test/app.e2e-spec.ts +++ b/api/test/app.e2e-spec.ts @@ -4,77 +4,74 @@ import * as request from 'supertest'; import { AppModule } from './../src/app.module'; describe('AppController (e2e)', () => { - let app: INestApplication; + let app: INestApplication; - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); - app = moduleFixture.createNestApplication(); - await app.init(); - }); + app = moduleFixture.createNestApplication(); + await app.init(); + }); - it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); - }); + it('/ (GET)', () => { + return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); + }); - it('/auth/register success (POST)', () => { - return request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: 'test@user.com', - password: 'testpassword', - username: 'testuser', - fullname: 'Test User', - }) - .expect(201); - }); + it('/auth/register success (POST)', () => { + return request(app.getHttpServer()) + .post('/auth/register') + .send({ + email: 'test@user.com', + password: 'testpassword', + username: 'testuser', + fullname: 'Test User', + }) + .expect(201); + }); - it('/auth/register error (POST)', () => { - return request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: 'testuser', - password: 'testpassword', - username: 'testuser', - fullname: 'Test User', - }) - .expect(401); - }); + it('/auth/register error (POST)', () => { + return request(app.getHttpServer()) + .post('/auth/register') + .send({ + email: 'testuser', + password: 'testpassword', + username: 'testuser', + fullname: 'Test User', + }) + .expect(401); + }); - it('/auth/login success (POST)', () => { - request(app.getHttpServer()).post('/auth/register').send({ - email: 'test@user.com', - password: 'testpassword', - username: 'testuser', - fullname: 'Test User', - }); - return request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: 'test@user.com', - password: 'testpassword', - }) - .expect(201); + it('/auth/login success (POST)', () => { + request(app.getHttpServer()).post('/auth/register').send({ + email: 'test@user.com', + password: 'testpassword', + username: 'testuser', + fullname: 'Test User', }); + return request(app.getHttpServer()) + .post('/auth/login') + .send({ + email: 'test@user.com', + password: 'testpassword', + }) + .expect(201); + }); - it('/auth/login error (POST)', () => { - request(app.getHttpServer()).post('/auth/register').send({ - email: 'test@user.com', - password: 'testpassword', - username: 'testuser', - fullname: 'Test User', - }); - return request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: 'test', - password: 'testpassword', - }) - .expect(401); + it('/auth/login error (POST)', () => { + request(app.getHttpServer()).post('/auth/register').send({ + email: 'test@user.com', + password: 'testpassword', + username: 'testuser', + fullname: 'Test User', }); + return request(app.getHttpServer()) + .post('/auth/login') + .send({ + email: 'test', + password: 'testpassword', + }) + .expect(401); + }); }); diff --git a/api/test/jest-e2e.json b/api/test/jest-e2e.json index 055b528..e9d912f 100644 --- a/api/test/jest-e2e.json +++ b/api/test/jest-e2e.json @@ -1,9 +1,9 @@ { - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", - "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - } + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } } diff --git a/api/test.sh b/api/test/test.sh similarity index 81% rename from api/test.sh rename to api/test/test.sh index e516970..ce4e5d6 100755 --- a/api/test.sh +++ b/api/test/test.sh @@ -1,7 +1,7 @@ #!/bin/bash # Define the base URL of your API -BASE_URL="http://localhost:8080" +BASE_URL="http://patatoserv.ddns.net:8085" ERROR=0 # Function to perform a test @@ -11,22 +11,16 @@ perform_test() { local body=$3 local request_type=$4 - echo "Testing $url" - echo "Expected status: $expected_status" - echo "Body: $body" - echo "Request type: $request_type" - response=$(curl --silent --header "Content-Type: application/json" \ --request POST \ --data "$body" \ "$url") - echo "Response: $response" status_code=$(echo "$response" | grep -o '"statusCode":[0-9]*' | grep -o '[0-9]*') - echo "Status code: $status_code" # if no status code is found, then it's 201 if [ -z "$status_code" ]; then status_code=201 fi + echo "Status code: $status_code" # Check if the status code is the expected one or 409 if [ "$status_code" -ne "$expected_status" ] && [ "$status_code" -ne 409 ]; then @@ -43,13 +37,27 @@ login_data_wrong='{"usernameOrEmail":"testuser", "password":"testpass"}' login_data_good='{"usernameOrEmail":"newUser", "password":"JeSuisFou21341!"}' register_data='{"username":"newUser", "email":"newuser@test-example.com", "password":"JeSuisFou21341!", "fullName":"newUser"}' + +echo "Testing routes" +echo "-------------" +echo "Bad register" perform_test "$BASE_URL/auth/register" 400 "" "POST" +echo "Good register" perform_test "$BASE_URL/auth/register" 201 "$register_data" "POST" +echo "Bad login" perform_test "$BASE_URL/auth/login" 401 "$login_data_wrong" "POST" +echo "Good login" perform_test "$BASE_URL/auth/login" 201 "$login_data_good" "POST" +#connect to the database and exec the script mongosh < area_db.sh +ssh $SSH_HOST -p $SSH_PORT -l $SSH_USER "mongosh < area_db.sh && exit" + # Exit with an error code if any test failed if [ $ERROR -eq 1 ]; then echo "Some tests failed" + exit 1 +else + echo "All tests passed" + exit 0 fi diff --git a/api/tsconfig.json b/api/tsconfig.json index d357958..5441dc5 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -1,21 +1,21 @@ { - "compilerOptions": { - "module": "commonjs", - "declaration": false, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2021", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": ".", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false - } + "compilerOptions": { + "module": "commonjs", + "declaration": false, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": ".", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false + } } diff --git a/doc/developer/.env.example b/doc/developer/.env.example new file mode 100644 index 0000000..b1c5eda --- /dev/null +++ b/doc/developer/.env.example @@ -0,0 +1,12 @@ +JWT_SECRET='Secret jwt' +NASA_API_KEY='KEY NASA' +WEATHER_KEY='KEY WEATHER' +MONGO_URI='mongodb://mongo/awarea' + +GITHUB_CLIENT_ID='GIT ID' +GITHUB_CLIENT_SECRET='GIT SECRET' +GITHUB_CALLBACK_URL='GIT CALLBACK WEBHOOK URL' + +GOOGLE_CLIENT_ID='GOOGLE ID' +GOOGLE_CLIENT_SECRET='GOOGLE SECRET' +GOOGLE_CALLBACK_URL='http://localhost:8081/home' diff --git a/doc/developer/Introduction.md b/doc/developer/Introduction.md new file mode 100644 index 0000000..9251bb2 --- /dev/null +++ b/doc/developer/Introduction.md @@ -0,0 +1,122 @@ +# AwArea - Developer Documentation + +## Overview + +- The AwArea project is a web and mobile application that allows users to set up and manage their own action and reaction rules. +- It's heavily inspired by IFTTT and Zapier. + +### Architecture Overview + +- Follow the [NestJS architecture](https://docs.nestjs.com) + - Use the [NestJS CLI](https://docs.nestjs.com/cli/overview) to generate modules, controllers, services, etc. + - Use the [NestJS style guide](https://docs.nestjs.com/standards/style-guide) +- Follow the [Flutter architecture](https://docs.flutter.dev/ui) + +### Technology Stack + +- Check out the [Technologies](Technologies.md) document for more information. + +## Environment Setup + +### Prerequisites + +| Language / Tools | Link | Description | +|:---:|:---:|:---:| +| [![My Skills](https://skillicons.dev/icons?i=docker)](Docker) | [**Docker**](https://www.docker.com/) | Docker is used to containerize the application, the api and the database. | +| [![My Skills](https://skillicons.dev/icons?i=mongodb)](MongoDB) | [**MongoDB**](https://www.mongodb.com/) | MongoDB is used as the database for the application. (not mandatory if using Docker) | +| [![My Skills](https://skillicons.dev/icons?i=nodejs)](NodeJS) | [**NodeJS**](https://nodejs.org/en/) | NodeJS is used to run the application. (not mandatory if using Docker) | +| [![My Skills](https://skillicons.dev/icons?i=typescript)](TypeScript) | [**TypeScript**](https://www.typescriptlang.org/) | TypeScript is used to write the application. | +| [![My Skills](https://skillicons.dev/icons?i=flutter)](Flutter) | [**Flutter**](https://flutter.dev/) | Flutter is used to write the mobile application. | +| [![My Skills](https://skillicons.dev/icons?i=git)](Git) | [**Git**](https://git-scm.com/) | Git is used for version control. | +--- + +### Installation Steps + +1. Clone the repository: +```sh +git clone git@github.com:UwUClub/AwARea.git +``` +2. Install dependencies: + - Check each link from the [Prerequisites](#prerequisites) section. + - Run `npm install` in the api directory of the project. +3. Set up environment variables: + - Create a `.env` file in the api directory of the project. + - Copy the contents of `.env.example` into `.env`. + - Fill in the values for the environment variables. + +### Common Issues and Troubleshooting + +- Backend not working: Check if the MongoDB server is running and if the environment variables are set correctly. + +## Codebase Structure + +### Directory Structure +#### **`.github`** : Contains the github actions. + +#### **`api`** : Contains the backend code. +- src : Contains the source code of the backend. + - Each folder contains a module of the backend except for `_utils` which contains the common utils of the backend. +- test : Contains the tests of the backend. + +#### **`flutter_area`** : Contains the mobile and web application code. +- lib : Contains the source code of the mobile and web application. + - UI : Contains the UI code of the mobile and web application. + - Core : Contains the Locator and the Manager of the mobile and web application. + - l10n : Contains the localization of the mobile and web application. + - Utils : Contains the common utils of the mobile and web application. +- test : Contains the tests of the mobile and web application. + +#### **`doc`** : Contains the documentation. +- developer : Contains the developer documentation. +- user : Contains the user documentation. + +### Coding Standards +Linter used : [tslint](https://palantir.github.io/tslint/) and [flutter_lints](https://pub.dev/packages/flutter_lints). + +Formatter used : [prettier](https://prettier.io/). + +Each work repository has its own linter and formatter configuration files. + +## Development Workflow + +### Branching Strategy + +- Main branch: `main` (protected) +- Development branch: `dev` (protected) +- Feature branches: `AW--` ex: `AW-1-Login` +- Hotfix branches: `AW--` ex: `AW-1-LoginFix` +- Release branches: `v` ex: `v1.0.0` +- Tagging: `v` ex: `v1.0.0` +- Pull requests: `AW--` ex: `AW-1-Login` +- Commit messages: ` ` ex: `Add Login feature` + +### Pull Request Process + +- Feature branches are merged into the `dev` branch via pull requests. +- Pull requests are reviewed by at least *one* other team member. +- Pull requests are merged into the `dev` branch after approval. +- Pull requests are merged into the `main` branch after a release. + +### Testing Standards + +- Unit tests are written for all backend and frontend code. +- Make a script for your tests + +## Build and Deployment + +### Local Build Instructions + +- docker compose up + +## Additional Resources + +- [Technologies](./Technologies.md) +- [API Documentation (back-end)](./end/Back-end.md) +- [Front-end Documentation](./end/Front-end.md) + +### Communication Channels + +- Discord +- Email + +*This document is subject to change and will be updated as needed.* diff --git a/doc/Technologies.md b/doc/developer/Technologies.md similarity index 100% rename from doc/Technologies.md rename to doc/developer/Technologies.md diff --git a/doc/developer/end/Back-end.md b/doc/developer/end/Back-end.md new file mode 100644 index 0000000..cecf830 --- /dev/null +++ b/doc/developer/end/Back-end.md @@ -0,0 +1,130 @@ +# Backend Development Documentation + +## Table of Contents + +- [Adding a new service](#adding-a-new-service) + - [What is a service?](#what-is-a-service) + - [Adding a new module](#adding-a-new-module) + - [Adding a new controller (route)](#adding-a-new-controller-route) + - [Adding a new service (business logic)](#adding-a-new-service-business-logic) + - [With 2oauth authentication](#with-2oauth-authentication) + - [Without 2oauth authentication](#without-2oauth-authentication) +- [Testing](#testing) + - [Writing Tests](#writing-tests) + - [Running Tests](#running-tests) +- [Database Management](#database-management) + - [Database Setup](#database-setup) + - [Database Schema](#database-schema) +- [API Documentation](#api-documentation) + - [API Endpoints](#api-endpoints) + - [Authentication](#authentication) +- [Troubleshooting and Support](#troubleshooting-and-support) +- [Additional Resources](#additional-resources) + +## Adding a new service + +### What is a service? + +- A service is a third party service that we want to integrate with. +- A service can be a social media platform, a cloud storage platform, a payment platform, etc. +- A service contains the actions and reactions that the user can use in their rules. +- A service can be added to the backend by following the steps below. + +### Adding a new module + +- In the `api/src/` folder, create a new folder with the name of the service. ex: `api/src/MyService`. +- Use NestJS CLI to generate a new module in the new folder. ex: +```sh +nest g module my-service +``` +- Add the new module to the `api/src/app.module.ts` file. + +### Adding a new controller (route) + +- In the `api/src/` folder, use NestJS CLI to generate a new controller. ex: +```sh +nest g controller my-service +``` +- Add the new controller to the `api/src/my-service/my-service.module.ts` file. + +### Adding a new service (business logic) + +- In the `api/src/` folder, use NestJS CLI to generate a new service. ex: +```sh +nest g service my-service +``` +- Add the new service to the `api/src/my-service/my-service.module.ts` file. + +### With 2oauth authentication + +- Make a controller that gets the user's access token from the front end. +- Now you can interact with the 2oauth API of the service using the access token. + +### Without 2oauth authentication + +- Get the API key from the service's website. +- Use the API key to interact with the service's API. + +## Testing + +### Writing Tests + +- Two ways to write tests: + - Jest: Unit tests. + - Scripts: Unit tests and integration tests. + +Chose the one that suits your needs. + +### Running Tests + +- If you chose Jest, run the tests using the following command: +```sh +npm run test +``` +- If you chose Scripts, run the tests using the following command: +```sh +./.sh +``` + +## Database Management + +### Database Setup + +- Refer to the [introduction documentation](./Introduction.md) for the database setup. + +### Database Schema + +- We use DTOs to interact with the database. + - You need to create a DTO for each interaction used in the database. (Create, Read, Update, Delete) + +## API Documentation + +### API Endpoints + +- Once the backend is running, the API documentation can be accessed at `http://localhost:8000/api`. +- A release version of the API documentation can be found [here](http://patatoserv.ddns.net:8085/api). + +### Authentication + +- Most routes require authentication. +- Use the `/auth/login` or `/auth/register` route to get an access token. +- Use the access token in the `Authorization` header of your requests. + +## Troubleshooting and Support + +- If you have any questions, please contact the project maintainer. + +## Additional Resources + +- [NestJS](https://nestjs.com/) +- [MongoDB](https://www.mongodb.com/) +- [NodeJS](https://nodejs.org/en/) +- [TypeScript](https://www.typescriptlang.org/) +- [Git](https://git-scm.com/) +- [Introduction](../Introduction.md) +- [Technologies](../Technologies.md) +- [Frontend Development Documentation](./Front-end.md) + +--- + +*This document is subject to change and will be updated as needed.* diff --git a/doc/developer/end/Front-end.md b/doc/developer/end/Front-end.md new file mode 100644 index 0000000..60b2f32 --- /dev/null +++ b/doc/developer/end/Front-end.md @@ -0,0 +1,50 @@ +# Frontend Development Documentation + +## Introduction + +This document provides an overview and guidelines for the frontend development of our project. It covers architecture, and best practices that our team follows. + +## Table of Contents + +- [Introduction](#introduction) +- [Architecture](#architecture) +- [Troubleshooting and Support](#troubleshooting-and-support) +- [Contributing](#contributing) +- [Additional Resources](#additional-resources) + +## Development Guidelines + +### Coding Standards + +- Follow the default coding standards of [Flutter](https://flutter.dev/docs/development/tools/formatting). + +### Component Architecture + +- Follow the Flutter [component architecture](https://flutter.dev/docs/development/ui/widgets-intro). +- `main.dart` is the entry point of the application. +- `android` and `ios` folders contain the native code for Android and iOS respectively. +- `web` folder contains the web code for the application. +- `lib` folder contains the Dart code for the application. +- `lib/Core` folder contains the core code of the application. + - `lib/Core/Locator/locator.dart` contains the locator for the application. + - `lib/Core/Manager/X_manager` contains a manager for X service. + +## Troubleshooting and Support + +- [Flutter Documentation](https://flutter.dev/docs) +- [Flutter Community](https://flutter.dev/community) +- [Flutter FAQ](https://flutter.dev/docs/resources/faq) + +## Contributing + +- [How to Contribute](Introduction.md#Pull-Request-Process) + +## Additional Resources + +- [Introduction](../Introduction.md) +- [Technologies](../Technologies.md) +- [API Documentation (back-end)](./Back-end.md) + +--- + +*This document is subject to change and will be updated as needed.* diff --git a/doc/user/HowToMakeSlackApp.md b/doc/user/HowToMakeSlackApp.md new file mode 100644 index 0000000..a3a4c1b --- /dev/null +++ b/doc/user/HowToMakeSlackApp.md @@ -0,0 +1,37 @@ +# How to connect to Slack + +## Create a Slack App + +1. Go to [slack](https://api.slack.com/apps) and click on "Create New App" +[![My Skills](https://cdn.discordapp.com/attachments/684788230515851297/1193857127341629521/create.png?ex=65ae3d17&is=659bc817&hm=0efb709b7e66658e793c0c367333ec675151c3cb117cd4ed3e71ac1d86ef6145&)](https://api.slack.com/apps) +2. Click on "From scratch" +[![My Skills](https://cdn.discordapp.com/attachments/684788230515851297/1193857127765262336/create2.png?ex=65ae3d18&is=659bc818&hm=90d355099fadc6c77db49cdd59680085d56b5d79903045b7d9bd17171ba8509c&)](https://api.slack.com/apps) +3. Enter a name for your app and select the workspace you want to connect to +[![My Skills](https://cdn.discordapp.com/attachments/684788230515851297/1193857128612503612/create4.png?ex=65ae3d18&is=659bc818&hm=559701faf7379ae47845ed3e361d7d30206e2002a624e331ce5daf1920949b1d&&)](https://api.slack.com/apps) +4. Click on "Create App" +5. Click on "OAuth & Permissions" in the left menu +[![My Skills](https://cdn.discordapp.com/attachments/684788230515851297/1193857128138559529/create3.png?ex=65ae3d18&is=659bc818&hm=cc8a26b2f471fe8e4b1ed03d119e16cab8ae48762d90a7fd5076c5f2bceffc02&)](https://api.slack.com/apps) +6. Add rediret URL `https://localhost` +[![My Skills](https://cdn.discordapp.com/attachments/684788230515851297/1193857128998387712/create5.png?ex=65ae3d18&is=659bc818&hm=384ae0b47d811c226f8fcb031c171c8ec7d39c401685ef6961a44bca5e8279b8&)](https://api.slack.com/apps) +7. Add User scopes + - `channels:history` + - `channels:read` + - `chat:write` + - `groups:history` + - `groups:read` + - `im:history` + - `im:read` + - `mpim:history` + - `mpim:read` + - `users:read` + - `admin` +[![My Skills](https://cdn.discordapp.com/attachments/684788230515851297/1193857129858211940/create7.png?ex=65ae3d18&is=659bc818&hm=6df9e574705ab29ca317c15ded918cad9a51c34b03f3e13b60f34791a963633b&)](https://api.slack.com/apps) +[![My Skills](https://cdn.discordapp.com/attachments/684788230515851297/1193857130218930216/create8.png?ex=65ae3d18&is=659bc818&hm=00868b01e1c975799bb706b120aaefbfc73a665310f9a23ce25a608a9df2b7a1&)](https://api.slack.com/apps) +8. Click on "Install App to Workspace" +[![My Skills](https://cdn.discordapp.com/attachments/684788230515851297/1193857130625781801/create9.png?ex=65ae3d18&is=659bc818&hm=5f8c3b54a1ff08cd8022a2fc606455e7417fd9ee48cfdfdd53f57891d40c1643&)](https://api.slack.com/apps) +9. Click on "Allow" +[![My Skills](https://cdn.discordapp.com/attachments/684788230515851297/1193857131024228493/create10.png?ex=65ae3d18&is=659bc818&hm=2f33c703750f2795986326ec666376a3a4613f8f994262114713cb8a70495973&)](https://api.slack.com/apps) +10. Copy the token from "OAuth Access Token" +[![My Skills](https://cdn.discordapp.com/attachments/684788230515851297/1193857137785450558/create11.png?ex=65ae3d1a&is=659bc81a&hm=0dd426b1a38d278ce611c8718f9b846d4db95014e395d5cba093807dcebbfeb6&)](https://api.slack.com/apps) +11. Paste the token into the `slack_token` field in the Maker connection form. +[![My Skills](https://cdn.discordapp.com/attachments/514127963604647947/1193905402383966379/Capture_decran_2024-01-08_a_14.10.27.png?ex=65ae6a0d&is=659bf50d&hm=f3e39622c06f341217f41cc8aa4c58c78ccd41a66caa32b71625700dce1d8c67&)](https://api.slack.com/apps) diff --git a/docker-compose.yml b/docker-compose.yml index 7a147b6..834bdd5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,9 +19,9 @@ services: - 8080:8080 depends_on: - mongo - restart: always environment: - - MONGO_URL=mongodb://mongo/awarea + - MONGO_URI=mongodb://mongo/awarea + restart: always frontend: build: diff --git a/flutter_area/android/app/build.gradle b/flutter_area/android/app/build.gradle index 4050ccd..dc6599e 100644 --- a/flutter_area/android/app/build.gradle +++ b/flutter_area/android/app/build.gradle @@ -1,5 +1,6 @@ plugins { id "com.android.application" + id "com.google.gms.google-services" id "kotlin-android" id "dev.flutter.flutter-gradle-plugin" } @@ -23,7 +24,7 @@ if (flutterVersionName == null) { } android { - namespace "com.example.flutter_area" + namespace "com.uwuclub.maker" compileSdkVersion flutter.compileSdkVersion ndkVersion flutter.ndkVersion @@ -42,7 +43,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.example.flutter_area" + applicationId "com.uwuclub.maker" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. minSdkVersion flutter.minSdkVersion @@ -64,4 +65,6 @@ flutter { source '../..' } -dependencies {} +dependencies { + implementation platform('com.google.firebase:firebase-bom:32.7.0') +} diff --git a/flutter_area/android/app/google-services.json b/flutter_area/android/app/google-services.json new file mode 100644 index 0000000..61f0367 --- /dev/null +++ b/flutter_area/android/app/google-services.json @@ -0,0 +1,46 @@ +{ + "project_info": { + "project_number": "295533130266", + "project_id": "maker-area", + "storage_bucket": "maker-area.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:295533130266:android:6ab94c91b5a80eb52417a3", + "android_client_info": { + "package_name": "com.uwuclub.maker" + } + }, + "oauth_client": [ + { + "client_id": "295533130266-6ebnkrmahq3hh9ftbilaaa9bnni7clvt.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyAhI34W4M0J4RLe-VC_pt3inEWmmfrlWXM" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "295533130266-6ebnkrmahq3hh9ftbilaaa9bnni7clvt.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "295533130266-1hlo2h5s174uktsjluh46hdinq3uh8jh.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.uwuclub.maker" + } + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/flutter_area/android/app/src/main/kotlin/com/example/flutter_area/MainActivity.kt b/flutter_area/android/app/src/main/kotlin/com/example/flutter_area/MainActivity.kt index d579365..3357dbc 100644 --- a/flutter_area/android/app/src/main/kotlin/com/example/flutter_area/MainActivity.kt +++ b/flutter_area/android/app/src/main/kotlin/com/example/flutter_area/MainActivity.kt @@ -1,4 +1,4 @@ -package com.example.flutter_area +package com.uwuclub.maker import io.flutter.embedding.android.FlutterActivity diff --git a/flutter_area/android/build.gradle b/flutter_area/android/build.gradle index f7eb7f6..c9bbd81 100644 --- a/flutter_area/android/build.gradle +++ b/flutter_area/android/build.gradle @@ -6,7 +6,8 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:7.3.0' + classpath "com.android.tools.build:gradle:7.3.0" + classpath "com.google.gms:google-services:4.4.0" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/flutter_area/ios/Podfile.lock b/flutter_area/ios/Podfile.lock new file mode 100644 index 0000000..5579e19 --- /dev/null +++ b/flutter_area/ios/Podfile.lock @@ -0,0 +1,68 @@ +PODS: + - AppAuth (1.6.2): + - AppAuth/Core (= 1.6.2) + - AppAuth/ExternalUserAgent (= 1.6.2) + - AppAuth/Core (1.6.2) + - AppAuth/ExternalUserAgent (1.6.2): + - AppAuth/Core + - Flutter (1.0.0) + - flutter_web_auth (0.5.0): + - Flutter + - google_sign_in_ios (0.0.1): + - Flutter + - FlutterMacOS + - GoogleSignIn (~> 7.0) + - GoogleSignIn (7.0.0): + - AppAuth (~> 1.5) + - GTMAppAuth (< 3.0, >= 1.3) + - GTMSessionFetcher/Core (< 4.0, >= 1.1) + - GTMAppAuth (2.0.0): + - AppAuth/Core (~> 1.6) + - GTMSessionFetcher/Core (< 4.0, >= 1.5) + - GTMSessionFetcher/Core (3.2.0) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`) + - google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +SPEC REPOS: + trunk: + - AppAuth + - GoogleSignIn + - GTMAppAuth + - GTMSessionFetcher + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + flutter_web_auth: + :path: ".symlinks/plugins/flutter_web_auth/ios" + google_sign_in_ios: + :path: ".symlinks/plugins/google_sign_in_ios/darwin" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + AppAuth: 3bb1d1cd9340bd09f5ed189fb00b1cc28e1e8570 + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d + google_sign_in_ios: ede92ec558c3a73ec07cb180f31820152fb8ca89 + GoogleSignIn: b232380cf495a429b8095d3178a8d5855b42e842 + GTMAppAuth: 99fb010047ba3973b7026e45393f51f27ab965ae + GTMSessionFetcher: 41b9ef0b4c08a6db4b7eb51a21ae5183ec99a2c8 + shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + url_launcher_ios: bf5ce03e0e2088bad9cc378ea97fa0ed5b49673b + +PODFILE CHECKSUM: 70d9d25280d0dd177a5f637cdb0f0b0b12c6a189 + +COCOAPODS: 1.14.3 diff --git a/flutter_area/ios/Runner.xcodeproj/project.pbxproj b/flutter_area/ios/Runner.xcodeproj/project.pbxproj index 6d14567..e1868c0 100644 --- a/flutter_area/ios/Runner.xcodeproj/project.pbxproj +++ b/flutter_area/ios/Runner.xcodeproj/project.pbxproj @@ -7,13 +7,15 @@ objects = { /* Begin PBXBuildFile section */ + 07BA28DB0A6A8B9802CC078B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 808F541EC3E9C641DDAFC36E /* Pods_RunnerTests.framework */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 4F1FFF1743E3FA842DFF1C95 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B1A4949952D9B78C02D29B7D /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -40,12 +42,18 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 02316E78F49BD660D3E1EB1C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 0C907C757CBE57A9D4D1E665 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 808F541EC3E9C641DDAFC36E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 86F4087C9063A6F0B16DB344 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -53,21 +61,62 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 9CEB377D011BDB144A3899DD /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + B1A4949952D9B78C02D29B7D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BBDC8D445A9657981AAF989F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + C25C9373CDE2CE134CA25C0F /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 0901BE586A07B8F03374CA64 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 07BA28DB0A6A8B9802CC078B /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4F1FFF1743E3FA842DFF1C95 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 57207927DE6EC51683ED9E44 /* Frameworks */ = { + isa = PBXGroup; + children = ( + B1A4949952D9B78C02D29B7D /* Pods_Runner.framework */, + 808F541EC3E9C641DDAFC36E /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 7A0C9A44F8EB708660B5E9AB /* Pods */ = { + isa = PBXGroup; + children = ( + BBDC8D445A9657981AAF989F /* Pods-Runner.debug.xcconfig */, + 86F4087C9063A6F0B16DB344 /* Pods-Runner.release.xcconfig */, + 9CEB377D011BDB144A3899DD /* Pods-Runner.profile.xcconfig */, + 02316E78F49BD660D3E1EB1C /* Pods-RunnerTests.debug.xcconfig */, + 0C907C757CBE57A9D4D1E665 /* Pods-RunnerTests.release.xcconfig */, + C25C9373CDE2CE134CA25C0F /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -79,14 +128,6 @@ name = Flutter; sourceTree = ""; }; - 331C8082294A63A400263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C807B294A618700263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( @@ -94,6 +135,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 7A0C9A44F8EB708660B5E9AB /* Pods */, + 57207927DE6EC51683ED9E44 /* Frameworks */, ); sourceTree = ""; }; @@ -128,9 +171,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + F92DD03E457238BE66F7A1E9 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, - 331C807E294A63A400263BE5 /* Frameworks */, 331C807F294A63A400263BE5 /* Resources */, + 0901BE586A07B8F03374CA64 /* Frameworks */, ); buildRules = ( ); @@ -146,12 +190,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 071AA16844F4DAEBBE386758 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ADD5358CAE10508861CBFA2E /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -223,6 +269,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 071AA16844F4DAEBBE386758 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -254,6 +322,45 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + ADD5358CAE10508861CBFA2E /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + F92DD03E457238BE66F7A1E9 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -361,13 +468,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = XR46KVW4Y3; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterArea; + PRODUCT_BUNDLE_IDENTIFIER = com.uwuclub.maker; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -377,7 +485,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = AE0B7B92F70575B8D7E0D07E /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 02316E78F49BD660D3E1EB1C /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -395,7 +503,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 89B67EB44CE7B6631473024E /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = 0C907C757CBE57A9D4D1E665 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -411,7 +519,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 640959BDD8F10B91D80A66BE /* Pods-RunnerTests.profile.xcconfig */; + baseConfigurationReference = C25C9373CDE2CE134CA25C0F /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -539,13 +647,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = XR46KVW4Y3; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterArea; + PRODUCT_BUNDLE_IDENTIFIER = com.uwuclub.maker; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -561,13 +670,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = XR46KVW4Y3; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterArea; + PRODUCT_BUNDLE_IDENTIFIER = com.uwuclub.maker; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/flutter_area/ios/Runner.xcworkspace/contents.xcworkspacedata b/flutter_area/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/flutter_area/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/flutter_area/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/flutter_area/ios/Runner/Info.plist b/flutter_area/ios/Runner/Info.plist index b2dca8a..4b6c441 100644 --- a/flutter_area/ios/Runner/Info.plist +++ b/flutter_area/ios/Runner/Info.plist @@ -2,16 +2,24 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Flutter Area + Maker CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 + CFBundleLocalizations + + fr + en + es + CFBundleName Maker CFBundlePackageType @@ -24,16 +32,12 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile Main - CFBundleLocalizations - - fr - en - es - UISupportedInterfaceOrientations UIInterfaceOrientationPortrait @@ -47,9 +51,20 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - + GIDClientID + 295533130266-1hlo2h5s174uktsjluh46hdinq3uh8jh.apps.googleusercontent.com + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.295533130266-1hlo2h5s174uktsjluh46hdinq3uh8jh + + + + diff --git a/flutter_area/lib/Core/Locator/locator.dart b/flutter_area/lib/Core/Locator/locator.dart index 76ce7db..b7c05c2 100644 --- a/flutter_area/lib/Core/Locator/locator.dart +++ b/flutter_area/lib/Core/Locator/locator.dart @@ -1,6 +1,9 @@ import 'package:get_it/get_it.dart'; -import '../Manager/action_manager.dart'; +import '../Manager/action_reaction_manager.dart'; +import '../Manager/github_manager.dart'; +import '../Manager/google_manager.dart'; +import '../Manager/slack_manager.dart'; import '../Manager/theme_manager.dart'; import '../Manager/user_manager.dart'; @@ -9,5 +12,8 @@ final GetIt locator = GetIt.I; void setupLocator() { locator.registerSingleton(ThemeManager()); locator.registerSingleton(UserManager()); - locator.registerSingleton(ActionManager()); + locator.registerSingleton(ActionReactionManager()); + locator.registerSingleton(SlackManager()); + locator.registerSingleton(GoogleManager()); + locator.registerSingleton(GithubManager()); } diff --git a/flutter_area/lib/Core/Manager/action_manager.dart b/flutter_area/lib/Core/Manager/action_manager.dart deleted file mode 100644 index 41c1ec1..0000000 --- a/flutter_area/lib/Core/Manager/action_manager.dart +++ /dev/null @@ -1,31 +0,0 @@ -class MkAction { - MkAction({ - required this.service, - required this.name, - required this.description, - this.reaction, - }); - late String service; - late String name; - late String description; - late MkReaction? reaction; -} - -class MkReaction { - MkReaction({ - required this.service, - required this.name, - required this.description, - }); - late String service; - late String name; - late String description; -} - -class ActionManager { - List actions = []; - - void addAction(MkAction action) { - actions.add(action); - } -} diff --git a/flutter_area/lib/Core/Manager/action_reaction_manager.dart b/flutter_area/lib/Core/Manager/action_reaction_manager.dart new file mode 100644 index 0000000..bd2ebde --- /dev/null +++ b/flutter_area/lib/Core/Manager/action_reaction_manager.dart @@ -0,0 +1,278 @@ +// ignore_for_file: avoid_dynamic_calls + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; + +import '../../UI/NewTask/new_task_viewmodel.dart'; +import '../../Utils/constants.dart'; +import '../../Utils/mk_print.dart'; +import '../Locator/locator.dart'; +import 'user_manager.dart'; + +class MkActionReaction { + MkActionReaction({ + required this.id, + required this.name, + required this.action, + this.reaction, + this.schedule, + }); + late String id; + late String name; + late MkAction action; + late MkReaction? reaction; + late String? schedule; +} + +enum ActionType { + NASA_GET_APOD, + WEATHER_GET_CURRENT, + TIMER, + PULL_REQUEST_CREATED, + ISSUE_OPENED, + BRANCH_MERGED, + PULL_REQUEST_REVIEW_REQUESTED, + PULL_REQUEST_REVIEW_REQUEST_REMOVED, + BRANCH_CREATED, + BRANCH_DELETED, + STAR_ADDED, + STAR_REMOVED, + NONE, +} + +extension ActionTypeExtension on ActionType { + String get label { + switch (this) { + case ActionType.NASA_GET_APOD: + return 'NASA Get APOD'; + case ActionType.WEATHER_GET_CURRENT: + return 'Weather Get Current'; + case ActionType.TIMER: + return 'Timer'; + case ActionType.PULL_REQUEST_CREATED: + return 'Pull Request Created'; + case ActionType.ISSUE_OPENED: + return 'Issue Opened'; + case ActionType.BRANCH_MERGED: + return 'Branch Merged'; + case ActionType.PULL_REQUEST_REVIEW_REQUESTED: + return 'Pull Request Review Requested'; + case ActionType.PULL_REQUEST_REVIEW_REQUEST_REMOVED: + return 'Pull Request Review Request Removed'; + case ActionType.BRANCH_CREATED: + return 'Branch Created'; + case ActionType.BRANCH_DELETED: + return 'Branch Deleted'; + case ActionType.STAR_ADDED: + return 'Star Added'; + case ActionType.STAR_REMOVED: + return 'Star Removed'; + case ActionType.NONE: + return 'None'; + default: + return 'None'; + } + } +} + +class MkAction { + MkAction({ + required this.type, + }); + factory MkAction.fromJson(Map json) { + return MkAction( + type: ActionType.values.firstWhere( + (ActionType e) => e.name == json['actionType'] as String, + orElse: () => ActionType.NONE), + ); + } + late ActionType type; +} + +enum ReactionType { + CREATE_DRAFT, + SEND_SLACK_MESSAGE, + CREATE_SLACK_CHANNEL, + SEND_EMAIL, + NONE, +} + +extension ReactionTypeExtension on ReactionType { + String get label { + switch (this) { + case ReactionType.CREATE_DRAFT: + return 'Create Draft'; + case ReactionType.SEND_SLACK_MESSAGE: + return 'Send Slack Message'; + case ReactionType.CREATE_SLACK_CHANNEL: + return 'Create Slack Channel'; + case ReactionType.SEND_EMAIL: + return 'Send Email'; + case ReactionType.NONE: + return 'None'; + default: + return 'None'; + } + } +} + +class MkReaction { + MkReaction({ + required this.type, + }); + factory MkReaction.fromJson(Map json) { + return MkReaction( + type: ReactionType.values.firstWhere( + (ReactionType e) => e.name == json['reactionType'] as String, + orElse: () => ReactionType.NONE), + ); + } + late ReactionType type; +} + +class ActionReactionManager extends ChangeNotifier { + List actionsReactions = []; + NewTaskViewModel? newTaskViewModel; + + Future getActionsReactions() async { + try { + final UserManager userManager = locator(); + final http.Response res = await http.get( + Uri.parse('$kBaseUrl/action-reaction'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'Authorization': 'Bearer ${userManager.accessToken}', + }); + if (res.statusCode != 200) { + return; + } + final List jsonBody = jsonDecode(res.body) as List; + for (final dynamic actionReaction in jsonBody) { + final String id = actionReaction['id'] as String; + final String name = actionReaction['name'] as String; + final Map? actionJson = + actionReaction['action'] as Map?; + if (actionJson == null) { + continue; + } + final Map? reaction = + actionReaction['reaction'] as Map?; + final String? schedule = actionReaction['schedule'] as String?; + actionsReactions.add(MkActionReaction( + id: id, + name: name, + action: MkAction.fromJson(actionJson), + reaction: reaction != null ? MkReaction.fromJson(reaction) : null, + schedule: schedule, + )); + } + } catch (e) { + mkPrint('Error: $e'); + } + } + + void addLocalActionForm(String name, ActionType type) { + actionsReactions.add(MkActionReaction( + id: 'local', + name: name, + action: MkAction(type: type), + )); + newTaskViewModel?.notify(); + } + + Future addAction(ActionType actionType, String name, + Map body, int localIndex) async { + try { + final UserManager userManager = locator(); + + // Create ActionReaction + final http.Response res = + await http.post(Uri.parse('$kBaseUrl/action-reaction'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'Authorization': 'Bearer ${userManager.accessToken}', + }, + body: jsonEncode({'name': name})); + if (res.statusCode != 201) { + return false; + } + + // Create Action + final String id = jsonDecode(res.body)['id'] as String; + final http.Response res2 = + await http.post(Uri.parse('$kBaseUrl/action-reaction/$id/action'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'Authorization': 'Bearer ${userManager.accessToken}', + }, + body: jsonEncode(body)); + if (res2.statusCode != 201) { + return false; + } + + // Update local list + actionsReactions[localIndex].id = id; + actionsReactions[localIndex].name = name; + actionsReactions[localIndex].action = MkAction(type: actionType); + newTaskViewModel?.notify(); + return true; + } catch (e) { + mkPrint('Error: $e'); + return false; + } + } + + Future setReaction(String actionReactionId, ReactionType type, + Map body) async { + try { + final UserManager userManager = locator(); + final http.Response res = await http.post( + Uri.parse('$kBaseUrl/action-reaction/$actionReactionId/reaction'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'Authorization': 'Bearer ${userManager.accessToken}', + }, + body: jsonEncode(body)); + if (res.statusCode != 201) { + return false; + } + final int index = actionsReactions.indexWhere( + (MkActionReaction element) => element.id == actionReactionId); + actionsReactions[index].reaction = MkReaction(type: type); + newTaskViewModel?.notify(); + return true; + } catch (e) { + mkPrint('Error: $e'); + return false; + } + } + + void removeReactionLocally(String actionReactionId) { + final int index = actionsReactions.indexWhere( + (MkActionReaction element) => element.id == actionReactionId); + actionsReactions[index].reaction = null; + newTaskViewModel?.notify(); + } + + Future deleteActionReaction(String actionReactionId) async { + try { + final UserManager userManager = locator(); + final http.Response res = await http.delete( + Uri.parse('$kBaseUrl/action-reaction/$actionReactionId'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'Authorization': 'Bearer ${userManager.accessToken}', + }); + if (res.statusCode != 200) { + return; + } + actionsReactions.removeWhere( + (MkActionReaction element) => element.id == actionReactionId); + newTaskViewModel?.notify(); + } catch (e) { + mkPrint('Error: $e'); + } + } +} diff --git a/flutter_area/lib/Core/Manager/github_manager.dart b/flutter_area/lib/Core/Manager/github_manager.dart new file mode 100644 index 0000000..8790cb2 --- /dev/null +++ b/flutter_area/lib/Core/Manager/github_manager.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; + +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_web_auth/flutter_web_auth.dart'; +import 'package:http/http.dart' as http; + +import '../../Utils/constants.dart'; +import '../Locator/locator.dart'; +import 'user_manager.dart'; + +class GithubManager { + Future signInWithGitHub() async { + final String url = 'https://github.com/login/oauth/authorize' + '?client_id=${dotenv.env['GITHUB_CLIENT_ID']}' + '&scope=repo,user'; + await FlutterWebAuth.authenticate(url: url, callbackUrlScheme: 'http'); + } + + Future signOutFromGitHub() async { + final UserManager userManager = locator(); + final http.Response response = await http.post( + Uri.parse( + '$kBaseUrl/users/github-token', + ), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ${userManager.accessToken}', + }, + body: jsonEncode({'githubToken': 'none'})); + if (response.statusCode == 201) { + userManager.isGithubLogged = false; + userManager.connectionsViewModel?.notify(); + } + } +} diff --git a/flutter_area/lib/Core/Manager/google_manager.dart b/flutter_area/lib/Core/Manager/google_manager.dart new file mode 100644 index 0000000..5180265 --- /dev/null +++ b/flutter_area/lib/Core/Manager/google_manager.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:http/http.dart' as http; + +import '../../Utils/constants.dart'; +import '../../Utils/mk_print.dart'; +import '../Locator/locator.dart'; +import 'user_manager.dart'; + +class GoogleManager { + Future openGoogleAuthPopup() async { + const List scopes = [ + 'email', + 'https://www.googleapis.com/auth/gmail.addons.current.action.compose', + 'https://www.googleapis.com/auth/gmail.compose', + ]; + + final GoogleSignIn googleSignIn = GoogleSignIn( + scopes: scopes, + ); + final GoogleSignInAccount? res = await googleSignIn.signIn(); + return res; + } + + Future loginWithGoogle( + String googleToken, String completeName, String email) async { + final http.Response res = await http.post( + Uri.parse('$kBaseUrl/auth/google-login'), + headers: { + 'accept': 'application/json', + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: jsonEncode({ + 'accessToken': googleToken, + 'completeName': completeName, + 'email': email + })); + final dynamic jsonBody = jsonDecode(res.body) as Map; + if (res.statusCode == 201) { + final UserManager userManager = locator(); + userManager.storeUser(jsonBody['user'] as Map); + userManager.storeAccessToken(jsonBody['accessToken'] as String); + userManager.connectionsViewModel?.notify(); + return true; + } + return false; + } + + Future setGoogleToken(String googleToken) async { + final UserManager userManager = locator(); + final http.Response res = + await http.post(Uri.parse('$kBaseUrl/users/google-token'), + headers: { + 'accept': 'application/json', + 'Content-Type': 'application/json; charset=UTF-8', + 'Authorization': 'Bearer ${userManager.accessToken}', + }, + body: jsonEncode({'googleToken': googleToken})); + if (res.statusCode != 201) { + return; + } + userManager.isGoogleLogged = googleToken != 'none'; + userManager.connectionsViewModel?.notify(); + } + + Future createDraft(String name, String email) async { + try { + final UserManager userManager = locator(); + final http.Response res = + await http.post(Uri.parse('$kBaseUrl/action-reaction/mvp'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'Authorization': 'Bearer ${userManager.accessToken}', + }, + body: jsonEncode({'name': name, 'email': email})); + if (res.statusCode == 201) { + return true; + } + } catch (e) { + mkPrint('Error: $e'); + } + return false; + } +} diff --git a/flutter_area/lib/Core/Manager/slack_manager.dart b/flutter_area/lib/Core/Manager/slack_manager.dart new file mode 100644 index 0000000..a19456c --- /dev/null +++ b/flutter_area/lib/Core/Manager/slack_manager.dart @@ -0,0 +1,30 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +import '../../Utils/constants.dart'; +import '../Locator/locator.dart'; +import 'user_manager.dart'; + +class SlackManager { + String? botToken; + + Future updateBotToken(String token) async { + final UserManager userManager = locator(); + final http.Response res = + await http.patch(Uri.parse('$kBaseUrl/slack/update-slack-bot'), + headers: { + 'accept': 'application/json', + 'Content-Type': 'application/json; charset=UTF-8', + 'Authorization': 'Bearer ${userManager.accessToken}', + }, + body: jsonEncode({ + 'botToken': token, + })); + final dynamic jsonBody = jsonDecode(res.body) as Map; + if (res.statusCode == 200) { + botToken = token; + return true; + } + return false; + } +} diff --git a/flutter_area/lib/Core/Manager/theme_manager.dart b/flutter_area/lib/Core/Manager/theme_manager.dart index d56905f..3f2fd17 100644 --- a/flutter_area/lib/Core/Manager/theme_manager.dart +++ b/flutter_area/lib/Core/Manager/theme_manager.dart @@ -1,11 +1,37 @@ import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class ThemeManager { + ThemeManager() { + final Future prefsF = SharedPreferences.getInstance(); + prefsF.then((SharedPreferences prefs) { + final String? themeMode = prefs.getString('themeMode'); + if (themeMode != null) { + themeModeNotifier.value = themeMode == 'light' + ? ThemeMode.light + : themeMode == 'dark' + ? ThemeMode.dark + : ThemeMode.system; + } + }); + } + ValueNotifier themeModeNotifier = - ValueNotifier(ThemeMode.light); + ValueNotifier(ThemeMode.system); - void inverseThemeMode() => - themeModeNotifier.value = themeModeNotifier.value == ThemeMode.light - ? ThemeMode.dark - : ThemeMode.light; + void inverseThemeMode() { + themeModeNotifier.value = themeModeNotifier.value == ThemeMode.light + ? ThemeMode.dark + : ThemeMode.light; + final Future prefsF = SharedPreferences.getInstance(); + prefsF.then((SharedPreferences prefs) { + prefs.setString( + 'themeMode', + themeModeNotifier.value == ThemeMode.light + ? 'light' + : themeModeNotifier.value == ThemeMode.dark + ? 'dark' + : 'system'); + }); + } } diff --git a/flutter_area/lib/Core/Manager/user_manager.dart b/flutter_area/lib/Core/Manager/user_manager.dart index c3602e5..f5bb86c 100644 --- a/flutter_area/lib/Core/Manager/user_manager.dart +++ b/flutter_area/lib/Core/Manager/user_manager.dart @@ -1,7 +1,9 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import 'package:stacked/stacked.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../UI/Connections/connections_viewmodel.dart'; +import '../../Utils/constants.dart'; import '../../Utils/mk_print.dart'; class UserManager { @@ -9,13 +11,17 @@ class UserManager { String? fullName; String? email; String? accessToken; - String? githubToken; + bool? isGoogleLogged; + bool? isGithubLogged; + ConnectionsViewModel? connectionsViewModel; + + AuthStateEnum state = AuthStateEnum.splash; Future<(bool, String?)> signUp( String email, String password, String username, String fullName) async { try { final http.Response res = await http.post( - Uri.parse('http://localhost:8080/auth/register'), + Uri.parse('$kBaseUrl/auth/register'), headers: { 'Content-Type': 'application/json', 'accept': 'application/json', @@ -29,11 +35,12 @@ class UserManager { })); final dynamic jsonBody = jsonDecode(res.body) as Map; if (res.statusCode == 201) { - _storeData(jsonBody); + storeUser(jsonBody['user'] as Map); + storeAccessToken(jsonBody['accessToken'] as String); return (true, null); } // ignore: avoid_dynamic_calls - return (false, _parseErrorMessage(jsonBody['message'])); + return (false, parseErrorMessage(jsonBody['message'])); } catch (e) { mkPrint('Error: $e'); return (false, e.toString()); @@ -41,8 +48,7 @@ class UserManager { } Future<(bool, String?)> login(String usernameOrEmail, String password) async { - final http.Response res = await http.post( - Uri.parse('http://localhost:8080/auth/login'), + final http.Response res = await http.post(Uri.parse('$kBaseUrl/auth/login'), headers: { 'Content-Type': 'application/json', 'accept': 'application/json', @@ -52,55 +58,71 @@ class UserManager { 'usernameOrEmail': usernameOrEmail, 'password': password })); - mkPrint(res.body); final dynamic jsonBody = jsonDecode(res.body) as Map; if (res.statusCode == 201) { - _storeData(jsonBody); + storeUser(jsonBody['user'] as Map); + storeAccessToken(jsonBody['accessToken'] as String); return (true, null); } // ignore: avoid_dynamic_calls - return (false, _parseErrorMessage(jsonBody['message'])); + return (false, parseErrorMessage(jsonBody['message'])); } - Future loginWithGoogle( - String accessToken, String completeName, String email) async { - final http.Response res = await http.post( - Uri.parse('http://localhost:8080/auth/google-login'), + Future getCurrentUser(String accessToken) async { + try { + final http.Response res = await http.get( + Uri.parse('$kBaseUrl/users/me'), headers: { 'accept': 'application/json', - 'Content-Type': 'application/json; charset=UTF-8', + 'Authorization': 'Bearer $accessToken', }, - body: jsonEncode({ - 'accessToken': accessToken, - 'completeName': completeName, - 'email': email - })); - final dynamic jsonBody = jsonDecode(res.body) as Map; - if (res.statusCode == 201) { - _storeData(jsonBody); - return true; + ); + mkPrint(res.statusCode); + if (res.statusCode == 200) { + final dynamic jsonBody = jsonDecode(res.body) as Map; + storeUser(jsonBody as Map); + return true; + } + } catch (e) { + mkPrint('Error: $e'); } return false; } - void _storeData(dynamic jsonBody) { - final Map body = jsonBody as Map; - final Map user = body['user'] as Map; - username = user['username'] as String?; - fullName = user['fullName'] as String; - email = user['email'] as String; - accessToken = body['accessToken'] as String; + Future logout() async { + final Future prefsF = SharedPreferences.getInstance(); + final SharedPreferences prefs = await prefsF; + prefs.remove('accessToken'); + username = null; + fullName = null; + email = null; + accessToken = null; + state = AuthStateEnum.splash; + } + + void storeUser(Map userJson) { + username = userJson['username'] as String?; + fullName = userJson['fullName'] as String?; + email = userJson['email'] as String; + isGoogleLogged = userJson['isLoggedInGoogle'] as bool; + isGithubLogged = userJson['isLoggedInGithub'] as bool; + } + + Future storeAccessToken(String token) async { + accessToken = token; + final SharedPreferences prefs = await SharedPreferences.getInstance(); + prefs.setString('accessToken', accessToken!); } Future createDraft(String name, String email) async { try { - final http.Response res = await http.post( - Uri.parse('http://localhost:8080/action-reaction/mvp'), - headers: { - 'Content-Type': 'application/json; charset=UTF-8', - 'Authorization': 'Bearer $accessToken', - }, - body: jsonEncode({'name': name, 'email': email})); + final http.Response res = + await http.post(Uri.parse('$kBaseUrl/action-reaction/mvp'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'Authorization': 'Bearer $accessToken', + }, + body: jsonEncode({'name': name, 'email': email})); if (res.statusCode == 201) { return true; } @@ -110,7 +132,7 @@ class UserManager { return false; } - String? _parseErrorMessage(dynamic msg) { + String? parseErrorMessage(dynamic msg) { if (msg is String) { return msg; } @@ -120,3 +142,5 @@ class UserManager { return null; } } + +enum AuthStateEnum { authenticated, unauthenticated, splash } diff --git a/flutter_area/lib/UI/Boostrap/boostrap_view.dart b/flutter_area/lib/UI/Boostrap/boostrap_view.dart new file mode 100644 index 0000000..ce6701d --- /dev/null +++ b/flutter_area/lib/UI/Boostrap/boostrap_view.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../Core/Locator/locator.dart'; +import '../../Core/Manager/action_reaction_manager.dart'; +import '../../Core/Manager/user_manager.dart'; + +class BoostrapView extends StatefulWidget { + const BoostrapView({super.key}); + + @override + State createState() => _BoostrapViewState(); +} + +class _BoostrapViewState extends State { + final Future _prefs = SharedPreferences.getInstance(); + + UserManager userManager = locator(); + ActionReactionManager actionReactionManager = + locator(); + + @override + void initState() { + super.initState(); + _prefs.then((SharedPreferences value) async { + final String? accessToken = value.getString('accessToken'); + if (accessToken != null) { + userManager.accessToken = accessToken; + if (await userManager.getCurrentUser(accessToken)) { + await actionReactionManager.getActionsReactions(); + userManager.state = AuthStateEnum.authenticated; + } else { + userManager.state = AuthStateEnum.unauthenticated; + } + } else { + userManager.state = AuthStateEnum.unauthenticated; + } + _navigateToScreen(); + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _navigateToScreen(); + }); + } + + void _navigateToScreen() { + switch (userManager.state) { + case AuthStateEnum.splash: + Navigator.of(context).pushNamed('/splash'); + case AuthStateEnum.authenticated: + Navigator.of(context).pushNamed('/home'); + case AuthStateEnum.unauthenticated: + Navigator.of(context).pushNamed('/login'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: false, + body: Container(), + ); + } +} diff --git a/flutter_area/lib/UI/Callback/callback_github.dart b/flutter_area/lib/UI/Callback/callback_github.dart index 153d3df..fe32026 100644 --- a/flutter_area/lib/UI/Callback/callback_github.dart +++ b/flutter_area/lib/UI/Callback/callback_github.dart @@ -1,15 +1,20 @@ import 'dart:convert'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:http/http.dart' as http; +import '../../Core/Locator/locator.dart'; +import '../../Core/Manager/github_manager.dart'; +import '../../Core/Manager/user_manager.dart'; +import '../../Utils/Extensions/color_extensions.dart'; import '../../Utils/Extensions/double_extensions.dart'; +import '../../Utils/constants.dart'; import '../../Utils/mk_print.dart'; -const String clientId = 'd373b5fe2e411c74b948'; -const String clientSecret = '155580817cc512a05837beb6d7bdf6e8d080f265'; - class CallbackGithubView extends StatefulWidget { const CallbackGithubView({super.key}); @@ -19,8 +24,8 @@ class CallbackGithubView extends StatefulWidget { class CallbackGithubViewState extends State { String? token; - static String clientId = 'd373b5fe2e411c74b948'; - static String clientSecret = '155580817cc512a05837beb6d7bdf6e8d080f265'; + UserManager userManager = locator(); + GithubManager githubManager = locator(); @override void initState() { @@ -33,30 +38,43 @@ class CallbackGithubViewState extends State { final Uri uri = Uri.base; final String? code = uri.queryParameters['code']; - mkPrint('Uri: $uri'); - mkPrint('Code: $code'); if (code != null) { - final http.Response response = await http.post( + http.Response response = await http.post( Uri.parse('https://github.com/login/oauth/access_token'), headers: { - 'Content-Type': 'application/json', 'Accept': 'application/json', + 'Access-Control-Allow-Origin': '*' }, - body: jsonEncode({ - 'client_id': clientId, - 'client_secret': clientSecret, + body: { + 'client_id': dotenv.env['GITHUB_CLIENT_ID']!, + 'client_secret': dotenv.env['GITHUB_CLIENT_SECRET']!, 'code': code, - }), + }, ); - if (response.statusCode == 200) { setState(() { // ignore: avoid_dynamic_calls token = jsonDecode(response.body)['access_token'] as String; }); mkPrint(token); + response = await http.post( + Uri.parse( + '$kBaseUrl/users/github-token', + ), + headers: { + 'Content-Type': 'application/json', + 'accept': 'application/json', + 'Authorization': 'Bearer ${userManager.accessToken}', + }, + body: jsonEncode({'githubToken': token!})); + if (response.statusCode == 201) { + userManager.isGithubLogged = true; + userManager.connectionsViewModel?.notify(); + mkPrint('Github token saved'); + } else { + mkPrint('Échec de la requête : ${response.statusCode}'); + } } else { - // Gérer l'erreur mkPrint('Échec de la requête : ${response.statusCode}'); } } @@ -64,19 +82,24 @@ class CallbackGithubViewState extends State { @override Widget build(BuildContext context) { - // Afficher une page de confirmation ou rediriger l'utilisateur return Scaffold( body: Column( children: [ const Spacer(), Center( - child: Text('Callback Github', + child: Text(AppLocalizations.of(context)!.connectionGithub, style: Theme.of(context).textTheme.titleLarge)), SizedBox(height: 100.0.ratioH()), Center( child: token != null - ? Text('Token: $token') - : const CircularProgressIndicator(), + ? Icon(CupertinoIcons.checkmark_alt_circle_fill, + color: Theme.of(context).colorScheme.redColor, + size: 100.0.ratioH()) + : SizedBox( + height: 100.0.ratioH(), + width: 100.0.ratioH(), + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.redColor)), ), const Spacer() ], diff --git a/flutter_area/lib/UI/Connections/connection_github.dart b/flutter_area/lib/UI/Connections/connection_github.dart deleted file mode 100644 index b443942..0000000 --- a/flutter_area/lib/UI/Connections/connection_github.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:flutter_web_auth/flutter_web_auth.dart'; - -const String clientId = 'd373b5fe2e411c74b948'; -const String accessToken = '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'; - -Future signInWithGitHub() async { - // URL de l'authentification - const String url = 'https://github.com/login/oauth/authorize' - '?client_id=$clientId' - '&scope=repo,user'; - await FlutterWebAuth.authenticate(url: url, callbackUrlScheme: 'http'); - - // Parser le token - // ignore: avoid_dynamic_calls - // Utiliser le token pour accéder à l'API GitHub -} diff --git a/flutter_area/lib/UI/Connections/connections_mobile_view.dart b/flutter_area/lib/UI/Connections/connections_mobile_view.dart new file mode 100644 index 0000000..2c5b340 --- /dev/null +++ b/flutter_area/lib/UI/Connections/connections_mobile_view.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:provider/provider.dart'; + +import '../../Core/Locator/locator.dart'; +import '../../Core/Manager/github_manager.dart'; +import '../../Core/Manager/slack_manager.dart'; +import '../../Core/Manager/theme_manager.dart'; +import '../../Core/Manager/user_manager.dart'; +import '../../Utils/Extensions/double_extensions.dart'; +import '../ReusableWidgets/mk_background.dart'; +import '../ReusableWidgets/mk_button.dart'; +import '../ReusableWidgets/mk_input.dart'; +import 'connections_viewmodel.dart'; + +class ConnectionsMobileView extends StatefulWidget { + const ConnectionsMobileView({super.key}); + + @override + State createState() => _ConnectionsMobileStateView(); +} + +class _ConnectionsMobileStateView extends State { + ThemeManager themeManager = locator(); + SlackManager slackManager = locator(); + GithubManager githubManager = locator(); + UserManager userManager = locator(); + + String slackBotTokenInput = ''; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (BuildContext context) => ConnectionsViewModel(), + builder: (BuildContext context, Widget? child) { + return Consumer( + builder: + (BuildContext context, ConnectionsViewModel vm, Widget? child) { + userManager.connectionsViewModel = vm; + return SafeArea( + child: MkBackground( + child: Padding( + padding: EdgeInsets.only( + top: 45.0.ratioH(), + left: 37.0.ratioW(), + right: 37.0.ratioW(), + bottom: 36.0.ratioH()), + child: Column( + children: [ + Row(children: [ + Text(AppLocalizations.of(context)!.connection, + style: Theme.of(context).textTheme.titleMedium) + ]), + const Divider( + endIndent: 0, + indent: 0, + ), + Row(children: [ + const Icon(FontAwesomeIcons.github, size: 20), + Text(AppLocalizations.of(context)!.github, + style: Theme.of(context).textTheme.labelLarge) + ]), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + AppLocalizations.of(context)!.connectGithub, + style: Theme.of(context).textTheme.labelMedium), + ) + ], + ), + MkButton( + label: AppLocalizations.of(context)!.connect, + onPressed: () async { + await githubManager.signInWithGitHub(); + }, + ), + const Divider( + endIndent: 0, + indent: 0, + ), + Row(children: [ + const Icon(FontAwesomeIcons.google, size: 20), + Text(AppLocalizations.of(context)!.google, + style: Theme.of(context).textTheme.labelLarge) + ]), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + AppLocalizations.of(context)!.connectGoogle, + style: Theme.of(context).textTheme.labelMedium), + ), + ], + ), + MkButton( + label: AppLocalizations.of(context)!.connect, + onPressed: () {}, + ), + const Divider( + endIndent: 0, + indent: 0, + ), + Row(children: [ + const Icon(FontAwesomeIcons.slack, size: 20), + Text(AppLocalizations.of(context)!.slack, + style: Theme.of(context).textTheme.labelLarge) + ]), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + AppLocalizations.of(context)!.connectSlack, + style: Theme.of(context).textTheme.labelMedium), + ), + ], + ), + MkInput( + placeholder: 'xxxx-...', + onChanged: (String value) { + setState(() { + slackBotTokenInput = value; + }); + }, + ), + MkButton( + label: AppLocalizations.of(context)!.validate, + onPressed: () { + SlackManager().updateBotToken(slackBotTokenInput); + }, + ), + ], + ), + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/flutter_area/lib/UI/Connections/connections_view.dart b/flutter_area/lib/UI/Connections/connections_view.dart index c797ab7..ce01ac4 100644 --- a/flutter_area/lib/UI/Connections/connections_view.dart +++ b/flutter_area/lib/UI/Connections/connections_view.dart @@ -1,14 +1,20 @@ -// ignore_for_file: always_specify_types import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:provider/provider.dart'; import '../../Core/Locator/locator.dart'; +import '../../Core/Manager/github_manager.dart'; +import '../../Core/Manager/google_manager.dart'; +import '../../Core/Manager/slack_manager.dart'; import '../../Core/Manager/theme_manager.dart'; +import '../../Core/Manager/user_manager.dart'; import '../../Utils/Extensions/double_extensions.dart'; import '../ReusableWidgets/mk_background.dart'; import '../ReusableWidgets/mk_button.dart'; -import 'connection_github.dart'; +import '../ReusableWidgets/mk_input.dart'; +import 'connections_viewmodel.dart'; class ConnectionsView extends StatefulWidget { const ConnectionsView({super.key}); @@ -19,64 +25,135 @@ class ConnectionsView extends StatefulWidget { class _ConnectionsViewState extends State { ThemeManager themeManager = locator(); + SlackManager slackManager = locator(); + GithubManager githubManager = locator(); + GoogleManager googleManager = locator(); + UserManager userManager = locator(); + + String slackBotTokenInput = ''; @override Widget build(BuildContext context) { - return MkBackground( - child: Padding( - padding: EdgeInsets.only( - top: 45.0.ratioH(), - left: 137.0.ratioW(), - right: 137.0.ratioW(), - bottom: 36.0.ratioH()), - child: Column( - children: [ - Row(children: [ - Text(AppLocalizations.of(context)!.connection, - style: Theme.of(context).textTheme.titleMedium) - ]), - const Divider( - endIndent: 0, - indent: 0, - ), - Row(children: [ - const Icon(FontAwesomeIcons.github, - size: 20, color: Colors.black), - Text(AppLocalizations.of(context)!.github, - style: Theme.of(context).textTheme.labelLarge) - ]), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(AppLocalizations.of(context)!.connectGithub, - style: Theme.of(context).textTheme.labelMedium), - MkButton( - label: AppLocalizations.of(context)!.connect, - onPressed: () async { - await signInWithGitHub(); - }, - ), - Row(children: [ - const Icon(FontAwesomeIcons.google, size: 20), - Text(AppLocalizations.of(context)!.google, - style: Theme.of(context).textTheme.labelLarge) - ]), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(AppLocalizations.of(context)!.connectGoogle, - style: Theme.of(context).textTheme.labelMedium), - MkButton( - label: AppLocalizations.of(context)!.connect, - onPressed: () {}, + return ChangeNotifierProvider( + create: (BuildContext context) => ConnectionsViewModel(), + builder: (BuildContext context, Widget? child) { + return Consumer( + builder: + (BuildContext context, ConnectionsViewModel vm, Widget? child) { + userManager.connectionsViewModel = vm; + return MkBackground( + child: Padding( + padding: EdgeInsets.only( + top: 45.0.ratioH(), + left: 137.0.ratioW(), + right: 137.0.ratioW(), + bottom: 36.0.ratioH()), + child: Column( + children: [ + Row(children: [ + Text(AppLocalizations.of(context)!.connection, + style: Theme.of(context).textTheme.titleMedium) + ]), + const Divider( + endIndent: 0, + indent: 0, + ), + Row(children: [ + const Icon(FontAwesomeIcons.github, size: 20), + Text(AppLocalizations.of(context)!.github, + style: Theme.of(context).textTheme.labelLarge) + ]), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(AppLocalizations.of(context)!.connectGithub, + style: Theme.of(context).textTheme.labelMedium), + MkButton( + label: userManager.isGithubLogged! + ? AppLocalizations.of(context)!.logout + : AppLocalizations.of(context)!.connect, + onPressed: () async { + if (userManager.isGithubLogged!) { + await githubManager.signOutFromGitHub(); + } else { + await githubManager.signInWithGitHub(); + } + }, + ), + ], + ), + Row(children: [ + const Icon(FontAwesomeIcons.google, size: 20), + Text(AppLocalizations.of(context)!.google, + style: Theme.of(context).textTheme.labelLarge) + ]), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(AppLocalizations.of(context)!.connectGoogle, + style: Theme.of(context).textTheme.labelMedium), + MkButton( + label: userManager.isGoogleLogged! + ? AppLocalizations.of(context)!.logout + : AppLocalizations.of(context)!.connect, + onPressed: () async { + if (userManager.isGoogleLogged!) { + await googleManager.setGoogleToken('none'); + } else { + final GoogleSignInAccount? res = + await googleManager.openGoogleAuthPopup(); + if (res == null) { + return; + } + final GoogleSignInAuthentication googleToken = + await res.authentication; + if (googleToken.accessToken == null) { + return; + } + googleManager + .setGoogleToken(googleToken.accessToken!); + } + }, + ), + ], + ), + Row(children: [ + const Icon(FontAwesomeIcons.slack, size: 20), + Text(AppLocalizations.of(context)!.slack, + style: Theme.of(context).textTheme.labelLarge) + ]), + SizedBox(height: 10.0.ratioH()), + Row(children: [ + Text(AppLocalizations.of(context)!.connectSlack, + style: Theme.of(context).textTheme.labelMedium) + ]), + SizedBox(height: 10.0.ratioH()), + MkInput( + placeholder: 'xxxx-...', + onChanged: (String value) { + setState(() { + slackBotTokenInput = value; + }); + }, + ), + SizedBox(height: 10.0.ratioH()), + Row( + children: [ + MkButton( + label: AppLocalizations.of(context)!.validate, + onPressed: () { + SlackManager().updateBotToken(slackBotTokenInput); + }, + ) + ], ), ], ), - ], - ), - ], - ), - ), + ), + ); + }, + ); + }, ); } } diff --git a/flutter_area/lib/UI/Connections/connections_viewmodel.dart b/flutter_area/lib/UI/Connections/connections_viewmodel.dart new file mode 100644 index 0000000..f928d25 --- /dev/null +++ b/flutter_area/lib/UI/Connections/connections_viewmodel.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; + +class ConnectionsViewModel with ChangeNotifier { + void notify() { + notifyListeners(); + } +} diff --git a/flutter_area/lib/UI/Home/home_mobile_view.dart b/flutter_area/lib/UI/Home/home_mobile_view.dart index 251c06e..0a9b4d8 100644 --- a/flutter_area/lib/UI/Home/home_mobile_view.dart +++ b/flutter_area/lib/UI/Home/home_mobile_view.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; +import '../../Core/Locator/locator.dart'; +import '../../Core/Manager/user_manager.dart'; import '../../Utils/Extensions/double_extensions.dart'; class HomeMobileView extends StatefulWidget { @@ -11,26 +13,27 @@ class HomeMobileView extends StatefulWidget { } class _MyWidgetState extends State { + UserManager userManager = locator(); + @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.symmetric(horizontal: 40.0.ratioH()), - child: Center( - child: Row( - children: [ - SvgPicture.asset('assets/images/Logo.svg', - semanticsLabel: 'Logo', - width: 50.0.ratioW(), - height: 50.0.ratioH()), - const Spacer(), - Text('Bienvenue, MalvinDu87.', - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(fontSize: 24)), - ], - ), + return SafeArea( + child: Align( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset('assets/images/Logo.svg', + semanticsLabel: 'Logo', + width: 50.0.ratioW(), + height: 50.0.ratioH()), + Text('Bienvenue,\n${userManager.fullName}.', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontSize: 24), + textAlign: TextAlign.center), + ], ), - ); + )); } } diff --git a/flutter_area/lib/UI/Home/home_view.dart b/flutter_area/lib/UI/Home/home_view.dart index 9a94803..04cef5c 100644 --- a/flutter_area/lib/UI/Home/home_view.dart +++ b/flutter_area/lib/UI/Home/home_view.dart @@ -1,22 +1,14 @@ -// ignore_for_file: duplicate_import - -import 'package:easy_sidemenu/easy_sidemenu.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../Core/Locator/locator.dart'; +import '../../Core/Manager/google_manager.dart'; import '../../Core/Manager/theme_manager.dart'; import '../../Core/Manager/user_manager.dart'; import '../../Utils/Extensions/color_extensions.dart'; import '../../Utils/Extensions/double_extensions.dart'; -import '../../Utils/constants.dart'; -import '../../Utils/mk_print.dart'; -import '../Connections/connections_view.dart'; -import '../NewTask/new_task_view.dart'; -import '../ReusableWidgets/mk_background.dart'; -import '../Settings/settings_view.dart'; -import 'home_mobile_view.dart'; class HomeView extends StatefulWidget { const HomeView({super.key}); @@ -28,288 +20,43 @@ class HomeView extends StatefulWidget { class _HomeViewState extends State { ThemeManager themeManager = locator(); UserManager userManager = locator(); - PageController pageController = PageController(); - SideMenuController sideMenu = SideMenuController(); + GoogleManager googleManager = locator(); TextEditingController nameController = TextEditingController(text: ''); TextEditingController emailController = TextEditingController(text: ''); - @override - void initState() { - sideMenu.addListener((int index) { - pageController.jumpToPage(index); - }); - super.initState(); - } - - int currentPageIndex = 0; - @override Widget build(BuildContext context) { - if (kIsPc) { - return MkBackground( - child: Scaffold( - backgroundColor: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor1 - : Theme.of(context).colorScheme.darkColor1, - body: Row( - children: [ - SideMenu( - controller: sideMenu, - style: SideMenuStyle( - displayMode: SideMenuDisplayMode.open, - compactSideMenuWidth: 60, - hoverColor: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor3 - : Theme.of(context).colorScheme.darkColor3, - selectedHoverColor: - Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor3 - : Theme.of(context).colorScheme.darkColor3, - selectedColor: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor4 - : Theme.of(context).colorScheme.darkColor4, - selectedTitleTextStyle: Theme.of(context).brightness == - Brightness.light - ? TextStyle(color: Theme.of(context).colorScheme.darkColor2) - : TextStyle( - color: Theme.of(context).colorScheme.lightColor2), - unselectedTitleTextStyle: Theme.of(context).brightness == - Brightness.light - ? TextStyle(color: Theme.of(context).colorScheme.darkColor2) - : TextStyle( - color: Theme.of(context).colorScheme.lightColor2), - selectedIconColor: - Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.darkColor2 - : Theme.of(context).colorScheme.lightColor2, - unselectedIconColor: - Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.darkColor2 - : Theme.of(context).colorScheme.lightColor2, - backgroundColor: - Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor2 - : Theme.of(context).colorScheme.darkColor2, - toggleColor: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.darkColor2 - : Theme.of(context).colorScheme.lightColor2, - itemInnerSpacing: 13.0, - ), - showToggle: true, - title: Row( - children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: SvgPicture.asset( - 'assets/images/Logo.svg', - semanticsLabel: 'Logo', - width: 18.0.ratioW(), - height: 18.0.ratioH(), - )), - ], - ), - items: [ - SideMenuItem( - title: AppLocalizations.of(context)!.home, - onTap: (int index, _) { - sideMenu.changePage(index); - }, - icon: const Icon(Icons.home), - ), - SideMenuItem( - title: AppLocalizations.of(context)!.profile, - onTap: (int index, _) { - sideMenu.changePage(index); - }, - icon: const Icon(Icons.person), - ), - SideMenuItem( - title: AppLocalizations.of(context)!.settings, - onTap: (int index, _) { - sideMenu.changePage(index); - }, - icon: const Icon(Icons.settings), - ), - SideMenuItem( - title: AppLocalizations.of(context)!.connection, - onTap: (int index, _) { - sideMenu.changePage(index); - }, - icon: const Icon(Icons.share), - ), - SideMenuItem( - title: AppLocalizations.of(context)!.newTask, - onTap: (int index, _) { - sideMenu.changePage(index); - }, - icon: const Icon(Icons.add_circle), - ), - SideMenuItem( - builder: - (BuildContext context, SideMenuDisplayMode displayMode) { - return const Divider( - endIndent: 8, - indent: 8, - ); - }, - ), - ], - ), - Expanded( - child: PageView( - controller: pageController, - children: [ - Padding( - padding: EdgeInsets.symmetric( - vertical: 100.0.ratioW(), horizontal: 100.0.ratioH()), - child: Column( - children: [ - Center( - child: Text(AppLocalizations.of(context)!.home), - ), - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Theme.of(context).brightness == - Brightness.light - ? Theme.of(context).colorScheme.lightColor2 - : Theme.of(context).colorScheme.darkColor2), - padding: EdgeInsets.symmetric( - vertical: 50.0.ratioW(), - horizontal: 50.0.ratioH()), - child: Column( - children: [ - TextField( - controller: nameController, - decoration: const InputDecoration( - hintText: 'Enter a name')), - SizedBox(height: 50.0.ratioH()), - TextField( - controller: emailController, - decoration: const InputDecoration( - hintText: 'Enter an email')), - SizedBox(height: 50.0.ratioH()), - ElevatedButton( - onPressed: () async { - if (nameController.text.isEmpty || - emailController.text.isEmpty) { - return; - } - mkPrint(await userManager.createDraft( - nameController.text, - emailController.text)); - }, - child: Text('Send an email', - style: Theme.of(context) - .textTheme - .bodyLarge)), - ], - ), - ) - ], - ), - ), - Center( - child: Text(AppLocalizations.of(context)!.profile), - ), - const SettingsView(), - const ConnectionsView(), - const Center( - child: NewTaskView(), - ), - ], - ), - ), - ], - ), - )); - } else { - return MkBackground( - child: Scaffold( - backgroundColor: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor1 - : Theme.of(context).colorScheme.darkColor1, - bottomNavigationBar: NavigationBar( - onDestinationSelected: (int index) { - setState(() { - currentPageIndex = index; - }); - }, - height: 200.0.ratioH(), - backgroundColor: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor2 - : Theme.of(context).colorScheme.darkColor2, - indicatorColor: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor4 - : Theme.of(context).colorScheme.darkColor4, - selectedIndex: currentPageIndex, - destinations: [ - NavigationDestination( - label: '', - selectedIcon: Icon(Icons.home, - color: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.darkColor1 - : Theme.of(context).colorScheme.lightColor1), - icon: Icon(Icons.home_outlined, - color: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor4 - : Theme.of(context).colorScheme.darkColor4), - ), - NavigationDestination( - label: '', - selectedIcon: Icon(Icons.account_circle, - color: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.darkColor1 - : Theme.of(context).colorScheme.lightColor1), - icon: Icon(Icons.account_circle_outlined, - color: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor4 - : Theme.of(context).colorScheme.darkColor4), - ), - NavigationDestination( - label: '', - selectedIcon: Icon(Icons.settings, - color: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.darkColor1 - : Theme.of(context).colorScheme.lightColor1), - icon: Icon(Icons.settings_outlined, - color: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor4 - : Theme.of(context).colorScheme.darkColor4), - ), - NavigationDestination( - label: '', - selectedIcon: Icon(Icons.share, - color: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.darkColor1 - : Theme.of(context).colorScheme.lightColor1), - icon: Icon(Icons.share_outlined, - color: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor4 - : Theme.of(context).colorScheme.darkColor4), - ), - NavigationDestination( - label: '', - selectedIcon: Icon(Icons.add_circle, - color: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.darkColor1 - : Theme.of(context).colorScheme.lightColor1), - icon: Icon(Icons.add_circle_outline, - color: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor4 - : Theme.of(context).colorScheme.darkColor4), - ), - ], - ), - body: [ - const HomeMobileView(), - const Center(child: Text('Account')), - const Center(child: Text('Settings')), - const Center(child: Text('Connections')), - const Center(child: Text('Add')), - ][currentPageIndex], - )); - } + return SafeArea( + child: Align( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset('assets/images/Logo.svg', + semanticsLabel: 'Logo', + width: 45.0.ratioW(), + height: 45.0.ratioH()), + Text('Bienvenue,\n${userManager.fullName}.', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontSize: 24), + textAlign: TextAlign.center), + SizedBox(height: 50.0.ratioH()), + TextButton( + style: TextButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.redColor), + onPressed: () async { + final Uri url = Uri.parse( + 'https://github.com/UwUClub/AwARea/releases/latest/download/maker.apk'); + if (!await launchUrl(url)) { + throw Exception('Could not launch $url'); + } + }, + child: Text(AppLocalizations.of(context)!.downloadAPK, + style: Theme.of(context).textTheme.bodyLarge)), + ], + ), + )); } } diff --git a/flutter_area/lib/UI/Login/google_button.dart b/flutter_area/lib/UI/Login/google_button.dart index f10b426..720842d 100644 --- a/flutter_area/lib/UI/Login/google_button.dart +++ b/flutter_area/lib/UI/Login/google_button.dart @@ -3,7 +3,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:google_sign_in/google_sign_in.dart'; import '../../Core/Locator/locator.dart'; -import '../../Core/Manager/user_manager.dart'; +import '../../Core/Manager/google_manager.dart'; import '../../Utils/Extensions/color_extensions.dart'; import '../../Utils/Extensions/double_extensions.dart'; @@ -13,31 +13,22 @@ class GoogleButton extends StatelessWidget { }); Future loginWithGoogle(BuildContext context) async { - final UserManager userManager = locator(); + final GoogleManager googleManager = locator(); - const List scopes = [ - 'email', - 'https://www.googleapis.com/auth/gmail.addons.current.action.compose', - 'https://www.googleapis.com/auth/gmail.compose', - ]; - - final GoogleSignIn googleSignIn = GoogleSignIn( - scopes: scopes, - ); + final GoogleSignInAccount? res = await googleManager.openGoogleAuthPopup(); + if (res == null) { + return; + } - try { - final GoogleSignInAccount? res = await googleSignIn.signIn(); - final GoogleSignInAuthentication token = await res!.authentication; - if (token.accessToken == null || res.displayName == null) { - return; - } - final bool success = await userManager.loginWithGoogle( - token.accessToken!, res.displayName!, res.email); - if (success) { - Navigator.of(context).pushNamed('/home'); - } - } catch (error) { - print(error); + final GoogleSignInAuthentication token = await res.authentication; + if (token.accessToken == null || res.displayName == null) { + return; + } + final bool success = await googleManager.loginWithGoogle( + token.accessToken!, res.displayName!, res.email); + if (success) { + // ignore: use_build_context_synchronously + Navigator.of(context).pushNamed('/home'); } } diff --git a/flutter_area/lib/UI/Login/login_form.dart b/flutter_area/lib/UI/Login/login_form.dart index fea7e5b..39356b1 100644 --- a/flutter_area/lib/UI/Login/login_form.dart +++ b/flutter_area/lib/UI/Login/login_form.dart @@ -25,14 +25,20 @@ class _LoginFormState extends State { String _emailOrUsername = ''; String _password = ''; String? _errorMessage; + bool loading = false; Future login() async { + setState(() => loading = true); final (bool success, String? error) = await userManager.login(_emailOrUsername, _password); if (success) { Navigator.of(context).pushNamed('/home'); + setState(() => loading = false); } else { - setState(() => _errorMessage = error); + setState(() { + _errorMessage = error; + loading = false; + }); } } @@ -40,19 +46,27 @@ class _LoginFormState extends State { Widget build(BuildContext context) { return Container( padding: EdgeInsets.symmetric( - vertical: kDeviceWidth > kLargeScreenWidth ? 108.0.ratioH() : 0), + vertical: + kDeviceWidth > kLargeScreenWidth ? 32.0.ratioH() : 50.0.ratioH()), width: MediaQuery.of(context).size.width, - color: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor2 - : Theme.of(context).colorScheme.darkColor2, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.0.ratioW()), + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor2 + : Theme.of(context).colorScheme.darkColor2, + ), child: Center( child: Container( padding: kDeviceWidth > kLargeScreenWidth ? null - : EdgeInsets.symmetric(horizontal: 30.0.ratioH()), + : EdgeInsets.symmetric(horizontal: 100.0.ratioH()), width: kDeviceWidth > kLargeScreenWidth ? 333.0.ratioW() : null, child: Column(children: [ const GoogleButton(), + if (kDeviceWidth > kLargeScreenWidth) + const SizedBox() + else + SizedBox(height: 20.0.ratioH()), Text(AppLocalizations.of(context)!.loginTitle, style: Theme.of(context).textTheme.headlineLarge), Divider( @@ -72,7 +86,8 @@ class _LoginFormState extends State { MkInput( label: AppLocalizations.of(context)!.password, onChanged: (String text) => setState(() => _password = text), - placeholder: AppLocalizations.of(context)!.passwordPlaceholder), + placeholder: AppLocalizations.of(context)!.passwordPlaceholder, + displayed: false), SizedBox(height: 28.0.ratioH()), if (_errorMessage == null) const SizedBox() @@ -80,15 +95,21 @@ class _LoginFormState extends State { Text(_errorMessage!, style: Theme.of(context).textTheme.headlineLarge?.merge( TextStyle( - color: Theme.of(context).colorScheme.redColor))), + color: Theme.of(context).colorScheme.redColor, + fontSize: + kDeviceWidth > kLargeScreenWidth ? null : 12))), SizedBox(height: 20.0.ratioH()), - MkButton( - labelColor: Theme.of(context).colorScheme.darkColor1, - backgroundColor: Theme.of(context).colorScheme.lightColor3, - label: '${AppLocalizations.of(context)!.login}...', - onPressed: () { - login(); - }), + if (loading) + CircularProgressIndicator( + color: Theme.of(context).colorScheme.redColor) + else + MkButton( + labelColor: Theme.of(context).colorScheme.darkColor1, + backgroundColor: Theme.of(context).colorScheme.lightColor3, + label: '${AppLocalizations.of(context)!.login}...', + onPressed: () { + login(); + }), ]), ), ), diff --git a/flutter_area/lib/UI/Login/login_screen.dart b/flutter_area/lib/UI/Login/login_screen.dart index 8881dc7..a7a6116 100644 --- a/flutter_area/lib/UI/Login/login_screen.dart +++ b/flutter_area/lib/UI/Login/login_screen.dart @@ -34,14 +34,16 @@ class _LoginScreenState extends State { child: SingleChildScrollView( child: Padding( padding: EdgeInsets.only( - top: 50.0.ratioH(), + top: kDeviceWidth > kLargeScreenWidth + ? 20.0.ratioH() + : 150.0.ratioH(), left: kDeviceWidth > kLargeScreenWidth ? 237.0.ratioW() - : 33.0.ratioW(), + : 40.0.ratioW(), right: kDeviceWidth > kLargeScreenWidth ? 237.0.ratioW() - : 33.0.ratioW(), - bottom: 36.0.ratioH()), + : 40.0.ratioW(), + bottom: 18.0.ratioH()), child: Column( children: [ Text( @@ -58,20 +60,24 @@ class _LoginScreenState extends State { children: [ SvgPicture.asset('assets/images/Logo.svg', semanticsLabel: 'Logo', - width: 40.0.ratioW(), - height: 40.0.ratioH()), - SizedBox(width: 9.0.ratioW()), - Text( - AppLocalizations.of(context)!.subslogan, - style: Theme.of(context).textTheme.headlineLarge?.merge( - TextStyle( - fontSize: kDeviceWidth > kLargeScreenWidth - ? 26 - : 13)), - textAlign: TextAlign.center, + width: 32.0.ratioW(), + height: 32.0.ratioW()), + SizedBox(width: 10.0.ratioW()), + Flexible( + child: Text( + AppLocalizations.of(context)!.subslogan, + style: Theme.of(context) + .textTheme + .headlineLarge + ?.merge(TextStyle( + fontSize: kDeviceWidth > kLargeScreenWidth + ? 26 + : 18)), + textAlign: TextAlign.center, + ), ) ]), - SizedBox(height: 40.0.ratioH()), + SizedBox(height: 20.0.ratioH()), if (kDeviceWidth > kLargeScreenWidth) Flex(direction: Axis.horizontal, children: [ Flexible( @@ -110,29 +116,35 @@ class _LoginScreenState extends State { const SignupForm(), if (kDeviceWidth <= kLargeScreenWidth) if (_currentForm == FormType.login) - GestureDetector( - onTap: () => - setState(() => _currentForm = FormType.signUp), - child: Text( - AppLocalizations.of(context)!.signup, - style: Theme.of(context) - .textTheme - .labelLarge - ?.merge(const TextStyle( - decoration: TextDecoration.underline, - )), - )) - else - GestureDetector( - onTap: () => - setState(() => _currentForm = FormType.login), - child: Text(AppLocalizations.of(context)!.login, + Padding( + padding: EdgeInsets.only(top: 40.0.ratioH()), + child: GestureDetector( + onTap: () => + setState(() => _currentForm = FormType.signUp), + child: Text( + AppLocalizations.of(context)!.signup, style: Theme.of(context) .textTheme .labelLarge ?.merge(const TextStyle( decoration: TextDecoration.underline, - )))), + )), + )), + ) + else + Padding( + padding: EdgeInsets.only(top: 40.0.ratioH()), + child: GestureDetector( + onTap: () => + setState(() => _currentForm = FormType.login), + child: Text(AppLocalizations.of(context)!.login, + style: Theme.of(context) + .textTheme + .labelLarge + ?.merge(const TextStyle( + decoration: TextDecoration.underline, + )))), + ), ], ), ), diff --git a/flutter_area/lib/UI/Login/signup_form.dart b/flutter_area/lib/UI/Login/signup_form.dart index 7a9b0f2..b6089aa 100644 --- a/flutter_area/lib/UI/Login/signup_form.dart +++ b/flutter_area/lib/UI/Login/signup_form.dart @@ -9,7 +9,6 @@ import '../../Utils/Extensions/double_extensions.dart'; import '../../Utils/constants.dart'; import '../ReusableWidgets/mk_button.dart'; import '../ReusableWidgets/mk_input.dart'; -import 'google_button.dart'; class SignupForm extends StatefulWidget { const SignupForm({super.key}); @@ -27,67 +26,102 @@ class SignupFormState extends State { String _email = ''; String _password = ''; String? _errorMessage; + bool loading = false; @override Widget build(BuildContext context) { return Container( - padding: EdgeInsets.symmetric(vertical: 108.0.ratioH()), + padding: EdgeInsets.symmetric( + vertical: + kDeviceWidth > kLargeScreenWidth ? 32.0.ratioH() : 50.0.ratioH()), width: MediaQuery.of(context).size.width, - color: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor2 - : Theme.of(context).colorScheme.darkColor2, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.0.ratioW()), + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor2 + : Theme.of(context).colorScheme.darkColor2, + ), child: Center( - child: SizedBox( + child: Container( + padding: kDeviceWidth > kLargeScreenWidth + ? null + : EdgeInsets.symmetric(horizontal: 100.0.ratioH()), width: kDeviceWidth > kLargeScreenWidth ? 333.0.ratioW() : null, child: Column(children: [ - const GoogleButton(), + // const GoogleButton(), Text(AppLocalizations.of(context)!.signUpTitle, - style: Theme.of(context).textTheme.headlineLarge), + style: Theme.of(context) + .textTheme + .headlineLarge + ?.copyWith(fontSize: kIsPc ? 26 : 20)), Divider( color: Theme.of(context).colorScheme.lightColor4, ), - SizedBox(height: 53.0.ratioH()), + SizedBox( + height: kDeviceWidth > kLargeScreenWidth + ? 15.0.ratioH() + : 40.0.ratioH()), MkInput( label: AppLocalizations.of(context)!.fullName, onChanged: (String text) => setState(() => _fullName = text), placeholder: AppLocalizations.of(context)!.fullNamePlaceholder), - SizedBox(height: 20.0.ratioH()), + SizedBox( + height: kDeviceWidth > kLargeScreenWidth + ? 15.0.ratioH() + : 40.0.ratioH()), MkInput( label: AppLocalizations.of(context)!.username, onChanged: (String text) => setState(() => _username = text), placeholder: AppLocalizations.of(context)!.usernamePlaceholder), - SizedBox(height: 20.0.ratioH()), + SizedBox( + height: kDeviceWidth > kLargeScreenWidth + ? 15.0.ratioH() + : 40.0.ratioH()), MkInput( label: AppLocalizations.of(context)!.email, onChanged: (String text) => setState(() => _email = text), placeholder: AppLocalizations.of(context)!.emailPlaceholder), - SizedBox(height: 20.0.ratioH()), + SizedBox(height: 10.0.ratioH()), MkInput( label: AppLocalizations.of(context)!.password, onChanged: (String text) => setState(() => _password = text), - placeholder: AppLocalizations.of(context)!.passwordPlaceholder), - SizedBox(height: 28.0.ratioH()), + displayed: false), + SizedBox( + height: kDeviceWidth > kLargeScreenWidth + ? 15.0.ratioH() + : 40.0.ratioH()), if (_errorMessage == null) const SizedBox() else Text(_errorMessage!, style: Theme.of(context).textTheme.headlineLarge?.merge( TextStyle( - color: Theme.of(context).colorScheme.redColor))), - SizedBox(height: 20.0.ratioH()), - MkButton( - labelColor: Theme.of(context).colorScheme.darkColor1, - backgroundColor: Theme.of(context).colorScheme.lightColor3, - label: '${AppLocalizations.of(context)!.signup}...', - onPressed: () async { - final (bool success, String? error) = await userManager - .signUp(_email, _password, _username, _fullName); - if (success) { - Navigator.of(context).pushNamed('/home'); - } else { - setState(() => _errorMessage = error); - } - }), + color: Theme.of(context).colorScheme.redColor, + fontSize: + kDeviceWidth > kLargeScreenWidth ? null : 12))), + SizedBox(height: 10.0.ratioH()), + if (loading) + CircularProgressIndicator( + color: Theme.of(context).colorScheme.redColor) + else + MkButton( + labelColor: Theme.of(context).colorScheme.darkColor1, + backgroundColor: Theme.of(context).colorScheme.lightColor3, + label: '${AppLocalizations.of(context)!.signup}...', + onPressed: () async { + setState(() => loading = true); + final (bool success, String? error) = await userManager + .signUp(_email, _password, _username, _fullName); + if (success) { + Navigator.of(context).pushNamed('/home'); + setState(() => loading = false); + } else { + setState(() { + _errorMessage = error; + loading = false; + }); + } + }), ]), ), ), diff --git a/flutter_area/lib/UI/MainNavigator/main_navigator.dart b/flutter_area/lib/UI/MainNavigator/main_navigator.dart new file mode 100644 index 0000000..5ab2198 --- /dev/null +++ b/flutter_area/lib/UI/MainNavigator/main_navigator.dart @@ -0,0 +1,171 @@ +import 'package:easy_sidemenu/easy_sidemenu.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../Core/Locator/locator.dart'; +import '../../Core/Manager/theme_manager.dart'; +import '../../Core/Manager/user_manager.dart'; +import '../../Utils/Extensions/color_extensions.dart'; +import '../../Utils/Extensions/double_extensions.dart'; +import '../Connections/connections_view.dart'; +import '../Home/home_view.dart'; +import '../NewTask/new_task_view.dart'; +import '../Profile/profile_view.dart'; +import '../ReusableWidgets/mk_background.dart'; +import '../Settings/settings_view.dart'; + +class MainNavigator extends StatefulWidget { + const MainNavigator({super.key}); + + @override + State createState() => _MainNavigatorState(); +} + +class _MainNavigatorState extends State { + ThemeManager themeManager = locator(); + UserManager userManager = locator(); + PageController pageController = PageController(); + SideMenuController sideMenu = SideMenuController(); + + @override + void initState() { + sideMenu.addListener((int index) { + pageController.jumpToPage(index); + }); + super.initState(); + } + + int currentPageIndex = 0; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor1 + : Theme.of(context).colorScheme.darkColor1, + body: MkBackground( + child: Row( + children: [ + SideMenu( + controller: sideMenu, + style: SideMenuStyle( + displayMode: SideMenuDisplayMode.compact, + compactSideMenuWidth: 60, + hoverColor: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor3 + : Theme.of(context).colorScheme.darkColor3, + selectedHoverColor: + Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor3 + : Theme.of(context).colorScheme.darkColor3, + selectedColor: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor4 + : Theme.of(context).colorScheme.darkColor4, + selectedTitleTextStyle: Theme.of(context).brightness == + Brightness.light + ? TextStyle(color: Theme.of(context).colorScheme.darkColor2) + : TextStyle( + color: Theme.of(context).colorScheme.lightColor2), + unselectedTitleTextStyle: Theme.of(context).brightness == + Brightness.light + ? TextStyle(color: Theme.of(context).colorScheme.darkColor2) + : TextStyle( + color: Theme.of(context).colorScheme.lightColor2), + selectedIconColor: + Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.darkColor2 + : Theme.of(context).colorScheme.lightColor2, + unselectedIconColor: + Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.darkColor2 + : Theme.of(context).colorScheme.lightColor2, + backgroundColor: + Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor2 + : Theme.of(context).colorScheme.darkColor2, + toggleColor: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.darkColor2 + : Theme.of(context).colorScheme.lightColor2, + itemInnerSpacing: 13.0, + ), + showToggle: true, + title: Row( + children: [ + Padding( + padding: const EdgeInsets.all(12.0), + child: SvgPicture.asset( + 'assets/images/Logo.svg', + semanticsLabel: 'Logo', + width: 18.0.ratioW(), + height: 18.0.ratioH(), + )), + ], + ), + items: [ + SideMenuItem( + title: AppLocalizations.of(context)!.home, + onTap: (int index, _) { + sideMenu.changePage(index); + }, + icon: const Icon(Icons.home), + ), + SideMenuItem( + title: AppLocalizations.of(context)!.profile, + onTap: (int index, _) { + sideMenu.changePage(index); + }, + icon: const Icon(Icons.person), + ), + SideMenuItem( + title: AppLocalizations.of(context)!.settings, + onTap: (int index, _) { + sideMenu.changePage(index); + }, + icon: const Icon(Icons.settings), + ), + SideMenuItem( + title: AppLocalizations.of(context)!.connection, + onTap: (int index, _) { + sideMenu.changePage(index); + }, + icon: const Icon(Icons.share), + ), + SideMenuItem( + title: AppLocalizations.of(context)!.newTask, + onTap: (int index, _) { + sideMenu.changePage(index); + }, + icon: const Icon(Icons.add_circle), + ), + SideMenuItem( + builder: + (BuildContext context, SideMenuDisplayMode displayMode) { + return const Divider( + endIndent: 8, + indent: 8, + ); + }, + ), + ], + ), + Expanded( + child: PageView( + controller: pageController, + children: const [ + HomeView(), + ProfileView(), + SettingsView(), + ConnectionsView(), + Center( + child: NewTaskView(), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_area/lib/UI/MainNavigator/main_navigator_mobile.dart b/flutter_area/lib/UI/MainNavigator/main_navigator_mobile.dart new file mode 100644 index 0000000..b34b034 --- /dev/null +++ b/flutter_area/lib/UI/MainNavigator/main_navigator_mobile.dart @@ -0,0 +1,132 @@ +import 'package:easy_sidemenu/easy_sidemenu.dart'; +import 'package:flutter/material.dart'; + +import '../../Core/Locator/locator.dart'; +import '../../Core/Manager/theme_manager.dart'; +import '../../Core/Manager/user_manager.dart'; +import '../../Utils/Extensions/color_extensions.dart'; +import '../../Utils/Extensions/double_extensions.dart'; +import '../Connections/connections_mobile_view.dart'; +import '../Home/home_mobile_view.dart'; +import '../NewTask/new_task_mobile_view.dart'; +import '../ReusableWidgets/mk_background.dart'; +import '../Settings/settings_mobile_view.dart'; + +class MainNavigatorMobile extends StatefulWidget { + const MainNavigatorMobile({super.key}); + + @override + State createState() => _MainNavigatorMobileState(); +} + +class _MainNavigatorMobileState extends State { + ThemeManager themeManager = locator(); + UserManager userManager = locator(); + PageController pageController = PageController(); + SideMenuController sideMenu = SideMenuController(); + + TextEditingController nameController = TextEditingController(text: ''); + TextEditingController emailController = TextEditingController(text: ''); + + @override + void initState() { + sideMenu.addListener((int index) { + pageController.jumpToPage(index); + }); + super.initState(); + } + + int currentPageIndex = 0; + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + child: MkBackground( + child: Scaffold( + backgroundColor: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor1 + : Theme.of(context).colorScheme.darkColor1, + bottomNavigationBar: NavigationBar( + onDestinationSelected: (int index) { + setState(() { + currentPageIndex = index; + }); + }, + height: 200.0.ratioH(), + backgroundColor: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor2 + : Theme.of(context).colorScheme.darkColor2, + indicatorColor: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor4 + : Theme.of(context).colorScheme.darkColor4, + selectedIndex: currentPageIndex, + destinations: [ + NavigationDestination( + label: '', + selectedIcon: Icon(Icons.home, + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.darkColor1 + : Theme.of(context).colorScheme.lightColor1), + icon: Icon(Icons.home_outlined, + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor4 + : Theme.of(context).colorScheme.darkColor4), + ), + NavigationDestination( + label: '', + selectedIcon: Icon(Icons.account_circle, + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.darkColor1 + : Theme.of(context).colorScheme.lightColor1), + icon: Icon(Icons.account_circle_outlined, + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor4 + : Theme.of(context).colorScheme.darkColor4), + ), + NavigationDestination( + label: '', + selectedIcon: Icon(Icons.settings, + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.darkColor1 + : Theme.of(context).colorScheme.lightColor1), + icon: Icon(Icons.settings_outlined, + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor4 + : Theme.of(context).colorScheme.darkColor4), + ), + NavigationDestination( + label: '', + selectedIcon: Icon(Icons.share, + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.darkColor1 + : Theme.of(context).colorScheme.lightColor1), + icon: Icon(Icons.share_outlined, + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor4 + : Theme.of(context).colorScheme.darkColor4), + ), + NavigationDestination( + label: '', + selectedIcon: Icon(Icons.add_circle, + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.darkColor1 + : Theme.of(context).colorScheme.lightColor1), + icon: Icon(Icons.add_circle_outline, + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor4 + : Theme.of(context).colorScheme.darkColor4), + ), + ], + ), + body: [ + const HomeMobileView(), + const Center(child: Text('Account')), + const SettingsMobileView(), + const ConnectionsMobileView(), + const NewTaskMobileView() + ][currentPageIndex], + )), + ); + } +} diff --git a/flutter_area/lib/UI/NewTask/Components/action_card.dart b/flutter_area/lib/UI/NewTask/Components/action_card.dart new file mode 100644 index 0000000..6556076 --- /dev/null +++ b/flutter_area/lib/UI/NewTask/Components/action_card.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import '../../../Core/Locator/locator.dart'; +import '../../../Core/Manager/action_reaction_manager.dart'; +import '../../../Utils/Extensions/color_extensions.dart'; +import '../../../Utils/Extensions/double_extensions.dart'; +import '../../../Utils/constants.dart'; +import 'action_form.dart'; + +class ActionCard extends StatefulWidget { + const ActionCard({super.key, required this.actionReaction}); + + final MkActionReaction actionReaction; + + @override + State createState() => _ActionCardState(); +} + +class _ActionCardState extends State { + ActionReactionManager manager = locator(); + + @override + Widget build(BuildContext context) { + return Container( + margin: kIsPc + ? EdgeInsets.all(10.0.ratioW()) + : EdgeInsets.only(top: 20.0.ratioW()), + padding: kIsPc ? EdgeInsets.all(20.0.ratioW()) : null, + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor2 + : Theme.of(context).colorScheme.darkColor2), + width: kIsPc ? 312.0.ratioW() : 165.0.ratioW(), + height: kIsPc ? 138.0.ratioH() : 400.0.ratioH(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Action ${widget.actionReaction.name}', + style: kIsPc + ? Theme.of(context).textTheme.headlineLarge + : Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith(fontWeight: FontWeight.bold)), + const Spacer(), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + manager.deleteActionReaction(widget.actionReaction.id); + }, + ), + ], + ), + Divider( + color: Theme.of(context).colorScheme.lightColor4, + ), + if (widget.actionReaction.id == 'local') + ActionForm( + actionReaction: widget.actionReaction, + onSubmit: (Map data) => >{ + manager.addAction( + widget.actionReaction.action.type, + widget.actionReaction.name, + data, + manager.actionsReactions.indexOf(widget.actionReaction)) + }, + ) + else + Column(children: [ + Text(widget.actionReaction.action.type.label, + style: Theme.of(context).textTheme.headlineMedium), + ]), + ]), + ); + } +} diff --git a/flutter_area/lib/UI/NewTask/Components/action_form.dart b/flutter_area/lib/UI/NewTask/Components/action_form.dart new file mode 100644 index 0000000..a821765 --- /dev/null +++ b/flutter_area/lib/UI/NewTask/Components/action_form.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../../../Core/Manager/action_reaction_manager.dart'; + +class ActionForm extends StatefulWidget { + const ActionForm({ + super.key, + required this.actionReaction, + required this.onSubmit, + }); + + final MkActionReaction actionReaction; + final void Function(Map) onSubmit; + + @override + State createState() => _ActionFormState(); +} + +class _ActionFormState extends State { + List fieldLabels = []; + List fieldValues = []; + + @override + void initState() { + super.initState(); + switch (widget.actionReaction.action.type) { + case ActionType.NASA_GET_APOD: + fieldLabels = []; + case ActionType.WEATHER_GET_CURRENT: + fieldLabels = ['location']; + case ActionType.TIMER: + fieldLabels = ['date']; + case ActionType.PULL_REQUEST_CREATED || + ActionType.ISSUE_OPENED || + ActionType.BRANCH_MERGED || + ActionType.PULL_REQUEST_REVIEW_REQUESTED || + ActionType.PULL_REQUEST_REVIEW_REQUEST_REMOVED || + ActionType.BRANCH_CREATED || + ActionType.BRANCH_DELETED || + ActionType.STAR_ADDED || + ActionType.STAR_REMOVED: + fieldLabels = ['githubRepoName']; + case ActionType.NONE: + break; + } + } + + String convertDateFormat(String date) { + final List dateParts = date.split(' '); + if (dateParts.length < 2) { + return date; + } + final List dateParts2 = dateParts[0].split('/'); + final String result = + '${dateParts2[2]}-${dateParts2[1]}-${dateParts2[0]}T${dateParts[1]}.000Z'; + return result; + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + for (int i = 0; i < fieldLabels.length; i++) + TextFormField( + style: const TextStyle(fontSize: 12), + onChanged: (String value) { + fieldValues[i] = value; + }, + decoration: InputDecoration( + labelText: fieldLabels[i], + labelStyle: + const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + hintText: fieldLabels[i] == 'date' + ? 'format: dd/mm/yyyy hh:mm:ss' + : null, + ), + validator: (String? value) { + if (value == null || value.isEmpty) { + return 'Please enter some text'; + } + return null; + }, + ), + ElevatedButton( + onPressed: () { + final Map data = {}; + for (int i = 0; i < fieldLabels.length; i++) { + data[fieldLabels[i]] = fieldValues[i]; + } + data['actionType'] = widget.actionReaction.action.type.name; + if (data['date'] != null) { + data['date'] = convertDateFormat(data['date']!); + } + widget.onSubmit(data); + }, + child: Text(AppLocalizations.of(context)!.createAction), + ), + ], + ); + } +} diff --git a/flutter_area/lib/UI/NewTask/Components/action_selection.dart b/flutter_area/lib/UI/NewTask/Components/action_selection.dart new file mode 100644 index 0000000..f48bbe8 --- /dev/null +++ b/flutter_area/lib/UI/NewTask/Components/action_selection.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:prompt_dialog/prompt_dialog.dart'; + +import '../../../Core/Locator/locator.dart'; +import '../../../Core/Manager/action_reaction_manager.dart'; +import '../../../Utils/Extensions/color_extensions.dart'; + +import '../../ReusableWidgets/mk_button.dart'; + +class ActionSelection extends StatelessWidget { + const ActionSelection( + {super.key, required this.label, required this.actionTypes}); + + final String label; + final List? actionTypes; + + @override + Widget build(BuildContext context) { + return SizedBox( + child: Column(children: [ + Text(label, style: Theme.of(context).textTheme.headlineMedium), + for (final ActionType actionType in actionTypes!) + Row( + children: [ + MkButton( + label: actionType.label, + backgroundColor: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor3 + : Theme.of(context).colorScheme.darkColor3, + labelColor: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.darkTransColor2 + : Theme.of(context).colorScheme.lightTransColor2, + onPressed: () async { + final String? name = + await prompt(context, title: const Text('Action name:')); + if (name != null) { + locator() + .addLocalActionForm(name, actionType); + } + }, + ), + ], + ), + ])); + } +} diff --git a/flutter_area/lib/UI/NewTask/Components/action_selection_list.dart b/flutter_area/lib/UI/NewTask/Components/action_selection_list.dart new file mode 100644 index 0000000..e2c907f --- /dev/null +++ b/flutter_area/lib/UI/NewTask/Components/action_selection_list.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../../../Core/Manager/action_reaction_manager.dart'; +import 'action_selection.dart'; + +class ActionSelectionList extends StatefulWidget { + const ActionSelectionList({super.key}); + + @override + State createState() => _ActionSelectionListState(); +} + +class _ActionSelectionListState extends State { + @override + Widget build(BuildContext context) { + return const Column( + children: [ + ActionSelection( + label: 'Horloge', + actionTypes: [ + ActionType.TIMER, + ], + ), + ActionSelection( + label: 'Météo', + actionTypes: [ + ActionType.WEATHER_GET_CURRENT, + ], + ), + ActionSelection( + label: 'Nasa', + actionTypes: [ + ActionType.NASA_GET_APOD, + ], + ), + ActionSelection( + label: 'Github', + actionTypes: [ + ActionType.PULL_REQUEST_CREATED, + ActionType.ISSUE_OPENED, + ActionType.BRANCH_MERGED, + ActionType.PULL_REQUEST_REVIEW_REQUESTED, + ActionType.PULL_REQUEST_REVIEW_REQUEST_REMOVED, + ActionType.BRANCH_CREATED, + ActionType.BRANCH_DELETED, + ActionType.STAR_ADDED, + ActionType.STAR_REMOVED + ], + ), + ], + ); + } +} diff --git a/flutter_area/lib/UI/NewTask/Components/reaction_card.dart b/flutter_area/lib/UI/NewTask/Components/reaction_card.dart new file mode 100644 index 0000000..d4bc75a --- /dev/null +++ b/flutter_area/lib/UI/NewTask/Components/reaction_card.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +import '../../../Core/Locator/locator.dart'; +import '../../../Core/Manager/action_reaction_manager.dart'; +import '../../../Utils/Extensions/color_extensions.dart'; +import '../../../Utils/Extensions/double_extensions.dart'; +import '../../../Utils/constants.dart'; +import 'reaction_form.dart'; +import 'reaction_selection.dart'; + +class ReactionCard extends StatefulWidget { + const ReactionCard({super.key, required this.actionReaction}); + + final MkActionReaction actionReaction; + + @override + State createState() => _ReactionCardState(); +} + +class _ReactionCardState extends State { + ReactionType? creatingReaction; + ActionReactionManager manager = locator(); + + void setReaction(ReactionType type, Map data) { + manager.setReaction( + widget.actionReaction.id, + type, + data, + ); + } + + @override + Widget build(BuildContext context) { + return Container( + margin: kIsPc + ? EdgeInsets.all(10.0.ratioW()) + : EdgeInsets.only(top: 20.0.ratioW()), + padding: kIsPc ? EdgeInsets.all(20.0.ratioW()) : null, + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor2 + : Theme.of(context).colorScheme.darkColor2), + width: kIsPc ? 312.0.ratioW() : 165.0.ratioW(), + height: kIsPc ? 138.0.ratioH() : 400.0.ratioH(), + child: widget.actionReaction.id == 'local' + ? const SizedBox() + : widget.actionReaction.reaction == null + ? (creatingReaction == null + ? ListView(children: [ + ReactionSelection( + label: 'Google', + reactionTypes: const [ + ReactionType.CREATE_DRAFT, + ReactionType.SEND_EMAIL, + ], + setReaction: (ReactionType type) { + setState(() { + creatingReaction = type; + }); + }), + ReactionSelection( + label: 'Slack', + reactionTypes: const [ + ReactionType.SEND_SLACK_MESSAGE, + ReactionType.CREATE_SLACK_CHANNEL, + ], + setReaction: (ReactionType type) { + setState(() { + creatingReaction = type; + }); + }), + ]) + : ReactionForm( + reactionType: creatingReaction!, + onSubmit: (Map data) => + {setReaction(creatingReaction!, data)})) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Reaction', + style: kIsPc + ? Theme.of(context).textTheme.headlineLarge + : Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith(fontWeight: FontWeight.bold), + softWrap: false, + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.edit), + onPressed: () { + manager.removeReactionLocally( + widget.actionReaction.id); + }, + ), + ], + ), + Divider( + color: Theme.of(context).colorScheme.lightColor4, + ), + Text(widget.actionReaction.reaction!.type.label, + style: Theme.of(context).textTheme.headlineMedium), + ]), + ); + } +} diff --git a/flutter_area/lib/UI/NewTask/Components/reaction_form.dart b/flutter_area/lib/UI/NewTask/Components/reaction_form.dart new file mode 100644 index 0000000..30ac88d --- /dev/null +++ b/flutter_area/lib/UI/NewTask/Components/reaction_form.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../../../Core/Manager/action_reaction_manager.dart'; + +class ReactionForm extends StatefulWidget { + const ReactionForm( + {super.key, required this.reactionType, required this.onSubmit}); + + final ReactionType reactionType; + final void Function(Map) onSubmit; + + @override + State createState() => _ReactionFormState(); +} + +class _ReactionFormState extends State { + List fieldLabels = []; + List fieldValues = []; + + @override + void initState() { + super.initState(); + switch (widget.reactionType) { + case ReactionType.CREATE_DRAFT: + fieldLabels = ['destinationEmail', 'subject', 'body']; + case ReactionType.SEND_SLACK_MESSAGE: + fieldLabels = ['channelName', 'message']; + case ReactionType.CREATE_SLACK_CHANNEL: + fieldLabels = ['channelName']; + case ReactionType.SEND_EMAIL: + fieldLabels = ['destinationEmail', 'subject', 'body']; + case ReactionType.NONE: + break; + } + } + + @override + Widget build(BuildContext context) { + return ListView( + children: [ + for (int i = 0; i < fieldLabels.length; i++) + TextFormField( + style: const TextStyle(fontSize: 12), + onChanged: (String value) { + fieldValues[i] = value; + }, + decoration: InputDecoration( + labelText: fieldLabels[i], + labelStyle: + const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + validator: (String? value) { + if (value == null || value.isEmpty) { + return 'Please enter some text'; + } + return null; + }, + ), + ElevatedButton( + onPressed: () { + final Map data = {}; + for (int i = 0; i < fieldLabels.length; i++) { + data[fieldLabels[i]] = fieldValues[i]; + } + data['reactionType'] = widget.reactionType.name; + widget.onSubmit(data); + }, + child: Text(AppLocalizations.of(context)!.createReaction), + ), + ], + ); + } +} diff --git a/flutter_area/lib/UI/NewTask/reaction_selection.dart b/flutter_area/lib/UI/NewTask/Components/reaction_selection.dart similarity index 71% rename from flutter_area/lib/UI/NewTask/reaction_selection.dart rename to flutter_area/lib/UI/NewTask/Components/reaction_selection.dart index 6e284f4..b21b2c1 100644 --- a/flutter_area/lib/UI/NewTask/reaction_selection.dart +++ b/flutter_area/lib/UI/NewTask/Components/reaction_selection.dart @@ -1,21 +1,21 @@ import 'package:flutter/material.dart'; -import '../../Core/Manager/action_manager.dart'; -import '../../Utils/Extensions/color_extensions.dart'; -import '../../Utils/Extensions/double_extensions.dart'; +import '../../../Core/Manager/action_reaction_manager.dart'; +import '../../../Utils/Extensions/color_extensions.dart'; +import '../../../Utils/Extensions/double_extensions.dart'; -import '../ReusableWidgets/mk_button.dart'; +import '../../ReusableWidgets/mk_button.dart'; class ReactionSelection extends StatelessWidget { const ReactionSelection( {super.key, required this.label, - this.reactions, + this.reactionTypes, required this.setReaction}); final String label; - final List? reactions; - final void Function(MkReaction) setReaction; + final List? reactionTypes; + final void Function(ReactionType) setReaction; @override Widget build(BuildContext context) { @@ -25,11 +25,11 @@ class ReactionSelection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: Theme.of(context).textTheme.headlineMedium), - for (final MkReaction reaction in reactions!) + for (final ReactionType reactionType in reactionTypes!) Row( children: [ MkButton( - label: reaction.name, + label: reactionType.label, backgroundColor: Theme.of(context).brightness == Brightness.light ? Theme.of(context).colorScheme.lightColor3 @@ -39,7 +39,7 @@ class ReactionSelection extends StatelessWidget { ? Theme.of(context).colorScheme.darkTransColor2 : Theme.of(context).colorScheme.lightTransColor2, onPressed: () { - setReaction(reaction); + setReaction(reactionType); }, ), ], diff --git a/flutter_area/lib/UI/NewTask/task_card.dart b/flutter_area/lib/UI/NewTask/Components/task_card.dart similarity index 83% rename from flutter_area/lib/UI/NewTask/task_card.dart rename to flutter_area/lib/UI/NewTask/Components/task_card.dart index 38f07c1..4ffaf61 100644 --- a/flutter_area/lib/UI/NewTask/task_card.dart +++ b/flutter_area/lib/UI/NewTask/Components/task_card.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../../Core/Manager/action_manager.dart'; +import '../../../Core/Manager/action_reaction_manager.dart'; class TaskCard extends StatefulWidget { const TaskCard({super.key, required this.action, required this.delete}); @@ -17,7 +17,7 @@ class _TaskCardState extends State { return Column(children: [ Row( children: [ - Text(widget.action.name), + Text(widget.action.type.label), IconButton( icon: const Icon(Icons.delete), onPressed: () { @@ -26,7 +26,6 @@ class _TaskCardState extends State { ), ], ), - Text(widget.action.description), ]); } } diff --git a/flutter_area/lib/UI/NewTask/action_card.dart b/flutter_area/lib/UI/NewTask/action_card.dart deleted file mode 100644 index 325b263..0000000 --- a/flutter_area/lib/UI/NewTask/action_card.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter/material.dart'; -import '../../Core/Manager/action_manager.dart'; -import '../../Utils/Extensions/color_extensions.dart'; -import '../../Utils/Extensions/double_extensions.dart'; - -class ActionCard extends StatefulWidget { - const ActionCard({super.key, required this.action, required this.delete}); - - final MkAction action; - final void Function() delete; - - @override - State createState() => _ActionCardState(); -} - -class _ActionCardState extends State { - @override - Widget build(BuildContext context) { - return Container( - margin: EdgeInsets.all(10.0.ratioW()), - padding: EdgeInsets.all(20.0.ratioW()), - decoration: BoxDecoration( - color: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor2 - : Theme.of(context).colorScheme.darkColor2), - width: 312.0.ratioW(), - height: 138.0.ratioH(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text('Action ${widget.action.service}', - style: Theme.of(context).textTheme.headlineLarge), - const Expanded(child: SizedBox()), - IconButton( - icon: const Icon(Icons.delete), - onPressed: () { - widget.delete(); - }, - ), - ], - ), - Divider( - color: Theme.of(context).colorScheme.lightColor4, - ), - Text(widget.action.name, - style: Theme.of(context).textTheme.headlineMedium), - Text(widget.action.description, - style: Theme.of(context).textTheme.headlineSmall), - ]), - ); - } -} diff --git a/flutter_area/lib/UI/NewTask/action_selection.dart b/flutter_area/lib/UI/NewTask/action_selection.dart deleted file mode 100644 index a794d30..0000000 --- a/flutter_area/lib/UI/NewTask/action_selection.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../Core/Manager/action_manager.dart'; -import '../../Utils/Extensions/color_extensions.dart'; -import '../../Utils/Extensions/double_extensions.dart'; - -import '../ReusableWidgets/mk_button.dart'; - -class ActionSelection extends StatelessWidget { - const ActionSelection( - {super.key, - required this.label, - this.actions, - this.reactions, - required this.addAction}); - - final String label; - final List? actions; - final List? reactions; - final void Function(MkAction) addAction; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 240.0.ratioW(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: Theme.of(context).textTheme.headlineMedium), - for (final MkAction action in actions!) - Row( - children: [ - MkButton( - label: action.name, - backgroundColor: - Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor3 - : Theme.of(context).colorScheme.darkColor3, - labelColor: - Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.darkTransColor2 - : Theme.of(context).colorScheme.lightTransColor2, - onPressed: () { - addAction(action); - }, - ), - ], - ), - ])); - } -} diff --git a/flutter_area/lib/UI/NewTask/new_task_mobile_view.dart b/flutter_area/lib/UI/NewTask/new_task_mobile_view.dart new file mode 100644 index 0000000..1323bbb --- /dev/null +++ b/flutter_area/lib/UI/NewTask/new_task_mobile_view.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../Core/Locator/locator.dart'; +import '../../Core/Manager/action_reaction_manager.dart'; +import '../../Utils/Extensions/color_extensions.dart'; +import '../../Utils/Extensions/double_extensions.dart'; +import '../ReusableWidgets/mk_background.dart'; +import 'Components/action_card.dart'; +import 'Components/action_selection_list.dart'; +import 'Components/reaction_card.dart'; +import 'new_task_viewmodel.dart'; + +class NewTaskMobileView extends StatefulWidget { + const NewTaskMobileView({super.key}); + + @override + State createState() => _NewTaskMobileViewState(); +} + +class _NewTaskMobileViewState extends State { + ActionReactionManager actionReactionManager = + locator(); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (BuildContext context) => NewTaskViewModel(), + builder: (BuildContext context, Widget? child) { + return Consumer( + builder: (BuildContext context, NewTaskViewModel vm, Widget? child) { + actionReactionManager.newTaskViewModel = vm; + return SafeArea( + child: MkBackground( + child: SingleChildScrollView( + child: Column(children: [ + Container( + padding: EdgeInsets.all(20.0.ratioW()), + decoration: BoxDecoration( + color: + Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor2 + : Theme.of(context).colorScheme.darkColor2), + child: const ActionSelectionList()), + Container( + padding: EdgeInsets.symmetric(horizontal: 20.0.ratioW()), + child: Column( + children: [ + for (final MkActionReaction actionReaction + in actionReactionManager.actionsReactions) + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + ActionCard( + actionReaction: actionReaction, + ), + ReactionCard( + actionReaction: actionReaction, + ), + ]), + ], + ), + ), + ]), + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/flutter_area/lib/UI/NewTask/new_task_view.dart b/flutter_area/lib/UI/NewTask/new_task_view.dart index d98b2eb..c9a6fff 100644 --- a/flutter_area/lib/UI/NewTask/new_task_view.dart +++ b/flutter_area/lib/UI/NewTask/new_task_view.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; -import '../../Core/Manager/action_manager.dart'; +import '../../Core/Locator/locator.dart'; +import '../../Core/Manager/action_reaction_manager.dart'; import '../../Utils/Extensions/color_extensions.dart'; import '../../Utils/Extensions/double_extensions.dart'; import '../ReusableWidgets/mk_background.dart'; -import 'action_card.dart'; -import 'action_selection.dart'; -import 'reaction_card.dart'; +import 'Components/action_card.dart'; +import 'Components/action_selection_list.dart'; +import 'Components/reaction_card.dart'; +import 'new_task_viewmodel.dart'; class NewTaskView extends StatefulWidget { const NewTaskView({super.key}); @@ -16,80 +19,54 @@ class NewTaskView extends StatefulWidget { } class _NewTaskViewState extends State { - List actions = []; - - void addAction(MkAction action) { - setState(() { - actions.add(action); - }); - } + ActionReactionManager actionReactionManager = + locator(); @override Widget build(BuildContext context) { - return MkBackground( - child: Row(children: [ - Expanded( - child: SingleChildScrollView( - child: Container( - padding: EdgeInsets.all(70.0.ratioW()), - constraints: - BoxConstraints(minHeight: MediaQuery.of(context).size.height), - child: Column( - children: [ - for (final MkAction action in actions) - Row(children: [ - ActionCard( - action: action, - delete: () { - setState(() { - actions.remove(action); - }); - }), - ReactionCard( - reaction: action.reaction, - setReaction: (MkReaction? reaction) { - setState(() { - action.reaction = reaction; - }); - }, + return ChangeNotifierProvider( + create: (BuildContext context) => NewTaskViewModel(), + builder: (BuildContext context, Widget? child) { + return Consumer( + builder: (BuildContext context, NewTaskViewModel vm, Widget? child) { + actionReactionManager.newTaskViewModel = vm; + return MkBackground( + child: Row(children: [ + Expanded( + child: SingleChildScrollView( + child: Container( + padding: EdgeInsets.all(70.0.ratioW()), + constraints: BoxConstraints( + minHeight: MediaQuery.of(context).size.height), + child: Column( + children: [ + for (final MkActionReaction actionReaction + in actionReactionManager.actionsReactions) + Row(children: [ + ActionCard( + actionReaction: actionReaction, + ), + ReactionCard( + actionReaction: actionReaction, + ), + ]), + ], ), - ]), - ], - ), - ), - ), - ), - Container( - padding: EdgeInsets.all(30.0.ratioW()), - decoration: BoxDecoration( - color: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor2 - : Theme.of(context).colorScheme.darkColor2), - child: Column( - children: [ - ActionSelection( - label: 'Météo', - actions: [ - MkAction( - service: 'Weather', - name: 'Get meteo', - description: 'toto'), - ], - addAction: addAction, - ), - ActionSelection( - label: 'Clock', - actions: [ - MkAction( - service: 'Clock', - name: 'Every n minutes', - description: 'toto'), - ], - addAction: addAction, + ), + ), ), - ], - )) - ]), + Container( + padding: EdgeInsets.all(30.0.ratioW()), + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor2 + : Theme.of(context).colorScheme.darkColor2), + child: const ActionSelectionList()) + ]), + ); + }, + ); + }, ); } } diff --git a/flutter_area/lib/UI/NewTask/new_task_viewmodel.dart b/flutter_area/lib/UI/NewTask/new_task_viewmodel.dart new file mode 100644 index 0000000..8b3dce4 --- /dev/null +++ b/flutter_area/lib/UI/NewTask/new_task_viewmodel.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; + +class NewTaskViewModel with ChangeNotifier { + void notify() { + notifyListeners(); + } +} diff --git a/flutter_area/lib/UI/NewTask/reaction_card.dart b/flutter_area/lib/UI/NewTask/reaction_card.dart deleted file mode 100644 index a3eab28..0000000 --- a/flutter_area/lib/UI/NewTask/reaction_card.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../Core/Manager/action_manager.dart'; -import '../../Utils/Extensions/color_extensions.dart'; -import '../../Utils/Extensions/double_extensions.dart'; -import 'reaction_selection.dart'; - -class ReactionCard extends StatefulWidget { - const ReactionCard({super.key, this.reaction, required this.setReaction}); - - final MkReaction? reaction; - final void Function(MkReaction?) setReaction; - - @override - State createState() => _ReactionCardState(); -} - -class _ReactionCardState extends State { - void setReaction(MkReaction? reaction) { - setState(() { - widget.setReaction(reaction); - }); - } - - @override - Widget build(BuildContext context) { - return Container( - margin: EdgeInsets.all(10.0.ratioW()), - padding: EdgeInsets.all(20.0.ratioW()), - decoration: BoxDecoration( - color: Theme.of(context).brightness == Brightness.light - ? Theme.of(context).colorScheme.lightColor2 - : Theme.of(context).colorScheme.darkColor2), - width: 312.0.ratioW(), - height: 138.0.ratioH(), - child: widget.reaction == null - ? ReactionSelection( - label: 'Google', - reactions: [ - MkReaction( - service: 'Gmail', - name: 'Send a mail', - description: 'description'), - MkReaction( - service: 'Drive', - name: 'Upload a file', - description: 'description'), - ], - setReaction: setReaction, - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'Reaction ${widget.reaction!.service}', - style: Theme.of(context).textTheme.headlineLarge, - softWrap: false, - ), - const Expanded(child: SizedBox()), - IconButton( - icon: const Icon(Icons.edit), - onPressed: () { - widget.setReaction(null); - }, - ), - ], - ), - Divider( - color: Theme.of(context).colorScheme.lightColor4, - ), - Text(widget.reaction!.name, - style: Theme.of(context).textTheme.headlineMedium), - Text(widget.reaction!.description, - style: Theme.of(context).textTheme.headlineSmall), - ]), - ); - } -} diff --git a/flutter_area/lib/UI/Profile/profile_view.dart b/flutter_area/lib/UI/Profile/profile_view.dart new file mode 100644 index 0000000..476284e --- /dev/null +++ b/flutter_area/lib/UI/Profile/profile_view.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../../Core/Locator/locator.dart'; +import '../../Core/Manager/user_manager.dart'; +import '../../Utils/Extensions/double_extensions.dart'; +import '../ReusableWidgets/mk_background.dart'; + +class ProfileView extends StatefulWidget { + const ProfileView({super.key}); + + @override + State createState() => _ProfileViewState(); +} + +class _ProfileViewState extends State { + UserManager userManager = locator(); + + @override + Widget build(BuildContext context) { + return MkBackground( + child: Padding( + padding: EdgeInsets.only( + top: 45.0.ratioH(), + left: 137.0.ratioW(), + right: 137.0.ratioW(), + bottom: 36.0.ratioH()), + child: Column( + children: [ + Row(children: [ + Text(AppLocalizations.of(context)!.myProfile, + style: Theme.of(context).textTheme.titleMedium) + ]), + const Divider( + endIndent: 0, + indent: 0, + ), + Row(children: [ + Text(AppLocalizations.of(context)!.logout, + style: Theme.of(context).textTheme.labelLarge), + const Spacer(), + IconButton( + icon: const Icon(Icons.logout), + onPressed: () async { + await userManager.logout(); + // ignore: use_build_context_synchronously + Navigator.of(context).pop(); + }) + ]), + ], + ), + )); + } +} diff --git a/flutter_area/lib/UI/ReusableWidgets/mk_input.dart b/flutter_area/lib/UI/ReusableWidgets/mk_input.dart index 4b22a25..149a4a3 100644 --- a/flutter_area/lib/UI/ReusableWidgets/mk_input.dart +++ b/flutter_area/lib/UI/ReusableWidgets/mk_input.dart @@ -5,11 +5,16 @@ import '../../Utils/Extensions/double_extensions.dart'; class MkInput extends StatefulWidget { const MkInput( - {super.key, required this.label, this.placeholder, this.onChanged}); + {super.key, + this.label, + this.placeholder, + this.onChanged, + this.displayed = true}); - final String label; + final String? label; final String? placeholder; final void Function(String)? onChanged; + final bool displayed; @override State createState() => _MkInputState(); @@ -21,21 +26,27 @@ class _MkInputState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(widget.label, - style: Theme.of(context).textTheme.labelMedium, - textAlign: TextAlign.left), - SizedBox(height: 6.0.ratioH()), - TextField( + if (widget.label != null) + Text(widget.label!, + style: Theme.of(context).textTheme.labelMedium, + textAlign: TextAlign.left), + if (widget.label != null) SizedBox(height: 6.0.ratioH()), + TextFormField( style: Theme.of(context).textTheme.displayMedium, onChanged: widget.onChanged, + obscureText: !widget.displayed, decoration: InputDecoration( hintText: widget.placeholder, hintStyle: Theme.of(context).textTheme.displayMedium, filled: true, - fillColor: Theme.of(context).colorScheme.lightColor3, + fillColor: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor3 + : Theme.of(context).colorScheme.darkColor3, enabledBorder: OutlineInputBorder( borderSide: BorderSide( - color: Theme.of(context).colorScheme.lightColor4, + color: Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.lightColor4 + : Theme.of(context).colorScheme.darkColor4, width: 0.5)), contentPadding: EdgeInsets.symmetric( horizontal: 16.0.ratioW(), vertical: 11.0.ratioH())), diff --git a/flutter_area/lib/UI/Settings/settings_mobile_view.dart b/flutter_area/lib/UI/Settings/settings_mobile_view.dart new file mode 100644 index 0000000..478ce76 --- /dev/null +++ b/flutter_area/lib/UI/Settings/settings_mobile_view.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../../Core/Locator/locator.dart'; +import '../../Core/Manager/theme_manager.dart'; +import '../../Utils/Extensions/double_extensions.dart'; +import '../ReusableWidgets/mk_background.dart'; +import '../ReusableWidgets/mk_switch.dart'; + +class SettingsMobileView extends StatefulWidget { + const SettingsMobileView({super.key}); + + @override + State createState() => _SettingsMobileViewState(); +} + +class _SettingsMobileViewState extends State { + ThemeManager themeManager = locator(); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: MkBackground( + child: Padding( + padding: EdgeInsets.only( + top: 45.0.ratioH(), + left: 37.0.ratioW(), + right: 37.0.ratioW(), + bottom: 36.0.ratioH()), + child: Column( + children: [ + Row(children: [ + Text(AppLocalizations.of(context)!.mySettings, + style: Theme.of(context).textTheme.titleMedium) + ]), + const Divider( + endIndent: 0, + indent: 0, + ), + Row(children: [ + Text(AppLocalizations.of(context)!.appearance, + style: Theme.of(context).textTheme.labelLarge) + ]), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + AppLocalizations.of(context)!.changeAppearance, + style: Theme.of(context).textTheme.labelSmall, + ), + ), + SizedBox(width: 10.0.ratioW()), + const SwitchExample(), + ], + ), + ], + ), + )), + ); + } +} diff --git a/flutter_area/lib/UI/Settings/settings_view.dart b/flutter_area/lib/UI/Settings/settings_view.dart index 5c99f0d..20d5058 100644 --- a/flutter_area/lib/UI/Settings/settings_view.dart +++ b/flutter_area/lib/UI/Settings/settings_view.dart @@ -7,7 +7,6 @@ import '../../Utils/Extensions/double_extensions.dart'; import '../ReusableWidgets/mk_background.dart'; import '../ReusableWidgets/mk_switch.dart'; - class SettingsView extends StatefulWidget { const SettingsView({super.key}); @@ -21,33 +20,36 @@ class _SettingsViewState extends State { @override Widget build(BuildContext context) { return MkBackground( - child: Padding( - padding: EdgeInsets.only( - top: 45.0.ratioH(), - left: 137.0.ratioW(), - right: 137.0.ratioW(), - bottom: 36.0.ratioH()), - child: Column( - children: [ - Row(children: [ - Text(AppLocalizations.of(context)!.mySettings, style: Theme.of(context).textTheme.titleMedium) - ]), - const Divider( - endIndent: 0, - indent: 0, - ), - Row(children: [ - Text(AppLocalizations.of(context)!.appearance, style: Theme.of(context).textTheme.labelLarge) - ]), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(AppLocalizations.of(context)!.changeAppearance, style: Theme.of(context).textTheme.labelMedium), - const SwitchExample(), - ], - ), - ], - ), + child: Padding( + padding: EdgeInsets.only( + top: 45.0.ratioH(), + left: 137.0.ratioW(), + right: 137.0.ratioW(), + bottom: 36.0.ratioH()), + child: Column( + children: [ + Row(children: [ + Text(AppLocalizations.of(context)!.mySettings, + style: Theme.of(context).textTheme.titleMedium) + ]), + const Divider( + endIndent: 0, + indent: 0, + ), + Row(children: [ + Text(AppLocalizations.of(context)!.appearance, + style: Theme.of(context).textTheme.labelLarge) + ]), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(AppLocalizations.of(context)!.changeAppearance, + style: Theme.of(context).textTheme.labelMedium), + const SwitchExample(), + ], + ), + ], + ), )); } } diff --git a/flutter_area/lib/UI/Splash/splash_view.dart b/flutter_area/lib/UI/Splash/splash_view.dart new file mode 100644 index 0000000..144fd36 --- /dev/null +++ b/flutter_area/lib/UI/Splash/splash_view.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import '../../Utils/Extensions/double_extensions.dart'; + +class SplashView extends StatefulWidget { + const SplashView({super.key}); + + @override + SplashViewState createState() => SplashViewState(); +} + +class SplashViewState extends State + with SingleTickerProviderStateMixin { + AnimationController? _animationController; + late Animation? _opacityAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + ); + + _opacityAnimation = + Tween(begin: 0.0, end: 1.0).animate(_animationController!) + ..addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.completed) { + _animationController?.reverse(); + } else if (status == AnimationStatus.dismissed) { + _animationController?.forward(); + } + }); + + _animationController?.forward(); + } + + @override + void dispose() { + _animationController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + alignment: Alignment.center, + child: FadeTransition( + opacity: _opacityAnimation!, + child: SvgPicture.asset( + 'assets/images/Logo.svg', + semanticsLabel: 'Logo', + width: 300.0.ratioH(), + ), + ), + ); + } +} diff --git a/flutter_area/lib/Utils/Extensions/double_extensions.dart b/flutter_area/lib/Utils/Extensions/double_extensions.dart index 8a31a7c..906e986 100644 --- a/flutter_area/lib/Utils/Extensions/double_extensions.dart +++ b/flutter_area/lib/Utils/Extensions/double_extensions.dart @@ -1,13 +1,11 @@ -import 'package:flutter/foundation.dart' show kIsWeb; - import '../constants.dart'; extension DoubleExtension on double { double ratioW() { - return this / (kIsWeb ? 1440 : 393) * kDeviceWidth; + return this / (kIsPc ? 1440 : 393) * kDeviceWidth; } double ratioH() { - return this / (kIsWeb ? 1024 : 852) * kDeviceWidth; + return this / (kIsPc ? 1024 : 852) * kDeviceWidth; } } diff --git a/flutter_area/lib/Utils/Extensions/enum_extensions.dart b/flutter_area/lib/Utils/Extensions/enum_extensions.dart new file mode 100644 index 0000000..2a24de5 --- /dev/null +++ b/flutter_area/lib/Utils/Extensions/enum_extensions.dart @@ -0,0 +1,3 @@ +extension EnumExtension on Enum { + String get name => toString().split('.').last; +} diff --git a/flutter_area/lib/Utils/constants.dart b/flutter_area/lib/Utils/constants.dart index e1694f2..53ef213 100644 --- a/flutter_area/lib/Utils/constants.dart +++ b/flutter_area/lib/Utils/constants.dart @@ -2,3 +2,4 @@ double kDeviceHeight = 0; double kDeviceWidth = 0; double kLargeScreenWidth = 1200; bool kIsPc = true; +String kBaseUrl = 'http://patatoserv.ddns.net:8085'; // 'http://localhost:8080'; diff --git a/flutter_area/lib/l10n/app_en.arb b/flutter_area/lib/l10n/app_en.arb index a56962c..1d934ad 100644 --- a/flutter_area/lib/l10n/app_en.arb +++ b/flutter_area/lib/l10n/app_en.arb @@ -31,5 +31,18 @@ "connect": "Log in", "google": " Google", "connectGoogle": "Log to Google to access its services", - "home": "Home" + "slack": " Slack", + "connectSlack": "Update the token of your Slack app to access its services (listen events, send messages...) ", + "validate": "Confirm", + "home": "Home", + "connectionGithub": "Connected to Github", + "inProgress": "In progress", + "completed": "Completed", + "failed": "Failed", + "myProfile": "My profile", + "logout": "Log out", + "promptActionName": "Action name:", + "createAction": "Create action", + "createReaction": "Create reaction", + "downloadAPK": "Download Android APK" } diff --git a/flutter_area/lib/l10n/app_es.arb b/flutter_area/lib/l10n/app_es.arb index b08e6ab..df3a8d7 100644 --- a/flutter_area/lib/l10n/app_es.arb +++ b/flutter_area/lib/l10n/app_es.arb @@ -31,5 +31,18 @@ "connect": "", "google": " Google", "connectGoogle": "", - "home": "" + "slack": " Slack", + "connectSlack": "", + "validate": "", + "home": "", + "connectionGithub": "", + "inProgress": "", + "completed": "", + "failed": "", + "myProfile": "", + "logout": "", + "promptActionName": "", + "createAction": "", + "createReaction": "", + "downloadAPK": "" } diff --git a/flutter_area/lib/l10n/app_fr.arb b/flutter_area/lib/l10n/app_fr.arb index 6628de4..1ba48db 100644 --- a/flutter_area/lib/l10n/app_fr.arb +++ b/flutter_area/lib/l10n/app_fr.arb @@ -27,9 +27,22 @@ "emailPlaceholder": "Tapez votre adresse e-mail...", "connections": "Mes connexions", "github": " Github", - "connectGithub": "Se connecter à Github pour accèder à ses services", + "connectGithub": "Se connecter à Github pour accéder à ses services", "connect": "Se connecter", "google": " Google", - "connectGoogle": "Se connecter à Google pour accèder à ses services", - "home": "Accueil" + "connectGoogle": "Se connecter à Google pour accéder à ses services", + "slack": " Slack", + "connectSlack": "Mettez à jour le token de votre app Slack pour accéder à ses services (écoute d'événements, envoi de messages...)", + "validate": "Valider", + "home": "Accueil", + "connectionGithub": "Connexion à Github", + "inProgress": "En cours", + "completed": "Terminé", + "failed": "Échoué", + "myProfile": "Mon profil", + "logout": "Se déconnecter", + "promptActionName": "Nom de l'action:", + "createAction": "Créer l'action", + "createReaction": "Créer la réaction", + "downloadAPK": "Télécharger l'APK Android" } diff --git a/flutter_area/lib/main.dart b/flutter_area/lib/main.dart index 8fc13a2..448af7d 100644 --- a/flutter_area/lib/main.dart +++ b/flutter_area/lib/main.dart @@ -1,17 +1,20 @@ -// ignore_for_file: unreachable_from_main - import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'Core/Locator/locator.dart'; import 'Core/Manager/theme_manager.dart'; +import 'UI/Boostrap/boostrap_view.dart'; import 'UI/Callback/callback_github.dart'; -import 'UI/Home/home_view.dart'; import 'UI/Login/login_screen.dart'; +import 'UI/MainNavigator/main_navigator.dart'; +import 'UI/MainNavigator/main_navigator_mobile.dart'; +import 'UI/Splash/splash_view.dart'; import 'Utils/constants.dart'; import 'Utils/theme_data.dart'; -void main() { +void main() async { + await dotenv.load(); setupLocator(); runApp(const MyApp()); } @@ -42,13 +45,16 @@ class MyAppState extends State { debugShowCheckedModeBanner: false, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - initialRoute: '/login', + initialRoute: '/', routes: { + '/': (BuildContext context) => const BoostrapView(), '/login': (BuildContext context) => const LoginScreen(), - '/home': (BuildContext context) => const HomeView(), + '/home': (BuildContext context) => + kIsPc ? const MainNavigator() : const MainNavigatorMobile(), + '/splash': (BuildContext context) => const SplashView(), '/callback': (BuildContext context) => const CallbackGithubView(), }); }, ); - } + } } diff --git a/flutter_area/pubspec.yaml b/flutter_area/pubspec.yaml index d17d28f..41bf022 100644 --- a/flutter_area/pubspec.yaml +++ b/flutter_area/pubspec.yaml @@ -48,6 +48,10 @@ dependencies: font_awesome_flutter: ^10.6.0 flutter_web_auth: ^0.5.0 url_launcher: ^6.2.2 + flutter_dotenv: ^5.1.0 + shared_preferences: ^2.2.2 + provider: ^6.1.1 + prompt_dialog: ^1.0.14 dev_dependencies: flutter_test: @@ -74,6 +78,7 @@ flutter: assets: - assets/images/ + - .env fonts: - family: Epilogue