diff --git a/.github/workflows/community-first-pr-comment.yml b/.github/workflows/community-first-pr-comment.yml deleted file mode 100644 index 18948e33..00000000 --- a/.github/workflows/community-first-pr-comment.yml +++ /dev/null @@ -1,16 +0,0 @@ -# This workflow comments on PRs opened by first time contributors. -# Reminds first timer contributors to associate their PR with an issue and follow the guidelines. -# See for more info: https://github.com/actions/first-interaction - -name: First Interaction PR Comment - -on: [pull_request] - -jobs: - greeting: - runs-on: ubuntu-latest - steps: - - uses: actions/first-interaction@v1 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - pr-message: "First time contributors to Chayn: Please make sure that this PR is linked to an issue you are assigned! We will not merge contributor PRs without a linked assigned issue. Please ask to be assigned an existing issue or create your own before opening a PR. Read our Contributing Guidelines in the CONTRIBUTING.md file for more details. Thank you for your contribution!" diff --git a/.github/workflows/community-issue-comment.yml b/.github/workflows/community-issue-comment.yml index 139e0f24..40f6e8cd 100644 --- a/.github/workflows/community-issue-comment.yml +++ b/.github/workflows/community-issue-comment.yml @@ -1,50 +1,62 @@ -# This workflow handles issue comments. -# See for more info: https://github.com/actions/github-script +# ----------------------------------------------------------------------------- +# GitHub Actions Workflow: Issue Comments +# Description: Post issue comments +# Jobs: +# - Assigned comment +# - Stale label comment +# ----------------------------------------------------------------------------- -name: Issue Comments +name: Issue Comment Workflows on: + workflow_run: + workflows: [Label Stale Contributions] + types: + - completed issues: types: - assigned - labeled jobs: - # When issues are assigned, a comment is posted - # Tags the assignee with links to helpful resources + # Job: Assigned issue comment + # Trigger: Issues are assigned + # Returns: Posts comment tagging assignee and helpful message assigned-comment: if: github.event.action == 'assigned' runs-on: ubuntu-latest steps: - name: Post assignee issue comment id: assigned-comment - uses: actions/github-script@v7 + uses: actions/github-script@v7 # https://github.com/actions/github-script with: github-token: ${{secrets.GITHUB_TOKEN}} script: | github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.issue.number, - body: `Thank you @${context.payload.issue.assignee.login} you have been assigned this issue! - **Please follow the directions in our [Contributing Guide](https://github.com/chaynHQ/.github/blob/main/docs/CONTRIBUTING.md). We look forward to reviewing your pull request shortly ✨** - - --- - - Support Chayn's mission? ⭐ Please star this repo to help us find more contributors like you! - Learn more about Chayn [here](https://linktr.ee/chayn) and [explore our projects](https://org.chayn.co/projects). 🌸` - }) + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body: `Thank you @${context.payload.issue.assignee.login} you have been assigned this issue! + **Please follow the directions in our [Contributing Guide](https://github.com/chaynHQ/.github/blob/main/docs/CONTRIBUTING.md). We look forward to reviewing your pull request. ✨** + + --- + + Support Chayn's mission? ⭐ Please star this repo to help us find more contributors like you! + Learn more about our [impact](https://github.com/chaynHQ/.github/blob/main/profile/README.md) and [sign-up for our volunteer programs](https://www.chayn.co/get-involved)to join our mission!. 🌸` + }) - # When issues are labeled as stale, a comment is posted. - # Tags the assignee with warning. - # Enables manual issue management in addition to community-stale-management.yml + # Job: Stale label comment + # Triggers: + # Labeled as stale by maintainer + # 'Label Stale Contributions' workflow runs + # Returns: Posts warning comment tagging assignee stale-label-comment: - if: github.event.action == 'labeled' && github.event.label.name == 'stale' + if: ${{ github.event.action == 'labeled' && github.event.label.name == 'stale' }} runs-on: ubuntu-latest steps: - name: Post stale issue comment id: stale-label-comment - uses: actions/github-script@v7 + uses: actions/github-script@v7 # https://github.com/actions/github-script with: github-token: ${{secrets.GITHUB_TOKEN}} script: | diff --git a/.github/workflows/community-stale-management.yml b/.github/workflows/community-stale-management.yml index 0fa63dcd..190d0a4f 100644 --- a/.github/workflows/community-stale-management.yml +++ b/.github/workflows/community-stale-management.yml @@ -1,17 +1,22 @@ -# This workflow labels stale issues and PRs after 30 days of inactivity. -# Stale PRs are closed after 1 week of inactivity after labeled stale. -# See for more info: https://github.com/actions/stale +# ----------------------------------------------------------------------------- +# GitHub Actions Workflow: Label Stale Contributions +# Description: Labels stale contributions +# Job: actions/stale +# ----------------------------------------------------------------------------- -name: Mark Stale Contributions +name: Label Stale Contributions on: - # Enable manual run from the Actions tab so workflow can be run at any time - workflow_dispatch: + workflow_dispatch: # enables manual trigger # Scheduled to run at 12:00 on every Monday schedule: - cron: "0 0 * * MON" jobs: + # Trigger: Scheduled weekly + # Returns: labels issues and PRs with 'stale' after 30 days inactivity + # PRs: automated closing after 1 more week of inactivity + # Issues: requires manual closing by maintainers stale: runs-on: ubuntu-latest permissions: @@ -19,7 +24,7 @@ jobs: pull-requests: write steps: - - uses: actions/stale@v9 + - uses: actions/stale@v9 # https://github.com/actions/stale with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-label: "stale" @@ -27,7 +32,6 @@ jobs: days-before-stale: 30 # disables closing issues days-before-issue-close: -1 - # close pr after 1 week no updates after stale warning days-before-pr-close: 7 # only scan assigned issues include-only-assigned: true @@ -39,5 +43,5 @@ jobs: exempt-pr-labels: dependencies # disable counting irrelevant activity (branch updates) towards day counter on prs. ignore-pr-updates: true - stale-pr-message: "As per Chayn policy, after 30 days of inactivity, we will close this PR." - close-pr-message: "This PR has been closed due to inactivity." + # actions/stale does not enable tagging authors / assignees, so comments are handled by Issue Comments Workflows. + stale-pr-message: "As per Chayn policy, after 30 days of inactivity, we will close this PR in 7 days. Please comment or update to keep open." diff --git a/README.md b/README.md index a2631ddc..0563cc7f 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,27 @@ -# Bloom by Chayn +# Welcome to Bloom [![Bloom Backend CI Pipeline](https://github.com/chaynHQ/bloom-backend/actions/workflows/.ci.yml/badge.svg)](https://github.com/chaynHQ/bloom-backend/actions/workflows/.ci.yml) -Bloom is a remote trauma support service from [Chayn](https://www.chayn.co/about), a global, award-winning charity designing open-source tools to support the healing of survivors across the world. Since 2013, Chayn has reached over 500,000 survivors worldwide with our trauma-informed, survivor-centred, and intersectional approaches in utilizing tech for social impact. Bloom is our flagship product; a free, web-based, secure support service designed to aid in the healing of survivors. Through a combination of online video-based courses, anonymous interaction, and 1:1 chat, Bloom provides tailored information, self-help guidance, everyday tools, and comfort to cope with traumatic events. +[Bloom](https://bloom.chayn.co/) is a free, secure, and web-based trauma healing support service from [Chayn](https://www.chayn.co/about). Since 2013, Chayn has reached over 500,000 survivors worldwide with our trauma-informed, survivor-centred, and intersectional approaches in utilizing open-source tech for positive social impact. Bloom is our flagship product — providing tailored video-based courses, anonymous interaction, 1:1 chat, self-led healing guidance, everyday tools, and comfort to cope with traumatic events. Explore Chayn's [website](https://www.chayn.co/about), [research](https://org.chayn.co/research), [resources](https://www.chayn.co/resources), [projects](https://org.chayn.co/projects), [impact](https://org.chayn.co/impact), and [support services directory](https://www.chayn.co/global-directory). 💖 -## Support Our Work +## Key Features: -Chayn is proudly open-source and built with volunteer contributions. We are grateful for the generosity of the open-source community and aim to provide a fulfilling experience for open-source developers. +Chayn's Bloom service offers several key features designed to support individuals on their healing journey: -**Please give this repository a star ⭐ and follow our GitHub profile 🙏 to help us grow our open-source community and find more contributors like you!** +- **Free and Anonymous:** Bloom is a free service that ensures anonymity for its users. +- **Self-Paced Activities and Exploration:** Users can explore all resources and activities at their own pace. +- **Multi-lingual Support:** All features are available in multiple languages including Spanish, Hindi, French, English, Portuguese, and German. +- **Online Video Courses:** Provides healing educational video courses. +- **1-to-1 Messaging:** Secure messaging service to share reflections, feelings, and questions with Chayn therapists. +- **Supportive Messaging:** Receive tailored supportive messaging from Chayn through Whatsapp. -Support our mission further by [sponsoring us on GitHub](https://github.com/sponsors/chaynHQ), exploring our [volunteer programs](https://www.chayn.co/get-involved), and following Chayn on social media: - Linktree: [https://linktr.ee/chayn](https://linktr.ee/chayn) - Twitter: [@chaynhq](https://twitter.com/ChaynHQ) - Instagram: [@chaynhq](https://www.instagram.com/chaynhq/) - Youtube: [@chaynhq](https://www.youtube.com/@chaynhq) - Facebook: [@chayn](https://www.facebook.com/chayn) - LinkedIn: [@chayn](https://www.linkedin.com/company/chayn). +## Bloom Backend Technical Documentation -# Bloom Backend Contribution Docs: +Read our [Bloom Backend Tech Wiki Docs](https://github.com/chaynHQ/bloom-backend/wiki) for overviews of key concepts and data & database architecture. -By making an open-source contribution to Chayn, you have agreed to our [Code of Conduct](/CODE_OF_CONDUCT.md). - -Happy coding! ⭐ - -## Technologies Used: - -Visit the [/docs directory](https://github.com/chaynHQ/bloom-backend/tree/develop/docs) for an overview of Bloom's backend architecture and key concepts. +Technologies Used: - [NestJS](https://nestjs.com/) - NodeJs framework for building scalable and reliable server-side applications - [PostgreSQL](https://www.postgresql.org/about/) - Object-relational SQL database system @@ -39,240 +38,24 @@ Visit the [/docs directory](https://github.com/chaynHQ/bloom-backend/tree/develo - [GitHub Actions](https://github.com/features/actions) - CI pipeline - [ESLint](https://eslint.org/) and [Prettier](https://prettier.io/) for linting and formatting. -## Local development - -### Summary - -To run Bloom's backend: install prerequisites, configure environment variables, install dependencies, then run in a Dev Container, with Docker, or manually. - -Most contributions (and running Cypress integration tests from the frontend) require [populating your local database](#populate-database) with test data. - -### Prerequisites - -- NodeJS v20.x -- Yarn v1.x -- Docker -- PostgreSQL 16 -- Read [Contribution Guidelines](/CONTRIBUTING.md) - -### Configure Environment Variables - -Create a new `.env` file and fill it with the **required** environment variables: - -``` -# Variables for building and running tests. -# Provided variables are read-only and subject to change. -#=============================================================== -# REQUIRED VARIABLES FOR LOCAL DEVELOPMENT -#--------------------------------------------------------------- -# CORE ENVIRONMENT VARIABLES -PORT=35001 -DATABASE_URL=postgres://:@:/ -NODE_ENV=development - -# FIREBASE AUTH AND ANALYTICS -FIREBASE_TYPE=service_account -FIREBASE_PROJECT_ID= -FIREBASE_PRIVATE_KEY_ID= -FIREBASE_PRIVATE_KEY= -FIREBASE_CLIENT_EMAIL= -FIREBASE_CLIENT_ID= -FIREBASE_AUTH_URI=https://accounts.google.com/o/oauth2/auth -FIREBASE_TOKEN_URI=https://oauth2.googleapis.com/token -FIREBASE_CERT_URL=https://www.googleapis.com/oauth2/v1/certs -FIREBASE_CLIENT_CERT= -FIREBASE_API_KEY= -FIREBASE_AUTH_DOMAIN= -FIREBASE_PROJECT_ID= -FIREBASE_STORAGE_BUCKET= -FIREBASE_MESSAGING_SENDER_ID= -FIREBASE_API_ID= -FIREBASE_MEASUREMENT_ID= - -# REQUIRED VARIABLES FOR TESTING -#--------------------------------------------------------------- -# MOCK VALUES (can replace with real values or new mocks in same format) -SIMPLYBOOK_CREDENTIALS='{"login":"testlogin","password":"testpassword","company":"testcompany"}' -SIMPLYBOOK_COMPANY_NAME=testcompany - -# OPTIONAL VARIABLES -#--------------------------------------------------------------- -ROLLBAR_ENV=development # Rollbar logging -ROLLBAR_TOKEN= # Rollbar logging -ZAPIER_TOKEN= # Zapier automation -SLACK_WEBHOOK_URL= # Slack messaging bots -CRISP_TOKEN= # Crisp chat -MAILCHIMP_API_KEY= # Email messaging -RESPOND_IO_CREATE_CONTACT_WEBHOOK= # RESPOND.IO -RESPOND_IO_DELETE_CONTACT_WEBHOOK= # RESPOND.IO -``` - -#### How to Configure Firebase Variables: - -Bloom's required Firebase variables are obtained by creating a Firebase project, then generating a private key file in JSON format. First, create a Firebase project [in the Firebase console here](https://firebase.google.com/) (Google account required). Next, [follow these directions](https://firebase.google.com/docs/cloud-messaging/auth-server#provide-credentials-manually) to generate your private key file. - -### Install dependencies with yarn - -```bash -yarn -``` - -### Run in Docker - Recommended - -Bloom's backend is containerized and can be run solely in Docker - both the PostgreSQL database and NestJS app. To run the backend locally, make sure your system has Docker installed - you may need Docker Desktop if using a Mac or Windows. - -First, make sure the Docker app is running (just open the app). Then run - -```bash -docker-compose up -``` - -You should see this in the shell output: - -```shell -Listening on localhost:35001, CTRL+C to stop -``` - -_Note: you can use an application like Postman to test the apis locally_ - -### Run in Dev Container - Recommended for Visual Studio Users - -This method will automatically install all dependencies, IDE settings, and postgres container in a Dev Container (Docker container) within Visual Studio Code. - -Directions for running a dev container: - -1. Meet the [system requirements](https://code.visualstudio.com/docs/devcontainers/containers#_system-requirements) -2. Follow the [installation instructions](https://code.visualstudio.com/docs/devcontainers/containers#_installation) -3. [Check the installation](https://code.visualstudio.com/docs/devcontainers/tutorial#_check-installation) -4. After you've verified that the extension is installed and working, click on the "Remote Status" bar icon and select - "Reopen in Container". From here, the option to "Re-open in Container" should pop up in notifications whenever opening this project in VS. -5. [Configure your environment variables](#configure-environment-variables) and develop as you normally would. - -The dev Container is configured in the `.devcontainer` directory: - -- `docker-compose.yml` file in this directory extends the `docker-compose.yml` in the root directory. -- `devcontainer.json` configures the integrations with Visual Studio Code, such as the IDE extensions and settings in the `vscode` directory. - -See [Visual Studio Code Docs: Developing Inside a Dev Container](https://code.visualstudio.com/docs/devcontainers/containers) for more info. - -### Run Manually - -Manage postgres locally to [populate the database](#populate-database), then run: - -```bash -yarn start:dev -``` - -You should see this in the shell output: +## Local Development -```shell -Listening on localhost:35001, CTRL+C to stop -``` +Making an open-source contribution you have agreed to our [Code of Conduct](/CODE_OF_CONDUCT.md). -### Unit Testing +- See [docs/local-development.md](docs/local-development.md) to get started. +- New contributors should start with our [Contribution Guidelines](CONTRIBUTING.md). -To run all unit tests - -```bash -yarn test -``` - -To have your unit tests running in the background as you change code: - -```bash -yarn test:watch -``` - -### Format and Linting - -Linting and formatting are provided by [ESLint](https://eslint.org/) and [Prettier](https://prettier.io/). We recommend VSCode users to utilize the workspace settings in [.vscode/settings.json](.vscode/settings.json) and install the extensions in [.vscode/extensions](.vscode/extensions.json) for automated consistency. - -**We strongly recommend maintaining consistent code style by linting and formatting before every commit:** - -To run linting - -```bash -yarn lint -``` - -To lint and fix - -```bash -yarn lint:fix -``` - -Run format and fix: - -```bash -yarn format -``` - -### Populate Database - -Most open-source contributions (like running Cypress integration tests from the frontend) require adding test data to your local database. To do this, navigate to our [Chayn Tech Wiki Guide](https://www.notion.so/chayn/Chayn-Tech-Contributor-Wiki-5356c7118c134863a2e092e9df6cbc34?pvs=4#eb2e24de94bd451f8683abe496656013) to obtain a backup file and follow directions there to populate your local database. - -Chayn staff with access to Heroku, you also have the option to seed the database via the following script. Before you start, make sure: - -1. bloom-local-db container is running in Docker -2. you are logged into the Heroku via your terminal. Read more about the Heroku Cli [here](https://devcenter.heroku.com/articles/heroku-cli) -3. Replace with the correct Heroku app name in the `seed-local-db.sh file` -4. Run `chmod +x ./seed-local-db.sh` in your terminal to make the file executable - - After the above has been confirmed, run - - ```bash - bash seed-local-db.sh - ``` - -### Database Migrations - -A migration in TypeORM is a single file with SQL queries to update a database schema as updates/additions are made. Read more about migrations [here](https://github.com/typeorm/typeorm/blob/master/docs/migrations.md). - -Migrations are automatically run when the app is built docker (locally) or Heroku for staging and production apps. - -**You'll need to generate and run a migration each time you add or update a database field or table.** - -To generate a new migration - -```bash -yarn migration:generate -``` - -Add the new migration import into [typeorm.config.ts](./src/typeorm.config.ts) - -To run (apply) migrations - -```bash -yarn migration:run -``` - -To revert a migration - -```bash -yarn migration:revert -``` - -Note that when running the app in Docker, you may need to run migration commands from the docker terminal/Exec - -**New environment variables must be added to Heroku before release.** - -## Git Flow and Deployment - -**The develop branch is our source of truth, not main.** - -Create new branches from the `develop` base branch. There is no need to run the build command before pushing changes to GitHub, simply push and create a pull request for the new branch. GitHub Actions will run build and linting tasks automatically. Rebase and merge feature/bug branches into `develop`. - -This will trigger an automatic deployment to the staging app by Heroku. +Happy coding! ⭐ -When changes have been tested in staging, merge `develop` into `main`. This will trigger an automatic deployment to the production app by Heroku. +# Support Our Work -## Swagger +Chayn is proudly open-source and built with volunteer contributions. We are grateful for the generosity of the open-source community. -Swagger automatically reflects all of the endpoints in the app, showing their urls and example request and response objects. +**Please consider giving this repository a star ⭐ and follow our GitHub profile to help us grow our open-source community and find more contributors like you!** -To access Swagger simply run the project and visit http://localhost:35001/swagger +Support our mission further by [sponsoring us on GitHub](https://github.com/sponsors/chaynHQ), exploring [our volunteer programs](), and following us on [social media](https://linktr.ee/chayn). -## License +# Licence Bloom and all of Chayn's projects are open source. The core tech stack included here is open source however some external integrations used in the project require subscriptions. diff --git a/docs/configure-env.md b/docs/configure-env.md new file mode 100644 index 00000000..09b2fae5 --- /dev/null +++ b/docs/configure-env.md @@ -0,0 +1,63 @@ +## Configure Environment Variables + +Create a new `.env` file and fill it with the **required** environment variables: + +``` +# Variables for building and running tests. +# Provided variables are read-only and subject to change. +#=============================================================== +# REQUIRED VARIABLES FOR LOCAL DEVELOPMENT +#--------------------------------------------------------------- +# CORE ENVIRONMENT VARIABLES +PORT=35001 +DATABASE_URL=postgres://:@:/ +NODE_ENV=development + +# FIREBASE AUTH AND ANALYTICS +FIREBASE_TYPE=service_account +FIREBASE_PROJECT_ID= +FIREBASE_PRIVATE_KEY_ID= +FIREBASE_PRIVATE_KEY= +FIREBASE_CLIENT_EMAIL= +FIREBASE_CLIENT_ID= +FIREBASE_AUTH_URI=https://accounts.google.com/o/oauth2/auth +FIREBASE_TOKEN_URI=https://oauth2.googleapis.com/token +FIREBASE_CERT_URL=https://www.googleapis.com/oauth2/v1/certs +FIREBASE_CLIENT_CERT= +FIREBASE_API_KEY= +FIREBASE_AUTH_DOMAIN= +FIREBASE_PROJECT_ID= +FIREBASE_STORAGE_BUCKET= +FIREBASE_MESSAGING_SENDER_ID= +FIREBASE_API_ID= +FIREBASE_MEASUREMENT_ID= + +# REQUIRED VARIABLES FOR TESTING +#--------------------------------------------------------------- +# MOCK VALUES (can replace with real values or new mocks in same format) +SIMPLYBOOK_CREDENTIALS='{"login":"testlogin","password":"testpassword","company":"testcompany"}' +SIMPLYBOOK_COMPANY_NAME=testcompany + +# OPTIONAL VARIABLES +#--------------------------------------------------------------- +ROLLBAR_ENV=development # Rollbar logging +ROLLBAR_TOKEN= # Rollbar logging +ZAPIER_TOKEN= # Zapier automation +SLACK_WEBHOOK_URL= # Slack messaging bots +CRISP_TOKEN= # Crisp chat +MAILCHIMP_API_KEY= # Email messaging +RESPOND_IO_CREATE_CONTACT_WEBHOOK= # RESPOND.IO +RESPOND_IO_DELETE_CONTACT_WEBHOOK= # RESPOND.IO +``` + +## How to Configure Variables: + +The Firebase environment variables for Bloom’s frontend and backend are configured by [creating a project in the Firebase console](https://firebase.google.com/) (Google account is required). Ensure the toggle is turned on to enable Google Analytics as it is required for the `NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID` + +For `FIREBASE_PRIVATE_KEY_ID`, follow [these directions](https://firebase.google.com/docs/cloud-messaging/auth-server#provide-credentials-manually) to generate a private key file in JSON format. + +The frontend and backend each have _required_ and _optional_ environment variables. Required variables are necessary for local development, and optional variables are for specific features. + +Note: Variables provided by Chayn are public, not linked to production, and subject to change at any time. Check for updates if you are experiencing problems. The absence of some optional environment variables may result in test failures. If you require an optional environment variable and cannot acquire it yourself (some must be connected to Chayn in some way), please reach out to the team in GitHub’s issue discussions. + +**Please notify us if creating new environment variables in your PR so we can add it to Heroku before release deployment.** diff --git a/docs/data-architecture.md b/docs/data-architecture.md deleted file mode 100644 index f83fefb7..00000000 --- a/docs/data-architecture.md +++ /dev/null @@ -1,33 +0,0 @@ -## Database models - -![Database models](database_models.jpg 'Database models') - -**User** -Stores basic profile data for a user and relationships. - -**Partner** -Stores basic profile data for a partner and relationships. - -**Partner Admin** -Stores relationship between a partner and a user, and the partner access records created by the partner admin. - -**Partner Access** -Stores the features assigned for a user by a partner. When a partner access record is created by a partner admin user, it is initially unassigned but the unique code generated will be shared with the user. When the user registers using this code, the partner access will be assigned/related to the user, granting them access to extra features. See `assignPartnerAccess`. Currently there are "tiers" of access determined by the frontend, however if future partners want different features for their users, settings for their features/tiers should be added to the `Partner` model. - -**Therapy Session** -Stores data related to therapy sessions booked via Simplybook, copying the session time and details about the therapist etc. The duplication of data here (vs leaving it in Simplybook) was made to allow therapy reporting to be included in Data Studio. This model also allows for displaying therapy session data on the frontend. Populated by Simplybook -> Zapier webhooks. - -**Course** -Stores data related to courses in Storyblok, copying the story id's. The `Course` records allow us to relate users to courses (via `CourseUser`) and a `Course` to `Session` records, which is required e.g. to check if a user completed a course. The slug and name etc are also stored for convenience, e.g. to be used in reporting. Populated and updated by Storyblok webhooks when course stories are published. - -**Session** -Stores data related to courses in Storyblok, copying the story id's. The `Session` records allow us to relate users to sessions (via `SessionUser`). The slug and name etc are also stored for convenience, e.g. to be used in reporting. Populated and updated by Storyblok webhooks when course stories are published. - -**CourseUser** -Stores relationship between a `User` and `Course` records, once a user has started a course. A users progress (`completed`) for the course is updated (`true`) when all related `SessionUser` records are `completed` for the related `Course`. - -**SessionUser** -Stores relationship between a `User` and `Session` records, once a user has started a session. A users session progress (`completed`) for the session is updated (true) when the `/complete` endpoint is called. - -## Database Schema -![Database schema](database_schema.png) diff --git a/docs/database-guide.md b/docs/database-guide.md new file mode 100644 index 00000000..0deaf1d9 --- /dev/null +++ b/docs/database-guide.md @@ -0,0 +1,111 @@ +# Bloom Backend Database Guide + +## How to Populate the Database + +**Prerequisites:** + +- [Postgres 16](https://www.postgresql.org/download/) \*technically not required if running in Docker +- Running Bloom’s backend + +### Summary + +Most open-source contributions (like running Cypress integration tests from the frontend) require adding test data to your local database. To do this, download Bloom's test data dump file, connect to the database server, then populate the database with the backup data. + +### Download Test Data Dump File + +Download the test data dump file [linked here from our Google Drive](https://drive.google.com/file/d/1y6KJpAlpozEg3GqhK8JRdmK-s7uzJXtB/view?usp=drive_link). + +### Connect to Server and Add Data + +There are multiple methods you can use here. For simplicity, these directions assume you are using Docker. + +Run the following command to restore the database from the dump file using pg_restore: + +``` +docker exec -i pg_restore -U -d --clean --if-exists < +``` + +`container_name`, `username`, and `database_name` are defined in the `docker-compose.yml` file under ‘db’. + +Start the bloom psql database server: + +``` +docker exec -it psql -U -d +``` + +This will open the psql server for bloom, where you can run queries to verify the restore. + +You can verify the restore by running a SQL query to test if one of our test user's data has been properly populated into the database: + +``` +SELECT * FROM public."user" users WHERE users."email" = 'tech+cypresspublic@chayn.co'; +``` + +If the user exists, the database has successfully been seeded! + +### Troubleshooting + +- If you remove **`--clean`** from the restore command but encounter duplicate object errors, the existing schema may conflict with the restore. In that case, clean the specific objects manually or use **`DROP SCHEMA public CASCADE`** before restoring. +- Verify that the dump file is valid by running: `pg_restore --list yourfile.dump` If it fails to list contents, the dump file may be corrupted or incomplete. +- In the psql server, verify the tables and columns exist with `\dt` , `\dt public.*` , and `\d public."user";` +- Run a **`DROP SCHEMA`** or truncate tables before running **`pg_restore`:** + ``` + DROP SCHEMA public CASCADE; + CREATE SCHEMA public; + ``` +- Try the following: delete the existing db, create a new db with the same name, and try the restore on this new db. The db drop may throw an error, if so run the following command first. + + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'bloom';` + + Then drop the database using: + + `DROP DATABASE bloom;` + +- If the sql dump file is outdated, you can update it by running `docker compose down` then `docker compose up` again as this is configured to run migrations. + +### Chayn Staff - Heroku Directions + +Chayn staff with access to Heroku, you also have the option to seed the database via the following script. Before you start, make sure: + +1. bloom-local-db container is running in Docker +2. you are logged into the Heroku via your terminal. Read more about the Heroku Cli [here](https://devcenter.heroku.com/articles/heroku-cli) +3. Replace with the correct Heroku app name in the `seed-local-db.sh file` +4. Run `chmod +x ./seed-local-db.sh` in your terminal to make the file executable + + After the above has been confirmed, run + + ```bash + bash seed-local-db.sh + ``` + +## Database Migrations + +A migration in TypeORM is a single file with SQL queries to update a database schema as updates/additions are made. Read more about migrations [here](https://github.com/typeorm/typeorm/blob/master/docs/migrations.md). + +Migrations are automatically run when the app is built docker (locally) or Heroku for staging and production apps. + +**You'll need to generate and run a migration each time you add or update a database field or table.** + +To generate a new migration + +```bash +yarn migration:generate +``` + +Add the new migration import into [typeorm.config.ts](./src/typeorm.config.ts) + +To run (apply) migrations + +```bash +yarn migration:run +``` + +To revert a migration + +```bash +yarn migration:revert +``` + +Note that when running the app in Docker, you may need to run migration commands from the docker terminal/Exec + +https://drive.google.com/file/d/1y6KJpAlpozEg3GqhK8JRdmK-s7uzJXtB/view?usp=sharing diff --git a/docs/database_models.jpg b/docs/database_models.jpg deleted file mode 100644 index f19b5249..00000000 Binary files a/docs/database_models.jpg and /dev/null differ diff --git a/docs/database_schema.png b/docs/database_schema.png deleted file mode 100644 index fa404e10..00000000 Binary files a/docs/database_schema.png and /dev/null differ diff --git a/docs/key-concepts.md b/docs/key-concepts.md deleted file mode 100644 index c425934d..00000000 --- a/docs/key-concepts.md +++ /dev/null @@ -1,28 +0,0 @@ -## Key concepts - -### User types - -There are several user types with different features enabled. - -**Public user** - joins Bloom without a partner, with access to self guided and live courses. - -**Partner user** - joins Bloom via a partner (and access code) with access to extra features and courses enabled by the partner(s). - -**Partner admin user** - a partner team member who uses the app to complete Bloom admin tasks such as creating new partner access codes. - -### Authentication - -User authentication is handled by [Firebase Auth](https://firebase.google.com/docs/auth). Bearer tokens are sent in api request headers and verified in [auth.service.ts](src/auth/auth.service.ts). The user record is fetched using the retrieved user email of the token. - -### Crisp profiles - -Crisp is the messaging platform used to message the Chayn team in relation to bloom course content or other queries and support. For public users, this 1-1 chat feature is available on _live_ courses only. For partner users, This 1-1 chat feature is available to users with a `PartnerAccess` that has 1-1 chat enabled. - -Users who have access to 1-1 chat also have a profile on Crisp that reflects data from our database regarding their partners, access and course progress. See [crisp.service.ts](src/crisp/crisp.service.ts) - -### Reporting - -Google Data Studio is an online tool for converting data into customizable informative reports and dashboards. - -The reports are generated by writing custom sql queries that return actionable data to Data Studio. Filters are applied in Data Studio allowing -data to be segregated into different time periods based on the data createdAt date diff --git a/docs/local-development.md b/docs/local-development.md new file mode 100644 index 00000000..11c16a08 --- /dev/null +++ b/docs/local-development.md @@ -0,0 +1,163 @@ +# Local Development Guide + +## Summary + +**The develop branch is our source of truth.** Fork from develop, create new feature branch, then when your PR is merged, develop will automatically merge into the main branch for deployment to production. + +To run Bloom's backend: + +1. Install prerequisites +2. Configure environment variables +3. Install dependencies +4. Run in a Dev Container, with Docker, or manually. +5. Populate the database (required for most fullstack contributions and running integration tests from the frontend) + +To test the backend: + +- Run unit tests +- Run e2e integration tests from the frontend for fullstack contributions \*requires populating the database with data first + +## Prerequisites + +- NodeJS v20.x +- Yarn v1.x +- Docker +- PostgreSQL 16 + +#### Recommended Minimum System Requirements: + +- CPU: Quad-core 2.5 GHz (i5/Ryzen 5) +- Memory: 16 GB RAM +- Storage: 512 GB +- OS: Linux, macOS, Windows, or WSL2 (latest versions) +- Internet Connection: For accessing dependencies and external APIs/services + +## Configure Environment Variables + +See [configure-env.md](configure-env.md) for instructions on configuring environment variables. + +## Install dependencies with yarn + +```bash +yarn +``` + +## Run the App Locally + +There are 3 methods you can use to run Bloom’s backend locally: + +1. **Using Docker (recommended)** - runs postgres in a container. +2. **Visual Studio Code Dev Container (recommended for Visual Studio users)** - installs all dependencies and the postgres database container automatically. +3. **Manually** - manage postgres locally. + +### With Docker - Recommended + +Bloom's backend is containerized and can be run solely in Docker - both the PostgreSQL database and NestJS app. This uses the least resources on your computer. To run the backend locally, make sure your system has Docker installed - you may need Docker Desktop if using a Mac or Windows. + +First, make sure the Docker app is running (just open the app). Then run + +```bash +docker-compose up +``` + +You should see this in the shell output: + +```shell +Listening on localhost:35001, CTRL+C to stop +``` + +_Note: you can use an application like Postman to test the apis locally_ + +### Run in Dev Container - Recommended for Visual Studio Users + +This method will automatically install all dependencies, IDE settings, and postgres container in a Dev Container (Docker container) within Visual Studio Code. + +Directions for running a dev container: + +1. Meet the [system requirements](https://code.visualstudio.com/docs/devcontainers/containers#_system-requirements) +2. Follow the [installation instructions](https://code.visualstudio.com/docs/devcontainers/containers#_installation) +3. [Check the installation](https://code.visualstudio.com/docs/devcontainers/tutorial#_check-installation) +4. After you've verified that the extension is installed and working, click on the "Remote Status" bar icon and select + "Reopen in Container". From here, the option to "Re-open in Container" should pop up in notifications whenever opening this project in VS. +5. [Configure your environment variables](#configure-environment-variables) and develop as you normally would. + +The dev Container is configured in the `.devcontainer` directory: + +- `docker-compose.yml` file in this directory extends the `docker-compose.yml` in the root directory. +- `devcontainer.json` configures the integrations with Visual Studio Code, such as the IDE extensions and settings in the `vscode` directory. + +See [Visual Studio Code Docs: Developing Inside a Dev Container](https://code.visualstudio.com/docs/devcontainers/containers) for more info. + +### Run Manually + +Manage postgres locally to [populate the database](#populate-database), then run: + +```bash +yarn start:dev +``` + +You should see this in the shell output: + +```shell +Listening on localhost:35001, CTRL+C to stop +``` + +## Unit Testing + +To run all unit tests + +```bash +yarn test +``` + +To have your unit tests running in the background as you change code: + +```bash +yarn test:watch +``` + +## Format and Linting + +Linting and formatting are provided by [ESLint](https://eslint.org/) and [Prettier](https://prettier.io/). We recommend VSCode users to utilize the workspace settings in [.vscode/settings.json](.vscode/settings.json) and install the extensions in [.vscode/extensions](.vscode/extensions.json) for automated consistency. + +**We strongly recommend maintaining consistent code style by linting and formatting before every commit:** + +To run linting + +```bash +yarn lint +``` + +To lint and fix + +```bash +yarn lint:fix +``` + +Run format and fix: + +```bash +yarn format +``` + +# Populate the Database and Database Migrations + +Populating your local database with test data is required for running Cypress integration tests and testing Bloom’s full-stack functionality. + +See the [database-guide.md](database-guide.md) for instructions. + +# Git Flow and Deployment + +**The develop branch is our source of truth, not main.** + +Create new branches from the `develop` base branch. There is no need to run the build command before pushing changes to GitHub, simply push and create a pull request for the new branch. GitHub Actions will run build and linting tasks automatically. Rebase and merge feature/bug branches into `develop`. + +This will trigger an automatic deployment to the staging app by Heroku. + +When changes have been tested in staging, merge `develop` into `main`. This will trigger an automatic deployment to the production app by Heroku. + +# Swagger + +Swagger automatically reflects all of the endpoints in the app, showing their urls and example request and response objects. + +To access Swagger simply run the project and visit http://localhost:35001/swagger diff --git a/package.json b/package.json index cc162fcd..f1f8de2d 100644 --- a/package.json +++ b/package.json @@ -31,17 +31,17 @@ "dependencies": { "@mailchimp/mailchimp_marketing": "^3.0.80", "@nestjs/axios": "^3.0.3", - "@nestjs/common": "^10.4.6", - "@nestjs/config": "^3.2.3", + "@nestjs/common": "^10.4.13", + "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.4", - "@nestjs/platform-express": "^10.4.6", + "@nestjs/platform-express": "^10.4.12", "@nestjs/swagger": "^7.4.0", "@nestjs/terminus": "^10.2.3", "@nestjs/typeorm": "^10.0.2", "axios": "^1.7.7", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", - "crisp-api": "^9.2.0", + "crisp-api": "^9.4.0", "date-fns": "^3.6.0", "dotenv": "^16.4.5", "firebase": "^10.10.0", @@ -61,15 +61,15 @@ "@eslint/js": "^9.11.1", "@golevelup/ts-jest": "^0.5.0", "@nestjs/cli": "^10.3.2", - "@nestjs/schematics": "^10.1.4", - "@nestjs/testing": "^10.4.1", + "@nestjs/testing": "^10.4.6", + "@nestjs/schematics": "^10.2.3", "@types/date-fns": "^2.6.0", - "@types/express": "^4.17.21", + "@types/express": "^5.0.0", "@types/jest": "^29.5.13", - "@types/lodash": "^4.17.7", + "@types/lodash": "^4.17.13", "@types/node": "^22.8.1", "@types/supertest": "^6.0.2", - "eslint": "^9.9.1", + "eslint": "^9.14.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", "jest": "^29.7.0", @@ -80,7 +80,7 @@ "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.5.4", - "typescript-eslint": "^8.11.0" + "typescript-eslint": "^8.17.0" }, "engines": { "node": "20.x", diff --git a/src/api/mailchimp/mailchimp-api.ts b/src/api/mailchimp/mailchimp-api.ts index d4aa2bac..06937b29 100644 --- a/src/api/mailchimp/mailchimp-api.ts +++ b/src/api/mailchimp/mailchimp-api.ts @@ -109,25 +109,39 @@ export const createMailchimpMergeField = async ( export const deleteMailchimpProfile = async (email: string) => { try { - return await mailchimp.lists.deleteListMember(mailchimpAudienceId, getEmailMD5Hash(email)); + return await mailchimp.lists.deleteListMemberPermanent( + mailchimpAudienceId, + getEmailMD5Hash(email), + ); } catch (error) { logger.warn(`Delete mailchimp profile API call failed: ${error}`); } }; export const deleteCypressMailchimpProfiles = async () => { - try { - const cypressProfiles = (await mailchimp.lists.getSegmentMembersList( - mailchimpAudienceId, - '5101590', - )) as { members: ListMember[] }; + let cypressProfiles: { members: ListMember[] }; - cypressProfiles.members.forEach(async (profile: ListMember) => { - deleteMailchimpProfile(profile.email_address); - }); + try { + cypressProfiles = (await mailchimp.lists.getSegmentMembersList(mailchimpAudienceId, '5046292', { + include_cleaned: true, + include_unsubscribed: true, + count: 200, + })) as { + members: ListMember[]; + }; } catch (error) { - throw new Error(`Delete cypress mailchimp profiles API call failed: ${error}`); + throw new Error(`Delete cypress mailchimp profiles API call failed to get users: ${error}`); } + + logger.log(`Deleting ${cypressProfiles.members.length} mailchimp profiles`); + + cypressProfiles.members.forEach(async (profile: ListMember) => { + try { + await deleteMailchimpProfile(profile.email_address); + } catch (error) { + throw new Error(`Delete cypress mailchimp profiles API call failed: ${error}`); + } + }); }; export const sendMailchimpUserEvent = async (email: string, event: MAILCHIMP_CUSTOM_EVENTS) => { diff --git a/src/app.module.ts b/src/app.module.ts index 09723755..76f28cee 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,9 @@ import { PartnerAccessModule } from './partner-access/partner-access.module'; import { PartnerAdminModule } from './partner-admin/partner-admin.module'; import { PartnerFeatureModule } from './partner-feature/partner-feature.module'; import { PartnerModule } from './partner/partner.module'; +import { ResourceFeedbackModule } from './resource-feedback/resource-feedback.module'; +import { ResourceUserModule } from './resource-user/resource-user.module'; +import { ResourceModule } from './resource/resource.module'; import { SessionFeedbackModule } from './session-feedback/session-feedback.module'; import { SessionUserModule } from './session-user/session-user.module'; import { SessionModule } from './session/session.module'; @@ -47,6 +50,9 @@ import { WebhooksModule } from './webhooks/webhooks.module'; HealthModule, CrispModule, CrispListenerModule, + ResourceModule, + ResourceUserModule, + ResourceFeedbackModule, ], }) export class AppModule {} diff --git a/src/crisp/crisp.service.ts b/src/crisp/crisp.service.ts index 4b03b4de..fa4ba7dc 100644 --- a/src/crisp/crisp.service.ts +++ b/src/crisp/crisp.service.ts @@ -122,7 +122,7 @@ export class CrispService { async deleteCypressCrispProfiles() { try { - const profiles = CrispClient.website.listPeopleProfiles( + const profiles = await CrispClient.website.listPeopleProfiles( crispWebsiteId, undefined, undefined, @@ -132,8 +132,10 @@ export class CrispService { 'cypresstestemail+', ); - profiles.data.data.forEach(async (profile) => { - CrispClient.website.removePeopleProfile(crispWebsiteId, profile.email); + console.log(`Deleting ${profiles.length} crisp profiles`); + + profiles?.forEach(async (profile) => { + await CrispClient.website.removePeopleProfile(crispWebsiteId, profile.email); }); } catch (error) { throw new Error(`Delete cypress crisp profiles API call failed: ${error}`); diff --git a/src/entities/resource-feedback.entity.ts b/src/entities/resource-feedback.entity.ts new file mode 100644 index 00000000..7eb91034 --- /dev/null +++ b/src/entities/resource-feedback.entity.ts @@ -0,0 +1,25 @@ +import { Column, Entity, JoinTable, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { FEEDBACK_TAGS_ENUM } from '../utils/constants'; +import { BaseBloomEntity } from './base.entity'; +import { ResourceEntity } from './resource.entity'; + +@Entity({ name: 'resource_feedback' }) +export class ResourceFeedbackEntity extends BaseBloomEntity { + @PrimaryGeneratedColumn('uuid', { name: 'resourceFeedbackId' }) + id: string; + + @Column() + resourceId: string; + + @ManyToOne(() => ResourceEntity, (resourceEntity) => resourceEntity.resourceFeedback, { + onDelete: 'CASCADE', + }) + @JoinTable({ name: 'resource', joinColumn: { name: 'resourceId' } }) + resource: ResourceEntity; + + @Column() + feedbackTags: FEEDBACK_TAGS_ENUM; + + @Column() + feedbackDescription: string; +} diff --git a/src/entities/resource-user.entity.ts b/src/entities/resource-user.entity.ts new file mode 100644 index 00000000..bb5dca88 --- /dev/null +++ b/src/entities/resource-user.entity.ts @@ -0,0 +1,29 @@ +import { Column, Entity, JoinTable, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { BaseBloomEntity } from './base.entity'; +import { ResourceEntity } from './resource.entity'; +import { UserEntity } from './user.entity'; + +// Many to many join table documentation can be found here: https://orkhan.gitbook.io/typeorm/docs/many-to-many-relations#many-to-many-relations-with-custom-properties + +@Entity({ name: 'resource_user' }) +export class ResourceUserEntity extends BaseBloomEntity { + @PrimaryGeneratedColumn('uuid', { name: 'resourceUserId' }) + id: string; + + @Column({ type: 'date', nullable: true }) + completedAt: Date; + + @Column() + resourceId: string; + @ManyToOne(() => ResourceEntity, (resourceEntity) => resourceEntity.resourceUser, { + onDelete: 'CASCADE', + }) + @JoinTable({ name: 'resource', joinColumn: { name: 'resourceId' } }) + resource: ResourceEntity; + + @Column() + userId: string; + @ManyToOne(() => UserEntity, (userEntity) => userEntity.resourceUser, { onDelete: 'CASCADE' }) + @JoinTable({ name: 'user', joinColumn: { name: 'userId' } }) + user: UserEntity; +} diff --git a/src/entities/resource.entity.ts b/src/entities/resource.entity.ts new file mode 100644 index 00000000..e0876c6a --- /dev/null +++ b/src/entities/resource.entity.ts @@ -0,0 +1,55 @@ +import { Column, Entity, JoinTable, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { RESOURCE_CATEGORIES, STORYBLOK_STORY_STATUS_ENUM } from '../utils/constants'; +import { BaseBloomEntity } from './base.entity'; +import { ResourceFeedbackEntity } from './resource-feedback.entity'; +import { ResourceUserEntity } from './resource-user.entity'; + +@Entity({ name: 'resource' }) +export class ResourceEntity extends BaseBloomEntity { + @PrimaryGeneratedColumn('uuid', { name: 'resourceId' }) + id: string; + + @Column() + name: string; + + @Column() + slug: string; + + @Column({ + nullable: true, + }) + status: STORYBLOK_STORY_STATUS_ENUM; + + @Column({ + nullable: false, + }) + category: RESOURCE_CATEGORIES; + + @Column({ + unique: true, + nullable: true, + }) + storyblokUuid: string; + + @Column({ + unique: true, + nullable: true, + }) + storyblokId: number; + + @OneToMany(() => ResourceUserEntity, (resourceUserEntity) => resourceUserEntity.resource, { + cascade: true, + }) + @JoinTable({ name: 'resourceUser', joinColumn: { name: 'resourceUserId' } }) + resourceUser: ResourceUserEntity[]; + + @OneToMany( + () => ResourceFeedbackEntity, + (resourceFeedbackEntity) => resourceFeedbackEntity.resource, + { + cascade: true, + }, + ) + @JoinTable({ name: 'resourceFeedback', joinColumn: { name: 'resourceFeedbackId' } }) + resourceFeedback: ResourceFeedbackEntity[]; +} diff --git a/src/entities/session-feedback.entity.ts b/src/entities/session-feedback.entity.ts index af2a0870..403ddc5e 100644 --- a/src/entities/session-feedback.entity.ts +++ b/src/entities/session-feedback.entity.ts @@ -1,4 +1,4 @@ -import { Column, Entity, JoinTable, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { FEEDBACK_TAGS_ENUM } from '../utils/constants'; import { BaseBloomEntity } from './base.entity'; import { SessionEntity } from './session.entity'; @@ -8,12 +8,9 @@ export class SessionFeedbackEntity extends BaseBloomEntity { @PrimaryGeneratedColumn('uuid', { name: 'sessionFeedbackId' }) sessionFeedbackId: string; - @Column() - sessionId: string; - @ManyToOne(() => SessionEntity, (sessionEntity) => sessionEntity.sessionUser, { + @ManyToOne(() => SessionEntity, (sessionEntity) => sessionEntity.sessionFeedback, { onDelete: 'CASCADE', }) - @JoinTable({ name: 'session', joinColumn: { name: 'sessionId' } }) session: SessionEntity; @Column() diff --git a/src/entities/session-user.entity.ts b/src/entities/session-user.entity.ts index 2fa7c0f2..e0cd293b 100644 --- a/src/entities/session-user.entity.ts +++ b/src/entities/session-user.entity.ts @@ -20,7 +20,6 @@ export class SessionUserEntity extends BaseBloomEntity { @ManyToOne(() => SessionEntity, (sessionEntity) => sessionEntity.sessionUser, { onDelete: 'CASCADE', }) - @JoinTable({ name: 'session', joinColumn: { name: 'sessionId' } }) session: SessionEntity; @Column() diff --git a/src/entities/session.entity.ts b/src/entities/session.entity.ts index 163e8727..950b0cd6 100644 --- a/src/entities/session.entity.ts +++ b/src/entities/session.entity.ts @@ -2,6 +2,7 @@ import { Column, Entity, JoinTable, ManyToOne, OneToMany, PrimaryGeneratedColumn import { STORYBLOK_STORY_STATUS_ENUM } from '../utils/constants'; import { BaseBloomEntity } from './base.entity'; import { CourseEntity } from './course.entity'; +import { SessionFeedbackEntity } from './session-feedback.entity'; import { SessionUserEntity } from './session-user.entity'; @Entity({ name: 'session' }) @@ -42,4 +43,13 @@ export class SessionEntity extends BaseBloomEntity { cascade: true, }) sessionUser: SessionUserEntity[]; + + @OneToMany( + () => SessionFeedbackEntity, + (sessionFeedbackEntity) => sessionFeedbackEntity.session, + { + cascade: true, + }, + ) + sessionFeedback: SessionFeedbackEntity[]; } diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index 260203d4..55830181 100644 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -5,6 +5,7 @@ import { EMAIL_REMINDERS_FREQUENCY } from '../utils/constants'; import { BaseBloomEntity } from './base.entity'; import { CourseUserEntity } from './course-user.entity'; import { EventLogEntity } from './event-log.entity'; +import { ResourceUserEntity } from './resource-user.entity'; import { SubscriptionUserEntity } from './subscription-user.entity'; import { TherapySessionEntity } from './therapy-session.entity'; @@ -55,6 +56,11 @@ export class UserEntity extends BaseBloomEntity { @OneToMany(() => CourseUserEntity, (courseUser) => courseUser.user, { cascade: true }) courseUser: CourseUserEntity[]; + @OneToMany(() => ResourceUserEntity, (resourceUser) => resourceUser.user, { + cascade: true, + }) + resourceUser: ResourceUserEntity[]; + @OneToMany(() => SubscriptionUserEntity, (subscriptionUser) => subscriptionUser.user, { cascade: true, }) diff --git a/src/event-logger/event-logger.service.spec.ts b/src/event-logger/event-logger.service.spec.ts index a66eeffa..ce088184 100644 --- a/src/event-logger/event-logger.service.spec.ts +++ b/src/event-logger/event-logger.service.spec.ts @@ -46,14 +46,14 @@ describe('EventLoggerService', () => { expect(service).toBeDefined(); }); describe('createEventLog', () => { - it('when supplied with correct data should return new feature', async () => { + it('should create and return an event log record', async () => { const response = await service.createEventLog({ event: EVENT_NAME.CHAT_MESSAGE_SENT, date: new Date(2000, 1, 1), userId: 'userId', }); expect(response).toMatchObject({ - id: 'newId', + id: 'logId', event: EVENT_NAME.CHAT_MESSAGE_SENT, date: new Date(2000, 1, 1), userId: 'userId', diff --git a/src/event-logger/event-logger.service.ts b/src/event-logger/event-logger.service.ts index 710abd9c..8c443bfe 100644 --- a/src/event-logger/event-logger.service.ts +++ b/src/event-logger/event-logger.service.ts @@ -51,13 +51,7 @@ export class EventLoggerService { userId = user.id; } - const eventLog = await this.eventLoggerRepository.create({ - userId, - event, - date, - }); - const eventLogRecord = this.eventLoggerRepository.save(eventLog); - return eventLogRecord; + return await this.eventLoggerRepository.save({ userId, event, date }); } catch (err) { throw new HttpException( `createEventLog - failed to create event log ${err}`, diff --git a/src/logger/logging.interceptor.ts b/src/logger/logging.interceptor.ts index 3a0b890e..4ec6e563 100644 --- a/src/logger/logging.interceptor.ts +++ b/src/logger/logging.interceptor.ts @@ -14,7 +14,7 @@ export class LoggingInterceptor implements NestInterceptor { //@ts-expect-error: userEntity is modified in authGuard const userId = req?.userEntity?.id; - const commonMessage = `${req.method} "${req.originalUrl}" (IP address: ${req.ip}, requestUserId: ${userId})`; + const commonMessage = `${req.method} "${req.originalUrl}" (requestUserId: ${userId})`; this.logger.log(`Started ${commonMessage}`); diff --git a/src/migrations/1733160378757-bloom-backend.ts b/src/migrations/1733160378757-bloom-backend.ts new file mode 100644 index 00000000..2550f49b --- /dev/null +++ b/src/migrations/1733160378757-bloom-backend.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class BloomBackend1733160378757 implements MigrationInterface { + name = 'BloomBackend1733160378757'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "resource_feedback" ("createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "resourceFeedbackId" uuid NOT NULL DEFAULT uuid_generate_v4(), "resourceId" uuid NOT NULL, "feedbackTags" character varying NOT NULL, "feedbackDescription" character varying NOT NULL, CONSTRAINT "PK_97393ce3b5c5d462500e181613b" PRIMARY KEY ("resourceFeedbackId"))`, + ); + await queryRunner.query( + `CREATE TABLE "resource" ("createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "resourceId" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "slug" character varying NOT NULL, "status" character varying, "category" character varying NOT NULL, "storyblokUuid" character varying, "storyblokId" integer, CONSTRAINT "UQ_575686fbc2bc272030a15ac3ea1" UNIQUE ("storyblokUuid"), CONSTRAINT "UQ_1b4b84228b725114ccc955dcec7" UNIQUE ("storyblokId"), CONSTRAINT "PK_f59f8360a61e63c72d0f1a6aa00" PRIMARY KEY ("resourceId"))`, + ); + await queryRunner.query( + `CREATE TABLE "resource_user" ("createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "resourceUserId" uuid NOT NULL DEFAULT uuid_generate_v4(), "completedAt" date, "resourceId" uuid, "userId" uuid, CONSTRAINT "PK_e88a671cf058fac35384d8e1426" PRIMARY KEY ("resourceUserId"))`, + ); + await queryRunner.query( + `ALTER TABLE "session_feedback" DROP CONSTRAINT "FK_a0567dbf6bd30cf4bd05b110a17"`, + ); + await queryRunner.query( + `ALTER TABLE "session_feedback" ALTER COLUMN "sessionId" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "session_feedback" ADD CONSTRAINT "FK_a0567dbf6bd30cf4bd05b110a17" FOREIGN KEY ("sessionId") REFERENCES "session"("sessionId") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "resource_feedback" ADD CONSTRAINT "FK_3218ac4ae760f580ce260a43e3a" FOREIGN KEY ("resourceId") REFERENCES "resource"("resourceId") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "resource_user" ADD CONSTRAINT "FK_774b61a463074cee88e57685925" FOREIGN KEY ("resourceId") REFERENCES "resource"("resourceId") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "resource_user" ADD CONSTRAINT "FK_ea89e3c7f0126d7e9d02308c2ca" FOREIGN KEY ("userId") REFERENCES "user"("userId") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "resource_user" DROP CONSTRAINT "FK_ea89e3c7f0126d7e9d02308c2ca"`, + ); + await queryRunner.query( + `ALTER TABLE "resource_user" DROP CONSTRAINT "FK_774b61a463074cee88e57685925"`, + ); + await queryRunner.query( + `ALTER TABLE "resource_feedback" DROP CONSTRAINT "FK_3218ac4ae760f580ce260a43e3a"`, + ); + await queryRunner.query( + `ALTER TABLE "session_feedback" DROP CONSTRAINT "FK_a0567dbf6bd30cf4bd05b110a17"`, + ); + await queryRunner.query(`ALTER TABLE "session_feedback" ALTER COLUMN "sessionId" SET NOT NULL`); + await queryRunner.query( + `ALTER TABLE "session_feedback" ADD CONSTRAINT "FK_a0567dbf6bd30cf4bd05b110a17" FOREIGN KEY ("sessionId") REFERENCES "session"("sessionId") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query(`DROP TABLE "resource_user"`); + await queryRunner.query(`DROP TABLE "resource"`); + await queryRunner.query(`DROP TABLE "resource_feedback"`); + } +} diff --git a/src/migrations/1733850090811-bloom-backend.ts b/src/migrations/1733850090811-bloom-backend.ts new file mode 100644 index 00000000..2b92ecdc --- /dev/null +++ b/src/migrations/1733850090811-bloom-backend.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class BloomBackend1733850090811 implements MigrationInterface { + name = 'BloomBackend1733850090811'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "resource_user" DROP CONSTRAINT "FK_774b61a463074cee88e57685925"`, + ); + await queryRunner.query( + `ALTER TABLE "resource_user" DROP CONSTRAINT "FK_ea89e3c7f0126d7e9d02308c2ca"`, + ); + await queryRunner.query(`ALTER TABLE "resource_user" ALTER COLUMN "resourceId" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "resource_user" ALTER COLUMN "userId" SET NOT NULL`); + await queryRunner.query( + `ALTER TABLE "resource_user" ADD CONSTRAINT "FK_774b61a463074cee88e57685925" FOREIGN KEY ("resourceId") REFERENCES "resource"("resourceId") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "resource_user" ADD CONSTRAINT "FK_ea89e3c7f0126d7e9d02308c2ca" FOREIGN KEY ("userId") REFERENCES "user"("userId") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "resource_user" DROP CONSTRAINT "FK_ea89e3c7f0126d7e9d02308c2ca"`, + ); + await queryRunner.query( + `ALTER TABLE "resource_user" DROP CONSTRAINT "FK_774b61a463074cee88e57685925"`, + ); + await queryRunner.query(`ALTER TABLE "resource_user" ALTER COLUMN "userId" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "resource_user" ALTER COLUMN "resourceId" DROP NOT NULL`); + await queryRunner.query( + `ALTER TABLE "resource_user" ADD CONSTRAINT "FK_ea89e3c7f0126d7e9d02308c2ca" FOREIGN KEY ("userId") REFERENCES "user"("userId") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "resource_user" ADD CONSTRAINT "FK_774b61a463074cee88e57685925" FOREIGN KEY ("resourceId") REFERENCES "resource"("resourceId") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } +} diff --git a/src/partner-admin/partner-admin-auth.guard.spec.ts b/src/partner-admin/partner-admin-auth.guard.spec.ts index 11f42bad..8b44eb1d 100644 --- a/src/partner-admin/partner-admin-auth.guard.spec.ts +++ b/src/partner-admin/partner-admin-auth.guard.spec.ts @@ -30,6 +30,7 @@ const userEntity: UserEntity = { signUpLanguage: 'en', subscriptionUser: [], therapySession: [], + resourceUser: [], eventLog: [], }; describe('PartnerAdminAuthGuard', () => { diff --git a/src/resource-feedback/dtos/create-resource-feedback.dto.ts b/src/resource-feedback/dtos/create-resource-feedback.dto.ts new file mode 100644 index 00000000..13b14ece --- /dev/null +++ b/src/resource-feedback/dtos/create-resource-feedback.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { FEEDBACK_TAGS_ENUM } from 'src/utils/constants'; + +export class CreateResourceFeedbackDto { + @IsNotEmpty() + @IsString() + @ApiProperty({ type: String }) + resourceId: string; + + @IsNotEmpty() + @IsEnum(FEEDBACK_TAGS_ENUM) + @ApiProperty({ + enum: FEEDBACK_TAGS_ENUM, + type: String, + example: Object.values(FEEDBACK_TAGS_ENUM), + }) + feedbackTags: FEEDBACK_TAGS_ENUM; + + @IsString() + @ApiProperty({ type: String }) + feedbackDescription: string; +} diff --git a/src/resource-feedback/dtos/resource-feedback.dto.ts b/src/resource-feedback/dtos/resource-feedback.dto.ts new file mode 100644 index 00000000..bbbae2c1 --- /dev/null +++ b/src/resource-feedback/dtos/resource-feedback.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { FEEDBACK_TAGS_ENUM } from 'src/utils/constants'; + +export class ResourceFeedbackDto { + @IsNotEmpty() + @IsString() + @ApiProperty({ type: String }) + id: string; + + @IsNotEmpty() + @IsString() + @ApiProperty({ type: String }) + resourceId: string; + + @IsNotEmpty() + @IsEnum(FEEDBACK_TAGS_ENUM) + @ApiProperty({ + enum: FEEDBACK_TAGS_ENUM, + type: String, + example: Object.values(FEEDBACK_TAGS_ENUM), + }) + feedbackTags: FEEDBACK_TAGS_ENUM; + + @IsString() + @ApiProperty({ type: String }) + feedbackDescription: string; +} diff --git a/src/resource-feedback/resource-feedback.controller.ts b/src/resource-feedback/resource-feedback.controller.ts new file mode 100644 index 00000000..95dd37e9 --- /dev/null +++ b/src/resource-feedback/resource-feedback.controller.ts @@ -0,0 +1,21 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ControllerDecorator } from 'src/utils/controller.decorator'; +import { CreateResourceFeedbackDto } from './dtos/create-resource-feedback.dto'; +import { ResourceFeedbackService } from './resource-feedback.service'; + +@ApiTags('Resource Feedback') +@ControllerDecorator() +@Controller('/v1/resource-feedback') +export class ResourceFeedbackController { + constructor(private readonly resourceFeedbackService: ResourceFeedbackService) {} + + // TODO how do we protect this public endpoint from being abused? + @Post() + @ApiOperation({ + description: 'Stores feedback from a user', + }) + create(@Body() createResourceFeedbackDto: CreateResourceFeedbackDto) { + return this.resourceFeedbackService.create(createResourceFeedbackDto); + } +} diff --git a/src/resource-feedback/resource-feedback.module.ts b/src/resource-feedback/resource-feedback.module.ts new file mode 100644 index 00000000..d8e24cb4 --- /dev/null +++ b/src/resource-feedback/resource-feedback.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ResourceFeedbackEntity } from 'src/entities/resource-feedback.entity'; +import { ResourceEntity } from 'src/entities/resource.entity'; +import { ResourceService } from 'src/resource/resource.service'; +import { ResourceFeedbackController } from './resource-feedback.controller'; +import { ResourceFeedbackService } from './resource-feedback.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([ResourceFeedbackEntity, ResourceEntity])], + controllers: [ResourceFeedbackController], + providers: [ResourceFeedbackService, ResourceService], +}) +export class ResourceFeedbackModule {} diff --git a/src/resource-feedback/resource-feedback.service.spec.ts b/src/resource-feedback/resource-feedback.service.spec.ts new file mode 100644 index 00000000..4808198e --- /dev/null +++ b/src/resource-feedback/resource-feedback.service.spec.ts @@ -0,0 +1,66 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ResourceService } from 'src/resource/resource.service'; +import { FEEDBACK_TAGS_ENUM } from 'src/utils/constants'; +import { mockResource } from 'test/utils/mockData'; +import { Repository } from 'typeorm'; +import { ResourceFeedbackEntity } from '../entities/resource-feedback.entity'; +import { ResourceFeedbackService } from './resource-feedback.service'; + +describe('ResourceFeedbackService', () => { + let service: ResourceFeedbackService; + let mockResourceFeedbackRepository: DeepMocked>; + let mockResourceService: DeepMocked; + + const resourceFeedbackDto = { + resourceId: mockResource.id, + feedbackTags: FEEDBACK_TAGS_ENUM.RELATABLE, + feedbackDescription: 'feedback comments', + } as ResourceFeedbackEntity; + + beforeEach(async () => { + mockResourceFeedbackRepository = createMock>(); + mockResourceService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ResourceFeedbackService, + { + provide: getRepositoryToken(ResourceFeedbackEntity), + useValue: mockResourceFeedbackRepository, + }, + { + provide: ResourceService, + useValue: mockResourceService, + }, + ], + }).compile(); + + service = module.get(ResourceFeedbackService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a resource feedback when resource exists', async () => { + jest.spyOn(mockResourceService, 'findOne').mockResolvedValueOnce(mockResource); + jest.spyOn(mockResourceFeedbackRepository, 'save').mockResolvedValueOnce(resourceFeedbackDto); + + const result = await service.create(resourceFeedbackDto); + expect(result).toEqual(resourceFeedbackDto); + expect(mockResourceFeedbackRepository.save).toHaveBeenCalledWith(resourceFeedbackDto); + }); + + it('should throw an HttpException when resource does not exist', async () => { + jest.spyOn(mockResourceService, 'findOne').mockResolvedValueOnce(null); + + await expect(service.create(resourceFeedbackDto)).rejects.toThrow( + new HttpException('RESOURCE NOT FOUND', HttpStatus.NOT_FOUND), + ); + }); + }); +}); diff --git a/src/resource-feedback/resource-feedback.service.ts b/src/resource-feedback/resource-feedback.service.ts new file mode 100644 index 00000000..2614b478 --- /dev/null +++ b/src/resource-feedback/resource-feedback.service.ts @@ -0,0 +1,27 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ResourceService } from 'src/resource/resource.service'; +import { Repository } from 'typeorm'; +import { ResourceFeedbackEntity } from '../entities/resource-feedback.entity'; +import { CreateResourceFeedbackDto } from './dtos/create-resource-feedback.dto'; + +@Injectable() +export class ResourceFeedbackService { + constructor( + @InjectRepository(ResourceFeedbackEntity) + private resourceFeedbackRepository: Repository, + private readonly resourceService: ResourceService, + ) {} + + async create( + createResourceFeedbackDto: CreateResourceFeedbackDto, + ): Promise { + const resource = await this.resourceService.findOne(createResourceFeedbackDto.resourceId); + + if (!resource) { + throw new HttpException('RESOURCE NOT FOUND', HttpStatus.NOT_FOUND); + } + + return this.resourceFeedbackRepository.save(createResourceFeedbackDto); + } +} diff --git a/src/resource-user/dtos/resource-user.dto.ts b/src/resource-user/dtos/resource-user.dto.ts new file mode 100644 index 00000000..b70f42e5 --- /dev/null +++ b/src/resource-user/dtos/resource-user.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsNotEmpty, IsString } from 'class-validator'; + +export class ResourceUserDto { + @IsNotEmpty() + @IsString() + @ApiProperty({ type: String }) + resourceId: string; + + @IsNotEmpty() + @IsString() + @ApiProperty({ type: String }) + userId: string; + + @IsBoolean() + @ApiProperty({ type: Date }) + completedAt?: Date; +} diff --git a/src/resource-user/dtos/update-resource-user.dto.ts b/src/resource-user/dtos/update-resource-user.dto.ts new file mode 100644 index 00000000..8e8c6b8e --- /dev/null +++ b/src/resource-user/dtos/update-resource-user.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDefined, IsNotEmpty } from 'class-validator'; + +export class UpdateResourceUserDto { + @IsNotEmpty() + @IsDefined() + @ApiProperty({ type: Number }) + storyblokId: number; +} diff --git a/src/resource-user/resource-user.controller.ts b/src/resource-user/resource-user.controller.ts new file mode 100644 index 00000000..dbf9efc9 --- /dev/null +++ b/src/resource-user/resource-user.controller.ts @@ -0,0 +1,61 @@ +import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Request } from 'express'; +import { ControllerDecorator } from 'src/utils/controller.decorator'; +import { UserEntity } from '../entities/user.entity'; +import { FirebaseAuthGuard } from '../firebase/firebase-auth.guard'; +import { UpdateResourceUserDto } from './dtos/update-resource-user.dto'; +import { ResourceUserService } from './resource-user.service'; + +@ApiTags('Resource User') +@ControllerDecorator() +@Controller('/v1/resource-user') +export class ResourceUserController { + constructor(private readonly resourceUserService: ResourceUserService) {} + + @Post() + @ApiBearerAuth('access-token') + @ApiOperation({ + description: + 'Stores relationship between a `User` and `Resource` records, once a user has started a resource.', + }) + @UseGuards(FirebaseAuthGuard) + async createResourceUser( + @Req() req: Request, + @Body() createResourceUserDto: UpdateResourceUserDto, + ) { + return await this.resourceUserService.createResourceUser( + req['userEntity'] as UserEntity, + createResourceUserDto, + ); + } + + @Post('/complete') + @ApiOperation({ + description: 'Updates a users resources progress to completed', + }) + @ApiBearerAuth('access-token') + @UseGuards(FirebaseAuthGuard) + async complete(@Req() req: Request, @Body() updateResourceUserDto: UpdateResourceUserDto) { + return await this.resourceUserService.setResourceUserCompleted( + req['userEntity'] as UserEntity, + updateResourceUserDto, + true, + ); + } + + @Post('/incomplete') + @ApiOperation({ + description: + 'Updates a users resources progress to incomplete, undoing a previous complete action', + }) + @ApiBearerAuth('access-token') + @UseGuards(FirebaseAuthGuard) + async incomplete(@Req() req: Request, @Body() updateResourceUserDto: UpdateResourceUserDto) { + return await this.resourceUserService.setResourceUserCompleted( + req['userEntity'] as UserEntity, + updateResourceUserDto, + false, + ); + } +} diff --git a/src/resource-user/resource-user.module.ts b/src/resource-user/resource-user.module.ts new file mode 100644 index 00000000..ef1fdf69 --- /dev/null +++ b/src/resource-user/resource-user.module.ts @@ -0,0 +1,65 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SlackMessageClient } from 'src/api/slack/slack-api'; +import { ZapierWebhookClient } from 'src/api/zapier/zapier-webhook-client'; +import { CrispService } from 'src/crisp/crisp.service'; +import { CourseEntity } from 'src/entities/course.entity'; +import { EventLogEntity } from 'src/entities/event-log.entity'; +import { PartnerAccessEntity } from 'src/entities/partner-access.entity'; +import { PartnerAdminEntity } from 'src/entities/partner-admin.entity'; +import { PartnerEntity } from 'src/entities/partner.entity'; +import { ResourceUserEntity } from 'src/entities/resource-user.entity'; +import { ResourceEntity } from 'src/entities/resource.entity'; +import { SessionEntity } from 'src/entities/session.entity'; +import { SubscriptionUserEntity } from 'src/entities/subscription-user.entity'; +import { SubscriptionEntity } from 'src/entities/subscription.entity'; +import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; +import { UserEntity } from 'src/entities/user.entity'; +import { EventLoggerService } from 'src/event-logger/event-logger.service'; +import { PartnerAccessService } from 'src/partner-access/partner-access.service'; +import { PartnerService } from 'src/partner/partner.service'; +import { ResourceService } from 'src/resource/resource.service'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; +import { SubscriptionUserService } from 'src/subscription-user/subscription-user.service'; +import { SubscriptionService } from 'src/subscription/subscription.service'; +import { TherapySessionService } from 'src/therapy-session/therapy-session.service'; +import { UserService } from 'src/user/user.service'; +import { ResourceUserController } from './resource-user.controller'; +import { ResourceUserService } from './resource-user.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + UserEntity, + ResourceEntity, + ResourceUserEntity, + PartnerAccessEntity, + PartnerEntity, + SessionEntity, + PartnerAccessEntity, + CourseEntity, + PartnerAdminEntity, + SubscriptionUserEntity, + TherapySessionEntity, + SubscriptionEntity, + EventLogEntity, + ]), + ], + controllers: [ResourceUserController], + providers: [ + ResourceService, + ResourceUserService, + UserService, + PartnerAccessService, + PartnerService, + ServiceUserProfilesService, + SubscriptionService, + SubscriptionUserService, + TherapySessionService, + CrispService, + EventLoggerService, + ZapierWebhookClient, + SlackMessageClient, + ], +}) +export class ResourceUserModule {} diff --git a/src/resource-user/resource-user.service.ts b/src/resource-user/resource-user.service.ts new file mode 100644 index 00000000..d89532d3 --- /dev/null +++ b/src/resource-user/resource-user.service.ts @@ -0,0 +1,97 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ResourceUserEntity } from 'src/entities/resource-user.entity'; +import { UserEntity } from 'src/entities/user.entity'; +import { ResourceService } from 'src/resource/resource.service'; +import { formatResourceUserObject } from 'src/utils/serialize'; +import { Repository } from 'typeorm'; +import { ResourceUserDto } from './dtos/resource-user.dto'; +import { UpdateResourceUserDto } from './dtos/update-resource-user.dto'; + +@Injectable() +export class ResourceUserService { + constructor( + @InjectRepository(ResourceUserEntity) + private resourceUserRepository: Repository, + private resourceService: ResourceService, + ) {} + + private async getResourceUser({ + resourceId, + userId, + }: ResourceUserDto): Promise { + return await this.resourceUserRepository + .createQueryBuilder('resource_user') + .leftJoinAndSelect('resource_user.resource', 'resource') + .where('resource_user.userId = :userId', { userId }) + .andWhere('resource_user.resourceId = :resourceId', { resourceId }) + .getOne(); + } + + async createResourceUserRecord({ + resourceId, + userId, + }: ResourceUserDto): Promise { + return await this.resourceUserRepository.save({ + resourceId, + userId, + completedAt: null, + }); + } + + public async createResourceUser(user: UserEntity, { storyblokId }: UpdateResourceUserDto) { + const resource = await this.resourceService.getResourceByStoryblokId(storyblokId); + + if (!resource) { + throw new HttpException('RESOURCE NOT FOUND', HttpStatus.NOT_FOUND); + } + + let resourceUser = await this.getResourceUser({ + resourceId: resource.id, + userId: user.id, + }); + + if (!resourceUser) { + resourceUser = await this.createResourceUserRecord({ + resourceId: resource.id, + userId: user.id, + }); + } + + return formatResourceUserObject([{ ...resourceUser, resource }])[0]; + } + + public async setResourceUserCompleted( + user: UserEntity, + { storyblokId }: UpdateResourceUserDto, + completed: boolean, + ) { + const resource = await this.resourceService.getResourceByStoryblokId(storyblokId); + + if (!resource) { + throw new HttpException( + `Resource not found for storyblok id: ${storyblokId}`, + HttpStatus.NOT_FOUND, + ); + } + + let resourceUser = await this.getResourceUser({ + resourceId: resource.id, + userId: user.id, + }); + + if (resourceUser) { + resourceUser = await this.resourceUserRepository.save({ + ...resourceUser, + completedAt: completed ? new Date() : null, + }); + } else { + resourceUser = await this.createResourceUserRecord({ + resourceId: resource.id, + userId: user.id, + }); + } + + return formatResourceUserObject([{ ...resourceUser, resource }])[0]; + } +} diff --git a/src/resource/dtos/create-resource.dto.ts b/src/resource/dtos/create-resource.dto.ts new file mode 100644 index 00000000..7ca851b3 --- /dev/null +++ b/src/resource/dtos/create-resource.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { RESOURCE_CATEGORIES, STORYBLOK_STORY_STATUS_ENUM } from 'src/utils/constants'; + +export class CreateResourceDto { + @IsNotEmpty() + @IsString() + @ApiProperty({ type: String }) + name: string; + + @IsNotEmpty() + @IsString() + @ApiProperty({ type: String }) + slug: string; + + @IsOptional() + @IsEnum(STORYBLOK_STORY_STATUS_ENUM) + @ApiProperty({ enum: STORYBLOK_STORY_STATUS_ENUM, type: String, required: false }) + status?: STORYBLOK_STORY_STATUS_ENUM; + + @IsOptional() + @IsEnum(RESOURCE_CATEGORIES) + @ApiProperty({ enum: RESOURCE_CATEGORIES, type: String, required: false }) + category: RESOURCE_CATEGORIES; + + @IsOptional() + @IsString() + @ApiProperty({ type: String, required: false }) + storyblokUuid?: string; +} diff --git a/src/resource/dtos/resource.dto.ts b/src/resource/dtos/resource.dto.ts new file mode 100644 index 00000000..57bf8137 --- /dev/null +++ b/src/resource/dtos/resource.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { STORYBLOK_STORY_STATUS_ENUM } from 'src/utils/constants'; + +export class ResourceDto { + @IsNotEmpty() + @IsString() + @ApiProperty({ type: String }) + id: string; + + @IsNotEmpty() + @IsString() + @ApiProperty({ type: String }) + name: string; + + @IsNotEmpty() + @IsString() + @ApiProperty({ type: String }) + slug: string; + + @IsOptional() + @IsEnum(STORYBLOK_STORY_STATUS_ENUM) + @ApiProperty({ enum: STORYBLOK_STORY_STATUS_ENUM, type: String, required: false }) + status?: STORYBLOK_STORY_STATUS_ENUM; + + @IsOptional() + @IsString() + @ApiProperty({ type: String, required: false }) + storyblokUuid?: string; +} diff --git a/src/resource/resource.controller.ts b/src/resource/resource.controller.ts new file mode 100644 index 00000000..37c7ac3d --- /dev/null +++ b/src/resource/resource.controller.ts @@ -0,0 +1,26 @@ +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { SuperAdminAuthGuard } from 'src/partner-admin/super-admin-auth.guard'; +import { ControllerDecorator } from 'src/utils/controller.decorator'; +import { CreateResourceDto } from './dtos/create-resource.dto'; +import { ResourceService } from './resource.service'; + +@ApiTags('Resources') +@ControllerDecorator() +@Controller('/v1/resource') +export class ResourceController { + constructor(private readonly resourceService: ResourceService) {} + + @Get(':id') + async getResource(@Param('id') id: string) { + return this.resourceService.findOne(id); + } + + @Post() + @ApiBearerAuth('access-token') + @ApiOperation({ description: 'Creates resource' }) + @UseGuards(SuperAdminAuthGuard) + async createResource(@Body() createResourceDto: CreateResourceDto) { + return this.resourceService.create(createResourceDto); + } +} diff --git a/src/resource/resource.interface.ts b/src/resource/resource.interface.ts new file mode 100644 index 00000000..169ee489 --- /dev/null +++ b/src/resource/resource.interface.ts @@ -0,0 +1,14 @@ +import { RESOURCE_CATEGORIES, STORYBLOK_STORY_STATUS_ENUM } from 'src/utils/constants'; + +export interface IResource { + id?: string; + createdAt?: Date | string; + updatedAt?: Date | string; + name?: string; + slug?: string; + status?: STORYBLOK_STORY_STATUS_ENUM; + storyblokId?: number; + storyblokUuid?: string; + category?: RESOURCE_CATEGORIES; + completedAt?: Date | string; +} diff --git a/src/resource/resource.module.ts b/src/resource/resource.module.ts new file mode 100644 index 00000000..38f2e7f5 --- /dev/null +++ b/src/resource/resource.module.ts @@ -0,0 +1,65 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SlackMessageClient } from 'src/api/slack/slack-api'; +import { ZapierWebhookClient } from 'src/api/zapier/zapier-webhook-client'; +import { CrispService } from 'src/crisp/crisp.service'; +import { CourseEntity } from 'src/entities/course.entity'; +import { EventLogEntity } from 'src/entities/event-log.entity'; +import { PartnerAccessEntity } from 'src/entities/partner-access.entity'; +import { PartnerAdminEntity } from 'src/entities/partner-admin.entity'; +import { PartnerEntity } from 'src/entities/partner.entity'; +import { SessionEntity } from 'src/entities/session.entity'; +import { SubscriptionUserEntity } from 'src/entities/subscription-user.entity'; +import { SubscriptionEntity } from 'src/entities/subscription.entity'; +import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; +import { UserEntity } from 'src/entities/user.entity'; +import { EventLoggerService } from 'src/event-logger/event-logger.service'; +import { PartnerAccessService } from 'src/partner-access/partner-access.service'; +import { PartnerService } from 'src/partner/partner.service'; +import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; +import { SubscriptionUserService } from 'src/subscription-user/subscription-user.service'; +import { SubscriptionService } from 'src/subscription/subscription.service'; +import { TherapySessionService } from 'src/therapy-session/therapy-session.service'; +import { UserService } from 'src/user/user.service'; +import { ResourceFeedbackEntity } from '../entities/resource-feedback.entity'; +import { ResourceUserEntity } from '../entities/resource-user.entity'; +import { ResourceEntity } from '../entities/resource.entity'; +import { ResourceController } from './resource.controller'; +import { ResourceService } from './resource.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + ResourceEntity, + ResourceUserEntity, + ResourceFeedbackEntity, + UserEntity, + PartnerAccessEntity, + PartnerEntity, + SessionEntity, + PartnerAccessEntity, + CourseEntity, + PartnerAdminEntity, + SubscriptionUserEntity, + TherapySessionEntity, + SubscriptionEntity, + EventLogEntity, + ]), + ], + providers: [ + ResourceService, + UserService, + PartnerAccessService, + PartnerService, + ServiceUserProfilesService, + SubscriptionService, + SubscriptionUserService, + TherapySessionService, + CrispService, + EventLoggerService, + ZapierWebhookClient, + SlackMessageClient, + ], + controllers: [ResourceController], +}) +export class ResourceModule {} diff --git a/src/resource/resource.service.ts b/src/resource/resource.service.ts new file mode 100644 index 00000000..e881da38 --- /dev/null +++ b/src/resource/resource.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Logger } from 'src/logger/logger'; +import { Repository } from 'typeorm'; +import { ResourceEntity } from '../entities/resource.entity'; +import { CreateResourceDto } from './dtos/create-resource.dto'; + +@Injectable() +export class ResourceService { + private readonly logger = new Logger('ResourceService'); + + constructor( + @InjectRepository(ResourceEntity) + private resourceRepository: Repository, + ) {} + + async findOne(id: string): Promise { + return this.resourceRepository.findOne({ where: { id } }); + } + async create(createResourceDto: CreateResourceDto): Promise { + return this.resourceRepository.save(createResourceDto); + } + async getResourceByStoryblokId(storyblokId: number): Promise { + return await this.resourceRepository.findOneBy({ storyblokId: storyblokId }); + } +} diff --git a/src/session-feedback/session-feedback.service.ts b/src/session-feedback/session-feedback.service.ts index 25c980cd..cf0a9ddd 100644 --- a/src/session-feedback/session-feedback.service.ts +++ b/src/session-feedback/session-feedback.service.ts @@ -1,14 +1,8 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { SlackMessageClient } from 'src/api/slack/slack-api'; -import { PartnerAccessEntity } from 'src/entities/partner-access.entity'; -import { PartnerEntity } from 'src/entities/partner.entity'; import { SessionFeedbackEntity } from 'src/entities/session-feedback.entity'; import { SessionEntity } from 'src/entities/session.entity'; -import { SubscriptionUserEntity } from 'src/entities/subscription-user.entity'; -import { SubscriptionEntity } from 'src/entities/subscription.entity'; -import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; -import { UserEntity } from 'src/entities/user.entity'; import { Repository } from 'typeorm'; import { SessionService } from '../session/session.service'; import { SessionFeedbackDto } from './dtos/session-feedback.dto'; @@ -16,20 +10,6 @@ import { SessionFeedbackDto } from './dtos/session-feedback.dto'; @Injectable() export class SessionFeedbackService { constructor( - @InjectRepository(PartnerAccessEntity) - private partnerAccessRepository: Repository, - @InjectRepository(PartnerEntity) - private partnerRepository: Repository, - @InjectRepository(UserEntity) - private userRepository: Repository, - @InjectRepository(SessionEntity) - private sessionRepository: Repository, - @InjectRepository(SubscriptionUserEntity) - private subscriptionUserRepository: Repository, - @InjectRepository(SubscriptionEntity) - private subscriptionRepository: Repository, - @InjectRepository(TherapySessionEntity) - private therapySessionRepository: Repository, @InjectRepository(SessionFeedbackEntity) private sessionFeedbackRepository: Repository, private readonly sessionService: SessionService, @@ -45,8 +25,7 @@ export class SessionFeedbackService { throw new HttpException('SESSION NOT FOUND', HttpStatus.NOT_FOUND); } - const sessionFeedbackObject = this.sessionFeedbackRepository.create(sessionFeedbackDto); - await this.sessionFeedbackRepository.save(sessionFeedbackObject); + await this.sessionFeedbackRepository.save(sessionFeedbackDto); this.sendSlackSessionFeedback(sessionFeedbackDto, session); return sessionFeedbackDto; diff --git a/src/session-user/dtos/create-session-user.dto.ts b/src/session-user/dtos/create-session-user.dto.ts deleted file mode 100644 index 60676928..00000000 --- a/src/session-user/dtos/create-session-user.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsDefined, IsNotEmpty, IsString } from 'class-validator'; - -export class CreateSessionUserDto { - @IsString() - @IsNotEmpty() - @IsDefined() - @ApiProperty({ type: String }) - sessionId: string; -} diff --git a/src/session-user/session-user.interface.ts b/src/session-user/session-user.interface.ts deleted file mode 100644 index 25a21dcd..00000000 --- a/src/session-user/session-user.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ICourseUser } from 'src/course-user/course-user.interface'; -import { ISession } from 'src/session/session.interface'; - -export interface ISessionUser { - completed?: boolean; - session?: ISession; - courseUser?: ICourseUser; -} diff --git a/src/session/session.interface.ts b/src/session/session.interface.ts deleted file mode 100644 index f04d5b39..00000000 --- a/src/session/session.interface.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { STORYBLOK_STORY_STATUS_ENUM } from 'src/utils/constants'; -import { ICourse } from '../course/course.interface'; - -export interface ISession { - id?: string; - createdAt?: Date | string; - updatedAt?: Date | string; - name?: string; - slug?: string; - status?: STORYBLOK_STORY_STATUS_ENUM; - storyBlokId?: string; - course?: ICourse; -} diff --git a/src/typeorm.config.ts b/src/typeorm.config.ts index 2a1dab8f..60e3cd2d 100644 --- a/src/typeorm.config.ts +++ b/src/typeorm.config.ts @@ -11,6 +11,9 @@ import { PartnerAccessEntity } from './entities/partner-access.entity'; import { PartnerAdminEntity } from './entities/partner-admin.entity'; import { PartnerFeatureEntity } from './entities/partner-feature.entity'; import { PartnerEntity } from './entities/partner.entity'; +import { ResourceFeedbackEntity } from './entities/resource-feedback.entity'; +import { ResourceUserEntity } from './entities/resource-user.entity'; +import { ResourceEntity } from './entities/resource.entity'; import { SessionFeedbackEntity } from './entities/session-feedback.entity'; import { SessionUserEntity } from './entities/session-user.entity'; import { SessionEntity } from './entities/session.entity'; @@ -49,6 +52,8 @@ import { BloomBackend1718300621138 } from './migrations/1718300621138-bloom-back import { BloomBackend1718728423454 } from './migrations/1718728423454-bloom-backend'; import { BloomBackend1719668310816 } from './migrations/1719668310816-bloom-backend'; import { BloomBackend1722295564731 } from './migrations/1722295564731-bloom-backend'; +import { BloomBackend1733160378757 } from './migrations/1733160378757-bloom-backend'; +import { BloomBackend1733850090811 } from './migrations/1733850090811-bloom-backend'; config(); const configService = new ConfigService(); @@ -87,6 +92,9 @@ export const dataSourceOptions = { SubscriptionUserEntity, TherapySessionEntity, SessionFeedbackEntity, + ResourceUserEntity, + ResourceEntity, + ResourceFeedbackEntity, ], migrations: [ bloomBackend1637704119795, @@ -120,6 +128,8 @@ export const dataSourceOptions = { BloomBackend1718728423454, BloomBackend1719668310816, BloomBackend1722295564731, + BloomBackend1733160378757, + BloomBackend1733850090811, ], subscribers: [], ssl: isProduction || isStaging, diff --git a/src/user/dtos/get-user.dto.ts b/src/user/dtos/get-user.dto.ts index 24bee062..f534942c 100644 --- a/src/user/dtos/get-user.dto.ts +++ b/src/user/dtos/get-user.dto.ts @@ -1,5 +1,6 @@ import { ICoursesWithSessions } from 'src/course/course.interface'; -import { ITherapySession } from 'src/webhooks/therapy-session.interface'; +import { IResource } from 'src/resource/resource.interface'; +import { ITherapySession } from 'src/webhooks/webhooks.interface'; import { IPartnerAccessWithPartner } from '../../partner-access/partner-access.interface'; import { IPartnerAdminWithPartner } from '../../partner-admin/partner-admin.interface'; import { ISubscriptionUser } from '../../subscription-user/subscription-user.interface'; @@ -10,6 +11,7 @@ export class GetUserDto { partnerAccesses?: IPartnerAccessWithPartner[]; partnerAdmin?: IPartnerAdminWithPartner; courses?: ICoursesWithSessions[]; + resources?: IResource[]; therapySessions?: ITherapySession[]; subscriptions?: ISubscriptionUser[]; } diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 0968a18a..5ad1d63e 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -7,6 +7,7 @@ import { EventLogEntity } from 'src/entities/event-log.entity'; import { PartnerAccessEntity } from 'src/entities/partner-access.entity'; import { PartnerAdminEntity } from 'src/entities/partner-admin.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; +import { ResourceUserEntity } from 'src/entities/resource-user.entity'; import { SubscriptionUserEntity } from 'src/entities/subscription-user.entity'; import { SubscriptionEntity } from 'src/entities/subscription.entity'; import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; @@ -33,6 +34,7 @@ import { UserService } from './user.service'; PartnerAdminEntity, TherapySessionEntity, EventLogEntity, + ResourceUserEntity, ]), FirebaseModule, ], diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 41e184ff..16758b6a 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -1,6 +1,9 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { deleteMailchimpProfile } from 'src/api/mailchimp/mailchimp-api'; +import { + deleteCypressMailchimpProfiles, + deleteMailchimpProfile, +} from 'src/api/mailchimp/mailchimp-api'; import { CrispService } from 'src/crisp/crisp.service'; import { PartnerAccessEntity } from 'src/entities/partner-access.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; @@ -139,6 +142,8 @@ export class UserService { .leftJoinAndSelect('courseUser.course', 'course') .leftJoinAndSelect('courseUser.sessionUser', 'sessionUser') .leftJoinAndSelect('sessionUser.session', 'session') + .leftJoinAndSelect('user.resourceUser', 'resourceUser') + .leftJoinAndSelect('resourceUser.resource', 'resource') .leftJoinAndSelect('user.subscriptionUser', 'subscriptionUser') .leftJoinAndSelect('subscriptionUser.subscription', 'subscription') .where('user.id = :id', { id }) @@ -366,6 +371,9 @@ export class UserService { // Delete all remaining cypress firebase users (e.g. from failed user creations) await this.authService.deleteCypressFirebaseUsers(); + // Delete all remaining crisp accounts + await deleteCypressMailchimpProfiles(); + // Delete all remaining crisp accounts await this.crispService.deleteCypressCrispProfiles(); } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index d77e8ef5..d6ea85bd 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -19,6 +19,29 @@ export enum LANGUAGE_DEFAULT { ES = 'es', } +export enum RESOURCE_CATEGORIES { + SHORT_VIDEO = 'short_video', + CONVERSATION = 'conversation', +} + +export enum STORYBLOK_PAGE_COMPONENTS { + COURSE = 'Course', + SESSION = 'Session', + SESSION_IBA = 'session_iba', + RESOURCE_SHORT_VIDEO = 'resource_short_video', + RESOURCE_CONVERSATION = 'resource_conversation', + MEET_THE_TEAM = 'meet_the_team', + WELCOME = 'Welcome', + PAGE = 'page', +} + +export enum STORYBLOK_STORY_STATUS_ENUM { + PUBLISHED = 'published', + UNPUBLISHED = 'unpublished', + DELETED = 'deleted', + MOVED = 'moved', +} + export enum EMAIL_REMINDERS_FREQUENCY { TWO_WEEKS = 'TWO_WEEKS', ONE_MONTH = 'ONE_MONTH', @@ -52,12 +75,6 @@ export enum FEEDBACK_TAGS_ENUM { NOT_USEFUL = 'not useful', } -export enum STORYBLOK_STORY_STATUS_ENUM { - PUBLISHED = 'published', - UNPUBLISHED = 'unpublished', - DELETED = 'deleted', -} - export enum PartnerAccessCodeStatusEnum { VALID = 'VALID', INVALID_CODE = 'INVALID_CODE', diff --git a/src/utils/serialize.ts b/src/utils/serialize.ts index a97e4dc8..0d8f31b6 100644 --- a/src/utils/serialize.ts +++ b/src/utils/serialize.ts @@ -1,5 +1,6 @@ import { PartnerAdminEntity } from 'src/entities/partner-admin.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; +import { ResourceUserEntity } from 'src/entities/resource-user.entity'; import { IPartnerFeature } from 'src/partner-feature/partner-feature.interface'; import { IPartner } from 'src/partner/partner.interface'; import { GetSubscriptionUserDto } from 'src/subscription-user/dto/get-subscription-user.dto'; @@ -43,6 +44,22 @@ export const formatCourseUserObject = (courseUser: CourseUserEntity) => { }; }; +export const formatResourceUserObject = (resourceUsers: ResourceUserEntity[]) => { + return resourceUsers.map((resourceUser) => { + return { + id: resourceUser.resource.id, + createdAt: resourceUser.createdAt, + updatedAt: resourceUser.updatedAt, + name: resourceUser.resource.name, + slug: resourceUser.resource.slug, + status: resourceUser.resource.status, + storyblokId: resourceUser.resource.storyblokId, + storyblokUuid: resourceUser.resource.storyblokUuid, + completed: !!resourceUser.completedAt, // convert to boolean from data populated + }; + }); +}; + export const formatPartnerAdminObjects = (partnerAdminObject: PartnerAdminEntity) => { return { id: partnerAdminObject.id, @@ -113,6 +130,7 @@ export const formatUserObject = (userObject: UserEntity): GetUserDto => { ? formatPartnerAdminObjects(userObject.partnerAdmin) : null, courses: userObject.courseUser ? formatCourseUserObjects(userObject.courseUser) : [], + resources: userObject.resourceUser ? formatResourceUserObject(userObject.resourceUser) : [], subscriptions: userObject.subscriptionUser && userObject.subscriptionUser.length > 0 ? formatSubscriptionObjects(userObject.subscriptionUser) diff --git a/src/webhooks/dto/story.dto.ts b/src/webhooks/dto/story.dto.ts index bbcfe267..5bbf9c85 100644 --- a/src/webhooks/dto/story.dto.ts +++ b/src/webhooks/dto/story.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsDefined, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; import { STORYBLOK_STORY_STATUS_ENUM } from '../../utils/constants'; -export class StoryDto { +export class StoryWebhookDto { @IsOptional() @IsString() text: string; diff --git a/src/webhooks/webhooks.controller.spec.ts b/src/webhooks/webhooks.controller.spec.ts index 45d25d5f..573fb314 100644 --- a/src/webhooks/webhooks.controller.spec.ts +++ b/src/webhooks/webhooks.controller.spec.ts @@ -6,7 +6,7 @@ import { storyblokWebhookSecret } from 'src/utils/constants'; import { mockSessionEntity, mockSimplybookBodyBase, - mockStoryDto, + mockStoryWebhookDto, mockTherapySessionEntity, } from 'test/utils/mockData'; import { mockWebhooksServiceMethods } from 'test/utils/mockedServices'; @@ -62,16 +62,16 @@ describe('AppController', () => { webhooksController.updatePartnerAccessTherapy(mockSimplybookBodyBase), ).rejects.toThrow('Therapy session not found'); }); - describe('updateStory', () => { - it('updateStory should pass if service returns true', async () => { - jest.spyOn(mockWebhooksService, 'updateStory').mockImplementationOnce(async () => { + describe('handleStoryUpdated', () => { + it('handleStoryUpdated should pass if service returns true', async () => { + jest.spyOn(mockWebhooksService, 'handleStoryUpdated').mockImplementationOnce(async () => { return mockSessionEntity; }); await expect( - webhooksController.updateStory( - createRequestObject(mockStoryDto), - mockStoryDto, - generateMockHeaders(mockStoryDto), + webhooksController.handleStoryUpdated( + createRequestObject(mockStoryWebhookDto), + mockStoryWebhookDto, + generateMockHeaders(mockStoryWebhookDto), ), ).resolves.toBe(mockSessionEntity); }); diff --git a/src/webhooks/webhooks.controller.ts b/src/webhooks/webhooks.controller.ts index 5c3162d2..3c560e74 100644 --- a/src/webhooks/webhooks.controller.ts +++ b/src/webhooks/webhooks.controller.ts @@ -16,7 +16,7 @@ import { storyblokWebhookSecret } from 'src/utils/constants'; import { ControllerDecorator } from 'src/utils/controller.decorator'; import { ZapierSimplybookBodyDto } from '../partner-access/dtos/zapier-body.dto'; import { ZapierAuthGuard } from '../partner-access/zapier-auth.guard'; -import { StoryDto } from './dto/story.dto'; +import { StoryWebhookDto } from './dto/story.dto'; import { WebhooksService } from './webhooks.service'; @ApiTags('Webhooks') @@ -36,8 +36,8 @@ export class WebhooksController { } @Post('storyblok') - @ApiBody({ type: StoryDto }) - async updateStory(@Request() req, @Body() data: StoryDto, @Headers() headers) { + @ApiBody({ type: StoryWebhookDto }) + async handleStoryUpdated(@Request() req, @Body() data: StoryWebhookDto, @Headers() headers) { const signature: string | undefined = headers['webhook-signature']; // Verify storyblok signature uses storyblok webhook secret - see https://www.storyblok.com/docs/guide/in-depth/webhooks#securing-a-webhook if (!signature) { @@ -53,6 +53,6 @@ export class WebhooksController { this.logger.error(error); throw new HttpException(error, HttpStatus.UNAUTHORIZED); } - return this.webhooksService.updateStory(data); + return this.webhooksService.handleStoryUpdated(data); } } diff --git a/src/webhooks/therapy-session.interface.ts b/src/webhooks/webhooks.interface.ts similarity index 100% rename from src/webhooks/therapy-session.interface.ts rename to src/webhooks/webhooks.interface.ts diff --git a/src/webhooks/webhooks.module.ts b/src/webhooks/webhooks.module.ts index 1e939312..e47612db 100644 --- a/src/webhooks/webhooks.module.ts +++ b/src/webhooks/webhooks.module.ts @@ -9,11 +9,13 @@ import { EventLogEntity } from 'src/entities/event-log.entity'; import { PartnerAccessEntity } from 'src/entities/partner-access.entity'; import { PartnerAdminEntity } from 'src/entities/partner-admin.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; +import { ResourceEntity } from 'src/entities/resource.entity'; import { SessionEntity } from 'src/entities/session.entity'; import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; import { UserEntity } from 'src/entities/user.entity'; import { EventLoggerService } from 'src/event-logger/event-logger.service'; import { PartnerService } from 'src/partner/partner.service'; +import { ResourceService } from 'src/resource/resource.service'; import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { WebhooksController } from './webhooks.controller'; import { WebhooksService } from './webhooks.service'; @@ -30,6 +32,7 @@ import { WebhooksService } from './webhooks.service'; TherapySessionEntity, PartnerAdminEntity, EventLogEntity, + ResourceEntity, ]), ], providers: [ @@ -40,6 +43,7 @@ import { WebhooksService } from './webhooks.service'; SlackMessageClient, CrispService, EventLoggerService, + ResourceService, ], controllers: [WebhooksController], }) diff --git a/src/webhooks/webhooks.service.spec.ts b/src/webhooks/webhooks.service.spec.ts index 5513ab02..4bb90413 100644 --- a/src/webhooks/webhooks.service.spec.ts +++ b/src/webhooks/webhooks.service.spec.ts @@ -10,18 +10,26 @@ import { EventLogEntity } from 'src/entities/event-log.entity'; import { PartnerAccessEntity } from 'src/entities/partner-access.entity'; import { PartnerAdminEntity } from 'src/entities/partner-admin.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; +import { ResourceEntity } from 'src/entities/resource.entity'; import { SessionEntity } from 'src/entities/session.entity'; import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; import { UserEntity } from 'src/entities/user.entity'; import { EventLoggerService } from 'src/event-logger/event-logger.service'; import { PartnerService } from 'src/partner/partner.service'; import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; -import { SIMPLYBOOK_ACTION_ENUM, STORYBLOK_STORY_STATUS_ENUM } from 'src/utils/constants'; +import { + RESOURCE_CATEGORIES, + SIMPLYBOOK_ACTION_ENUM, + STORYBLOK_STORY_STATUS_ENUM, +} from 'src/utils/constants'; import StoryblokClient from 'storyblok-js-client'; import { mockCourse, mockCourseStoryblokResult, mockPartnerAccessEntity, + mockResource, + mockResource2, + mockResourceStoryblokResult, mockSession, mockSessionStoryblokResult, mockSimplybookBodyBase, @@ -36,6 +44,7 @@ import { mockPartnerAccessRepositoryMethods, mockPartnerAdminRepositoryMethods, mockPartnerRepositoryMethods, + mockResourceRepositoryMethods, mockSessionRepositoryMethods, mockSlackMessageClientMethods, mockTherapySessionRepositoryMethods, @@ -78,6 +87,9 @@ describe('WebhooksService', () => { const mockedCoursePartnerService = createMock( mockCoursePartnerServiceMethods, ); + const mockedResourceRepository = createMock>( + mockResourceRepositoryMethods, + ); const mockedUserRepository = createMock>(mockUserRepositoryMethods); const mockedTherapySessionRepository = createMock>( mockTherapySessionRepositoryMethods, @@ -115,6 +127,10 @@ describe('WebhooksService', () => { provide: getRepositoryToken(UserEntity), useValue: mockedUserRepository, }, + { + provide: getRepositoryToken(ResourceEntity), + useValue: mockedResourceRepository, + }, { provide: getRepositoryToken(CourseEntity), useValue: mockedCourseRepository, @@ -168,7 +184,7 @@ describe('WebhooksService', () => { expect(service).toBeDefined(); }); - describe('updateStory', () => { + describe('handleStoryUpdated', () => { it('when story does not exist, it returns with a 404', async () => { // unfortunately it is mega hard to mock classes that are also node modules and this was // the only solution i got working @@ -189,7 +205,7 @@ describe('WebhooksService', () => { text: '', }; - return expect(service.updateStory(body)).rejects.toThrow('STORYBLOK STORY NOT FOUND'); + return expect(service.handleStoryUpdated(body)).rejects.toThrow('STORYBLOK STORY NOT FOUND'); }); it('when action is deleted, story should be set as deleted in database', async () => { @@ -199,7 +215,7 @@ describe('WebhooksService', () => { text: '', }; - const deletedStory = (await service.updateStory(body)) as SessionEntity; + const deletedStory = (await service.handleStoryUpdated(body)) as SessionEntity; expect(deletedStory.status).toBe(STORYBLOK_STORY_STATUS_ENUM.DELETED); }); @@ -211,7 +227,7 @@ describe('WebhooksService', () => { text: '', }; - const unpublished = (await service.updateStory(body)) as SessionEntity; + const unpublished = (await service.handleStoryUpdated(body)) as SessionEntity; expect(unpublished.status).toBe(STORYBLOK_STORY_STATUS_ENUM.UNPUBLISHED); }); @@ -245,10 +261,14 @@ describe('WebhooksService', () => { }); const sessionSaveRepoSpy = jest.spyOn(mockedSessionRepository, 'save'); - const sessionFindOneRepoSpy = jest.spyOn(mockedSessionRepository, 'findOneBy'); + const sessionFindOneRepoSpy = jest + .spyOn(mockedSessionRepository, 'findOneBy') + .mockImplementationOnce(async () => { + return { ...mockSession, course: course2 }; + }); const courseFindOneSpy = jest - .spyOn(mockedCourseRepository, 'findOneBy') + .spyOn(mockedCourseRepository, 'findOneByOrFail') .mockImplementationOnce(async () => { return course2; }); @@ -259,7 +279,7 @@ describe('WebhooksService', () => { text: '', }; - const session = (await service.updateStory(body)) as SessionEntity; + const session = (await service.handleStoryUpdated(body)) as SessionEntity; expect(courseFindOneSpy).toHaveBeenCalledWith({ storyblokUuid: 'anotherCourseUuId', @@ -289,31 +309,35 @@ describe('WebhooksService', () => { it('when a session is new, the session should be created', async () => { const sessionSaveRepoSpy = jest.spyOn(mockedSessionRepository, 'save'); - const sessionCreateRepoSpy = jest.spyOn(mockedSessionRepository, 'create'); const sessionFindOneRepoSpy = jest .spyOn(mockedSessionRepository, 'findOneBy') .mockImplementationOnce(async () => undefined); - const courseFindOneSpy = jest.spyOn(mockedCourseRepository, 'findOneBy'); + const courseFindOneSpy = jest.spyOn(mockedCourseRepository, 'findOneByOrFail'); const body = { action: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED, story_id: mockSession.storyblokId, + full_slug: mockSession.slug, text: '', }; - const session = (await service.updateStory(body)) as SessionEntity; + const expectedResponse = { + storyblokId: mockSession.storyblokId, + storyblokUuid: mockSession.storyblokUuid, + status: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED, + slug: mockSession.slug, + name: mockSession.name, + courseId: mockSession.courseId, + }; + + const session = (await service.handleStoryUpdated(body)) as SessionEntity; - expect(session).toEqual(mockSession); + expect(session).toEqual(expectedResponse); expect(courseFindOneSpy).toHaveBeenCalledWith({ storyblokUuid: 'courseUuid1', }); - expect(sessionSaveRepoSpy).toHaveBeenCalledWith({ - ...mockSession, - }); - expect(sessionSaveRepoSpy).toHaveBeenCalledWith({ - ...mockSession, - }); + expect(sessionSaveRepoSpy).toHaveBeenCalledWith(expectedResponse); expect(sessionFindOneRepoSpy).toHaveBeenCalledWith({ storyblokId: mockSession.storyblokId, }); @@ -321,18 +345,16 @@ describe('WebhooksService', () => { courseFindOneSpy.mockClear(); sessionSaveRepoSpy.mockClear(); sessionFindOneRepoSpy.mockClear(); - sessionCreateRepoSpy.mockClear(); }); it('when a session with session_iba type is new, the session should be created', async () => { const sessionSaveRepoSpy = jest.spyOn(mockedSessionRepository, 'save'); - const sessionCreateRepoSpy = jest.spyOn(mockedSessionRepository, 'create'); const sessionFindOneRepoSpy = jest .spyOn(mockedSessionRepository, 'findOneBy') .mockImplementationOnce(async () => undefined); - const courseFindOneSpy = jest.spyOn(mockedCourseRepository, 'findOneBy'); + const courseFindOneSpy = jest.spyOn(mockedCourseRepository, 'findOneByOrFail'); // eslint-disable-next-line // @ts-ignore @@ -358,27 +380,33 @@ describe('WebhooksService', () => { const body = { action: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED, story_id: mockSession.storyblokId, + full_slug: mockSession.slug, text: '', }; - const session = (await service.updateStory(body)) as SessionEntity; + const expectedResponse = { + storyblokId: mockSession.storyblokId, + storyblokUuid: mockSession.storyblokUuid, + status: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED, + slug: mockSession.slug, + name: mockSession.name, + courseId: mockSession.courseId, + }; - expect(session).toEqual(mockSession); - expect(sessionSaveRepoSpy).toHaveBeenCalledWith({ - ...mockSession, - }); + const session = (await service.handleStoryUpdated(body)) as SessionEntity; + + expect(session).toEqual(expectedResponse); + expect(sessionSaveRepoSpy).toHaveBeenCalledWith(expectedResponse); courseFindOneSpy.mockClear(); sessionSaveRepoSpy.mockClear(); sessionFindOneRepoSpy.mockClear(); - sessionCreateRepoSpy.mockClear(); }); it('when a course is new, the course should be created', async () => { const courseFindOneRepoSpy = jest .spyOn(mockedCourseRepository, 'findOneBy') .mockImplementationOnce(async () => undefined); - const courseCreateRepoSpy = jest.spyOn(mockedCourseRepository, 'create'); const courseSaveRepoSpy = jest.spyOn(mockedCourseRepository, 'save'); // eslint-disable-next-line @@ -391,33 +419,163 @@ describe('WebhooksService', () => { const body = { action: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED, - story_id: 5678, + story_id: mockCourseStoryblokResult.data.story.id, + full_slug: mockCourseStoryblokResult.data.story.full_slug, text: '', }; - const course = (await service.updateStory(body)) as CourseEntity; + const expectedResponse = { + storyblokId: mockCourseStoryblokResult.data.story.id, + storyblokUuid: mockCourseStoryblokResult.data.story.uuid, + status: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED, + slug: mockCourseStoryblokResult.data.story.full_slug, + name: mockCourseStoryblokResult.data.story.name, + }; + + const course = (await service.handleStoryUpdated(body)) as CourseEntity; - expect(course).toEqual(mockCourse); + expect(course).toEqual(expectedResponse); expect(courseFindOneRepoSpy).toHaveBeenCalledWith({ storyblokId: mockCourseStoryblokResult.data.story.id, }); - expect(courseCreateRepoSpy).toHaveBeenCalledWith({ - storyblokId: mockCourseStoryblokResult.data.story.id, - name: mockCourseStoryblokResult.data.story.name, - status: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED, - slug: mockCourseStoryblokResult.data.story.full_slug, - storyblokUuid: mockCourseStoryblokResult.data.story.uuid, - }); + expect(courseSaveRepoSpy).toHaveBeenCalledWith(expectedResponse); - expect(courseSaveRepoSpy).toHaveBeenCalledWith(mockCourse); expect(mockedServiceUserProfilesService.createMailchimpCourseMergeField).toHaveBeenCalledWith( - mockCourse.name, + mockCourseStoryblokResult.data.story.name, ); courseFindOneRepoSpy.mockClear(); - courseCreateRepoSpy.mockClear(); courseSaveRepoSpy.mockClear(); }); + + it('should handle unpublished action for a resource', async () => { + const body = { + action: STORYBLOK_STORY_STATUS_ENUM.UNPUBLISHED, + story_id: mockResource.storyblokId, + text: '', + }; + + const unpublishedResource = (await service.handleStoryUpdated(body)) as ResourceEntity; + + expect(unpublishedResource.status).toBe(STORYBLOK_STORY_STATUS_ENUM.UNPUBLISHED); + }); + + it('should handle published action for a resource', async () => { + const body = { + action: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED, + story_id: mockResource.storyblokId, + text: '', + }; + + const publishedResource = (await service.handleStoryUpdated(body)) as ResourceEntity; + + expect(publishedResource.status).toBe(STORYBLOK_STORY_STATUS_ENUM.PUBLISHED); + }); + + it('should handle deleted action for a resource', async () => { + const body = { + action: STORYBLOK_STORY_STATUS_ENUM.DELETED, + story_id: mockResource.storyblokId, + text: '', + }; + + const deletedResource = (await service.handleStoryUpdated(body)) as ResourceEntity; + + expect(deletedResource.status).toBe(STORYBLOK_STORY_STATUS_ENUM.DELETED); + }); + + it('should handle a new resource', async () => { + const resourceSaveRepoSpy = jest.spyOn(mockedResourceRepository, 'save'); + const resourceFindOneRepoSpy = jest + .spyOn(mockedResourceRepository, 'findOneBy') + .mockImplementationOnce(async () => undefined); + + // Mock StoryblokClient to return a resource story + // eslint-disable-next-line + // @ts-ignore + StoryblokClient.mockImplementationOnce(() => { + return { + get: async () => mockResourceStoryblokResult, + }; + }); + + const body = { + action: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED, + story_id: mockResourceStoryblokResult.data.story.id, + full_slug: mockResourceStoryblokResult.data.story.full_slug, + text: '', + }; + + const expectedResponse = { + storyblokId: mockResourceStoryblokResult.data.story.id, + storyblokUuid: mockResourceStoryblokResult.data.story.uuid, + status: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED, + slug: mockResourceStoryblokResult.data.story.full_slug, + name: mockResourceStoryblokResult.data.story.name, + category: RESOURCE_CATEGORIES.SHORT_VIDEO, + }; + + const resource = (await service.handleStoryUpdated(body)) as ResourceEntity; + + expect(resource).toEqual(expectedResponse); + expect(resourceSaveRepoSpy).toHaveBeenCalledWith(expectedResponse); + expect(resourceFindOneRepoSpy).toHaveBeenCalledWith({ + storyblokUuid: mockResourceStoryblokResult.data.story.uuid, + }); + + resourceSaveRepoSpy.mockClear(); + resourceFindOneRepoSpy.mockClear(); + }); + + it('should handle updating an existing resource', async () => { + const resourceSaveRepoSpy = jest.spyOn(mockedResourceRepository, 'save'); + const resourceFindOneRepoSpy = jest + .spyOn(mockedResourceRepository, 'findOneBy') + .mockImplementationOnce(async () => mockResource2); + + const updatedMockResourceStoryblokResult = { ...mockResourceStoryblokResult }; + const newName = 'New resource name'; + const newSlug = 'resources/shorts/new-resource-name'; + updatedMockResourceStoryblokResult.data.story.name = newName; + updatedMockResourceStoryblokResult.data.story.full_slug = newSlug; + + // Mock StoryblokClient to return a resource story + // eslint-disable-next-line + // @ts-ignore + StoryblokClient.mockImplementationOnce(() => { + return { + get: async () => updatedMockResourceStoryblokResult, + }; + }); + + const body = { + action: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED, + story_id: mockResourceStoryblokResult.data.story.id, + full_slug: mockResourceStoryblokResult.data.story.full_slug, + text: '', + }; + + const expectedResponse = { + ...mockResource2, + storyblokId: mockResourceStoryblokResult.data.story.id, + storyblokUuid: mockResourceStoryblokResult.data.story.uuid, + status: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED, + slug: newSlug, + name: newName, + category: RESOURCE_CATEGORIES.SHORT_VIDEO, + }; + + const updatedResource = (await service.handleStoryUpdated(body)) as ResourceEntity; + + expect(updatedResource).toEqual(expectedResponse); + expect(resourceSaveRepoSpy).toHaveBeenCalled(); + expect(resourceFindOneRepoSpy).toHaveBeenCalledWith({ + storyblokUuid: mockResourceStoryblokResult.data.story.uuid, + }); + + resourceSaveRepoSpy.mockClear(); + resourceFindOneRepoSpy.mockClear(); + }); }); describe('updatePartnerAccessTherapy', () => { diff --git a/src/webhooks/webhooks.service.ts b/src/webhooks/webhooks.service.ts index 8f0421b5..2463bc59 100644 --- a/src/webhooks/webhooks.service.ts +++ b/src/webhooks/webhooks.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { SlackMessageClient } from 'src/api/slack/slack-api'; import { CourseEntity } from 'src/entities/course.entity'; import { PartnerAccessEntity } from 'src/entities/partner-access.entity'; +import { ResourceEntity } from 'src/entities/resource.entity'; import { SessionEntity } from 'src/entities/session.entity'; import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; import { UserEntity } from 'src/entities/user.entity'; @@ -10,16 +11,18 @@ import { ZapierSimplybookBodyDto } from 'src/partner-access/dtos/zapier-body.dto import { ServiceUserProfilesService } from 'src/service-user-profiles/service-user-profiles.service'; import { IUser } from 'src/user/user.interface'; import { serializeZapierSimplyBookDtoToTherapySessionEntity } from 'src/utils/serialize'; -import StoryblokClient from 'storyblok-js-client'; +import StoryblokClient, { ISbStoryData } from 'storyblok-js-client'; import { ILike, MoreThan, Repository } from 'typeorm'; import { CoursePartnerService } from '../course-partner/course-partner.service'; import { isProduction, + RESOURCE_CATEGORIES, SIMPLYBOOK_ACTION_ENUM, + STORYBLOK_PAGE_COMPONENTS, STORYBLOK_STORY_STATUS_ENUM, storyblokToken, } from '../utils/constants'; -import { StoryDto } from './dto/story.dto'; +import { StoryWebhookDto } from './dto/story.dto'; @Injectable() export class WebhooksService { @@ -31,6 +34,7 @@ export class WebhooksService { @InjectRepository(UserEntity) private userRepository: Repository, @InjectRepository(CourseEntity) private courseRepository: Repository, @InjectRepository(SessionEntity) private sessionRepository: Repository, + @InjectRepository(ResourceEntity) private resourceRepository: Repository, private readonly coursePartnerService: CoursePartnerService, @InjectRepository(TherapySessionEntity) private therapySessionRepository: Repository, @@ -259,135 +263,170 @@ export class WebhooksService { } } - private async createNewStory(story_id: number, action: STORYBLOK_STORY_STATUS_ENUM) { - let story; + private async updateOrCreateStoryData( + storyData: ISbStoryData, + status: STORYBLOK_STORY_STATUS_ENUM, + ) { + const storyPageComponent = storyData.content.component as STORYBLOK_PAGE_COMPONENTS; - const Storyblok = new StoryblokClient({ - accessToken: storyblokToken, - cache: { - clear: 'auto', - type: 'memory', - }, - }); + const updatedStoryData = { + name: storyData.name, + slug: storyData.full_slug, + status: status, + }; // fields to update on existing and new stories - try { - const response = await Storyblok.get(`cdn/stories/${story_id}`); - if (response?.data?.story) { - story = response.data.story; - } - } catch (err) { - const error = `Storyblok webhook failed - error getting story from storyblok - ${err}`; - this.logger.error(error); - throw new HttpException(error, HttpStatus.NOT_FOUND); - } + const newStoryData = { + storyblokId: storyData.id, + storyblokUuid: storyData.uuid, + ...updatedStoryData, + }; // includes storyblok id and uuid for new stories only - const storyData = { - name: story.name, - slug: story.full_slug, - status: action, - storyblokId: story_id, - storyblokUuid: story.uuid, - }; try { - if (story.content?.component === 'Course') { - const courseName = story.content?.name; + if ( + storyPageComponent === STORYBLOK_PAGE_COMPONENTS.RESOURCE_SHORT_VIDEO || + storyPageComponent === STORYBLOK_PAGE_COMPONENTS.RESOURCE_CONVERSATION + ) { + const resourceCategory = + storyPageComponent === STORYBLOK_PAGE_COMPONENTS.RESOURCE_SHORT_VIDEO + ? RESOURCE_CATEGORIES.SHORT_VIDEO + : RESOURCE_CATEGORIES.CONVERSATION; - let course = await this.courseRepository.findOneBy({ - storyblokId: story_id, + const existingResource = await this.resourceRepository.findOneBy({ + storyblokUuid: storyData.uuid, }); + const data = existingResource + ? { ...existingResource, ...updatedStoryData } + : { ...newStoryData, category: resourceCategory }; - if (course) { - course.status = action; - course.slug = story.full_slug; - } else { - course = this.courseRepository.create(storyData); - this.serviceUserProfilesService.createMailchimpCourseMergeField(courseName); - } + const resource = await this.resourceRepository.save(data); + this.logger.log(`Storyblok resource ${status} success - ${resource.name}`); + return resource; + } + + if (storyPageComponent === STORYBLOK_PAGE_COMPONENTS.COURSE) { + const existingCourse = await this.courseRepository.findOneBy({ + storyblokId: storyData.id, + }); + const data = existingCourse + ? { ...existingCourse, ...updatedStoryData } + : { ...newStoryData }; - course.name = courseName; - course = await this.courseRepository.save(course); + const course = await this.courseRepository.save(data); + + if (!existingCourse) + // new course, add mailchimp course field + this.serviceUserProfilesService.createMailchimpCourseMergeField(updatedStoryData.name); await this.coursePartnerService.updateCoursePartners( - story.content?.included_for_partners, + storyData.content?.included_for_partners, course.id, ); - - this.logger.log(`Storyblok course ${action} success - ${course.name}`); + this.logger.log(`Storyblok course ${status} success - ${course.name}`); return course; - } else if ( - story.content?.component === 'Session' || - story.content?.component === 'session_iba' + } + + if ( + storyPageComponent === STORYBLOK_PAGE_COMPONENTS.SESSION || + storyPageComponent === STORYBLOK_PAGE_COMPONENTS.SESSION_IBA ) { - const course = await this.courseRepository.findOneBy({ - storyblokUuid: story.content.course, + const course = await this.courseRepository.findOneByOrFail({ + storyblokUuid: storyData.content.course, }); - if (!course.id) { - const error = `Storyblok webhook failed - course not found for session story`; - this.logger.error(error); - throw new HttpException(error, HttpStatus.NOT_FOUND); - } - - let session = await this.sessionRepository.findOneBy({ - storyblokId: story_id, + const existingSession = await this.sessionRepository.findOneBy({ + storyblokId: storyData.id, }); + const data = existingSession + ? { ...existingSession, ...updatedStoryData, courseId: course.id } + : { ...newStoryData, courseId: course.id }; - const newSession = session - ? { - ...session, - status: action, - slug: story.full_slug, - name: story.name, - course: course, - courseId: course.id, - } - : this.sessionRepository.create({ ...storyData, ...{ courseId: course.id } }); - - session = await this.sessionRepository.save(newSession); - this.logger.log(`Storyblok session ${action} success - ${session.name}`); + const session = await this.sessionRepository.save(data); + this.logger.log(`Storyblok session ${status} success - ${session.name}`); return session; } - return undefined; // New story wasn't a course or session story, ignore + return undefined; // Story wasn't a course, session or resource story. No sync or updates completed } catch (err) { - const error = `Storyblok webhook failed - error creating new ${story.content?.component} record - ${err}`; + const error = `Storyblok webhook failed - error updating or creating ${status} ${storyPageComponent} story record ${storyData.id} - ${err}`; this.logger.error(error); throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR); } } - async updateStory(data: StoryDto) { - const action = data.action; - const story_id = data.story_id; - - this.logger.log(`Storyblok story ${action} request - ${story_id}`); - - if (action === STORYBLOK_STORY_STATUS_ENUM.PUBLISHED) { - return this.createNewStory(story_id, action); - } - - // Story was unpublished or deleted so cant be fetched from storyblok to get story type (Course or Session) + async updateInactiveStoryStatus(story_id: number, status: STORYBLOK_STORY_STATUS_ENUM) { + // Story is deleted so cant be fetched from storyblok to get story type // Try to find course with matching story_id first let course = await this.courseRepository.findOneBy({ storyblokId: story_id, }); if (course) { - course.status = action; - course = await this.courseRepository.save(course); - this.logger.log(`Storyblok course ${action} success - ${course.name}`); + course = await this.courseRepository.save({ ...course, status }); + this.logger.log(`Storyblok course ${status} success - ${course.name}`); return course; - } else if (!course) { - // No course found, try finding session instead - let session = await this.sessionRepository.findOneBy({ - storyblokId: story_id, - }); + } + // No course found, try finding session instead + let session = await this.sessionRepository.findOneBy({ + storyblokId: story_id, + }); - if (session) { - session.status = action; - session = await this.sessionRepository.save(session); - this.logger.log(`Storyblok session ${action} success - ${session.name}`); - return session; + if (session) { + session = await this.sessionRepository.save({ ...session, status }); + this.logger.log(`Storyblok session ${status} success - ${session.name}`); + return session; + } + + // No session found, try finding resource instead + let resource = await this.resourceRepository.findOneBy({ + storyblokId: story_id, + }); + + if (resource) { + resource = await this.resourceRepository.save({ ...resource, status }); + this.logger.log(`Storyblok session ${status} success - ${resource.name}`); + return resource; + } + } + + // Handle Storyblok story status change (published, unpublished, moved, deleted) + // Triggered by a webhook, this function handles updating our database records to sync with storyblok story data + async handleStoryUpdated(data: StoryWebhookDto) { + const status = data.action; + const story_id = data.story_id; + + this.logger.log(`Storyblok story ${status} request - ${story_id}`); + + if ( + status === STORYBLOK_STORY_STATUS_ENUM.UNPUBLISHED || + status === STORYBLOK_STORY_STATUS_ENUM.DELETED + ) { + // Story can't be retrieved from storyblok so we just update the status of existing records + return this.updateInactiveStoryStatus(story_id, status); + } + + // Story was either published or moved + // Retrieve the story data from storyblok before handling the update/create + let story: ISbStoryData; + + const Storyblok = new StoryblokClient({ + accessToken: storyblokToken, + cache: { + clear: 'auto', + type: 'memory', + }, + }); + + try { + const response = await Storyblok.get(`cdn/stories/${story_id}`); + if (response?.data?.story) { + story = response.data.story as ISbStoryData; } + } catch (err) { + const error = `Storyblok webhook failed - error getting story from storyblok - ${err}`; + this.logger.error(error); + throw new HttpException(error, HttpStatus.NOT_FOUND); } + + // Create or update the resource/course/session record in our database + return this.updateOrCreateStoryData(story, status); } } diff --git a/test/utils/mockData.ts b/test/utils/mockData.ts index 277c0cce..d6710e1f 100644 --- a/test/utils/mockData.ts +++ b/test/utils/mockData.ts @@ -9,6 +9,7 @@ import { PartnerAccessEntity } from 'src/entities/partner-access.entity'; import { PartnerAdminEntity } from 'src/entities/partner-admin.entity'; import { PartnerFeatureEntity } from 'src/entities/partner-feature.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; +import { ResourceEntity } from 'src/entities/resource.entity'; import { SessionUserEntity } from 'src/entities/session-user.entity'; import { SessionEntity } from 'src/entities/session.entity'; import { SubscriptionUserEntity } from 'src/entities/subscription-user.entity'; @@ -20,6 +21,7 @@ import { IFirebaseUser } from 'src/firebase/firebase-user.interface'; import { ZapierSimplybookBodyDto } from 'src/partner-access/dtos/zapier-body.dto'; import { EMAIL_REMINDERS_FREQUENCY, + RESOURCE_CATEGORIES, SIMPLYBOOK_ACTION_ENUM, STORYBLOK_STORY_STATUS_ENUM, } from 'src/utils/constants'; @@ -81,6 +83,28 @@ export const mockCourseStoryblokResult = { headers: undefined, } as ISbResult; +export const mockResourceStoryblokResult = { + data: { + story: { + name: 'Resource name 2', + created_at: '2022-05-05T11:29:10.888Z', + published_at: '2022-05-19T16:32:44.502Z', + id: 98765, + uuid: 'resourceUuid2', + content: { + _uid: '23456', + name: 'Resource name 2', + component: 'resource_short_video', + }, + slug: 'resource-name', + full_slug: 'resources/shorts/resource-name', + }, + }, + perPage: 1, + total: 1, + headers: undefined, +} as ISbResult; + export const mockCourse: CourseEntity = { coursePartner: [], courseUser: [], @@ -107,6 +131,35 @@ export const mockSession: SessionEntity = { updatedAt: new Date(100), courseId: 'courseId1', course: { ...mockCourse }, + sessionFeedback: [], +}; + +export const mockResource: ResourceEntity = { + resourceUser: [], + id: 'resourceId1', + storyblokId: 123456, + storyblokUuid: 'resourceUuid1', + slug: 'resources/shorts/resource-name', + status: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED, + name: 'Resource name', + category: RESOURCE_CATEGORIES.SHORT_VIDEO, + createdAt: new Date(100), + updatedAt: new Date(100), + resourceFeedback: [], +}; + +export const mockResource2: ResourceEntity = { + id: 'resourceId2', + storyblokId: 98765, + storyblokUuid: 'resourceUuid2', + slug: 'resources/shorts/resource-name', + status: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED, + name: 'Resource name 2', + category: RESOURCE_CATEGORIES.SHORT_VIDEO, + createdAt: new Date(100), + updatedAt: new Date(100), + resourceUser: [], + resourceFeedback: [], }; export const mockIFirebaseUser: IFirebaseUser = { @@ -149,6 +202,7 @@ export const mockUserEntity: UserEntity = { signUpLanguage: 'en', subscriptionUser: [], therapySession: [], + resourceUser: [], eventLog: [], }; @@ -392,7 +446,7 @@ export const mockSubscriptionUserEntity = { subscription: mockSubscriptionEntity, } as SubscriptionUserEntity; -export const mockStoryDto = { +export const mockStoryWebhookDto = { text: 'string', action: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED, story_id: 1, diff --git a/test/utils/mockedServices.ts b/test/utils/mockedServices.ts index 79fb4242..9ab6f62d 100644 --- a/test/utils/mockedServices.ts +++ b/test/utils/mockedServices.ts @@ -11,6 +11,7 @@ import { PartnerAccessEntity } from 'src/entities/partner-access.entity'; import { PartnerAdminEntity } from 'src/entities/partner-admin.entity'; import { PartnerFeatureEntity } from 'src/entities/partner-feature.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; +import { ResourceEntity } from 'src/entities/resource.entity'; import { SessionEntity } from 'src/entities/session.entity'; import { SubscriptionUserEntity } from 'src/entities/subscription-user.entity'; import { TherapySessionEntity } from 'src/entities/therapy-session.entity'; @@ -30,6 +31,7 @@ import { mockPartnerAdminEntity, mockPartnerEntity, mockPartnerFeatureEntity, + mockResource, mockSession, mockSubscriptionUserEntity, mockTherapySessionEntity, @@ -92,10 +94,25 @@ export const mockSessionRepositoryMethods: PartialFuncReturn> = { + findOneBy: async () => { + return mockResource; + }, + save: async (entity) => { + return entity as ResourceEntity; + }, + create: () => { + return mockResource; + }, +}; + export const mockCourseRepositoryMethods: PartialFuncReturn> = { findOneBy: async () => { return mockCourse; }, + findOneByOrFail: async () => { + return mockCourse; + }, create: () => { return mockCourse; }, @@ -287,6 +304,13 @@ export const mockEventLoggerRepositoryMethods: PartialFuncReturn { + return { + ...mockEventLog, + ...dto, + id: 'logId', + } as EventLogEntity; + }, findOneBy: async (arg) => { return { ...mockEventLog, ...(arg ? { ...arg } : {}) } as EventLogEntity; }, @@ -296,7 +320,6 @@ export const mockEventLoggerRepositoryMethods: PartialFuncReturn { return [{ ...mockEventLog, ...(arg ? { ...arg } : {}) }] as EventLogEntity[]; }, - save: async (arg) => arg as EventLogEntity, }; export const mockSubscriptionUserRepositoryMethods: PartialFuncReturn< diff --git a/yarn.lock b/yarn.lock index bec21be8..5315a84f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22,10 +22,10 @@ rxjs "7.8.1" source-map "0.7.4" -"@angular-devkit/core@17.3.8": - version "17.3.8" - resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-17.3.8.tgz#8679cacf84cf79764f027811020e235ab32016d2" - integrity sha512-Q8q0voCGudbdCgJ7lXdnyaxKHbNQBARH68zPQV72WT8NWy+Gw/tys870i6L58NWbBaCJEUcIj/kb6KoakSRu+Q== +"@angular-devkit/core@17.3.11": + version "17.3.11" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-17.3.11.tgz#a74b042ec06cf626d5a2f6a3971b156c6759fe09" + integrity sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ== dependencies: ajv "8.12.0" ajv-formats "2.1.1" @@ -57,12 +57,12 @@ ora "5.4.1" rxjs "7.8.1" -"@angular-devkit/schematics@17.3.8": - version "17.3.8" - resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-17.3.8.tgz#f853eb21682aadfb6667e090b5b509fc95ce8442" - integrity sha512-QRVEYpIfgkprNHc916JlPuNbLzOgrm9DZalHasnLUz4P6g7pR21olb8YCyM2OTJjombNhya9ZpckcADU5Qyvlg== +"@angular-devkit/schematics@17.3.11": + version "17.3.11" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-17.3.11.tgz#37095fb08b0ab0343c7c0dde57ca81115178714f" + integrity sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ== dependencies: - "@angular-devkit/core" "17.3.8" + "@angular-devkit/core" "17.3.11" jsonc-parser "3.2.1" magic-string "0.30.8" ora "5.4.1" @@ -388,10 +388,10 @@ dependencies: eslint-visitor-keys "^3.3.0" -"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.11.0": - version "4.11.0" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.11.0.tgz#b0ffd0312b4a3fd2d6f77237e7248a5ad3a680ae" - integrity sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A== +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.1": + version "4.12.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" + integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== "@eslint/config-array@^0.18.0": version "0.18.0" @@ -402,6 +402,11 @@ debug "^4.3.1" minimatch "^3.1.2" +"@eslint/core@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.7.0.tgz#a1bb4b6a4e742a5ff1894b7ee76fbf884ec72bd3" + integrity sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw== + "@eslint/eslintrc@^3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.1.0.tgz#dbd3482bfd91efa663cbe7aa1f506839868207b6" @@ -417,21 +422,23 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@9.9.1": - version "9.9.1" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.9.1.tgz#4a97e85e982099d6c7ee8410aacb55adaa576f06" - integrity sha512-xIDQRsfg5hNBqHz04H1R3scSVwmI+KUbqjsQKHKQ1DAUSaUjYPReZZmS/5PNiKu1fUvzDd6H7DEDKACSEhu+TQ== - -"@eslint/js@^9.11.1": - version "9.11.1" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.11.1.tgz#8bcb37436f9854b3d9a561440daf916acd940986" - integrity sha512-/qu+TWz8WwPWc7/HcIJKi+c+MOm46GdVaSlTTQcaqaL53+GsoA6MxWp5PtTx48qbSP7ylM1Kn7nhvkugfJvRSA== +"@eslint/js@9.14.0", "@eslint/js@^9.11.1": + version "9.14.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.14.0.tgz#2347a871042ebd11a00fd8c2d3d56a265ee6857e" + integrity sha512-pFoEtFWCPyDOl+C6Ift+wC7Ro89otjigCf5vcuWqWgqNSQbRrpjSvdeE6ofLz4dHmyxD5f7gIdGT4+p36L6Twg== "@eslint/object-schema@^2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.4.tgz#9e69f8bb4031e11df79e03db09f9dbbae1740843" integrity sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ== +"@eslint/plugin-kit@^0.2.0": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.2.3.tgz#812980a6a41ecf3a8341719f92a6d1e784a2e0e8" + integrity sha512-2b/g5hRmpbb1o4GnTZax9N9m0FXzz9OV42ZzI4rDDMDuHUqigAiQCEWChBWCY4ztAGVRjoWT19v0yMmc5/L5kA== + dependencies: + levn "^0.4.1" + "@fastify/busboy@^2.0.0", "@fastify/busboy@^2.1.0": version "2.1.1" resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d" @@ -901,6 +908,19 @@ protobufjs "^7.2.5" yargs "^17.7.2" +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.6": + version "0.16.6" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.6.tgz#ee2a10eaabd1131987bf0488fd9b820174cd765e" + integrity sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw== + dependencies: + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.3.0" + "@humanwhocodes/module-importer@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" @@ -911,6 +931,11 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.3.0.tgz#6d86b8cb322660f03d3f0aa94b99bdd8e172d570" integrity sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew== +"@humanwhocodes/retry@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.0.tgz#b57438cab2a2381b4b597b0ab17339be381bd755" + integrity sha512-xnRgu9DxZbkWak/te3fcytNyp8MTbuiZIaueg2rgEvBuN55n04nwLYLU9TX/VVlusc9L2ZNXi99nUFNkHXtr5g== + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -1242,19 +1267,19 @@ webpack "5.90.1" webpack-node-externals "3.0.0" -"@nestjs/common@^10.4.6": - version "10.4.6" - resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.6.tgz#952e8fd0ceafeffcc4eaf47effd67fb395844ae0" - integrity sha512-KkezkZvU9poWaNq4L+lNvx+386hpOxPJkfXBBeSMrcqBOx8kVr36TGN2uYkF4Ta4zNu1KbCjmZbc0rhHSg296g== +"@nestjs/common@^10.4.13": + version "10.4.13" + resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.13.tgz#e53fafaf084740ec3321f16ef6a3ffd597a62ddc" + integrity sha512-NVJ2UYMRdMkxCcwmoWP8xihpUyd1uqKR+7QqTF3m8aedufpZm8W6WbUmNkD1j/o9TxRzhKW43PemeSMigZj+Bw== dependencies: uid "2.0.2" iterare "1.2.1" - tslib "2.7.0" + tslib "2.8.1" -"@nestjs/config@^3.2.3": - version "3.2.3" - resolved "https://registry.yarnpkg.com/@nestjs/config/-/config-3.2.3.tgz#569888a33ada50b0f182002015e152e054990016" - integrity sha512-p6yv/CvoBewJ72mBq4NXgOAi2rSQNWx3a+IMJLVKS2uiwFCOQQuiIatGwq6MRjXV3Jr+B41iUO8FIf4xBrZ4/w== +"@nestjs/config@^3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@nestjs/config/-/config-3.3.0.tgz#ddc520ba26a8453ee5e690e18fb7b35e9bac7974" + integrity sha512-pdGTp8m9d0ZCrjTpjkUbZx6gyf2IKf+7zlkrPNMsJzYZ4bFRRTpXrnj+556/5uiI6AfL5mMrJc2u7dB6bvM+VA== dependencies: dotenv "16.4.5" dotenv-expand "10.0.0" @@ -1277,25 +1302,25 @@ resolved "https://registry.yarnpkg.com/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz#485d6b44e19779c98d04e52bd1d2bcc7001df0ea" integrity sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg== -"@nestjs/platform-express@^10.4.6": - version "10.4.6" - resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-10.4.6.tgz#6c39c522fa66036b4256714fea203fbeb49fc4de" - integrity sha512-HcyCpAKccAasrLSGRTGWv5BKRs0rwTIFOSsk6laNyqfqvgvYcJQAedarnm4jmaemtmSJ0PFI9PmtEZADd2ahCg== +"@nestjs/platform-express@^10.4.12": + version "10.4.12" + resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-10.4.12.tgz#2a9c3ee053b29083e154d6622b8ded4e284da894" + integrity sha512-+m8BQas9mnY29Y6rZv8EUqIYwcta99/dTiGIUy48LB/+YoAyDTEHpsLd2+rpetk54niGgKJYclCZRUwRcjrYYA== dependencies: body-parser "1.20.3" cors "2.8.5" express "4.21.1" multer "1.4.4-lts.1" - tslib "2.7.0" + tslib "2.8.1" -"@nestjs/schematics@^10.0.1", "@nestjs/schematics@^10.1.4": - version "10.1.4" - resolved "https://registry.yarnpkg.com/@nestjs/schematics/-/schematics-10.1.4.tgz#e445b856eefce9bd338c5fc1cf2c95f0985849cf" - integrity sha512-QpY8ez9cTvXXPr3/KBrtSgXQHMSV6BkOUYy2c2TTe6cBqriEdGnCYqGl8cnfrQl3632q3lveQPaZ/c127dHsEw== +"@nestjs/schematics@^10.0.1", "@nestjs/schematics@^10.2.3": + version "10.2.3" + resolved "https://registry.yarnpkg.com/@nestjs/schematics/-/schematics-10.2.3.tgz#6053f43c5065b9e825cd08c4db1bf6bcbc9a6a62" + integrity sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg== dependencies: - "@angular-devkit/core" "17.3.8" - "@angular-devkit/schematics" "17.3.8" - comment-json "4.2.3" + "@angular-devkit/core" "17.3.11" + "@angular-devkit/schematics" "17.3.11" + comment-json "4.2.5" jsonc-parser "3.3.1" pluralize "8.0.0" @@ -1319,12 +1344,12 @@ boxen "5.1.2" check-disk-space "3.4.0" -"@nestjs/testing@^10.4.1": - version "10.4.1" - resolved "https://registry.yarnpkg.com/@nestjs/testing/-/testing-10.4.1.tgz#146c0161ab98524ea9fafe4ca5316229d1e44387" - integrity sha512-pR+su5+YGqCLH0RhhVkPowQK7FCORU0/PWAywPK7LScAOtD67ZoviZ7hAU4vnGdwkg4HCB0D7W8Bkg19CGU8Xw== +"@nestjs/testing@^10.4.6": + version "10.4.6" + resolved "https://registry.yarnpkg.com/@nestjs/testing/-/testing-10.4.6.tgz#3797a40c0628788e381f299d3c72acac364ca4ef" + integrity sha512-aiDicKhlGibVGNYuew399H5qZZXaseOBT/BS+ERJxxCmco7ZdAqaujsNjSaSbTK9ojDPf27crLT0C4opjqJe3A== dependencies: - tslib "2.6.3" + tslib "2.7.0" "@nestjs/typeorm@^10.0.2": version "10.0.2" @@ -1383,7 +1408,7 @@ resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== -"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": +"@nodelib/fs.walk@^1.2.3": version "1.2.8" resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== @@ -1632,10 +1657,10 @@ "@types/estree" "*" "@types/json-schema" "*" -"@types/estree@*", "@types/estree@^1.0.5": - version "1.0.5" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" - integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== +"@types/estree@*", "@types/estree@^1.0.5", "@types/estree@^1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" + integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== "@types/express-serve-static-core@^4.17.33": version "4.19.0" @@ -1647,7 +1672,17 @@ "@types/range-parser" "*" "@types/send" "*" -"@types/express@^4.17.17", "@types/express@^4.17.21": +"@types/express-serve-static-core@^5.0.0": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.0.1.tgz#3c9997ae9d00bc236e45c6374e84f2596458d9db" + integrity sha512-CRICJIl0N5cXDONAdlTv5ShATZ4HEwk6kDDIW2/w9qOWKg+NU/5F8wYRWCrONad0/UKkloNSmmyN/wX4rtpbVA== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express@^4.17.17": version "4.17.21" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== @@ -1657,6 +1692,16 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/express@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.0.tgz#13a7d1f75295e90d19ed6e74cab3678488eaa96c" + integrity sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^5.0.0" + "@types/qs" "*" + "@types/serve-static" "*" + "@types/form-data@0.0.33": version "0.0.33" resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-0.0.33.tgz#c9ac85b2a5fd18435b8c85d9ecb50e6d6c893ff8" @@ -1708,7 +1753,7 @@ expect "^29.0.0" pretty-format "^29.0.0" -"@types/json-schema@*", "@types/json-schema@^7.0.8": +"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -1727,10 +1772,10 @@ dependencies: "@types/node" "*" -"@types/lodash@^4.17.7": - version "4.17.7" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.7.tgz#2f776bcb53adc9e13b2c0dfd493dfcbd7de43612" - integrity sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA== +"@types/lodash@^4.17.13": + version "4.17.13" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.13.tgz#786e2d67cfd95e32862143abe7463a7f90c300eb" + integrity sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg== "@types/long@^4.0.0": version "4.0.2" @@ -1864,62 +1909,62 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/eslint-plugin@8.11.0": - version "8.11.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz#c3f087d20715fa94310b30666c08b3349e0ab084" - integrity sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA== +"@typescript-eslint/eslint-plugin@8.17.0": + version "8.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.17.0.tgz#2ee073c421f4e81e02d10e731241664b6253b23c" + integrity sha512-HU1KAdW3Tt8zQkdvNoIijfWDMvdSweFYm4hWh+KwhPstv+sCmWb89hCIP8msFm9N1R/ooh9honpSuvqKWlYy3w== dependencies: "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "8.11.0" - "@typescript-eslint/type-utils" "8.11.0" - "@typescript-eslint/utils" "8.11.0" - "@typescript-eslint/visitor-keys" "8.11.0" + "@typescript-eslint/scope-manager" "8.17.0" + "@typescript-eslint/type-utils" "8.17.0" + "@typescript-eslint/utils" "8.17.0" + "@typescript-eslint/visitor-keys" "8.17.0" graphemer "^1.4.0" ignore "^5.3.1" natural-compare "^1.4.0" ts-api-utils "^1.3.0" -"@typescript-eslint/parser@8.11.0": - version "8.11.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.11.0.tgz#2ad1481388dc1c937f50b2d138c9ca57cc6c5cce" - integrity sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg== +"@typescript-eslint/parser@8.17.0": + version "8.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.17.0.tgz#2ee972bb12fa69ac625b85813dc8d9a5a053ff52" + integrity sha512-Drp39TXuUlD49F7ilHHCG7TTg8IkA+hxCuULdmzWYICxGXvDXmDmWEjJYZQYgf6l/TFfYNE167m7isnc3xlIEg== dependencies: - "@typescript-eslint/scope-manager" "8.11.0" - "@typescript-eslint/types" "8.11.0" - "@typescript-eslint/typescript-estree" "8.11.0" - "@typescript-eslint/visitor-keys" "8.11.0" + "@typescript-eslint/scope-manager" "8.17.0" + "@typescript-eslint/types" "8.17.0" + "@typescript-eslint/typescript-estree" "8.17.0" + "@typescript-eslint/visitor-keys" "8.17.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@8.11.0": - version "8.11.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.11.0.tgz#9d399ce624118966732824878bc9a83593a30405" - integrity sha512-Uholz7tWhXmA4r6epo+vaeV7yjdKy5QFCERMjs1kMVsLRKIrSdM6o21W2He9ftp5PP6aWOVpD5zvrvuHZC0bMQ== +"@typescript-eslint/scope-manager@8.17.0": + version "8.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.17.0.tgz#a3f49bf3d4d27ff8d6b2ea099ba465ef4dbcaa3a" + integrity sha512-/ewp4XjvnxaREtqsZjF4Mfn078RD/9GmiEAtTeLQ7yFdKnqwTOgRMSvFz4et9U5RiJQ15WTGXPLj89zGusvxBg== dependencies: - "@typescript-eslint/types" "8.11.0" - "@typescript-eslint/visitor-keys" "8.11.0" + "@typescript-eslint/types" "8.17.0" + "@typescript-eslint/visitor-keys" "8.17.0" -"@typescript-eslint/type-utils@8.11.0": - version "8.11.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.11.0.tgz#b7f9e6120c1ddee8a1a07615646642ad85fc91b5" - integrity sha512-ItiMfJS6pQU0NIKAaybBKkuVzo6IdnAhPFZA/2Mba/uBjuPQPet/8+zh5GtLHwmuFRShZx+8lhIs7/QeDHflOg== +"@typescript-eslint/type-utils@8.17.0": + version "8.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.17.0.tgz#d326569f498cdd0edf58d5bb6030b4ad914e63d3" + integrity sha512-q38llWJYPd63rRnJ6wY/ZQqIzPrBCkPdpIsaCfkR3Q4t3p6sb422zougfad4TFW9+ElIFLVDzWGiGAfbb/v2qw== dependencies: - "@typescript-eslint/typescript-estree" "8.11.0" - "@typescript-eslint/utils" "8.11.0" + "@typescript-eslint/typescript-estree" "8.17.0" + "@typescript-eslint/utils" "8.17.0" debug "^4.3.4" ts-api-utils "^1.3.0" -"@typescript-eslint/types@8.11.0": - version "8.11.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.11.0.tgz#7c766250502097f49bbc2e651132e6bf489e20b8" - integrity sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw== +"@typescript-eslint/types@8.17.0": + version "8.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.17.0.tgz#ef84c709ef8324e766878834970bea9a7e3b72cf" + integrity sha512-gY2TVzeve3z6crqh2Ic7Cr+CAv6pfb0Egee7J5UAVWCpVvDI/F71wNfolIim4FE6hT15EbpZFVUj9j5i38jYXA== -"@typescript-eslint/typescript-estree@8.11.0": - version "8.11.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.11.0.tgz#35fe5d3636fc5727c52429393415412e552e222b" - integrity sha512-yHC3s1z1RCHoCz5t06gf7jH24rr3vns08XXhfEqzYpd6Hll3z/3g23JRi0jM8A47UFKNc3u/y5KIMx8Ynbjohg== +"@typescript-eslint/typescript-estree@8.17.0": + version "8.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.17.0.tgz#40b5903bc929b1e8dd9c77db3cb52cfb199a2a34" + integrity sha512-JqkOopc1nRKZpX+opvKqnM3XUlM7LpFMD0lYxTqOTKQfCWAmxw45e3qlOCsEqEB2yuacujivudOFpCnqkBDNMw== dependencies: - "@typescript-eslint/types" "8.11.0" - "@typescript-eslint/visitor-keys" "8.11.0" + "@typescript-eslint/types" "8.17.0" + "@typescript-eslint/visitor-keys" "8.17.0" debug "^4.3.4" fast-glob "^3.3.2" is-glob "^4.0.3" @@ -1927,23 +1972,23 @@ semver "^7.6.0" ts-api-utils "^1.3.0" -"@typescript-eslint/utils@8.11.0": - version "8.11.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.11.0.tgz#4480d1e9f2bb18ea3510c79f870a1aefc118103d" - integrity sha512-CYiX6WZcbXNJV7UNB4PLDIBtSdRmRI/nb0FMyqHPTQD1rMjA0foPLaPUV39C/MxkTd/QKSeX+Gb34PPsDVC35g== +"@typescript-eslint/utils@8.17.0": + version "8.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.17.0.tgz#41c05105a2b6ab7592f513d2eeb2c2c0236d8908" + integrity sha512-bQC8BnEkxqG8HBGKwG9wXlZqg37RKSMY7v/X8VEWD8JG2JuTHuNK0VFvMPMUKQcbk6B+tf05k+4AShAEtCtJ/w== dependencies: "@eslint-community/eslint-utils" "^4.4.0" - "@typescript-eslint/scope-manager" "8.11.0" - "@typescript-eslint/types" "8.11.0" - "@typescript-eslint/typescript-estree" "8.11.0" + "@typescript-eslint/scope-manager" "8.17.0" + "@typescript-eslint/types" "8.17.0" + "@typescript-eslint/typescript-estree" "8.17.0" -"@typescript-eslint/visitor-keys@8.11.0": - version "8.11.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.11.0.tgz#273de1cbffe63d9f9cd7dfc20b5a5af66310cb92" - integrity sha512-EaewX6lxSjRJnc+99+dqzTeoDZUfyrA52d2/HRrkI830kgovWsmIiTfmr0NZorzqic7ga+1bS60lRBUgR3n/Bw== +"@typescript-eslint/visitor-keys@8.17.0": + version "8.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.17.0.tgz#4dbcd0e28b9bf951f4293805bf34f98df45e1aa8" + integrity sha512-1Hm7THLpO6ww5QU6H/Qp+AusUUl+z/CAm3cNZZ0jQvon9yicgO7Rwd+/WWRpMKLYV6p2UvdbR27c86rzCPpreg== dependencies: - "@typescript-eslint/types" "8.11.0" - eslint-visitor-keys "^3.4.3" + "@typescript-eslint/types" "8.17.0" + eslint-visitor-keys "^4.2.0" "@tyriar/fibonacci-heap@^2.0.7": version "2.0.9" @@ -2116,10 +2161,10 @@ acorn-walk@^8.1.1: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa" integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A== -acorn@^8.12.0: - version "8.12.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" - integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== +acorn@^8.14.0: + version "8.14.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0" + integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== acorn@^8.4.1, acorn@^8.7.1, acorn@^8.8.2: version "8.11.3" @@ -2795,10 +2840,10 @@ commander@^2.20.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -comment-json@4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/comment-json/-/comment-json-4.2.3.tgz#50b487ebbf43abe44431f575ebda07d30d015365" - integrity sha512-SsxdiOf064DWoZLH799Ata6u7iV658A11PlWtZATDlXPpKGJnbJZ5Z24ybixAi+LUUqJ/GKowAejtC5GFUG7Tw== +comment-json@4.2.5: + version "4.2.5" + resolved "https://registry.yarnpkg.com/comment-json/-/comment-json-4.2.5.tgz#482e085f759c2704b60bc6f97f55b8c01bc41e70" + integrity sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw== dependencies: array-timsort "^1.0.3" core-util-is "^1.0.3" @@ -2919,10 +2964,10 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -crisp-api@^9.2.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/crisp-api/-/crisp-api-9.2.0.tgz#03c695dc82e55ed2e9019a6d289c401771763ce6" - integrity sha512-lbet95fu/4BbeTRZl0xZEuGdgmn8C4RbF71NC7sWDdX0Kl6hgWZwgHAdw2eCvb2HIEExdlHmkm9JG6173uptMg== +crisp-api@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/crisp-api/-/crisp-api-9.4.0.tgz#4389ba6fb903682d6eb865b95cc84cc73a0484d0" + integrity sha512-GK53Ge1HOOsIAQd3y6Jlu9iEMds5p0IjTh/GS66FCt4ncM1ypjN5TVjqtY26L7yB9jdXimCCWL6M6YVnZbr5xA== dependencies: fbemitter "github:crisp-dev/emitter#695f60594bdca0c876e5c232de57702ab3151b6f" got "11.8.5" @@ -2936,9 +2981,9 @@ cross-fetch@^3.1.5: node-fetch "^2.6.12" cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" @@ -2966,12 +3011,12 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@~4.3.1, debug@~4.3.2: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== dependencies: - ms "2.1.2" + ms "^2.1.3" debug@^3.1.0: version "3.2.7" @@ -2980,13 +3025,6 @@ debug@^3.1.0: dependencies: ms "^2.1.1" -debug@^4.3.5, debug@~4.3.1, debug@~4.3.2: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== - dependencies: - ms "^2.1.3" - decache@^3.0.5: version "3.1.0" resolved "https://registry.yarnpkg.com/decache/-/decache-3.1.0.tgz#4f5036fbd6581fcc97237ac3954a244b9536c2da" @@ -3267,45 +3305,49 @@ eslint-scope@5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-scope@^8.0.2: - version "8.0.2" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.0.2.tgz#5cbb33d4384c9136083a71190d548158fe128f94" - integrity sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA== +eslint-scope@^8.2.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.2.0.tgz#377aa6f1cb5dc7592cfd0b7f892fd0cf352ce442" + integrity sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A== dependencies: esrecurse "^4.3.0" estraverse "^5.2.0" -eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.3: +eslint-visitor-keys@^3.3.0: version "3.4.3" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint-visitor-keys@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz#e3adc021aa038a2a8e0b2f8b0ce8f66b9483b1fb" - integrity sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw== +eslint-visitor-keys@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz#687bacb2af884fcdda8a6e7d65c606f46a14cd45" + integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw== -eslint@^9.9.1: - version "9.9.1" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.9.1.tgz#147ac9305d56696fb84cf5bdecafd6517ddc77ec" - integrity sha512-dHvhrbfr4xFQ9/dq+jcVneZMyRYLjggWjk6RVsIiHsP8Rz6yZ8LvZ//iU4TrZF+SXWG+JkNF2OyiZRvzgRDqMg== +eslint@^9.14.0: + version "9.14.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.14.0.tgz#534180a97c00af08bcf2b60b0ebf0c4d6c1b2c95" + integrity sha512-c2FHsVBr87lnUtjP4Yhvk4yEhKrQavGafRA/Se1ouse8PfbfC/Qh9Mxa00yWsZRlqeUB9raXip0aiiUZkgnr9g== dependencies: "@eslint-community/eslint-utils" "^4.2.0" - "@eslint-community/regexpp" "^4.11.0" + "@eslint-community/regexpp" "^4.12.1" "@eslint/config-array" "^0.18.0" + "@eslint/core" "^0.7.0" "@eslint/eslintrc" "^3.1.0" - "@eslint/js" "9.9.1" + "@eslint/js" "9.14.0" + "@eslint/plugin-kit" "^0.2.0" + "@humanfs/node" "^0.16.6" "@humanwhocodes/module-importer" "^1.0.1" - "@humanwhocodes/retry" "^0.3.0" - "@nodelib/fs.walk" "^1.2.8" + "@humanwhocodes/retry" "^0.4.0" + "@types/estree" "^1.0.6" + "@types/json-schema" "^7.0.15" ajv "^6.12.4" chalk "^4.0.0" cross-spawn "^7.0.2" debug "^4.3.2" escape-string-regexp "^4.0.0" - eslint-scope "^8.0.2" - eslint-visitor-keys "^4.0.0" - espree "^10.1.0" + eslint-scope "^8.2.0" + eslint-visitor-keys "^4.2.0" + espree "^10.3.0" esquery "^1.5.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" @@ -3315,24 +3357,21 @@ eslint@^9.9.1: ignore "^5.2.0" imurmurhash "^0.1.4" is-glob "^4.0.0" - is-path-inside "^3.0.3" json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" lodash.merge "^4.6.2" minimatch "^3.1.2" natural-compare "^1.4.0" optionator "^0.9.3" - strip-ansi "^6.0.1" text-table "^0.2.0" -espree@^10.0.1, espree@^10.1.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-10.1.0.tgz#8788dae611574c0f070691f522e4116c5a11fc56" - integrity sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA== +espree@^10.0.1, espree@^10.3.0: + version "10.3.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.3.0.tgz#29267cf5b0cb98735b65e64ba07e0ed49d1eed8a" + integrity sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg== dependencies: - acorn "^8.12.0" + acorn "^8.14.0" acorn-jsx "^5.3.2" - eslint-visitor-keys "^4.0.0" + eslint-visitor-keys "^4.2.0" esprima@^4.0.0, esprima@^4.0.1: version "4.0.1" @@ -4374,11 +4413,6 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== -is-path-inside@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - is-stream@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" @@ -5433,11 +5467,6 @@ ms@2.0.0: resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== -ms@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - ms@2.1.3, ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" @@ -6585,7 +6614,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6622,7 +6660,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -6990,16 +7035,16 @@ tsconfig-paths@4.2.0, tsconfig-paths@^4.1.2, tsconfig-paths@^4.2.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@2.6.3: - version "2.6.3" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" - integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== - -tslib@2.7.0, tslib@^2.1.0, tslib@^2.5.0, tslib@^2.6.2: +tslib@2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== +tslib@2.8.1, tslib@^2.1.0, tslib@^2.5.0, tslib@^2.6.2: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -7056,14 +7101,14 @@ typeorm@^0.3.20: uuid "^9.0.0" yargs "^17.6.2" -typescript-eslint@^8.11.0: - version "8.11.0" - resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.11.0.tgz#74a0551972d675b4141672cec3acc5139b7399c0" - integrity sha512-cBRGnW3FSlxaYwU8KfAewxFK5uzeOAp0l2KebIlPDOT5olVi65KDG/yjBooPBG0kGW/HLkoz1c/iuBFehcS3IA== +typescript-eslint@^8.17.0: + version "8.17.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.17.0.tgz#fa4033c26b3b40f778287bc12918d985481b220b" + integrity sha512-409VXvFd/f1br1DCbuKNFqQpXICoTB+V51afcwG1pn1a3Cp92MqAUges3YjwEdQ0cMUoCIodjVDAYzyD8h3SYA== dependencies: - "@typescript-eslint/eslint-plugin" "8.11.0" - "@typescript-eslint/parser" "8.11.0" - "@typescript-eslint/utils" "8.11.0" + "@typescript-eslint/eslint-plugin" "8.17.0" + "@typescript-eslint/parser" "8.17.0" + "@typescript-eslint/utils" "8.17.0" typescript@5.3.3: version "5.3.3" @@ -7314,7 +7359,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -7332,6 +7377,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"