diff --git a/.env.example b/.env.example index 19294da8b..8ebeba876 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,136 @@ -# PL Network API +# PL Network API Configuration +# ENVIRONMENT: The environment mode for the application. +# Example: development, staging, or production +# For local use: development ENVIRONMENT= + +# PORT: The port on which the server runs. +# Example: 3000 +# For local use: 3000 PORT= -# Database configuration +# Database Configuration +# DB_HOST_PORT: The port used by the PostgreSQL database. +# Example: 5432 (default PostgreSQL port) DB_HOST_PORT= + +# DB_USER: The username for connecting to the PostgreSQL database. +# Example: postgres DB_USER= + +# DB_PASSWORD: The password for the PostgreSQL database user. +# Example: mysecurepassword DB_PASSWORD= + +# DB_NAME: The name of the PostgreSQL database. +# Example: pl-dev-db DB_NAME= + +# DATABASE_URL: The connection string for the PostgreSQL database. +# Format: "postgres://username:password@host:port/database" +# Example: "postgres://postgres:mysecurepassword@localhost:5432/pl-dev-db" DATABASE_URL= -# Redis +# Redis Configuration +# REDIS_HOST: The hostname or IP address of the Redis server. +# Example: localhost or redis-server REDIS_HOST= + +# REDIS_PORT: The port on which the Redis server runs. +# Example: 6379 (default Redis port) REDIS_PORT= +# Google APIs +# GOOGLE_PLACES_API_KEY: The API key for accessing Google Places. +# Set any valid Google Places API key for local use. +GOOGLE_PLACES_API_KEY= + +# Authentication Configuration +# AUTH_API_URL: The endpoint URL for the authentication API. +# For local use: https://sandbox-auth.plnetwork.io +AUTH_API_URL= + +# AUTH_APP_CLIENT_SECRET: The client secret for the authentication application. +# Set any random string for local use. +AUTH_APP_CLIENT_SECRET= + +# AUTH_APP_CLIENT_ID: The client ID for the authentication application. +# Set any random string for local use. +AUTH_APP_CLIENT_ID= + +# WEB_UI_BASE_URL: The base URL for the web application. +# For local use: http://localhost:4200 +WEB_UI_BASE_URL= + +# LOGIN_REDIRECT_URL: The URL path for redirecting after login. +# For local use: directory/members/verify-member +LOGIN_REDIRECT_URL= + +# GitHub API +# GITHUB_API_KEY: The API key for accessing GitHub data. +# Github personal access token +GITHUB_API_KEY= + +# Admin Configuration +# ADMIN_TOKEN_SECRET: The secret used to generate admin tokens. +# For local use set any secure random string +ADMIN_TOKEN_SECRET= + +# AWS S3 Configuration +# AWS_ACCESS_KEY: The AWS access key for S3. +AWS_ACCESS_KEY= + +# AWS_SECRET_KEY: The AWS secret key for S3. +AWS_SECRET_KEY= + +# AWS_REGION: The AWS region where the S3 bucket is located. +# Ex: us-west-1 +AWS_REGION= + +# AWS_S3_BUCKET_NAME: The name of the S3 bucket for storing files. +# Ex: pl-events-service +AWS_S3_BUCKET_NAME= + +# AWS_S3_DOMAIN: The domain name for accessing the S3 bucket. +# Ex: s3.amazonaws.com +AWS_S3_DOMAIN= + +# Email Configuration +# IS_EMAIL_ENABLED: Toggle for enabling or disabling email functionality. +# Make sure it should be false for local development +IS_EMAIL_ENABLED= + +# Env variables for Back Office +#---------------------------------# + +# Directory Backend API URL +# For local development, use: http://localhost:, where PORT is the env variable configured above. +# eg. http://localhost:3000, where PORT=3000 +WEB_API_BASE_URL= + +# Back Office Application Login Username +# For local development, use: admin +ADMIN_LOGIN_USERNAME= + +# Back Office Application Login Password +# For local development, use: admin +ADMIN_LOGIN_PASSWORD= + + + +# Optional for local development + # Error Reporting SENTRY_DSN= +# Airtable +AIRTABLE_API_KEY= +AIRTABLE_BASE_ID= +AIRTABLE_TEAMS_TABLE_ID= +AIRTABLE_MEMBERS_TABLE_ID= + # File Encryption +FILE_STORAGE= FILE_ENCRYPTION_ALGORITHM= FILE_ENCRYPTION_PASSWORD= FILE_ENCRYPTION_SALT= @@ -28,20 +142,16 @@ WORKER_IMAGE_URL= # Web3 File Storage WEB3_STORAGE_API_TOKEN= -# Web API -WEB_API_BASE_URL= - -# Forest Admin +# Forest Admin API keys FOREST_ENV_SECRET= FOREST_AUTH_SECRET= +# Hightouch API HIGHTOUCH_API_KEY= -# Google APIs -GOOGLE_PLACES_API_KEY= - -# login redirect url -LOGIN_REDIRECT_URL= +# Google Recaptcha +GOOGLE_SITE_KEY= +GOOGLE_RECAPTCHA_SECRET= # Cloudflare login url LOGIN_URL= @@ -49,26 +159,41 @@ LOGIN_URL= # Interval to check whether expired token is expired NEXT_PUBLIC_TOKEN_EXPIRY_CHECK_IN_MINUTES= // in minutes -#Announcement banner json fetch token -ANNOUNCEMENT_S3_AUTH_TOKEN= +# SES Admin emails +SES_SOURCE_EMAIL= +SES_ADMIN_EMAIL_IDS= +SUPPORT_EMAILS= -#Cookie domain -COOKIE_DOMAIN= // domain +# SLACK +CHANNEL_ID= +SLACK_BOT_TOKEN= -FILE_STORAGE= +# AWS Cloud watch +CLOUDWATCH_GROUP_NAME= +CLOUDWATCH_ACCESS_KEY= +CLOUDWATCH_SECRET_KEY= +CLOUDWATCH_REGION= -AWS_S3_BUCKET_NAME= +# Announcement banner json fetch token +ANNOUNCEMENT_S3_AUTH_TOKEN= -AWS_S3_DOMAIN= +# Cookie domain +COOKIE_DOMAIN= + +# File storage for image (IPFS or AWS) +FILE_STORAGE= -// no of days delay the follow up for interaction +# No of days delay the follow up for interaction INTERACTION_FOLLOWUP_DELAY_IN_DAYS= -// no of milliseconds delay in between interacitons +# No of milliseconds delay in between interactions INTERACTION_INTERVAL_DELAY_IN_MILLISECONDS= -// no of days to decide recent record +# Number of days to determine if records (teams, members, projects) are considered recent +# Example: 30 (indicating records from the last 30 days will be treated as recent) RECENT_RECORD_DURATION_IN_DAYS= -// internal auth token -INTERNAL_AUTH_TOKEN= \ No newline at end of file +# Authentication token for secure communication between internal services (e.g., events service) +INTERNAL_AUTH_TOKEN= + + diff --git a/README.md b/README.md index 8bff68e62..6bf33bbfd 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,232 @@ -![Protocol Labs Network logo](./apps/web-app/public/assets/images/protocol-labs-network-logo-horizontal-white.svg#gh-dark-mode-only) -![Protocol Labs Network logo](./apps/web-app/public/assets/images/protocol-labs-network-logo-horizontal-black.svg#gh-light-mode-only) +# Directory Backend -# Protocol Labs Network +This is the PL Network backend built with [Nest](https://github.com/nestjs/nest), [Prisma](https://www.prisma.io/), and [PostgreSQL](https://www.postgresql.org/). The parent project is generated using [Nx](https://nx.dev/). Check the docs to learn more. -This project was generated using [Nx](https://nx.dev). Check the docs to learn more. +It is set up in monorepo (soon will be revamped to single microservice pattern) fashion currently hosting directory backend and directory frontend for admin (soon will be revamped). -## Setting up the project +The actual frontend for directory has been moved [here](https://github.com/memser-spaceport/pln-directory-portal-v2). -1. Run `yarn install` in the root of the project -2. Setup the environment variables via the `.env` file: - 1. Run `cp .env.example .env` at the root of the project - 2. Copy & paste the necessary environment variables values from the 1Password vault +## Folder Structure -### Create a new pre-release & deploy app to staging +The folder structure of this project is organized as follows: -1. Make sure you have the [GitHub CLI](https://cli.github.com/) installed on your machine (installation instructions [here](https://github.com/cli/cli#installation)) -2. Run `yarn run deploy:staging` +- **apps/web-api**: Contains the actual backend service +- **apps/web-api/prisma**: Contains the database schema and migration files +- **libs/contracts**: Contains the API contracts -Alternatively, direclty run the "Staging Release & Deployment" workflow on GitHub, under Actions. +--- -### Create a new release & deploy app to production +## Prerequisites -1. Make sure you have the [GitHub CLI](https://cli.github.com/) installed on your machine (installation instructions [here](https://github.com/cli/cli#installation)) -2. Run `yarn run deploy:production` +Before running this project, ensure the following software is installed on your system: -Alternatively, direclty run the "Production Release & Deployment" workflow on GitHub, under Actions. +1. **Docker** + Docker is essential for containerizing the application, making it easier to manage dependencies and deployments. + [Install Docker](https://docs.docker.com/get-docker/) -## Adding capabilities to our workspace +2. **Docker Compose** + Docker Compose is a tool for defining and running multi-container Docker applications, which allows for easier orchestration of containers. + [Install Docker Compose](https://docs.docker.com/compose/install/) -Nx supports many plugins which add capabilities for developing different types of applications and different tools. +3. **PostgreSQL** + PostgreSQL is the primary database used in this project. Make sure to have it installed and configured, or use the Docker image provided in the `docker-compose.yml` file. + [Install PostgreSQL](https://www.postgresql.org/download/) -These capabilities include generating applications, libraries, etc as well as the devtools to test, and build projects as well. +4. **Redis** + Redis is used for caching, which improves performance and scalability. You can also run Redis as a Docker container if you prefer. + [Install Redis](https://redis.io/download) -### Generate a React application +5. **Node.js** + Node.js is the JavaScript runtime for server-side scripting in this project. Ensure that a compatible version is installed. + [Install Node.js](https://nodejs.org/) -Run `nx g @nrwl/react:app my-react-app` to generate an application. +6. **npm (Node Package Manager) and Yarn** + npm is included with Node.js and is used for installing dependencies and managing packages. + [Learn about npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) + [Install Yarn](https://classic.yarnpkg.com/lang/en/docs/install/) -### Generate a Next.js application +7. **NestJS** + NestJS is a framework for building efficient, reliable, and scalable server-side applications. It is the primary framework used in this project. + [Learn about NestJS](https://docs.nestjs.com/) -Run `nx g @nrwl/next:app my-nextjs-app` to generate an application. +8. **Prisma** + Prisma is an ORM (Object-Relational Mapper) used for interacting with the database in a type-safe way. + [Learn about Prisma](https://www.prisma.io/docs/) -### Generate a library +9. **Zod** + Zod is a TypeScript-first schema validation library used for data validation. + [Learn about Zod](https://zod.dev/) -Run `nx g @nrwl/react:lib my-lib` to generate a library. +--- -Libraries are shareable across libraries and applications. They can be imported from `@protocol-labs-network/mylib`. +## Logging in Cloudwatch -### Understand the workspace +The application is set to send all the logs to Cloudwatch logs. If you have your own AWS access and secret keys with Cloudwatch permissions, you can configure them in `.env`. -Run `nx graph` to see a diagram of the dependencies of your projects. +If you do not want to log in CloudWatch or do not have the necessary AWS keys, you can set `LOG_ENV=local`. + +## Setting up Dependent Services + +| Name | Type | Purpose | Mandatory | +| - | - | - | - | +| [Privy](https://www.privy.io/) | External | The hybrid auth solution provider for users to login | Yes, for local we have already provided you with a client id, just use that | +| AWS Cloudwatch logs | External | To store logs | No | +| AWS S3 | External | To store runtime images like profile pictures | Yes (You can skip it for local development but you will not be able to upload profile images) | +| AWS SES | External | To send email notifications | Yes, but you can skip in local by disabling email in .env | +| PL Auth service | Internal | To manage user auth requests and issue tokens, works in OAuth 2.0 standard | Yes, for local we have provided you with sandbox url | +| [Google API](https://developers.google.com/maps/documentation/places/web-service/get-api-key) | External | For location-based services | Yes | +| [Forestadmin](https://www.forestadmin.com/) | External | To manage data directly from/to database. The admin panel for the database | No | +| Github API Key | External | To get information about projects from Github repo | Yes | + +## Installation + +```sh +$ yarn install +``` + +### Setup Docker for Postgres and Redis + +Install [Docker](https://docs.docker.com/engine/install/) and [Docker Compose](https://docs.docker.com/compose/install/). + +Then run: + +```sh +$ docker-compose up -d +``` + +Once this is done, you will have your Postgres and Redis running through Docker and they will be up and running based on the following configurations: + +- Sample values through which Docker will run Postgres and Redis: + ```sh + DB_HOST_PORT=19432 + DB_USER=postgres + DB_PASSWORD=postgres + DB_NAME=plnetwork_dev + DATABASE_URL=postgresql://postgres:postgres@localhost:19432/plnetwork_dev + + REDIS_HOST=localhost + REDIS_PORT=6379 + ``` + +## Add Local Environment Variables + +1. Create the environment variables file: + ```sh + # ~/protocol-labs-network + cp .env.example .env + ``` + +### Some Key Environment Variables for Local Mode + + ```sh + ENVIRONMENT=development + ``` + +- Generate a Github [personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens): + ```sh + GITHUB_API_KEY= + ``` + +- Make sure it has permission to read and write from the provided S3 bucket (If you do not have aws keys, leave it assuming you will not be uploading any profile images): + ```sh + AWS_ACCESS_KEY= + AWS_SECRET_KEY= + ``` + +- Must be a public bucket: (Leave it if you do not have any) + ```sh + AWS_S3_BUCKET_NAME= + ``` + +- Set to false if you do not have aws keys with ses permission + ```sh + IS_EMAIL_ENABLED=false + ``` + +## Generate Prisma Schemas and Update the Database + +```sh +$ npx prisma generate --schema=./apps/web-api/prisma/schema.prisma +$ npx prisma db push --schema=./apps/web-api/prisma/schema.prisma +``` + +## Populate a Database with Mock Data + +⚠ Keep in mind that running the following command completely wipes the database before inserting any mock data. + +ℹ Before running the following command, make sure that your [database is in sync with your Prisma schema](https://github.com/memser-spaceport/pln-directory-portal/blob/main/apps/web-api/README.md#generate-prisma-schemas-and-update-the-database). + +```sh +$ yarn nx seed web-api +``` + +## Running the App + +Go to the parent folder: + +### Development + +```sh +$ yarn nx serve web-api +``` + +### API Documentation + +The API documentation is available at the following URL: + +[API Documentation](http://localhost:3000/api/docs) + +Visit this link to explore the API endpoints, parameters, and responses. + +### Production Mode + +```sh +$ yarn nx build web-api --configuration=production +``` + +## Test + +```sh +$ yarn nx run web-api:test +``` +To ensure code reliability and functionality, we use the Jest framework for writing and running test cases. Jest provides a robust environment for unit and integration testing, helping maintain the quality and stability of the application. + +## Lint + +```sh +$ yarn nx run web-api:lint +``` + +## Running the Back Office (Admin app) +This app is used by admin to approve/reject/edit - members and teams join requests + +### Set the environment variables +Set the following environment variables for the back office app: + +- `WEB_API_BASE_URL` - Directory Backend API URL configured above. (for local development, use: http://localhost:, where PORT is the variable configured in .env file from the above steps. Eg. http://localhost:3000, where PORT=3000) +- `ADMIN_LOGIN_USERNAME` - Back Office Application Login Username (Set the username you want to use for logging in to the back office app) +- `ADMIN_LOGIN_PASSWORD` - Back Office Application Login Password (Set the password you want to use for logging in to the back office app) + +### Running the Back Office + +```sh +$ yarn nx serve back-office +``` + +### Accessing the Back Office +Application will be available at http://localhost:4201, port 4201 is set in project.json file in back-office app. + +Use the username and password you set in the environment variables above to log in to the back office app. + +### Commit Guidelines + +Refer [here] (./docs/GUIDELINES_COMMIT.md) + +### Contributing Guidelines + +Refer + - [Branching Guidelines](./docs/GUIDELINES_BRANCHING.md) + - [Commit Guidelines](./docs/GUIDELINES_COMMIT.md) + - [Pull Request Guidelines](./docs/GUIDELINES_PULL_REQUEST.md) + +And give a PR to develop branch and our team will review and approve it. diff --git a/apps/back-office/components/focus-areas-popup/focus-areas-list.tsx b/apps/back-office/components/focus-areas-popup/focus-areas-list.tsx index 04cdf0b78..4920ec72c 100644 --- a/apps/back-office/components/focus-areas-popup/focus-areas-list.tsx +++ b/apps/back-office/components/focus-areas-popup/focus-areas-list.tsx @@ -1,8 +1,5 @@ // eslint-disable-next-line @nrwl/nx/enforce-module-boundaries import { TFocusArea } from 'apps/back-office/utils/teams.types'; -import { APP_ANALYTICS_EVENTS } from 'apps/web-app/constants'; -import useAppAnalytics from 'apps/web-app/hooks/shared/use-app-analytics'; - interface IFocusAreasList { selectedItems: TFocusArea[]; diff --git a/apps/back-office/components/members/memberskillform.tsx b/apps/back-office/components/members/memberskillform.tsx index ec53b5f06..dd00b8e04 100644 --- a/apps/back-office/components/members/memberskillform.tsx +++ b/apps/back-office/components/members/memberskillform.tsx @@ -14,7 +14,7 @@ export default function AddMemberSkillForm(props) { const dropDownValues = props?.dropDownValues; // const teamNames = getAvailableTeams(dropDownValues.teamNames, teamAndRoles); const teamNames = teamAndRoles.map((item) => item.teamUid); - const isOpenToWorkEnabled = process.env.NEXT_PUBLIC_ENABLE_OPEN_TO_WORK; + const isOpenToWorkEnabled = "true"; return ( <>
diff --git a/apps/back-office/next.config.js b/apps/back-office/next.config.js index 6cc3b15b7..b575b911d 100644 --- a/apps/back-office/next.config.js +++ b/apps/back-office/next.config.js @@ -7,8 +7,9 @@ const AWS_S3_DOMAIN = process.env.AWS_S3_DOMAIN || ''; **/ const nextConfig = { env: { - NEXT_PUBLIC_USERNAME: process.env.NEXT_PUBLIC_USERNAME, - NEXT_PUBLIC_PASSWORD: process.env.NEXT_PUBLIC_PASSWORD, + WEB_API_BASE_URL: process.env.WEB_API_BASE_URL, + ADMIN_LOGIN_USERNAME: process.env.ADMIN_LOGIN_USERNAME, + ADMIN_LOGIN_PASSWORD: process.env.ADMIN_LOGIN_PASSWORD, }, nx: { // Set this to true if you would like to to use SVGR diff --git a/apps/back-office/pages/api/login.ts b/apps/back-office/pages/api/login.ts index 64bc05144..6b7efb52e 100644 --- a/apps/back-office/pages/api/login.ts +++ b/apps/back-office/pages/api/login.ts @@ -10,7 +10,7 @@ interface DecodedJwtPayload { export default async function login(req, res) { const { username, password } = req.body; await api - .post('/v1/admin/signin', { username: username, password: password }) + .post('/v1/admin/auth/login', { username: username, password: password }) .then((response) => { if (response?.data?.accessToken) { // Set the authentication cookie @@ -28,7 +28,7 @@ export default async function login(req, res) { } }) .catch((err) => { - console.log('catcherror', err.response.data.statusCode); + console.log('catcherror', err); if (err?.response?.data?.statusCode === 401) { res.status(401).json({ success: false }); } diff --git a/apps/back-office/pages/member-view.tsx b/apps/back-office/pages/member-view.tsx index 2e8ad87cf..612fa2cea 100644 --- a/apps/back-office/pages/member-view.tsx +++ b/apps/back-office/pages/member-view.tsx @@ -151,7 +151,7 @@ export default function MemberView(props) { requestId: props.id, }; api - .post(`/v1/participants-request/unique-identifier`, data) + .get(`/v1/participants-request/unique-identifier?type=${data?.participantType}&identifier=${data?.uniqueIdentifier}`) .then((response) => { setDisableSave(false); response?.data && diff --git a/apps/back-office/pages/team-view.tsx b/apps/back-office/pages/team-view.tsx index 5d85fe8ad..e51bf9101 100644 --- a/apps/back-office/pages/team-view.tsx +++ b/apps/back-office/pages/team-view.tsx @@ -169,7 +169,7 @@ export default function TeamView(props) { requestId: props.id, }; api - .post(`/v1/participants-request/unique-identifier`, data) + .get(`/v1/participants-request/unique-identifier?type=${data?.participantType}&identifier=${data.uniqueIdentifier}`) .then((response) => { setDisableSave(false); response?.data && diff --git a/apps/back-office/utils/api.ts b/apps/back-office/utils/api.ts index a51fcb506..d6c69e27e 100644 --- a/apps/back-office/utils/api.ts +++ b/apps/back-office/utils/api.ts @@ -2,7 +2,7 @@ import axios from 'axios'; // Create an Axios instance with default configuration const api = axios.create({ - baseURL: process.env.NEXT_PUBLIC_WEB_API_BASE_URL, + baseURL: process.env.WEB_API_BASE_URL, }); export default api; diff --git a/apps/back-office/utils/constants.ts b/apps/back-office/utils/constants.ts index 37deda6ee..e010c32c2 100644 --- a/apps/back-office/utils/constants.ts +++ b/apps/back-office/utils/constants.ts @@ -31,7 +31,7 @@ export const ROUTE_CONSTANTS = { }; export const API_ROUTE = { - PARTICIPANTS_REQUEST: APP_CONSTANTS.V1 + 'admin/participants', + PARTICIPANTS_REQUEST: APP_CONSTANTS.V1 + 'admin/participants-request', TEAMS: APP_CONSTANTS.V1 + 'teams', SKILLS: APP_CONSTANTS.V1 + 'skills', IMAGES: APP_CONSTANTS.V1 + 'images', diff --git a/apps/web-api/README.md b/apps/web-api/README.md index f81e41e08..8e501cc91 100644 --- a/apps/web-api/README.md +++ b/apps/web-api/README.md @@ -1,67 +1,194 @@ -## Description +# Directory Backend -This is the PL Network backend built with [Nest](https://github.com/nestjs/nest), [Prisma](https://www.prisma.io/) and [PostgresSQL](https://www.postgresql.org/). +This is the PL Network backend built with [Nest](https://github.com/nestjs/nest), [Prisma](https://www.prisma.io/), and [PostgreSQL](https://www.postgresql.org/). The parent project is generated using [Nx](https://nx.dev/). Check the docs to learn more. -### Installation +It is set up in monorepo (soon will be revamped to single microservice pattern) fashion currently hosting directory backend and directory frontend for admin (soon will be revamped). -```bash +The actual frontend for directory has been moved [here](https://github.com/memser-spaceport/pln-directory-portal-v2). + +## Folder Structure + +The folder structure of this project is organized as follows: + +- **apps/web-api**: Contains the actual backend service +- **apps/web-api/prisma**: Contains the database schema and migration files +- **libs/contracts**: Contains the API contracts + +--- + +## Prerequisites + +Before running this project, ensure the following software is installed on your system: + +1. **Docker** + Docker is essential for containerizing the application, making it easier to manage dependencies and deployments. + [Install Docker](https://docs.docker.com/get-docker/) + +2. **Docker Compose** + Docker Compose is a tool for defining and running multi-container Docker applications, which allows for easier orchestration of containers. + [Install Docker Compose](https://docs.docker.com/compose/install/) + +3. **PostgreSQL** + PostgreSQL is the primary database used in this project. Make sure to have it installed and configured, or use the Docker image provided in the `docker-compose.yml` file. + [Install PostgreSQL](https://www.postgresql.org/download/) + +4. **Redis** + Redis is used for caching, which improves performance and scalability. You can also run Redis as a Docker container if you prefer. + [Install Redis](https://redis.io/download) + +5. **Node.js** + Node.js is the JavaScript runtime for server-side scripting in this project. Ensure that a compatible version is installed. + [Install Node.js](https://nodejs.org/) + +6. **npm (Node Package Manager) and Yarn** + npm is included with Node.js and is used for installing dependencies and managing packages. + [Learn about npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) + [Install Yarn](https://classic.yarnpkg.com/lang/en/docs/install/) + +7. **NestJS** + NestJS is a framework for building efficient, reliable, and scalable server-side applications. It is the primary framework used in this project. + [Learn about NestJS](https://docs.nestjs.com/) + +8. **Prisma** + Prisma is an ORM (Object-Relational Mapper) used for interacting with the database in a type-safe way. + [Learn about Prisma](https://www.prisma.io/docs/) + +9. **Zod** + Zod is a TypeScript-first schema validation library used for data validation. + [Learn about Zod](https://zod.dev/) + +--- + +## Logging in Cloudwatch + +The application is set to send all the logs to Cloudwatch logs. If you have your own AWS access and secret keys with Cloudwatch permissions, you can configure them in `.env`. + +If you do not want to log in CloudWatch or do not have the necessary AWS keys, you can set `LOG_ENV=local`. + +## Setting up Dependent Services + +| Name | Type | Purpose | Mandatory | +| - | - | - | - | +| [Privy](https://www.privy.io/) | External | The hybrid auth solution provider for users to login | Yes, for local we have already provided you with a client id, just use that | +| AWS Cloudwatch logs | External | To store logs | No | +| AWS S3 | External | To store runtime images like profile pictures | Yes (You can skip it for local development but you will not be able to upload profile images) | +| AWS SES | External | To send email notifications | Yes, but you can skip in local by disabling email in .env | +| PL Auth service | Internal | To manage user auth requests and issue tokens, works in OAuth 2.0 standard | Yes, for local we have provided you with sandbox url | +| [Google API](https://developers.google.com/maps/documentation/places/web-service/get-api-key) | External | For location-based services | Yes | +| [Forestadmin](https://www.forestadmin.com/) | External | To manage data directly from/to database. The admin panel for the database | No | +| Github API Key | External | To get information about projects from Github repo | Yes | + +## Installation + +```sh $ yarn install +``` + +### Setup Docker for Postgres and Redis + +Install [Docker](https://docs.docker.com/engine/install/) and [Docker Compose](https://docs.docker.com/compose/install/). + +Then run: -# Setup docker for postgres +```sh $ docker-compose up -d ``` -### Add local environment variables +Once this is done, you will have your Postgres and Redis running through Docker and they will be up and running based on the following configurations: -1 - Create the environment variables file: +- Sample values through which Docker will run Postgres and Redis: + ```sh + DB_HOST_PORT=19432 + DB_USER=postgres + DB_PASSWORD=postgres + DB_NAME=plnetwork_dev + DATABASE_URL=postgresql://postgres:postgres@localhost:19432/plnetwork_dev -```bash -# ~/protocol-labs-network -cp .env.example .env -``` + REDIS_HOST=localhost + REDIS_PORT=6379 + ``` -2 - Copy the variables from this [1Password secure note](https://start.1password.com/open/i?a=RHJRUUECTJFG3A24NTR5KGKPZU&v=st4nf6p3qb35zzgc4hqwxyixra&i=7kuen7hvpjfstldqyenosgryza&h=pixelmatters.1password.com). +## Add Local Environment Variables -### Generate Prisma Schemas and update the database +1. Create the environment variables file: + ```sh + # ~/protocol-labs-network + cp .env.example .env + ``` -```bash -$ npx prisma generate --schema=./apps/web-api/prisma/schema.prisma +### Some Key Environment Variables for Local Mode + + ```sh + ENVIRONMENT=development + ``` + +- Generate a Github [personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens): + ```sh + GITHUB_API_KEY= + ``` + +- Make sure it has permission to read and write from the provided S3 bucket (If you do not have aws keys, leave it assuming you will not be uploading any profile images): + ```sh + AWS_ACCESS_KEY= + AWS_SECRET_KEY= + ``` +- Must be a public bucket: (Leave it if you do not have any) + ```sh + AWS_S3_BUCKET_NAME= + ``` + +- Set to false if you do not have aws keys with ses permission + ```sh + IS_EMAIL_ENABLED=false + ``` + +## Generate Prisma Schemas and Update the Database + +```sh +$ npx prisma generate --schema=./apps/web-api/prisma/schema.prisma $ npx prisma db push --schema=./apps/web-api/prisma/schema.prisma ``` -### Populate a database with mock data +## Populate a Database with Mock Data -⚠️ **Keep in mind that running the following command completely wipes the database before inserting any mock data.** +⚠ Keep in mind that running the following command completely wipes the database before inserting any mock data. -ℹ️ Before running the following command make sure that your [database is in sync with your Prisma schema](#generate-prisma-schemas-and-update-the-database). +ℹ Before running the following command, make sure that your [database is in sync with your Prisma schema](https://github.com/memser-spaceport/pln-directory-portal/blob/main/apps/web-api/README.md#generate-prisma-schemas-and-update-the-database). -```bash +```sh $ yarn nx seed web-api ``` -### Running the app +## Running the App + +Go to the parent folder: -```bash -# development +### Development + +```sh $ yarn nx serve web-api +``` + +### Production Mode -# production mode +```sh $ yarn nx build web-api --configuration=production ``` -### Test +## Test -```bash -$ nx run web-api:test +```sh +$ yarn nx run web-api:test ``` +To ensure code reliability and functionality, we use the Jest framework for writing and running test cases. Jest provides a robust environment for unit and integration testing, helping maintain the quality and stability of the application. -### Lint +## Lint + +```sh +$ yarn nx run web-api:lint``` -```bash -$ nx run web-api:lint -``` ### Full type safety endpoint development -For more information on this access the contracts lib documentation [here](../../libs/contracts/README.md). +For more information on this access the contracts lib documentation [here](./libs/contracts/README.md). diff --git a/apps/web-api/docs/ERD-Diagram.jpg b/apps/web-api/docs/ERD-Diagram.jpg new file mode 100644 index 000000000..8c4ed4d0d Binary files /dev/null and b/apps/web-api/docs/ERD-Diagram.jpg differ diff --git a/apps/web-api/prisma/fixtures/discovery-questions.ts b/apps/web-api/prisma/fixtures/discovery-questions.ts new file mode 100644 index 000000000..0ad0c79a6 --- /dev/null +++ b/apps/web-api/prisma/fixtures/discovery-questions.ts @@ -0,0 +1,83 @@ +import { Factory } from 'fishery'; +import { DiscoveryQuestion } from '@prisma/client'; +import { faker } from '@faker-js/faker'; +import sample from 'lodash/sample'; +import { prisma } from './../index'; + +const getUidsAndNamesFrom = async (model, nameField = 'name', where = {}) => { + return await prisma[model].findMany({ + select: { uid: true, [nameField]: true }, + where, + }); +}; + +const discoveryQuestionFactory = Factory.define>( + ({ sequence, onCreate }) => { + onCreate(async (discoveryQuestion) => { + const memberUids = await getUidsAndNamesFrom('member'); + discoveryQuestion.createdBy = sample(memberUids.map((u) => u.uid)) || ''; + discoveryQuestion.modifiedBy = sample(memberUids.map((u) => u.uid)) || ''; + + if (sequence >= 4) { + // Fetch UIDs and names for event, project, and team only if sequence >= 4 + const [eventData, projectData, teamData] = await Promise.all([ + getUidsAndNamesFrom('pLEvent'), + getUidsAndNamesFrom('project'), + getUidsAndNamesFrom('team'), + ]); + + // Randomly assign one of the UIDs and its corresponding name + const assignment = sample([ + { uidKey: 'eventUid', nameKey: 'eventName', data: eventData }, + { uidKey: 'projectUid', nameKey: 'projectName', data: projectData }, + { uidKey: 'teamUid', nameKey: 'teamName', data: teamData } + ]); + + if (assignment && assignment.data.length > 0) { + const selected = sample(assignment.data); + discoveryQuestion[assignment.uidKey] = selected?.uid || null; + discoveryQuestion[assignment.nameKey] = selected?.name || null; + } + } + + return discoveryQuestion; + }); + + return { + uid: faker.datatype.uuid(), + title: faker.lorem.words(5), + content: faker.lorem.words(10), + viewCount: faker.datatype.number({ min: 0, max: 1000 }), + shareCount: faker.datatype.number({ min: 0, max: 500 }), + slug: `${faker.helpers.slugify(faker.lorem.words(5))}-${sequence}`, + isActive: faker.datatype.boolean(), + teamUid: null, + teamName: null, + projectUid: null, + projectName: null, + eventUid: null, + eventName: null, + createdBy: '', + modifiedBy: '', + answer: faker.lorem.sentence(), + answerSources: [{ + title: faker.lorem.words(5), + link: faker.internet.url(), + description: faker.lorem.sentence() + }, { + title: faker.lorem.words(5), + link: faker.internet.url(), + description: faker.lorem.sentence() + }], + answerSourceFrom: faker.internet.url(), + relatedQuestions: [ + { content : faker.lorem.words(5)}, + { content : faker.lorem.words(4)}, + ], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + }; + } +); + +export const discoveryQuestions = async () => await discoveryQuestionFactory.createList(15); diff --git a/apps/web-api/prisma/fixtures/focus-areas.ts b/apps/web-api/prisma/fixtures/focus-areas.ts new file mode 100644 index 000000000..4f60da1b8 --- /dev/null +++ b/apps/web-api/prisma/fixtures/focus-areas.ts @@ -0,0 +1,15 @@ +import { faker } from '@faker-js/faker'; + +// Seed data for predefined focus areas +export const focusAreas = [ + 'Build Innovation Network', + 'Develop Advanced Technologies', + 'Public Goods', + 'Digital Human Rights', +].map((title) => ({ + uid: faker.helpers.slugify(`uid-${title.toLowerCase()}`), + title, + description: faker.lorem.sentence(), + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), +})); \ No newline at end of file diff --git a/apps/web-api/prisma/fixtures/index.ts b/apps/web-api/prisma/fixtures/index.ts index 86f910ce9..aafc0b547 100644 --- a/apps/web-api/prisma/fixtures/index.ts +++ b/apps/web-api/prisma/fixtures/index.ts @@ -11,3 +11,10 @@ export * from './team-member-roles'; export * from './teams'; export * from './member-roles'; export * from './project'; +export * from './pl-event-locations'; +export * from './pl-events'; +export * from './pl-event-guests'; +export * from './focus-areas'; +export * from './team-focus-areas'; +export * from './project-focus-area'; +export * from './discovery-questions'; diff --git a/apps/web-api/prisma/fixtures/locations.ts b/apps/web-api/prisma/fixtures/locations.ts index fbfc043a6..bfdfa2ca7 100644 --- a/apps/web-api/prisma/fixtures/locations.ts +++ b/apps/web-api/prisma/fixtures/locations.ts @@ -60,4 +60,4 @@ const locationsFactory = Factory.define>( } ); -export const locations = locationsFactory.buildList(250); +export const locations = locationsFactory.buildList(25); diff --git a/apps/web-api/prisma/fixtures/members.ts b/apps/web-api/prisma/fixtures/members.ts index 519fe7003..a53ca402e 100644 --- a/apps/web-api/prisma/fixtures/members.ts +++ b/apps/web-api/prisma/fixtures/members.ts @@ -59,7 +59,7 @@ const membersFactory = Factory.define>( } ); -export const members = async () => await membersFactory.createList(800); +export const members = async () => await membersFactory.createList(25); export const memberRelations = async (members) => { const skillUids = await getUidsFrom(Prisma.ModelName.Skill); diff --git a/apps/web-api/prisma/fixtures/pl-event-guests.ts b/apps/web-api/prisma/fixtures/pl-event-guests.ts new file mode 100644 index 000000000..45e3d7626 --- /dev/null +++ b/apps/web-api/prisma/fixtures/pl-event-guests.ts @@ -0,0 +1,58 @@ +import { Factory } from 'fishery'; +import { PLEventGuest } from '@prisma/client'; +import { faker } from '@faker-js/faker'; +import sample from 'lodash/sample'; +import { prisma } from './../index'; + +const getUidsFrom = async (model, where = {}) => { + return await prisma[model].findMany({ + select: { + uid: true, + }, + where, + }); +}; + +const eventGuestFactory = Factory.define>( + ({ onCreate }) => { + onCreate(async (eventGuest) => { + // Fetching UIDs for relational fields + const memberUids = await ( + await getUidsFrom('member') + ).map((result) => result.uid); + eventGuest.memberUid = sample(memberUids) || ''; + + const teamUids = await ( + await getUidsFrom('team') + ).map((result) => result.uid); + eventGuest.teamUid = sample(teamUids) || ''; + + const eventUids = await ( + await getUidsFrom('pLEvent') + ).map((result) => result.uid); + eventGuest.eventUid = sample(eventUids) || ''; + + return eventGuest; + }); + + return { + uid: faker.datatype.uuid(), + telegramId: faker.datatype.uuid(), + officeHours: faker.helpers.arrayElement([null, faker.internet.url()]), + reason: faker.helpers.arrayElement([null, faker.lorem.sentence()]), + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + memberUid: '', + teamUid: '', + eventUid: '', + additionalInfo: faker.helpers.arrayElement([{}, { details: faker.lorem.paragraph() }]), + topics: faker.helpers.arrayElements(faker.lorem.words(5).split(' '), faker.datatype.number({ min: 1, max: 5 })), + priority: faker.datatype.number({ min: 1, max: 100 }), + isHost: faker.datatype.boolean(), + isSpeaker: faker.datatype.boolean(), + isFeatured: faker.datatype.boolean(), + }; + } +); + +export const eventGuests = async () => await eventGuestFactory.createList(25); diff --git a/apps/web-api/prisma/fixtures/pl-event-locations.ts b/apps/web-api/prisma/fixtures/pl-event-locations.ts new file mode 100644 index 000000000..87b56ec84 --- /dev/null +++ b/apps/web-api/prisma/fixtures/pl-event-locations.ts @@ -0,0 +1,26 @@ +import { Factory } from 'fishery'; +import { PLEventLocation } from '@prisma/client'; +import { faker } from '@faker-js/faker'; + +const eventLocationFactory = Factory.define>(({ onCreate }) => { + onCreate(async (location) => { + return location; + }); + return { + uid: faker.datatype.uuid(), + location: faker.address.city(), + flag: "https://plabs-assets.s3.us-west-1.amazonaws.com/images/Thailand-flag.png", + icon: "https://plabs-assets.s3.us-west-1.amazonaws.com/images/Bangkok.svg", + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + timezone: faker.address.timeZone(), + latitude: faker.address.latitude(), + longitude: faker.address.longitude(), + priority: faker.datatype.number(), + resources: [{ url: faker.internet.url(), description: faker.lorem.sentence()}], + additionalInfo: {} + }; +}); + +export const eventLocations = async () => await eventLocationFactory.createList(25); + diff --git a/apps/web-api/prisma/fixtures/pl-events.ts b/apps/web-api/prisma/fixtures/pl-events.ts new file mode 100644 index 000000000..0ed389dc3 --- /dev/null +++ b/apps/web-api/prisma/fixtures/pl-events.ts @@ -0,0 +1,65 @@ +import { Factory } from 'fishery'; +import { PLEvent } from '@prisma/client'; +import { faker } from '@faker-js/faker'; +import sample from 'lodash/sample'; +import { prisma } from './../index'; + +const getUidsFrom = async (model, where = {}) => { + return await prisma[model].findMany({ + select: { + uid: true, + }, + where, + }); +}; + +const eventFactory = Factory.define>( + ({ sequence, onCreate }) => { + onCreate(async (event) => { + const locationUids = await ( + await getUidsFrom('pLEventLocation') + ).map((result) => result.uid); + event.locationUid = sample(locationUids) || ''; + + const logoImageUids = await ( + await getUidsFrom('image', { thumbnailToUid: null }) + ).map((result) => result.uid); + event.logoUid = sample(logoImageUids) || ''; + event.bannerUid = sample(logoImageUids) || ''; + return event; + }); + + const startDate = faker.date.future(); + // Manually set endDate to a random time after startDate, ensuring it's in the future + const endDate = new Date(startDate.getTime() + faker.datatype.number({ min: 1, max: 30 }) * 24 * 60 * 60 * 1000); + + return { + uid: `uid_${sequence}`, + type: faker.helpers.arrayElement(['INVITE_ONLY', null]), + name: faker.company.name(), + description: faker.lorem.paragraph(), + eventsCount: faker.datatype.number({ min: 1, max: 100 }), + startDate: startDate, + endDate: endDate, + shortDescription: faker.lorem.sentence(), + isFeatured: faker.datatype.boolean(), + telegramId: faker.datatype.uuid(), + websiteURL: faker.internet.url(), + resources: [{ + url: faker.internet.url(), + description: faker.lorem.sentence(), + name: faker.company.name() + }], + logoUid: '', + bannerUid: '', + locationUid: '', + additionalInfo: {}, + priority: faker.datatype.number({ min: 1, max: 100 }), + slugURL: `${faker.helpers.slugify(faker.company.name())}-${sequence}`, + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + }; + } +); + +export const events = async () => await eventFactory.createList(25); diff --git a/apps/web-api/prisma/fixtures/project-focus-area.ts b/apps/web-api/prisma/fixtures/project-focus-area.ts new file mode 100644 index 000000000..e9c659182 --- /dev/null +++ b/apps/web-api/prisma/fixtures/project-focus-area.ts @@ -0,0 +1,41 @@ +import { Factory } from 'fishery'; +import { ProjectFocusArea } from '@prisma/client'; +import sample from 'lodash/sample'; +import { prisma } from './../index'; + +const getUidsFrom = async (model, where = {}) => { + return await prisma[model].findMany({ + select: { + uid: true, + }, + where, + }); +}; + +const projectFocusAreaFactory = Factory.define>( + ({ onCreate }) => { + onCreate(async (projectFocusArea) => { + // Fetch UIDs for relational fields + const projectUids = await ( + await getUidsFrom('project') + ).map((result) => result.uid); + projectFocusArea.projectUid = sample(projectUids); + + const focusAreaUids = await ( + await getUidsFrom('focusArea') + ).map((result) => result.uid); + const focusAreaUid = sample(focusAreaUids); + projectFocusArea.focusAreaUid = focusAreaUid; + projectFocusArea.ancestorAreaUid = focusAreaUid; + + return projectFocusArea; + }); + return { + projectUid: '', + focusAreaUid: '', + ancestorAreaUid: '', + }; + } +); + +export const projectFocusAreas = async () => await projectFocusAreaFactory.createList(8); diff --git a/apps/web-api/prisma/fixtures/project.ts b/apps/web-api/prisma/fixtures/project.ts index f21c64982..31e5f6d4c 100644 --- a/apps/web-api/prisma/fixtures/project.ts +++ b/apps/web-api/prisma/fixtures/project.ts @@ -61,7 +61,7 @@ const ProjectFactory = Factory.define>( } ); -export const projects = async () => await ProjectFactory.createList(25); +export const projects = async () => await ProjectFactory.createList(40); export const projectRelations = async (projects) => { const teamUids = await (await getUidsFrom(Prisma.ModelName.Team)); diff --git a/apps/web-api/prisma/fixtures/team-focus-areas.ts b/apps/web-api/prisma/fixtures/team-focus-areas.ts new file mode 100644 index 000000000..78d1fc874 --- /dev/null +++ b/apps/web-api/prisma/fixtures/team-focus-areas.ts @@ -0,0 +1,40 @@ +import { Factory } from 'fishery'; +import { TeamFocusArea } from '@prisma/client'; +import sample from 'lodash/sample'; +import { prisma } from './../index'; + +const getUidsFrom = async (model, where = {}) => { + return await prisma[model].findMany({ + select: { + uid: true, + }, + where, + }); +}; + +const teamFocusAreaFactory = Factory.define>( + ({ onCreate }) => { + onCreate(async (teamFocusArea) => { + // Fetch UIDs for relational fields + const teamUids = await ( + await getUidsFrom('team') + ).map((result) => result.uid); + teamFocusArea.teamUid = sample(teamUids); + + const focusAreaUids = await ( + await getUidsFrom('focusArea') + ).map((result) => result.uid); + const focusAreaUid = sample(focusAreaUids); + teamFocusArea.focusAreaUid = focusAreaUid; + teamFocusArea.ancestorAreaUid = focusAreaUid; + return teamFocusArea; + }); + return { + teamUid: '', + focusAreaUid: '', + ancestorAreaUid: '', + }; + } +); + +export const teamFocusAreas = async () => await teamFocusAreaFactory.createList(8); diff --git a/apps/web-api/prisma/fixtures/team-member-roles.ts b/apps/web-api/prisma/fixtures/team-member-roles.ts index 95eadeb59..b64a72c85 100644 --- a/apps/web-api/prisma/fixtures/team-member-roles.ts +++ b/apps/web-api/prisma/fixtures/team-member-roles.ts @@ -24,7 +24,7 @@ export const teamMemberRoles = async () => { teamUid: teamUid.uid, memberUid: uid, role: faker.name.jobTitle(), - mainTeam: false, + mainTeam: faker.datatype.boolean(), teamLead: faker.datatype.boolean(), startDate: faker.date.past(), endDate: faker.date.recent(), diff --git a/apps/web-api/prisma/fixtures/teams.ts b/apps/web-api/prisma/fixtures/teams.ts index c077141e8..ce6baad2c 100644 --- a/apps/web-api/prisma/fixtures/teams.ts +++ b/apps/web-api/prisma/fixtures/teams.ts @@ -67,7 +67,7 @@ const teamsFactory = Factory.define>( } ); -export const teams = async () => await teamsFactory.createList(300); +export const teams = async () => await teamsFactory.createList(40); export const teamRelations = async (teams) => { const industryTagUids = await getUidsFrom(Prisma.ModelName.IndustryTag); diff --git a/apps/web-api/prisma/seed.ts b/apps/web-api/prisma/seed.ts index 31e280f1f..2bbe98130 100644 --- a/apps/web-api/prisma/seed.ts +++ b/apps/web-api/prisma/seed.ts @@ -19,7 +19,14 @@ import { technologies, memberRoles, projects, - projectRelations + projectRelations, + eventLocations, + events, + eventGuests, + focusAreas, + teamFocusAreas, + projectFocusAreas, + discoveryQuestions } from './fixtures'; /** @@ -58,8 +65,7 @@ async function load(fixtures) { .relations(fixturesToCreate) .then((data) => Promise.all(data)) : null; - - await prisma[camelCase(model)].createMany({ + await prisma[model].createMany({ data: fixturesToCreate, }); console.log(`✅ Added ${model} data`); @@ -102,7 +108,32 @@ load([ { [Prisma.ModelName.Project]: { fixtures: projects, relations: projectRelations - }} + }}, + { [Prisma.ModelName.PLEventLocation]: { + fixtures: eventLocations + } }, + { [Prisma.ModelName.PLEvent]: { + fixtures: events + }}, + { [Prisma.ModelName.PLEventGuest]: { + fixtures: eventGuests + }}, + { [Prisma.ModelName.FocusArea]: focusAreas }, + { + [Prisma.ModelName.TeamFocusArea]: { + fixtures: teamFocusAreas + } + }, + { + [Prisma.ModelName.ProjectFocusArea]: { + fixtures: projectFocusAreas + } + }, + { + [Prisma.ModelName.DiscoveryQuestion]: { + fixtures: discoveryQuestions + } + } ]) .then(async () => { await prisma.$disconnect(); diff --git a/apps/web-api/src/admin/admin.controller.ts b/apps/web-api/src/admin/admin.controller.ts deleted file mode 100644 index 7d8f5b794..000000000 --- a/apps/web-api/src/admin/admin.controller.ts +++ /dev/null @@ -1,173 +0,0 @@ -/* eslint-disable prettier/prettier */ -import { - Body, - Controller, - ForbiddenException, - Get, - Param, - Patch, - Post, - Put, - Query, - Req, - UseGuards, -} from '@nestjs/common'; -import { ApprovalStatus, ParticipantType } from '@prisma/client'; - -import { NoCache } from '../decorators/no-cache.decorator'; -import { ParticipantsRequestService } from '../participants-request/participants-request.service'; -import { - ParticipantProcessRequestSchema, - ParticipantRequestMemberSchema, - ParticipantRequestTeamSchema, -} from 'libs/contracts/src/schema/participants-request'; -import { AdminAuthGuard } from '../guards/admin-auth.guard'; -import { AdminService } from './admin.service'; -@Controller('v1/admin') -export class AdminController { - constructor( - private readonly participantsRequestService: ParticipantsRequestService, - private readonly adminService: AdminService - ) {} - - @Post('signin') - async signIn(@Body() body) { - return await this.adminService.signIn(body.username, body.password); - } - - @Get('participants') - @NoCache() - @UseGuards(AdminAuthGuard) - async findAll(@Query() query) { - const result = await this.participantsRequestService.getAll(query); - return result; - } - - @Get('participants/:uid') - @NoCache() - @UseGuards(AdminAuthGuard) - async findOne(@Param() params) { - const result = await this.participantsRequestService.getByUid(params.uid); - return result; - } - - @Post('participants') - @UseGuards(AdminAuthGuard) - // @UseGuards(GoogleRecaptchaGuard) - async addRequest(@Body() body) { - const postData = body; - const participantType = body.participantType; - // delete postData.captchaToken; - - if ( - participantType === ParticipantType.MEMBER.toString() && - !ParticipantRequestMemberSchema.safeParse(postData).success - ) { - throw new ForbiddenException(); - } else if ( - participantType === ParticipantType.TEAM.toString() && - !ParticipantRequestTeamSchema.safeParse(postData).success - ) { - throw new ForbiddenException(); - } else if ( - participantType !== ParticipantType.TEAM.toString() && - participantType !== ParticipantType.MEMBER.toString() - ) { - throw new ForbiddenException(); - } - - const result = await this.participantsRequestService.addRequest(postData); - return result; - } - - @Put('participants/:uid') - @UseGuards(AdminAuthGuard) - //@UseGuards(GoogleRecaptchaGuard) - async updateRequest(@Body() body, @Param() params) { - const postData = body; - const participantType = body.participantType; - - if ( - participantType === ParticipantType.MEMBER.toString() && - !ParticipantRequestMemberSchema.safeParse(postData).success - ) { - throw new ForbiddenException(); - } else if ( - participantType === ParticipantType.TEAM.toString() && - !ParticipantRequestTeamSchema.safeParse(postData).success - ) { - throw new ForbiddenException(); - } else if ( - participantType !== ParticipantType.TEAM.toString() && - participantType !== ParticipantType.MEMBER.toString() - ) { - throw new ForbiddenException(); - } - const result = await this.participantsRequestService.updateRequest( - postData, - params.uid - ); - return result; - } - - @Patch('participants/:uid') - // @UseGuards(GoogleRecaptchaGuard) - @UseGuards(AdminAuthGuard) - async processRequest(@Body() body, @Param() params) { - const validation = ParticipantProcessRequestSchema.safeParse(body); - if (!validation.success) { - throw new ForbiddenException(); - } - const uid = params.uid; - const participantType = body.participantType; - const referenceUid = body.referenceUid; - const statusToProcess = body.status; - let result; - - // Process reject - if (statusToProcess === ApprovalStatus.REJECTED.toString()) { - result = await this.participantsRequestService.processRejectRequest(uid); - } - // Process approval for create team - else if ( - participantType === 'TEAM' && - statusToProcess === ApprovalStatus.APPROVED.toString() && - !referenceUid - ) { - result = await this.participantsRequestService.processTeamCreateRequest( - uid - ); - } - // Process approval for create Member - else if ( - participantType === 'MEMBER' && - statusToProcess === ApprovalStatus.APPROVED.toString() && - !referenceUid - ) { - result = await this.participantsRequestService.processMemberCreateRequest( - uid - ); - } - // Process approval for Edit Team - else if ( - participantType === 'TEAM' && - statusToProcess === ApprovalStatus.APPROVED.toString() && - referenceUid - ) { - result = await this.participantsRequestService.processTeamEditRequest( - uid - ); - } - // Process approval for Edit Member - else if ( - participantType === 'MEMBER' && - statusToProcess === ApprovalStatus.APPROVED.toString() && - referenceUid - ) { - result = await this.participantsRequestService.processMemberEditRequest( - uid - ); - } - return result; - } -} diff --git a/apps/web-api/src/admin/admin.module.ts b/apps/web-api/src/admin/admin.module.ts index 36b05bb91..35195ad6c 100644 --- a/apps/web-api/src/admin/admin.module.ts +++ b/apps/web-api/src/admin/admin.module.ts @@ -1,28 +1,16 @@ -/* eslint-disable prettier/prettier */ import { CacheModule, Module } from '@nestjs/common'; - -import { ParticipantsRequestService } from '../participants-request/participants-request.service'; -import { LocationTransferService } from '../utils/location-transfer/location-transfer.service'; -import { AwsService } from '../utils/aws/aws.service'; -import { RedisService } from '../utils/redis/redis.service'; -import { SlackService } from '../utils/slack/slack.service'; import { AdminService } from './admin.service'; import { JwtService } from '../utils/jwt/jwt.service'; -import { AdminController } from './admin.controller'; -import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; - +import { ParticipantsRequestModule } from '../participants-request/participants-request.module'; +import { SharedModule } from '../shared/shared.module'; +import { AdminParticipantsRequestController } from './participants-request.controller'; +import { AdminAuthController } from './auth.controller'; @Module({ - imports: [CacheModule.register()], - controllers: [AdminController], + imports: [CacheModule.register(), ParticipantsRequestModule, SharedModule], + controllers: [AdminParticipantsRequestController, AdminAuthController], providers: [ - ParticipantsRequestService, - LocationTransferService, - AwsService, - RedisService, - SlackService, AdminService, - JwtService, - ForestAdminService, + JwtService ], }) export class AdminModule {} diff --git a/apps/web-api/src/admin/admin.service.ts b/apps/web-api/src/admin/admin.service.ts index fe232ed5e..c342c57d8 100644 --- a/apps/web-api/src/admin/admin.service.ts +++ b/apps/web-api/src/admin/admin.service.ts @@ -1,26 +1,41 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '../utils/jwt/jwt.service'; +import { LogService } from '../shared/log.service'; @Injectable() export class AdminService { - constructor(private readonly jwtService: JwtService) {} + constructor( + private readonly jwtService: JwtService, + private logger: LogService, + ) {} /** - * Validates given username and password for the admin against the env config and - * creates a signed jwt token if credentials are valid else, throws {@link UnauthorizedException} - * @param username admin username - * @param password admin password - * @returns a signed jwt token in json format {code: 1, accessToken: } if crdentials valid - * @throws UnauthorizedException if credentials not valid + * Logs in the admin using the provided username and password. + * Validates credentials from environment variables. + * @param username - Admin username + * @param password - Admin password + * @returns Object containing access token on successful login + * @throws UnauthorizedException if credentials are invalid */ - async signIn(username: string, password: string): Promise { - const usernameFromEnv = process.env.ADMIN_USERNAME; - const passwordFromEnv = process.env.ADMIN_PASSWORD; - - if (username !== usernameFromEnv || passwordFromEnv !== password) { - throw new UnauthorizedException(); + async login(username: string, password: string): Promise<{ code:Number, accessToken: string }> { + if (!this.isValidAdminCredentials(username, password)) { + this.logger.error('Invalid credentials provided for admin login.'); + throw new UnauthorizedException('Invalid credentials'); } + this.logger.info('Generating admin access token...'); const accessToken = await this.jwtService.getSignedToken(['DIRECTORYADMIN']); return { code: 1, accessToken: accessToken }; } + + /** + * Validates the provided credentials against stored environment variables. + * @param username - Input username + * @param password - Input password + * @returns Boolean indicating if credentials are valid + */ + private isValidAdminCredentials(username: string, password: string): boolean { + const validUsername = process.env.ADMIN_LOGIN_USERNAME; + const validPassword = process.env.ADMIN_LOGIN_PASSWORD; + return username === validUsername && password === validPassword; + } } diff --git a/apps/web-api/src/admin/auth.controller.ts b/apps/web-api/src/admin/auth.controller.ts new file mode 100644 index 000000000..acb4569f7 --- /dev/null +++ b/apps/web-api/src/admin/auth.controller.ts @@ -0,0 +1,22 @@ +import { Body, Controller, Post, UsePipes } from '@nestjs/common'; +import { LoginRequestDto } from 'libs/contracts/src/schema'; +import { ZodValidationPipe } from 'nestjs-zod'; +import { AdminService } from './admin.service'; + +@Controller('v1/admin/auth') +export class AdminAuthController { + constructor(private readonly adminService: AdminService) {} + + /** + * Handles admin login requests. + * Validates the request body against the LoginRequestDto schema. + * @param loginRequestDto - The login request data transfer object + * @returns Access token if login is successful + */ + @Post('login') + @UsePipes(ZodValidationPipe) + async login(@Body() loginRequestDto: LoginRequestDto): Promise<{ accessToken: string }> { + const { username, password } = loginRequestDto; + return await this.adminService.login(username, password); + } +} diff --git a/apps/web-api/src/admin/participants-request.controller.ts b/apps/web-api/src/admin/participants-request.controller.ts new file mode 100644 index 000000000..1a0a1d027 --- /dev/null +++ b/apps/web-api/src/admin/participants-request.controller.ts @@ -0,0 +1,93 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Put, + Query, + UseGuards, + UsePipes, + BadRequestException, + NotFoundException +} from '@nestjs/common'; +import { NoCache } from '../decorators/no-cache.decorator'; +import { ParticipantsRequestService } from '../participants-request/participants-request.service'; +import { AdminAuthGuard } from '../guards/admin-auth.guard'; +import { ParticipantsReqValidationPipe } from '../pipes/participant-request-validation.pipe'; +import { ProcessParticipantReqDto } from 'libs/contracts/src/schema'; +import { ApprovalStatus, ParticipantsRequest, ParticipantType } from '@prisma/client'; + +@Controller('v1/admin/participants-request') +@UseGuards(AdminAuthGuard) +export class AdminParticipantsRequestController { + constructor( + private readonly participantsRequestService: ParticipantsRequestService + ) {} + + /** + * Retrieve all participants requests based on query parameters. + * @param query - Filter parameters for participants requests + * @returns A list of participants requests + */ + @Get("/") + @NoCache() + async findAll(@Query() query) { + return this.participantsRequestService.getAll(query); + } + + /** + * Retrieve a single participants request by its UID. + * @param uid - The unique identifier of the participants request + * @returns The participants request entry matching the UID + */ + @Get("/:uid") + @NoCache() + async findOne(@Param('uid') uid: string) { + return await this.participantsRequestService.findOneByUid(uid); + } + + /** + * Update an existing participants request by its UID. + * @param body - The updated data for the participants request + * @param uid - The unique identifier of the participants request + * @returns The updated participants request entry + */ + @Put('/:uid') + @UsePipes(new ParticipantsReqValidationPipe()) + async updateRequest( + @Body() body: any, + @Param('uid') uid: string + ) { + return await this.participantsRequestService.updateByUid(uid, body); + } + + /** + * Process (approve/reject) a pending participants request. + * @param body - The request body containing the status for processing (e.g., approve/reject) + * @param uid - The unique identifier of the participants request + * @returns The result of processing the participants request + */ + @Patch('/:uid') + async processRequest( + @Param('uid') uid: string, + @Body() body: ProcessParticipantReqDto + ): Promise { + const participantRequest: ParticipantsRequest | null = await this.participantsRequestService.findOneByUid(uid); + if (!participantRequest) { + throw new NotFoundException('Request not found'); + } + if (participantRequest.status !== ApprovalStatus.PENDING) { + throw new BadRequestException( + `Request cannot be processed. It has already been ${participantRequest.status.toLowerCase()}.` + ); + } + if (participantRequest.participantType === ParticipantType.TEAM && !participantRequest.requesterEmailId) { + throw new BadRequestException( + 'Requester email is required for team participation requests. Please provide a valid email address.' + ); + } + return await this.participantsRequestService.processRequestByUid(uid, participantRequest, body.status); + } +} + \ No newline at end of file diff --git a/apps/web-api/src/faq/faq.service.ts b/apps/web-api/src/faq/faq.service.ts index 4896aa368..e3155a434 100644 --- a/apps/web-api/src/faq/faq.service.ts +++ b/apps/web-api/src/faq/faq.service.ts @@ -92,7 +92,7 @@ export class FaqService { this.supportEmails, [] ); - this.logger.info(`New faq request from ${faq.email} - ${faq.uid} notified to support team ref: ${result.MessageId}`); + this.logger.info(`New faq request from ${faq.email} - ${faq.uid} notified to support team ref: ${result?.MessageId}`); } else { this.logger.error( `Cannot send custom question content for ${faq.uid} as ${this.supportEmails} does not contain valid email addresses` diff --git a/apps/web-api/src/join-requests/join-request.service.ts b/apps/web-api/src/join-requests/join-request.service.ts index d7636a53a..47b30e1d1 100644 --- a/apps/web-api/src/join-requests/join-request.service.ts +++ b/apps/web-api/src/join-requests/join-request.service.ts @@ -60,7 +60,7 @@ export class JoinRequestsService { this.supportEmails, [] ); - this.logger.info(`New Join request from ${joinRequest.email} - ${joinRequest.uid} notified to support team ref: ${result.MessageId}`); + this.logger.info(`New Join request from ${joinRequest.email} - ${joinRequest.uid} notified to support team ref: ${result?.MessageId}`); } else { this.logger.error( `Cannot send new join request content for ${joinRequest.uid} as ${this.supportEmails} does not contain valid email addresses` diff --git a/apps/web-api/src/main.ts b/apps/web-api/src/main.ts index 74994ec85..e02d653db 100644 --- a/apps/web-api/src/main.ts +++ b/apps/web-api/src/main.ts @@ -23,7 +23,7 @@ export async function bootstrap() { .addTag('PL') .build(); const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api', app, document); + SwaggerModule.setup('api/docs', app, document); // Sentry - Error Reporting Sentry.init({ diff --git a/apps/web-api/src/members/members.controller.ts b/apps/web-api/src/members/members.controller.ts index f28d5aa9f..5102acdfd 100644 --- a/apps/web-api/src/members/members.controller.ts +++ b/apps/web-api/src/members/members.controller.ts @@ -1,7 +1,8 @@ -import { Body, Controller, Param, Req, UnauthorizedException, UseGuards } from '@nestjs/common'; +import { Body, Controller, Param, Req, UseGuards, UsePipes, BadRequestException, ForbiddenException } from '@nestjs/common'; import { ApiNotFoundResponse, ApiParam } from '@nestjs/swagger'; import { Api, ApiDecorator, initNestServer } from '@ts-rest/nest'; import { Request } from 'express'; +import { ZodValidationPipe } from 'nestjs-zod'; import { MemberDetailQueryParams, MemberQueryParams, @@ -22,6 +23,7 @@ import { NoCache } from '../decorators/no-cache.decorator'; import { AuthGuard } from '../guards/auth.guard'; import { UserAccessTokenValidateGuard } from '../guards/user-access-token-validate.guard'; import { LogService } from '../shared/log.service'; +import { ParticipantsReqValidationPipe } from '../pipes/participant-request-validation.pipe'; const server = initNestServer(apiMembers); type RouteShape = typeof server.routeShapes; @@ -30,7 +32,14 @@ type RouteShape = typeof server.routeShapes; @NoCache() export class MemberController { constructor(private readonly membersService: MembersService, private logger: LogService) {} - + + /** + * Retrieves a list of members based on query parameters. + * Builds a Prisma query from the queryable fields and adds filters for names, roles, and recent members. + * + * @param request - HTTP request object containing query parameters + * @returns Array of members with related data + */ @Api(server.route.getMembers) @ApiQueryFromZod(MemberQueryParams) @ApiOkResponseFromZod(ResponseMemberWithRelationsSchema.array()) @@ -54,6 +63,13 @@ export class MemberController { return await this.membersService.findAll(builtQuery); } + /** + * Retrieves member roles based on query parameters with their counts. + * Builds a Prisma query and applies filters to return roles with the count of associated members. + * + * @param request - HTTP request object containing query parameters + * @returns Array of roles with member counts + */ @Api(server.route.getMemberRoles) async getMemberFilters(@Req() request: Request) { const queryableFields = prismaQueryableFieldsFromZod(ResponseMemberWithRelationsSchema); @@ -74,6 +90,14 @@ export class MemberController { return await this.membersService.getRolesWithCount(builtQuery, queryParams); } + /** + * Retrieves details of a specific member by UID. + * Builds a query for member details including relations and profile data. + * + * @param request - HTTP request object containing query parameters + * @param uid - UID of the member to fetch + * @returns Member details with related data + */ @Api(server.route.getMember) @ApiParam({ name: 'uid', type: 'string' }) @ApiNotFoundResponse(NOT_FOUND_GLOBAL_RESPONSE_SCHEMA) @@ -88,14 +112,39 @@ export class MemberController { return member; } + /** + * Updates member details based on the provided participant request data. + * Uses a validation pipe to ensure that the request is valid before processing. + * + * @param id - ID of the member to update + * @param body - Request body containing member data to update + * @param req - HTTP request object containing user email + * @returns Updated member data + */ @Api(server.route.modifyMember) @UseGuards(UserTokenValidation) - async updateOne(@Param('id') id, @Body() body, @Req() req) { + @UsePipes(new ParticipantsReqValidationPipe()) + async updateMember(@Param('uid') uid, @Body() participantsRequest, @Req() req) { this.logger.info(`Member update request - Initated by -> ${req.userEmail}`); - const participantsRequest = body; - return await this.membersService.editMemberParticipantsRequest(participantsRequest, req.userEmail); + const requestor = await this.membersService.findMemberByEmail(req.userEmail); + const { referenceUid } = participantsRequest; + if ( + !requestor.isDirectoryAdmin && + referenceUid !== requestor.uid + ) { + throw new ForbiddenException(`Member isn't authorized to update the member`); + } + return await this.membersService.updateMemberFromParticipantsRequest(uid, participantsRequest, requestor.email); } + /** + * Updates a member's preference settings. + * + * @param id - UID of the member whose preferences will be updated + * @param body - Request body containing preference data + * @param req - HTTP request object + * @returns Updated preference data + */ @Api(server.route.modifyMemberPreference) @UseGuards(AuthGuard) async updatePrefernce(@Param('uid') id, @Body() body, @Req() req) { @@ -105,10 +154,16 @@ export class MemberController { @Api(server.route.updateMember) @UseGuards(UserTokenValidation) - async updateMember(@Param('uid') uid, @Body() body) { - return await this.membersService.updateMember(uid, body); + async updateMemberByUid(@Param('uid') uid, @Body() body) { + return await this.membersService.updateMemberByUid(uid, body); } + /** + * Retrieves a member's preference settings by UID. + * + * @param uid - UID of the member whose preferences will be fetched + * @returns Member's preferences + */ @Api(server.route.getMemberPreferences) @UseGuards(AuthGuard) @NoCache() @@ -116,18 +171,56 @@ export class MemberController { return await this.membersService.getPreferences(uid); } + /** + * Sends an OTP for email change to the new email provided by the member. + * + * @param sendOtpRequest - Request DTO containing the new email + * @param req - HTTP request object containing user email + * @returns Response indicating success of OTP sending + */ @Api(server.route.sendOtpForEmailChange) @UseGuards(UserAccessTokenValidateGuard) + @UsePipes(ZodValidationPipe) async sendOtpForEmailChange(@Body() sendOtpRequest: SendEmailOtpRequestDto, @Req() req) { - return await this.membersService.sendOtpForEmailChange(sendOtpRequest.newEmail, req.userEmail); + const oldEmailId = req.userEmail; + if (sendOtpRequest.newEmail.toLowerCase().trim() === oldEmailId.toLowerCase().trim()) { + throw new BadRequestException('New email cannot be same as old email'); + } + let isMemberAvailable = await this.membersService.isMemberExistForEmailId(oldEmailId); + if (!isMemberAvailable) { + throw new ForbiddenException('Your email seems to have been updated recently. Please login and try again'); + } + isMemberAvailable = await this.membersService.isMemberExistForEmailId(sendOtpRequest.newEmail); + if (isMemberAvailable) { + throw new BadRequestException('Above email id is already used. Please try again with different email id.'); + } + return await this.membersService.sendOtpForEmailChange(sendOtpRequest.newEmail); } + /** + * Updates a member's email address to a new one. + * + * @param changeEmailRequest - Request DTO containing the new email address + * @param req - HTTP request object containing user email + * @returns Updated member data with new email + */ @Api(server.route.updateMemberEmail) @UseGuards(UserAccessTokenValidateGuard) + @UsePipes(ZodValidationPipe) async updateMemberEmail(@Body() changeEmailRequest: ChangeEmailRequestDto, @Req() req) { - return await this.membersService.updateMemberEmail(changeEmailRequest.newEmail, req.userEmail); + const memberInfo = await this.membersService.findMemberByEmail(req.userEmail); + if(!memberInfo || !memberInfo.externalId) { + throw new ForbiddenException("Please login again and try") + } + return await this.membersService.updateMemberEmail(changeEmailRequest.newEmail, req.userEmail, memberInfo); } + /** + * Retrieves GitHub projects associated with the member identified by UID. + * + * @param uid - UID of the member whose GitHub projects will be fetched + * @returns Array of GitHub projects associated with the member + */ @Api(server.route.getMemberGitHubProjects) async getGitProjects(@Param('uid') uid) { return await this.membersService.getGitProjects(uid); diff --git a/apps/web-api/src/members/members.module.ts b/apps/web-api/src/members/members.module.ts index f26a31be5..16c05a344 100644 --- a/apps/web-api/src/members/members.module.ts +++ b/apps/web-api/src/members/members.module.ts @@ -1,37 +1,20 @@ import { Module } from '@nestjs/common'; -import { ImagesController } from '../images/images.controller'; -import { ImagesService } from '../images/images.service'; -import { FileEncryptionService } from '../utils/file-encryption/file-encryption.service'; -import { AwsService } from '../utils/aws/aws.service'; -import { FileMigrationService } from '../utils/file-migration/file-migration.service'; -import { FileUploadService } from '../utils/file-upload/file-upload.service'; -import { LocationTransferService } from '../utils/location-transfer/location-transfer.service'; import { MemberController } from './members.controller'; -import { RedisService } from '../utils/redis/redis.service'; -import { SlackService } from '../utils/slack/slack.service'; import { MembersService } from './members.service'; -import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; -import { AuthService } from '../auth/auth.service'; import { ParticipantsRequestModule } from '../participants-request/participants-request.module'; import { OtpModule } from '../otp/otp.module'; +import { SharedModule } from '../shared/shared.module'; import { AuthModule } from '../auth/auth.module'; - @Module({ - imports: [OtpModule, ParticipantsRequestModule], + imports: [ + SharedModule, + AuthModule, + OtpModule, + ParticipantsRequestModule + ], providers: [ - MembersService, - FileMigrationService, - ImagesController, - ImagesService, - FileUploadService, - FileEncryptionService, - LocationTransferService, - AwsService, - RedisService, - SlackService, - ForestAdminService, - AuthService + MembersService ], controllers: [MemberController], exports: [MembersService] diff --git a/apps/web-api/src/members/members.service.ts b/apps/web-api/src/members/members.service.ts index 70a695e82..12cd54869 100644 --- a/apps/web-api/src/members/members.service.ts +++ b/apps/web-api/src/members/members.service.ts @@ -2,46 +2,85 @@ import { BadRequestException, CACHE_MANAGER, - ForbiddenException, - HttpException, + ConflictException, + NotFoundException, Inject, Injectable, - InternalServerErrorException, - Logger, - UnauthorizedException, + forwardRef } from '@nestjs/common'; -import { Cache } from 'cache-manager'; -import { ParticipantType, Prisma } from '@prisma/client'; -import * as path from 'path'; import { z } from 'zod'; +import axios from 'axios'; +import * as path from 'path'; +import { Cache } from 'cache-manager'; +import { Prisma, Member, ParticipantsRequest } from '@prisma/client'; import { PrismaService } from '../shared/prisma.service'; import { ParticipantsRequestService } from '../participants-request/participants-request.service'; import { AirtableMemberSchema } from '../utils/airtable/schema/airtable-member.schema'; import { FileMigrationService } from '../utils/file-migration/file-migration.service'; -import { hashFileName } from '../utils/hashing'; +import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; import { LocationTransferService } from '../utils/location-transfer/location-transfer.service'; -import { ParticipantRequestMemberSchema } from 'libs/contracts/src/schema/participants-request'; -import axios from 'axios'; +import { NotificationService } from '../utils/notification/notification.service'; import { EmailOtpService } from '../otp/email-otp.service'; import { AuthService } from '../auth/auth.service'; import { LogService } from '../shared/log.service'; -import { DIRECTORYADMIN, DEFAULT_MEMBER_ROLES } from '../utils/constants'; +import { DEFAULT_MEMBER_ROLES } from '../utils/constants'; +import { hashFileName } from '../utils/hashing'; +import { copyObj, buildMultiRelationMapping } from '../utils/helper/helper'; + @Injectable() export class MembersService { constructor( private prisma: PrismaService, private locationTransferService: LocationTransferService, - private participantsRequestService: ParticipantsRequestService, private fileMigrationService: FileMigrationService, private emailOtpService: EmailOtpService, private authService: AuthService, private logger: LogService, + private forestadminService: ForestAdminService, + @Inject(forwardRef(() => ParticipantsRequestService)) + private participantsRequestService: ParticipantsRequestService, + @Inject(forwardRef(() => NotificationService)) + private notificationService: NotificationService, @Inject(CACHE_MANAGER) private cacheService: Cache ) {} - findAll(queryOptions: Prisma.MemberFindManyArgs) { - return this.prisma.member.findMany(queryOptions); + /** + * Creates a new member in the database within a transaction. + * + * @param member - The data for the new member to be created + * @param tx - The transaction client to ensure atomicity + * @returns The created member record + */ + async createMember( + member: Prisma.MemberUncheckedCreateInput, + tx: Prisma.TransactionClient = this.prisma + ): Promise { + try { + return await tx.member.create({ + data: member, + }); + } catch(error) { + return this.handleErrors(error); + } + } + + /** + * Retrieves a list of members based on the provided query options. + * + * This method interacts with the Prisma ORM to execute a `findMany` query on the `member` table, + * using the query options specified in the `Prisma.MemberFindManyArgs` object. + * + * @param queryOptions - An object containing the query options to filter, sort, and paginate + * the members. These options are based on Prisma's `MemberFindManyArgs`. + * @returns A promise that resolves to an array of member records matching the query criteria. + */ + async findAll(queryOptions: Prisma.MemberFindManyArgs): Promise { + try { + return await this.prisma.member.findMany(queryOptions); + } catch(error) { + return this.handleErrors(error); + } } /** @@ -146,471 +185,1023 @@ export class MembersService { } } - findOne( + /** + * Updates the Member data in the database within a transaction. + * + * @param uid - Unique identifier of the member being updated + * @param member - The new data to be applied to the member + * @param tx - The transaction client to ensure atomicity + * @returns The updated member record + */ + async updateMemberByUid( + uid: string, + member: Prisma.MemberUncheckedUpdateInput, + tx: Prisma.TransactionClient = this.prisma, + ): Promise { + try { + return await tx.member.update({ + where: { uid }, + data: member, + }); + } catch(error) { + return this.handleErrors(error); + } + } + + /** + * Retrieves a member record by its UID, with additional relational data. + * If the member is not found, an exception is thrown. + * + * @param uid - The unique identifier (UID) of the member to retrieve. + * @param queryOptions - Additional query options to customize the search (excluding the 'where' clause). + * @param tx - An optional Prisma TransactionClient for executing within a transaction. + * @returns A promise that resolves to the member object, including related data such as image, location, + * skills, roles, team roles, and project contributions. Throws an exception if the member is not found. + */ + async findOne( uid: string, queryOptions: Omit = {}, tx?: Prisma.TransactionClient - ) { - return (tx || this.prisma).member.findUniqueOrThrow({ - where: { uid }, - ...queryOptions, - include: { - image: true, - location: true, - skills: true, - memberRoles: true, - teamMemberRoles: { - include: { - team: { - include: { - logo: true, + ): Promise { + try { + return await (tx || this.prisma).member.findUniqueOrThrow({ + where: { uid }, + ...queryOptions, + include: { + image: true, + location: true, + skills: true, + memberRoles: true, + teamMemberRoles: { + include: { + team: { + include: { + logo: true, + }, + }, + }, + }, + projectContributions: { + include: { + project: { + include: { + logo: true, + }, }, }, }, }, - projectContributions: { - include: { - project: { - include:{ - logo: true - } - } - } - } - }, - }); - } - - async findMemberByEmail(emailId) { - return await this.prisma.member.findUnique({ - where: { email: emailId.toLowerCase().trim() }, - include: { - image: true, - memberRoles: true, - teamMemberRoles: true, - projectContributions: true - }, - }); - } - - async findMemberByExternalId(externalId) { - return await this.prisma.member.findUnique({ - where: { externalId: externalId }, - include: { - image: true, - memberRoles: true, - teamMemberRoles: true, - projectContributions: true - }, - }); + }); + } catch(error) { + return this.handleErrors(error); + } } - - async sendOtpForEmailChange(newEmailId, oldEmailId) { - if (newEmailId.toLowerCase().trim() === oldEmailId.toLowerCase().trim()) { - throw new BadRequestException('New email cannot be same as old email'); + /** + * Retrieves a member record by its external ID, with additional relational data. + * + * @param externalId - The external ID of the member to find. + * @returns A promise that resolves to the member object, including associated image, roles, team roles, + * and project contributions. If no member is found, it returns `null`. + */ + async findMemberByExternalId(externalId: string): Promise { + try { + return await this.prisma.member.findUnique({ + where: { externalId }, + include: { + image: true, + memberRoles: true, + teamMemberRoles: true, + projectContributions: true, + } + }); + } catch(error) { + return this.handleErrors(error); } + } - let isMemberAvailable = await this.isMemberExistForEmailId(oldEmailId); - if (!isMemberAvailable) { - throw new ForbiddenException('Your email seems to have been updated recently. Please login and try again'); + /** + * Fetches existing member data including relationships. + * @param tx - Prisma transaction client or Prisma client. + * @param uid - Member UID to fetch. + */ + async findMemberByUid(uid: string, tx: Prisma.TransactionClient = this.prisma){ + try { + return tx.member.findUniqueOrThrow({ + where: { uid }, + include: { + image: true, + location: true, + skills: true, + teamMemberRoles: true, + memberRoles: true, + projectContributions: true + }, + }); + } catch(error) { + return this.handleErrors(error); } + } - isMemberAvailable = await this.isMemberExistForEmailId(newEmailId); - if (isMemberAvailable) { - throw new BadRequestException('Above email id is already used. Please try again with different email id.'); + /** + * Finds a member by their email address. + * + * @param email - The member's email address. + * @returns The member object if found. + */ + async findMemberFromEmail(email: string): Promise { + try { + return await this.prisma.member.findUniqueOrThrow({ + where: { email: email.toLowerCase().trim() }, + include: { + memberRoles: true + }, + }); + } catch(error) { + return this.handleErrors(error); } - return await this.emailOtpService.sendEmailOtp(newEmailId); } - async updateMemberEmail(newEmail, oldEmail) { - const memberInfo = await this.findMemberByEmail(oldEmail); - if(!memberInfo || !memberInfo.externalId) { - throw new ForbiddenException("Please login again and try") + /** + * Retrieves a member by email, including additional data such as roles, teams, and project contributions. + * Also determines if the member is a Directory Admin. + * + * @param userEmail - The email address of the member to retrieve. + * @returns A promise that resolves to an object containing the member's details, their roles, + * and whether they are a Directory Admin. It also returns the teams the member leads. + * If the member is not found, it returns `null`. + */ + async findMemberByEmail(userEmail: string) { + try { + const foundMember = await this.prisma.member.findUnique({ + where: { + email: userEmail.toLowerCase().trim(), + }, + include: { + image: true, + memberRoles: true, + teamMemberRoles: true, + projectContributions: true, + }, + }); + if (!foundMember) { + return null; + } + const roleNames = foundMember.memberRoles.map((m) => m.name); + const isDirectoryAdmin = roleNames.includes('DIRECTORYADMIN'); + return { + ...foundMember, + isDirectoryAdmin, + roleNames, + leadingTeams: foundMember.teamMemberRoles + .filter((role) => role.teamLead) + .map((role) => role.teamUid), + }; + } catch(error) { + return this.handleErrors(error); } + } - let newTokens; - let newMemberInfo; - - await this.prisma.$transaction(async (tx) => { - await this.participantsRequestService.addAutoApprovalEntry(tx, { - status: 'AUTOAPPROVED', - requesterEmailId: oldEmail, - referenceUid: memberInfo.uid, - uniqueIdentifier: oldEmail, - participantType: 'MEMBER', - newData: { oldEmail: oldEmail, newEmail: newEmail } - }) - - newMemberInfo = await tx.member.update({ - where: {email: oldEmail.toLowerCase().trim()}, - data: {email: newEmail.toLowerCase().trim()}, - include: { - memberRoles: true, - image: true, - teamMemberRoles: true, - } - }) - newTokens = await this.authService.updateEmailInAuth(newEmail, oldEmail, memberInfo.externalId) - }) + /** + * Sends an OTP (One-Time Password) to the provided email address for verification purposes. + * This method utilizes the `emailOtpService` to generate and send the OTP. + * + * @param newEmailId - The email address to which the OTP should be sent. + * @returns A promise that resolves when the OTP is successfully sent to the provided email address. + */ + async sendOtpForEmailChange(newEmailId: string) { + return await this.emailOtpService.sendEmailOtp(newEmailId); + } - // Log Info - this.logger.info(`Email has been successfully updated from ${oldEmail} to ${newEmail}`) - await this.cacheService.reset(); - return { - refreshToken: newTokens.refresh_token, - idToken: newTokens.id_token, - accessToken: newTokens.access_token, - userInfo: this.memberToUserInfo(newMemberInfo) + /** + * Updates a member's email address in both the database and the authentication service. + * This method performs the following operations: + * - Logs the email change request in the `participantsRequestService` for audit purposes. + * - Updates the member's email in the database, including associated member roles, images, and team member roles. + * - Updates the member's email in the authentication service to ensure consistency across services. + * - Resets the cache to reflect the updated member information. + * - Logs the successful email update. + * + * @param newEmail - The new email address to update. + * @param oldEmail - The current email address that will be replaced. + * @param memberInfo - An object containing the member's information, including their unique ID and external ID. + * @returns A promise that resolves with updated authentication tokens (refresh token, ID token, access token) + * and the updated member information in the form of `userInfo`. + * + * @throws If any operation within the transaction fails, the entire transaction is rolled back. + */ + async updateMemberEmail(newEmail:string, oldEmail:string, memberInfo) { + try { + let newTokens; + let newMemberInfo; + await this.prisma.$transaction(async (tx) => { + await this.participantsRequestService.addRequest({ + status: 'AUTOAPPROVED', + requesterEmailId: oldEmail, + referenceUid: memberInfo.uid, + uniqueIdentifier: oldEmail, + participantType: 'MEMBER', + newData: { + oldEmail: oldEmail, + email: newEmail + }}, + false, + tx + ); + newMemberInfo = await tx.member.update({ + where: { email: oldEmail.toLowerCase().trim()}, + data: { email: newEmail.toLowerCase().trim()}, + include: { + memberRoles: true, + image: true, + teamMemberRoles: true, + } + }) + newTokens = await this.authService.updateEmailInAuth(newEmail, oldEmail, memberInfo.externalId) + }); + this.logger.info(`Email has been successfully updated from ${oldEmail} to ${newEmail}`) + await this.cacheService.reset(); + return { + refreshToken: newTokens.refresh_token, + idToken: newTokens.id_token, + accessToken: newTokens.access_token, + userInfo: this.memberToUserInfo(newMemberInfo) + }; + } catch(error) { + return this.handleErrors(error); } } - - async isMemberExistForEmailId(emailId) { - const member = await this.prisma.member.findUnique({ - where: { email: emailId.toLowerCase().trim() }, - }); - - return member ? true : false; + /** + * Checks if a member exists with the provided email address. + * The email address is normalized to lowercase and trimmed before querying. + * + * @param emailId - The email address to check for an existing member. + * @returns A boolean value indicating whether the member exists (`true`) or not (`false`). + */ + async isMemberExistForEmailId(emailId: string): Promise { + const member = await this.findMemberByEmail(emailId); + return !!member; // Simplified return to directly return boolean } + + /** + * Converts the member entity to a user information object. + * This method maps necessary member details such as login state, name, email, roles, + * profile image URL, and teams they lead. + * + * @param memberInfo - The member object from the database. + * @returns A structured user information object containing fields like + * isFirstTimeLogin, name, email, profileImageUrl, uid, roles, and leadingTeams. + */ private memberToUserInfo(memberInfo) { return { - isFirstTimeLogin: memberInfo?.externalId ? false : true, + isFirstTimeLogin: !!memberInfo?.externalId === false, name: memberInfo.name, email: memberInfo.email, - profileImageUrl: memberInfo.image?.url, + profileImageUrl: memberInfo.image?.url ?? null, uid: memberInfo.uid, - roles: memberInfo.memberRoles?.map((r) => r.name), + roles: memberInfo.memberRoles?.map((r) => r.name) ?? [], leadingTeams: memberInfo.teamMemberRoles?.filter((role) => role.teamLead) - .map(role => role.teamUid) + .map(role => role.teamUid) ?? [] }; } - - async updateExternalIdByEmail(emailId, externalId) { - return await this.prisma.member.update({ - where: { email: emailId.toLowerCase().trim() }, - data: { externalId: externalId }, - }); + /** + * Updates the external ID for the member identified by the provided email address. + * This method normalizes the email address before updating the external ID in the database. + * + * @param emailId - The email address of the member whose external ID should be updated. + * @param externalId - The new external ID to be assigned to the member. + * @returns The updated member object after the external ID is updated. + * @throws Error if the member does not exist or the update fails. + */ + async updateExternalIdByEmail(emailId: string, externalId: string): Promise { + try { + return await this.prisma.member.update({ + where: { email: emailId.toLowerCase().trim() }, + data: { externalId }, + }); + } catch(error){ + return this.handleErrors(error); + } } - async insertManyWithLocationsFromAirtable( - airtableMembers: z.infer[] - ) { - const skills = await this.prisma.skill.findMany(); - const images = await this.prisma.image.findMany(); - - for (const member of airtableMembers) { - if (!member.fields?.Name) { - continue; - } - - let image; - - if (member.fields['Profile picture']) { - const ppf = member.fields['Profile picture'][0]; - - const hashedPpf = ppf.filename - ? hashFileName(`${path.parse(ppf.filename).name}-${ppf.id}`) - : ''; - - image = - images.find( - (image) => path.parse(image.filename).name === hashedPpf - ) || - (await this.fileMigrationService.migrateFile({ - id: ppf.id || '', - url: ppf.url || '', - filename: ppf.filename || '', - size: ppf.size || 0, - type: ppf.type || '', - height: ppf.height || 0, - width: ppf.width || 0, - })); - } - - const optionalFieldsToAdd = Object.entries({ - email: 'Email', - githubHandler: 'Github Handle', - discordHandler: 'Discord handle', - twitterHandler: 'Twitter', - officeHours: 'Office hours link', - }).reduce( - (optionalFields, [prismaField, airtableField]) => ({ - ...optionalFields, - ...(member.fields?.[airtableField] && { - [prismaField]: member.fields?.[airtableField], - }), - }), - {} - ); - - const manyToManyRelations = { - skills: { - connect: skills - .filter( - (skill) => - !!member.fields?.['Skills'] && - member.fields?.['Skills'].includes(skill.title) - ) - .map((skill) => ({ id: skill.id })), - }, - }; - - const { location } = await this.locationTransferService.transferLocation( - member - ); - - await this.prisma.member.upsert({ - where: { - airtableRecId: member.id, - }, - update: { - ...optionalFieldsToAdd, - ...manyToManyRelations, - }, - create: { - airtableRecId: member.id, - name: member.fields.Name, - plnFriend: member.fields['Friend of PLN'] || false, - locationUid: location ? location?.uid : null, - imageUid: image?.uid, - ...optionalFieldsToAdd, - ...manyToManyRelations, - }, + /** + * Retrieves a member's GitHub handler based on their UID. + * + * @param uid - The UID of the member. + * @returns The GitHub handler of the member or null if not found. + */ + private async getMemberGitHubHandler(uid: string): Promise { + try { + const member = await this.prisma.member.findUnique({ + where: { uid }, + select: { githubHandler: true }, }); + return member?.githubHandler || null; + } catch(error) { + return this.handleErrors(error); } } - async getGitProjects(uid) { - const member = await this.prisma.member.findUnique( - { - where: { uid: uid }, - select: { githubHandler: true } - } - ); - if (!member || !member.githubHandler) { - return []; - } - try { - const resp = await axios - .post( - 'https://api.github.com/graphql', - { - query: `{ - user(login: "${member?.githubHandler}") { - pinnedItems(first: 6, types: REPOSITORY) { - nodes { - ... on RepositoryInfo { - name - description - url - createdAt - updatedAt - } - } - } + /** + * Sends a request to the GitHub GraphQL API to fetch pinned repositories. + * + * @param githubHandler - The GitHub username of the member. + * @returns An array of pinned repositories or an empty array if none are found. + */ + private async fetchPinnedRepositories(githubHandler: string) { + const query = { + query: `{ + user(login: "${githubHandler}") { + pinnedItems(first: 6, types: REPOSITORY) { + nodes { + ... on RepositoryInfo { + name + description + url + createdAt + updatedAt } - }`, - }, - { - headers: { - Authorization: `Bearer ${process.env.GITHUB_API_KEY}`, - 'Content-Type': 'application/json', - }, + } } - ); - const response = await axios - .get(`https://api.github.com/users/${member.githubHandler}/repos?sort=pushed&per_page=50`); - const repositories = response?.data.map((item) => { - return { - name: item.name, - description: item.description, - url: item.html_url, - createdAt: item.created_at, - updatedAt: item.updated_at, - }; - }); - if (resp?.data?.data?.user) { - const { pinnedItems } = resp.data.data.user; - if (pinnedItems?.nodes?.length > 0) { - // Create a Set of pinned repository names for efficient lookup - const pinnedRepositoryNames = new Set(pinnedItems.nodes.map((repo) => repo.name)); - // Filter out the pinned repositories from the list of all repositories - const filteredRepositories = repositories?.filter((repo) => !pinnedRepositoryNames.has(repo.name)); - return [...pinnedItems.nodes, ...filteredRepositories].slice(0, 50); - } else { - return repositories || []; } - } - } - catch(err) { - this.logger.error('Error occured while fetching the git projects.', err); - return { - statusCode: 500, - message: 'Internal Server Error.' - }; - } - return []; - } - - async editMemberParticipantsRequest(participantsRequest, userEmail) { - this.logger.info(`Member update request - Processing with values - ${JSON.stringify(participantsRequest)}`) - const { referenceUid } = participantsRequest; - const requestorDetails = - await this.participantsRequestService.findMemberByEmail(userEmail); - if (!requestorDetails) { - throw new UnauthorizedException(); - } - if ( - !requestorDetails.isDirectoryAdmin && - referenceUid !== requestorDetails.uid - ) { - throw new ForbiddenException(); - } - participantsRequest.requesterEmailId = requestorDetails.email; - if ( - participantsRequest.participantType === - ParticipantType.MEMBER.toString() && - !ParticipantRequestMemberSchema.safeParse(participantsRequest).success - ) { - throw new BadRequestException(); - } - if ( - participantsRequest.participantType === ParticipantType.MEMBER.toString() - ) { - const { city, country, region } = participantsRequest.newData; - if (city || country || region) { - const result: any = await this.locationTransferService.fetchLocation( - city, - country, - null, - region, - null - ); - if (!result || !result?.location) { - throw new BadRequestException('Invalid Location info'); + }`, + }; + try { + const response = await axios.post( + 'https://api.github.com/graphql', + query, + { + headers: { + Authorization: `Bearer ${process.env.GITHUB_API_KEY}`, + 'Content-Type': 'application/json', + }, } - } + ); + return response?.data?.data?.user?.pinnedItems?.nodes || []; + } catch (err) { + this.logger.error('Error fetching pinned repositories from GitHub.', err); + return []; } - let result; + } + + /** + * Sends a request to the GitHub REST API to fetch recent repositories. + * + * @param githubHandler - The GitHub username of the member. + * @returns An array of recent repositories or an empty array if none are found. + */ + private async fetchRecentRepositories(githubHandler: string) { try { - await this.prisma.$transaction(async (tx) => { - result = await this.participantsRequestService.addRequest( - participantsRequest, - true, - tx - ); - if (result?.uid) { - this.logger.info(`Member update request - Added entry in pariticipants request table, requestId -> ${result.uid}, requestor -> ${userEmail}`) - await this.participantsRequestService.processMemberEditRequest( - result.uid, - true, // disable the notification - true, // enable the auto approval - requestorDetails.isDirectoryAdmin, - tx - ); - this.logger.info(`Member update request - completed, requestId -> ${result.uid}, requestor -> ${userEmail}`) - } else { - throw new InternalServerErrorException(`Error in updating member request`); - } - }); - } catch (error) { - this.logger.info(`Member update request - error , requestor -> ${userEmail}, referenceId -> ${referenceUid}, error -> ${JSON.stringify(error)}`) - if (error?.response?.statusCode && error?.response?.message) { - throw new HttpException( - error?.response?.message, - error?.response?.statusCode - ); - } else { - throw new BadRequestException( - 'Oops, something went wrong. Please try again!' - ); - } + const response = await axios.get( + `https://api.github.com/users/${githubHandler}/repos?sort=pushed&per_page=50` + ); + return response?.data.map((repo) => ({ + name: repo.name, + description: repo.description, + url: repo.html_url, + createdAt: repo.created_at, + updatedAt: repo.updated_at, + })) || []; + } catch (err) { + this.logger.error('Error fetching recent repositories from GitHub.', err); + return []; } - return result; } - findMemberFromEmail(email:string){ - return this.prisma.member.findUniqueOrThrow({ - where: { email: email.toLowerCase().trim() }, - include: { - memberRoles: true, - }, + /** + * Combines pinned and recent repositories, ensuring no duplicates. + * + * @param pinnedRepos - Array of pinned repositories. + * @param recentRepos - Array of recent repositories. + * @returns An array of up to 50 combined repositories with pinned ones first. + */ + private combineRepositories(pinnedRepos, recentRepos) { + const pinnedRepoNames = new Set(pinnedRepos.map((repo) => repo.name)); + const filteredRecentRepos = recentRepos.filter((repo) => !pinnedRepoNames.has(repo.name)); + return [...pinnedRepos, ...filteredRecentRepos].slice(0, 50); + } + + /** + * Fetches a member's GitHub repositories (pinned and recent). + * + * @param uid - The UID of the member for whom the GitHub projects are to be fetched. + * @returns An array of repositories (both pinned and recent), or an error response if something goes wrong. + */ + async getGitProjects(uid: string) { + const githubHandler = await this.getMemberGitHubHandler(uid); + if (!githubHandler) { + return []; + } + try { + const pinnedRepos = await this.fetchPinnedRepositories(githubHandler); + const recentRepos = await this.fetchRecentRepositories(githubHandler); + return this.combineRepositories(pinnedRepos, recentRepos); + } catch (err) { + this.logger.error('Error occurred while fetching GitHub projects.', err); + return { + statusCode: 500, + message: 'Internal Server Error.', + }; + } + } + + /** + * Creates a new team from the participants request data. + * resets the cache, and triggers post-update actions like Airtable synchronization. + * @param teamParticipantRequest - The request containing the team details. + * @param requestorEmail - The email of the requestor. + * @param tx - The transaction client to ensure atomicity + * @returns The newly created team. + */ + async createMemberFromParticipantsRequest( + memberParticipantRequest: ParticipantsRequest, + tx: Prisma.TransactionClient = this.prisma + ): Promise { + const memberData: any = memberParticipantRequest.newData; + const member = await this.prepareMemberFromParticipantRequest(null, memberData, null, tx); + await this.mapLocationToMember(memberData, null, member, tx); + return await this.createMember(member, tx); + } + + async updateMemberFromParticipantsRequest( + memberUid: string, + memberParticipantsRequest: ParticipantsRequest, + requestorEmail: string + ): Promise { + let result; + await this.prisma.$transaction(async (tx) => { + const memberData: any = memberParticipantsRequest.newData; + const existingMember = await this.findMemberByUid(memberUid, tx); + const isExternalIdAvailable = existingMember.externalId ? true : false; + const isEmailChanged = await this.checkIfEmailChanged(memberData, existingMember, tx); + this.logger.info(`Member update request - Initiaing update for member uid - ${existingMember.uid}, requestId -> ${memberUid}`) + const member = await this.prepareMemberFromParticipantRequest(memberUid, memberData, existingMember, tx, 'Update'); + await this.mapLocationToMember(memberData, existingMember, member, tx); + result = await this.updateMemberByUid( + memberUid, + { + ...member, + ...(isEmailChanged && isExternalIdAvailable && { externalId: null }) + }, + tx + ); + await this.updateMemberEmailChange(memberUid, isEmailChanged, isExternalIdAvailable, memberData, existingMember); + await this.logParticipantRequest(requestorEmail, memberData, existingMember.uid, tx); + this.notificationService.notifyForMemberEditApproval(memberData.name, memberUid, requestorEmail); + this.logger.info(`Member update request - completed, requestId -> ${result.uid}, requestor -> ${requestorEmail}`) }); + await this.postUpdateActions(); + return result; + } + + /** + * Checks if the email has changed during update and verifies if the new email is already in use. + * + * @param transactionType - The Prisma transaction client, used for querying the database. + * @param dataToProcess - The input data containing the new email. + * @param existingData - The existing member data, used for comparing the current email. + * @throws {BadRequestException} - Throws if the email has been changed and the new email is already in use. + */ + async checkIfEmailChanged( + memberData, + existingMember, + transactionType: Prisma.TransactionClient, + ): Promise { + const isEmailChanged = existingMember.email?.toLowerCase() !== memberData.email?.toLowerCase(); + if (isEmailChanged) { + const foundUser = await transactionType.member.findUnique({ + where: { email: memberData.email.toLowerCase().trim() }, + }); + if (foundUser && foundUser.email) { + throw new BadRequestException('Email already exists. Please try again with a different email'); + } + } + return isEmailChanged; } - async updatePreference(id,preference){ - const response = this.prisma.member.update( - { - where: {uid: id}, - data: {preferences: preference} + /** + * prepare member data for creation or update + * + * @param memberUid - The unique identifier for the member (used for updates) + * @param memberData - Raw member data to be formatted + * @param tx - Transaction client for atomic operations + * @param type - Operation type ('create' or 'update') + * @returns - Formatted member data for Prisma query + */ + async prepareMemberFromParticipantRequest( + memberUid: string | null, + memberData, + existingMember, + tx: Prisma.TransactionClient, + type: string = 'Create' + ) { + const member: any = {}; + const directFields = [ + 'name', 'email', 'githubHandler', 'discordHandler', 'bio', + 'twitterHandler', 'linkedinHandler', 'telegramHandler', + 'officeHours', 'moreDetails', 'plnStartDate', 'openToWork' + ]; + copyObj(memberData, member, directFields); + member.email = member.email.toLowerCase().trim(); + member['image'] = memberData.imageUid ? { connect: { uid: memberData.imageUid } } + : type === 'Update' ? { disconnect: true } : undefined ; + member['skills'] = buildMultiRelationMapping('skills', memberData, type); + if (type === 'Create') { + member['teamMemberRoles'] = this.buildTeamMemberRoles(memberData); + if (Array.isArray(memberData.projectContributions)) { + member['projectContributions'] = { + createMany: { data: memberData.projectContributions }, + }; + } + } else { + await this.updateProjectContributions(memberData, existingMember, memberUid, tx); + await this.updateTeamMemberRoles(memberData, existingMember, memberUid, tx); } - ); - this.cacheService.reset(); - return response; + return member; } - async updateMember(uid, member) { - const response = this.prisma.member.update( - { - where: { uid }, - data: { ...member } + /** + * Process and map location data for both create and update operations. + * It fetches and upserts location details based on the provided city, country, and region, + * and connects or disconnects the location accordingly. + * + * @param memberData - The input data containing location fields (city, country, region). + * @param existingData - The existing member data, used for comparing locations during updates. + * @param member - The data object that will be saved with the mapped location. + * @param tx - The Prisma transaction client, used for upserting location. + * @returns {Promise} - Resolves once the location has been processed and mapped. + * @throws {BadRequestException} - Throws if the location data is invalid. + */ + async mapLocationToMember( + memberData: any, + existingMember: any, + member: any, + tx: Prisma.TransactionClient, + ): Promise { + const { city, country, region } = memberData; + if (city || country || region) { + const result = await this.locationTransferService.fetchLocation(city, country, null, region, null); + // If the location has a valid placeId, proceed with upsert + if (result?.location?.placeId) { + const finalLocation = await tx.location.upsert({ + where: { placeId: result.location.placeId }, + update: result.location, + create: result.location, + }); + // Only connect the new location if it's different from the existing one + if (finalLocation?.uid && existingMember?.location?.uid !== finalLocation.uid) { + member['location'] = { connect: { uid: finalLocation.uid } }; + } + } else { + // If the location is invalid, throw an exception + throw new BadRequestException('Invalid Location info'); + } + } else { + if (existingMember) { + member['location'] = { disconnect: true }; + } } + } + + /** + * Main function to process team member role updates by creating, updating, or deleting roles. + * + * @param memberData - New data for processing team member roles. + * @param existingMember - Existing member data used to identify roles for update or deletion. + * @param referenceUid - The member's reference UID. + * @param tx - The Prisma transaction client. + * @returns {Promise} + */ + async updateTeamMemberRoles( + memberData, + existingMember, + memberUid, + tx: Prisma.TransactionClient, + ) { + const oldTeamUids = existingMember.teamMemberRoles.map((t: any) => t.teamUid); + const newTeamUids = memberData.teamAndRoles.map((t: any) => t.teamUid); + // Determine which roles need to be deleted, updated, or created + const rolesToDelete = existingMember.teamMemberRoles.filter( + (t: any) => !newTeamUids.includes(t.teamUid) ); - this.cacheService.reset(); - return response; - } - - async getPreferences(uid) { - const resp:any = await this.prisma.member.findUnique( - { - where: { uid: uid }, - select: { - email: true, - githubHandler: true, - telegramHandler:true, - discordHandler: true, - linkedinHandler: true, - twitterHandler: true, - preferences: true, + const rolesToUpdate = memberData.teamAndRoles.filter((t: any, index: number) => { + const foundIndex = existingMember.teamMemberRoles.findIndex( + (v: any) => v.teamUid === t.teamUid + ); + if (foundIndex > -1) { + const foundValue = existingMember.teamMemberRoles[foundIndex]; + if (foundValue.role !== t.role) { + let foundDefaultRoleTag = false; + // Check if there's a default member role tag + foundValue.roleTags?.some((tag: any) => { + if (Object.keys(DEFAULT_MEMBER_ROLES).includes(tag)) { + foundDefaultRoleTag = true; + return true; + } + }); + // Set roleTags for the new role based on default roleTags or split role string + memberData.teamAndRoles[index].roleTags = foundDefaultRoleTag + ? foundValue.roleTags + : t.role?.split(',').map((item: string) => item.trim()); + return true; } } + return false; + }); + const rolesToCreate = memberData.teamAndRoles.filter( + (t: any) => !oldTeamUids.includes(t.teamUid) ); - const preferences = {...resp.preferences}; - if (!resp.preferences) { - preferences.isnull = true; - } else{ - preferences.isnull = false; - } - preferences.email = resp?.email ? true: false; - preferences.github = resp?.githubHandler ? true: false; - preferences.telegram = resp?.telegramHandler ? true: false; - preferences.discord = resp?.discordHandler ? true: false; - preferences.linkedin = resp?.linkedinHandler ? true : false; - preferences.twitter = resp?.twitterHandler ? true: false; - return preferences; + // Process deletions, updates, and creations + await this.deleteTeamMemberRoles(tx, rolesToDelete, memberUid); + await this.modifyTeamMemberRoles(tx, rolesToUpdate, memberUid); + await this.createTeamMemberRoles(tx, rolesToCreate, memberUid); + } + + /** + * Function to handle the creation of new team member roles. + * + * @param tx - The Prisma transaction client. + * @param rolesToCreate - Array of team roles to create. + * @param referenceUid - The member's reference UID. + * @returns {Promise} + */ + async createTeamMemberRoles( + tx: Prisma.TransactionClient, + rolesToCreate: any[], + memberUid: string + ) { + if (rolesToCreate.length > 0) { + const rolesToCreateData = rolesToCreate.map((t: any) => ({ + role: t.role, + mainTeam: false, // Set your default values here if needed + teamLead: false, // Set your default values here if needed + teamUid: t.teamUid, + memberUid, + roleTags: t.role?.split(',').map((item: string) => item.trim()), // Properly format roleTags + })); + + await tx.teamMemberRole.createMany({ + data: rolesToCreateData, + }); + } + } + + + /** + * Function to handle deletion of team member roles. + * + * @param tx - The Prisma transaction client. + * @param rolesToDelete - Array of team UIDs to delete. + * @param referenceUid - The member's reference UID. + * @returns {Promise} + */ + async deleteTeamMemberRoles( + tx: Prisma.TransactionClient, + rolesToDelete, + memberUid: string + ) { + if (rolesToDelete.length > 0) { + await tx.teamMemberRole.deleteMany({ + where: { + teamUid: { in: rolesToDelete.map((t: any) => t.teamUid) }, + memberUid, + }, + }); + } } - async isMemberLeadTeam(member, teamUid) { - const user = await this.memberToUserInfo(member); - if (user.leadingTeams.includes(teamUid)) { - return true; + /** + * Function to handle the update of existing team member roles. + * + * @param tx - The Prisma transaction client. + * @param rolesToUpdate - Array of team roles to update. + * @param referenceUid - The member's reference UID. + * @returns {Promise} + */ + async modifyTeamMemberRoles( + tx: Prisma.TransactionClient, + rolesToUpdate, + memberUid: string + ): Promise { + if (rolesToUpdate.length > 0) { + const updatePromises = rolesToUpdate.map((roleToUpdate: any) => + tx.teamMemberRole.update({ + where: { + memberUid_teamUid: { + teamUid: roleToUpdate.teamUid, + memberUid, + }, + }, + data: { role: roleToUpdate.role, roleTags: roleToUpdate.roleTags }, + }) + ); + await Promise.all(updatePromises); } - return false; } - checkIfAdminUser = (member) => { - const roleFilter = member.memberRoles.filter((roles) => { - return roles.name === DIRECTORYADMIN; + /** + * Builds the team member roles relational data + * @param dataToProcess - Raw data containing team and roles + * @returns - Team member roles relational data for Prisma query + */ + private buildTeamMemberRoles(memberData) { + return { + createMany: { + data: memberData.teamAndRoles.map((t) => ({ + role: t.role, + mainTeam: false, + teamLead: false, + teamUid: t.teamUid, + roleTags: t.role?.split(',')?.map(item => item.trim()), + })), + }, + }; + } + + /** + * function to handle creation, updating, and deletion of project contributions + * with fewer database calls by using batch operations. + * + * @param memberData - The input data containing the new project contributions. + * @param existingMember - The existing member data, used to identify contributions to update or delete. + * @param memberUid - The reference UID for associating the new contributions with the member. + * @param tx - The Prisma transaction client. + * @returns {Promise} + */ + async updateProjectContributions( + memberData, + existingMember, + memberUid: string | null, + tx: Prisma.TransactionClient, + ): Promise { + const contributionsToCreate = memberData.projectContributions?.filter( + (contribution) => !contribution.uid + ) || []; + const contributionUidsInRequest = memberData.projectContributions + ?.filter((contribution) => contribution.uid) + .map((contribution) => contribution.uid) || []; + const contributionIdsToDelete: string[] = []; + const contributionIdsToUpdate: any = []; + existingMember.projectContributions?.forEach((existingContribution: any) => { + if (!contributionUidsInRequest.includes(existingContribution.uid)) { + contributionIdsToDelete.push(existingContribution.uid); + } else { + const newContribution = memberData.projectContributions.find( + (contribution) => contribution.uid === existingContribution.uid + ); + if (JSON.stringify(existingContribution) !== JSON.stringify(newContribution)) { + contributionIdsToUpdate.push(newContribution); + } + } }); - return roleFilter.length > 0; - }; + if (contributionIdsToDelete.length > 0) { + await tx.projectContribution.deleteMany({ + where: { uid: { in: contributionIdsToDelete } }, + }); + } + if (contributionIdsToUpdate.length > 0) { + const updatePromises = contributionIdsToUpdate.map((contribution: any) => + tx.projectContribution.update({ + where: { uid: contribution.uid }, + data: { ...contribution }, + }) + ); + await Promise.all(updatePromises); + } + if (contributionsToCreate.length > 0) { + const contributionsToCreateData = contributionsToCreate.map((contribution: any) => ({ + ...contribution, + memberUid, + })); + await tx.projectContribution.createMany({ + data: contributionsToCreateData, + }); + } + } + + /** + * Update member email and handle external account deletion if email changes. + * + * @param uidToEdit - The unique identifier of the member being updated. + * @param isEmailChange - Boolean flag indicating if the email has changed. + * @param isExternalIdAvailable - Boolean flag indicating if an external ID is available. + * @param memberData - The object containing the updated member data. + * @param existingMember - The object containing the existing member data. + * @returns {Promise} + */ + async updateMemberEmailChange( + uidToEdit: string, + isEmailChange: boolean, + isExternalIdAvailable: boolean, + memberData, + existingMember + ): Promise { + try { + this.logger.info(`Member update request - attributes updated, requestId -> ${uidToEdit}`); + if (isEmailChange && isExternalIdAvailable) { + this.logger.info( + `Member update request - Initiating email change - newEmail: ${memberData.email}, oldEmail: ${existingMember.email}, externalId: ${existingMember.externalId}, requestId -> ${uidToEdit}` + ); + const clientToken = await this.fetchAccessToken(); + const headers = { + Authorization: `Bearer ${clientToken}`, + }; + // Attempt to delete the external account associated with the old email + await this.deleteExternalAccount(existingMember.externalId, headers, uidToEdit); + this.logger.info(`Member update request - Email changed, requestId -> ${uidToEdit}`); + } + } catch (error) { + this.logger.error(`Member update request - Failed to update email, requestId -> ${uidToEdit}, error -> ${error.message}`); + throw new Error(`Email update failed: ${error.message}`); + } + } - async isMemberPartOfTeams(member, teams) { - return member.teamMemberRoles.some((role) => { - return teams.includes(role.teamUid) + /** + * Deletes the external account associated with a given external ID. + * + * @param externalId - The external ID of the account to be deleted. + * @param headers - The authorization headers for the request. + * @param uidToEdit - The unique identifier of the member being updated. + * @returns {Promise} + */ + async deleteExternalAccount(externalId: string, headers: any, uidToEdit: string): Promise { + try { + await axios.delete(`${process.env.AUTH_API_URL}/admin/accounts/external/${externalId}`, { + headers: headers, + }); + this.logger.info(`External account deleted, externalId -> ${externalId}, requestId -> ${uidToEdit}`); + } catch (error) { + // Handle cases where the external account is not found (404) and other errors + if (error?.response?.status === 404) { + this.logger.error(`External account not found for deletion, externalId -> ${externalId}, requestId -> ${uidToEdit}`); + } else { + this.logger.error(`Failed to delete external account, externalId -> ${externalId}, requestId -> ${uidToEdit}, error -> ${error.message}`); + throw error; + } + } + } + + /** + * Fetches the access token from the authentication service. + * + * @returns {Promise} - The client token used for authorization. + * @throws {Error} - Throws an error if token retrieval fails. + */ + async fetchAccessToken(): Promise { + try { + const response = await axios.post(`${process.env.AUTH_API_URL}/auth/token`, { + client_id: process.env.AUTH_APP_CLIENT_ID, + client_secret: process.env.AUTH_APP_CLIENT_SECRET, + grant_type: 'client_credentials', + grantTypes: ['client_credentials', 'authorization_code', 'refresh_token'], + }); + + return response.data.access_token; + } catch (error) { + throw new Error('Failed to retrieve client token'); + } + } + + /** + * Validates if an email change is required and whether the new email is unique. + * @param isEmailChange - Flag indicating if email is being changed. + * @param transactionType - Prisma transaction client or Prisma client. + * @param newEmail - The new email to validate. + */ + async validateEmailChange(isEmailChange, transactionType, newEmail) { + if (isEmailChange) { + const foundUser = await transactionType.member.findUnique({ where: { email: newEmail.toLowerCase().trim() } }); + if (foundUser?.email) { + throw new BadRequestException('Email already exists. Please try again with a different email.'); + } + } + } + + /** + * Logs the participant request in the participants request table for audit and tracking purposes. + * + * @param tx - The transaction client to ensure atomicity + * @param requestorEmail - Email of the requestor who is updating the team + * @param newMemberData - The new data being applied to the team + * @param referenceUid - Unique identifier of the existing team to be referenced + */ + private async logParticipantRequest( + requestorEmail: string, + newMemberData, + referenceUid: string, + tx: Prisma.TransactionClient, + ): Promise { + await this.participantsRequestService.add({ + status: 'AUTOAPPROVED', + requesterEmailId: requestorEmail, + referenceUid, + uniqueIdentifier: newMemberData?.email || '', + participantType: 'MEMBER', + newData: { ...newMemberData }, + }, + tx + ); + } + + /** + * Updates the member's preferences and resets the cache. + * + * @param id - The UID of the member. + * @param preferences - The new preferences data to be updated. + * @returns The updated member object. + */ + async updatePreference(id: string, preferences: any): Promise { + const updatedMember = await this.updateMemberByUid(id, { preferences }); + await this.cacheService.reset(); + return updatedMember; + } + + /** + * Executes post-update actions such as resetting the cache and triggering Airtable sync. + * This ensures that the system is up-to-date with the latest changes. + */ + private async postUpdateActions(): Promise { + await this.cacheService.reset(); + await this.forestadminService.triggerAirtableSync(); + } + + /** + * Retrieves member preferences along with social media handlers. + * + * @param uid - The UID of the member. + * @returns An object containing the member's preferences and handler statuses. + */ + async getPreferences(uid: string): Promise { + const member = await this.prisma.member.findUnique({ + where: { uid }, + select: { + email: true, + githubHandler: true, + telegramHandler: true, + discordHandler: true, + linkedinHandler: true, + twitterHandler: true, + preferences: true, + }, }); + return this.buildPreferenceResponse(member); + } + + /** + * Helper function to build the preference response object. + * + * @param member - The member data. + * @returns The processed preferences and handlers. + */ + private buildPreferenceResponse(member: any): any { + const preferences = { ...member.preferences }; + if (!preferences) { + preferences.isNull = true; + } else { + preferences.isNull = false; + } + preferences.email = !!member.email; + preferences.github = !!member.githubHandler; + preferences.telegram = !!member.telegramHandler; + preferences.discord = !!member.discordHandler; + preferences.linkedin = !!member.linkedinHandler; + preferences.twitter = !!member.twitterHandler; + return preferences; + } + + /** + * Checks if the given member is a team lead for the provided team UID. + * + * @param member - The member object. + * @param teamUid - The UID of the team. + * @returns True if the member is leading the team, false otherwise. + */ + async isMemberLeadTeam(member: Member, teamUid: string): Promise { + const userInfo = await this.memberToUserInfo(member); + return userInfo.leadingTeams.includes(teamUid); + } + + /** + * Checks if the given member is a part of the provided teams. + * + * @param member - The member object. + * @param teams - An array of team UIDs. + * @returns True if the member belongs to any of the provided teams, false otherwise. + */ + isMemberPartOfTeams(member, teams: string[]): boolean { + return member.teamMemberRoles.some((role) => teams.includes(role.teamUid)); + } + + /** + * Checks if the member is an admin. + * + * @param member - The member object. + * @returns True if the member is a directory admin, false otherwise. + */ + checkIfAdminUser(member): boolean { + return member.memberRoles.some((role) => role.name === 'DIRECTORYADMIN'); } /** @@ -701,27 +1292,181 @@ export class MembersService { return { }; } - async updateTelegramIfChanged(member, telegram, tx?:Prisma.TransactionClient) { - if (member.telegramHandler != telegram) { - member = await (tx || this.prisma).member.update({ - where: { uid: member.uid }, - data: { - telegramHandler: telegram - } - }); + /** + * Updates the member's field if the value has changed. + * + * @param member - The member object to check for updates. + * @param field - The field in the member object that may be updated. + * @param newValue - The new value to update the field with. + * @param tx - Optional transaction client. + * @returns Updated member object if a change was made, otherwise the original member object. + */ + private async updateFieldIfChanged( + member: Member, + field: keyof Member, + newValue: string, + tx?: Prisma.TransactionClient + ): Promise { + if (member[field] !== newValue) { + member = await this.updateMemberByUid(member.uid, { [field]: newValue }, tx); } return member; } - async updateOfficeHoursIfChanged(member, officeHours, tx?:Prisma.TransactionClient) { - if (member.officeHours != officeHours) { - member = await (tx || this.prisma).member.update({ - where: { uid: member.uid }, - data: { - officeHours - } + /** + * Updates the member's telegram handler if it has changed. + * + * @param member - The member object to check for updates. + * @param telegram - The new telegram handler value. + * @param tx - Optional transaction client. + * @returns Updated member object if a change was made, otherwise the original member object. + */ + async updateTelegramIfChanged( + member: Member, + telegram: string, + tx?: Prisma.TransactionClient + ): Promise { + return await this.updateFieldIfChanged(member, 'telegramHandler', telegram, tx); + } + + /** + * Updates the member's office hours if it has changed. + * + * @param member - The member object to check for updates. + * @param officeHours - The new office hours value. + * @param tx - Optional transaction client. + * @returns Updated member object if a change was made, otherwise the original member object. + */ + async updateOfficeHoursIfChanged( + member: Member, + officeHours: string, + tx?: Prisma.TransactionClient + ): Promise { + return await this.updateFieldIfChanged(member, 'officeHours', officeHours, tx); + } + + /** + * Handles database-related errors specifically for the Member entity. + * Logs the error and throws an appropriate HTTP exception based on the error type. + * + * @param {any} error - The error object thrown by Prisma or other services. + * @param {string} [message] - An optional message to provide additional context, + * such as the member UID when an entity is not found. + * @throws {ConflictException} - If there's a unique key constraint violation. + * @throws {BadRequestException} - If there's a foreign key constraint violation or validation error. + * @throws {NotFoundException} - If a member is not found with the provided UID. + */ + private handleErrors(error, message?: string) { + this.logger.error(error); + if (error instanceof Prisma.PrismaClientKnownRequestError) { + switch (error?.code) { + case 'P2002': + throw new ConflictException( + 'Unique key constraint error on Member:', + error.message + ); + case 'P2003': + throw new BadRequestException( + 'Foreign key constraint error on Member', + error.message + ); + case 'P2025': + throw new NotFoundException('Member not found with uid: ' + message); + default: + throw error; + } + } else if (error instanceof Prisma.PrismaClientValidationError) { + throw new BadRequestException('Database field validation error on Member', error.message); + } + return error; + } + + + async insertManyWithLocationsFromAirtable( + airtableMembers: z.infer[] + ) { + const skills = await this.prisma.skill.findMany(); + const images = await this.prisma.image.findMany(); + + for (const member of airtableMembers) { + if (!member.fields?.Name) { + continue; + } + + let image; + + if (member.fields['Profile picture']) { + const ppf = member.fields['Profile picture'][0]; + + const hashedPpf = ppf.filename + ? hashFileName(`${path.parse(ppf.filename).name}-${ppf.id}`) + : ''; + + image = + images.find( + (image) => path.parse(image.filename).name === hashedPpf + ) || + (await this.fileMigrationService.migrateFile({ + id: ppf.id || '', + url: ppf.url || '', + filename: ppf.filename || '', + size: ppf.size || 0, + type: ppf.type || '', + height: ppf.height || 0, + width: ppf.width || 0, + })); + } + + const optionalFieldsToAdd = Object.entries({ + email: 'Email', + githubHandler: 'Github Handle', + discordHandler: 'Discord handle', + twitterHandler: 'Twitter', + officeHours: 'Office hours link', + }).reduce( + (optionalFields, [prismaField, airtableField]) => ({ + ...optionalFields, + ...(member.fields?.[airtableField] && { + [prismaField]: member.fields?.[airtableField], + }), + }), + {} + ); + + const manyToManyRelations = { + skills: { + connect: skills + .filter( + (skill) => + !!member.fields?.['Skills'] && + member.fields?.['Skills'].includes(skill.title) + ) + .map((skill) => ({ id: skill.id })), + }, + }; + + const { location } = await this.locationTransferService.transferLocation( + member + ); + + await this.prisma.member.upsert({ + where: { + airtableRecId: member.id, + }, + update: { + ...optionalFieldsToAdd, + ...manyToManyRelations, + }, + create: { + airtableRecId: member.id, + name: member.fields.Name, + plnFriend: member.fields['Friend of PLN'] || false, + locationUid: location ? location?.uid : null, + imageUid: image?.uid, + ...optionalFieldsToAdd, + ...manyToManyRelations, + }, }); } - return member; } } diff --git a/apps/web-api/src/participants-request/participants-request.controller.ts b/apps/web-api/src/participants-request/participants-request.controller.ts index d6672427a..2ca803a32 100644 --- a/apps/web-api/src/participants-request/participants-request.controller.ts +++ b/apps/web-api/src/participants-request/participants-request.controller.ts @@ -1,85 +1,43 @@ -/* eslint-disable prettier/prettier */ -import { - Body, - Controller, - ForbiddenException, - Get, - Param, - Post, - Query, - Req, - BadRequestException -} from '@nestjs/common'; -import { ParticipantType } from '@prisma/client'; +import { Body, Controller, Get, Post, Query, UsePipes } from '@nestjs/common'; import { ParticipantsRequestService } from './participants-request.service'; -import { GoogleRecaptchaGuard } from '../guards/google-recaptcha.guard'; -import { - ParticipantRequestTeamSchema, - ParticipantRequestMemberSchema, -} from '../../../../libs/contracts/src/schema/participants-request'; import { NoCache } from '../decorators/no-cache.decorator'; +import { ParticipantsReqValidationPipe } from '../pipes/participant-request-validation.pipe'; +import { FindUniqueIdentiferDto } from 'libs/contracts/src/schema/participants-request'; + @Controller('v1/participants-request') +@NoCache() export class ParticipantsRequestController { constructor( private readonly participantsRequestService: ParticipantsRequestService ) {} - @Get() - @NoCache() - async findAll(@Query() query) { - const result = await this.participantsRequestService.getAll(query); - return result; - } - - @Get(':uid') - @NoCache() - async findOne(@Param() params) { - const result = await this.participantsRequestService.getByUid(params.uid); - return result; + /** + * Add a new entry to the Participants request table. + * @param body - The request data to be added to the participants request table. + * @returns A promise with the participants request entry that was added. + */ + @Post("/") + @UsePipes(new ParticipantsReqValidationPipe()) + async addRequest(@Body() body) { + const uniqueIdentifier = this.participantsRequestService.getUniqueIdentifier(body); + // Validate unique identifier existence + await this.participantsRequestService.validateUniqueIdentifier(body.participantType, uniqueIdentifier); + await this.participantsRequestService.validateParticipantRequest(body); + return await this.participantsRequestService.addRequest(body); } - @Post() - async addRequest(@Body() body, @Req() req) { - const postData = body; - const participantType = body.participantType; - const referenceUid = body.referenceUid; - - if ( - participantType === ParticipantType.MEMBER.toString() && - !ParticipantRequestMemberSchema.safeParse(postData).success - ) { - throw new BadRequestException("Validation failed") - } else if ( - participantType === ParticipantType.TEAM.toString() && - !ParticipantRequestTeamSchema.safeParse(postData).success - ) { - throw new BadRequestException("Validation failed") - } else if ( - participantType !== ParticipantType.TEAM.toString() && - participantType !== ParticipantType.MEMBER.toString() - ) { - throw new BadRequestException("Validation failed") - } - - const checkDuplicate = await this.participantsRequestService.findDuplicates( - postData?.uniqueIdentifier, - participantType, - referenceUid, - '' + /** + * Check if the given identifier already exists in participants-request, members, or teams tables. + * @param queryParams - The query parameters containing the identifier and its type. + * @returns A promise indicating whether the identifier already exists. + */ + @Get("/unique-identifier") + async findMatchingIdentifier( + @Query() queryParams: FindUniqueIdentiferDto + ) { + return await this.participantsRequestService.checkIfIdentifierAlreadyExist( + queryParams.type, + queryParams.identifier ); - if ( - checkDuplicate && - (checkDuplicate.isUniqueIdentifierExist || - checkDuplicate.isRequestPending) - ) { - const text = - participantType === ParticipantType.MEMBER - ? 'Member email' - : 'Team name'; - throw new BadRequestException(`${text} already exists`); - } - - const result = await this.participantsRequestService.addRequest(postData); - return result; } } diff --git a/apps/web-api/src/participants-request/participants-request.module.ts b/apps/web-api/src/participants-request/participants-request.module.ts index db9c3647f..3356cfbf4 100644 --- a/apps/web-api/src/participants-request/participants-request.module.ts +++ b/apps/web-api/src/participants-request/participants-request.module.ts @@ -1,24 +1,18 @@ /* eslint-disable prettier/prettier */ -import { CacheModule, Module } from '@nestjs/common'; -import { AwsService } from '../utils/aws/aws.service'; -import { LocationTransferService } from '../utils/location-transfer/location-transfer.service'; +import {Module, forwardRef } from '@nestjs/common'; +import { MembersModule } from '../members/members.module'; +import { SharedModule } from '../shared/shared.module'; +import { TeamsModule } from '../teams/teams.module'; import { ParticipantsRequestController } from './participants-request.controller'; import { ParticipantsRequestService } from './participants-request.service'; -import { UniqueIdentifier } from './unique-identifier/unique-identifier.controller'; -import { RedisService } from '../utils/redis/redis.service'; -import { SlackService } from '../utils/slack/slack.service'; -import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; +import { NotificationService } from '../utils/notification/notification.service'; @Module({ - imports: [], - controllers: [ParticipantsRequestController, UniqueIdentifier], + imports: [forwardRef(() => MembersModule), forwardRef(() => TeamsModule), SharedModule], + controllers: [ParticipantsRequestController], providers: [ ParticipantsRequestService, - LocationTransferService, - AwsService, - RedisService, - SlackService, - ForestAdminService, + NotificationService ], - exports: [ParticipantsRequestService] + exports: [ParticipantsRequestService, NotificationService] }) export class ParticipantsRequestModule {} diff --git a/apps/web-api/src/participants-request/participants-request.service.ts b/apps/web-api/src/participants-request/participants-request.service.ts index 94e0612e2..7c022a551 100644 --- a/apps/web-api/src/participants-request/participants-request.service.ts +++ b/apps/web-api/src/participants-request/participants-request.service.ts @@ -1,1065 +1,414 @@ /* eslint-disable prettier/prettier */ -import { - BadRequestException, - ForbiddenException, - Inject, - Injectable, - CACHE_MANAGER, - CacheModule, - UnauthorizedException, +import { + BadRequestException, + ConflictException, + NotFoundException, + Inject, + Injectable, + CACHE_MANAGER, + forwardRef } from '@nestjs/common'; - -import { - ApprovalStatus, - ParticipantType, - Prisma, - PrismaClient, -} from '@prisma/client'; +import { ApprovalStatus, ParticipantType } from '@prisma/client'; +import { Cache } from 'cache-manager'; +import { Prisma, ParticipantsRequest, PrismaClient } from '@prisma/client'; +import { generateProfileURL } from '../utils/helper/helper'; +import { LogService } from '../shared/log.service'; import { PrismaService } from '../shared/prisma.service'; -import { AwsService } from '../utils/aws/aws.service'; +import { MembersService } from '../members/members.service'; +import { TeamsService } from '../teams/teams.service'; +import { NotificationService } from '../utils/notification/notification.service'; import { LocationTransferService } from '../utils/location-transfer/location-transfer.service'; -import { RedisService } from '../utils/redis/redis.service'; -import { SlackService } from '../utils/slack/slack.service'; import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; -import { getRandomId, generateProfileURL } from '../utils/helper/helper'; -import axios from 'axios'; -import { LogService } from '../shared/log.service'; -import { Cache } from 'cache-manager'; -import { DEFAULT_MEMBER_ROLES } from '../utils/constants'; @Injectable() export class ParticipantsRequestService { constructor( private prisma: PrismaService, + private logger: LogService, private locationTransferService: LocationTransferService, - private awsService: AwsService, - private redisService: RedisService, - private slackService: SlackService, private forestAdminService: ForestAdminService, - private logger: LogService, - @Inject(CACHE_MANAGER) private cacheService: Cache, + private notificationService: NotificationService, + @Inject(CACHE_MANAGER) + private cacheService: Cache, + @Inject(forwardRef(() => MembersService)) + private membersService: MembersService, + @Inject(forwardRef(() => TeamsService)) + private teamsService: TeamsService, ) {} - async getAll(userQuery) { - const filters = {}; - - if (userQuery.participantType) { - filters['participantType'] = { equals: userQuery.participantType }; - } - - if (userQuery.status) { - filters['status'] = { equals: userQuery.status }; - } - - if (userQuery.uniqueIdentifier) { - filters['uniqueIdentifier'] = { equals: userQuery.uniqueIdentifier }; - } - - if (userQuery.requestType && userQuery.requestType === 'edit') { - filters['referenceUid'] = { not: null }; - } - - if (userQuery.requestType && userQuery.requestType === 'new') { - filters['referenceUid'] = { equals: null }; - } - - if (userQuery.referenceUid) { - filters['referenceUid'] = { equals: userQuery.referenceUid }; + /** + * Find all participant requests based on the query. + * Filters are dynamically applied based on the presence of query parameters. + * + * @param userQuery - The query object containing filtering options like participantType, status, etc. + * @returns A promise that resolves with the filtered participant requests + */ + async getAll(userQuery): Promise { + try { + const filters = { + ...(userQuery.participantType && { + participantType: { equals: userQuery.participantType }, + }), + ...(userQuery.status && { status: { equals: userQuery.status } }), + ...(userQuery.uniqueIdentifier && { + uniqueIdentifier: { equals: userQuery.uniqueIdentifier } + }), + ...('edit' === userQuery.requestType && { referenceUid: { not: null } }), + ...('new' === userQuery.requestType && { referenceUid: { equals: null } }), + ...(userQuery.referenceUid && { + referenceUid: { equals: userQuery.referenceUid } + }) + }; + return await this.prisma.participantsRequest.findMany({ + where: filters, + orderBy: { createdAt: 'desc' }, + }); + } catch(err) { + return this.handleErrors(err) } - - const results = await this.prisma.participantsRequest.findMany({ - where: filters, - orderBy: { createdAt: 'desc' }, - }); - return results; - } - - async addAutoApprovalEntry(tx, newEntry) { - await tx.participantsRequest.create({ - data: {...newEntry} - }) } - async getByUid(uid) { - const result = await this.prisma.participantsRequest.findUnique({ - where: { uid: uid }, - }); - return result; - } - - async findMemberByEmail(userEmail) { - const foundMember = await this.prisma.member.findUnique({ - where: { - email: userEmail, - }, - include: { - memberRoles: true, - teamMemberRoles: true, - }, - }); - - if (!foundMember) { - return null; + /** + * Add a new entry to the participants request table. + * + * @param tx - The transactional Prisma client + * @param newEntry - The data for the new participants request entry + * @returns A promise that resolves with the newly created entry + */ + async add( + newEntry: Prisma.ParticipantsRequestUncheckedCreateInput, + tx?: Prisma.TransactionClient, + ): Promise { + try { + return await (tx || this.prisma).participantsRequest.create({ + data: { ...newEntry }, + }); + } catch(err) { + return this.handleErrors(err) } - - const roleNames = foundMember.memberRoles.map((m) => m.name); - const isDirectoryAdmin = roleNames.includes('DIRECTORYADMIN'); - - const formattedMemberDetails = { - ...foundMember, - isDirectoryAdmin, - roleNames, - leadingTeams: foundMember.teamMemberRoles - .filter((role) => role.teamLead) - .map((role) => role.teamUid), - }; - - return formattedMemberDetails; } - async findDuplicates(uniqueIdentifier, participantType, uid, requestId) { - let itemInRequest = await this.prisma.participantsRequest.findMany({ - where: { - participantType, - status: ApprovalStatus.PENDING, - OR: [{ referenceUid: uid }, { uniqueIdentifier }], - }, - }); - itemInRequest = itemInRequest?.filter((item) => item.uid !== requestId); - if (itemInRequest.length === 0) { - if (participantType === 'TEAM') { - let teamResult = await this.prisma.team.findMany({ - where: { - name: uniqueIdentifier, - }, - }); - teamResult = teamResult?.filter((item) => item.uid !== uid); - if (teamResult.length > 0) { - return { isRequestPending: false, isUniqueIdentifierExist: true }; - } else { - return { isRequestPending: false, isUniqueIdentifierExist: false }; - } - } else { - let memResult = await this.prisma.member.findMany({ - where: { - email: uniqueIdentifier.toLowerCase(), - }, - }); - memResult = memResult?.filter((item) => item.uid !== uid); - if (memResult.length > 0) { - return { isRequestPending: false, isUniqueIdentifierExist: true }; - } else { - return { isRequestPending: false, isUniqueIdentifierExist: false }; - } - } - } else { - return { isRequestPending: true }; + /** + * Find a single entry from the participants request table that matches the provided UID. + * + * @param uid - The UID of the participants request entry to be fetched + * @returns A promise that resolves with the matching entry or null if not found + */ + async findOneByUid(uid: string): Promise { + try { + return await this.prisma.participantsRequest.findUnique({ + where: { uid }, + }); + } catch(err) { + return this.handleErrors(err, uid) } } - async addRequest( - requestData, - disableNotification = false, - transactionType: Prisma.TransactionClient | PrismaClient = this.prisma - ) { - const uniqueIdentifier = - requestData.participantType === 'TEAM' - ? requestData.newData.name - : requestData.newData.email.toLowerCase().trim(); - const postData = { ...requestData, uniqueIdentifier }; - requestData[uniqueIdentifier] = uniqueIdentifier; - if (requestData.participantType === ParticipantType.MEMBER.toString()) { - const { city, country, region } = postData.newData; - if (city || country || region) { - const result: any = await this.locationTransferService.fetchLocation( - city, - country, - null, - region, - null - ); - if (!result || !result?.location) { - throw new BadRequestException('Invalid Location info'); - } + /** + * Check if any entry exists in the participants-request table and the members/teams table + * for the given identifier. + * + * @param type - The participant type (either TEAM or MEMBER) + * @param identifier - The unique identifier (team name or member email) + * @returns A promise that resolves with an object containing flags indicating whether a request is pending and whether the identifier exists + */ + async checkIfIdentifierAlreadyExist( + type: ParticipantType, + identifier: string + ): Promise<{ + isRequestPending: boolean; + isUniqueIdentifierExist: boolean + }> { + try { + const existingRequest = await this.prisma.participantsRequest.findFirst({ + where: { + status: ApprovalStatus.PENDING, + participantType: type, + uniqueIdentifier: identifier, + }, + }); + if (existingRequest) { + return { isRequestPending: true, isUniqueIdentifierExist: false }; + } + const existingEntry = + type === ParticipantType.TEAM + ? await this.teamsService.findTeamByName(identifier) + : await this.membersService.findMemberByEmail(identifier); + if (existingEntry) { + return { isRequestPending: false, isUniqueIdentifierExist: true }; } + return { isRequestPending: false, isUniqueIdentifierExist: false }; + } + catch(err) { + return this.handleErrors(err) } + } - const slackConfig = { - requestLabel: '', - url: '', - name: requestData.newData.name, - }; - const result: any = await transactionType.participantsRequest.create({ - data: { ...postData }, - }); - if ( - result.participantType === ParticipantType.MEMBER.toString() && - result.referenceUid === null - ) { - slackConfig.requestLabel = 'New Labber Request'; - slackConfig.url = `${process.env.WEB_ADMIN_UI_BASE_URL}/member-view?id=${result.uid}`; - await this.awsService.sendEmail('NewMemberRequest', true, [], { - memberName: result.newData.name, - requestUid: result.uid, - adminSiteUrl: `${process.env.WEB_ADMIN_UI_BASE_URL}/member-view?id=${result.uid}`, - }); - } else if ( - result.participantType === ParticipantType.MEMBER.toString() && - result.referenceUid !== null && - !disableNotification - ) { - slackConfig.requestLabel = 'Edit Labber Request'; - slackConfig.url = `${process.env.WEB_ADMIN_UI_BASE_URL}/member-view?id=${result.uid}`; - await this.awsService.sendEmail('EditMemberRequest', true, [], { - memberName: result.newData.name, - requestUid: result.uid, - requesterEmailId: requestData.requesterEmailId, - adminSiteUrl: `${process.env.WEB_ADMIN_UI_BASE_URL}/member-view?id=${result.uid}`, - }); - } else if ( - result.participantType === ParticipantType.TEAM.toString() && - result.referenceUid === null - ) { - slackConfig.requestLabel = 'New Team Request'; - slackConfig.url = `${process.env.WEB_ADMIN_UI_BASE_URL}/team-view?id=${result.uid}`; - await this.awsService.sendEmail('NewTeamRequest', true, [], { - teamName: result.newData.name, - requestUid: result.uid, - adminSiteUrl: `${process.env.WEB_ADMIN_UI_BASE_URL}/team-view?id=${result.uid}`, + /** + * Update a participants request entry by UID. + * The function will omit specific fields (like uid, id, status, participantType) from being updated. + * + * @param participantRequest - The updated data for the participants request + * @param requestedUid - The UID of the participants request to update + * @returns A success response after updating the request + */ + async updateByUid( + uid: string, + participantRequest: Prisma.ParticipantsRequestUncheckedUpdateInput, + ):Promise { + try { + const formattedData = { ...participantRequest }; + delete formattedData.id; + delete formattedData.uid; + delete formattedData.status; + delete formattedData.participantType; + const result:ParticipantsRequest = await this.prisma.participantsRequest.update({ + where: { uid }, + data: formattedData, }); - } else if ( - result.participantType === ParticipantType.TEAM.toString() && - result.referenceUid !== null - ) { - slackConfig.requestLabel = 'Edit Team Request'; - slackConfig.url = `${process.env.WEB_ADMIN_UI_BASE_URL}/team-view?id=${result.uid}`; - if (!disableNotification) - await this.awsService.sendEmail('EditTeamRequest', true, [], { - teamName: result.newData.name, - teamUid: result.referenceUid, - requesterEmailId: requestData.requesterEmailId, - adminSiteUrl: `${process.env.WEB_ADMIN_UI_BASE_URL}/team-view?id=${result.uid}`, - }); + await this.cacheService.reset(); + return result; + } catch(err) { + return this.handleErrors(err) } - - if (!disableNotification) - await this.slackService.notifyToChannel(slackConfig); - await this.cacheService.reset() - return result; } - async updateRequest(newData, requestedUid) { - const formattedData = { ...newData }; - - // remove id and Uid if present - delete formattedData.id; - delete formattedData.uid; - delete formattedData.status; - delete formattedData.participantType; - await this.prisma.participantsRequest.update({ - where: { uid: requestedUid }, - data: { ...formattedData }, - }); - await this.cacheService.reset() - return { code: 1, message: 'success' }; - } - - async processRejectRequest(uidToReject) { - const dataFromDB: any = await this.prisma.participantsRequest.findUnique({ - where: { uid: uidToReject }, - }); - if (dataFromDB.status !== ApprovalStatus.PENDING.toString()) { - throw new BadRequestException('Request already processed'); + /** + * Process a reject operation on a pending participants request. + * If the request is not in a pending state, an exception is thrown. + * + * @param uidToReject - The UID of the participants request to reject + * @returns A success response after rejecting the request + * @throws BadRequestException if the request is already processed + */ + async rejectRequestByUid(uidToReject: string): Promise { + try { + const result:ParticipantsRequest = await this.prisma.participantsRequest.update({ + where: { uid: uidToReject }, + data: { status: ApprovalStatus.REJECTED } + }); + await this.cacheService.reset(); + return result; + } catch(err) { + return this.handleErrors(err) } - await this.prisma.participantsRequest.update({ - where: { uid: uidToReject }, - data: { status: ApprovalStatus.REJECTED }, - }); - await this.cacheService.reset() - return { code: 1, message: 'Success' }; } - async processMemberCreateRequest(uidToApprove) { - // Get - const dataFromDB: any = await this.prisma.participantsRequest.findUnique({ - where: { uid: uidToApprove }, - }); - - if (dataFromDB.status !== ApprovalStatus.PENDING.toString()) { - throw new BadRequestException('Request already processed'); - } - const dataToProcess: any = dataFromDB.newData; - const dataToSave: any = {}; - const slackConfig = { - requestLabel: '', - url: '', - name: dataToProcess.name, - }; - - // Mandatory fields - dataToSave['name'] = dataToProcess.name; - dataToSave['email'] = dataToProcess.email.toLowerCase().trim(); - - // Optional fields - dataToSave['githubHandler'] = dataToProcess.githubHandler; - dataToSave['discordHandler'] = dataToProcess.discordHandler; - dataToSave['twitterHandler'] = dataToProcess.twitterHandler; - dataToSave['linkedinHandler'] = dataToProcess.linkedinHandler; - dataToSave['telegramHandler'] = dataToProcess.telegramHandler; - dataToSave['officeHours'] = dataToProcess.officeHours; - dataToSave['moreDetails'] = dataToProcess.moreDetails; - dataToSave['plnStartDate'] = dataToProcess.plnStartDate; - dataToSave['openToWork'] = dataToProcess.openToWork; - - // Team member roles relational mapping - dataToSave['teamMemberRoles'] = { - createMany: { - data: dataToProcess.teamAndRoles.map((t) => { - return { - role: t.role, - mainTeam: false, - teamLead: false, - teamUid: t.teamUid, - roleTags: t.role?.split(',')?.map(item => item.trim()) - }; - }), - }, - }; - - // Save Experience if available - if(Array.isArray(dataToProcess.projectContributions) - && dataToProcess.projectContributions?.length > 0) { - dataToSave['projectContributions'] = { - createMany: { - data: dataToProcess.projectContributions - }, - }; - } - - // Skills relation mapping - dataToSave['skills'] = { - connect: dataToProcess.skills.map((s) => { - return { uid: s.uid }; - }), - }; - - // Image Mapping - if (dataToProcess.imageUid) { - dataToSave['image'] = { connect: { uid: dataToProcess.imageUid } }; - } - - // Unique Location Uid needs to be formulated based on city, country & region using google places api and mapped to member - const { city, country, region } = dataToProcess; - if (city || country || region) { - const result: any = await this.locationTransferService.fetchLocation( - city, - country, - null, - region, - null - ); - if (result && result?.location?.placeId) { - const finalLocation: any = await this.prisma.location.upsert({ - where: { placeId: result?.location?.placeId }, - update: result?.location, - create: result?.location, - }); - if (finalLocation && finalLocation.uid) { - dataToSave['location'] = { connect: { uid: finalLocation.uid } }; - } + /** + * Approves a participant request by UID, creating either a new member or a team based on the request type. + * + * 1. Validates and processes the new data in the participant request (either MEMBER or TEAM). + * 2. Uses a transaction to: + * - Create a new member or team based on the `participantType`. + * - Update the participant request status to `APPROVED`. + * 3. Sends a notification based on the type of participant (member or team) after creation. + * 4. Resets the cache and triggers an Airtable synchronization. + * + * @param uidToApprove - The unique identifier of the participant request to approve. + * @param participantsRequest - The participant request data containing details of the request. + * @returns The updated participant request with the status set to `APPROVED`. + */ + private async approveRequestByUid( + uidToApprove: string, + participantsRequest: ParticipantsRequest + ): Promise { + let result; + let createdItem; + const dataToProcess: any = participantsRequest; + const participantType = participantsRequest.participantType; + // Add new member or team and update status to approved + await this.prisma.$transaction(async (tx) => { + if (participantType === 'MEMBER') { + dataToProcess.requesterEmailId = dataToProcess.newData.email.toLowerCase().trim(); + createdItem = await this.membersService.createMemberFromParticipantsRequest( + dataToProcess, + tx + ); } else { - throw new BadRequestException('Invalid Location info'); - } - } - - // Insert member details - const newMember = await this.prisma.member.create({ - data: { ...dataToSave }, - }); - await this.prisma.participantsRequest.update({ - where: { uid: uidToApprove }, - data: { status: ApprovalStatus.APPROVED }, - }); - await this.awsService.sendEmail('MemberCreated', true, [], { - memberName: dataToProcess.name, - memberUid: newMember.uid, - adminSiteUrl: `${process.env.WEB_UI_BASE_URL}/members/${ - newMember.uid - }?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`, - }); - await this.awsService.sendEmail( - 'NewMemberSuccess', - false, - [dataToSave.email], - { - memberName: dataToProcess.name, - memberProfileLink: `${process.env.WEB_UI_BASE_URL}/members/${ - newMember.uid - }?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`, + createdItem = await this.teamsService.createTeamFromParticipantsRequest( + dataToProcess, + tx + ); } - ); - slackConfig.requestLabel = 'New Labber Added'; - slackConfig.url = `${process.env.WEB_UI_BASE_URL}/members/${ - newMember.uid - }?utm_source=notification&utm_medium=slack&utm_code=${getRandomId()}`; - await this.slackService.notifyToChannel(slackConfig); - await this.cacheService.reset() - //await this.forestAdminService.triggerAirtableSync(); - return { code: 1, message: 'Success' }; - } - - async processMemberEditRequest( - uidToEdit, - disableNotification = false, - isAutoApproval = false, - isDirectoryAdmin = false, - transactionType: Prisma.TransactionClient | PrismaClient = this.prisma - ) { - // Get - const dataFromDB: any = - await transactionType.participantsRequest.findUnique({ - where: { uid: uidToEdit }, + result = await tx.participantsRequest.update({ + where: { uid: uidToApprove }, + data: { status: ApprovalStatus.APPROVED }, }); - if (dataFromDB?.status !== ApprovalStatus.PENDING.toString()) { - throw new BadRequestException('Request already processed'); - } - const existingData: any = await transactionType.member.findUnique({ - where: { uid: dataFromDB.referenceUid }, - include: { - image: true, - location: true, - skills: true, - teamMemberRoles: true, - memberRoles: true, - projectContributions: true - }, }); - const dataToProcess = dataFromDB?.newData; - const dataToSave: any = {}; - const slackConfig = { - requestLabel: '', - url: '', - name: dataToProcess.name, - }; - - const isEmailChange = existingData.email !== dataToProcess.email ? true: false; - if(isEmailChange) { - const foundUser: any = await transactionType.member.findUnique({where: {email: dataToProcess.email.toLowerCase().trim()}}); - if(foundUser && foundUser.email) { - throw new BadRequestException("Email already exists. Please try again with different email") - } - } - this.logger.info(`Member update request - Initiaing update for member uid - ${existingData.uid}, requestId -> ${uidToEdit}`) - // Mandatory fields - dataToSave['name'] = dataToProcess.name; - dataToSave['email'] = dataToProcess.email.toLowerCase().trim(); - - // Optional fields - dataToSave['githubHandler'] = dataToProcess.githubHandler; - dataToSave['discordHandler'] = dataToProcess.discordHandler; - dataToSave['twitterHandler'] = dataToProcess.twitterHandler; - dataToSave['linkedinHandler'] = dataToProcess.linkedinHandler; - dataToSave['telegramHandler'] = dataToProcess.telegramHandler; - dataToSave['officeHours'] = dataToProcess.officeHours; - dataToSave['moreDetails'] = dataToProcess.moreDetails; - dataToSave['plnStartDate'] = dataToProcess.plnStartDate; - dataToSave['openToWork'] = dataToProcess.openToWork; - dataToSave['bio'] = dataToProcess.bio; - - // Skills relation mapping - dataToSave['skills'] = { - set: dataToProcess.skills.map((s) => { - return { uid: s.uid }; - }), - }; - - // Image Mapping - if (dataToProcess.imageUid) { - dataToSave['image'] = { connect: { uid: dataToProcess.imageUid } }; - } else { - dataToSave['image'] = { disconnect: true }; - } - - // Unique Location Uid needs to be formulated based on city, country & region using google places api and mapped to member - const { city, country, region } = dataToProcess; - if (city || country || region) { - const result: any = await this.locationTransferService.fetchLocation( - city, - country, - null, - region, - null + if (participantType === 'MEMBER') { + await this.notificationService.notifyForMemberCreationApproval( + createdItem.name, + createdItem.uid, + dataToProcess.requesterEmailId ); - if (result && result?.location?.placeId) { - const finalLocation: any = await this.prisma.location.upsert({ - where: { placeId: result?.location?.placeId }, - update: result?.location, - create: result?.location, - }); - if ( - finalLocation && - finalLocation.uid && - existingData?.location?.uid !== finalLocation.uid - ) { - dataToSave['location'] = { connect: { uid: finalLocation.uid } }; - } - } else { - throw new BadRequestException('Invalid Location info'); - } } else { - dataToSave['location'] = { disconnect: true }; + await this.notificationService.notifyForTeamCreationApproval( + createdItem.name, + createdItem.uid, + participantsRequest.requesterEmailId + ); } + await this.cacheService.reset(); + await this.forestAdminService.triggerAirtableSync(); + return result; + } - if (transactionType === this.prisma) { - await this.prisma.$transaction(async (tx) => { - await this.processMemberEditChanges( - existingData, - dataFromDB, - dataToSave, - uidToEdit, - isAutoApproval, - tx - ); - }); + /** + * Approve/Reject request in participants-request table. + * @param statusToProcess + * @param uid + * @returns + */ + async processRequestByUid(uid:string, participantsRequest:ParticipantsRequest, statusToProcess) { + if (statusToProcess === ApprovalStatus.REJECTED) { + return await this.rejectRequestByUid(uid); } else { - await this.processMemberEditChanges( - existingData, - dataFromDB, - dataToSave, - uidToEdit, - isAutoApproval, - transactionType - ); + return await this.approveRequestByUid(uid, participantsRequest); } + } + /** + * Adds a new participants request. + * Validates the request data, checks for duplicate identifiers, + * and optionally sends notifications upon successful creation. + * + * @param {Prisma.ParticipantsRequestUncheckedCreateInput} requestData - The request data for adding a new participant. + * @param {boolean} [disableNotification=false] - Flag to disable notification sending. + * @param {Prisma.TransactionClient | PrismaClient} [tx=this.prisma] - Database transaction or client. + * @returns {Promise} - The newly created participant request. + * @throws {BadRequestException} - If validation fails or unique identifier already exists. + */ + async addRequest( + requestData: Prisma.ParticipantsRequestUncheckedCreateInput, + disableNotification: boolean = false, + tx: Prisma.TransactionClient | PrismaClient = this.prisma + ): Promise { + const uniqueIdentifier = this.getUniqueIdentifier(requestData); + const postData = { ...requestData, uniqueIdentifier }; + // Add the new request + const result: ParticipantsRequest = await this.add({ + ...postData + }, + tx + ); if (!disableNotification) { - await this.awsService.sendEmail('MemberEditRequestCompleted', true, [], { - memberName: dataToProcess.name, - }); - await this.awsService.sendEmail( - 'EditMemberSuccess', - false, - [dataFromDB.requesterEmailId], - { - memberName: dataToProcess.name, - memberProfileLink: `${process.env.WEB_UI_BASE_URL}/members/${ - dataFromDB.referenceUid - }?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`, - } - ); - slackConfig.requestLabel = 'Edit Labber Request Completed'; - slackConfig.url = `${process.env.WEB_UI_BASE_URL}/members/${ - dataFromDB.referenceUid - }?utm_source=notification&utm_medium=slack&utm_code=${getRandomId()}`; - await this.slackService.notifyToChannel(slackConfig); + this.notifyForCreate(result); } await this.cacheService.reset(); - // Send ack email to old & new email of member reg his/her email change. - if (isEmailChange && isDirectoryAdmin) { - const oldEmail = existingData.email; - const newEmail = dataToSave.email; - await this.awsService.sendEmail( - 'MemberEmailChangeAcknowledgement', - false, - [oldEmail, newEmail], - { - oldEmail, - newEmail, - memberName: dataToProcess.name, - profileURL: this.generateMemberProfileURL(existingData.uid), - loginURL: process.env.LOGIN_URL - } - ); - } - //await this.forestAdminService.triggerAirtableSync(); - return { code: 1, message: 'Success' }; + return result; } - async processMemberEditChanges( - existingData, - dataFromDB, - dataToSave, - uidToEdit, - isAutoApproval, - tx - ) { - const dataToProcess = dataFromDB?.newData; - const isEmailChange = - existingData.email !== dataToProcess.email ? true : false; - const isExternalIdAvailable = existingData.externalId ? true : false; - // Team member roles relational mapping - const oldTeamUids = [...existingData.teamMemberRoles].map((t) => t.teamUid); - const newTeamUids = [...dataToProcess.teamAndRoles].map((t) => t.teamUid); - const teamAndRolesUidsToDelete: any[] = [ - ...existingData.teamMemberRoles, - ].filter((t) => !newTeamUids.includes(t.teamUid)); - const teamAndRolesUidsToUpdate = [...dataToProcess.teamAndRoles].filter( - (t, index) => { - if (oldTeamUids.includes(t.teamUid)) { - const foundIndex = [...existingData.teamMemberRoles].findIndex( - (v) => v.teamUid === t.teamUid - ); - if (foundIndex > -1) { - const foundValue = [...existingData.teamMemberRoles][foundIndex]; - if (foundValue.role !== t.role) { - let foundDefaultRoleTag = false; - foundValue.roleTags?.some(tag => { - if (Object.keys(DEFAULT_MEMBER_ROLES).includes(tag)) { - foundDefaultRoleTag = true; - return true - } - }); - if (foundDefaultRoleTag) { - dataToProcess.teamAndRoles[index].roleTags = foundValue.roleTags; - } else { - dataToProcess.teamAndRoles[index].roleTags = - dataToProcess.teamAndRoles[index].role?.split(',')?.map(item => item.trim()); - } - return true; - } - } - } - return false; - } - ); - - const teamAndRolesUidsToCreate = [...dataToProcess.teamAndRoles].filter( - (t) => !oldTeamUids.includes(t.teamUid) - ); - - const promisesToDelete = teamAndRolesUidsToDelete.map((v) => - tx.teamMemberRole.delete({ - where: { - memberUid_teamUid: { - teamUid: v.teamUid, - memberUid: dataFromDB.referenceUid, - }, - }, - }) - ); - const promisesToUpdate = teamAndRolesUidsToUpdate.map((v) => - tx.teamMemberRole.update({ - where: { - memberUid_teamUid: { - teamUid: v.teamUid, - memberUid: dataFromDB.referenceUid, - }, - }, - data: { role: v.role, roleTags: v.roleTags }, - }) - ); - await Promise.all(promisesToDelete); - await Promise.all(promisesToUpdate); - await tx.teamMemberRole.createMany({ - data: teamAndRolesUidsToCreate.map((t) => { - return { - role: t.role, - mainTeam: false, - teamLead: false, - teamUid: t.teamUid, - memberUid: dataFromDB.referenceUid, - roleTags: t.role?.split(',')?.map(item => item.trim()) - }; - }), - }); - - const contributionsToCreate: any = dataToProcess.projectContributions - ?.filter(contribution => !contribution.uid); - const contributionIdsToDelete:any = []; - const contributionIdsToUpdate:any = []; - const contributionIds = dataToProcess.projectContributions - ?.filter(contribution => contribution.uid).map(contribution => contribution.uid); - - existingData.projectContributions?.map((contribution:any)=> { - if(!contributionIds.includes(contribution.uid)) { - contributionIdsToDelete.push(contribution.uid); - } else { - contributionIdsToUpdate.push(contribution.uid); + /** + * Validates the location information for a participant if provided. + * + * @param data - The participant data containing location details (city, country, region). + * @throws {BadRequestException} - If the location data is invalid. + */ + async validateLocation(data: any): Promise { + const { city, country, region } = data; + if (city || country || region) { + const result: any = await this.locationTransferService.fetchLocation(city, country, null, region, null); + if (!result || !result?.location) { + throw new BadRequestException('Invalid Location info'); } - }); - - const contributionToDelete = contributionIdsToDelete.map((uid) => - tx.projectContribution.delete({ - where: { - uid - } - }) - ); - const contributions = dataToProcess.projectContributions. - filter(contribution => contributionIdsToUpdate.includes(contribution.uid)); - const contributionsToUpdate = contributions.map((contribution) => - tx.projectContribution.update({ - where: { - uid: contribution.uid - }, - data: { - ...contribution - } - }) - ); - await Promise.all(contributionToDelete); - await Promise.all(contributionsToUpdate); - await tx.projectContribution.createMany({ - data: contributionsToCreate.map((contribution) => { - contribution.memberUid = dataFromDB.referenceUid; - return contribution; - }), - }); - - // Other member Changes - - await tx.member.update({ - where: { uid: dataFromDB.referenceUid }, - data: { - ...dataToSave, - ...(isEmailChange && isExternalIdAvailable && { externalId: null }), - }, - }); - - this.logger.info(`Member update request - attibutes updated, requestId -> ${uidToEdit}`) - if (isEmailChange && isExternalIdAvailable) { - // try { - this.logger.info(`Member update request - Initiating email change - newEmail - ${dataToSave.email}, oldEmail - ${existingData.email}, externalId - ${existingData.externalId}, requestId -> ${uidToEdit}`) - const response = await axios.post( - `${process.env.AUTH_API_URL}/auth/token`, - { - client_id: process.env.AUTH_APP_CLIENT_ID, - client_secret: process.env.AUTH_APP_CLIENT_SECRET, - grant_type: 'client_credentials', - grantTypes: [ - 'client_credentials', - 'authorization_code', - 'refresh_token', - ], - } - ); - - const clientToken = response.data.access_token; - const headers = { - Authorization: `Bearer ${clientToken}`, - }; - - await axios.delete( - `${process.env.AUTH_API_URL}/admin/accounts/external/${existingData.externalId}`, - { headers: headers } - ); - // } catch (e) { - // if (e?.response?.data?.message && e?.response.status === 404) { - // } else { - // throw e; - // } - // } - this.logger.info(`Member update request - Email changed, requestId -> ${uidToEdit}`) - } - // Updating status - await tx.participantsRequest.update({ - where: { uid: uidToEdit }, - data: { - status: isAutoApproval - ? ApprovalStatus.AUTOAPPROVED - : ApprovalStatus.APPROVED, - }, - }); - } - - async processTeamCreateRequest(uidToApprove) { - const dataFromDB: any = await this.prisma.participantsRequest.findUnique({ - where: { uid: uidToApprove }, - }); - if (dataFromDB.status !== ApprovalStatus.PENDING.toString()) { - throw new BadRequestException('Request already processed'); } - const dataToProcess: any = dataFromDB.newData; - const dataToSave: any = {}; - const slackConfig = { - requestLabel: '', - url: '', - name: dataToProcess.name, - }; - - // Mandatory fields - dataToSave['name'] = dataToProcess.name; - dataToSave['contactMethod'] = dataToProcess.contactMethod; - dataToSave['website'] = dataToProcess.website; - dataToSave['shortDescription'] = dataToProcess.shortDescription; - dataToSave['longDescription'] = dataToProcess.longDescription; - - // Non Mandatory Fields - dataToSave['twitterHandler'] = dataToProcess.twitterHandler; - dataToSave['linkedinHandler'] = dataToProcess.linkedinHandler; - dataToSave['telegramHandler'] = dataToProcess.telegramHandler; - dataToSave['airtableRecId'] = dataToProcess.airtableRecId; - dataToSave['blog'] = dataToProcess.blog; - dataToSave['officeHours'] = dataToProcess.officeHours; - dataToSave['shortDescription'] = dataToProcess.shortDescription; - dataToSave['longDescription'] = dataToProcess.longDescription; - dataToSave['moreDetails'] = dataToProcess.moreDetails; - - // Funding Stage Mapping - dataToSave['fundingStage'] = { - connect: { uid: dataToProcess.fundingStage.uid }, - }; - - // Industry Tag Mapping - dataToSave['industryTags'] = { - connect: dataToProcess.industryTags.map((i) => { - return { uid: i.uid }; - }), - }; - - // Technologies Mapping - if (dataToProcess.technologies && dataToProcess.technologies.length > 0) { - dataToSave['technologies'] = { - connect: dataToProcess.technologies.map((t) => { - return { uid: t.uid }; - }), - }; - } - - // focusAreas Mapping - dataToSave['teamFocusAreas'] = { - ...await this.createTeamWithFocusAreas(dataToProcess, this.prisma) - }; - - // Membership Sources Mapping - dataToSave['membershipSources'] = { - connect: dataToProcess.membershipSources.map((m) => { - return { uid: m.uid }; - }), - }; - - // Logo image Mapping - if (dataToProcess.logoUid) { - dataToSave['logo'] = { connect: { uid: dataToProcess.logoUid } }; - } - - const newTeam = await this.prisma.team.create({ data: { ...dataToSave } }); - await this.prisma.participantsRequest.update({ - where: { uid: uidToApprove }, - data: { status: ApprovalStatus.APPROVED }, - }); - await this.awsService.sendEmail('TeamCreated', true, [], { - teamName: dataToProcess.name, - teamUid: newTeam.uid, - adminSiteUrl: `${process.env.WEB_UI_BASE_URL}/teams/${ - newTeam.uid - }?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`, - }); - await this.awsService.sendEmail( - 'NewTeamSuccess', - false, - [dataFromDB.requesterEmailId], - { - teamName: dataToProcess.name, - teamProfileLink: `${process.env.WEB_UI_BASE_URL}/teams/${ - newTeam.uid - }?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`, - } - ); - slackConfig.requestLabel = 'New Team Added'; - slackConfig.url = `${process.env.WEB_UI_BASE_URL}/teams/${ - newTeam.uid - }?utm_source=notification&utm_medium=slack&utm_code=${getRandomId()}`; - await this.slackService.notifyToChannel(slackConfig); - await this.cacheService.reset() - //await this.forestAdminService.triggerAirtableSync(); - return { code: 1, message: 'Success' }; } - - async processTeamEditRequest( - uidToEdit, - disableNotification = false, - isAutoApproval = false, - transactionType: Prisma.TransactionClient | PrismaClient = this.prisma - ) { - const dataFromDB: any = await transactionType.participantsRequest.findUnique({ - where: { uid: uidToEdit }, - }); - if (dataFromDB.status !== ApprovalStatus.PENDING.toString()) { - throw new BadRequestException('Request already processed'); + + /** + * Extract unique identifier based on participant type. + * @param requestData + * @returns string + */ + getUniqueIdentifier(requestData): string { + return requestData.participantType === 'TEAM' + ? requestData.newData.name + : requestData.newData.email?.toLowerCase().trim(); + } + + /** + * Validate if the unique identifier already exists. + * @param participantType + * @param uniqueIdentifier + * @throws BadRequestException if identifier already exists + */ + async validateUniqueIdentifier( + participantType: ParticipantType, + uniqueIdentifier: string + ): Promise { + const { isRequestPending, isUniqueIdentifierExist } = await this.checkIfIdentifierAlreadyExist( + participantType, + uniqueIdentifier + ); + if (isRequestPending || isUniqueIdentifierExist) { + const typeLabel = participantType === 'TEAM' ? 'Team name' : 'Member email'; + throw new BadRequestException(`${typeLabel} already exists`); } - const dataToProcess: any = dataFromDB.newData; - const dataToSave: any = {}; - - const slackConfig = { - requestLabel: '', - url: '', - name: dataToProcess.name, - }; - const existingData: any = await this.prisma.team.findUnique({ - where: { uid: dataFromDB.referenceUid }, - include: { - fundingStage: true, - industryTags: true, - logo: true, - membershipSources: true, - technologies: true, - }, - }); - - // Mandatory fields - dataToSave['name'] = dataToProcess.name; - dataToSave['contactMethod'] = dataToProcess.contactMethod; - dataToSave['website'] = dataToProcess.website; - dataToSave['shortDescription'] = dataToProcess.shortDescription; - dataToSave['longDescription'] = dataToProcess.longDescription; - - // Non Mandatory Fields - dataToSave['twitterHandler'] = dataToProcess.twitterHandler; - dataToSave['linkedinHandler'] = dataToProcess.linkedinHandler; - dataToSave['telegramHandler'] = dataToProcess.telegramHandler; - dataToSave['airtableRecId'] = dataToProcess.airtableRecId; - dataToSave['blog'] = dataToProcess.blog; - dataToSave['officeHours'] = dataToProcess.officeHours; - dataToSave['shortDescription'] = dataToProcess.shortDescription; - dataToSave['longDescription'] = dataToProcess.longDescription; - dataToSave['moreDetails'] = dataToProcess.moreDetails; - dataToSave['lastModifier'] = { - connect: { uid: dataToProcess.lastModifiedBy } - }; - - // Funding Stage Mapping - dataToSave['fundingStage'] = { - connect: { uid: dataToProcess.fundingStage.uid }, - }; - - // Logo image Mapping - if (dataToProcess.logoUid) { - dataToSave['logo'] = { connect: { uid: dataToProcess.logoUid } }; - } else { - dataToSave['logo'] = { disconnect: true }; + } + + /** + * Validate location for members or email for teams. + * @param requestData + * @throws BadRequestException if validation fails + */ + async validateParticipantRequest(requestData: any): Promise { + if (requestData.participantType === ParticipantType.MEMBER.toString()) { + await this.validateLocation(requestData.newData); } - - // Industry Tag Mapping - dataToSave['industryTags'] = { - set: dataToProcess.industryTags.map((i) => { - return { uid: i.uid }; - }), - }; - - // Technologies Mapping - if (dataToProcess.technologies) { - dataToSave['technologies'] = { - set: dataToProcess.technologies.map((t) => { - return { uid: t.uid }; - }), - }; + if (requestData.participantType === ParticipantType.TEAM.toString() && !requestData.requesterEmailId) { + throw new BadRequestException( + 'Requester email is required for team participation requests. Please provide a valid email address.' + ); } - - // Membership Sources Mapping - dataToSave['membershipSources'] = { - set: dataToProcess.membershipSources.map((m) => { - return { uid: m.uid }; - }), - }; - - // focusAreas Mapping - dataToSave['teamFocusAreas'] = { - ...await this.updateTeamWithFocusAreas(dataFromDB.referenceUid, dataToProcess, transactionType) - }; - if (transactionType === this.prisma) { - await this.prisma.$transaction(async (tx) => { - // Update data - await tx.team.update({ - where: { uid: dataFromDB.referenceUid }, - data: { ...dataToSave }, - }); - // Updating status - await tx.participantsRequest.update({ - where: { uid: uidToEdit }, - data: { - status: isAutoApproval - ? ApprovalStatus.AUTOAPPROVED - : ApprovalStatus.APPROVED, - }, - }); - }); + } + + /** + * Send notification based on the participant type. + * @param result + */ + private notifyForCreate(result: any): void { + if (result.participantType === ParticipantType.MEMBER.toString()) { + this.notificationService.notifyForCreateMember(result.newData.name, result.uid); } else { - await transactionType.team.update({ - where: { uid: dataFromDB.referenceUid }, - data: { ...dataToSave }, - }); - // Updating status - await transactionType.participantsRequest.update({ - where: { uid: uidToEdit }, - data: { - status: isAutoApproval - ? ApprovalStatus.AUTOAPPROVED - : ApprovalStatus.APPROVED, - }, - }); - } - - if (!disableNotification) { - await this.awsService.sendEmail('TeamEditRequestCompleted', true, [], { - teamName: dataToProcess.name, - }); - await this.awsService.sendEmail( - 'EditTeamSuccess', - false, - [dataFromDB.requesterEmailId], - { - teamName: dataToProcess.name, - teamProfileLink: `${process.env.WEB_UI_BASE_URL}/teams/${existingData.uid}`, - } - ); - slackConfig.requestLabel = 'Edit Team Request Completed '; - slackConfig.url = `${process.env.WEB_UI_BASE_URL}/teams/${existingData.uid}`; - await this.slackService.notifyToChannel(slackConfig); + this.notificationService.notifyForCreateTeam(result.newData.name, result.uid); } - await this.cacheService.reset() - //await this.forestAdminService.triggerAirtableSync(); - return { code: 1, message: 'Success' }; } - async createTeamWithFocusAreas(dataToProcess, transaction) { - if (dataToProcess.focusAreas && dataToProcess.focusAreas.length > 0) { - let teamFocusAreas:any = []; - const focusAreaHierarchies = await transaction.focusAreaHierarchy.findMany({ - where: { - subFocusAreaUid: { - in: dataToProcess.focusAreas.map(area => area.uid) - } - } - }); - focusAreaHierarchies.map(areaHierarchy => { - teamFocusAreas.push({ - focusAreaUid: areaHierarchy.subFocusAreaUid, - ancestorAreaUid: areaHierarchy.focusAreaUid - }); - }); - dataToProcess.focusAreas.map(area => { - teamFocusAreas.push({ - focusAreaUid: area.uid, - ancestorAreaUid: area.uid - }); - }); - return { - createMany: { - data: teamFocusAreas - } + /** + * Handles database-related errors specifically for the Participant entity. + * Logs the error and throws an appropriate HTTP exception based on the error type. + * + * @param {any} error - The error object thrown by Prisma or other services. + * @param {string} [message] - An optional message to provide additional context, + * such as the participant UID when an entity is not found. + * @throws {ConflictException} - If there's a unique key constraint violation. + * @throws {BadRequestException} - If there's a foreign key constraint violation or validation error. + * @throws {NotFoundException} - If a participant is not found with the provided UID. + */ + private handleErrors(error, message?: string) { + this.logger.error(error); + if (error instanceof Prisma.PrismaClientKnownRequestError) { + switch (error?.code) { + case 'P2002': + throw new ConflictException( + 'Unique key constraint error on Participant:', + error.message + ); + case 'P2003': + throw new BadRequestException( + 'Foreign key constraint error on Participant', + error.message + ); + case 'P2025': + throw new NotFoundException('Participant not found with uid: ' + message); + default: + throw error; } + } else if (error instanceof Prisma.PrismaClientValidationError) { + throw new BadRequestException('Database field validation error on Participant', error.message); } - return {}; + return error; } - async updateTeamWithFocusAreas(teamId, dataToProcess, transaction) { - if (dataToProcess.focusAreas && dataToProcess.focusAreas.length > 0) { - await transaction.teamFocusArea.deleteMany({ - where: { - teamUid: teamId - } - }); - return await this.createTeamWithFocusAreas(dataToProcess, transaction); - } else { - await transaction.teamFocusArea.deleteMany({ - where: { - teamUid: teamId - } - }); - } - return {}; - } generateMemberProfileURL(value) { return generateProfileURL(value); diff --git a/apps/web-api/src/participants-request/unique-identifier/unique-identifier.controller.ts b/apps/web-api/src/participants-request/unique-identifier/unique-identifier.controller.ts deleted file mode 100644 index ad43c6496..000000000 --- a/apps/web-api/src/participants-request/unique-identifier/unique-identifier.controller.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* eslint-disable prettier/prettier */ -import { Body, Controller, Post } from '@nestjs/common'; -import { ParticipantsRequestService } from '../participants-request.service'; - -@Controller('v1/participants-request/unique-identifier') -export class UniqueIdentifier { - constructor( - private readonly participantsRequestService: ParticipantsRequestService - ) {} - - @Post() - async findDuplicates(@Body() body) { - const result = await this.participantsRequestService.findDuplicates( - body.uniqueIdentifier, - body.participantType, - body.uid, - body.requestId - ); - return result; - } -} diff --git a/apps/web-api/src/pipes/participant-request-validation.pipe.ts b/apps/web-api/src/pipes/participant-request-validation.pipe.ts new file mode 100644 index 000000000..b23af9f96 --- /dev/null +++ b/apps/web-api/src/pipes/participant-request-validation.pipe.ts @@ -0,0 +1,49 @@ +import { + ArgumentMetadata, + BadRequestException, + Injectable, + PipeTransform, +} from '@nestjs/common'; +import { + ParticipantRequestMemberSchema, + ParticipantRequestTeamSchema, +} from 'libs/contracts/src/schema/participants-request'; +import { ZodError } from 'zod'; + +@Injectable() +export class ParticipantsReqValidationPipe implements PipeTransform { + /** + * Transforms and validates the incoming request body based on the participant type. + * @param value - The incoming request body + * @param metadata - The metadata of the argument (checks if it is 'body') + * @returns The validated value or throws an exception if validation fails + */ + transform(value: any, metadata: ArgumentMetadata): any { + if (metadata.type !== 'body') { + return value; + } + try { + const { participantType } = value; + if (participantType === 'MEMBER') { + ParticipantRequestMemberSchema.parse(value); + } else if (participantType === 'TEAM') { + ParticipantRequestTeamSchema.parse(value); + } else { + throw new BadRequestException({ + statusCode: 400, + message: `Invalid participant request type ${participantType}`, + }); + } + return value; + } catch (error) { + if (error instanceof ZodError) { + throw new BadRequestException({ + statusCode: 400, + message: 'Participant request validation failed', + errors: error.errors, + }); + } + throw error; + } + } +} diff --git a/apps/web-api/src/setup.service.ts b/apps/web-api/src/setup.service.ts index ec055f780..e400b9677 100644 --- a/apps/web-api/src/setup.service.ts +++ b/apps/web-api/src/setup.service.ts @@ -13,7 +13,7 @@ export class SetupService { winston.format.timestamp(), winston.format.ms(), winston.format.printf((info) => { - return `${info.timestamp} : ${info.level} - ${info.message}`; + return `${JSON.stringify(info)}`;; }) //nestWinstonModuleUtilities.format.nestLike() ), diff --git a/apps/web-api/src/shared/shared.module.ts b/apps/web-api/src/shared/shared.module.ts index aa6aab6c2..26a424c30 100644 --- a/apps/web-api/src/shared/shared.module.ts +++ b/apps/web-api/src/shared/shared.module.ts @@ -1,10 +1,44 @@ import { Global, Logger, Module } from '@nestjs/common'; import { PrismaService } from './prisma.service'; import { LogService } from './log.service'; +import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; +import { AwsService } from '../utils/aws/aws.service'; +import { SlackService } from '../utils/slack/slack.service'; +import { LocationTransferService } from '../utils/location-transfer/location-transfer.service'; +import { FileMigrationService } from '../utils/file-migration/file-migration.service'; +import { ImagesController } from '../images/images.controller'; +import { ImagesService } from '../images/images.service'; +import { FileUploadService } from '../utils/file-upload/file-upload.service'; +import { FileEncryptionService } from '../utils/file-encryption/file-encryption.service'; @Global() @Module({ - providers: [PrismaService, LogService, Logger], - exports: [PrismaService, LogService], + providers: [ + PrismaService, + LogService, + Logger, + ForestAdminService, + AwsService, + SlackService, + LocationTransferService, + FileMigrationService, + ImagesController, + ImagesService, + FileUploadService, + FileEncryptionService, + ], + exports: [ + PrismaService, + LogService, + ForestAdminService, + AwsService, + SlackService, + LocationTransferService, + FileMigrationService, + ImagesController, + ImagesService, + FileUploadService, + FileEncryptionService, + ], }) -export class SharedModule {} +export class SharedModule {} \ No newline at end of file diff --git a/apps/web-api/src/teams/teams.controller.ts b/apps/web-api/src/teams/teams.controller.ts index fa7e18ae1..a026e2138 100644 --- a/apps/web-api/src/teams/teams.controller.ts +++ b/apps/web-api/src/teams/teams.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Req, UseGuards, Body, Param } from '@nestjs/common'; +import { Controller, Req, UseGuards, Body, Param, UsePipes } from '@nestjs/common'; import { ApiNotFoundResponse, ApiParam } from '@nestjs/swagger'; import { Api, ApiDecorator, initNestServer } from '@ts-rest/nest'; import { Request } from 'express'; @@ -17,7 +17,7 @@ import { prismaQueryableFieldsFromZod } from '../utils/prisma-queryable-fields-f import { TeamsService } from './teams.service'; import { NoCache } from '../decorators/no-cache.decorator'; import { UserTokenValidation } from '../guards/user-token-validation.guard'; -import { ParticipantRequestTeamSchema } from '../../../../libs/contracts/src/schema/participants-request'; +import { ParticipantsReqValidationPipe } from '../pipes/participant-request-validation.pipe'; const server = initNestServer(apiTeam); type RouteShape = typeof server.routeShapes; @@ -64,16 +64,14 @@ export class TeamsController { ENABLED_RETRIEVAL_PROFILE ); const builtQuery = builder.build(request.query); - return this.teamsService.findOne(uid, builtQuery); + return this.teamsService.findTeamByUid(uid, builtQuery); } @Api(server.route.modifyTeam) @UseGuards(UserTokenValidation) - async updateOne(@Param('id') id, @Body() body, @Req() req) { - const participantsRequest = body; - return await this.teamsService.editTeamParticipantsRequest( - participantsRequest, - req.userEmail - ); + @UsePipes(new ParticipantsReqValidationPipe()) + async updateOne(@Param('uid') teamUid, @Body() body, @Req() req) { + await this.teamsService.validateRequestor(req.userEmail, teamUid); + return await this.teamsService.updateTeamFromParticipantsRequest(teamUid, body, req.userEmail); } } diff --git a/apps/web-api/src/teams/teams.module.ts b/apps/web-api/src/teams/teams.module.ts index 51b75fc9c..8b55aa8f8 100644 --- a/apps/web-api/src/teams/teams.module.ts +++ b/apps/web-api/src/teams/teams.module.ts @@ -1,34 +1,14 @@ -import { Module } from '@nestjs/common'; -import { ImagesController } from '../images/images.controller'; -import { ImagesService } from '../images/images.service'; -import { ParticipantsRequestService } from '../participants-request/participants-request.service'; -import { AwsService } from '../utils/aws/aws.service'; -import { FileEncryptionService } from '../utils/file-encryption/file-encryption.service'; -import { FileMigrationService } from '../utils/file-migration/file-migration.service'; -import { FileUploadService } from '../utils/file-upload/file-upload.service'; -import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; -import { LocationTransferService } from '../utils/location-transfer/location-transfer.service'; -import { RedisService } from '../utils/redis/redis.service'; -import { SlackService } from '../utils/slack/slack.service'; +import { Module, forwardRef } from '@nestjs/common'; import { TeamsController } from './teams.controller'; import { TeamsService } from './teams.service'; +import { SharedModule } from '../shared/shared.module'; +import { ParticipantsRequestModule } from '../participants-request/participants-request.module'; +import { MembersModule } from '../members/members.module'; @Module({ + imports: [forwardRef(() => ParticipantsRequestModule), forwardRef(() => MembersModule), SharedModule], controllers: [TeamsController], - providers: [ - TeamsService, - FileMigrationService, - ImagesController, - ImagesService, - FileUploadService, - FileEncryptionService, - ParticipantsRequestService, - LocationTransferService, - AwsService, - RedisService, - SlackService, - ForestAdminService - ], - exports:[TeamsService] + providers: [TeamsService], + exports: [TeamsService] }) -export class TeamsModule {} +export class TeamsModule {} \ No newline at end of file diff --git a/apps/web-api/src/teams/teams.service.ts b/apps/web-api/src/teams/teams.service.ts index e1ef48d34..dc05703e2 100644 --- a/apps/web-api/src/teams/teams.service.ts +++ b/apps/web-api/src/teams/teams.service.ts @@ -1,268 +1,387 @@ import { Injectable, - UnauthorizedException, + ConflictException, ForbiddenException, - InternalServerErrorException, BadRequestException, - HttpException, + NotFoundException, + Inject, + forwardRef, + CACHE_MANAGER } from '@nestjs/common'; -import { Prisma, ParticipantType } from '@prisma/client'; import * as path from 'path'; import { z } from 'zod'; +import { Prisma, Team, Member, ParticipantsRequest } from '@prisma/client'; import { PrismaService } from '../shared/prisma.service'; import { AirtableTeamSchema } from '../utils/airtable/schema/airtable-team.schema'; import { FileMigrationService } from '../utils/file-migration/file-migration.service'; +import { NotificationService } from '../utils/notification/notification.service'; import { ParticipantsRequestService } from '../participants-request/participants-request.service'; import { hashFileName } from '../utils/hashing'; -import { ParticipantRequestTeamSchema } from 'libs/contracts/src/schema/participants-request'; +import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; +import { MembersService } from '../members/members.service'; +import { LogService } from '../shared/log.service'; +import { Cache } from 'cache-manager'; +import { copyObj, buildMultiRelationMapping, buildRelationMapping } from '../utils/helper/helper'; @Injectable() export class TeamsService { constructor( private prisma: PrismaService, private fileMigrationService: FileMigrationService, - private participantsRequestService: ParticipantsRequestService + @Inject(forwardRef(() => ParticipantsRequestService)) + private participantsRequestService: ParticipantsRequestService, + @Inject(forwardRef(() => MembersService)) + private membersService: MembersService, + private logger: LogService, + private forestadminService: ForestAdminService, + private notificationService: NotificationService, + @Inject(CACHE_MANAGER) private cacheService: Cache ) {} - async findAll(queryOptions: Prisma.TeamFindManyArgs) { - return this.prisma.team.findMany({ - ...queryOptions - }); + /** + * Find all teams based on provided query options. + * Allows flexibility in filtering, sorting, and pagination through Prisma.TeamFindManyArgs. + * + * @param queryOptions - Prisma query options to customize the result set + * (filter, pagination, sorting, etc.) + * @returns A list of teams that match the query options + */ + async findAll(queryOptions: Prisma.TeamFindManyArgs): Promise { + try { + return this.prisma.team.findMany({ ...queryOptions }); + } catch(err) { + return this.handleErrors(err); + } } - async findOne( + /** + * Find a single team by its unique identifier (UID). + * Retrieves detailed information about the team, + * including related data like projects, technologies, and team focus areas. + * + * @param uid - Unique identifier for the team + * @param queryOptions - Additional Prisma query options (excluding 'where') for + * customizing the result set + * @returns The team object with all related information or throws an error if not found + * @throws {NotFoundException} If the team with the given UID is not found + */ + async findTeamByUid( uid: string, queryOptions: Omit = {} - ) { - const team = await this.prisma.team.findUniqueOrThrow({ - where: { uid }, - ...queryOptions, - include: { - fundingStage: true, - industryTags: true, - logo: true, - membershipSources: true, - technologies: true, - maintainingProjects:{ - orderBy: [ - { - name: 'asc' - } - ], - include: { - logo: { select: { url: true, uid: true } }, - maintainingTeam: { - select: { - name: true, - logo: { select: { url: true, uid: true } }, - }, + ): Promise { + try { + const team = await this.prisma.team.findUniqueOrThrow({ + where: { uid }, + ...queryOptions, + include: { + fundingStage: true, + industryTags: true, + logo: true, + membershipSources: true, + technologies: true, + maintainingProjects: { + orderBy: { name: 'asc' }, + include: { + logo: { select: { url: true, uid: true } }, + maintainingTeam: { select: { name: true, logo: { select: { url: true, uid: true } } } }, + contributingTeams: true, }, - contributingTeams: true - } - }, - contributingProjects: { - orderBy: [ - { - name: 'asc' - } - ], - include: { - logo: { select: { url: true, uid: true } }, - maintainingTeam: { - select: { - name: true, - logo: { select: { url: true, uid: true } }, - }, + }, + contributingProjects: { + orderBy: { name: 'asc' }, + include: { + logo: { select: { url: true, uid: true } }, + maintainingTeam: { select: { name: true, logo: { select: { url: true, uid: true } } } }, + contributingTeams: true, }, - contributingTeams: true - } + }, + teamFocusAreas: { + select: { + focusArea: { select: { uid: true, title: true } }, + }, + }, }, - teamFocusAreas: { - select: { - focusArea: { - select: { - uid: true, - title: true - } - } - } - } - }, - }); - team.teamFocusAreas = this.removeDuplicateFocusAreas(team.teamFocusAreas); - return team; + }); + team.teamFocusAreas = this.removeDuplicateFocusAreas(team.teamFocusAreas); + return team; + } catch(err) { + return this.handleErrors(err, uid); + } } - async insertManyFromAirtable( - airtableTeams: z.infer[] - ) { - const fundingStages = await this.prisma.fundingStage.findMany(); - const industryTags = await this.prisma.industryTag.findMany(); - const technologies = await this.prisma.technology.findMany(); - const membershipSources = await this.prisma.membershipSource.findMany(); - const images = await this.prisma.image.findMany(); - - for (const team of airtableTeams) { - const optionalFieldsToAdd = Object.entries({ - blog: 'Blog', - website: 'Website', - twitterHandler: 'Twitter', - shortDescription: 'Short description', - contactMethod: 'Preferred Method of Contact', - longDescription: 'Long description', - plnFriend: 'Friend of PLN', - }).reduce( - (optionalFields, [prismaField, airtableField]) => ({ - ...optionalFields, - ...(team.fields?.[airtableField] && { - [prismaField]: team.fields?.[airtableField], - }), - }), - {} - ); - - const oneToManyRelations = { - fundingStageUid: - fundingStages.find( - (fundingStage) => - fundingStage.title === team.fields?.['Funding Stage'] - )?.uid || null, - }; + /** + * Find a team by its name. + * + * @param name - The name of the team to find + * @returns The team object if found, otherwise null + */ + async findTeamByName(name: string): Promise { + try { + return this.prisma.team.findUniqueOrThrow({ + where: { name }, + }); + } catch(err) { + return this.handleErrors(err); + } + }; - const manyToManyRelations = { - industryTags: { - connect: industryTags - .filter( - (tag) => - !!team.fields?.['Tags lookup'] && - team.fields?.['Tags lookup'].includes(tag.title) - ) - .map((tag) => ({ id: tag.id })), - }, - membershipSources: { - connect: membershipSources - .filter( - (program) => - !!team.fields?.['Accelerator Programs'] && - team.fields?.['Accelerator Programs'].includes(program.title) - ) - .map((tag) => ({ id: tag.id })), - }, - technologies: { - connect: technologies - .filter( - (tech) => - (team.fields?.['Filecoin User'] && tech.title === 'Filecoin') || - (team.fields?.['IPFS User'] && tech.title === 'IPFS') - ) - .map((tech) => ({ id: tech.id })), - }, - }; + /** + * Creates a new team in the database within a transaction. + * + * @param team - The data for the new team to be created + * @param tx - The transaction client to ensure atomicity + * @returns The created team record + */ + async createTeam( + team: Prisma.TeamUncheckedCreateInput, + tx: Prisma.TransactionClient + ): Promise { + try { + return await tx.team.create({ + data: team, + }); + } catch(err) { + return this.handleErrors(err); + } + } - let image; + /** + * Updates the team data in the database within a transaction. + * + * @param teamUid - Unique identifier of the team being updated + * @param team - The new data to be applied to the team + * @param tx - The transaction client to ensure atomicity + * @returns The updated team record + */ + async updateTeamByUid( + uid: string, + team: Prisma.TeamUncheckedUpdateInput, + tx: Prisma.TransactionClient, + ): Promise { + try { + return await tx.team.update({ + where: { uid }, + data: team, + }); + } catch(err) { + return this.handleErrors(err, `${uid}`); + } + } - if (team.fields.Logo) { - const logo = team.fields.Logo[0]; + /** + * Updates the existing team with new information. + * updates the team, logs the update in the participants request table, + * resets the cache, and triggers post-update actions like Airtable synchronization. + * + * @param teamUid - Unique identifier of the team to be updated + * @param teamParticipantRequest - Data containing the updated team information + * @param requestorEmail - Email of the person making the request + * @returns A success message if the operation is successful + */ + async updateTeamFromParticipantsRequest( + teamUid: string, + teamParticipantRequest: ParticipantsRequest, + requestorEmail: string + ): Promise { + const updatedTeam: any = teamParticipantRequest.newData; + const existingTeam = await this.findTeamByUid(teamUid); + let result; + await this.prisma.$transaction(async (tx) => { + const team = await this.formatTeam(teamUid, updatedTeam, tx, "Update"); + result = await this.updateTeamByUid(teamUid, team, tx); + await this.logParticipantRequest(requestorEmail, updatedTeam, existingTeam.uid, tx); + }); + this.notificationService.notifyForTeamEditApproval(updatedTeam.name, teamUid, requestorEmail); + await this.postUpdateActions(); + return result; + } - const hashedLogo = logo.filename - ? hashFileName(`${path.parse(logo.filename).name}-${logo.id}`) - : ''; - image = - images.find( - (image) => path.parse(image.filename).name === hashedLogo - ) || - (await this.fileMigrationService.migrateFile({ - id: logo.id ? logo.id : '', - url: logo.url ? logo.url : '', - filename: logo.filename ? logo.filename : '', - size: logo.size ? logo.size : 0, - type: logo.type ? logo.type : '', - height: logo.height ? logo.height : 0, - width: logo.width ? logo.width : 0, - })); - } + /** + * Creates a new team from the participants request data. + * resets the cache, and triggers post-update actions like Airtable synchronization. + * @param teamParticipantRequest - The request containing the team details. + * @param requestorEmail - The email of the requestor. + * @returns The newly created team. + */ + async createTeamFromParticipantsRequest( + teamParticipantRequest: ParticipantsRequest, + tx: Prisma.TransactionClient + ): Promise { + const newTeam: any = teamParticipantRequest.newData; + const formattedTeam = await this.formatTeam(null, newTeam, tx); + const createdTeam = await this.createTeam(formattedTeam, tx); + return createdTeam; + } - await this.prisma.team.upsert({ - where: { airtableRecId: team.id }, - update: { - ...optionalFieldsToAdd, - ...oneToManyRelations, - ...manyToManyRelations, - }, - create: { - airtableRecId: team.id, - name: team.fields.Name, - plnFriend: team.fields['Friend of PLN'] || false, - logoUid: image && image.uid ? image.uid : undefined, - ...optionalFieldsToAdd, - ...oneToManyRelations, - ...manyToManyRelations, - ...(team.fields?.['Created'] && { - createdAt: new Date(team.fields['Created']), - }), - ...(team.fields?.['Last Modified'] && { - updatedAt: new Date(team.fields['Last Modified']), - }), - }, - }); + /** + * Format team data for creation or update + * + * @param teamUid - The unique identifier for the team (used for updates) + * @param teamData - Raw team data to be formatted + * @param tx - Transaction client for atomic operations + * @param type - Operation type ('create' or 'update') + * @returns - Formatted team data for Prisma query + */ + async formatTeam( + teamUid: string | null, + teamData: Partial, + tx: Prisma.TransactionClient, + type: string = 'Create' + ) { + const team: any = {}; + const directFields = [ + 'name', 'blog', 'contactMethod', 'twitterHandler', + 'linkedinHandler', 'telegramHandler', 'officeHours', + 'shortDescription', 'website', 'airtableRecId', + 'longDescription', 'moreDetails' + ]; + copyObj(teamData, team, directFields); + // Handle one-to-one or one-to-many mappings + team['fundingStage'] = buildRelationMapping('fundingStage', teamData); + team['industryTags'] = buildMultiRelationMapping('industryTags', teamData, type); + team['technologies'] = buildMultiRelationMapping('technologies', teamData, type); + team['membershipSources'] = buildMultiRelationMapping('membershipSources', teamData, type); + if (type === 'create') { + team['teamFocusAreas'] = await this.createTeamWithFocusAreas(teamData, tx); + } + if (teamUid) { + team['teamFocusAreas'] = await this.updateTeamWithFocusAreas(teamUid, teamData, tx); } + team['logo'] = teamData.logoUid + ? { connect: { uid: teamData.logoUid } } + : type === 'update' ? { disconnect: true } : undefined; + return team; } - async editTeamParticipantsRequest(participantsRequest, userEmail) { - const { referenceUid } = participantsRequest; - const requestorDetails = - await this.participantsRequestService.findMemberByEmail(userEmail); - if (!requestorDetails) { - throw new UnauthorizedException(); - } - if ( - !requestorDetails.isDirectoryAdmin && - !requestorDetails.leadingTeams?.includes(referenceUid) - ) { - throw new ForbiddenException(); - } - participantsRequest.requesterEmailId = requestorDetails.email; - participantsRequest.newData.lastModifiedBy = requestorDetails.uid; - if ( - participantsRequest.participantType === ParticipantType.TEAM.toString() && - !ParticipantRequestTeamSchema.safeParse(participantsRequest).success - ) { - throw new BadRequestException(); + /** + * Validates the permissions of the requestor. The requestor must either be an admin or the leader of the team. + * + * @param requestorEmail - The email of the person requesting the update + * @param teamUid - The unique identifier of the team being updated + * @returns The requestor's member data if validation passes + * @throws {UnauthorizedException} If the requestor is not found + * @throws {ForbiddenException} If the requestor does not have sufficient permissions + */ + async validateRequestor(requestorEmail: string, teamUid: string): Promise { + const requestor = await this.membersService.findMemberByEmail(requestorEmail); + if (!requestor.isDirectoryAdmin && !requestor.leadingTeams.includes(teamUid)) { + throw new ForbiddenException('Requestor does not have permission to update this team'); } - let result; - try { - await this.prisma.$transaction(async (tx) => { - result = await this.participantsRequestService.addRequest( - participantsRequest, - true, - tx - ); - if (result?.uid) { - result = await this.participantsRequestService.processTeamEditRequest( - result.uid, - true, // disable the notification - true, // enable the auto approval - tx - ); - } else { - throw new InternalServerErrorException(); + return requestor; + } + + /** + * Removes duplicate focus areas from the team object based on their UID. + * Ensures that each focus area is unique in the result set. + * + * @param focusAreas - An array of focus areas associated with the team + * @returns A deduplicated array of focus areas + */ + private removeDuplicateFocusAreas(focusAreas):any { + const uniqueFocusAreas = {}; + focusAreas.forEach(item => { + const { uid, title } = item.focusArea; + uniqueFocusAreas[uid] = { uid, title }; + }); + return Object.values(uniqueFocusAreas); + }; + + /** + * Logs the participant request in the participants request table for audit and tracking purposes. + * + * @param requestorEmail - Email of the requestor who is updating the team + * @param newTeamData - The new data being applied to the team + * @param referenceUid - Unique identifier of the existing team to be referenced + * @param tx - The transaction client to ensure atomicity + */ + private async logParticipantRequest( + requestorEmail: string, + newTeamData, + referenceUid: string, + tx: Prisma.TransactionClient, + ): Promise { + await this.participantsRequestService.add({ + status: 'AUTOAPPROVED', + requesterEmailId: requestorEmail, + referenceUid, + uniqueIdentifier: newTeamData?.name || '', + participantType: 'TEAM', + newData: { ...newTeamData }, + }, + tx + ); + } + + /** + * Executes post-update actions such as resetting the cache and triggering Airtable sync. + * This ensures that the system is up-to-date with the latest changes. + */ + private async postUpdateActions(): Promise { + await this.cacheService.reset(); + await this.forestadminService.triggerAirtableSync(); + } + + /** + * Creates focus area mappings for a new team. + * + * @param team - The team object containing focus areas + * @param transaction - The transaction client for atomic operations + * @returns - Data for bulk insertion of focus areas + */ + async createTeamWithFocusAreas(team, transaction: Prisma.TransactionClient) { + if (team.focusAreas && team.focusAreas.length > 0) { + let teamFocusAreas:any = []; + const focusAreaHierarchies = await transaction.focusAreaHierarchy.findMany({ + where: { + subFocusAreaUid: { + in: team.focusAreas.map(area => area.uid) + } } }); - } catch (error) { - if (error?.response?.statusCode && error?.response?.message) { - throw new HttpException( - error?.response?.message, - error?.response?.statusCode - ); - } else { - throw new BadRequestException( - 'Oops, something went wrong. Please try again!' - ); + focusAreaHierarchies.map(areaHierarchy => { + teamFocusAreas.push({ + focusAreaUid: areaHierarchy.subFocusAreaUid, + ancestorAreaUid: areaHierarchy.focusAreaUid + }); + }); + team.focusAreas.map(area => { + teamFocusAreas.push({ + focusAreaUid: area.uid, + ancestorAreaUid: area.uid + }); + }); + return { + createMany: { + data: teamFocusAreas + } } } - return result; + return {}; } - + + /** + * Updates focus areas for an existing team. + * + * @param teamUid - The unique identifier of the team + * @param team - The team object containing new focus areas + * @param transaction - The transaction client for atomic operations + * @returns - Data for bulk insertion of updated focus areas + */ + async updateTeamWithFocusAreas(teamUid: string, team, transaction: Prisma.TransactionClient) { + await transaction.teamFocusArea.deleteMany({ + where: { teamUid } + }); + if (!team.focusAreas || team.focusAreas.length === 0) { + return {}; + } + return await this.createTeamWithFocusAreas(team, transaction); + } + + /** + * Builds filter for focus areas by splitting the input and matching ancestor titles. + * @param focusAreas - Comma-separated focus area titles + * @returns - Prisma filter for teamFocusAreas + */ buildFocusAreaFilters(focusAreas) { if (focusAreas?.split(',')?.length > 0) { return { @@ -279,7 +398,12 @@ export class TeamsService { } return {}; } - + + /** + * Constructs the team filter based on multiple query parameters. + * @param queryParams - Query parameters from the request + * @returns - Prisma AND filter combining all conditions + */ buildTeamFilter(queryParams){ const { name, @@ -288,7 +412,7 @@ export class TeamsService { technologies, membershipSources, fundingStage, - officeHours + officeHours } = queryParams; const filter:any = []; this.buildNameAndPLNFriendFilter(name, plnFriend, filter); @@ -303,6 +427,12 @@ export class TeamsService { }; }; + /** + * Adds name and PLN friend filter conditions to the filter array. + * @param name - Team name to search for (case-insensitive) + * @param plnFriend - Boolean to filter teams that are PLN friends + * @param filter - Filter array to be appended to + */ buildNameAndPLNFriendFilter(name, plnFriend, filter) { if (name) { filter.push({ @@ -319,6 +449,11 @@ export class TeamsService { } } + /** + * Adds industry tags filter to the filter array. + * @param industryTags - Comma-separated industry tags + * @param filter - Filter array to be appended to + */ buildIndustryTagsFilter(industryTags, filter) { const tags = industryTags?.split(',').map(tag=> tag.trim()); if (tags?.length > 0) { @@ -336,6 +471,11 @@ export class TeamsService { } } + /** + * Adds technology tags filter to the filter array. + * @param technologies - Comma-separated technology tags + * @param filter - Filter array to be appended to + */ buildTechnologiesFilter(technologies, filter) { const tags = technologies?.split(',').map(tech => tech.trim()); if (tags?.length > 0) { @@ -353,6 +493,11 @@ export class TeamsService { } } + /** + * Adds membership sources filter to the filter array. + * @param membershipSources - Comma-separated membership source titles + * @param filter - Filter array to be appended to + */ buildMembershipSourcesFilter(membershipSources, filter) { const sources = membershipSources?.split(',').map(source => source.trim()); if (sources?.length > 0) { @@ -370,6 +515,11 @@ export class TeamsService { } } + /** + * Adds funding stage filter to the filter array. + * @param fundingStage - Title of the funding stage + * @param filter - Filter array to be appended to + */ buildFundingStageFilter(fundingStage, filter) { if (fundingStage?.length > 0) { filter.push({ @@ -380,25 +530,19 @@ export class TeamsService { } } - removeDuplicateFocusAreas(focusAreas): any { - const uniqueFocusAreas = {}; - focusAreas.forEach(item => { - const uid = item.focusArea.uid; - const title = item.focusArea.title; - uniqueFocusAreas[uid] = { uid, title }; - }); - return Object.values(uniqueFocusAreas); - } - + /** + * Adds office hours filter to the filter array. + * @param officeHours - Boolean to check if teams have office hours + * @param filter - Filter array to be appended to + */ buildOfficeHoursFilter(officeHours, filter) { - if ((officeHours === "true")) { - filter.push({ + if (officeHours === "true") { + filter.push({ officeHours: { not: null } }); } } - /** * Constructs a dynamic filter query for retrieving recent teams based on the 'is_recent' query parameter. * If 'is_recent' is set to 'true', it creates a 'createdAt' filter to retrieve records created within a @@ -426,4 +570,154 @@ export class TeamsService { } return {}; } + + /** + * Handles database-related errors specifically for the Team entity. + * Logs the error and throws an appropriate HTTP exception based on the error type. + * + * @param {any} error - The error object thrown by Prisma or other services. + * @param {string} [message] - An optional message to provide additional context, + * such as the team UID when an entity is not found. + * @throws {ConflictException} - If there's a unique key constraint violation. + * @throws {BadRequestException} - If there's a foreign key constraint violation or validation error. + * @throws {NotFoundException} - If a team is not found with the provided UID. + */ + private handleErrors(error, message?: string) { + this.logger.error(error); + if (error instanceof Prisma.PrismaClientKnownRequestError) { + switch (error?.code) { + case 'P2002': + throw new ConflictException('Unique key constraint error on Team:', error.message); + case 'P2003': + throw new BadRequestException('Foreign key constraint error on Team', error.message); + case 'P2025': + throw new NotFoundException('Team not found with uid: ' + message); + default: + throw error; + } + } + else if (error instanceof Prisma.PrismaClientValidationError) { + throw new BadRequestException( + 'Database field validation error on Team', + error.message + ); + } + return error; + } + + + async insertManyFromAirtable( + airtableTeams: z.infer[] + ) { + const fundingStages = await this.prisma.fundingStage.findMany(); + const industryTags = await this.prisma.industryTag.findMany(); + const technologies = await this.prisma.technology.findMany(); + const membershipSources = await this.prisma.membershipSource.findMany(); + const images = await this.prisma.image.findMany(); + + for (const team of airtableTeams) { + const optionalFieldsToAdd = Object.entries({ + blog: 'Blog', + website: 'Website', + twitterHandler: 'Twitter', + shortDescription: 'Short description', + contactMethod: 'Preferred Method of Contact', + longDescription: 'Long description', + plnFriend: 'Friend of PLN', + }).reduce( + (optionalFields, [prismaField, airtableField]) => ({ + ...optionalFields, + ...(team.fields?.[airtableField] && { + [prismaField]: team.fields?.[airtableField], + }), + }), + {} + ); + + const oneToManyRelations = { + fundingStageUid: + fundingStages.find( + (fundingStage) => + fundingStage.title === team.fields?.['Funding Stage'] + )?.uid || null, + }; + + const manyToManyRelations = { + industryTags: { + connect: industryTags + .filter( + (tag) => + !!team.fields?.['Tags lookup'] && + team.fields?.['Tags lookup'].includes(tag.title) + ) + .map((tag) => ({ id: tag.id })), + }, + membershipSources: { + connect: membershipSources + .filter( + (program) => + !!team.fields?.['Accelerator Programs'] && + team.fields?.['Accelerator Programs'].includes(program.title) + ) + .map((tag) => ({ id: tag.id })), + }, + technologies: { + connect: technologies + .filter( + (tech) => + (team.fields?.['Filecoin User'] && tech.title === 'Filecoin') || + (team.fields?.['IPFS User'] && tech.title === 'IPFS') + ) + .map((tech) => ({ id: tech.id })), + }, + }; + + let image; + + if (team.fields.Logo) { + const logo = team.fields.Logo[0]; + + const hashedLogo = logo.filename + ? hashFileName(`${path.parse(logo.filename).name}-${logo.id}`) + : ''; + image = + images.find( + (image) => path.parse(image.filename).name === hashedLogo + ) || + (await this.fileMigrationService.migrateFile({ + id: logo.id ? logo.id : '', + url: logo.url ? logo.url : '', + filename: logo.filename ? logo.filename : '', + size: logo.size ? logo.size : 0, + type: logo.type ? logo.type : '', + height: logo.height ? logo.height : 0, + width: logo.width ? logo.width : 0, + })); + } + + await this.prisma.team.upsert({ + where: { airtableRecId: team.id }, + update: { + ...optionalFieldsToAdd, + ...oneToManyRelations, + ...manyToManyRelations, + }, + create: { + airtableRecId: team.id, + name: team.fields.Name, + plnFriend: team.fields['Friend of PLN'] || false, + logoUid: image && image.uid ? image.uid : undefined, + ...optionalFieldsToAdd, + ...oneToManyRelations, + ...manyToManyRelations, + ...(team.fields?.['Created'] && { + createdAt: new Date(team.fields['Created']), + }), + ...(team.fields?.['Last Modified'] && { + updatedAt: new Date(team.fields['Last Modified']), + }), + }, + }); + } + } } diff --git a/apps/web-api/src/utils/aws/aws.service.ts b/apps/web-api/src/utils/aws/aws.service.ts index 8a9d60655..c7a321195 100644 --- a/apps/web-api/src/utils/aws/aws.service.ts +++ b/apps/web-api/src/utils/aws/aws.service.ts @@ -13,8 +13,13 @@ const CONFIG = { @Injectable() export class AwsService { + isEmailServiceEnabled() { + return process.env.IS_EMAIL_ENABLED === 'true'; + } async sendEmail(templateName, includeAdmins, toAddresses, data) { try { + if (!this.isEmailServiceEnabled()) + return null; const AWS_SES = new AWS.SES(CONFIG); const adminEmailIdsFromEnv = process.env.SES_ADMIN_EMAIL_IDS; const adminEmailIds = adminEmailIdsFromEnv?.split('|') ?? []; @@ -43,6 +48,8 @@ export class AwsService { toAddresses: string[], ccAddresses: string[] ) { + if (!this.isEmailServiceEnabled()) + return null; const emailTemplate = fs.readFileSync( templateName, 'utf-8' @@ -82,6 +89,11 @@ export class AwsService { } async uploadFileToS3(file, bucketName, fileName: string) { + if (process.env.ENVIRONMENT === "development" && (!bucketName || !CONFIG.accessKeyId || !CONFIG.secretAccessKey || !CONFIG.region)) { + return { + Location: "" + }; + } const s3 = new AWS.S3(CONFIG); const params = { Bucket: bucketName, diff --git a/apps/web-api/src/utils/helper/helper.ts b/apps/web-api/src/utils/helper/helper.ts index 08ae6c7e1..c1523714b 100644 --- a/apps/web-api/src/utils/helper/helper.ts +++ b/apps/web-api/src/utils/helper/helper.ts @@ -35,4 +35,47 @@ export const slugify = (name: string) => { .replace(/--+/g, '-') // Replace multiple hyphens with single hyphen .replace(/^-+/, '') // Trim hyphens from start of string .replace(/-+$/, ''); // Trim hyphens from end of string +} + +/** + * Copies specific fields from the source JSON to the destination object + * @param srcJson - Source JSON + * @param destJson - Destination object + * @param directFields - List of fields to copy + */ +export const copyObj = (srcJson: any, destJson: any, directFields: string[]) => { + directFields.forEach(field => { + destJson[field] = srcJson[field]; + }); +} + +/** + * Utility function to map single relational data + * + * @param field - The field name to map + * @param rawData - The raw data input + * @returns - Relation object for Prisma query + */ +export const buildRelationMapping = (field: string, rawData: any) => { + return rawData[field]?.uid + ? { connect: { uid: rawData[field].uid } } + : undefined; +} + +/** + * Utility function to map multiple relational data + * + * @param field - The field name to map + * @param rawData - The raw data input + * @param type - Operation type ('create' or 'update') + * @returns - Multi-relation object for Prisma query + */ +export const buildMultiRelationMapping = (field: string, rawData: any, type: string) => { + const dataExists = rawData[field]?.length > 0; + if (!dataExists) { + return type === 'Update' ? { set: [] } : undefined; + } + return { + [type === 'Create' ? 'connect' : 'set']: rawData[field].map((item: any) => ({ uid: item.uid })) + }; } \ No newline at end of file diff --git a/apps/web-api/src/utils/notification/notification.service.ts b/apps/web-api/src/utils/notification/notification.service.ts new file mode 100644 index 000000000..a6895db7c --- /dev/null +++ b/apps/web-api/src/utils/notification/notification.service.ts @@ -0,0 +1,179 @@ +/* eslint-disable prettier/prettier */ +import { Injectable } from '@nestjs/common'; +import { AwsService } from '../aws/aws.service'; +import { SlackService } from '../slack/slack.service'; +import { getRandomId } from '../helper/helper'; + +@Injectable() +export class NotificationService { + constructor( + private awsService: AwsService, + private slackService: SlackService + ) { } + + /** + * This method sends notifications when a new member is created. + * @param memberName The name of the new member + * @param uid The unique identifier for the member + * @returns Sends an email to admins and posts a notification to Slack. + */ + async notifyForCreateMember(memberName: string, uid: string) { + const backOfficeMemberUrl = `${process.env.WEB_ADMIN_UI_BASE_URL}/member-view?id=${uid}`; + const slackConfig = { requestLabel: 'New Labber Request', url: backOfficeMemberUrl, name: memberName }; + await this.awsService.sendEmail( + 'NewMemberRequest', true, [], + { memberName: memberName, requestUid: uid, adminSiteUrl: backOfficeMemberUrl } + ); + await this.slackService.notifyToChannel(slackConfig); + } + + /** + * This method sends notifications when a member's profile is edited. + * @param memberName The name of the member whose profile is edited + * @param uid The unique identifier for the member + * @param requesterEmailId The email address of the person who requested the edit + * @returns Sends an email to admins and posts a notification to Slack. + */ + async notifyForEditMember(memberName: string, uid: string, requesterEmailId: string) { + const backOfficeMemberUrl = `${process.env.WEB_ADMIN_UI_BASE_URL}/member-view?id=${uid}`; + const slackConfig = { requestLabel: 'Edit Labber Request', url: backOfficeMemberUrl, name: memberName }; + await this.awsService.sendEmail( + 'EditMemberRequest', true, [], + { memberName, requestUid: uid, adminSiteUrl: backOfficeMemberUrl, requesterEmailId } + ); + await this.slackService.notifyToChannel(slackConfig); + } + + /** + * This method sends notifications when a new team is created. + * @param teamName The name of the new team + * @param uid The unique identifier for the team + * @returns Sends an email to admins and posts a notification to Slack. + */ + async notifyForCreateTeam(teamName: string, uid: string) { + const backOfficeTeamUrl = `${process.env.WEB_ADMIN_UI_BASE_URL}/team-view?id=${uid}`; + const slackConfig = { requestLabel: 'New Team Request', url: backOfficeTeamUrl, name: teamName }; + await this.awsService.sendEmail( + 'NewTeamRequest', true, [], + { teamName, requestUid: uid, adminSiteUrl: backOfficeTeamUrl } + ); + await this.slackService.notifyToChannel(slackConfig); + } + + /** + * This method sends notifications when a team's profile is edited. + * @param teamName The name of the team whose profile is edited + * @param teamUid The unique identifier for the team + * @param uid The unique identifier for the edit request + * @returns Sends an email to admins and posts a notification to Slack. + */ + async notifyForEditTeam(teamName: string, teamUid: string, uid: string) { + const backOfficeTeamUrl = `${process.env.WEB_ADMIN_UI_BASE_URL}/team-view?id=${uid}`; + const slackConfig = { requestLabel: 'Edit Team Request', url: backOfficeTeamUrl, name: teamName }; + await this.awsService.sendEmail( + 'EditTeamRequest', true, [], + { teamName, teamUid, requestUid: uid, adminSiteUrl: backOfficeTeamUrl } + ); + await this.slackService.notifyToChannel(slackConfig); + } + + /** + * This method sends notifications after a member is approved. + * @param memberName The name of the member being approved + * @param uid The unique identifier for the member + * @param memberEmailId The email address of the member being approved + * @returns Sends an approval email to the member and posts a notification to Slack. + */ + async notifyForMemberCreationApproval(memberName: string, uid: string, memberEmailId: string) { + const memberUrl = `${process.env.WEB_UI_BASE_URL}/members/${uid}?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`; + const slackConfig = { requestLabel: 'New Labber Added', url: memberUrl, name: memberName }; + await this.awsService.sendEmail( + 'MemberCreated', true, [], + { memberName, memberUid: uid, adminSiteUrl: memberUrl } + ); + await this.awsService.sendEmail( + 'NewMemberSuccess', false, [memberEmailId], + { memberName, memberProfileLink: memberUrl } + ); + await this.slackService.notifyToChannel(slackConfig); + } + + /** + * This method sends notifications after a member's profile is approved for edits. + * @param memberName The name of the member whose profile was edited + * @param uid The unique identifier for the member + * @param memberEmailId The email address of the member + * @returns Sends an email notifying approval and posts a notification to Slack. + */ + async notifyForMemberEditApproval(memberName: string, uid: string, memberEmailId: string) { + const memberUrl = `${process.env.WEB_UI_BASE_URL}/members/${uid}?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`; + const slackConfig = { requestLabel: 'Edit Labber Request Completed', url: memberUrl, name: memberName }; + await this.awsService.sendEmail( + 'MemberEditRequestCompleted', true, [], + { memberName, memberUid: uid, adminSiteUrl: memberUrl } + ); + await this.awsService.sendEmail( + 'EditMemberSuccess', false, [memberEmailId], + { memberName, memberProfileLink: memberUrl } + ); + await this.slackService.notifyToChannel(slackConfig); + } + + /** + * This method sends an acknowledgment email when an admin changes a member's email. + * @param memberName The name of the member whose email is being changed + * @param uid The unique identifier for the member + * @param memberOldEmail The member's old email address + * @param memberNewEmail The member's new email address + * @returns Sends an email to both the old and new email addresses. + */ + async notifyForMemberChangesByAdmin(memberName: string, uid: string, memberOldEmail: string, memberNewEmail: string) { + const memberUrl = `${process.env.WEB_UI_BASE_URL}/members/${uid}?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`; + await this.awsService.sendEmail( + 'MemberEmailChangeAcknowledgement', false, [memberOldEmail, memberNewEmail], + { oldEmail: memberOldEmail, newEmail: memberNewEmail, memberName, profileURL: memberUrl, loginURL: process.env.LOGIN_URL } + ); + } + + /** + * This method sends notifications after a team creation request is approved. + * @param teamName The name of the team being approved + * @param teamUid The unique identifier for the team + * @param requesterEmailId The email address of the person who requested the team creation + * @returns Sends an email notifying approval and posts a notification to Slack. + */ + async notifyForTeamCreationApproval(teamName: string, teamUid: string, requesterEmailId: string) { + const teamUrl = `${process.env.WEB_UI_BASE_URL}/teams/${teamUid}?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`; + const slackConfig = { requestLabel: 'New Team Added', url: teamUrl, name: teamName }; + await this.awsService.sendEmail( + 'TeamCreated', true, [], + { teamName, teamUid, adminSiteUrl: teamUrl } + ); + await this.awsService.sendEmail( + 'NewTeamSuccess', false, [requesterEmailId], + { teamName, memberProfileLink: teamUrl } + ); + await this.slackService.notifyToChannel(slackConfig); + } + + /** + * This method sends notifications after a team edit request is approved. + * @param teamName The name of the team that was edited + * @param teamUid The unique identifier for the team + * @param requesterEmailId The email address of the person who requested the team edit + * @returns Sends an email notifying approval and posts a notification to Slack. + */ + async notifyForTeamEditApproval(teamName: string, teamUid: string, requesterEmailId: string) { + const teamUrl = `${process.env.WEB_UI_BASE_URL}/teams/${teamUid}?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`; + const slackConfig = { requestLabel: 'Edit Team Request Completed', url: teamUrl, name: teamName }; + await this.awsService.sendEmail( + 'TeamEditRequestCompleted', true, [], + { teamName, teamUid, adminSiteUrl: teamUrl } + ); + await this.awsService.sendEmail( + 'EditTeamSuccess', false, [requesterEmailId], + { teamName, memberProfileLink: teamUrl } + ); + await this.slackService.notifyToChannel(slackConfig); + } +} diff --git a/apps/web-api/src/utils/slack/slack.service.ts b/apps/web-api/src/utils/slack/slack.service.ts index abadf43ec..6862d7ed4 100644 --- a/apps/web-api/src/utils/slack/slack.service.ts +++ b/apps/web-api/src/utils/slack/slack.service.ts @@ -6,8 +6,12 @@ import axios from 'axios'; export class SlackService { async notifyToChannel({ requestLabel, url, name }) { try { + const channel = process.env.CHANNEL_ID; + const authToken = process.env.SLACK_BOT_TOKEN; + if (!channel || !authToken) { + return null; + } const content = `${requestLabel} : ${name} \n${url}`; - const slackResponse = await axios({ method: 'post', url: 'https://slack.com/api/chat.postMessage', diff --git a/apps/web-app-e2e/.eslintrc.json b/apps/web-app-e2e/.eslintrc.json deleted file mode 100644 index 4c5989b23..000000000 --- a/apps/web-app-e2e/.eslintrc.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": ["plugin:cypress/recommended", "../../.eslintrc.json"], - "ignorePatterns": ["!**/*"], - "overrides": [ - { - "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], - "rules": {} - }, - { - "files": ["src/plugins/index.js"], - "rules": { - "@typescript-eslint/no-var-requires": "off", - "no-undef": "off" - } - } - ] -} diff --git a/apps/web-app-e2e/cypress.json b/apps/web-app-e2e/cypress.json deleted file mode 100644 index 05067b804..000000000 --- a/apps/web-app-e2e/cypress.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "fileServerFolder": ".", - "fixturesFolder": "./src/fixtures", - "integrationFolder": "./src/integration", - "modifyObstructiveCode": false, - "supportFile": "./src/support/index.ts", - "pluginsFile": false, - "video": true, - "videosFolder": "../../dist/cypress/apps/web-app-e2e/videos", - "screenshotsFolder": "../../dist/cypress/apps/web-app-e2e/screenshots", - "chromeWebSecurity": false -} diff --git a/apps/web-app-e2e/project.json b/apps/web-app-e2e/project.json deleted file mode 100644 index b83c0af27..000000000 --- a/apps/web-app-e2e/project.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "root": "apps/web-app-e2e", - "sourceRoot": "apps/web-app-e2e/src", - "projectType": "application", - "targets": { - "e2e": { - "executor": "@nrwl/cypress:cypress", - "options": { - "cypressConfig": "apps/web-app-e2e/cypress.json", - "devServerTarget": "web-app:serve" - }, - "configurations": { - "production": { - "devServerTarget": "web-app:serve:production" - } - } - }, - "lint": { - "executor": "@nrwl/linter:eslint", - "outputs": ["{options.outputFile}"], - "options": { - "lintFilePatterns": ["apps/web-app-e2e/**/*.{js,ts}"] - } - } - }, - "tags": [], - "implicitDependencies": ["web-app"] -} diff --git a/apps/web-app-e2e/src/fixtures/example.json b/apps/web-app-e2e/src/fixtures/example.json deleted file mode 100644 index 294cbed6c..000000000 --- a/apps/web-app-e2e/src/fixtures/example.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "Using fixtures to represent data", - "email": "hello@cypress.io" -} diff --git a/apps/web-app-e2e/src/integration/app.spec.ts b/apps/web-app-e2e/src/integration/app.spec.ts deleted file mode 100644 index 8a979ffad..000000000 --- a/apps/web-app-e2e/src/integration/app.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { getGreeting } from '../support/app.po'; - -describe('web-app', () => { - beforeEach(() => cy.visit('/')); - - it('should display welcome message', () => { - // Custom command example, see `../support/commands.ts` file - cy.login('my-email@something.com', 'myPassword'); - - // Function helper example, see `../support/app.po.ts` file - getGreeting().contains('Welcome web-app'); - }); -}); diff --git a/apps/web-app-e2e/src/support/app.po.ts b/apps/web-app-e2e/src/support/app.po.ts deleted file mode 100644 index 329342469..000000000 --- a/apps/web-app-e2e/src/support/app.po.ts +++ /dev/null @@ -1 +0,0 @@ -export const getGreeting = () => cy.get('h1'); diff --git a/apps/web-app-e2e/src/support/commands.ts b/apps/web-app-e2e/src/support/commands.ts deleted file mode 100644 index 310f1fa0e..000000000 --- a/apps/web-app-e2e/src/support/commands.ts +++ /dev/null @@ -1,33 +0,0 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** - -// eslint-disable-next-line @typescript-eslint/no-namespace -declare namespace Cypress { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface Chainable { - login(email: string, password: string): void; - } -} -// -// -- This is a parent command -- -Cypress.Commands.add('login', (email, password) => { - console.log('Custom command example: Login', email, password); -}); -// -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) diff --git a/apps/web-app-e2e/src/support/index.ts b/apps/web-app-e2e/src/support/index.ts deleted file mode 100644 index 3d469a6b6..000000000 --- a/apps/web-app-e2e/src/support/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -// *********************************************************** -// This example support/index.js is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import './commands'; diff --git a/apps/web-app-e2e/tsconfig.json b/apps/web-app-e2e/tsconfig.json deleted file mode 100644 index c4f818ecd..000000000 --- a/apps/web-app-e2e/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "sourceMap": false, - "outDir": "../../dist/out-tsc", - "allowJs": true, - "types": ["cypress", "node"] - }, - "include": ["src/**/*.ts", "src/**/*.js"] -} diff --git a/apps/web-app/.eslintrc.json b/apps/web-app/.eslintrc.json deleted file mode 100644 index 94982546d..000000000 --- a/apps/web-app/.eslintrc.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "extends": [ - "plugin:@nrwl/nx/react-typescript", - "../../.eslintrc.json", - "next", - "next/core-web-vitals" - ], - "ignorePatterns": ["!**/*"], - "overrides": [ - { - "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], - "rules": { - "@next/next/no-html-link-for-pages": ["error", "apps/web-app/pages"] - } - }, - { - "files": ["*.ts", "*.tsx"], - "rules": {} - }, - { - "files": ["*.js", "*.jsx"], - "rules": {} - } - ], - "env": { - "jest": true - } -} diff --git a/apps/web-app/README.md b/apps/web-app/README.md deleted file mode 100644 index 3e1a74e1b..000000000 --- a/apps/web-app/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Web App - -## Run development server - -Run `nx serve web-app` for a dev server. Navigate to [localhost:4200](http://localhost:4200). The app will automatically reload if you change any of the source files. - -## Build the app - -Run `nx build web-app` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build. - -## Generate sitemap & robots.txt - -Run `nx run web-app:postbuild` after building the project to generate a sitemap of the app along with a `robots.txt` file. The resulting `sitemap.xml` and `robots.txt` files will be stored in the `dist/apps/web-app/public` directory. - -## Run unit tests - -Run `nx test web-app` to execute the unit tests via [Jest](https://jestjs.io). - -Run `nx affected:test` to execute the unit tests affected by a change. - -## Run end-to-end tests - -Run `nx e2e web-app` to execute the end-to-end tests via [Cypress](https://www.cypress.io). - -Run `nx affected:e2e` to execute the end-to-end tests affected by a change. - -## Scaffold a new component - -Run `nx g @nrwl/react:component my-component --project=web-app` to generate a new component. diff --git a/apps/web-app/analytics/auth.analytics.ts b/apps/web-app/analytics/auth.analytics.ts deleted file mode 100644 index 3eaac6ff4..000000000 --- a/apps/web-app/analytics/auth.analytics.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { usePostHog } from 'posthog-js/react' -import Cookies from 'js-cookie' -function useAuthAnalytics() { - const events = { - AUTH_LOGIN_BTN_CLICKED: 'AUTH_LOGIN_BTN_CLICKED', - AUTH_PROCEED_TO_LOGIN_CLICKED: 'AUTH_PROCEED_TO_LOGIN_CLICKED', - AUTH_INFO_POPUP_CLOSED: 'AUTH_INFO_POPUP_CLOSED', - AUTH_PRIVY_LOGIN_SUCCESS: 'AUTH_PRIVY_LOGIN_SUCCESS', - AUTH_DIRECTORY_LOGIN_INIT: 'AUTH_DIRECTORY_LOGIN_INIT', - AUTH_DIRECTORY_LOGIN_SUCCESS: 'AUTH_DIRECTORY_LOGIN_SUCCESS', - AUTH_DIRECTORY_LOGIN_FAILURE: 'AUTH_DIRECTORY_LOGIN_FAILURE', - AUTH_PRIVY_LINK_SUCCESS: 'AUTH_PRIVY_LINK_SUCCESS', - AUTH_PRIVY_UNLINK_EMAIL: 'AUTH_PRIVY_UNLINK_EMAIL', - AUTH_PRIVY_DELETE_USER: 'AUTH_PRIVY_DELETE_USER', - AUTH_PRIVY_LINK_ERROR: 'AUTH_PRIVY_LINK_ERROR', - AUTH_SETTINGS_PRIVY_ACCOUNT_LINK: 'AUTH_SETTINGS_PRIVY_ACCOUNT_LINK', - AUTH_SETTINGS_EMAIL_UPDATE_SUCCESS:'AUTH_SETTINGS_EMAIL_UPDATE_SUCCESS', - AUTH_SETTINGS_EMAIL_UPDATE_FAILED:'AUTH_SETTINGS_EMAIL_UPDATE_FAILED', - AUTH_SETTINGS_EMAIL_UPDATE_SAME_AS_OLD:"AUTH_SETTINGS_EMAIL_UPDATE_SAME_AS_OLD" - - } - const postHogProps = usePostHog(); - const getUserInfo = () => { - try { - let userInfo; - if (typeof window !== 'undefined') { - const rawUserInfo = Cookies.get('userInfo'); - if(rawUserInfo) { - userInfo = JSON.parse(rawUserInfo); - } - } - return userInfo; - } catch (e) { - console.error(e) - return null; - } - } - const captureEvent = (eventName, eventParams = {}) => { - try { - - if (postHogProps?.capture) { - const userInfo = getUserInfo() - const userName = userInfo?.name; - const userUid = userInfo?.uid; - const userEmail = userInfo?.email; - const distinct_id = userInfo?.uid - const allParams = {...eventParams, ...(userName && userUid && userEmail && distinct_id && {userName, userUid, userEmail, distinct_id}) } - postHogProps.capture(eventName, { ...allParams }) - } - } catch (e) { - console.error(e) - } - } - - const onLoginBtnClicked = () => { - captureEvent(events.AUTH_LOGIN_BTN_CLICKED) - } - - const onProceedToLogin = () => { - captureEvent(events.AUTH_PROCEED_TO_LOGIN_CLICKED) - } - - const onAuthInfoClosed = () => { - captureEvent(events.AUTH_INFO_POPUP_CLOSED) - } - - const onPrivyLoginSuccess = (privyUser) => { - captureEvent(events.AUTH_PRIVY_LOGIN_SUCCESS, {...privyUser}) - } - - const onDirectoryLoginInit = (privyUser) => { - captureEvent(events.AUTH_DIRECTORY_LOGIN_INIT, {...privyUser}) - } - - const onDirectoryLoginSuccess = () => { - captureEvent(events.AUTH_DIRECTORY_LOGIN_SUCCESS) - } - - const onDirectoryLoginFailure = (privyUser) => { - captureEvent(events.AUTH_DIRECTORY_LOGIN_FAILURE, {...privyUser}) - } - - const onPrivyUnlinkEmail = (privyUser) => { - captureEvent(events.AUTH_PRIVY_UNLINK_EMAIL, {...privyUser}) - } - - const onPrivyUserDelete = (privyUser) => { - captureEvent(events.AUTH_PRIVY_DELETE_USER, {...privyUser}) - } - - const onPrivyLinkSuccess = (privyUser) => { - captureEvent(events.AUTH_PRIVY_LINK_SUCCESS, {...privyUser}) - } - - const onAccountLinkError = (privyUser) => { - captureEvent(events.AUTH_PRIVY_LINK_ERROR, {...privyUser}) - } - - const onPrivyAccountLink = (privyUser) => { - captureEvent(events.AUTH_SETTINGS_PRIVY_ACCOUNT_LINK, {...privyUser}) - } - - const onUpdateEmailSuccess = (privyUser)=> { - captureEvent(events.AUTH_SETTINGS_EMAIL_UPDATE_SUCCESS, {...privyUser}) - } - - const onUpdateEmailFailure = (privyUser)=> { - captureEvent(events.AUTH_SETTINGS_EMAIL_UPDATE_FAILED, {...privyUser}) - } - - const onUpdateSameEmailProvided = (privyUser)=> { - captureEvent(events.AUTH_SETTINGS_EMAIL_UPDATE_SAME_AS_OLD, {...privyUser}) - } - - return { onLoginBtnClicked, onProceedToLogin, onAuthInfoClosed, onPrivyLinkSuccess, onPrivyUnlinkEmail, onPrivyUserDelete, onPrivyLoginSuccess, onDirectoryLoginInit, onDirectoryLoginSuccess, onDirectoryLoginFailure, onAccountLinkError, onPrivyAccountLink, onUpdateEmailSuccess, onUpdateEmailFailure, onUpdateSameEmailProvided } -} - -export default useAuthAnalytics; \ No newline at end of file diff --git a/apps/web-app/components/auth/auth-box.tsx b/apps/web-app/components/auth/auth-box.tsx deleted file mode 100644 index e57e7585c..000000000 --- a/apps/web-app/components/auth/auth-box.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useRouter } from 'next/router'; -import useLoginPopupStatus from '../../hooks/auth/useLoginPopupStatus'; -import axios from 'axios'; -import { generateOAuth2State } from '../../utils/services/auth'; -import PrivyModals from './privy-modals'; -import AuthInfo from './auth-info'; -import AuthInvalidUser from './auth-invalid-user'; -import { PrivyProvider } from '@privy-io/react-auth'; -import { AuthErrorModal } from './auth-info-modal'; - -function AuthBox(props) { - const { isLoginActive } = useLoginPopupStatus(); - - return ( - <> - - - - - {isLoginActive === true && } - - - ); -} - -export default AuthBox; diff --git a/apps/web-app/components/auth/auth-info-modal.tsx b/apps/web-app/components/auth/auth-info-modal.tsx deleted file mode 100644 index b69efe399..000000000 --- a/apps/web-app/components/auth/auth-info-modal.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { generateOAuth2State } from 'apps/web-app/utils/services/auth'; -import axios from 'axios'; -import { useEffect, useState } from 'react'; - -export function AuthErrorModal() { - const [isOpen, setIsModalOpen] = useState(false); - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const onModalClose = () => { - document.dispatchEvent(new CustomEvent('app-loader-status')) - setIsModalOpen(false); - }; - - const onLogin = async () => { - const result = await axios.post(`${process.env.WEB_API_BASE_URL}/v1/auth`, { - state: generateOAuth2State(), - }); - localStorage.setItem('stateUid', result.data); - document.dispatchEvent(new CustomEvent('auth-link-account', {detail: 'updateEmail'})); - setIsModalOpen(false) - } - - useEffect(() => { - function handleInfo(e) { - if(e.detail === 'email_changed'){ - setTitle('Email Update Required'); - setDescription('Your email has been changed recently in our system. Please update the email associated with the social account (Google/GitHub/Wallet) you are using to log in.'); - setIsModalOpen(true) - } - } - document.addEventListener('auth-info-modal',handleInfo ) - return function() { - document.removeEventListener('auth-info-modal',handleInfo ) - } - }, []) - return ( - <> - {isOpen &&
-
-
-

{title}

-

{description}

-
- -
- - -
-
-
} - - - ); -} diff --git a/apps/web-app/components/auth/auth-info.tsx b/apps/web-app/components/auth/auth-info.tsx deleted file mode 100644 index 1bf9a48d3..000000000 --- a/apps/web-app/components/auth/auth-info.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { generateOAuth2State } from '../../utils/services/auth'; -import axios from 'axios'; -import { useRouter } from 'next/router'; -import { useEffect } from 'react'; -import Cookies from 'js-cookie'; -import usePrivyWrapper from '../../hooks/auth/usePrivyWrapper'; -import useAuthAnalytics from 'apps/web-app/analytics/auth.analytics'; - -function AuthInfo(props) { - const router = useRouter(); - const { logout } = usePrivyWrapper(); - const analytics = useAuthAnalytics(); - const loginBanner = process.env.LOGIN_BANNER_URL; - - // Reset Url - const onClose = () => { - analytics.onAuthInfoClosed() - const queryString = window.location.search.substring(1); - const params = new URLSearchParams(queryString); - let queryParams = `?`; - params.forEach((value, key) => { - if (!key.includes('privy_')) { - queryParams = `${queryParams}${queryParams === '?' ? '' : '&'}${key}=${value}`; - } - }); - router.push(`${window.location.pathname}${queryParams === '?' ? '' : queryParams}`); - }; - - // Initiate Privy Login and get the auth code for state - const onLogin = async () => { - analytics.onProceedToLogin() - localStorage.clear(); - await logout(); - const result = await axios.post(`${process.env.WEB_API_BASE_URL}/v1/auth`, { - state: generateOAuth2State(), - }); - localStorage.setItem('stateUid', result.data); - document.dispatchEvent(new CustomEvent('privy-init-login')); - router.push(`${window.location.pathname}${window.location.search}`); - }; - - useEffect(() => { - if (Cookies.get('refreshToken')) { - router.push(`${window.location.pathname}${window.location.search}`); - } - }, []); - - return ( - <> -
-
-
-
- -

New Authentication Method

-

- We've updated our authentication experience. You will need to login with your Directory profile email or link it to a login method of your choice. If you don't remember which email is tied to your Directory profile, please{' '} - - contact us here - {' '} - for assistance. -

- -
- - login banner -
-
- - -
-
-
- - - ); -} - -export default AuthInfo; diff --git a/apps/web-app/components/auth/auth-invalid-user.tsx b/apps/web-app/components/auth/auth-invalid-user.tsx deleted file mode 100644 index 0a3beb6f3..000000000 --- a/apps/web-app/components/auth/auth-invalid-user.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useEffect, useState } from "react" -import { VerifyEmailModal } from "../layout/navbar/login-menu/verify-email-modal" - -function AuthInvalidUser() { - const [isOpen, setIsModalOpen] = useState(false); - const [title, setTitle] = useState('Email Verification Failed'); - const [description, setDescription] = useState(''); - - const handleModalClose = () => { - document.dispatchEvent(new CustomEvent('app-loader-status')) - setIsModalOpen(false); - setTimeout(()=>{ - setTitle("Email Verification Failed"); - setDescription(''); - },500) - }; - - useEffect(() => { - function handleInvalidEmail(e) { - if(e?.detail) { - if(e.detail === "linked_to_another_user") { - setTitle('Email Verification') - setDescription('The email you provided is already used or linked to another account. If this is your email id, then login with the email id and connect this social account in profile settings page. After that you can use any of your linked accounts for subsequent logins.') - } else if(e.detail === 'unexpected_error') { - setTitle('Something went wrong') - setDescription('We are unable to authenticate you at the moment due to technical issues. Please try again later') - } else if (e.detail === 'email-changed') { - setTitle('Email Changed recently') - setDescription('Your email in our directory has been changed recently. Please login with your updated email id.') - } - } - - setIsModalOpen(true) - } - document.addEventListener('auth-invalid-email',handleInvalidEmail ) - return function() { - document.removeEventListener('auth-invalid-email',handleInvalidEmail ) - } - }, []) - - - return <> - - - -} - -export default AuthInvalidUser \ No newline at end of file diff --git a/apps/web-app/components/auth/change-email-modal.tsx b/apps/web-app/components/auth/change-email-modal.tsx deleted file mode 100644 index f8431ed11..000000000 --- a/apps/web-app/components/auth/change-email-modal.tsx +++ /dev/null @@ -1,262 +0,0 @@ - -import { useEffect, useState } from "react" -import Cookies from 'js-cookie'; -import { resendEmailOtp } from "../../services/auth.service"; -import { LoadingIndicator } from "../shared/loading-indicator/loading-indicator"; -import EmailSubmissionForm from "./email-submission-form"; -import OtpSubmissionForm from "./otp-submission-form"; -import { calculateExpiry, decodeToken } from "../../utils/services/auth"; -import ErrorBox from "./error-box"; -import { APP_ANALYTICS_EVENTS, EMAIL_OTP_CONSTANTS } from "../../constants"; -import useAppAnalytics from "../../hooks/shared/use-app-analytics"; -import { sendOtpToChangeEmail, verifyAndProcessEmailChange } from "../../services/member.service"; -function ChangeEmailModal(props) { - // States - const [verificationStep, setVerificationStep] = useState(1) - const [isLoaderActive, setLoaderStatus] = useState(false); - const [errorMessage, setErrorMessage] = useState(''); - const [resendInSeconds, setResendInSeconds] = useState(30); - const [memberUid, setMemberUid] = useState(null) - const analytics = useAppAnalytics() - - // Variables - const onClose = props.onClose - const textConstants = EMAIL_OTP_CONSTANTS['CHANGE_EMAIL']; - - const setNewTokensAndUserInfo = (allData) => { - const { refreshToken, accessToken, userInfo} = allData; - if (refreshToken && accessToken) { - const accessTokenExpiry = decodeToken(accessToken); - const refreshTokenExpiry = decodeToken(refreshToken); - Cookies.set('authToken', JSON.stringify(accessToken), { - expires: calculateExpiry(new Date(accessTokenExpiry.exp)), - domain: process.env.COOKIE_DOMAIN || '' - }); - Cookies.set('refreshToken', JSON.stringify(refreshToken), { - expires: calculateExpiry(new Date(refreshTokenExpiry.exp)), - domain: process.env.COOKIE_DOMAIN || '' - }); - Cookies.set('userInfo', JSON.stringify(userInfo), { - expires: calculateExpiry(new Date(accessTokenExpiry.exp)), - domain: process.env.COOKIE_DOMAIN || '' - }); - } - } - - - const onOtpVerify = async (otp) => { - try { - setErrorMessage('') - const otpToken = Cookies.get('uniqueEmailVerifyToken'); - const accessToken = Cookies.get('authToken'); - - if (!accessToken) { - goToError('Invalid attempt. Please login and try again'); - return; - } - - if(!otpToken) { - goToError("OTP Session expired. Please login and try again"); - return; - } - const otpPayload = { - otp: otp.join(''), - otpToken: otpToken, - } - - setLoaderStatus(true) - const header = {headers: {Authorization: `Bearer ${JSON.parse(accessToken)}`}} - analytics.captureEvent(APP_ANALYTICS_EVENTS.SETTINGS_USER_CHANGE_EMAIL_VERIFY_OTP, {}) - const data = await verifyAndProcessEmailChange(otpPayload, memberUid, header) - setLoaderStatus(false) - if (data?.userInfo) { - setNewTokensAndUserInfo(data) - clearAllOtpSessionVaribles() - analytics.captureEvent(APP_ANALYTICS_EVENTS.SETTINGS_USER_CHANGE_EMAIL_SUCCESS, {}) - onClose(null); - Cookies.set('page_params', 'email_changed', { expires: 60, path: '/' }); - window.location.reload() - } else if (!data?.valid) { - setResendInSeconds(30); - setErrorMessage('Invalid OTP. Please enter valid OTP sent to your email or try resending OTP.') - } - } catch (e) { - setLoaderStatus(false) - handleServerErrors(e?.response?.status, e?.response?.data?.message) - } - } - - - - const onResendOtp = async () => { - setErrorMessage('') - const otpToken = Cookies.get('uniqueEmailVerifyToken'); - const accessToken = Cookies.get('authToken'); - if (!accessToken) { - goToError('Invalid attempt. Please login and try again'); - return; - } - - if(!otpToken) { - goToError("Otp Session expired. Please login and try again"); - return; - } - - try { - setLoaderStatus(true) - const otpPayload = {otpToken} - const header = {headers: {Authorization: `Bearer ${JSON.parse(accessToken)}`}} - analytics.captureEvent(APP_ANALYTICS_EVENTS.SETTINGS_USER_CHANGE_EMAIL_RESEND_OTP, {}) - const d = await resendEmailOtp(otpPayload, header); - setLoaderStatus(false) - - // Reset resend timer and set unique token for verification - const uniqueEmailVerifyToken = d.token; - Cookies.set('uniqueEmailVerifyToken', uniqueEmailVerifyToken, { expires: new Date(new Date().getTime() + 20 * 60 * 1000) }) - localStorage.setItem('resend-expiry', `${new Date(d.resendIn).getTime()}`) - // setResendTimer() - setResendInSeconds(30) - } catch (e) { - setLoaderStatus(false) - handleServerErrors(e?.response?.status, e?.response?.data?.message) - } - - } - - - - const onEmailSubmitted = async (email) => { - try { - const accessToken = Cookies.get('authToken'); - const otpPayload = { newEmail: email } - const header = {headers: {Authorization: `Bearer ${JSON.parse(accessToken)}`}} - setErrorMessage('') - setLoaderStatus(true) - analytics.captureEvent(APP_ANALYTICS_EVENTS.SETTINGS_USER_CHANGE_EMAIL_SEND_OTP, {}) - const d = await sendOtpToChangeEmail(otpPayload, memberUid, header); - setLoaderStatus(false) - const uniqueEmailVerifyToken = d.token; - Cookies.set('uniqueEmailVerifyToken', uniqueEmailVerifyToken, { expires: new Date(new Date().getTime() + 20 * 60 * 1000) }) - localStorage.setItem('otp-verification-email', email); - localStorage.setItem('resend-expiry', `${new Date(d.resendIn).getTime()}`) - setVerificationStep(2); - //setResendTimer(); - setResendInSeconds(30) - - } catch (error) { - console.error(error) - setLoaderStatus(false) - handleServerErrors(error?.response?.status, error?.response?.data?.message) - } - } - - const handleServerErrors = (statusCode, messageCode) => { - if(statusCode === 401 || statusCode === 403) { - if(messageCode === "MAX_OTP_ATTEMPTS_REACHED") { - goToError("Maximum OTP attempts exceeded. Please login again and try") - } else if(messageCode === "MAX_RESEND_ATTEMPTS_REACHED") { - goToError("Maximum OTP resend attempts exceeded. Please login again and try") - } else if(messageCode) { - goToError(messageCode) - } else { - goToError("Invalid Request. Please try again or contact support") - } - } else if (statusCode === 400) { - if(messageCode === "CODE_EXPIRED") { - setErrorMessage("OTP expired. Please request for new OTP and try again") - } else if(messageCode) { - setErrorMessage(messageCode) - } else { - setErrorMessage("Invalid Request. Please try again or contact support") - } - } else { - setErrorMessage("Unexpected error. please try again or contact support") - } - } - - - const onCloseDialog = () => { - clearAllOtpSessionVaribles() - - if(onClose) { - onClose(verificationStep) - } - } - - const clearAllAuthCookies = () => { - Cookies.remove('idToken') - Cookies.remove('authToken', { path: '/', domain: process.env.COOKIE_DOMAIN || '' }); - Cookies.remove('refreshToken', { path: '/', domain: process.env.COOKIE_DOMAIN || ''}); - Cookies.remove('userInfo', { path: '/', domain: process.env.COOKIE_DOMAIN || '' }); - } - - const goToError = (errorMessage) => { - clearAllOtpSessionVaribles() - setErrorMessage(errorMessage); - clearAllAuthCookies() - setVerificationStep(3); - } - - const setResendTimer = () => { - if (localStorage.getItem('resend-expiry')) { - const resendExpiry = localStorage.getItem('resend-expiry'); - const resendRemainingSeconds = Math.round((Number(resendExpiry) - new Date().getTime()) / 1000) - setResendInSeconds(resendRemainingSeconds); - } - } - - const clearAllOtpSessionVaribles = () => { - Cookies.remove('clientAccessToken') - Cookies.remove('uniqueEmailVerifyToken') - localStorage.removeItem('resend-expiry'); - localStorage.removeItem('otp-verification-email'); - } - - useEffect(() => { - let countdown; - if (resendInSeconds > 0) { - countdown = setInterval(() => { - setResendInSeconds((prevTimer) => prevTimer - 1); - }, 1000); - } - return () => clearInterval(countdown); - - }, [resendInSeconds]); - - useEffect(() => { - const userInfoFromCookie = Cookies.get('userInfo'); - if (userInfoFromCookie) { - const parsedUserInfo = JSON.parse(userInfoFromCookie); - setMemberUid(parsedUserInfo.uid) - } - }, []) - - - return <> - -
-
-
- {verificationStep === 1 && } - {verificationStep === 2 && } - {verificationStep === 3 && } - {isLoaderActive &&
} -
-
-
- - - -} - -export default ChangeEmailModal \ No newline at end of file diff --git a/apps/web-app/components/auth/email-otp-verification-modal.tsx b/apps/web-app/components/auth/email-otp-verification-modal.tsx deleted file mode 100644 index 55dee9396..000000000 --- a/apps/web-app/components/auth/email-otp-verification-modal.tsx +++ /dev/null @@ -1,288 +0,0 @@ - -import { useEffect, useState } from "react" -import Cookies from 'js-cookie'; -import { resendEmailOtp, sendEmailOtp, verifyEmailOtp } from "../../services/auth.service"; -import { LoadingIndicator } from "../shared/loading-indicator/loading-indicator"; -import EmailSubmissionForm from "./email-submission-form"; -import OtpSubmissionForm from "./otp-submission-form"; -import { calculateExpiry, decodeToken } from "../../utils/services/auth"; -import ErrorBox from "./error-box"; -import { toast } from "react-toastify"; -import { ReactComponent as SuccessIcon } from '../../public/assets/images/icons/success.svg'; -import { APP_ANALYTICS_EVENTS, EMAIL_OTP_CONSTANTS, PAGE_ROUTES } from "../../constants"; -import useAppAnalytics from "../../hooks/shared/use-app-analytics"; -function EmailOtpVerificationModal() { - // States - const [showDialog, setDialogStatus] = useState(false); - const [verificationStep, setVerificationStep] = useState(1) - const [isLoaderActive, setLoaderStatus] = useState(false); - const [errorMessage, setErrorMessage] = useState(''); - const [resendInSeconds, setResendInSeconds] = useState(30); - const analytics = useAppAnalytics() - - - const setNewTokensAndUserInfo = (allData) => { - const { refreshToken, accessToken, userInfo } = allData; - if (refreshToken && accessToken) { - const accessTokenExpiry = decodeToken(accessToken); - const refreshTokenExpiry = decodeToken(refreshToken); - Cookies.set('authToken', JSON.stringify(accessToken), { - expires: calculateExpiry(new Date(accessTokenExpiry.exp)), - domain: process.env.COOKIE_DOMAIN || '' - }); - Cookies.set('refreshToken', JSON.stringify(refreshToken), { - expires: calculateExpiry(new Date(refreshTokenExpiry.exp)), - domain: process.env.COOKIE_DOMAIN || '' - }); - Cookies.set('userInfo', JSON.stringify(userInfo), { - expires: calculateExpiry(new Date(accessTokenExpiry.exp)), - domain: process.env.COOKIE_DOMAIN || '' - }); - } - } - - - const onOtpVerify = async (otp) => { - try { - const authToken = Cookies.get('authToken'); - const otpToken = Cookies.get('uniqueEmailVerifyToken'); - const idToken = Cookies.get('idToken'); - if (!authToken || !otpToken || !idToken) { - goToError('Invalid attempt. Please login and try again'); - return; - } - setErrorMessage('') - const otpPayload = { otp: otp.join(''), otpToken, idToken} - const header = { headers: {Authorization: `Bearer ${authToken}`}} - setLoaderStatus(true) - analytics.captureEvent(APP_ANALYTICS_EVENTS.USER_VERIFICATION_VERIFY_OTP, {}) - const data = await verifyEmailOtp(otpPayload, header) - setLoaderStatus(false) - if (data?.userInfo) { - const externalRedirectUrl = Cookies.get('external_redirect_url'); - setNewTokensAndUserInfo(data); - clearAllOtpSessionVaribles(); - analytics.captureEvent(APP_ANALYTICS_EVENTS.USER_VERIFICATION_SUCCESS, {}) - setDialogStatus(false); - localStorage.removeItem('otp-verification-email'); - localStorage.setItem('otp-verify', 'success'); - if (externalRedirectUrl && externalRedirectUrl != "undefined") { - window.location.href = externalRedirectUrl; - } else if (data?.userInfo?.isFirstTimeLogin) { - window.location.href = PAGE_ROUTES.SETTINGS; - } else { - window.location.reload(); - } - } else if (!data?.valid) { - setResendInSeconds(30); - setErrorMessage('Invalid OTP. Please enter valid OTP sent to your email or try resending OTP.') - } - } catch (e) { - setLoaderStatus(false) - handleServerErrors(e?.response?.status, e?.response?.data?.message) - } - } - - - - const onResendOtp = async () => { - setErrorMessage('') - const otpToken = Cookies.get('uniqueEmailVerifyToken'); - const email = localStorage.getItem('otp-verification-email'); - const authToken = Cookies.get('authToken'); - if (!authToken || !email || !otpToken) { - goToError('Invalid attempt. Please login and try again'); - return; - } - - try { - setLoaderStatus(true) - const otpPayload = { email, otpToken } - const header = { headers: {Authorization: `Bearer ${authToken}`}} - - analytics.captureEvent(APP_ANALYTICS_EVENTS.USER_VERIFICATION_RESEND_OTP, {}) - const d = await resendEmailOtp(otpPayload, header); - setLoaderStatus(false) - - // Reset resend timer and set unique token for verification - const uniqueEmailVerifyToken = d.token; - Cookies.set('uniqueEmailVerifyToken', uniqueEmailVerifyToken, { expires: new Date(new Date().getTime() + 20 * 60 * 1000) }) - localStorage.setItem('resend-expiry', `${new Date(d.resendIn).getTime()}`) - - //setResendTimer() - setResendInSeconds(30) - - } catch (e) { - setLoaderStatus(false) - handleServerErrors(e?.response?.status, e?.response?.data?.message) - } - - } - - - - const onEmailSubmitted = async (email) => { - try { - - // Validation - const authToken = Cookies.get('authToken'); - if (!authToken) { - goToError('Invalid attempt. Please login and try again'); - return; - } - - // Initiate API - setErrorMessage('') - setLoaderStatus(true) - const otpPayload = { email } - const header = {headers: {Authorization: `Bearer ${authToken}`}} - analytics.captureEvent(APP_ANALYTICS_EVENTS.USER_VERIFICATION_SEND_OTP, {email}) - const d = await sendEmailOtp(otpPayload, header); - - // Handle Success - setLoaderStatus(false) - const uniqueEmailVerifyToken = d.token; - Cookies.set('uniqueEmailVerifyToken', uniqueEmailVerifyToken, { expires: new Date(new Date().getTime() + 20 * 60 * 1000) }) - localStorage.setItem('otp-verification-email', email); - localStorage.setItem('otp-verification-step', '2'); - localStorage.setItem('resend-expiry', `${new Date(d.resendIn).getTime()}`) - setVerificationStep(2); - setResendInSeconds(30) - - } catch (error) { - console.error(error) - setLoaderStatus(false) - handleServerErrors(error?.response?.status, error?.response?.data?.message) - } - } - - const handleServerErrors = (statusCode, messageCode) => { - if(statusCode === 401 || statusCode === 403) { - if(messageCode === "MAX_OTP_ATTEMPTS_REACHED") { - goToError("Maximum OTP attempts exceeded. Please login again and try") - } else if(messageCode === "MAX_RESEND_ATTEMPTS_REACHED") { - goToError("Maximum OTP resend attempts exceeded. Please login again and try") - } else if(messageCode === 'ACCOUNT_ALREADY_LINKED') { - goToError("Account is already linked to another email.") - } else if(messageCode) { - goToError(messageCode) - } else { - goToError("Invalid Request. Please try again or contact support") - } - } else if (statusCode === 400) { - if(messageCode === "CODE_EXPIRED") { - setErrorMessage("OTP expired. Please request for new OTP and try again") - } - else if(messageCode) { - setErrorMessage(messageCode) - } else { - setErrorMessage("Invalid Request. Please try again or contact support") - } - } else { - setErrorMessage("Unexpected error. please try again or contact support") - } - } - - const onCloseDialog = () => { - clearAllAuthCookies() - clearAllOtpSessionVaribles() - setDialogStatus(false) - } - - const goToError = (errorMessage) => { - clearAllAuthCookies() - clearAllOtpSessionVaribles() - setErrorMessage(errorMessage); - setVerificationStep(3); - } - - const setResendTimer = () => { - if (localStorage.getItem('resend-expiry')) { - const resendExpiry = localStorage.getItem('resend-expiry'); - const resendRemainingSeconds = Math.round((Number(resendExpiry) - new Date().getTime()) / 1000) - setResendInSeconds(resendRemainingSeconds); - } - } - - const clearAllOtpSessionVaribles = () => { - Cookies.remove('clientToken'); - Cookies.remove('uniqueEmailVerifyToken'); - Cookies.remove('show-email-verification-box'); - Cookies.remove('external_redirect_url'); - localStorage.removeItem('resend-expiry'); - localStorage.removeItem('otp-verification-step'); - } - - const clearAllAuthCookies = () => { - Cookies.remove('idToken') - Cookies.remove('authToken', { path: '/', domain: process.env.COOKIE_DOMAIN || '' }); - Cookies.remove('refreshToken', { path: '/', domain: process.env.COOKIE_DOMAIN || ''}); - Cookies.remove('userInfo', { path: '/', domain: process.env.COOKIE_DOMAIN || '' }); - } - - useEffect(() => { - const verifyBox = Cookies.get('show-email-verification-box'); - const otpVerify = localStorage.getItem('otp-verify') - if (verifyBox) { - if (localStorage.getItem('otp-verification-step') === '2') { - setVerificationStep(2) - //setResendTimer() - setResendInSeconds(30) - } - Cookies.remove('show-email-verification-box') - analytics.captureEvent(APP_ANALYTICS_EVENTS.USER_VERIFICATION_INIT, {}) - setDialogStatus(true) - } else { - clearAllOtpSessionVaribles(); - const userInfo = Cookies.get('userInfo'); - if(!userInfo) { - clearAllAuthCookies(); - } - } - - if (otpVerify) { - toast.success("Your account has been verified", { - icon: - }); - localStorage.removeItem('otp-verify') - } - }, []) - - useEffect(() => { - let countdown; - if (resendInSeconds > 0) { - countdown = setInterval(() => { - setResendInSeconds((prevTimer) => prevTimer - 1); - }, 1000); - } - return () => clearInterval(countdown); - }, [resendInSeconds]); - - return <> - - {showDialog &&
-
-
- {verificationStep === 1 && } - {verificationStep === 2 && } - {verificationStep === 3 && } - {isLoaderActive &&
} -
-
-
} - - - -} - -export default EmailOtpVerificationModal diff --git a/apps/web-app/components/auth/email-submission-form.tsx b/apps/web-app/components/auth/email-submission-form.tsx deleted file mode 100644 index df76708e1..000000000 --- a/apps/web-app/components/auth/email-submission-form.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -function EmailSubmissionForm(props) { - // Props variables - const title = props.title || ''; - const desc= props.desc || ''; - const onSendOtp = props.onSendOtp; - const onClose = props.onClose; - const validationError = props.validationError || ''; - - - // State & ref variables - const [errorMessage, setErrorMessage] = useState('') - const inputRef: any = useRef() - - const onInputChange = () => { - if(errorMessage !== '') { - setErrorMessage(''); - } - } - - const onEmailSubmitted = async (e) => { - e.preventDefault(); - const emailValue = inputRef.current.value.trim(); - const emailRegex = /^(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})$/; - if(!emailRegex.test(emailValue)) { - setErrorMessage('Please enter valid email') - return - } - if(onSendOtp) { - await onSendOtp(emailValue) - } - } - - const onCloseIconClicked = () => { - if(onClose) { - onClose() - } - } - - useEffect(() => { - console.log(validationError) - setErrorMessage(validationError) - }, [validationError]) - - - return <> -
- {/*** Close Icon ***/} -
- close -
- - {/*** Header ***/} -
-

{title}

-

{desc}

-
- - {/*** Body ***/} -
- - {errorMessage &&

{errorMessage}

} -
- -
-
-
- - -} - -export default EmailSubmissionForm \ No newline at end of file diff --git a/apps/web-app/components/auth/error-box.tsx b/apps/web-app/components/auth/error-box.tsx deleted file mode 100644 index 9a129176d..000000000 --- a/apps/web-app/components/auth/error-box.tsx +++ /dev/null @@ -1,41 +0,0 @@ -function ErrorBox(props) { - const desc = props.desc; - const onClose = props.onClose; - - const onCloseClicked = () => { - if(onClose) { - onClose() - } - } - return <> -
-
- -

Something went wrong

-
-
-

{desc}

-
- -
-
-
- - -} - -export default ErrorBox \ No newline at end of file diff --git a/apps/web-app/components/auth/otp-submission-form.tsx b/apps/web-app/components/auth/otp-submission-form.tsx deleted file mode 100644 index 3549e4b24..000000000 --- a/apps/web-app/components/auth/otp-submission-form.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -function OtpSubmissionForm(props) { - //Props Variables - const onClose = props.onClose - const onVerifyOtp = props.onVerifyOtp - const onResendOtp = props.onResendOtp - const resendInSeconds = props.resendInSeconds - const validationError = props.validationError; - const title = props.title || ''; - const desc = props.desc || ''; - const showResend = resendInSeconds > 0 ? false : true - - // State variables - const [otp, setOtp] = useState(['', '', '', '', '', '']); - const inputRefs = useRef([]); - const [errorMessage, setErrorMessage] = useState(''); - - const onOtpSubmit = async (e) => { - e.preventDefault(); - if (isOtpValid()) { - if (onVerifyOtp) { - await onVerifyOtp(otp) - } - } else { - setErrorMessage('Please enter valid 6 digits OTP'); - } - } - - - const onCloseIconClicked = () => { - if (onClose) { - onClose() - } - } - - const handleInputChange = (index, value) => { - if (!isNaN(value)) { - const newOtp = [...otp]; - newOtp[index] = value; - setOtp(newOtp); - setErrorMessage(''); - if (value !== '' && index < 5) { - inputRefs.current[index + 1].focus(); - } - } - }; - - const handleKeyDown = (index, e) => { - if (e.key === 'Backspace') { - if (index === 0 && otp[index] === '') { - return; // Prevent clearing the first OTP box - } - - const newOtp = [...otp]; - newOtp[index] = ''; - setOtp(newOtp); - setErrorMessage(''); - - if (index > 0 && otp[index] === '') { - inputRefs.current[index - 1].focus(); - } - } else if (e.key === 'ArrowRight' && otp[index] !== '') { - if (index < 5) { - inputRefs.current[index + 1].focus(); - inputRefs.current[index + 1].setSelectionRange(0, 0); - } - } else if (e.key === 'ArrowLeft' && otp[index] !== '') { - if (index > 0) { - inputRefs.current[index - 1].focus(); - inputRefs.current[index - 1].setSelectionRange(0, 0); - } - } - }; - - const handlePaste = (e) => { - const pastedText = e.clipboardData.getData('text/plain').trim(); - if (/^[0-9]+$/.test(pastedText) && pastedText.length === 6) { - const newOtp = pastedText.split(''); - setOtp(newOtp); - setErrorMessage(''); - } else { - setErrorMessage('Please enter valid 6 digits OTP'); - } - }; - - const isOtpValid = () => { - return otp.join('').length === 6; - }; - - const handleResendClick = async (e) => { - e.preventDefault() - // Set timer to 60 seconds for example, adjust as needed - if (onResendOtp) { - setErrorMessage('') - setOtp(['', '', '', '', '', '']) - await onResendOtp() - } - }; - - const formatTime = (seconds) => { - if (seconds > 0) { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - - const formattedMinutes = String(minutes).padStart(2, '0'); - const formattedSeconds = String(remainingSeconds).padStart(2, '0'); - - return `${formattedMinutes}:${formattedSeconds}`; - } - - return `00:00`; - - }; - - - - useEffect(() => { - if (validationError) { - setErrorMessage(validationError) - } - }, [validationError]) - - - return <> -
- {/*** Close Icon ***/} -
- close -
- - {/*** Header ***/} -
-

{title}

-

{desc}

-
- -
- {/*** OTP ***/} -
- {otp.map((digit, index) => ( - (inputRefs.current[index] = ref)} - onChange={(e) => handleInputChange(index, e.target.value)} - onKeyDown={(e) => handleKeyDown(index, e)} - onPaste={(e) => handlePaste(e)} - maxLength={1} - autoFocus={index === 0} - /> - ))} - -
- - {/*** Error message ***/} - {errorMessage &&
-

{errorMessage}

-
} - - {/*** Action buttons ***/} -
- {showResend &&

Resend Code

} - {!showResend &&
{`Resend passcode in ${formatTime(resendInSeconds)}`}
} - -
-
- - -
- - -} - -export default OtpSubmissionForm \ No newline at end of file diff --git a/apps/web-app/components/auth/privy-modals.tsx b/apps/web-app/components/auth/privy-modals.tsx deleted file mode 100644 index 082ecf50c..000000000 --- a/apps/web-app/components/auth/privy-modals.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import { useEffect, useState } from 'react'; -import usePrivyWrapper from '../../hooks/auth/usePrivyWrapper'; -import { toast } from 'react-toastify'; -import axios from 'axios'; -import { calculateExpiry, createLogoutChannel, decodeToken } from '../../utils/services/auth'; -import Cookies from 'js-cookie'; -import { useRouter } from 'next/router'; -import { LOGOUT_MSG } from '../../constants'; -import useAuthAnalytics from '../../analytics/auth.analytics'; - -function PrivyModals() { - const { - authenticated, - getAccessToken, - linkEmail, - linkGithub, - linkGoogle, - linkWallet, - login, - logout, - ready, - unlinkEmail, - updateEmail, - user, - PRIVY_CUSTOM_EVENTS, - } = usePrivyWrapper(); - const analytics = useAuthAnalytics(); - const [linkAccountKey, setLinkAccountKey] = useState(''); - const router = useRouter(); - - const clearPrivyParams = () => { - const queryString = window.location.search.substring(1); - const params = new URLSearchParams(queryString); - let queryParams = `?`; - params.forEach((value, key) => { - if (!key.includes('privy_')) { - queryParams = `${queryParams}${queryParams === '?' ? '' : '&'}${key}=${value}`; - } - }); - router.push(`${window.location.pathname}${queryParams === '?' ? '' : queryParams}`); - }; - - const getLinkedAccounts = (user) => { - const userLinkedAccounts = user?.linkedAccounts ?? []; - const linkedAccounts = userLinkedAccounts.map((account) => { - const linkedType = account.type; - if (linkedType === 'wallet') { - return 'siwe'; - } else if (linkedType === 'google_oauth') { - return 'google'; - } else if (linkedType === 'github_oauth') { - return 'github'; - } else { - return ''; - } - }); - - return linkedAccounts.filter((v) => v !== '').join(','); - }; - - const loginInUser = (output) => { - if (output.userInfo?.isFirstTimeLogin) { - router.push('/settings'); - } - clearPrivyParams(); - setLinkAccountKey(''); - document.dispatchEvent(new CustomEvent('app-loader-status', { detail: false })); - toast.success('Successfully Logged In', { hideProgressBar: true }); - }; - - const saveTokensAndUserInfo = (output, user) => { - const authLinkedAccounts = getLinkedAccounts(user); - const accessTokenExpiry = decodeToken(output.accessToken); - const refreshTokenExpiry = decodeToken(output.refreshToken); - localStorage.removeItem('stateUid'); - Cookies.set('authToken', JSON.stringify(output.accessToken), { - expires: new Date(accessTokenExpiry.exp * 1000), - domain: process.env.COOKIE_DOMAIN || '', - }); - - Cookies.set('refreshToken', JSON.stringify(output.refreshToken), { - expires: new Date(refreshTokenExpiry.exp * 1000), - path: '/', - domain: process.env.COOKIE_DOMAIN || '', - }); - Cookies.set('userInfo', JSON.stringify(output.userInfo), { - expires: new Date(accessTokenExpiry.exp * 1000), - path: '/', - domain: process.env.COOKIE_DOMAIN || '', - }); - - Cookies.set('authLinkedAccounts', JSON.stringify(authLinkedAccounts), { - expires: new Date(refreshTokenExpiry.exp * 1000), - path: '/', - domain: process.env.COOKIE_DOMAIN || '', - }); - }; - - const deleteUser = async (errorCode) => { - analytics.onPrivyUserDelete({ ...user, type: 'init' }); - const token = await getAccessToken(); - await axios.post(`${process.env.WEB_API_BASE_URL}/v1/auth/accounts/external/${user.id}`, { token: token }); - analytics.onPrivyUserDelete({ type: 'success' }); - setLinkAccountKey(''); - await logout(); - document.dispatchEvent(new CustomEvent('auth-invalid-email', { detail: errorCode })); - }; - - const handleInvalidDirectoryEmail = async () => { - try { - analytics.onDirectoryLoginFailure({ ...user, type: 'INVALID_DIRECTORY_EMAIL' }); - if (user?.email?.address && user?.linkedAccounts.length > 1) { - analytics.onPrivyUnlinkEmail({ ...user, type: 'init' }); - await unlinkEmail(user?.email?.address); - analytics.onPrivyUnlinkEmail({ type: 'success' }); - await deleteUser(''); - } else if (user?.email?.address && user?.linkedAccounts.length === 1) { - setLinkAccountKey(''); - await deleteUser(''); - } else { - await logout(); - document.dispatchEvent(new CustomEvent('auth-invalid-email')); - } - } catch (error) { - document.dispatchEvent(new CustomEvent('auth-invalid-email')); - } - }; - - const initDirectoryLogin = async () => { - try { - document.dispatchEvent(new CustomEvent('app-loader-status', { detail: true })); - const privyToken = await getAccessToken(); - const result = await axios.post(`${process.env.WEB_API_BASE_URL}/v1/auth/token`, { - exchangeRequestToken: privyToken, - exchangeRequestId: localStorage.getItem('stateUid'), - grantType: 'token_exchange', - }); - - if (result?.data?.isEmailChanged) { - document.dispatchEvent(new CustomEvent('auth-info-modal', { detail: 'email_changed' })); - } else if(result?.data?.isDeleteAccount) { - if (user?.linkedAccounts.length > 1) { - await unlinkEmail(user?.email?.address); - await deleteUser('email-changed'); - } else if (user?.email?.address && user?.linkedAccounts.length === 1) { - setLinkAccountKey(''); - await deleteUser('email-changed'); - } - } else { - saveTokensAndUserInfo(result.data, user); - loginInUser(result.data); - analytics.onDirectoryLoginSuccess(); - } - } catch (error) { - - document.dispatchEvent(new CustomEvent('app-loader-status', { detail: false })); - if (user?.email?.address && error?.response?.status === 403) { - await handleInvalidDirectoryEmail(); - } else { - document.dispatchEvent(new CustomEvent('auth-invalid-email', { detail: 'unexpected_error' })); - setLinkAccountKey(''); - await logout(); - } - } - }; - - useEffect(() => { - async function handlePrivyLoginSuccess(e) { - const info = e.detail; - analytics.onPrivyLoginSuccess(info?.user); - // If email is not linked, link email mandatorily - if (!info?.user?.email?.address) { - setLinkAccountKey('email'); - return; - } - const stateUid = localStorage.getItem('stateUid'); - if (stateUid) { - // If linked login user - analytics.onDirectoryLoginInit({ ...info?.user, stateUid }); - await initDirectoryLogin(); - } - } - - async function handlePrivyLinkSuccess(e) { - const { linkMethod, linkedAccount } = e.detail; - const authLinkedAccounts = getLinkedAccounts(e.detail.user); - analytics.onPrivyLinkSuccess({ linkMethod, linkedAccount, authLinkedAccounts }); - if (linkMethod === 'email') { - const userInfo = Cookies.get('userInfo'); - const accessToken = Cookies.get('accessToken'); - const refreshToken = Cookies.get('refreshToken'); - if (!userInfo && !accessToken && !refreshToken) { - // Initiate Directory Login to validate email and login user - const stateUid = localStorage.getItem('stateUid'); - analytics.onDirectoryLoginInit({ ...e?.detail?.user, stateUid, linkedAccount }); - await initDirectoryLogin(); - } else { - document.dispatchEvent(new CustomEvent('app-loader-status', { detail: true })); - document.dispatchEvent( - new CustomEvent('directory-update-email', { detail: { newEmail: linkedAccount.address } }) - ); - } - } else if (linkMethod === 'github') { - document.dispatchEvent(new CustomEvent('new-auth-accounts', { detail: authLinkedAccounts })); - toast.success('Github linked successfully', { hideProgressBar: true }); - } else if (linkMethod === 'google') { - document.dispatchEvent(new CustomEvent('new-auth-accounts', { detail: authLinkedAccounts })); - toast.success('Google linked successfully', { hideProgressBar: true }); - } else if (linkMethod === 'siwe') { - document.dispatchEvent(new CustomEvent('new-auth-accounts', { detail: authLinkedAccounts })); - toast.success('Wallet linked successfully', { hideProgressBar: true }); - } - setLinkAccountKey(''); - } - - function handlePrivyLoginError(e) { - console.log('Privy login error'); - } - - async function handlePrivyLinkError(e) { - const userInfo = Cookies.get('userInfo'); - const accessToken = Cookies.get('accessToken'); - const refreshToken = Cookies.get('refreshToken'); - - if (!userInfo && !accessToken && !refreshToken) { - analytics.onAccountLinkError({ type: 'loggedout', error: e?.detail?.error }); - if ( - e?.detail?.error === 'linked_to_another_user' || - e?.detail?.error === 'exited_link_flow' || - e?.detail?.error === 'invalid_credentials' - ) { - try { - await deleteUser(e?.detail?.error); - } catch (err) { - document.dispatchEvent(new CustomEvent('auth-invalid-email', { detail: e?.detail?.error })); - } - } else { - await logout(); - setLinkAccountKey(''); - document.dispatchEvent(new CustomEvent('auth-invalid-email', { detail: 'unexpected_error' })); - } - } else { - analytics.onAccountLinkError({ type: 'loggedin', error: e?.detail?.error }); - } - } - async function initPrivyLogin() { - const stateUid = localStorage.getItem('stateUid'); - if (stateUid) { - login(); - } - } - function addAccountToPrivy(e) { - analytics.onPrivyAccountLink({ account: e?.detail }); - setLinkAccountKey(e.detail); - } - async function handlePrivyLogout() { - Cookies.remove('authLinkedAccounts'); - await logout(); - } - - async function handlePrivyLogoutSuccess() { - const isDirectory = localStorage.getItem('directory-logout'); - if (isDirectory) { - localStorage.clear(); - toast.info(LOGOUT_MSG, { - hideProgressBar: true, - }); - createLogoutChannel().postMessage('logout'); - } - } - - document.addEventListener('privy-init-login', initPrivyLogin); - document.addEventListener('auth-link-account', addAccountToPrivy); - document.addEventListener('init-privy-logout', handlePrivyLogout); - document.addEventListener(PRIVY_CUSTOM_EVENTS.AUTH_LOGIN_SUCCESS, handlePrivyLoginSuccess); - document.addEventListener(PRIVY_CUSTOM_EVENTS.AUTH_LINK_ACCOUNT_SUCCESS, handlePrivyLinkSuccess); - document.addEventListener(PRIVY_CUSTOM_EVENTS.AUTH_LOGIN_ERROR, handlePrivyLoginError); - document.addEventListener(PRIVY_CUSTOM_EVENTS.AUTH_LINK_ERROR, handlePrivyLinkError); - document.addEventListener('privy-logout-success', handlePrivyLogoutSuccess); - return function () { - document.removeEventListener('privy-init-login', initPrivyLogin); - document.removeEventListener('auth-link-account', addAccountToPrivy); - document.removeEventListener('init-privy-logout', handlePrivyLogout); - document.removeEventListener(PRIVY_CUSTOM_EVENTS.AUTH_LOGIN_SUCCESS, handlePrivyLoginSuccess); - document.removeEventListener(PRIVY_CUSTOM_EVENTS.AUTH_LINK_ACCOUNT_SUCCESS, handlePrivyLinkSuccess); - document.removeEventListener(PRIVY_CUSTOM_EVENTS.AUTH_LOGIN_ERROR, handlePrivyLoginError); - document.removeEventListener(PRIVY_CUSTOM_EVENTS.AUTH_LINK_ERROR, handlePrivyLinkError); - document.removeEventListener('privy-logout-success', handlePrivyLogoutSuccess); - }; - }, [user, login, logout, ready]); - - /**** FIX NEEDED: Currently privy link methods throws errors when called directly. Requires useEffect based setup like below *****/ - useEffect(() => { - if (linkAccountKey === 'github') { - linkGithub(); - setLinkAccountKey(''); - } else if (linkAccountKey === 'google') { - linkGoogle(); - setLinkAccountKey(''); - } else if (linkAccountKey === 'siwe') { - linkWallet(); - setLinkAccountKey(''); - } else if (linkAccountKey === 'email') { - linkEmail(); - setLinkAccountKey(''); - } else if (linkAccountKey === 'updateEmail') { - updateEmail(); - setLinkAccountKey(''); - } - }, [linkAccountKey]); - - return ( - <> - - - ); -} - -export default PrivyModals; diff --git a/apps/web-app/components/changeLog/change-logs.tsx b/apps/web-app/components/changeLog/change-logs.tsx deleted file mode 100644 index 3221fe3e1..000000000 --- a/apps/web-app/components/changeLog/change-logs.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { ChangeLogList, tagColors } from "apps/web-app/constants"; - - -const ChangeLogs = (props: any) => { - return ( -
- {ChangeLogList.map((changeLog: any, index: number) => { - const tagColor = tagColors.find( - (item: any) => item.name === changeLog.tag - ).color; - return ( -
-
- - {changeLog.date} - - - - {changeLog.tag} - - {changeLog.isBeta && - beta logo - } -
-
-
- {changeLog.title} -
-
-
-
- ); - })} -
- ); -}; - -export default ChangeLogs; diff --git a/apps/web-app/components/irl/add-details-popup.tsx b/apps/web-app/components/irl/add-details-popup.tsx deleted file mode 100644 index 34fbeeea8..000000000 --- a/apps/web-app/components/irl/add-details-popup.tsx +++ /dev/null @@ -1,691 +0,0 @@ -import { Dialog } from '@headlessui/react'; -import { ReactComponent as CloseIcon } from '/public/assets/images/icons/close-grey.svg'; -import { useEffect, useRef, useState } from 'react'; -import TeamsDropDown from './teams-dropdown'; -import { createEventGuest, editEventGuest, getEventDetailBySlug } from 'apps/web-app/services/irl.service'; -import { toast } from 'react-toastify'; -import { LoadingIndicator } from '../shared/loading-indicator/loading-indicator'; -import Cookies from 'js-cookie'; -import { useRouter } from 'next/router'; -import useAppAnalytics from 'apps/web-app/hooks/shared/use-app-analytics'; -import { getUserInfo, parseCookie } from 'apps/web-app/utils/shared.utils'; -import { APP_ANALYTICS_EVENTS, OH_GUIDELINE_URL } from 'apps/web-app/constants'; -import TagsPicker from './tags-picker'; -import useTagsPicker from 'apps/web-app/hooks/shared/use-tags-picker'; -import { - formatDateRangeForDescription, - formatDateToISO, - getArrivalDepartureDateRange, - getTelegramUsername, - removeAt, -} from 'apps/web-app/utils/irl.utils'; -import Link from 'next/link'; -import api from 'apps/web-app/utils/api'; -import { fetchMember } from 'apps/web-app/utils/services/members'; - -const AddDetailsPopup = (props: any) => { - const isOpen = props.isOpen; - const onClose = props?.onClose; - const teams = props?.teams; - const userInfo = props?.userInfo; - const eventDetails = props?.eventDetails; - const isUserGoing = props?.isUserGoing; - const registeredGuest = props?.registeredGuest; - const showTelegram = props?.showTelegram; - const focusOHField = props?.focusOHField; - const router = useRouter(); - const slug = router.query.slug; - - const [isLoader, setIsLoader] = useState(false); - const [formErrors, setFormErrors] = useState({}); - const [connectDetail, setConnectDetail] = useState({}); - const [formValues, setFormValues] = useState({ - teamUid: '', - telegramId: '', - reason: '', - topics: [], - officeHours: '', - additionalInfo: { - checkInDate: '', - checkOutDate: '', - }, - }); - const analytics = useAppAnalytics(); - const user = getUserInfo(); - const dateRange = getArrivalDepartureDateRange(eventDetails?.startDate, eventDetails?.endDate, 5); - const endRange = getArrivalDepartureDateRange(eventDetails?.startDate, eventDetails?.endDate, 4); - const officeHoursRef = useRef(null); - - const [departureMinDate, setDepartureMinDate] = useState(getDepartureMinDate()); - // const departureMinDate = formatDateToISO(eventDetails?.startDate); - const [arrivalMaxDate, setArrivalMaxDate] = useState(getArrivalMaxDate()); - // const arrivalMaxDate = formatDateToISO(eventDetails?.endDate); - const startAndEndDateInfo = formatDateRangeForDescription(eventDetails?.startDate, eventDetails?.endDate); - - const defaultItems = eventDetails?.topics ?? []; - const topicsProps = useTagsPicker({ - defaultItems, - selectedItems: formValues?.topics, - }); - - function getDepartureMinDate() { - return formatDateToISO(eventDetails?.startDate); - } - - function getArrivalMaxDate() { - return formatDateToISO(eventDetails?.endDate); - } - - const intialTeamValue = teams?.find((team) => team?.id === formValues?.teamUid); - const handleChange = (event: any) => { - const { name, value } = event.target; - setFormValues((prevFormData) => ({ ...prevFormData, [name]: value })); - }; - - const onTeamsChange = (value) => { - setFormValues((prevFormData) => ({ ...prevFormData, teamUid: value?.id })); - }; - - //get event details - const getEventDetails = async () => { - const userCookie = parseCookie(Cookies.get('authToken')); - const eventDetails = await getEventDetailBySlug(slug, userCookie); - document.dispatchEvent( - new CustomEvent('updateGuests', { - detail: { - eventDetails, - }, - }) - ); - }; - - //edit guest details - const onEditGuestDetails = async () => { - analytics.captureEvent(APP_ANALYTICS_EVENTS.IRL_RSVP_POPUP_UPDATE_BTN_CLICKED, { - type: 'clicked', - eventId: eventDetails?.id, - eventName: eventDetails?.name, - user, - }); - - const payload = { - ...formValues, - topics: topicsProps?.selectedItems, - telegramId: removeAt(formValues?.telegramId), - memberUid: userInfo?.uid, - eventUid: eventDetails?.id, - uid: registeredGuest.uid, - }; - - const team = teams?.find((team) => team.id === payload?.teamUid); - const teamName = team?.name; - const isValid = validateForm(payload); - - if (isValid) { - analytics.captureEvent(APP_ANALYTICS_EVENTS.IRL_RSVP_POPUP_UPDATE_BTN_CLICKED, { - type: 'api_initiated', - eventId: eventDetails?.id, - eventName: eventDetails?.name, - user, - ...payload, - teamName, - }); - - const response = await editEventGuest(eventDetails?.slugUrl, registeredGuest.uid, payload); - - if (response.status === 200 || response.status === 201) { - analytics.captureEvent(APP_ANALYTICS_EVENTS.IRL_RSVP_POPUP_UPDATE_BTN_CLICKED, { - type: 'api_success', - eventId: eventDetails?.id, - eventName: eventDetails?.name, - user, - ...payload, - teamName, - }); - await getEventDetails(); - onClose(); - setIsLoader(false); - toast.success('Your details has been updated successfully'); - } - } else { - setIsLoader(false); - } - }; - - //validate form - const validateForm = (formValues: any) => { - const errors = {} as any; - const initialTeamValue = teams?.find((team) => team?.id === formValues?.teamUid); - const urlRE = /(^|\s)((https?:\/\/)?[\w-]+(\.[\w-]+)+(:\d+)?(\/\S*)?)(?![.\S])/gi; - if (!formValues?.teamUid?.trim() || !initialTeamValue) { - errors.teamUid = 'Team is required'; - } - - if (formValues.additionalInfo.checkInDate && !formValues.additionalInfo.checkOutDate) { - errors.checkOutDate = 'Departure date is required'; - } - - if (!formValues.additionalInfo.checkInDate && formValues.additionalInfo.checkOutDate) { - errors.checkInDate = 'Arrival date is required'; - } - - if (formValues.officeHours !== '' && !formValues.officeHours.match(urlRE)) { - errors.officeHours = 'Enter valid link'; - } - - const checkInDate = new Date(formValues.additionalInfo.checkInDate).getTime(); - const checkOutDate = new Date(formValues.additionalInfo.checkOutDate).getTime(); - - if (checkOutDate < checkInDate) { - errors.maxDate = 'Departure date should be greater than or equal to the Arrival date'; - } - - setFormErrors(errors); - return Object.keys(errors)?.length === 0; - }; - - //add event details - const onAddGuestDetails = async () => { - analytics.captureEvent(APP_ANALYTICS_EVENTS.IRL_RSVP_POPUP_SAVE_BTN_CLICKED, { - type: 'clicked', - eventId: eventDetails?.id, - eventName: eventDetails?.name, - user, - }); - - const payload = { - ...formValues, - topics: topicsProps?.selectedItems, - telegramId: removeAt(formValues?.telegramId), - memberUid: userInfo?.uid, - eventUid: eventDetails?.id, - }; - - const team = teams?.find((team) => team.id === payload?.teamUid); - const teamName = team?.name; - const isValid = validateForm(payload); - - if (isValid) { - analytics.captureEvent(APP_ANALYTICS_EVENTS.IRL_RSVP_POPUP_SAVE_BTN_CLICKED, { - type: 'api_initiated', - eventId: eventDetails?.id, - eventName: eventDetails?.name, - user, - ...payload, - teamName, - }); - - const response = await createEventGuest(eventDetails?.slugUrl, payload); - if (response.status === 201) { - analytics.captureEvent(APP_ANALYTICS_EVENTS.IRL_RSVP_POPUP_SAVE_BTN_CLICKED, { - type: 'api_success', - eventId: eventDetails?.id, - eventName: eventDetails?.name, - user, - ...payload, - teamName, - }); - await getEventDetails(); - onClose(); - setIsLoader(false); - toast.success('Your details has been added successfully'); - } - } else { - setIsLoader(false); - } - }; - - //form submit - const onSubmit = async (event) => { - event.preventDefault(); - setIsLoader(true); - try { - if (!isUserGoing) { - await onAddGuestDetails(); - } else { - await onEditGuestDetails(); - } - } catch { - onClose(); - toast.error('Something went wrong'); - } - }; - - const handleDisplayWarning = (elementId: string) => { - const messageElement = document.getElementById(elementId); - if (messageElement) { - messageElement.classList.remove('hidden-message'); - messageElement.classList.add('visible-message'); - } - }; - - const handleHideWarning = (elementId: string) => { - const messageElement = document.getElementById(elementId); - if (messageElement) { - messageElement.classList.remove('visible-message'); - messageElement.classList.add('hidden-message'); - } - }; - - const handleTelegramFocus = () => { - //update the telegram value when user focus on the field when blank value is saved already - if (connectDetail?.telegramId !== '' && registeredGuest?.isTelegramRemoved && formValues?.telegramId === '') { - setFormValues((prevFormData) => ({ - ...prevFormData, - telegramId: removeAt(getTelegramUsername(connectDetail?.telegramId)), - })); - } - handleDisplayWarning('telegram-message'); - }; - - const handleOfficeHoursFocus = () => { - //update the office hours value when user focus on the field when blank value is saved already - if (connectDetail?.officeHours !== '' && registeredGuest?.officeHours === '' && formValues?.officeHours === '') { - setFormValues((prevFormData) => ({ ...prevFormData, officeHours: connectDetail?.officeHours })); - } - handleDisplayWarning('oh-message'); - }; - - //prevent form submit from when user pressing enter in the empty input field - const handleKeyDown = (event) => { - if (event.key === 'Enter') { - event.preventDefault(); // Prevent form submission - } - }; - - //get additionalinfo from form - const onAdditionalInfoChange = (event: any) => { - const { name, value } = event.target; - setFormValues((prevFormData) => ({ - ...prevFormData, - additionalInfo: { ...prevFormData?.additionalInfo, [name]: value }, - })); - }; - - const handleOHGuidlineClick = () => { - analytics.captureEvent(APP_ANALYTICS_EVENTS.IRL_RSVP_POPUP_OH_GUIDELINE_URL_CLICKED, { - eventId: eventDetails?.id, - eventName: eventDetails?.name, - user, - }); - }; - - const handlePrivacySettingClick = () => { - analytics.captureEvent(APP_ANALYTICS_EVENTS.IRL_RSVP_POPUP_PRIVACY_SETTING_LINK_CLICKED, { - eventId: eventDetails?.id, - eventName: eventDetails?.name, - user, - }); - }; - - const onClearDate = (key) => { - setFormValues((prev) => ({ - ...prev, - additionalInfo: { - ...prev?.additionalInfo, - [key]: '', - }, - })); - }; - - useEffect(() => { - if (isUserGoing) { - const data = { - teamUid: registeredGuest?.teamUid, - telegramId: '', - reason: registeredGuest.reason ? registeredGuest?.reason?.trim() : '', - topics: registeredGuest?.topics, - officeHours: registeredGuest?.officeHours ? registeredGuest?.officeHours : '', - additionalInfo: { - ...formValues.additionalInfo, - checkInDate: registeredGuest?.additionalInfo?.checkInDate, - checkOutDate: registeredGuest?.additionalInfo?.checkOutDate, - }, - }; - setFormValues(data); - } else { - const teamUid = teams?.find((team) => team?.mainTeam)?.id; - setFormValues((prev) => ({ ...prev, teamUid })); - } - }, []); - - useEffect(() => { - const getMemberConnectDetails = async () => { - setIsLoader(true); - try { - const response = await fetchMember(userInfo?.uid); - const telegram = response?.telegramHandler ? removeAt(getTelegramUsername(response.telegramHandler)) : ''; - setConnectDetail({ telegramId: telegram, officeHours: response?.officeHours ?? '' }); - setFormValues((prevFormData) => ({ - ...prevFormData, - telegramId: !registeredGuest?.isTelegramRemoved ? telegram : '', - officeHours: registeredGuest?.officeHours === '' ? '' : response?.officeHours ?? '', - })); - setIsLoader(false); - } catch (error) { - console.error(error); - } - }; - getMemberConnectDetails(); - }, []); - - useEffect(() => { - if (focusOHField && officeHoursRef.current) { - officeHoursRef.current.focus(); - officeHoursRef.current.scrollIntoView({ behavior: 'smooth' }); - } - }, []); - - return ( - <> -
- -