diff --git a/.github/workflows/azure_synchronisation.yml b/.github/workflows/azure_synchronisation.yml deleted file mode 100644 index b296c030c..000000000 --- a/.github/workflows/azure_synchronisation.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Sync to Azure DevOps Repository - -on: - push: - branches: - - '*' - pull_request: - -jobs: - Sync: - runs-on: ubuntu-latest - env: - AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }} - steps: - - name: Checkout code - uses: actions/checkout@v2 - with: - submodules: true - fetch-depth: 0 # Fetch all branches - - - name: Configuring git - run: | - git config --global user.email "voznesenskijandrej5@gmail.com" - git config --global user.name "Andrii Voznesenskyi" - - - name: Adding Azure DevOps remote repository - env: - AZURE_DEVOPS_USERNAME: ${{ secrets.AZURE_DEVOPS_USERNAME }} - AZURE_DEVOPS_PASSWORD: ${{ secrets.AZURE_DEVOPS_PASSWORD }} - run: | - # git remote add azure https://$AZURE_DEVOPS_USERNAME:$AZURE_DEVOPS_PASSWORD@dev.azure.com/SocialAppOIPproject/SocialApp_IO/_git/SocialApp_IO - # Check if the remote already exists - if git remote get-url azure; then - echo "Remote 'azure' already exists. Updating URL if needed." - git remote set-url azure https://$AZURE_DEVOPS_PAT@dev.azure.com/SocialAppOIPproject/SocialApp_IO/_git/SocialApp_IO - else - git remote add azure https://$AZURE_DEVOPS_PAT@dev.azure.com/SocialAppOIPproject/SocialApp_IO/_git/SocialApp_IO - fi - - - name: Push all branches to Azure DevOps - run: git push azure --all --force - - - name: Push all tags to Azure DevOps - run: git push azure --tags --force - - - name: Push pull requests to Azure DevOps - run: | - # Fetch the pull request refs - git fetch origin +refs/pull/*:refs/remotes/origin/pr/* - # Push each pull request ref to Azure DevOps - for ref in $(git for-each-ref --format='%(refname)' refs/remotes/origin/pr/*); do - pr_ref=${ref#refs/remotes/origin/pr/} - # Split the ref by / and get the PR number - IFS='/' read -ra ADDR <<< "$pr_ref" - pr_number=${ADDR[0]} - # Check out the PR head branch - git fetch origin pull/$pr_number/head:pr-$pr_number - git checkout pr-$pr_number - # Push the PR head branch to Azure as a branch named pr-{PR number} - git push azure pr-$pr_number:refs/heads/pr-$pr_number --force - done - - - # # Important branches anlizer: - # - name: Checkout and push all branches to Azure DevOps - # run: | - # for branch in $(git branch -a | grep 'remotes/origin/' | sed 's/remotes\/origin\///'); do - # if git rev-parse --verify $branch > /dev/null 2>&1; then - # # If branch exists locally, just checkout - # git checkout $branch - # else - # # If branch does not exist locally, create it tracking the remote - # git checkout -b $branch origin/$branch - # fi - # git push azure $branch:$branch --force -v - # done diff --git a/.github/workflows/build_microservices.yml b/.github/workflows/build_microservices.yml index 70518cdd8..c0b18b144 100644 --- a/.github/workflows/build_microservices.yml +++ b/.github/workflows/build_microservices.yml @@ -20,8 +20,8 @@ jobs: test_dir: 'MiniSpace.Services.Posts/tests' - project: 'MiniSpace.Services.Comments/src/MiniSpace.Services.Comments.Api' test_dir: 'MiniSpace.Services.Comments/tests' - - project: 'MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Api' - test_dir: 'MiniSpace.Services.Organizations/tests' + # - project: 'MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Api' + # test_dir: 'MiniSpace.Services.Organizations/tests' - project: 'MiniSpace.Services.Posts/src/MiniSpace.Services.Posts.Api' test_dir: 'MiniSpace.Services.Posts/tests' - project: 'MiniSpace.Services.Reactions/src/MiniSpace.Services.Reactions.Api' diff --git a/.github/workflows/cloud_deploy.yml b/.github/workflows/cloud_deploy.yml index 787a40b66..c528ddaa3 100644 --- a/.github/workflows/cloud_deploy.yml +++ b/.github/workflows/cloud_deploy.yml @@ -15,79 +15,110 @@ jobs: - name: Checkout repository uses: actions/checkout@v2 - - name: Set up SSH + # - name: Set up SSH + # run: | + # mkdir -p ~/.ssh/ + # echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa + # chmod 600 ~/.ssh/id_rsa + # ssh-keyscan -H ${{ secrets.DROPLET_IP }} >> ~/.ssh/known_hosts + # cat ~/.ssh/known_hosts + + - name: Docker Login run: | - mkdir -p ~/.ssh/ - echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -H ${{ secrets.DROPLET_IP }} >> ~/.ssh/known_hosts - cat ~/.ssh/known_hosts + echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin - - name: Check the structure + - name: Clone appsettings repository + env: + GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} run: | - pwd - ls -la - cd ./MiniSpace/scripts/ - ls -la + git clone https://oauth2:${{ secrets.GITLAB_TOKEN }}@gitlab.com/distributed-asp-net-core-blazor-social-app/events_apsettings_dev.git /tmp/events_public_settings + ls -la /tmp/events_public_settings - - name: Docker Login + - name: Copy appsettings to the correct locations run: | - echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + cp /tmp/events_public_settings/APIGateway/appsettings.json ./MiniSpace.APIGateway/src/MiniSpace.APIGateway/ + cp /tmp/events_public_settings/APIGateway/appsettings.docker.json ./MiniSpace.APIGateway/src/MiniSpace.APIGateway/ + + cp /tmp/events_public_settings/Services.Comments/appsettings.json ./MiniSpace.Services.Comments/src/MiniSpace.Services.Comments.Api/ + cp /tmp/events_public_settings/Services.Comments/appsettings.docker.json ./MiniSpace.Services.Comments/src/MiniSpace.Services.Comments.Api/ + + cp /tmp/events_public_settings/Services.Email/appsettings.json ./MiniSpace.Services.Email/src/MiniSpace.Services.Email.Api/ + cp /tmp/events_public_settings/Services.Email/appsettings.docker.json ./MiniSpace.Services.Email/src/MiniSpace.Services.Email.Api/ + + cp /tmp/events_public_settings/Services.Events/appsettings.json ./MiniSpace.Services.Events/src/MiniSpace.Services.Events.Api/ + cp /tmp/events_public_settings/Services.Events/appsettings.docker.json ./MiniSpace.Services.Events/src/MiniSpace.Services.Events.Api/ + + cp /tmp/events_public_settings/Services.Friends/appsettings.json ./MiniSpace.Services.Friends/src/MiniSpace.Services.Friends.Api/ + cp /tmp/events_public_settings/Services.Friends/appsettings.docker.json ./MiniSpace.Services.Friends/src/MiniSpace.Services.Friends.Api/ + + cp /tmp/events_public_settings/Services.Identity/appsettings.json ./MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Api/ + cp /tmp/events_public_settings/Services.Identity/appsettings.docker.json ./MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Api/ + + cp /tmp/events_public_settings/Services.MediaFiles/appsettings.json ./MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/ + cp /tmp/events_public_settings/Services.MediaFiles/appsettings.docker.json ./MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/ + + cp /tmp/events_public_settings/Services.Notifications/appsettings.json ./MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Api/ + cp /tmp/events_public_settings/Services.Notifications/appsettings.docker.json ./MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Api/ + + cp /tmp/events_public_settings/Services.Organizations/appsettings.json ./MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Api/ + cp /tmp/events_public_settings/Services.Organizations/appsettings.docker.json ./MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Api/ + + cp /tmp/events_public_settings/Services.Posts/appsettings.json ./MiniSpace.Services.Posts/src/MiniSpace.Services.Posts.Api/ + cp /tmp/events_public_settings/Services.Posts/appsettings.docker.json ./MiniSpace.Services.Posts/src/MiniSpace.Services.Posts.Api/ + + cp /tmp/events_public_settings/Services.Reactions/appsettings.json ./MiniSpace.Services.Reactions/src/MiniSpace.Services.Reactions.Api/ + cp /tmp/events_public_settings/Services.Reactions/appsettings.docker.json ./MiniSpace.Services.Reactions/src/MiniSpace.Services.Reactions.Api/ + + cp /tmp/events_public_settings/Services.Reports/appsettings.json ./MiniSpace.Services.Reports/src/MiniSpace.Services.Reports.Api/ + cp /tmp/events_public_settings/Services.Reports/appsettings.docker.json ./MiniSpace.Services.Reports/src/MiniSpace.Services.Reports.Api/ + + cp /tmp/events_public_settings/Services.Students/appsettings.json ./MiniSpace.Services.Students/src/MiniSpace.Services.Students.Api/ + cp /tmp/events_public_settings/Services.Students/appsettings.docker.json ./MiniSpace.Services.Students/src/MiniSpace.Services.Students.Api/ + + cp /tmp/events_public_settings/Web/appsettings.json ./MiniSpace.Web/src/MiniSpace.Web/ + cp /tmp/events_public_settings/Web/appsettings.Development.json ./MiniSpace.Web/src/MiniSpace.Web/ + ls -la ./MiniSpace/ - name: Run dockerize_all.sh run: | chmod +x ./MiniSpace/scripts/dockerize-all.sh ./MiniSpace/scripts/dockerize-all.sh - - name: Check the structure 2 + - name: Check the structure run: | pwd ls -la - - name: Test SSH Connection - run: | - ssh -vvv ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }} "echo SSH Connection Successful" - - - name: Copy and recreate deployment files - run: | - scp -r ./MiniSpace/compose/infrastructure.yml ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }}:/root/social_net_app/ - scp -r ./MiniSpace/compose/services.yml ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }}:/root/social_net_app/ - scp -r ./MiniSpace/compose/prometheus ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }}:/root/social_net_app/ - scp -r ./MiniSpace/compose/rabbitmq ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }}:/root/social_net_app/ - - - name: Deploy containers with the infrastructure images - run: | - ssh -T ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }} << EOF - cd /root/social_net_app - docker-compose -f infrastructure.yml up -d - docker ps -a - EOF - - - name: Pull latest images for services - run: | - ssh -T ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }} << EOF - cd /root/social_net_app - docker-compose -f services.yml pull - EOF + # - name: Test SSH Connection + # run: | + # ssh -vvv ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }} "echo SSH Connection Successful" - - name: Deploy containers with the services images - run: | - ssh -T ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }} << EOF - cd /root/social_net_app - docker-compose -f services.yml up -d - docker ps -a - EOF + # - name: Copy and recreate deployment files + # run: | + # scp -r ./MiniSpace/compose/infrastructure.yml ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }}:/root/social_net_app/ + # scp -r ./MiniSpace/compose/services.yml ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }}:/root/social_net_app/ + # scp -r ./MiniSpace/compose/prometheus ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }}:/root/social_net_app/ + # scp -r ./MiniSpace/compose/rabbitmq ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }}:/root/social_net_app/ - - # - name: Stop and Remove Containers + # - name: Deploy containers with the infrastructure images # run: | - # ssh root@${{ secrets.DROPLET_IP }} "docker ps -q --filter network=swiftparcel-network | xargs -r docker stop && docker ps -a -q --filter network=swiftparcel-network | xargs -r docker rm" + # ssh -T ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }} << EOF + # cd /root/social_net_app + # docker-compose -f infrastructure.yml up -d + # docker ps -a + # EOF - # - name: Clean up existing Cloud networks + # - name: Pull latest images for services # run: | - # ssh root@${{ secrets.DROPLET_IP }} "docker network rm swiftparcel-network || true" + # ssh -T ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }} << EOF + # cd /root/social_net_app + # docker-compose -f services.yml pull + # EOF - # - name: Deploy to Cloud + # - name: Deploy containers with the services images # run: | - # scp -r ./SwiftParcel/d-docker-compose root@${{ secrets.DROPLET_IP }}:${{ secrets.CLOUD_PROJECT_PATH }} - # ssh root@${{ secrets.DROPLET_IP }} "cd ${{ secrets.CLOUD_PROJECT_PATH }}/SwiftParcel/d-docker-compose && docker-compose -f ${{ secrets.CLOUD_DEPLOYMENT_FILE }} pull && docker-compose -f ${{ secrets.CLOUD_DEPLOYMENT_FILE }} up -d --force-recreate" + # ssh -T ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }} << EOF + # cd /root/social_net_app + # docker-compose -f services.yml up -d + # docker ps -a + # EOF diff --git a/.github/workflows/gitlab_sync.yml b/.github/workflows/gitlab_sync.yml new file mode 100644 index 000000000..02fab950b --- /dev/null +++ b/.github/workflows/gitlab_sync.yml @@ -0,0 +1,70 @@ +name: Sync to GitLab Repositories + +on: + push: + branches: + - '*' + pull_request: + types: [closed] + branches: + - '*' + +jobs: + Sync: + runs-on: ubuntu-latest + env: + GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPO: ${{ github.repository }} + GITLAB_REPO_1: 'SaintAngeLs/distributed_minispace' + GITLAB_REPO_2: 'distributed-asp-net-core-blazor-social-app/distributed_minispace' + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + submodules: true + fetch-depth: 0 # Fetch all branches and tags + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install requests + + - name: Configuring git + run: | + git config --global user.email "voznesenskijandrej5@gmail.com" + git config --global user.name "Andrii Voznesenskyi" + + - name: Adding GitLab remote repository 1 + run: | + GITLAB_REPO="https://oauth2:${{ secrets.GITLAB_TOKEN }}@gitlab.com/SaintAngeLs/distributed_minispace.git" + git remote add gitlab1 $GITLAB_REPO || git remote set-url gitlab1 $GITLAB_REPO + + - name: Adding GitLab remote repository 2 + run: | + GITLAB_REPO="https://oauth2:${{ secrets.GITLAB_TOKEN }}@gitlab.com/distributed-asp-net-core-blazor-social-app/distributed_minispace.git" + git remote add gitlab2 $GITLAB_REPO || git remote set-url gitlab2 $GITLAB_REPO + + - name: Push all branches to both GitLab repositories + run: | + git push gitlab1 --all --force + git push gitlab2 --all --force + + - name: Push all tags to both GitLab repositories + run: | + git push gitlab1 --tags --force + git push gitlab2 --tags --force + + - name: Sync Pull Requests and Issues to GitLab + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} + GITHUB_REPO: ${{ github.repository }} + GITLAB_REPO_1: 'SaintAngeLs/distributed_minispace' + GITLAB_REPO_2: 'distributed-asp-net-core-blazor-social-app/distributed_minispace' + run: python .github/workflows/sync_prs_issues.py diff --git a/.github/workflows/gitlab_sync_pr_issues.yml b/.github/workflows/gitlab_sync_pr_issues.yml new file mode 100644 index 000000000..a193a3703 --- /dev/null +++ b/.github/workflows/gitlab_sync_pr_issues.yml @@ -0,0 +1,73 @@ +name: Sync PRs and Issues to GitLab + +on: + push: + branches: + - '*' + pull_request: + types: [closed] + branches: + - '*' + schedule: + - cron: '0 * * * *' # Runs every hour + workflow_dispatch: + +jobs: + Sync: + runs-on: ubuntu-latest + env: + GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPO: ${{ github.repository }} + GITLAB_REPO_1: 'SaintAngeLs/distributed_minispace' + GITLAB_REPO_2: 'distributed-asp-net-core-blazor-social-app/distributed_minispace' + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + submodules: true + fetch-depth: 0 # Fetch all branches and tags + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install requests + + - name: Configuring git + run: | + git config --global user.email "voznesenskijandrej5@gmail.com" + git config --global user.name "Andrii Voznesenskyi" + + - name: Adding GitLab remote repository 1 + run: | + GITLAB_REPO="https://oauth2:${{ secrets.GITLAB_TOKEN }}@gitlab.com/SaintAngeLs/distributed_minispace.git" + git remote add gitlab1 $GITLAB_REPO || git remote set-url gitlab1 $GITLAB_REPO + + - name: Adding GitLab remote repository 2 + run: | + GITLAB_REPO="https://oauth2:${{ secrets.GITLAB_TOKEN }}@gitlab.com/distributed-asp-net-core-blazor-social-app/distributed_minispace.git" + git remote add gitlab2 $GITLAB_REPO || git remote set-url gitlab2 $GITLAB_REPO + + - name: Push all branches to both GitLab repositories + run: | + git push gitlab1 --all --force + git push gitlab2 --all --force + + - name: Push all tags to both GitLab repositories + run: | + git push gitlab1 --tags --force + git push gitlab2 --tags --force + + - name: Sync Pull Requests and Issues to GitLab + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} + GITHUB_REPO: ${{ github.repository }} + GITLAB_REPO_1: 'SaintAngeLs/distributed_minispace' + GITLAB_REPO_2: 'distributed-asp-net-core-blazor-social-app/distributed_minispace' + run: python .github/workflows/sync_prs_issues.py diff --git a/.github/workflows/sync_prs_issues.py b/.github/workflows/sync_prs_issues.py new file mode 100644 index 000000000..40b020a65 --- /dev/null +++ b/.github/workflows/sync_prs_issues.py @@ -0,0 +1,78 @@ +import os +import requests + +GITHUB_API_URL = "https://api.github.com" +GITLAB_API_URL = "https://gitlab.com/api/v4" + +GITHUB_TOKEN = os.getenv('GITHUB_TOKEN') +GITLAB_TOKEN = os.getenv('GITLAB_TOKEN') +GITHUB_REPO = os.getenv('GITHUB_REPO') +GITLAB_REPO_1 = os.getenv('GITLAB_REPO_1') +GITLAB_REPO_2 = os.getenv('GITLAB_REPO_2') + +def get_github_issues(): + url = f"{GITHUB_API_URL}/repos/{GITHUB_REPO}/issues" + headers = {"Authorization": f"token {GITHUB_TOKEN}"} + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json() + +def get_github_pull_requests(): + url = f"{GITHUB_API_URL}/repos/{GITHUB_REPO}/pulls" + headers = {"Authorization": f"token {GITHUB_TOKEN}"} + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json() + +def create_gitlab_issue(issue, gitlab_repo): + url = f"{GITLAB_API_URL}/projects/{gitlab_repo.replace('/', '%2F')}/issues" + headers = {"PRIVATE-TOKEN": GITLAB_TOKEN} + labels = [label['name'] for label in issue.get('labels', [])] + data = { + "title": issue['title'], + "description": issue['body'] or '', + "labels": ','.join(labels) + } + response = requests.post(url, headers=headers, json=data) + response.raise_for_status() + return response.json() + +def create_gitlab_merge_request(pr, gitlab_repo): + url = f"{GITLAB_API_URL}/projects/{gitlab_repo.replace('/', '%2F')}/merge_requests" + search_params = { + "source_branch": pr['head']['ref'], + "target_branch": pr['base']['ref'] + } + headers = {"PRIVATE-TOKEN": GITLAB_TOKEN} + existing_mrs = requests.get(url, headers=headers, params=search_params) + if existing_mrs.status_code == 200 and existing_mrs.json(): + print("Merge request already exists between these branches. Skipping creation.") + return None + + data = { + "title": pr['title'], + "description": pr['body'] or '', + "source_branch": pr['head']['ref'], + "target_branch": pr['base']['ref'] + } + response = requests.post(url, headers=headers, json=data) + response.raise_for_status() + return response.json() + + +def sync_issues(): + issues = get_github_issues() + for issue in issues: + if 'pull_request' not in issue: + create_gitlab_issue(issue, GITLAB_REPO_1) + create_gitlab_issue(issue, GITLAB_REPO_2) + +def sync_pull_requests(): + prs = get_github_pull_requests() + for pr in prs: + create_gitlab_merge_request(pr, GITLAB_REPO_1) + create_gitlab_merge_request(pr, GITLAB_REPO_2) + +if __name__ == "__main__": + sync_issues() + sync_pull_requests() diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 000000000..1b9513c17 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,206 @@ +stages: + - build + - deploy + +variables: + DOCKER_REGISTRY: registry.gitlab.com + DOCKER_IMAGE: $DOCKER_REGISTRY/distributed-asp-net-core-blazor-social-app/distributed_minispace + +services: + - docker:dind + +before_script: + - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin + +.build_template: &build_template + stage: build + image: docker:latest + script: + - docker build -t $DOCKER_IMAGE/$SERVICE_NAME:latest $SERVICE_PATH + - docker push $DOCKER_IMAGE/$SERVICE_NAME:latest + + +build_api_gateway: + <<: *build_template + variables: + SERVICE_NAME: api-gateway + SERVICE_PATH: MiniSpace.APIGateway + rules: + - if: '$CI_COMMIT_MESSAGE =~ /deploy-all/' + - if: '$CI_COMMIT_MESSAGE !~ /deploy/' + changes: + - MiniSpace.APIGateway/** + +build_web: + <<: *build_template + variables: + SERVICE_NAME: web + SERVICE_PATH: MiniSpace.Web + rules: + - if: '$CI_COMMIT_MESSAGE =~ /deploy-all/' + - if: '$CI_COMMIT_MESSAGE !~ /deploy/' + changes: + - MiniSpace.Web/** + +build_services_identity: + <<: *build_template + variables: + SERVICE_NAME: services-identity + SERVICE_PATH: MiniSpace.Services.Identity + rules: + - if: '$CI_COMMIT_MESSAGE =~ /deploy-all/' + - if: '$CI_COMMIT_MESSAGE !~ /deploy/' + changes: + - MiniSpace.Services.Identity/** + +build_services_events: + <<: *build_template + variables: + SERVICE_NAME: services-events + SERVICE_PATH: MiniSpace.Services.Events + rules: + - if: '$CI_COMMIT_MESSAGE =~ /deploy-all/' + - if: '$CI_COMMIT_MESSAGE !~ /deploy/' + changes: + - MiniSpace.Services.Events/** + +build_services_students: + <<: *build_template + variables: + SERVICE_NAME: services-students + SERVICE_PATH: MiniSpace.Services.Students + rules: + - if: '$CI_COMMIT_MESSAGE =~ /deploy-all/' + - if: '$CI_COMMIT_MESSAGE !~ /deploy/' + changes: + - MiniSpace.Services.Students/** + +build_services_friends: + <<: *build_template + variables: + SERVICE_NAME: services-friends + SERVICE_PATH: MiniSpace.Services.Friends + rules: + - if: '$CI_COMMIT_MESSAGE =~ /deploy-all/' + - if: '$CI_COMMIT_MESSAGE !~ /deploy/' + changes: + - MiniSpace.Services.Friends/** + +build_services_reactions: + <<: *build_template + variables: + SERVICE_NAME: services-reactions + SERVICE_PATH: MiniSpace.Services.Reactions + rules: + - if: '$CI_COMMIT_MESSAGE =~ /deploy-all/' + - if: '$CI_COMMIT_MESSAGE !~ /deploy/' + changes: + - MiniSpace.Services.Reactions/** + +build_services_posts: + <<: *build_template + variables: + SERVICE_NAME: services-posts + SERVICE_PATH: MiniSpace.Services.Posts + rules: + - if: '$CI_COMMIT_MESSAGE =~ /deploy-all/' + - if: '$CI_COMMIT_MESSAGE !~ /deploy/' + changes: + - MiniSpace.Services.Posts/** + +build_services_comments: + <<: *build_template + variables: + SERVICE_NAME: services-comments + SERVICE_PATH: MiniSpace.Services.Comments + rules: + - if: '$CI_COMMIT_MESSAGE =~ /deploy-all/' + - if: '$CI_COMMIT_MESSAGE !~ /deploy/' + changes: + - MiniSpace.Services.Comments/** + +build_services_mediafiles: + <<: *build_template + variables: + SERVICE_NAME: services-mediafiles + SERVICE_PATH: MiniSpace.Services.MediaFiles + rules: + - if: '$CI_COMMIT_MESSAGE =~ /deploy-all/' + - if: '$CI_COMMIT_MESSAGE !~ /deploy/' + changes: + - MiniSpace.Services.MediaFiles/** + +# build_services_organizations: +# <<: *build_template +# variables: +# SERVICE_NAME: services-organizations +# SERVICE_PATH: MiniSpace.Services.Organizations +# rules: +# - if: '$CI_COMMIT_MESSAGE =~ /deploy-all/' +# - if: '$CI_COMMIT_MESSAGE !~ /deploy/' +# changes: +# - MiniSpace.Services.Organizations/** + +build_services_notifications: + <<: *build_template + variables: + SERVICE_NAME: services-notifications + SERVICE_PATH: MiniSpace.Services.Notifications + rules: + - if: '$CI_COMMIT_MESSAGE =~ /deploy-all/' + - if: '$CI_COMMIT_MESSAGE !~ /deploy/' + changes: + - MiniSpace.Services.Notifications/** + +build_services_reports: + <<: *build_template + variables: + SERVICE_NAME: services-reports + SERVICE_PATH: MiniSpace.Services.Reports + rules: + - if: '$CI_COMMIT_MESSAGE =~ /deploy-all/' + - if: '$CI_COMMIT_MESSAGE !~ /deploy/' + changes: + - MiniSpace.Services.Reports/** + +build_services_email: + <<: *build_template + variables: + SERVICE_NAME: services-email + SERVICE_PATH: MiniSpace.Services.Email + rules: + - if: '$CI_COMMIT_MESSAGE =~ /deploy-all/' + - if: '$CI_COMMIT_MESSAGE !~ /deploy/' + changes: + - MiniSpace.Services.Email/** + +deploy: + stage: deploy + image: docker:latest + script: + - apk add --no-cache openssh + - mkdir -p ~/.ssh + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - ssh-keyscan -H $PRODUCTION_SERVER_IP >> ~/.ssh/known_hosts + - scp ./MiniSpace/compose/services-prod.yml $PRODUCTION_SERVER_USER@$PRODUCTION_SERVER_IP:/root/social_net_app + - ssh $PRODUCTION_SERVER_USER@$PRODUCTION_SERVER_IP 'bash -s' < deploy.sh $GITLAB_TOKEN + rules: + - if: '$CI_COMMIT_BRANCH == "main" && $CI_COMMIT_MESSAGE =~ /deploy-all/' + - if: '$CI_COMMIT_BRANCH == "main" && $CI_COMMIT_MESSAGE =~ /deploy/' + - if: '$CI_COMMIT_BRANCH == "main" && $CI_COMMIT_MESSAGE !~ /deploy-all/ && $CI_COMMIT_MESSAGE !~ /deploy/' + changes: + - MiniSpace.APIGateway/** + - MiniSpace.Web/** + - MiniSpace.Services.Identity/** + - MiniSpace.Services.Events/** + - MiniSpace.Services.Students/** + - MiniSpace.Services.Friends/** + - MiniSpace.Services.Reactions/** + - MiniSpace.Services.Posts/** + - MiniSpace.Services.Comments/** + - MiniSpace.Services.MediaFiles/** + - MiniSpace.Services.Notifications/** + - MiniSpace.Services.Reports/** + - MiniSpace.Services.Email/** + # - MiniSpace.Services.Organizations/** \ No newline at end of file diff --git a/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada-async.docker.yml b/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada-async.docker.yml index 1e1a056ea..bb5ee68ae 100644 --- a/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada-async.docker.yml +++ b/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada-async.docker.yml @@ -164,6 +164,42 @@ modules: downstream: identity-service/password/reset auth: false + - upstream: /email/verify + method: POST + use: downstream + downstream: identity-service/email/verify + auth: false + + - upstream: /2fa/enable + method: POST + use: downstream + downstream: identity-service/2fa/enable + auth: true + + - upstream: /2fa/disable + method: POST + use: downstream + downstream: identity-service/2fa/disable + auth: true + + - upstream: /2fa/generate-secret + method: POST + use: downstream + downstream: identity-service/2fa/generate-secret + auth: true + + - upstream: /refresh-tokens/use + method: POST + use: downstream + downstream: identity-service/refresh-tokens/use + auth: false + + - upstream: /refresh-tokens/revoke + method: POST + use: downstream + downstream: identity-service/refresh-tokens/revoke + auth: false + services: identity-service: localUrl: localhost:5004 @@ -221,6 +257,7 @@ modules: use: downstream downstream: reports-service/reports/students/{studentId} auth: true + services: reports-service: @@ -273,6 +310,18 @@ modules: auth: true description: Updates the status of a specific notification. + - upstream: /notificationHub + method: GET + use: downstream + downstream: notifications-service/notificationHub + auth: false + + - upstream: /notificationHub/negotiate + method: POST + use: downstream + downstream: notifications-service/notificationHub/negotiate + auth: false + services: notifications-service: localUrl: localhost:5006 @@ -337,6 +386,18 @@ modules: downstream: students-service/students/{studentId}/events auth: true + - upstream: /{studentId}/notifications + method: GET + use: downstream + downstream: students-service/students/{studentId}/notifications + auth: true + + - upstream: /{studentId}/notifications + method: POST + use: downstream + downstream: students-service/students/{studentId}/notifications + auth: true + services: students-service: localUrl: localhost:5007 @@ -745,10 +806,10 @@ modules: use: downstream downstream: mediafiles-service/media-files/{mediaFileId}/original - - upstream: /{mediaFileId} + - upstream: /delete/{mediaFileUrl} method: DELETE use: downstream - downstream: mediafiles-service/media-files/{mediaFileId} + downstream: mediafiles-service/media-files/delete/{mediaFileUrl} auth: true services: diff --git a/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada-async.yml b/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada-async.yml index 1a3a7f587..017e20d1c 100644 --- a/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada-async.yml +++ b/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada-async.yml @@ -164,6 +164,42 @@ modules: downstream: identity-service/password/reset auth: false + - upstream: /email/verify + method: POST + use: downstream + downstream: identity-service/email/verify + auth: false + + - upstream: /2fa/enable + method: POST + use: downstream + downstream: identity-service/2fa/enable + auth: true + + - upstream: /2fa/disable + method: POST + use: downstream + downstream: identity-service/2fa/disable + auth: true + + - upstream: /2fa/generate-secret + method: POST + use: downstream + downstream: identity-service/2fa/generate-secret + auth: true + + - upstream: /refresh-tokens/use + method: POST + use: downstream + downstream: identity-service/refresh-tokens/use + auth: false + + - upstream: /refresh-tokens/revoke + method: POST + use: downstream + downstream: identity-service/refresh-tokens/revoke + auth: false + services: identity-service: localUrl: localhost:5004 @@ -273,6 +309,18 @@ modules: auth: true description: Updates the status of a specific notification. + - upstream: /notificationHub + method: GET + use: downstream + downstream: notifications-service/notificationHub + auth: false + + - upstream: /notificationHub/negotiate + method: POST + use: downstream + downstream: notifications-service/notificationHub/negotiate + auth: false + services: notifications-service: localUrl: localhost:5006 @@ -337,6 +385,18 @@ modules: downstream: students-service/students/{studentId}/events auth: true + - upstream: /{studentId}/notifications + method: GET + use: downstream + downstream: students-service/students/{studentId}/notifications + auth: true + + - upstream: /{studentId}/notifications + method: POST + use: downstream + downstream: students-service/students/{studentId}/notifications + auth: true + services: students-service: localUrl: localhost:5007 @@ -745,10 +805,10 @@ modules: use: downstream downstream: mediafiles-service/media-files/{mediaFileId}/original - - upstream: /{mediaFileId} + - upstream: /delete/{mediaFileUrl} method: DELETE use: downstream - downstream: mediafiles-service/media-files/{mediaFileId} + downstream: mediafiles-service/media-files/delete/{mediaFileUrl} auth: true services: diff --git a/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada.docker.yml b/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada.docker.yml index 1faf2a469..f834ef7ff 100644 --- a/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada.docker.yml +++ b/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada.docker.yml @@ -141,6 +141,42 @@ modules: downstream: identity-service/password/reset auth: false + - upstream: /email/verify + method: POST + use: downstream + downstream: identity-service/email/verify + auth: false + + - upstream: /2fa/enable + method: POST + use: downstream + downstream: identity-service/2fa/enable + auth: true + + - upstream: /2fa/disable + method: POST + use: downstream + downstream: identity-service/2fa/disable + auth: true + + - upstream: /2fa/generate-secret + method: POST + use: downstream + downstream: identity-service/2fa/generate-secret + auth: true + + - upstream: /refresh-tokens/use + method: POST + use: downstream + downstream: identity-service/refresh-tokens/use + auth: false + + - upstream: /refresh-tokens/revoke + method: POST + use: downstream + downstream: identity-service/refresh-tokens/revoke + auth: false + services: identity-service: localUrl: localhost:5004 @@ -250,6 +286,18 @@ modules: auth: true description: Updates the status of a specific notification. + - upstream: /notificationHub + method: GET + use: downstream + downstream: notifications-service/notificationHub + auth: false + + - upstream: /notificationHub/negotiate + method: POST + use: downstream + downstream: notifications-service/notificationHub/negotiate + auth: false + services: notifications-service: localUrl: localhost:5006 @@ -314,6 +362,18 @@ modules: downstream: students-service/students/{studentId}/events auth: true + - upstream: /{studentId}/notifications + method: GET + use: downstream + downstream: students-service/students/{studentId}/notifications + auth: true + + - upstream: /{studentId}/notifications + method: POST + use: downstream + downstream: students-service/students/{studentId}/notifications + auth: true + services: students-service: localUrl: localhost:5007 @@ -722,10 +782,10 @@ modules: use: downstream downstream: mediafiles-service/media-files/{mediaFileId}/original - - upstream: /{mediaFileId} + - upstream: /delete/{mediaFileUrl} method: DELETE use: downstream - downstream: mediafiles-service/media-files/{mediaFileId} + downstream: mediafiles-service/media-files/delete/{mediaFileUrl} auth: true services: diff --git a/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada.yml b/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada.yml index 3c16c73d5..fda5d6091 100644 --- a/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada.yml +++ b/MiniSpace.APIGateway/src/MiniSpace.APIGateway/ntrada.yml @@ -143,6 +143,42 @@ modules: downstream: identity-service/password/reset auth: false + - upstream: /email/verify + method: POST + use: downstream + downstream: identity-service/email/verify + auth: false + + - upstream: /2fa/enable + method: POST + use: downstream + downstream: identity-service/2fa/enable + auth: true + + - upstream: /2fa/disable + method: POST + use: downstream + downstream: identity-service/2fa/disable + auth: true + + - upstream: /2fa/generate-secret + method: POST + use: downstream + downstream: identity-service/2fa/generate-secret + auth: true + + - upstream: /refresh-tokens/use + method: POST + use: downstream + downstream: identity-service/refresh-tokens/use + auth: false + + - upstream: /refresh-tokens/revoke + method: POST + use: downstream + downstream: identity-service/refresh-tokens/revoke + auth: false + services: identity-service: localUrl: localhost:5004 @@ -252,6 +288,18 @@ modules: auth: true description: Updates the status of a specific notification. + # - upstream: /notificationHub + # method: GET + # use: downstream + # downstream: notifications-service/notificationHub + # auth: false + + - upstream: /notificationHub/negotiate + method: POST + use: downstream + downstream: notifications-service/notificationHub/negotiate + auth: false + services: notifications-service: localUrl: localhost:5006 @@ -316,6 +364,18 @@ modules: downstream: students-service/students/{studentId}/events auth: true + - upstream: /{studentId}/notifications + method: GET + use: downstream + downstream: students-service/students/{studentId}/notifications + auth: true + + - upstream: /{studentId}/notifications + method: POST + use: downstream + downstream: students-service/students/{studentId}/notifications + auth: true + services: students-service: localUrl: localhost:5007 @@ -430,6 +490,7 @@ modules: use: downstream downstream: events-service/events/{eventId}/participants auth: true + services: events-service: @@ -724,10 +785,10 @@ modules: use: downstream downstream: mediafiles-service/media-files/{mediaFileId}/original - - upstream: /{mediaFileId} + - upstream: /delete/{mediaFileUrl} method: DELETE use: downstream - downstream: mediafiles-service/media-files/{mediaFileId} + downstream: mediafiles-service/media-files/delete/{mediaFileUrl} auth: true services: diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Api/Program.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Api/Program.cs index ac5800cb6..f0f2149c6 100644 --- a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Api/Program.cs +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Api/Program.cs @@ -99,7 +99,26 @@ public static async Task Main(string[] args) await ctx.RequestServices.GetService().ResetPasswordAsync(cmd); ctx.Response.StatusCode = 204; }) - + .Post("email/verify", async (cmd, ctx) => + { + await ctx.RequestServices.GetService().VerifyEmailAsync(cmd); + ctx.Response.StatusCode = 204; + }) + .Post("2fa/enable", async (cmd, ctx) => + { + await ctx.RequestServices.GetService().EnableTwoFactorAsync(cmd); + ctx.Response.StatusCode = 204; + }) + .Post("2fa/disable", async (cmd, ctx) => + { + await ctx.RequestServices.GetService().DisableTwoFactorAsync(cmd); + ctx.Response.StatusCode = 204; + }) + .Post("2fa/generate-secret", async (cmd, ctx) => + { + var secret = await ctx.RequestServices.GetService().GenerateTwoFactorSecretAsync(cmd); + await ctx.Response.WriteJsonAsync(new { Secret = secret }); + }) )) .UseLogging() .Build() diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/DisableTwoFactor.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/DisableTwoFactor.cs new file mode 100644 index 000000000..a4c5706da --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/DisableTwoFactor.cs @@ -0,0 +1,15 @@ +using System; +using Convey.CQRS.Commands; + +namespace MiniSpace.Services.Identity.Application.Commands +{ + public class DisableTwoFactor : ICommand + { + public Guid UserId { get; } + + public DisableTwoFactor(Guid userId) + { + UserId = userId; + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/EnableTwoFactor.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/EnableTwoFactor.cs new file mode 100644 index 000000000..df091d6d4 --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/EnableTwoFactor.cs @@ -0,0 +1,17 @@ +using System; +using Convey.CQRS.Commands; + +namespace MiniSpace.Services.Identity.Application.Commands +{ + public class EnableTwoFactor : ICommand + { + public Guid UserId { get; } + public string Secret { get; } + + public EnableTwoFactor(Guid userId, string secret) + { + UserId = userId; + Secret = secret; + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/GenerateTwoFactorSecret.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/GenerateTwoFactorSecret.cs new file mode 100644 index 000000000..6c5c52999 --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/GenerateTwoFactorSecret.cs @@ -0,0 +1,15 @@ +using System; +using Convey.CQRS.Commands; + +namespace MiniSpace.Services.Identity.Application.Commands +{ + public class GenerateTwoFactorSecret : ICommand + { + public Guid UserId { get; } + + public GenerateTwoFactorSecret(Guid userId) + { + UserId = userId; + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/Handlers/DisableTwoFactorHandler.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/Handlers/DisableTwoFactorHandler.cs new file mode 100644 index 000000000..ca366c12a --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/Handlers/DisableTwoFactorHandler.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Convey.CQRS.Commands; +using MiniSpace.Services.Identity.Application.Services; +using Microsoft.Extensions.Logging; + +namespace MiniSpace.Services.Identity.Application.Commands.Handlers +{ + internal sealed class DisableTwoFactorHandler : ICommandHandler + { + private readonly IIdentityService _identityService; + private readonly ILogger _logger; + + public DisableTwoFactorHandler(IIdentityService identityService, ILogger logger) + { + _identityService = identityService; + _logger = logger; + } + + public async Task HandleAsync(DisableTwoFactor command) + { + await _identityService.DisableTwoFactorAsync(command); + _logger.LogInformation($"Two-factor authentication disabled for user ID: {command.UserId}"); + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/Handlers/EnableTwoFactorHandler.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/Handlers/EnableTwoFactorHandler.cs new file mode 100644 index 000000000..db3832713 --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/Handlers/EnableTwoFactorHandler.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Convey.CQRS.Commands; +using MiniSpace.Services.Identity.Application.Services; +using Microsoft.Extensions.Logging; + +namespace MiniSpace.Services.Identity.Application.Commands.Handlers +{ + internal sealed class EnableTwoFactorHandler : ICommandHandler + { + private readonly IIdentityService _identityService; + private readonly ILogger _logger; + + public EnableTwoFactorHandler(IIdentityService identityService, ILogger logger) + { + _identityService = identityService; + _logger = logger; + } + + public async Task HandleAsync(EnableTwoFactor command) + { + await _identityService.EnableTwoFactorAsync(command); + _logger.LogInformation($"Two-factor authentication enabled for user ID: {command.UserId}"); + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/Handlers/GenerateTwoFactorSecretHandler.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/Handlers/GenerateTwoFactorSecretHandler.cs new file mode 100644 index 000000000..1bece84e9 --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/Handlers/GenerateTwoFactorSecretHandler.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Convey.CQRS.Commands; +using MiniSpace.Services.Identity.Application.Services; +using Microsoft.Extensions.Logging; + +namespace MiniSpace.Services.Identity.Application.Commands.Handlers +{ + internal sealed class GenerateTwoFactorSecretHandler : ICommandHandler + { + private readonly IIdentityService _identityService; + private readonly ILogger _logger; + + public GenerateTwoFactorSecretHandler(IIdentityService identityService, ILogger logger) + { + _identityService = identityService; + _logger = logger; + } + + public async Task HandleAsync(GenerateTwoFactorSecret command) + { + var secret = await _identityService.GenerateTwoFactorSecretAsync(command); + _logger.LogInformation($"Generated a new two-factor authentication secret for user ID: {command.UserId}"); + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/Handlers/VerifyEmailHandler.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/Handlers/VerifyEmailHandler.cs new file mode 100644 index 000000000..eb3b3c344 --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/Handlers/VerifyEmailHandler.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Convey.CQRS.Commands; +using MiniSpace.Services.Identity.Application.Services; +using Microsoft.Extensions.Logging; + +namespace MiniSpace.Services.Identity.Application.Commands.Handlers +{ + internal sealed class VerifyEmailHandler : ICommandHandler + { + private readonly IIdentityService _identityService; + private readonly ILogger _logger; + + public VerifyEmailHandler(IIdentityService identityService, ILogger logger) + { + _identityService = identityService; + _logger = logger; + } + + public async Task HandleAsync(VerifyEmail command) + { + await _identityService.VerifyEmailAsync(command); + _logger.LogInformation($"Email verification for token: {command.Token}, email: {command.Email}, and hashed token: {command.HashedToken} processed."); + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/VerifyEmail.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/VerifyEmail.cs new file mode 100644 index 000000000..92c286f7a --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Commands/VerifyEmail.cs @@ -0,0 +1,19 @@ +using Convey.CQRS.Commands; + +namespace MiniSpace.Services.Identity.Application.Commands +{ + [Contract] + public class VerifyEmail : ICommand + { + public string Token { get; } + public string Email { get; } + public string HashedToken { get; } + + public VerifyEmail(string token, string email, string hashedToken) + { + Token = token; + Email = email; + HashedToken = hashedToken; + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/DTO/UserDto.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/DTO/UserDto.cs index 7a21be8b8..cf86a66d3 100644 --- a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/DTO/UserDto.cs +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/DTO/UserDto.cs @@ -14,6 +14,10 @@ public class UserDto public string Role { get; set; } public DateTime CreatedAt { get; set; } public IEnumerable Permissions { get; set; } + public bool IsEmailVerified { get; set; } + public DateTime? EmailVerifiedAt { get; set; } + public bool IsTwoFactorEnabled { get; set; } + public string TwoFactorSecret { get; set; } public UserDto() { @@ -27,6 +31,10 @@ public UserDto(User user) Role = user.Role; CreatedAt = user.CreatedAt; Permissions = user.Permissions; + IsEmailVerified = user.IsEmailVerified; + EmailVerifiedAt = user.EmailVerifiedAt; + IsTwoFactorEnabled = user.IsTwoFactorEnabled; + TwoFactorSecret = user.TwoFactorSecret; } } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/EmailVerified.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/EmailVerified.cs new file mode 100644 index 000000000..f46dcff50 --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/EmailVerified.cs @@ -0,0 +1,19 @@ +using System; +using Convey.CQRS.Events; + +namespace MiniSpace.Services.Identity.Application.Events +{ + public class EmailVerified : IEvent + { + public Guid UserId { get; } + public string Email { get; } + public DateTime VerifiedAt { get; } + + public EmailVerified(Guid userId, string email, DateTime verifiedAt) + { + UserId = userId; + Email = email; + VerifiedAt = verifiedAt; + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/Rejected/DisableTwoFactorRejected.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/Rejected/DisableTwoFactorRejected.cs new file mode 100644 index 000000000..947d4e016 --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/Rejected/DisableTwoFactorRejected.cs @@ -0,0 +1,20 @@ +using System; +using Convey.CQRS.Events; + +namespace MiniSpace.Services.Identity.Application.Events.Rejected +{ + [Contract] + public class DisableTwoFactorRejected : IRejectedEvent + { + public Guid UserId { get; } + public string Reason { get; } + public string Code { get; } + + public DisableTwoFactorRejected(Guid userId, string reason, string code) + { + UserId = userId; + Reason = reason; + Code = code; + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/Rejected/EmailVerificationRejected.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/Rejected/EmailVerificationRejected.cs new file mode 100644 index 000000000..c68a86140 --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/Rejected/EmailVerificationRejected.cs @@ -0,0 +1,19 @@ +using Convey.CQRS.Events; + +namespace MiniSpace.Services.Identity.Application.Events.Rejected +{ + [Contract] + public class EmailVerificationRejected : IRejectedEvent + { + public string Email { get; } + public string Reason { get; } + public string Code { get; } + + public EmailVerificationRejected(string email, string reason, string code) + { + Email = email; + Reason = reason; + Code = code; + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/Rejected/EnableTwoFactorRejected.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/Rejected/EnableTwoFactorRejected.cs new file mode 100644 index 000000000..26a39d002 --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/Rejected/EnableTwoFactorRejected.cs @@ -0,0 +1,20 @@ +using System; +using Convey.CQRS.Events; + +namespace MiniSpace.Services.Identity.Application.Events.Rejected +{ + [Contract] + public class EnableTwoFactorRejected : IRejectedEvent + { + public Guid UserId { get; } + public string Reason { get; } + public string Code { get; } + + public EnableTwoFactorRejected(Guid userId, string reason, string code) + { + UserId = userId; + Reason = reason; + Code = code; + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/SignedUp.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/SignedUp.cs index 14053ced5..9b715488c 100644 --- a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/SignedUp.cs +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/SignedUp.cs @@ -11,14 +11,18 @@ public class SignedUp : IEvent public string LastName { get; } public string Email { get; } public string Role { get; } - - public SignedUp(Guid userId, string firstName, string lastName, string email, string role) + public string Token { get; } + public string HashedToken { get; } + + public SignedUp(Guid userId, string firstName, string lastName, string email, string role, string token, string hashedToken) { UserId = userId; FirstName = firstName; LastName = lastName; Email = email; Role = role; + Token = token; + HashedToken = hashedToken; } } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/TwoFactorAuthenticationDisabled.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/TwoFactorAuthenticationDisabled.cs new file mode 100644 index 000000000..c8d2352ac --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/TwoFactorAuthenticationDisabled.cs @@ -0,0 +1,15 @@ +using System; +using Convey.CQRS.Events; + +namespace MiniSpace.Services.Identity.Application.Events +{ + public class TwoFactorAuthenticationDisabled : IEvent + { + public Guid UserId { get; } + + public TwoFactorAuthenticationDisabled(Guid userId) + { + UserId = userId; + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/TwoFactorAuthenticationEnabled.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/TwoFactorAuthenticationEnabled.cs new file mode 100644 index 000000000..23f27edf0 --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Events/TwoFactorAuthenticationEnabled.cs @@ -0,0 +1,17 @@ +using System; +using Convey.CQRS.Events; + +namespace MiniSpace.Services.Identity.Application.Events +{ + public class TwoFactorAuthenticationEnabled : IEvent + { + public Guid UserId { get; } + public string Secret { get; } + + public TwoFactorAuthenticationEnabled(Guid userId, string secret) + { + UserId = userId; + Secret = secret; + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Exceptions/UserNotFoundByEmailException.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Exceptions/UserNotFoundByEmailException.cs new file mode 100644 index 000000000..f8d77bbdf --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Exceptions/UserNotFoundByEmailException.cs @@ -0,0 +1,15 @@ +using System; + +namespace MiniSpace.Services.Identity.Application.Exceptions +{ + public class UserNotFoundByEmailException : AppException + { + public override string Code { get; } = "user_not_found_by_email"; + public string Email { get; } + + public UserNotFoundByEmailException(string email) : base($"User with email: '{email}' was not found.") + { + Email = email; + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Services/IIdentityService.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Services/IIdentityService.cs index 593088019..f7de1ed96 100644 --- a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Services/IIdentityService.cs +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Services/IIdentityService.cs @@ -16,5 +16,10 @@ public interface IIdentityService Task UnbanUserAsync(UnbanUser command); Task ForgotPasswordAsync(ForgotPassword command); Task ResetPasswordAsync(ResetPassword command); + + Task VerifyEmailAsync(VerifyEmail command); + Task EnableTwoFactorAsync(EnableTwoFactor command); + Task DisableTwoFactorAsync(DisableTwoFactor command); + Task GenerateTwoFactorSecretAsync(GenerateTwoFactorSecret command); } } \ No newline at end of file diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Services/ITwoFactorSecretTokenService.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Services/ITwoFactorSecretTokenService.cs new file mode 100644 index 000000000..405377c4a --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Services/ITwoFactorSecretTokenService.cs @@ -0,0 +1,7 @@ +namespace MiniSpace.Services.Identity.Application.Services +{ + public interface ITwoFactorSecretTokenService + { + string GenerateSecret(); + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Services/IVerificationTokenService.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Services/IVerificationTokenService.cs new file mode 100644 index 000000000..777722fd4 --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Services/IVerificationTokenService.cs @@ -0,0 +1,10 @@ +using System; + +namespace MiniSpace.Services.Identity.Application.Services +{ + public interface IVerificationTokenService + { + (string Token, string HashedToken) GenerateToken(Guid userId, string email); + bool ValidateToken(string token, string hashedToken); + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Services/Identity/IdentityService.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Services/Identity/IdentityService.cs index e39dd1a6a..e9d70faaa 100644 --- a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Services/Identity/IdentityService.cs +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Application/Services/Identity/IdentityService.cs @@ -1,233 +1,313 @@ - using System; - using System.Collections.Generic; - using System.Linq; - using System.Text.RegularExpressions; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - using MiniSpace.Services.Identity.Application.Commands; - using MiniSpace.Services.Identity.Application.DTO; - using MiniSpace.Services.Identity.Application.Events; - using MiniSpace.Services.Identity.Application.Exceptions; - using MiniSpace.Services.Identity.Core.Entities; - using MiniSpace.Services.Identity.Core.Exceptions; - using MiniSpace.Services.Identity.Core.Repositories; - - namespace MiniSpace.Services.Identity.Application.Services.Identity +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using MiniSpace.Services.Identity.Application.Commands; +using MiniSpace.Services.Identity.Application.DTO; +using MiniSpace.Services.Identity.Application.Events; +using MiniSpace.Services.Identity.Application.Events.Rejected; +using MiniSpace.Services.Identity.Application.Exceptions; +using MiniSpace.Services.Identity.Core.Entities; +using MiniSpace.Services.Identity.Core.Exceptions; +using MiniSpace.Services.Identity.Core.Repositories; + +namespace MiniSpace.Services.Identity.Application.Services.Identity +{ + public class IdentityService : IIdentityService { - public class IdentityService : IIdentityService + private static readonly Regex EmailRegex = new Regex( + @"^(?("")("".+?(? _logger; + + public IdentityService(IUserRepository userRepository, IPasswordService passwordService, + IJwtProvider jwtProvider, IRefreshTokenService refreshTokenService, + IMessageBroker messageBroker, IUserResetTokenRepository userResetTokenRepository, + IVerificationTokenService verificationTokenService, ITwoFactorSecretTokenService twoFactorSecretTokenService, ILogger logger) + { + _userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository)); + _passwordService = passwordService ?? throw new ArgumentNullException(nameof(passwordService)); + _jwtProvider = jwtProvider ?? throw new ArgumentNullException(nameof(jwtProvider)); + _refreshTokenService = refreshTokenService ?? throw new ArgumentNullException(nameof(refreshTokenService)); + _messageBroker = messageBroker ?? throw new ArgumentNullException(nameof(messageBroker)); + _userResetTokenRepository = userResetTokenRepository ?? throw new ArgumentNullException(nameof(userResetTokenRepository)); + _verificationTokenService = verificationTokenService ?? throw new ArgumentNullException(nameof(verificationTokenService)); + _twoFactorSecretTokenService = twoFactorSecretTokenService ?? throw new ArgumentNullException(nameof(twoFactorSecretTokenService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetAsync(Guid id) + { + var user = await _userRepository.GetAsync(id); + return user is null ? null : new UserDto(user); + } + + public async Task SignInAsync(SignIn command) + { + if (!EmailRegex.IsMatch(command.Email)) + { + _logger.LogError($"Invalid email: {command.Email}"); + throw new InvalidEmailException(command.Email); + } + + var user = await _userRepository.GetAsync(command.Email); + if (user is null || !_passwordService.IsValid(user.Password, command.Password)) + { + _logger.LogError($"User with email: {command.Email} was not found."); + throw new InvalidCredentialsException(command.Email); + } + + if (!_passwordService.IsValid(user.Password, command.Password)) + { + _logger.LogError($"Invalid password for user with id: {user.Id}"); + throw new InvalidCredentialsException(command.Email); + } + + var claims = new Dictionary> + { + ["name"] = new[] { user.Name }, + ["e-mail"] = new[] { user.Email } + }; + if (user.Permissions.Any()) + { + claims.Add("permissions", user.Permissions); + } + var auth = _jwtProvider.Create(user.Id, user.Role, claims: claims); + auth.RefreshToken = await _refreshTokenService.CreateAsync(user.Id); + + _logger.LogInformation($"User with id: {user.Id} has been authenticated."); + await _messageBroker.PublishAsync(new SignedIn(user.Id, user.Role)); + + return auth; + } + + public async Task SignUpAsync(SignUp command) { - private static readonly Regex EmailRegex = new Regex( - @"^(?("")("".+?(? _logger; - - public IdentityService(IUserRepository userRepository, IPasswordService passwordService, - IJwtProvider jwtProvider, IRefreshTokenService refreshTokenService, - IMessageBroker messageBroker, IUserResetTokenRepository userResetTokenRepository, ILogger logger) - { - _userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository)); - _passwordService = passwordService ?? throw new ArgumentNullException(nameof(passwordService)); - _jwtProvider = jwtProvider ?? throw new ArgumentNullException(nameof(jwtProvider)); - _refreshTokenService = refreshTokenService ?? throw new ArgumentNullException(nameof(refreshTokenService)); - _messageBroker = messageBroker ?? throw new ArgumentNullException(nameof(messageBroker)); - _userResetTokenRepository = userResetTokenRepository ?? throw new ArgumentNullException(nameof(userResetTokenRepository)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - - public async Task GetAsync(Guid id) - { - var user = await _userRepository.GetAsync(id); - - return user is null ? null : new UserDto(user); - } - - public async Task SignInAsync(SignIn command) - { - if (!EmailRegex.IsMatch(command.Email)) - { - _logger.LogError($"Invalid email: {command.Email}"); - throw new InvalidEmailException(command.Email); - } - - var user = await _userRepository.GetAsync(command.Email); - if (user is null || !_passwordService.IsValid(user.Password, command.Password)) - { - _logger.LogError($"User with email: {command.Email} was not found."); - throw new InvalidCredentialsException(command.Email); - } - - if (!_passwordService.IsValid(user.Password, command.Password)) - { - _logger.LogError($"Invalid password for user with id: {user.Id.Value}"); - throw new InvalidCredentialsException(command.Email); - } - - var claims = new Dictionary> - { - ["name"] = new[] { user.Name }, - ["e-mail"] = new[] { user.Email } - }; - if(user.Permissions.Any()) - { - claims.Add("permissions", user.Permissions); - } - var auth = _jwtProvider.Create(user.Id, user.Role, claims: claims); - auth.RefreshToken = await _refreshTokenService.CreateAsync(user.Id); - - _logger.LogInformation($"User with id: {user.Id} has been authenticated."); - await _messageBroker.PublishAsync(new SignedIn(user.Id, user.Role)); - - return auth; - } - - public async Task SignUpAsync(SignUp command) - { - if (!EmailRegex.IsMatch(command.Email)) - { - _logger.LogError($"Invalid email: {command.Email}"); - throw new InvalidEmailException(command.Email); - } - - var user = await _userRepository.GetAsync(command.Email); - if (user is {}) - { - _logger.LogError($"Email already in use: {command.Email}"); - throw new EmailInUseException(command.Email); - } - - var role = string.IsNullOrWhiteSpace(command.Role) ? "user" : command.Role.ToLowerInvariant(); - var password = _passwordService.Hash(command.Password); - user = new User(command.UserId, $"{command.FirstName} {command.LastName}", command.Email, password, - role, DateTime.UtcNow, command.Permissions); - await _userRepository.AddAsync(user); - - _logger.LogInformation($"Created an account for the user with id: {user.Id}."); - await _messageBroker.PublishAsync(new SignedUp(user.Id, command.FirstName, command.LastName, - user.Email, user.Role)); - } - - public async Task GrantOrganizerRightsAsync(GrantOrganizerRights command) - { - var user = await _userRepository.GetAsync(command.UserId); - if (user is null) - { - _logger.LogError($"User with id: {command.UserId} was not found."); - throw new UserNotFoundException(command.UserId); - } - - user.GrantOrganizerRights(); - await _userRepository.UpdateAsync(user); - - _logger.LogInformation($"Granted organizer rights to the user with id: {user.Id}."); - await _messageBroker.PublishAsync(new OrganizerRightsGranted(user.Id)); - } - - public async Task RevokeOrganizerRightsAsync(RevokeOrganizerRights command) - { - var user = await _userRepository.GetAsync(command.UserId); - if (user is null) - { - _logger.LogError($"User with id: {command.UserId} was not found."); - throw new UserNotFoundException(command.UserId); - } - - user.RevokeOrganizerRights(); - await _userRepository.UpdateAsync(user); - - _logger.LogInformation($"Revoked organizer rights from the user with id: {user.Id}."); - await _messageBroker.PublishAsync(new OrganizerRightsRevoked(user.Id)); - } - - public async Task BanUserAsync(BanUser command) - { - var user = await _userRepository.GetAsync(command.UserId); - if (user is null) - { - _logger.LogError($"User with id: {command.UserId} was not found."); - throw new UserNotFoundException(command.UserId); - } - - user.Ban(); - await _userRepository.UpdateAsync(user); - - _logger.LogInformation($"Banned the user with id: {user.Id}."); - await _messageBroker.PublishAsync(new UserBanned(user.Id)); - } - - public async Task UnbanUserAsync(UnbanUser command) - { - var user = await _userRepository.GetAsync(command.UserId); - if (user is null) - { - _logger.LogError($"User with id: {command.UserId} was not found."); - throw new UserNotFoundException(command.UserId); - } - - user.Unban(); - await _userRepository.UpdateAsync(user); - - _logger.LogInformation($"Unbanned the user with id: {user.Id}."); - await _messageBroker.PublishAsync(new UserUnbanned(user.Id)); - } - - public async Task ForgotPasswordAsync(ForgotPassword command) - { - var user = await _userRepository.GetAsync(command.Email); - if (user == null) - { - _logger.LogError($"No user associated with email: {command.Email}"); - throw new UserNotFoundException(command.UserId); - } - - var resetToken = _jwtProvider.GenerateResetToken(user.Id); - var userResetToken = new UserResetToken(user.Id, resetToken, DateTime.UtcNow.AddDays(1)); - await _messageBroker.PublishAsync(new PasswordResetTokenGenerated(user.Id, command.Email, resetToken)); - - _logger.LogInformation($"Reset token generated for user id: {user.Id}"); - - await _userResetTokenRepository.SaveAsync(userResetToken); - - } - - public async Task ResetPasswordAsync(ResetPassword command) - { - if (command.UserId == Guid.Empty) - { - _logger.LogError("Reset password attempt failed: User ID is empty."); - throw new UserNotFoundException(command.UserId); - } - - _logger.LogInformation("Fetching user reset token from repository..."); - var userResetToken = await _userResetTokenRepository.GetByUserIdAsync(command.UserId); - - if (userResetToken == null || !userResetToken.ResetTokenIsValid(command.Token)) - { - _logger.LogError($"Invalid or expired reset token for user ID: {command.UserId}"); - throw new InvalidTokenException(); - } - - var user = await _userRepository.GetAsync(userResetToken.UserId); - if (user == null) - { - _logger.LogError($"User not found for ID: {command.UserId}"); - throw new UserNotFoundException(command.UserId); - } - - _logger.LogInformation("Updating user's password..."); - user.Password = _passwordService.Hash(command.NewPassword); - await _userRepository.UpdateAsync(user); - await _userResetTokenRepository.InvalidateTokenAsync(user.Id); + if (!EmailRegex.IsMatch(command.Email)) + { + _logger.LogError($"Invalid email: {command.Email}"); + throw new InvalidEmailException(command.Email); + } - await _messageBroker.PublishAsync(new PasswordReset(user.Id)); + var user = await _userRepository.GetAsync(command.Email); + if (user is {}) + { + _logger.LogError($"Email already in use: {command.Email}"); + throw new EmailInUseException(command.Email); } + + var role = string.IsNullOrWhiteSpace(command.Role) ? "user" : command.Role.ToLowerInvariant(); + var password = _passwordService.Hash(command.Password); + user = new User(command.UserId, $"{command.FirstName} {command.LastName}", command.Email, password, + role, DateTime.UtcNow, command.Permissions); + + // Generate email verification token and hashed token + var (token, hashedToken) = _verificationTokenService.GenerateToken(user.Id, user.Email); + user.SetEmailVerificationToken(hashedToken); + + await _userRepository.AddAsync(user); + + _logger.LogInformation($"Created an account for the user with id: {user.Id}."); + + await _messageBroker.PublishAsync(new SignedUp(user.Id, command.FirstName, command.LastName, + user.Email, user.Role, token, hashedToken)); + } + + public async Task GrantOrganizerRightsAsync(GrantOrganizerRights command) + { + var user = await _userRepository.GetAsync(command.UserId); + if (user is null) + { + _logger.LogError($"User with id: {command.UserId} was not found."); + throw new UserNotFoundException(command.UserId); + } + + user.GrantOrganizerRights(); + await _userRepository.UpdateAsync(user); + + _logger.LogInformation($"Granted organizer rights to the user with id: {user.Id}."); + await _messageBroker.PublishAsync(new OrganizerRightsGranted(user.Id)); + } + + public async Task RevokeOrganizerRightsAsync(RevokeOrganizerRights command) + { + var user = await _userRepository.GetAsync(command.UserId); + if (user is null) + { + _logger.LogError($"User with id: {command.UserId} was not found."); + throw new UserNotFoundException(command.UserId); + } + + user.RevokeOrganizerRights(); + await _userRepository.UpdateAsync(user); + + _logger.LogInformation($"Revoked organizer rights from the user with id: {user.Id}."); + await _messageBroker.PublishAsync(new OrganizerRightsRevoked(user.Id)); + } + + public async Task BanUserAsync(BanUser command) + { + var user = await _userRepository.GetAsync(command.UserId); + if (user is null) + { + _logger.LogError($"User with id: {command.UserId} was not found."); + throw new UserNotFoundException(command.UserId); + } + + user.Ban(); + await _userRepository.UpdateAsync(user); + + _logger.LogInformation($"Banned the user with id: {user.Id}."); + await _messageBroker.PublishAsync(new UserBanned(user.Id)); + } + + public async Task UnbanUserAsync(UnbanUser command) + { + var user = await _userRepository.GetAsync(command.UserId); + if (user is null) + { + _logger.LogError($"User with id: {command.UserId} was not found."); + throw new UserNotFoundException(command.UserId); + } + + user.Unban(); + await _userRepository.UpdateAsync(user); + + _logger.LogInformation($"Unbanned the user with id: {user.Id}."); + await _messageBroker.PublishAsync(new UserUnbanned(user.Id)); + } + + public async Task ForgotPasswordAsync(ForgotPassword command) + { + var user = await _userRepository.GetAsync(command.Email); + if (user == null) + { + _logger.LogError($"No user associated with email: {command.Email}"); + throw new UserNotFoundException(command.UserId); + } + + var resetToken = _jwtProvider.GenerateResetToken(user.Id); + var userResetToken = new UserResetToken(user.Id, resetToken, DateTime.UtcNow.AddDays(1)); + await _messageBroker.PublishAsync(new PasswordResetTokenGenerated(user.Id, command.Email, resetToken)); + + _logger.LogInformation($"Reset token generated for user id: {user.Id}"); + + await _userResetTokenRepository.SaveAsync(userResetToken); + } + + public async Task ResetPasswordAsync(ResetPassword command) + { + if (command.UserId == Guid.Empty) + { + _logger.LogError("Reset password attempt failed: User ID is empty."); + throw new UserNotFoundException(command.UserId); + } + + _logger.LogInformation("Fetching user reset token from repository..."); + var userResetToken = await _userResetTokenRepository.GetByUserIdAsync(command.UserId); + + if (userResetToken == null || !userResetToken.ResetTokenIsValid(command.Token)) + { + _logger.LogError($"Invalid or expired reset token for user ID: {command.UserId}"); + throw new InvalidTokenException(); + } + + var user = await _userRepository.GetAsync(userResetToken.UserId); + if (user == null) + { + _logger.LogError($"User not found for ID: {command.UserId}"); + throw new UserNotFoundException(command.UserId); + } + + _logger.LogInformation("Updating user's password..."); + user.Password = _passwordService.Hash(command.NewPassword); + await _userRepository.UpdateAsync(user); + await _userResetTokenRepository.InvalidateTokenAsync(user.Id); + + await _messageBroker.PublishAsync(new PasswordReset(user.Id)); + } + + public async Task VerifyEmailAsync(VerifyEmail command) + { + var user = await _userRepository.GetAsync(command.Email); + if (user == null) + { + _logger.LogError($"No user associated with email: {command.Email}"); + throw new UserNotFoundByEmailException(command.Email); + } + + if (!_verificationTokenService.ValidateToken(command.Token, command.HashedToken)) + { + _logger.LogError($"Invalid verification token for email: {command.Email}"); + throw new InvalidTokenException(); + } + + user.VerifyEmail(); + await _userRepository.UpdateAsync(user); + + _logger.LogInformation($"Email verified for user id: {user.Id}"); + await _messageBroker.PublishAsync(new EmailVerified(user.Id, user.Email, DateTime.UtcNow)); + } + + + public async Task EnableTwoFactorAsync(EnableTwoFactor command) + { + var user = await _userRepository.GetAsync(command.UserId); + if (user == null) + { + _logger.LogError($"User with id: {command.UserId} was not found."); + throw new UserNotFoundException(command.UserId); + } + + user.EnableTwoFactorAuthentication(command.Secret); + await _userRepository.UpdateAsync(user); + + _logger.LogInformation($"Two-factor authentication enabled for user id: {user.Id}"); + await _messageBroker.PublishAsync(new TwoFactorAuthenticationEnabled(user.Id, command.Secret)); + } + + public async Task DisableTwoFactorAsync(DisableTwoFactor command) + { + var user = await _userRepository.GetAsync(command.UserId); + if (user == null) + { + _logger.LogError($"User with id: {command.UserId} was not found."); + throw new UserNotFoundException(command.UserId); + } + + user.DisableTwoFactorAuthentication(); + await _userRepository.UpdateAsync(user); + + _logger.LogInformation($"Two-factor authentication disabled for user id: {user.Id}"); + await _messageBroker.PublishAsync(new TwoFactorAuthenticationDisabled(user.Id)); + } + + public async Task GenerateTwoFactorSecretAsync(GenerateTwoFactorSecret command) + { + var user = await _userRepository.GetAsync(command.UserId); + if (user == null) + { + throw new UserNotFoundException(command.UserId); + } + + var secret = _twoFactorSecretTokenService.GenerateSecret(); + + user.SetTwoFactorSecret(secret); + await _userRepository.UpdateAsync(user); + + return secret; } - } \ No newline at end of file + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Entities/User.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Entities/User.cs index 90477307b..03c2976b0 100644 --- a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Entities/User.cs +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Entities/User.cs @@ -13,11 +13,16 @@ public class User : AggregateRoot public string Password { get; set; } public DateTime CreatedAt { get; private set; } public IEnumerable Permissions { get; private set; } + public bool IsEmailVerified { get; set; } + public string EmailVerificationToken { get; set; } + public DateTime? EmailVerifiedAt { get; set; } + public bool IsTwoFactorEnabled { get; set; } + public string TwoFactorSecret { get; set; } public User(Guid id, string name, string email, string password, string role, DateTime createdAt, IEnumerable permissions = null) { - if(string.IsNullOrWhiteSpace(name)) + if (string.IsNullOrWhiteSpace(name)) { throw new InvalidNameException(name); } @@ -45,6 +50,18 @@ public User(Guid id, string name, string email, string password, string role, Da CreatedAt = createdAt; Permissions = permissions ?? Enumerable.Empty(); } + + internal User(Guid id, string name, string email, string password, string role, DateTime createdAt, + bool isEmailVerified, string emailVerificationToken, DateTime? emailVerifiedAt, + bool isTwoFactorEnabled, string twoFactorSecret, IEnumerable permissions = null) + : this(id, name, email, password, role, createdAt, permissions) + { + IsEmailVerified = isEmailVerified; + EmailVerificationToken = emailVerificationToken; + EmailVerifiedAt = emailVerifiedAt; + IsTwoFactorEnabled = isTwoFactorEnabled; + TwoFactorSecret = twoFactorSecret; + } public void GrantOrganizerRights() { @@ -85,10 +102,59 @@ public void Unban() Role = Entities.Role.User; } + + public void SetEmailVerificationToken(string token) + { + if (IsEmailVerified) + { + throw new EmailAlreadyVerifiedException(); + } + + EmailVerificationToken = token; + } + + public void VerifyEmail() + { + if (IsEmailVerified) + { + throw new EmailAlreadyVerifiedException(); + } + + IsEmailVerified = true; + EmailVerificationToken = null; + EmailVerifiedAt = DateTime.UtcNow; + } + + public void EnableTwoFactorAuthentication(string secret) + { + if (IsTwoFactorEnabled) + { + throw new TwoFactorAlreadyEnabledException(Id); + } + + IsTwoFactorEnabled = true; + TwoFactorSecret = secret; + } + + public void DisableTwoFactorAuthentication() + { + if (!IsTwoFactorEnabled) + { + throw new TwoFactorNotEnabledException(Id); + } + + IsTwoFactorEnabled = false; + TwoFactorSecret = null; + } + + public void SetTwoFactorSecret(string secret) + { + TwoFactorSecret = secret; + } } public static class UserPermissions { public static string OrganizeEvents { get; private set; } = "organize_events"; } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Exceptions/EmailAlreadyVerifiedException.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Exceptions/EmailAlreadyVerifiedException.cs new file mode 100644 index 000000000..128248639 --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Exceptions/EmailAlreadyVerifiedException.cs @@ -0,0 +1,13 @@ +using System; + +namespace MiniSpace.Services.Identity.Core.Exceptions +{ + public class EmailAlreadyVerifiedException : DomainException + { + public override string Code { get; } = "email_already_verified"; + + public EmailAlreadyVerifiedException() : base("Email is already verified.") + { + } + } +} \ No newline at end of file diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Exceptions/InvalidVerificationTokenException.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Exceptions/InvalidVerificationTokenException.cs new file mode 100644 index 000000000..1a10242bc --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Exceptions/InvalidVerificationTokenException.cs @@ -0,0 +1,14 @@ +using System; + +namespace MiniSpace.Services.Identity.Core.Exceptions +{ + public class InvalidVerificationTokenException : DomainException + { + public override string Code { get; } = "invalid_verification_token"; + + public InvalidVerificationTokenException() : base("Invalid or expired verification token.") + { + } + } + +} \ No newline at end of file diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Exceptions/TwoFactorAlreadyEnabledException.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Exceptions/TwoFactorAlreadyEnabledException.cs new file mode 100644 index 000000000..59e2a33c7 --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Exceptions/TwoFactorAlreadyEnabledException.cs @@ -0,0 +1,16 @@ +using System; + +namespace MiniSpace.Services.Identity.Core.Exceptions +{ + public class TwoFactorAlreadyEnabledException : DomainException + { + public override string Code { get; } = "two_factor_already_enabled"; + public Guid UserId { get; } + + public TwoFactorAlreadyEnabledException(Guid userId) + : base($"Two-factor authentication is already enabled for user with ID: {userId}.") + { + UserId = userId; + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Exceptions/TwoFactorNotEnabledException.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Exceptions/TwoFactorNotEnabledException.cs new file mode 100644 index 000000000..3ca834ad5 --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Core/Exceptions/TwoFactorNotEnabledException.cs @@ -0,0 +1,16 @@ +using System; + +namespace MiniSpace.Services.Identity.Core.Exceptions +{ + public class TwoFactorNotEnabledException : DomainException + { + public override string Code { get; } = "two_factor_not_enabled"; + public Guid UserId { get; } + + public TwoFactorNotEnabledException(Guid userId) + : base($"Two-factor authentication is not enabled for user with ID: {userId}.") + { + UserId = userId; + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Auth/TwoFactorSecretTokenService.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Auth/TwoFactorSecretTokenService.cs new file mode 100644 index 000000000..5ac06fe0d --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Auth/TwoFactorSecretTokenService.cs @@ -0,0 +1,78 @@ +using MiniSpace.Services.Identity.Application.Services; +using System; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace MiniSpace.Services.Identity.Infrastructure.Auth +{ + public class TwoFactorSecretTokenService : ITwoFactorSecretTokenService + { + private static readonly char[] Base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".ToCharArray(); + + public string GenerateSecret() + { + byte[] bytes = new byte[20]; + using (var rng = new RNGCryptoServiceProvider()) + { + rng.GetBytes(bytes); + } + return ToBase32(bytes); + } + + private static string ToBase32(byte[] bytes) + { + StringBuilder base32 = new StringBuilder((bytes.Length * 8 + 4) / 5); + + for (int i = 0; i < bytes.Length;) + { + int currentByte = bytes[i++]; + int digit; + + base32.Append(Base32Chars[(currentByte & 0xF8) >> 3]); + digit = (currentByte & 0x07) << 2; + if (i >= bytes.Length) + { + base32.Append(Base32Chars[digit]); + break; + } + currentByte = bytes[i++]; + digit |= (currentByte & 0xC0) >> 6; + base32.Append(Base32Chars[digit]); + base32.Append(Base32Chars[(currentByte & 0x3E) >> 1]); + digit = (currentByte & 0x01) << 4; + if (i >= bytes.Length) + { + base32.Append(Base32Chars[digit]); + break; + } + currentByte = bytes[i++]; + digit |= (currentByte & 0xF0) >> 4; + base32.Append(Base32Chars[digit]); + digit = (currentByte & 0x0F) << 1; + if (i >= bytes.Length) + { + base32.Append(Base32Chars[digit]); + break; + } + currentByte = bytes[i++]; + digit |= (currentByte & 0x80) >> 7; + base32.Append(Base32Chars[digit]); + base32.Append(Base32Chars[(currentByte & 0x7C) >> 2]); + digit = (currentByte & 0x03) << 3; + if (i >= bytes.Length) + { + base32.Append(Base32Chars[digit]); + break; + } + currentByte = bytes[i++]; + digit |= (currentByte & 0xE0) >> 5; + base32.Append(Base32Chars[digit]); + base32.Append(Base32Chars[currentByte & 0x1F]); + } + + return base32.ToString(); + } + } +} + diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Auth/VerificationTokenService.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Auth/VerificationTokenService.cs new file mode 100644 index 000000000..c4eecbaf9 --- /dev/null +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Auth/VerificationTokenService.cs @@ -0,0 +1,42 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Identity; +using MiniSpace.Services.Identity.Application.Services; + +namespace MiniSpace.Services.Identity.Infrastructure.Auth +{ + public class VerificationTokenService : IVerificationTokenService + { + private readonly IPasswordHasher _passwordHasher; + + public VerificationTokenService(IPasswordHasher passwordHasher) + { + _passwordHasher = passwordHasher; + } + + public (string Token, string HashedToken) GenerateToken(Guid userId, string email) + { + var token = GenerateTokenString(userId, email); + var hashedToken = _passwordHasher.HashPassword(this, token); + return (token, hashedToken); + } + + public bool ValidateToken(string token, string hashedToken) + { + return _passwordHasher.VerifyHashedPassword(this, hashedToken, token) != PasswordVerificationResult.Failed; + } + + private string GenerateTokenString(Guid userId, string email) + { + using (var rng = new RNGCryptoServiceProvider()) + { + var tokenData = new byte[32]; + rng.GetBytes(tokenData); + var token = Convert.ToBase64String(tokenData); + var combined = $"{userId}{email}{token}"; + return Convert.ToBase64String(Encoding.UTF8.GetBytes(combined)); + } + } + } +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Exceptions/ExceptionToMessageMapper.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Exceptions/ExceptionToMessageMapper.cs index 96422251e..561fc75ca 100644 --- a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Exceptions/ExceptionToMessageMapper.cs +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Exceptions/ExceptionToMessageMapper.cs @@ -3,6 +3,7 @@ using Convey.MessageBrokers.RabbitMQ; using MiniSpace.Services.Identity.Application.Commands; using MiniSpace.Services.Identity.Application.Events.Rejected; +using MiniSpace.Services.Identity.Application.Exceptions; using MiniSpace.Services.Identity.Core.Exceptions; namespace MiniSpace.Services.Identity.Infrastructure.Exceptions @@ -12,17 +13,30 @@ internal sealed class ExceptionToMessageMapper : IExceptionToMessageMapper { public object Map(Exception exception, object message) => exception switch - { EmailInUseException ex => new SignUpRejected(ex.Email, ex.Message, ex.Code), InvalidCredentialsException ex => new SignInRejected(ex.Email, ex.Message, ex.Code), InvalidEmailException ex => message switch { SignIn command => new SignInRejected(command.Email, ex.Message, ex.Code), - SignUpRejected command => new SignUpRejected(command.Email, ex.Message, ex.Code), + SignUp command => new SignUpRejected(command.Email, ex.Message, ex.Code), + VerifyEmail command => new EmailVerificationRejected(command.Email, ex.Message, ex.Code), + _ => null + }, + UserNotFoundException ex => message switch + { + EnableTwoFactor command => new EnableTwoFactorRejected(command.UserId, ex.Message, ex.Code), + DisableTwoFactor command => new DisableTwoFactorRejected(command.UserId, ex.Message, ex.Code), + _ => null + }, + InvalidTokenException ex => message switch + { + VerifyEmail command => new EmailVerificationRejected(command.Email, ex.Message, ex.Code), _ => null }, + TwoFactorAlreadyEnabledException ex => new EnableTwoFactorRejected(ex.UserId, ex.Message, ex.Code), + TwoFactorNotEnabledException ex => new DisableTwoFactorRejected(ex.UserId, ex.Message, ex.Code), _ => null }; } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Extensions.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Extensions.cs index a0bced0b7..260553d9a 100644 --- a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Extensions.cs +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Extensions.cs @@ -60,6 +60,9 @@ public static IConveyBuilder AddInfrastructure(this IConveyBuilder builder) builder.Services.AddSingleton, PasswordHasher>(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton, PasswordHasher>(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/MiniSpace.Services.Identity.Infrastructure.csproj b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/MiniSpace.Services.Identity.Infrastructure.csproj index 033db66d7..6fa6050eb 100644 --- a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/MiniSpace.Services.Identity.Infrastructure.csproj +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/MiniSpace.Services.Identity.Infrastructure.csproj @@ -26,6 +26,7 @@ + diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Mongo/Documents/Extensions.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Mongo/Documents/Extensions.cs index fd697a15b..20f1f4616 100644 --- a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Mongo/Documents/Extensions.cs +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Mongo/Documents/Extensions.cs @@ -11,7 +11,14 @@ internal static class Extensions { public static User AsEntity(this UserDocument document) => new User(document.Id, document.Name, document.Email, document.Password, document.Role, document.CreatedAt, - document.Permissions); + document.Permissions) + { + IsEmailVerified = document.IsEmailVerified, + EmailVerificationToken = document.EmailVerificationToken, + EmailVerifiedAt = document.EmailVerifiedAt, + IsTwoFactorEnabled = document.IsTwoFactorEnabled, + TwoFactorSecret = document.TwoFactorSecret + }; public static UserDocument AsDocument(this User entity) => new UserDocument @@ -22,7 +29,12 @@ public static UserDocument AsDocument(this User entity) Password = entity.Password, Role = entity.Role, CreatedAt = entity.CreatedAt, - Permissions = entity.Permissions ?? Enumerable.Empty() + Permissions = entity.Permissions ?? Enumerable.Empty(), + IsEmailVerified = entity.IsEmailVerified, + EmailVerificationToken = entity.EmailVerificationToken, + EmailVerifiedAt = entity.EmailVerifiedAt, + IsTwoFactorEnabled = entity.IsTwoFactorEnabled, + TwoFactorSecret = entity.TwoFactorSecret }; public static UserDto AsDto(this UserDocument document) @@ -33,7 +45,11 @@ public static UserDto AsDto(this UserDocument document) Email = document.Email, Role = document.Role, CreatedAt = document.CreatedAt, - Permissions = document.Permissions ?? Enumerable.Empty() + Permissions = document.Permissions ?? Enumerable.Empty(), + IsEmailVerified = document.IsEmailVerified, + EmailVerifiedAt = document.EmailVerifiedAt, + IsTwoFactorEnabled = document.IsTwoFactorEnabled, + TwoFactorSecret = document.TwoFactorSecret }; public static RefreshToken AsEntity(this RefreshTokenDocument document) @@ -80,4 +96,4 @@ public static UserResetTokenDocument AsDocument(this UserResetToken userResetTok }; } } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Mongo/Documents/UserDocument.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Mongo/Documents/UserDocument.cs index dcf434293..2ecf2fc72 100644 --- a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Mongo/Documents/UserDocument.cs +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Mongo/Documents/UserDocument.cs @@ -15,5 +15,10 @@ internal sealed class UserDocument : IIdentifiable public string Password { get; set; } public DateTime CreatedAt { get; set; } public IEnumerable Permissions { get; set; } + public bool IsEmailVerified { get; set; } + public string EmailVerificationToken { get; set; } + public DateTime? EmailVerifiedAt { get; set; } + public bool IsTwoFactorEnabled { get; set; } + public string TwoFactorSecret { get; set; } } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Mongo/Repositories/UserResetTokenRepository.cs b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Mongo/Repositories/UserResetTokenRepository.cs index b3f13d578..2d42918e5 100644 --- a/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Mongo/Repositories/UserResetTokenRepository.cs +++ b/MiniSpace.Services.Identity/src/MiniSpace.Services.Identity.Infrastructure/Mongo/Repositories/UserResetTokenRepository.cs @@ -54,7 +54,7 @@ public async Task InvalidateTokenAsync(Guid userId) var document = await GetByUserIdAsync(userId); if (document != null && document.ResetTokenExpires > DateTime.UtcNow) { - document.ResetTokenExpires = DateTime.UtcNow; // Adjust logic as needed + document.ResetTokenExpires = DateTime.UtcNow; await _repository.UpdateAsync(document.AsDocument()); } } diff --git a/MiniSpace.Services.MediaFiles/.gitignore b/MiniSpace.Services.MediaFiles/.gitignore index 6f04bbaa1..1f571fcfe 100644 --- a/MiniSpace.Services.MediaFiles/.gitignore +++ b/MiniSpace.Services.MediaFiles/.gitignore @@ -8,7 +8,8 @@ *.user *.userosscache *.sln.docstates - +**/.env +.env # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/.gitignore b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/.gitignore index 6f04bbaa1..d30eaf4d5 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/.gitignore +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/.gitignore @@ -11,7 +11,7 @@ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs - +.env # Build results [Dd]ebug/ [Dd]ebugPublic/ diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/MiniSpace.Services.MediaFiles.Api.csproj b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/MiniSpace.Services.MediaFiles.Api.csproj index 667a9e009..f5657baf4 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/MiniSpace.Services.MediaFiles.Api.csproj +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/MiniSpace.Services.MediaFiles.Api.csproj @@ -12,6 +12,7 @@ + diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/MiniSpace.Services.MediaFiles.Api.sln b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/MiniSpace.Services.MediaFiles.Api.sln new file mode 100644 index 000000000..18c40f1d0 --- /dev/null +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/MiniSpace.Services.MediaFiles.Api.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MiniSpace.Services.MediaFiles.Api", "MiniSpace.Services.MediaFiles.Api.csproj", "{1A6416D2-1E21-4C47-A884-A2E314625024}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1A6416D2-1E21-4C47-A884-A2E314625024}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A6416D2-1E21-4C47-A884-A2E314625024}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A6416D2-1E21-4C47-A884-A2E314625024}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A6416D2-1E21-4C47-A884-A2E314625024}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {37108F20-F640-498C-A6DB-EF59BCA7B42E} + EndGlobalSection +EndGlobal diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/Program.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/Program.cs index ea28dcc14..b30e65e5b 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/Program.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/Program.cs @@ -16,13 +16,18 @@ using MiniSpace.Services.MediaFiles.Application.Queries; using MiniSpace.Services.MediaFiles.Application.Services; using MiniSpace.Services.MediaFiles.Infrastructure; +using DotNetEnv; +using Convey.CQRS.Commands; namespace MiniSpace.Services.MediaFiles.Api { public class Program { public static async Task Main(string[] args) - => await WebHost.CreateDefaultBuilder(args) + { + Env.Load(); + + await WebHost.CreateDefaultBuilder(args) .ConfigureServices(services => services .AddConvey() .AddWebApi() @@ -42,10 +47,11 @@ public static async Task Main(string[] args) .Get("", ctx => ctx.Response.WriteAsync(ctx.RequestServices.GetService().Name)) .Get("media-files/{mediaFileId}") .Get("media-files/{mediaFileId}/original") - .Delete("media-files/{mediaFileId}") - )) + .Delete("media-files/delete/{mediaFileUrl}") + )) .UseLogging() .Build() .RunAsync(); + } } -} +} \ No newline at end of file diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/appsettings.docker.json b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/appsettings.docker.json index 5d8e69926..423cece0b 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/appsettings.docker.json +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/appsettings.docker.json @@ -149,5 +149,10 @@ "enabled": false } } + }, + "Aws": { + "AccessKeyId": "%AWS_ACCESS_KEY_ID%", + "SecretAccessKey": "%AWS_SECRET_ACCESS_KEY%", + "Region": "%AWS_REGION%" } } \ No newline at end of file diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/appsettings.local.json b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/appsettings.local.json index 5647c8fd6..10fc06a73 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/appsettings.local.json +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Api/appsettings.local.json @@ -191,5 +191,10 @@ } } } + }, + "Aws": { + "AccessKeyId": "%AWS_ACCESS_KEY_ID%", + "SecretAccessKey": "%AWS_SECRET_ACCESS_KEY%", + "Region": "%AWS_REGION%" } } diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Commands/DeleteMediaFile.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Commands/DeleteMediaFile.cs index 902fdefea..3148a64c2 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Commands/DeleteMediaFile.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Commands/DeleteMediaFile.cs @@ -4,6 +4,12 @@ namespace MiniSpace.Services.MediaFiles.Application.Commands { public class DeleteMediaFile: ICommand { - public Guid MediaFileId { get; set; } + public string MediaFileUrl { get; set; } + + public DeleteMediaFile() {} + public DeleteMediaFile(string mediaFileUrl) + { + MediaFileUrl = mediaFileUrl; + } } } \ No newline at end of file diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Commands/Handlers/CleanupUnassociatedFilesHandler.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Commands/Handlers/CleanupUnassociatedFilesHandler.cs index 53946e804..25069f3e4 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Commands/Handlers/CleanupUnassociatedFilesHandler.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Commands/Handlers/CleanupUnassociatedFilesHandler.cs @@ -3,23 +3,27 @@ using MiniSpace.Services.MediaFiles.Application.Services; using MiniSpace.Services.MediaFiles.Core.Entities; using MiniSpace.Services.MediaFiles.Core.Repositories; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace MiniSpace.Services.MediaFiles.Application.Commands.Handlers { - public class CleanupUnassociatedFilesHandler: ICommandHandler + public class CleanupUnassociatedFilesHandler : ICommandHandler { private readonly IFileSourceInfoRepository _fileSourceInfoRepository; - private readonly IGridFSService _gridFSService; + private readonly IS3Service _s3Service; private readonly IMessageBroker _messageBroker; - - public CleanupUnassociatedFilesHandler(IFileSourceInfoRepository fileSourceInfoRepository, IGridFSService gridFSService, + + public CleanupUnassociatedFilesHandler(IFileSourceInfoRepository fileSourceInfoRepository, IS3Service s3Service, IMessageBroker messageBroker) { _fileSourceInfoRepository = fileSourceInfoRepository; - _gridFSService = gridFSService; + _s3Service = s3Service; _messageBroker = messageBroker; } - + public async Task HandleAsync(CleanupUnassociatedFiles command, CancellationToken cancellationToken) { var unassociatedFileSourceInfos = await _fileSourceInfoRepository.GetAllUnassociatedAsync(); @@ -30,12 +34,12 @@ public async Task HandleAsync(CleanupUnassociatedFiles command, CancellationToke continue; } - await _gridFSService.DeleteFileAsync(file.OriginalFileId); - await _gridFSService.DeleteFileAsync(file.FileId); - await _fileSourceInfoRepository.DeleteAsync(file.Id); + await _s3Service.DeleteFileAsync(file.OriginalFileUrl); + await _s3Service.DeleteFileAsync(file.FileUrl); + await _fileSourceInfoRepository.DeleteAsync(file.FileUrl); } - + await _messageBroker.PublishAsync(new UnassociatedFilesCleaned(command.Now)); } } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Commands/Handlers/DeleteMediaFileHandler.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Commands/Handlers/DeleteMediaFileHandler.cs index d3c005d49..fed9b647c 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Commands/Handlers/DeleteMediaFileHandler.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Commands/Handlers/DeleteMediaFileHandler.cs @@ -1,4 +1,7 @@ -using Convey.CQRS.Commands; +using System; +using System.Threading; +using System.Threading.Tasks; +using Convey.CQRS.Commands; using MiniSpace.Services.MediaFiles.Application.Events; using MiniSpace.Services.MediaFiles.Application.Exceptions; using MiniSpace.Services.MediaFiles.Application.Services; @@ -6,41 +9,45 @@ namespace MiniSpace.Services.MediaFiles.Application.Commands.Handlers { - public class DeleteMediaFileHandler: ICommandHandler + public class DeleteMediaFileHandler : ICommandHandler { private readonly IFileSourceInfoRepository _fileSourceInfoRepository; - private readonly IGridFSService _gridFSService; + private readonly IS3Service _s3Service; private readonly IAppContext _appContext; private readonly IMessageBroker _messageBroker; - - public DeleteMediaFileHandler(IFileSourceInfoRepository fileSourceInfoRepository, IGridFSService gridFSService, + + public DeleteMediaFileHandler(IFileSourceInfoRepository fileSourceInfoRepository, IS3Service s3Service, IAppContext appContext, IMessageBroker messageBroker) { _fileSourceInfoRepository = fileSourceInfoRepository; - _gridFSService = gridFSService; + _s3Service = s3Service; _appContext = appContext; _messageBroker = messageBroker; } - + public async Task HandleAsync(DeleteMediaFile command, CancellationToken cancellationToken) { - var fileSourceInfo = await _fileSourceInfoRepository.GetAsync(command.MediaFileId); + // Decode the URL before using it + var decodedUrl = Uri.UnescapeDataString(command.MediaFileUrl); + + Console.WriteLine($"DeleteMediaFileHandler: {decodedUrl}"); + var fileSourceInfo = await _fileSourceInfoRepository.GetAsync(decodedUrl); if (fileSourceInfo is null) { - throw new MediaFileNotFoundException(command.MediaFileId); + throw new MediaFileNotFoundException(decodedUrl); } - + var identity = _appContext.Identity; - if(identity.IsAuthenticated && identity.Id != fileSourceInfo.UploaderId && !identity.IsAdmin) + if (identity.IsAuthenticated && identity.Id != fileSourceInfo.UploaderId) { throw new UnauthorizedMediaFileAccessException(fileSourceInfo.Id, identity.Id, fileSourceInfo.UploaderId); } - - await _gridFSService.DeleteFileAsync(fileSourceInfo.OriginalFileId); - await _gridFSService.DeleteFileAsync(fileSourceInfo.FileId); - await _fileSourceInfoRepository.DeleteAsync(command.MediaFileId); - await _messageBroker.PublishAsync(new MediaFileDeleted(command.MediaFileId, + + await _s3Service.DeleteFileAsync(fileSourceInfo.OriginalFileUrl); + await _s3Service.DeleteFileAsync(fileSourceInfo.FileUrl); + await _fileSourceInfoRepository.DeleteAsync(decodedUrl); + await _messageBroker.PublishAsync(new MediaFileDeleted(decodedUrl, fileSourceInfo.SourceId, fileSourceInfo.SourceType.ToString())); } } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/Handlers/StudentCreatedHandler.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/Handlers/StudentCreatedHandler.cs index 0e1050a04..9d214d48c 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/Handlers/StudentCreatedHandler.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/Handlers/StudentCreatedHandler.cs @@ -20,7 +20,7 @@ public StudentCreatedHandler(IFileSourceInfoRepository fileSourceInfoRepository, public async Task HandleAsync(StudentCreated @event, CancellationToken cancellationToken) { var fileSourceInfos = - await _fileSourceInfoRepository.FindAsync(@event.StudentId, ContextType.StudentProfile); + await _fileSourceInfoRepository.FindAsync(@event.StudentId, ContextType.StudentProfileImage); foreach (var fileSourceInfo in fileSourceInfos) { if (fileSourceInfo.Id == @event.MediaFileId) diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/Handlers/StudentDeletedHandler.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/Handlers/StudentDeletedHandler.cs index dfbf9cf71..b0b885f6b 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/Handlers/StudentDeletedHandler.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/Handlers/StudentDeletedHandler.cs @@ -20,7 +20,7 @@ public StudentDeletedHandler(IFileSourceInfoRepository fileSourceInfoRepository, public async Task HandleAsync(StudentDeleted @event, CancellationToken cancellationToken) { var fileSourceInfos = - await _fileSourceInfoRepository.FindAsync(@event.StudentId, ContextType.StudentProfile); + await _fileSourceInfoRepository.FindAsync(@event.StudentId, ContextType.StudentProfileImage); foreach (var fileSourceInfo in fileSourceInfos) { fileSourceInfo.Unassociate(); diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/Handlers/StudentUpdatedHandler.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/Handlers/StudentUpdatedHandler.cs index e630a5f8b..fb5d5761a 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/Handlers/StudentUpdatedHandler.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/Handlers/StudentUpdatedHandler.cs @@ -1,8 +1,11 @@ using Convey.CQRS.Commands; using Convey.CQRS.Events; -using MiniSpace.Services.MediaFiles.Application.Commands; using MiniSpace.Services.MediaFiles.Core.Entities; using MiniSpace.Services.MediaFiles.Core.Repositories; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace MiniSpace.Services.MediaFiles.Application.Events.External.Handlers { @@ -17,22 +20,48 @@ public StudentUpdatedHandler(IFileSourceInfoRepository fileSourceInfoRepository, _commandDispatcher = commandDispatcher; } - public async Task HandleAsync(StudentUpdated @event, CancellationToken cancellationToken) + public async Task HandleAsync(StudentUpdated @event, CancellationToken cancellationToken = default) { - var fileSourceInfos = - await _fileSourceInfoRepository.FindAsync(@event.StudentId, ContextType.StudentProfile); - foreach (var fileSourceInfo in fileSourceInfos) - { - if (fileSourceInfo.Id == @event.MediaFileId) - { - fileSourceInfo.Associate(); - } - else - { - fileSourceInfo.Unassociate(); - } - await _fileSourceInfoRepository.UpdateAsync(fileSourceInfo); - } + // Handle profile image + // var profileImageInfo = await _fileSourceInfoRepository.GetAsync(@event.ProfileImageUrl); + // if (profileImageInfo != null) + // { + // profileImageInfo.Associate(); + // await _fileSourceInfoRepository.UpdateAsync(profileImageInfo); + // } + + // Handle banner image + // if (!string.IsNullOrEmpty(@event.BannerUrl)) + // { + // var bannerImageInfo = await _fileSourceInfoRepository.GetAsync(@event.BannerUrl); + // if (bannerImageInfo != null) + // { + // bannerImageInfo.Associate(); + // await _fileSourceInfoRepository.UpdateAsync(bannerImageInfo); + // } + // } + + // Handle gallery images + // foreach (var galleryImageUrl in @event.GalleryOfImageUrls) + // { + // var galleryImageInfo = await _fileSourceInfoRepository.GetAsync(galleryImageUrl); + // if (galleryImageInfo != null) + // { + // galleryImageInfo.Associate(); + // await _fileSourceInfoRepository.UpdateAsync(galleryImageInfo); + // } + // } + + // Unassociate files that are no longer associated with the student + // var allStudentFiles = await _fileSourceInfoRepository.FindAsync(@event.StudentId, ContextType.StudentProfileImage); + // foreach (var file in allStudentFiles) + // { + // if (file.FileUrl != @event.ProfileImageUrl && file.FileUrl != @event.BannerUrl && !@event.GalleryOfImageUrls.Contains(file.FileUrl)) + // { + // file.Unassociate(); + // await _fileSourceInfoRepository.UpdateAsync(file); + // } + // } } } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/StudentUpdated.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/StudentUpdated.cs index 43ebef223..90a67442f 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/StudentUpdated.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/External/StudentUpdated.cs @@ -1,18 +1,25 @@ using Convey.CQRS.Events; using Convey.MessageBrokers; +using System; +using System.Collections.Generic; namespace MiniSpace.Services.MediaFiles.Application.Events.External { [Message("students")] public class StudentUpdated : IEvent { - public Guid StudentId { get; } - public Guid MediaFileId { get; } - - public StudentUpdated(Guid studentId, Guid mediaFileId) - { - StudentId = studentId; - MediaFileId = mediaFileId; - } + // public Guid StudentId { get; } + // public string ProfileImageUrl { get; } + // public string BannerUrl { get; } + // public IEnumerable GalleryOfImageUrls { get; } + + // public StudentUpdated(Guid studentId, string profileImageUrl, string bannerUrl, + // IEnumerable galleryOfImageUrls) + // { + // StudentId = studentId; + // ProfileImageUrl = profileImageUrl; + // BannerUrl = bannerUrl; + // GalleryOfImageUrls = galleryOfImageUrls; + // } } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/MediaFileDeleted.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/MediaFileDeleted.cs index a7eb41a84..45f30e82e 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/MediaFileDeleted.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/MediaFileDeleted.cs @@ -2,15 +2,15 @@ namespace MiniSpace.Services.MediaFiles.Application.Events { - public class MediaFileDeleted: IEvent + public class MediaFileDeleted : IEvent { - public Guid MediaFileId { get; } + public string MediaFileUrl { get; } public Guid SourceId { get; } public string Source { get; } - public MediaFileDeleted(Guid mediaFileId, Guid sourceId, string source) + public MediaFileDeleted(string mediaFileUrl, Guid sourceId, string source) { - MediaFileId = mediaFileId; + MediaFileUrl = mediaFileUrl; SourceId = sourceId; Source = source; } diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/StudentImageUploaded.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/StudentImageUploaded.cs new file mode 100644 index 000000000..e01f3e7db --- /dev/null +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Events/StudentImageUploaded.cs @@ -0,0 +1,19 @@ +using Convey.CQRS.Events; +using System; + +namespace MiniSpace.Services.MediaFiles.Application.Events.External +{ + public class StudentImageUploaded : IEvent + { + public Guid StudentId { get; } + public string ImageUrl { get; } + public string ImageType { get; } + + public StudentImageUploaded(Guid studentId, string imageUrl, string imageType) + { + StudentId = studentId; + ImageUrl = imageUrl; + ImageType = imageType; + } + } +} diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Exceptions/MediaFileNotFoundException.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Exceptions/MediaFileNotFoundException.cs index 9c9bd144a..8ea013f3b 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Exceptions/MediaFileNotFoundException.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Exceptions/MediaFileNotFoundException.cs @@ -3,12 +3,12 @@ public class MediaFileNotFoundException: AppException { public override string Code { get; } = "media_file_not_found"; - public Guid MediaFileId { get; } + public string MediaFileUrl { get; } - public MediaFileNotFoundException(Guid mediaFileId) - : base($"Media file with ID: {mediaFileId} was not found.") + public MediaFileNotFoundException(string mediaFileUrl) + : base($"Media file with ID: {mediaFileUrl} was not found.") { - MediaFileId = mediaFileId; + MediaFileUrl = mediaFileUrl; } } } \ No newline at end of file diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/MiniSpace.Services.MediaFiles.Application.sln b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/MiniSpace.Services.MediaFiles.Application.sln new file mode 100644 index 000000000..ffcb53c36 --- /dev/null +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/MiniSpace.Services.MediaFiles.Application.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MiniSpace.Services.MediaFiles.Application", "MiniSpace.Services.MediaFiles.Application.csproj", "{49C91FD0-5542-46C6-A63B-14BD21A5C0A6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {49C91FD0-5542-46C6-A63B-14BD21A5C0A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {49C91FD0-5542-46C6-A63B-14BD21A5C0A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49C91FD0-5542-46C6-A63B-14BD21A5C0A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {49C91FD0-5542-46C6-A63B-14BD21A5C0A6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B396083A-B979-4C46-A216-B34CFF5EDFE5} + EndGlobalSection +EndGlobal diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Services/IS3Service.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Services/IS3Service.cs new file mode 100644 index 000000000..4026acd55 --- /dev/null +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Application/Services/IS3Service.cs @@ -0,0 +1,12 @@ +using System.IO; +using System.Threading.Tasks; + +namespace MiniSpace.Services.MediaFiles.Application.Services +{ + public interface IS3Service + { + Task UploadFileAsync(string folderName, string fileName, Stream fileStream); + Task DownloadFileAsync(string fileUrl, Stream destination); + Task DeleteFileAsync(string fileUrl); + } +} diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Core/Entities/ContextType.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Core/Entities/ContextType.cs index c93a71bd3..4fafe77bc 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Core/Entities/ContextType.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Core/Entities/ContextType.cs @@ -4,6 +4,8 @@ public enum ContextType { Event, Post, - StudentProfile, + StudentProfileImage, + StudentBannerImage, + StudentGalleryImage } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Core/Entities/FileSourceInfo.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Core/Entities/FileSourceInfo.cs index 84e52e5c5..7ccd3a40f 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Core/Entities/FileSourceInfo.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Core/Entities/FileSourceInfo.cs @@ -1,21 +1,21 @@ -using MongoDB.Bson; +using System; namespace MiniSpace.Services.MediaFiles.Core.Entities { - public class FileSourceInfo: AggregateRoot + public class FileSourceInfo : AggregateRoot { public Guid SourceId { get; set; } public ContextType SourceType { get; set; } public Guid UploaderId { get; set; } public State State { get; set; } public DateTime CreatedAt { get; set; } - public ObjectId OriginalFileId { get; set; } + public string OriginalFileUrl { get; set; } public string OriginalFileContentType { get; set; } - public ObjectId FileId { get; set; } + public string FileUrl { get; set; } public string FileName { get; set; } public FileSourceInfo(Guid id, Guid sourceId, ContextType sourceType, Guid uploaderId, State state, - DateTime createdAt, ObjectId originalFileId, string originalFileContentType, ObjectId fileId, string fileName) + DateTime createdAt, string originalFileUrl, string originalFileContentType, string fileUrl, string fileName) { Id = id; SourceId = sourceId; @@ -23,9 +23,9 @@ public FileSourceInfo(Guid id, Guid sourceId, ContextType sourceType, Guid uploa UploaderId = uploaderId; State = state; CreatedAt = createdAt; - OriginalFileId = originalFileId; + OriginalFileUrl = originalFileUrl; OriginalFileContentType = originalFileContentType; - FileId = fileId; + FileUrl = fileUrl; FileName = fileName; } @@ -39,4 +39,4 @@ public void Unassociate() State = State.Unassociated; } } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Core/MiniSpace.Services.MediaFiles.Core.sln b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Core/MiniSpace.Services.MediaFiles.Core.sln new file mode 100644 index 000000000..e0b4ee539 --- /dev/null +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Core/MiniSpace.Services.MediaFiles.Core.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MiniSpace.Services.MediaFiles.Core", "MiniSpace.Services.MediaFiles.Core.csproj", "{8767FE38-F252-4727-913A-F16800755822}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8767FE38-F252-4727-913A-F16800755822}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8767FE38-F252-4727-913A-F16800755822}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8767FE38-F252-4727-913A-F16800755822}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8767FE38-F252-4727-913A-F16800755822}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D7E2904D-2FE3-4793-B024-10E1ACAC0E80} + EndGlobalSection +EndGlobal diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Core/Repositories/IFileSourceInfoRepository.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Core/Repositories/IFileSourceInfoRepository.cs index 5095eadc5..1b1a4eae3 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Core/Repositories/IFileSourceInfoRepository.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Core/Repositories/IFileSourceInfoRepository.cs @@ -4,12 +4,15 @@ namespace MiniSpace.Services.MediaFiles.Core.Repositories { public interface IFileSourceInfoRepository { - Task GetAsync(Guid id); - Task> GetAllUnassociatedAsync(); Task AddAsync(FileSourceInfo fileSourceInfo); - Task UpdateAsync(FileSourceInfo fileSourceInfo); - Task DeleteAsync(Guid id); - Task ExistsAsync(Guid id); + Task DeleteAsync(string url); + Task ExistsAsync(string url); Task> FindAsync(Guid sourceId, ContextType sourceType); + Task> GetAllUnassociatedAsync(); + Task GetAsync(string url); + Task UpdateAsync(FileSourceInfo fileSourceInfo); + Task> FindByUploaderIdAndSourceTypeAsync(Guid uploaderId, ContextType sourceType); + Task> GetAllAsync(string url); + Task DeleteAllAsync(string url); } } \ No newline at end of file diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Extensions.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Extensions.cs index 0cec9be95..9503594fa 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Extensions.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Extensions.cs @@ -40,6 +40,8 @@ using MiniSpace.Services.MediaFiles.Infrastructure.Services; using MiniSpace.Services.MediaFiles.Infrastructure.Services.Workers; using MongoDB.Driver; +using Amazon.S3; +using MiniSpace.Services.MediaFiles.Infrastructure.Options; namespace MiniSpace.Services.MediaFiles.Infrastructure { @@ -54,6 +56,7 @@ public static IConveyBuilder AddInfrastructure(this IConveyBuilder builder) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(ctx => ctx.GetRequiredService().Create()); builder.Services.TryDecorate(typeof(ICommandHandler<>), typeof(OutboxCommandHandlerDecorator<>)); builder.Services.TryDecorate(typeof(IEventHandler<>), typeof(OutboxEventHandlerDecorator<>)); @@ -64,7 +67,22 @@ public static IConveyBuilder AddInfrastructure(this IConveyBuilder builder) var database = mongoClient.GetDatabase(mongoDbOptions.Database); return new GridFSService(database); }); - builder.Services.AddHostedService(); + + var awsOptions = new AwsOptions + { + AccessKeyId = Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID"), + SecretAccessKey = Environment.GetEnvironmentVariable("AWS_SECRET_ACCESS_KEY"), + Region = Environment.GetEnvironmentVariable("AWS_REGION") + }; + builder.Services.AddSingleton(awsOptions); + + builder.Services.AddSingleton(sp => + { + var options = sp.GetRequiredService(); + return new AmazonS3Client(options.AccessKeyId, options.SecretAccessKey, Amazon.RegionEndpoint.GetBySystemName(options.Region)); + }); + + // builder.Services.AddHostedService(); return builder .AddErrorHandler() @@ -101,7 +119,7 @@ public static IApplicationBuilder UseInfrastructure(this IApplicationBuilder app .SubscribeCommand() .SubscribeCommand() .SubscribeEvent() - .SubscribeEvent() + // .SubscribeEvent() .SubscribeEvent() .SubscribeEvent(); diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Logging/MessageToLogTemplateMapper.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Logging/MessageToLogTemplateMapper.cs index 706960785..251878fd2 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Logging/MessageToLogTemplateMapper.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Logging/MessageToLogTemplateMapper.cs @@ -12,13 +12,13 @@ private static IReadOnlyDictionary MessageTemplates { typeof(UploadMediaFile), new HandlerLogTemplate { - After = "Uploaded media file with ID: {MediaFileId} and name: {FileName}.", + After = "Uploaded media file with ID: {MediaFileUrl} and name: {FileName}.", } }, { typeof(DeleteMediaFile), new HandlerLogTemplate { - After = "Deleted media file with ID: {MediaFileId}.", + After = "Deleted media file with ID: {MediaFileUrl}.", } }, { diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/MiniSpace.Services.MediaFiles.Infrastructure.csproj b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/MiniSpace.Services.MediaFiles.Infrastructure.csproj index cbe16ffa6..e2325a2e4 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/MiniSpace.Services.MediaFiles.Infrastructure.csproj +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/MiniSpace.Services.MediaFiles.Infrastructure.csproj @@ -7,6 +7,7 @@ + diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/MiniSpace.Services.MediaFiles.Infrastructure.sln b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/MiniSpace.Services.MediaFiles.Infrastructure.sln new file mode 100644 index 000000000..cbd6d7bde --- /dev/null +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/MiniSpace.Services.MediaFiles.Infrastructure.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MiniSpace.Services.MediaFiles.Infrastructure", "MiniSpace.Services.MediaFiles.Infrastructure.csproj", "{E988DA06-B9CD-460B-82A3-0D4886D45644}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E988DA06-B9CD-460B-82A3-0D4886D45644}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E988DA06-B9CD-460B-82A3-0D4886D45644}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E988DA06-B9CD-460B-82A3-0D4886D45644}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E988DA06-B9CD-460B-82A3-0D4886D45644}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8AC7BA0A-191A-484A-91DB-288BBA7F99B0} + EndGlobalSection +EndGlobal diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Documents/Extensions.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Documents/Extensions.cs index ca41d0535..9f3c159ce 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Documents/Extensions.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Documents/Extensions.cs @@ -1,27 +1,38 @@ -using MiniSpace.Services.MediaFiles.Core.Entities; +using MiniSpace.Services.MediaFiles.Application.Dto; +using MiniSpace.Services.MediaFiles.Core.Entities; +using System; namespace MiniSpace.Services.MediaFiles.Infrastructure.Mongo.Documents { public static class Extensions { - public static FileSourceInfoDocument AsDocument(this FileSourceInfo fileSourceInfo) + public static FileSourceInfo AsEntity(this FileSourceInfoDocument document) + => new FileSourceInfo( + document.Id, + document.SourceId, + document.SourceType, + document.UploaderId, + document.State, + document.CreatedAt, + document.OriginalFileUrl, + document.OriginalFileContentType, + document.FileUrl, + document.FileName + ); + + public static FileSourceInfoDocument AsDocument(this FileSourceInfo entity) => new FileSourceInfoDocument { - Id = fileSourceInfo.Id, - SourceId = fileSourceInfo.SourceId, - SourceType = fileSourceInfo.SourceType, - UploaderId = fileSourceInfo.UploaderId, - State = fileSourceInfo.State, - CreatedAt = fileSourceInfo.CreatedAt, - OriginalFileId = fileSourceInfo.OriginalFileId, - OriginalFileContentType = fileSourceInfo.OriginalFileContentType, - FileId = fileSourceInfo.FileId, - FileName = fileSourceInfo.FileName + Id = entity.Id, + SourceId = entity.SourceId, + SourceType = entity.SourceType, + UploaderId = entity.UploaderId, + State = entity.State, + CreatedAt = entity.CreatedAt, + OriginalFileUrl = entity.OriginalFileUrl, + OriginalFileContentType = entity.OriginalFileContentType, + FileUrl = entity.FileUrl, + FileName = entity.FileName }; - - public static FileSourceInfo AsEntity(this FileSourceInfoDocument document) - => new FileSourceInfo(document.Id, document.SourceId, document.SourceType, document.UploaderId, - document.State, document.CreatedAt, document.OriginalFileId, document.OriginalFileContentType, - document.FileId, document.FileName); } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Documents/FileSourceInfoDocument.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Documents/FileSourceInfoDocument.cs index 8aba3c17a..cbebbe56b 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Documents/FileSourceInfoDocument.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Documents/FileSourceInfoDocument.cs @@ -1,6 +1,7 @@ using Convey.Types; using MiniSpace.Services.MediaFiles.Core.Entities; using MongoDB.Bson; +using System; namespace MiniSpace.Services.MediaFiles.Infrastructure.Mongo.Documents { @@ -12,9 +13,9 @@ public class FileSourceInfoDocument : IIdentifiable public Guid UploaderId { get; set; } public State State { get; set; } public DateTime CreatedAt { get; set; } - public ObjectId OriginalFileId { get; set; } + public string OriginalFileUrl { get; set; } public string OriginalFileContentType { get; set; } - public ObjectId FileId { get; set; } + public string FileUrl { get; set; } public string FileName { get; set; } } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Queries/Handlers/GetMediaFileHandler.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Queries/Handlers/GetMediaFileHandler.cs index 2d5abd75a..33b6734bc 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Queries/Handlers/GetMediaFileHandler.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Queries/Handlers/GetMediaFileHandler.cs @@ -1,26 +1,27 @@ using Convey.CQRS.Queries; using Convey.Persistence.MongoDB; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Net.Http.Headers; using MiniSpace.Services.MediaFiles.Application.Dto; using MiniSpace.Services.MediaFiles.Application.Queries; using MiniSpace.Services.MediaFiles.Application.Services; using MiniSpace.Services.MediaFiles.Infrastructure.Mongo.Documents; -using MongoDB.Bson; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; namespace MiniSpace.Services.MediaFiles.Infrastructure.Mongo.Queries.Handlers { public class GetMediaFileHandler : IQueryHandler { private readonly IMongoRepository _fileSourceInfoRepository; - private readonly IGridFSService _gridFSService; + private readonly IS3Service _s3Service; private const string FileContentType = "image/webp"; public GetMediaFileHandler(IMongoRepository fileSourceInfoRepository, - IGridFSService gridFSService) + IS3Service s3Service) { _fileSourceInfoRepository = fileSourceInfoRepository; - _gridFSService = gridFSService; + _s3Service = s3Service; } public async Task HandleAsync(GetMediaFile query, CancellationToken cancellationToken) @@ -32,7 +33,7 @@ public async Task HandleAsync(GetMediaFile query, CancellationToken can } var fileStream = new MemoryStream(); - await _gridFSService.DownloadFileAsync(fileSourceInfo.FileId, fileStream); + await _s3Service.DownloadFileAsync(fileSourceInfo.FileUrl, fileStream); fileStream.Seek(0, SeekOrigin.Begin); byte[] fileContent = fileStream.ToArray(); var base64String = Convert.ToBase64String(fileContent); @@ -42,4 +43,4 @@ public async Task HandleAsync(GetMediaFile query, CancellationToken can fileSourceInfo.FileName, FileContentType, base64String); } } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Queries/Handlers/GetOriginalMediaFileHandler.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Queries/Handlers/GetOriginalMediaFileHandler.cs index 3e8edf827..8c7c6c61b 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Queries/Handlers/GetOriginalMediaFileHandler.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Queries/Handlers/GetOriginalMediaFileHandler.cs @@ -1,25 +1,26 @@ using Convey.CQRS.Queries; using Convey.Persistence.MongoDB; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Net.Http.Headers; using MiniSpace.Services.MediaFiles.Application.Dto; using MiniSpace.Services.MediaFiles.Application.Queries; using MiniSpace.Services.MediaFiles.Application.Services; using MiniSpace.Services.MediaFiles.Infrastructure.Mongo.Documents; -using MongoDB.Bson; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; namespace MiniSpace.Services.MediaFiles.Infrastructure.Mongo.Queries.Handlers { public class GetOriginalMediaFileHandler : IQueryHandler { private readonly IMongoRepository _fileSourceInfoRepository; - private readonly IGridFSService _gridFSService; + private readonly IS3Service _s3Service; public GetOriginalMediaFileHandler(IMongoRepository fileSourceInfoRepository, - IGridFSService gridFSService) + IS3Service s3Service) { _fileSourceInfoRepository = fileSourceInfoRepository; - _gridFSService = gridFSService; + _s3Service = s3Service; } public async Task HandleAsync(GetOriginalMediaFile query, CancellationToken cancellationToken) @@ -31,7 +32,7 @@ public async Task HandleAsync(GetOriginalMediaFile query, CancellationT } var fileStream = new MemoryStream(); - await _gridFSService.DownloadFileAsync(fileSourceInfo.OriginalFileId, fileStream); + await _s3Service.DownloadFileAsync(fileSourceInfo.OriginalFileUrl, fileStream); fileStream.Seek(0, SeekOrigin.Begin); byte[] fileContent = fileStream.ToArray(); var base64String = Convert.ToBase64String(fileContent); @@ -41,4 +42,4 @@ public async Task HandleAsync(GetOriginalMediaFile query, CancellationT fileSourceInfo.FileName, fileSourceInfo.OriginalFileContentType, base64String); } } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Repositories/FileSourceInfoMongoRepository.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Repositories/FileSourceInfoMongoRepository.cs index c3e80cbc3..bc0514c69 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Repositories/FileSourceInfoMongoRepository.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Mongo/Repositories/FileSourceInfoMongoRepository.cs @@ -1,5 +1,4 @@ using System; -using System.Collections; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -21,17 +20,30 @@ public FileSourceInfoMongoRepository(IMongoRepository GetAsync(Guid id) + public async Task> GetAllAsync(string url) { - var fileSourceInfo = await _repository.GetAsync(s => s.Id == id); + var fileSourceInfos = await _repository.FindAsync(s => (s.OriginalFileUrl == url || s.FileUrl == url) && s.State == State.Associated); + return fileSourceInfos?.Select(s => s.AsEntity()); + } + + public async Task DeleteAllAsync(string url) + { + var fileSourceInfos = await _repository.FindAsync(s => (s.OriginalFileUrl == url || s.FileUrl == url) && s.State == State.Associated); + foreach (var fileSourceInfo in fileSourceInfos) + { + await _repository.DeleteAsync(fileSourceInfo.Id); + } + } + public async Task GetAsync(string url) + { + var fileSourceInfo = await _repository.GetAsync(s => s.OriginalFileUrl == url || s.FileUrl == url); return fileSourceInfo?.AsEntity(); } public async Task> GetAllUnassociatedAsync() { var fileSourceInfos = await _repository.FindAsync(s => s.State == State.Unassociated); - return fileSourceInfos?.Select(s => s.AsEntity()); } @@ -41,18 +53,29 @@ public Task AddAsync(FileSourceInfo fileSourceInfo) public Task UpdateAsync(FileSourceInfo fileSourceInfo) => _repository.UpdateAsync(fileSourceInfo.AsDocument()); - public Task DeleteAsync(Guid id) - => _repository.DeleteAsync(id); + public async Task DeleteAsync(string url) + { + var fileSourceInfo = await _repository.GetAsync(s => s.OriginalFileUrl == url || s.FileUrl == url); + if (fileSourceInfo != null) + { + await _repository.DeleteAsync(fileSourceInfo.Id); + } + } - public Task ExistsAsync(Guid id) - => _repository.ExistsAsync(s => s.Id == id); + public Task ExistsAsync(string url) + => _repository.ExistsAsync(s => s.OriginalFileUrl == url || s.FileUrl == url); public async Task> FindAsync(Guid sourceId, ContextType sourceType) { var fileSourceInfos = await _repository.FindAsync( s => s.SourceId == sourceId && s.SourceType == sourceType); + return fileSourceInfos?.Select(s => s.AsEntity()); + } + public async Task> FindByUploaderIdAndSourceTypeAsync(Guid uploaderId, ContextType sourceType) + { + var fileSourceInfos = await _repository.FindAsync(s => s.UploaderId == uploaderId && s.SourceType == sourceType); return fileSourceInfos?.Select(s => s.AsEntity()); } } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Options/AwsOptions.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Options/AwsOptions.cs new file mode 100644 index 000000000..a1533f1f3 --- /dev/null +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Options/AwsOptions.cs @@ -0,0 +1,9 @@ +namespace MiniSpace.Services.MediaFiles.Infrastructure.Options +{ + public class AwsOptions + { + public string AccessKeyId { get; set; } + public string SecretAccessKey { get; set; } + public string Region { get; set; } + } +} diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Services/FileValidator.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Services/FileValidator.cs index 30cf2b34d..9ca5c3fb6 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Services/FileValidator.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Services/FileValidator.cs @@ -5,7 +5,7 @@ namespace MiniSpace.Services.MediaFiles.Infrastructure.Services { public class FileValidator : IFileValidator { - private const int MaxFileSize = 1_000_000; + private const int MaxFileSize = 5_000_000; private readonly Dictionary _mimeTypes = new Dictionary() { diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Services/MediaFilesService.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Services/MediaFilesService.cs index 4d540f282..8a20a9fc1 100644 --- a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Services/MediaFilesService.cs +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Services/MediaFilesService.cs @@ -2,70 +2,116 @@ using MiniSpace.Services.MediaFiles.Application.Commands; using MiniSpace.Services.MediaFiles.Application.Dto; using MiniSpace.Services.MediaFiles.Application.Events; +using MiniSpace.Services.MediaFiles.Application.Events.External; using MiniSpace.Services.MediaFiles.Application.Exceptions; using MiniSpace.Services.MediaFiles.Application.Services; using MiniSpace.Services.MediaFiles.Core.Entities; using MiniSpace.Services.MediaFiles.Core.Repositories; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Webp; +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; namespace MiniSpace.Services.MediaFiles.Infrastructure.Services { - public class MediaFilesService: IMediaFilesService + public class MediaFilesService : IMediaFilesService { private readonly IFileSourceInfoRepository _fileSourceInfoRepository; private readonly IFileValidator _fileValidator; - private readonly IGridFSService _gridFSService; + private readonly IS3Service _s3Service; private readonly IDateTimeProvider _dateTimeProvider; private readonly IAppContext _appContext; private readonly IMessageBroker _messageBroker; - + public MediaFilesService(IFileSourceInfoRepository fileSourceInfoRepository, IFileValidator fileValidator, - IGridFSService gridFSService, IDateTimeProvider dateTimeProvider, IAppContext appContext, + IS3Service s3Service, IDateTimeProvider dateTimeProvider, IAppContext appContext, IMessageBroker messageBroker) { _fileSourceInfoRepository = fileSourceInfoRepository; _fileValidator = fileValidator; - _gridFSService = gridFSService; + _s3Service = s3Service; _dateTimeProvider = dateTimeProvider; _appContext = appContext; _messageBroker = messageBroker; } - + public async Task UploadAsync(UploadMediaFile command) { var identity = _appContext.Identity; - if(identity.IsAuthenticated && identity.Id != command.UploaderId) + if (identity.IsAuthenticated && identity.Id != command.UploaderId) { throw new UnauthorizedMediaFileUploadException(identity.Id, command.UploaderId); } - + if (!Enum.TryParse(command.SourceType, out ContextType sourceType)) { throw new InvalidContextTypeException(command.SourceType); } - + + if (sourceType == ContextType.StudentProfileImage || sourceType == ContextType.StudentBannerImage) + { + var existingFiles = await _fileSourceInfoRepository.FindByUploaderIdAndSourceTypeAsync(command.UploaderId, sourceType); + foreach (var existingFile in existingFiles) + { + existingFile.Unassociate(); + await _fileSourceInfoRepository.UpdateAsync(existingFile); + } + } + byte[] bytes = Convert.FromBase64String(command.Base64Content); _fileValidator.ValidateFileSize(bytes.Length); _fileValidator.ValidateFileExtensions(bytes, command.FileContentType); - + using var inStream = new MemoryStream(bytes); using var myImage = await Image.LoadAsync(inStream); using var outStream = new MemoryStream(); - await myImage.SaveAsync(outStream, new WebpEncoder()); + await myImage.SaveAsync(outStream, new WebpEncoder { Quality = 75 }); inStream.Position = 0; outStream.Position = 0; - var originalObjectId = await _gridFSService.UploadFileAsync(command.FileName, inStream); - var objectId = await _gridFSService.UploadFileAsync(command.FileName, outStream); + string originalFileName = GenerateUniqueFileName(command.SourceType, command.UploaderId, command.FileName); + string webpFileName = GenerateUniqueFileName(command.SourceType, command.UploaderId, command.FileName, "webp"); + + var originalUrl = await _s3Service.UploadFileAsync("images", originalFileName, inStream); + var processedUrl = await _s3Service.UploadFileAsync("webps", webpFileName, outStream); + var fileSourceInfo = new FileSourceInfo(command.MediaFileId, command.SourceId, sourceType, - command.UploaderId, State.Unassociated, _dateTimeProvider.Now, originalObjectId, - command.FileContentType, objectId, command.FileName); + command.UploaderId, State.Associated, _dateTimeProvider.Now, originalUrl, + command.FileContentType, processedUrl, originalFileName); + await _fileSourceInfoRepository.AddAsync(fileSourceInfo); - await _messageBroker.PublishAsync(new MediaFileUploaded(command.MediaFileId, command.FileName)); + await _messageBroker.PublishAsync(new MediaFileUploaded(command.MediaFileId, originalFileName)); + + if (sourceType == ContextType.StudentProfileImage || + sourceType == ContextType.StudentBannerImage || + sourceType == ContextType.StudentGalleryImage) + { + var imageType = sourceType.ToString(); + var studentImageUploadedEvent = new StudentImageUploaded(command.UploaderId, processedUrl, imageType); + await _messageBroker.PublishAsync(studentImageUploadedEvent); + } return new FileUploadResponseDto(fileSourceInfo.Id); } + private string GenerateUniqueFileName(string contextType, Guid uploaderId, string originalFileName, string extension = null) + { + string timestamp = _dateTimeProvider.Now.ToString("yyyyMMddHHmmssfff"); + string hashedFileName = HashFileName(originalFileName); + string fileExtension = extension ?? Path.GetExtension(originalFileName); + + return $"{contextType}_{uploaderId}_{timestamp}_{hashedFileName}{fileExtension}"; + } + + private string HashFileName(string fileName) + { + using var sha256 = SHA256.Create(); + byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(fileName)); + return BitConverter.ToString(hashBytes).Replace("-", "").ToLower(); + } + } -} \ No newline at end of file +} diff --git a/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Services/S3Service.cs b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Services/S3Service.cs new file mode 100644 index 000000000..3a9725348 --- /dev/null +++ b/MiniSpace.Services.MediaFiles/src/MiniSpace.Services.MediaFiles.Infrastructure/Services/S3Service.cs @@ -0,0 +1,56 @@ +using Amazon.S3; +using Amazon.S3.Model; +using Amazon.S3.Transfer; +using MiniSpace.Services.MediaFiles.Application.Services; +using System.IO; +using System.Threading.Tasks; + +namespace MiniSpace.Services.MediaFiles.Infrastructure.Services +{ + public class S3Service : IS3Service + { + private readonly IAmazonS3 _s3Client; + private const string BucketName = "minispace-data-files"; + + public S3Service(IAmazonS3 s3Client) + { + _s3Client = s3Client; + } + + public async Task UploadFileAsync(string folderName, string fileName, Stream fileStream) + { + var fileTransferUtility = new TransferUtility(_s3Client); + var key = $"{folderName}/{fileName}"; + await fileTransferUtility.UploadAsync(fileStream, BucketName, key); + return $"https://{BucketName}.s3.amazonaws.com/{key}"; + } + + public async Task DownloadFileAsync(string fileUrl, Stream destination) + { + var uri = new Uri(fileUrl); + var request = new GetObjectRequest + { + BucketName = uri.Host.Split('.')[0], + Key = uri.AbsolutePath.Substring(1) + }; + + using (var response = await _s3Client.GetObjectAsync(request)) + using (var responseStream = response.ResponseStream) + { + await responseStream.CopyToAsync(destination); + } + } + + public async Task DeleteFileAsync(string fileUrl) + { + var uri = new Uri(fileUrl); + var deleteObjectRequest = new DeleteObjectRequest + { + BucketName = uri.Host.Split('.')[0], + Key = uri.AbsolutePath.Substring(1) + }; + + await _s3Client.DeleteObjectAsync(deleteObjectRequest); + } + } +} diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Api/Program.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Api/Program.cs index de2217e75..e7eb3260c 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Api/Program.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Api/Program.cs @@ -6,6 +6,8 @@ using Convey.WebApi; using Convey.WebApi.CQRS; using Microsoft.AspNetCore; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -14,6 +16,8 @@ using MiniSpace.Services.Notifications.Application.Dto; using MiniSpace.Services.Notifications.Application.Queries; using MiniSpace.Services.Notifications.Infrastructure; +using MiniSpace.Services.Notifications.Application.Hubs; + namespace MiniSpace.Services.Notifications.Api { @@ -21,14 +25,36 @@ public class Program { public static async Task Main(string[] args) => await WebHost.CreateDefaultBuilder(args) - .ConfigureServices(services => services - .AddConvey() - .AddWebApi() - .AddApplication() - .AddInfrastructure() - .Build()) + .ConfigureServices(services => + { + services.AddConvey() + .AddWebApi() + .AddApplication() + .AddInfrastructure(); + services.AddCors(options => + { + options.AddPolicy("CorsPolicy", + builder => builder + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials() + .SetIsOriginAllowed((host) => true)); + }); + services.AddSignalR(); + services.AddAuthentication(); + services.AddAuthorization(); + }) .Configure(app => app .UseInfrastructure() + .UseRouting() + .UseCors("CorsPolicy") + .UseAuthentication() + .UseAuthorization() + .UseEndpoints(endpoints => + { + endpoints.MapHub("/notificationHub").RequireCors("CorsPolicy"); + endpoints.MapHub("/chatHub").RequireCors("CorsPolicy"); + }) .UseDispatcherEndpoints(endpoints => endpoints .Get("", ctx => ctx.Response.WriteAsync(ctx.RequestServices.GetService().Name)) .Get>("notifications/{userId}") diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/CommentCreatedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/CommentCreatedHandler.cs index a6390921e..029508793 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/CommentCreatedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/CommentCreatedHandler.cs @@ -1,11 +1,14 @@ +using System; using Convey.CQRS.Events; using MiniSpace.Services.Notifications.Core.Repositories; using MiniSpace.Services.Notifications.Application.Services; using MiniSpace.Services.Notifications.Core.Entities; -using System; using System.Threading.Tasks; using System.Threading; using MiniSpace.Services.Notifications.Application.Services.Clients; +using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.SignalR; +using MiniSpace.Services.Notifications.Application.Hubs; using MiniSpace.Services.Notifications.Application.Dto; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers @@ -17,19 +20,25 @@ public class CommentCreatedHandler : IEventHandler private readonly IEventsServiceClient _eventsServiceClient; private readonly IStudentNotificationsRepository _studentNotificationsRepository; private readonly ICommentsServiceClient _commentsServiceClient; + private readonly ILogger _logger; + private readonly IHubContext _hubContext; public CommentCreatedHandler( IMessageBroker messageBroker, IStudentsServiceClient studentsServiceClient, IEventsServiceClient eventsServiceClient, IStudentNotificationsRepository studentNotificationsRepository, - ICommentsServiceClient commentsServiceClient) + ICommentsServiceClient commentsServiceClient, + ILogger logger, + IHubContext hubContext) { _messageBroker = messageBroker; _studentsServiceClient = studentsServiceClient; _eventsServiceClient = eventsServiceClient; _studentNotificationsRepository = studentNotificationsRepository; _commentsServiceClient = commentsServiceClient; + _logger = logger; + _hubContext = hubContext; } public async Task HandleAsync(CommentCreated eventArgs, CancellationToken cancellationToken) @@ -40,13 +49,13 @@ public async Task HandleAsync(CommentCreated eventArgs, CancellationToken cancel commentDetails = await _commentsServiceClient.GetCommentAsync(eventArgs.CommentId); if (commentDetails == null) { - Console.WriteLine("No comment details found."); + _logger.LogError("No comment details found."); return; } } catch (Exception ex) { - Console.WriteLine($"Failed to retrieve comment details: {ex.Message}"); + _logger.LogError($"Failed to retrieve comment details: {ex.Message}"); throw; } @@ -56,13 +65,13 @@ public async Task HandleAsync(CommentCreated eventArgs, CancellationToken cancel eventDetails = await _eventsServiceClient.GetEventAsync(commentDetails.ContextId); if (eventDetails == null) { - Console.WriteLine("Event details for comment context not found."); + _logger.LogError("Event details for comment context not found."); return; } } catch (Exception ex) { - Console.WriteLine($"Failed to retrieve event details for comment context: {ex.Message}"); + _logger.LogError($"Failed to retrieve event details for comment context: {ex.Message}"); throw; } @@ -84,29 +93,41 @@ public async Task HandleAsync(CommentCreated eventArgs, CancellationToken cancel ); studentNotifications.AddNotification(userNotification); - await _studentNotificationsRepository.AddOrUpdateAsync(studentNotifications); - + await _studentNotificationsRepository.AddOrUpdateAsync(studentNotifications); + var userNotificationDetailsHtml = $"

Your comment on the event '{eventDetails.Name}' has been posted successfully.

"; var notificationCreatedEvent = new NotificationCreated( notificationId: Guid.NewGuid(), userId: commentDetails.StudentId, - message: $"Thank you for your comment on the event '{eventDetails.Name}'.", - createdAt: DateTime.UtcNow, - eventType: NotificationEventType.NewEvent.ToString(), + message: $"Thank you for your comment on the event '{eventDetails.Name}'.", + createdAt: DateTime.UtcNow, + eventType: NotificationEventType.CommentCreated.ToString(), relatedEntityId: eventArgs.CommentId, details: userNotificationDetailsHtml ); await _messageBroker.PublishAsync(notificationCreatedEvent); + var notificationDto = new NotificationDto + { + UserId = commentDetails.StudentId, + Message = $"Thank you for your comment on the event '{eventDetails.Name}'.", + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.CommentCreated, + RelatedEntityId = eventArgs.CommentId, + Details = userNotificationDetailsHtml + }; + + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation("Broadcasted SignalR notification to all users."); + var organizerNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(eventDetails.Organizer.Id); if (organizerNotifications == null) { organizerNotifications = new StudentNotifications(eventDetails.Organizer.Id); } - var organizerNotification = new Notification( notificationId: Guid.NewGuid(), userId: eventDetails.Organizer.Id, @@ -119,8 +140,8 @@ public async Task HandleAsync(CommentCreated eventArgs, CancellationToken cancel ); organizerNotifications.AddNotification(organizerNotification); - await _studentNotificationsRepository.AddOrUpdateAsync(organizerNotifications); - + await _studentNotificationsRepository.AddOrUpdateAsync(organizerNotifications); + var organizerNotificationDetailsHtml = $"

{commentDetails.StudentName} commented on your event '{eventDetails.Name}': {commentDetails.CommentContext}

"; var organizerNotificationCreatedEvent = new NotificationCreated( @@ -134,6 +155,19 @@ public async Task HandleAsync(CommentCreated eventArgs, CancellationToken cancel ); await _messageBroker.PublishAsync(organizerNotificationCreatedEvent); + + var organizerNotificationDto = new NotificationDto + { + UserId = eventDetails.Organizer.Id, + Message = $"A new comment has been posted by {commentDetails.StudentName} on your event '{eventDetails.Name}'.", + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.CommentCreated, + RelatedEntityId = eventArgs.CommentId, + Details = organizerNotificationDetailsHtml + }; + + await NotificationHub.BroadcastNotification(_hubContext, organizerNotificationDto, _logger); + _logger.LogInformation("Broadcasted SignalR notification to all users."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/CommentUpdatedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/CommentUpdatedHandler.cs index a7c056f96..7234a9295 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/CommentUpdatedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/CommentUpdatedHandler.cs @@ -1,11 +1,14 @@ +using System; using Convey.CQRS.Events; using MiniSpace.Services.Notifications.Core.Repositories; using MiniSpace.Services.Notifications.Application.Services; using MiniSpace.Services.Notifications.Core.Entities; -using System; using System.Threading.Tasks; using System.Threading; using MiniSpace.Services.Notifications.Application.Services.Clients; +using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.SignalR; +using MiniSpace.Services.Notifications.Application.Hubs; using MiniSpace.Services.Notifications.Application.Dto; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers @@ -17,19 +20,25 @@ public class CommentUpdatedHandler : IEventHandler private readonly IEventsServiceClient _eventsServiceClient; private readonly IStudentNotificationsRepository _studentNotificationsRepository; private readonly ICommentsServiceClient _commentsServiceClient; + private readonly ILogger _logger; + private readonly IHubContext _hubContext; public CommentUpdatedHandler( IMessageBroker messageBroker, IStudentsServiceClient studentsServiceClient, IEventsServiceClient eventsServiceClient, IStudentNotificationsRepository studentNotificationsRepository, - ICommentsServiceClient commentsServiceClient) + ICommentsServiceClient commentsServiceClient, + ILogger logger, + IHubContext hubContext) { _messageBroker = messageBroker; _studentsServiceClient = studentsServiceClient; _eventsServiceClient = eventsServiceClient; _studentNotificationsRepository = studentNotificationsRepository; _commentsServiceClient = commentsServiceClient; + _logger = logger; + _hubContext = hubContext; } public async Task HandleAsync(CommentUpdated eventArgs, CancellationToken cancellationToken) @@ -40,13 +49,13 @@ public async Task HandleAsync(CommentUpdated eventArgs, CancellationToken cancel commentDetails = await _commentsServiceClient.GetCommentAsync(eventArgs.CommentId); if (commentDetails == null) { - Console.WriteLine("Updated comment details not found."); + _logger.LogError("Updated comment details not found."); return; } } catch (Exception ex) { - Console.WriteLine($"Failed to retrieve updated comment details: {ex.Message}"); + _logger.LogError($"Failed to retrieve updated comment details: {ex.Message}"); throw; } @@ -56,13 +65,13 @@ public async Task HandleAsync(CommentUpdated eventArgs, CancellationToken cancel eventDetails = await _eventsServiceClient.GetEventAsync(commentDetails.ContextId); if (eventDetails == null) { - Console.WriteLine("Event details for comment context not found."); + _logger.LogError("Event details for comment context not found."); return; } } catch (Exception ex) { - Console.WriteLine($"Failed to retrieve event details for comment context: {ex.Message}"); + _logger.LogError($"Failed to retrieve event details for comment context: {ex.Message}"); throw; } @@ -100,6 +109,19 @@ public async Task HandleAsync(CommentUpdated eventArgs, CancellationToken cancel await _messageBroker.PublishAsync(notificationUpdatedEvent); + var notificationDto = new NotificationDto + { + UserId = commentDetails.StudentId, + Message = userNotification.Message, + CreatedAt = userNotification.CreatedAt, + EventType = NotificationEventType.CommentUpdated, + RelatedEntityId = eventArgs.CommentId, + Details = userNotificationDetailsHtml + }; + + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation("Broadcasted SignalR notification to all users."); + var organizerNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(eventDetails.Organizer.Id); if (organizerNotifications == null) { @@ -133,6 +155,19 @@ public async Task HandleAsync(CommentUpdated eventArgs, CancellationToken cancel ); await _messageBroker.PublishAsync(organizerNotificationUpdatedEvent); + + var organizerNotificationDto = new NotificationDto + { + UserId = eventDetails.Organizer.Id, + Message = organizerNotification.Message, + CreatedAt = organizerNotification.CreatedAt, + EventType = NotificationEventType.CommentUpdated, + RelatedEntityId = eventArgs.CommentId, + Details = organizerNotificationDetailsHtml + }; + + await NotificationHub.BroadcastNotification(_hubContext, organizerNotificationDto, _logger); + _logger.LogInformation("Broadcasted SignalR notification to all users."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/EventDeletedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/EventDeletedHandler.cs index c9b62c4cb..fdf8e3c51 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/EventDeletedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/EventDeletedHandler.cs @@ -1,59 +1,93 @@ -using Convey.CQRS.Events; -using MiniSpace.Services.Notifications.Core.Repositories; -using MiniSpace.Services.Notifications.Application.Services; -using MiniSpace.Services.Notifications.Core.Entities; using System; -using System.Collections.Generic; -using System.Threading.Tasks; using System.Threading; -using MiniSpace.Services.Notifications.Application.Services.Clients; +using System.Threading.Tasks; +using Convey.CQRS.Events; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Application.Hubs; +using MiniSpace.Services.Notifications.Application.Services; +using MiniSpace.Services.Notifications.Application.Services.Clients; +using MiniSpace.Services.Notifications.Core.Entities; +using MiniSpace.Services.Notifications.Core.Repositories; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { public class EventDeletedHandler : IEventHandler { private readonly IMessageBroker _messageBroker; - private readonly IEventsServiceClient _eventsServiceClient; + private readonly IEventsServiceClient _eventsServiceClient; private readonly IStudentNotificationsRepository _studentNotificationsRepository; private readonly IStudentsServiceClient _studentsServiceClient; + private readonly ILogger _logger; + private readonly IHubContext _hubContext; public EventDeletedHandler( IMessageBroker messageBroker, - IEventsServiceClient eventsServiceClient, + IEventsServiceClient eventsServiceClient, IStudentNotificationsRepository studentNotificationsRepository, - IStudentsServiceClient studentsServiceClient) + IStudentsServiceClient studentsServiceClient, + ILogger logger, + IHubContext hubContext) { _messageBroker = messageBroker; - _eventsServiceClient = eventsServiceClient; + _eventsServiceClient = eventsServiceClient; _studentNotificationsRepository = studentNotificationsRepository; _studentsServiceClient = studentsServiceClient; + _logger = logger; + _hubContext = hubContext; } - public async Task HandleAsync(EventDeleted eventDeleted, CancellationToken cancellationToken) { - EventDto eventDetails = await _eventsServiceClient.GetEventAsync(eventDeleted.EventId); - if (eventDetails == null) + EventDto eventDetails; + try + { + eventDetails = await _eventsServiceClient.GetEventAsync(eventDeleted.EventId); + if (eventDetails == null) + { + _logger.LogError($"Event with ID {eventDeleted.EventId} not found."); + return; + } + } + catch (Exception ex) { - Console.WriteLine("Event details could not be retrieved."); + _logger.LogError($"Failed to retrieve event details: {ex.Message}"); return; } - var eventParticipants = await _eventsServiceClient.GetParticipantsAsync(eventDeleted.EventId); - if (eventParticipants == null) + EventParticipantsDto eventParticipants; + try { - Console.WriteLine("No participants found for the event."); + eventParticipants = await _eventsServiceClient.GetParticipantsAsync(eventDeleted.EventId); + if (eventParticipants == null) + { + _logger.LogError($"No participants found for event with ID {eventDeleted.EventId}."); + return; + } + } + catch (Exception ex) + { + _logger.LogError($"Failed to retrieve participants for event with ID {eventDeleted.EventId}: {ex.Message}"); return; } - foreach (var studentParticipant in eventParticipants.SignedUpStudents) { - var student = await _studentsServiceClient.GetAsync(studentParticipant.StudentId); - if (student == null) + StudentDto student; + try { - continue; // Skip if student details cannot be retrieved + student = await _studentsServiceClient.GetAsync(studentParticipant.StudentId); + if (student == null) + { + _logger.LogWarning($"Student with ID {studentParticipant.StudentId} not found."); + continue; + } + } + catch (Exception ex) + { + _logger.LogError($"Failed to retrieve student with ID {studentParticipant.StudentId}: {ex.Message}"); + continue; } var notificationMessage = $"The event you were signed up for has been cancelled."; @@ -70,29 +104,40 @@ public async Task HandleAsync(EventDeleted eventDeleted, CancellationToken cance eventType: NotificationEventType.EventDeleted ); - var studentNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(student.Id); if (studentNotifications == null) { studentNotifications = new StudentNotifications(student.Id); } - - + studentNotifications.AddNotification(notification); await _studentNotificationsRepository.AddOrUpdateAsync(studentNotifications); var notificationCreatedEvent = new NotificationCreated( - notificationId: Guid.NewGuid(), - userId: student.Id, - message: notificationMessage, - createdAt: DateTime.UtcNow, - eventType: NotificationEventType.EventDeleted.ToString(), - relatedEntityId: eventDeleted.EventId, - details: detailsHtml + notification.NotificationId, + student.Id, + notificationMessage, + DateTime.UtcNow, + NotificationEventType.EventDeleted.ToString(), + eventDeleted.EventId, + detailsHtml ); await _messageBroker.PublishAsync(notificationCreatedEvent); - } + + var notificationDto = new NotificationDto + { + UserId = student.Id, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.EventDeleted, + RelatedEntityId = eventDeleted.EventId, + Details = detailsHtml + }; + + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to student with ID {student.Id}."); + } } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/EventParticipantAddedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/EventParticipantAddedHandler.cs index 7850f0673..820a3efba 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/EventParticipantAddedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/EventParticipantAddedHandler.cs @@ -1,12 +1,15 @@ using System; +using System.Threading; +using System.Threading.Tasks; using Convey.CQRS.Events; -using MiniSpace.Services.Notifications.Core.Repositories; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Application.Hubs; using MiniSpace.Services.Notifications.Application.Services; -using MiniSpace.Services.Notifications.Core.Entities; -using System.Collections.Generic; -using System.Threading.Tasks; -using System.Threading; using MiniSpace.Services.Notifications.Application.Services.Clients; +using MiniSpace.Services.Notifications.Core.Entities; +using MiniSpace.Services.Notifications.Core.Repositories; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -16,17 +19,23 @@ public class EventParticipantAddedHandler : IEventHandler private readonly IStudentNotificationsRepository _studentNotificationsRepository; private readonly IStudentsServiceClient _studentsServiceClient; private readonly IEventsServiceClient _eventsServiceClient; + private readonly ILogger _logger; + private readonly IHubContext _hubContext; public EventParticipantAddedHandler( IMessageBroker messageBroker, IStudentNotificationsRepository studentNotificationsRepository, IStudentsServiceClient studentsServiceClient, - IEventsServiceClient eventsServiceClient) + IEventsServiceClient eventsServiceClient, + ILogger logger, + IHubContext hubContext) { _messageBroker = messageBroker; _studentNotificationsRepository = studentNotificationsRepository; _studentsServiceClient = studentsServiceClient; _eventsServiceClient = eventsServiceClient; + _logger = logger; + _hubContext = hubContext; } public async Task HandleAsync(EventParticipantAdded eventArgs, CancellationToken cancellationToken) @@ -56,7 +65,10 @@ public async Task HandleAsync(EventParticipantAdded eventArgs, CancellationToken eventType: NotificationEventType.EventParticipantAdded, details: detailsHtml ); - + + participantNotifications.AddNotification(notification); + await _studentNotificationsRepository.AddOrUpdateAsync(participantNotifications); + var notificationCreatedEvent = new NotificationCreated( notificationId: notification.NotificationId, userId: notification.UserId, @@ -69,11 +81,18 @@ public async Task HandleAsync(EventParticipantAdded eventArgs, CancellationToken await _messageBroker.PublishAsync(notificationCreatedEvent); + var notificationDto = new NotificationDto + { + UserId = eventArgs.ParticipantId, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.EventParticipantAdded, + RelatedEntityId = eventArgs.EventId, + Details = detailsHtml + }; - participantNotifications.AddNotification(notification); - await _studentNotificationsRepository.AddOrUpdateAsync(participantNotifications); - - + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to participant with ID {eventArgs.ParticipantId}."); if (eventDetails != null && eventDetails.Organizer != null) { @@ -96,23 +115,33 @@ public async Task HandleAsync(EventParticipantAdded eventArgs, CancellationToken organizerNotifications = new StudentNotifications(eventDetails.Organizer.Id); } - - organizerNotifications.AddNotification(organizerNotification); await _studentNotificationsRepository.AddOrUpdateAsync(organizerNotifications); var organizerNotificationCreatedEvent = new NotificationCreated( - notificationId: Guid.NewGuid(), + notificationId: organizerNotification.NotificationId, userId: eventDetails.Organizer.Id, - message: $"{eventArgs.ParticipantName} has been added as a participant to your event '{eventDetails.Name}'.", + message: organizerNotification.Message, createdAt: DateTime.UtcNow, eventType: NotificationEventType.EventParticipantAdded.ToString(), relatedEntityId: eventArgs.EventId, details: detailsHtmlForOrganizer ); + await _messageBroker.PublishAsync(organizerNotificationCreatedEvent); - + var organizerNotificationDto = new NotificationDto + { + UserId = eventDetails.Organizer.Id, + Message = organizerNotification.Message, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.EventParticipantAdded, + RelatedEntityId = eventArgs.EventId, + Details = detailsHtmlForOrganizer + }; + + await NotificationHub.BroadcastNotification(_hubContext, organizerNotificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to organizer with ID {eventDetails.Organizer.Id}."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/EventParticipantRemovedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/EventParticipantRemovedHandler.cs index 2ea1c1364..2bc1f5b7c 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/EventParticipantRemovedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/EventParticipantRemovedHandler.cs @@ -1,11 +1,15 @@ using System; +using System.Threading; +using System.Threading.Tasks; using Convey.CQRS.Events; -using MiniSpace.Services.Notifications.Core.Repositories; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Application.Hubs; using MiniSpace.Services.Notifications.Application.Services; -using MiniSpace.Services.Notifications.Core.Entities; -using System.Threading.Tasks; -using System.Threading; using MiniSpace.Services.Notifications.Application.Services.Clients; +using MiniSpace.Services.Notifications.Core.Entities; +using MiniSpace.Services.Notifications.Core.Repositories; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -15,17 +19,23 @@ public class EventParticipantRemovedHandler : IEventHandler _logger; + private readonly IHubContext _hubContext; public EventParticipantRemovedHandler( IMessageBroker messageBroker, IStudentNotificationsRepository studentNotificationsRepository, IStudentsServiceClient studentsServiceClient, - IEventsServiceClient eventsServiceClient) + IEventsServiceClient eventsServiceClient, + ILogger logger, + IHubContext hubContext) { _messageBroker = messageBroker; _studentNotificationsRepository = studentNotificationsRepository; _studentsServiceClient = studentsServiceClient; _eventsServiceClient = eventsServiceClient; + _logger = logger; + _hubContext = hubContext; } public async Task HandleAsync(EventParticipantRemoved eventArgs, CancellationToken cancellationToken) @@ -60,16 +70,29 @@ public async Task HandleAsync(EventParticipantRemoved eventArgs, CancellationTok await _studentNotificationsRepository.AddOrUpdateAsync(participantNotifications); var notificationCreatedEvent = new NotificationCreated( - notificationId: Guid.NewGuid(), - userId: eventArgs.Participant, - message: notificationMessage, - createdAt: DateTime.UtcNow, - eventType: NotificationEventType.NewEvent.ToString(), - relatedEntityId: eventArgs.EventId, - details: detailsHtml + notification.NotificationId, + eventArgs.Participant, + notificationMessage, + DateTime.UtcNow, + NotificationEventType.EventParticipantRemoved.ToString(), + eventArgs.EventId, + detailsHtml ); await _messageBroker.PublishAsync(notificationCreatedEvent); + + var notificationDto = new NotificationDto + { + UserId = eventArgs.Participant, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.EventParticipantRemoved, + RelatedEntityId = eventArgs.EventId, + Details = detailsHtml + }; + + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to participant with ID {eventArgs.Participant}."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendAddedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendAddedHandler.cs index 7530b33a9..c1d7c0d56 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendAddedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendAddedHandler.cs @@ -4,7 +4,7 @@ using MiniSpace.Services.Notifications.Core.Entities; using System.Collections.Generic; using MiniSpace.Services.Notifications.Application.Exceptions; - +// This event handler is not used! ⁉️ namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { public class FriendAddedHandler : IEventHandler diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendInvitedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendInvitedHandler.cs index 8dd3362b6..888e257c8 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendInvitedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendInvitedHandler.cs @@ -6,6 +6,10 @@ using MiniSpace.Services.Notifications.Application.Exceptions; using MiniSpace.Services.Notifications.Application.Services.Clients; using System.Text.Json; +using Microsoft.AspNetCore.SignalR; +using MiniSpace.Services.Notifications.Application.Hubs; +using Microsoft.Extensions.Logging; +using MiniSpace.Services.Notifications.Application.Dto; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -16,13 +20,17 @@ public class FriendInvitedHandler : IEventHandler private readonly IStudentsServiceClient _studentsServiceClient; private readonly IEventMapper _eventMapper; private readonly IMessageBroker _messageBroker; + private readonly ILogger _logger; + private readonly IHubContext _hubContext; public FriendInvitedHandler( INotificationRepository notificationRepository, IStudentNotificationsRepository studentNotificationsRepository, IStudentsServiceClient studentsServiceClient, IEventMapper eventMapper, - IMessageBroker messageBroker + IMessageBroker messageBroker, + ILogger logger, + IHubContext hubContext ) { _notificationRepository = notificationRepository; @@ -30,6 +38,8 @@ IMessageBroker messageBroker _studentsServiceClient = studentsServiceClient; _eventMapper = eventMapper; _messageBroker = messageBroker; + _logger = logger; + _hubContext = hubContext; } public async Task HandleAsync(FriendInvited @event, CancellationToken cancellationToken) @@ -68,6 +78,20 @@ public async Task HandleAsync(FriendInvited @event, CancellationToken cancellati @event.InviterId, detailsHtml ); + + var notificationDto = new NotificationDto + { + UserId = @event.InviteeId, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.FriendRequestAccepted, + RelatedEntityId = @event.InviterId, + Details = detailsHtml + }; + + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Sent SignalR notification to all users with user id UserId={@event.InviteeId}."); + var serializedEvent = JsonSerializer.Serialize(notificationCreatedEvent, new JsonSerializerOptions { diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendRequestCreatedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendRequestCreatedHandler.cs index 28e7b3f4d..afa812cb0 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendRequestCreatedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendRequestCreatedHandler.cs @@ -1,11 +1,16 @@ -using System.Text.Json; +using System; using Convey.CQRS.Events; using Microsoft.Extensions.Logging; +using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Application.Hubs; using MiniSpace.Services.Notifications.Application.Services; using MiniSpace.Services.Notifications.Application.Services.Clients; using MiniSpace.Services.Notifications.Core.Entities; using MiniSpace.Services.Notifications.Core.Events; using MiniSpace.Services.Notifications.Core.Repositories; +using Microsoft.AspNetCore.SignalR; +using System.Threading.Tasks; +using System.Threading; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -17,14 +22,22 @@ public class FriendRequestCreatedHandler : IEventHandler private readonly IEventMapper _eventMapper; private readonly IMessageBroker _messageBroker; private readonly ILogger _logger; + private readonly IHubContext _hubContext; - public FriendRequestCreatedHandler(IFriendEventRepository friendEventRepository, IStudentsServiceClient studentsServiceClient, IEventMapper eventMapper, IMessageBroker messageBroker, ILogger logger) + public FriendRequestCreatedHandler( + IFriendEventRepository friendEventRepository, + IStudentsServiceClient studentsServiceClient, + IEventMapper eventMapper, + IMessageBroker messageBroker, + ILogger logger, + IHubContext hubContext) { _friendEventRepository = friendEventRepository; _studentsServiceClient = studentsServiceClient; _eventMapper = eventMapper; _messageBroker = messageBroker; _logger = logger; + _hubContext = hubContext; } public async Task HandleAsync(FriendRequestCreated friendEvent, CancellationToken cancellationToken) @@ -44,7 +57,6 @@ public async Task HandleAsync(FriendRequestCreated friendEvent, CancellationToke var notificationMessage = $"You have received a friend request from {requester.FirstName} {requester.LastName}"; var detailsHtml = $"

Click here to view the request.

"; - var newFriendEvent = new FriendEvent( id: Guid.NewGuid(), eventId: Guid.NewGuid(), @@ -66,18 +78,30 @@ public async Task HandleAsync(FriendRequestCreated friendEvent, CancellationToke eventType: NotificationEventType.NewFriendRequest, details: detailsHtml ); - await _messageBroker.PublishAsync(friendEvent); - await _friendEventRepository.AddAsync(newFriendEvent); - + var notificationDto = new NotificationDto + { + UserId = friendEvent.FriendId, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.NewFriendRequest, + RelatedEntityId = friendEvent.RequesterId, + Details = detailsHtml + }; + + await NotificationHub.SendNotification(_hubContext, friendEvent.FriendId.ToString(), notificationDto, _logger); + _logger.LogInformation($"Sent SignalR notification to UserId={friendEvent.FriendId}"); + + await _friendEventRepository.AddAsync(newFriendEvent); _logger.LogInformation($"Stored new friend event for UserId={friendEvent.RequesterId} with details: {eventDetails}"); + var notificationCreatedEvent = new NotificationCreated( - notificationId: Guid.NewGuid(), - userId: friendEvent.FriendId, + notificationId: notification.NotificationId, + userId: friendEvent.FriendId, message: notificationMessage, createdAt: DateTime.UtcNow, eventType: NotificationEventType.NewFriendRequest.ToString(), - relatedEntityId: friendEvent.FriendId, + relatedEntityId: friendEvent.RequesterId, details: detailsHtml ); diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendRequestSentHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendRequestSentHandler.cs index 29fff237e..0f1e93a13 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendRequestSentHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/FriendRequestSentHandler.cs @@ -7,6 +7,10 @@ using System.Threading; using MiniSpace.Services.Notifications.Application.Services.Clients; using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.SignalR; +using MiniSpace.Services.Notifications.Application.Hubs; +using MiniSpace.Services.Notifications.Application.Dto; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -15,20 +19,32 @@ public class FriendRequestSentHandler : IEventHandler private readonly IMessageBroker _messageBroker; private readonly IStudentNotificationsRepository _studentNotificationsRepository; private readonly IStudentsServiceClient _studentsServiceClient; + private readonly ILogger _logger; + private readonly IHubContext _hubContext; public FriendRequestSentHandler( IMessageBroker messageBroker, IStudentNotificationsRepository studentNotificationsRepository, - IStudentsServiceClient studentsServiceClient) + IStudentsServiceClient studentsServiceClient, + ILogger logger, + IHubContext hubContext) { _messageBroker = messageBroker; _studentNotificationsRepository = studentNotificationsRepository; _studentsServiceClient = studentsServiceClient; + _logger = logger; + _hubContext = hubContext; } public async Task HandleAsync(FriendRequestSent @event, CancellationToken cancellationToken) { var inviter = await _studentsServiceClient.GetAsync(@event.InviterId); + if (inviter == null) + { + _logger.LogError($"Inviter not found with ID={@event.InviterId}"); + return; + } + var notificationMessage = $"You have been invited by {inviter.FirstName} {inviter.LastName} to be friends."; var detailsHtml = $"

View {inviter.FirstName} {inviter.LastName}'s profile to respond to the friend invitation.

"; @@ -58,7 +74,24 @@ public async Task HandleAsync(FriendRequestSent @event, CancellationToken cancel detailsHtml ); + var notificationDto = new NotificationDto + { + UserId = @event.InviteeId, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.FriendRequestAccepted, + RelatedEntityId = @event.InviterId, + Details = detailsHtml + }; + + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation("Sent SignalR notification to all users."); + + // await NotificationHub.SendNotification(_hubContext, @event.InviteeId.ToString(), notificationDto, _logger); + // _logger.LogInformation($"Sent SignalR notification to UserId={@event.InviteeId}"); + await _messageBroker.PublishAsync(notificationCreatedEvent); + _logger.LogInformation($"Published NotificationCreated event for UserId={notification.UserId}"); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/PendingFriendRequestAcceptedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/PendingFriendRequestAcceptedHandler.cs index 992acaa43..1a7ef5d67 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/PendingFriendRequestAcceptedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/PendingFriendRequestAcceptedHandler.cs @@ -7,6 +7,10 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using MiniSpace.Services.Notifications.Application.Services; +using Microsoft.AspNetCore.SignalR; +using MiniSpace.Services.Notifications.Application.Hubs; +using MiniSpace.Services.Notifications.Application.Dto; + namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -16,17 +20,20 @@ public class PendingFriendRequestAcceptedHandler : IEventHandler _logger; + private readonly IHubContext _hubContext; public PendingFriendRequestAcceptedHandler( IStudentNotificationsRepository studentNotificationsRepository, IMessageBroker messageBroker, IStudentsServiceClient studentsServiceClient, - ILogger logger) + ILogger logger, + IHubContext hubContext) { _studentNotificationsRepository = studentNotificationsRepository; _messageBroker = messageBroker; _studentsServiceClient = studentsServiceClient; _logger = logger; + _hubContext = hubContext; } public async Task HandleAsync(PendingFriendAccepted @event, CancellationToken cancellationToken) @@ -75,6 +82,19 @@ public async Task HandleAsync(PendingFriendAccepted @event, CancellationToken ca details: detailsHtml ); + var notificationDto = new NotificationDto + { + UserId = @event.RequesterId, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.FriendRequestAccepted, + RelatedEntityId = @event.FriendId, + Details = detailsHtml + }; + + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation("Broadcasted SignalR notification to all users."); + await _messageBroker.PublishAsync(notificationCreatedEvent); _logger.LogInformation($"Published enhanced NotificationCreated event for UserId={notification.UserId}"); diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/PostCreatedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/PostCreatedHandler.cs index 2635698b0..376975fd3 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/PostCreatedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/PostCreatedHandler.cs @@ -7,6 +7,9 @@ using MiniSpace.Services.Notifications.Core.Entities; using MiniSpace.Services.Notifications.Application.Dto; using MiniSpace.Services.Notifications.Application.Services; +using Microsoft.AspNetCore.SignalR; +using MiniSpace.Services.Notifications.Application.Hubs; +using Microsoft.Extensions.Logging; using MiniSpace.Services.Notifications.Application.DTO; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers @@ -18,19 +21,25 @@ public class PostCreatedHandler : IEventHandler private readonly IEventsServiceClient _eventsServiceClient; private readonly IPostsServiceClient _postsServiceClient; private readonly IStudentsServiceClient _studentsServiceClient; + private readonly IHubContext _hubContext; + private readonly ILogger _logger; public PostCreatedHandler( IMessageBroker messageBroker, IStudentNotificationsRepository studentNotificationsRepository, IEventsServiceClient eventsServiceClient, IPostsServiceClient postsServiceClient, - IStudentsServiceClient studentsServiceClient) + IStudentsServiceClient studentsServiceClient, + IHubContext hubContext, + ILogger logger) { _messageBroker = messageBroker; _studentNotificationsRepository = studentNotificationsRepository; _eventsServiceClient = eventsServiceClient; _postsServiceClient = postsServiceClient; _studentsServiceClient = studentsServiceClient; + _hubContext = hubContext; + _logger = logger; } public async Task HandleAsync(PostCreated eventArgs, CancellationToken cancellationToken) @@ -38,24 +47,24 @@ public async Task HandleAsync(PostCreated eventArgs, CancellationToken cancellat var post = await _postsServiceClient.GetPostAsync(eventArgs.PostId); if (post == null) { - Console.WriteLine("Post not found."); + _logger.LogError("Post not found."); return; } var eventDetails = await _eventsServiceClient.GetEventAsync(post.EventId); if (eventDetails == null) { - Console.WriteLine("Event not found for the post."); + _logger.LogError("Event not found for the post."); return; } var eventParticipants = await _eventsServiceClient.GetParticipantsAsync(post.EventId); if (eventParticipants == null) { - Console.WriteLine("No participants found for the event."); + _logger.LogError("No participants found for the event."); return; } - + foreach (var studentParticipant in eventParticipants.InterestedStudents) { var student = await _studentsServiceClient.GetAsync(studentParticipant.StudentId); @@ -73,7 +82,7 @@ public async Task HandleAsync(PostCreated eventArgs, CancellationToken cancellat await NotifyStudent(student, eventDetails, post); } } - + // Notify the organizer await NotifyOrganizer(eventDetails.Organizer, eventDetails, post); } @@ -95,7 +104,7 @@ private async Task NotifyStudent(StudentDto student, EventDto eventDetails, Post details: detailsHtml ); - Console.WriteLine($"Creating post creation notification for user: {student.Id}"); + _logger.LogInformation($"Creating post creation notification for user: {student.Id}"); var studentNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(student.Id); if (studentNotifications == null) @@ -107,17 +116,30 @@ private async Task NotifyStudent(StudentDto student, EventDto eventDetails, Post await _studentNotificationsRepository.AddOrUpdateAsync(studentNotifications); var notificationCreatedEvent = new NotificationCreated( - notificationId: Guid.NewGuid(), - userId: student.Id, - message: notificationMessage, - createdAt: DateTime.UtcNow, - eventType: NotificationEventType.PostCreated.ToString(), - relatedEntityId: post.EventId, - details: detailsHtml + notification.NotificationId, + student.Id, + notificationMessage, + DateTime.UtcNow, + NotificationEventType.PostCreated.ToString(), + post.EventId, + detailsHtml ); await _messageBroker.PublishAsync(notificationCreatedEvent); - + + var notificationDto = new NotificationDto + { + UserId = student.Id, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.PostCreated, + RelatedEntityId = post.EventId, + Details = detailsHtml + }; + + // Broadcast SignalR notification + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to student with ID {student.Id}."); } private async Task NotifyOrganizer(OrganizerDto organizer, EventDto eventDetails, PostDto post) @@ -137,30 +159,42 @@ private async Task NotifyOrganizer(OrganizerDto organizer, EventDto eventDetails details: detailsHtml ); - Console.WriteLine($"Creating post creation notification for organizer: {organizer.Id}"); + _logger.LogInformation($"Creating post creation notification for organizer: {organizer.Id}"); var organizerNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(organizer.Id); if (organizerNotifications == null) { organizerNotifications = new StudentNotifications(organizer.Id); } - + organizerNotifications.AddNotification(notification); await _studentNotificationsRepository.AddOrUpdateAsync(organizerNotifications); - var notificationCreatedEvent = new NotificationCreated( - notificationId: Guid.NewGuid(), - userId: organizer.Id, - message: notificationMessage, - createdAt: DateTime.UtcNow, - eventType: NotificationEventType.PostCreated.ToString(), - relatedEntityId: post.EventId, - details: detailsHtml + var notificationCreatedEvent = new NotificationCreated( + notification.NotificationId, + organizer.Id, + notificationMessage, + DateTime.UtcNow, + NotificationEventType.PostCreated.ToString(), + post.EventId, + detailsHtml ); await _messageBroker.PublishAsync(notificationCreatedEvent); - + var notificationDto = new NotificationDto + { + UserId = organizer.Id, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.PostCreated, + RelatedEntityId = post.EventId, + Details = detailsHtml + }; + + // Broadcast SignalR notification + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to organizer with ID {organizer.Id}."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/PostUpdatedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/PostUpdatedHandler.cs index bbc0ad00c..802fc603b 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/PostUpdatedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/PostUpdatedHandler.cs @@ -1,11 +1,14 @@ using System; -using System.Threading.Tasks; using System.Threading; +using System.Threading.Tasks; using Convey.CQRS.Events; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Application.Hubs; using MiniSpace.Services.Notifications.Application.Services.Clients; -using MiniSpace.Services.Notifications.Core.Repositories; using MiniSpace.Services.Notifications.Core.Entities; -using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Core.Repositories; using MiniSpace.Services.Notifications.Application.Services; using MiniSpace.Services.Notifications.Application.DTO; @@ -18,19 +21,25 @@ public class PostUpdatedHandler : IEventHandler private readonly IEventsServiceClient _eventsServiceClient; private readonly IPostsServiceClient _postsServiceClient; private readonly IStudentsServiceClient _studentsServiceClient; + private readonly IHubContext _hubContext; + private readonly ILogger _logger; public PostUpdatedHandler( IMessageBroker messageBroker, IStudentNotificationsRepository studentNotificationsRepository, IEventsServiceClient eventsServiceClient, IPostsServiceClient postsServiceClient, - IStudentsServiceClient studentsServiceClient) + IStudentsServiceClient studentsServiceClient, + IHubContext hubContext, + ILogger logger) { _messageBroker = messageBroker; _studentNotificationsRepository = studentNotificationsRepository; _eventsServiceClient = eventsServiceClient; _postsServiceClient = postsServiceClient; _studentsServiceClient = studentsServiceClient; + _hubContext = hubContext; + _logger = logger; } public async Task HandleAsync(PostUpdated eventArgs, CancellationToken cancellationToken) @@ -38,24 +47,24 @@ public async Task HandleAsync(PostUpdated eventArgs, CancellationToken cancellat var post = await _postsServiceClient.GetPostAsync(eventArgs.PostId); if (post == null) { - Console.WriteLine("Updated post not found."); + _logger.LogError("Post not found."); return; } var eventDetails = await _eventsServiceClient.GetEventAsync(post.EventId); if (eventDetails == null) { - Console.WriteLine("Event not found for the updated post."); + _logger.LogError("Event not found for the post."); return; } var eventParticipants = await _eventsServiceClient.GetParticipantsAsync(post.EventId); if (eventParticipants == null) { - Console.WriteLine("No participants found for the event."); + _logger.LogError("No participants found for the event."); return; } - + foreach (var studentParticipant in eventParticipants.InterestedStudents) { var student = await _studentsServiceClient.GetAsync(studentParticipant.StudentId); @@ -73,7 +82,6 @@ public async Task HandleAsync(PostUpdated eventArgs, CancellationToken cancellat await NotifyStudent(student, eventDetails, post); } } - // Notify the organizer await NotifyOrganizer(eventDetails.Organizer, eventDetails, post); @@ -96,28 +104,42 @@ private async Task NotifyStudent(StudentDto student, EventDto eventDetails, Post details: detailsHtml ); - Console.WriteLine($"Creating post update notification for user: {student.Id}"); + _logger.LogInformation($"Creating post update notification for user: {student.Id}"); var studentNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(student.Id); if (studentNotifications == null) { studentNotifications = new StudentNotifications(student.Id); } - + studentNotifications.AddNotification(notification); await _studentNotificationsRepository.AddOrUpdateAsync(studentNotifications); var notificationCreatedEvent = new NotificationCreated( - notificationId: notification.NotificationId, - userId: notification.UserId, - message: notification.Message, - createdAt: notification.CreatedAt, - eventType: notification.EventType.ToString(), - relatedEntityId: notification.RelatedEntityId, - details: detailsHtml + notification.NotificationId, + student.Id, + notificationMessage, + DateTime.UtcNow, + NotificationEventType.PostUpdated.ToString(), + post.EventId, + detailsHtml ); await _messageBroker.PublishAsync(notificationCreatedEvent); + + var notificationDto = new NotificationDto + { + UserId = student.Id, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.PostUpdated, + RelatedEntityId = post.EventId, + Details = detailsHtml + }; + + // Broadcast SignalR notification + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to student with ID {student.Id}."); } private async Task NotifyOrganizer(OrganizerDto organizer, EventDto eventDetails, PostDto post) @@ -137,7 +159,7 @@ private async Task NotifyOrganizer(OrganizerDto organizer, EventDto eventDetails details: detailsHtml ); - Console.WriteLine($"Creating post update notification for organizer: {organizer.Id}"); + _logger.LogInformation($"Creating post update notification for organizer: {organizer.Id}"); var organizerNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(organizer.Id); if (organizerNotifications == null) @@ -149,18 +171,30 @@ private async Task NotifyOrganizer(OrganizerDto organizer, EventDto eventDetails await _studentNotificationsRepository.AddOrUpdateAsync(organizerNotifications); var notificationCreatedEvent = new NotificationCreated( - notificationId: Guid.NewGuid(), - userId: organizer.Id, - message: notificationMessage, - createdAt: DateTime.UtcNow, - eventType: NotificationEventType.PostUpdated.ToString(), - relatedEntityId: post.EventId, - details: detailsHtml + notification.NotificationId, + organizer.Id, + notificationMessage, + DateTime.UtcNow, + NotificationEventType.PostUpdated.ToString(), + post.EventId, + detailsHtml ); await _messageBroker.PublishAsync(notificationCreatedEvent); - + var notificationDto = new NotificationDto + { + UserId = organizer.Id, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.PostUpdated, + RelatedEntityId = post.EventId, + Details = detailsHtml + }; + + // Broadcast SignalR notification + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to organizer with ID {organizer.Id}."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReactionCreatedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReactionCreatedHandler.cs index a04ada7be..658775096 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReactionCreatedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReactionCreatedHandler.cs @@ -1,12 +1,16 @@ -using Convey.CQRS.Events; -using MiniSpace.Services.Notifications.Core.Repositories; -using MiniSpace.Services.Notifications.Application.Services; -using MiniSpace.Services.Notifications.Core.Entities; using System; -using System.Threading.Tasks; +using System.Linq; using System.Threading; -using MiniSpace.Services.Notifications.Application.Services.Clients; +using System.Threading.Tasks; +using Convey.CQRS.Events; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Application.Hubs; +using MiniSpace.Services.Notifications.Application.Services.Clients; +using MiniSpace.Services.Notifications.Core.Entities; +using MiniSpace.Services.Notifications.Core.Repositories; +using MiniSpace.Services.Notifications.Application.Services; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -17,19 +21,25 @@ public class ReactionCreatedHandler : IEventHandler private readonly IReactionsServiceClient _reactionsServiceClient; private readonly IEventsServiceClient _eventsServiceClient; private readonly IPostsServiceClient _postsServiceClient; + private readonly IHubContext _hubContext; + private readonly ILogger _logger; - public ReactionCreatedHandler( + public ReactionCreatedHandler( IMessageBroker messageBroker, IStudentNotificationsRepository studentNotificationsRepository, IReactionsServiceClient reactionsServiceClient, IEventsServiceClient eventsServiceClient, - IPostsServiceClient postsServiceClient) + IPostsServiceClient postsServiceClient, + IHubContext hubContext, + ILogger logger) { _messageBroker = messageBroker; _studentNotificationsRepository = studentNotificationsRepository; _reactionsServiceClient = reactionsServiceClient; _eventsServiceClient = eventsServiceClient; _postsServiceClient = postsServiceClient; + _hubContext = hubContext; + _logger = logger; } public async Task HandleAsync(ReactionCreated eventArgs, CancellationToken cancellationToken) @@ -39,7 +49,7 @@ public async Task HandleAsync(ReactionCreated eventArgs, CancellationToken cance if (reaction == null) { - Console.WriteLine("Reaction details not found."); + _logger.LogError("Reaction details not found."); return; } @@ -49,35 +59,50 @@ public async Task HandleAsync(ReactionCreated eventArgs, CancellationToken cance studentNotifications = new StudentNotifications(reaction.StudentId); } + var notificationMessage = "Your reaction has been recorded."; + var notificationDetailsHtml = "

Thank you for your reaction! Your interaction helps us to better understand what content resonates with our community.

"; + var notification = new Notification( notificationId: Guid.NewGuid(), userId: reaction.StudentId, - message: $"Your reaction has been recorded.", + message: notificationMessage, status: NotificationStatus.Unread, createdAt: DateTime.UtcNow, updatedAt: null, relatedEntityId: reaction.ContentId, - eventType: NotificationEventType.ReactionAdded + eventType: NotificationEventType.ReactionAdded, + details: notificationDetailsHtml ); studentNotifications.AddNotification(notification); await _studentNotificationsRepository.AddOrUpdateAsync(studentNotifications); - var notificationDetailsHtml = $@" -

Thank you for your reaction! Your interaction helps us to better understand what content resonates with our community.

"; - var notificationCreatedEvent = new NotificationCreated( - notificationId: Guid.NewGuid(), - userId: reaction.StudentId, - message: $"Your reaction has been recorded.", - createdAt: DateTime.UtcNow, - eventType: NotificationEventType.ReactionAdded.ToString(), - relatedEntityId: reaction.ContentId, - details: notificationDetailsHtml + notification.NotificationId, + reaction.StudentId, + notificationMessage, + DateTime.UtcNow, + NotificationEventType.ReactionAdded.ToString(), + reaction.ContentId, + notificationDetailsHtml ); await _messageBroker.PublishAsync(notificationCreatedEvent); + var notificationDto = new NotificationDto + { + UserId = reaction.StudentId, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.ReactionAdded, + RelatedEntityId = reaction.ContentId, + Details = notificationDetailsHtml + }; + + // Broadcast SignalR notification to the student + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to student with ID {reaction.StudentId}."); + // Notify the organizer Guid? organizerId = null; if (reaction.ContentType == ReactionContentType.Event) @@ -99,29 +124,49 @@ public async Task HandleAsync(ReactionCreated eventArgs, CancellationToken cance organizerNotifications = new StudentNotifications(organizerId.Value); } + var organizerNotificationMessage = "A new reaction has been added to your content."; + var organizerNotificationDetailsHtml = $"

{reaction.StudentFullName} reacted to your content.

"; + var organizerNotification = new Notification( notificationId: Guid.NewGuid(), userId: organizerId.Value, - message: $"A new reaction has been added to your content.", + message: organizerNotificationMessage, status: NotificationStatus.Unread, createdAt: DateTime.UtcNow, updatedAt: null, relatedEntityId: reaction.ContentId, - eventType: NotificationEventType.ReactionAdded + eventType: NotificationEventType.ReactionAdded, + details: organizerNotificationDetailsHtml ); organizerNotifications.AddNotification(organizerNotification); await _studentNotificationsRepository.AddOrUpdateAsync(organizerNotifications); - await _messageBroker.PublishAsync(new NotificationCreated( - notificationId: Guid.NewGuid(), - userId: organizerId.Value, - message: $"A new reaction has been added to your content.", - createdAt: DateTime.UtcNow, - eventType: NotificationEventType.ReactionAdded.ToString(), - relatedEntityId: reaction.ContentId, - details: $"

{reaction.StudentFullName} reacted to your content.

" - )); + var organizerNotificationCreatedEvent = new NotificationCreated( + organizerNotification.NotificationId, + organizerId.Value, + organizerNotificationMessage, + DateTime.UtcNow, + NotificationEventType.ReactionAdded.ToString(), + reaction.ContentId, + organizerNotificationDetailsHtml + ); + + await _messageBroker.PublishAsync(organizerNotificationCreatedEvent); + + var organizerNotificationDto = new NotificationDto + { + UserId = organizerId.Value, + Message = organizerNotificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.ReactionAdded, + RelatedEntityId = reaction.ContentId, + Details = organizerNotificationDetailsHtml + }; + + // Broadcast SignalR notification to the organizer + await NotificationHub.BroadcastNotification(_hubContext, organizerNotificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to organizer with ID {organizerId.Value}."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportCancelledHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportCancelledHandler.cs index 478eddaa9..6b69e520f 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportCancelledHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportCancelledHandler.cs @@ -1,11 +1,15 @@ -using Convey.CQRS.Events; using System; -using System.Threading.Tasks; using System.Threading; -using MiniSpace.Services.Notifications.Core.Entities; -using MiniSpace.Services.Notifications.Core.Repositories; +using System.Threading.Tasks; +using Convey.CQRS.Events; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Application.Hubs; using MiniSpace.Services.Notifications.Application.Services; using MiniSpace.Services.Notifications.Application.Services.Clients; +using MiniSpace.Services.Notifications.Core.Entities; +using MiniSpace.Services.Notifications.Core.Repositories; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -14,15 +18,21 @@ public class ReportCancelledHandler : IEventHandler private readonly IMessageBroker _messageBroker; private readonly IStudentNotificationsRepository _studentNotificationsRepository; private readonly IStudentsServiceClient _studentsServiceClient; + private readonly IHubContext _hubContext; + private readonly ILogger _logger; public ReportCancelledHandler( IMessageBroker messageBroker, IStudentNotificationsRepository studentNotificationsRepository, - IStudentsServiceClient studentsServiceClient) + IStudentsServiceClient studentsServiceClient, + IHubContext hubContext, + ILogger logger) { _messageBroker = messageBroker; _studentNotificationsRepository = studentNotificationsRepository; _studentsServiceClient = studentsServiceClient; + _hubContext = hubContext; + _logger = logger; } public async Task HandleAsync(ReportCancelled eventArgs, CancellationToken cancellationToken) @@ -30,21 +40,27 @@ public async Task HandleAsync(ReportCancelled eventArgs, CancellationToken cance var issuer = await _studentsServiceClient.GetAsync(eventArgs.IssuerId); var targetOwner = await _studentsServiceClient.GetAsync(eventArgs.TargetOwnerId); + if (issuer == null || targetOwner == null) + { + _logger.LogError("Issuer or target owner details not found."); + return; + } + string issuerName = $"{issuer.FirstName} {issuer.LastName}"; string targetOwnerName = $"{targetOwner.FirstName} {targetOwner.LastName}"; - // Notification message for issuer with more details + // Notification message for issuer string issuerMessage = $"Your report about '{eventArgs.Category}' concerning '{eventArgs.ContextType}' has been successfully cancelled."; var issuerNotification = await CreateNotificationForUser(eventArgs.IssuerId, eventArgs, issuerMessage); - await PublishAndSaveNotification(issuerNotification, eventArgs.IssuerId, "ReportCancellationConfirmed", issuerName); + await PublishAndSaveNotification(issuerNotification, eventArgs.IssuerId, issuerMessage); - // Notification message for target owner with more details + // Notification message for target owner string targetOwnerMessage = $"A report about '{eventArgs.Category}' concerning your content '{eventArgs.ContextType}' has been cancelled."; var targetOwnerNotification = await CreateNotificationForUser(eventArgs.TargetOwnerId, eventArgs, targetOwnerMessage); - await PublishAndSaveNotification(targetOwnerNotification, eventArgs.TargetOwnerId, "ReportCancelled", targetOwnerName); + await PublishAndSaveNotification(targetOwnerNotification, eventArgs.TargetOwnerId, targetOwnerMessage); } - private async Task CreateNotificationForUser(Guid userId, ReportCancelled eventArgs, string message) + private async Task CreateNotificationForUser(Guid userId, ReportCancelled eventArgs, string message) { var notifications = await _studentNotificationsRepository.GetByStudentIdAsync(userId) ?? new StudentNotifications(userId); var notification = new Notification( @@ -62,19 +78,33 @@ private async Task CreateNotificationForUser(Guid userId, ReportCa return notification; } - private async Task PublishAndSaveNotification(Notification notification, Guid userId, string eventType, string userName) + private async Task PublishAndSaveNotification(Notification notification, Guid userId, string message) { var notificationCreatedEvent = new NotificationCreated( - notificationId: notification.NotificationId, - userId: notification.UserId, - message: $"{userName}, {notification.Message}", - createdAt: notification.CreatedAt, - eventType: NotificationEventType.ReportCancelled.ToString(), - relatedEntityId: notification.RelatedEntityId, - details: $"Notification for user {userId} ({userName}). Message: {notification.Message}" + notification.NotificationId, + notification.UserId, + message, + notification.CreatedAt, + NotificationEventType.ReportCancelled.ToString(), + notification.RelatedEntityId, + $"Notification for user {userId}. Message: {message}" ); await _messageBroker.PublishAsync(notificationCreatedEvent); + + var notificationDto = new NotificationDto + { + UserId = notification.UserId, + Message = message, + CreatedAt = notification.CreatedAt, + EventType = NotificationEventType.ReportCancelled, + RelatedEntityId = notification.RelatedEntityId, + Details = $"Notification for user {userId}. Message: {message}" + }; + + // Broadcast SignalR notification + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to user with ID {userId}."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportCreatedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportCreatedHandler.cs index 748f756dc..1218d7c85 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportCreatedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportCreatedHandler.cs @@ -1,12 +1,15 @@ -using Convey.CQRS.Events; using System; -using System.Threading.Tasks; using System.Threading; +using System.Threading.Tasks; +using Convey.CQRS.Events; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Application.Hubs; using MiniSpace.Services.Notifications.Application.Services.Clients; using MiniSpace.Services.Notifications.Core.Entities; using MiniSpace.Services.Notifications.Core.Repositories; using MiniSpace.Services.Notifications.Application.Services; -using MiniSpace.Services.Notifications.Application.Dto; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -15,16 +18,21 @@ public class ReportCreatedHandler : IEventHandler private readonly IMessageBroker _messageBroker; private readonly IStudentNotificationsRepository _studentNotificationsRepository; private readonly IStudentsServiceClient _studentsServiceClient; - + private readonly IHubContext _hubContext; + private readonly ILogger _logger; public ReportCreatedHandler( IMessageBroker messageBroker, IStudentNotificationsRepository studentNotificationsRepository, - IStudentsServiceClient studentsServiceClient) + IStudentsServiceClient studentsServiceClient, + IHubContext hubContext, + ILogger logger) { _messageBroker = messageBroker; _studentNotificationsRepository = studentNotificationsRepository; _studentsServiceClient = studentsServiceClient; + _hubContext = hubContext; + _logger = logger; } public async Task HandleAsync(ReportCreated eventArgs, CancellationToken cancellationToken) @@ -33,18 +41,24 @@ public async Task HandleAsync(ReportCreated eventArgs, CancellationToken cancell var issuer = await _studentsServiceClient.GetAsync(eventArgs.IssuerId); var targetOwner = await _studentsServiceClient.GetAsync(eventArgs.TargetOwnerId); + if (issuer == null || targetOwner == null) + { + _logger.LogError("Issuer or target owner details not found."); + return; + } + string issuerName = $"{issuer.FirstName} {issuer.LastName}"; string targetOwnerName = $"{targetOwner.FirstName} {targetOwner.LastName}"; - // Notification message for issuer with more details + // Notification message for issuer string issuerMessage = $"Thank you, {issuerName}, for submitting your report concerning '{eventArgs.Category}' about '{eventArgs.ContextType}'. We will review it promptly."; var issuerNotification = await CreateNotificationForUser(eventArgs.IssuerId, eventArgs, issuerMessage); - await PublishAndSaveNotification(issuerNotification, eventArgs.IssuerId, "ThankYouForReporting", issuerName); + await PublishAndSaveNotification(issuerNotification, eventArgs.IssuerId, issuerMessage); - // Notification message for target owner with more details + // Notification message for target owner string targetOwnerMessage = $"A report concerning '{eventArgs.Category}' about your content '{eventArgs.ContextType}' has been created. It is under review."; var targetOwnerNotification = await CreateNotificationForUser(eventArgs.TargetOwnerId, eventArgs, targetOwnerMessage); - await PublishAndSaveNotification(targetOwnerNotification, eventArgs.TargetOwnerId, "ReportCreated", targetOwnerName); + await PublishAndSaveNotification(targetOwnerNotification, eventArgs.TargetOwnerId, targetOwnerMessage); } private async Task CreateNotificationForUser(Guid userId, ReportCreated eventArgs, string message) @@ -65,19 +79,33 @@ private async Task CreateNotificationForUser(Guid userId, ReportCr return notification; } - private async Task PublishAndSaveNotification(Notification notification, Guid userId, string eventType, string userName) + private async Task PublishAndSaveNotification(Notification notification, Guid userId, string message) { var notificationCreatedEvent = new NotificationCreated( - notificationId: notification.NotificationId, - userId: notification.UserId, - message: $"{userName}, {notification.Message}", - createdAt: notification.CreatedAt, - eventType: NotificationEventType.ReportCreated.ToString(), - relatedEntityId: notification.RelatedEntityId, - details: $"Notification for user {userId} ({userName}). Message: {notification.Message}" + notification.NotificationId, + notification.UserId, + message, + notification.CreatedAt, + NotificationEventType.ReportCreated.ToString(), + notification.RelatedEntityId, + $"Notification for user {userId}. Message: {message}" ); await _messageBroker.PublishAsync(notificationCreatedEvent); + + var notificationDto = new NotificationDto + { + UserId = notification.UserId, + Message = message, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.ReportCreated, + RelatedEntityId = notification.RelatedEntityId, + Details = $"Notification for user {userId}. Message: {message}" + }; + + // Broadcast SignalR notification + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to user with ID {userId}."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportDeletedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportDeletedHandler.cs index c7801aeff..945e15673 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportDeletedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportDeletedHandler.cs @@ -1,11 +1,15 @@ -using Convey.CQRS.Events; using System; using System.Threading.Tasks; using System.Threading; +using Convey.CQRS.Events; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Application.Hubs; +using MiniSpace.Services.Notifications.Application.Services.Clients; using MiniSpace.Services.Notifications.Core.Entities; using MiniSpace.Services.Notifications.Core.Repositories; using MiniSpace.Services.Notifications.Application.Services; -using MiniSpace.Services.Notifications.Application.Services.Clients; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -14,35 +18,46 @@ public class ReportDeletedHandler : IEventHandler private readonly IMessageBroker _messageBroker; private readonly IStudentNotificationsRepository _studentNotificationsRepository; private readonly IStudentsServiceClient _studentsServiceClient; + private readonly IHubContext _hubContext; + private readonly ILogger _logger; public ReportDeletedHandler( IMessageBroker messageBroker, IStudentNotificationsRepository studentNotificationsRepository, - IStudentsServiceClient studentsServiceClient) + IStudentsServiceClient studentsServiceClient, + IHubContext hubContext, + ILogger logger) { _messageBroker = messageBroker; _studentNotificationsRepository = studentNotificationsRepository; _studentsServiceClient = studentsServiceClient; + _hubContext = hubContext; + _logger = logger; } public async Task HandleAsync(ReportDeleted eventArgs, CancellationToken cancellationToken) { - // Fetch student details var issuer = await _studentsServiceClient.GetAsync(eventArgs.IssuerId); var targetOwner = await _studentsServiceClient.GetAsync(eventArgs.TargetOwnerId); + if (issuer == null || targetOwner == null) + { + _logger.LogError("Issuer or target owner details not found."); + return; + } + string issuerName = $"{issuer.FirstName} {issuer.LastName}"; string targetOwnerName = $"{targetOwner.FirstName} {targetOwner.LastName}"; // Detailed notification for issuer string issuerMessage = $"Dear {issuerName}, the report you filed about '{eventArgs.Category}' concerning '{eventArgs.ContextType}' has been deleted."; var issuerNotification = await CreateNotificationForUser(eventArgs.IssuerId, eventArgs, issuerMessage); - await PublishAndSaveNotification(issuerNotification, eventArgs.IssuerId, "ReportDeletionConfirmed", issuerName); - + await PublishAndSaveNotification(issuerNotification, eventArgs.IssuerId, issuerMessage); + // Detailed notification for target owner string targetOwnerMessage = $"Hello {targetOwnerName}, a report about '{eventArgs.Category}' concerning your content '{eventArgs.ContextType}' has been deleted."; var targetOwnerNotification = await CreateNotificationForUser(eventArgs.TargetOwnerId, eventArgs, targetOwnerMessage); - await PublishAndSaveNotification(targetOwnerNotification, eventArgs.TargetOwnerId, "ReportDeleted", targetOwnerName); + await PublishAndSaveNotification(targetOwnerNotification, eventArgs.TargetOwnerId, targetOwnerMessage); } private async Task CreateNotificationForUser(Guid userId, ReportDeleted eventArgs, string message) @@ -63,19 +78,33 @@ private async Task CreateNotificationForUser(Guid userId, ReportDe return notification; } - private async Task PublishAndSaveNotification(Notification notification, Guid userId, string eventType, string userName) + private async Task PublishAndSaveNotification(Notification notification, Guid userId, string message) { var notificationCreatedEvent = new NotificationCreated( - notificationId: notification.NotificationId, - userId: notification.UserId, - message: $"{userName}, {notification.Message}", - createdAt: notification.CreatedAt, - eventType: NotificationEventType.ReportDeleted.ToString(), - relatedEntityId: notification.RelatedEntityId, - details: $"Notification for user {userId} ({userName}). Message: {notification.Message}" + notification.NotificationId, + notification.UserId, + message, + notification.CreatedAt, + NotificationEventType.ReportDeleted.ToString(), + notification.RelatedEntityId, + $"Notification for user {userId}. Message: {message}" ); await _messageBroker.PublishAsync(notificationCreatedEvent); + + var notificationDto = new NotificationDto + { + UserId = notification.UserId, + Message = message, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.ReportDeleted, + RelatedEntityId = notification.RelatedEntityId, + Details = $"Notification for user {userId}. Message: {message}" + }; + + // Broadcast SignalR notification + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to user with ID {userId}."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportRejectedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportRejectedHandler.cs index 1a220ec0b..05576b0cc 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportRejectedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportRejectedHandler.cs @@ -1,11 +1,15 @@ -using Convey.CQRS.Events; using System; using System.Threading.Tasks; using System.Threading; +using Convey.CQRS.Events; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Application.Hubs; +using MiniSpace.Services.Notifications.Application.Services.Clients; using MiniSpace.Services.Notifications.Core.Entities; using MiniSpace.Services.Notifications.Core.Repositories; using MiniSpace.Services.Notifications.Application.Services; -using MiniSpace.Services.Notifications.Application.Services.Clients; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -14,15 +18,21 @@ public class ReportRejectedHandler : IEventHandler private readonly IMessageBroker _messageBroker; private readonly IStudentNotificationsRepository _studentNotificationsRepository; private readonly IStudentsServiceClient _studentsServiceClient; + private readonly IHubContext _hubContext; + private readonly ILogger _logger; public ReportRejectedHandler( IMessageBroker messageBroker, IStudentNotificationsRepository studentNotificationsRepository, - IStudentsServiceClient studentsServiceClient) + IStudentsServiceClient studentsServiceClient, + IHubContext hubContext, + ILogger logger) { _messageBroker = messageBroker; _studentNotificationsRepository = studentNotificationsRepository; _studentsServiceClient = studentsServiceClient; + _hubContext = hubContext; + _logger = logger; } public async Task HandleAsync(ReportRejected eventArgs, CancellationToken cancellationToken) @@ -31,13 +41,18 @@ public async Task HandleAsync(ReportRejected eventArgs, CancellationToken cancel var issuer = await _studentsServiceClient.GetAsync(eventArgs.IssuerId); var targetOwner = await _studentsServiceClient.GetAsync(eventArgs.TargetOwnerId); + if (issuer == null || targetOwner == null) + { + _logger.LogError("Issuer or target owner details not found."); + return; + } + string issuerName = $"{issuer.FirstName} {issuer.LastName}"; - string targetOwnerName = $"{targetOwner.FirstName} {targetOwner.LastName}"; // Detailed notification for issuer string issuerMessage = $"Dear {issuerName}, your report about '{eventArgs.Category}' concerning '{eventArgs.ContextType}' has been rejected for the following reason: '{eventArgs.Reason}'."; var issuerNotification = await CreateNotificationForUser(eventArgs.IssuerId, eventArgs, issuerMessage); - await PublishAndSaveNotification(issuerNotification, eventArgs.IssuerId, "ReportRejected", issuerName); + await PublishAndSaveNotification(issuerNotification, eventArgs.IssuerId, issuerMessage); } private async Task CreateNotificationForUser(Guid userId, ReportRejected eventArgs, string message) @@ -58,19 +73,33 @@ private async Task CreateNotificationForUser(Guid userId, ReportRe return notification; } - private async Task PublishAndSaveNotification(Notification notification, Guid userId, string eventType, string userName) + private async Task PublishAndSaveNotification(Notification notification, Guid userId, string message) { var notificationCreatedEvent = new NotificationCreated( - notificationId: notification.NotificationId, - userId: notification.UserId, - message: $"{userName}, {notification.Message}", - createdAt: notification.CreatedAt, - eventType: NotificationEventType.ReportRejected.ToString(), - relatedEntityId: notification.RelatedEntityId, - details: $"Notification for user {userId} ({userName}). Message: {notification.Message}" + notification.NotificationId, + notification.UserId, + message, + notification.CreatedAt, + NotificationEventType.ReportRejected.ToString(), + notification.RelatedEntityId, + $"Notification for user {userId}. Message: {message}" ); await _messageBroker.PublishAsync(notificationCreatedEvent); + + var notificationDto = new NotificationDto + { + UserId = notification.UserId, + Message = message, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.ReportRejected, + RelatedEntityId = notification.RelatedEntityId, + Details = $"Notification for user {userId}. Message: {message}" + }; + + // Broadcast SignalR notification + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to user with ID {userId}."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportResolvedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportResolvedHandler.cs index 65bff490d..33795b5e0 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportResolvedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportResolvedHandler.cs @@ -27,7 +27,6 @@ public ReportResolvedHandler( public async Task HandleAsync(ReportResolved eventArgs, CancellationToken cancellationToken) { - // Fetch student details var issuer = await _studentsServiceClient.GetAsync(eventArgs.IssuerId); var reviewer = eventArgs.ReviewerId.HasValue ? await _studentsServiceClient.GetAsync(eventArgs.ReviewerId.Value) : null; diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportReviewStartedHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportReviewStartedHandler.cs index 0e056beb9..9fac1b165 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportReviewStartedHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/ReportReviewStartedHandler.cs @@ -1,11 +1,15 @@ -using Convey.CQRS.Events; using System; using System.Threading.Tasks; using System.Threading; +using Convey.CQRS.Events; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Application.Hubs; +using MiniSpace.Services.Notifications.Application.Services.Clients; using MiniSpace.Services.Notifications.Core.Entities; using MiniSpace.Services.Notifications.Core.Repositories; using MiniSpace.Services.Notifications.Application.Services; -using MiniSpace.Services.Notifications.Application.Services.Clients; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -14,27 +18,40 @@ public class ReportReviewStartedHandler : IEventHandler private readonly IMessageBroker _messageBroker; private readonly IStudentNotificationsRepository _studentNotificationsRepository; private readonly IStudentsServiceClient _studentsServiceClient; + private readonly IHubContext _hubContext; + private readonly ILogger _logger; public ReportReviewStartedHandler( IMessageBroker messageBroker, IStudentNotificationsRepository studentNotificationsRepository, - IStudentsServiceClient studentsServiceClient) + IStudentsServiceClient studentsServiceClient, + IHubContext hubContext, + ILogger logger) { _messageBroker = messageBroker; _studentNotificationsRepository = studentNotificationsRepository; _studentsServiceClient = studentsServiceClient; + _hubContext = hubContext; + _logger = logger; } public async Task HandleAsync(ReportReviewStarted eventArgs, CancellationToken cancellationToken) { // Fetch student details var issuer = await _studentsServiceClient.GetAsync(eventArgs.IssuerId); + + if (issuer == null) + { + _logger.LogError($"Issuer details not found for IssuerId={eventArgs.IssuerId}"); + return; + } + string issuerName = $"{issuer.FirstName} {issuer.LastName}"; // Detailed notification for issuer string issuerMessage = $"Dear {issuerName}, the review of your report about '{eventArgs.Category}' concerning '{eventArgs.ContextType}' has started."; var issuerNotification = await CreateNotificationForUser(eventArgs.IssuerId, eventArgs, issuerMessage); - await PublishAndSaveNotification(issuerNotification, eventArgs.IssuerId, "ReportReviewStarted", issuerName); + await PublishAndSaveNotification(issuerNotification, eventArgs.IssuerId, issuerMessage); } private async Task CreateNotificationForUser(Guid userId, ReportReviewStarted eventArgs, string message) @@ -55,19 +72,32 @@ private async Task CreateNotificationForUser(Guid userId, ReportRe return notification; } - private async Task PublishAndSaveNotification(Notification notification, Guid userId, string eventType, string userName) + private async Task PublishAndSaveNotification(Notification notification, Guid userId, string message) { var notificationCreatedEvent = new NotificationCreated( - notificationId: notification.NotificationId, - userId: notification.UserId, - message: $"{userName}, {notification.Message}", - createdAt: notification.CreatedAt, - eventType: NotificationEventType.ReportReviewStarted.ToString(), - relatedEntityId: notification.RelatedEntityId, - details: $"Notification for user {userId} ({userName}). Message: {notification.Message}" + notification.NotificationId, + notification.UserId, + message, + notification.CreatedAt, + NotificationEventType.ReportReviewStarted.ToString(), + notification.RelatedEntityId, + $"Notification for user {userId}. Message: {message}" ); await _messageBroker.PublishAsync(notificationCreatedEvent); + + var notificationDto = new NotificationDto + { + UserId = notification.UserId, + Message = message, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.ReportReviewStarted, + RelatedEntityId = notification.RelatedEntityId, + Details = $"Notification for user {userId}. Message: {message}" + }; + + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to user with ID {userId}."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/SignedUpHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/SignedUpHandler.cs index a4fa4755a..26be61b58 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/SignedUpHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/SignedUpHandler.cs @@ -1,10 +1,11 @@ using System; -using Convey.CQRS.Events; -using MiniSpace.Services.Notifications.Core.Repositories; -using MiniSpace.Services.Notifications.Core.Entities; -using System.Threading.Tasks; using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Convey.CQRS.Events; using MiniSpace.Services.Notifications.Application.Services; +using MiniSpace.Services.Notifications.Core.Entities; +using MiniSpace.Services.Notifications.Core.Repositories; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -21,12 +22,20 @@ public SignedUpHandler( _studentNotificationsRepository = studentNotificationsRepository; } - public async Task HandleAsync(SignedUp @event, CancellationToken cancellationToken) + public async Task HandleAsync(SignedUp @event, CancellationToken cancellationToken) { var userNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(@event.UserId) ?? new StudentNotifications(@event.UserId); var welcomeMessage = $"Welcome to MiniSpace, {@event.FirstName} {@event.LastName}!"; - var detailsHtml = $"

Dear {@event.FirstName},
Welcome to MiniSpace! Your account with the email {@event.Email} has been successfully created. You have been registered as a {@event.Role}. We are excited to have you on board!

"; + + var verificationLink = $"https://localhost:5606/verify-email/{HttpUtility.UrlEncode(@event.Token)}/{HttpUtility.UrlEncode(@event.Email)}/{HttpUtility.UrlEncode(@event.HashedToken)}/verify"; + + var detailsHtml = $@" +

Dear {@event.FirstName},
+ Welcome to MiniSpace! Your account with the email {@event.Email} has been successfully created. + You have been registered as a {@event.Role}. We are excited to have you on board!



+

Please verify your email address by clicking the link below:
+ Verify Email

"; var notification = new Notification( notificationId: Guid.NewGuid(), @@ -45,7 +54,7 @@ public async Task HandleAsync(SignedUp @event, CancellationToken cancellationTok var notificationCreatedEvent = new NotificationCreated( notificationId: Guid.NewGuid(), - userId: @event.UserId, + userId: @event.UserId, message: welcomeMessage, createdAt: DateTime.UtcNow, eventType: NotificationEventType.UserSignUp.ToString(), diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentCancelledInterestInEventHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentCancelledInterestInEventHandler.cs index 0946b109e..3496c177c 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentCancelledInterestInEventHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentCancelledInterestInEventHandler.cs @@ -1,11 +1,15 @@ using System; -using Convey.CQRS.Events; -using MiniSpace.Services.Notifications.Core.Repositories; -using MiniSpace.Services.Notifications.Application.Services; -using MiniSpace.Services.Notifications.Core.Entities; using System.Threading.Tasks; using System.Threading; +using Convey.CQRS.Events; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Application.Hubs; using MiniSpace.Services.Notifications.Application.Services.Clients; +using MiniSpace.Services.Notifications.Core.Entities; +using MiniSpace.Services.Notifications.Core.Repositories; +using MiniSpace.Services.Notifications.Application.Services; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -15,33 +19,48 @@ public class StudentCancelledInterestInEventHandler : IEventHandler _hubContext; + private readonly ILogger _logger; public StudentCancelledInterestInEventHandler( IMessageBroker messageBroker, IStudentNotificationsRepository studentNotificationsRepository, IStudentsServiceClient studentsServiceClient, - IEventsServiceClient eventsServiceClient) + IEventsServiceClient eventsServiceClient, + IHubContext hubContext, + ILogger logger) { _messageBroker = messageBroker; _studentNotificationsRepository = studentNotificationsRepository; _studentsServiceClient = studentsServiceClient; _eventsServiceClient = eventsServiceClient; + _hubContext = hubContext; + _logger = logger; } public async Task HandleAsync(StudentCancelledInterestInEvent eventArgs, CancellationToken cancellationToken) { - var studentNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(eventArgs.StudentId); var student = await _studentsServiceClient.GetAsync(eventArgs.StudentId); - if (studentNotifications == null) + if (student == null) { - studentNotifications = new StudentNotifications(eventArgs.StudentId); + _logger.LogError($"Student details not found for StudentId={eventArgs.StudentId}"); + return; } var eventDetails = await _eventsServiceClient.GetEventAsync(eventArgs.EventId); - var detailsHtml = eventDetails != null ? - $"

{student.FirstName} {student.LastName}, you have cancelled your interest in the event '{eventDetails.Name}' on {eventDetails.StartDate:yyyy-MM-dd}.

" : - "

Event details could not be retrieved.

"; + if (eventDetails == null) + { + _logger.LogError($"Event details not found for EventId={eventArgs.EventId}"); + return; + } + var studentNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(eventArgs.StudentId); + if (studentNotifications == null) + { + studentNotifications = new StudentNotifications(eventArgs.StudentId); + } + + var detailsHtml = $"

{student.FirstName} {student.LastName}, you have cancelled your interest in the event '{eventDetails.Name}' on {eventDetails.StartDate:yyyy-MM-dd}.

"; var notificationMessage = $"You have cancelled your interest in the event '{eventDetails.Name}'."; var notification = new Notification( @@ -56,12 +75,11 @@ public async Task HandleAsync(StudentCancelledInterestInEvent eventArgs, Cancell details: detailsHtml ); - studentNotifications.AddNotification(notification); await _studentNotificationsRepository.AddOrUpdateAsync(studentNotifications); var notificationCreatedEvent = new NotificationCreated( - notificationId: Guid.NewGuid(), + notificationId: notification.NotificationId, userId: eventArgs.StudentId, message: notificationMessage, createdAt: DateTime.UtcNow, @@ -72,7 +90,68 @@ public async Task HandleAsync(StudentCancelledInterestInEvent eventArgs, Cancell await _messageBroker.PublishAsync(notificationCreatedEvent); - + var notificationDto = new NotificationDto + { + UserId = eventArgs.StudentId, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.StudentCancelledInterestInEvent, + RelatedEntityId = eventArgs.EventId, + Details = detailsHtml + }; + + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to student with ID {eventArgs.StudentId}."); + + // Notify the organizer + var organizerNotificationMessage = $"{student.FirstName} {student.LastName} has cancelled their interest in your event '{eventDetails.Name}'."; + var organizerDetailsHtml = $"

{student.FirstName} {student.LastName} has cancelled their interest in your event '{eventDetails.Name}' on {eventDetails.StartDate:yyyy-MM-dd}.

"; + + var organizerNotification = new Notification( + notificationId: Guid.NewGuid(), + userId: eventDetails.Organizer.Id, + message: organizerNotificationMessage, + status: NotificationStatus.Unread, + createdAt: DateTime.UtcNow, + updatedAt: null, + relatedEntityId: eventArgs.EventId, + eventType: NotificationEventType.StudentCancelledInterestInEvent, + details: organizerDetailsHtml + ); + + var organizerNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(eventDetails.Organizer.Id); + if (organizerNotifications == null) + { + organizerNotifications = new StudentNotifications(eventDetails.Organizer.Id); + } + + organizerNotifications.AddNotification(organizerNotification); + await _studentNotificationsRepository.AddOrUpdateAsync(organizerNotifications); + + var organizerNotificationCreatedEvent = new NotificationCreated( + notificationId: organizerNotification.NotificationId, + userId: eventDetails.Organizer.Id, + message: organizerNotificationMessage, + createdAt: DateTime.UtcNow, + eventType: NotificationEventType.StudentCancelledInterestInEvent.ToString(), + relatedEntityId: eventArgs.EventId, + details: organizerDetailsHtml + ); + + await _messageBroker.PublishAsync(organizerNotificationCreatedEvent); + + var organizerNotificationDto = new NotificationDto + { + UserId = eventDetails.Organizer.Id, + Message = organizerNotificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.StudentCancelledInterestInEvent, + RelatedEntityId = eventArgs.EventId, + Details = organizerDetailsHtml + }; + + await NotificationHub.BroadcastNotification(_hubContext, organizerNotificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to organizer with ID {eventDetails.Organizer.Id}."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentCancelledSignUpToEventHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentCancelledSignUpToEventHandler.cs index 4949faf40..3893d1f32 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentCancelledSignUpToEventHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentCancelledSignUpToEventHandler.cs @@ -1,12 +1,15 @@ using System; -using Convey.CQRS.Events; -using MiniSpace.Services.Notifications.Core.Repositories; -using MiniSpace.Services.Notifications.Application.Services; -using MiniSpace.Services.Notifications.Core.Entities; -using System.Collections.Generic; using System.Threading.Tasks; using System.Threading; +using Convey.CQRS.Events; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Application.Hubs; using MiniSpace.Services.Notifications.Application.Services.Clients; +using MiniSpace.Services.Notifications.Core.Entities; +using MiniSpace.Services.Notifications.Core.Repositories; +using MiniSpace.Services.Notifications.Application.Services; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -16,33 +19,48 @@ public class StudentCancelledSignUpToEventHandler : IEventHandler _hubContext; + private readonly ILogger _logger; public StudentCancelledSignUpToEventHandler( IMessageBroker messageBroker, IStudentNotificationsRepository studentNotificationsRepository, IStudentsServiceClient studentsServiceClient, - IEventsServiceClient eventsServiceClient) + IEventsServiceClient eventsServiceClient, + IHubContext hubContext, + ILogger logger) { _messageBroker = messageBroker; _studentNotificationsRepository = studentNotificationsRepository; _studentsServiceClient = studentsServiceClient; _eventsServiceClient = eventsServiceClient; + _hubContext = hubContext; + _logger = logger; } public async Task HandleAsync(StudentCancelledSignUpToEvent eventArgs, CancellationToken cancellationToken) { - var studentNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(eventArgs.StudentId); var student = await _studentsServiceClient.GetAsync(eventArgs.StudentId); - if (studentNotifications == null) + if (student == null) { - studentNotifications = new StudentNotifications(eventArgs.StudentId); + _logger.LogError($"Student details not found for StudentId={eventArgs.StudentId}"); + return; } var eventDetails = await _eventsServiceClient.GetEventAsync(eventArgs.EventId); - var detailsHtml = eventDetails != null ? - $"

{student.FirstName} {student.LastName}, you have cancelled your registration for the event '{eventDetails.Name}' on {eventDetails.StartDate:yyyy-MM-dd}.

" : - "

Event details could not be retrieved.

"; + if (eventDetails == null) + { + _logger.LogError($"Event details not found for EventId={eventArgs.EventId}"); + return; + } + + var studentNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(eventArgs.StudentId); + if (studentNotifications == null) + { + studentNotifications = new StudentNotifications(eventArgs.StudentId); + } + var detailsHtml = $"

{student.FirstName} {student.LastName}, you have cancelled your registration for the event '{eventDetails.Name}' on {eventDetails.StartDate:yyyy-MM-dd}.

"; var notificationMessage = $"You have cancelled your registration for the event '{eventDetails.Name}'."; var notification = new Notification( @@ -61,7 +79,7 @@ public async Task HandleAsync(StudentCancelledSignUpToEvent eventArgs, Cancellat await _studentNotificationsRepository.AddOrUpdateAsync(studentNotifications); var notificationCreatedEvent = new NotificationCreated( - notificationId: Guid.NewGuid(), + notificationId: notification.NotificationId, userId: eventArgs.StudentId, message: notificationMessage, createdAt: DateTime.UtcNow, @@ -72,43 +90,68 @@ public async Task HandleAsync(StudentCancelledSignUpToEvent eventArgs, Cancellat await _messageBroker.PublishAsync(notificationCreatedEvent); - if (eventDetails != null && eventDetails.Organizer != null) + var notificationDto = new NotificationDto + { + UserId = eventArgs.StudentId, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.StudentCancelledSignedUpToEvent, + RelatedEntityId = eventArgs.EventId, + Details = detailsHtml + }; + + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to student with ID {eventArgs.StudentId}."); + + // Notify the organizer + var organizerNotificationMessage = $"{student.FirstName} {student.LastName} has cancelled their registration for your event '{eventDetails.Name}'."; + var organizerDetailsHtml = $"

{student.FirstName} {student.LastName} has cancelled their registration for your event '{eventDetails.Name}' on {eventDetails.StartDate:yyyy-MM-dd}.

"; + + var organizerNotification = new Notification( + notificationId: Guid.NewGuid(), + userId: eventDetails.Organizer.Id, + message: organizerNotificationMessage, + status: NotificationStatus.Unread, + createdAt: DateTime.UtcNow, + updatedAt: null, + relatedEntityId: eventArgs.EventId, + eventType: NotificationEventType.StudentCancelledSignedUpToEvent, + details: organizerDetailsHtml + ); + + var organizerNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(eventDetails.Organizer.Id); + if (organizerNotifications == null) { - var detailsHtmlForOrganizer = $"

{student.FirstName} {student.LastName} has cancelled their registration for your event '{eventDetails.Name}' on {eventDetails.StartDate:yyyy-MM-dd}.

"; - var organizerNotification = new Notification( - notificationId: Guid.NewGuid(), - userId: eventDetails.Organizer.Id, - message: $"{student.FirstName} {student.LastName} has cancelled their registration for your event '{eventDetails.Name}'.", - status: NotificationStatus.Unread, - createdAt: DateTime.UtcNow, - updatedAt: null, - relatedEntityId: eventArgs.EventId, - eventType: NotificationEventType.StudentCancelledSignedUpToEvent, - details: detailsHtmlForOrganizer - ); - - var organizerNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(eventDetails.Organizer.Id); - if (organizerNotifications == null) - { - organizerNotifications = new StudentNotifications(eventDetails.Organizer.Id); - } - - - - organizerNotifications.AddNotification(organizerNotification); - await _studentNotificationsRepository.AddOrUpdateAsync(organizerNotifications); - - var organizerNotificationCreatedEvent = new NotificationCreated( - notificationId: Guid.NewGuid(), - userId: eventDetails.Organizer.Id, - message: $"{student.FirstName} {student.LastName} has cancelled their registration for your event '{eventDetails.Name}'.", - createdAt: DateTime.UtcNow, - eventType: NotificationEventType.StudentCancelledSignedUpToEvent.ToString(), - relatedEntityId: eventArgs.EventId, - details: detailsHtmlForOrganizer - ); - await _messageBroker.PublishAsync(organizerNotificationCreatedEvent); + organizerNotifications = new StudentNotifications(eventDetails.Organizer.Id); } + + organizerNotifications.AddNotification(organizerNotification); + await _studentNotificationsRepository.AddOrUpdateAsync(organizerNotifications); + + var organizerNotificationCreatedEvent = new NotificationCreated( + notificationId: organizerNotification.NotificationId, + userId: eventDetails.Organizer.Id, + message: organizerNotificationMessage, + createdAt: DateTime.UtcNow, + eventType: NotificationEventType.StudentCancelledSignedUpToEvent.ToString(), + relatedEntityId: eventArgs.EventId, + details: organizerDetailsHtml + ); + + await _messageBroker.PublishAsync(organizerNotificationCreatedEvent); + + var organizerNotificationDto = new NotificationDto + { + UserId = eventDetails.Organizer.Id, + Message = organizerNotificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.StudentCancelledSignedUpToEvent, + RelatedEntityId = eventArgs.EventId, + Details = organizerDetailsHtml + }; + + await NotificationHub.BroadcastNotification(_hubContext, organizerNotificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to organizer with ID {eventDetails.Organizer.Id}."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentShowedInterestInEventHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentShowedInterestInEventHandler.cs index a6d70ba67..afeb9d8d5 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentShowedInterestInEventHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentShowedInterestInEventHandler.cs @@ -1,12 +1,15 @@ using System; -using Convey.CQRS.Events; -using MiniSpace.Services.Notifications.Core.Repositories; -using MiniSpace.Services.Notifications.Application.Services; -using MiniSpace.Services.Notifications.Core.Entities; -using System.Collections.Generic; using System.Threading.Tasks; using System.Threading; +using Convey.CQRS.Events; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Application.Hubs; using MiniSpace.Services.Notifications.Application.Services.Clients; +using MiniSpace.Services.Notifications.Core.Entities; +using MiniSpace.Services.Notifications.Core.Repositories; +using MiniSpace.Services.Notifications.Application.Services; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -16,33 +19,48 @@ public class StudentShowedInterestInEventHandler : IEventHandler _hubContext; + private readonly ILogger _logger; public StudentShowedInterestInEventHandler( IMessageBroker messageBroker, IStudentNotificationsRepository studentNotificationsRepository, IStudentsServiceClient studentsServiceClient, - IEventsServiceClient eventsServiceClient) + IEventsServiceClient eventsServiceClient, + IHubContext hubContext, + ILogger logger) { _messageBroker = messageBroker; _studentNotificationsRepository = studentNotificationsRepository; _studentsServiceClient = studentsServiceClient; _eventsServiceClient = eventsServiceClient; + _hubContext = hubContext; + _logger = logger; } public async Task HandleAsync(StudentShowedInterestInEvent eventArgs, CancellationToken cancellationToken) { - var studentNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(eventArgs.StudentId); var student = await _studentsServiceClient.GetAsync(eventArgs.StudentId); - if (studentNotifications == null) + if (student == null) { - studentNotifications = new StudentNotifications(eventArgs.StudentId); + _logger.LogError($"Student details not found for StudentId={eventArgs.StudentId}"); + return; } var eventDetails = await _eventsServiceClient.GetEventAsync(eventArgs.EventId); - var detailsHtml = eventDetails != null ? - $"

{student.FirstName} {student.LastName}, you have shown interest in the event '{eventDetails.Name}' on {eventDetails.StartDate:yyyy-MM-dd}.

" : - "

Event details could not be retrieved.

"; + if (eventDetails == null) + { + _logger.LogError($"Event details not found for EventId={eventArgs.EventId}"); + return; + } + var studentNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(eventArgs.StudentId); + if (studentNotifications == null) + { + studentNotifications = new StudentNotifications(eventArgs.StudentId); + } + + var detailsHtml = $"

{student.FirstName} {student.LastName}, you have shown interest in the event '{eventDetails.Name}' on {eventDetails.StartDate:yyyy-MM-dd}.

"; var notificationMessage = $"You have shown interest in the event '{eventDetails.Name}'."; var notification = new Notification( @@ -56,12 +74,12 @@ public async Task HandleAsync(StudentShowedInterestInEvent eventArgs, Cancellati eventType: NotificationEventType.StudentShowedInterestInEvent, details: detailsHtml ); - + studentNotifications.AddNotification(notification); await _studentNotificationsRepository.AddOrUpdateAsync(studentNotifications); - var notificationCreatedEvent = new NotificationCreated( - notificationId: Guid.NewGuid(), + var notificationCreatedEvent = new NotificationCreated( + notificationId: notification.NotificationId, userId: eventArgs.StudentId, message: notificationMessage, createdAt: DateTime.UtcNow, @@ -71,15 +89,29 @@ public async Task HandleAsync(StudentShowedInterestInEvent eventArgs, Cancellati ); await _messageBroker.PublishAsync(notificationCreatedEvent); - - if (eventDetails != null && eventDetails.Organizer != null) + var notificationDto = new NotificationDto + { + UserId = eventArgs.StudentId, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.StudentShowedInterestInEvent, + RelatedEntityId = eventArgs.EventId, + Details = detailsHtml + }; + + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to student with ID {eventArgs.StudentId}."); + + if (eventDetails.Organizer != null) { var detailsHtmlForOrganizer = $"

{student.FirstName} {student.LastName} has shown interest in your event '{eventDetails.Name}' on {eventDetails.StartDate:yyyy-MM-dd}.

"; + var organizerNotificationMessage = $"{student.FirstName} {student.LastName} has shown interest in your event '{eventDetails.Name}'."; + var organizerNotification = new Notification( notificationId: Guid.NewGuid(), userId: eventDetails.Organizer.Id, - message: $"{student.FirstName} {student.LastName} has shown interest in your event '{eventDetails.Name}'.", + message: organizerNotificationMessage, status: NotificationStatus.Unread, createdAt: DateTime.UtcNow, updatedAt: null, @@ -93,20 +125,34 @@ public async Task HandleAsync(StudentShowedInterestInEvent eventArgs, Cancellati { organizerNotifications = new StudentNotifications(eventDetails.Organizer.Id); } - + organizerNotifications.AddNotification(organizerNotification); await _studentNotificationsRepository.AddOrUpdateAsync(organizerNotifications); var organizerNotificationCreatedEvent = new NotificationCreated( - notificationId: Guid.NewGuid(), + notificationId: organizerNotification.NotificationId, userId: eventDetails.Organizer.Id, - message: $"{student.FirstName} {student.LastName} has shown interest in your event '{eventDetails.Name}'.", + message: organizerNotificationMessage, createdAt: DateTime.UtcNow, eventType: NotificationEventType.StudentShowedInterestInEvent.ToString(), relatedEntityId: eventArgs.EventId, details: detailsHtmlForOrganizer ); + await _messageBroker.PublishAsync(organizerNotificationCreatedEvent); + + var organizerNotificationDto = new NotificationDto + { + UserId = eventDetails.Organizer.Id, + Message = organizerNotificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.StudentShowedInterestInEvent, + RelatedEntityId = eventArgs.EventId, + Details = detailsHtmlForOrganizer + }; + + await NotificationHub.BroadcastNotification(_hubContext, organizerNotificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to organizer with ID {eventDetails.Organizer.Id}."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentSignedUpToEventHandler.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentSignedUpToEventHandler.cs index 43e20509d..ec1c6fe78 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentSignedUpToEventHandler.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/Handlers/StudentSignedUpToEventHandler.cs @@ -1,12 +1,15 @@ using System; -using Convey.CQRS.Events; -using MiniSpace.Services.Notifications.Core.Repositories; -using MiniSpace.Services.Notifications.Application.Services; -using MiniSpace.Services.Notifications.Core.Entities; -using System.Collections.Generic; using System.Threading.Tasks; using System.Threading; +using Convey.CQRS.Events; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using MiniSpace.Services.Notifications.Application.Dto; +using MiniSpace.Services.Notifications.Application.Hubs; using MiniSpace.Services.Notifications.Application.Services.Clients; +using MiniSpace.Services.Notifications.Core.Entities; +using MiniSpace.Services.Notifications.Core.Repositories; +using MiniSpace.Services.Notifications.Application.Services; namespace MiniSpace.Services.Notifications.Application.Events.External.Handlers { @@ -16,39 +19,54 @@ public class StudentSignedUpToEventHandler : IEventHandler _hubContext; + private readonly ILogger _logger; public StudentSignedUpToEventHandler( IMessageBroker messageBroker, IStudentNotificationsRepository studentNotificationsRepository, IStudentsServiceClient studentsServiceClient, - IEventsServiceClient eventsServiceClient) + IEventsServiceClient eventsServiceClient, + IHubContext hubContext, + ILogger logger) { _messageBroker = messageBroker; _studentNotificationsRepository = studentNotificationsRepository; _studentsServiceClient = studentsServiceClient; _eventsServiceClient = eventsServiceClient; + _hubContext = hubContext; + _logger = logger; } - public async Task HandleAsync(StudentSignedUpToEvent eventArgs, CancellationToken cancellationToken) { - var studentNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(eventArgs.StudentId); var student = await _studentsServiceClient.GetAsync(eventArgs.StudentId); - if (studentNotifications == null) + if (student == null) { - studentNotifications = new StudentNotifications(eventArgs.StudentId); + _logger.LogError($"Student details not found for StudentId={eventArgs.StudentId}"); + return; } var eventDetails = await _eventsServiceClient.GetEventAsync(eventArgs.EventId); - var detailsHtml = eventDetails != null ? - $"

{student.FirstName} {student.LastName}, you have signed up for the event '{eventDetails.Name}' on {eventDetails.StartDate:yyyy-MM-dd}.

" : - "

Event details could not be retrieved.

"; + if (eventDetails == null) + { + _logger.LogError($"Event details not found for EventId={eventArgs.EventId}"); + return; + } + var studentNotifications = await _studentNotificationsRepository.GetByStudentIdAsync(eventArgs.StudentId); + if (studentNotifications == null) + { + studentNotifications = new StudentNotifications(eventArgs.StudentId); + } + + var detailsHtml = $"

{student.FirstName} {student.LastName}, you have signed up for the event '{eventDetails.Name}' on {eventDetails.StartDate:yyyy-MM-dd}.

"; + var notificationMessage = $"You have successfully signed up for the event '{eventDetails.Name}'."; var notification = new Notification( notificationId: Guid.NewGuid(), userId: eventArgs.StudentId, - message: $"You have successfully signed up for the event '{eventDetails.Name}'.", + message: notificationMessage, status: NotificationStatus.Unread, createdAt: DateTime.UtcNow, updatedAt: null, @@ -61,9 +79,9 @@ public async Task HandleAsync(StudentSignedUpToEvent eventArgs, CancellationToke await _studentNotificationsRepository.AddOrUpdateAsync(studentNotifications); var notificationCreatedEvent = new NotificationCreated( - notificationId: Guid.NewGuid(), + notificationId: notification.NotificationId, userId: eventArgs.StudentId, - message: $"You have successfully signed up for the event '{eventDetails.Name}'.", + message: notificationMessage, createdAt: DateTime.UtcNow, eventType: NotificationEventType.EventNewSignUp.ToString(), relatedEntityId: eventArgs.EventId, @@ -72,13 +90,28 @@ public async Task HandleAsync(StudentSignedUpToEvent eventArgs, CancellationToke await _messageBroker.PublishAsync(notificationCreatedEvent); - if (eventDetails != null && eventDetails.Organizer != null) + var notificationDto = new NotificationDto + { + UserId = eventArgs.StudentId, + Message = notificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.EventNewSignUp, + RelatedEntityId = eventArgs.EventId, + Details = detailsHtml + }; + + await NotificationHub.BroadcastNotification(_hubContext, notificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to student with ID {eventArgs.StudentId}."); + + if (eventDetails.Organizer != null) { var detailsHtmlForOrganizer = $"

{student.FirstName} {student.LastName} has signed up for your event '{eventDetails.Name}' on {eventDetails.StartDate:yyyy-MM-dd}.

"; + var organizerNotificationMessage = $"{student.FirstName} {student.LastName} has signed up for your event '{eventDetails.Name}'."; + var organizerNotification = new Notification( notificationId: Guid.NewGuid(), userId: eventDetails.Organizer.Id, - message: $"{student.FirstName} {student.LastName} has signed up for your event '{eventDetails.Name}'.", + message: organizerNotificationMessage, status: NotificationStatus.Unread, createdAt: DateTime.UtcNow, updatedAt: null, @@ -97,15 +130,29 @@ public async Task HandleAsync(StudentSignedUpToEvent eventArgs, CancellationToke await _studentNotificationsRepository.AddOrUpdateAsync(organizerNotifications); var organizerNotificationCreatedEvent = new NotificationCreated( - notificationId: Guid.NewGuid(), + notificationId: organizerNotification.NotificationId, userId: eventDetails.Organizer.Id, - message: $"{student.FirstName} {student.LastName} has signed up for your event '{eventDetails.Name}'.", + message: organizerNotificationMessage, createdAt: DateTime.UtcNow, eventType: NotificationEventType.EventNewSignUp.ToString(), - relatedEntityId: eventArgs.EventId, + relatedEntityId: eventArgs.EventId, details: detailsHtmlForOrganizer ); + await _messageBroker.PublishAsync(organizerNotificationCreatedEvent); + + var organizerNotificationDto = new NotificationDto + { + UserId = eventDetails.Organizer.Id, + Message = organizerNotificationMessage, + CreatedAt = DateTime.UtcNow, + EventType = NotificationEventType.EventNewSignUp, + RelatedEntityId = eventArgs.EventId, + Details = detailsHtmlForOrganizer + }; + + await NotificationHub.BroadcastNotification(_hubContext, organizerNotificationDto, _logger); + _logger.LogInformation($"Broadcasted SignalR notification to organizer with ID {eventDetails.Organizer.Id}."); } } } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/SignedUp.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/SignedUp.cs index bb3fc18ff..c17acad68 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/SignedUp.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Events/External/SignedUp.cs @@ -12,14 +12,18 @@ public class SignedUp : IEvent public string LastName { get; } public string Email { get; } public string Role { get; } - - public SignedUp(Guid userId, string firstName, string lastName, string email, string role) + public string Token { get; } + public string HashedToken { get; } + + public SignedUp(Guid userId, string firstName, string lastName, string email, string role, string token, string hashedToken) { UserId = userId; FirstName = firstName; LastName = lastName; Email = email; Role = role; + Token = token; + HashedToken = hashedToken; } } } \ No newline at end of file diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Hubs/ChatHub.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Hubs/ChatHub.cs new file mode 100644 index 000000000..f77ee1d49 --- /dev/null +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Hubs/ChatHub.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.SignalR; +using System.Threading.Tasks; +using System.Collections.Concurrent; + +namespace MiniSpace.Services.Notifications.Application.Hubs +{ + public class ChatHub : Hub + { + private static readonly ConcurrentDictionary Users = new ConcurrentDictionary(); + + public override async Task OnConnectedAsync() + { + var username = Context.User.Identity.Name; + Users.TryAdd(Context.ConnectionId, username); + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception exception) + { + Users.TryRemove(Context.ConnectionId, out _); + await base.OnDisconnectedAsync(exception); + } + + public async Task SendMessageToUser(string targetUser, string message) + { + var connectionId = Users.FirstOrDefault(u => u.Value == targetUser).Key; + if (!string.IsNullOrEmpty(connectionId)) + { + await Clients.Client(connectionId).SendAsync("ReceiveMessage", Context.User.Identity.Name, message); + } + } + + public async Task SendMessageToGroup(string groupName, string message) + { + await Clients.Group(groupName).SendAsync("ReceiveGroupMessage", Context.User.Identity.Name, message); + } + + public async Task AddToGroup(string groupName) + { + await Groups.AddToGroupAsync(Context.ConnectionId, groupName); + await Clients.Group(groupName).SendAsync("ShowWho", $"{Context.User.Identity.Name} has joined the group {groupName}."); + } + + public async Task RemoveFromGroup(string groupName) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); + await Clients.Group(groupName).SendAsync("ShowWho", $"{Context.User.Identity.Name} has left the group {groupName}."); + } + } +} diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Hubs/INotificationHub.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Hubs/INotificationHub.cs new file mode 100644 index 000000000..cfdf825f9 --- /dev/null +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Hubs/INotificationHub.cs @@ -0,0 +1,14 @@ +// INotificationHub.cs +using MiniSpace.Services.Notifications.Application.Dto; +using System.Threading.Tasks; + +namespace MiniSpace.Services.Notifications.Application.Hubs +{ + public interface INotificationHub + { + Task SendMessage(string user, string message); + Task AddToGroup(string groupName); + Task RemoveFromGroup(string groupName); + Task SendNotification(string userId, NotificationDto notification); + } +} diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Hubs/NotificationHub.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Hubs/NotificationHub.cs new file mode 100644 index 000000000..033e35df2 --- /dev/null +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Hubs/NotificationHub.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.SignalR; +using MiniSpace.Services.Notifications.Application.Dto; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace MiniSpace.Services.Notifications.Application.Hubs +{ + public class NotificationHub : Hub + { + private readonly ILogger _logger; + + public NotificationHub(ILogger logger) + { + _logger = logger; + } + + public override async Task OnConnectedAsync() + { + var userId = Context.GetHttpContext().Request.Query["userId"]; + if (!string.IsNullOrEmpty(userId)) + { + await Groups.AddToGroupAsync(Context.ConnectionId, userId); + _logger.LogInformation($"======================================================================================User {userId} connected with connection ID: {Context.ConnectionId}"); + } + else + { + _logger.LogWarning("=========================================================================================User ID is missing in the query string."); + } + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception exception) + { + var userId = Context.GetHttpContext().Request.Query["userId"]; + if (!string.IsNullOrEmpty(userId)) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId); + _logger.LogInformation($"User {userId} disconnected with connection ID: {Context.ConnectionId}"); + } + await base.OnDisconnectedAsync(exception); + } + + public async Task SendMessage(string user, string message) + { + _logger.LogInformation($"Sending message to user {user}: {message}"); + await Clients.User(user).SendAsync("ReceiveMessage", message); + } + + public async Task AddToGroup(string groupName) + { + _logger.LogInformation($"Adding connection {Context.ConnectionId} to group {groupName}"); + await Groups.AddToGroupAsync(Context.ConnectionId, groupName); + } + + public async Task RemoveFromGroup(string groupName) + { + _logger.LogInformation($"Removing connection {Context.ConnectionId} from group {groupName}"); + await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); + } + + public async Task SendNotification(string userId, NotificationDto notification) + { + var jsonMessage = JsonSerializer.Serialize(notification); + _logger.LogInformation($"Sending notification to user {userId}: {jsonMessage}"); + await Clients.User(userId).SendAsync("ReceiveNotification", jsonMessage); + } + + public static async Task SendNotification(IHubContext hubContext, string userId, NotificationDto notification, ILogger logger) + { + var jsonMessage = JsonSerializer.Serialize(notification); + logger.LogInformation($"Sending static notification to user {userId}: {jsonMessage}"); + await hubContext.Clients.User(userId).SendAsync("ReceiveNotification", jsonMessage); + } + + public static async Task BroadcastNotification(IHubContext hubContext, NotificationDto notification, ILogger logger) + { + var jsonMessage = JsonSerializer.Serialize(notification); + logger.LogInformation($"Broadcasting notification to all users: {jsonMessage}"); + await hubContext.Clients.All.SendAsync("ReceiveNotification", jsonMessage); + } + } +} diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Services/ISignalRConnectionManager.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Services/ISignalRConnectionManager.cs new file mode 100644 index 000000000..231b8b16d --- /dev/null +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Application/Services/ISignalRConnectionManager.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace MiniSpace.Services.Notifications.Application.Services +{ + public interface ISignalRConnectionManager + { + Task SendMessageAsync(string user, string message); + } +} \ No newline at end of file diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Core/Entities/Message.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Core/Entities/Message.cs new file mode 100644 index 000000000..40f02e896 --- /dev/null +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Core/Entities/Message.cs @@ -0,0 +1,23 @@ + +namespace MiniSpace.Services.Notifications.Core.Entities +{ + public class Message + { + public Guid Id { get; set; } + public string Sender { get; set; } + public string Receiver { get; set; } // can be a user or a group name + public string Content { get; set; } + public DateTime Timestamp { get; set; } + public MessageType Type { get; set; } + + public Message(string sender, string receiver, string content, MessageType type) + { + Id = Guid.NewGuid(); + Sender = sender; + Receiver = receiver; + Content = content; + Timestamp = DateTime.UtcNow; + Type = type; + } + } +} diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Core/Entities/MessageType.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Core/Entities/MessageType.cs new file mode 100644 index 000000000..183fc9101 --- /dev/null +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Core/Entities/MessageType.cs @@ -0,0 +1,5 @@ +public enum MessageType +{ + Personal, + Group +} diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Infrastructure/Extensions.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Infrastructure/Extensions.cs index 5f0b196de..65d2a2218 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Infrastructure/Extensions.cs +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Infrastructure/Extensions.cs @@ -39,9 +39,10 @@ using MiniSpace.Services.Notifications.Infrastructure.Mongo.Documents; using MiniSpace.Services.Notifications.Infrastructure.Mongo.Repositories; using MiniSpace.Services.Notifications.Infrastructure.Services; -using MiniSpace.Services.Notifications.Infrastructure; using MiniSpace.Services.Notifications.Application.Services.Clients; using MiniSpace.Services.Notifications.Infrastructure.Services.Clients; +// using MiniSpace.Services.Notifications.Infrastructure.Managers; +using MiniSpace.Services.Notifications.Application.Hubs; namespace MiniSpace.Services.Notifications.Infrastructure { @@ -51,6 +52,7 @@ public static IConveyBuilder AddInfrastructure(this IConveyBuilder builder) { builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); @@ -72,6 +74,7 @@ public static IConveyBuilder AddInfrastructure(this IConveyBuilder builder) builder.Services.TryDecorate(typeof(IEventHandler<>), typeof(OutboxEventHandlerDecorator<>)); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + // builder.Services.AddScoped(); return builder .AddErrorHandler() @@ -93,6 +96,7 @@ public static IConveyBuilder AddInfrastructure(this IConveyBuilder builder) .AddMongoRepository("students") .AddMongoRepository("students-notifications") // .AddMongoRepository("events-service") + .AddSignalRInfrastructure() .AddWebApiSwaggerDocs() .AddCertificateAuthentication() .AddSecurity(); @@ -138,10 +142,27 @@ public static IApplicationBuilder UseInfrastructure(this IApplicationBuilder app .SubscribeEvent() .SubscribeEvent() .SubscribeEvent(); - return app; } + public static IConveyBuilder AddSignalRInfrastructure(this IConveyBuilder builder) + { + builder.Services.AddCors(options => + { + options.AddPolicy("CorsPolicy", + builder => builder + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials() + .SetIsOriginAllowed((host) => true)); + }); + + builder.Services.AddSignalR(); + + return builder; + } + + internal static CorrelationContext GetCorrelationContext(this IHttpContextAccessor accessor) => accessor.HttpContext?.Request.Headers.TryGetValue("Correlation-Context", out var json) is true ? JsonConvert.DeserializeObject(json.FirstOrDefault()) diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Infrastructure/Hubs/NotificationHub.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Infrastructure/Hubs/NotificationHub.cs new file mode 100644 index 000000000..3ba4cff6a --- /dev/null +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Infrastructure/Hubs/NotificationHub.cs @@ -0,0 +1,62 @@ +// using Microsoft.AspNetCore.SignalR; +// using MiniSpace.Services.Notifications.Application.Dto; +// using MiniSpace.Services.Notifications.Application.Hubs; +// using Microsoft.Extensions.Logging; +// using System.Text.Json; +// using System.Threading.Tasks; + +// namespace MiniSpace.Services.Notifications.Infrastructure.Hubs +// { +// public class NotificationHub : Hub, INotificationHub +// { +// private readonly ILogger _logger; + +// public NotificationHub(ILogger logger) +// { +// _logger = logger; +// } + +// public async Task SendMessage(string user, string message) +// { +// _logger.LogInformation($"Sending message to user {user}: {message}"); +// await Clients.User(user).SendAsync("ReceiveMessage", message); +// } + +// public async Task AddToGroup(string groupName) +// { +// _logger.LogInformation($"Adding connection {Context.ConnectionId} to group {groupName}"); +// await Groups.AddToGroupAsync(Context.ConnectionId, groupName); +// } + +// public async Task RemoveFromGroup(string groupName) +// { +// _logger.LogInformation($"Removing connection {Context.ConnectionId} from group {groupName}"); +// await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); +// } + +// public async Task SendNotification(string userId, NotificationDto notification) +// { +// if (Clients == null) +// { +// _logger.LogError("SignalR Clients is null. Cannot send notification."); +// return; +// } + +// var jsonMessage = JsonSerializer.Serialize(notification); +// _logger.LogInformation($"Sending notification to user {userId}: {jsonMessage}"); +// await Clients.User(userId).SendAsync("ReceiveNotification", jsonMessage); +// } + +// public override Task OnConnectedAsync() +// { +// _logger.LogInformation($"Connection established: {Context.ConnectionId}"); +// return base.OnConnectedAsync(); +// } + +// public override Task OnDisconnectedAsync(Exception exception) +// { +// _logger.LogInformation($"Connection disconnected: {Context.ConnectionId}"); +// return base.OnDisconnectedAsync(exception); +// } +// } +// } diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Infrastructure/Managers/SignalRConnectionManager.cs b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Infrastructure/Managers/SignalRConnectionManager.cs new file mode 100644 index 000000000..567787a87 --- /dev/null +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Infrastructure/Managers/SignalRConnectionManager.cs @@ -0,0 +1,27 @@ +// using System; +// using System.Collections.Generic; +// using System.Linq; +// using System.Threading.Tasks; +// using Microsoft.AspNetCore.SignalR; +// using MiniSpace.Services.Notifications.Application.Services; +// using MiniSpace.Services.Notifications.Application.Hubs; +// using MiniSpace.Services.Notifications.Infrastructure.Hubs; + +// namespace MiniSpace.Services.Notifications.Infrastructure.Managers +// { +// public class SignalRConnectionManager : ISignalRConnectionManager +// { +// private readonly IHubContext _hubContext; + +// public SignalRConnectionManager(IHubContext hubContext) +// { +// _hubContext = hubContext; +// } + +// public async Task SendMessageAsync(string user, string message) +// { +// await _hubContext.Clients.User(user).SendAsync("ReceiveMessage", message); +// } +// } + +// } \ No newline at end of file diff --git a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Infrastructure/MiniSpace.Services.Notifications.Infrastructure.csproj b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Infrastructure/MiniSpace.Services.Notifications.Infrastructure.csproj index 7309d81dd..aae3a8d6c 100644 --- a/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Infrastructure/MiniSpace.Services.Notifications.Infrastructure.csproj +++ b/MiniSpace.Services.Notifications/src/MiniSpace.Services.Notifications.Infrastructure/MiniSpace.Services.Notifications.Infrastructure.csproj @@ -30,6 +30,7 @@ +
diff --git a/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Api/MiniSpace.Services.Organizations.Api.sln b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Api/MiniSpace.Services.Organizations.Api.sln new file mode 100644 index 000000000..5423b9d52 --- /dev/null +++ b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Api/MiniSpace.Services.Organizations.Api.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MiniSpace.Services.Organizations.Api", "MiniSpace.Services.Organizations.Api.csproj", "{A52FDB79-BF7B-4FAB-BD7D-2ED015B3F0DB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A52FDB79-BF7B-4FAB-BD7D-2ED015B3F0DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A52FDB79-BF7B-4FAB-BD7D-2ED015B3F0DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A52FDB79-BF7B-4FAB-BD7D-2ED015B3F0DB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A52FDB79-BF7B-4FAB-BD7D-2ED015B3F0DB}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {2A890DC3-CEA2-4AE8-8C0A-00C080FDF6C2} + EndGlobalSection +EndGlobal diff --git a/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Api/Program.cs b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Api/Program.cs index df7e99935..79a7e56b9 100644 --- a/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Api/Program.cs +++ b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Api/Program.cs @@ -45,6 +45,8 @@ public static async Task Main(string[] args) .Delete("organizations/{organizationId}") .Post("organizations/{organizationId}/organizer") .Delete("organizations/{organizationId}/organizer/{organizerId}") + .Post("organizations/{organizationId}/invite") + .Post("organizations/{organizationId}/privacy") )) .UseLogging() .Build() diff --git a/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Application/MiniSpace.Services.Organizations.Application.sln b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Application/MiniSpace.Services.Organizations.Application.sln new file mode 100644 index 000000000..784f737bb --- /dev/null +++ b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Application/MiniSpace.Services.Organizations.Application.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MiniSpace.Services.Organizations.Application", "MiniSpace.Services.Organizations.Application.csproj", "{4BC7A851-8F1E-4F8D-9C07-98D13B9C987F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4BC7A851-8F1E-4F8D-9C07-98D13B9C987F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4BC7A851-8F1E-4F8D-9C07-98D13B9C987F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4BC7A851-8F1E-4F8D-9C07-98D13B9C987F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4BC7A851-8F1E-4F8D-9C07-98D13B9C987F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {AB82124B-D867-448E-8A9D-3AC910D43536} + EndGlobalSection +EndGlobal diff --git a/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Entities/Invitation.cs b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Entities/Invitation.cs new file mode 100644 index 000000000..bda1726f7 --- /dev/null +++ b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Entities/Invitation.cs @@ -0,0 +1,14 @@ +namespace MiniSpace.Services.Organizations.Core.Entities +{ + public class Invitation + { + public Guid UserId { get; } + public string Email { get; } + + public Invitation(Guid userId, string email) + { + UserId = userId; + Email = email; + } + } +} \ No newline at end of file diff --git a/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Entities/Organization.cs b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Entities/Organization.cs index ee3282b03..a0c1b3c71 100644 --- a/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Entities/Organization.cs +++ b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Entities/Organization.cs @@ -1,4 +1,7 @@ using MiniSpace.Services.Organizations.Core.Exceptions; +using System; +using System.Collections.Generic; +using System.Linq; namespace MiniSpace.Services.Organizations.Core.Entities { @@ -6,55 +9,94 @@ public class Organization : AggregateRoot { private ISet _organizers = new HashSet(); private ISet _subOrganizations = new HashSet(); + private ISet _invitations = new HashSet(); + private ISet _users = new HashSet(); public string Name { get; private set; } + public bool IsPublic { get; private set; } public IEnumerable Organizers { get => _organizers; private set => _organizers = new HashSet(value); } - + public IEnumerable SubOrganizations { get => _subOrganizations; private set => _subOrganizations = new HashSet(value); } - - public Organization(Guid id, string name, IEnumerable organizationOrganizers = null, + + public IEnumerable Invitations + { + get => _invitations; + private set => _invitations = new HashSet(value); + } + + public IEnumerable Users + { + get => _users; + private set => _users = new HashSet(value); + } + + public Organization(Guid id, string name, bool isPublic, IEnumerable organizationOrganizers = null, IEnumerable organizations = null) { Id = id; Name = name; + IsPublic = isPublic; Organizers = organizationOrganizers ?? Enumerable.Empty(); SubOrganizations = organizations ?? Enumerable.Empty(); } - + public void AddOrganizer(Guid organizerId) { - if(Organizers.Any(x => x.Id == organizerId)) + if (Organizers.Any(x => x.Id == organizerId)) { throw new OrganizerAlreadyAddedToOrganizationException(organizerId, Id); } _organizers.Add(new Organizer(organizerId)); } - + public void RemoveOrganizer(Guid organizerId) { var organizer = _organizers.SingleOrDefault(x => x.Id == organizerId); - if(organizer is null) + if (organizer is null) { throw new OrganizerIsNotInOrganization(organizerId, Id); } _organizers.Remove(organizer); } + public void InviteUser(Guid userId, string email) + { + if (_invitations.Any(i => i.UserId == userId)) + { + throw new UserAlreadyInvitedException(userId, Id); + } + _invitations.Add(new Invitation(userId, email)); + } + + public void SignUpUser(Guid userId) + { + if (_users.Any(u => u.Id == userId)) + { + throw new UserAlreadySignedUpException(userId, Id); + } + _users.Add(new User(userId)); + } + + public void SetPrivacy(bool isPublic) + { + IsPublic = isPublic; + } + public Organization GetSubOrganization(Guid id) { if (Id == id) { return this; } - + foreach (var subOrg in SubOrganizations) { var result = subOrg.GetSubOrganization(id); @@ -63,13 +105,13 @@ public Organization GetSubOrganization(Guid id) return result; } } - + return null; } - + public void AddSubOrganization(Organization organization) => _subOrganizations.Add(organization); - + public static List FindOrganizations(Guid targetOrganizerId, Organization rootOrganization) { var organizations = new List(); @@ -77,7 +119,7 @@ public static List FindOrganizations(Guid targetOrganizerId, Organ return organizations; } - private static void FindOrganizationsRecursive(Guid targetOrganizerId, Organization currentOrganization, + private static void FindOrganizationsRecursive(Guid targetOrganizerId, Organization currentOrganization, ICollection organizations) { if (currentOrganization.Organizers.Any(x => x.Id == targetOrganizerId)) @@ -90,15 +132,15 @@ private static void FindOrganizationsRecursive(Guid targetOrganizerId, Organizat FindOrganizationsRecursive(targetOrganizerId, subOrg, organizations); } } - + public static List FindAllChildrenOrganizations(Organization rootOrganization) { var organizations = new List(); FindAllChildrenOrganizationsRecursive(rootOrganization, organizations); return organizations; } - - private static void FindAllChildrenOrganizationsRecursive(Organization currentOrganization, + + private static void FindAllChildrenOrganizationsRecursive(Organization currentOrganization, ICollection organizations) { organizations.Add(currentOrganization.Id); @@ -108,7 +150,7 @@ private static void FindAllChildrenOrganizationsRecursive(Organization currentOr FindAllChildrenOrganizationsRecursive(subOrg, organizations); } } - + private Organization GetParentOrganization(Guid id) { foreach (var subOrg in SubOrganizations) @@ -117,25 +159,27 @@ private Organization GetParentOrganization(Guid id) { return this; } - + var result = subOrg.GetParentOrganization(id); if (result != null) { return result; } } - + return null; } - + public void RemoveChildOrganization(Organization organization) { var parent = GetParentOrganization(organization.Id); - if(parent is null) + if (parent is null) { throw new ParentOfOrganizationNotFoundException(organization.Id); } parent._subOrganizations.Remove(organization); } } -} \ No newline at end of file + + +} diff --git a/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Entities/User.cs b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Entities/User.cs new file mode 100644 index 000000000..2ec477928 --- /dev/null +++ b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Entities/User.cs @@ -0,0 +1,13 @@ +namespace MiniSpace.Services.Organizations.Core.Entities +{ + public class User + { + public Guid Id { get; } + + public User(Guid id) + { + Id = id; + } + } + +} \ No newline at end of file diff --git a/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Exceptions/UserAlreadyInvitedException.cs b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Exceptions/UserAlreadyInvitedException.cs new file mode 100644 index 000000000..0ffc6a767 --- /dev/null +++ b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Exceptions/UserAlreadyInvitedException.cs @@ -0,0 +1,16 @@ +namespace MiniSpace.Services.Organizations.Core.Exceptions +{ + public class UserAlreadyInvitedException : DomainException + { + public override string Code { get; } = "user_already_invited"; + public Guid UserId { get; } + public Guid OrganizationId { get; } + + public UserAlreadyInvitedException(Guid userId, Guid organizationId) + : base($"User with ID: '{userId}' has already been invited to organization with ID: '{organizationId}'.") + { + UserId = userId; + OrganizationId = organizationId; + } + } +} diff --git a/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Exceptions/UserAlreadySignedUpException.cs b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Exceptions/UserAlreadySignedUpException.cs new file mode 100644 index 000000000..fc13a353d --- /dev/null +++ b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/Exceptions/UserAlreadySignedUpException.cs @@ -0,0 +1,16 @@ +namespace MiniSpace.Services.Organizations.Core.Exceptions +{ + public class UserAlreadySignedUpException : DomainException + { + public override string Code { get; } = "user_already_signed_up"; + public Guid UserId { get; } + public Guid OrganizationId { get; } + + public UserAlreadySignedUpException(Guid userId, Guid organizationId) + : base($"User with ID: '{userId}' has already signed up for organization with ID: '{organizationId}'.") + { + UserId = userId; + OrganizationId = organizationId; + } + } +} diff --git a/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/MiniSpace.Services.Organizations.Core.sln b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/MiniSpace.Services.Organizations.Core.sln new file mode 100644 index 000000000..4696d082b --- /dev/null +++ b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Core/MiniSpace.Services.Organizations.Core.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MiniSpace.Services.Organizations.Core", "MiniSpace.Services.Organizations.Core.csproj", "{4A1D5807-F7C3-4930-A0D4-C0DE5DEE51F1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4A1D5807-F7C3-4930-A0D4-C0DE5DEE51F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4A1D5807-F7C3-4930-A0D4-C0DE5DEE51F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4A1D5807-F7C3-4930-A0D4-C0DE5DEE51F1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4A1D5807-F7C3-4930-A0D4-C0DE5DEE51F1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {BDCB65D9-633B-4A62-876D-C70F221B04B9} + EndGlobalSection +EndGlobal diff --git a/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Infrastructure/MiniSpace.Services.Organizations.Infrastructure.sln b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Infrastructure/MiniSpace.Services.Organizations.Infrastructure.sln new file mode 100644 index 000000000..d75c77792 --- /dev/null +++ b/MiniSpace.Services.Organizations/src/MiniSpace.Services.Organizations.Infrastructure/MiniSpace.Services.Organizations.Infrastructure.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MiniSpace.Services.Organizations.Infrastructure", "MiniSpace.Services.Organizations.Infrastructure.csproj", "{134B1B9E-CDD8-4D30-B7BE-E7F8A57FF4C9}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {134B1B9E-CDD8-4D30-B7BE-E7F8A57FF4C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {134B1B9E-CDD8-4D30-B7BE-E7F8A57FF4C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {134B1B9E-CDD8-4D30-B7BE-E7F8A57FF4C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {134B1B9E-CDD8-4D30-B7BE-E7F8A57FF4C9}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {07ECF736-90F5-46B5-B9BE-93D179D52541} + EndGlobalSection +EndGlobal diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Api/Program.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Api/Program.cs index d32e63650..2ee9a562a 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Api/Program.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Api/Program.cs @@ -32,7 +32,6 @@ public static async Task Main(string[] args) .UseInfrastructure() .UseDispatcherEndpoints(endpoints => endpoints .Get("", ctx => ctx.Response.WriteAsync(ctx.RequestServices.GetService().Name)) - // .Get>("students") .Get>("students") .Get("students/{studentId}") .Put("students/{studentId}") @@ -41,7 +40,9 @@ public static async Task Main(string[] args) afterDispatch: (cmd, ctx) => ctx.Response.Created($"students/{cmd.StudentId}")) .Put("students/{studentId}/state/{state}", afterDispatch: (cmd, ctx) => ctx.Response.NoContent()) - .Get("students/{studentId}/events"))) + .Get("students/{studentId}/events") + .Get("students/{studentId}/notifications") + .Post("students/{studentId}/notifications"))) .UseLogging() .Build() .RunAsync(); diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/CompleteStudentRegistration.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/CompleteStudentRegistration.cs index 7c159bb66..11713de39 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/CompleteStudentRegistration.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/CompleteStudentRegistration.cs @@ -5,12 +5,12 @@ namespace MiniSpace.Services.Students.Application.Commands public class CompleteStudentRegistration : ICommand { public Guid StudentId { get; } - public Guid ProfileImage { get; } + public string ProfileImage { get; } public string Description { get; } public DateTime DateOfBirth { get; } public bool EmailNotifications { get; } - public CompleteStudentRegistration(Guid studentId, Guid profileImage, + public CompleteStudentRegistration(Guid studentId, string profileImage, string description, DateTime dateOfBirth, bool emailNotifications) { StudentId = studentId; diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/Handlers/ChangeStudentStateHandler.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/Handlers/ChangeStudentStateHandler.cs index c72d19c8a..ab26ecf62 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/Handlers/ChangeStudentStateHandler.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/Handlers/ChangeStudentStateHandler.cs @@ -4,6 +4,9 @@ using MiniSpace.Services.Students.Core.Entities; using MiniSpace.Services.Students.Core.Exceptions; using MiniSpace.Services.Students.Core.Repositories; +using System; +using System.Threading; +using System.Threading.Tasks; namespace MiniSpace.Services.Students.Application.Commands.Handlers { @@ -50,6 +53,9 @@ public async Task HandleAsync(ChangeStudentState command, CancellationToken canc case State.Banned: student.SetBanned(); break; + case State.Unverified: + student.SetUnverified(); + break; default: throw new CannotChangeStudentStateException(student.Id, state); } diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/Handlers/UpdateStudentHandler.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/Handlers/UpdateStudentHandler.cs index a2ea9dd8a..b48f13680 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/Handlers/UpdateStudentHandler.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/Handlers/UpdateStudentHandler.cs @@ -1,7 +1,13 @@ using Convey.CQRS.Commands; +using MiniSpace.Services.Students.Application.Events; using MiniSpace.Services.Students.Application.Exceptions; using MiniSpace.Services.Students.Application.Services; using MiniSpace.Services.Students.Core.Repositories; +using System; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; namespace MiniSpace.Services.Students.Application.Commands.Handlers { @@ -20,26 +26,61 @@ public UpdateStudentHandler(IStudentRepository studentRepository, IAppContext ap _eventMapper = eventMapper; _messageBroker = messageBroker; } - + public async Task HandleAsync(UpdateStudent command, CancellationToken cancellationToken = default) { + // Log the command received + var commandJson = JsonSerializer.Serialize(command); + Console.WriteLine($"Received UpdateStudent command: {commandJson}"); + var student = await _studentRepository.GetAsync(command.StudentId); if (student is null) { throw new StudentNotFoundException(command.StudentId); } - + var identity = _appContext.Identity; if (identity.IsAuthenticated && identity.Id != student.Id && !identity.IsAdmin) { throw new UnauthorizedStudentAccessException(command.StudentId, identity.Id); } - - student.Update(command.ProfileImage, command.Description, command.EmailNotifications); + + student.Update(command.FirstName, command.LastName, command.ProfileImageUrl, command.Description, command.EmailNotifications, command.ContactEmail); + student.UpdateBannerUrl(command.BannerUrl); + student.UpdateGalleryOfImageUrls(command.GalleryOfImageUrls); + student.UpdateEducation(command.Education); + student.UpdateWorkPosition(command.WorkPosition); + student.UpdateCompany(command.Company); + student.UpdateLanguages(command.Languages); + student.UpdateInterests(command.Interests); + + if (command.EnableTwoFactor) + { + student.EnableTwoFactorAuthentication(command.TwoFactorSecret); + } + + if (command.DisableTwoFactor) + { + student.DisableTwoFactorAuthentication(); + } + await _studentRepository.UpdateAsync(student); - var events = _eventMapper.MapAll(student.Events); - await _messageBroker.PublishAsync(events.ToArray()); + var studentUpdatedEvent = new StudentUpdated( + student.Id, + student.FullName, + student.ProfileImageUrl, + student.BannerUrl, + student.GalleryOfImageUrls, + student.Education, + student.WorkPosition, + student.Company, + student.Languages, + student.Interests, + student.ContactEmail + ); + + await _messageBroker.PublishAsync(studentUpdatedEvent); } - } + } } diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/Handlers/UpdateUserNotificationPreferencesHandler.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/Handlers/UpdateUserNotificationPreferencesHandler.cs new file mode 100644 index 000000000..e6a76682d --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/Handlers/UpdateUserNotificationPreferencesHandler.cs @@ -0,0 +1,40 @@ +using Convey.CQRS.Commands; +using MiniSpace.Services.Students.Core.Entities; +using MiniSpace.Services.Students.Core.Repositories; +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace MiniSpace.Services.Students.Application.Commands.Handlers +{ + public class UpdateUserNotificationPreferencesHandler : ICommandHandler + { + private readonly IUserNotificationPreferencesRepository _userNotificationPreferencesRepository; + + public UpdateUserNotificationPreferencesHandler(IUserNotificationPreferencesRepository userNotificationPreferencesRepository) + { + _userNotificationPreferencesRepository = userNotificationPreferencesRepository; + } + + public async Task HandleAsync(UpdateUserNotificationPreferences command, CancellationToken cancellationToken = default) + { + // Log the command received + var commandJson = JsonSerializer.Serialize(command); + Console.WriteLine($"Received UpdateUserNotificationPreferences command: {commandJson}"); + + var notificationPreferences = new NotificationPreferences( + command.AccountChanges, + command.SystemLogin, + command.NewEvent, + command.InterestBasedEvents, + command.EventNotifications, + command.CommentsNotifications, + command.PostsNotifications, + command.FriendsNotifications + ); + + await _userNotificationPreferencesRepository.UpdateNotificationPreferencesAsync(command.StudentId, notificationPreferences); + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/UpdateStudent.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/UpdateStudent.cs index ecd0e256f..d76d0a8a5 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/UpdateStudent.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/UpdateStudent.cs @@ -1,20 +1,52 @@ using Convey.CQRS.Commands; +using System; +using System.Collections.Generic; +using System.Linq; namespace MiniSpace.Services.Students.Application.Commands { public class UpdateStudent : ICommand { public Guid StudentId { get; } - public Guid ProfileImage { get; } + public string FirstName { get; } + public string LastName { get; } + public string ProfileImageUrl { get; } public string Description { get; } public bool EmailNotifications { get; } - - public UpdateStudent(Guid studentId, Guid profileImage, string description, bool emailNotifications) + public string? BannerUrl { get; } + public IEnumerable GalleryOfImageUrls { get; } + public string Education { get; } + public string WorkPosition { get; } + public string Company { get; } + public IEnumerable Languages { get; } + public IEnumerable Interests { get; } + public bool EnableTwoFactor { get; } + public bool DisableTwoFactor { get; } + public string TwoFactorSecret { get; } + public string? ContactEmail { get; } + + public UpdateStudent(Guid studentId, string firstName, string lastName, string profileImageUrl, string description, bool emailNotifications, + string? bannerUrl, IEnumerable galleryOfImageUrls, string education, string workPosition, + string company, IEnumerable languages, IEnumerable interests, + bool enableTwoFactor, bool disableTwoFactor, string twoFactorSecret, string? contactEmail) { StudentId = studentId; - ProfileImage = profileImage; + FirstName = firstName; + LastName = lastName; + ProfileImageUrl = profileImageUrl; Description = description; EmailNotifications = emailNotifications; + BannerUrl = bannerUrl; + GalleryOfImageUrls = galleryOfImageUrls ?? Enumerable.Empty(); + Education = education; + WorkPosition = workPosition; + Company = company; + Languages = languages ?? Enumerable.Empty(); + Interests = interests ?? Enumerable.Empty(); + EnableTwoFactor = enableTwoFactor; + DisableTwoFactor = disableTwoFactor; + TwoFactorSecret = twoFactorSecret; + ContactEmail = contactEmail; } } } diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/UpdateUserNotificationPreferences.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/UpdateUserNotificationPreferences.cs new file mode 100644 index 000000000..4f8ea0451 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Commands/UpdateUserNotificationPreferences.cs @@ -0,0 +1,34 @@ +using Convey.CQRS.Commands; +using System; + +namespace MiniSpace.Services.Students.Application.Commands +{ + public class UpdateUserNotificationPreferences : ICommand + { + public Guid StudentId { get; } + public bool AccountChanges { get; } + public bool SystemLogin { get; } + public bool NewEvent { get; } + public bool InterestBasedEvents { get; } + public bool EventNotifications { get; } + public bool CommentsNotifications { get; } + public bool PostsNotifications { get; } + public bool FriendsNotifications { get; } + + public UpdateUserNotificationPreferences(Guid studentId, bool accountChanges, bool systemLogin, bool newEvent, + bool interestBasedEvents, bool eventNotifications, + bool commentsNotifications, bool postsNotifications, + bool friendsNotifications) + { + StudentId = studentId; + AccountChanges = accountChanges; + SystemLogin = systemLogin; + NewEvent = newEvent; + InterestBasedEvents = interestBasedEvents; + EventNotifications = eventNotifications; + CommentsNotifications = commentsNotifications; + PostsNotifications = postsNotifications; + FriendsNotifications = friendsNotifications; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Dto/NotificationPreferencesDto.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Dto/NotificationPreferencesDto.cs new file mode 100644 index 000000000..e8e3b9151 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Dto/NotificationPreferencesDto.cs @@ -0,0 +1,44 @@ +using System; + +namespace MiniSpace.Services.Students.Application.Dto +{ + public class NotificationPreferencesDto + { + public Guid StudentId { get; set; } + public bool AccountChanges { get; set; } + public bool SystemLogin { get; set; } + public bool NewEvent { get; set; } + public bool InterestBasedEvents { get; set; } + public bool EventNotifications { get; set; } + public bool CommentsNotifications { get; set; } + public bool PostsNotifications { get; set; } + public bool FriendsNotifications { get; set; } + + public NotificationPreferencesDto() + { + AccountChanges = false; + SystemLogin = false; + NewEvent = false; + InterestBasedEvents = false; + EventNotifications = false; + CommentsNotifications = false; + PostsNotifications = false; + FriendsNotifications = false; + } + + public NotificationPreferencesDto(Guid studentId, bool accountChanges, bool systemLogin, bool newEvent, bool interestBasedEvents, + bool eventNotifications, bool commentsNotifications, bool postsNotifications, + bool friendsNotifications) + { + StudentId = studentId; + AccountChanges = accountChanges; + SystemLogin = systemLogin; + NewEvent = newEvent; + InterestBasedEvents = interestBasedEvents; + EventNotifications = eventNotifications; + CommentsNotifications = commentsNotifications; + PostsNotifications = postsNotifications; + FriendsNotifications = friendsNotifications; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Dto/StudentDto.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Dto/StudentDto.cs index cd96faedc..9eeebb56b 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Dto/StudentDto.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Dto/StudentDto.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; namespace MiniSpace.Services.Students.Application.Dto @@ -10,7 +12,7 @@ public class StudentDto public string FirstName { get; set; } public string LastName { get; set; } public int NumberOfFriends { get; set; } - public Guid ProfileImage { get; set; } + public string ProfileImageUrl { get; set; } public string Description { get; set; } public DateTime? DateOfBirth { get; set; } public bool EmailNotifications { get; set; } @@ -18,7 +20,18 @@ public class StudentDto public bool IsOrganizer { get; set; } public string State { get; set; } public DateTime CreatedAt { get; set; } + public string Education { get; set; } + public string WorkPosition { get; set; } + public string Company { get; set; } + public IEnumerable Languages { get; set; } + public IEnumerable Interests { get; set; } + public bool IsTwoFactorEnabled { get; set; } + public string TwoFactorSecret { get; set; } public IEnumerable InterestedInEvents { get; set; } public IEnumerable SignedUpEvents { get; set; } - } + public string BannerUrl { get; set; } + public IEnumerable GalleryOfImageUrls { get; set; } + public string ContactEmail { get; set; } + public NotificationPreferencesDto NotificationPreferences { get; set; } + } } diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Dto/StudentImagesDto.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Dto/StudentImagesDto.cs new file mode 100644 index 000000000..41a5b6c5b --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Dto/StudentImagesDto.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace MiniSpace.Services.Students.Application.Dto +{ + [ExcludeFromCodeCoverage] + public class StudentImagesDto + { + public Guid StudentId { get; set; } + public string BannerUrl { get; set; } + public IEnumerable GalleryOfImageUrls { get; set; } + + public StudentImagesDto(Guid studentId, string bannerMediaFileId, IEnumerable galleryOfImages) + { + StudentId = studentId; + BannerUrl = bannerMediaFileId; + GalleryOfImageUrls = galleryOfImages ?? new List(); + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/EmailVerified.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/EmailVerified.cs new file mode 100644 index 000000000..93db65351 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/EmailVerified.cs @@ -0,0 +1,21 @@ +using Convey.CQRS.Events; +using Convey.MessageBrokers; +using System; + +namespace MiniSpace.Services.Students.Application.Events.External +{ + [Message("identity")] + public class EmailVerified : IEvent + { + public Guid UserId { get; } + public string Email { get; } + public DateTime VerifiedAt { get; } + + public EmailVerified(Guid userId, string email, DateTime verifiedAt) + { + UserId = userId; + Email = email; + VerifiedAt = verifiedAt; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/Handlers/MediaFileDeletedHandler.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/Handlers/MediaFileDeletedHandler.cs index bb3dfc3f4..5c68f920e 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/Handlers/MediaFileDeletedHandler.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/Handlers/MediaFileDeletedHandler.cs @@ -1,30 +1,66 @@ using Convey.CQRS.Events; using MiniSpace.Services.Students.Core.Repositories; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace MiniSpace.Services.Students.Application.Events.External.Handlers { - public class MediaFileDeletedHandler: IEventHandler + public class MediaFileDeletedHandler : IEventHandler { private readonly IStudentRepository _studentRepository; - + public MediaFileDeletedHandler(IStudentRepository studentRepository) { _studentRepository = studentRepository; } - + public async Task HandleAsync(MediaFileDeleted @event, CancellationToken cancellationToken) { - if(@event.Source.ToLowerInvariant() != "studentprofile") + Console.WriteLine($"Received MediaFileDeleted event: {@event.MediaFileUrl}"); + + if (@event.Source.ToLowerInvariant() != "studentprofileimage") { + Console.WriteLine("Event source is not 'studentprofileimage', ignoring the event."); return; } var student = await _studentRepository.GetAsync(@event.SourceId); - if(student != null) + if (student != null) { - student.RemoveProfileImage(@event.MediaFileId); - await _studentRepository.UpdateAsync(student); + bool updated = false; + + // Check and remove profile image + if (student.ProfileImageUrl == @event.MediaFileUrl) + { + student.RemoveProfileImage(); + updated = true; + Console.WriteLine("Removed profile image."); + } + + // Check and remove banner image + if (student.BannerUrl == @event.MediaFileUrl) + { + student.RemoveBannerImage(); + updated = true; + Console.WriteLine("Removed banner image."); + } + + // Check and remove gallery images + if (student.GalleryOfImageUrls.Contains(@event.MediaFileUrl)) + { + student.RemoveGalleryImage(@event.MediaFileUrl); + updated = true; + Console.WriteLine("Removed gallery image."); + } + + if (updated) + { + await _studentRepository.UpdateAsync(student); + Console.WriteLine("Updated student repository."); + } } } + } } diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/Handlers/SignedUpHandler.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/Handlers/SignedUpHandler.cs index 0e6202eeb..168c4d734 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/Handlers/SignedUpHandler.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/Handlers/SignedUpHandler.cs @@ -4,6 +4,8 @@ using MiniSpace.Services.Students.Application.Services; using MiniSpace.Services.Students.Core.Entities; using MiniSpace.Services.Students.Core.Repositories; +using System.Threading; +using System.Threading.Tasks; namespace MiniSpace.Services.Students.Application.Events.External.Handlers { @@ -13,13 +15,15 @@ public class SignedUpHandler : IEventHandler private readonly IStudentRepository _studentRepository; private readonly IDateTimeProvider _dateTimeProvider; private readonly ILogger _logger; - + private readonly IUserNotificationPreferencesRepository _notificationPreferencesRepository; + public SignedUpHandler(IStudentRepository studentRepository, IDateTimeProvider dateTimeProvider, - ILogger logger) + ILogger logger, IUserNotificationPreferencesRepository notificationPreferencesRepository) { _studentRepository = studentRepository; _dateTimeProvider = dateTimeProvider; _logger = logger; + _notificationPreferencesRepository = notificationPreferencesRepository; } public async Task HandleAsync(SignedUp @event, CancellationToken cancellationToken = default) @@ -38,6 +42,11 @@ public async Task HandleAsync(SignedUp @event, CancellationToken cancellationTok var newStudent = new Student(@event.UserId, @event.FirstName, @event.LastName, @event.Email, _dateTimeProvider.Now); await _studentRepository.AddAsync(newStudent); + + var defaultPreferences = new NotificationPreferences(); + await _notificationPreferencesRepository.UpdateNotificationPreferencesAsync(newStudent.Id, defaultPreferences); + + _logger.LogInformation($"New student created with ID: {@event.UserId} and default notification preferences set."); } - } + } } diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/Handlers/StudentEmailVerifiedHandler.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/Handlers/StudentEmailVerifiedHandler.cs new file mode 100644 index 000000000..3c3a4b801 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/Handlers/StudentEmailVerifiedHandler.cs @@ -0,0 +1,38 @@ +using Convey.CQRS.Events; +using Microsoft.Extensions.Logging; +using MiniSpace.Services.Students.Application.Exceptions; +using MiniSpace.Services.Students.Core.Entities; +using MiniSpace.Services.Students.Core.Repositories; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace MiniSpace.Services.Students.Application.Events.External.Handlers +{ + public class StudentEmailVerifiedHandler : IEventHandler + { + private readonly IStudentRepository _studentRepository; + private readonly ILogger _logger; + + public StudentEmailVerifiedHandler(IStudentRepository studentRepository, ILogger logger) + { + _studentRepository = studentRepository; + _logger = logger; + } + + public async Task HandleAsync(EmailVerified @event, CancellationToken cancellationToken = default) + { + var student = await _studentRepository.GetAsync(@event.UserId); + if (student == null) + { + _logger.LogError($"Student with ID {@event.UserId} not found."); + throw new StudentNotFoundException(@event.UserId); + } + + student.SetValid(); + await _studentRepository.UpdateAsync(student); + + _logger.LogInformation($"Student with ID {@event.UserId} email verified and state set to valid."); + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/Handlers/StudentImageUploadedHandler.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/Handlers/StudentImageUploadedHandler.cs new file mode 100644 index 000000000..1b1aac931 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/Handlers/StudentImageUploadedHandler.cs @@ -0,0 +1,46 @@ +using Convey.CQRS.Events; +using MiniSpace.Services.Students.Application.Exceptions; +using MiniSpace.Services.Students.Core.Entities; +using MiniSpace.Services.Students.Core.Repositories; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace MiniSpace.Services.Students.Application.Events.External.Handlers +{ + public class StudentImageUploadedHandler : IEventHandler + { + private readonly IStudentRepository _studentRepository; + + public StudentImageUploadedHandler(IStudentRepository studentRepository) + { + _studentRepository = studentRepository; + } + + public async Task HandleAsync(StudentImageUploaded @event, CancellationToken cancellationToken) + { + var student = await _studentRepository.GetAsync(@event.StudentId); + if (student == null) + { + throw new StudentNotFoundException(@event.StudentId); + } + + switch (@event.ImageType) + { + case nameof(ContextType.StudentProfileImage): + student.UpdateProfileImageUrl(@event.ImageUrl); + break; + case nameof(ContextType.StudentBannerImage): + student.UpdateBannerUrl(@event.ImageUrl); + break; + case nameof(ContextType.StudentGalleryImage): + student.AddGalleryImageUrl(@event.ImageUrl); + break; + default: + throw new InvalidContextTypeException(@event.ImageType); + } + + await _studentRepository.UpdateAsync(student); + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/MediaFileDeleted.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/MediaFileDeleted.cs index 98e856a63..c6a00d89c 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/MediaFileDeleted.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/MediaFileDeleted.cs @@ -4,15 +4,15 @@ namespace MiniSpace.Services.Students.Application.Events.External { [Message("mediafiles")] - public class MediaFileDeleted: IEvent + public class MediaFileDeleted : IEvent { - public Guid MediaFileId { get; } + public string MediaFileUrl { get; } public Guid SourceId { get; } public string Source { get; } - public MediaFileDeleted(Guid mediaFileId, Guid sourceId, string source) + public MediaFileDeleted(string mediaFileUrl, Guid sourceId, string source) { - MediaFileId = mediaFileId; + MediaFileUrl = mediaFileUrl; SourceId = sourceId; Source = source; } diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/StudentImageUploaded.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/StudentImageUploaded.cs new file mode 100644 index 000000000..361ef0d1c --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/External/StudentImageUploaded.cs @@ -0,0 +1,21 @@ +using Convey.CQRS.Events; +using Convey.MessageBrokers; +using System; + +namespace MiniSpace.Services.Students.Application.Events.External +{ + [Message("mediafiles")] + public class StudentImageUploaded : IEvent + { + public Guid StudentId { get; } + public string ImageUrl { get; } + public string ImageType { get; } + + public StudentImageUploaded(Guid studentId, string imageUrl, string imageType) + { + StudentId = studentId; + ImageUrl = imageUrl; + ImageType = imageType; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/StudentCreated.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/StudentCreated.cs index 3d0966caf..b7556f615 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/StudentCreated.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/StudentCreated.cs @@ -8,13 +8,13 @@ public class StudentCreated : IEvent { public Guid StudentId { get; } public string FullName { get; } - public Guid MediaFileId { get; } + public string ProfileImageUrl { get; } - public StudentCreated(Guid studentId, string fullName, Guid mediaFileId) + public StudentCreated(Guid studentId, string fullName, string profileImageUrl) { StudentId = studentId; FullName = fullName; - MediaFileId = mediaFileId; + ProfileImageUrl = profileImageUrl; } } } diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/StudentUpdated.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/StudentUpdated.cs index 79d6d38c0..56a05047c 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/StudentUpdated.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Events/StudentUpdated.cs @@ -1,4 +1,6 @@ using Convey.CQRS.Events; +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; namespace MiniSpace.Services.Students.Application.Events @@ -8,13 +10,32 @@ public class StudentUpdated : IEvent { public Guid StudentId { get; } public string FullName { get; } - public Guid MediaFileId { get; } + public string ProfileImageUrl { get; } + public string BannerUrl { get; } + public IEnumerable GalleryOfImageUrls { get; } + public string Education { get; } + public string WorkPosition { get; } + public string Company { get; } + public IEnumerable Languages { get; } + public IEnumerable Interests { get; } + public string ContactEmail { get; } // New property - public StudentUpdated(Guid studentId, string fullName, Guid mediaFileId) + public StudentUpdated(Guid studentId, string fullName, string profileImageUrl, string bannerUrl, + IEnumerable galleryOfImageUrls, string education, string workPosition, + string company, IEnumerable languages, IEnumerable interests, + string contactEmail) { StudentId = studentId; FullName = fullName; - MediaFileId = mediaFileId; + ProfileImageUrl = profileImageUrl; + BannerUrl = bannerUrl; + GalleryOfImageUrls = galleryOfImageUrls; + Education = education; + WorkPosition = workPosition; + Company = company; + Languages = languages; + Interests = interests; + ContactEmail = contactEmail; } - } + } } diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Exceptions/InvalidContextTypeException.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Exceptions/InvalidContextTypeException.cs new file mode 100644 index 000000000..3c2ef06ca --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Exceptions/InvalidContextTypeException.cs @@ -0,0 +1,14 @@ +namespace MiniSpace.Services.Students.Application.Exceptions +{ + public class InvalidContextTypeException : AppException + { + public override string Code { get; } = "invalid_context_type"; + public string ContextType { get; } + + public InvalidContextTypeException(string contextType) + : base($"Invalid context type: {contextType}.") + { + ContextType = contextType; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Queries/GetStudentImages.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Queries/GetStudentImages.cs new file mode 100644 index 000000000..8c7aad8d4 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Queries/GetStudentImages.cs @@ -0,0 +1,18 @@ +using Convey.CQRS.Queries; +using MiniSpace.Services.Students.Application.Dto; +using System; +using System.Diagnostics.CodeAnalysis; + +namespace MiniSpace.Services.Students.Application.Queries +{ + [ExcludeFromCodeCoverage] + public class GetStudentImages : IQuery + { + public Guid StudentId { get; set; } + + public GetStudentImages(Guid studentId) + { + StudentId = studentId; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Queries/GetUserNotificationPreferences.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Queries/GetUserNotificationPreferences.cs new file mode 100644 index 000000000..b2b58cd51 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Queries/GetUserNotificationPreferences.cs @@ -0,0 +1,16 @@ +using Convey.CQRS.Queries; +using MiniSpace.Services.Students.Application.Dto; +using System; + +namespace MiniSpace.Services.Students.Application.Queries +{ + public class GetUserNotificationPreferences : IQuery + { + public Guid StudentId { get; } + + public GetUserNotificationPreferences(Guid studentId) + { + StudentId = studentId; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Queries/PagedResult.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Queries/PagedResult.cs index c5b47db97..17904904f 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Queries/PagedResult.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Application/Queries/PagedResult.cs @@ -19,8 +19,7 @@ public PagedResult(List results, int total, int pageSize, int page, string ba Total = total; PageSize = pageSize; Page = page; - - // Calculate if there's a next page + int totalPages = (int)Math.Ceiling(total / (double)pageSize); NextPage = page < totalPages ? $"{baseUrl}?page={page + 1}&resultsPerPage={pageSize}" : null; PrevPage = page > 1 ? $"{baseUrl}?page={page - 1}&resultsPerPage={pageSize}" : null; diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/ContextType.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/ContextType.cs new file mode 100644 index 000000000..d530c8195 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/ContextType.cs @@ -0,0 +1,11 @@ +namespace MiniSpace.Services.Students.Core.Entities +{ + public enum ContextType + { + Event, + Post, + StudentProfileImage, + StudentBannerImage, + StudentGalleryImage + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/NotificationPreferences.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/NotificationPreferences.cs new file mode 100644 index 000000000..0ae96e0ee --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/NotificationPreferences.cs @@ -0,0 +1,59 @@ +using System; + +namespace MiniSpace.Services.Students.Core.Entities +{ + public class NotificationPreferences + { + public bool AccountChanges { get; private set; } + public bool SystemLogin { get; private set; } + public bool NewEvent { get; private set; } + public bool InterestBasedEvents { get; private set; } + public bool EventNotifications { get; private set; } + public bool CommentsNotifications { get; private set; } + public bool PostsNotifications { get; private set; } + public bool FriendsNotifications { get; private set; } + + + public NotificationPreferences() + { + + AccountChanges = false; + SystemLogin = false; + NewEvent = false; + InterestBasedEvents = false; + EventNotifications = false; + CommentsNotifications = false; + PostsNotifications = false; + FriendsNotifications = false; + } + + + public NotificationPreferences(bool accountChanges, bool systemLogin, bool newEvent, bool interestBasedEvents, + bool eventNotifications, bool commentsNotifications, bool postsNotifications, + bool friendsNotifications) + { + AccountChanges = accountChanges; + SystemLogin = systemLogin; + NewEvent = newEvent; + InterestBasedEvents = interestBasedEvents; + EventNotifications = eventNotifications; + CommentsNotifications = commentsNotifications; + PostsNotifications = postsNotifications; + FriendsNotifications = friendsNotifications; + } + + public void UpdatePreferences(bool accountChanges, bool systemLogin, bool newEvent, bool interestBasedEvents, + bool eventNotifications, bool commentsNotifications, bool postsNotifications, + bool friendsNotifications) + { + AccountChanges = accountChanges; + SystemLogin = systemLogin; + NewEvent = newEvent; + InterestBasedEvents = interestBasedEvents; + EventNotifications = eventNotifications; + CommentsNotifications = commentsNotifications; + PostsNotifications = postsNotifications; + FriendsNotifications = friendsNotifications; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/State.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/State.cs index d496907e8..9c6af860b 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/State.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/State.cs @@ -5,6 +5,7 @@ public enum State Unknown, Valid, Incomplete, - Banned + Banned, + Unverified } } diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/Student.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/Student.cs index 38cdae5e8..6488e43d0 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/Student.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/Student.cs @@ -1,5 +1,8 @@ using MiniSpace.Services.Students.Core.Events; using MiniSpace.Services.Students.Core.Exceptions; +using System; +using System.Collections.Generic; +using System.Linq; namespace MiniSpace.Services.Students.Core.Entities { @@ -7,13 +10,16 @@ public class Student : AggregateRoot { private ISet _interestedInEvents = new HashSet(); private ISet _signedUpEvents = new HashSet(); - + private ISet _galleryOfImages = new HashSet(); + private ISet _languages = new HashSet(); + private ISet _interests = new HashSet(); + public string Email { get; private set; } public string FirstName { get; private set; } public string LastName { get; private set; } public string FullName => $"{FirstName} {LastName}"; public int NumberOfFriends { get; private set; } - public Guid ProfileImage { get; private set; } + public string ProfileImageUrl { get; private set; } public string Description { get; private set; } public DateTime? DateOfBirth { get; private set; } public bool EmailNotifications { get; private set; } @@ -21,29 +27,55 @@ public class Student : AggregateRoot public bool IsOrganizer { get; private set; } public State State { get; private set; } public DateTime CreatedAt { get; private set; } - + public string ContactEmail { get; private set; } + public string BannerUrl { get; private set; } + public IEnumerable GalleryOfImageUrls + { + get => _galleryOfImages; + set => _galleryOfImages = new HashSet(value ?? Enumerable.Empty()); + } + public string Education { get; private set; } + public string WorkPosition { get; private set; } + public string Company { get; private set; } + public IEnumerable Languages + { + get => _languages; + set => _languages = new HashSet(value ?? Enumerable.Empty()); + } + public IEnumerable Interests + { + get => _interests; + set => _interests = new HashSet(value ?? Enumerable.Empty()); + } + public bool IsTwoFactorEnabled { get; private set; } + public string TwoFactorSecret { get; private set; } public IEnumerable InterestedInEvents { get => _interestedInEvents; - set => _interestedInEvents = new HashSet(value); + set => _interestedInEvents = new HashSet(value ?? Enumerable.Empty()); } public IEnumerable SignedUpEvents { get => _signedUpEvents; - set => _signedUpEvents = new HashSet(value); + set => _signedUpEvents = new HashSet(value ?? Enumerable.Empty()); } public Student(Guid id, string firstName, string lastName, string email, DateTime createdAt) - : this(id, email, createdAt, firstName, lastName, 0, Guid.Empty, string.Empty, null, - false, false, false, State.Incomplete, Enumerable.Empty(), Enumerable.Empty()) + : this(id, email, createdAt, firstName, lastName, 0, string.Empty, string.Empty, null, + false, false, false, State.Unverified, Enumerable.Empty(), Enumerable.Empty(), null, + Enumerable.Empty(), null, null, null, Enumerable.Empty(), Enumerable.Empty(), + false, null, null) { CheckFullName(firstName, lastName); } - + public Student(Guid id, string email, DateTime createdAt, string firstName, string lastName, - int numberOfFriends, Guid profileImage, string description, DateTime? dateOfBirth, + int numberOfFriends, string profileImageUrl, string description, DateTime? dateOfBirth, bool emailNotifications, bool isBanned, bool isOrganizer, State state, - IEnumerable interestedInEvents = null, IEnumerable signedUpEvents = null) + IEnumerable interestedInEvents, IEnumerable signedUpEvents, + string bannerUrl, IEnumerable galleryOfImageUrls, string education, + string workPosition, string company, IEnumerable languages, IEnumerable interests, + bool isTwoFactorEnabled, string twoFactorSecret, string contactEmail = null) { Id = id; Email = email; @@ -51,7 +83,7 @@ public Student(Guid id, string email, DateTime createdAt, string firstName, stri FirstName = firstName; LastName = lastName; NumberOfFriends = numberOfFriends; - ProfileImage = profileImage; + ProfileImageUrl = profileImageUrl; Description = description; DateOfBirth = dateOfBirth; EmailNotifications = emailNotifications; @@ -60,55 +92,167 @@ public Student(Guid id, string email, DateTime createdAt, string firstName, stri State = state; InterestedInEvents = interestedInEvents ?? Enumerable.Empty(); SignedUpEvents = signedUpEvents ?? Enumerable.Empty(); + BannerUrl = bannerUrl; + GalleryOfImageUrls = galleryOfImageUrls ?? Enumerable.Empty(); + Education = education; + WorkPosition = workPosition; + Company = company; + Languages = languages ?? Enumerable.Empty(); + Interests = interests ?? Enumerable.Empty(); + IsTwoFactorEnabled = isTwoFactorEnabled; + TwoFactorSecret = twoFactorSecret; + ContactEmail = contactEmail; } - + public void SetIncomplete() => SetState(State.Incomplete); public void SetValid() => SetState(State.Valid); public void SetBanned() => SetState(State.Banned); - + public void SetUnverified() => SetState(State.Unverified); + private void SetState(State state) { var previousState = State; State = state; AddEvent(new StudentStateChanged(this, previousState)); } - - public void CompleteRegistration(Guid profileImage, string description, + + public void CompleteRegistration(string profileImageUrl, string description, DateTime dateOfBirth, DateTime now, bool emailNotifications) { CheckDescription(description); CheckDateOfBirth(dateOfBirth, now); - - if (State != State.Incomplete) + + if (State != State.Incomplete && State != State.Unverified) { throw new CannotChangeStudentStateException(Id, State); } - - ProfileImage = profileImage; + + ProfileImageUrl = profileImageUrl; Description = description; DateOfBirth = dateOfBirth; EmailNotifications = emailNotifications; - + State = State.Valid; AddEvent(new StudentRegistrationCompleted(this)); } - public void Update(Guid profileImage, string description, bool emailNotifications) + public void Update(string firstName, string lastName, string profileImageUrl, string description, bool emailNotifications, string contactEmail) { + CheckFullName(firstName, lastName); CheckDescription(description); if (State != State.Valid) { throw new CannotUpdateStudentException(Id); } - - ProfileImage = profileImage; + + FirstName = firstName; + LastName = lastName; + ProfileImageUrl = profileImageUrl; Description = description; EmailNotifications = emailNotifications; - + ContactEmail = contactEmail; + + AddEvent(new StudentUpdated(this)); + } + + public void UpdateProfileImageUrl(string profileImageUrl) + { + ProfileImageUrl = profileImageUrl; AddEvent(new StudentUpdated(this)); } - + + public void UpdateBannerUrl(string bannerUrl) + { + BannerUrl = bannerUrl; + AddEvent(new StudentBannerUpdated(this)); + } + + public void AddGalleryImageUrl(string imageUrl) + { + _galleryOfImages.Add(imageUrl); + AddEvent(new StudentGalleryOfImagesUpdated(this)); + } + + public void UpdateGalleryOfImageUrls(IEnumerable galleryOfImageUrls) + { + GalleryOfImageUrls = new HashSet(galleryOfImageUrls ?? Enumerable.Empty()); + AddEvent(new StudentGalleryOfImagesUpdated(this)); + } + + public void RemoveGalleryImage(string imageUrl) + { + if (!_galleryOfImages.Contains(imageUrl)) + { + throw new StudentGalleryImageNotFoundException(Id, imageUrl); + } + + _galleryOfImages = new HashSet(_galleryOfImages.Select(url => url == imageUrl ? string.Empty : url)); + AddEvent(new StudentGalleryOfImagesUpdated(this)); + } + + public void RemoveBannerImage() + { + + BannerUrl = string.Empty; + AddEvent(new StudentBannerUpdated(this)); + } + + public void UpdateEducation(string education) + { + Education = education; + AddEvent(new StudentEducationUpdated(this)); + } + + public void UpdateWorkPosition(string workPosition) + { + WorkPosition = workPosition; + AddEvent(new StudentWorkPositionUpdated(this)); + } + + public void UpdateCompany(string company) + { + Company = company; + AddEvent(new StudentCompanyUpdated(this)); + } + + public void UpdateLanguages(IEnumerable languages) + { + Languages = new HashSet(languages ?? Enumerable.Empty()); + AddEvent(new StudentLanguagesUpdated(this)); + } + + public void UpdateInterests(IEnumerable interests) + { + Interests = new HashSet(interests ?? Enumerable.Empty()); + AddEvent(new StudentInterestsUpdated(this)); + } + + public void UpdateContactEmail(string contactEmail) + { + ContactEmail = contactEmail; + AddEvent(new StudentUpdated(this)); + } + + public void EnableTwoFactorAuthentication(string twoFactorSecret) + { + if (string.IsNullOrWhiteSpace(twoFactorSecret)) + { + throw new InvalidTwoFactorSecretException(Id); + } + + IsTwoFactorEnabled = true; + TwoFactorSecret = twoFactorSecret; + AddEvent(new StudentTwoFactorEnabled(this)); + } + + public void DisableTwoFactorAuthentication() + { + IsTwoFactorEnabled = false; + TwoFactorSecret = null; + AddEvent(new StudentTwoFactorDisabled(this)); + } + private void CheckFullName(string firstName, string lastName) { if (string.IsNullOrWhiteSpace(firstName) || string.IsNullOrWhiteSpace(lastName)) @@ -119,10 +263,10 @@ private void CheckFullName(string firstName, string lastName) private void CheckDescription(string description) { - if (string.IsNullOrWhiteSpace(description)) - { - throw new InvalidStudentDescriptionException(Id, description); - } + // if (string.IsNullOrWhiteSpace(description)) + // { + // throw new InvalidStudentDescriptionException(Id, description); + // } } private void CheckDateOfBirth(DateTime dateOfBirth, DateTime now) @@ -139,7 +283,7 @@ public void AddInterestedInEvent(Guid eventId) { return; } - + if (!_interestedInEvents.Add(eventId)) { throw new StudentAlreadyInterestedInException(Id, eventId); @@ -151,7 +295,7 @@ public void RemoveInterestedInEvent(Guid eventId) if (!_interestedInEvents.Remove(eventId)) { throw new StudentIsNotInterestedException(Id, eventId); - } + } } public void AddSignedUpEvent(Guid eventId) @@ -160,7 +304,7 @@ public void AddSignedUpEvent(Guid eventId) { return; } - + if (!_signedUpEvents.Add(eventId)) { throw new StudentAlreadySignedUpException(Id, eventId); @@ -169,25 +313,21 @@ public void AddSignedUpEvent(Guid eventId) public void RemoveSignedUpEvent(Guid eventId) { - if(!_signedUpEvents.Remove(eventId)) + if (!_signedUpEvents.Remove(eventId)) { throw new StudentIsNotSignedUpException(Id, eventId); } } - - public void RemoveProfileImage(Guid mediaFileId) - { - if (ProfileImage != mediaFileId) - { - return; - } - ProfileImage = Guid.Empty; + public void RemoveProfileImage() + { + ProfileImageUrl = string.Empty; } public void Ban() => IsBanned = true; public void Unban() => IsBanned = false; public void GrantOrganizerRights() => IsOrganizer = true; public void RevokeOrganizerRights() => IsOrganizer = false; - } + + } } diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/UserNotifications.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/UserNotifications.cs new file mode 100644 index 000000000..26ae175aa --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Entities/UserNotifications.cs @@ -0,0 +1,23 @@ +using System; +using MiniSpace.Services.Students.Core.Events; + +namespace MiniSpace.Services.Students.Core.Entities +{ + public class UserNotifications : AggregateRoot + { + public Guid StudentId { get; private set; } + public NotificationPreferences NotificationPreferences { get; private set; } + + public UserNotifications(Guid studentId, NotificationPreferences notificationPreferences) + { + StudentId = studentId; + NotificationPreferences = notificationPreferences ?? new NotificationPreferences(); + } + + public void UpdatePreferences(NotificationPreferences notificationPreferences) + { + NotificationPreferences = notificationPreferences ?? new NotificationPreferences(); + AddEvent(new StudentNotificationPreferencesUpdated(this)); + } + } +} \ No newline at end of file diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentBannerUpdated.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentBannerUpdated.cs new file mode 100644 index 000000000..db3bf4c88 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentBannerUpdated.cs @@ -0,0 +1,16 @@ +using MiniSpace.Services.Students.Core.Entities; +using System.Diagnostics.CodeAnalysis; + +namespace MiniSpace.Services.Students.Core.Events +{ + [ExcludeFromCodeCoverage] + public class StudentBannerUpdated : IDomainEvent + { + public Student Student { get; } + + public StudentBannerUpdated(Student student) + { + Student = student; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentCompanyUpdated.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentCompanyUpdated.cs new file mode 100644 index 000000000..e7cec07aa --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentCompanyUpdated.cs @@ -0,0 +1,16 @@ +using MiniSpace.Services.Students.Core.Entities; +using System.Diagnostics.CodeAnalysis; + +namespace MiniSpace.Services.Students.Core.Events +{ + [ExcludeFromCodeCoverage] + public class StudentCompanyUpdated : IDomainEvent + { + public Student Student { get; } + + public StudentCompanyUpdated(Student student) + { + Student = student; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentEducationUpdated.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentEducationUpdated.cs new file mode 100644 index 000000000..02d1f610c --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentEducationUpdated.cs @@ -0,0 +1,16 @@ +using MiniSpace.Services.Students.Core.Entities; +using System.Diagnostics.CodeAnalysis; + +namespace MiniSpace.Services.Students.Core.Events +{ + [ExcludeFromCodeCoverage] + public class StudentEducationUpdated : IDomainEvent + { + public Student Student { get; } + + public StudentEducationUpdated(Student student) + { + Student = student; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentGalleryOfImagesUpdated.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentGalleryOfImagesUpdated.cs new file mode 100644 index 000000000..55331eb83 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentGalleryOfImagesUpdated.cs @@ -0,0 +1,16 @@ +using MiniSpace.Services.Students.Core.Entities; +using System.Diagnostics.CodeAnalysis; + +namespace MiniSpace.Services.Students.Core.Events +{ + [ExcludeFromCodeCoverage] + public class StudentGalleryOfImagesUpdated : IDomainEvent + { + public Student Student { get; } + + public StudentGalleryOfImagesUpdated(Student student) + { + Student = student; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentInterestsUpdated.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentInterestsUpdated.cs new file mode 100644 index 000000000..e082c43f9 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentInterestsUpdated.cs @@ -0,0 +1,16 @@ +using MiniSpace.Services.Students.Core.Entities; +using System.Diagnostics.CodeAnalysis; + +namespace MiniSpace.Services.Students.Core.Events +{ + [ExcludeFromCodeCoverage] + public class StudentInterestsUpdated : IDomainEvent + { + public Student Student { get; } + + public StudentInterestsUpdated(Student student) + { + Student = student; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentLanguagesUpdated.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentLanguagesUpdated.cs new file mode 100644 index 000000000..93a1f455c --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentLanguagesUpdated.cs @@ -0,0 +1,16 @@ +using MiniSpace.Services.Students.Core.Entities; +using System.Diagnostics.CodeAnalysis; + +namespace MiniSpace.Services.Students.Core.Events +{ + [ExcludeFromCodeCoverage] + public class StudentLanguagesUpdated : IDomainEvent + { + public Student Student { get; } + + public StudentLanguagesUpdated(Student student) + { + Student = student; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentNotificationPreferencesUpdated.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentNotificationPreferencesUpdated.cs new file mode 100644 index 000000000..d3059bfed --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentNotificationPreferencesUpdated.cs @@ -0,0 +1,17 @@ +using System; +using MiniSpace.Services.Students.Core.Entities; + +namespace MiniSpace.Services.Students.Core.Events +{ + public class StudentNotificationPreferencesUpdated : IDomainEvent + { + public Guid StudentId { get; } + public NotificationPreferences NotificationPreferences { get; } + + public StudentNotificationPreferencesUpdated(UserNotifications userNotifications) + { + StudentId = userNotifications.StudentId; + NotificationPreferences = userNotifications.NotificationPreferences; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentTwoFactorDisabled.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentTwoFactorDisabled.cs new file mode 100644 index 000000000..d6e2e998d --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentTwoFactorDisabled.cs @@ -0,0 +1,16 @@ +using MiniSpace.Services.Students.Core.Entities; +using System.Diagnostics.CodeAnalysis; + +namespace MiniSpace.Services.Students.Core.Events +{ + [ExcludeFromCodeCoverage] + public class StudentTwoFactorDisabled : IDomainEvent + { + public Student Student { get; } + + public StudentTwoFactorDisabled(Student student) + { + Student = student; + } + } +} \ No newline at end of file diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentTwoFactorEnabled.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentTwoFactorEnabled.cs new file mode 100644 index 000000000..628c82b51 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentTwoFactorEnabled.cs @@ -0,0 +1,17 @@ + +using MiniSpace.Services.Students.Core.Entities; +using System.Diagnostics.CodeAnalysis; + +namespace MiniSpace.Services.Students.Core.Events +{ + [ExcludeFromCodeCoverage] + public class StudentTwoFactorEnabled : IDomainEvent + { + public Student Student { get; } + + public StudentTwoFactorEnabled(Student student) + { + Student = student; + } + } +} \ No newline at end of file diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentWorkPositionUpdated.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentWorkPositionUpdated.cs new file mode 100644 index 000000000..646367cb3 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Events/StudentWorkPositionUpdated.cs @@ -0,0 +1,16 @@ +using MiniSpace.Services.Students.Core.Entities; +using System.Diagnostics.CodeAnalysis; + +namespace MiniSpace.Services.Students.Core.Events +{ + [ExcludeFromCodeCoverage] + public class StudentWorkPositionUpdated : IDomainEvent + { + public Student Student { get; } + + public StudentWorkPositionUpdated(Student student) + { + Student = student; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidBannerIdException.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidBannerIdException.cs new file mode 100644 index 000000000..a73f95476 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidBannerIdException.cs @@ -0,0 +1,13 @@ +namespace MiniSpace.Services.Students.Core.Exceptions +{ + public class InvalidBannerIdException : DomainException + { + public override string Code { get; } = "invalid_banner_id"; + public Guid Id { get; } + + public InvalidBannerIdException(Guid id) : base($"Student with id: {id} has an invalid banner ID.") + { + Id = id; + } + } +} \ No newline at end of file diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidCompanyException.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidCompanyException.cs new file mode 100644 index 000000000..0da185a6e --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidCompanyException.cs @@ -0,0 +1,13 @@ +namespace MiniSpace.Services.Students.Core.Exceptions +{ + public class InvalidCompanyException : DomainException + { + public override string Code { get; } = "invalid_company"; + public Guid Id { get; } + + public InvalidCompanyException(Guid id) : base($"Student with id: {id} has an invalid company.") + { + Id = id; + } + } +} \ No newline at end of file diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidEducationException.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidEducationException.cs new file mode 100644 index 000000000..df7c6ef67 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidEducationException.cs @@ -0,0 +1,13 @@ +namespace MiniSpace.Services.Students.Core.Exceptions +{ + public class InvalidEducationException : DomainException + { + public override string Code { get; } = "invalid_education"; + public Guid Id { get; } + + public InvalidEducationException(Guid id) : base($"Student with id: {id} has invalid education information.") + { + Id = id; + } + } +} \ No newline at end of file diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidGalleryOfImagesException.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidGalleryOfImagesException.cs new file mode 100644 index 000000000..e33c33b0a --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidGalleryOfImagesException.cs @@ -0,0 +1,13 @@ +namespace MiniSpace.Services.Students.Core.Exceptions +{ + public class InvalidGalleryOfImagesException : DomainException + { + public override string Code { get; } = "invalid_gallery_of_images"; + public Guid Id { get; } + + public InvalidGalleryOfImagesException(Guid id) : base($"Student with id: {id} has an invalid gallery of images.") + { + Id = id; + } + } +} \ No newline at end of file diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidInterestsException.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidInterestsException.cs new file mode 100644 index 000000000..ee7dcd464 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidInterestsException.cs @@ -0,0 +1,13 @@ +namespace MiniSpace.Services.Students.Core.Exceptions +{ + public class InvalidInterestsException : DomainException + { + public override string Code { get; } = "invalid_interests"; + public Guid Id { get; } + + public InvalidInterestsException(Guid id) : base($"Student with id: {id} has invalid interests.") + { + Id = id; + } + } +} \ No newline at end of file diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidLanguagesException.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidLanguagesException.cs new file mode 100644 index 000000000..c30fbc31f --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidLanguagesException.cs @@ -0,0 +1,13 @@ +namespace MiniSpace.Services.Students.Core.Exceptions +{ + public class InvalidLanguagesException : DomainException + { + public override string Code { get; } = "invalid_languages"; + public Guid Id { get; } + + public InvalidLanguagesException(Guid id) : base($"Student with id: {id} has invalid languages.") + { + Id = id; + } + } +} \ No newline at end of file diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidTwoFactorSecretException.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidTwoFactorSecretException.cs new file mode 100644 index 000000000..b858b276e --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidTwoFactorSecretException.cs @@ -0,0 +1,13 @@ +namespace MiniSpace.Services.Students.Core.Exceptions +{ + public class InvalidTwoFactorSecretException : DomainException + { + public override string Code { get; } = "invalid_two_factor_secret"; + public Guid Id { get; } + + public InvalidTwoFactorSecretException(Guid id) : base($"Student with id: {id} has an invalid two-factor authentication secret.") + { + Id = id; + } + } +} \ No newline at end of file diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidWorkPositionException.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidWorkPositionException.cs new file mode 100644 index 000000000..7acba933f --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/InvalidWorkPositionException.cs @@ -0,0 +1,13 @@ +namespace MiniSpace.Services.Students.Core.Exceptions +{ + public class InvalidWorkPositionException : DomainException + { + public override string Code { get; } = "invalid_work_position"; + public Guid Id { get; } + + public InvalidWorkPositionException(Guid id) : base($"Student with id: {id} has an invalid work position.") + { + Id = id; + } + } +} \ No newline at end of file diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/StudentGalleryImageNotFoundException.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/StudentGalleryImageNotFoundException.cs new file mode 100644 index 000000000..d662bd817 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Exceptions/StudentGalleryImageNotFoundException.cs @@ -0,0 +1,16 @@ +namespace MiniSpace.Services.Students.Core.Exceptions +{ + public class StudentGalleryImageNotFoundException : DomainException + { + public override string Code { get; } = "student_gallery_image_not_found"; + public Guid StudentId { get; } + public string MediaFileId { get; } + + public StudentGalleryImageNotFoundException(Guid studentId, string mediaFileId) + : base($"Student with id: {studentId} does not have an image with media file id: {mediaFileId} in the gallery.") + { + StudentId = studentId; + MediaFileId = mediaFileId; + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Repositories/IUserNotificationPreferencesRepository.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Repositories/IUserNotificationPreferencesRepository.cs new file mode 100644 index 000000000..5783c7604 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Core/Repositories/IUserNotificationPreferencesRepository.cs @@ -0,0 +1,12 @@ +using MiniSpace.Services.Students.Core.Entities; +using System; +using System.Threading.Tasks; + +namespace MiniSpace.Services.Students.Core.Repositories +{ + public interface IUserNotificationPreferencesRepository + { + Task GetNotificationPreferencesAsync(Guid studentId); + Task UpdateNotificationPreferencesAsync(Guid studentId, NotificationPreferences notificationPreferences); + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Extensions.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Extensions.cs index fd5b40cf6..3388aedb0 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Extensions.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Extensions.cs @@ -49,6 +49,7 @@ public static class Extensions public static IConveyBuilder AddInfrastructure(this IConveyBuilder builder) { builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddTransient(); @@ -73,6 +74,7 @@ public static IConveyBuilder AddInfrastructure(this IConveyBuilder builder) .AddJaeger() .AddHandlersLogging() .AddMongoRepository("students") + .AddMongoRepository("user-notifications") .AddWebApiSwaggerDocs() .AddCertificateAuthentication() .AddSecurity(); @@ -93,6 +95,7 @@ public static IApplicationBuilder UseInfrastructure(this IApplicationBuilder app .SubscribeCommand() .SubscribeCommand() .SubscribeEvent() + .SubscribeEvent() .SubscribeEvent() .SubscribeEvent() .SubscribeEvent() @@ -100,7 +103,9 @@ public static IApplicationBuilder UseInfrastructure(this IApplicationBuilder app .SubscribeEvent() .SubscribeEvent() .SubscribeEvent() - .SubscribeEvent(); + .SubscribeEvent() + .SubscribeEvent() + .SubscribeEvent(); return app; } diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Documents/Extensions.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Documents/Extensions.cs index 57eadaeda..743de41c4 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Documents/Extensions.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Documents/Extensions.cs @@ -5,14 +5,36 @@ namespace MiniSpace.Services.Students.Infrastructure.Mongo.Documents { [ExcludeFromCodeCoverage] - public static class Extensions + public static class Extensions { public static Student AsEntity(this StudentDocument document) - => new Student(document.Id, document.Email, document.CreatedAt, document.FirstName, - document.LastName, document.NumberOfFriends, document.ProfileImage, - document.Description, document.DateOfBirth, document.EmailNotifications, - document.IsBanned, document.IsOrganizer, document.State, - document.InterestedInEvents, document.SignedUpEvents); + => new Student( + document.Id, + document.Email, + document.CreatedAt, + document.FirstName, + document.LastName, + document.NumberOfFriends, + document.ProfileImageUrl, + document.Description, + document.DateOfBirth, + document.EmailNotifications, + document.IsBanned, + document.IsOrganizer, + document.State, + document.InterestedInEvents, + document.SignedUpEvents, + document.BannerUrl, + document.GalleryOfImageUrls, + document.Education, + document.WorkPosition, + document.Company, + document.Languages, + document.Interests, + document.IsTwoFactorEnabled, + document.TwoFactorSecret, + document.ContactEmail + ); public static StudentDocument AsDocument(this Student entity) => new StudentDocument() @@ -22,7 +44,7 @@ public static StudentDocument AsDocument(this Student entity) FirstName = entity.FirstName, LastName = entity.LastName, NumberOfFriends = entity.NumberOfFriends, - ProfileImage = entity.ProfileImage, + ProfileImageUrl = entity.ProfileImageUrl, Description = entity.Description, DateOfBirth = entity.DateOfBirth, EmailNotifications = entity.EmailNotifications, @@ -31,7 +53,17 @@ public static StudentDocument AsDocument(this Student entity) State = entity.State, CreatedAt = entity.CreatedAt, InterestedInEvents = entity.InterestedInEvents, - SignedUpEvents = entity.SignedUpEvents + SignedUpEvents = entity.SignedUpEvents, + BannerUrl = entity.BannerUrl, + GalleryOfImageUrls = entity.GalleryOfImageUrls, + Education = entity.Education, + WorkPosition = entity.WorkPosition, + Company = entity.Company, + Languages = entity.Languages, + Interests = entity.Interests, + IsTwoFactorEnabled = entity.IsTwoFactorEnabled, + TwoFactorSecret = entity.TwoFactorSecret, + ContactEmail = entity.ContactEmail }; public static StudentDto AsDto(this StudentDocument document) @@ -42,7 +74,7 @@ public static StudentDto AsDto(this StudentDocument document) FirstName = document.FirstName, LastName = document.LastName, NumberOfFriends = document.NumberOfFriends, - ProfileImage = document.ProfileImage, + ProfileImageUrl = document.ProfileImageUrl, Description = document.Description, DateOfBirth = document.DateOfBirth, EmailNotifications = document.EmailNotifications, @@ -51,7 +83,62 @@ public static StudentDto AsDto(this StudentDocument document) State = document.State.ToString().ToLowerInvariant(), CreatedAt = document.CreatedAt, InterestedInEvents = document.InterestedInEvents, - SignedUpEvents = document.SignedUpEvents + SignedUpEvents = document.SignedUpEvents, + BannerUrl = document.BannerUrl, + GalleryOfImageUrls = document.GalleryOfImageUrls, + Education = document.Education, + WorkPosition = document.WorkPosition, + Company = document.Company, + Languages = document.Languages, + Interests = document.Interests, + IsTwoFactorEnabled = document.IsTwoFactorEnabled, + TwoFactorSecret = document.TwoFactorSecret, + ContactEmail = document.ContactEmail }; - } + + + public static UserNotifications AsEntity(this UserNotificationsDocument document) + => new UserNotifications( + document.StudentId, + document.NotificationPreferences + ); + + public static UserNotificationsDocument AsDocument(this UserNotifications entity) + => new UserNotificationsDocument + { + Id = Guid.NewGuid(), // Ensure a unique identifier is set + StudentId = entity.StudentId, + NotificationPreferences = entity.NotificationPreferences + }; + + public static NotificationPreferencesDto AsDto(this NotificationPreferences notificationPreferences) + => new NotificationPreferencesDto + { + AccountChanges = notificationPreferences.AccountChanges, + SystemLogin = notificationPreferences.SystemLogin, + NewEvent = notificationPreferences.NewEvent, + InterestBasedEvents = notificationPreferences.InterestBasedEvents, + EventNotifications = notificationPreferences.EventNotifications, + CommentsNotifications = notificationPreferences.CommentsNotifications, + PostsNotifications = notificationPreferences.PostsNotifications, + FriendsNotifications = notificationPreferences.FriendsNotifications + }; + + public static UserNotificationsDocument AsDocument(this NotificationPreferencesDto dto) + => new UserNotificationsDocument + { + Id = Guid.NewGuid(), + StudentId = dto.StudentId, + NotificationPreferences = new NotificationPreferences( + dto.AccountChanges, + dto.SystemLogin, + dto.NewEvent, + dto.InterestBasedEvents, + dto.EventNotifications, + dto.CommentsNotifications, + dto.PostsNotifications, + dto.FriendsNotifications + ) + }; + } } diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Documents/StudentDocument.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Documents/StudentDocument.cs index 8d27d93f3..9c19ae2e0 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Documents/StudentDocument.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Documents/StudentDocument.cs @@ -1,5 +1,7 @@ using Convey.Types; using MiniSpace.Services.Students.Core.Entities; +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; namespace MiniSpace.Services.Students.Infrastructure.Mongo.Documents @@ -12,7 +14,7 @@ public class StudentDocument : IIdentifiable public string FirstName { get; set; } public string LastName { get; set; } public int NumberOfFriends { get; set; } - public Guid ProfileImage { get; set; } + public string ProfileImageUrl { get; set; } public string Description { get; set; } public DateTime? DateOfBirth { get; set; } public bool EmailNotifications { get; set; } @@ -22,5 +24,15 @@ public class StudentDocument : IIdentifiable public DateTime CreatedAt { get; set; } public IEnumerable InterestedInEvents { get; set; } public IEnumerable SignedUpEvents { get; set; } - } + public string BannerUrl { get; set; } + public IEnumerable GalleryOfImageUrls { get; set; } + public string Education { get; set; } + public string WorkPosition { get; set; } + public string Company { get; set; } + public IEnumerable Languages { get; set; } + public IEnumerable Interests { get; set; } + public bool IsTwoFactorEnabled { get; set; } + public string TwoFactorSecret { get; set; } + public string ContactEmail { get; set; } + } } diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Documents/UserNotificationsDocument.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Documents/UserNotificationsDocument.cs new file mode 100644 index 000000000..9b7a32080 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Documents/UserNotificationsDocument.cs @@ -0,0 +1,13 @@ +using Convey.Types; +using MiniSpace.Services.Students.Core.Entities; +using System; + +namespace MiniSpace.Services.Students.Infrastructure.Mongo.Documents +{ + public class UserNotificationsDocument : IIdentifiable + { + public Guid Id { get; set; } + public Guid StudentId { get; set; } + public NotificationPreferences NotificationPreferences { get; set; } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Queries/Handlers/GetStudentImagesHandler.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Queries/Handlers/GetStudentImagesHandler.cs new file mode 100644 index 000000000..3b6a563f5 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Queries/Handlers/GetStudentImagesHandler.cs @@ -0,0 +1,42 @@ +using Convey.CQRS.Queries; +using Convey.Persistence.MongoDB; +using MiniSpace.Services.Students.Application.Dto; +using MiniSpace.Services.Students.Application.Exceptions; +using MiniSpace.Services.Students.Application.Queries; +using MiniSpace.Services.Students.Infrastructure.Mongo.Documents; +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; + +namespace MiniSpace.Services.Students.Infrastructure.Mongo.Queries.Handlers +{ + [ExcludeFromCodeCoverage] + public class GetStudentImagesHandler : IQueryHandler + { + private readonly IMongoRepository _studentRepository; + + public GetStudentImagesHandler(IMongoRepository studentRepository) + { + _studentRepository = studentRepository; + } + + public async Task HandleAsync(GetStudentImages query, CancellationToken cancellationToken) + { + var document = await _studentRepository.GetAsync(p => p.Id == query.StudentId); + if (document is null) + { + throw new StudentNotFoundException(query.StudentId); + } + + var studentImages = new StudentImagesDto( + document.Id, + document.BannerUrl, + document.GalleryOfImageUrls + ); + + return studentImages; + } + } +} + diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Queries/Handlers/GetStudentsHandler.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Queries/Handlers/GetStudentsHandler.cs index 2f5703b3e..394b59ec0 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Queries/Handlers/GetStudentsHandler.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Queries/Handlers/GetStudentsHandler.cs @@ -32,7 +32,6 @@ public GetStudentsHandler(IMongoRepository studentReposit var parts = searchTerm.Split(' ', StringSplitOptions.RemoveEmptyEntries); - // Create regex filters var filters = new List>(); if (parts.Length == 1) diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Queries/Handlers/GetUserNotificationPreferencesHandler.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Queries/Handlers/GetUserNotificationPreferencesHandler.cs new file mode 100644 index 000000000..742d4ed38 --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Queries/Handlers/GetUserNotificationPreferencesHandler.cs @@ -0,0 +1,28 @@ +using Convey.CQRS.Queries; +using Convey.Persistence.MongoDB; +using MiniSpace.Services.Students.Application.Dto; +using MiniSpace.Services.Students.Application.Queries; +using MiniSpace.Services.Students.Infrastructure.Mongo.Documents; +using MiniSpace.Services.Students.Infrastructure.Mongo.Repositories; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace MiniSpace.Services.Students.Infrastructure.Mongo.Queries.Handlers +{ + public class GetUserNotificationPreferencesHandler : IQueryHandler + { + private readonly IMongoRepository _repository; + + public GetUserNotificationPreferencesHandler(IMongoRepository repository) + { + _repository = repository; + } + + public async Task HandleAsync(GetUserNotificationPreferences query, CancellationToken cancellationToken) + { + var userNotificationsDocument = await _repository.GetAsync(x => x.StudentId == query.StudentId); + return userNotificationsDocument?.NotificationPreferences.AsDto(); + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Repositories/StudentMongoRepository.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Repositories/StudentMongoRepository.cs index 75ecef9cd..42d1014b3 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Repositories/StudentMongoRepository.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Repositories/StudentMongoRepository.cs @@ -49,13 +49,11 @@ public async Task> FindAsync(FilterDefinition(await result.ToListAsync(cancellationToken).ConfigureAwait(false), page, pageSize, (int)await CountAsync(filter, cancellationToken).ConfigureAwait(false), baseUrl); } private async Task CountAsync(FilterDefinition filter, CancellationToken cancellationToken) { - // Use the CountDocumentsAsync method of IMongoCollection to count documents that match the filter return await _repository.Collection.CountDocumentsAsync(filter).ConfigureAwait(false); } } diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Repositories/UserNotificationPreferencesRepository.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Repositories/UserNotificationPreferencesRepository.cs new file mode 100644 index 000000000..1beb99f2c --- /dev/null +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Mongo/Repositories/UserNotificationPreferencesRepository.cs @@ -0,0 +1,50 @@ +using Convey.Persistence.MongoDB; +using MongoDB.Driver; +using MiniSpace.Services.Students.Core.Entities; +using MiniSpace.Services.Students.Core.Repositories; +using MiniSpace.Services.Students.Infrastructure.Mongo.Documents; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; + +namespace MiniSpace.Services.Students.Infrastructure.Mongo.Repositories +{ + [ExcludeFromCodeCoverage] + public class UserNotificationPreferencesRepository : IUserNotificationPreferencesRepository + { + private readonly IMongoRepository _repository; + + public UserNotificationPreferencesRepository(IMongoRepository repository) + { + _repository = repository; + } + + public async Task GetNotificationPreferencesAsync(Guid studentId) + { + var userNotificationsDocument = await _repository.GetAsync(x => x.StudentId == studentId); + return userNotificationsDocument?.NotificationPreferences; + } + + public async Task UpdateNotificationPreferencesAsync(Guid studentId, NotificationPreferences notificationPreferences) + { + var userNotificationsDocument = await _repository.GetAsync(x => x.StudentId == studentId); + + if (userNotificationsDocument == null) + { + userNotificationsDocument = new UserNotificationsDocument + { + Id = Guid.NewGuid(), + StudentId = studentId, + NotificationPreferences = notificationPreferences + }; + + await _repository.AddAsync(userNotificationsDocument); + } + else + { + userNotificationsDocument.NotificationPreferences = notificationPreferences; + await _repository.UpdateAsync(userNotificationsDocument); + } + } + } +} diff --git a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Services/EventMapper.cs b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Services/EventMapper.cs index be5277ea8..a12c9eb49 100644 --- a/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Services/EventMapper.cs +++ b/MiniSpace.Services.Students/src/MiniSpace.Services.Students.Infrastructure/Services/EventMapper.cs @@ -17,12 +17,26 @@ public IEvent Map(IDomainEvent @event) switch (@event) { case StudentRegistrationCompleted e: - return new Application.Events.StudentCreated(e.Student.Id, e.Student.FullName, e.Student.ProfileImage); + return new Application.Events.StudentCreated(e.Student.Id, e.Student.FullName, e.Student.ProfileImageUrl); case StudentUpdated e: - return new Application.Events.StudentUpdated(e.Student.Id, e.Student.FullName, e.Student.ProfileImage); + return new Application.Events.StudentUpdated( + e.Student.Id, + e.Student.FullName, + e.Student.ProfileImageUrl, + e.Student.BannerUrl, + e.Student.GalleryOfImageUrls, + e.Student.Education, + e.Student.WorkPosition, + e.Student.Company, + e.Student.Languages, + e.Student.Interests, + e.Student.ContactEmail); case StudentStateChanged e: - return new Application.Events.StudentStateChanged(e.Student.Id, e.Student.FullName, - e.Student.State.ToString().ToLowerInvariant(), e.PreviousState.ToString().ToLowerInvariant()); + return new Application.Events.StudentStateChanged( + e.Student.Id, + e.Student.FullName, + e.Student.State.ToString().ToLowerInvariant(), + e.PreviousState.ToString().ToLowerInvariant()); } return null; diff --git a/MiniSpace.Web/src/MiniSpace.Web/App.razor b/MiniSpace.Web/src/MiniSpace.Web/App.razor index 684b30e53..e9ea53364 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/App.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/App.razor @@ -33,7 +33,9 @@ @using Microsoft.AspNetCore.Components.Authorization +@using MudBlazor.Services + diff --git a/MiniSpace.Web/src/MiniSpace.Web/Areas/Friends/FriendsService.cs b/MiniSpace.Web/src/MiniSpace.Web/Areas/Friends/FriendsService.cs index 8536b4b3b..3729fe1cf 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Areas/Friends/FriendsService.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/Areas/Friends/FriendsService.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using MiniSpace.Services.Friends.Application.Dto; using MiniSpace.Web.Areas.Identity; using MiniSpace.Web.DTO; using MiniSpace.Web.HttpClients; diff --git a/MiniSpace.Web/src/MiniSpace.Web/Areas/Friends/IFriendsService.cs b/MiniSpace.Web/src/MiniSpace.Web/Areas/Friends/IFriendsService.cs index c2063565c..f289bc17b 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Areas/Friends/IFriendsService.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/Areas/Friends/IFriendsService.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using MiniSpace.Services.Friends.Application.Dto; using MiniSpace.Web.DTO; using MiniSpace.Web.HttpClients; diff --git a/MiniSpace.Web/src/MiniSpace.Web/Areas/Identity/GenerateTwoFactorSecretResponse.cs b/MiniSpace.Web/src/MiniSpace.Web/Areas/Identity/GenerateTwoFactorSecretResponse.cs new file mode 100644 index 000000000..fde27d589 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/Areas/Identity/GenerateTwoFactorSecretResponse.cs @@ -0,0 +1,5 @@ + +public class GenerateTwoFactorSecretResponse +{ + public string Secret { get; set; } +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/Areas/Identity/IIdentityService.cs b/MiniSpace.Web/src/MiniSpace.Web/Areas/Identity/IIdentityService.cs index d777a280c..9d99e8c05 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Areas/Identity/IIdentityService.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/Areas/Identity/IIdentityService.cs @@ -29,5 +29,10 @@ public interface IIdentityService Task ForgotPasswordAsync(string email); Task> ResetPasswordAsync(string token, string email, string newPassword); + Task> VerifyEmailAsync(string token, string email, string hashedToken); + Task GenerateTwoFactorSecretAsync(Guid userId); + + Task EnableTwoFactorAsync(Guid userId, string secret); + Task DisableTwoFactorAsync(Guid userId); } } \ No newline at end of file diff --git a/MiniSpace.Web/src/MiniSpace.Web/Areas/Identity/IdentityService.cs b/MiniSpace.Web/src/MiniSpace.Web/Areas/Identity/IdentityService.cs index 0b1e8524e..e22ec9f0c 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Areas/Identity/IdentityService.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/Areas/Identity/IdentityService.cs @@ -20,8 +20,8 @@ class IdentityService : IIdentityService public JwtDto JwtDto { get; set; } public UserDto UserDto { get; set; } - public string Name {get; private set; } - public string Email {get; private set; } + public string Name { get; private set; } + public string Email { get; private set; } public bool IsAuthenticated { get; set; } public IdentityService(IHttpClient httpClient, ILocalStorageService localStorage, NavigationManager navigationManager) @@ -29,7 +29,7 @@ public IdentityService(IHttpClient httpClient, ILocalStorageService localStorage _httpClient = httpClient; _jwtHandler = new JwtSecurityTokenHandler(); _localStorage = localStorage; - _navigationManager = navigationManager; + _navigationManager = navigationManager; } public Task GetAccountAsync(JwtDto jwtDto) @@ -42,29 +42,10 @@ public async Task> SignUpAsync(string firstName, string las IEnumerable permissions = null) { return await _httpClient.PostAsync("identity/sign-up", - new {firstName, lastName, email, password, role, permissions}); - + new { firstName, lastName, email, password, role, permissions }); } - // public async Task> SignInAsync(string email, string password) - // { - // var response = await _httpClient.PostAsync("identity/sign-in", new {email, password}); - // JwtDto = response.Content; - - // if (JwtDto != null) - // { - // var jwtToken = _jwtHandler.ReadJwtToken(JwtDto.AccessToken); - // var payload = jwtToken.Payload; - // UserDto = await GetAccountAsync(JwtDto); - // Name = (string)payload["name"]; - // Email = (string)payload["e-mail"]; - // IsAuthenticated = true; - // } - - // return response; - // } - - public async Task> SignInAsync(string email, string password) + public async Task> SignInAsync(string email, string password) { var response = await _httpClient.PostAsync("identity/sign-in", new { email, password }); if (response.Content != null) @@ -85,6 +66,10 @@ public async Task> SignInAsync(string email, string passwor public async Task Logout() { + if (JwtDto != null && !string.IsNullOrEmpty(JwtDto.RefreshToken)) + { + await RevokeRefreshToken(JwtDto.RefreshToken); + } await _localStorage.RemoveItemAsync("jwtDto"); JwtDto = null; UserDto = null; @@ -97,7 +82,7 @@ public async Task Logout() private async Task RefreshAccessToken(string refreshToken) { var payload = new { refreshToken }; - var response = await _httpClient.PostAsync("identity/refresh-token", payload); + var response = await _httpClient.PostAsync("identity/refresh-tokens/use", payload); if (response.ErrorMessage != null) { throw new InvalidOperationException($"Error refreshing token: {response.ErrorMessage.Reason}"); @@ -111,15 +96,16 @@ private async Task RefreshAccessToken(string refreshToken) throw new InvalidOperationException("Failed to refresh token"); } - // Make the Logout asynchronous 😕 - // public void Logout() - // { - // JwtDto = null; - // UserDto = null; - // Name = null; - // Email = null; - // IsAuthenticated = false; - // } + private async Task RevokeRefreshToken(string refreshToken) + { + var payload = new { refreshToken }; + var response = await _httpClient.PostAsync("identity/refresh-tokens/revoke", payload); + if (response.ErrorMessage != null) + { + throw new InvalidOperationException($"Error revoking refresh token: {response.ErrorMessage.Reason}"); + } + } + public async Task GetAccessTokenAsync() { var jwtDtoJson = await _localStorage.GetItemAsStringAsync("jwtDto"); @@ -154,7 +140,7 @@ public async Task GetAccessTokenAsync() catch (Exception ex) { await _localStorage.RemoveItemAsync("jwtDto"); - + _navigationManager.NavigateTo("signin", forceLoad: true); throw new InvalidOperationException("Failed to refresh token: " + ex.Message); @@ -225,7 +211,7 @@ private async Task TryRefreshToken(string refreshToken) { try { - var response = await _httpClient.PostAsync("identity/refresh-token", new { refreshToken }); + var response = await _httpClient.PostAsync("identity/refresh-tokens/use", new { refreshToken }); if (response.Content != null) { var newJwtDtoJson = JsonSerializer.Serialize(response.Content); @@ -273,7 +259,7 @@ public string GetCurrentUserRole() public Task GrantOrganizerRightsAsync(Guid userId) { _httpClient.SetAccessToken(JwtDto.AccessToken); - return _httpClient.PostAsync($"identity/users/{userId}/organizer-rights", new {userId}); + return _httpClient.PostAsync($"identity/users/{userId}/organizer-rights", new { userId }); } public Task RevokeOrganizerRightsAsync(Guid userId) @@ -285,7 +271,7 @@ public Task RevokeOrganizerRightsAsync(Guid userId) public Task BanUserAsync(Guid userId) { _httpClient.SetAccessToken(JwtDto.AccessToken); - return _httpClient.PostAsync($"identity/users/{userId}/ban", new {userId}); + return _httpClient.PostAsync($"identity/users/{userId}/ban", new { userId }); } public Task UnbanUserAsync(Guid userId) @@ -326,13 +312,39 @@ public async Task> ResetPasswordAsync(string token, string private Guid DecodeToken(string token) { - // Implement token decoding to extract the UserID - // This is pseudo-code. You need to implement according to your JWT structure and validation method var handler = new JwtSecurityTokenHandler(); var jwtToken = handler.ReadJwtToken(token); - var userIdClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "sub"); // Assuming 'sub' holds UserId + var userIdClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "sub"); return Guid.Parse(userIdClaim.Value); } + public async Task> VerifyEmailAsync(string token, string email, string hashedToken) + { + var response = await _httpClient.PostAsync("identity/email/verify", new { Token = token, Email = email, HashedToken = hashedToken }); + return response; + } + + public async Task GenerateTwoFactorSecretAsync(Guid userId) + { + _httpClient.SetAccessToken(JwtDto.AccessToken); + var response = await _httpClient.PostAsync("identity/2fa/generate-secret", new { UserId = userId }); + if (response.Content != null) + { + return response.Content.Secret; + } + throw new InvalidOperationException("Failed to generate two-factor secret."); + } + + public async Task EnableTwoFactorAsync(Guid userId, string secret) + { + await _httpClient.PostAsync("identity/2fa/enable", new { UserId = userId, Secret = secret }); + } + + public async Task DisableTwoFactorAsync(Guid userId) + { + await _httpClient.PostAsync("identity/2fa/disable", new { UserId = userId }); + } + + } -} \ No newline at end of file +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/Areas/MediaFiles/IMediaFilesService.cs b/MiniSpace.Web/src/MiniSpace.Web/Areas/MediaFiles/IMediaFilesService.cs index 10de21231..07e8483cf 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Areas/MediaFiles/IMediaFilesService.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/Areas/MediaFiles/IMediaFilesService.cs @@ -11,6 +11,6 @@ public interface IMediaFilesService public Task GetOriginalFileAsync(Guid fileId); public Task> UploadMediaFileAsync(Guid sourceId, string sourceType, Guid uploaderId, string fileName, string fileContentType, string base64Content); - public Task DeleteMediaFileAsync(Guid fileId); + public Task DeleteMediaFileAsync(string fileUrl); } } \ No newline at end of file diff --git a/MiniSpace.Web/src/MiniSpace.Web/Areas/MediaFiles/MediaFilesService.cs b/MiniSpace.Web/src/MiniSpace.Web/Areas/MediaFiles/MediaFilesService.cs index c34371437..f372748b6 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Areas/MediaFiles/MediaFilesService.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/Areas/MediaFiles/MediaFilesService.cs @@ -28,19 +28,32 @@ public Task GetOriginalFileAsync(Guid fileId) return _httpClient.GetAsync($"media-files/{fileId}/original"); } - public Task> UploadMediaFileAsync(Guid sourceId, string sourceType, Guid uploaderId, string fileName, - string fileContentType, string base64Content) + public Task> UploadMediaFileAsync( + Guid sourceId, + string sourceType, + Guid uploaderId, + string fileName, + string fileContentType, + string base64Content) { _httpClient.SetAccessToken(_identityService.JwtDto.AccessToken); - return _httpClient.PostAsync("media-files", new {sourceId, sourceType, uploaderId, - fileName, fileContentType, base64Content }); + return _httpClient.PostAsync("media-files", new { + MediaFileId = Guid.NewGuid(), + SourceId = sourceId, + SourceType = sourceType, + UploaderId = uploaderId, + FileName = fileName, + FileContentType = fileContentType, + Base64Content = base64Content + }); } - public Task DeleteMediaFileAsync(Guid fileId) + public Task DeleteMediaFileAsync(string fileUrl) { _httpClient.SetAccessToken(_identityService.JwtDto.AccessToken); - return _httpClient.DeleteAsync($"media-files/{fileId}"); + return _httpClient.DeleteAsync($"media-files/delete/{Uri.EscapeDataString(fileUrl)}", new { MediaFileUrl = fileUrl }); } + } } \ No newline at end of file diff --git a/MiniSpace.Web/src/MiniSpace.Web/Areas/Notifications/SignalRService.cs b/MiniSpace.Web/src/MiniSpace.Web/Areas/Notifications/SignalRService.cs new file mode 100644 index 000000000..6fa947ee8 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/Areas/Notifications/SignalRService.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR.Client; +using MiniSpace.Web.Areas.Identity; +using MiniSpace.Web.DTO.Notifications; +using System; +using System.Threading.Tasks; + +namespace MiniSpace.Web.Areas.Notifications +{ + public class SignalRService : IAsyncDisposable + { + private HubConnection _hubConnection; + private readonly NavigationManager _navigationManager; + private readonly IIdentityService _identityService; + private Guid _userId; + + public event Action NotificationReceived; + + public SignalRService(NavigationManager navigationManager, IIdentityService identityService) + { + _navigationManager = navigationManager; + _identityService = identityService; + } + + public async Task StartAsync(Guid userId) + { + _userId = userId; + var hubUrl = $"http://localhost:5006/notificationHub?userId={userId}"; + + Console.WriteLine($"Initializing SignalR connection to URL: {hubUrl}"); + + _hubConnection = new HubConnectionBuilder() + .WithUrl(hubUrl, options => + { + options.AccessTokenProvider = async () => + { + var token = await _identityService.GetAccessTokenAsync(); + Console.WriteLine($"Using Access Token: {token}"); + return token; + }; + }) + .WithAutomaticReconnect() + .Build(); + + _hubConnection.On("ReceiveNotification", (jsonMessage) => + { + var notification = System.Text.Json.JsonSerializer.Deserialize(jsonMessage); + NotificationReceived?.Invoke(notification); + }); + + _hubConnection.Closed += async (error) => + { + Console.WriteLine($"Connection closed due to an error: {error?.Message}"); + await Task.Delay(new Random().Next(0, 5) * 1000); + await _hubConnection.StartAsync(); + }; + + try + { + await _hubConnection.StartAsync(); + Console.WriteLine("SignalR connection started successfully."); + } + catch (Exception ex) + { + Console.WriteLine($"Error starting SignalR connection: {ex.Message}"); + } + } + + public async Task StopAsync() + { + try + { + Console.WriteLine("Stopping SignalR connection..."); + await _hubConnection.StopAsync(); + Console.WriteLine("SignalR connection stopped successfully."); + } + catch (Exception ex) + { + Console.WriteLine($"Error stopping SignalR connection: {ex.Message}"); + } + } + + public async ValueTask DisposeAsync() + { + if (_hubConnection != null) + { + await _hubConnection.DisposeAsync(); + } + } + } +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/Areas/Students/IStudentsService.cs b/MiniSpace.Web/src/MiniSpace.Web/Areas/Students/IStudentsService.cs index 0514d4e03..abcee86e8 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Areas/Students/IStudentsService.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/Areas/Students/IStudentsService.cs @@ -13,9 +13,27 @@ public interface IStudentsService void ClearStudentDto(); Task GetStudentAsync(Guid studentId); Task> GetStudentsAsync(); - Task UpdateStudentAsync(Guid studentId, Guid profileImage, string description, bool emailNotifications); - Task> CompleteStudentRegistrationAsync(Guid studentId, Guid profileImage, - string description, DateTime dateOfBirth, bool emailNotifications); + Task UpdateStudentAsync( + Guid studentId, + string firstName, + string lastName, + string profileImageUrl, + string description, + bool emailNotifications, + string contactEmail, + IEnumerable languages, + IEnumerable interests, + bool enableTwoFactor, + bool disableTwoFactor, + string twoFactorSecret, + string education, + string workPosition, + string company); + public Task> CompleteStudentRegistrationAsync(Guid studentId, string profileImageUrl, + string description, DateTime dateOfBirth, bool emailNotifications, string contactEmail); Task GetStudentStateAsync(Guid studentId); + + Task GetUserNotificationPreferencesAsync(Guid studentId); + Task UpdateUserNotificationPreferencesAsync(Guid studentId, NotificationPreferencesDto preferencesDto); } } diff --git a/MiniSpace.Web/src/MiniSpace.Web/Areas/Students/StudentsService.cs b/MiniSpace.Web/src/MiniSpace.Web/Areas/Students/StudentsService.cs index d0757b6e8..be56f2196 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Areas/Students/StudentsService.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/Areas/Students/StudentsService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text.Json; using System.Threading.Tasks; using MiniSpace.Web.Areas.Identity; using MiniSpace.Web.DTO; @@ -45,22 +46,91 @@ public Task> GetStudentsAsync() return _httpClient.GetAsync>("students"); } - public Task UpdateStudentAsync(Guid studentId, Guid profileImage, string description, bool emailNotifications) + public async Task UpdateStudentAsync( + Guid studentId, + string firstName, + string lastName, + string profileImageUrl, + string description, + bool emailNotifications, + string contactEmail, + IEnumerable languages, + IEnumerable interests, + bool enableTwoFactor, + bool disableTwoFactor, + string twoFactorSecret, + string education, + string workPosition, + string company) { - _httpClient.SetAccessToken(_identityService.JwtDto.AccessToken); - return _httpClient.PutAsync($"students/{studentId}", new {studentId, profileImage, - description, emailNotifications}); + var accessToken = await _identityService.GetAccessTokenAsync(); + _httpClient.SetAccessToken(accessToken); + + var updateStudentData = new + { + studentId, + firstName, + lastName, + profileImageUrl, + description, + emailNotifications, + contactEmail, + languages, + interests, + enableTwoFactor, + disableTwoFactor, + twoFactorSecret, + education, + workPosition, + company + }; + + var jsonData = JsonSerializer.Serialize(updateStudentData); + Console.WriteLine($"Sending UpdateStudent request: {jsonData}"); + + await _httpClient.PutAsync($"students/{studentId}", updateStudentData); } - public Task> CompleteStudentRegistrationAsync(Guid studentId, Guid profileImage, - string description, DateTime dateOfBirth, bool emailNotifications) - => _httpClient.PostAsync("students", new {studentId, profileImage, - description, dateOfBirth, emailNotifications}); + public Task> CompleteStudentRegistrationAsync(Guid studentId, string profileImageUrl, string description, DateTime dateOfBirth, bool emailNotifications, string contactEmail) + => _httpClient.PostAsync("students", new { studentId, profileImageUrl, description, dateOfBirth, emailNotifications, contactEmail }); public async Task GetStudentStateAsync(Guid studentId) { var student = await GetStudentAsync(studentId); - return student != null ? student.State : "invalid"; + return student != null ? student.State : "invalid"; + } + + // New methods for notification preferences + public async Task GetUserNotificationPreferencesAsync(Guid studentId) + { + var accessToken = await _identityService.GetAccessTokenAsync(); + _httpClient.SetAccessToken(accessToken); + return await _httpClient.GetAsync($"students/{studentId}/notifications"); + } + + public async Task UpdateUserNotificationPreferencesAsync(Guid studentId, NotificationPreferencesDto preferencesDto) + { + var accessToken = await _identityService.GetAccessTokenAsync(); + _httpClient.SetAccessToken(accessToken); + + var updatePreferencesData = new + { + studentId, + preferencesDto.AccountChanges, + preferencesDto.SystemLogin, + preferencesDto.NewEvent, + preferencesDto.InterestBasedEvents, + preferencesDto.EventNotifications, + preferencesDto.CommentsNotifications, + preferencesDto.PostsNotifications, + preferencesDto.FriendsNotifications + }; + + // Serialize the data to JSON and log it + var jsonData = JsonSerializer.Serialize(updatePreferencesData); + Console.WriteLine($"Sending UpdateUserNotificationPreferences request: {jsonData}"); + + await _httpClient.PostAsync($"students/{studentId}/notifications", updatePreferencesData); } - } + } } diff --git a/MiniSpace.Web/src/MiniSpace.Web/DTO/ChangeProductImageModel.cs b/MiniSpace.Web/src/MiniSpace.Web/DTO/ChangeProductImageModel.cs new file mode 100644 index 000000000..2041a6414 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/DTO/ChangeProductImageModel.cs @@ -0,0 +1,11 @@ + +using System.Collections.Generic; +using Microsoft.AspNetCore.Components.Forms; + +namespace MiniSpace.Web.DTO +{ + public class ChangeProductImageModel + { + public IReadOnlyList? Files { get; set; } + } +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/DTO/FileUploadResponseDto.cs b/MiniSpace.Web/src/MiniSpace.Web/DTO/FileUploadResponseDto.cs index cfb4804c5..249549a11 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/DTO/FileUploadResponseDto.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/DTO/FileUploadResponseDto.cs @@ -5,5 +5,6 @@ namespace MiniSpace.Web.DTO public class FileUploadResponseDto { public Guid FileId { get; set; } + public string FileUrl { get; set;} } } \ No newline at end of file diff --git a/MiniSpace.Web/src/MiniSpace.Web/DTO/FriendRequestDto.cs b/MiniSpace.Web/src/MiniSpace.Web/DTO/FriendRequestDto.cs index 6dd7ce913..2ed375678 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/DTO/FriendRequestDto.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/DTO/FriendRequestDto.cs @@ -1,7 +1,7 @@ using System; using MiniSpace.Web.DTO.States; -namespace MiniSpace.Services.Friends.Application.Dto +namespace MiniSpace.Web.DTO { public class FriendRequestDto { diff --git a/MiniSpace.Web/src/MiniSpace.Web/DTO/NotificationPreferencesDto.cs b/MiniSpace.Web/src/MiniSpace.Web/DTO/NotificationPreferencesDto.cs new file mode 100644 index 000000000..c9e04687e --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/DTO/NotificationPreferencesDto.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; + +namespace MiniSpace.Web.DTO +{ + public class NotificationPreferencesDto + { + public Guid StudentId { get; set; } + public bool AccountChanges { get; set; } + public bool SystemLogin { get; set; } + public bool NewEvent { get; set; } + public bool InterestBasedEvents { get; set; } + public bool EventNotifications { get; set; } + public bool CommentsNotifications { get; set; } + public bool PostsNotifications { get; set; } + public bool FriendsNotifications { get; set; } + } +} \ No newline at end of file diff --git a/MiniSpace.Web/src/MiniSpace.Web/DTO/StudentDto.cs b/MiniSpace.Web/src/MiniSpace.Web/DTO/StudentDto.cs index f05da8759..a33a0b2c7 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/DTO/StudentDto.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/DTO/StudentDto.cs @@ -11,7 +11,7 @@ public class StudentDto public string LastName { get; set; } public string FullName => $"{FirstName} {LastName}"; public int NumberOfFriends { get; set; } - public Guid ProfileImage { get; set; } + public string ProfileImageUrl { get; set; } public string Description { get; set; } public DateTime DateOfBirth { get; set; } public bool EmailNotifications { get; set; } @@ -19,8 +19,20 @@ public class StudentDto public bool IsOrganizer { get; set; } public string State { get; set; } public DateTime CreatedAt { get; set; } + public string Education { get; set; } + public string WorkPosition { get; set; } + public string Company { get; set; } + public IEnumerable Languages { get; set; } + public IEnumerable Interests { get; set; } + public bool IsTwoFactorEnabled { get; set; } + public string TwoFactorSecret { get; set; } public IEnumerable InterestedInEvents { get; set; } public IEnumerable SignedUpEvents { get; set; } + public string BannerUrl { get; set; } + public IEnumerable GalleryOfImageUrls { get; set; } + public string ContactEmail { get; set; } + + public bool IsInvitationPending { get; set; } public bool InvitationSent { get; set; } public bool Selected { get; set; } diff --git a/MiniSpace.Web/src/MiniSpace.Web/DTO/StudentRequestsDto.cs b/MiniSpace.Web/src/MiniSpace.Web/DTO/StudentRequestsDto.cs index 501844fb4..542198ded 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/DTO/StudentRequestsDto.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/DTO/StudentRequestsDto.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using MiniSpace.Services.Friends.Application.Dto; namespace MiniSpace.Web.DTO { diff --git a/MiniSpace.Web/src/MiniSpace.Web/DTO/Types/MediaFileContextType.cs b/MiniSpace.Web/src/MiniSpace.Web/DTO/Types/MediaFileContextType.cs index 9cbe6b2ea..234998c21 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/DTO/Types/MediaFileContextType.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/DTO/Types/MediaFileContextType.cs @@ -4,6 +4,8 @@ public enum MediaFileContextType { Event, Post, - StudentProfile, + StudentProfileImage, + StudentBannerImage, + StudentGalleryImage } } \ No newline at end of file diff --git a/MiniSpace.Web/src/MiniSpace.Web/DTO/UserDto.cs b/MiniSpace.Web/src/MiniSpace.Web/DTO/UserDto.cs index 6d8c5248f..43eb96093 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/DTO/UserDto.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/DTO/UserDto.cs @@ -8,6 +8,9 @@ public class UserDto public string Name { get; set; } public string Email { get; set; } public string Role { get; set; } - public DateTime CreatedAt { get; set; } + public bool IsEmailVerified { get; set; } + public DateTime? EmailVerifiedAt { get; set; } + public bool IsTwoFactorEnabled { get; set; } + public string TwoFactorSecret { get; set; } } } \ No newline at end of file diff --git a/MiniSpace.Web/src/MiniSpace.Web/MiniSpace.Web.csproj b/MiniSpace.Web/src/MiniSpace.Web/MiniSpace.Web.csproj index de79ee777..b0776a3ce 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/MiniSpace.Web.csproj +++ b/MiniSpace.Web/src/MiniSpace.Web/MiniSpace.Web.csproj @@ -7,9 +7,10 @@ - - + + + diff --git a/MiniSpace.Web/src/MiniSpace.Web/Models/Identity/SignUpModel.cs b/MiniSpace.Web/src/MiniSpace.Web/Models/Identity/SignUpModel.cs index c8f097953..c1e9baa8c 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Models/Identity/SignUpModel.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/Models/Identity/SignUpModel.cs @@ -11,6 +11,7 @@ public class SignUpModel public string LastName { get; set; } public string Email { get; set; } public string Password { get; set; } + public string ConfirmPassword { get; set; } public string Role { get; set; } = "user"; } -} \ No newline at end of file +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/Models/Students/CompleteRegistrationModel.cs b/MiniSpace.Web/src/MiniSpace.Web/Models/Students/CompleteRegistrationModel.cs index b7b904a1e..7c56f08ef 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Models/Students/CompleteRegistrationModel.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/Models/Students/CompleteRegistrationModel.cs @@ -5,7 +5,7 @@ namespace MiniSpace.Web.Models.Students public class CompleteRegistrationModel { public Guid StudentId { get; set; } - public Guid ProfileImage { get; set; } + public string ProfileImageUrl { get; set; } public string Description { get; set; } public DateTime DateOfBirth { get; set; } public bool EmailNotifications { get; set; } diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/CompleteRegistration.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/CompleteRegistration.razor index ab42d7da3..d551b3000 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/CompleteRegistration.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/CompleteRegistration.razor @@ -70,16 +70,22 @@ if (IdentityService.IsAuthenticated) { completeRegistrationModel.StudentId = IdentityService.UserDto.Id; - completeRegistrationModel.ProfileImage = Guid.Empty; + completeRegistrationModel.ProfileImageUrl = String.Empty; completeRegistrationModel.DateOfBirth = DateTime.Now; } } private async Task HandleCompleteRegistration() { - var response = await StudentsService.CompleteStudentRegistrationAsync(completeRegistrationModel.StudentId, - completeRegistrationModel.ProfileImage, completeRegistrationModel.Description, - completeRegistrationModel.DateOfBirth.ToUniversalTime(), completeRegistrationModel.EmailNotifications); + var response = await StudentsService.CompleteStudentRegistrationAsync( + completeRegistrationModel.StudentId, + completeRegistrationModel.ProfileImageUrl, + completeRegistrationModel.Description, + completeRegistrationModel.DateOfBirth.ToUniversalTime(), + completeRegistrationModel.EmailNotifications, + null // Add default value for contactEmail + ); + // Handle the post-sign-up logic, such as redirection or displaying a success message if (response.ErrorMessage != null) { @@ -123,13 +129,13 @@ var stream = file.OpenReadStream(maxFileSize); byte[] bytes = await ReadFully(stream); var base64Content = Convert.ToBase64String(bytes); - var response = await MediaFilesService.UploadMediaFileAsync(IdentityService.UserDto.Id, - MediaFileContextType.StudentProfile.ToString(), IdentityService.UserDto.Id, + @* var response = await MediaFilesService.UploadMediaFileAsync(IdentityService.UserDto.Id, + MediaFileContextType.StudentProfileImage.ToString(), IdentityService.UserDto.Id, file.Name, file.ContentType, base64Content); if (response.Content != null && response.Content.FileId != Guid.Empty) { completeRegistrationModel.ProfileImage = response.Content.FileId; - } + } *@ stream.Close(); } catch (Exception ex) @@ -152,4 +158,4 @@ return ms.ToArray(); } } -} \ No newline at end of file +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/Components/EditGallery.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/Components/EditGallery.razor new file mode 100644 index 000000000..d61ce2323 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/Components/EditGallery.razor @@ -0,0 +1,197 @@ +@page "/edit-gallery" +@using MiniSpace.Web.DTO.Types +@using MiniSpace.Web.DTO +@using MiniSpace.Web.Areas.MediaFiles +@using MiniSpace.Web.Areas.Students +@inject IMediaFilesService MediaFilesService +@inject IIdentityService IdentityService +@inject IStudentsService StudentsService +@inject NavigationManager NavigationManager +@inject IDialogService DialogService +@using Microsoft.AspNetCore.Components.Forms +@using MudBlazor +@using System.IO +@inject IJSRuntime JS +@using Microsoft.JSInterop + + + + + + + +
+ Edit Gallery + @if (isLoading) + { + + } + else + { + + + Gallery Images + @if (studentDto.GalleryOfImageUrls == null || !studentDto.GalleryOfImageUrls.Any(IsValidImageUrl)) + { + No gallery images available + } + else + { + + @foreach (var imageUrl in studentDto.GalleryOfImageUrls.Where(IsValidImageUrl)) + { + + + + + + + + + } + + } +
+
+
+
+ } +
+
+ +@code { + private List _items = new List + { + new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), + new BreadcrumbItem("Account settings", href: "/account", icon: @Icons.Material.Filled.ManageAccounts), + new BreadcrumbItem("Gallery", href: "/edit-gallery", disabled: true, icon: @Icons.Material.Filled.ManageAccounts), + }; + + private bool isLoading = true; + private bool isUploading = false; + private StudentDto studentDto = new(); + private List galleryFiles = new List(); + private long maxFileSize = 32 * 1024 * 1024; + private Dictionary selectedImages = new(); + private readonly List validImageExtensions = new List { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" }; + + protected override async Task OnInitializedAsync() + { + isLoading = true; + try + { + await IdentityService.InitializeAuthenticationState(); + if (IdentityService.IsAuthenticated) + { + var studentId = IdentityService.GetCurrentUserId(); + studentDto = await StudentsService.GetStudentAsync(studentId); + selectedImages = studentDto.GalleryOfImageUrls.ToDictionary(url => url, url => false); + } + else + { + NavigationManager.NavigateTo("/login"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error in OnInitializedAsync: {ex.Message}"); + } + finally + { + isLoading = false; + StateHasChanged(); + } + } + + private void OnGalleryFilesChanged(InputFileChangeEventArgs args) + { + galleryFiles = args.GetMultipleFiles().ToList(); + StateHasChanged(); + } + + private async Task UploadGalleryImagesAsync() + { + if (galleryFiles != null && galleryFiles.Count > 0) + { + isUploading = true; + StateHasChanged(); + try + { + foreach (var file in galleryFiles) + { + using var stream = file.OpenReadStream(maxFileSize); + byte[] bytes = await ReadFully(stream); + var base64Content = Convert.ToBase64String(bytes); + var response = await MediaFilesService.UploadMediaFileAsync(IdentityService.GetCurrentUserId(), + MediaFileContextType.StudentGalleryImage.ToString(), IdentityService.GetCurrentUserId(), + file.Name, file.ContentType, base64Content); + if (response.Content != null && !string.IsNullOrEmpty(response.Content.FileUrl)) + { + studentDto.GalleryOfImageUrls = studentDto.GalleryOfImageUrls.Append(response.Content.FileUrl).ToList(); + selectedImages.Add(response.Content.FileUrl, false); + } + } + } + finally + { + galleryFiles.Clear(); + isUploading = false; + NavigationManager.NavigateTo(NavigationManager.Uri, forceLoad: true); + } + } + } + + private async Task DeleteSelectedImages() + { + var imagesToDelete = selectedImages.Where(kv => kv.Value).Select(kv => kv.Key).ToList(); + foreach (var imageUrl in imagesToDelete) + { + await MediaFilesService.DeleteMediaFileAsync(imageUrl); + studentDto.GalleryOfImageUrls = studentDto.GalleryOfImageUrls.Where(url => url != imageUrl).ToList(); + selectedImages.Remove(imageUrl); + } + NavigationManager.NavigateTo(NavigationManager.Uri, forceLoad: true); + } + + private static async Task ReadFully(Stream input) + { + byte[] buffer = new byte[16 * 1024]; + using (MemoryStream ms = new MemoryStream()) + { + int read; + while ((read = await input.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + ms.Write(buffer, 0, read); + } + return ms.ToArray(); + } + } + + private async Task TriggerFileInputClick() + { + var fileInput = await JS.InvokeAsync("document.getElementById", "fileInput"); + await fileInput.InvokeVoidAsync("click"); + } + + private bool IsValidImageUrl(string url) + { + return validImageExtensions.Contains(Path.GetExtension(url)?.ToLower()); + } +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/Components/GalleryComponent.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/Components/GalleryComponent.razor new file mode 100644 index 000000000..837ce9035 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/Components/GalleryComponent.razor @@ -0,0 +1,217 @@ +@page "/student-profile" +@using MiniSpace.Web.DTO.Types +@using MiniSpace.Web.DTO +@using MiniSpace.Web.Areas.MediaFiles +@using MiniSpace.Web.Areas.Students +@using System.IO +@using Microsoft.AspNetCore.Components.Forms +@inject IMediaFilesService MediaFilesService +@inject IIdentityService IdentityService +@inject IStudentsService StudentsService +@inject NavigationManager NavigationManager +@inject IDialogService DialogService +@using MudBlazor +@inject IJSRuntime JS +@using Microsoft.JSInterop + + +
+ Gallery + @if (isLoading) + { + + } + else + { + + + Current Banner + @if (!string.IsNullOrEmpty(studentDto.BannerUrl) && IsValidImageUrl(studentDto.BannerUrl)) + { + + + + Remove Banner Image + } + else + { + No banner image available + } + Change Banner Image + + + + Gallery Images + @if (studentDto.GalleryOfImageUrls == null || !studentDto.GalleryOfImageUrls.Any(IsValidImageUrl)) + { + No gallery images available + } + else + { + + @foreach (var imageUrl in studentDto.GalleryOfImageUrls.Where(IsValidImageUrl)) + { + + + + + + + + } + + } +
+
+
+
+ } +
+ + + +@code { + private bool isLoading = true; + private bool isUploading = false; // New state for upload progress + private StudentDto studentDto = new(); + private IList galleryFiles = new List(); + + private long maxFileSize = 32 * 1024 * 1024; + private readonly List validImageExtensions = new List { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" }; + + protected override async Task OnInitializedAsync() + { + isLoading = true; + try + { + await IdentityService.InitializeAuthenticationState(); + if (IdentityService.IsAuthenticated) + { + var studentId = IdentityService.GetCurrentUserId(); + studentDto = await StudentsService.GetStudentAsync(studentId); + } + else + { + NavigationManager.NavigateTo("/login"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error in OnInitializedAsync: {ex.Message}"); + } + finally + { + isLoading = false; + StateHasChanged(); + } + } + + private void OnGalleryFilesChanged(InputFileChangeEventArgs args) + { + galleryFiles = args.GetMultipleFiles().ToList(); + StateHasChanged(); + } + + private async Task UploadGalleryImagesAsync() + { + if (galleryFiles != null && galleryFiles.Count > 0) + { + isUploading = true; // Set uploading state to true + StateHasChanged(); + try + { + foreach (var file in galleryFiles) + { + var stream = file.OpenReadStream(maxFileSize); + byte[] bytes = await ReadFully(stream); + var base64Content = Convert.ToBase64String(bytes); + var response = await MediaFilesService.UploadMediaFileAsync(IdentityService.GetCurrentUserId(), + MediaFileContextType.StudentGalleryImage.ToString(), IdentityService.GetCurrentUserId(), + file.Name, file.ContentType, base64Content); + if (response.Content != null && !string.IsNullOrEmpty(response.Content.FileUrl)) + { + studentDto.GalleryOfImageUrls = studentDto.GalleryOfImageUrls.Append(response.Content.FileUrl).ToList(); + } + stream.Close(); + } + } + finally + { + galleryFiles.Clear(); + isUploading = false; // Set uploading state to false + StateHasChanged(); + } + } + } + + private static async Task ReadFully(Stream input) + { + byte[] buffer = new byte[16 * 1024]; + using (MemoryStream ms = new MemoryStream()) + { + int read; + while ((read = await input.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + ms.Write(buffer, 0, read); + } + return ms.ToArray(); + } + } + + private void NavigateToBannerUpdate() + { + NavigationManager.NavigateTo("/update-banner"); + } + + private async Task TriggerFileInputClick() + { + var fileInput = await JS.InvokeAsync("document.getElementById", "fileInput"); + await fileInput.InvokeVoidAsync("click"); + } + + private void NavigateToEditGallery() + { + NavigationManager.NavigateTo("/edit-gallery"); + } + + private async Task RemoveBannerImage() + { + try + { + if (!string.IsNullOrEmpty(studentDto.BannerUrl)) + { + Console.WriteLine($"{studentDto.BannerUrl}"); + await MediaFilesService.DeleteMediaFileAsync(studentDto.BannerUrl); + studentDto.BannerUrl = null; + StateHasChanged(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error removing banner image: {ex.Message}"); + } + } + + private bool IsValidImageUrl(string url) + { + return validImageExtensions.Contains(Path.GetExtension(url)?.ToLower()); + } +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/Components/UpdateBannerImage.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/Components/UpdateBannerImage.razor new file mode 100644 index 000000000..085fcea67 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/Components/UpdateBannerImage.razor @@ -0,0 +1,190 @@ +@page "/update-banner" +@using MiniSpace.Web.DTO.Types +@using MiniSpace.Web.DTO +@using MiniSpace.Web.Areas.MediaFiles +@using Microsoft.AspNetCore.Components.Forms +@inject IMediaFilesService MediaFilesService +@inject IIdentityService IdentityService +@inject IJSRuntime JSRuntime +@using System.IO +@inject NavigationManager NavigationManager +@using MudBlazor + + + + + + + + +
+ Update Banner Image + + + @if (isProcessing) + { +
+ + + +
+ } + +
+ + @if (!string.IsNullOrEmpty(croppedImageBase64)) + { +
+ Cropped Image +
+ } +
+
+ + + +@code { + + private List _items = new List + { + new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), + new BreadcrumbItem("Account settings", href: "/account", icon: @Icons.Material.Filled.ManageAccounts), + new BreadcrumbItem("Banner Image", href: "/update-banner", disabled: true, icon: @Icons.Material.Filled.ManageAccounts), + }; + private string croppedImageBase64; + private bool isProcessing = false; + + private DotNetObjectReference dotNetRef; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + dotNetRef = DotNetObjectReference.Create(this); + await JSRuntime.InvokeVoidAsync("GLOBAL.SetDotnetReference", dotNetRef); + } + } + + private async Task HandleImageSelected(InputFileChangeEventArgs e) + { + var imageFile = e.File; + if (imageFile != null) + { + isProcessing = true; + StateHasChanged(); + long maxAllowedSize = 5 * 1024 * 1024; // 5 MB in bytes + + try + { + using var stream = imageFile.OpenReadStream(maxAllowedSize); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + var buffer = ms.ToArray(); + var base64String = Convert.ToBase64String(buffer); + await JSRuntime.InvokeVoidAsync("displayImageAndInitializeCropper", base64String); + } + catch (System.IO.IOException ex) + { + Console.Error.WriteLine($"File size exceeds the maximum limit: {ex.Message}"); + } + finally + { + isProcessing = false; + StateHasChanged(); + } + } + } + + [JSInvokable] + public async Task ReceiveCroppedImage(string base64Result) + { + croppedImageBase64 = base64Result; + StateHasChanged(); + } + + private string GetFileExtensionFromBase64(string base64String) + { + var dataUriPattern = new System.Text.RegularExpressions.Regex(@"^data:(?image\/.+?);base64,(?.+)$"); + var match = dataUriPattern.Match(base64String); + if (match.Success) + { + var type = match.Groups["type"].Value; + return type switch + { + "image/jpeg" => "jpg", + "image/png" => "png", + "image/gif" => "gif", + "image/tiff" => "tiff", + "image/webp" => "webp", + _ => throw new InvalidOperationException("Unsupported image type"), + }; + } + throw new InvalidOperationException("Invalid base64 image data"); + } + + [JSInvokable] + public async Task SaveCroppedImage() + { + try + { + isProcessing = true; + StateHasChanged(); + + var userId = IdentityService.GetCurrentUserId(); + var fileExtension = GetFileExtensionFromBase64(croppedImageBase64); + var randomGuid = Guid.NewGuid().ToString(); + var fileName = $"banner_image_{userId}_{randomGuid}.{fileExtension}"; + var contentType = $"image/{fileExtension}"; + + var base64Content = croppedImageBase64.Split(',')[1]; + var response = await MediaFilesService.UploadMediaFileAsync( + Guid.NewGuid(), + MediaFileContextType.StudentBannerImage.ToString(), + userId, + fileName, + contentType, + base64Content + ); + + if (response != null) + { + NavigationManager.NavigateTo("/account"); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error saving the cropped image: {ex.Message}"); + } + finally + { + isProcessing = false; + StateHasChanged(); + } + } +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/EmailVerification.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/EmailVerification.razor new file mode 100644 index 000000000..68c2d88ae --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/EmailVerification.razor @@ -0,0 +1,141 @@ +@page "/verify-email/{Token}/{Email}/{HashedToken}/verify" +@using MiniSpace.Web.Areas.Identity +@using MudBlazor +@inject IIdentityService IdentityService +@inject NavigationManager NavigationManager +@using System.Web + + + +
+
+ +
+
+
+

Email Verification

+ @if (showSuccess) + { + + @successMessage + +
+ @* Include the sign-in form *@ + +
+ } + @if (showError) + { + + @errorMessage + + } +
+
+
+ +@code { + [Parameter] public string Token { get; set; } = ""; + [Parameter] public string Email { get; set; } = ""; + [Parameter] public string HashedToken { get; set; } = ""; + + private bool showSuccess = false; + private bool showError = false; + private string successMessage = string.Empty; + private string errorMessage = string.Empty; + + protected override async Task OnInitializedAsync() + { + Console.WriteLine($"Token received: {Token}"); + Console.WriteLine($"Email received: {Email}"); + Console.WriteLine($"HashedToken received: {HashedToken}"); + + if (!string.IsNullOrEmpty(Token) && !string.IsNullOrEmpty(Email) && !string.IsNullOrEmpty(HashedToken)) + { + var decodedEmail = HttpUtility.UrlDecode(Email); + var response = await IdentityService.VerifyEmailAsync(Token, decodedEmail, HashedToken); + if (response != null) + { + successMessage = "Thank you, your email has been verified."; + showSuccess = true; + } + else + { + errorMessage = "Sorry, we could not verify your email."; + showError = true; + } + } + else + { + errorMessage = "Invalid verification link."; + showError = true; + } + } + + private void OnAlertClose() + { + showSuccess = false; + showError = false; + successMessage = string.Empty; + errorMessage = string.Empty; + } +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/EmailVerificationInfo.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/EmailVerificationInfo.razor new file mode 100644 index 000000000..f4e4aa319 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/EmailVerificationInfo.razor @@ -0,0 +1,81 @@ +@page "/email-verification-info" +@using MudBlazor + + + + +
+
+ +
+
+
+ +
+
+
diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/ForgotPassword.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/ForgotPassword.razor index 774e1c166..00df8ab25 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/ForgotPassword.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/ForgotPassword.razor @@ -2,14 +2,14 @@ @using MiniSpace.Web.Models.Identity @using MiniSpace.Web.Areas.Identity @using MiniSpace.Web.Areas.Http +@using MiniSpace.Web.DTO +@using MudBlazor @inject IIdentityService IdentityService @inject NavigationManager NavigationManager @inject IJSRuntime JSRuntime -@using MiniSpace.Web.DTO -@using Radzen +
@@ -86,33 +80,30 @@

Reset Your Password

Please enter your new password below.

- - - - - - - - - - - - @* *@ - - - - - - + + + + + + + + + + + + + Reset Password + + + @if (showError) { -
+ @errorMessage -
+ } -
- Back to Sign In + Back to Sign In
@@ -125,28 +116,48 @@ private ResetPasswordModel resetPasswordModel = new ResetPasswordModel(); private bool showError = false; private string errorMessage = string.Empty; + private InputType passwordInputType = InputType.Password; + private string passwordInputIcon = Icons.Material.Filled.VisibilityOff; - -protected override void OnInitialized() -{ - Console.WriteLine($"Current URL: {NavigationManager.Uri}"); - Console.WriteLine($"Token received: {Token}"); - - if (string.IsNullOrEmpty(Token)) + protected override void OnInitialized() { - showError = true; - errorMessage = "Invalid token."; + Console.WriteLine($"Current URL: {NavigationManager.Uri}"); + Console.WriteLine($"Token received: {Token}"); + + if (string.IsNullOrEmpty(Token)) + { + showError = true; + errorMessage = "Invalid token."; + } + else + { + resetPasswordModel.Token = Token; + } } - else + + private void OnAlertClose() { - resetPasswordModel.Token = Token; + showError = false; + errorMessage = string.Empty; } -} + private void TogglePasswordVisibility() + { + if (passwordInputType == InputType.Password) + { + passwordInputType = InputType.Text; + passwordInputIcon = Icons.Material.Filled.Visibility; + } + else + { + passwordInputType = InputType.Password; + passwordInputIcon = Icons.Material.Filled.VisibilityOff; + } + } private async Task HandleResetPassword() { - Console.WriteLine($"NewPassword: {resetPasswordModel.NewPassword}, ConfirmPassword: {resetPasswordModel.ConfirmPassword}"); + Console.WriteLine($"NewPassword: {resetPasswordModel.NewPassword}, ConfirmPassword: {resetPasswordModel.ConfirmPassword}"); try { var response = await IdentityService.ResetPasswordAsync(resetPasswordModel.Token, resetPasswordModel.Email, resetPasswordModel.NewPassword); diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/ShowAccount.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/ShowAccount.razor index bfb1ab0bb..d777f9a05 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/ShowAccount.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/ShowAccount.razor @@ -3,20 +3,122 @@ @using MiniSpace.Web.Areas.Students @using MiniSpace.Web.Components @using MiniSpace.Web.DTO -@using System.IO @using MiniSpace.Web.Areas.MediaFiles @using MiniSpace.Web.DTO.Types @using MiniSpace.Web.Shared -@using Radzen +@using MudBlazor +@using System.IO +@using System.Text.Json @inject IIdentityService IdentityService @inject IStudentsService StudentsService @inject IMediaFilesService MediaFilesService @inject NavigationManager NavigationManager -@using MudBlazor - + + + + + + + + @code { private List _items = new List @@ -24,216 +126,150 @@ new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), new BreadcrumbItem("Account settings", href: "/events/follow", disabled: true, icon: @Icons.Material.Filled.ManageAccounts), }; -} - + private List availableLanguages = new List + { + "English", + "Spanish", + "French", + "German", + "Chinese", + "Japanese", + "Korean", + "Italian", + "Russian", + "Portuguese" + }; -@if (studentDto.Id != Guid.Empty) -{ - - @if (studentDto.State == "valid") - { - - - - } - - - - - @if (studentDto.State == "valid") + private int activeTabIndex = 0; + private bool isTwoFactorEnabled; + private string twoFactorSecret; + private bool isLoading = true; + private string profileImage = string.Empty; + private bool isUploading = false; + private IBrowserFile file; + private long maxFileSize = 10 * 1024 * 1024; + private StudentDto studentDto = new(); + private NotificationPreferencesDto notificationPreferencesDto = new NotificationPreferencesDto(); + + protected override async Task OnInitializedAsync() + { + isLoading = true; + StateHasChanged(); + + try { - @if (editionDisabled) + await IdentityService.InitializeAuthenticationState(); + + if (IdentityService.IsAuthenticated) { - + var studentId = IdentityService.GetCurrentUserId(); + studentDto = await StudentsService.GetStudentAsync(studentId); + if (studentDto.EmailNotifications) + { + notificationPreferencesDto = await StudentsService.GetUserNotificationPreferencesAsync(studentId); + } + profileImage = studentDto.ProfileImageUrl; + isTwoFactorEnabled = studentDto.IsTwoFactorEnabled; + twoFactorSecret = studentDto.IsTwoFactorEnabled ? studentDto.TwoFactorSecret : null; } else { - + NavigationManager.NavigateTo("/login"); } - - - - - - - - } - - @if (studentDto.State == "incomplete") + catch (Exception ex) { -

- Completing registration is necessary, go ahead by clicking the button below. -

- - - - - - + Console.WriteLine(ex.Message); } - else + finally { - if (editionDisabled) - { - - - - - - } - else - { - - - - - - - @if (isUploading) - { - - - - } - - - - - - } + isLoading = false; + StateHasChanged(); } -
-} -else -{ -
- - - -
-} - - -@code { - private StudentDto studentDto = new(); - private bool editionDisabled = true; - private string profileImage = string.Empty; - private TaskCompletionSource clientChangeCompletionSource; - private bool isUploading = false; - - private const string dateFormat = "dd/MM/yyyy HH:mm"; - private const string shortDateFormat = "dd/MM/yyyy"; - - protected override async Task OnInitializedAsync() + private async Task SaveChangesAsync() +{ + try { - if (IdentityService.IsAuthenticated) + var updateStudentData = new { - await StudentsService.UpdateStudentDto(IdentityService.UserDto.Id); - studentDto = StudentsService.StudentDto; - StateHasChanged(); - if (studentDto.ProfileImage != Guid.Empty) - { - var imageResponse = await MediaFilesService.GetFileAsync(studentDto.ProfileImage); - profileImage = imageResponse.Base64Content; - } - } - } + studentDto.Id, + studentDto.FirstName, + studentDto.LastName, + studentDto.ProfileImageUrl, + studentDto.Description, + studentDto.EmailNotifications, + studentDto.ContactEmail, + studentDto.Languages, + studentDto.Interests, + studentDto.Education, + studentDto.WorkPosition, + studentDto.Company, + IsTwoFactorEnabled = isTwoFactorEnabled, + TwoFactorSecret = isTwoFactorEnabled ? twoFactorSecret : null + }; - void EnableEdition() - { - editionDisabled = false; - StateHasChanged(); - } + var jsonData = JsonSerializer.Serialize(updateStudentData); + Console.WriteLine($"Sending UpdateStudent request: {jsonData}"); - private async Task HandleUpdateStudent() - { - if (clientChangeCompletionSource != null) + await StudentsService.UpdateStudentAsync( + studentDto.Id, + studentDto.FirstName, + studentDto.LastName, + studentDto.ProfileImageUrl, + studentDto.Description, + studentDto.EmailNotifications, + studentDto.ContactEmail, + studentDto.Languages, + studentDto.Interests, + isTwoFactorEnabled, + !isTwoFactorEnabled, + isTwoFactorEnabled ? twoFactorSecret : null, + studentDto.Education, + studentDto.WorkPosition, + studentDto.Company + ); + + if (studentDto.EmailNotifications) { - await clientChangeCompletionSource.Task; + await StudentsService.UpdateUserNotificationPreferencesAsync(studentDto.Id, notificationPreferencesDto); } - editionDisabled = true; - await StudentsService.UpdateStudentAsync(studentDto.Id, studentDto.ProfileImage, - studentDto.Description, studentDto.EmailNotifications); - await OnInitializedAsync(); } - - private string GetImage() + catch (Exception ex) { - if (profileImage != string.Empty) - { - return $"data:image/webp;base64,{profileImage}"; - } - - return "images/default_profile_image.webp"; + Console.WriteLine(ex.Message); } - - async void OnClientChange(UploadChangeEventArgs args) - { - @* Console.WriteLine("Client-side upload changed"); *@ - clientChangeCompletionSource = new TaskCompletionSource(); +} + - foreach (var file in args.Files) +private async Task SaveImageAsync() +{ + try + { + if (file != null) { - @* Console.WriteLine($"File: {file.Name} / {file.Size} bytes"); *@ isUploading = true; StateHasChanged(); try { - long maxFileSize = 10 * 1024 * 1024; var stream = file.OpenReadStream(maxFileSize); byte[] bytes = await ReadFully(stream); var base64Content = Convert.ToBase64String(bytes); var response = await MediaFilesService.UploadMediaFileAsync(IdentityService.UserDto.Id, - MediaFileContextType.StudentProfile.ToString(), IdentityService.UserDto.Id, + MediaFileContextType.StudentProfileImage.ToString(), IdentityService.UserDto.Id, file.Name, file.ContentType, base64Content); - if (response.Content != null && response.Content.FileId != Guid.Empty) - { - studentDto.ProfileImage = response.Content.FileId; - } + + studentDto.ProfileImageUrl = response.Content.FileUrl; + profileImage = response.Content.FileUrl; stream.Close(); - clientChangeCompletionSource.SetResult(true); } catch (Exception ex) { - @* Console.WriteLine($"Client-side file read error: {ex.Message}"); *@ - clientChangeCompletionSource.SetResult(false); + Console.WriteLine($"Error uploading image: {ex.Message}"); } finally { @@ -242,10 +278,268 @@ else } } } - + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } +} + + + + private RenderFragment ProfileContent() => @
+ Profile + @if (isLoading) + { + + } + else + { + + +
+ + + + + Upload Image + + + + @if (file != null) + { + @file.Name + Save Image + } + else + { + No Files Selected + } + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + Save profile + + @if (isUploading) + { + + } + } +
; + + + private RenderFragment SecurityContent() => @
+ Security + @if (isLoading) + { + + } + else + { + + + @if (isTwoFactorEnabled) + { + @if (string.IsNullOrEmpty(twoFactorSecret)) + { + Generate Secret Token + } + else + { + + } + } + + + Save 2FA Settings + } +
; + + private async Task SaveTwoFactorSettingsAsync() + { + try + { + if (isTwoFactorEnabled) + { + if (string.IsNullOrEmpty(twoFactorSecret)) + { + throw new InvalidOperationException("Secret token must be generated before enabling 2FA."); + } + + await IdentityService.EnableTwoFactorAsync(IdentityService.GetCurrentUserId(), twoFactorSecret); + } + else + { + await IdentityService.DisableTwoFactorAsync(IdentityService.GetCurrentUserId()); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error saving 2FA settings: {ex.Message}"); + } + } + + + private async Task ToggleTwoFactor(bool enabled) + { + isTwoFactorEnabled = enabled; + if (!enabled) + { + await IdentityService.DisableTwoFactorAsync(IdentityService.GetCurrentUserId()); + twoFactorSecret = null; + } + else + { + twoFactorSecret = await IdentityService.GenerateTwoFactorSecretAsync(IdentityService.GetCurrentUserId()); + await IdentityService.EnableTwoFactorAsync(IdentityService.GetCurrentUserId(), twoFactorSecret); + } + StateHasChanged(); + } + + private async Task GenerateTwoFactorSecret() + { + var userId = IdentityService.GetCurrentUserId(); + twoFactorSecret = await IdentityService.GenerateTwoFactorSecretAsync(userId); + StateHasChanged(); + } + + private RenderFragment NotificationsContent() => @
+ Notifications + @if (isLoading) + { + + } + else + { + + + @if (studentDto.EmailNotifications) + { + + + + + + + + + } + + + Save preferences + } +
; + + private RenderFragment PrivacyContent() => @
+ Privacy + @if (isLoading) + { + + } + else + { + + + + + Save privacy + } +
; + + private RenderFragment LanguagesAndInterestsContent() => @
+ Languages & Interests + @if (isLoading) + { + + } + else + { + + + Languages + + @foreach (var language in availableLanguages) + { + @language + } + + + + Interests + + + + } +
; + + private RenderFragment GalleryContent() => @
+ Gallery + @if (isLoading) + { + + } + else + { + + @foreach (var imageUrl in studentDto.GalleryOfImageUrls) + { + + + + + + } + + } +
; + + private string GetImage() + { + return !string.IsNullOrEmpty(profileImage) ? profileImage : "images/default_profile_image.webp"; + } + private static async Task ReadFully(Stream input) { - byte[] buffer = new byte[16*1024]; + byte[] buffer = new byte[16 * 1024]; using (MemoryStream ms = new MemoryStream()) { int read; @@ -256,4 +550,9 @@ else return ms.ToArray(); } } + + private void SetActiveTabIndex(int index) + { + activeTabIndex = index; + } } diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/ShowAccountOLD.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/ShowAccountOLD.razor new file mode 100644 index 000000000..1bcb3dd86 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/ShowAccountOLD.razor @@ -0,0 +1,293 @@ +@* @page "/account" +@using System.Globalization +@using MiniSpace.Web.Areas.Students +@using MiniSpace.Web.Components +@using MiniSpace.Web.DTO +@using MiniSpace.Web.Areas.MediaFiles +@using MiniSpace.Web.DTO.Types +@using MiniSpace.Web.Shared +@using Radzen +@using System.IO +@inject IIdentityService IdentityService +@inject IStudentsService StudentsService +@inject IMediaFilesService MediaFilesService +@inject NavigationManager NavigationManager +@using MudBlazor + + + + +@code { + private List _items = new List + { + new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), + new BreadcrumbItem("Account settings", href: "/events/follow", disabled: true, icon: @Icons.Material.Filled.ManageAccounts), + }; + private StudentDto studentDto = new(); + private bool editionDisabled = true; + private string profileImage = string.Empty; + private TaskCompletionSource clientChangeCompletionSource; + private bool isUploading = false; + private bool isLoading = true; + + private const string dateFormat = "dd/MM/yyyy HH:mm"; + private const string shortDateFormat = "dd/MM/yyyy"; + + protected override async Task OnInitializedAsync() + { + isLoading = true; + StateHasChanged(); + + try + { + await IdentityService.InitializeAuthenticationState(); + + if (IdentityService.IsAuthenticated) + { + var studentId = IdentityService.GetCurrentUserId(); + studentDto = await StudentsService.GetStudentAsync(studentId); + profileImage = studentDto.ProfileImageUrl; // Directly using the URL from the DTO + } + else + { + NavigationManager.NavigateTo("/login"); + } + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + finally + { + isLoading = false; + StateHasChanged(); + } + } + + void EnableEdition() + { + editionDisabled = false; + StateHasChanged(); + } + + private async Task HandleUpdateStudent() + { + if (clientChangeCompletionSource != null) + { + await clientChangeCompletionSource.Task; + } + editionDisabled = true; + await StudentsService.UpdateStudentAsync(studentDto.Id, studentDto.ProfileImageUrl, + studentDto.Description, studentDto.EmailNotifications, studentDto.ContactEmail); + await OnInitializedAsync(); + } + + private string GetImage() + { + if (!string.IsNullOrEmpty(profileImage)) + { + return profileImage; + } + + return "images/default_profile_image.webp"; + } + + async void OnClientChange(UploadChangeEventArgs args) + { + clientChangeCompletionSource = new TaskCompletionSource(); + + foreach (var file in args.Files) + { + isUploading = true; + StateHasChanged(); + + try + { + long maxFileSize = 10 * 1024 * 1024; + var stream = file.OpenReadStream(maxFileSize); + byte[] bytes = await ReadFully(stream); + var base64Content = Convert.ToBase64String(bytes); + var response = await MediaFilesService.UploadMediaFileAsync(IdentityService.UserDto.Id, + MediaFileContextType.StudentProfileImage.ToString(), IdentityService.UserDto.Id, + file.Name, file.ContentType, base64Content); + if (response.Content != null && !string.IsNullOrEmpty(response.Content.FileUrl)) + { + studentDto.ProfileImageUrl = response.Content.FileUrl; + profileImage = response.Content.FileUrl; // Update the local profile image URL + } + + stream.Close(); + clientChangeCompletionSource.SetResult(true); + } + catch (Exception ex) + { + clientChangeCompletionSource.SetResult(false); + } + finally + { + isUploading = false; + StateHasChanged(); + } + } + } + + private static async Task ReadFully(Stream input) + { + byte[] buffer = new byte[16 * 1024]; + using (MemoryStream ms = new MemoryStream()) + { + int read; + while ((read = await input.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + ms.Write(buffer, 0, read); + } + return ms.ToArray(); + } + } +} + + + +@if (isLoading) +{ +
+ + + +
+} +else if (studentDto.Id != Guid.Empty) +{ + + @if (studentDto.State == "valid") + { + + + + } + + + + + + + + + + + + + +} +else +{ +
+ + + +
+} +
+ + *@ diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/SignIn.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/SignIn.razor index 71a7d798f..28656bac4 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/SignIn.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/SignIn.razor @@ -2,17 +2,17 @@ @using MiniSpace.Web.Models.Identity @using MiniSpace.Web.Areas.Identity @using MiniSpace.Web.Areas.Students -@using Radzen @using MiniSpace.Web.Areas.Http +@using MudBlazor @inject IIdentityService IdentityService @inject IStudentsService StudentsService @inject IErrorMapperService ErrorMapperService @inject NavigationManager NavigationManager @inject IJSRuntime JSRuntime - -
- - @errorMessage - - - - - - - - - - - - - - - - + @if (showError) + { + + @errorMessage + + } + + + + + + + + + + + Forgot Password? + + + Sign In + + +
- Forgot Password? | Create Account + Create Account
+ @code { private SignInModel signInModel = new SignInModel(); private bool showError = false; private string errorMessage = string.Empty; + private bool rememberMe = false; + private InputType passwordInputType = InputType.Password; + private string passwordInputIcon = Icons.Material.Filled.VisibilityOff; private void OnAlertClose() { showError = false; errorMessage = string.Empty; } - - @* private async Task HandleSignIn() + + private void TogglePasswordVisibility() { - var response = await IdentityService.SignInAsync(signInModel.Email, signInModel.Password); - var jwtDto = response.Content; - if (jwtDto != null && !string.IsNullOrEmpty(jwtDto.AccessToken)) + if (passwordInputType == InputType.Password) { - await StudentsService.UpdateStudentDto(IdentityService.UserDto.Id); - NavigationManager.NavigateTo(StudentsService.StudentDto.State == "incomplete" ? "/signup/complete" : "/account"); + passwordInputType = InputType.Text; + passwordInputIcon = Icons.Material.Filled.Visibility; } else { - showError = true; - errorMessage = ErrorMapperService.MapError(response.ErrorMessage); - StateHasChanged(); // Force the component to re-render + passwordInputType = InputType.Password; + passwordInputIcon = Icons.Material.Filled.VisibilityOff; } - } *@ + } private async Task HandleSignIn() { @@ -176,19 +152,17 @@ { await StudentsService.UpdateStudentDto(IdentityService.UserDto.Id); var nextPage = StudentsService.StudentDto.State == "incomplete" ? "/signin/first" : "/"; - // Force a page reload when navigating NavigationManager.NavigateTo(nextPage, true); } else { showError = true; - errorMessage = $"Error during sign in: {response?.ErrorMessage.Reason}"; + errorMessage = $"Error during sign in: {response?.ErrorMessage?.Reason}"; StateHasChanged(); } } catch (Exception ex) { - showError = true; errorMessage = $"Error during sign in: {ex.Message}"; StateHasChanged(); diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/SignInComponent.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/SignInComponent.razor new file mode 100644 index 000000000..4462bdedc --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/SignInComponent.razor @@ -0,0 +1,95 @@ +@page "/signin-verify" +@using MiniSpace.Web.Models.Identity +@using MiniSpace.Web.Areas.Identity +@using MudBlazor +@inject IIdentityService IdentityService +@inject NavigationManager NavigationManager + +
+ + @if (showError) + { + + @errorMessage + + } + + + + + + + + + + + Forgot Password? + + + Sign In + + + +
+ Create Account +
+
+ +@code { + private SignInModel signInModel = new SignInModel(); + private bool showError = false; + private string errorMessage = string.Empty; + private bool rememberMe = false; + private InputType passwordInputType = InputType.Password; + private string passwordInputIcon = Icons.Material.Filled.VisibilityOff; + + private void OnAlertClose() + { + showError = false; + errorMessage = string.Empty; + } + + private void TogglePasswordVisibility() + { + if (passwordInputType == InputType.Password) + { + passwordInputType = InputType.Text; + passwordInputIcon = Icons.Material.Filled.Visibility; + } + else + { + passwordInputType = InputType.Password; + passwordInputIcon = Icons.Material.Filled.VisibilityOff; + } + } + + private async Task HandleSignIn() + { + try + { + var response = await IdentityService.SignInAsync(signInModel.Email, signInModel.Password); + if (response != null && response.Content != null && !string.IsNullOrEmpty(response.Content.AccessToken)) + { + NavigationManager.NavigateTo("/", true); + } + else + { + showError = true; + errorMessage = $"Error during sign in: {response?.ErrorMessage?.Reason}"; + StateHasChanged(); + } + } + catch (Exception ex) + { + showError = true; + errorMessage = $"Error during sign in: {ex.Message}"; + StateHasChanged(); + } + } +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/SignUp.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/SignUp.razor index 0f996e9a3..7fc1c69b6 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/SignUp.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Account/SignUp.razor @@ -2,16 +2,14 @@ @using MiniSpace.Web.Models.Identity @using MiniSpace.Web.Areas.Identity @using MiniSpace.Web.Areas.Students -@using Radzen @using MiniSpace.Web.Areas.Http +@using MudBlazor @inject IIdentityService IdentityService -@inject IStudentsService StudentsService -@inject IErrorMapperService ErrorMapperService @inject NavigationManager NavigationManager -@inject IJSRuntime JSRuntime +@inject IErrorMapperService ErrorMapperService + @@ -81,32 +75,45 @@
-

Welcome to MiniSpace

-

Please sign up to create your account and explore our world to the full.

-

Sign Up

- - - - - - - - - - - - - - - - - - - - - -
- Already have an account? Sign In + + @if (showError) + { + + @errorMessage + + } + + + + + + + + + + + + + + + + + + + Sign Up + + + +
+ Already have an account? Sign In
@@ -116,23 +123,56 @@ private SignUpModel signUpModel = new SignUpModel(); private bool showError = false; private string errorMessage = string.Empty; - private bool popup; + private InputType passwordInputType = InputType.Password; + private string passwordInputIcon = Icons.Material.Filled.VisibilityOff; - private async Task HandleSignUp() + private void OnAlertClose() { - var response = await IdentityService.SignUpAsync(signUpModel.FirstName, signUpModel.LastName, signUpModel.Email, signUpModel.Password, "user"); - if(response.ErrorMessage == null) - NavigationManager.NavigateTo("/signin"); + showError = false; + errorMessage = string.Empty; + } + + private void TogglePasswordVisibility() + { + if (passwordInputType == InputType.Password) + { + passwordInputType = InputType.Text; + passwordInputIcon = Icons.Material.Filled.Visibility; + } else { - showError = true; - errorMessage = ErrorMapperService.MapError(response.ErrorMessage); + passwordInputType = InputType.Password; + passwordInputIcon = Icons.Material.Filled.VisibilityOff; } } - @* protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender) { - await JSRuntime.InvokeVoidAsync("initializeVideoPlayer", "videos/video-component/video_1.mp4", "videos/video-component/video_2.mp4"); + private async Task HandleSignUp() + { + try + { + if (signUpModel.Password != signUpModel.ConfirmPassword) + { + showError = true; + errorMessage = "Passwords do not match."; + return; + } + + var response = await IdentityService.SignUpAsync(signUpModel.FirstName, signUpModel.LastName, signUpModel.Email, signUpModel.Password, "user"); + if (response.ErrorMessage == null) + { + NavigationManager.NavigateTo("/email-verification-info"); + } + else + { + showError = true; + errorMessage = ErrorMapperService.MapError(response.ErrorMessage); + } + } + catch (Exception ex) + { + showError = true; + errorMessage = $"Error during sign up: {ex.Message}"; + StateHasChanged(); } - } *@ + } } diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Admin/ManageOrganizations.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Admin/ManageOrganizations.razor index d24515519..f93a9e10a 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Admin/ManageOrganizations.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Admin/ManageOrganizations.razor @@ -13,6 +13,7 @@ @inject IStudentsService StudentsService @inject NavigationManager NavigationManager +

Manage organizations

@if (!pageInitialized) @@ -73,7 +74,7 @@ else } - +
@code { private bool pageInitialized = false; diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Admin/ManageReports.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Admin/ManageReports.razor index f2709724b..e2fda0ef3 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Admin/ManageReports.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Admin/ManageReports.razor @@ -16,6 +16,7 @@ @inject IReportsService ReportsService @inject Blazored.LocalStorage.ILocalStorageService LocalStorage +

Manage reports

@if (!pageInitialized) @@ -106,7 +107,7 @@ } } - +
@code { private SearchReportsModel searchReportsModel = new(); diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Admin/ManageStudents.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Admin/ManageStudents.razor index dcb167112..d3603b420 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Admin/ManageStudents.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Admin/ManageStudents.razor @@ -9,6 +9,7 @@ @inject IIdentityService IdentityService @inject IStudentsService StudentsService +

Manage students

@if (!pageInitialized) @@ -53,7 +54,7 @@ } - +
@code { private const string dateFormat = "dd/MM/yyyy HH:mm"; private const string shortDateFormat = "dd/MM/yyyy"; diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/Dialogs/InviteFriendsToEventDialog.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/Dialogs/InviteFriendsToEventDialog.razor index 96c7d18a3..212bf87cf 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/Dialogs/InviteFriendsToEventDialog.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/Dialogs/InviteFriendsToEventDialog.razor @@ -64,9 +64,9 @@ foreach (var friend in friends) { if (friend.StudentDetails == null) continue; - Console.WriteLine(friend.StudentDetails.ProfileImage); - var result = await MediaFilesService.GetFileAsync(friend.StudentDetails.ProfileImage); - images[friend.StudentDetails.Id] = result?.Base64Content ?? "images/user_default.png"; + Console.WriteLine(friend.StudentDetails.ProfileImageUrl); + @* var result = await MediaFilesService.GetFileAsync(friend.StudentDetails.ProfileImageUrl); + images[friend.StudentDetails.Id] = result?.Base64Content ?? "images/user_default.png"; *@ } } diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/Dialogs/ParticipantDetailsDialog.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/Dialogs/ParticipantDetailsDialog.razor index a5cd248a6..7617d6758 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/Dialogs/ParticipantDetailsDialog.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/Dialogs/ParticipantDetailsDialog.razor @@ -159,7 +159,8 @@ await base.OnInitializedAsync(); dialogInitialized = true; StateHasChanged(); - profileImage = await MediaFilesService.GetFileAsync(studentDto.ProfileImage); + @* profileImage = await MediaFilesService.GetFileAsync(studentDto.ProfileImage); *@ + @* profileImage = studentDto.ProfileImageUrl; *@ } private string GetImage(FileDto file) diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventCreate.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventCreate.razor index c7efbebd6..4e33de57b 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventCreate.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventCreate.razor @@ -16,6 +16,7 @@ @inject IErrorMapperService ErrorMapperService @inject NavigationManager NavigationManager +

Create new event

@if (!pageInitialized) @@ -213,7 +214,7 @@ } - +
@code { private Guid organizerId; private bool pageInitialized = false; diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventUpdate.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventUpdate.razor index 62e5f3a44..fbe655b25 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventUpdate.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventUpdate.razor @@ -10,6 +10,7 @@ @inject IErrorMapperService ErrorMapperService @inject NavigationManager NavigationManager +

Update your event

@if (!pageInitialized) @@ -166,7 +167,7 @@ } - +
@code { [Parameter] public string EventId { get; set; } diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventsFollow.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventsFollow.razor index d5d69f441..24072652d 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventsFollow.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventsFollow.razor @@ -14,6 +14,7 @@ @inject IEventsService EventsService @inject NavigationManager NavigationManager + @@ -105,12 +106,12 @@ } - + diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventsOrganize.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventsOrganize.razor index d73c2c6f1..266a5c488 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventsOrganize.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Events/EventsOrganize.razor @@ -18,6 +18,7 @@ @inject NavigationManager NavigationManager @inject Blazored.LocalStorage.ILocalStorageService LocalStorage +

Organize events

@@ -69,7 +70,7 @@ Rectangular="true" ShowFirstButton="true" ShowLastButton="true"/> } - + + + +@code { + private IEnumerable posts; + private Guid studentId; + private bool pageInitialized = false; + private SearchPosts searchModel; + + protected override async Task OnInitializedAsync() + { + if (IdentityService != null && IdentityService.IsAuthenticated) + { + studentId = IdentityService.GetCurrentUserId(); + searchModel = InitializeSearchModel(studentId); + var result = await PostsService.SearchPostsAsync(searchModel); + posts = result.Content.Content; + pageInitialized = true; + } + else + { + NavigationManager.NavigateTo(""); + + } + } + + private static SearchPosts InitializeSearchModel(Guid studentId) + { + return new() + { + StudentId = studentId, + Pageable = new PageableDto() + { + Page = 1, + Size = 8, + Sort = new SortDto() + { + SortBy = new List() {"publishDate"}, + Direction = "des" + } + } + }; + } +} *@ diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Feeds/PostCard.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Feeds/PostCard.razor new file mode 100644 index 000000000..9a63b514d --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Feeds/PostCard.razor @@ -0,0 +1,20 @@ +@using MiniSpace.Web.DTO +@using MudBlazor + + + + + @Post.TextContent + + + @Post.State + + + Read More + + + +@code { + [Parameter] + public PostDto Post { get; set; } +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Feeds/UserInformation.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Feeds/UserInformation.razor new file mode 100644 index 000000000..bf701de1a --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Feeds/UserInformation.razor @@ -0,0 +1,218 @@ +@using MiniSpace.Web.DTO +@using MiniSpace.Web.Areas.Students +@inject IStudentsService StudentsService +@inject NavigationManager NavigationManager + +@using MudBlazor + + @if (student != null) + { + + + + +
+ +
+ @student.FullName + @student.Description + Edit Profile +
+
+
+
+
+ + @if (!string.IsNullOrEmpty(student.ContactEmail)) + { + + + + + Contact Information + + + @if (!string.IsNullOrEmpty(student.ContactEmail)) + { + Contact Email: @student.ContactEmail + } + + + + } + + @if (!string.IsNullOrEmpty(student.Education)) + { + + + + + Education + + + @student.Education + + + + } + + @if (!string.IsNullOrEmpty(student.WorkPosition) || !string.IsNullOrEmpty(student.Company)) + { + + + + + Work Experience + + + @if (!string.IsNullOrEmpty(student.WorkPosition)) + { + Position: @student.WorkPosition + } + @if (!string.IsNullOrEmpty(student.Company)) + { + Company: @student.Company + } + + + + } + + @if ((student.Languages != null && student.Languages.Any()) || (student.Interests != null && student.Interests.Any())) + { + + + + + Skills and Interests + + + @if (student.Languages != null && student.Languages.Any()) + { + Languages: @string.Join(", ", student.Languages) + } + @if (student.Interests != null && student.Interests.Any()) + { + Interests: @string.Join(", ", student.Interests) + } + + + + } + + @if ((student.InterestedInEvents != null && student.InterestedInEvents.Any()) || (student.SignedUpEvents != null && student.SignedUpEvents.Any())) + { + + + + + Events + + + @if (student.InterestedInEvents != null && student.InterestedInEvents.Any()) + { + Interested in Events: @string.Join(", ", student.InterestedInEvents.Select(e => e.ToString())) + } + @if (student.SignedUpEvents != null && student.SignedUpEvents.Any()) + { + Signed Up for Events: @string.Join(", ", student.SignedUpEvents.Select(e => e.ToString())) + } + + + + } +
+ } +
+ +@code { + [Parameter] + public Guid UserId { get; set; } + + private StudentDto student; + + protected override async Task OnInitializedAsync() + { + student = await StudentsService.GetStudentAsync(UserId); + } + + private string GetProfileImage() + { + var defaultImage = "path/to/default/image.png"; // Set path to your default image + var validExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" }; + + if (string.IsNullOrEmpty(student?.ProfileImageUrl) || !validExtensions.Contains(System.IO.Path.GetExtension(student.ProfileImageUrl)?.ToLower())) + { + return defaultImage; + } + + return student.ProfileImageUrl; + } + + private void NavigateToProfileSettings() + { + NavigationManager.NavigateTo("/showaccount"); + } +} + + diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Feeds/UserRelatedContent.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Feeds/UserRelatedContent.razor new file mode 100644 index 000000000..bf8830f64 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Feeds/UserRelatedContent.razor @@ -0,0 +1,35 @@ +@using MiniSpace.Web.DTO +@using MiniSpace.Web.Areas.Students +@inject IStudentsService StudentsService +@using MudBlazor + + + Recommendations + + @if (recommendations != null) + { + @foreach (var recommendation in recommendations) + { + @recommendation + } + } + + +@code { + [Parameter] + public Guid UserId { get; set; } + + private List recommendations; + + protected override async Task OnInitializedAsync() + { + // Fetch recommendations based on the user ID + recommendations = await FetchRecommendationsAsync(UserId); + } + + private Task> FetchRecommendationsAsync(Guid userId) + { + // Dummy implementation, replace with actual recommendation logic + return Task.FromResult(new List { "Event 1", "Event 2", "Event 3" }); + } +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/Friends.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/Friends.razor index 16d87c68c..bbcb2de70 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/Friends.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/Friends.razor @@ -1,22 +1,21 @@ @page "/friends" @using MiniSpace.Web.HttpClients -@using Radzen.Blazor @using MiniSpace.Web.Areas.Friends -@using MiniSpace.Web.DTO; +@using MiniSpace.Web.DTO @inject IIdentityService IdentityService @inject IFriendsService FriendsService @inject NavigationManager NavigationManager @inject Radzen.NotificationService NotificationService @inject Radzen.DialogService DialogService -@using MiniSpace.Web.Areas.MediaFiles -@inject IMediaFilesService MediaFilesService @using MudBlazor -@using Radzen - + +
+ + @code { private List _items = new List { @@ -26,70 +25,104 @@ }; } - +
- -
- @if (friends != null && friends.Any()) + + @if (!pageInitialized) { - foreach (var friend in friends) +
+ +
+ } + else if (friends != null && friends.Any()) + { + @foreach (var friend in friends) { -
- Friend Image -
-
@friend.StudentDetails.FirstName @friend.StudentDetails.LastName
-

@friend.StudentDetails.Email

+
+
+ Friend Image +
+
@friend.StudentDetails.FirstName @friend.StudentDetails.LastName
+

@friend.StudentDetails.Email

+
- - + + + Details + + + + Remove +
} } - else if (!pageInitialized) - { -
- - - -
- } else {

No friends to show. Start connecting now!

}
- +
+ + @code { private List friends; - private Guid studentId; private string searchTerm; - private Dictionary images = new (); private bool pageInitialized; protected override async Task OnInitializedAsync() @@ -151,26 +193,17 @@ await IdentityService.InitializeAuthenticationState(); if (IdentityService.IsAuthenticated) { - try { - studentId = IdentityService.GetCurrentUserId(); + var studentId = IdentityService.GetCurrentUserId(); var friendsResult = await FriendsService.GetAllFriendsAsync(studentId); - if (friendsResult != null) - { - friends = friendsResult.ToList(); - await LoadImages(); - } - else - { - friends = new List(); - } + friends = friendsResult?.ToList() ?? new List(); } - catch (Exception ex) { + catch (Exception ex) + { NotificationService.Notify(Radzen.NotificationSeverity.Error, "Failed to Load Friends", $"An error occurred: {ex.Message}", 5000); friends = new List(); } - pageInitialized = true; } else @@ -179,13 +212,9 @@ } } - private async Task LoadImages() { - foreach (var friend in friends) { - var result = await MediaFilesService.GetFileAsync(friend.StudentDetails.ProfileImage); - if (result != null) { - images[friend.StudentDetails.Id] = result.Base64Content; - } - } + private string GetProfileImageUrl(string profileImageUrl) + { + return string.IsNullOrEmpty(profileImageUrl) ? "images/default_profile_image.webp" : profileImageUrl; } private void ViewDetails(Guid friendId) @@ -196,23 +225,11 @@ private async Task RemoveFriend(Guid friendId) { await FriendsService.RemoveFriendAsync(friendId); - friends.RemoveAll(f => f.StudentId == friendId); - + friends = friends.Where(f => f.StudentId != friendId).ToList(); NotificationService.Notify(Radzen.NotificationSeverity.Warning, "Friend Removed", $"You have removed a friend.", 5000); StateHasChanged(); } - private string GetImage(Guid studentId) - { - if (images.TryGetValue(studentId, out var image)) - { - return $"data:image/webp;base64,{image}"; - } - - return "images/default_profile_image.webp"; - } - - private async Task ConfirmRemoveFriend(Guid friendId) { var confirm = await DialogService.Confirm("Are you sure you want to remove this friend?", "Confirm Removal", new Radzen.ConfirmOptions() { OkButtonText = "Yes", CancelButtonText = "No" }); @@ -222,38 +239,32 @@ } } - private async Task LoadFriends() + private void SearchFriends() { - try + if (!string.IsNullOrWhiteSpace(searchTerm)) { - var friendsResult = await FriendsService.GetAllFriendsAsync(studentId); - if (friendsResult != null) - { - friends = friendsResult.ToList(); - } - else - { - friends = new List(); - } + searchTerm = searchTerm.Trim(); + friends = friends.Where(f => f.StudentDetails.FirstName.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) || f.StudentDetails.LastName.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)).ToList(); } - catch (Exception ex) + else { - NotificationService.Notify(Radzen.NotificationSeverity.Error, "Failed to Load Friends", $"An error occurred: {ex.Message}", 5000); - friends = new List(); + ReloadFriends().Wait(); } + StateHasChanged(); } - private void SearchFriends() + private async Task ReloadFriends() { - searchTerm = searchTerm.Trim(); - if (!string.IsNullOrEmpty(searchTerm)) + try { - friends = friends.Where(f => f.StudentDetails.FirstName.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) || f.StudentDetails.LastName.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)).ToList(); + var studentId = IdentityService.GetCurrentUserId(); + var friendsResult = await FriendsService.GetAllFriendsAsync(studentId); + friends = friendsResult?.ToList() ?? new List(); } - else + catch (Exception ex) { - LoadFriends().Wait(); + NotificationService.Notify(Radzen.NotificationSeverity.Error, "Failed to Load Friends", $"An error occurred: {ex.Message}", 5000); + friends = new List(); } - StateHasChanged(); } } diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/FriendsDetails.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/FriendsDetails.razor deleted file mode 100644 index 0332022a9..000000000 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/FriendsDetails.razor +++ /dev/null @@ -1,144 +0,0 @@ -@page "/student-details/{Id:guid}" -@using MiniSpace.Web.Areas.Friends -@inject NavigationManager NavigationManager -@inject IFriendsService FriendsService -@using MiniSpace.Web.Areas.MediaFiles -@inject IMediaFilesService MediaFilesService -@using MiniSpace.Web.Models.Reports -@using MiniSpace.Web.Pages.Reports.Dialogs -@using DialogOptions = Radzen.DialogOptions -@using DialogService = Radzen.DialogService -@inject DialogService DialogService -@inject IIdentityService IdentityService -@using MiniSpace.Web.DTO -@using Radzen -@using MudBlazor - - -@code { - private List _items = new List - { - new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), - new BreadcrumbItem("Search", href: "/friends/search", icon: Icons.Material.Filled.PersonSearch), - new BreadcrumbItem("Friends", href: "/friends", icon: Icons.Material.Filled.LibraryAddCheck), - new BreadcrumbItem("Requests", href: "/friends/requests", icon: Icons.Material.Filled.GroupAdd), - new BreadcrumbItem("Sent Requests", href: "/friends/sent-requests", icon: Icons.Material.Filled.PersonAddAlt1), - new BreadcrumbItem("Student details", href: "/student-details/{Id:guid}", disabled: true, icon: Icons.Material.Filled.Person), - }; -} - -

Student Profile

- -@if (studentNotFound) -{ -

Student profile not found!

-

Probably has been deleted!

-} - -@if (student == null) -{ - -} -else -{ -
-
- Profile Image -

@student.FirstName @student.LastName

-

@student.Email

-
-
-

Description: @student.Description

-

Number of Friends: @student.NumberOfFriends

-

Date of Birth: @student.DateOfBirth.ToLocalTime().ToString("yyyy-MM-dd")

-

State: @student.State

-

Joined: @student.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")

-
- @if (IdentityService.IsAuthenticated) - { - - } -
-} - - -@code { - [Parameter] public Guid Id { get; set; } - private StudentDto student; - private string profileImage; - private bool studentNotFound; - - protected override async Task OnInitializedAsync() - { - await IdentityService.InitializeAuthenticationState(); - if (IdentityService.IsAuthenticated) - { - student = await FriendsService.GetStudentAsync(Id); - if (student == null) - { - studentNotFound = true; - return; - } - var imageResult = await MediaFilesService.GetFileAsync(student.ProfileImage); - profileImage = imageResult != null ? $"data:image/webp;base64,{imageResult.Base64Content}" : "images/default_profile_image.webp"; - } - else - { - NavigationManager.NavigateTo("/login"); - } - } - - private async Task OpenReportStudentProfileDialog(CreateReportModel createReportModel) - { - await DialogService.OpenAsync("Report profile of the student:", - new Dictionary() { { "CreateReportModel", createReportModel } }, - new DialogOptions() - { - Width = "700px", Height = "350px", Resizable = true, Draggable = true, - AutoFocusFirstElement = false - }); - } - - private async Task ReportStudentProfile(StudentDto studentDto) - { - var createReportModel = new CreateReportModel - { - IssuerId = IdentityService.GetCurrentUserId(), - TargetId = studentDto.Id, - TargetOwnerId = studentDto.Id, - ContextType = "StudentProfile" - }; - - await OpenReportStudentProfileDialog(createReportModel); - } -} - diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/FriendsRequests.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/FriendsRequests.razor index 66dfec306..7eb7e04a6 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/FriendsRequests.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/FriendsRequests.razor @@ -1,20 +1,18 @@ @page "/friends/requests" @using MiniSpace.Web.HttpClients -@using Radzen.Blazor +@using MudBlazor @inject NavigationManager NavigationManager @using MiniSpace.Web.Areas.Friends @inject IFriendsService FriendsService @using MiniSpace.Web.DTO @using MiniSpace.Web.Areas.Identity -@using MiniSpace.Services.Friends.Application.Dto; @inject IIdentityService IdentityService @inject Radzen.NotificationService NotificationService @inject IJSRuntime JSRuntime -@using MiniSpace.Web.Areas.MediaFiles -@inject IMediaFilesService MediaFilesService -@using MudBlazor -@using Radzen + +
+ @@ -34,44 +32,53 @@
-
- @if (incomingRequests == null) - { -
- - - -
- } - else if (filteredIncomingRequests != null && filteredIncomingRequests.Any()) + @if (incomingRequests == null) + { +
+ +
+ } + else if (filteredIncomingRequests != null && filteredIncomingRequests.Any()) + { + @foreach (var request in filteredIncomingRequests) { - @foreach (var request in filteredIncomingRequests) - { -
- Inviter Image +
+
+ Inviter Image
@request.InviterName
-

@request.InviterEmail

-

@request.RequestedAt.ToLocalTime().ToString("yyyy-MM-dd")

-

@request.State

+

@request.InviterEmail

+

@request.RequestedAt.ToLocalTime().ToString("yyyy-MM-dd")

+

@request.State

+
+
+ + + Decline + + + + Accept +
- -
- - } - } - else - { -

No incoming requests.

+
} -
+ } + else + { +

No incoming requests.

+ }
+
+ @code { private string searchTerm; - private List students = new List(); private IEnumerable incomingRequests; private IEnumerable filteredIncomingRequests; - private Dictionary images = new(); protected override async Task OnInitializedAsync() { await IdentityService.InitializeAuthenticationState(); if (IdentityService.IsAuthenticated) - { + { incomingRequests = await FriendsService.GetIncomingFriendRequestsAsync(); - - if (incomingRequests == null || !incomingRequests.Any()) - { - return; - } - - var inviterIds = incomingRequests.Select(r => r.InviterId).Distinct(); - var studentTasks = inviterIds.Select(id => FriendsService.GetStudentAsync(id)); - - students = (await Task.WhenAll(studentTasks)).ToList(); - - var imageTasks = students.Select(student => FetchImageAsync(student.Id, student.ProfileImage)); - await Task.WhenAll(imageTasks); - - foreach (var request in incomingRequests) - { - var student = students.FirstOrDefault(s => s.Id == request.InviterId); - if (student != null) - { - request.InviterName = $"{student.FirstName} {student.LastName}"; - request.InviterEmail = student.Email; - if (images.ContainsKey(student.Id)) - { - request.InviterImage = images[student.Id]; - } - } - } - filteredIncomingRequests = incomingRequests; } else @@ -255,29 +208,15 @@ } } - private async Task FetchImageAsync(Guid inviterId, Guid profileImage) + private string GetProfileImageUrl(string profileImageUrl) { - var result = await MediaFilesService.GetFileAsync(profileImage); - if (result != null) - { - images[inviterId] = result.Base64Content; - } - } - - private string GetImage(Guid studentId) - { - if (images.TryGetValue(studentId, out var image)) - { - return $"data:image/webp;base64,{image}"; - } - - return "images/default_profile_image.webp"; + return string.IsNullOrEmpty(profileImageUrl) ? "images/default_profile_image.webp" : profileImageUrl; } private void SearchIncomingRequests() { - filteredIncomingRequests = string.IsNullOrWhiteSpace(searchTerm) - ? incomingRequests + filteredIncomingRequests = string.IsNullOrWhiteSpace(searchTerm) + ? incomingRequests : incomingRequests.Where(x => x.InviterName.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)); } @@ -294,10 +233,8 @@ await FriendsService.AcceptFriendRequestAsync(request.Id, request.InviterId, request.InviteeId); await JSRuntime.InvokeVoidAsync("playNotificationSound"); NotificationService.Notify(Radzen.NotificationSeverity.Success, "Friend Request Accepted", duration: 4000); - StateHasChanged(); incomingRequests = incomingRequests.Where(r => r.Id != requestId).ToList(); SearchIncomingRequests(); // Reapply search filter to update the UI - NotificationService.Notify(Radzen.NotificationSeverity.Success, "Request Accepted", duration: 4000); } } diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/FriendsSearch.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/FriendsSearch.razor index 794a2b62b..b88b17c2b 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/FriendsSearch.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/FriendsSearch.razor @@ -1,20 +1,18 @@ @page "/friends/search" -@using MiniSpace.Services.Friends.Application.Dto @using MiniSpace.Web.HttpClients -@using Radzen.Blazor +@using MudBlazor @inject NavigationManager NavigationManager @using MiniSpace.Web.Areas.Friends @inject IFriendsService FriendsService @using MiniSpace.Web.DTO @using MiniSpace.Web.Areas.Identity -@using MiniSpace.Web.Areas.MediaFiles @inject IIdentityService IdentityService -@inject IMediaFilesService MediaFilesService @inject Radzen.NotificationService NotificationService @inject IJSRuntime JSRuntime -@using MudBlazor -@using Radzen + +
+ @@ -29,143 +27,78 @@
@if (!pageInitialized) {
- - - +
}
@foreach (var student in students) { -
- Student Image -
-
@student.FirstName @student.LastName
-

@student.Email

+
+
+ Student Image +
+
@student.FirstName @student.LastName
+

@student.Email

+
- @* *@ - @if (student.Id != IdentityService.GetCurrentUserId() && !sentRequests.Any(r => r.InviteeId == student.Id) && !allFriends.Any(f => f.FriendId == student.Id)) { - + + + Connect + } else if (allFriends.Any(f => f.FriendId == student.Id)) { - + + + Connected + } else if (sentRequests.Any(r => r.InviteeId == student.Id)) { - + + + Pending + } else if (incomingRequests.Any(r => r.InviteeId == IdentityService.GetCurrentUserId() && r.State == DTO.States.FriendState.Requested)) { - + + + Incoming Request + } else if (student.Id == IdentityService.GetCurrentUserId()) { - + + + It's You + }
} +
- -
- - @if (student != null) - { -
- -
-
- -
- Profile Image -
-

@student?.FirstName @student?.LastName

-
    -
  • ID: @student?.Id
  • -
  • Email: @student?.Email
  • -
  • Description: @student?.Description
  • -
  • Number of Friends: @student?.NumberOfFriends
  • -
  • Date of Birth: @student?.DateOfBirth.ToLocalTime().ToString("yyyy-MM-dd")
  • -
  • State: @student?.State
  • -
  • Created At: @student?.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")
  • -
-
- - @if (student.Id != IdentityService.GetCurrentUserId() && !sentRequests.Any(r => r.InviteeId == student.Id) && !allFriends.Any(f => f.FriendId == student.Id)) - { - - } - else if (allFriends.Any(f => f.FriendId == student.Id)) - { - - } - else if (sentRequests.Any(r => r.InviteeId == student.Id)) - { - - } - else if (incomingRequests.Any(r => r.InviteeId == IdentityService.GetCurrentUserId() && r.State == DTO.States.FriendState.Requested)) - { - - } - else if (student.Id == IdentityService.GetCurrentUserId()) - { - - } - -
-
- -
-
- } - else - { -
-

Select a student to view details.

-
- } -
-
-
- - - Page @currentPage of @(Math.Ceiling((double)totalStudents / pageSize)) - - + +
- +
+ @code { @@ -344,10 +217,10 @@ private int currentPage = 1; private int pageSize = 10; private int totalStudents; - private Dictionary images = new (); private IEnumerable allFriends; private IEnumerable incomingRequests; private bool pageInitialized; + private DotNetObjectReference _dotNetRef; protected override async Task OnInitializedAsync() { @@ -362,87 +235,44 @@ await LoadStudents(); pageInitialized = true; StateHasChanged(); - - var tasks = new List(); - foreach (var student in students) - { - tasks.Add(FetchImageAsync(student)); - } - await Task.WhenAll(tasks); + _dotNetRef = DotNetObjectReference.Create(this); + await JSRuntime.InvokeVoidAsync("infiniteScroll.initialize", _dotNetRef); } else { NavigationManager.NavigateTo("/login"); } } - - private async Task FetchImageAsync(StudentDto student) + + private string GetProfileImageUrl(string profileImageUrl) { - var result = await MediaFilesService.GetFileAsync(student.ProfileImage); - if (result != null) - { - if (images.ContainsKey(student.Id)) - { - images[student.Id] = result.Base64Content; - } - else - { - images.Add(student.Id, result.Base64Content); - } - } + return string.IsNullOrEmpty(profileImageUrl) ? "images/default_profile_image.webp" : profileImageUrl; } - - private async Task LoadStudents(string searchArgument = null) { - int maxPage = (int)Math.Ceiling((double)totalStudents / pageSize); - if (currentPage > maxPage) currentPage = maxPage; - if (currentPage < 1) currentPage = 1; + private async Task LoadStudents(string searchArgument = null) + { var response = await FriendsService.GetAllStudentsAsync(currentPage, pageSize, searchArgument); if (response != null) { - students = response.Results; + students.AddRange(response.Results); totalStudents = response.Total; - StateHasChanged(); - await LoadImagesForStudents(students); - } else { - students = new List(); - } - StateHasChanged(); - } - - private async Task LoadImagesForStudents(List students) { - images = new Dictionary(); // Clear previous images - var tasks = students.Select(student => FetchImageAsync(student)).ToList(); - await Task.WhenAll(tasks); - } - - private string GetImage(Guid studentId) - { - if (images.TryGetValue(studentId, out var image)) - { - return $"data:image/webp;base64,{image}"; + StateHasChanged(); } - - return "images/default_profile_image.webp"; } - - private void OnDetails(StudentDto selectedStudent) + [JSInvokable] + public async Task LoadMoreData() { - student = selectedStudent; - StateHasChanged(); + currentPage++; + await LoadStudents(searchTerm); } private async Task SearchFriends() { + currentPage = 1; + students.Clear(); await LoadStudents(searchTerm); } - @* private async Task ConnectWithStudent(Guid studentId) - { - var response = await FriendsService.AddFriendAsync(studentId); - - } *@ - private async Task ConnectWithStudent(Guid studentId, MouseEventArgs e) { var currentUserId = IdentityService.GetCurrentUserId(); @@ -460,20 +290,6 @@ StateHasChanged(); } - private async Task SetPage(int page) - { - @* Console.WriteLine($"Attempting to set page to {page}"); *@ - if (page < 1 || page > Math.Ceiling((double)totalStudents / pageSize)) { - @* Console.WriteLine("Page number out of range."); *@ - return; - } - currentPage = page; - @* Console.WriteLine($"Page set to {currentPage}"); *@ - student = null; - await LoadStudents(); - @* StateHasChanged(); *@ - } - private void ViewDetails(Guid studentId) { NavigationManager.NavigateTo($"/student-details/{studentId}"); diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/OLD_FRINED_SEARCH_BAD_PAGIANTION.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/OLD_FRINED_SEARCH_BAD_PAGIANTION.razor new file mode 100644 index 000000000..706b594ee --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/OLD_FRINED_SEARCH_BAD_PAGIANTION.razor @@ -0,0 +1,341 @@ +@page "/bad-friends/bad-search" +@using MiniSpace.Web.HttpClients +@using MudBlazor +@inject NavigationManager NavigationManager +@using MiniSpace.Web.Areas.Friends +@inject IFriendsService FriendsService +@using MiniSpace.Web.DTO +@using MiniSpace.Web.Areas.Identity +@inject IIdentityService IdentityService +@inject Radzen.NotificationService NotificationService +@inject IJSRuntime JSRuntime + + + + +@code { + private List _items = new List + { + new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), + new BreadcrumbItem("Search", href: "/friends/search", disabled: true, icon: Icons.Material.Filled.PersonSearch) + }; +} + +
+
+ + @if (!pageInitialized) + { +
+ +
+ } +
+ @foreach (var student in students) + { +
+
+ Student Image +
+
@student.FirstName @student.LastName
+

@student.Email

+
+
+ + + Details + + @if (student.Id != IdentityService.GetCurrentUserId() && !sentRequests.Any(r => r.InviteeId == student.Id) && !allFriends.Any(f => f.FriendId == student.Id)) + { + + + Connect + + } + else if (allFriends.Any(f => f.FriendId == student.Id)) + { + + + Connected + + } + else if (sentRequests.Any(r => r.InviteeId == student.Id)) + { + + + Pending + + } + else if (incomingRequests.Any(r => r.InviteeId == IdentityService.GetCurrentUserId() && r.State == DTO.States.FriendState.Requested)) + { + + + Incoming Request + + } + else if (student.Id == IdentityService.GetCurrentUserId()) + { + + + It's You + + } +
+
+
+ } +
+
+
+
+ + + Page @currentPage of @(Math.Ceiling((double)totalStudents / pageSize)) + + +
+
+ + + +@code { + private string searchTerm; + private List students = new List(); + private IEnumerable sentRequests; + private StudentDto student; + RadzenNotification notificationComponent; + private int currentPage = 1; + private int pageSize = 10; + private int totalStudents; + private IEnumerable allFriends; + private IEnumerable incomingRequests; + private bool pageInitialized; + + protected override async Task OnInitializedAsync() + { + await IdentityService.InitializeAuthenticationState(); + + if (IdentityService.IsAuthenticated) + { + sentRequests = await FriendsService.GetSentFriendRequestsAsync(); + incomingRequests = await FriendsService.GetIncomingFriendRequestsAsync(); + allFriends = await FriendsService.GetAllFriendsAsync(IdentityService.GetCurrentUserId()); + + await LoadStudents(); + pageInitialized = true; + StateHasChanged(); + } + else + { + NavigationManager.NavigateTo("/login"); + } + } + + private string GetProfileImageUrl(string profileImageUrl) + { + return string.IsNullOrEmpty(profileImageUrl) ? "images/default_profile_image.webp" : profileImageUrl; + } + + private async Task LoadStudents(string searchArgument = null) { + int maxPage = (int)Math.Ceiling((double)totalStudents / pageSize); + if (currentPage > maxPage) currentPage = maxPage; + if (currentPage < 1) currentPage = 1; + + var response = await FriendsService.GetAllStudentsAsync(currentPage, pageSize, searchArgument); + if (response != null) { + students = response.Results; + totalStudents = response.Total; + StateHasChanged(); + } else { + students = new List(); + } + StateHasChanged(); + } + + private void OnDetails(StudentDto selectedStudent) + { + student = selectedStudent; + StateHasChanged(); + } + + private async Task SearchFriends() { + await LoadStudents(searchTerm); + } + + private async Task ConnectWithStudent(Guid studentId, MouseEventArgs e) + { + var currentUserId = IdentityService.GetCurrentUserId(); + await FriendsService.InviteStudent(currentUserId, studentId); + + var student = students.FirstOrDefault(s => s.Id == studentId); + if (student != null) + { + student.InvitationSent = true; + student.IsInvitationPending = true; + } + sentRequests = await FriendsService.GetSentFriendRequestsAsync(); + NotificationService.Notify(Radzen.NotificationSeverity.Success, "Invitation Sent", "The invitation has been successfully sent.", 10000); + await JSRuntime.InvokeVoidAsync("playNotificationSound"); + StateHasChanged(); + } + + private async Task SetPage(int page) + { + if (page < 1 || page > Math.Ceiling((double)totalStudents / pageSize)) { + return; + } + currentPage = page; + student = null; + await LoadStudents(); + } + + private void ViewDetails(Guid studentId) + { + NavigationManager.NavigateTo($"/student-details/{studentId}"); + } + +} \ No newline at end of file diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/SentRequests.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/SentRequests.razor index 20d0071cb..80a34abc0 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/SentRequests.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/SentRequests.razor @@ -1,20 +1,19 @@ @page "/friends/sent-requests" @using MiniSpace.Web.HttpClients -@using Radzen.Blazor +@using MudBlazor @inject NavigationManager NavigationManager @using MiniSpace.Web.Areas.Friends @inject IFriendsService FriendsService @using MiniSpace.Web.DTO @using MiniSpace.Web.Areas.Identity -@using MiniSpace.Services.Friends.Application.Dto @inject IIdentityService IdentityService @inject Radzen.NotificationService NotificationService @inject IJSRuntime JSRuntime -@using MiniSpace.Web.Areas.MediaFiles -@inject IMediaFilesService MediaFilesService -@using MudBlazor -@using Radzen + +
+ + @@ -30,52 +29,52 @@ }; } -

Sent Friend Requests

-
-
- @if (sentRequests == null) - { -
- - - -
- } - else if (filteredSentRequests.Any()) + @if (sentRequests == null) + { +
+ +
+ } + else if (filteredSentRequests.Any()) + { + @foreach (var request in filteredSentRequests) { - @foreach (var request in filteredSentRequests) - { -
- @if (images.ContainsKey(request.InviteeId)) - { - Invitee Image - } +
+
+ Invitee Image
-
@request.InviteeName
-

@request.InviteeEmail

-

@request.RequestedAt.ToLocalTime().ToString("yyyy-MM-dd")

-

@request.State

- +
@request.InviteeName
+

@request.InviteeEmail

+

@request.RequestedAt.ToLocalTime().ToString("yyyy-MM-dd")

+

@request.State

+
+
+ + + Withdraw +
-
- - } - } - else - { -

No sent requests.

+
} -
+ } + else + { +

No sent requests.

+ }
+
+ @code { private string searchTerm; - private List students = new List(); private IEnumerable sentRequests; private IEnumerable filteredSentRequests; - private Dictionary images = new(); + private bool pageInitialized = false; protected override async Task OnInitializedAsync() { await IdentityService.InitializeAuthenticationState(); if (IdentityService.IsAuthenticated) - { + { sentRequests = await FriendsService.GetSentFriendRequestsAsync(); - - var inviteeIds = sentRequests.Select(r => r.InviteeId).Distinct(); - var studentTasks = inviteeIds.Select(id => FriendsService.GetStudentAsync(id)); - - students = (await Task.WhenAll(studentTasks)).ToList(); - - var imageTasks = students.Select(student => FetchImageAsync(student.Id, student.ProfileImage)); - await Task.WhenAll(imageTasks); - - foreach (var request in sentRequests) - { - var student = students.FirstOrDefault(s => s.Id == request.InviteeId); - if (student != null) - { - request.InviteeName = $"{student.FirstName} {student.LastName}"; - request.InviteeEmail = student.Email; - if (images.ContainsKey(student.Id)) - { - request.InviteeImage = images[student.Id]; - } - } - } - filteredSentRequests = sentRequests; + pageInitialized = true; } else { @@ -255,26 +207,15 @@ } } - private async Task FetchImageAsync(Guid inviteeId, Guid profileImage) - { - var result = await MediaFilesService.GetFileAsync(profileImage); - images[inviteeId] = result.Base64Content; - } - - private string GetImage(Guid studentId) + private string GetProfileImageUrl(string profileImageUrl) { - if (images.TryGetValue(studentId, out var image)) - { - return $"data:image/webp;base64,{image}"; - } - - return "images/default_profile_image.webp"; + return string.IsNullOrEmpty(profileImageUrl) ? "images/default_profile_image.webp" : profileImageUrl; } private void SearchSentRequests() { - filteredSentRequests = string.IsNullOrWhiteSpace(searchTerm) - ? sentRequests + filteredSentRequests = string.IsNullOrWhiteSpace(searchTerm) + ? sentRequests : sentRequests.Where(x => x.InviteeName.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)); } @@ -294,7 +235,6 @@ } else { - // Optionally show an error notification NotificationService.Notify(Radzen.NotificationSeverity.Error, "Error", "Invalid user ID."); } } diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/StudentDetails.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/StudentDetails.razor new file mode 100644 index 000000000..a54af09a9 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Friends/StudentDetails.razor @@ -0,0 +1,243 @@ +@page "/student-details/{Id:guid}" +@using MiniSpace.Web.Areas.Friends +@inject NavigationManager NavigationManager +@inject IFriendsService FriendsService +@using MiniSpace.Web.Areas.MediaFiles +@inject IMediaFilesService MediaFilesService +@using MiniSpace.Web.Models.Reports +@using MiniSpace.Web.Pages.Reports.Dialogs +@using DialogOptions = Radzen.DialogOptions +@using DialogService = Radzen.DialogService +@inject DialogService DialogService +@inject IIdentityService IdentityService +@using MiniSpace.Web.DTO +@using MudBlazor + + +
+ + + @if (studentNotFound) + { +

Student profile not found!

+

Probably has been deleted!

+ } + else if (student == null) + { + + } + else + { +
+ +
+ Profile Image +

@student.FirstName @student.LastName

+

@student.Email

+
+
+

Description: @student.Description

+ @if (!string.IsNullOrEmpty(student.Education)) + { +

Education: @student.Education

+ } + @if (!string.IsNullOrEmpty(student.WorkPosition)) + { +

Work Position: @student.WorkPosition

+ } + @if (!string.IsNullOrEmpty(student.Company)) + { +

Company: @student.Company

+ } + @if (student.Languages != null && student.Languages.Any()) + { +

Languages: @string.Join(", ", student.Languages)

+ } + @if (student.Interests != null && student.Interests.Any()) + { +

Interests: @string.Join(", ", student.Interests)

+ } +

Number of Friends: @student.NumberOfFriends

+

Date of Birth: @student.DateOfBirth.ToLocalTime().ToString("yyyy-MM-dd")

+

State: @student.State

+

Joined: @student.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")

+
+ @if (IdentityService.IsAuthenticated) + { + + Report profile + + } +
+ +

Gallery

+ + Gallery Images + + @if (student.GalleryOfImageUrls != null && student.GalleryOfImageUrls.Any(IsValidImageUrl)) + { + @foreach (var imageUrl in student.GalleryOfImageUrls.Where(IsValidImageUrl)) + { + + + + + + + + } + } + else + { + No gallery images available. + } + + + } +
+
+ + + +@code { + [Parameter] public Guid Id { get; set; } + private StudentDto student; + private bool studentNotFound; + private List _items; + + protected override async Task OnInitializedAsync() + { + _items = new List + { + new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), + new BreadcrumbItem("Search", href: "/friends/search", icon: Icons.Material.Filled.PersonSearch), + new BreadcrumbItem("Friends", href: "/friends", icon: Icons.Material.Filled.LibraryAddCheck), + new BreadcrumbItem("Requests", href: "/friends/requests", icon: Icons.Material.Filled.GroupAdd), + new BreadcrumbItem("Sent Requests", href: "/friends/sent-requests", icon: Icons.Material.Filled.PersonAddAlt1), + new BreadcrumbItem("Student details", href: $"/student-details/{Id}", disabled: true, icon: Icons.Material.Filled.Person) + }; + + await IdentityService.InitializeAuthenticationState(); + if (IdentityService.IsAuthenticated) + { + student = await FriendsService.GetStudentAsync(Id); + if (student == null) + { + studentNotFound = true; + return; + } + } + else + { + NavigationManager.NavigateTo("/login"); + } + } + + private string GetProfileImageUrl(string profileImageUrl) + { + return string.IsNullOrEmpty(profileImageUrl) ? "images/default_profile_image.webp" : profileImageUrl; + } + + private bool IsValidImageUrl(string url) + { + if (string.IsNullOrEmpty(url)) + return false; + + string[] validExtensions = { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; + string extension = System.IO.Path.GetExtension(url)?.ToLower(); + return validExtensions.Contains(extension); + } + + private async Task ReportStudentProfile(StudentDto studentDto) + { + var createReportModel = new CreateReportModel + { + IssuerId = IdentityService.GetCurrentUserId(), + TargetId = studentDto.Id, + TargetOwnerId = studentDto.Id, + ContextType = "StudentProfile" + }; + + await DialogService.OpenAsync("Report profile of the student:", + new Dictionary() { { "CreateReportModel", createReportModel } }, + new DialogOptions() + { + Width = "700px", Height = "350px", Resizable = true, Draggable = true, + AutoFocusFirstElement = false + }); + } +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Home.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Home.razor deleted file mode 100644 index 298856348..000000000 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Home.razor +++ /dev/null @@ -1,90 +0,0 @@ -@page "/home" -@using MiniSpace.Web.DTO -@using MiniSpace.Web.Areas.Posts -@using MiniSpace.Web.Data.Posts -@using MiniSpace.Web.Components -@using MiniSpace.Web.DTO.Wrappers -@using Radzen -@using System.Globalization -@inject NavigationManager NavigationManager -@inject IIdentityService IdentityService -@inject IPostsService PostsService - -

Discover What's New

- -@if (pageInitialized) -{ - @if (posts.Any()) - { - - - - - - } - else - { -

No activity found

- - } -} - - - - -@code { - private IEnumerable posts; - private Guid studentId; - private bool pageInitialized = false; - private SearchPosts searchModel; - - protected override async Task OnInitializedAsync() - { - if (IdentityService != null && IdentityService.IsAuthenticated) - { - studentId = IdentityService.GetCurrentUserId(); - searchModel = InitializeSearchModel(studentId); - var result = await PostsService.SearchPostsAsync(searchModel); - posts = result.Content.Content; - pageInitialized = true; - } - else - { - NavigationManager.NavigateTo(""); - - } - } - - private static SearchPosts InitializeSearchModel(Guid studentId) - { - return new() - { - StudentId = studentId, - Pageable = new PageableDto() - { - Page = 1, - Size = 8, - Sort = new SortDto() - { - SortBy = new List() {"publishDate"}, - Direction = "des" - } - } - }; - } -} diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Index.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Index.razor index bd9c829fb..636f670fd 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Index.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Index.razor @@ -5,7 +5,7 @@ - + @foreach (var img in images) { @@ -65,7 +65,7 @@
- +
diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/AllNotifications.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/AllNotifications.razor index 12d267ac2..9baa76747 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/AllNotifications.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/AllNotifications.razor @@ -8,6 +8,7 @@ @inject IIdentityService IdentityService @using MudBlazor + @@ -73,7 +74,7 @@ else {

No notifications found.

} - +
@code { private List notifications; private int currentPage = 1; diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/HistoryNotifications.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/HistoryNotifications.razor index b983702ce..33cbb9639 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/HistoryNotifications.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/HistoryNotifications.razor @@ -8,6 +8,7 @@ @inject IIdentityService IdentityService @using MudBlazor + @@ -72,7 +73,7 @@ else {

No notifications found.

} - +
@code { private List notifications; private int currentPage = 1; diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/NewNotifications.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/NewNotifications.razor index 8ebeb1865..adf3e8c14 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/NewNotifications.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/NewNotifications.razor @@ -8,6 +8,7 @@ @inject IIdentityService IdentityService @using MudBlazor + @@ -70,6 +71,7 @@ else {

No notifications found.

} +
@code { private List notifications; private int currentPage = 1; diff --git a/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/Notification.razor b/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/Notification.razor index fdcabfb4a..f137b2d25 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/Notification.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/Pages/Notifications/Notification.razor @@ -7,6 +7,7 @@ @using System.Globalization @using MudBlazor + @@ -59,6 +60,7 @@ + + @code { - private List previousNotifications = new List(); - private List notifications; - private Timer timer; - private DateTime lastCheckedTime = DateTime.MinValue; - private int timezoneOffset; + private List notifications = new List(); + private SignalRService SignalRService; + private Guid userId; + private bool showNotifications = false; + private bool hasNewNotification = false; protected override async Task OnInitializedAsync() { + userId = IdentityService.GetCurrentUserId(); + SignalRService = new SignalRService(NavigationManager, IdentityService); + + SignalRService.NotificationReceived += OnNotificationReceived; + + await SignalRService.StartAsync(userId); + + await JSRuntime.InvokeVoidAsync("setUserId", userId.ToString()); + await LoadNotifications(); - var timezoneOffset = await JSRuntime.InvokeAsync("getUserTimezoneOffset"); - // Setup a timer to refresh notifications every 15 seconds - timer = new Timer(new TimerCallback(_ => InvokeAsync(LoadNotifications)), null, 0, 15000); } private async Task LoadNotifications() { try { - var userId = IdentityService.GetCurrentUserId(); - var paginatedResponse = await NotificationsService.GetNotificationsByUserAsync(userId, status: "Unread"); - var latestNotifications = paginatedResponse.Results; - - if (latestNotifications.Any(n => n.CreatedAt > lastCheckedTime)) - { - PlayNotificationSound(); - await JSRuntime.InvokeVoidAsync("updateTitle", "New Notification - MiniSpace | Social"); - lastCheckedTime = DateTime.Now; - } - - notifications = latestNotifications; - StateHasChanged(); + var paginatedResponse = await NotificationsService.GetNotificationsByUserAsync(userId, pageSize: 15, sortOrder: "desc", status: "Unread"); + notifications = paginatedResponse.Results; } catch (Exception ex) { - Console.WriteLine($"Error loading notifications: {ex.Message}"); + Logger.LogError($"Error loading notifications: {ex.Message}"); } } - - private bool isNewNotificationReceived() + private void OnNotificationReceived(NotificationDto notification) { - if (previousNotifications == null || !previousNotifications.Any()) - { - return notifications.Any(); - } - else + Logger.LogInformation($"Received notification for user {notification.UserId}"); + if (notification.UserId == userId) { - return notifications.Any(n => !previousNotifications.Any(p => p.NotificationId == n.NotificationId)); + notifications.Insert(0, notification); // Add to the top of the list + hasNewNotification = true; + InvokeAsync(StateHasChanged); // Ensure the UI updates + PlayNotificationSound(); } } private void PlayNotificationSound() { - JSRuntime.InvokeVoidAsync("playNotificationSoundNotificationsService"); + JSRuntime.InvokeVoidAsync("playNotificationSound"); } private void NavigateToNotificationDetail(Guid notificationId) @@ -104,19 +191,23 @@ return message.Length > 80 ? message.Substring(0, 80) + "..." : message; } - public void Dispose() + private void ToggleNotifications() { - timer?.Dispose(); + showNotifications = !showNotifications; + if (showNotifications) + { + hasNewNotification = false; + } } - private MarkupString RenderHtml(string htmlContent) + public async ValueTask DisposeAsync() { - return new MarkupString(htmlContent); + SignalRService.NotificationReceived -= OnNotificationReceived; + await SignalRService.StopAsync(); } - private DateTime ToLocalTime(DateTime utcDate, int offsetMinutes) + private MarkupString RenderHtml(string htmlContent) { - return utcDate.AddMinutes(-offsetMinutes); + return new MarkupString(htmlContent); } - } diff --git a/MiniSpace.Web/src/MiniSpace.Web/Shared/themes/MiniSpaceTheme.cs b/MiniSpace.Web/src/MiniSpace.Web/Shared/themes/MiniSpaceTheme.cs new file mode 100644 index 000000000..207facc07 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/Shared/themes/MiniSpaceTheme.cs @@ -0,0 +1,15 @@ +using MudBlazor; + +public static class MiniSpaceTheme +{ + public static MudTheme MiniSpaceCustomTheme = new MudTheme() + { + Palette = new Palette() + { + Primary = Colors.Indigo.Darken4, + Secondary = Colors.Indigo.Darken3, + AppbarBackground = Colors.Shades.White, + Background = Colors.Grey.Lighten5 + } + }; +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/Startup.cs b/MiniSpace.Web/src/MiniSpace.Web/Startup.cs index 4fbf3ea1e..b56eb7494 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/Startup.cs +++ b/MiniSpace.Web/src/MiniSpace.Web/Startup.cs @@ -28,7 +28,7 @@ using MiniSpace.Web.Areas.MediaFiles; using MiniSpace.Web.Areas.Reactions; using MiniSpace.Web.Areas.Reports; - +using Microsoft.AspNetCore.Server.Kestrel.Core; namespace MiniSpace.Web { @@ -61,9 +61,35 @@ public void ConfigureServices(IServiceCollection services) services.AddBlazoredLocalStorage(); + services.AddServerSideBlazor() + .AddHubOptions(options => + { + options.MaximumReceiveMessageSize = 32 * 1024 * 1024; // 32 MB + }); + + services.Configure(options => + { + options.MaxRequestBodySize = 32 * 1024 * 1024; + }); + + services.Configure(options => + { + options.Limits.MaxRequestBodySize = 32 * 1024 * 1024; + }); services.AddScoped(); services.AddScoped(); + + services.AddServerSideBlazor() + .AddCircuitOptions(options => + { + options.DetailedErrors = true; + }) + .AddHubOptions(options => + { + options.MaximumReceiveMessageSize = 32 * 1024 * 1024; // 32 MB + }); + services.AddScoped(); @@ -76,13 +102,13 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); } - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) @@ -92,7 +118,6 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) else { app.UseExceptionHandler("/Error"); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } @@ -102,13 +127,13 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseRouting(); app.UseAuthorization(); + app.UseEndpoints(endpoints => { endpoints.MapBlazorHub(); endpoints.MapFallbackToPage("/_Host"); }); - } } } diff --git a/MiniSpace.Web/src/MiniSpace.Web/_Imports.razor b/MiniSpace.Web/src/MiniSpace.Web/_Imports.razor index 6f2381777..01a332d01 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/_Imports.razor +++ b/MiniSpace.Web/src/MiniSpace.Web/_Imports.razor @@ -10,6 +10,4 @@ @using MiniSpace.Web.Models.Identity @using MiniSpace.Web.Areas.Identity @using Radzen.Blazor -@using MiniSpace.Web.Areas.Identity - - +@using Cropper.Blazor.Components diff --git a/MiniSpace.Web/src/MiniSpace.Web/wwwroot/css/site.css b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/css/site.css index 51b363406..18777ada0 100644 --- a/MiniSpace.Web/src/MiniSpace.Web/wwwroot/css/site.css +++ b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/css/site.css @@ -11,7 +11,7 @@ html, body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; } * { - margin: unset !important; + } a, .btn-link { @@ -445,7 +445,7 @@ html, body { background-color: #f9f9f9; border-radius: 5px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); - margin: 20px !important; + margin: 20px 0 20px 0 !important; margin-top: 30px !important; } @@ -590,3 +590,70 @@ html, body { +/* .notification-panel { + position: fixed; + top: 10%; + right: 0; + width: 300px; + max-height: 80vh; + overflow-y: auto; + background-color: #fff; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + border-radius: 5px; + z-index: 1000; + transition: transform 0.3s ease-in-out; +} + +.notification-panel.hidden { + transform: translateX(100%); +} */ + + +.friend-img { + width: 100px; + height: 100px; + border-radius: 50%; + margin-right: 20px; + object-fit: cover; + border: 3px solid #007BFF; +} + +@media only screen and (max-width: 768px) { + .friend-img { + width: 50px !important; + height: 50px !important; + margin-right: 10px; + } +} + + + +@media (max-width: 600px) { + .account-container { + padding: 0 5px; + } + + .profile-image { + height: 100px; + width: 100px; + } +} + +@media (max-width: 600px) { + .small-button { + padding: 0.15rem 0.3rem !important; + font-size: 0.65rem !important; + } + .button-text { + display: none !important; + } +} + + +.center-container { + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + max-width: 900px !important; + margin: auto !important; +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/wwwroot/icon-192.png b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/icon-192.png new file mode 100644 index 000000000..166f56da7 Binary files /dev/null and b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/icon-192.png differ diff --git a/MiniSpace.Web/src/MiniSpace.Web/wwwroot/icon-512.png b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/icon-512.png new file mode 100644 index 000000000..c2dd4842d Binary files /dev/null and b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/icon-512.png differ diff --git a/MiniSpace.Web/src/MiniSpace.Web/wwwroot/js/cropperInterop.js b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/js/cropperInterop.js new file mode 100644 index 000000000..b77cdff67 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/js/cropperInterop.js @@ -0,0 +1,94 @@ +// Ensure GLOBAL is defined and accessible +var GLOBAL = GLOBAL || {}; + +var cropper = null; + +// Function to set the DotNetReference from Blazor +GLOBAL.SetDotnetReference = function(dotNetReference) { + GLOBAL.DotNetReference = dotNetReference; +}; + +// Function to display the selected image and initialize the cropper and buttons +function displayImageAndInitializeCropper(base64String) { + var imageContainer = document.getElementById('image-container'); + imageContainer.innerHTML = ` +
+ + + +
+ `; + + initializeCropper('image-to-crop', 16 / 9); + + document.getElementById('crop-image').addEventListener('click', function() { + getCroppedImage('ReceiveCroppedImage'); + }); + + document.getElementById('save-image').addEventListener('click', function() { + if (GLOBAL.DotNetReference) { + GLOBAL.DotNetReference.invokeMethodAsync('SaveCroppedImage'); + } else { + console.error('DotNet reference not set.'); + } + }); +} + +// Function to initialize the cropper on a specified image element with an aspect ratio +function initializeCropper(imageId, aspectRatio) { + var imageElement = document.getElementById(imageId); + if (cropper) { + cropper.destroy(); + } + cropper = new Cropper(imageElement, { + aspectRatio: aspectRatio, + viewMode: 2, + autoCropArea: 1, + restore: false, + guides: true, + center: true, + highlight: true, + cropBoxMovable: true, + cropBoxResizable: true + }); +} + +// Function to get the cropped image and invoke a C# method asynchronously via JSInterop +function getCroppedImage(callbackMethodName) { + var croppedCanvas = cropper.getCroppedCanvas(); + croppedCanvas.toBlob(function(blob) { + var reader = new FileReader(); + reader.onload = function() { + if (GLOBAL.DotNetReference) { + GLOBAL.DotNetReference.invokeMethodAsync(callbackMethodName, reader.result); + document.getElementById('save-image').style.display = 'inline-block'; + } else { + console.error('DotNet reference not set.'); + } + }; + reader.readAsDataURL(blob); + }); +} + +// Function to cleanly destroy the cropper instance +function destroyCropper() { + if (cropper) { + cropper.destroy(); + cropper = null; + } +} + +// Attach event listener to file input for image upload +document.addEventListener('DOMContentLoaded', function () { + var fileInput = document.getElementById('file-input'); + fileInput.addEventListener('change', function (event) { + var file = event.target.files[0]; + if (file && file.type.match('image.*')) { + var reader = new FileReader(); + reader.onload = function (e) { + displayImageAndInitializeCropper(e.target.result.split(',')[1]); + }; + reader.readAsDataURL(file); + } + }); +}); diff --git a/MiniSpace.Web/src/MiniSpace.Web/wwwroot/js/infiniteScroll.js b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/js/infiniteScroll.js new file mode 100644 index 000000000..6363dce82 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/js/infiniteScroll.js @@ -0,0 +1,19 @@ +window.infiniteScroll = { + initialize: function (dotnetHelper) { + let options = { + root: null, + rootMargin: "0px", + threshold: 0.1 + }; + + let observer = new IntersectionObserver((entries, observer) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + dotnetHelper.invokeMethodAsync("LoadMoreData"); + } + }); + }, options); + + observer.observe(document.querySelector('#end-of-list')); + } +}; diff --git a/MiniSpace.Web/src/MiniSpace.Web/wwwroot/manifest.webmanifest b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/manifest.webmanifest new file mode 100644 index 000000000..361f5cca8 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/manifest.webmanifest @@ -0,0 +1,22 @@ +{ + "name": "MiniSpacePwa", + "short_name": "MiniSpacePwa", + "id": "./", + "start_url": "./", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#03173d", + "prefer_related_applications": false, + "icons": [ + { + "src": "icon-512.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "icon-192.png", + "type": "image/png", + "sizes": "192x192" + } + ] +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/wwwroot/service-worker.js b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/service-worker.js new file mode 100644 index 000000000..d8c868a0c --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/service-worker.js @@ -0,0 +1,19 @@ +// In development, always fetch from the network and do not enable offline support. +// This is because caching would make development more difficult (changes would not +// be reflected on the first load after each change). +// self.addEventListener('fetch', () => { }); +self.addEventListener('install', event => { + console.log('Service worker installing...'); + // Add a call to skipWaiting here + self.skipWaiting(); + }); + + self.addEventListener('activate', event => { + console.log('Service worker activating...'); + }); + + self.addEventListener('fetch', event => { + console.log('Fetching:', event.request.url); + // Add fetch event handler here + }); + \ No newline at end of file diff --git a/MiniSpace.Web/src/MiniSpace.Web/wwwroot/service-worker.published.js b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/service-worker.published.js new file mode 100644 index 000000000..003e3e78d --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/service-worker.published.js @@ -0,0 +1,55 @@ +// Caution! Be sure you understand the caveats before publishing an application with +// offline support. See https://aka.ms/blazor-offline-considerations + +self.importScripts('./service-worker-assets.js'); +self.addEventListener('install', event => event.waitUntil(onInstall(event))); +self.addEventListener('activate', event => event.waitUntil(onActivate(event))); +self.addEventListener('fetch', event => event.respondWith(onFetch(event))); + +const cacheNamePrefix = 'offline-cache-'; +const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; +const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ]; +const offlineAssetsExclude = [ /^service-worker\.js$/ ]; + +// Replace with your base path if you are hosting on a subfolder. Ensure there is a trailing '/'. +const base = "/"; +const baseUrl = new URL(base, self.origin); +const manifestUrlList = self.assetsManifest.assets.map(asset => new URL(asset.url, baseUrl).href); + +async function onInstall(event) { + console.info('Service worker: Install'); + + // Fetch and cache all matching items from the assets manifest + const assetsRequests = self.assetsManifest.assets + .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url))) + .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url))) + .map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' })); + await caches.open(cacheName).then(cache => cache.addAll(assetsRequests)); +} + +async function onActivate(event) { + console.info('Service worker: Activate'); + + // Delete unused caches + const cacheKeys = await caches.keys(); + await Promise.all(cacheKeys + .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName) + .map(key => caches.delete(key))); +} + +async function onFetch(event) { + let cachedResponse = null; + if (event.request.method === 'GET') { + // For all navigation requests, try to serve index.html from cache, + // unless that request is for an offline resource. + // If you need some URLs to be server-rendered, edit the following check to exclude those URLs + const shouldServeIndexHtml = event.request.mode === 'navigate' + && !manifestUrlList.some(url => url === event.request.url); + + const request = shouldServeIndexHtml ? 'index.html' : event.request; + const cache = await caches.open(cacheName); + cachedResponse = await cache.match(request); + } + + return cachedResponse || fetch(event.request); +} diff --git a/MiniSpace.Web/src/MiniSpace.Web/wwwroot/sounds/new-notification1.mp3 b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/sounds/new-notification1.mp3 new file mode 100644 index 000000000..4cb33947a Binary files /dev/null and b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/sounds/new-notification1.mp3 differ diff --git a/MiniSpace.Web/src/MiniSpace.Web/wwwroot/spotlight.bundle.js b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/spotlight.bundle.js new file mode 100644 index 000000000..810ed728e --- /dev/null +++ b/MiniSpace.Web/src/MiniSpace.Web/wwwroot/spotlight.bundle.js @@ -0,0 +1,28 @@ +/** + * Spotlight.js v0.7.8 (Bundle) + * Copyright 2019-2021 Nextapps GmbH + * Author: Thomas Wilkerling + * Licence: Apache-2.0 + * https://github.com/nextapps-de/spotlight + */ +(function(){'use strict';var aa=document.createElement("style");aa.innerHTML="@keyframes pulsate{0%,to{opacity:1}50%{opacity:.2}}#spotlight{position:fixed;top:-1px;bottom:-1px;width:100%;z-index:99999;color:#fff;background-color:#000;opacity:0;overflow:hidden;-webkit-user-select:none;-ms-user-select:none;user-select:none;transition:opacity .2s ease-out;font-family:Arial,sans-serif;font-size:16px;font-weight:400;contain:strict;touch-action:none;pointer-events:none}#spotlight.show{opacity:1;transition:none;pointer-events:auto}#spotlight.white{color:#212529;background-color:#fff}#spotlight.white .spl-next,#spotlight.white .spl-page~*,#spotlight.white .spl-prev,#spotlight.white .spl-spinner{filter:invert(1)}#spotlight.white .spl-progress{background-color:rgba(0,0,0,.35)}#spotlight.white .spl-footer,#spotlight.white .spl-header{background-color:rgba(255,255,255,.65)}#spotlight.white .spl-button{background:#212529;color:#fff}.spl-footer,.spl-header{background-color:rgba(0,0,0,.45)}#spotlight .contain,#spotlight .cover{object-fit:cover;height:100%;width:100%}#spotlight .contain{object-fit:contain}#spotlight .autofit{object-fit:none;width:auto;height:auto;max-height:none;max-width:none;transition:none}.spl-scene,.spl-spinner,.spl-track{width:100%;height:100%;position:absolute}.spl-track{contain:strict}.spl-spinner{background-position:center;background-repeat:no-repeat;background-size:42px;opacity:0}.spl-spinner.spin{background-image:url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzgiIGhlaWdodD0iMzgiIHZpZXdCb3g9IjAgMCAzOCAzOCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBzdHJva2U9IiNmZmYiPjxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMSAxKSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2Utb3BhY2l0eT0iLjY1Ij48Y2lyY2xlIHN0cm9rZS1vcGFjaXR5PSIuMTUiIGN4PSIxOCIgY3k9IjE4IiByPSIxOCIvPjxwYXRoIGQ9Ik0zNiAxOGMwLTkuOTQtOC4wNi0xOC0xOC0xOCI+PGFuaW1hdGVUcmFuc2Zvcm0gYXR0cmlidXRlTmFtZT0idHJhbnNmb3JtIiB0eXBlPSJyb3RhdGUiIGZyb209IjAgMTggMTgiIHRvPSIzNjAgMTggMTgiIGR1cj0iMXMiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIi8+PC9wYXRoPjwvZz48L2c+PC9zdmc+);transition:opacity .2s linear .25s;opacity:1}.spl-spinner.error{background-image:url(data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjMyIiB3aWR0aD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTYsMUExNSwxNSwwLDEsMCwzMSwxNiwxNSwxNSwwLDAsMCwxNiwxWm0wLDJhMTMsMTMsMCwwLDEsOC40NSwzLjE0TDYuMTQsMjQuNDVBMTMsMTMsMCwwLDEsMTYsM1ptMCwyNmExMywxMywwLDAsMS04LjQ1LTMuMTRMMjUuODYsNy41NUExMywxMywwLDAsMSwxNiwyOVoiIGlkPSJiYW5fc2lnbl9jcm9zc2VkX2NpcmNsZSIvPjwvc3ZnPg==);background-size:128px;transition:none;opacity:.5}.spl-scene{transition:transform .65s cubic-bezier(.1,1,.1,1);contain:layout size;will-change:transform}.spl-pane>*{position:absolute;width:auto;height:auto;max-width:100%;max-height:100%;left:50%;top:50%;margin:0;padding:0;border:0;transform:translate(-50%,-50%) scale(1);transition:transform .65s cubic-bezier(.3,1,.3,1),opacity .65s ease;contain:layout style;will-change:transform,opacity;visibility:hidden}.spl-header,.spl-pane,.spl-progress{position:absolute;top:0}.spl-pane{width:100%;height:100%;transition:transform .65s cubic-bezier(.3,1,.3,1);contain:layout size;will-change:transform,contents}.spl-header{width:100%;height:50px;text-align:right;transform:translateY(-100px);transition:transform .35s ease;overflow:hidden;will-change:transform}#spotlight.menu .spl-footer,#spotlight.menu .spl-header,.spl-footer:hover,.spl-header:hover{transform:translateY(0)}.spl-header div{display:inline-block;vertical-align:middle;white-space:nowrap;width:50px;height:50px;opacity:.5}.spl-progress{width:100%;height:3px;background-color:rgba(255,255,255,.45);transform:translateX(-100%);transition:transform linear}.spl-footer,.spl-next,.spl-prev{position:absolute;transition:transform .35s ease;will-change:transform}.spl-footer{left:0;right:0;bottom:0;line-height:20px;padding:20px 20px 0;padding-bottom:env(safe-area-inset-bottom,0);text-align:left;font-size:15px;font-weight:400;transform:translateY(100%)}.spl-title{font-size:22px}.spl-button,.spl-description,.spl-title{margin-bottom:20px}.spl-button{display:inline-block;background:#fff;color:#000;border-radius:5px;padding:10px 20px;cursor:pointer}.spl-next,.spl-page~*,.spl-prev{background-position:center;background-repeat:no-repeat}.spl-page{float:left;width:auto;line-height:50px}.spl-page~*{background-size:21px;float:right}.spl-fullscreen{background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+PHN2ZyBmaWxsPSJub25lIiBoZWlnaHQ9IjI0IiBzdHJva2U9IiNmZmYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyLjUiIHZpZXdCb3g9Ii0xIC0xIDI2IDI2IiB3aWR0aD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTggM0g1YTIgMiAwIDAgMC0yIDJ2M20xOCAwVjVhMiAyIDAgMCAwLTItMmgtM20wIDE4aDNhMiAyIDAgMCAwIDItMnYtM00zIDE2djNhMiAyIDAgMCAwIDIgMmgzIi8+PC9zdmc+)}.spl-fullscreen.on{background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+PHN2ZyBmaWxsPSJub25lIiBoZWlnaHQ9IjI0IiBzdHJva2U9IiNmZmYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyLjUiIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik04IDN2M2EyIDIgMCAwIDEtMiAySDNtMTggMGgtM2EyIDIgMCAwIDEtMi0yVjNtMCAxOHYtM2EyIDIgMCAwIDEgMi0yaDNNMyAxNmgzYTIgMiAwIDAgMSAyIDJ2MyIvPjwvc3ZnPg==)}.spl-autofit{background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+PHN2ZyBoZWlnaHQ9Ijk2cHgiIHZpZXdCb3g9IjAgMCA5NiA5NiIgd2lkdGg9Ijk2cHgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggdHJhbnNmb3JtPSJyb3RhdGUoOTAgNTAgNTApIiBmaWxsPSIjZmZmIiBkPSJNNzEuMzExLDgwQzY5LjY3LDg0LjY2LDY1LjIzLDg4LDYwLDg4SDIwYy02LjYzLDAtMTItNS4zNy0xMi0xMlYzNmMwLTUuMjMsMy4zNC05LjY3LDgtMTEuMzExVjc2YzAsMi4yMSwxLjc5LDQsNCw0SDcxLjMxMSAgeiIvPjxwYXRoIHRyYW5zZm9ybT0icm90YXRlKDkwIDUwIDUwKSIgZmlsbD0iI2ZmZiIgZD0iTTc2LDhIMzZjLTYuNjMsMC0xMiw1LjM3LTEyLDEydjQwYzAsNi42Myw1LjM3LDEyLDEyLDEyaDQwYzYuNjMsMCwxMi01LjM3LDEyLTEyVjIwQzg4LDEzLjM3LDgyLjYzLDgsNzYsOHogTTgwLDYwICBjMCwyLjIxLTEuNzksNC00LDRIMzZjLTIuMjEsMC00LTEuNzktNC00VjIwYzAtMi4yMSwxLjc5LTQsNC00aDQwYzIuMjEsMCw0LDEuNzksNCw0VjYweiIvPjwvc3ZnPg==)}.spl-zoom-in,.spl-zoom-out{background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+PHN2ZyBmaWxsPSJub25lIiBoZWlnaHQ9IjI0IiBzdHJva2U9IiNmZmYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIiB2aWV3Qm94PSIwIDAgMjQgMjQiIHdpZHRoPSIyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48Y2lyY2xlIGN4PSIxMSIgY3k9IjExIiByPSI4Ii8+PGxpbmUgeDE9IjIxIiB4Mj0iMTYuNjUiIHkxPSIyMSIgeTI9IjE2LjY1Ii8+PGxpbmUgeDE9IjgiIHgyPSIxNCIgeTE9IjExIiB5Mj0iMTEiLz48L3N2Zz4=);background-size:22px}.spl-zoom-in{background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+PHN2ZyBmaWxsPSJub25lIiBoZWlnaHQ9IjI0IiBzdHJva2U9IiNmZmYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIiB2aWV3Qm94PSIwIDAgMjQgMjQiIHdpZHRoPSIyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48Y2lyY2xlIGN4PSIxMSIgY3k9IjExIiByPSI4Ii8+PGxpbmUgeDE9IjIxIiB4Mj0iMTYuNjUiIHkxPSIyMSIgeTI9IjE2LjY1Ii8+PGxpbmUgeDE9IjExIiB4Mj0iMTEiIHkxPSI4IiB5Mj0iMTQiLz48bGluZSB4MT0iOCIgeDI9IjE0IiB5MT0iMTEiIHkyPSIxMSIvPjwvc3ZnPg==)}.spl-download{background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjxzdmcgaGVpZ2h0PSIxNDEuNzMycHgiIHZlcnNpb249IjEuMSIgdmlld0JveD0iMCAwIDE0MS43MzIgMTQxLjczMiIgd2lkdGg9IjE0MS43MzJweCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjZmZmIj48cGF0aCBkPSJNMTIwLjY3NCwxMjUuMTM4SDIwLjc5M3YxNi41OTRoOTkuODgxVjEyNS4xMzh6IE0xMTkuMDE5LDU4Ljc3NmMtMi41NjEtMi41NjItNi43MTYtMi41NjItOS4yNzUsMEw3Ny4yMSw5MS4zMTJWNi41NjIgICBDNzcuMjEsMi45MzYsNzQuMjY5LDAsNzAuNjQ4LDBjLTMuNjI0LDAtNi41NiwyLjkzNy02LjU2LDYuNTYzdjg0Ljc1TDMxLjk5Miw1OS4yMThjLTIuNTYyLTIuNTY0LTYuNzE1LTIuNTY0LTkuMjc3LDAgICBjLTIuNTY1LDIuNTYyLTIuNTY1LDYuNzE2LDAsOS4yNzlsNDMuMjk0LDQzLjI5M2MwLjE1LDAuMTU0LDAuMzE0LDAuMjk5LDAuNDgxLDAuNDM4YzAuMDc2LDAuMDYyLDAuMTU1LDAuMTEzLDAuMjM0LDAuMTc2ICAgYzAuMDk0LDAuMDY1LDAuMTg2LDAuMTQyLDAuMjc5LDAuMjA2YzAuMDk3LDAuMDYzLDAuMTkyLDAuMTE0LDAuMjg2LDAuMTc0YzAuMDg4LDAuMDU0LDAuMTc0LDAuMTA1LDAuMjY1LDAuMTUzICAgYzAuMSwwLjA1NiwwLjE5OSwwLjEsMC4yOTgsMC4xNDdjMC4wOTcsMC4wNDUsMC4xOSwwLjA5MSwwLjI4MywwLjEzMmMwLjA5OCwwLjA0LDAuMTk2LDAuMDcyLDAuMjk1LDAuMTA1ICAgYzAuMTA0LDAuMDM4LDAuMjA3LDAuMDc4LDAuMzEyLDAuMTA5YzAuMTAxLDAuMDMsMC4xOTcsMC4wNTIsMC4yOTcsMC4wNzdjMC4xMDgsMC4wMjMsMC4yMTQsMC4wNTgsMC4zMjQsMC4wNzggICBjMC4xMTUsMC4wMjEsMC4yMzEsMC4wMzMsMC4zNDYsMC4wNTRjMC4wOTcsMC4wMTUsMC4xOTIsMC4wMzIsMC4yODksMC4wNDJjMC40MywwLjA0MiwwLjg2NSwwLjA0MiwxLjI5NSwwICAgYzAuMS0wLjAxLDAuMTkxLTAuMDI3LDAuMjg5LTAuMDQyYzAuMTE0LTAuMDIxLDAuMjMzLTAuMDI5LDAuMzQ0LTAuMDU0YzAuMTA5LTAuMDIxLDAuMjE3LTAuMDU1LDAuMzI0LTAuMDc4ICAgYzAuMTAyLTAuMDI1LDAuMTk5LTAuMDQ3LDAuMjk5LTAuMDc3YzAuMTA1LTAuMDMxLDAuMjA3LTAuMDcxLDAuMzEyLTAuMTA5YzAuMTAyLTAuMDMsMC4xOTUtMC4wNjIsMC4yOTUtMC4xMDUgICBjMC4wOTYtMC4wNDEsMC4xOTEtMC4wODcsMC4yODMtMC4xMzJjMC4xLTAuMDQ4LDAuMTk5LTAuMDkyLDAuMjk3LTAuMTQ3YzAuMDkxLTAuMDQ4LDAuMTc3LTAuMTA0LDAuMjY0LTAuMTUzICAgYzAuMDk4LTAuMDYsMC4xOTMtMC4xMSwwLjI4Ny0wLjE3NGMwLjA5Ni0wLjA2NCwwLjE4OS0wLjE0MSwwLjI4MS0wLjIwNmMwLjA3Ni0wLjA2MiwwLjE1Ni0wLjExMywwLjIzMy0wLjE3NiAgIGMwLjI0OS0wLjIwNCwwLjQ3OS0wLjQzNywwLjY5NC0wLjY3YzAuMDc2LTAuMDY3LDAuMTU0LTAuMTMxLDAuMjI5LTAuMjAzbDQzLjI5NC00My4yOTYgICBDMTIxLjU4MSw2NS40OTEsMTIxLjU4MSw2MS4zMzcsMTE5LjAxOSw1OC43NzYiLz48L2c+PC9zdmc+);background-size:20px}.spl-theme{background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+PHN2ZyBoZWlnaHQ9IjI0cHgiIHZlcnNpb249IjEuMiIgdmlld0JveD0iMiAyIDIwIDIwIiB3aWR0aD0iMjRweCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjZmZmIj48cGF0aCBkPSJNMTIsNGMtNC40MTgsMC04LDMuNTgyLTgsOHMzLjU4Miw4LDgsOHM4LTMuNTgyLDgtOFMxNi40MTgsNCwxMiw0eiBNMTIsMThjLTMuMzE0LDAtNi0yLjY4Ni02LTZzMi42ODYtNiw2LTZzNiwyLjY4Niw2LDYgUzE1LjMxNCwxOCwxMiwxOHoiLz48cGF0aCBkPSJNMTIsN3YxMGMyLjc1NywwLDUtMi4yNDMsNS01UzE0Ljc1Nyw3LDEyLDd6Ii8+PC9nPjwvc3ZnPg==)}.spl-play{background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+PHN2ZyBmaWxsPSJub25lIiBoZWlnaHQ9IjI0IiBzdHJva2U9IiNmZmYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIiB2aWV3Qm94PSItMC41IC0wLjUgMjUgMjUiIHdpZHRoPSIyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxwb2x5Z29uIGZpbGw9IiNmZmYiIHBvaW50cz0iMTAgOCAxNiAxMiAxMCAxNiAxMCA4Ii8+PC9zdmc+)}.spl-play.on{background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+PHN2ZyBmaWxsPSJub25lIiBoZWlnaHQ9IjI0IiBzdHJva2U9IiNmZmYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIiB2aWV3Qm94PSItMC41IC0wLjUgMjUgMjUiIHdpZHRoPSIyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxsaW5lIHgxPSIxMCIgeDI9IjEwIiB5MT0iMTUiIHkyPSI5Ii8+PGxpbmUgeDE9IjE0IiB4Mj0iMTQiIHkxPSIxNSIgeTI9IjkiLz48L3N2Zz4=);animation:pulsate 1s ease infinite}.spl-close{background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+PHN2ZyBmaWxsPSJub25lIiBoZWlnaHQ9IjI0IiBzdHJva2U9IiNmZmYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIiB2aWV3Qm94PSIyIDIgMjAgMjAiIHdpZHRoPSIyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48bGluZSB4MT0iMTgiIHgyPSI2IiB5MT0iNiIgeTI9IjE4Ii8+PGxpbmUgeDE9IjYiIHgyPSIxOCIgeTE9IjYiIHkyPSIxOCIvPjwvc3ZnPg==)}.spl-next,.spl-prev{top:50%;width:50px;height:50px;opacity:.65;background-color:rgba(0,0,0,.45);border-radius:100%;cursor:pointer;margin-top:-25px;transform:translateX(-100px);background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+PHN2ZyBmaWxsPSJub25lIiBoZWlnaHQ9IjI0IiBzdHJva2U9IiNmZmYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIiB2aWV3Qm94PSIwIDAgMjQgMjQiIHdpZHRoPSIyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cG9seWxpbmUgcG9pbnRzPSIxNSAxOCA5IDEyIDE1IDYiLz48L3N2Zz4=);background-size:30px}.spl-prev{left:20px}.spl-next{left:auto;right:20px;transform:translateX(100px) scaleX(-1)}#spotlight.menu .spl-prev{transform:translateX(0)}#spotlight.menu .spl-next{transform:translateX(0) scaleX(-1)}@media (hover:hover){.spl-page~div{cursor:pointer;transition:opacity .2s ease}.spl-next:hover,.spl-page~div:hover,.spl-prev:hover{opacity:1}}@media (max-width:500px){.spl-header div{width:44px}.spl-footer .spl-title{font-size:20px}.spl-footer{font-size:14px}.spl-next,.spl-prev{width:35px;height:35px;margin-top:-17.5px;background-size:15px 15px}.spl-spinner{background-size:30px 30px}}.hide-scrollbars{overflow:hidden!important}"; +var ba=document.getElementsByTagName("head")[0];ba.firstChild?ba.insertBefore(aa,ba.firstChild):ba.appendChild(aa);Object.assign||(Object.assign=function(a,b){for(var c=Object.keys(b),e=0,f;e
";var ia={},ja=document.createElement("video");function ka(a,b,c,e){if("node"!==e)for(var f=Object.keys(c),A=0,w;A.5*(0>a?1:a?-1:0)?eb():db())}function W(a,b){("boolean"===typeof a?a:!R)===!R&&(R=R?clearTimeout(R):1,d(Wa,"on",R),b||ub(R))}function ub(a){wa&&(da(O,function(){g(O,"transition-duration","");g(O,"transform","")}),a&&(g(O,"transition-duration",Ha+"s"),g(O,"transform","translateX(0)")));a&&(R=setTimeout(ib,1E3*Ha))}function X(){Ba&&(Ya=Date.now()+2950,S||(d(p,"menu",!0),Eb(3E3)))} +function Eb(a){S=setTimeout(function(){var b=Date.now();b>=Ya?(d(p,"menu"),S=0):Eb(Ya-b)},a)}function Fb(a){"boolean"===typeof a&&(S=a?S:0);S?(S=clearTimeout(S),d(p,"menu")):X()}function kb(a){ea(a,!0);sa=!0;ta=!1;var b=a.touches;b&&(b=b[0])&&(a=b);ua=qa*v<=u;na=a.pageX;oa=a.pageY;m(L)}function mb(a){ea(a);if(sa){if(ta){if(ua&&ta){var b=(a=r<-(u/7)&&(zu/7&&(1b?r=b:r<-b&&(r=-b),ra*v>pa&&(b=(ra*v-pa)/2,t-=oa-(oa=a.pageY),t>b?t=b:t<-b&&(t=-b)));ta=!0;Z(r,t)}else X()}function cb(a){var b=yb();if("boolean"!==typeof a||a!==!!b)if(b)document[Za]();else p[T]()}function fb(a){"string"!==typeof a&&(a=y?"":Ca||"white");y!==a&&(y&&d(p,y),a&&d(p,a,!0),y=a)} +function V(a){"boolean"===typeof a&&(x=!a);x=1===v&&!x;d(N,"autofit",x);g(N,"transform","");v=1;t=r=0;wb();m(L);Z()}function db(){var a=v/.65;50>=a&&(x&&V(),r/=.65,t/=.65,Z(r,t),Gb(a))}function eb(){var a=.65*v;x&&V();1<=a&&(1===a?r=t=0:(r*=.65,t*=.65),Z(r,t),Gb(a))}function Gb(a){v=a||1;sb()}function gb(){var a=K,b=document.createElement("a"),c=N.src;b.href=c;b.download=c.substring(c.lastIndexOf("/")+1);a.appendChild(b);b.click();a.removeChild(b)} +function bb(a){setTimeout(function(){K.removeChild(p);L=N=P=D=E=C=xa=ya=za=Fa=null},200);d(K,"hide-scrollbars");d(p,"show");cb(!1);qb();history.go(!0===a?-1:-2);Q&&(La.src="");R&&W();N&&xb(N);S&&(S=clearTimeout(S));y&&fb();I&&d(p,I);za&&za()}function xb(a){if(a.g)a.g.appendChild(a),a.g=null;else{var b=a.parentNode;b&&b.removeChild(a);a.src=a.onerror=""}}function hb(a){a&&X();if(1z;z=a;pb(b);return!0}} +function Ib(a){var b=C[z-1],c=b;D={};E&&Object.assign(D,E);Object.assign(D,c.dataset||c);va=D.media;Fa=D.onclick;Ca=D.theme;I=D["class"];Ba=Y("autohide",!0);G=Y("infinite");wa=Y("progress",!0);H=Y("autoslide");Da=Y("preload",!0);Ea=D.buttonHref;Ha=H&&parseFloat(H)||7;y||Ca&&fb(Ca);I&&d(p,I,!0);I&&da(p);if(c=D.control){c="string"===typeof c?c.split(","):c;for(var e=0;e + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+ diff --git a/MiniSpace.Web/src/MiniSpacePwa/Layout/MainLayout.razor b/MiniSpace.Web/src/MiniSpacePwa/Layout/MainLayout.razor new file mode 100644 index 000000000..e465845a9 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpacePwa/Layout/MainLayout.razor @@ -0,0 +1,16 @@ +@inherits LayoutComponentBase +
+ + +
+
+ About +
+ +
+ @Body +
+
+
diff --git a/MiniSpace.Web/src/MiniSpacePwa/Layout/MainLayout.razor.css b/MiniSpace.Web/src/MiniSpacePwa/Layout/MainLayout.razor.css new file mode 100644 index 000000000..baef3ee5f --- /dev/null +++ b/MiniSpace.Web/src/MiniSpacePwa/Layout/MainLayout.razor.css @@ -0,0 +1,77 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} diff --git a/MiniSpace.Web/src/MiniSpacePwa/Layout/NavMenu.razor b/MiniSpace.Web/src/MiniSpacePwa/Layout/NavMenu.razor new file mode 100644 index 000000000..02b9dbeab --- /dev/null +++ b/MiniSpace.Web/src/MiniSpacePwa/Layout/NavMenu.razor @@ -0,0 +1,39 @@ + + + + +@code { + private bool collapseNavMenu = true; + + private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null; + + private void ToggleNavMenu() + { + collapseNavMenu = !collapseNavMenu; + } +} diff --git a/MiniSpace.Web/src/MiniSpacePwa/Layout/NavMenu.razor.css b/MiniSpace.Web/src/MiniSpacePwa/Layout/NavMenu.razor.css new file mode 100644 index 000000000..07d4c0f8c --- /dev/null +++ b/MiniSpace.Web/src/MiniSpacePwa/Layout/NavMenu.razor.css @@ -0,0 +1,83 @@ +.navbar-toggler { + background-color: rgba(255, 255, 255, 0.1); +} + +.top-row { + height: 3.5rem; + background-color: rgba(0,0,0,0.4); +} + +.navbar-brand { + font-size: 1.1rem; +} + +.bi { + display: inline-block; + position: relative; + width: 1.25rem; + height: 1.25rem; + margin-right: 0.75rem; + top: -1px; + background-size: cover; +} + +.bi-house-door-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); +} + +.bi-plus-square-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); +} + +.bi-list-nested-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); +} + +.nav-item { + font-size: 0.9rem; + padding-bottom: 0.5rem; +} + + .nav-item:first-of-type { + padding-top: 1rem; + } + + .nav-item:last-of-type { + padding-bottom: 1rem; + } + + .nav-item ::deep a { + color: #d7d7d7; + border-radius: 4px; + height: 3rem; + display: flex; + align-items: center; + line-height: 3rem; + } + +.nav-item ::deep a.active { + background-color: rgba(255,255,255,0.37); + color: white; +} + +.nav-item ::deep a:hover { + background-color: rgba(255,255,255,0.1); + color: white; +} + +@media (min-width: 641px) { + .navbar-toggler { + display: none; + } + + .collapse { + /* Never collapse the sidebar for wide screens */ + display: block; + } + + .nav-scrollable { + /* Allow sidebar to scroll for tall menus */ + height: calc(100vh - 3.5rem); + overflow-y: auto; + } +} diff --git a/MiniSpace.Web/src/MiniSpacePwa/MiniSpacePwa.csproj b/MiniSpace.Web/src/MiniSpacePwa/MiniSpacePwa.csproj new file mode 100644 index 000000000..68cdeaece --- /dev/null +++ b/MiniSpace.Web/src/MiniSpacePwa/MiniSpacePwa.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + service-worker-assets.js + + + + + + + + + + + + diff --git a/MiniSpace.Web/src/MiniSpacePwa/Pages/Counter.razor b/MiniSpace.Web/src/MiniSpacePwa/Pages/Counter.razor new file mode 100644 index 000000000..b21f05215 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpacePwa/Pages/Counter.razor @@ -0,0 +1,18 @@ +@page "/counter" + +Counter + +

Counter

+ +

Current count: @currentCount

+ + + +@code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } +} diff --git a/MiniSpace.Web/src/MiniSpacePwa/Pages/Home.razor b/MiniSpace.Web/src/MiniSpacePwa/Pages/Home.razor new file mode 100644 index 000000000..df0502354 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpacePwa/Pages/Home.razor @@ -0,0 +1,7 @@ +@page "/" + +Home + +

Hello, world!

+ +Welcome to your new app. diff --git a/MiniSpace.Web/src/MiniSpacePwa/Pages/Weather.razor b/MiniSpace.Web/src/MiniSpacePwa/Pages/Weather.razor new file mode 100644 index 000000000..ecba24922 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpacePwa/Pages/Weather.razor @@ -0,0 +1,57 @@ +@page "/weather" +@inject HttpClient Http + +Weather + +

Weather

+ +

This component demonstrates fetching data from the server.

+ +@if (forecasts == null) +{ +

Loading...

+} +else +{ + + + + + + + + + + + @foreach (var forecast in forecasts) + { + + + + + + + } + +
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
+} + +@code { + private WeatherForecast[]? forecasts; + + protected override async Task OnInitializedAsync() + { + forecasts = await Http.GetFromJsonAsync("sample-data/weather.json"); + } + + public class WeatherForecast + { + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + public string? Summary { get; set; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } +} diff --git a/MiniSpace.Web/src/MiniSpacePwa/Program.cs b/MiniSpace.Web/src/MiniSpacePwa/Program.cs new file mode 100644 index 000000000..363b72ad6 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpacePwa/Program.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using MiniSpacePwa; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + +await builder.Build().RunAsync(); diff --git a/MiniSpace.Web/src/MiniSpacePwa/Properties/launchSettings.json b/MiniSpace.Web/src/MiniSpacePwa/Properties/launchSettings.json new file mode 100644 index 000000000..e4724733d --- /dev/null +++ b/MiniSpace.Web/src/MiniSpacePwa/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:44048", + "sslPort": 44350 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5228", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7170;http://localhost:5228", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/MiniSpace.Web/src/MiniSpacePwa/_Imports.razor b/MiniSpace.Web/src/MiniSpacePwa/_Imports.razor new file mode 100644 index 000000000..33bc77b85 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpacePwa/_Imports.razor @@ -0,0 +1,10 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using MiniSpacePwa +@using MiniSpacePwa.Layout diff --git a/MiniSpace.Web/src/MiniSpacePwa/wwwroot/css/app.css b/MiniSpace.Web/src/MiniSpacePwa/wwwroot/css/app.css new file mode 100644 index 000000000..0a4519e08 --- /dev/null +++ b/MiniSpace.Web/src/MiniSpacePwa/wwwroot/css/app.css @@ -0,0 +1,103 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +h1:focus { + outline: none; +} + +a, .btn-link { + color: #0071c1; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid red; +} + +.validation-message { + color: red; +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.loading-progress { + position: relative; + display: block; + width: 8rem; + height: 8rem; + margin: 20vh auto 1rem auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + stroke: #1b6ec2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; + } + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +code { + color: #c02d76; +} diff --git a/MiniSpace.Web/src/MiniSpacePwa/wwwroot/css/bootstrap/bootstrap.min.css b/MiniSpace.Web/src/MiniSpacePwa/wwwroot/css/bootstrap/bootstrap.min.css new file mode 100644 index 000000000..02ae65b5f --- /dev/null +++ b/MiniSpace.Web/src/MiniSpacePwa/wwwroot/css/bootstrap/bootstrap.min.css @@ -0,0 +1,7 @@ +@charset "UTF-8";/*! + * Bootstrap v5.1.0 (https://getbootstrap.com/) + * Copyright 2011-2021 The Bootstrap Authors + * Copyright 2011-2021 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-rgb:33,37,41;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#6c757d}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{width:100%;padding-right:var(--bs-gutter-x,.75rem);padding-left:var(--bs-gutter-x,.75rem);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(var(--bs-gutter-y) * -1);margin-right:calc(var(--bs-gutter-x) * -.5);margin-left:calc(var(--bs-gutter-x) * -.5)}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.6666666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.6666666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.6666666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.6666666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-bg:transparent;--bs-table-accent-bg:transparent;--bs-table-striped-color:#212529;--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:#212529;--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:#212529;--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:#212529;vertical-align:top;border-color:#dee2e6}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table>:not(:last-child)>:last-child>*{border-bottom-color:currentColor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-striped>tbody>tr:nth-of-type(odd){--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-bg:#cfe2ff;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:#000;border-color:#bacbe6}.table-secondary{--bs-table-bg:#e2e3e5;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:#000;border-color:#cbccce}.table-success{--bs-table-bg:#d1e7dd;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:#000;border-color:#bcd0c7}.table-info{--bs-table-bg:#cff4fc;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:#000;border-color:#badce3}.table-warning{--bs-table-bg:#fff3cd;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:#000;border-color:#e6dbb9}.table-danger{--bs-table-bg:#f8d7da;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:#000;border-color:#dfc2c4}.table-light{--bs-table-bg:#f8f9fa;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:#000;border-color:#dfe0e1}.table-dark{--bs-table-bg:#212529;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:#fff;border-color:#373b3e}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#212529;background-color:#fff;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#dde0e3}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:auto;padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{height:1.5em;border-radius:.25rem}.form-control-color::-webkit-color-swatch{height:1.5em;border-radius:.25rem}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(0.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #212529}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;height:100%;padding:1rem .75rem;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control{padding:1rem .75rem}.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-select:focus{z-index:3}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:3}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#198754}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(25,135,84,.9);border-radius:.25rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#198754;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#198754}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#198754}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#198754}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#198754}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group .form-control.is-valid,.input-group .form-select.is-valid,.was-validated .input-group .form-control:valid,.was-validated .input-group .form-select:valid{z-index:1}.input-group .form-control.is-valid:focus,.input-group .form-select.is-valid:focus,.was-validated .input-group .form-control:valid:focus,.was-validated .input-group .form-select:valid:focus{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#dc3545}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#dc3545}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#dc3545}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group .form-control.is-invalid,.input-group .form-select.is-invalid,.was-validated .input-group .form-control:invalid,.was-validated .input-group .form-select:invalid{z-index:2}.input-group .form-control.is-invalid:focus,.input-group .form-select.is-invalid:focus,.was-validated .input-group .form-control:invalid:focus,.was-validated .input-group .form-select:invalid:focus{z-index:3}.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529}.btn-check:focus+.btn,.btn:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{pointer-events:none;opacity:.65}.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-primary:hover{color:#fff;background-color:#0b5ed7;border-color:#0a58ca}.btn-check:focus+.btn-primary,.btn-primary:focus{color:#fff;background-color:#0b5ed7;border-color:#0a58ca;box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-check:active+.btn-primary,.btn-check:checked+.btn-primary,.btn-primary.active,.btn-primary:active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0a58ca;border-color:#0a53be}.btn-check:active+.btn-primary:focus,.btn-check:checked+.btn-primary:focus,.btn-primary.active:focus,.btn-primary:active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5c636a;border-color:#565e64}.btn-check:focus+.btn-secondary,.btn-secondary:focus{color:#fff;background-color:#5c636a;border-color:#565e64;box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-check:active+.btn-secondary,.btn-check:checked+.btn-secondary,.btn-secondary.active,.btn-secondary:active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#565e64;border-color:#51585e}.btn-check:active+.btn-secondary:focus,.btn-check:checked+.btn-secondary:focus,.btn-secondary.active:focus,.btn-secondary:active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-success{color:#fff;background-color:#198754;border-color:#198754}.btn-success:hover{color:#fff;background-color:#157347;border-color:#146c43}.btn-check:focus+.btn-success,.btn-success:focus{color:#fff;background-color:#157347;border-color:#146c43;box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-check:active+.btn-success,.btn-check:checked+.btn-success,.btn-success.active,.btn-success:active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#146c43;border-color:#13653f}.btn-check:active+.btn-success:focus,.btn-check:checked+.btn-success:focus,.btn-success.active:focus,.btn-success:active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#198754;border-color:#198754}.btn-info{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-info:hover{color:#000;background-color:#31d2f2;border-color:#25cff2}.btn-check:focus+.btn-info,.btn-info:focus{color:#000;background-color:#31d2f2;border-color:#25cff2;box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-check:active+.btn-info,.btn-check:checked+.btn-info,.btn-info.active,.btn-info:active,.show>.btn-info.dropdown-toggle{color:#000;background-color:#3dd5f3;border-color:#25cff2}.btn-check:active+.btn-info:focus,.btn-check:checked+.btn-info:focus,.btn-info.active:focus,.btn-info:active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-info.disabled,.btn-info:disabled{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-warning{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#000;background-color:#ffca2c;border-color:#ffc720}.btn-check:focus+.btn-warning,.btn-warning:focus{color:#000;background-color:#ffca2c;border-color:#ffc720;box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-check:active+.btn-warning,.btn-check:checked+.btn-warning,.btn-warning.active,.btn-warning:active,.show>.btn-warning.dropdown-toggle{color:#000;background-color:#ffcd39;border-color:#ffc720}.btn-check:active+.btn-warning:focus,.btn-check:checked+.btn-warning:focus,.btn-warning.active:focus,.btn-warning:active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#bb2d3b;border-color:#b02a37}.btn-check:focus+.btn-danger,.btn-danger:focus{color:#fff;background-color:#bb2d3b;border-color:#b02a37;box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-check:active+.btn-danger,.btn-check:checked+.btn-danger,.btn-danger.active,.btn-danger:active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#b02a37;border-color:#a52834}.btn-check:active+.btn-danger:focus,.btn-check:checked+.btn-danger:focus,.btn-danger.active:focus,.btn-danger:active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-light{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:focus+.btn-light,.btn-light:focus{color:#000;background-color:#f9fafb;border-color:#f9fafb;box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-check:active+.btn-light,.btn-check:checked+.btn-light,.btn-light.active,.btn-light:active,.show>.btn-light.dropdown-toggle{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:active+.btn-light:focus,.btn-check:checked+.btn-light:focus,.btn-light.active:focus,.btn-light:active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-light.disabled,.btn-light:disabled{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-dark{color:#fff;background-color:#212529;border-color:#212529}.btn-dark:hover{color:#fff;background-color:#1c1f23;border-color:#1a1e21}.btn-check:focus+.btn-dark,.btn-dark:focus{color:#fff;background-color:#1c1f23;border-color:#1a1e21;box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-check:active+.btn-dark,.btn-check:checked+.btn-dark,.btn-dark.active,.btn-dark:active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1a1e21;border-color:#191c1f}.btn-check:active+.btn-dark:focus,.btn-check:checked+.btn-dark:focus,.btn-dark.active:focus,.btn-dark:active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#212529;border-color:#212529}.btn-outline-primary{color:#0d6efd;border-color:#0d6efd}.btn-outline-primary:hover{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:focus+.btn-outline-primary,.btn-outline-primary:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-check:active+.btn-outline-primary,.btn-check:checked+.btn-outline-primary,.btn-outline-primary.active,.btn-outline-primary.dropdown-toggle.show,.btn-outline-primary:active{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:active+.btn-outline-primary:focus,.btn-check:checked+.btn-outline-primary:focus,.btn-outline-primary.active:focus,.btn-outline-primary.dropdown-toggle.show:focus,.btn-outline-primary:active:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#0d6efd;background-color:transparent}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:focus+.btn-outline-secondary,.btn-outline-secondary:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-check:active+.btn-outline-secondary,.btn-check:checked+.btn-outline-secondary,.btn-outline-secondary.active,.btn-outline-secondary.dropdown-toggle.show,.btn-outline-secondary:active{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:active+.btn-outline-secondary:focus,.btn-check:checked+.btn-outline-secondary:focus,.btn-outline-secondary.active:focus,.btn-outline-secondary.dropdown-toggle.show:focus,.btn-outline-secondary:active:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-success{color:#198754;border-color:#198754}.btn-outline-success:hover{color:#fff;background-color:#198754;border-color:#198754}.btn-check:focus+.btn-outline-success,.btn-outline-success:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-check:active+.btn-outline-success,.btn-check:checked+.btn-outline-success,.btn-outline-success.active,.btn-outline-success.dropdown-toggle.show,.btn-outline-success:active{color:#fff;background-color:#198754;border-color:#198754}.btn-check:active+.btn-outline-success:focus,.btn-check:checked+.btn-outline-success:focus,.btn-outline-success.active:focus,.btn-outline-success.dropdown-toggle.show:focus,.btn-outline-success:active:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#198754;background-color:transparent}.btn-outline-info{color:#0dcaf0;border-color:#0dcaf0}.btn-outline-info:hover{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:focus+.btn-outline-info,.btn-outline-info:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-check:active+.btn-outline-info,.btn-check:checked+.btn-outline-info,.btn-outline-info.active,.btn-outline-info.dropdown-toggle.show,.btn-outline-info:active{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:active+.btn-outline-info:focus,.btn-check:checked+.btn-outline-info:focus,.btn-outline-info.active:focus,.btn-outline-info.dropdown-toggle.show:focus,.btn-outline-info:active:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#0dcaf0;background-color:transparent}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:focus+.btn-outline-warning,.btn-outline-warning:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-check:active+.btn-outline-warning,.btn-check:checked+.btn-outline-warning,.btn-outline-warning.active,.btn-outline-warning.dropdown-toggle.show,.btn-outline-warning:active{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:active+.btn-outline-warning:focus,.btn-check:checked+.btn-outline-warning:focus,.btn-outline-warning.active:focus,.btn-outline-warning.dropdown-toggle.show:focus,.btn-outline-warning:active:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:focus+.btn-outline-danger,.btn-outline-danger:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-check:active+.btn-outline-danger,.btn-check:checked+.btn-outline-danger,.btn-outline-danger.active,.btn-outline-danger.dropdown-toggle.show,.btn-outline-danger:active{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:active+.btn-outline-danger:focus,.btn-check:checked+.btn-outline-danger:focus,.btn-outline-danger.active:focus,.btn-outline-danger.dropdown-toggle.show:focus,.btn-outline-danger:active:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:focus+.btn-outline-light,.btn-outline-light:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-check:active+.btn-outline-light,.btn-check:checked+.btn-outline-light,.btn-outline-light.active,.btn-outline-light.dropdown-toggle.show,.btn-outline-light:active{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:active+.btn-outline-light:focus,.btn-check:checked+.btn-outline-light:focus,.btn-outline-light.active:focus,.btn-outline-light.dropdown-toggle.show:focus,.btn-outline-light:active:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-dark{color:#212529;border-color:#212529}.btn-outline-dark:hover{color:#fff;background-color:#212529;border-color:#212529}.btn-check:focus+.btn-outline-dark,.btn-outline-dark:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-check:active+.btn-outline-dark,.btn-check:checked+.btn-outline-dark,.btn-outline-dark.active,.btn-outline-dark.dropdown-toggle.show,.btn-outline-dark:active{color:#fff;background-color:#212529;border-color:#212529}.btn-check:active+.btn-outline-dark:focus,.btn-check:checked+.btn-outline-dark:focus,.btn-outline-dark.active:focus,.btn-outline-dark.dropdown-toggle.show:focus,.btn-outline-dark:active:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#212529;background-color:transparent}.btn-link{font-weight:400;color:#0d6efd;text-decoration:underline}.btn-link:hover{color:#0a58ca}.btn-link.disabled,.btn-link:disabled{color:#6c757d}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropend,.dropstart,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;z-index:1000;display:none;min-width:10rem;padding:.5rem 0;margin:0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:.125rem}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid rgba(0,0,0,.15)}.dropdown-item{display:block;width:100%;padding:.25rem 1rem;clear:both;font-weight:400;color:#212529;text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#1e2125;background-color:#e9ecef}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#0d6efd}.dropdown-item.disabled,.dropdown-item:disabled{color:#adb5bd;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1rem;color:#212529}.dropdown-menu-dark{color:#dee2e6;background-color:#343a40;border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item{color:#dee2e6}.dropdown-menu-dark .dropdown-item:focus,.dropdown-menu-dark .dropdown-item:hover{color:#fff;background-color:rgba(255,255,255,.15)}.dropdown-menu-dark .dropdown-item.active,.dropdown-menu-dark .dropdown-item:active{color:#fff;background-color:#0d6efd}.dropdown-menu-dark .dropdown-item.disabled,.dropdown-menu-dark .dropdown-item:disabled{color:#adb5bd}.dropdown-menu-dark .dropdown-divider{border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item-text{color:#dee2e6}.dropdown-menu-dark .dropdown-header{color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem;color:#0d6efd;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:#0a58ca}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-link{margin-bottom:-1px;background:0 0;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6;isolation:isolate}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{background:0 0;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#0d6efd}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding-top:.5rem;padding-bottom:.5rem}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;text-decoration:none;white-space:nowrap}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem;transition:box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 .25rem}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas-header{display:none}.navbar-expand-sm .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-sm .offcanvas-bottom,.navbar-expand-sm .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas-header{display:none}.navbar-expand-md .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-md .offcanvas-bottom,.navbar-expand-md .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas-header{display:none}.navbar-expand-lg .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-lg .offcanvas-bottom,.navbar-expand-lg .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas-header{display:none}.navbar-expand-xl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xl .offcanvas-bottom,.navbar-expand-xl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xxl .offcanvas-bottom,.navbar-expand-xxl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas-header{display:none}.navbar-expand .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand .offcanvas-bottom,.navbar-expand .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.55)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.55);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.55)}.navbar-light .navbar-text a,.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.55)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.55);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.55)}.navbar-dark .navbar-text a,.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:1rem 1rem}.card-title{margin-bottom:.5rem}.card-subtitle{margin-top:-.25rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:1rem}.card-header{padding:.5rem 1rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.5rem 1rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.5rem;margin-bottom:-.5rem;margin-left:-.5rem;border-bottom:0}.card-header-pills{margin-right:-.5rem;margin-left:-.5rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-group>.card{margin-bottom:.75rem}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:1rem 1.25rem;font-size:1rem;color:#212529;text-align:left;background-color:#fff;border:0;border-radius:0;overflow-anchor:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,border-radius .15s ease}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:#0c63e4;background-color:#e7f1ff;box-shadow:inset 0 -1px 0 rgba(0,0,0,.125)}.accordion-button:not(.collapsed)::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");transform:rotate(-180deg)}.accordion-button::after{flex-shrink:0;width:1.25rem;height:1.25rem;margin-left:auto;content:"";background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-size:1.25rem;transition:transform .2s ease-in-out}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.accordion-header{margin-bottom:0}.accordion-item{background-color:#fff;border:1px solid rgba(0,0,0,.125)}.accordion-item:first-of-type{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.accordion-item:first-of-type .accordion-button{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-body{padding:1rem 1.25rem}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button{border-radius:0}.breadcrumb{display:flex;flex-wrap:wrap;padding:0 0;margin-bottom:1rem;list-style:none}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:.5rem;color:#6c757d;content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:#6c757d}.pagination{display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;color:#0d6efd;text-decoration:none;background-color:#fff;border:1px solid #dee2e6;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:#0a58ca;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:3;color:#0a58ca;background-color:#e9ecef;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item.active .page-link{z-index:3;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;background-color:#fff;border-color:#dee2e6}.page-link{padding:.375rem .75rem}.page-item:first-child .page-link{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.35em .65em;font-size:.75em;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{position:relative;padding:1rem 1rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{color:#084298;background-color:#cfe2ff;border-color:#b6d4fe}.alert-primary .alert-link{color:#06357a}.alert-secondary{color:#41464b;background-color:#e2e3e5;border-color:#d3d6d8}.alert-secondary .alert-link{color:#34383c}.alert-success{color:#0f5132;background-color:#d1e7dd;border-color:#badbcc}.alert-success .alert-link{color:#0c4128}.alert-info{color:#055160;background-color:#cff4fc;border-color:#b6effb}.alert-info .alert-link{color:#04414d}.alert-warning{color:#664d03;background-color:#fff3cd;border-color:#ffecb5}.alert-warning .alert-link{color:#523e02}.alert-danger{color:#842029;background-color:#f8d7da;border-color:#f5c2c7}.alert-danger .alert-link{color:#6a1a21}.alert-light{color:#636464;background-color:#fefefe;border-color:#fdfdfe}.alert-light .alert-link{color:#4f5050}.alert-dark{color:#141619;background-color:#d3d3d4;border-color:#bcbebf}.alert-dark .alert-link{color:#101214}@-webkit-keyframes progress-bar-stripes{0%{background-position-x:1rem}}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#0d6efd;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:1s linear infinite progress-bar-stripes;animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.list-group{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>li::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.5rem 1rem;color:#212529;text-decoration:none;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#084298;background-color:#cfe2ff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#084298;background-color:#bacbe6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#084298;border-color:#084298}.list-group-item-secondary{color:#41464b;background-color:#e2e3e5}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#41464b;background-color:#cbccce}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#41464b;border-color:#41464b}.list-group-item-success{color:#0f5132;background-color:#d1e7dd}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#0f5132;background-color:#bcd0c7}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#0f5132;border-color:#0f5132}.list-group-item-info{color:#055160;background-color:#cff4fc}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#055160;background-color:#badce3}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#055160;border-color:#055160}.list-group-item-warning{color:#664d03;background-color:#fff3cd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#664d03;background-color:#e6dbb9}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#664d03;border-color:#664d03}.list-group-item-danger{color:#842029;background-color:#f8d7da}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#842029;background-color:#dfc2c4}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#842029;border-color:#842029}.list-group-item-light{color:#636464;background-color:#fefefe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#636464;background-color:#e5e5e5}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#636464;border-color:#636464}.list-group-item-dark{color:#141619;background-color:#d3d3d4}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141619;background-color:#bebebf}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141619;border-color:#141619}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#000;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:.25rem;opacity:.5}.btn-close:hover{color:#000;text-decoration:none;opacity:.75}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{width:350px;max-width:100%;font-size:.875rem;pointer-events:auto;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .5rem 1rem rgba(0,0,0,.15);border-radius:.25rem}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:.75rem}.toast-header{display:flex;align-items:center;padding:.5rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-header .btn-close{margin-right:-.375rem;margin-left:.75rem}.toast-body{padding:.75rem;word-wrap:break-word}.modal{position:fixed;top:0;left:0;z-index:1055;display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - 1rem)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1050;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .btn-close{padding:.5rem .5rem;margin:-.5rem -.5rem -.5rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;flex:1 1 auto;padding:1rem}.modal-footer{display:flex;flex-wrap:wrap;flex-shrink:0;align-items:center;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{height:calc(100% - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}.modal-fullscreen .modal-footer{border-radius:0}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}.modal-fullscreen-sm-down .modal-footer{border-radius:0}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}.modal-fullscreen-md-down .modal-footer{border-radius:0}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}.modal-fullscreen-lg-down .modal-footer{border-radius:0}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}.modal-fullscreen-xl-down .modal-footer{border-radius:0}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}.modal-fullscreen-xxl-down .modal-footer{border-radius:0}}.tooltip{position:absolute;z-index:1080;display:block;margin:0;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .tooltip-arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[data-popper-placement^=right],.bs-tooltip-end{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[data-popper-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[data-popper-placement^=left],.bs-tooltip-start{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1070;display:block;max-width:276px;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .popover-arrow{position:absolute;display:block;width:1rem;height:.5rem}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f0f0f0}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem 1rem;margin-bottom:0;font-size:1rem;background-color:#f0f0f0;border-bottom:1px solid rgba(0,0,0,.2);border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:1rem 1rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}@-webkit-keyframes spinner-border{to{transform:rotate(360deg)}}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:.75s linear infinite spinner-border;animation:.75s linear infinite spinner-border}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:.75s linear infinite spinner-grow;animation:.75s linear infinite spinner-grow}.spinner-grow-sm{width:1rem;height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{-webkit-animation-duration:1.5s;animation-duration:1.5s}}.offcanvas{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;visibility:hidden;background-color:#fff;background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem}.offcanvas-header .btn-close{padding:.5rem .5rem;margin-top:-.5rem;margin-right:-.5rem;margin-bottom:-.5rem}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:1rem 1rem;overflow-y:auto}.offcanvas-start{top:0;left:0;width:400px;border-right:1px solid rgba(0,0,0,.2);transform:translateX(-100%)}.offcanvas-end{top:0;right:0;width:400px;border-left:1px solid rgba(0,0,0,.2);transform:translateX(100%)}.offcanvas-top{top:0;right:0;left:0;height:30vh;max-height:100%;border-bottom:1px solid rgba(0,0,0,.2);transform:translateY(-100%)}.offcanvas-bottom{right:0;left:0;height:30vh;max-height:100%;border-top:1px solid rgba(0,0,0,.2);transform:translateY(100%)}.offcanvas.show{transform:none}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentColor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{-webkit-animation:placeholder-glow 2s ease-in-out infinite;animation:placeholder-glow 2s ease-in-out infinite}@-webkit-keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;-webkit-animation:placeholder-wave 2s linear infinite;animation:placeholder-wave 2s linear infinite}@-webkit-keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.link-primary{color:#0d6efd}.link-primary:focus,.link-primary:hover{color:#0a58ca}.link-secondary{color:#6c757d}.link-secondary:focus,.link-secondary:hover{color:#565e64}.link-success{color:#198754}.link-success:focus,.link-success:hover{color:#146c43}.link-info{color:#0dcaf0}.link-info:focus,.link-info:hover{color:#3dd5f3}.link-warning{color:#ffc107}.link-warning:focus,.link-warning:hover{color:#ffcd39}.link-danger{color:#dc3545}.link-danger:focus,.link-danger:hover{color:#b02a37}.link-light{color:#f8f9fa}.link-light:focus,.link-light:hover{color:#f9fafb}.link-dark{color:#212529}.link-dark:focus,.link-dark:hover{color:#1a1e21}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:calc(3 / 4 * 100%)}.ratio-16x9{--bs-aspect-ratio:calc(9 / 16 * 100%)}.ratio-21x9{--bs-aspect-ratio:calc(9 / 21 * 100%)}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentColor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:1px solid #dee2e6!important}.border-0{border:0!important}.border-top{border-top:1px solid #dee2e6!important}.border-top-0{border-top:0!important}.border-end{border-right:1px solid #dee2e6!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:1px solid #dee2e6!important}.border-start-0{border-left:0!important}.border-primary{border-color:#0d6efd!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#198754!important}.border-info{border-color:#0dcaf0!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#212529!important}.border-white{border-color:#fff!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#6c757d!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:.25rem!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:.2rem!important}.rounded-2{border-radius:.25rem!important}.rounded-3{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-end{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-start{border-bottom-left-radius:.25rem!important;border-top-left-radius:.25rem!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/MiniSpace.Web/src/MiniSpacePwa/wwwroot/css/bootstrap/bootstrap.min.css.map b/MiniSpace.Web/src/MiniSpacePwa/wwwroot/css/bootstrap/bootstrap.min.css.map new file mode 100644 index 000000000..afcd9e33e --- /dev/null +++ b/MiniSpace.Web/src/MiniSpacePwa/wwwroot/css/bootstrap/bootstrap.min.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/bootstrap.scss","../../scss/_root.scss","../../scss/_reboot.scss","dist/css/bootstrap.css","../../scss/vendor/_rfs.scss","../../scss/mixins/_border-radius.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/_tables.scss","../../scss/mixins/_table-variants.scss","../../scss/forms/_labels.scss","../../scss/forms/_form-text.scss","../../scss/forms/_form-control.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_gradients.scss","../../scss/forms/_form-select.scss","../../scss/forms/_form-check.scss","../../scss/forms/_form-range.scss","../../scss/forms/_floating-labels.scss","../../scss/forms/_input-group.scss","../../scss/mixins/_forms.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/_button-group.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_accordion.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/_alert.scss","../../scss/mixins/_alert.scss","../../scss/_progress.scss","../../scss/_list-group.scss","../../scss/mixins/_list-group.scss","../../scss/_close.scss","../../scss/_toasts.scss","../../scss/_modal.scss","../../scss/mixins/_backdrop.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/mixins/_clearfix.scss","../../scss/_spinners.scss","../../scss/_offcanvas.scss","../../scss/_placeholders.scss","../../scss/helpers/_colored-links.scss","../../scss/helpers/_ratio.scss","../../scss/helpers/_position.scss","../../scss/helpers/_stacks.scss","../../scss/helpers/_visually-hidden.scss","../../scss/mixins/_visually-hidden.scss","../../scss/helpers/_stretched-link.scss","../../scss/helpers/_text-truncation.scss","../../scss/mixins/_text-truncate.scss","../../scss/helpers/_vr.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"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","sourcesContent":["/*!\n * Bootstrap v5.1.0 (https://getbootstrap.com/)\n * Copyright 2011-2021 The Bootstrap Authors\n * Copyright 2011-2021 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n\n// scss-docs-start import-stack\n// Configuration\n@import \"functions\";\n@import \"variables\";\n@import \"mixins\";\n@import \"utilities\";\n\n// Layout & components\n@import \"root\";\n@import \"reboot\";\n@import \"type\";\n@import \"images\";\n@import \"containers\";\n@import \"grid\";\n@import \"tables\";\n@import \"forms\";\n@import \"buttons\";\n@import \"transitions\";\n@import \"dropdown\";\n@import \"button-group\";\n@import \"nav\";\n@import \"navbar\";\n@import \"card\";\n@import \"accordion\";\n@import \"breadcrumb\";\n@import \"pagination\";\n@import \"badge\";\n@import \"alert\";\n@import \"progress\";\n@import \"list-group\";\n@import \"close\";\n@import \"toasts\";\n@import \"modal\";\n@import \"tooltip\";\n@import \"popover\";\n@import \"carousel\";\n@import \"spinners\";\n@import \"offcanvas\";\n@import \"placeholders\";\n\n// Helpers\n@import \"helpers\";\n\n// Utilities\n@import \"utilities/api\";\n// scss-docs-end import-stack\n",":root {\n // Note: Custom variable values only support SassScript inside `#{}`.\n\n // Colors\n //\n // Generate palettes for full colors, grays, and theme colors.\n\n @each $color, $value in $colors {\n --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $grays {\n --#{$variable-prefix}gray-#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors-rgb {\n --#{$variable-prefix}#{$color}-rgb: #{$value};\n }\n\n --#{$variable-prefix}white-rgb: #{to-rgb($white)};\n --#{$variable-prefix}black-rgb: #{to-rgb($black)};\n --#{$variable-prefix}body-rgb: #{to-rgb($body-color)};\n\n // Fonts\n\n // Note: Use `inspect` for lists so that quoted items keep the quotes.\n // See https://github.com/sass/sass/issues/2383#issuecomment-336349172\n --#{$variable-prefix}font-sans-serif: #{inspect($font-family-sans-serif)};\n --#{$variable-prefix}font-monospace: #{inspect($font-family-monospace)};\n --#{$variable-prefix}gradient: #{$gradient};\n\n // Root and body\n // stylelint-disable custom-property-empty-line-before\n // scss-docs-start root-body-variables\n @if $font-size-root != null {\n --#{$variable-prefix}root-font-size: #{$font-size-root};\n }\n --#{$variable-prefix}body-font-family: #{$font-family-base};\n --#{$variable-prefix}body-font-size: #{$font-size-base};\n --#{$variable-prefix}body-font-weight: #{$font-weight-base};\n --#{$variable-prefix}body-line-height: #{$line-height-base};\n --#{$variable-prefix}body-color: #{$body-color};\n @if $body-text-align != null {\n --#{$variable-prefix}body-text-align: #{$body-text-align};\n }\n --#{$variable-prefix}body-bg: #{$body-bg};\n // scss-docs-end root-body-variables\n // stylelint-enable custom-property-empty-line-before\n}\n","// stylelint-disable declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix\n\n\n// Reboot\n//\n// Normalization of HTML elements, manually forked from Normalize.css to remove\n// styles targeting irrelevant browsers while applying new styles.\n//\n// Normalize is licensed MIT. https://github.com/necolas/normalize.css\n\n\n// Document\n//\n// Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n\n// Root\n//\n// Ability to the value of the root font sizes, affecting the value of `rem`.\n// null by default, thus nothing is generated.\n\n:root {\n @if $font-size-root != null {\n font-size: var(--#{$variable-prefix}-root-font-size);\n }\n\n @if $enable-smooth-scroll {\n @media (prefers-reduced-motion: no-preference) {\n scroll-behavior: smooth;\n }\n }\n}\n\n\n// Body\n//\n// 1. Remove the margin in all browsers.\n// 2. As a best practice, apply a default `background-color`.\n// 3. Prevent adjustments of font size after orientation changes in iOS.\n// 4. Change the default tap highlight to be completely transparent in iOS.\n\n// scss-docs-start reboot-body-rules\nbody {\n margin: 0; // 1\n font-family: var(--#{$variable-prefix}body-font-family);\n @include font-size(var(--#{$variable-prefix}body-font-size));\n font-weight: var(--#{$variable-prefix}body-font-weight);\n line-height: var(--#{$variable-prefix}body-line-height);\n color: var(--#{$variable-prefix}body-color);\n text-align: var(--#{$variable-prefix}body-text-align);\n background-color: var(--#{$variable-prefix}body-bg); // 2\n -webkit-text-size-adjust: 100%; // 3\n -webkit-tap-highlight-color: rgba($black, 0); // 4\n}\n// scss-docs-end reboot-body-rules\n\n\n// Content grouping\n//\n// 1. Reset Firefox's gray color\n// 2. Set correct height and prevent the `size` attribute to make the `hr` look like an input field\n\nhr {\n margin: $hr-margin-y 0;\n color: $hr-color; // 1\n background-color: currentColor;\n border: 0;\n opacity: $hr-opacity;\n}\n\nhr:not([size]) {\n height: $hr-height; // 2\n}\n\n\n// Typography\n//\n// 1. Remove top margins from headings\n// By default, `

`-`

` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n\n%heading {\n margin-top: 0; // 1\n margin-bottom: $headings-margin-bottom;\n font-family: $headings-font-family;\n font-style: $headings-font-style;\n font-weight: $headings-font-weight;\n line-height: $headings-line-height;\n color: $headings-color;\n}\n\nh1 {\n @extend %heading;\n @include font-size($h1-font-size);\n}\n\nh2 {\n @extend %heading;\n @include font-size($h2-font-size);\n}\n\nh3 {\n @extend %heading;\n @include font-size($h3-font-size);\n}\n\nh4 {\n @extend %heading;\n @include font-size($h4-font-size);\n}\n\nh5 {\n @extend %heading;\n @include font-size($h5-font-size);\n}\n\nh6 {\n @extend %heading;\n @include font-size($h6-font-size);\n}\n\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `

`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\n\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n\n// Abbreviations\n//\n// 1. Duplicate behavior to the data-bs-* attribute for our tooltip plugin\n// 2. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Prevent the text-decoration to be skipped.\n\nabbr[title],\nabbr[data-bs-original-title] { // 1\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n text-decoration-skip-ink: none; // 4\n}\n\n\n// Address\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\n\n// Lists\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\n// 1. Undo browser default\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // 1\n}\n\n\n// Blockquote\n\nblockquote {\n margin: 0 0 1rem;\n}\n\n\n// Strong\n//\n// Add the correct font weight in Chrome, Edge, and Safari\n\nb,\nstrong {\n font-weight: $font-weight-bolder;\n}\n\n\n// Small\n//\n// Add the correct font size in all browsers\n\nsmall {\n @include font-size($small-font-size);\n}\n\n\n// Mark\n\nmark {\n padding: $mark-padding;\n background-color: $mark-bg;\n}\n\n\n// Sub and Sup\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n\nsub,\nsup {\n position: relative;\n @include font-size($sub-sup-font-size);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n// Links\n\na {\n color: $link-color;\n text-decoration: $link-decoration;\n\n &:hover {\n color: $link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n &,\n &:hover {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n// Code\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-code;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n direction: ltr #{\"/* rtl:ignore */\"};\n unicode-bidi: bidi-override;\n}\n\n// 1. Remove browser default top margin\n// 2. Reset browser default of `1em` to use `rem`s\n// 3. Don't allow content to break outside\n\npre {\n display: block;\n margin-top: 0; // 1\n margin-bottom: 1rem; // 2\n overflow: auto; // 3\n @include font-size($code-font-size);\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n @include font-size(inherit);\n color: inherit;\n word-break: normal;\n }\n}\n\ncode {\n @include font-size($code-font-size);\n color: $code-color;\n word-wrap: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n @include font-size($kbd-font-size);\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n\n kbd {\n padding: 0;\n @include font-size(1em);\n font-weight: $nested-kbd-font-weight;\n }\n}\n\n\n// Figures\n//\n// Apply a consistent margin strategy (matches our type styles).\n\nfigure {\n margin: 0 0 1rem;\n}\n\n\n// Images and content\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\n\n// Tables\n//\n// Prevent double borders\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: $table-cell-padding-y;\n padding-bottom: $table-cell-padding-y;\n color: $table-caption-color;\n text-align: left;\n}\n\n// 1. Removes font-weight bold by inheriting\n// 2. Matches default `` alignment by inheriting `text-align`.\n// 3. Fix alignment for Safari\n\nth {\n font-weight: $table-th-font-weight; // 1\n text-align: inherit; // 2\n text-align: -webkit-match-parent; // 3\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\n\n// Forms\n//\n// 1. Allow labels to use `margin` for spacing.\n\nlabel {\n display: inline-block; // 1\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n// See https://github.com/twbs/bootstrap/issues/24093\n\nbutton {\n // stylelint-disable-next-line property-disallowed-list\n border-radius: 0;\n}\n\n// Explicitly remove focus outline in Chromium when it shouldn't be\n// visible (e.g. as result of mouse click or touch tap). It already\n// should be doing this automatically, but seems to currently be\n// confused and applies its very visible two-tone outline anyway.\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\n// 1. Remove the margin in Firefox and Safari\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // 1\n font-family: inherit;\n @include font-size(inherit);\n line-height: inherit;\n}\n\n// Remove the inheritance of text transform in Firefox\nbutton,\nselect {\n text-transform: none;\n}\n// Set the cursor for non-`