diff --git a/.env.example b/.env.example index 8ebeba876..af921c8c4 100644 --- a/.env.example +++ b/.env.example @@ -116,7 +116,9 @@ ADMIN_LOGIN_USERNAME= # For local development, use: admin ADMIN_LOGIN_PASSWORD= - +# Revalidate cache API token +# For local development, use: random string +REVALIDATE_API_TOKEN= # Optional for local development diff --git a/apps/back-office/components/common/loader.tsx b/apps/back-office/components/common/loader.tsx index 2762e28df..8b14bc147 100644 --- a/apps/back-office/components/common/loader.tsx +++ b/apps/back-office/components/common/loader.tsx @@ -4,7 +4,7 @@ import APP_CONSTANTS from '../../utils/constants'; export default function Loader() { return ( //
-
+
{/* */}
diff --git a/apps/back-office/components/footer-buttons/footer-buttons.tsx b/apps/back-office/components/footer-buttons/footer-buttons.tsx index 84e2d4ae8..3d6c097da 100644 --- a/apps/back-office/components/footer-buttons/footer-buttons.tsx +++ b/apps/back-office/components/footer-buttons/footer-buttons.tsx @@ -1,29 +1,26 @@ import { CheckIcon, XIcon } from '@heroicons/react/outline'; import api from '../../utils/api'; import router from 'next/router'; +import Modal from '../modal/modal'; import APP_CONSTANTS, { API_ROUTE, + ENROLLMENT_TYPE, ROUTE_CONSTANTS, } from '../../utils/constants'; import { toast } from 'react-toastify'; +import { useState } from 'react'; export function FooterButtons(props) { + const [openModal, setOpenModal] = useState(false); const saveButtonClassName = props.disableSave ? 'shadow-special-button-default inline-flex w-full justify-center rounded-full bg-slate-400 px-6 py-2 text-base font-semibold leading-6 text-white outline-none' : 'on-focus leading-3.5 text-md mb-2 mr-2 flex items-center rounded-full border border-blue-600 bg-blue-600 px-4 py-3 text-left font-medium text-white last:mr-0 focus-within:rounded-full hover:border-slate-400 focus:rounded-full focus-visible:rounded-full'; - async function handleAprroveOrReject( - id, - type, - referenceUid, - isApproved, - setLoader - ) { + async function approvelClickHandler(id: any, status: any, isVerified: any, setLoader) { const data = { - status: isApproved - ? APP_CONSTANTS.APPROVED_FLAG - : APP_CONSTANTS.REJECTED_FLAG, - participantType: type, - ...(referenceUid && { referenceUid: referenceUid }), + status: status, + participantType: ENROLLMENT_TYPE.MEMBER, + isVerified, + uid: id, }; const configuration = { headers: { @@ -31,39 +28,92 @@ export function FooterButtons(props) { }, }; setLoader(true); - await api - .patch(`${API_ROUTE.PARTICIPANTS_REQUEST}/${id}`, data, configuration) - .then((res) => { - if (res?.data?.code == 1) { - const message = `${ - isApproved - ? APP_CONSTANTS.APPROVED_LABEL - : APP_CONSTANTS.REJECTED_LABEL - } successfully`; - toast(message); - } else { - toast(res?.data?.message); - } + try { + let message = ""; + setLoader(true); + if (props.from === "approved") { + await api.post(`${API_ROUTE.ADMIN_APPROVAL}`, { memberIds: [props.id] }, configuration); + message = `Successfully ${APP_CONSTANTS.VERIFIED_FLAG}`; + } else { + await api.patch(`${API_ROUTE.PARTICIPANTS_REQUEST}/${id}`, data, configuration) + message = status === "REJECTED" + ? `Successfully ${APP_CONSTANTS.REJECTED_LABEL}` + : `Successfully ${isVerified ? APP_CONSTANTS.VERIFIED_FLAG : APP_CONSTANTS.UNVERIFIED_FLAG}`; + } + setOpenModal(false) + toast(message); + router.push({ + pathname: ROUTE_CONSTANTS.PENDING_LIST, + }); + } catch (error: any) { + if (error.response?.status === 500) { router.push({ - pathname: ROUTE_CONSTANTS.PENDING_LIST, + pathname: ROUTE_CONSTANTS.INTERNAL_SERVER_ERROR, }); - }) - .catch((e) => { - if (e.response.status === 500) { - router.push({ - pathname: ROUTE_CONSTANTS.INTERNAL_SERVER_ERROR, - }); - } else if (e.response.status === 400) { - toast(e?.response?.data?.message); - } else { - toast(e?.message); - } - }) - .finally(() => setLoader(false)); + } else if (error.response?.status === 400) { + toast(error.response?.data?.message || 'Bad request'); + } else { + toast(error.message || 'An unexpected error occurred'); + } + } finally { + setOpenModal(false) + setLoader(false); + } + } + + const handleOpen = () => { + setOpenModal(true); + } + + const onClose = () => { + setOpenModal(false); } return (
+ {openModal && + +
+
+ +
+
+
+ Are you sure you want to reject? +
+
+ Clicking reject will remove the member from the list. +
+ +
+ + + +
+
+
+
+ } diff --git a/apps/back-office/components/member-request-list.tsx b/apps/back-office/components/member-request-list.tsx new file mode 100644 index 000000000..204341870 --- /dev/null +++ b/apps/back-office/components/member-request-list.tsx @@ -0,0 +1,165 @@ +import { Fragment, useEffect, useState } from 'react'; +import APP_CONSTANTS, { API_ROUTE, ENROLLMENT_TYPE, ROUTE_CONSTANTS } from '../utils/constants'; +import MemberTable from './member-table/member-table'; +import Tab from './tab/tab'; +import Loader from './common/loader'; +import router from 'next/router'; +import api from '../utils/api'; +import { useNavbarContext } from '../context/navbar-context'; + +const MemberRequestList = (props: any) => { + const dataList = [...props.members]; + const type = props?.type; + const [currentTab, setCurrentTab] = useState(APP_CONSTANTS.PENDING_FLAG); + const [isLoading, setIsLoading] = useState(false); + + const [allMembers, setAllMembers] = useState({ pending: [], unverified: [] }); + const { setMemberList } = useNavbarContext(); + + useEffect(() => { + if (type !== APP_CONSTANTS.CLOSED_FLAG) { + updateMembers(); + } + }, []); + + const updateMembers = async () => { + setIsLoading(true); + const config = { + headers: { + authorization: `Bearer ${props.plnadmin}`, + }, + }; + const [listData, unVerifiedMembers] = await Promise.all([ + api.get(`${API_ROUTE.PARTICIPANTS_REQUEST}?status=PENDING`, config), + api.get(`${API_ROUTE.MEMBERS}?isVerified=false&pagination=false`, config), + ]); + + const pendingMembers = listData.data.filter((item) => item.participantType === ENROLLMENT_TYPE.MEMBER); + + const formattedPendingMembes = pendingMembers?.map((data) => { + return { + id: data.uid, + name: data.newData.name, + status: data.status, + }; + }); + + const filteredUnVerifiedMembers = unVerifiedMembers.data.members.map((data) => { + return { + id: data.uid, + name: data.name, + isVerified: data?.isVerified || false, + }; + }); + + setAllMembers({ + pending: [...formattedPendingMembes], + unverified: [...filteredUnVerifiedMembers], + }); + setIsLoading(false); + setMemberList([...formattedPendingMembes, ...filteredUnVerifiedMembers]); + }; + + const availableTabs = [ + { label: APP_CONSTANTS.PENDING_LABEL, name: APP_CONSTANTS.PENDING_FLAG, count: allMembers.pending.length }, + { + label: APP_CONSTANTS.UNVERIFIED_LABEL, + name: APP_CONSTANTS.UNVERIFIED_FLAG, + count: allMembers.unverified.length, + }, + ]; + + const onTabSelected = (name: string) => { + setCurrentTab(name); + }; + + const onTabClickHandler = (name: string) => { + onTabSelected(name); + }; + + function redirectToDetail(request) { + setIsLoading(true); + const route = ROUTE_CONSTANTS.MEMBER_VIEW; + router.push({ + pathname: route, + query: { + id: request.id, + }, + }); + } + + return ( + <> +
+ {isLoading && } + + {type !== APP_CONSTANTS.CLOSED_FLAG && ( + <> +
+ {availableTabs.map((tab: any, index: number) => ( + + + + ))} +
+
+ +
+ + )} + + {type === APP_CONSTANTS.CLOSED_FLAG && ( + <> + {dataList && + dataList.map((request, index) => { + const borderClass = + dataList.length == 1 + ? 'rounded-xl' + : index == 0 + ? 'rounded-tl-xl rounded-tr-xl' + : index == dataList.length - 1 + ? 'rounded-bl-xl rounded-br-xl' + : ''; + return ( +
redirectToDetail(request)} + > +
+ {request?.name} + {request.status !== APP_CONSTANTS.PENDING_LABEL && ( + + {request.status === 'REJECTED' ? APP_CONSTANTS.REJECTED_LABEL : APP_CONSTANTS.APPROVED_LABEL} + + )} +
+
+ ); + })} + + )} +
+ + ); +}; + +export default MemberRequestList; diff --git a/apps/back-office/components/member-table/member-table.tsx b/apps/back-office/components/member-table/member-table.tsx new file mode 100644 index 000000000..82f93e578 --- /dev/null +++ b/apps/back-office/components/member-table/member-table.tsx @@ -0,0 +1,422 @@ +import api from 'apps/back-office/utils/api'; +import APP_CONSTANTS, { API_ROUTE, ENROLLMENT_TYPE, ROUTE_CONSTANTS } from 'apps/back-office/utils/constants'; +import router from 'next/router'; +import { useEffect, useState } from 'react'; +import { toast } from 'react-toastify'; +import Loader from '../common/loader'; +import { useNavbarContext } from 'apps/back-office/context/navbar-context'; +import Modal from '../modal/modal'; + +const MemberTable = (props: any) => { + const selectedTab = props?.selectedTab ?? ''; + const allMembers = props?.allMembers ?? []; + const updateMembers = props?.updateMembers; + + const [isAllSelected, setIsAllSelected] = useState(false); + const [selectedMembers, setSelectedMembes] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSort, setIsSort] = useState(false); + const { setMemberList } = useNavbarContext(); + const [openModal, setOpenModal] = useState(false); + const [rejectId, setRejectId] = useState([]); + + + // const onSortClickHandler = () => { + // setIsSort(!isSort); + // const sortedMembers = members.sort((a: any, b: any) => { + // if (isSort) { + // return a.name.localeCompare(b.name); + // } else { + // return b.name.localeCompare(a.name); + // } + // }); + // setAllMembers(sortedMembers); + // }; + + const onSelectAllClickHandler = () => { + setIsAllSelected(!isAllSelected); + if (isAllSelected) { + setSelectedMembes([]); + } else { + setSelectedMembes(allMembers.map((member: any) => member.id)); + } + }; + + const onMemberSelectHandler = (id: any) => { + if (selectedMembers.includes(id)) { + setIsAllSelected(false); + const filteredMembes = selectedMembers.filter((uid) => uid !== id); + setSelectedMembes(filteredMembes); + if (filteredMembes.length === allMembers.length) { + setIsAllSelected(true); + } + } else { + const addedMembes = [...selectedMembers, id]; + setSelectedMembes(addedMembes); + if (addedMembes.length === allMembers.length) { + setIsAllSelected(true); + } + } + }; + + function redirectToDetail(request) { + setIsLoading(true); + const route = ROUTE_CONSTANTS.MEMBER_VIEW; + const from = selectedTab === APP_CONSTANTS.PENDING_FLAG ? "pending" : "approved"; + router.push({ + pathname: route, + query: { + id: request.id, + from, + }, + }); + } + + async function approvelClickHandler(id: any, status: any, isVerified: any) { + const data = { + status: status, + participantType: ENROLLMENT_TYPE.MEMBER, + isVerified, + uid: id, + }; + const configuration = { + headers: { + authorization: `Bearer ${props.plnadmin}`, + }, + }; + setIsLoading(true); + try { + let message = ''; + setIsLoading(true); + if (selectedTab === APP_CONSTANTS.PENDING_FLAG) { + await api.post(`${API_ROUTE.PARTICIPANTS_REQUEST}`, [data], configuration); + message = `Successfully ${(status === APP_CONSTANTS.REJECTED_FLAG ? APP_CONSTANTS.REJECTED_LABEL : (isVerified ? APP_CONSTANTS.VERIFIED_FLAG : APP_CONSTANTS.UNVERIFIED_FLAG))}`; + } else { + await api.post(`${API_ROUTE.ADMIN_APPROVAL}`, { memberIds: [id] }, configuration); + message = `Successfully ${APP_CONSTANTS.VERIFIED_FLAG}`; + } + + updateMembers(); + toast(message); + } catch (error: any) { + if (error.response?.status === 500) { + router.push({ + pathname: ROUTE_CONSTANTS.INTERNAL_SERVER_ERROR, + }); + } else if (error.response?.status === 400) { + toast(error.response?.data?.message || 'Bad request'); + } else { + toast(error.message || 'An unexpected error occurred'); + } + } finally { + setIsLoading(false); + } + } + + async function bulkApprovedClickHandler(isVerified: any) { + const data = selectedMembers.map((memberId: any) => { + return { + uid: memberId, + status: APP_CONSTANTS.APPROVED_FLAG, + partcipantType: ENROLLMENT_TYPE.MEMBER, + isVerified, + }; + }); + const configuration = { + headers: { + authorization: `Bearer ${props.plnadmin}`, + }, + }; + try { + setSelectedMembes([]); + setIsLoading(true); + if (selectedTab === APP_CONSTANTS.PENDING_FLAG) { + await api.post(`${API_ROUTE.PARTICIPANTS_REQUEST}`, data, configuration); + } else { + const data = selectedMembers?.map((memberId: any) => memberId); + await api.post(`${API_ROUTE.ADMIN_APPROVAL}`, { memberIds: data }, configuration); + } + updateMembers(); + setIsAllSelected(false); + const message = `Successfully ${APP_CONSTANTS.APPROVED_LABEL}`; + toast(message); + } catch (error: any) { + if (error.response?.status === 500) { + router.push({ + pathname: ROUTE_CONSTANTS.INTERNAL_SERVER_ERROR, + }); + } else if (error.response?.status === 400) { + toast(error.response?.data?.message || 'Bad request'); + } else { + toast(error.message || 'An unexpected error occurred'); + } + } finally { + setIsLoading(false); + } + } + + const onRemoveClickHandler = async (members: any) => { + const data = members.map((memberId: any) => { + return { + uid: memberId, + status: APP_CONSTANTS.REJECTED_FLAG, + partcipantType: ENROLLMENT_TYPE.MEMBER, + }; + }); + const configuration = { + headers: { + authorization: `Bearer ${props.plnadmin}`, + }, + }; + try { + setIsLoading(true); + await api.post(`${API_ROUTE.PARTICIPANTS_REQUEST}`, data, configuration); + updateMembers(); + setSelectedMembes([]); + setIsAllSelected(false); + const message = `Successfully ${APP_CONSTANTS.REJECTED_LABEL}`; + setOpenModal(false) + toast(message); + } catch (error: any) { + if (error.response?.status === 500) { + router.push({ + pathname: ROUTE_CONSTANTS.INTERNAL_SERVER_ERROR, + }); + } else if (error.response?.status === 400) { + toast(error.response?.data?.message || 'Bad request'); + } else { + toast(error.message || 'An unexpected error occurred'); + } + } finally { + setOpenModal(false); + setIsLoading(false); + } + }; + + const handleOpen = (id: any) => { + setOpenModal(true); + if (Array.isArray(id)) { + setRejectId(id); + } else { + setRejectId(id); + } + } + + const onClose = () => { + setOpenModal(false); + } + + return ( + <> + {isLoading && } + {allMembers?.length > 0 && ( +
+ {/* Header */} +
+
+ + Applicant Name + {/* */} +
+
Actions
+
+ {/* Members */} +
+ {allMembers?.map((member: any, index: number) => { + const isSelected = selectedMembers.includes(member.id) || isAllSelected; + const isDisableOptions = selectedMembers.length > 0; + return ( +
+
+ + + {member.name} + +
+ + {/* Options */} +
+ + + {selectedTab === APP_CONSTANTS.PENDING_FLAG && ( + + )} + + + + {selectedTab === APP_CONSTANTS.PENDING_FLAG && ( + + )} +
+
+ ); + })} +
+
+ )} + {allMembers?.length === 0 && ( +
+
+ {APP_CONSTANTS.NO_DATA_AVAILABLE_LABEL} +
+
+ )} + + {selectedMembers?.length > 0 && ( +
+
+
+ {`${selectedMembers.length} Applicant${selectedMembers.length > 1 ? 's' : ''} selected`} +
+ +
+ {selectedTab === APP_CONSTANTS.PENDING_FLAG && ( + + )} + + + + {selectedTab === APP_CONSTANTS.PENDING_FLAG && ( + + )} +
+
+
+ )} + + {openModal && + +
+
+ +
+
+
+ Are you sure you want to reject? +
+
+ Clicking reject will remove the member from the list. +
+ +
+ + + +
+
+
+
+ } + + ); +}; + +export default MemberTable; diff --git a/apps/back-office/components/members/memberskillform.tsx b/apps/back-office/components/members/memberskillform.tsx index dd00b8e04..614a88480 100644 --- a/apps/back-office/components/members/memberskillform.tsx +++ b/apps/back-office/components/members/memberskillform.tsx @@ -1,4 +1,4 @@ -import { MultiSelect, Switch } from '@protocol-labs-network/ui'; +import { InputField, MultiSelect, Switch } from '@protocol-labs-network/ui'; import { TeamAndRoleGrid } from './teamandrole'; import { ReactComponent as InformationCircleIcon } from '../../public/assets/icons/info_icon.svg'; @@ -20,8 +20,8 @@ export default function AddMemberSkillForm(props) {
{teamAndRoles?.length > 0 && (
- Team* - Role* + Team + Role
)} {teamAndRoles?.map((item, index) => ( @@ -56,11 +56,22 @@ export default function AddMemberSkillForm(props) {
+
+ +
1 && props.isEditEnabled + teamRowId > 0 && props.isEditEnabled ? `cursor-pointer"` : `invisible` } diff --git a/apps/back-office/components/menu/menu.tsx b/apps/back-office/components/menu/menu.tsx index ab1e67361..05a7a57fc 100644 --- a/apps/back-office/components/menu/menu.tsx +++ b/apps/back-office/components/menu/menu.tsx @@ -2,6 +2,7 @@ import { UserGroupIcon, UserIcon } from '@heroicons/react/solid'; import APP_CONSTANTS, { ROUTE_CONSTANTS } from '../../utils/constants'; import { useNavbarContext } from '../../context/navbar-context'; import { useRouter } from 'next/router'; +import { useEffect } from 'react'; type HeroIcon = (props: React.ComponentProps<'svg'>) => JSX.Element; @@ -18,6 +19,7 @@ export function Menu() { setIsTeamActive, isTeamActive, isOpenRequest, + setMemberList, } = useNavbarContext(); // const [isTeamActive, setIsTeamActive] = useState(true); const MENU_ITEMS: IMenuItem[] = [ diff --git a/apps/back-office/components/request-list.tsx b/apps/back-office/components/request-list.tsx index f2bf76d3c..0033aea30 100644 --- a/apps/back-office/components/request-list.tsx +++ b/apps/back-office/components/request-list.tsx @@ -1,53 +1,33 @@ -import React from 'react'; -import APP_CONSTANTS, { ROUTE_CONSTANTS } from '../utils/constants'; import { InputField } from '@protocol-labs-network/ui'; -import { useNavbarContext } from '../context/navbar-context'; -import router from 'next/router'; -import { useState } from 'react'; -import { useEffect } from 'react'; -import Loader from '../components/common/loader'; +import { useEffect, useState } from 'react'; import { ReactComponent as SearchIcon } from '../public/assets/icons/searchicon.svg'; +import APP_CONSTANTS from '../utils/constants'; +import TeamRequestList from './team-request-list'; +import { useNavbarContext } from '../context/navbar-context'; +import MemberRequestList from './member-request-list'; -export default function RequestList({ list, type }) { - const { isTeamActive } = useNavbarContext(); - const [dataList, setDataList] = useState([]); - const [isLoading, setIsLoading] = useState(false); +export default function RequestList({ list, type, plnadmin }) { + const [dataList, setDataList] = useState(list); + const { isTeamActive, setMemberList } = useNavbarContext(); useEffect(() => { setDataList(list); - }, [list]); + }, [list, isTeamActive]); const backupList = list; - function redirectToDetail(request) { - setIsLoading(true); - const route = isTeamActive - ? ROUTE_CONSTANTS.TEAM_VIEW - : ROUTE_CONSTANTS.MEMBER_VIEW; - router.push({ - pathname: route, - query: { - id: request.id, - }, - }); - } function searchList(input = '') { if (input === '') { setDataList(backupList); } else { - setDataList( - backupList.filter((req) => - req.name.toLowerCase().includes(input.toLowerCase()) - ) - ); + setDataList(backupList.filter((req) => req.name.toLowerCase().includes(input.toLowerCase()))); } } return ( <> - {isLoading && }
-
+
{type !== APP_CONSTANTS.PENDING_LABEL && (
)} - {dataList && - dataList.map((request, index) => { - const borderClass = - dataList.length == 1 - ? 'rounded-xl' - : index == 0 - ? 'rounded-tl-xl rounded-tr-xl' - : index == dataList.length - 1 - ? 'rounded-bl-xl rounded-br-xl' - : ''; - return ( -
redirectToDetail(request)} - > -
- - {request?.name} - - {request.status !== APP_CONSTANTS.PENDING_LABEL && ( - - {request.status === 'REJECTED' - ? APP_CONSTANTS.REJECTED_LABEL - : APP_CONSTANTS.APPROVED_LABEL} - - )} -
-
- ); - })} - {dataList.length === 0 && ( + {dataList.length > 0 && <>{isTeamActive && }} + {!isTeamActive && } + {dataList.length === 0 && isTeamActive && (
- - {APP_CONSTANTS.NO_DATA_AVAILABLE_LABEL} - + {APP_CONSTANTS.NO_DATA_AVAILABLE_LABEL}
)} diff --git a/apps/back-office/components/tab/tab.tsx b/apps/back-office/components/tab/tab.tsx new file mode 100644 index 000000000..277f48f32 --- /dev/null +++ b/apps/back-office/components/tab/tab.tsx @@ -0,0 +1,16 @@ + + +const Tab = (props: any) => { + const name=props?.name ?? ""; + const isSelected = props?.isSelected ?? false; + const onTabClickHandler = props?.onClick; + const count = props?.count ?? 0; + + + return ( + + ) + +} + +export default Tab; \ No newline at end of file diff --git a/apps/back-office/components/team-request-list.tsx b/apps/back-office/components/team-request-list.tsx new file mode 100644 index 000000000..21ec423f8 --- /dev/null +++ b/apps/back-office/components/team-request-list.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import APP_CONSTANTS, { ROUTE_CONSTANTS } from "../utils/constants"; +import Loader from "./common/loader"; +import { useNavbarContext } from "../context/navbar-context"; +import router from 'next/router'; + + + +const TeamRequestList = (props: any) => { + const dataList = props?.teams; + const [isLoading, setIsLoading] = useState(false); + + + function redirectToDetail(request) { + setIsLoading(true); + const route = ROUTE_CONSTANTS.TEAM_VIEW + router.push({ + pathname: route, + query: { + id: request.id, + }, + }); + } + + return <> + {isLoading && } + {dataList && + dataList.map((request, index) => { + const borderClass = + dataList.length == 1 + ? 'rounded-xl' + : index == 0 + ? 'rounded-tl-xl rounded-tr-xl' + : index == dataList.length - 1 + ? 'rounded-bl-xl rounded-br-xl' + : ''; + return ( +
redirectToDetail(request)} + > +
+ + {request?.name} + + {request.status !== APP_CONSTANTS.PENDING_LABEL && ( + + {request.status === 'REJECTED' + ? APP_CONSTANTS.REJECTED_LABEL + : APP_CONSTANTS.APPROVED_LABEL} + + )} +
+
+ ); + })} + +} + +export default TeamRequestList; \ No newline at end of file diff --git a/apps/back-office/layout/approval-layout.tsx b/apps/back-office/layout/approval-layout.tsx index bd5a52537..9dc66795f 100644 --- a/apps/back-office/layout/approval-layout.tsx +++ b/apps/back-office/layout/approval-layout.tsx @@ -2,9 +2,9 @@ import { Navbar } from '../components/navbar/navbar'; export function ApprovalLayout({ children }) { return ( -
+
-
+
{children}
diff --git a/apps/back-office/pages/closed-list.tsx b/apps/back-office/pages/closed-list.tsx index f1ab96773..eb0f3a7e9 100644 --- a/apps/back-office/pages/closed-list.tsx +++ b/apps/back-office/pages/closed-list.tsx @@ -13,31 +13,19 @@ type RequestList = { }; export default function ClosedList(props) { - const { - setIsOpenRequest, - setMemberList, - setTeamList, - isTeamActive, - setShowMenu, - } = useNavbarContext(); + const { setIsOpenRequest, setMemberList, setTeamList, isTeamActive, setShowMenu } = useNavbarContext(); setShowMenu(true); useEffect(() => { setMemberList(props.memberList); setTeamList(props.teamList); setIsOpenRequest(false); - }, [ - isTeamActive, - setMemberList, - props.memberList, - props.teamList, - setTeamList, - setIsOpenRequest, - ]); + }, [isTeamActive, setMemberList, props.memberList, props.teamList, setTeamList, setIsOpenRequest]); return ( @@ -45,9 +33,7 @@ export default function ClosedList(props) { ); } -export const getServerSideProps: GetServerSideProps = async ( - context -) => { +export const getServerSideProps: GetServerSideProps = async (context) => { const { plnadmin } = parseCookies(context); if (!plnadmin) { @@ -76,7 +62,7 @@ export const getServerSideProps: GetServerSideProps = async ( teamResponse = listData.data.filter( (item) => item.participantType === ENROLLMENT_TYPE.TEAM && - item.status !== APP_CONSTANTS.PENDING_LABEL && + item.status !== APP_CONSTANTS.PENDING_LABEL && item.status !== APP_CONSTANTS.AUTO_APPROVED_LABEL ); memberResponse = listData.data.filter( @@ -88,14 +74,14 @@ export const getServerSideProps: GetServerSideProps = async ( member = memberResponse?.map((data) => { return { id: data.uid, - name: data.newData.name, + name: data.newData.name ?? '', status: data.status, }; }); team = teamResponse?.map((data) => { return { id: data.uid, - name: data.newData.name, + name: data.newData.name ?? '', status: data.status, }; }); @@ -107,6 +93,7 @@ export const getServerSideProps: GetServerSideProps = async ( teamList: team, teamCount: team?.length ?? 0, memberCount: member?.length ?? 0, + plnadmin, }, }; }; diff --git a/apps/back-office/pages/member-view.tsx b/apps/back-office/pages/member-view.tsx index 612fa2cea..e26f91c95 100644 --- a/apps/back-office/pages/member-view.tsx +++ b/apps/back-office/pages/member-view.tsx @@ -29,32 +29,32 @@ function validateBasicForm(formValues, imageUrl) { if (!formValues.email.trim() || !formValues.email?.trim().match(emailRE)) { errors.push('Please add valid Email'); } - if ( - !formValues.requestorEmail?.trim() || - !formValues.requestorEmail?.trim().match(emailRE) - ) { - errors.push('Please add a valid Requestor Email'); - } + // if ( + // !formValues.requestorEmail?.trim() || + // !formValues.requestorEmail?.trim().match(emailRE) + // ) { + // errors.push('Please add a valid Requestor Email'); + // } return errors; } -function validateSkillForm(formValues) { - const errors = []; - if (!formValues.teamAndRoles.length) { - errors.push('Please add your Team and Role details'); - } else { - const missingValues = formValues.teamAndRoles.filter( - (item) => item.teamUid == '' || item.role == '' - ); - if (missingValues.length) { - errors.push('Please add missing Team(s)/Role(s)'); - } - } - if (!formValues.skills.length) { - errors.push('Please add your skill details'); - } - return errors; -} +// function validateSkillForm(formValues) { +// const errors = []; +// if (!formValues.teamAndRoles.length) { +// errors.push('Please add your Team and Role details'); +// } else { +// const missingValues = formValues.teamAndRoles.filter( +// (item) => item.teamUid == '' || item.role == '' +// ); +// if (missingValues.length) { +// errors.push('Please add missing Team(s)/Role(s)'); +// } +// } +// if (!formValues.skills.length) { +// errors.push('Please add your skill details'); +// } +// return errors; +// } function validateForm(formValues, imageUrl) { let errors = []; @@ -62,10 +62,10 @@ function validateForm(formValues, imageUrl) { if (basicFormErrors.length) { errors = [...errors, ...basicFormErrors]; } - const skillFormErrors = validateSkillForm(formValues); - if (skillFormErrors.length) { - errors = [...errors, ...skillFormErrors]; - } + // const skillFormErrors = validateSkillForm(formValues); + // if (skillFormErrors.length) { + // errors = [...errors, ...skillFormErrors]; + // } return errors; } @@ -85,7 +85,7 @@ export default function MemberView(props) { const [disableSave, setDisableSave] = useState(false); const [formValues, setFormValues] = useState(props?.formValues); const [isLoading, setIsLoading] = useState(false); - const [resetImg, setResetImg] = useState(false); + const [resetImg, setResetImg] = useState(false); const { setIsOpenRequest, setMemberList, @@ -102,7 +102,7 @@ export default function MemberView(props) { useEffect(() => { setDropDownValues({ skillValues: props?.skills, teamNames: props?.teams }); }, [props]); - + const handleResetImg = () => { setResetImg(false); } @@ -128,15 +128,16 @@ export default function MemberView(props) { twitterHandler: formValues.twitterHandler?.trim(), githubHandler: formValues.githubHandler?.trim(), telegramHandler: formValues.telegramHandler?.trim(), - officeHours: formValues.officeHours?.trim() === ''? null : formValues.officeHours?.trim(), + officeHours: formValues.officeHours?.trim() === '' ? null : formValues.officeHours?.trim(), comments: formValues.comments?.trim(), + teamOrProjectURL: formValues.teamOrProjectURL, plnStartDate: formValues.plnStartDate ? new Date(formValues.plnStartDate)?.toISOString() : null, skills: skills, teamAndRoles: formattedTeamAndRoles, openToWork: formValues.openToWork, - projectContributions:formValues.projectContributions, + projectContributions: formValues.projectContributions, oldName: name, }; delete formattedData.requestorEmail; @@ -203,21 +204,33 @@ export default function MemberView(props) { const data = { participantType: ENROLLMENT_TYPE.MEMBER, // referenceUid: props.id, - requesterEmailId: requestorEmail, + requesterEmailId: requestorEmail ? requestorEmail : null, uniqueIdentifier: values.email, newData: { ...values, imageUid: image?.uid ?? values.imageUid, imageUrl: image?.url ?? imageUrl, }, - }; - const configuration = { + }; + const configuration = { headers: { authorization: `Bearer ${props.plnadmin}`, }, }; - await api + if(props?.from === "approved") { + await api.patch( + `${API_ROUTE.ADMIN_APPROVAL}/${props.id}`, + data, + configuration + ) + .then((response) => { + setSaveCompleted(true); + setIsEditEnabled(false); + setResetImg(true); + }); + } else { + await api .put( `${API_ROUTE.PARTICIPANTS_REQUEST}/${props.id}`, data, @@ -228,6 +241,7 @@ export default function MemberView(props) { setIsEditEnabled(false); setResetImg(true); }); + } } catch (err) { toast(err?.message); console.log('error', err); @@ -329,7 +343,6 @@ export default function MemberView(props) {
)} )} @@ -393,9 +407,10 @@ export default function MemberView(props) { } export const getServerSideProps = async (context) => { - const { id, backLink = ROUTE_CONSTANTS.PENDING_LIST } = context.query as { + const { id, from, backLink = ROUTE_CONSTANTS.PENDING_LIST } = context.query as { id: string; backLink: string; + from: string; }; const { plnadmin } = parseCookies(context); @@ -421,117 +436,176 @@ export const getServerSideProps = async (context) => { let teamList = []; let oldName = ''; - // Check if provided ID is an Airtable ID, and if so, get the corresponding backend UID - - const [ - requestDetailResponse, - allRequestResponse, - memberTeamsResponse, - skillsResponse, - ] = await Promise.all([ - api.get(`${API_ROUTE.PARTICIPANTS_REQUEST}/${id}`, config), - api.get(API_ROUTE.PARTICIPANTS_REQUEST, config), - api.get(API_ROUTE.TEAMS), - api.get(API_ROUTE.SKILLS), - ]); - - if ( - requestDetailResponse.status === 200 && - allRequestResponse.status === 200 && - memberTeamsResponse.status === 200 && - skillsResponse.status === 200 - ) { - teamList = allRequestResponse?.data?.filter( - (item) => item.participantType === ENROLLMENT_TYPE.TEAM - ); - memberList = allRequestResponse?.data?.filter( - (item) => item.participantType === ENROLLMENT_TYPE.MEMBER - ); + + if (from !== "approved") { + const [ + requestDetailResponse, + allRequestResponse, + memberTeamsResponse, + skillsResponse, + ] = await Promise.all([ + api.get(`${API_ROUTE.PARTICIPANTS_REQUEST}/${id}`, config), + api.get(API_ROUTE.PARTICIPANTS_REQUEST, config), + api.get(API_ROUTE.TEAMS), + api.get(API_ROUTE.SKILLS), + ]); - let counter = 1; - referenceUid = requestDetailResponse?.data?.referenceUid ?? ''; - const requestData = requestDetailResponse?.data?.newData; - oldName = requestData?.oldName ?? requestData?.name; - status = requestDetailResponse?.data?.status; - const teamAndRoles = - requestData.teamAndRoles?.length && - requestData.teamAndRoles.map((team) => { + if ( + requestDetailResponse.status === 200 && + allRequestResponse.status === 200 && + memberTeamsResponse.status === 200 && + skillsResponse.status === 200 + ) { + teamList = allRequestResponse?.data?.filter( + (item) => item.participantType === ENROLLMENT_TYPE.TEAM + ); + memberList = allRequestResponse?.data?.filter( + (item) => item.participantType === ENROLLMENT_TYPE.MEMBER + ); + + let counter = 1; + referenceUid = requestDetailResponse?.data?.referenceUid ?? ''; + const requestData = requestDetailResponse?.data?.newData; + oldName = requestData?.oldName ?? requestData?.name; + status = requestDetailResponse?.data?.status; + const teamAndRoles = + requestData?.teamAndRoles?.length && + requestData?.teamAndRoles?.map((team) => { + return { + role: team.role ?? "", + teamUid: team.teamUid, + teamTitle: team.teamTitle, + rowId: counter++, + }; + }); + + formValues = { + name: requestData?.name, + email: requestData?.email, + imageUid: requestData?.imageUid ?? '', + imageFile: null, + plnStartDate: requestData?.plnStartDate + ? new Date(requestData?.plnStartDate).toISOString().split('T')[0] + : null, + city: requestData?.city ?? '', + region: requestData?.region ?? '', + country: requestData?.country ?? '', + linkedinHandler: requestData?.linkedinHandler ?? '', + discordHandler: requestData?.discordHandler ?? '', + twitterHandler: requestData?.twitterHandler ?? '', + githubHandler: requestData?.githubHandler ?? '', + telegramHandler: requestData?.telegramHandler ?? '', + officeHours: requestData?.officeHours ?? '', + requestorEmail: requestDetailResponse?.data?.requesterEmailId ?? '', + comments: requestData?.comments ?? '', + teamAndRoles: teamAndRoles || [ + // { teamUid: '', teamTitle: '', role: '', rowId: 1 }, + ], + teamOrProjectURL: requestData?.teamOrProjectURL ?? '', + skills: requestData?.skills?.map((item) => { + return { value: item.uid, label: item.title }; + }) || [], + openToWork: requestData?.openToWork ?? '', + projectContributions: requestData?.projectContributions ?? [] + }; + imageUrl = requestData?.imageUrl ?? ''; + + if (status == APP_CONSTANTS.PENDING_LABEL) { + teamList = allRequestResponse?.data + ?.filter((item) => item.participantType === ENROLLMENT_TYPE.TEAM) + ?.filter((item) => item.status === APP_CONSTANTS.PENDING_LABEL); + memberList = allRequestResponse?.data + ?.filter((item) => item.participantType === ENROLLMENT_TYPE.MEMBER) + .filter((item) => item.status === APP_CONSTANTS.PENDING_LABEL); + } else { + teamList = allRequestResponse?.data + ?.filter((item) => item.participantType === ENROLLMENT_TYPE.TEAM) + ?.filter((item) => item.status !== APP_CONSTANTS.PENDING_LABEL); + memberList = allRequestResponse?.data + ?.filter((item) => item.participantType === ENROLLMENT_TYPE.MEMBER) + .filter((item) => item.status !== APP_CONSTANTS.PENDING_LABEL); + } + + teams = Array.isArray(memberTeamsResponse?.data) ? + memberTeamsResponse?.data?.map((item) => { + return { value: item.uid, label: item.name }; + }) : []; + skills = skillsResponse?.data?.map((item) => { + return { value: item.uid, label: item.title }; + }); + } + } else { + const approvedApiResponse = await api.get(`${API_ROUTE.MEMBERS}/${id}?with=image`, config); + const skillsResponse = await api.get(API_ROUTE.SKILLS); + + let counter = 1; + if (approvedApiResponse.status === 200) { + const requestData = approvedApiResponse?.data; + const teamAndRoles = + requestData?.teamMemberRoles?.length && + requestData?.teamMemberRoles?.map((team) => { return { - role: team.role, + role: team.role ?? "", teamUid: team.teamUid, - teamTitle: team.teamTitle, + teamTitle: team.team.name, rowId: counter++, }; }); - - formValues = { - name: requestData?.name, - email: requestData.email, - imageUid: requestData.imageUid ?? '', - imageFile: null, - plnStartDate: requestData.plnStartDate - ? new Date(requestData.plnStartDate).toISOString().split('T')[0] - : null, - city: requestData?.city ?? '', - region: requestData?.region ?? '', - country: requestData?.country ?? '', - linkedinHandler: requestData.linkedinHandler ?? '', - discordHandler: requestData.discordHandler ?? '', - twitterHandler: requestData.twitterHandler ?? '', - githubHandler: requestData.githubHandler ?? '', - telegramHandler: requestData.telegramHandler ?? '', - officeHours: requestData.officeHours ?? '', - requestorEmail: requestDetailResponse?.data?.requesterEmailId ?? '', - comments: requestData?.comments ?? '', - teamAndRoles: teamAndRoles || [ - { teamUid: '', teamTitle: '', role: '', rowId: 1 }, - ], - skills: requestData.skills?.map((item) => { - return { value: item.uid, label: item.title }; - }), - openToWork: requestData?.openToWork ?? '', - projectContributions:requestData?.projectContributions ?? [] - }; - imageUrl = requestData?.imageUrl ?? ''; - - if (status == APP_CONSTANTS.PENDING_LABEL) { - teamList = allRequestResponse?.data - ?.filter((item) => item.participantType === ENROLLMENT_TYPE.TEAM) - ?.filter((item) => item.status === APP_CONSTANTS.PENDING_LABEL); - memberList = allRequestResponse?.data - ?.filter((item) => item.participantType === ENROLLMENT_TYPE.MEMBER) - .filter((item) => item.status === APP_CONSTANTS.PENDING_LABEL); - } else { - teamList = allRequestResponse?.data - ?.filter((item) => item.participantType === ENROLLMENT_TYPE.TEAM) - ?.filter((item) => item.status !== APP_CONSTANTS.PENDING_LABEL); - memberList = allRequestResponse?.data - ?.filter((item) => item.participantType === ENROLLMENT_TYPE.MEMBER) - .filter((item) => item.status !== APP_CONSTANTS.PENDING_LABEL); + formValues = { + name: requestData?.name, + email: requestData?.email, + imageUid: requestData?.imageUid ?? '', + imageFile: null, + plnStartDate: requestData?.plnStartDate + ? new Date(requestData?.plnStartDate).toISOString().split('T')[0] + : null, + city: requestData?.city ?? '', + region: requestData?.region ?? '', + country: requestData?.country ?? '', + linkedinHandler: requestData?.linkedinHandler ?? '', + discordHandler: requestData?.discordHandler ?? '', + twitterHandler: requestData?.twitterHandler ?? '', + githubHandler: requestData?.githubHandler ?? '', + telegramHandler: requestData?.telegramHandler ?? '', + officeHours: requestData?.officeHours ?? '', + comments: requestData?.comments ?? '', + teamAndRoles: teamAndRoles || + [ + // { teamUid: '', teamTitle: '', role: '', rowId: 1 }, + ], + teamOrProjectURL: requestData?.teamOrProjectURL ?? '', + skills: requestData?.skills?.map((item) => { + return { value: item.uid, label: item.title }; + }), + openToWork: requestData?.openToWork ?? '', + projectContributions: requestData?.projectContributions ?? [] + }; + imageUrl = requestData?.image?.url ?? '', + teamList = approvedApiResponse?.data?.teamList ?? []; + memberList = approvedApiResponse?.data?.memberList ?? []; + teams = approvedApiResponse?.data?.teams ?? []; + skills = skillsResponse?.data?.map((item) => { + return { value: item.uid, label: item.title }; + }); + status= APP_CONSTANTS.PENDING_LABEL; + } } - teams = memberTeamsResponse?.data?.map((item) => { - return { value: item.uid, label: item.name }; - }); - skills = skillsResponse?.data?.map((item) => { - return { value: item.uid, label: item.title }; - }); - } - - return { - props: { - formValues, - teams, - skills, - id, - referenceUid, - imageUrl, - status, - backLink, - teamList, - memberList, - plnadmin, - oldName, - }, + return { + props: { + formValues, + teams, + skills, + id, + // referenceUid, + imageUrl, + status, + backLink, + teamList, + memberList, + plnadmin, + oldName, + from, + }, + }; }; -}; diff --git a/apps/back-office/pages/pending-list.tsx b/apps/back-office/pages/pending-list.tsx index b91ddbc1a..aaaafa1a6 100644 --- a/apps/back-office/pages/pending-list.tsx +++ b/apps/back-office/pages/pending-list.tsx @@ -9,41 +9,27 @@ import { ApprovalLayout } from '../layout/approval-layout'; import { parseCookies } from 'nookies'; export default function PendingList(props) { - const { - setIsOpenRequest, - setMemberList, - setTeamList, - isTeamActive, - setShowMenu, - } = useNavbarContext(); + const { setIsOpenRequest, setMemberList, setTeamList, isTeamActive, setShowMenu, memberList, teamList } = useNavbarContext(); setShowMenu(true); useEffect(() => { - setMemberList(props.memberList); + setMemberList([...props.memberList, ...props.unverifiedMembers]); setTeamList(props.teamList); setIsOpenRequest(true); - }, [ - isTeamActive, - setMemberList, - props.memberList, - props.teamList, - setTeamList, - setIsOpenRequest, - ]); + }, [isTeamActive, setMemberList, props.memberList, props.teamList, setTeamList, setIsOpenRequest]); return ( ); } -export const getServerSideProps: GetServerSideProps = async ( - context -) => { +export const getServerSideProps: GetServerSideProps = async (context) => { const { plnadmin } = parseCookies(context); if (!plnadmin) { @@ -61,21 +47,18 @@ export const getServerSideProps: GetServerSideProps = async ( authorization: `Bearer ${plnadmin}`, }, }; - const listData = await api.get( - `${API_ROUTE.PARTICIPANTS_REQUEST}?status=PENDING`, - config - ); + const listData = await api.get(`${API_ROUTE.PARTICIPANTS_REQUEST}?status=PENDING`, config); + const unVerifiedMembes = await api.get(`${API_ROUTE.MEMBERS}?isVerified=false&pagination=false`, config); + let memberResponse = []; let teamResponse = []; let team = []; let member = []; + let membersCount = 0; + let unverifiedMembers = []; if (listData.data) { - teamResponse = listData.data.filter( - (item) => item.participantType === ENROLLMENT_TYPE.TEAM - ); - memberResponse = listData.data.filter( - (item) => item.participantType === ENROLLMENT_TYPE.MEMBER - ); + teamResponse = listData.data.filter((item) => item.participantType === ENROLLMENT_TYPE.TEAM); + memberResponse = listData.data.filter((item) => item.participantType === ENROLLMENT_TYPE.MEMBER); member = memberResponse?.map((data) => { return { id: data.uid, @@ -83,6 +66,14 @@ export const getServerSideProps: GetServerSideProps = async ( status: data.status, }; }); + unverifiedMembers = unVerifiedMembes.data.members.map((data) => { + return { + id: data.uid, + name: data.name, + isVerified: data?.isVerified || false, + }; + }); + team = teamResponse?.map((data) => { return { id: data.uid, @@ -91,12 +82,15 @@ export const getServerSideProps: GetServerSideProps = async ( }; }); } + membersCount = member?.length + unverifiedMembers?.length; return { props: { memberList: member, + unverifiedMembers: unverifiedMembers, teamList: team, teamCount: team?.length ?? 0, - memberCount: member?.length ?? 0, + memberCount: membersCount ?? 0, + plnadmin, }, }; }; diff --git a/apps/back-office/public/assets/icons/TrashIcon.svg b/apps/back-office/public/assets/icons/TrashIcon.svg new file mode 100644 index 000000000..de038b51c --- /dev/null +++ b/apps/back-office/public/assets/icons/TrashIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/back-office/public/assets/icons/upgrade-rounded.svg b/apps/back-office/public/assets/icons/upgrade-rounded.svg new file mode 100644 index 000000000..c9cec6c84 --- /dev/null +++ b/apps/back-office/public/assets/icons/upgrade-rounded.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/back-office/public/assets/images/delete-disabled.svg b/apps/back-office/public/assets/images/delete-disabled.svg new file mode 100644 index 000000000..31668aeae --- /dev/null +++ b/apps/back-office/public/assets/images/delete-disabled.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/back-office/public/assets/images/delete.svg b/apps/back-office/public/assets/images/delete.svg new file mode 100644 index 000000000..353162086 --- /dev/null +++ b/apps/back-office/public/assets/images/delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/back-office/public/assets/images/sort-unselected.svg b/apps/back-office/public/assets/images/sort-unselected.svg new file mode 100644 index 000000000..3526a2a6e --- /dev/null +++ b/apps/back-office/public/assets/images/sort-unselected.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/back-office/public/assets/images/unverified-disabled.svg b/apps/back-office/public/assets/images/unverified-disabled.svg new file mode 100644 index 000000000..500058f0a --- /dev/null +++ b/apps/back-office/public/assets/images/unverified-disabled.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/back-office/public/assets/images/unverified.svg b/apps/back-office/public/assets/images/unverified.svg new file mode 100644 index 000000000..1af9691f9 --- /dev/null +++ b/apps/back-office/public/assets/images/unverified.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/back-office/public/assets/images/verified-disabled.svg b/apps/back-office/public/assets/images/verified-disabled.svg new file mode 100644 index 000000000..500058f0a --- /dev/null +++ b/apps/back-office/public/assets/images/verified-disabled.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/back-office/public/assets/images/verified.svg b/apps/back-office/public/assets/images/verified.svg new file mode 100644 index 000000000..fda0c6a23 --- /dev/null +++ b/apps/back-office/public/assets/images/verified.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/back-office/utils/constants.ts b/apps/back-office/utils/constants.ts index e010c32c2..91ed19d14 100644 --- a/apps/back-office/utils/constants.ts +++ b/apps/back-office/utils/constants.ts @@ -2,6 +2,10 @@ const APP_CONSTANTS = { AUTO_APPROVED_LABEL:'AUTOAPPROVED', APPROVED_LABEL: 'Approved', PENDING_LABEL: 'PENDING', + PENDING_FLAG: "Pending", + UNVERIFIED_LABEL: 'UNVERIFIED', + UNVERIFIED_FLAG: 'Unverified', + VERIFIED_FLAG: 'Verified', REJECTED_LABEL: 'Rejected', APPROVED_FLAG: 'APPROVED', REJECTED_FLAG: 'REJECTED', @@ -40,6 +44,7 @@ export const API_ROUTE = { INDUSTRIES: APP_CONSTANTS.V1 + 'industry-tags', TECHNOLOGIES: APP_CONSTANTS.V1 + 'technologies', MEMBERS: APP_CONSTANTS.V1 + 'members', + ADMIN_APPROVAL: APP_CONSTANTS.V1 + 'admin/members', }; export const TOKEN = 'plnetwork@1'; diff --git a/apps/back-office/utils/members.types.ts b/apps/back-office/utils/members.types.ts index 0544c383a..1ad4560ff 100644 --- a/apps/back-office/utils/members.types.ts +++ b/apps/back-office/utils/members.types.ts @@ -66,4 +66,5 @@ export interface IFormValues { skills: Skill[]; openToWork: boolean; projectContributions: IProjectContribution[]; + teamOrProjectURL: string; } diff --git a/apps/back-office/utils/services/team.ts b/apps/back-office/utils/services/team.ts index ac1c2121f..4ca1390d9 100644 --- a/apps/back-office/utils/services/team.ts +++ b/apps/back-office/utils/services/team.ts @@ -18,7 +18,7 @@ export const fetchTeamsForAutocomplete = async (searchTerm) => { try { const response = await api.get(`/v1/teams?name__istartswith=${searchTerm}`); if (response.data) { - return response.data.map((item) => { + return response.data?.teams?.map((item) => { return { value: item.uid, label: item.name }; }); } diff --git a/apps/web-api/prisma/fixtures/members.ts b/apps/web-api/prisma/fixtures/members.ts index a53ca402e..8413b96c1 100644 --- a/apps/web-api/prisma/fixtures/members.ts +++ b/apps/web-api/prisma/fixtures/members.ts @@ -53,8 +53,24 @@ const membersFactory = Factory.define>( plnStartDate: faker.date.past(), updatedAt: faker.date.recent(), locationUid: '', + signUpSource: faker.company.name(), + signUpCampaign: faker.company.name(), + signUpMedium: faker.company.name(), + isVerified: faker.datatype.boolean(), + isUserConsent: faker.datatype.boolean(), + isSubscribedToNewsletter: faker.datatype.boolean(), + teamOrProjectURL: faker.internet.url(), openToWork: faker.datatype.boolean(), - preferences: {showEmail:true,showGithubHandle:true,showTelegram:true,showLinkedin:true,showDiscord:false,showGithubProjects:false,showTwitter:true} + preferences: { + showEmail:true, + showGithubHandle:true, + showTelegram:true, + showLinkedin:true, + showDiscord:false, + showGithubProjects:false, + showTwitter:true, + showSubscription:true + } }; } ); diff --git a/apps/web-api/prisma/migrations/20241127093907_member_signup/migration.sql b/apps/web-api/prisma/migrations/20241127093907_member_signup/migration.sql new file mode 100644 index 000000000..ea6e2f518 --- /dev/null +++ b/apps/web-api/prisma/migrations/20241127093907_member_signup/migration.sql @@ -0,0 +1,22 @@ +/* + Warnings: + + - A unique constraint covering the columns `[memberUid,teamUid,eventUid]` on the table `PLEventGuest` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Member" +ADD COLUMN "isVerified" BOOLEAN DEFAULT false, +ADD COLUMN "signUpSource" TEXT, +ADD COLUMN "isSubscribedToNewsletter" BOOLEAN DEFAULT false, +ADD COLUMN "isUserConsent" BOOLEAN DEFAULT false, +ADD COLUMN "teamOrProjectURL" TEXT, +ADD COLUMN "signUpCampaign" TEXT, +ADD COLUMN "signUpMedium" TEXT; + +-- Modify the "plnFriend" column to drop NOT NULL constraint +ALTER TABLE "Member" +ALTER COLUMN "plnFriend" DROP NOT NULL; + + + diff --git a/apps/web-api/prisma/schema.prisma b/apps/web-api/prisma/schema.prisma index b08be33b8..6b83e72af 100644 --- a/apps/web-api/prisma/schema.prisma +++ b/apps/web-api/prisma/schema.prisma @@ -66,12 +66,19 @@ model Member { officeHours String? moreDetails String? bio String? - plnFriend Boolean @default(false) + plnFriend Boolean? @default(false) plnStartDate DateTime? airtableRecId String? @unique externalId String? @unique openToWork Boolean? @default(false) isFeatured Boolean? @default(false) + isVerified Boolean? @default(false) + signUpSource String? + signUpMedium String? + signUpCampaign String? + isUserConsent Boolean? @default(false) + isSubscribedToNewsletter Boolean? @default(false) + teamOrProjectURL String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt approvedAt DateTime @default(now()) @@ -408,7 +415,6 @@ model PLEventGuest { isHost Boolean @default(false) isSpeaker Boolean @default(false) isFeatured Boolean @default(false) - @@unique([memberUid, teamUid, eventUid]) } model FocusArea { diff --git a/apps/web-api/src/admin/admin.module.ts b/apps/web-api/src/admin/admin.module.ts index 35195ad6c..076a07066 100644 --- a/apps/web-api/src/admin/admin.module.ts +++ b/apps/web-api/src/admin/admin.module.ts @@ -5,12 +5,14 @@ import { ParticipantsRequestModule } from '../participants-request/participants- import { SharedModule } from '../shared/shared.module'; import { AdminParticipantsRequestController } from './participants-request.controller'; import { AdminAuthController } from './auth.controller'; +import { MemberController } from './member.controller'; +import { MembersModule } from '../members/members.module'; @Module({ - imports: [CacheModule.register(), ParticipantsRequestModule, SharedModule], - controllers: [AdminParticipantsRequestController, AdminAuthController], + imports: [CacheModule.register(), ParticipantsRequestModule, SharedModule, MembersModule], + controllers: [AdminParticipantsRequestController, AdminAuthController, MemberController], providers: [ AdminService, JwtService ], }) -export class AdminModule {} +export class AdminModule { } diff --git a/apps/web-api/src/admin/member.controller.ts b/apps/web-api/src/admin/member.controller.ts new file mode 100644 index 000000000..d5c13e50b --- /dev/null +++ b/apps/web-api/src/admin/member.controller.ts @@ -0,0 +1,37 @@ +import { Body, Controller, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { AdminAuthGuard } from '../guards/admin-auth.guard'; +import { MembersService } from '../members/members.service'; + +@Controller('v1/admin/members') +export class MemberController { + constructor(private readonly membersService: MembersService) { } + + /** + * Updates a member to a verfied user. + * + * @param body - array of memberIds to be updated. + * @returns Array of updation status of the provided memberIds. + */ + @Post("/") + @UseGuards(AdminAuthGuard) + async verifyMembers(@Body() body) { + const requestor = await this.membersService.findMemberByRole(); + const { memberIds } = body; + return await this.membersService.verifyMembers(memberIds, requestor?.email); + } + + /** + * Updates a member to a verfied user. + * + * @param body - participation request data with updated member details + * @returns updated member object + */ + @Patch("/:uid") + @UseGuards(AdminAuthGuard) + async updateMemberAndVerify(@Param('uid') uid, @Body() participantsRequest) { + const requestor = await this.membersService.findMemberByRole(); + const requestorEmail = requestor?.email ?? ''; + return await this.membersService.updateMemberFromParticipantsRequest(uid, participantsRequest, requestorEmail, true); + } + +} diff --git a/apps/web-api/src/admin/participants-request.controller.ts b/apps/web-api/src/admin/participants-request.controller.ts index 1a0a1d027..b633bcbcd 100644 --- a/apps/web-api/src/admin/participants-request.controller.ts +++ b/apps/web-api/src/admin/participants-request.controller.ts @@ -9,13 +9,14 @@ import { UseGuards, UsePipes, BadRequestException, - NotFoundException + NotFoundException, + Post } 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 { ProcessBulkParticipantRequest, ProcessParticipantReqDto } from 'libs/contracts/src/schema'; import { ApprovalStatus, ParticipantsRequest, ParticipantType } from '@prisma/client'; @Controller('v1/admin/participants-request') @@ -23,7 +24,20 @@ import { ApprovalStatus, ParticipantsRequest, ParticipantType } from '@prisma/cl export class AdminParticipantsRequestController { constructor( private readonly participantsRequestService: ParticipantsRequestService - ) {} + ) { } + + /** + * Process (approve/reject) multiple pending participants requests. + * @param body - The request body containing array of uids and status of participants to be processed; + * @returns The result of processing the participants request + */ + @Post('/') + async processBulkRequest( + @Body() body: ProcessBulkParticipantRequest[] + ): Promise { + const participationRequests = body; + return await this.participantsRequestService.processBulkRequest(participationRequests); + } /** * Retrieve all participants requests based on query parameters. @@ -87,7 +101,7 @@ export class AdminParticipantsRequestController { 'Requester email is required for team participation requests. Please provide a valid email address.' ); } - return await this.participantsRequestService.processRequestByUid(uid, participantRequest, body.status); + return await this.participantsRequestService.processRequestByUid(uid, participantRequest, body.status, body.isVerified); } + } - \ No newline at end of file diff --git a/apps/web-api/src/auth/auth.module.ts b/apps/web-api/src/auth/auth.module.ts index c3482180c..03ab0ff58 100644 --- a/apps/web-api/src/auth/auth.module.ts +++ b/apps/web-api/src/auth/auth.module.ts @@ -4,12 +4,11 @@ import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { HttpModule } from '@nestjs/axios'; import { PrismaService } from '../shared/prisma.service'; -import { RedisService } from '../utils/redis/redis.service'; import { OtpModule } from '../otp/otp.module'; @Module({ imports: [HttpModule, OtpModule], controllers: [AuthController], - providers: [AuthService, PrismaService, RedisService], + providers: [AuthService, PrismaService], exports: [AuthService] }) export class AuthModule {} diff --git a/apps/web-api/src/home/home.controller.ts b/apps/web-api/src/home/home.controller.ts index 30c352def..ecd0872a0 100644 --- a/apps/web-api/src/home/home.controller.ts +++ b/apps/web-api/src/home/home.controller.ts @@ -6,15 +6,17 @@ import { ApiQueryFromZod } from '../decorators/api-query-from-zod'; import { ApiOkResponseFromZod } from '../decorators/api-response-from-zod'; import { apiHome } from 'libs/contracts/src/lib/contract-home'; import { HomeService } from './home.service'; -import { +import { DiscoveryQuestionQueryParams, ResponseDiscoveryQuestionSchemaWithRelations, ResponseDiscoveryQuestionSchema, CreateDiscoveryQuestionSchemaDto, - UpdateDiscoveryQuestionSchemaDto + UpdateDiscoveryQuestionSchemaDto, + TeamQueryParams, + MemberQueryParams } from 'libs/contracts/src/schema'; import { UserTokenValidation } from '../guards/user-token-validation.guard'; -import { MembersService } from '../members/members.service'; +import { MembersService } from '../members/members.service'; import { NoCache } from '../decorators/no-cache.decorator'; import { PrismaQueryBuilder } from '../utils/prisma-query-builder'; import { prismaQueryableFieldsFromZod } from '../utils/prisma-queryable-fields-from-zod'; @@ -28,15 +30,15 @@ export class HomeController { constructor( private homeService: HomeService, private memberService: MembersService, - private huskyService: HuskyService - ) {} - + private huskyService: HuskyService + ) { } + @Api(server.route.getAllFeaturedData) async getAllFeaturedData() { return await this.homeService.fetchAllFeaturedData(); } - @Api(server.route.getAllDiscoveryQuestions) + @Api(server.route.getAllDiscoveryQuestions) @ApiQueryFromZod(DiscoveryQuestionQueryParams) @ApiOkResponseFromZod(ResponseDiscoveryQuestionSchemaWithRelations.array()) @NoCache() @@ -50,12 +52,11 @@ export class HomeController { } - @Api(server.route.getDiscoveryQuestion) + @Api(server.route.getDiscoveryQuestion) @ApiQueryFromZod(DiscoveryQuestionQueryParams) @ApiOkResponseFromZod(ResponseDiscoveryQuestionSchemaWithRelations) @NoCache() - async getDiscoveryQuestion(@Param('slug') slug: string) - { + async getDiscoveryQuestion(@Param('slug') slug: string) { return await this.huskyService.fetchDiscoverQuestionBySlug(slug); } @@ -99,7 +100,7 @@ export class HomeController { ) { const attribute = body.attribute; switch (attribute) { - case "shareCount": + case "shareCount": return this.huskyService.updateDiscoveryQuestionShareCount(slug); case "viewCount": return this.huskyService.updateDiscoveryQuestionViewCount(slug); @@ -107,4 +108,19 @@ export class HomeController { throw new BadRequestException(`Invalid attribute: ${attribute}`); } } + + /** + * Retrieves a list of teams and projects based on search query. + * + * @param request - HTTP request object containing query parameters + * @returns Array of projects and teams. + */ + @Api(server.route.getTeamsAndProjects) + @ApiQueryFromZod(TeamQueryParams) + @ApiQueryFromZod(MemberQueryParams) + @NoCache() + async getTeamsAndProjects(@Req() request: Request) { + const queryParams = request.query; + return this.homeService.fetchTeamsAndProjects(queryParams); + } } diff --git a/apps/web-api/src/home/home.service.ts b/apps/web-api/src/home/home.service.ts index 8954b2ba8..0a0e329d1 100644 --- a/apps/web-api/src/home/home.service.ts +++ b/apps/web-api/src/home/home.service.ts @@ -1,4 +1,4 @@ -import { +import { Injectable, InternalServerErrorException } from '@nestjs/common'; @@ -6,7 +6,6 @@ import { MembersService } from '../members/members.service'; import { TeamsService } from '../teams/teams.service'; import { PLEventsService } from '../pl-events/pl-events.service'; import { ProjectsService } from '../projects/projects.service'; - @Injectable() export class HomeService { constructor( @@ -14,7 +13,7 @@ export class HomeService { private teamsService: TeamsService, private plEventsService: PLEventsService, private projectsService: ProjectsService - ) {} + ) { } async fetchAllFeaturedData() { try { @@ -45,4 +44,85 @@ export class HomeService { throw new InternalServerErrorException(`Error occured while retrieving featured data: ${error.message}`); } } + + /** + * Retrieves a list of teams and projects based on search term. + * Builds a Prisma query from the queryable fields and adds filters for team and project name. + * + * @param request - HTTP request object containing query parameters + * @returns Array of projects and teams. + */ + async fetchTeamsAndProjects(queryParams) { + let result: any[] = [] + const entities: string[] = queryParams.include?.split(","); + if (entities.includes('teams')) { + const resultantTeams = await this.fetchTeamsBySearchTerm(queryParams.name); + resultantTeams.teams.forEach((team) => result.push({ ...team, category: "TEAM" })); + } + if (entities.includes('projects')) { + const resultantProjects = await this.fetchProjectsBySearchTerm(queryParams.name); + resultantProjects?.projects.forEach((project) => result.push({ ...project, category: "PROJECT" })); + } + return [...result].sort((team, project) => team.name.localeCompare(project.name)) + } + + /** + * Retrieves a list of teams based on search term. + * Builds a Prisma query from the queryable fields and adds filters for team name. + * + * @param name - name of the team to be searched for. + * @returns Array of resultant teams. + */ + private fetchTeamsBySearchTerm(name) { + return this.teamsService.findAll({ + where: { + name: { + startsWith: name, + mode: 'insensitive' + } + }, + select: { + uid: true, + name: true, + logo: { + select: { + url: true, + } + } + }, + orderBy: { + name: 'asc' + } + }) + } + + /** + * Retrieves a list of projects based on search term. + * Builds a Prisma query from the queryable fields and adds filters for project name. + * + * @param name - name of the project to be searched for. + * @returns Array of resultant projects. + */ + private fetchProjectsBySearchTerm(name) { + return this.projectsService.getProjects({ + where: { + name: { + startsWith: name, + mode: 'insensitive' + } + }, + select: { + uid: true, + name: true, + logo: { + select: { + url: true, + } + } + }, + orderBy: { + name: 'asc' + } + }) + } } diff --git a/apps/web-api/src/interceptors/verified-member.interceptor.ts b/apps/web-api/src/interceptors/verified-member.interceptor.ts new file mode 100644 index 000000000..fd719b4f0 --- /dev/null +++ b/apps/web-api/src/interceptors/verified-member.interceptor.ts @@ -0,0 +1,15 @@ +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable } from 'rxjs'; + +@Injectable() +export class IsVerifiedMemberInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + if (request.query.isVerified === 'all') { + delete request.query.isVerified; + } else if (request.query.isVerified !== 'false') { + request.query.isVerified = 'true'; + } + return next.handle(); + } +} diff --git a/apps/web-api/src/members/members.controller.ts b/apps/web-api/src/members/members.controller.ts index 5102acdfd..fd04d3616 100644 --- a/apps/web-api/src/members/members.controller.ts +++ b/apps/web-api/src/members/members.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Param, Req, UseGuards, UsePipes, BadRequestException, ForbiddenException } from '@nestjs/common'; +import { Body, Controller, Param, Req, UseGuards, UsePipes, UseInterceptors, BadRequestException, ForbiddenException } from '@nestjs/common'; import { ApiNotFoundResponse, ApiParam } from '@nestjs/swagger'; import { Api, ApiDecorator, initNestServer } from '@ts-rest/nest'; import { Request } from 'express'; @@ -24,6 +24,8 @@ 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'; +import { IsVerifiedMemberInterceptor } from '../interceptors/verified-member.interceptor'; +import { isEmpty } from 'lodash'; const server = initNestServer(apiMembers); type RouteShape = typeof server.routeShapes; @@ -31,8 +33,8 @@ type RouteShape = typeof server.routeShapes; @Controller() @NoCache() export class MemberController { - constructor(private readonly membersService: MembersService, private logger: LogService) {} - + 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. @@ -43,6 +45,8 @@ export class MemberController { @Api(server.route.getMembers) @ApiQueryFromZod(MemberQueryParams) @ApiOkResponseFromZod(ResponseMemberWithRelationsSchema.array()) + @UseInterceptors(IsVerifiedMemberInterceptor) + @NoCache() async findAll(@Req() request: Request) { const queryableFields = prismaQueryableFieldsFromZod(ResponseMemberWithRelationsSchema); const queryParams = request.query; @@ -71,7 +75,9 @@ export class MemberController { * @returns Array of roles with member counts */ @Api(server.route.getMemberRoles) - async getMemberFilters(@Req() request: Request) { + @UseInterceptors(IsVerifiedMemberInterceptor) + @NoCache() + async getMemberRoleFilters(@Req() request: Request) { const queryableFields = prismaQueryableFieldsFromZod(ResponseMemberWithRelationsSchema); const queryParams = request.query; const builder = new PrismaQueryBuilder(queryableFields); @@ -90,6 +96,35 @@ export class MemberController { return await this.membersService.getRolesWithCount(builtQuery, queryParams); } + /** + * Retrieves member filters. + * + * @param request - HTTP request object containing query parameters + * @returns return list of member filters. + */ + @Api(server.route.getMemberFilters) + @UseInterceptors(IsVerifiedMemberInterceptor) + @NoCache() + async getMembersFilter(@Req() request: Request) { + const queryableFields = prismaQueryableFieldsFromZod(ResponseMemberWithRelationsSchema); + const queryParams = request.query; + const builder = new PrismaQueryBuilder(queryableFields); + const builtQuery = builder.build(queryParams); + const { name__icontains } = queryParams; + if (name__icontains) { + delete builtQuery.where?.name; + } + builtQuery.where = { + AND: [ + builtQuery.where, + this.membersService.buildNameFilters(queryParams), + this.membersService.buildRoleFilters(queryParams), + this.membersService.buildRecentMembersFilter(queryParams) + ], + }; + return await this.membersService.getMemberFilters(builtQuery); + } + /** * Retrieves details of a specific member by UID. * Builds a query for member details including relations and profile data. @@ -134,7 +169,10 @@ export class MemberController { ) { throw new ForbiddenException(`Member isn't authorized to update the member`); } - return await this.membersService.updateMemberFromParticipantsRequest(uid, participantsRequest, requestor.email); + if (!isEmpty(participantsRequest.newData.isVerified) && !this.membersService.checkIfAdminUser(requestor)) { + throw new ForbiddenException(`Member isn't authorized to verify a member`); + } + return await this.membersService.updateMemberFromParticipantsRequest(uid, participantsRequest, requestor.email, requestor.isDirectoryAdmin); } /** @@ -154,7 +192,18 @@ export class MemberController { @Api(server.route.updateMember) @UseGuards(UserTokenValidation) - async updateMemberByUid(@Param('uid') uid, @Body() body) { + async updateMemberByUid(@Param('uid') uid, @Body() body, @Req() req) { + this.logger.info(`Member update request - Initated by -> ${req.userEmail}`); + const requestor = await this.membersService.findMemberByEmail(req.userEmail); + if ( + !requestor.isDirectoryAdmin && + uid !== requestor.uid + ) { + throw new ForbiddenException(`Member isn't authorized to update the member`); + } + if (!isEmpty(body.isVerified) && !this.membersService.checkIfAdminUser(requestor)) { + throw new ForbiddenException(`Member isn't authorized to verify a member`); + } return await this.membersService.updateMemberByUid(uid, body); } @@ -209,7 +258,7 @@ export class MemberController { @UsePipes(ZodValidationPipe) async updateMemberEmail(@Body() changeEmailRequest: ChangeEmailRequestDto, @Req() req) { const memberInfo = await this.membersService.findMemberByEmail(req.userEmail); - if(!memberInfo || !memberInfo.externalId) { + if (!memberInfo || !memberInfo.externalId) { throw new ForbiddenException("Please login again and try") } return await this.membersService.updateMemberEmail(changeEmailRequest.newEmail, req.userEmail, memberInfo); diff --git a/apps/web-api/src/members/members.service.ts b/apps/web-api/src/members/members.service.ts index 12cd54869..8c6852a9d 100644 --- a/apps/web-api/src/members/members.service.ts +++ b/apps/web-api/src/members/members.service.ts @@ -1,7 +1,6 @@ /* eslint-disable prettier/prettier */ import { BadRequestException, - CACHE_MANAGER, ConflictException, NotFoundException, Inject, @@ -11,7 +10,6 @@ import { 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'; @@ -26,6 +24,7 @@ import { LogService } from '../shared/log.service'; import { DEFAULT_MEMBER_ROLES } from '../utils/constants'; import { hashFileName } from '../utils/hashing'; import { copyObj, buildMultiRelationMapping } from '../utils/helper/helper'; +import { CacheService } from '../utils/cache/cache.service'; @Injectable() export class MembersService { @@ -41,9 +40,8 @@ export class MembersService { private participantsRequestService: ParticipantsRequestService, @Inject(forwardRef(() => NotificationService)) private notificationService: NotificationService, - @Inject(CACHE_MANAGER) private cacheService: Cache - - ) {} + private cacheService: CacheService + ) { } /** * Creates a new member in the database within a transaction. @@ -60,9 +58,9 @@ export class MembersService { return await tx.member.create({ data: member, }); - } catch(error) { + } catch (error) { return this.handleErrors(error); - } + } } /** @@ -75,10 +73,14 @@ export class MembersService { * 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 { + async findAll(queryOptions: Prisma.MemberFindManyArgs): Promise<{ count: Number, members: Member[] }> { try { - return await this.prisma.member.findMany(queryOptions); - } catch(error) { + const [members, membersCount] = await Promise.all([ + this.prisma.member.findMany(queryOptions), + this.prisma.member.count({ where: queryOptions.where }), + ]); + return { count: membersCount, members: members } + } catch (error) { return this.handleErrors(error); } } @@ -174,6 +176,9 @@ export class MembersService { formattedDefaultRoles.push({ ...defaultRole, count: 0 }); } }); + if (!searchTerm) { + return formattedDefaultRoles; + } const result: any = await this.getRoleCountForExcludedAndNonSelectedRoles(selectedRoles, members, searchTerm); return [...formattedDefaultRoles, ...result]; } catch (error) { @@ -203,7 +208,7 @@ export class MembersService { where: { uid }, data: member, }); - } catch(error) { + } catch (error) { return this.handleErrors(error); } } @@ -252,7 +257,7 @@ export class MembersService { }, }, }); - } catch(error) { + } catch (error) { return this.handleErrors(error); } } @@ -274,8 +279,8 @@ export class MembersService { teamMemberRoles: true, projectContributions: true, } - }); - } catch(error) { + }); + } catch (error) { return this.handleErrors(error); } } @@ -285,20 +290,20 @@ export class MembersService { * @param tx - Prisma transaction client or Prisma client. * @param uid - Member UID to fetch. */ - async findMemberByUid(uid: string, tx: Prisma.TransactionClient = this.prisma){ + 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, + include: { + image: true, + location: true, + skills: true, + teamMemberRoles: true, + memberRoles: true, projectContributions: true }, - }); - } catch(error) { + }); + } catch (error) { return this.handleErrors(error); } } @@ -313,11 +318,11 @@ export class MembersService { try { return await this.prisma.member.findUniqueOrThrow({ where: { email: email.toLowerCase().trim() }, - include: { - memberRoles: true + include: { + memberRoles: true }, }); - } catch(error) { + } catch (error) { return this.handleErrors(error); } } @@ -357,7 +362,7 @@ export class MembersService { .filter((role) => role.teamLead) .map((role) => role.teamUid), }; - } catch(error) { + } catch (error) { return this.handleErrors(error); } } @@ -390,7 +395,7 @@ export class MembersService { * * @throws If any operation within the transaction fails, the entire transaction is rolled back. */ - async updateMemberEmail(newEmail:string, oldEmail:string, memberInfo) { + async updateMemberEmail(newEmail: string, oldEmail: string, memberInfo) { try { let newTokens; let newMemberInfo; @@ -401,33 +406,34 @@ export class MembersService { referenceUid: memberInfo.uid, uniqueIdentifier: oldEmail, participantType: 'MEMBER', - newData: { - oldEmail: oldEmail, - email: newEmail - }}, + 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, - } - }) + 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(); + await this.cacheService.reset({ service: 'members' }); return { refreshToken: newTokens.refresh_token, idToken: newTokens.id_token, accessToken: newTokens.access_token, userInfo: this.memberToUserInfo(newMemberInfo) }; - } catch(error) { + } catch (error) { return this.handleErrors(error); } } @@ -481,7 +487,7 @@ export class MembersService { where: { email: emailId.toLowerCase().trim() }, data: { externalId }, }); - } catch(error){ + } catch (error) { return this.handleErrors(error); } } @@ -499,7 +505,7 @@ export class MembersService { select: { githubHandler: true }, }); return member?.githubHandler || null; - } catch(error) { + } catch (error) { return this.handleErrors(error); } } @@ -622,13 +628,16 @@ export class MembersService { 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); + const createdMember = await this.createMember(member, tx); + await this.postCreateActions(); + return createdMember; } - + async updateMemberFromParticipantsRequest( memberUid: string, memberParticipantsRequest: ParticipantsRequest, - requestorEmail: string + requestorEmail: string, + isDirectoryAdmin = false ): Promise { let result; await this.prisma.$transaction(async (tx) => { @@ -640,22 +649,24 @@ export class MembersService { const member = await this.prepareMemberFromParticipantRequest(memberUid, memberData, existingMember, tx, 'Update'); await this.mapLocationToMember(memberData, existingMember, member, tx); result = await this.updateMemberByUid( - memberUid, + memberUid, { ...member, - ...(isEmailChanged && isExternalIdAvailable && { externalId: null }) - }, + ...(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); + if (isEmailChanged && isDirectoryAdmin) { + this.notificationService.notifyForMemberChangesByAdmin(memberData.name, memberUid, existingMember.email, memberData.email); + } 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. * @@ -700,16 +711,20 @@ export class MembersService { const member: any = {}; const directFields = [ 'name', 'email', 'githubHandler', 'discordHandler', 'bio', - 'twitterHandler', 'linkedinHandler', 'telegramHandler', - 'officeHours', 'moreDetails', 'plnStartDate', 'openToWork' + 'twitterHandler', 'linkedinHandler', 'telegramHandler', + 'officeHours', 'moreDetails', 'plnStartDate', 'openToWork', + 'isVerified', 'signUpSource', 'signUpMedium', 'signUpCampaign', + 'isUserConsent', 'isSubscribedToNewsletter', 'teamOrProjectURL', ]; copyObj(memberData, member, directFields); member.email = member.email.toLowerCase().trim(); - member['image'] = memberData.imageUid ? { connect: { uid: memberData.imageUid } } - : type === 'Update' ? { disconnect: true } : undefined ; + 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.teamAndRoles)) { + member['teamMemberRoles'] = this.buildTeamMemberRoles(memberData); + } if (Array.isArray(memberData.projectContributions)) { member['projectContributions'] = { createMany: { data: memberData.projectContributions }, @@ -717,7 +732,7 @@ export class MembersService { } } else { await this.updateProjectContributions(memberData, existingMember, memberUid, tx); - await this.updateTeamMemberRoles(memberData, existingMember, memberUid, tx); + await this.updateTeamMemberRoles(memberData, existingMember, memberUid, tx); } return member; } @@ -759,7 +774,7 @@ export class MembersService { throw new BadRequestException('Invalid Location info'); } } else { - if (existingMember) { + if (existingMember) { member['location'] = { disconnect: true }; } } @@ -919,7 +934,7 @@ export class MembersService { }, }; } - + /** * function to handle creation, updating, and deletion of project contributions * with fewer database calls by using batch operations. @@ -1101,10 +1116,51 @@ export class MembersService { participantType: 'MEMBER', newData: { ...newMemberData }, }, - tx + tx ); } + /** + * Verify the list of members and log into participant request. + * @param memberIds array of member IDs + * @param userEmail logged in member email + * @returns result + */ + async verifyMembers(memberIds: string[], userEmail): Promise { + return await this.prisma.$transaction(async (tx) => { + const result = await tx.member.updateMany({ + where: { uid: { in: memberIds } }, + data: { + isVerified: true + } + }); + if (result.count !== memberIds.length) { + throw new NotFoundException('One or more member IDs are invalid.'); + } + const members = await tx.member.findMany({ + where: { uid: { in: memberIds } } + }) + await Promise.all(members.map(async (member) => { + await this.participantsRequestService.add({ + status: 'AUTOAPPROVED', + requesterEmailId: userEmail, + referenceUid: member.uid, + uniqueIdentifier: member?.email || '', + participantType: 'MEMBER', + oldData: { + isVerified: false + }, + newData: { + isVerified: true + }, + }, + tx + ); + })); + return result; + }); + } + /** * Updates the member's preferences and resets the cache. * @@ -1114,16 +1170,25 @@ export class MembersService { */ async updatePreference(id: string, preferences: any): Promise { const updatedMember = await this.updateMemberByUid(id, { preferences }); - await this.cacheService.reset(); + await this.cacheService.reset({ service: 'members' }); return updatedMember; } + /** + * Executes post-create 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 postCreateActions(): Promise { + await this.cacheService.reset({ service: 'members'}); + await this.forestadminService.triggerAirtableSync(); + } + /** * 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.cacheService.reset({ service: 'members' }); await this.forestadminService.triggerAirtableSync(); } @@ -1144,6 +1209,7 @@ export class MembersService { linkedinHandler: true, twitterHandler: true, preferences: true, + isSubscribedToNewsletter: true }, }); return this.buildPreferenceResponse(member); @@ -1168,6 +1234,7 @@ export class MembersService { preferences.discord = !!member.discordHandler; preferences.linkedin = !!member.linkedinHandler; preferences.twitter = !!member.twitterHandler; + preferences.subscription = !!member.isSubscribedToNewsletter; return preferences; } @@ -1213,7 +1280,7 @@ export class MembersService { * @returns Constructed query with a 'createdAt' filter if 'recent' is set to 'true', * or an empty object if 'recent' is not provided or set to 'false'. */ - buildRecentMembersFilter(queryParams) { + buildRecentMembersFilter(queryParams) { const { isRecent } = queryParams; if (isRecent === 'true') { return { @@ -1232,7 +1299,7 @@ export class MembersService { * @returns Constructed query based on given member role input */ buildRoleFilters(queryParams) { - const { memberRoles } : any = queryParams; + const { memberRoles }: any = queryParams; const roles = memberRoles?.split(','); if (roles?.length > 0) { return { @@ -1257,41 +1324,92 @@ export class MembersService { if (name__icontains) { return { OR: [ - { name: { + { + name: { contains: name__icontains, mode: 'insensitive' } }, - { teamMemberRoles : { + { + teamMemberRoles: { some: { - team: { + team: { name: { contains: name__icontains, mode: 'insensitive' - } - } - } + } + } + } } }, - { - projectContributions : { + { + projectContributions: { some: { - project: { + project: { name: { contains: name__icontains, mode: 'insensitive' }, - isDeleted: false - } - } + isDeleted: false + } + } } } ] } } - return { }; + return {}; + } + + /** + * Fetches filter tags for members for facilitating easy searching. + * @param queryParams HTTP request query params object + * @returns Set of skills, locations that contain at least one member. + */ + async getMemberFilters(queryParams) { + const skills = await this.prisma.skill.findMany({ + where: { + members: { + some: { ...queryParams.where }, + }, + }, + select: { + title: true, + }, + }); + const locations = await this.prisma.location.findMany({ + where: { + members: { + some: { ...queryParams.where }, + }, + }, + select: { + city: true, + continent: true, + country: true, + region: true, + metroArea: true + }, + }); + + // Deduplicate cities, countries, and regions using Set + const uniqueCities = [...new Set(locations.map((location) => location.city).filter(Boolean))]; + const uniqueCountries = [...new Set(locations.map((location) => location.country).filter(Boolean))]; + const uniqueRegions = [...new Set(locations.map((location) => location.continent).filter(Boolean))]; + const uniqueMetroAreas = [...new Set(locations.map((location) => location.metroArea).filter(Boolean))]; + + // Return deduplicated skills and locations + return { + skills: skills.map((skill) => skill.title), + cities: uniqueCities, + countries: uniqueCountries, + regions: uniqueRegions, + metroAreas: uniqueMetroAreas + }; } + + /** * Updates the member's field if the value has changed. * @@ -1380,7 +1498,7 @@ export class MembersService { } return error; } - + async insertManyWithLocationsFromAirtable( airtableMembers: z.infer[] @@ -1469,4 +1587,20 @@ export class MembersService { }); } } + + async findMemberByRole() { + const member = await this.prisma.member.findFirst({ + where: { + memberRoles: { + some: { + name: 'DIRECTORYADMIN', // Adjust this based on the actual field name in your schema + }, + }, + }, + select: { + email: true + } + }); + return member; + } } 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 7c022a551..e549454f4 100644 --- a/apps/web-api/src/participants-request/participants-request.service.ts +++ b/apps/web-api/src/participants-request/participants-request.service.ts @@ -2,14 +2,12 @@ import { BadRequestException, ConflictException, - NotFoundException, - Inject, - Injectable, - CACHE_MANAGER, - forwardRef + NotFoundException, + Injectable, + forwardRef, + Inject } from '@nestjs/common'; 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'; @@ -19,6 +17,7 @@ import { TeamsService } from '../teams/teams.service'; import { NotificationService } from '../utils/notification/notification.service'; import { LocationTransferService } from '../utils/location-transfer/location-transfer.service'; import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; +import { CacheService } from '../utils/cache/cache.service'; @Injectable() export class ParticipantsRequestService { @@ -28,13 +27,12 @@ export class ParticipantsRequestService { private locationTransferService: LocationTransferService, private forestAdminService: ForestAdminService, private notificationService: NotificationService, - @Inject(CACHE_MANAGER) - private cacheService: Cache, + private cacheService: CacheService, @Inject(forwardRef(() => MembersService)) private membersService: MembersService, - @Inject(forwardRef(() => TeamsService)) + @Inject(forwardRef(() => TeamsService)) private teamsService: TeamsService, - ) {} + ) { } /** * Find all participant requests based on the query. @@ -63,7 +61,7 @@ export class ParticipantsRequestService { where: filters, orderBy: { createdAt: 'desc' }, }); - } catch(err) { + } catch (err) { return this.handleErrors(err) } } @@ -77,13 +75,13 @@ export class ParticipantsRequestService { */ async add( newEntry: Prisma.ParticipantsRequestUncheckedCreateInput, - tx?: Prisma.TransactionClient, + tx?: Prisma.TransactionClient, ): Promise { try { return await (tx || this.prisma).participantsRequest.create({ data: { ...newEntry }, }); - } catch(err) { + } catch (err) { return this.handleErrors(err) } } @@ -99,7 +97,7 @@ export class ParticipantsRequestService { return await this.prisma.participantsRequest.findUnique({ where: { uid }, }); - } catch(err) { + } catch (err) { return this.handleErrors(err, uid) } } @@ -115,9 +113,9 @@ export class ParticipantsRequestService { async checkIfIdentifierAlreadyExist( type: ParticipantType, identifier: string - ): Promise<{ - isRequestPending: boolean; - isUniqueIdentifierExist: boolean + ): Promise<{ + isRequestPending: boolean; + isUniqueIdentifierExist: boolean }> { try { const existingRequest = await this.prisma.participantsRequest.findFirst({ @@ -130,16 +128,16 @@ export class ParticipantsRequestService { if (existingRequest) { return { isRequestPending: true, isUniqueIdentifierExist: false }; } - const existingEntry = - type === ParticipantType.TEAM - ? await this.teamsService.findTeamByName(identifier) + 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) { + } + catch (err) { return this.handleErrors(err) } } @@ -155,20 +153,20 @@ export class ParticipantsRequestService { async updateByUid( uid: string, participantRequest: Prisma.ParticipantsRequestUncheckedUpdateInput, - ):Promise { + ): 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({ + const result: ParticipantsRequest = await this.prisma.participantsRequest.update({ where: { uid }, data: formattedData, }); - await this.cacheService.reset(); + await this.cacheService.reset({ service: "participants-requests" }); return result; - } catch(err) { + } catch (err) { return this.handleErrors(err) } } @@ -183,13 +181,13 @@ export class ParticipantsRequestService { */ async rejectRequestByUid(uidToReject: string): Promise { try { - const result:ParticipantsRequest = await this.prisma.participantsRequest.update({ + const result: ParticipantsRequest = await this.prisma.participantsRequest.update({ where: { uid: uidToReject }, data: { status: ApprovalStatus.REJECTED } }); - await this.cacheService.reset(); + await this.cacheService.reset({ service: "participants-requests" }); return result; - } catch(err) { + } catch (err) { return this.handleErrors(err) } } @@ -209,12 +207,14 @@ export class ParticipantsRequestService { * @returns The updated participant request with the status set to `APPROVED`. */ private async approveRequestByUid( - uidToApprove: string, - participantsRequest: ParticipantsRequest + uidToApprove: string, + participantsRequest: ParticipantsRequest, + isVerified: boolean ): Promise { let result; let createdItem; const dataToProcess: any = participantsRequest; + dataToProcess.newData.isVerified = isVerified; const participantType = participantsRequest.participantType; // Add new member or team and update status to approved await this.prisma.$transaction(async (tx) => { @@ -248,7 +248,7 @@ export class ParticipantsRequestService { participantsRequest.requesterEmailId ); } - await this.cacheService.reset(); + await this.cacheService.reset({ service: "participants-requests" }); await this.forestAdminService.triggerAirtableSync(); return result; } @@ -259,11 +259,11 @@ export class ParticipantsRequestService { * @param uid * @returns */ - async processRequestByUid(uid:string, participantsRequest:ParticipantsRequest, statusToProcess) { + async processRequestByUid(uid: string, participantsRequest: ParticipantsRequest, statusToProcess, isVerified: boolean) { if (statusToProcess === ApprovalStatus.REJECTED) { return await this.rejectRequestByUid(uid); } else { - return await this.approveRequestByUid(uid, participantsRequest); + return await this.approveRequestByUid(uid, participantsRequest, isVerified); } } @@ -288,13 +288,13 @@ export class ParticipantsRequestService { // Add the new request const result: ParticipantsRequest = await this.add({ ...postData - }, + }, tx ); if (!disableNotification) { this.notifyForCreate(result); } - await this.cacheService.reset(); + await this.cacheService.reset({ service: "participants-requests" }); return result; } @@ -313,7 +313,7 @@ export class ParticipantsRequestService { } } } - + /** * Extract unique identifier based on participant type. * @param requestData @@ -324,7 +324,7 @@ export class ParticipantsRequestService { ? requestData.newData.name : requestData.newData.email?.toLowerCase().trim(); } - + /** * Validate if the unique identifier already exists. * @param participantType @@ -332,7 +332,7 @@ export class ParticipantsRequestService { * @throws BadRequestException if identifier already exists */ async validateUniqueIdentifier( - participantType: ParticipantType, + participantType: ParticipantType, uniqueIdentifier: string ): Promise { const { isRequestPending, isUniqueIdentifierExist } = await this.checkIfIdentifierAlreadyExist( @@ -344,7 +344,7 @@ export class ParticipantsRequestService { throw new BadRequestException(`${typeLabel} already exists`); } } - + /** * Validate location for members or email for teams. * @param requestData @@ -360,7 +360,7 @@ export class ParticipantsRequestService { ); } } - + /** * Send notification based on the participant type. * @param result @@ -409,8 +409,57 @@ export class ParticipantsRequestService { return error; } - + generateMemberProfileURL(value) { return generateProfileURL(value); } + + /** + * Process (approve/reject) multiple pending participants requests. + * @param participantRequests - The request body containing array of uids and status of participants to be processed; + * @returns The result of processing the participants request along with the success count. + */ + async processBulkRequest(participantRequests) { + let successCount = 0; + const results = await Promise.all( + participantRequests.map(async (request) => { + try { + const participantRequest: ParticipantsRequest | null = + await this.findOneByUid(request.uid); + if (!participantRequest) { + return { + uid: request.uid, + message: 'Request not found', + }; + } + if (participantRequest.status !== ApprovalStatus.PENDING) { + return { + uid: request.uid, + message: `Request cannot be processed. It has already been ${participantRequest.status.toLowerCase()}.`, + }; + } + if (participantRequest.participantType === ParticipantType.TEAM && !participantRequest.requesterEmailId) { + return { + uid: request.uid, + message: 'Requester email is required for team participation requests. Please provide a valid email address.', + }; + } + await this.processRequestByUid( + request.uid, + participantRequest, + request.status, + request.isVerified + ); + successCount++; + return { uid: request.uid, message: 'Processed successfully' }; + } catch (error) { + return { + uid: request.uid, + message: 'An error occurred while processing the request', + }; + } + }) + ); + return { count: successCount, results }; + } } diff --git a/apps/web-api/src/pl-events/pl-event-guests.service.ts b/apps/web-api/src/pl-events/pl-event-guests.service.ts index 8ea507220..e16df6f5e 100644 --- a/apps/web-api/src/pl-events/pl-event-guests.service.ts +++ b/apps/web-api/src/pl-events/pl-event-guests.service.ts @@ -1,9 +1,8 @@ -import { Injectable, NotFoundException, ConflictException, BadRequestException, Inject, CACHE_MANAGER } from '@nestjs/common'; +import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { LogService } from '../shared/log.service'; import { PrismaService } from '../shared/prisma.service'; import { Prisma, Member } from '@prisma/client'; import { MembersService } from '../members/members.service'; -import { Cache } from 'cache-manager'; import { PLEventLocationsService } from './pl-event-locations.service'; import { CreatePLEventGuestSchemaDto, @@ -13,6 +12,7 @@ import { FormattedLocationWithEvents, PLEvent } from './pl-event-locations.types'; +import { CacheService } from '../utils/cache/cache.service'; @Injectable() export class PLEventGuestsService { @@ -21,7 +21,7 @@ export class PLEventGuestsService { private logger: LogService, private memberService: MembersService, private eventLocationsService: PLEventLocationsService, - @Inject(CACHE_MANAGER) private cacheService: Cache + private cacheService: CacheService ) {} /** @@ -43,7 +43,7 @@ export class PLEventGuestsService { data.memberUid = isAdmin ? data.memberUid : member.uid; const guests = this.formatInputToEventGuests(data); const result = await (tx || this.prisma).pLEventGuest.createMany({ data: guests }); - this.cacheService.reset(); + this.cacheService.reset({ service: 'PLEventGuest' }); return result; } catch(err) { this.handleErrors(err); @@ -100,7 +100,7 @@ export class PLEventGuestsService { OR: deleteConditions } }); - await this.cacheService.reset(); + await this.cacheService.reset({ service: 'PLEventGuest' }); return result; } catch (err) { this.handleErrors(err); diff --git a/apps/web-api/src/projects/projects.controller.ts b/apps/web-api/src/projects/projects.controller.ts index 3bb5881c0..ab0b9f482 100644 --- a/apps/web-api/src/projects/projects.controller.ts +++ b/apps/web-api/src/projects/projects.controller.ts @@ -19,7 +19,13 @@ type RouteShape = typeof server.routeShapes; @Controller() export class ProjectsController { - constructor(private readonly projectsService: ProjectsService) {} + constructor(private readonly projectsService: ProjectsService) { } + + @Api(server.route.getProjectFilters) + @NoCache() + async getProjectFilters() { + return await this.projectsService.getProjectFilters(); + } @Api(server.route.createProject) @UsePipes(ZodValidationPipe) @@ -40,7 +46,7 @@ export class ProjectsController { ) { return this.projectsService.updateProjectByUid(uid, body as any, request.userEmail); } - + @Api(server.route.getProjects) @ApiOkResponseFromZod(ResponseProjectWithRelationsSchema.array()) async findAll(@Req() req) { @@ -49,7 +55,7 @@ export class ProjectsController { ); const builder = new PrismaQueryBuilder(queryableFields); const builtQuery = builder.build(req.query); - const { focusAreas } : any = req.query; + const { focusAreas }: any = req.query; builtQuery.where = { AND: [ builtQuery.where ? builtQuery.where : {}, @@ -82,4 +88,5 @@ export class ProjectsController { ) { return this.projectsService.removeProjectByUid(uid, request.userEmail); } + } diff --git a/apps/web-api/src/projects/projects.service.ts b/apps/web-api/src/projects/projects.service.ts index ea189f53d..253c08924 100644 --- a/apps/web-api/src/projects/projects.service.ts +++ b/apps/web-api/src/projects/projects.service.ts @@ -1,9 +1,9 @@ -import { Inject, CACHE_MANAGER, BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, HttpException } from '@nestjs/common'; +import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { LogService } from '../shared/log.service'; import { PrismaService } from '../shared/prisma.service'; import { Prisma } from '@prisma/client'; import { MembersService } from '../members/members.service'; -import { Cache } from 'cache-manager'; +import { CacheService } from '../utils/cache/cache.service'; @Injectable() export class ProjectsService { @@ -11,21 +11,21 @@ export class ProjectsService { private prisma: PrismaService, private memberService: MembersService, private logger: LogService, - @Inject(CACHE_MANAGER) private cacheService: Cache - ) {} + private cacheService: CacheService + ) { } async createProject(project: Prisma.ProjectUncheckedCreateInput, userEmail: string) { try { - const member:any = await this.getMemberInfo(userEmail); - const { contributingTeams, contributions, focusAreas} : any = project; + const member: any = await this.getMemberInfo(userEmail); + const { contributingTeams, contributions, focusAreas }: any = project; project.createdBy = member.uid; - project['projectFocusAreas'] = {...await this.createProjectWithFocusAreas(focusAreas, this.prisma)}; + project['projectFocusAreas'] = { ...await this.createProjectWithFocusAreas(focusAreas, this.prisma) }; delete project['focusAreas']; const result = await this.prisma.project.create({ data: { ...project, contributingTeams: { - connect: contributingTeams?.map(team => { return { uid: team.uid }}) + connect: contributingTeams?.map(team => { return { uid: team.uid } }) }, contributions: { create: contributions?.map((contribution) => { @@ -34,9 +34,9 @@ export class ProjectsService { } } }); - await this.cacheService.reset(); + await this.cacheService.reset({ service: 'projects'}); return result; - } catch(err) { + } catch (err) { this.handleErrors(err); } } @@ -47,13 +47,13 @@ export class ProjectsService { userEmail: string ) { try { - const member:any = await this.getMemberInfo(userEmail); - const existingData:any = await this.getProjectByUid(uid); + const member: any = await this.getMemberInfo(userEmail); + const existingData: any = await this.getProjectByUid(uid); const contributingTeamsUid = existingData?.contributingTeams?.map(team => team.uid) || []; await this.isMemberAllowedToEdit(member, [existingData?.maintainingTeamUid, ...contributingTeamsUid], existingData); - const { contributingTeams, contributions, focusAreas } : any = project; - const contributionsToCreate:any = []; - const contributionUidsToDelete:any = []; + const { contributingTeams, contributions, focusAreas }: any = project; + const contributionsToCreate: any = []; + const contributionUidsToDelete: any = []; contributions?.map((contribution) => { if (!contribution.uid) { contributionsToCreate.push(contribution); @@ -62,8 +62,8 @@ export class ProjectsService { contributionUidsToDelete.push({ uid: contribution.uid }); } }); - return await this.prisma.$transaction(async(tx) => { - project['projectFocusAreas'] = {...await this.updateProjectWithFocusAreas(uid, focusAreas, tx)}; + return await this.prisma.$transaction(async (tx) => { + project['projectFocusAreas'] = { ...await this.updateProjectWithFocusAreas(uid, focusAreas, tx) }; delete project['focusAreas']; const result = await tx.project.update({ where: { @@ -72,8 +72,8 @@ export class ProjectsService { data: { ...project, contributingTeams: { - disconnect: contributingTeamsUid?.map(uid => { return { uid }}), - connect: contributingTeams?.map(team => { return { uid: team.uid }}) || [] + disconnect: contributingTeamsUid?.map(uid => { return { uid } }), + connect: contributingTeams?.map(team => { return { uid: team.uid } }) || [] }, contributions: { create: contributionsToCreate, @@ -81,39 +81,22 @@ export class ProjectsService { } } }); - await this.cacheService.reset(); + await this.cacheService.reset({ service: 'projects'}); return result; }); - } catch(err) { + } catch (err) { this.handleErrors(err, `${uid}`); } } async getProjects(queryOptions: Prisma.ProjectFindManyArgs) { try { - queryOptions.where = { - ...queryOptions.where, - isDeleted: false - }; - queryOptions.include = { - contributions: { - select: { - uid: true, - member: { - select: { - uid: true, - name: true, - image: true - } - } - } - }, - maintainingTeam: { select: { uid: true, name: true, logo: true }}, - creator: { select: { uid: true, name: true, image: true }}, - logo: true - }; - return await this.prisma.project.findMany(queryOptions); - } catch(err) { + const [projects, projectsCount] = await Promise.all([ + this.prisma.project.findMany(queryOptions), + this.prisma.project.count({ where: queryOptions.where }), + ]); + return { count: projectsCount, projects: projects } + } catch (err) { this.handleErrors(err); } } @@ -125,23 +108,23 @@ export class ProjectsService { const project = await this.prisma.project.findUniqueOrThrow({ where: { uid }, include: { - maintainingTeam: { select: { uid: true, name: true, logo: true }}, - contributingTeams: { select: { uid: true, name: true, logo: true }}, - contributions: { - select: { + maintainingTeam: { select: { uid: true, name: true, logo: true } }, + contributingTeams: { select: { uid: true, name: true, logo: true } }, + contributions: { + select: { uid: true, - member: { - select: { - uid: true, - name: true, - image: true , - teamMemberRoles:{ - select:{ - mainTeam:true, - teamLead:true, - role:true, - team:{ - select:{ + member: { + select: { + uid: true, + name: true, + image: true, + teamMemberRoles: { + select: { + mainTeam: true, + teamLead: true, + role: true, + team: { + select: { uid: true, name: true } @@ -149,18 +132,18 @@ export class ProjectsService { } } } - }, + }, projectUid: true } }, - creator: { select: { uid: true, name: true, image: true }}, + creator: { select: { uid: true, name: true, image: true } }, logo: true, projectFocusAreas: { select: { focusArea: { select: { uid: true, - title: true + title: true } } } @@ -169,7 +152,7 @@ export class ProjectsService { }); project['projectFocusAreas'] = this.removeDuplicateFocusAreas(project?.projectFocusAreas); return project; - } catch(err) { + } catch (err) { this.handleErrors(err, `${uid}`); } } @@ -178,7 +161,7 @@ export class ProjectsService { uid: string, userEmail: string ) { - const member:any = await this.getMemberInfo(userEmail); + const member: any = await this.getMemberInfo(userEmail); const existingData = await this.getProjectByUid(uid); await this.isMemberAllowedToDelete(member, existingData); try { @@ -186,9 +169,9 @@ export class ProjectsService { where: { uid }, data: { isDeleted: true } }); - await this.cacheService.reset(); + await this.cacheService.reset({ service: 'projects'}); return result; - } catch(err) { + } catch (err) { this.handleErrors(err, `${uid}`); } } @@ -216,7 +199,7 @@ export class ProjectsService { return await this.memberService.findMemberByEmail(memberEmail) }; - async isMemberAllowedToEdit(member, teams, project ) { + async isMemberAllowedToEdit(member, teams, project) { const res = await this.memberService.isMemberPartOfTeams(member, teams); if (res || this.memberService.checkIfAdminUser(member) || member.uid === project.createdBy) { return true; @@ -236,7 +219,7 @@ export class ProjectsService { async createProjectWithFocusAreas(focusAreas, transaction) { if (focusAreas && focusAreas.length) { - const projectFocusAreas:any = []; + const projectFocusAreas: any = []; const focusAreaHierarchies = await transaction.focusAreaHierarchy.findMany({ where: { subFocusAreaUid: { @@ -261,7 +244,7 @@ export class ProjectsService { data: projectFocusAreas } } - } + } } async isFocusAreaModified(projectId, focusAreas, transaction) { @@ -275,10 +258,10 @@ export class ProjectsService { if (newFocusAreaUIds.length !== focusAreasUIds.length) { return true; - } + } if (projectFocusAreas.length === 0 && focusAreas.length === 0) { - return false + return false } return !focusAreasUIds.every(area => newFocusAreaUIds.includes(area)); } @@ -309,7 +292,7 @@ export class ProjectsService { return { projectFocusAreas: { some: { - ancestorArea:{ + ancestorArea: { title: { in: focusAreas?.split(',') } @@ -324,56 +307,56 @@ export class ProjectsService { removeDuplicateFocusAreas(focusAreas): any { const uniqueFocusAreas = {}; focusAreas.forEach(item => { - const uid = item.focusArea.uid; - const title = item.focusArea.title; - uniqueFocusAreas[uid] = { uid, title }; + const uid = item.focusArea.uid; + const title = item.focusArea.title; + uniqueFocusAreas[uid] = { uid, title }; }); return Object.values(uniqueFocusAreas); } - buildProjectFilter(query){ - const { + buildProjectFilter(query) { + const { name, lookingForFunding, team } = query; - const filter:any = [{ + const filter: any = [{ isDeleted: false }]; this.buildNameFilter(name, filter); this.buildFundingFilter(lookingForFunding, filter); this.buildMaintainingTeamFilter(team, filter); this.buildRecentProjectsFilter(query, filter); - return { + return { AND: filter }; } buildNameFilter(name, filter) { if (name) { - filter.push({ + filter.push({ name: { contains: name, mode: 'insensitive' } }); - } + } } buildFundingFilter(funding, filter) { if (funding === "true") { - filter.push({ + filter.push({ lookingForFunding: true }); - } + } } buildMaintainingTeamFilter(team, filter) { if (team) { - filter.push({ + filter.push({ maintainingTeamUid: team }); - } + } } /** @@ -388,7 +371,7 @@ export class ProjectsService { * @returns The constructed query with a 'createdAt' filter if 'is_recent' is 'true', * or an empty object if 'is_recent' is not provided or set to 'false'. */ - buildRecentProjectsFilter(queryParams, filter?) { + buildRecentProjectsFilter(queryParams, filter?) { const { isRecent } = queryParams; const recentFilter = { createdAt: { @@ -397,10 +380,35 @@ export class ProjectsService { }; if (isRecent === 'true' && !filter) { return recentFilter; - } + } if (isRecent === 'true' && filter) { filter.push(recentFilter); } return {}; } + + /** + * Fetches team names that maintain atleast a single project. + * + * @returns Set of team names. + */ + async getProjectFilters() { + const maintainingTeams = await this.prisma.team.findMany({ + where: { + maintainingProjects: { + some: {}, + } + }, + select: { + uid: true, + name: true, + logo: { + select: { + url: true + } + } + } + }) + return { maintainedBy: maintainingTeams.map((team) => ({ uid: team.uid, name: team.name, logo: team.logo?.url })) }; + } } diff --git a/apps/web-api/src/shared/shared.module.ts b/apps/web-api/src/shared/shared.module.ts index 26a424c30..a29c68c0a 100644 --- a/apps/web-api/src/shared/shared.module.ts +++ b/apps/web-api/src/shared/shared.module.ts @@ -10,6 +10,7 @@ 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'; +import { CacheService } from '../utils/cache/cache.service'; @Global() @Module({ @@ -26,6 +27,7 @@ import { FileEncryptionService } from '../utils/file-encryption/file-encryption. ImagesService, FileUploadService, FileEncryptionService, + CacheService ], exports: [ PrismaService, @@ -39,6 +41,7 @@ import { FileEncryptionService } from '../utils/file-encryption/file-encryption. ImagesService, FileUploadService, FileEncryptionService, + CacheService ], }) 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 a026e2138..68a813b12 100644 --- a/apps/web-api/src/teams/teams.controller.ts +++ b/apps/web-api/src/teams/teams.controller.ts @@ -23,19 +23,37 @@ const server = initNestServer(apiTeam); type RouteShape = typeof server.routeShapes; @Controller() export class TeamsController { - constructor(private readonly teamsService: TeamsService) {} + constructor(private readonly teamsService: TeamsService) { } + + @Api(server.route.teamFilters) + @ApiQueryFromZod(TeamQueryParams) + async getTeamFilters(@Req() request: Request) { + const queryableFields = prismaQueryableFieldsFromZod( + ResponseTeamWithRelationsSchema + ); + const builder = new PrismaQueryBuilder(queryableFields); + const builtQuery = builder.build(request.query); + const { focusAreas }: any = request.query; + builtQuery.where = { + AND: [ + builtQuery.where ? builtQuery.where : {}, + this.teamsService.buildFocusAreaFilters(focusAreas), + this.teamsService.buildRecentTeamsFilter(request.query) + ] + } + return await this.teamsService.getTeamFilters(builtQuery); + } @Api(server.route.getTeams) @ApiQueryFromZod(TeamQueryParams) @ApiOkResponseFromZod(ResponseTeamWithRelationsSchema.array()) - @NoCache() findAll(@Req() request: Request) { const queryableFields = prismaQueryableFieldsFromZod( ResponseTeamWithRelationsSchema ); const builder = new PrismaQueryBuilder(queryableFields); const builtQuery = builder.build(request.query); - const { focusAreas } : any = request.query; + const { focusAreas }: any = request.query; builtQuery.where = { AND: [ builtQuery.where ? builtQuery.where : {}, @@ -74,4 +92,5 @@ export class TeamsController { 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.service.ts b/apps/web-api/src/teams/teams.service.ts index dc05703e2..8c9456ee3 100644 --- a/apps/web-api/src/teams/teams.service.ts +++ b/apps/web-api/src/teams/teams.service.ts @@ -4,9 +4,8 @@ import { ForbiddenException, BadRequestException, NotFoundException, - Inject, forwardRef, - CACHE_MANAGER + Inject } from '@nestjs/common'; import * as path from 'path'; import { z } from 'zod'; @@ -19,9 +18,9 @@ import { ParticipantsRequestService } from '../participants-request/participants import { hashFileName } from '../utils/hashing'; 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 { LogService } from '../shared/log.service'; import { copyObj, buildMultiRelationMapping, buildRelationMapping } from '../utils/helper/helper'; +import { CacheService } from '../utils/cache/cache.service'; @Injectable() export class TeamsService { @@ -35,8 +34,8 @@ export class TeamsService { private logger: LogService, private forestadminService: ForestAdminService, private notificationService: NotificationService, - @Inject(CACHE_MANAGER) private cacheService: Cache - ) {} + private cacheService: CacheService + ) { } /** * Find all teams based on provided query options. @@ -46,10 +45,14 @@ export class TeamsService { * (filter, pagination, sorting, etc.) * @returns A list of teams that match the query options */ - async findAll(queryOptions: Prisma.TeamFindManyArgs): Promise { + async findAll(queryOptions: Prisma.TeamFindManyArgs): Promise<{ count: Number, teams: Team[] }> { try { - return this.prisma.team.findMany({ ...queryOptions }); - } catch(err) { + const [teams, teamsCount] = await Promise.all([ + this.prisma.team.findMany(queryOptions), + this.prisma.team.count({ where: queryOptions.where }), + ]); + return { count: teamsCount, teams: teams }; + } catch (err) { return this.handleErrors(err); } } @@ -104,7 +107,7 @@ export class TeamsService { }); team.teamFocusAreas = this.removeDuplicateFocusAreas(team.teamFocusAreas); return team; - } catch(err) { + } catch (err) { return this.handleErrors(err, uid); } } @@ -120,7 +123,7 @@ export class TeamsService { return this.prisma.team.findUniqueOrThrow({ where: { name }, }); - } catch(err) { + } catch (err) { return this.handleErrors(err); } }; @@ -133,14 +136,14 @@ export class TeamsService { * @returns The created team record */ async createTeam( - team: Prisma.TeamUncheckedCreateInput, + team: Prisma.TeamUncheckedCreateInput, tx: Prisma.TransactionClient ): Promise { try { return await tx.team.create({ data: team, }); - } catch(err) { + } catch (err) { return this.handleErrors(err); } } @@ -163,7 +166,7 @@ export class TeamsService { where: { uid }, data: team, }); - } catch(err) { + } catch (err) { return this.handleErrors(err, `${uid}`); } } @@ -211,7 +214,7 @@ export class TeamsService { const formattedTeam = await this.formatTeam(null, newTeam, tx); const createdTeam = await this.createTeam(formattedTeam, tx); return createdTeam; - } + } /** * Format team data for creation or update @@ -243,7 +246,7 @@ export class TeamsService { 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); } @@ -277,7 +280,7 @@ export class TeamsService { * @param focusAreas - An array of focus areas associated with the team * @returns A deduplicated array of focus areas */ - private removeDuplicateFocusAreas(focusAreas):any { + private removeDuplicateFocusAreas(focusAreas): any { const uniqueFocusAreas = {}; focusAreas.forEach(item => { const { uid, title } = item.focusArea; @@ -308,7 +311,7 @@ export class TeamsService { participantType: 'TEAM', newData: { ...newTeamData }, }, - tx + tx ); } @@ -317,7 +320,7 @@ export class TeamsService { * This ensures that the system is up-to-date with the latest changes. */ private async postUpdateActions(): Promise { - await this.cacheService.reset(); + await this.cacheService.reset({ service: "teams" }); await this.forestadminService.triggerAirtableSync(); } @@ -330,7 +333,7 @@ export class TeamsService { */ async createTeamWithFocusAreas(team, transaction: Prisma.TransactionClient) { if (team.focusAreas && team.focusAreas.length > 0) { - let teamFocusAreas:any = []; + let teamFocusAreas: any = []; const focusAreaHierarchies = await transaction.focusAreaHierarchy.findMany({ where: { subFocusAreaUid: { @@ -358,7 +361,7 @@ export class TeamsService { } return {}; } - + /** * Updates focus areas for an existing team. * @@ -376,7 +379,7 @@ export class TeamsService { } 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 @@ -387,7 +390,7 @@ export class TeamsService { return { teamFocusAreas: { some: { - ancestorArea:{ + ancestorArea: { title: { in: focusAreas?.split(',') } @@ -398,23 +401,23 @@ 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 { + buildTeamFilter(queryParams) { + const { name, - plnFriend, - industryTags, + plnFriend, + industryTags, technologies, membershipSources, fundingStage, - officeHours + officeHours } = queryParams; - const filter:any = []; + const filter: any = []; this.buildNameAndPLNFriendFilter(name, plnFriend, filter); this.buildIndustryTagsFilter(industryTags, filter); this.buildTechnologiesFilter(technologies, filter); @@ -422,7 +425,7 @@ export class TeamsService { this.buildFundingStageFilter(fundingStage, filter); this.buildOfficeHoursFilter(officeHours, filter); this.buildRecentTeamsFilter(queryParams, filter); - return { + return { AND: filter }; }; @@ -435,17 +438,17 @@ export class TeamsService { */ buildNameAndPLNFriendFilter(name, plnFriend, filter) { if (name) { - filter.push({ + filter.push({ name: { contains: name, mode: 'insensitive' } }); - } + } if (!(plnFriend === "true")) { - filter.push({ + filter.push({ plnFriend: false - }); + }); } } @@ -455,14 +458,14 @@ export class TeamsService { * @param filter - Filter array to be appended to */ buildIndustryTagsFilter(industryTags, filter) { - const tags = industryTags?.split(',').map(tag=> tag.trim()); + const tags = industryTags?.split(',').map(tag => tag.trim()); if (tags?.length > 0) { - tags.map((tag)=> { + tags.map((tag) => { filter.push({ - industryTags:{ + industryTags: { some: { - title: { - in: tag + title: { + in: tag } } } @@ -479,12 +482,12 @@ export class TeamsService { buildTechnologiesFilter(technologies, filter) { const tags = technologies?.split(',').map(tech => tech.trim()); if (tags?.length > 0) { - tags.map((tag)=> { + tags.map((tag) => { filter.push({ technologies: { some: { - title: { - in: tag + title: { + in: tag } } } @@ -501,12 +504,12 @@ export class TeamsService { buildMembershipSourcesFilter(membershipSources, filter) { const sources = membershipSources?.split(',').map(source => source.trim()); if (sources?.length > 0) { - sources.map((source)=> { + sources.map((source) => { filter.push({ membershipSources: { some: { - title: { - in: source + title: { + in: source } } } @@ -555,7 +558,7 @@ export class TeamsService { * @returns The constructed query with a 'createdAt' filter if 'is_recent' is 'true', * or an empty object if 'is_recent' is not provided or set to 'false'. */ - buildRecentTeamsFilter(queryParams, filter?) { + buildRecentTeamsFilter(queryParams, filter?) { const { isRecent } = queryParams; const recentFilter = { createdAt: { @@ -564,7 +567,7 @@ export class TeamsService { }; if (isRecent === 'true' && !filter) { return recentFilter; - } + } if (isRecent === 'true' && filter) { filter.push(recentFilter); } @@ -720,4 +723,64 @@ export class TeamsService { }); } } + + /** + * Fetches filter tags for teams for felicitating ease searching. + * + * @returns Set of industry tags, membership sources, funding stages + * and technologies that contains atleast one team. + */ + async getTeamFilters(queryParams) { + const [industryTags, membershipSources, fundingStages, technologies] = await Promise.all([ + this.prisma.industryTag.findMany({ + where: { + teams: { + some: { ...queryParams.where }, + }, + }, + select: { + title: true, + }, + }), + + this.prisma.membershipSource.findMany({ + where: { + teams: { + some: { ...queryParams.where }, + }, + }, + select: { + title: true, + }, + }), + + this.prisma.fundingStage.findMany({ + where: { + teams: { + some: { ...queryParams.where }, + }, + }, + select: { + title: true, + }, + }), + + this.prisma.technology.findMany({ + where: { + teams: { + some: { ...queryParams.where }, + }, + }, + select: { + title: true, + }, + }), + ]); + return { + industryTags: industryTags.map((tag) => tag.title), + membershipSources: membershipSources.map((source) => source.title), + fundingStages: fundingStages.map((stage) => stage.title), + technologies: technologies.map((tech) => tech.title), + }; + } } diff --git a/apps/web-api/src/utils/cache/cache.service.ts b/apps/web-api/src/utils/cache/cache.service.ts new file mode 100644 index 000000000..0bfbd5eb5 --- /dev/null +++ b/apps/web-api/src/utils/cache/cache.service.ts @@ -0,0 +1,59 @@ +import { Injectable, Inject, CACHE_MANAGER } from '@nestjs/common'; +import { Cache } from 'cache-manager'; +import axios from 'axios'; +import { LogService } from '../../shared/log.service'; + +@Injectable() +export class CacheService { + constructor( + @Inject(CACHE_MANAGER) private cache: Cache, + private logService: LogService + ) {} + + // Mapping service names to tags + private serviceTagsMap = { + members: ['member-filters', 'member-list'], + projects: ['project-list', 'focus-areas'], + teams: ['team-filters', 'team-list', 'focus-areas'], + 'participants-requests': ['member-filters', 'member-list','team-filters', 'team-list', 'focus-areas'] + }; + + // Reset cache and call API based on service + async reset(data) { + const { service } = data; + await this.cache.reset(); // Reset the cache + const tags = this.serviceTagsMap[service]; + if (tags) { + await this.revalidateCache(tags); + } + } + + // Function to call the revalidate API + private async revalidateCache(tags: string[]) { + const baseUrl = process.env.WEB_UI_BASE_URL; + const token = process.env.REVALIDATE_API_TOKEN; // Assuming token is stored in env variable + if (!baseUrl) { + this.logService.error('WEB_UI_BASE_URL is not defined in the environment variables.'); + return; + } + if (!token) { + this.logService.error('REVALIDATE_API_TOKEN is not defined in the environment variables.'); + return; + } + const url = `${baseUrl}/api/revalidate`; + try { + const response = await axios.post( + url, + { tags }, + { + headers: { + Authorization: `Bearer ${token}`, // Adding Bearer token to headers + }, + }, + ); + this.logService.info(`Revalidation API called successfully with tags: ${tags.join(', ')}`); + } catch (error) { + this.logService.error('Error calling revalidate API:', error.message); + } + } +} diff --git a/apps/web-api/src/utils/redis/redis.service.ts b/apps/web-api/src/utils/redis/redis.service.ts deleted file mode 100644 index a44562f50..000000000 --- a/apps/web-api/src/utils/redis/redis.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* eslint-disable prettier/prettier */ -import { Injectable } from '@nestjs/common'; -import cacheManager from 'cache-manager'; -import redisStore from 'cache-manager-redis-store'; - -@Injectable() -export class RedisService { - async resetAllCache() { - const redisCache = cacheManager.caching({ - store: redisStore, - host: process.env.REDIS_HOST, - url: process.env.REDIS_TLS_URL, - port: Number(process.env.REDIS_PORT), - password: process.env.REDIS_PASSWORD, - tls: process.env.REDIS_WITH_TLS - ? { - rejectUnauthorized: false, - requestCert: true, - } - : null, - }); - await redisCache.reset(); - } -} diff --git a/libs/contracts/src/lib/contract-home.ts b/libs/contracts/src/lib/contract-home.ts index 8b3b25f4e..09a322c46 100644 --- a/libs/contracts/src/lib/contract-home.ts +++ b/libs/contracts/src/lib/contract-home.ts @@ -7,6 +7,15 @@ import { const contract = initContract(); export const apiHome = contract.router({ + getTeamsAndProjects: { + method: 'GET', + path: `${getAPIVersionAsPath('1')}/home/entities`, + query: contract.query, + responses: { + 200: contract.response() + }, + summary: 'Get all featured members, projects, teams and events' + }, getAllFeaturedData: { method: 'GET', path: `${getAPIVersionAsPath('1')}/home/featured`, diff --git a/libs/contracts/src/lib/contract-member.ts b/libs/contracts/src/lib/contract-member.ts index 7e2b2352e..66eba60ac 100644 --- a/libs/contracts/src/lib/contract-member.ts +++ b/libs/contracts/src/lib/contract-member.ts @@ -28,6 +28,15 @@ export const apiMembers = contract.router({ }, summary: 'Get member roles', }, + getMemberFilters: { + method: 'GET', + path: `${getAPIVersionAsPath('1')}/members/filters`, + query: MemberQueryParams, + responses: { + 200: contract.response(), + }, + summary: 'Get member filter values', + }, getMember: { method: 'GET', path: `${getAPIVersionAsPath('1')}/members/:uid`, diff --git a/libs/contracts/src/lib/contract-project.ts b/libs/contracts/src/lib/contract-project.ts index b5fabc1a7..85d6f9adc 100644 --- a/libs/contracts/src/lib/contract-project.ts +++ b/libs/contracts/src/lib/contract-project.ts @@ -1,12 +1,20 @@ import { initContract } from '@ts-rest/core'; import { - ResponseProjectWithRelationsSchema + ResponseProjectWithRelationsSchema } from '../schema'; import { getAPIVersionAsPath } from '../utils/versioned-path'; const contract = initContract(); export const apiProjects = contract.router({ + getProjectFilters: { + method: 'GET', + path: `${getAPIVersionAsPath('1')}/projects/filters`, + responses: { + 200: contract.response(), + }, + summary: 'Get project filters', + }, getProjects: { method: 'GET', path: `${getAPIVersionAsPath('1')}/projects`, diff --git a/libs/contracts/src/lib/contract-team.ts b/libs/contracts/src/lib/contract-team.ts index 4578d06f6..199bc93d7 100644 --- a/libs/contracts/src/lib/contract-team.ts +++ b/libs/contracts/src/lib/contract-team.ts @@ -9,6 +9,15 @@ import { getAPIVersionAsPath } from '../utils/versioned-path'; const contract = initContract(); export const apiTeam = contract.router({ + teamFilters: { + method: 'GET', + path: `${getAPIVersionAsPath('1')}/teams/filters`, + query: TeamQueryParams, + responses: { + 200: contract.response(), + }, + summary: 'filter teams', + }, getTeams: { method: 'GET', path: `${getAPIVersionAsPath('1')}/teams`, diff --git a/libs/contracts/src/schema/member.ts b/libs/contracts/src/schema/member.ts index 7a026bbdd..4cbcf8084 100644 --- a/libs/contracts/src/schema/member.ts +++ b/libs/contracts/src/schema/member.ts @@ -22,7 +22,8 @@ export const PreferenceSchema = z.object({ showLinkedin:z.boolean(), showDiscord:z.boolean(), showGithubProjects:z.boolean(), - showTwitter:z.boolean() + showTwitter:z.boolean(), + showSubscription:z.boolean() }); export const MemberSchema = z.object({ @@ -30,7 +31,7 @@ export const MemberSchema = z.object({ uid: z.string(), name: z.string(), email: z.string(), - externalId: z.string(), + externalId: z.string().nullish(), imageUid: z.string().nullish(), githubHandler: z.string().nullish(), discordHandler: z.string().nullish(), @@ -38,17 +39,24 @@ export const MemberSchema = z.object({ telegramHandler: z.string().nullish(), officeHours: z.string().nullish(), airtableRecId: z.string().nullish(), - plnFriend: z.boolean(), + plnFriend: z.boolean().nullish(), bio: z.string().nullish(), + signUpSource: z.string().nullish(), + signUpMedium: z.string().nullish(), + signUpCampaign: z.string().nullish(), isFeatured: z.boolean().nullish(), createdAt: z.string(), updatedAt: z.string(), - locationUid: z.string(), - openToWork: z.boolean(), + locationUid: z.string().nullable(), + openToWork: z.boolean().nullish(), linkedinHandler: z.string().nullish(), repositories: GitHubRepositorySchema.array().optional(), preferences: PreferenceSchema.optional(), - projectContributions: z.array(ProjectContributionSchema).optional() + projectContributions: z.array(ProjectContributionSchema).optional(), + isVerified:z.boolean().nullish(), + isUserConsent: z.boolean().nullish(), + isSubscribedToNewsletter: z.boolean().nullish(), + teamOrProjectURL: z.string().nullish() }); @@ -74,7 +82,18 @@ export const CreateMemberSchema = MemberSchema.pick({ officeHours: true, plnFriend: true, locationUid: true, - bio: true + bio: true, + signUpSource: true, + isFeatured: true, + openToWork: true, + linkedinHandler: true, + telegramHandler: true, + isVerified: true, + isUserConsent: true, + isSubscribedToNewsletter: true, + teamOrProjectURL: true, + preferences: true, + projectContributions: true }); export const MemberRelationalFields = ResponseMemberWithRelationsSchema.pick({ diff --git a/libs/contracts/src/schema/participants-request.ts b/libs/contracts/src/schema/participants-request.ts index 4d998cdb9..6d83db9c4 100644 --- a/libs/contracts/src/schema/participants-request.ts +++ b/libs/contracts/src/schema/participants-request.ts @@ -6,7 +6,7 @@ export const statusEnum = z.enum(['PENDING', 'APPROVED', 'REJECTED']); export const participantTypeEnum = z.enum(['MEMBER', 'TEAM']); const oldDataPostSchema = z.object({}); const teamMappingSchema = z.object({ - role: z.string(), + role: z.string().nullish().optional(), teamUid: z.string(), teamTitle: z.string(), }); @@ -35,8 +35,8 @@ const newDataMemberSchema = z.object({ name: z.string(), email: z.string(), plnStartDate: z.string().optional().nullable(), - teamAndRoles: z.array(teamMappingSchema).nonempty(), - skills: z.array(skillsMappingSchema).nonempty(), + teamAndRoles: z.array(teamMappingSchema).optional(), + skills: z.array(skillsMappingSchema).optional(), city: z.string().optional().nullable(), country: z.string().optional().nullable(), region: z.string().optional().nullable(), @@ -46,9 +46,20 @@ const newDataMemberSchema = z.object({ linkedinHandler: z.string().optional().nullable(), telegramHandler: z.string().optional().nullable(), officeHours: z.string().optional().nullable(), - imageUid: z.string().optional().nullable(), - moreDetails: z.string().optional().nullable(), - projectContributions: z.array(ProjectContributionSchema as any).optional() + imageUid: z.string().optional().nullish(), + moreDetails: z.string().optional().nullish(), + projectContributions: z.array(ProjectContributionSchema as any).optional(), + bio: z.string().nullish(), + signUpSource: z.string().nullish(), + signUpMedium: z.string().nullish(), + signUpCampaign: z.string().nullish(), + isFeatured: z.boolean().nullish(), + locationUid: z.string().nullish(), + openToWork: z.boolean().nullish(), + isVerified: z.boolean().nullish(), + isUserConsent: z.boolean().nullish(), + isSubscribedToNewsletter: z.boolean().nullish(), + teamOrProjectURL: z.string().nullish() }); const newDataTeamSchema = z.object({ @@ -103,7 +114,15 @@ export const FindUniqueIdentiferSchema = z.object({ }) const ProcessParticipantRequest = z.object({ - status: statusEnum, + status: statusEnum, + isVerified: z.boolean() +}) +const ProcessBulkRequest = z.object({ + uid: z.string(), + status: statusEnum, + participantType: participantTypeEnum, + isVerified: z.boolean() }) -export class ProcessParticipantReqDto extends createZodDto(ProcessParticipantRequest) {} +export class ProcessBulkParticipantRequest extends createZodDto(ProcessBulkRequest) { } +export class ProcessParticipantReqDto extends createZodDto(ProcessParticipantRequest) { } export class FindUniqueIdentiferDto extends createZodDto(FindUniqueIdentiferSchema) { } diff --git a/libs/contracts/src/schema/project-contribution.ts b/libs/contracts/src/schema/project-contribution.ts index 0b1335565..5be0fd3a5 100644 --- a/libs/contracts/src/schema/project-contribution.ts +++ b/libs/contracts/src/schema/project-contribution.ts @@ -2,24 +2,16 @@ import { z } from 'zod'; import { compareDateWithoutTime, compareMonthYear } from '../../src/utils/date-utils'; const ProjectContribution = z.object({ - role: z.string(), - currentProject: z.boolean(), + role: z.string().nullish(), + currentProject: z.boolean().nullish(), startDate: z.string().nullish(), - endDate: z.string().optional(), - description: z.string().optional().nullish(), + endDate: z.string().nullish(), + description: z.string().nullish(), projectUid: z.string(), - uid: z.string().optional() + uid: z.string().nullish() }); export const ProjectContributionSchema = ProjectContribution.superRefine((data, ctx) => { - if (!data.currentProject && !data.endDate) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'End date should not be null for past contribution', - fatal: true, - }); - } - if (data.startDate && data.endDate && compareDateWithoutTime(data.startDate, data.endDate) >= 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/libs/contracts/src/schema/project.ts b/libs/contracts/src/schema/project.ts index c31399961..312ee9115 100644 --- a/libs/contracts/src/schema/project.ts +++ b/libs/contracts/src/schema/project.ts @@ -1,5 +1,8 @@ -import { createZodDto } from 'nestjs-zod'; -import { z } from 'nestjs-zod/z'; +import { createZodDto } from '@abitia/zod-dto'; +import { z } from 'zod'; +import { ResponseTeamWithRelationsSchema } from './team'; +import { ResponseMemberWithRelationsSchema } from './member'; +import { ResponseImageWithRelationsSchema } from './image'; const TypeEnum = z.enum(['MAINTENER', 'COLLABORATOR']); @@ -12,6 +15,7 @@ const ContributionSchema = z.object({ const ProjectSchema = z.object({ id: z.number().int(), + uid: z.string(), logoUid: z.string().optional().nullable(), name: z.string(), tagline: z.string(), @@ -40,11 +44,17 @@ const ProjectSchema = z.object({ uid: z.string(), title: z.string() }).array().optional(), - contributions: ContributionSchema.array().optional() + contributions: ContributionSchema.array().optional(), + isDeleted: z.boolean().default(false) }); export const ResponseProjectSchema = ProjectSchema.omit({ id: true }).strict(); -export const ResponseProjectWithRelationsSchema = ProjectSchema.extend({}); +export const ResponseProjectWithRelationsSchema = ResponseProjectSchema.extend({ + logo: ResponseImageWithRelationsSchema.optional(), + maintainingTeam: ResponseTeamWithRelationsSchema.optional(), + contributingTeams: ResponseTeamWithRelationsSchema.array().optional(), + creator: ResponseMemberWithRelationsSchema.optional() +}); export const ResponseProjectSuccessSchema = z.object({ success: z.boolean()}); // omit score and id to avoid update from request export class UpdateProjectDto extends createZodDto(ProjectSchema.partial().omit({ id:true, score: true })) {}