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 &&
- 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.
-
- Explore key network events, find out who will be attending, and
- plan out meetings in advance to get the most out of your time
- together in-person
-
-
- Are you open to collaborate?
-
- {
- const events = {
- target: {
- value: evt,
- name: 'openToWork',
- },
- };
- props.onChange(events);
- }}
- />
-
-
-
-
-
-
-
- Enabling this implies you are open to collaborate on shared ideas
- & projects with other members. This is one way to join forces &
- reach a common goal.
-
-
-
- This will help us tag you with permissions to access the best
- Discord channels for you
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Drop your calendar link here so others can get in touch with you at
- a time that is convenient. We recommend 15-min meetings scheduled
- via Calendly or Google Calendar appointments
-
-
-
- Please fill out only the fields you would like to change
- for this member. If there is something you want to change
- that is not available, please leave a detailed explanation
- in "Additional Notes". If you don't want to
- change a field, leave it blank.
-
-
- You are viewing {member?.name.concat("'s")} limited profile.{' '}
-
- Login
- {' '}
- to access details such as social profiles, projects & office hours.
-
-
- Up to 3 pinned project repositories from member's GitHub profile
- is displayed by default. This view utilizes the same ordering (if any)
- as the pinned feature of member's GitHub profile page.{' '}
- {seeAllInfoText}
-
-
Protocol Labs is an open-source R&D lab building protocols, tools, and services to radically improve the internet. Learn more about Protocol Labs.
",
- handleClick: (open) =>
- !open && trackGoal(FATHOM_EVENTS.portal.faq.whatIsPl, 0),
- },
- {
- triggerText: 'How is Protocol Labs related to the Protocol Labs Network?',
- content:
- '
Protocol Labs and the teams in its network are united by a shared mission. By working together as a community, we are able to speed up the research - development - deployment pipeline and bring about computing breakthroughs more quickly.
We’ve successfully launched multiple web3 projects that deliver against this vision, and we have ambitious goals to continue to transform and improve computing and the decentralized web.
',
- handleClick: (open) =>
- !open && trackGoal(FATHOM_EVENTS.portal.faq.plAndPln, 0),
- },
- {
- triggerText: 'I want to join the network, what can I do?',
- content:
- "
We welcome teams interested in the Protocol Labs mission to collaborate with us on a short or long-term basis. You can read more about membership options here, or reach out to spaceport-admin@protocol.ai and we'll take things from there.
- LabWeek23 is Protocol Lab's annual decentralized global conference. It features several days of curated events, all organized by the visionary teams in the Protocol Labs Network to advance our mission — to drive breakthroughs in computing to push humanity forward.
-
- The Protocol Labs Network drives{' '}
- breakthroughs in computing to
- push humanity forward.
-
-
Membership into the Protocol Labs Network (PLN) gets teams benefits to help them research, develop, and deploy these breakthroughs more quickly. Teams get help on:
- We are excited to introduce office hours for teams. With this option, you can now schedule office hours with other teams to drop in, ask questions, discuss projects, or seek guidance.
-
A dedicated "Resources" section now lists all important URLs.
-
The "Attendees" section features UI improvements and allows searching by name, team, or project.
-
Attendees can now tag topics of interest and filter others using these tags.
-
-
- Updated Authentication Service (for both the Directory & ProtoSphere)
-
-
We have updated our Authentication Service. Please verify and link your directory membership email to a login method of your choice. If you can't remember your membership email, contact us here for assistance.
- We're excited to unveil our new landing page dedicated to IRL Gatherings! Our new landing page serves as a one-stop destination for all upcoming IRL gatherings hosted within our network. Network members can easily navigate through a curated list of events, each accompanied by detailed information and RSVP options.
-
We have added a new filter in Project's page to search projects based on the focus areas that they contribute to.
-
Projects are categorized into one of these categories-
-
-
Digital Human Rights: Building a foundation of freedom and safety in the digital age.
-
Public Goods: Creating more efficient and equitable structures for global progress.
-
Advanced Technologies: Ensuring responsible advancement in AI, AR, VR, BCI, and other emerging fields.
-
Innovation Network: Projects that facilitate collaboration, offer technical and financial support to drive research and development.
-
-
-
We can add a member as a contributor in Project module and the contribution details would get reflected automatically in the related member details page.
-
In addition to the current capability of searching members by member name & team name, this enhancement will allow the members to be searched using a project name as well. Every member associated with the project as a contributor would be returned in the search result.
- Team leads can use this update to make changes to the Focus and Sub focus areas within their teams. Additionally, a quick access feature to submit a support request from the directory has been enabled.
-
- This release is an further improvement on the filters based on member roles which was released as Version 2.0.1 on 22, Mar 2024. This feature update enables users to type and search roles they are looking for into the Role filter's search bar.
-
- Exciting news! We've rolled out a feature (Beta) that brings detailed participation information to our IRL Gatherings. Network members can now view a list of attendees for upcoming conferences and events, empowering them to see who else is attending and facilitating networking opportunities. With this new feature, network members can now connect with like-minded individuals, plan meetups, and maximize their conference experience.
-
- With this update, in addition to the current capability of searching by member name, this enhancement will allow the members to be searched using a team name as well. Every member of the team would be returned in the search result.
-
`,
- },
- {
- title: "Version 2.0.2 - Filters based on Teams' Focus areas",
- tag: 'New Feature',
- date: '29, Mar 2024',
- shortContent: `
-
-
Added a new filter in Team's page to search teams based on the focus areas/sub focus areas that they contribute to.
-
Teams are categorized into one of these categories-
-
-
Digital Human Rights: Building a foundation of freedom and safety in the digital age.
-
Public Goods: Creating more efficient and equitable structures for global progress.
-
Advanced Technologies: Ensuring responsible advancement in AI, AR, VR, BCI, and other emerging fields.
-
Innovation Network: Teams, members, and projects that facilitate collaboration, offer technical and financial support to drive research and development.
-
-
-
`,
- },
- {
- title: 'Version 2.0.1 - Filters based on Member roles',
- tag: 'New Feature',
- date: '22, Mar 2024 ',
- shortContent: `
-
-
Added a new filter in Member's page to search members based on their role.
-
Roles that are currently supported in the filter are
-
-
Founder/Co-Founder
-
CEO
-
CTO
-
COO
-
-
-
`,
- },
-];
-
-export const tagColors = [
- {
- name: 'New Feature',
- color: '#2ABC76',
- },
- {
- name: 'Improvements',
- color: '#35BAE4',
- },
- { name: 'Beta', color: '#C169D7' },
- { name: 'Fixed', color: '#4871D9' },
-];
-
-//API route for filters in home page
-export const FILTER_API_ROUTES = {
- FOCUS_AREA: '/v1/focus-areas',
-};
-
-export const ROLE_FILTER_QUERY_NAME = "memberRoles";
-
-export const ABOUT_PLN_LINK = "https://protocol.ai/blog/transcription-pl-vision-driving-a-breakthroughs-in-computing-to-push-humanity-forward/"
-
-export const FOCUS_AREAS_FILTER_KEYS = {
- projects: "projectAncestorFocusAreas",
- teams: "teamAncestorFocusAreas"
-}
-
-export const INVITE_ONLY_RESTRICTION_ERRORS = {
- NOT_LOGGED_IN: "not_logged_in",
- UNAUTHORIZED: "unauthorized",
-}
-
-export const IRL_LW_EE_DATES = {
- startDate:"2024-05-28",
- endDate:"2024-07-04"
-}
-
-export const EVENT_TYPE = {
- INVITE_ONLY:"INVITE_ONLY"
-}
-
-// Protoshpere Link
-export const OH_GUIDELINE_URL = "https://protosphere.plnetwork.io/posts/Office-Hours-Guidelines-and-Tips-clsdgrbkk000ypocoqsceyfaq"
\ No newline at end of file
diff --git a/apps/web-app/context/projects/add.context.tsx b/apps/web-app/context/projects/add.context.tsx
deleted file mode 100644
index aec1a74a0..000000000
--- a/apps/web-app/context/projects/add.context.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { createContext, useReducer } from "react";
-
-export const AddProjectsContext = createContext({ addProjectsState: null, addProjectsDispatch: null });
-
-export function AddProjectContextProvider(props) {
-
- const defaultState = {
- inputs: {
- logoURL: '',
- logoObject:'',
- name: '',
- tagline: '',
- // maintainedBy: { value: '', label: '', logo: '' },
- maintainedBy:null,
- maintainedByContributors: [],
- collabTeamsList: [],
- contributingTeams: [],
- contributors: [],
- desc: '',
- projectURLs: [{
- name: '',
- value: '',
- id: 0
- }],
- contactEmail: '',
- fundsNeeded: false,
- KPIs: [],
- readme: '## Sample Template\n### Goals \nExplain the problems, use case or user goals this project focuses on\n### Proposed Solution\nHow will this project solve the user problems & achieve it’s goals\n### Milestones\n| Milestone | Milestone Description | When |\n| - | - | - |\n| content | content | content |\n| content | content | content |\n \n### Contributing Members\n| Member Name | Member Role | GH Handle | Twitter/Telegram |\n| - | - | - | - |\n| content | content | content | content |\n| content | content | content | content |\n\n### Reference Documents\n- [Reference Document](https://plsummit23.labweek.io/)\n\n',
- id:'',
- logo:null,
- projectFocusAreas: [],
- },
- mode: props.mode,
- errors: null,
- currentStep: 0
- }
-
- if(props.mode === 'EDIT'){
- const projectDetail = props.data ?? null;
- if(projectDetail){
- defaultState.inputs = {
- id:projectDetail.id,
- logoURL: projectDetail.image,
- logoObject:'',
- name: projectDetail.name,
- tagline: projectDetail.tagline,
- // maintainedBy: {
- // value: projectDetail.maintainingTeam?.uid,
- // label: projectDetail.maintainingTeam?.name,
- // logo: projectDetail.maintainingTeam?.logo?.url
- // },
- maintainedBy: {
- uid: projectDetail.maintainingTeam?.uid,
- name: projectDetail.maintainingTeam?.name,
- logo: projectDetail.maintainingTeam?.logo?.url
- },
- desc: projectDetail.description,
- maintainedByContributors:[],
- collabTeamsList:[],
- contributors: projectDetail.contributors,
- projectURLs: projectDetail.projectLinks,
- contactEmail: projectDetail.contactEmail,
- fundsNeeded: projectDetail.fundingNeeded,
- KPIs: projectDetail.KPIs,
- readme: projectDetail.readMe,
- contributingTeams: projectDetail.contributingTeams,
- logo:projectDetail.logo,
- projectFocusAreas: projectDetail.projectFocusAreas
- }
-
- const tempCollab = [];
- if(projectDetail.contributors && projectDetail.contributors.length > 0){
- const tempMaintainer = [];
- projectDetail.contributors.map(contri=>{
- if(contri?.type === "MAINTENER"){
- const copyTeam = {
- uid: contri.member?.uid,
- name: contri.member?.name,
- logo: contri.member?.image?.url,
- cuid: contri.uid,
- };
- tempMaintainer.push(copyTeam);
- }else if(contri?.type === "COLLABORATOR"){
- tempCollab.push(contri);
- }
- });
- if(tempMaintainer.length){
- defaultState.inputs.maintainedByContributors = [...tempMaintainer];
- }
- }
-
- projectDetail.contributingTeams?.map((team)=>{
- const temp = {
- team:{
- uid: team?.value,
- name:team?.label,
- logo:team?.logo
- },
- members:[]
- };
- tempCollab?.map(collab=>{
- if(collab.teamUid === team?.value){
- const copyTeam = {
- uid : collab.member?.uid,
- name: collab.member?.name,
- logo: collab.member?.image?.url,
- cuid:collab.uid
- };
- temp.members.push(copyTeam);
- }
- });
- defaultState.inputs.collabTeamsList.push(temp);
- })
- }
- }
-
- const reducer = (state, action) => {
- const newState = { ...state }
- switch (action.type) {
- case 'SET_INPUT':
- newState.inputs = { ...action.payload };
- break;
- case 'SET_ERROR':
- if(action.payload){
- newState.errors = { ...action.payload };
- }else{
- newState.errors = null;
- }
- break;
- case 'SET_CURRENT_STEP':
- newState.currentStep = action.payload;
- }
-
- return newState
- }
-
- const [addProjectsState, addProjectsDispatch] = useReducer(reducer, defaultState);
- return (
-
- {props.children}
-
- )
-}
\ No newline at end of file
diff --git a/apps/web-app/context/projects/contributors.context.tsx b/apps/web-app/context/projects/contributors.context.tsx
deleted file mode 100644
index 57cf7acee..000000000
--- a/apps/web-app/context/projects/contributors.context.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { createContext, useReducer } from 'react';
-
-export const ContributorsContext = createContext({
- contributorsState: null,
- contributorsDispatch: null,
-});
-
-export function ContributorsContextProvider(props) {
- const defaultState = {
- chooseTeamPopup : {
- showChooseTeamPopup: false,
- chooseTeamPopupTitle: '',
- chooseTeamPopupMode: 'ADD',
- UIType: 'TEAM',
- selectedTeam: null
- },
- type:'',
-
- maintainerTeamDetails: null
- };
-
- const reducer = (state, action) => {
-
- const newState = { ...state };
-
- switch (action.type) {
- case 'SET_CHOOSE_TEAM_POPUP':
- newState.chooseTeamPopup = { ...action.payload };
- break;
- case 'SET_TYPE':
- newState.type = action.payload;
- break;
- case 'SET_MAINTAINER':
- newState.maintainerTeamDetails = { ...action.payload };
- break;
- }
-
- return newState;
- };
-
- const [contributorsState, contributorsDispatch] = useReducer(
- reducer,
- defaultState
- );
- return (
-
- {props.children}
-
- );
-}
diff --git a/apps/web-app/context/projects/project.context.tsx b/apps/web-app/context/projects/project.context.tsx
deleted file mode 100644
index 23e01e60c..000000000
--- a/apps/web-app/context/projects/project.context.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { useRouter } from "next/router";
-import { createContext, useReducer } from "react";
-
-export const ProjectsContext = createContext({ projectsState: null, projectsDispatch: null });
-
-export function ProjectContextProvider(props) {
- const { query, push, pathname } = useRouter();
- const initialFilterState = {
- FUNDING: query['FUNDING'] && query['FUNDING'] === 'true' ? true : false,
- TEAM: query['TEAM'] ? query['TEAM'] : null
- }
-
- const defaultState = {
- filterState: { ...initialFilterState }
- }
-
- const reducer = (state, action) => {
- const newState = { ...state }
- switch (action.type) {
- case 'SET_FILTER':
- // eslint-disable-next-line no-case-declarations
- const { filterType, value } = action.payload;
- // eslint-disable-next-line no-case-declarations
- const oldFilterState = newState.filterState;
- newState.filterState = { ...oldFilterState, [filterType]: value };
- // eslint-disable-next-line no-case-declarations
- const { [filterType]: queryFilterValue,...restQuery } = query;
- push({
- pathname,
- query: {
- ...restQuery,
- ...(value && {
- [filterType]: value,
- }),
- },
- });
- break;
- case 'CLEAR_FILTER':
- newState.filterState = { FUNDING: false, TEAM: null };
- break;
- }
-
- return newState
- }
-
- const [projectsState, projectsDispatch] = useReducer(reducer, defaultState);
- return (
-
- {props.children}
-
- )
-}
\ No newline at end of file
diff --git a/apps/web-app/hooks/auth/useLoginPopupStatus.tsx b/apps/web-app/hooks/auth/useLoginPopupStatus.tsx
deleted file mode 100644
index 6ef81684f..000000000
--- a/apps/web-app/hooks/auth/useLoginPopupStatus.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { usePrivy } from '@privy-io/react-auth';
-import { useRouter } from 'next/router';
-import { useEffect, useState } from 'react';
-
-function useLoginPopupStatus() {
- const { authenticated, logout, unlinkEmail, user, } = usePrivy();
- const [isLoginActive, setLoginStatus] = useState(false);
- const router = useRouter();
-
- useEffect(() => {
- setLoginStatus(window.location.hash === '#login');
- }, [router]);
- return {
- isLoginActive
- }
-}
-
-export default useLoginPopupStatus;
diff --git a/apps/web-app/hooks/auth/usePrivyWrapper.ts b/apps/web-app/hooks/auth/usePrivyWrapper.ts
deleted file mode 100644
index ceb72ed4e..000000000
--- a/apps/web-app/hooks/auth/usePrivyWrapper.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { useLinkAccount, useLogin, usePrivy, useLogout } from '@privy-io/react-auth';
-
-function usePrivyWrapper() {
- const { authenticated, unlinkEmail, updateEmail, ready, linkGoogle, linkWallet, user, getAccessToken } = usePrivy();
- const PRIVY_CUSTOM_EVENTS = {
- AUTH_LOGIN_SUCCESS: 'AUTH_LOGIN_SUCCESS',
- AUTH_LINK_ACCOUNT_SUCCESS: 'AUTH_LINK_ACCOUNT_SUCCESS',
- AUTH_LOGIN_ERROR: 'AUTH_LOGIN_ERROR',
- AUTH_LINK_ERROR: 'AUTH_LINK_ERROR',
- };
-
- const {logout} = useLogout({
- onSuccess: () => {
- document.dispatchEvent(new CustomEvent('privy-logout-success'))
- }
- })
- /***** SETUP FOR PRIVY LOGIN POPUP *******/
- const { login } = useLogin({
- onComplete: (user) => {
- document.dispatchEvent(new CustomEvent(PRIVY_CUSTOM_EVENTS.AUTH_LOGIN_SUCCESS, { detail: { user } }));
- },
- onError: (error) => {
- document.dispatchEvent(new CustomEvent(PRIVY_CUSTOM_EVENTS.AUTH_LOGIN_ERROR, { detail: { error } }));
- },
- });
-
- /***** SETUP FOR PRIVY LINK ACCOUNT POPUP *******/
- const { linkEmail, linkGithub } = useLinkAccount({
- onSuccess: (user, linkMethod, linkedAccount) => {
- document.dispatchEvent(
- new CustomEvent(PRIVY_CUSTOM_EVENTS.AUTH_LINK_ACCOUNT_SUCCESS, { detail: { user, linkMethod, linkedAccount } })
- );
- },
- onError: (error) => {
- document.dispatchEvent(new CustomEvent(PRIVY_CUSTOM_EVENTS.AUTH_LINK_ERROR, { detail: { error } }));
- },
- });
-
- return {
- login,
- linkEmail,
- unlinkEmail,
- linkGithub,
- linkGoogle,
- linkWallet,
- logout,
- updateEmail,
- getAccessToken,
- useLogout,
- user,
- authenticated,
- ready,
- PRIVY_CUSTOM_EVENTS,
- };
-}
-
-export default usePrivyWrapper;
diff --git a/apps/web-app/hooks/directory/README.md b/apps/web-app/hooks/directory/README.md
deleted file mode 100644
index 37b6028a7..000000000
--- a/apps/web-app/hooks/directory/README.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# Infinite Scroll
-
-_This feature was temporarily disabled due to Airtable limitations._
-
-### How does it work?
-
-- `getTeamsDirectoryRequestParametersFromQuery` on `list.utils.ls` helps getting the query parameters to be used on the infinite scroll API Route requests
-- `getTeamsDirectoryRequestOptionsFromQuery` on `list.utils.ls` helps getting the options for the first page request, using the Airtable's Javascript SDK
-- `getFirstTeamPage` method from Airtable lib, which retrieves the first X teams, is used on the `/teams` page's `getServerSideProps` instead of `getTeams` (which retrieves all teams at once)
-- `useInfiniteScroll` hook adds infinite scroll logic to the directory list component
-
-### How to make it work again?
-
-- Use `getFirstTeamPage` method from Airtable lib on the `/teams` page's `getServerSideProps` instead of `getTeams`
-- Add `pageSize` property back into `getTeamsDirectoryRequestOptionsFromQuery` so that we can request a sub-set of the whole resultset, in a paginated fashion
-- Show `DirectoryLoading` component within `TeamsDirectoryList`, when `loading` is set to `true`
-- Show `DirectoryError` component within `TeamsDirectoryList`, when `error` is set to `true`
-- Use `useInfiniteScroll` hook to manage `TeamsDirectoryList` state
-
-### What's the problem?
-
-The `offset` property retrieved by Airtable along with the data for each page contains a token that gets invalidated after a short period of inactivity.
-This means that, if a user leaves a Protocol Labs Network Directory page open for some time, he will get an error requesting the next infinite scroll page, due to token invalidation.
-
-### What can be done to fix it?
-
-- When Airtable response has an `error` property, and `error.type` is equal to `'LIST_RECORDS_ITERATOR_NOT_AVAILABLE'`:
- - We need to save the next item ID from the previous token before getting a new token
- - We then need to make a new request for the first page of results, using the same exact criteria
- - When we get a response, we need to look at the `offset` property from the returned data and get a new token from it (the substring before `'/'`)
- - The new offset will then be: `/`
- - We then make a new request using the same criteria, but the new offset
-- We may consider using the bottom of the last card instead the middle of the card for improved user experience
-- We need to reset the `loading` and the `error` states on router change
-- The scroll event listeners should occur after the route change events
diff --git a/apps/web-app/hooks/directory/use-fake-infinite-scroll.hook.ts b/apps/web-app/hooks/directory/use-fake-infinite-scroll.hook.ts
deleted file mode 100644
index 247949b8a..000000000
--- a/apps/web-app/hooks/directory/use-fake-infinite-scroll.hook.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { ReactElement, useCallback, useEffect, useState } from 'react';
-
-type UseFakeInfiniteScrollProps = {
- items: ReactElement[];
- lastVisibleItemElementSelector: string;
-};
-
-const ITEMS_PER_PAGE = 30;
-
-export function useFakeInfiniteScroll({
- items,
- lastVisibleItemElementSelector,
-}: UseFakeInfiniteScrollProps) {
- const [visibleItems, setVisibleItems] = useState([]);
-
- // Set first batch of items based on the items provided
- useEffect(() => {
- if (items) {
- setVisibleItems(items.slice(0, ITEMS_PER_PAGE));
- }
- }, [items]);
-
- useEffect(() => {
- // Make sure that we show additional items if the first batch of items
- // is not enough to fill the viewport
- handleScroll();
-
- // Listen to the scroll position for showing additional items
- window.addEventListener('scroll', handleScroll);
-
- return () => {
- window.removeEventListener('scroll', handleScroll);
- };
- });
-
- const handleScroll = useCallback(async () => {
- // Get last visible item
- const lastVisibleItem: HTMLDivElement = document.querySelector(
- lastVisibleItemElementSelector
- );
-
- if (lastVisibleItem) {
- // Get offset from the middle of the last visible item card
- const lastVisibleItemOffset =
- lastVisibleItem.offsetTop + lastVisibleItem.clientHeight / 2;
- const pageOffset = window.pageYOffset + window.innerHeight;
-
- // Check if user scrolled down till the middle of the last card
- // and if there are more items to show
- if (
- pageOffset > lastVisibleItemOffset &&
- items.length > visibleItems.length
- ) {
- // Show additional items
- setVisibleItems(items.slice(0, visibleItems.length + ITEMS_PER_PAGE));
- }
- }
- }, [lastVisibleItemElementSelector, items, visibleItems]);
-
- return [visibleItems] as const;
-}
diff --git a/apps/web-app/hooks/directory/use-infinite-scroll.hook.ts b/apps/web-app/hooks/directory/use-infinite-scroll.hook.ts
deleted file mode 100644
index 51361dc7b..000000000
--- a/apps/web-app/hooks/directory/use-infinite-scroll.hook.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { useRouter } from 'next/router';
-import { stringify } from 'querystring';
-import { useCallback, useEffect, useState } from 'react';
-import { ITEMS_PER_PAGE } from '../../constants';
-import { IMember } from '../../utils/members.types';
-import { ITeam } from '../../utils/teams.types';
-
-type UseInfiniteScrollProps = {
- initialItems: ITeam[] | IMember[];
- baseAPIRoute: string;
- cardSelector: string;
- dataResultsProp: string;
-};
-
-export function useInfiniteScroll({
- initialItems,
- baseAPIRoute,
- cardSelector,
- dataResultsProp,
-}: UseInfiniteScrollProps) {
- const [items, setItems] = useState([]);
- const [initialLoad, setInitialLoad] = useState(true);
- const [offset, setOffset] = useState();
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(false);
- const router = useRouter();
-
- // Set first page of items based on initial items provided
- useEffect(() => {
- if (initialItems) {
- setItems(initialItems);
- }
- }, [initialItems]);
-
- useEffect(() => {
- // Make sure that we load additional items if the first 9 items don't
- // take more than the viewport height
- handleScroll();
-
- // Listen to the scroll position for loading additional data
- window.addEventListener('scroll', handleScroll);
-
- // Listen to route changes to reset state on non-shallow navigations
- router.events.on('routeChangeComplete', handleRouteChange);
-
- return () => {
- window.removeEventListener('scroll', handleScroll);
- router.events.off('routeChangeComplete', handleRouteChange);
- };
- });
-
- // When navigation isn't shallow, reset component state to ensure that we
- // request data from the first page onwards
- const handleRouteChange = useCallback(
- (_pathname: string, { shallow }: { shallow: boolean }) => {
- if (!shallow) {
- setInitialLoad(true);
- setOffset('');
- }
- },
- [setInitialLoad, setOffset]
- );
-
- const handleScroll = useCallback(async () => {
- // Get last visible item
- const lastVisibleItem: HTMLDivElement =
- document.querySelector(cardSelector);
-
- if (lastVisibleItem) {
- // Get offset from the middle of the last visible item card
- const lastVisibleItemOffset =
- lastVisibleItem.offsetTop + lastVisibleItem.clientHeight / 2;
- const pageOffset = window.pageYOffset + window.innerHeight;
-
- // Detects when user scrolls down till the middle of the last card
- if (pageOffset > lastVisibleItemOffset) {
- // Prevent new requests:
- // - When there is no offset defined (unless it's the initial load)
- // - When there is only one page of results
- // - While already loading data;
- if (
- (initialLoad || offset) &&
- items.length >= ITEMS_PER_PAGE &&
- !loading
- ) {
- setLoading(true);
-
- // After the initial load, set the tracking state to false
- if (initialLoad) {
- setInitialLoad(false);
- }
-
- // Make a request to the API Route
- // with the currently selected query params and the available offset
- let url = baseAPIRoute;
- const queryString = stringify(router.query);
-
- if (offset) {
- url += `?offset=${offset}`;
-
- if (queryString) {
- url += `&${queryString}`;
- }
- } else if (queryString) {
- url += `?${queryString}`;
- }
-
- const response = await fetch(url);
- const data = await response.json();
-
- if (data.error) {
- setError(true);
- } else {
- if (data[dataResultsProp]) {
- // Update internal state with the response data
- setItems([...items, ...data[dataResultsProp]]);
- setOffset(data.offset);
- }
- }
-
- setLoading(false);
- }
- }
- }
- }, [
- initialLoad,
- loading,
- offset,
- items,
- router,
- baseAPIRoute,
- cardSelector,
- dataResultsProp,
- ]);
-
- return [items, loading, error] as const;
-}
diff --git a/apps/web-app/hooks/directory/use-switch-filter.hook.ts b/apps/web-app/hooks/directory/use-switch-filter.hook.ts
deleted file mode 100644
index 4c40bf982..000000000
--- a/apps/web-app/hooks/directory/use-switch-filter.hook.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { useRouter } from 'next/router';
-import { useCallback, useEffect, useState } from 'react';
-
-export function useSwitchFilter(filterName: string) {
- const { query, push, pathname } = useRouter();
- const [enabled, setEnabled] = useState(!!query[filterName]);
-
- useEffect(() => {
- setEnabled(!!query[filterName]);
- }, [setEnabled, query, filterName]);
-
- const onSetEnabled = useCallback(
- (value: boolean) => {
- const { [filterName]: queryFilterValue, ...restQuery } = query;
- setEnabled(value);
- push({
- pathname,
- query: {
- ...restQuery,
- ...(value && {
- [filterName]: true,
- }),
- },
- });
- },
- [query, push, pathname, setEnabled, filterName]
- );
-
- return { enabled, onSetEnabled };
-}
diff --git a/apps/web-app/hooks/irl/use-irl-details.ts b/apps/web-app/hooks/irl/use-irl-details.ts
deleted file mode 100644
index 79d470298..000000000
--- a/apps/web-app/hooks/irl/use-irl-details.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { sortByDefault } from 'apps/web-app/utils/irl.utils';
-import { useEffect, useState } from 'react';
-
-export const useIrlDetails = (rawGuestList, userInfo) => {
- const rawGuest = [...rawGuestList];
- const [sortConfig, setSortConfig] = useState({ key: null, order: 'default' });
- const [filteredList, setFilteredList] = useState([...rawGuestList]);
- const [searchItem, setSearchItem] = useState('');
- const [filterConfig, setFilterConfig] = useState({});
-
- useEffect(() => {
- const searchHandler = (e: any) => {
- setSearchItem(e.detail.searchValue);
- };
-
- const sortHandler = (e: any) => {
- const sortColumn = e.detail.sortColumn;
- setSortConfig((old) => {
- if (old.key === sortColumn) {
- if (old.order === 'asc') {
- return { key: old.key, order: 'desc' };
- } else if (old.order === 'desc') {
- return { key: old.key, order: 'default' };
- } else if (old.order === 'default') {
- return { key: old.key, order: 'asc' };
- }
- } else {
- return { key: sortColumn, order: 'asc' };
- }
- });
- };
-
- const filterHandler = (e: any) => {
- const key = e.detail?.key;
- const selectedItems = e.detail?.selectedItems;
- setFilterConfig((prev) => ({ ...prev, [key]: selectedItems }));
- };
-
- document.addEventListener('irl-details-searchlist', searchHandler);
- document.addEventListener('irl-details-sortlist', sortHandler);
- document.addEventListener('irl-details-filterList', filterHandler);
-
- return () => {
- document.removeEventListener('irl-details-searchlist', searchHandler);
- document.removeEventListener('irl-details-sortlist', sortHandler);
- document.removeEventListener('irl-details-filter', filterHandler);
- };
- }, []);
-
- useEffect(() => {
- let filteredItems = [...rawGuest];
-
- //search by memberName, teamName, projectName
- if (searchItem?.trim() !== '') {
- filteredItems = [...rawGuest].filter(
- (v) =>
- v?.memberName
- ?.toLowerCase()
- ?.includes(searchItem?.toLowerCase()?.trim()) ||
- v?.teamName
- ?.toLowerCase()
- ?.includes(searchItem?.toLowerCase()?.trim()) ||
- v?.projectContributions?.some((project: string) =>
- project?.toLowerCase()?.includes(searchItem?.toLowerCase()?.trim())
- )
- );
- filteredItems = filteredItems.sort((a, b) =>
- a?.memberName.localeCompare(b?.memberName)
- );
- }
-
- //sort by team & member
- if (sortConfig.key !== null) {
- if (sortConfig.order === 'asc' || sortConfig.order === 'desc') {
- const sortedData = [...filteredItems].sort((a, b) => {
- const valueA = a[sortConfig.key];
- const valueB = b[sortConfig.key];
-
- return sortConfig.order === 'asc'
- ? valueA?.localeCompare(valueB)
- : valueB?.localeCompare(valueA);
- });
- filteredItems = [...sortedData];
- } else {
- const sortedGuests = sortByDefault(filteredItems);
- filteredItems = sortedGuests;
-
- const isUserGoing = filteredItems.some(
- (guest) => guest.memberUid === userInfo.uid
- );
-
- if (isUserGoing) {
- const currentUser = [...sortedGuests]?.find(
- (v) => v.memberUid === userInfo?.uid
- );
- if (currentUser) {
- const filteredList = [...sortedGuests]?.filter(
- (v) => v.memberUid !== userInfo?.uid
- );
- const formattedGuests = [currentUser, ...filteredList];
- filteredItems = formattedGuests;
- }
- }
-
- setFilteredList([...filteredItems]);
- }
- }
-
- // Roles filter
- if (filterConfig['roles']?.length > 0) {
- const selectedRoles = new Set(filterConfig['roles']);
- filteredItems = [...filteredItems]?.filter((item) =>
- selectedRoles.has(item?.memberRole)
- );
- }
-
- // Topics filter
- if (filterConfig['topics']?.length > 0) {
- const selectedTopics = new Set(filterConfig['topics']);
- filteredItems = [...filteredItems]?.filter((item) =>
- item.topics.some((topic) => selectedTopics?.has(topic))
- );
- }
-
- // Update the filtered list
- setFilteredList(filteredItems);
- }, [searchItem, sortConfig, filterConfig, rawGuestList]);
-
- return { filteredList, sortConfig, filterConfig };
-};
diff --git a/apps/web-app/hooks/plugins/use-directory-filters-fathom-logger.hook.ts b/apps/web-app/hooks/plugins/use-directory-filters-fathom-logger.hook.ts
deleted file mode 100644
index ad403e789..000000000
--- a/apps/web-app/hooks/plugins/use-directory-filters-fathom-logger.hook.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { trackGoal } from 'fathom-client';
-import { useRouter } from 'next/router';
-import { useCallback, useEffect } from 'react';
-import { FATHOM_EVENTS } from '../../constants';
-
-type TDirectoryFiltersFathomLoggerDirectoryType = 'members' | 'teams';
-
-export function useDirectoryFiltersFathomLogger(
- directoryType: TDirectoryFiltersFathomLoggerDirectoryType,
- filters: string[]
-) {
- const { query } = useRouter();
-
- const trackGoals = useCallback(
- (items: string[], eventType: string) => {
- items
- .filter((item) => Object.keys(query).includes(item))
- .forEach((item) => {
- const eventCode =
- FATHOM_EVENTS[directoryType].directory[eventType][item];
-
- eventCode && trackGoal(eventCode, 0);
- });
- },
- [directoryType, query]
- );
-
- useEffect(() => {
- const controls = ['searchBy', 'sort', 'viewType'];
-
- trackGoals(filters, 'filters');
- trackGoals(controls, 'controls');
- }, [filters, trackGoals]);
-}
diff --git a/apps/web-app/hooks/plugins/use-fathom.hook.ts b/apps/web-app/hooks/plugins/use-fathom.hook.ts
deleted file mode 100644
index 4d41e3784..000000000
--- a/apps/web-app/hooks/plugins/use-fathom.hook.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { load, trackPageview } from 'fathom-client';
-import { useRouter } from 'next/router';
-import { useEffect } from 'react';
-
-export function useFathom() {
- const router = useRouter();
-
- useEffect(() => {
- function onRouteChangeComplete() {
- trackPageview();
- }
-
- if (
- process.env.NEXT_PUBLIC_FATHOM_TRACKING_CODE &&
- process.env.NEXT_PUBLIC_FATHOM_INCLUDED_DOMAINS
- ) {
- load(process.env.NEXT_PUBLIC_FATHOM_TRACKING_CODE, {
- ...(process.env.NEXT_PUBLIC_FATHOM_SCRIPT_URL
- ? { url: process.env.NEXT_PUBLIC_FATHOM_SCRIPT_URL }
- : {}),
- includedDomains: [process.env.NEXT_PUBLIC_FATHOM_INCLUDED_DOMAINS],
- });
-
- // Record a pageview when route changes
- router.events.on('routeChangeComplete', onRouteChangeComplete);
-
- // Unassign event listeners
- return () => {
- router.events.off('routeChangeComplete', onRouteChangeComplete);
- };
- }
- }, [router.events]);
-}
diff --git a/apps/web-app/hooks/profile/use-profile-breadcrumb.hook.ts b/apps/web-app/hooks/profile/use-profile-breadcrumb.hook.ts
deleted file mode 100644
index 4aa2d6e0a..000000000
--- a/apps/web-app/hooks/profile/use-profile-breadcrumb.hook.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { IBreadcrumbItem } from '@protocol-labs-network/ui';
-import { useRouter } from 'next/router';
-import { useEffect } from 'react';
-
-type UseProfileBreadcrumbProps = {
- backLink: string;
- directoryName: string;
- pageName: string;
-};
-
-export function useProfileBreadcrumb({
- backLink,
- directoryName,
- pageName,
-}: UseProfileBreadcrumbProps) {
- const router = useRouter();
- const breadcrumbItems: IBreadcrumbItem[] = [
- {
- label: directoryName,
- href: backLink,
- },
- { label: pageName },
- ];
-
- useEffect(() => {
- if (router.query['backLink']) {
- const { backLink, ...query } = router.query;
- router.replace({ query }, undefined, { shallow: true });
- }
- }, [router]);
-
- return { breadcrumbItems };
-}
diff --git a/apps/web-app/hooks/shared/use-app-analytics.ts b/apps/web-app/hooks/shared/use-app-analytics.ts
deleted file mode 100644
index efaa59f7a..000000000
--- a/apps/web-app/hooks/shared/use-app-analytics.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { usePostHog } from 'posthog-js/react'
-import Cookies from 'js-cookie'
-function useAppAnalytics() {
- 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)
- }
- }
-
- return { captureEvent }
-}
-
-export default useAppAnalytics;
\ No newline at end of file
diff --git a/apps/web-app/hooks/shared/use-click-outside.ts b/apps/web-app/hooks/shared/use-click-outside.ts
deleted file mode 100644
index 5d2844d4c..000000000
--- a/apps/web-app/hooks/shared/use-click-outside.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { useEffect } from "react";
-
-const useClickOutside = (ref, callback) => {
- const handleClick = e => {
- if (ref.current && !ref.current.contains(e.target)) {
- callback();
- }
- };
- useEffect(() => {
- document.addEventListener('click', handleClick);
- return () => {
- document.removeEventListener('click', handleClick);
- };
- });
-};
-
-export default useClickOutside
\ No newline at end of file
diff --git a/apps/web-app/hooks/shared/use-debounce.ts b/apps/web-app/hooks/shared/use-debounce.ts
deleted file mode 100644
index 7bcd373b1..000000000
--- a/apps/web-app/hooks/shared/use-debounce.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { useState, useEffect } from 'react'
-
-export const useDebounce = (value, milliSeconds) => {
- const [debouncedValue, setDebouncedValue] = useState(value);
-
- useEffect(() => {
- const handler = setTimeout(() => {
- setDebouncedValue(value);
- }, milliSeconds);
-
- return () => {
- clearTimeout(handler);
- };
- }, [value, milliSeconds]);
-
- return debouncedValue;
-}
\ No newline at end of file
diff --git a/apps/web-app/hooks/shared/use-floating-multi-select.ts b/apps/web-app/hooks/shared/use-floating-multi-select.ts
deleted file mode 100644
index dedd276af..000000000
--- a/apps/web-app/hooks/shared/use-floating-multi-select.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { useEffect, useState } from 'react';
-
-const useFloatingMultiSelect = (props: any) => {
- const items = props.items ?? [];
- const alreadySelected = props?.selectedItems ?? [];
- const sortedItems = [...items].sort((a, b) =>
- a?.toLowerCase() > b?.toLowerCase() ? 1 : -1
- );
- const [isPaneActive, setIsPaneActive] = useState(false);
- const [filteredItems, setFilteredItems] = useState([...sortedItems]);
- const [selectedItems, setSelectedItems] = useState(alreadySelected);
-
- const onOpenPane = () => {
- setIsPaneActive(true);
- };
-
- const onClosePane = () => {
- setSelectedItems(alreadySelected);
- setIsPaneActive(false);
- };
-
- const onClearSelection = (e: any) => {
- e.stopPropagation();
- setSelectedItems([]);
- };
-
- const onItemSelected = (value: string) => {
- if (selectedItems?.includes(value)) {
- setSelectedItems(selectedItems?.filter((item: string) => item !== value));
- } else {
- setSelectedItems([...selectedItems, value]);
- }
- };
-
- const onInputChange = (value: string) => {
- const inputValue = value?.trim();
- if (inputValue === '') {
- setFilteredItems([...items]);
- } else {
- const filteredValues = [...items].filter((v) =>
- v?.toLowerCase().includes(inputValue?.toLowerCase())
- );
- setFilteredItems([...filteredValues]);
- }
- };
-
- useEffect(() => {
- setSelectedItems(alreadySelected);
- }, [alreadySelected?.length]);
-
- useEffect(() => {
- setFilteredItems(sortedItems);
- }, [items?.length]);
-
- return {
- onInputChange,
- onItemSelected,
- onClearSelection,
- filteredItems,
- selectedItems,
- isPaneActive,
- setFilteredItems,
- onOpenPane,
- onClosePane
- };
-};
-
-export default useFloatingMultiSelect;
diff --git a/apps/web-app/hooks/shared/use-is-email.hook.spec.ts b/apps/web-app/hooks/shared/use-is-email.hook.spec.ts
deleted file mode 100644
index 66b88cfe9..000000000
--- a/apps/web-app/hooks/shared/use-is-email.hook.spec.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { renderHook } from '@testing-library/react-hooks';
-import { useIsEmail } from './use-is-email.hook';
-
-describe('useIsEmail', () => {
- it('should return true for a valid email address', () => {
- const { result } = renderHook(() => useIsEmail('test@example.com'));
- expect(result.current).toBe(true);
- });
-
- it('should return false for an invalid email address', () => {
- const { result } = renderHook(() => useIsEmail('invalid'));
- expect(result.current).toBe(false);
- });
-
- it('should return false for a webpage URL', () => {
- const { result } = renderHook(() => useIsEmail('https://www.example.com'));
- expect(result.current).toBe(false);
- });
-
- it('should return false for a webpage URL that contains an email address', () => {
- const { result } = renderHook(() =>
- useIsEmail('https://www.example.com?email=test@example.com')
- );
- expect(result.current).toBe(false);
- });
-
- it('should return false for an empty string', () => {
- const { result } = renderHook(() => useIsEmail(''));
- expect(result.current).toBe(false);
- });
-
- it('should return false for null', () => {
- const { result } = renderHook(() => useIsEmail(null));
- expect(result.current).toBe(false);
- });
-});
diff --git a/apps/web-app/hooks/shared/use-is-email.hook.ts b/apps/web-app/hooks/shared/use-is-email.hook.ts
deleted file mode 100644
index 067a71342..000000000
--- a/apps/web-app/hooks/shared/use-is-email.hook.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { useMemo } from 'react';
-
-export function useIsEmail(value: string | null): boolean {
- return useMemo(() => {
- if (value == null) {
- return false;
- }
-
- const emailRegex = /^(\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b)$/gi;
-
- return value.match(emailRegex) != null;
- }, [value]);
-}
diff --git a/apps/web-app/hooks/shared/use-md-viewer.ts b/apps/web-app/hooks/shared/use-md-viewer.ts
deleted file mode 100644
index bc5f48278..000000000
--- a/apps/web-app/hooks/shared/use-md-viewer.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-// import { remark } from 'remark';
-// import html from 'remark-html';
-// import matter from 'gray-matter';
-import { useEffect, useState } from 'react';
-// import marked from 'marked';
-import { Converter } from 'showdown';
-
-export function useMdViewer(content) {
- const [response, setResponse] = useState(null);
- const testCon = new Converter({
- tables: true
- , tasklists: true,
- ghMentions: true,
- splitAdjacentBlockquotes: true,
- underline: true,
- strikethrough: true,
- simplifiedAutoLink: true,
- simpleLineBreaks: true,
- emoji: true,
- openLinksInNewWindow:true
- });
-
- useEffect(() => {
- const fileContent = `
-| h1 | h2 | h3 |
-|-------|---------|---------|
-| 100 | [a][1] | ![b][2] |
-| *foo* | **bar** | ~~baz~~ |
-
-- Type some Markdown on the left
-
-## Tables
-
-| Left columns | Right columns |
-| ------------- |:-------------:|
-| left foo | right foo |
-| left bar | right bar |
-| left baz | right baz |
-> or formatting instructions.
-
-> Markdown is a lightweight markup language with plain-text-formatting syntax, created in 2004 by John Gruber with Aaron Swartz.
->
->> Markdown is often used to format readme files, for writing messages in online discussion forums, and to create rich text using a plain text editor.
-[![N|Solid](https://cldup.com/dTxpPi9lDf.thumb.png)](https://nodesource.com/products/nsolid)
-- [x] This task is done
-## Installation
-
-Dillinger requires [Node.js](https://nodejs.org/) v10+ to run.
-- ✨Magic ✨
-* cdnjs
-https://cdnjs.cloudflare.com/ajax/libs/showdown//showdown.min.js
-- [Introduction](#introduction)
-
-| Plugin | README |
-| ------ | ------ |
-| Dropbox | [plugins/dropbox/README.md][PlDb] |
-| GitHub | [plugins/github/README.md][PlGh] |
-| Google Drive | [plugins/googledrive/README.md][PlGd] |
-| OneDrive | [plugins/onedrive/README.md][PlOd] |
-| Medium | [plugins/medium/README.md][PlMe] |
-| Google Analytics | [plugins/googleanalytics/README.md][PlGa] |
-
- `;
- // const matterResult = matter(fileContent);
- // setResponse(matterResult.content);
-
- // const markedContent = marked.parse(fileContent);
- const test = testCon.makeHtml(content);
-
- setResponse(test);
-
-
- // remark()
- // .use(html)
- // .process(matterResult.content).then((res) => {
- // console.log(res.toString());
-
- // setResponse(res.toString());
- // });
- }, [content])
-
-
- return {
- response
- }
-}
\ No newline at end of file
diff --git a/apps/web-app/hooks/shared/use-tags-picker.ts b/apps/web-app/hooks/shared/use-tags-picker.ts
deleted file mode 100644
index 606f27095..000000000
--- a/apps/web-app/hooks/shared/use-tags-picker.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { useEffect, useState } from 'react';
-
-const useTagsPicker = (props: any) => {
- const defaultItems = props?.defaultItems ?? [];
- const alreadySelected = props?.selectedItems;
-
- const [selectedItems, setSelectedItems] = useState(alreadySelected);
- const [filteredOptions, setFilteredOptions] = useState(defaultItems);
- const [inputValue, setInputValue] = useState('');
- const [error, setError] = useState('');
-
- const onInputChange = (e: any) => {
- const searchText = e.target?.value ?? '';
- setInputValue(searchText);
- if(searchText===''){
- setError('');
- }
- let newDefaultItems = defaultItems;
- if (searchText) {
- newDefaultItems = defaultItems.filter((item: any) => item.toLowerCase().includes(searchText.toLowerCase()));
- }
- setFilteredOptions(newDefaultItems);
- };
-
- const findExactMatch = (tag: string) => {
- const tagLower = tag.toLowerCase();
- return defaultItems.find((item) => item.toLowerCase() === tagLower) || null;
- };
-
- const isValueExist = (tag: string) => {
- const tagLower = tag.toLowerCase();
- return selectedItems.find((item) => item.toLowerCase() === tagLower) || null;
- };
-
- const addCurrentInputValue = () => {
- if (inputValue.trim() !== '') {
- if (isValueExist(inputValue)) {
- setError('Tag already exists');
- } else {
- const existingValue = findExactMatch(inputValue);
- const newItem = existingValue || inputValue;
- setSelectedItems([...selectedItems, newItem]);
- setInputValue('');
- setFilteredOptions(defaultItems);
- setError('');
- }
- }
- };
-
- const onInputKeyDown = (e: any) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- addCurrentInputValue();
- }
- };
-
- const onItemsSelected = (value: string) => {
- if (selectedItems?.includes(value)) {
- setSelectedItems(selectedItems?.filter((item: any) => item !== value));
- } else {
- setSelectedItems([...selectedItems, value]);
- }
- };
-
- useEffect(() => {
- setSelectedItems(alreadySelected);
- }, [alreadySelected]);
-
- return {
- onItemsSelected,
- selectedItems,
- defaultItems,
- onInputChange,
- onInputKeyDown,
- inputValue,
- error,
- filteredOptions,
- addCurrentInputValue,
- };
-};
-
-export default useTagsPicker;
diff --git a/apps/web-app/index.d.ts b/apps/web-app/index.d.ts
deleted file mode 100644
index 7ba08fa17..000000000
--- a/apps/web-app/index.d.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-declare module '*.svg' {
- const content: any;
- export const ReactComponent: any;
- export default content;
-}
diff --git a/apps/web-app/jest.config.js b/apps/web-app/jest.config.js
deleted file mode 100644
index d8accadb8..000000000
--- a/apps/web-app/jest.config.js
+++ /dev/null
@@ -1,16 +0,0 @@
-module.exports = {
- displayName: 'web-app',
- preset: '../../jest.preset.js',
- transform: {
- '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nrwl/react/plugins/jest',
- '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nrwl/next/babel'] }],
- },
- moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
- coverageDirectory: '../../coverage/apps/web-app',
- /**
- * Enable `@testing-library/jest-dom` matchers.
- *
- * @see https://jestjs.io/docs/configuration#setupfilesafterenv-array
- */
- setupFilesAfterEnv: ['/jest.setup.ts'],
-};
diff --git a/apps/web-app/jest.setup.ts b/apps/web-app/jest.setup.ts
deleted file mode 100644
index 2203f7101..000000000
--- a/apps/web-app/jest.setup.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-/**
- * Enable `@testing-library/jest-dom` matchers such as toHaveTextContent or toHaveAttribute.
- */
-import '@testing-library/jest-dom';
diff --git a/apps/web-app/layouts/directory-layout.tsx b/apps/web-app/layouts/directory-layout.tsx
deleted file mode 100644
index 135e4b97d..000000000
--- a/apps/web-app/layouts/directory-layout.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Navbar } from '../components/layout/navbar/navbar';
-import posthog from 'posthog-js'
-import { PostHogProvider } from 'posthog-js/react'
-import ErrorBoundary from '../components/shared/error-boundary/ErrorBoundary';
-import { MobileNavbar } from '../components/layout/navbar/mobile-navbar';
-
-
-// Check that PostHog is client-side (used to handle Next.js SSR)
-if (typeof window !== 'undefined') {
- posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
- api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com',
- // Enable debug mode in development
- loaded: (posthog) => {
- if (process.env.NODE_ENV === 'development') posthog.debug()
- }
- })
-}
-
-export function DirectoryLayout(props:any) {
- const children = props.children;
- const isIrlPage = props.isIrlPage;
- // Second children is acutual page element.
- const childrens = children.props.children;
- return (
-
-
- {!isIrlPage && }
- {isIrlPage && <>
-
-
-
-
-
-
- );
-}
diff --git a/apps/web-app/pages/_error.tsx b/apps/web-app/pages/_error.tsx
deleted file mode 100644
index 841d0a833..000000000
--- a/apps/web-app/pages/_error.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import * as Sentry from '@sentry/nextjs';
-import NextErrorComponent from 'next/error';
-
-const MyError = ({ statusCode, hasGetInitialPropsRun, err }) => {
- if (!hasGetInitialPropsRun && err) {
- // getInitialProps is not called in case of
- // https://github.com/vercel/next.js/issues/8592. As a workaround, we pass
- // err via _app.js so it can be captured
- Sentry.captureException(err);
- // Flushing is not required in this case as it only happens on the client
- }
-
- return ;
-};
-
-MyError.getInitialProps = async (context) => {
- const errorInitialProps = await NextErrorComponent.getInitialProps(context);
-
- const { res, err, asPath } = context;
-
- // Workaround for https://github.com/vercel/next.js/issues/8592, mark when
- // getInitialProps has run
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (errorInitialProps as any).hasGetInitialPropsRun = true;
-
- // Returning early because we don't want to log 404 errors to Sentry.
- if (res?.statusCode === 404) {
- return errorInitialProps;
- }
-
- // Running on the server, the response object (`res`) is available.
- //
- // Next.js will pass an err on the server if a page's data fetching methods
- // threw or returned a Promise that rejected
- //
- // Running on the client (browser), Next.js will provide an err if:
- //
- // - a page's `getInitialProps` threw or returned a Promise that rejected
- // - an exception was thrown somewhere in the React lifecycle (render,
- // componentDidMount, etc) that was caught by Next.js's React Error
- // Boundary. Read more about what types of exceptions are caught by Error
- // Boundaries: https://reactjs.org/docs/error-boundaries.html
-
- if (err) {
- Sentry.captureException(err);
-
- // Flushing before returning is necessary if deploying to Vercel, see
- // https://vercel.com/docs/platform/limits#streaming-responses
- await Sentry.flush(2000);
-
- return errorInitialProps;
- }
-
- // If this point is reached, getInitialProps was called without any
- // information about what the error might be. This is unexpected and may
- // indicate a bug introduced in Next.js, so record it in Sentry
- Sentry.captureException(
- new Error(`_error.js getInitialProps missing data at path: ${asPath}`)
- );
- await Sentry.flush(2000);
-
- return errorInitialProps;
-};
-
-export default MyError;
diff --git a/apps/web-app/pages/changelog/index.tsx b/apps/web-app/pages/changelog/index.tsx
deleted file mode 100644
index a964a2edc..000000000
--- a/apps/web-app/pages/changelog/index.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import ChangeLogs from 'apps/web-app/components/changeLog/change-logs';
-import { DirectoryLayout } from 'apps/web-app/layouts/directory-layout';
-import { DIRECTORY_SEO } from 'apps/web-app/seo.config';
-import {
- convertCookiesToJson,
- renewAndStoreNewAccessToken,
-} from 'apps/web-app/utils/services/auth';
-import { GetServerSideProps } from 'next';
-import { NextSeo } from 'next-seo';
-import { destroyCookie } from 'nookies';
-import { ReactElement } from 'react';
-
-export default function ChangeLog() {
- return (
- <>
-
-
-
-
- Changelog
-
-
-
-
- >
- );
-}
-
-ChangeLog.getLayout = function getLayout(page: ReactElement) {
- return {page};
-};
-
-export const getServerSideProps: GetServerSideProps = async (ctx) => {
- const { req } = ctx;
- let cookies = req?.cookies;
- if (!cookies?.authToken) {
- await renewAndStoreNewAccessToken(cookies?.refreshToken, ctx);
- if (ctx.res.getHeader('Set-Cookie'))
- cookies = convertCookiesToJson(ctx.res.getHeader('Set-Cookie'));
- }
- destroyCookie(null, 'state');
- const userInfo = cookies?.userInfo ? JSON.parse(cookies?.userInfo) : {};
- const isUserLoggedIn = cookies?.authToken && cookies?.userInfo ? true : false;
- return {
- props: {
- isUserLoggedIn,
- userInfo,
- },
- };
-};
diff --git a/apps/web-app/pages/index.module.scss b/apps/web-app/pages/index.module.scss
deleted file mode 100644
index 8a13e21cb..000000000
--- a/apps/web-app/pages/index.module.scss
+++ /dev/null
@@ -1,2 +0,0 @@
-.page {
-}
diff --git a/apps/web-app/pages/index.tsx b/apps/web-app/pages/index.tsx
deleted file mode 100644
index 7fc7eeffc..000000000
--- a/apps/web-app/pages/index.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { GetServerSideProps } from 'next';
-import { ReactElement } from 'react';
-import { PortalDivider } from '../components/portal/portal-divider/portal-divider';
-import { Directory } from '../components/portal/sections/directory/directory';
-import { Faq } from '../components/portal/sections/faq/faq';
-import { Footer } from '../components/portal/sections/footer/footer';
-import { LabWeek } from '../components/portal/sections/labweek/labweek';
-import { Mission } from '../components/portal/sections/mission/mission';
-import { Projects } from '../components/portal/sections/projects/projects';
-import { Substack } from '../components/portal/sections/substack/substack';
-import { PortalLayout } from '../layouts/portal-layout';
-import api from '../utils/api';
-import { NW_SPOTLIGHT_CONSTANTS } from '../constants';
-
-export default function Index({videoDetails,playlistDetails}) {
- return (
-