diff --git a/.github/workflows/server_jest.yml b/.github/workflows/server_jest.yml new file mode 100644 index 00000000..3dd095a6 --- /dev/null +++ b/.github/workflows/server_jest.yml @@ -0,0 +1,66 @@ +# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs + +name: Server Jest + +on: + push: + branches: [ "main", "dev","dev-be" ] + paths: 'server/**' + pull_request: + branches: [ "main", "dev","dev-be" ] + paths: 'server/**' + +jobs: + build: + runs-on: ubuntu-latest + steps: + # 해당 저장소의 코드를 가져옵니다. + - name: Checkout + uses: actions/checkout@v2 + + # Node 18 버전을 사용합니다. + - name: Install node + uses: actions/setup-node@v2 + with: + node-version: '18' + cache: 'npm' + + # yarn을 설치합니다. + - name: Install Yarn + run: npm install yarn + + # 설치된 yarn을 통해 패키지를 설치합니다. + - name: Install dependencies + run: yarn install + + # 테스트 수행과 그 테스트 결과를 xml파일로 생성합니다. + - name: Run tests + run: yarn server-test | tee ./coverage.txt + + # 테스트 결과를 담은 xml 파일을 레포트로 변환합니다. + - name: Test Report + uses: dorny/test-reporter@v1 + if: success() || failure() # run this step even if previous step failed + with: + name: test-results + path: server/junit.xml + fail-on-error: 'false' + reporter: jest-junit # Format of test results + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Jest Coverage Comment + uses: MishaKav/jest-coverage-comment@main + with: + coverage-path: ./coverage.txt + coverage-summary-path: ./server/coverage/coverage-final.json + junitxml-path: ./server/junit.xml + - name: build 실패 시 Slack 알림 + uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + author_name: 백엔드 빌드 실패 알림 + fields: repo, message, commit, author, action, eventName, ref, workflow, job, took + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_FAIL_WEBHOOK_URL }} + if: failure() \ No newline at end of file diff --git a/client/.eslintrc b/client/.eslintrc index e8578692..6048da0b 100644 --- a/client/.eslintrc +++ b/client/.eslintrc @@ -26,6 +26,10 @@ ], "@typescript-eslint/no-var-requires": "off", "react/no-unknown-property": "off", - "no-duplicate-imports": "off" + "no-duplicate-imports": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/consistent-type-imports": "error", + "no-nested-ternary": "off", + "@typescript-eslint/no-empty-interface": "off" } } diff --git a/client/.gitignore b/client/.gitignore index 8582caf0..b0c8c708 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -1,4 +1,5 @@ env/* build/* dist/* -.idea/ \ No newline at end of file +.idea/ +*.pem \ No newline at end of file diff --git a/client/config/webpack.dev.https.ts b/client/config/webpack.dev.https.ts new file mode 100644 index 00000000..13552d87 --- /dev/null +++ b/client/config/webpack.dev.https.ts @@ -0,0 +1,34 @@ +import 'webpack-dev-server'; + +import type { Configuration } from 'webpack'; + +import path from 'path'; + +import { merge } from 'webpack-merge'; + +import common from './webpack.common'; + +const config: Configuration = { + devtool: 'inline-source-map', + mode: 'development', + module: { + rules: [ + { + test: /\.css$/, + use: ['style-loader', 'css-loader', 'postcss-loader'], + exclude: /node_modules/, + }, + ], + }, + devServer: { + https: { + key: path.resolve(__dirname, '..', 'localhost+2-key.pem'), + cert: path.resolve(__dirname, '..', 'localhost+2.pem'), + }, + hot: true, + open: true, + historyApiFallback: true, + }, +}; + +export default merge(common, config); diff --git a/client/package.json b/client/package.json index 7c64c5ea..b22b0ec2 100644 --- a/client/package.json +++ b/client/package.json @@ -5,6 +5,7 @@ "license": "MIT", "scripts": { "dev": "cross-env NODE_ENV=development webpack serve --config ./config/webpack.dev.ts --progress", + "dev:https": "cross-env NODE_ENV=development webpack serve --config ./config/webpack.dev.https.ts --progress", "build": "cross-env NODE_ENV=production webpack --config ./config/webpack.prod.ts --progress", "analysis": "cross-env NODE_ENV=production webpack --config ./config/webpack.analysis.ts --progress", "test": "jest" @@ -15,8 +16,10 @@ "axios": "^1.1.3", "classnames": "^2.3.2", "react": "^18.2.0", + "react-custom-scrollbars-2": "^4.5.0", "react-dom": "^18.2.0", "react-hook-form": "^7.39.4", + "react-modal": "^3.16.1", "react-router-dom": "^6.4.3", "react-toastify": "^9.1.1", "shared": "1.0.0", @@ -29,6 +32,7 @@ "@babel/preset-env": "^7.20.2", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.18.6", + "@faker-js/faker": "^7.6.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.9", "@tanstack/react-query-devtools": "^4.16.1", "@testing-library/jest-dom": "^5.16.5", @@ -36,6 +40,7 @@ "@types/jest": "^29.2.2", "@types/react": "^18.0.25", "@types/react-dom": "^18.0.8", + "@types/react-modal": "^3.13.1", "@types/webpack-bundle-analyzer": "^4.6.0", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", diff --git a/client/src/App.tsx b/client/src/App.tsx index 072a03b9..7b823fb5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,6 +1,5 @@ -import AuthorizedLayer from '@components/AuthorizedLayer'; -import UnAuthorizedLayer from '@components/UnAuthorizedLayer'; import AccessDenied from '@pages/AccessDenied'; +import AuthorizedLayer from '@pages/AuthorizedLayer'; import Community from '@pages/Community'; import DM from '@pages/DM'; import DMRoom from '@pages/DMRoom'; @@ -10,6 +9,8 @@ import NotFound from '@pages/NotFound'; import Root from '@pages/Root'; import SignIn from '@pages/SignIn'; import SignUp from '@pages/SignUp'; +import UnAuthorizedLayer from '@pages/UnAuthorizedLayer'; +import UnknownError from '@pages/UnknownError'; import React from 'react'; import { RouterProvider, @@ -38,7 +39,8 @@ const router = createBrowserRouter( } /> } /> - } /> + } /> + } /> } /> , ), diff --git a/client/src/apis/auth.ts b/client/src/apis/auth.ts new file mode 100644 index 00000000..7c9e17bc --- /dev/null +++ b/client/src/apis/auth.ts @@ -0,0 +1,66 @@ +import type { SuccessResponse } from '@@types/apis/response'; + +import { API_URL } from '@constants/url'; +import axios from 'axios'; + +export interface SignUpRequest { + id: string; + nickname: string; + password: string; +} + +export interface SignUpResult { + message: string; +} + +export type SignUp = ( + fields: SignUpRequest, +) => Promise>; + +export const signUp: SignUp = ({ id, nickname, password }) => { + const endPoint = `${API_URL}/api/user/auth/signup`; + + return axios + .post(endPoint, { id, nickname, password }) + .then((response) => response.data); +}; + +export interface SignInRequest { + id: string; + password: string; +} + +export interface SignInResult { + _id: string; + accessToken: string; +} + +export type SignIn = ( + fields: SignInRequest, +) => Promise>; + +export const signIn: SignIn = ({ id, password }) => { + const endPoint = `${API_URL}/api/user/auth/signin`; + + return axios + .post(endPoint, { id, password }, { withCredentials: true }) + .then((response) => response.data); +}; +// 액세스 토큰으로 다시 유저 정보 요청해야함 +// _id, id(이메일), nickname, status, profileUrl, description + +export interface ReissueTokenResult { + accessToken: string; +} + +export type ReissueToken = () => Promise>; + +export const reissueToken: ReissueToken = () => { + const endPoint = `${API_URL}/api/user/auth/refresh`; + + return axios + .post(endPoint, {}, { withCredentials: true }) + .then((response) => { + return response.data; + }); +}; diff --git a/client/src/apis/dm.ts b/client/src/apis/dm.ts new file mode 100644 index 00000000..a241d3c2 --- /dev/null +++ b/client/src/apis/dm.ts @@ -0,0 +1,16 @@ +import type { User } from '@apis/user'; + +import { API_URL } from '@constants/url'; +import axios from 'axios'; + +export interface DirectMessage { + _id: string; + user: User; +} + +export type GetDirectMessagesResult = DirectMessage[]; + +export type GetDirectMessages = () => Promise; + +export const getDirectMessages: GetDirectMessages = () => + axios.get(`${API_URL}/api/user/dms`).then((res) => res.data.result); diff --git a/client/src/apis/user.ts b/client/src/apis/user.ts new file mode 100644 index 00000000..a2e5f6d0 --- /dev/null +++ b/client/src/apis/user.ts @@ -0,0 +1,63 @@ +import type { SuccessResponse } from '@@types/apis/response'; + +import { API_URL } from '@constants/url'; +import axios from 'axios'; + +export type UserStatus = 'online' | 'offline' | 'afk'; + +export interface User { + _id: string; + id: string; + nickname: string; + status: UserStatus; + profileUrl: string; + description: string; +} + +export type MyInfoResult = User; + +type GetMyInfo = () => Promise; + +export const getMyInfo: GetMyInfo = () => { + return axios + .get(`${API_URL}/api/user/auth/me`) + .then((response) => response.data.result); +}; + +export interface GetFollowingsResult { + followings: User[]; +} +export type GetFollowingsResponse = SuccessResponse; + +export const getFollowings = (): Promise => + axios.get(`${API_URL}/api/user/followings`).then((res) => res.data); + +export interface UpdateFollowingResult { + message?: string; +} +export type UpdateFollowingResponse = SuccessResponse; + +export const updateFollowing = ( + userId: string, +): Promise => + axios.post(`${API_URL}/api/user/following/${userId}`).then((res) => res.data); + +export interface GetFollowersResult { + followers: User[]; +} + +export type GetFollowersResponse = SuccessResponse; + +export const getFollowers = (): Promise => + axios.get(`${API_URL}/api/user/followers`).then((res) => res.data); +export interface GetUsersParams { + search: string; +} +export interface GetUsersResult { + users: User[]; +} + +export type GetUsersResponse = SuccessResponse; + +export const GetUsers = (params: GetUsersParams): Promise => + axios.get(`${API_URL}/api/users`, { params }).then((res) => res.data); diff --git a/client/src/components/AuthInput/index.tsx b/client/src/components/AuthInput/index.tsx index 9161148e..d9258939 100644 --- a/client/src/components/AuthInput/index.tsx +++ b/client/src/components/AuthInput/index.tsx @@ -5,7 +5,7 @@ import type { } from 'react'; import cn from 'classnames'; -import React from 'react'; +import React, { forwardRef } from 'react'; interface Props extends ComponentPropsWithRef<'input'> { type?: HTMLInputTypeAttribute; @@ -15,49 +15,57 @@ interface Props extends ComponentPropsWithRef<'input'> { placeholder?: string; } -const AuthInput: React.FC = ({ - type = 'text', - value = '', - onChange, - className = '', - placeholder = 'Default', - ...restProps -}) => { - const inputValueFilled = value.length >= 1; - - const movePlaceholderTop = cn([ +const AuthInput: React.FC = forwardRef( + ( { - 'top-1/2': !inputValueFilled, - 'text-s16': !inputValueFilled, - 'top-[16px]': inputValueFilled, - 'text-s12': inputValueFilled, + type = 'text', + value = '', + onChange, + className = '', + placeholder = 'Default', + ...restProps }, - ]); + ref, + ) => { + const inputValueFilled = value.length >= 1; - const moveInputValueBottom = cn([ - { - 'pt-6': inputValueFilled, - }, - ]); - - return ( -
- + const movePlaceholderTop = cn([ + { + 'top-1/2': !inputValueFilled, + 'text-s16': !inputValueFilled, + 'top-[16px]': inputValueFilled, + 'text-s12': inputValueFilled, + }, + ]); + + const moveInputValueBottom = cn([ + { + 'pt-6': inputValueFilled, + }, + ]); + + return (
- {placeholder} + +
+ {placeholder} +
-
- ); -}; + ); + }, +); + +AuthInput.displayName = 'AuthInput'; export default AuthInput; diff --git a/client/src/components/AuthorizedLayer/index.tsx b/client/src/components/AuthorizedLayer/index.tsx deleted file mode 100644 index f1d0d205..00000000 --- a/client/src/components/AuthorizedLayer/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import { Outlet } from 'react-router-dom'; - -const AuthorizedLayer = () => { - return ; -}; - -export default AuthorizedLayer; diff --git a/client/src/components/Avatar/index.tsx b/client/src/components/Avatar/index.tsx index 88f4c14e..c339739d 100644 --- a/client/src/components/Avatar/index.tsx +++ b/client/src/components/Avatar/index.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react'; + import React from 'react'; export interface AvatarProps { @@ -5,6 +7,8 @@ export interface AvatarProps { variant: 'circle' | 'rectangle'; name: string; url?: string; + className?: string; + children?: ReactNode; } const ROUNDED = { @@ -12,25 +16,47 @@ const ROUNDED = { circle: 'rounded-full', }; -const WH = { +const SCALE = { small: 'w-[57px] h-[57px]', medium: 'w-[65px] h-[65px]', }; -const Avatar: React.FC = ({ name, url, size, variant }) => { +const getFirstLetter = (str: string) => { + const firstLetter = str.at(0); + + if (!firstLetter) { + console.warn( + `getFirstLetter의 인자로는 반드시 길이 1이상의 문자열이 들어와야 합니다.`, + ); + return ''; + } + + return firstLetter; +}; + +const Avatar: React.FC = ({ + name, + url, + size, + variant, + className = '', + children, +}) => { return (
- {url ? ( - {`${name}의 - ) : ( - name.at(0) - )} + {children} + {!children && + (url ? ( + {`${name}의 + ) : ( + getFirstLetter(name) + ))}
); }; diff --git a/client/src/components/Button/index.tsx b/client/src/components/Button/index.tsx index 115a0a4a..2c00cd1c 100644 --- a/client/src/components/Button/index.tsx +++ b/client/src/components/Button/index.tsx @@ -15,13 +15,13 @@ const buttonRounded = { const buttonBg = (outlined: boolean) => ({ primary: outlined ? 'border-primary hover:border-primary-dark active:border-primary' - : 'bg-primary hover:bg-primary-dark active:bg-primary', + : 'bg-primary hover:bg-primary-dark active:bg-primary border-primary', secondary: outlined ? 'border-secondary hover:border-secondary-dark active:border-secondary' - : 'bg-secondary hover:bg-secondary-dark active:bg-secondary', + : 'bg-secondary hover:bg-secondary-dark active:bg-secondary border-secondary', dark: outlined ? 'border-indigo hover:border-titleActive active:border-indigo' - : 'bg-indigo hover:bg-titleActive active:bg-indigo', + : 'bg-indigo hover:bg-titleActive active:bg-indigo border-indigo', }); const buttonText = (outlined: boolean) => ({ diff --git a/client/src/components/CommunityAvatar/index.tsx b/client/src/components/CommunityAvatar/index.tsx deleted file mode 100644 index bc0306f7..00000000 --- a/client/src/components/CommunityAvatar/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import Avatar, { AvatarProps } from '@components/Avatar'; -import React from 'react'; - -const CommunityAvatar: React.FC = ({ - variant = 'rectangle', - size = 'medium', - name, - url, -}) => { - return ; -}; - -export default CommunityAvatar; diff --git a/client/src/components/FollowerUserItem/index.tsx b/client/src/components/FollowerUserItem/index.tsx new file mode 100644 index 00000000..7eefe8fd --- /dev/null +++ b/client/src/components/FollowerUserItem/index.tsx @@ -0,0 +1,27 @@ +import type { User } from '@apis/user'; + +import UserItem from '@components/UserItem'; +import { EllipsisHorizontalIcon } from '@heroicons/react/20/solid'; +import React from 'react'; + +interface Props { + user: User; +} + +const FollowerUserItem: React.FC = ({ user }) => { + return ( + + + + } + /> + ); +}; + +export default FollowerUserItem; diff --git a/client/src/components/FollowingUserItem/index.tsx b/client/src/components/FollowingUserItem/index.tsx new file mode 100644 index 00000000..038f8459 --- /dev/null +++ b/client/src/components/FollowingUserItem/index.tsx @@ -0,0 +1,45 @@ +import type { User } from '@apis/user'; + +import UserItem from '@components/UserItem'; +import { + EllipsisHorizontalIcon, + ChatBubbleLeftIcon, +} from '@heroicons/react/20/solid'; +import useFollowingMutation from '@hooks/useFollowingMutation'; +import React, { memo } from 'react'; +import { Link } from 'react-router-dom'; + +interface Props { + user: User; +} + +const FollowingUserItem: React.FC = ({ user }) => { + const followingMutation = useFollowingMutation(user._id); + const { mutate: updateFollowing } = followingMutation; + + return ( + + + 다이렉트 메시지 + + + + + } + /> + ); +}; + +export default memo(FollowingUserItem); diff --git a/client/src/components/GnbItemContainer/index.tsx b/client/src/components/GnbItemContainer/index.tsx new file mode 100644 index 00000000..2f49c3c0 --- /dev/null +++ b/client/src/components/GnbItemContainer/index.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react'; + +import cn from 'classnames'; +import React, { useState, memo, useCallback } from 'react'; + +interface Props { + children: ReactNode; + disableLeftFillBar?: boolean; + isActive?: boolean; + tooltip?: string; +} + +// TODO: Tooltip 추가하기 +const GnbItemContainer: React.FC = ({ + children, + disableLeftFillBar = false, + isActive = false, +}) => { + const [isItemHover, setIsItemHover] = useState(false); + const leftFillBarClassnames = disableLeftFillBar + ? '' + : cn({ + 'bg-primary-light': isItemHover, + 'bg-primary-dark': isActive, + }); + + const handleMouseEnterOnItem = useCallback(() => setIsItemHover(true), []); + const handleMouseLeaveFromItem = useCallback(() => setIsItemHover(false), []); + + return ( +
+
+
+ {/* Item 영역 */} +
+ {children} +
+
+
+ ); +}; + +export default memo(GnbItemContainer); diff --git a/client/src/components/Modals/CreateCommunityModal/index.tsx b/client/src/components/Modals/CreateCommunityModal/index.tsx new file mode 100644 index 00000000..75daa3d7 --- /dev/null +++ b/client/src/components/Modals/CreateCommunityModal/index.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactModal from 'react-modal'; + +interface Props {} + +const CreateCommunityModal: React.FC = () => { + return 커뮤니티 모달; +}; + +export default CreateCommunityModal; diff --git a/client/src/layouts/FollowingTab/components/searchInput.tsx b/client/src/components/SearchInput/index.tsx similarity index 57% rename from client/src/layouts/FollowingTab/components/searchInput.tsx rename to client/src/components/SearchInput/index.tsx index 8865bdb6..ad655d8e 100644 --- a/client/src/layouts/FollowingTab/components/searchInput.tsx +++ b/client/src/components/SearchInput/index.tsx @@ -1,22 +1,28 @@ +import type { InputHTMLAttributes } from 'react'; + import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'; import React from 'react'; -type SearchInputProps = React.InputHTMLAttributes; +type SearchInputProps = InputHTMLAttributes; const SearchInput: React.FC = ({ + className, value, onChange, placeholder, + ...props }) => { return ( -
+
+ 검색
= ({ ); }; -SearchInput.displayName = 'SearchInput'; - export default SearchInput; diff --git a/client/src/components/UnAuthorizedLayer/index.tsx b/client/src/components/UnAuthorizedLayer/index.tsx deleted file mode 100644 index 3d9c16f2..00000000 --- a/client/src/components/UnAuthorizedLayer/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import { Outlet } from 'react-router-dom'; - -const UnAuthorizedLayer = () => { - return ; -}; - -export default UnAuthorizedLayer; diff --git a/client/src/components/UserAvatar/index.tsx b/client/src/components/UserAvatar/index.tsx deleted file mode 100644 index 2f7dbaa4..00000000 --- a/client/src/components/UserAvatar/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import Avatar, { AvatarProps } from '@components/Avatar'; -import React from 'react'; - -const UserAvatar: React.FC = ({ - variant = 'circle', - size = 'small', - name, - url, -}) => { - return ; -}; - -export default UserAvatar; diff --git a/client/src/components/UserItem/index.tsx b/client/src/components/UserItem/index.tsx new file mode 100644 index 00000000..6d4d1d78 --- /dev/null +++ b/client/src/components/UserItem/index.tsx @@ -0,0 +1,21 @@ +import type { User } from '@apis/user'; +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; + +import UserProfile from '@components/UserProfile'; +import React, { memo } from 'react'; + +interface Props extends ComponentPropsWithoutRef<'li'> { + user: User; + right?: ReactNode; +} + +const UserItem: React.FC = ({ user, right }) => { + return ( +
  • + + {right} +
  • + ); +}; + +export default memo(UserItem); diff --git a/client/src/components/UserList/index.tsx b/client/src/components/UserList/index.tsx new file mode 100644 index 00000000..e9fa6eb7 --- /dev/null +++ b/client/src/components/UserList/index.tsx @@ -0,0 +1,17 @@ +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; + +import React from 'react'; + +interface Props extends ComponentPropsWithoutRef<'ul'> { + children?: ReactNode; +} + +const UserList = ({ children }: Props) => { + return ( +
      + {children} +
    + ); +}; + +export default UserList; diff --git a/client/src/components/UserProfile/index.tsx b/client/src/components/UserProfile/index.tsx index f86de791..6c0c1446 100644 --- a/client/src/components/UserProfile/index.tsx +++ b/client/src/components/UserProfile/index.tsx @@ -1,34 +1,27 @@ +import type { User } from '@apis/user'; +import type { ComponentPropsWithoutRef } from 'react'; + import Avatar from '@components/Avatar'; import Badge from '@components/Badge'; +import { USER_STATUS } from '@constants/user'; import React from 'react'; -import { User } from 'shared/lib/user'; -interface UserItemProps { +interface Props extends ComponentPropsWithoutRef<'div'> { user: User; } -const USER_STATUS = { - OFFLINE: 'offline', - ONLINE: 'online', - AFK: 'afk', -}; - -type UserStatus = typeof USER_STATUS[keyof typeof USER_STATUS]; - -const statusColor: { - [key: UserStatus]: 'default' | 'success' | 'error'; -} = { +const STATUS_COLOR = { [USER_STATUS.OFFLINE]: 'default', [USER_STATUS.ONLINE]: 'success', [USER_STATUS.AFK]: 'error', -}; +} as const; -const UserProfile: React.FC = ({ +const UserProfile: React.FC = ({ user: { nickname, profileUrl, status }, }) => { return ( -
    - +
    + { + if (error instanceof AxiosError) { + const errorMessage = + error?.response?.data?.message || '에러가 발생했습니다!'; + + if (Array.isArray(errorMessage)) { + errorMessage.forEach((message) => { + toast.error(message); + }); + return; + } + + toast.error(errorMessage); + return; + } + + toast.error('Unknown Error'); +}; + +export default defaultErrorHandler; diff --git a/client/src/layouts/FollowingTab/hooks/useDebouncedValue.ts b/client/src/hooks/useDebouncedValue.ts similarity index 100% rename from client/src/layouts/FollowingTab/hooks/useDebouncedValue.ts rename to client/src/hooks/useDebouncedValue.ts diff --git a/client/src/hooks/useDirectMessagesQuery.ts b/client/src/hooks/useDirectMessagesQuery.ts new file mode 100644 index 00000000..474f4ac5 --- /dev/null +++ b/client/src/hooks/useDirectMessagesQuery.ts @@ -0,0 +1,18 @@ +import type { GetDirectMessagesResult } from '@apis/dm'; +import type { AxiosError } from 'axios'; + +import { getDirectMessages } from '@apis/dm'; +import { useQuery } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +const useDirectMessagesQuery = () => { + const query = useQuery( + queryKeyCreator.directMessage.list(), + getDirectMessages, + ); + + return query; +}; + +export default useDirectMessagesQuery; diff --git a/client/src/hooks/useFollowersQuery.ts b/client/src/hooks/useFollowersQuery.ts new file mode 100644 index 00000000..a3ff8668 --- /dev/null +++ b/client/src/hooks/useFollowersQuery.ts @@ -0,0 +1,36 @@ +import type { GetFollowersResponse, GetFollowersResult } from '@apis/user'; + +import { getFollowers } from '@apis/user'; +import { useQuery } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +type FollowersQueryData = { + statusCode: number; +} & GetFollowersResult; + +const useFollowersQuery = (filter: string, options?: { suspense: boolean }) => { + const key = queryKeyCreator.followers(); + const query = useQuery< + GetFollowersResponse, + unknown, + FollowersQueryData, + [string] + >(key, getFollowers, { + ...options, + select: (data) => { + const { statusCode, result } = data; + const followers = filter + ? result.followers.filter(({ nickname }) => + nickname.toUpperCase().includes(filter.toUpperCase()), + ) + : result.followers; + + return { statusCode, ...result, followers }; + }, + }); + + return query; +}; + +export default useFollowersQuery; diff --git a/client/src/hooks/useFollowingMutation.ts b/client/src/hooks/useFollowingMutation.ts new file mode 100644 index 00000000..72f5cacb --- /dev/null +++ b/client/src/hooks/useFollowingMutation.ts @@ -0,0 +1,46 @@ +import type { GetFollowingsResponse, User } from '@apis/user'; + +import { updateFollowing } from '@apis/user'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +const useFollowingMutation = (userId: string) => { + const key = queryKeyCreator.followings(); + const queryClient = useQueryClient(); + const mutation = useMutation(() => updateFollowing(userId), { + onMutate: async (deleted: User) => { + await queryClient.cancelQueries(key); + + const previousFollowings = + queryClient.getQueryData(key); + + if (previousFollowings) { + queryClient.setQueryData(key, { + ...previousFollowings, + result: { + ...previousFollowings.result, + followings: previousFollowings.result.followings.filter( + (following) => following._id !== deleted._id, + ), + }, + }); + } + return { previousFollowings }; + }, + onError: (err, variables, context) => { + if (context?.previousFollowings) + queryClient.setQueryData( + key, + context.previousFollowings, + ); + }, + onSettled: () => { + queryClient.invalidateQueries(key); + }, + }); + + return mutation; +}; + +export default useFollowingMutation; diff --git a/client/src/hooks/useFollowingsQuery.ts b/client/src/hooks/useFollowingsQuery.ts new file mode 100644 index 00000000..1d6cd0af --- /dev/null +++ b/client/src/hooks/useFollowingsQuery.ts @@ -0,0 +1,39 @@ +import type { GetFollowingsResponse, GetFollowingsResult } from '@apis/user'; + +import { getFollowings } from '@apis/user'; +import { useQuery } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +type FollowingsQueryData = { + statusCode: number; +} & GetFollowingsResult; + +const useFollowingsQuery = ( + filter: string, + options?: { suspense: boolean }, +) => { + const key = queryKeyCreator.followings(); + const query = useQuery< + GetFollowingsResponse, + unknown, + FollowingsQueryData, + [string] + >(key, getFollowings, { + ...options, + select: (data) => { + const { statusCode, result } = data; + const followings = filter + ? result.followings.filter(({ nickname }) => + nickname.toUpperCase().includes(filter.toUpperCase()), + ) + : result.followings; + + return { statusCode, ...result, followings }; + }, + }); + + return query; +}; + +export default useFollowingsQuery; diff --git a/client/src/hooks/useMyInfoQuery.ts b/client/src/hooks/useMyInfoQuery.ts new file mode 100644 index 00000000..f8c6d976 --- /dev/null +++ b/client/src/hooks/useMyInfoQuery.ts @@ -0,0 +1,23 @@ +import type { MyInfoResult } from '@apis/user'; +import type { AxiosError } from 'axios'; + +import { getMyInfo } from '@apis/user'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import queryKeyCreator from 'src/queryKeyCreator'; + +export const useMyInfoQuery = () => { + const key = queryKeyCreator.me(); + const query = useQuery(key, getMyInfo, {}); + + return query; +}; + +export default useMyInfoQuery; + +export const useMyInfo = () => { + const queryClient = useQueryClient(); + const key = queryKeyCreator.me(); + const me = queryClient.getQueryData(key); + + return me; +}; diff --git a/client/src/hooks/useReissueTokenMutation.ts b/client/src/hooks/useReissueTokenMutation.ts new file mode 100644 index 00000000..d30af9c3 --- /dev/null +++ b/client/src/hooks/useReissueTokenMutation.ts @@ -0,0 +1,69 @@ +import type { ErrorResponse, SuccessResponse } from '@@types/apis/response'; +import type { ReissueTokenResult } from '@apis/auth'; +import type { UseMutationResult } from '@tanstack/react-query'; + +import { reissueToken } from '@apis/auth'; +import { useTokenStore } from '@stores/tokenStore'; +import { useMutation } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { useNavigate } from 'react-router-dom'; + +import queryKeyCreator from '@/queryKeyCreator'; + +type UseReissueTokenMutationResult = UseMutationResult< + SuccessResponse, + unknown, + void, + unknown +>; + +interface UseReissueTokenMutation { + ( + invalidTokenErrorFallback?: string | (() => void), + unknownErrorFallback?: string | (() => void), + ): UseReissueTokenMutationResult; +} + +/** + * @description 서버에서 401 에러가 발생하는 경우에만 accessToken을 리셋하기 때문에, 그 이외의 에러로 로그인이 풀리지는 않음. + */ +const useReissueTokenMutation: UseReissueTokenMutation = ( + invalidTokenErrorFallback, + unknownErrorFallback, +) => { + const navigate = useNavigate(); + const setAccessToken = useTokenStore((state) => state.setAccessToken); + const key = queryKeyCreator.reissueToken(); + const mutation = useMutation(key, reissueToken, { + onSuccess: (data) => { + setAccessToken(data.result.accessToken); + }, + onError: (error) => { + if (!(error instanceof AxiosError)) { + console.error(error); + return; + } + + const errorResponse = error.response?.data as ErrorResponse | undefined; + + /** 유효하지 않은 토큰 */ + if (errorResponse?.statusCode === 401) { + setAccessToken(null); + if (typeof invalidTokenErrorFallback === 'string') + navigate(invalidTokenErrorFallback); + else invalidTokenErrorFallback && invalidTokenErrorFallback(); + + return; + } + + /** 네트워크 오류나 기타 서버 오류 등 */ + if (typeof unknownErrorFallback === 'string') { + navigate(unknownErrorFallback); + } else unknownErrorFallback && unknownErrorFallback(); + }, + }); + + return mutation; +}; + +export default useReissueTokenMutation; diff --git a/client/src/hooks/useSignInMutation.ts b/client/src/hooks/useSignInMutation.ts new file mode 100644 index 00000000..c8b118f4 --- /dev/null +++ b/client/src/hooks/useSignInMutation.ts @@ -0,0 +1,25 @@ +import type { SuccessResponse } from '@@types/apis/response'; +import type { SignInRequest, SignInResult } from '@apis/auth'; +import type { UseMutationOptions } from '@tanstack/react-query'; + +import { signIn } from '@apis/auth'; +import { useMutation } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +const useSignInMutation = ( + options: UseMutationOptions< + SuccessResponse, + unknown, + SignInRequest + >, +) => { + const key = queryKeyCreator.signIn(); + const mutation = useMutation(key, signIn, { + ...options, + }); + + return mutation; +}; + +export default useSignInMutation; diff --git a/client/src/hooks/useSignUpMutation.ts b/client/src/hooks/useSignUpMutation.ts new file mode 100644 index 00000000..eceb2fef --- /dev/null +++ b/client/src/hooks/useSignUpMutation.ts @@ -0,0 +1,25 @@ +import type { SuccessResponse } from '@@types/apis/response'; +import type { SignUpRequest, SignUpResult } from '@apis/auth'; +import type { UseMutationOptions } from '@tanstack/react-query'; + +import { signUp } from '@apis/auth'; +import { useMutation } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +const useSignUpMutation = ( + options: UseMutationOptions< + SuccessResponse, + unknown, + SignUpRequest + >, +) => { + const key = queryKeyCreator.signUp(); + const mutation = useMutation(key, signUp, { + ...options, + }); + + return mutation; +}; + +export default useSignUpMutation; diff --git a/client/src/hooks/useUsersQuery.ts b/client/src/hooks/useUsersQuery.ts new file mode 100644 index 00000000..7f375c8b --- /dev/null +++ b/client/src/hooks/useUsersQuery.ts @@ -0,0 +1,34 @@ +import type { GetUsersResponse, GetUsersResult } from '@apis/user'; + +import { GetUsers } from '@apis/user'; +import { useQuery } from '@tanstack/react-query'; + +import queryKeyCreator from '@/queryKeyCreator'; + +type UsersQueryData = { + statusCode: number; +} & GetUsersResult; + +const useUserSearchQuery = ( + filter: string, + options?: { suspense?: boolean; enabled?: boolean }, +) => { + const key = queryKeyCreator.userSearch(filter); + const query = useQuery( + key, + () => GetUsers({ search: filter }), + { + ...options, + select: (data) => { + const { statusCode, result } = data; + const { users } = result; + + return { statusCode, ...result, users }; + }, + }, + ); + + return query; +}; + +export default useUserSearchQuery; diff --git a/client/src/index.css b/client/src/index.css index ff8c90ab..5510c518 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -10,4 +10,21 @@ #root { width: 100vw; -} \ No newline at end of file +} + +.wrapper { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; +} + +.no-display-scrollbar::-webkit-scrollbar { + display: none; +} + +.no-display-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} diff --git a/client/src/index.tsx b/client/src/index.tsx index c7d541f2..ac339abd 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -19,10 +19,8 @@ const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement, ); -/** - * react-toastify import css 안되는 이슈 - * // TODO: 링크 업데이트하기 - */ +// https://github.com/fkhadra/react-toastify/issues/195#issuecomment-860722903 +// https://grand-beanie-e57.notion.site/react-toastify-import-css-2b14956185394af797bc1c1842207473 injectStyle(); root.render( diff --git a/client/src/layouts/CommunityNav/index.tsx b/client/src/layouts/CommunityNav/index.tsx index 7145d6d3..2a66c10d 100644 --- a/client/src/layouts/CommunityNav/index.tsx +++ b/client/src/layouts/CommunityNav/index.tsx @@ -5,11 +5,11 @@ const CommunityNav = () => { const { communityId } = useParams(); return ( - <> + ); }; diff --git a/client/src/layouts/DmNav/index.tsx b/client/src/layouts/DmNav/index.tsx index e56fd6cb..281e2d2b 100644 --- a/client/src/layouts/DmNav/index.tsx +++ b/client/src/layouts/DmNav/index.tsx @@ -1,13 +1,32 @@ +import UserProfile from '@components/UserProfile'; +import useDirectMessagesQuery from '@hooks/useDirectMessagesQuery'; import React from 'react'; import { Link } from 'react-router-dom'; const DmNav = () => { + const directMessagesQuery = useDirectMessagesQuery(); + return ( - <> + ); }; diff --git a/client/src/layouts/Followers/index.tsx b/client/src/layouts/Followers/index.tsx new file mode 100644 index 00000000..d00dd559 --- /dev/null +++ b/client/src/layouts/Followers/index.tsx @@ -0,0 +1,41 @@ +import FollowerUserItem from '@components/FollowerUserItem'; +import SearchInput from '@components/SearchInput'; +import UserList from '@components/UserList'; +import useDebouncedValue from '@hooks/useDebouncedValue'; +import useFollowersQuery from '@hooks/useFollowersQuery'; +import React, { useState } from 'react'; +import Scrollbars from 'react-custom-scrollbars-2'; + +const Followers = () => { + const DEBOUNCE_DELAY = 500; + const [filter, setFilter] = useState(''); + const debouncedFilter = useDebouncedValue(filter, DEBOUNCE_DELAY); + const followersQuery = useFollowersQuery(debouncedFilter); + + return ( +
    +
    + setFilter(e.target.value)} + placeholder="검색하기" + /> +
    + + {followersQuery.isLoading ? ( +
    로딩중...
    + ) : followersQuery.data?.followers.length ? ( + + {followersQuery.data.followers.map((user) => ( + + ))} + + ) : ( + '일치하는 사용자가 없습니다.' + )} +
    +
    + ); +}; + +export default Followers; diff --git a/client/src/layouts/FollowingTab/apis/getFollowings.ts b/client/src/layouts/FollowingTab/apis/getFollowings.ts deleted file mode 100644 index 2955f518..00000000 --- a/client/src/layouts/FollowingTab/apis/getFollowings.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { API_URL } from '@constants/url'; -import axios from 'axios'; - -const BASE_URL = `${API_URL}/api`; - -const getFollowings = (query: string) => - axios - .get(`${BASE_URL}/user/followings?query=${query}`) - .then((res) => res.data); - -export default getFollowings; diff --git a/client/src/layouts/FollowingTab/apis/updateFollowing.ts b/client/src/layouts/FollowingTab/apis/updateFollowing.ts deleted file mode 100644 index 8b581487..00000000 --- a/client/src/layouts/FollowingTab/apis/updateFollowing.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { API_URL } from '@constants/url'; -import axios from 'axios'; - -const BASE_URL = `${API_URL}/api`; - -const updateFollowing = (userId: string) => - axios.post(`${BASE_URL}/user/following/${userId}`).then((res) => res.data); - -export default updateFollowing; diff --git a/client/src/layouts/FollowingTab/components/item.tsx b/client/src/layouts/FollowingTab/components/item.tsx deleted file mode 100644 index dfdc5cd2..00000000 --- a/client/src/layouts/FollowingTab/components/item.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import UserProfile from '@components/UserProfile'; -import { - EllipsisHorizontalIcon, - ChatBubbleLeftIcon, -} from '@heroicons/react/20/solid'; -import React from 'react'; -import { useNavigate } from 'react-router-dom'; -import { User } from 'shared/lib/user'; - -import useFollowingsMutation from '../hooks/useFollowingsMutation'; - -interface FollowingProps { - user: User; -} - -const FollowingItem: React.FC = ({ user }) => { - const navigate = useNavigate(); - const updateFollowing = useFollowingsMutation(user._id); - - const handleChatButtonClick = () => { - navigate(`/dms/${user._id}`); - }; - - return ( -
  • - -
    - - -
    -
  • - ); -}; - -export default FollowingItem; diff --git a/client/src/layouts/FollowingTab/components/list.tsx b/client/src/layouts/FollowingTab/components/list.tsx deleted file mode 100644 index 9d024caf..00000000 --- a/client/src/layouts/FollowingTab/components/list.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { User } from 'shared/lib/user'; - -import FollowingItem from './item'; - -interface FollowingListProps { - users: User[]; -} - -const FollowingList: React.FC = ({ users }) => { - return ( -
      - {users.map((user: User) => ( - - ))} -
    - ); -}; - -export default FollowingList; diff --git a/client/src/layouts/FollowingTab/hooks/useFollowingsMutation.ts b/client/src/layouts/FollowingTab/hooks/useFollowingsMutation.ts deleted file mode 100644 index 88fec020..00000000 --- a/client/src/layouts/FollowingTab/hooks/useFollowingsMutation.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { GetUsersReponse, User } from 'shared/lib/user'; - -import updateFollowing from '../apis/updateFollowing'; - -const useFollowingsMutation = (userId: string) => { - const queryClient = useQueryClient(); - const mutation = useMutation(() => updateFollowing(userId), { - onMutate: async (deleted: User) => { - await queryClient.cancelQueries(['followings']); - const previousFollowings = queryClient.getQueryData([ - 'followings', - ]); - - if (previousFollowings) { - const { users } = previousFollowings.result; - - queryClient.setQueryData(['followings'], { - ...previousFollowings, - result: { - ...previousFollowings.result, - users: users.filter((user) => user._id !== deleted._id), - }, - }); - } - return { previousFollowings }; - }, - onError: (err, variables, context) => { - if (context?.previousFollowings) - queryClient.setQueryData( - ['followings'], - context.previousFollowings, - ); - }, - onSettled: () => { - queryClient.invalidateQueries(['followings']); - }, - }); - - return mutation; -}; - -export default useFollowingsMutation; diff --git a/client/src/layouts/FollowingTab/hooks/useFollowingsQuery.ts b/client/src/layouts/FollowingTab/hooks/useFollowingsQuery.ts deleted file mode 100644 index 9cb25d2d..00000000 --- a/client/src/layouts/FollowingTab/hooks/useFollowingsQuery.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import getFollowings from '../apis/getFollowings'; - -const useFollowingsQuery = (search: string, options?: Record) => { - const query = useQuery( - ['followings', search], - () => getFollowings(search), - options, - ); - - return query; -}; - -export default useFollowingsQuery; diff --git a/client/src/layouts/FollowingTab/index.tsx b/client/src/layouts/FollowingTab/index.tsx deleted file mode 100644 index 8660f4d3..00000000 --- a/client/src/layouts/FollowingTab/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React, { useState, Suspense } from 'react'; - -import FollowingList from './components/list'; -import SearchInput from './components/searchInput'; -import useDebouncedValue from './hooks/useDebouncedValue'; -import useFollowingsQuery from './hooks/useFollowingsQuery'; - -const FollowingTab = () => { - const DEBOUNCE_DELAY = 500; - const [filter, setFilter] = useState(''); - const debouncedFilter = useDebouncedValue(filter, DEBOUNCE_DELAY); - const { data } = useFollowingsQuery(debouncedFilter, { suspense: true }); - - return ( -
    -
    - setFilter(e.target.value)} - placeholder="검색하기" - /> -
    - loading...
    }> - {data.result.followings.length ? ( - - ) : ( - '일치하는 사용자가 없습니다.' - )} - -
    - ); -}; - -export default FollowingTab; diff --git a/client/src/layouts/Followings/index.tsx b/client/src/layouts/Followings/index.tsx new file mode 100644 index 00000000..645f0f94 --- /dev/null +++ b/client/src/layouts/Followings/index.tsx @@ -0,0 +1,41 @@ +import FollowingUserItem from '@components/FollowingUserItem'; +import SearchInput from '@components/SearchInput'; +import UserList from '@components/UserList'; +import useDebouncedValue from '@hooks/useDebouncedValue'; +import useFollowingsQuery from '@hooks/useFollowingsQuery'; +import React, { useState } from 'react'; +import Scrollbars from 'react-custom-scrollbars-2'; + +const Followings = () => { + const DEBOUNCE_DELAY = 500; + const [filter, setFilter] = useState(''); + const debouncedFilter = useDebouncedValue(filter, DEBOUNCE_DELAY); + const followingsQuery = useFollowingsQuery(debouncedFilter); + + return ( +
    +
    + setFilter(e.target.value)} + placeholder="검색하기" + /> +
    + + {followingsQuery.isLoading ? ( +
    로딩중...
    + ) : followingsQuery.data?.followings.length ? ( + + {followingsQuery.data.followings.map((user) => ( + + ))} + + ) : ( + '일치하는 사용자가 없습니다.' + )} +
    +
    + ); +}; + +export default Followings; diff --git a/client/src/layouts/Gnb/index.tsx b/client/src/layouts/Gnb/index.tsx index 49f5e6b8..847b8700 100644 --- a/client/src/layouts/Gnb/index.tsx +++ b/client/src/layouts/Gnb/index.tsx @@ -1,9 +1,53 @@ +import Avatar from '@components/Avatar'; +import GnbItemContainer from '@components/GnbItemContainer'; +import { LOGO_IMG_URL } from '@constants/url'; +import { PlusIcon } from '@heroicons/react/24/solid'; import React from 'react'; +import { Link, useLocation } from 'react-router-dom'; const Gnb = () => { + const { pathname } = useLocation(); + return ( -
    - Gnb +
    +
    + + + + + + +
    + +
      + + + + + + + + + + +
    + + +
    ); }; diff --git a/client/src/layouts/Sidebar/index.tsx b/client/src/layouts/Sidebar/index.tsx index 1d436ad9..ccf25476 100644 --- a/client/src/layouts/Sidebar/index.tsx +++ b/client/src/layouts/Sidebar/index.tsx @@ -1,26 +1,28 @@ import UserProfile from '@components/UserProfile'; -import CommunityNav from '@features/CommunityNav'; -import DmNav from '@features/DmNav'; import { Cog6ToothIcon } from '@heroicons/react/20/solid'; -import { useQuery } from '@tanstack/react-query'; -import axios from 'axios'; +import useMyInfoQuery from '@hooks/useMyInfoQuery'; +import CommunityNav from '@layouts/CommunityNav'; +import DmNav from '@layouts/DmNav'; import React from 'react'; import { useLocation } from 'react-router-dom'; -const getMyInfo = () => axios.get('/api/user/auth/me').then((res) => res.data); - const Sidebar = () => { const { pathname } = useLocation(); - const { isLoading, data } = useQuery(['me'], getMyInfo); + const myInfoQuery = useMyInfoQuery(); return (
    - +
    - {isLoading ? 'loading' : } + {myInfoQuery.isLoading ? ( +
    로딩중...
    + ) : ( + myInfoQuery.data && + )}
    diff --git a/client/src/layouts/UserSearch/index.tsx b/client/src/layouts/UserSearch/index.tsx new file mode 100644 index 00000000..e94d20dc --- /dev/null +++ b/client/src/layouts/UserSearch/index.tsx @@ -0,0 +1,60 @@ +import type { FormEvent } from 'react'; + +import FollowerUserItem from '@components/FollowerUserItem'; +import SearchInput from '@components/SearchInput'; +import UserList from '@components/UserList'; +import useUsersQuery from '@hooks/useUsersQuery'; +import React, { useState } from 'react'; +import Scrollbars from 'react-custom-scrollbars-2'; + +import Button from '@/components/Button'; + +// TODO: `handleKeyDown` 이벤트 핸들러 네이밍 명확하게 지어야함 +const UserSearch = () => { + const [submittedFilter, setSubmittedFilter] = useState(''); + const usersQuery = useUsersQuery(submittedFilter, { + enabled: !!submittedFilter, + }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const filter = + (new FormData(e.currentTarget).get('user-search') as string) ?? ''; + + if (filter.length === 0) return; + + setSubmittedFilter(filter); + }; + + return ( +
    +
    +
    + + + +
    + + {usersQuery.data?.users.length ? ( + + {usersQuery.data.users.map((user) => ( + + ))} + + ) : ( +
    + 검색된 사용자가 없습니다 +
    + )} +
    +
    + ); +}; + +export default UserSearch; diff --git a/client/src/mocks/data/users.js b/client/src/mocks/data/users.js index a2d890e9..1990c30d 100644 --- a/client/src/mocks/data/users.js +++ b/client/src/mocks/data/users.js @@ -1,36 +1,14 @@ +import { faker } from '@faker-js/faker'; + import { getRandomInt } from '../utils/rand'; -export const users = [ - { - _id: 'a', - id: '1', - nickname: '나영', - status: 'online', - profileUrl: `https://picsum.photos/id/${getRandomInt(500)}/70`, - description: 'default description', - }, - { - _id: 'b', - id: '2', - nickname: '수만', - status: 'offline', - profileUrl: `https://picsum.photos/id/${getRandomInt(500)}/70`, - description: 'default descrption', - }, - { - _id: 'c', - id: '3', - nickname: '민종', - status: 'afk', - profileUrl: `https://picsum.photos/id/${getRandomInt(500)}/70`, - description: 'default descrption', - }, - { - _id: 'd', - id: '4', - nickname: '준영', - status: 'afk', - profileUrl: `https://picsum.photos/id/${getRandomInt(500)}/70`, - description: 'default descrption', - }, -]; +export const createMockUser = () => ({ + _id: faker.datatype.uuid(), + id: faker.internet.email(), + nickname: faker.name.fullName(), + status: ['online', 'offline', 'afk'][getRandomInt(3)], + profileUrl: faker.image.avatar(), + description: faker.lorem.sentence(), +}); + +export const users = [...Array(30)].map(createMockUser); diff --git a/client/src/mocks/handlers/Auth.js b/client/src/mocks/handlers/Auth.js index 074d78d5..b06b703b 100644 --- a/client/src/mocks/handlers/Auth.js +++ b/client/src/mocks/handlers/Auth.js @@ -4,6 +4,9 @@ import { rest } from 'msw'; import { users } from '../data/users'; const BASE_URL = `${API_URL}/api`; +const devCookies = { + refreshTokenKey: 'dev_refreshToken', +}; // 회원가입 const SignUp = rest.post(`${BASE_URL}/user/auth/signup`, (req, res, ctx) => { @@ -41,6 +44,7 @@ const SignIn = rest.post(`${BASE_URL}/user/auth/signin`, (req, res, ctx) => { const ERROR = false; const successResponse = res( + ctx.cookie(devCookies.refreshTokenKey, '.'), ctx.status(200), ctx.delay(500), ctx.json({ @@ -65,17 +69,67 @@ const SignIn = rest.post(`${BASE_URL}/user/auth/signin`, (req, res, ctx) => { return ERROR ? errorResponse : successResponse; }); -export const GetMyInfo = rest.get('/api/user/auth/me', (req, res, ctx) => { - return res( - ctx.delay(), - ctx.status(200), - ctx.json({ - statusCode: 200, - result: { - user: users[0], - }, - }), - ); -}); +// 토큰 재발급 +const ReissueToken = rest.post( + `${BASE_URL}/user/auth/refresh`, + (req, res, ctx) => { + // 응답 메세지 성공-실패를 토글하려면 이 값을 바꿔주세요. + const existsRefreshToken = !!req.cookies[devCookies.refreshTokenKey]; + + const ERROR = !existsRefreshToken || false; + const isUnknownError = true; + + const successResponse = res( + ctx.status(200), + ctx.delay(1000), + ctx.json({ + statusCode: 200, + result: { + accessToken: 'accessToken', + }, + }), + ); + + const unAuthErrorResponse = res( + ctx.status(401), + ctx.delay(1000), + ctx.json({ + statusCode: 401, + message: 'Unauthorized', + error: '', + }), + ); + + const unknownErrorResponse = res( + ctx.status(502), + ctx.delay(1000), + ctx.json({ + statusCode: 502, + message: 'Unknown', + error: '', + }), + ); + + const errorResponse = isUnknownError + ? unknownErrorResponse + : unAuthErrorResponse; + + return ERROR ? errorResponse : successResponse; + }, +); + +export const GetMyInfo = rest.get( + `${BASE_URL}/user/auth/me`, + (req, res, ctx) => { + return res( + ctx.delay(300), + ctx.status(200), + ctx.json({ + statusCode: 200, + result: users[0], + }), + ); + }, +); -export default [SignUp, SignIn, GetMyInfo]; +export default [SignUp, SignIn, GetMyInfo, ReissueToken]; diff --git a/client/src/mocks/handlers/DM.js b/client/src/mocks/handlers/DM.js new file mode 100644 index 00000000..a8e6b504 --- /dev/null +++ b/client/src/mocks/handlers/DM.js @@ -0,0 +1,23 @@ +import { API_URL } from '@constants/url'; +import { rest } from 'msw'; + +import { users } from '../data/users'; + +const GetDirectMessages = rest.get( + `${API_URL}/api/user/dms`, + (req, res, ctx) => { + return res( + ctx.delay(), + ctx.status(200), + ctx.json({ + statusCode: 200, + result: [...users.slice(0, 5)].map((user, idx) => ({ + _id: idx, + user, + })), + }), + ); + }, +); + +export default [GetDirectMessages]; diff --git a/client/src/mocks/handlers/Friend.js b/client/src/mocks/handlers/Friend.js index 8ea10e21..71558100 100644 --- a/client/src/mocks/handlers/Friend.js +++ b/client/src/mocks/handlers/Friend.js @@ -8,19 +8,13 @@ const BASE_URL = `${API_URL}/api`; const GetFollowings = rest.get( `${BASE_URL}/user/followings`, (req, res, ctx) => { - const query = req.url.searchParams.get('query') ?? ''; - return res( ctx.delay(), ctx.status(200), ctx.json({ statusCode: 200, result: { - followings: query - ? users.filter(({ nickname }) => - nickname.toUpperCase().includes(query.toUpperCase()), - ) - : users, + followings: users, }, }), ); @@ -34,7 +28,7 @@ const UpdateFollowing = rest.post( const idx = users.findIndex((user) => user._id === userId); users.splice(idx, 1); - console.log(users); + return res( ctx.delay(), ctx.status(200), @@ -46,6 +40,19 @@ const UpdateFollowing = rest.post( }, ); -const FriendHandlers = [GetFollowings, UpdateFollowing]; +const GetFollowers = rest.get(`${BASE_URL}/user/followers`, (req, res, ctx) => { + return res( + ctx.delay(), + ctx.status(200), + ctx.json({ + statusCode: 200, + result: { + followers: users, + }, + }), + ); +}); + +const FriendHandlers = [GetFollowings, UpdateFollowing, GetFollowers]; export default FriendHandlers; diff --git a/client/src/mocks/handlers/User.js b/client/src/mocks/handlers/User.js new file mode 100644 index 00000000..358eae5e --- /dev/null +++ b/client/src/mocks/handlers/User.js @@ -0,0 +1,25 @@ +import { API_URL } from '@constants/url'; +import { rest } from 'msw'; + +import { users } from '../data/users'; + +const GetFilteredUsers = rest.get(`${API_URL}/api/users`, (req, res, ctx) => { + const search = req.url.searchParams.get('search').toUpperCase(); + + return res( + ctx.delay(), + ctx.status(200), + ctx.json({ + statusCode: 200, + result: { + users: users.filter( + (user) => + user.id.toUpperCase().includes(search) || + user.nickname.toUpperCase().includes(search), + ), + }, + }), + ); +}); + +export default [GetFilteredUsers]; diff --git a/client/src/mocks/handlers/index.js b/client/src/mocks/handlers/index.js index 53cf66eb..a411c0e4 100644 --- a/client/src/mocks/handlers/index.js +++ b/client/src/mocks/handlers/index.js @@ -1,4 +1,11 @@ import AuthHandlers from './Auth'; +import DMHandlers from './DM'; import FriendHandlers from './Friend'; +import UserHandlers from './User'; -export const handlers = [...AuthHandlers, ...FriendHandlers]; +export const handlers = [ + ...AuthHandlers, + ...FriendHandlers, + ...UserHandlers, + ...DMHandlers, +]; diff --git a/client/src/pages/AuthorizedLayer/index.tsx b/client/src/pages/AuthorizedLayer/index.tsx new file mode 100644 index 00000000..5dec1805 --- /dev/null +++ b/client/src/pages/AuthorizedLayer/index.tsx @@ -0,0 +1,33 @@ +import { useMyInfo } from '@hooks/useMyInfoQuery'; +import useReissueTokenMutation from '@hooks/useReissueTokenMutation'; +import { useTokenStore } from '@stores/tokenStore'; +import React, { useEffect } from 'react'; +import { Outlet, useNavigate } from 'react-router-dom'; + +/** + * ## 로그인 한 유저들만 머무를 수 있는 페이지. + * - 새로고침시 토큰 갱신을 시도하며, 로그인하지 않은(유저 상태나 액세스 토큰 상태가 없는) 유저가 접근하면 **`/`** 로 리다이렉트된다. + * - 토큰 갱신 요청시, 유효하지 않은 토큰 에러가 발생하면 **`/sign-in`** 으로 리다이렉트 된다. + * - 토큰 갱신 요청시, 알 수 없는 에러가 발생하면 **`/unknown-error`** 로 리다이렉트 된다. + */ +const AuthorizedLayer = () => { + const user = useMyInfo(); + + const accessToken = useTokenStore((state) => state.accessToken); + const navigate = useNavigate(); + + const reissueTokenMutation = useReissueTokenMutation(() => { + navigate('/sign-in', { state: { alreadyTriedReissueToken: true } }); + }, '/unknown-error'); + + useEffect(() => { + if (user || accessToken) return; + + reissueTokenMutation.mutate(); + }, []); + + if (!user && !accessToken) return
    로딩중...
    ; // Spinner 넣기 + return ; +}; + +export default AuthorizedLayer; diff --git a/client/src/pages/Followers/index.tsx b/client/src/pages/Followers/index.tsx deleted file mode 100644 index 99df5f18..00000000 --- a/client/src/pages/Followers/index.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -const Followers = () => { - return
    Followers
    ; -}; - -export default Followers; diff --git a/client/src/pages/Friends/index.tsx b/client/src/pages/Friends/index.tsx index 61faf47e..dd16ff97 100644 --- a/client/src/pages/Friends/index.tsx +++ b/client/src/pages/Friends/index.tsx @@ -1,14 +1,18 @@ -import FollowingTab from '@features/FollowingTab'; -import Followers from '@pages/Followers'; -import UserSearch from '@pages/UserSearch'; +import type { ReactNode } from 'react'; + +import Followers from '@layouts/Followers'; +import Followings from '@layouts/Followings'; +import UserSearch from '@layouts/UserSearch'; import React, { useState } from 'react'; +// TODO: 네이밍 생각해보기 const TAB = { FOLLOWINGS: 'followings', FOLLOWERS: 'followers', USER_SEARCH: 'user-search', -}; +} as const; +// TODO: 필드 이름 수정하기 const tabs = [ { name: '팔로잉', @@ -22,10 +26,11 @@ const tabs = [ name: '사용자 검색', tab: 'user-search', }, -]; +] as const; -const TabPanel: Record = { - [TAB.FOLLOWINGS]: , +// TODO: 컴포넌트 이름 수정하기 (FollowingTab -> Followings) +const TabPanel: Record = { + [TAB.FOLLOWINGS]: , [TAB.FOLLOWERS]: , [TAB.USER_SEARCH]: , }; @@ -33,11 +38,13 @@ const TabPanel: Record = { const DEFAULT_TAB = TAB.FOLLOWINGS; const Friends = () => { - const [tab, setTab] = useState(DEFAULT_TAB); + const [tab, setTab] = useState<'followings' | 'followers' | 'user-search'>( + DEFAULT_TAB, + ); return ( -
    -
    +
    +
      {tabs.map(({ name, tab: t }) => (
    • { ))}
    -
    -
    +
    +
    {TabPanel[tab]}
    diff --git a/client/src/pages/Home/index.tsx b/client/src/pages/Home/index.tsx index bac9f50a..aeb911e0 100644 --- a/client/src/pages/Home/index.tsx +++ b/client/src/pages/Home/index.tsx @@ -1,14 +1,16 @@ -import Gnb from '@features/Gnb'; -import Sidebar from '@features/Sidebar'; +import CreateCommunityModal from '@components/Modals/CreateCommunityModal'; +import Gnb from '@layouts/Gnb'; +import Sidebar from '@layouts/Sidebar'; import React from 'react'; import { Outlet } from 'react-router-dom'; const Home = () => { return ( -
    +
    +
    ); }; diff --git a/client/src/pages/Root/index.tsx b/client/src/pages/Root/index.tsx index 7c44f79c..ced43a86 100644 --- a/client/src/pages/Root/index.tsx +++ b/client/src/pages/Root/index.tsx @@ -1,7 +1,21 @@ +import { useMyInfo } from '@hooks/useMyInfoQuery'; +import { useTokenStore } from '@stores/tokenStore'; import React from 'react'; +import { Navigate } from 'react-router-dom'; +/** + * @description + * ## 인증 상태에 따라 리다이렉트 분기처리하는 페이지 + * - 로그인 되어있으면 **`/dms`** 로 이동한다. + * - 로그인 되어있지 않으면, **`/sign-in`** 으로 이동한다. + * - 조건문에 user || accessToken 중 하나라도 없으면 **`/sign-in`** -> **`/`** -> **`/sign-in`** ... 무한루프 발생함. + */ const Root = () => { - return
    ; + const user = useMyInfo(); + const accessToken = useTokenStore((state) => state.accessToken); + + if (user || accessToken) return ; + return ; }; export default Root; diff --git a/client/src/pages/SignIn/index.tsx b/client/src/pages/SignIn/index.tsx index 9848110e..84d87bc2 100644 --- a/client/src/pages/SignIn/index.tsx +++ b/client/src/pages/SignIn/index.tsx @@ -1,91 +1,51 @@ +import type { SignInRequest } from '@apis/auth'; + import AuthInput from '@components/AuthInput'; import Button from '@components/Button'; import ErrorMessage from '@components/ErrorMessage'; import Logo from '@components/Logo'; import TextButton from '@components/TextButton'; -import { API_URL } from '@constants/url'; +import REGEX from '@constants/regex'; +import defaultErrorHandler from '@errors/defaultErrorHandler'; +import useSignInMutation from '@hooks/useSignInMutation'; import { useTokenStore } from '@stores/tokenStore'; -import { useMutation } from '@tanstack/react-query'; -import axios, { AxiosError } from 'axios'; import React from 'react'; import { Controller, useForm } from 'react-hook-form'; -import { Navigate, useNavigate } from 'react-router-dom'; -import { toast } from 'react-toastify'; - -interface SignInFields { - id: string; - password: string; -} - -interface SuccessResponse { - statusCode: number; - result: T; -} - -type SignInApi = ( - fields: SignInFields, -) => Promise>; - -const endPoint = `${API_URL}/api/user/auth/signin`; +import { useNavigate } from 'react-router-dom'; -const signInApi: SignInApi = ({ id, password }) => { - return axios - .post(endPoint, { id, password }) - .then((response) => response.data); +const signUpFormDefaultValues = { + id: '', + password: '', }; -// 액세스 토큰으로 다시 유저 정보 요청해야함 -// _id, id(이메일), nickname, status, profileUrl, description const SignIn = () => { - const accessToken = useTokenStore((state) => state.accessToken); const setAccessToken = useTokenStore((state) => state.setAccessToken); - const { control, handleSubmit, reset } = useForm({ + const { control, handleSubmit, reset } = useForm({ mode: 'all', - defaultValues: { - id: '', - password: '', - }, + defaultValues: signUpFormDefaultValues, }); const navigate = useNavigate(); - const signInMutate = useMutation(['signIn'], signInApi, { + const signInMutation = useSignInMutation({ onSuccess: (data) => { setAccessToken(data.result.accessToken); + navigate('/dms'); }, onError: (error) => { reset(); - if (error instanceof AxiosError) { - const errorMessage = - error?.response?.data?.message || '에러가 발생했습니다!'; - - if (Array.isArray(errorMessage)) { - errorMessage.forEach((message) => { - toast.error(message); - }); - return; - } - - toast.error(errorMessage); - return; - } - - toast.error('Unknown Error'); + defaultErrorHandler(error); }, }); - const handleSubmitSignInForm = ({ id, password }: SignInFields) => { - signInMutate.mutate({ id, password }); + const handleSubmitSignInForm = ({ id, password }: SignInRequest) => { + signInMutation.mutate({ id, password }); }; const handleNavigateSignUpPage = () => { navigate('/sign-up'); }; - if (accessToken) { - return ; - } - return (
    @@ -102,7 +62,7 @@ const SignIn = () => { control={control} rules={{ pattern: { - value: /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, + value: REGEX.EMAIL, message: '아이디는 이메일 형식으로 입력해야 합니다!', }, }} @@ -149,7 +109,7 @@ const SignIn = () => { size="md" type="submit" minWidth={340} - disabled={signInMutate.isLoading} + disabled={signInMutation.isLoading} > 로그인 diff --git a/client/src/pages/SignUp/index.tsx b/client/src/pages/SignUp/index.tsx index 00c1b267..59163f27 100644 --- a/client/src/pages/SignUp/index.tsx +++ b/client/src/pages/SignUp/index.tsx @@ -1,84 +1,52 @@ +import type { SignUpRequest } from '@apis/auth'; + import AuthInput from '@components/AuthInput'; import Button from '@components/Button'; import ErrorMessage from '@components/ErrorMessage'; import Logo from '@components/Logo'; import SuccessMessage from '@components/SuccessMessage'; import TextButton from '@components/TextButton'; -import { API_URL } from '@constants/url'; -import { useMutation } from '@tanstack/react-query'; -import axios, { AxiosError } from 'axios'; +import REGEX from '@constants/regex'; +import defaultErrorHandler from '@errors/defaultErrorHandler'; +import useSignUpMutation from '@hooks/useSignUpMutation'; import React from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { toast } from 'react-toastify'; -interface SignUpFields { - id: string; - nickname: string; - password: string; +interface SignUpFormFields extends SignUpRequest { passwordCheck: string; } -interface SuccessResponse { - statusCode: number; - result: T; -} - -type SignUpApi = ( - fields: Omit, -) => Promise>; - -const endPoint = `${API_URL}/api/user/auth/signup`; - -const signUpApi: SignUpApi = ({ id, nickname, password }) => { - return axios - .post(endPoint, { id, nickname, password }) - .then((response) => response.data); +const signUpFormDefaultValues = { + id: '', + nickname: '', + password: '', + passwordCheck: '', }; const SignUp = () => { - // TODO: 리팩토링 하자 - const { control, handleSubmit, watch, reset } = useForm({ + const { control, handleSubmit, watch, reset } = useForm({ mode: 'all', - defaultValues: { - id: '', - nickname: '', - password: '', - passwordCheck: '', - }, + defaultValues: signUpFormDefaultValues, }); + const password = watch('password'); + const navigate = useNavigate(); - const signUpMutate = useMutation(['signUp'], signUpApi, { + const signUpMutation = useSignUpMutation({ onSuccess: () => { toast.success('회원가입에 성공했습니다.'); reset(); }, onError: (error) => { - if (error instanceof AxiosError) { - const errorMessage = - error?.response?.data?.message || '에러가 발생했습니다!'; - - if (Array.isArray(errorMessage)) { - errorMessage.forEach((message) => { - toast.error(message); - }); - return; - } - - toast.error(errorMessage); - return; - } - - toast.error('Unknown Error'); + defaultErrorHandler(error); }, }); - const password = watch('password'); - - const handleSubmitSignUpForm = (fields: SignUpFields) => { - signUpMutate.mutate(fields); + const handleSubmitSignUpForm = (fields: SignUpFormFields) => { + signUpMutation.mutate(fields); }; const handleNavigateSignInPage = () => { @@ -101,7 +69,7 @@ const SignUp = () => { control={control} rules={{ pattern: { - value: /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, + value: REGEX.EMAIL, message: '아이디는 이메일 형식으로 입력해야 합니다!', }, required: '필수 요소입니다!', @@ -220,7 +188,7 @@ const SignUp = () => { size="md" type="submit" minWidth={340} - disabled={signUpMutate.isLoading} + disabled={signUpMutation.isLoading} > 회원가입 diff --git a/client/src/pages/UnAuthorizedLayer/index.tsx b/client/src/pages/UnAuthorizedLayer/index.tsx new file mode 100644 index 00000000..94963043 --- /dev/null +++ b/client/src/pages/UnAuthorizedLayer/index.tsx @@ -0,0 +1,37 @@ +import { useMyInfo } from '@hooks/useMyInfoQuery'; +import useReissueTokenMutation from '@hooks/useReissueTokenMutation'; +import { useTokenStore } from '@stores/tokenStore'; +import React, { useEffect, useState } from 'react'; +import { Outlet, Navigate, useLocation } from 'react-router-dom'; + +/** + * ## 로그인 하지 않은 유저들만 머무를 수 있는 페이지. + * - 새로고침시 토큰 갱신을 시도하며, 로그인한(유저 상태나 액세스 토큰 상태가 있는) 유저가 접근하면 **`/`** 로 리다이렉트된다. + * - 토큰 갱신 요청시, 유효하지 않은 토큰 에러나 알 수 없는 에러가 발생하면 페이지 이동 없이 그대로 유지한다. + */ +const UnAuthorizedLayer = () => { + const user = useMyInfo(); + const location = useLocation(); + + const accessToken = useTokenStore((state) => state.accessToken); + const [isTryingReissueToken, setIsTryingReissueToken] = useState(true); + + const handleReissueTokenError = () => setIsTryingReissueToken(false); + + const reissueTokenMutation = useReissueTokenMutation( + handleReissueTokenError, + handleReissueTokenError, + ); + + useEffect(() => { + if (user) return; + reissueTokenMutation.mutate(); + }, []); + + if (user || accessToken) return ; + if (location.state?.alreadyTriedReissueToken) return ; + if (isTryingReissueToken) return
    로딩중...
    ; + return ; +}; + +export default UnAuthorizedLayer; diff --git a/client/src/pages/UnknownError/index.tsx b/client/src/pages/UnknownError/index.tsx new file mode 100644 index 00000000..f7f0485a --- /dev/null +++ b/client/src/pages/UnknownError/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +const UnknownError = () => { + console.log(`[${location.pathname}]`); + + return
    Unknown Error
    ; +}; + +export default UnknownError; diff --git a/client/src/pages/UserSearch/index.tsx b/client/src/pages/UserSearch/index.tsx deleted file mode 100644 index 356d07d3..00000000 --- a/client/src/pages/UserSearch/index.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -const UserSearch = () => { - return
    UserSearch
    ; -}; - -export default UserSearch; diff --git a/client/src/queryKeyCreator.ts b/client/src/queryKeyCreator.ts new file mode 100644 index 00000000..31e04bce --- /dev/null +++ b/client/src/queryKeyCreator.ts @@ -0,0 +1,22 @@ +const directMessageQueryKey = { + all: ['directMessages'] as const, + list: () => [...directMessageQueryKey.all] as const, + detail: (id: string) => [...directMessageQueryKey.all, id] as const, +} as const; + +const queryKeyCreator = { + me: () => ['me'] as const, + signUp: () => ['signUp'] as const, + signIn: () => ['signIn'] as const, + followings: (): [string] => ['followings'], + followers: (): [string] => ['followers'], + reissueToken: () => ['reissueToken'] as const, + userSearch: (filter: string) => ['userSearch', { filter }], + directMessage: directMessageQueryKey, +} as const; + +export default queryKeyCreator; + +type QueryKeyCreatorType = typeof queryKeyCreator; +export type QueryKeyCreator = + QueryKeyCreatorType[T]; diff --git a/client/src/stores/modalSlice.ts b/client/src/stores/modalSlice.ts new file mode 100644 index 00000000..4fca1f10 --- /dev/null +++ b/client/src/stores/modalSlice.ts @@ -0,0 +1,15 @@ +import type { StateCreator } from 'zustand'; + +export interface ModalSlice { + createCommunityModal: { + open: boolean; + }; +} + +export const createModalSlice: StateCreator = ( + set, +) => ({ + createCommunityModal: { + open: false, + }, +}); diff --git a/client/src/stores/rootStore.ts b/client/src/stores/rootStore.ts new file mode 100644 index 00000000..f4421b24 --- /dev/null +++ b/client/src/stores/rootStore.ts @@ -0,0 +1,14 @@ +import type { ModalSlice } from '@stores/modalSlice'; + +import store from 'zustand'; +import { devtools } from 'zustand/middleware'; + +import { createModalSlice } from './modalSlice'; + +export type Store = ModalSlice; + +export const useStore = store()( + devtools((...a) => ({ + ...createModalSlice(...a), + })), +); diff --git a/client/src/stores/tokenStore.ts b/client/src/stores/tokenStore.ts index b13ad94a..854fdd91 100644 --- a/client/src/stores/tokenStore.ts +++ b/client/src/stores/tokenStore.ts @@ -9,7 +9,7 @@ type TokenStore = { export const tokenStore = createVanillaStore()( devtools((set) => ({ - accessToken: null, + accessToken: process.env.NODE_ENV === 'development' ? 'null' : null, setAccessToken: (newAccessToken) => set(() => ({ accessToken: newAccessToken })), })), diff --git a/client/src/types/apis/process.d.ts b/client/src/types/apis/process.d.ts new file mode 100644 index 00000000..cac93912 --- /dev/null +++ b/client/src/types/apis/process.d.ts @@ -0,0 +1,6 @@ +declare namespace NodeJS { + interface ProcessEnv { + NODE_ENV?: string; + API_URL?: string; + } +} diff --git a/client/src/types/apis/response.ts b/client/src/types/apis/response.ts new file mode 100644 index 00000000..4d63b12f --- /dev/null +++ b/client/src/types/apis/response.ts @@ -0,0 +1,10 @@ +export interface SuccessResponse { + statusCode: number; + result: T; +} + +export interface ErrorResponse { + statusCode: number; + message: string | string[]; + error: string; +} diff --git a/client/src/utils/axios.ts b/client/src/utils/axios.ts new file mode 100644 index 00000000..b78d783e --- /dev/null +++ b/client/src/utils/axios.ts @@ -0,0 +1,48 @@ +import { API_URL } from '@constants/url'; +import { tokenStore } from '@stores/tokenStore'; +import axios from 'axios'; + +const { getState } = tokenStore; + +/** + * ## Asnity api server 전용 Axios instance + * - `baseURL`은 Asnity server 이다. 따라서 엔드포인트 작성시 `baseURL`이후 부분만 적는다. + * - Api 요청시 전역 상태에서 관리하는 accessToken을 Authorization header에 삽입하고 보낸다. + * - accessToken이 없다면 요청 Promise가 Reject된다. + * - 토큰 만료 응답시 Response interceptors에서 재발급 후 Request 재요청 하는 로직은 추후에 추가할 예정. + */ +export const tokenAxios = axios.create({ + baseURL: API_URL, +}); + +/** + * ## Asnity api server 전용 Axios instance + * - `baseURL`은 Asnity server 이다. 따라서 엔드포인트 작성시 `baseURL`이후 부분만 적는다. + * - accessToken이 필요없는 요청을 보낼 때 사용한다. + */ +export const publicAxios = axios.create({ + baseURL: API_URL, +}); + +tokenAxios.interceptors.request.use( + (config) => { + const { accessToken } = getState(); + + console.warn('tokenAxios 사용 확인용 로그. 무시하시면 됩니다.'); + + if (!accessToken) { + console.warn(`accessToken이 없습니다.`); + return Promise.reject( + `tokenAxios instance로 요청을 보내기 위해서는 accessToken이 필요합니다.`, + ); + } + + config.headers = { + Authorization: `Bearer ${accessToken}`, + }; + return config; + }, + function (error) { + return Promise.reject(error); + }, +); diff --git a/client/tsconfig.json b/client/tsconfig.json index 929f22a5..d7bde1a6 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -1,7 +1,9 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "lib": ["DOM"], + "esModuleInterop": true, + "moduleResolution": "Node", + "lib": ["DOM", "es2015.iterable"], "jsx": "react-jsx", "baseUrl": ".", "outDir": "./build", @@ -14,11 +16,13 @@ "@icons/*": ["src/assets/icons/*"], "@constants/*": ["src/constants/*"], "@apis/*": ["src/apis/*"], + "@hooks/*": ["src/hooks/*"], + "@errors/*": ["src/errors/*"], + "@@types/*": ["src/types/*"], + "@/*": ["src/*"] } }, "include": ["src", "config"], "exclude": ["node_modules", "build", "dist"], - "references": [ - { "path": "../shared" } - ] -} \ No newline at end of file + "references": [{ "path": "../shared" }] +} diff --git a/server/__mock__/community.mock.ts b/server/__mock__/community.mock.ts new file mode 100644 index 00000000..f631960e --- /dev/null +++ b/server/__mock__/community.mock.ts @@ -0,0 +1,8 @@ +import { CreateCommunityDto } from '@api/src/community/dto/create-community.dto'; + +export const communityDto1 = { + name: 'asnity commu', + managerId: '63734af9e62b37012c73e399', + description: 'test description', + profileUrl: 'test profileUrl', +} as CreateCommunityDto; diff --git a/server/apps/api/src/api.module.ts b/server/apps/api/src/api.module.ts index ecc0fc45..fefba2ab 100644 --- a/server/apps/api/src/api.module.ts +++ b/server/apps/api/src/api.module.ts @@ -9,9 +9,6 @@ import { UserModule } from './user/user.module'; import * as winston from 'winston'; import { utilities as nestWinstonModuleUtilities, WinstonModule } from 'nest-winston'; import { AuthModule } from './auth/auth.module'; -import { APP_INTERCEPTOR } from '@nestjs/core'; -import { SentryInterceptor } from '../../webhook.interceptor'; -import { RavenInterceptor, RavenModule } from 'nest-raven'; @Module({ imports: [ diff --git a/server/apps/api/src/auth/dto/index.ts b/server/apps/api/src/auth/dto/index.ts index 0ee0429b..aa80f504 100644 --- a/server/apps/api/src/auth/dto/index.ts +++ b/server/apps/api/src/auth/dto/index.ts @@ -1 +1,2 @@ -export * from './auth.dto'; +export * from './sign-in.dto'; +export * from './sign-up.dto'; diff --git a/server/apps/api/src/auth/dto/sign-in.dto.ts b/server/apps/api/src/auth/dto/sign-in.dto.ts new file mode 100644 index 00000000..f23a0ee1 --- /dev/null +++ b/server/apps/api/src/auth/dto/sign-in.dto.ts @@ -0,0 +1,11 @@ +import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; + +export class SignInDto { + @IsEmail() + @IsNotEmpty() + id: string; + + @IsString() + @MinLength(8) + password: string; +} diff --git a/server/apps/api/src/auth/dto/auth.dto.ts b/server/apps/api/src/auth/dto/sign-up.dto.ts similarity index 67% rename from server/apps/api/src/auth/dto/auth.dto.ts rename to server/apps/api/src/auth/dto/sign-up.dto.ts index 87a5fdcd..66bda45e 100644 --- a/server/apps/api/src/auth/dto/auth.dto.ts +++ b/server/apps/api/src/auth/dto/sign-up.dto.ts @@ -1,15 +1,5 @@ import { IsEmail, IsNotEmpty, IsString, Length, MinLength } from 'class-validator'; -export class SignInDto { - @IsEmail() - @IsNotEmpty() - id: string; - - @IsString() - @MinLength(8) - password: string; -} - export class SignUpDto { @IsEmail() @IsNotEmpty() diff --git a/server/apps/api/src/auth/strategy/jwt-access.strategy.ts b/server/apps/api/src/auth/strategy/jwt-access.strategy.ts index e890d211..e82bd868 100644 --- a/server/apps/api/src/auth/strategy/jwt-access.strategy.ts +++ b/server/apps/api/src/auth/strategy/jwt-access.strategy.ts @@ -19,6 +19,6 @@ export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt-access-to throw new ForbiddenException('잘못된 요청입니다.'); } delete user.password; - return user; + return { ...user, _id: user._id.toString() }; } } diff --git a/server/apps/api/src/channel/channel.controller.ts b/server/apps/api/src/channel/channel.controller.ts index fff4d422..6fa995b6 100644 --- a/server/apps/api/src/channel/channel.controller.ts +++ b/server/apps/api/src/channel/channel.controller.ts @@ -1,4 +1,27 @@ -import { Controller } from '@nestjs/common'; +import { Body, Controller, Inject, LoggerService, Post, Req, UseGuards } from '@nestjs/common'; +import { ChannelService } from '@api/src/channel/channel.service'; +import { JwtAccessGuard } from '@api/src/auth/guard'; +import { CreateChannelDto } from '@api/src/channel/dto'; +import { responseForm } from '@utils/responseForm'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; -@Controller('channel') -export class ChannelController {} +@Controller('api/channel') +export class ChannelController { + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService, + private channelService: ChannelService, + ) {} + + @Post() + @UseGuards(JwtAccessGuard) + async createChannel(@Body() createChannelDto: CreateChannelDto, @Req() req: any) { + const _id = req.user._id; + try { + await this.channelService.createChannel({ ...createChannelDto, managerId: _id }); + return responseForm(200, { message: '채널 생성 성공!' }); + } catch (error) { + this.logger.error(JSON.stringify(error.response)); + throw error; + } + } +} diff --git a/server/apps/api/src/channel/channel.module.ts b/server/apps/api/src/channel/channel.module.ts index fe632c18..6dd53985 100644 --- a/server/apps/api/src/channel/channel.module.ts +++ b/server/apps/api/src/channel/channel.module.ts @@ -1,9 +1,22 @@ import { Module } from '@nestjs/common'; import { ChannelController } from './channel.controller'; import { ChannelService } from './channel.service'; +import { MongooseModule } from '@nestjs/mongoose'; +import { ChannelRepository } from '@repository/channel.repository'; +import { ChannelSchema } from '@schemas/channel.schema'; +import { Channel } from 'diagnostics_channel'; +import { CommunityRepository } from '@repository/community.repository'; +import { CommunityModule } from '@community/community.module'; +import { UserRepository } from '@repository/user.repository'; +import { UserModule } from '@user/user.module'; @Module({ + imports: [ + MongooseModule.forFeature([{ name: Channel.name, schema: ChannelSchema }]), + CommunityModule, + UserModule, + ], controllers: [ChannelController], - providers: [ChannelService] + providers: [ChannelService, ChannelRepository, CommunityRepository, UserRepository], }) export class ChannelModule {} diff --git a/server/apps/api/src/channel/channel.service.ts b/server/apps/api/src/channel/channel.service.ts index fc8cd3a7..dd1dadc6 100644 --- a/server/apps/api/src/channel/channel.service.ts +++ b/server/apps/api/src/channel/channel.service.ts @@ -1,4 +1,39 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ChannelRepository } from '@repository/channel.repository'; +import { CreateChannelDto } from '@api/src/channel/dto'; +import { CommunityRepository } from '@repository/community.repository'; +import { UserRepository } from '@repository/user.repository'; +import { addChannelToUserForm } from '@utils/addObjectForm'; @Injectable() -export class ChannelService {} +export class ChannelService { + constructor( + private readonly channelRepository: ChannelRepository, + private readonly communityRepository: CommunityRepository, + private readonly userRepository: UserRepository, + ) {} + + async createChannel(createChannelDto: CreateChannelDto) { + // 자신이 속한 커뮤니티 찾기 + try { + const community = await this.communityRepository.findById(createChannelDto.communityId); + + // 채널 생성 + const channel = await this.channelRepository.create({ + ...createChannelDto, + users: [createChannelDto.managerId], + }); + + // 커뮤니티 목록에 채널 업데이트 + await this.communityRepository.addArrAtArr({ _id: community.id }, 'channels', [ + channel._id.toString(), + ]); + + // 유저 목록에 자신이 속한 채널 업데이트 + const newChannel = addChannelToUserForm(community._id, channel._id); + await this.userRepository.updateObject({ _id: createChannelDto.managerId }, newChannel); + } catch (error) { + throw new BadRequestException('채널 생성 중 오류 발생!'); + } + } +} diff --git a/server/apps/api/src/channel/dto/create-channel.dto.ts b/server/apps/api/src/channel/dto/create-channel.dto.ts new file mode 100644 index 00000000..95d020d5 --- /dev/null +++ b/server/apps/api/src/channel/dto/create-channel.dto.ts @@ -0,0 +1,37 @@ +import { IsBoolean, IsNotEmpty, IsOptional, IsString, Length } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class CreateChannelDto { + @IsString() + @IsNotEmpty() + communityId: string; + + @IsString() + @Length(2, 20) + @IsNotEmpty() + name: string; + + @IsString() + @IsOptional() + managerId: string; + + @IsString() + @IsOptional() + description: string; + + @IsString() + @IsOptional() + profileUrl: string; + + @IsString() + @IsNotEmpty() + type: 'Channel' | 'DM'; + + @IsBoolean() + @IsNotEmpty() + @Transform(({ value }) => value === 'true') + isPrivate: boolean; + + @IsOptional() + users: string[]; +} diff --git a/server/apps/api/src/channel/dto/index.ts b/server/apps/api/src/channel/dto/index.ts new file mode 100644 index 00000000..a21d4e9f --- /dev/null +++ b/server/apps/api/src/channel/dto/index.ts @@ -0,0 +1 @@ +export * from './create-channel.dto'; diff --git a/server/apps/api/src/community/community.controller.ts b/server/apps/api/src/community/community.controller.ts index a169f161..7fc975fc 100644 --- a/server/apps/api/src/community/community.controller.ts +++ b/server/apps/api/src/community/community.controller.ts @@ -1,4 +1,104 @@ -import { Controller } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Inject, + LoggerService, + Param, + Patch, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { CommunityService } from '@api/src/community/community.service'; +import { responseForm } from '@utils/responseForm'; +import { JwtAccessGuard } from '@api/src/auth/guard'; +import { + CreateCommunityDto, + AppendUsersToCommunityDto, + ModifyCommunityDto, + DeleteCommunityDto, +} from './dto'; -@Controller('community') -export class CommunityController {} +@Controller('api/community') +export class CommunityController { + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService, + private communityService: CommunityService, + ) {} + + @Get() + @UseGuards(JwtAccessGuard) + async getCommunities(@Req() req: any) { + try { + const result = await this.communityService.getCommunities(req.user._doc); + return responseForm(200, result); + } catch (error) { + this.logger.error(JSON.stringify(error.response)); + throw error; + } + } + + @Post() + @UseGuards(JwtAccessGuard) + async crateCommunity(@Body() createCommunityDto: CreateCommunityDto, @Req() req: any) { + try { + const _id = req.user._id; + const result = await this.communityService.createCommunity({ + managerId: _id, + ...createCommunityDto, + }); + return responseForm(200, result); + } catch (error) { + this.logger.error(JSON.stringify(error.response)); + throw error; + } + } + + @Post('participants') + @UseGuards(JwtAccessGuard) + async appendParticipantsToCommunity( + @Body() appendUsersToCommunityDto: AppendUsersToCommunityDto, + @Req() req: any, + ) { + try { + await this.communityService.appendParticipantsToCommunity( + req.user, + appendUsersToCommunityDto, + ); + return responseForm(200, { message: '커뮤니티 사용자 추가 완료' }); + } catch (error) { + this.logger.error(JSON.stringify(error.response)); + throw error; + } + } + + @Patch('settings') + @UseGuards(JwtAccessGuard) + async modifyCommunitySetting(@Body() modifyCommunityDto: ModifyCommunityDto, @Req() req: any) { + try { + const _id = req.user._id; + await this.communityService.modifyCommunity({ ...modifyCommunityDto, managerId: _id }); + return responseForm(200, { message: '커뮤니티 정보 수정 완료' }); + } catch (error) { + this.logger.error(JSON.stringify(error.response)); + throw error; + } + } + + @Delete(':_id') + @UseGuards(JwtAccessGuard) + async deleteCommunity(@Param('_id') community_id: string, @Req() req: any) { + try { + const managerId = req.user._id; + const deleteCommunityDto: DeleteCommunityDto = { managerId, community_id }; + await this.communityService.deleteCommunity(deleteCommunityDto); + return responseForm(200, { message: '커뮤니티 삭제 성공' }); + } catch (error) { + this.logger.error(JSON.stringify(error.response)); + throw error; + } + } +} diff --git a/server/apps/api/src/community/community.module.ts b/server/apps/api/src/community/community.module.ts index d4fb4995..474dda22 100644 --- a/server/apps/api/src/community/community.module.ts +++ b/server/apps/api/src/community/community.module.ts @@ -1,9 +1,19 @@ import { Module } from '@nestjs/common'; import { CommunityController } from './community.controller'; import { CommunityService } from './community.service'; +import { MongooseModule } from '@nestjs/mongoose'; +import { Community, CommunitySchema } from '@schemas/community.schema'; +import { CommunityRepository } from '@repository/community.repository'; +import { UserRepository } from '@repository/user.repository'; +import { UserModule } from '@user/user.module'; @Module({ + imports: [ + MongooseModule.forFeature([{ name: Community.name, schema: CommunitySchema }]), + UserModule, + ], controllers: [CommunityController], - providers: [CommunityService], + providers: [CommunityService, CommunityRepository, UserRepository], + exports: [MongooseModule], }) export class CommunityModule {} diff --git a/server/apps/api/src/community/community.service.ts b/server/apps/api/src/community/community.service.ts index 4cd3bcc7..1e0fc2d0 100644 --- a/server/apps/api/src/community/community.service.ts +++ b/server/apps/api/src/community/community.service.ts @@ -1,4 +1,164 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { CommunityRepository } from '@repository/community.repository'; +import { UserRepository } from '@repository/user.repository'; +import { + CreateCommunityDto, + AppendUsersToCommunityDto, + ModifyCommunityDto, + DeleteCommunityDto, +} from './dto'; +import { IsUserInCommunity, makeCommunityObj } from '@community/helper'; +import { communityInUser } from '@user/dto/community-in-user.dto'; @Injectable() -export class CommunityService {} +export class CommunityService { + constructor( + private readonly communityRepository: CommunityRepository, + private readonly userRepository: UserRepository, + ) {} + + async getCommunities(user2) { + const user = await this.userRepository.findById('637f2abb146636e4082885b1'); + const infos = []; + await Promise.all( + Array.from(user.communities.values()).map(async (userCommunity) => { + const { _id, channels } = userCommunity as communityInUser; + const community = await this.communityRepository.findById(_id); + if (!community) { + throw new BadRequestException('해당하는 커뮤니티의 _id가 올바르지 않습니다.'); + } + const result = Array.from(channels.keys()).filter( + (channel_id: string) => !community.channels.includes(channel_id), + ); + if (result.length > 0) { + console.log(result); + throw new BadRequestException('커뮤니티에 없는 비정상적인 채널이 존재합니다.'); + } + const info = {}; + info[_id] = []; + await Promise.all( + Array.from(channels.keys()).map(async (channelId) => { + const lastRead = channels.get(channelId); + console.log(lastRead); + // TODO: soft delete이면 조건 다시 설정 + // const channel = await this.channelRepository.findById(channelId); + // if (!channel) { + // throw new BadRequestException('존재하지 않는 채널입니다.'); + // } + // info[_id][channelId] = lastRead < channel.updatedAt; + // console.log(info[_id]); + }), + ); + console.log(info); + + infos.push(info); + }), + ); + return infos; + } + async createCommunity(createCommunityDto: CreateCommunityDto) { + const community = await this.communityRepository.create({ + ...createCommunityDto, + users: [createCommunityDto.managerId], + }); + const newCommunity = makeCommunityObj(community._id.toString()); + await this.userRepository.updateObject({ _id: createCommunityDto.managerId }, newCommunity); + return community; + } + + async appendParticipantsToCommunity( + reqUser, + appendUsersToCommunityDto: AppendUsersToCommunityDto, + ) { + const communityId = appendUsersToCommunityDto.community_id; + if (!IsUserInCommunity(reqUser, communityId)) { + throw new BadRequestException(`커뮤니티에 속하지 않는 사용자는 요청할 수 없습니다.`); + } + const newCommunity = makeCommunityObj(communityId); + await Promise.all( + // 사용자 document 검증 (올바른 사용자인지, 해당 사용자가 이미 커뮤니티에 참여하고 있는건 아닌지) + appendUsersToCommunityDto.users.map(async (user_id) => { + const user = await this.userRepository.findById(user_id); + if (!user) { + throw new BadRequestException( + `커뮤니티에 추가를 요청한 사용자 _id(${user_id})가 올바르지 않습니다.`, + ); + } else if (IsUserInCommunity(user, communityId)) { + throw new BadRequestException(`이미 커뮤니티에 추가된 사용자 입니다.`); + } + }), + ); + await Promise.all( + // 사용자 document 검증이 끝난 후 update + appendUsersToCommunityDto.users.map(async (user_id) => { + await this.userRepository.updateObject({ _id: user_id }, newCommunity); + }), + ); + const community = await this.communityRepository.addArrAtArr( + { _id: appendUsersToCommunityDto.community_id }, + 'users', + appendUsersToCommunityDto.users, + ); + if (!community) { + await Promise.all( + // 사용자 document에서 다시 삭제 + appendUsersToCommunityDto.users.map((user_id) => { + this.userRepository.deleteObject({ _id: user_id }, newCommunity); + }), + ); + throw new BadRequestException('해당하는 커뮤니티의 _id가 올바르지 않습니다.'); + } + } + + async modifyCommunity(modifyCommunityDto: ModifyCommunityDto) { + // TODO : refactoring을 findAndUpdate로 해서 매니저 id, deletedAt인지 바로 검증이랑 동시에 하도록.. + const community = await this.verfiyCommunity(modifyCommunityDto.community_id); + if (community.managerId != modifyCommunityDto.managerId) { + throw new BadRequestException('사용자의 커뮤니티 수정 권한이 없습니다.'); + } + const { managerId, community_id, ...updateField } = modifyCommunityDto; + // TODO: 꼭 기다려줘야하는지 생각해보기 + return await this.communityRepository.updateOne({ _id: community_id }, updateField); + } + + async deleteCommunity(deleteCommunityDto: DeleteCommunityDto) { + const updateField = { deletedAt: new Date() }; + let community = await this.communityRepository.findAndUpdateOne( + { + _id: deleteCommunityDto.community_id, + managerId: deleteCommunityDto.managerId, + deletedAt: { $exists: false }, + }, + updateField, + ); + if (!community) { + community = await this.verfiyCommunity(deleteCommunityDto.community_id); + if (community.managerId != deleteCommunityDto.managerId) { + throw new BadRequestException('사용자의 커뮤니티 수정 권한이 없습니다.'); + } else if (community.deletedAt) { + throw new BadRequestException('이미 삭제된 커뮤니티입니다.'); + } + } + await Promise.all( + community.users.map((user_id) => + this.deleteCommunityAtUserDocument(user_id, community._id.toString()), + ), + ); + } + async verfiyCommunity(community_id: string) { + const community = await this.communityRepository.findOne({ _id: community_id }); + if (!community) { + throw new BadRequestException('해당하는 커뮤니티의 _id가 올바르지 않습니다.'); + } + return community; + } + + deleteCommunityAtUserDocument(user_id: string, community_id: string) { + this.userRepository.deleteElementAtArr( + { _id: user_id }, + { + communities: [community_id], + }, + ); + } +} diff --git a/server/apps/api/src/community/dto/append-participants-to-community.dto.ts b/server/apps/api/src/community/dto/append-participants-to-community.dto.ts new file mode 100644 index 00000000..fb6b8ab7 --- /dev/null +++ b/server/apps/api/src/community/dto/append-participants-to-community.dto.ts @@ -0,0 +1,23 @@ +import { + ArrayNotEmpty, + IsArray, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; + +export class AppendUsersToCommunityDto { + @IsNotEmpty() + @IsString() + community_id: string; + + @IsOptional() + @IsString() + requestUser_id: string; + + @IsNotEmpty() + @IsArray() + @ArrayNotEmpty() + users: string[]; +} diff --git a/server/apps/api/src/community/dto/create-community.dto.ts b/server/apps/api/src/community/dto/create-community.dto.ts new file mode 100644 index 00000000..7d7d173d --- /dev/null +++ b/server/apps/api/src/community/dto/create-community.dto.ts @@ -0,0 +1,22 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateCommunityDto { + @IsNotEmpty() + @IsString() + name: string; + + @IsOptional() + @IsString() + managerId: string; + + @IsOptional() + @IsString() + description: string; + + @IsOptional() + @IsString() + profileUrl: string; + + @IsOptional() + users: string[]; +} diff --git a/server/apps/api/src/community/dto/delete-community.dto.ts b/server/apps/api/src/community/dto/delete-community.dto.ts new file mode 100644 index 00000000..0ad34141 --- /dev/null +++ b/server/apps/api/src/community/dto/delete-community.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class DeleteCommunityDto { + @IsNotEmpty() + @IsString() + community_id: string; + + @IsNotEmpty() + @IsString() + managerId: string; +} diff --git a/server/apps/api/src/community/dto/index.ts b/server/apps/api/src/community/dto/index.ts new file mode 100644 index 00000000..a50668c8 --- /dev/null +++ b/server/apps/api/src/community/dto/index.ts @@ -0,0 +1,4 @@ +export * from './append-participants-to-community.dto'; +export * from './create-community.dto'; +export * from './modify-community.dto'; +export * from './delete-community.dto'; diff --git a/server/apps/api/src/community/dto/modify-community.dto.ts b/server/apps/api/src/community/dto/modify-community.dto.ts new file mode 100644 index 00000000..18a5a292 --- /dev/null +++ b/server/apps/api/src/community/dto/modify-community.dto.ts @@ -0,0 +1,23 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class ModifyCommunityDto { + @IsNotEmpty() + @IsString() + community_id: string; + + @IsOptional() + @IsString() + name: string; + + @IsOptional() + @IsString() + managerId: string; + + @IsOptional() + @IsString() + description: string; + + @IsOptional() + @IsString() + profileUrl: string; +} diff --git a/server/apps/api/src/community/helper/checkUserIsInCommunity.ts b/server/apps/api/src/community/helper/checkUserIsInCommunity.ts new file mode 100644 index 00000000..eb279d47 --- /dev/null +++ b/server/apps/api/src/community/helper/checkUserIsInCommunity.ts @@ -0,0 +1,2 @@ +export const IsUserInCommunity = (user, communityId) => + (user.communities ?? false) && Array.from(user.communities.keys()).includes(communityId); diff --git a/server/apps/api/src/community/helper/index.ts b/server/apps/api/src/community/helper/index.ts new file mode 100644 index 00000000..cd9c8465 --- /dev/null +++ b/server/apps/api/src/community/helper/index.ts @@ -0,0 +1,2 @@ +export * from './checkUserIsInCommunity'; +export * from './makeCommunityObj'; diff --git a/server/apps/api/src/community/helper/makeCommunityObj.ts b/server/apps/api/src/community/helper/makeCommunityObj.ts new file mode 100644 index 00000000..108db276 --- /dev/null +++ b/server/apps/api/src/community/helper/makeCommunityObj.ts @@ -0,0 +1,8 @@ +export const makeCommunityObj = (community_id: string) => { + const newCommunity = {}; + newCommunity[`communities.${community_id}`] = { + _id: community_id, + channels: {}, + }; + return newCommunity; +}; diff --git a/server/apps/api/src/main.ts b/server/apps/api/src/main.ts index 22661db8..8e5e4cdb 100644 --- a/server/apps/api/src/main.ts +++ b/server/apps/api/src/main.ts @@ -8,7 +8,11 @@ import * as cookieParser from 'cookie-parser'; async function bootstrap() { const app = await NestFactory.create(ApiModule); - app.enableCors(); + app.enableCors({ + origin: true, + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', + credentials: true, + }); if (process.env.NODE_ENV == 'prod') { Sentry.init({ dsn: process.env.SENTRY_DSN, diff --git a/server/apps/api/src/user/dto/community-in-user.dto.ts b/server/apps/api/src/user/dto/community-in-user.dto.ts new file mode 100644 index 00000000..07a69aee --- /dev/null +++ b/server/apps/api/src/user/dto/community-in-user.dto.ts @@ -0,0 +1,4 @@ +export interface communityInUser { + _id: string; + channels?: Map; +} diff --git a/server/apps/api/src/user/dto/index.ts b/server/apps/api/src/user/dto/index.ts new file mode 100644 index 00000000..c9dac182 --- /dev/null +++ b/server/apps/api/src/user/dto/index.ts @@ -0,0 +1,4 @@ +export * from './modify-user.dto'; +export * from './create-user.dto'; +export * from './follower.dto'; +export * from './user-basic-info.dto'; diff --git a/server/apps/api/src/user/user.controller.ts b/server/apps/api/src/user/user.controller.ts index 5d24caed..d077262a 100644 --- a/server/apps/api/src/user/user.controller.ts +++ b/server/apps/api/src/user/user.controller.ts @@ -8,15 +8,16 @@ import { Param, Patch, Post, + Req, Res, + UseGuards, } from '@nestjs/common'; import { UserService } from './user.service'; -import { FollowerDto } from '@user/dto/follower.dto'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { responseForm } from '@utils/responseForm'; import { ObjectIdValidationPipe } from '@custom_pipe/mongodbObjectIdValidation.pipe'; -import { ModifyUserDto } from '@user/dto/modify-user.dto'; -import { Response } from 'express'; +import { JwtAccessGuard } from '@api/src/auth/guard'; +import { FollowerDto, ModifyUserDto } from './dto'; @Controller('api/user') export class UserController { @@ -25,33 +26,25 @@ export class UserController { private userService: UserService, ) {} - // @Get() - // getUsers() { - // const createUserDto: CreateUserDto = { id: 'mj', pw: 'mjpw' }; - // this.userService.createUser(createUserDto); - // return 'hello user'; - // } - @Post('following/:id') - async addFollowing(@Param('id', ObjectIdValidationPipe) id: string) { + @UseGuards(JwtAccessGuard) + async addFollowing(@Param('id', ObjectIdValidationPipe) id: string, @Req() req: any) { try { - const myId = '63786b635d4f08bbe0c940de'; - // TODO: Request Header에서 access token으로 현재 사용자 알아내기 + const myId = req.user._id; const addFollowingDto: FollowerDto = { myId, followId: id }; const result = await this.userService.toggleFollowing(addFollowingDto); return responseForm(200, result); } catch (error) { this.logger.error(JSON.stringify(error.response)); - // res.status(400).json(error.response); throw error; } } @Get('followers') - async getFollowers() { + @UseGuards(JwtAccessGuard) + async getFollowers(@Req() req: any) { try { - const _id = '63786b635d4f08bbe0c940dc'; - // TODO: Request Header에서 access token으로 현재 사용자 알아내기 + const _id = req.user._id; const result = await this.userService.getRelatedUsers(_id, 'followers'); return responseForm(200, { followers: result }); } catch (error) { @@ -61,10 +54,10 @@ export class UserController { } @Get('followings') - async getFollowings() { + @UseGuards(JwtAccessGuard) + async getFollowings(@Req() req: any) { try { - const _id = '63739b643969101c3fec884'; - // TODO: Request Header에서 access token으로 현재 사용자 알아내기 + const _id = req.user._id; const result = await this.userService.getRelatedUsers(_id, 'followings'); return responseForm(200, { followings: result }); } catch (error) { @@ -77,7 +70,7 @@ export class UserController { async getUser(@Param('id') id: string) { try { const result = await this.userService.getUser(id); - return responseForm(200, { result }); + return responseForm(200, { users: result }); } catch (error) { this.logger.error(JSON.stringify(error.response)); throw error; @@ -85,10 +78,10 @@ export class UserController { } @Patch('settings') - async modifyUserSetting(@Body() modifyUserDto: ModifyUserDto) { + @UseGuards(JwtAccessGuard) + async modifyUserSetting(@Body() modifyUserDto: ModifyUserDto, @Req() req: any) { try { - const _id = '63786b635d4f08bbe0c940de'; - // TODO: Request Header에서 access token으로 현재 사용자 알아내기 + const _id = req.user._id; await this.userService.modifyUser({ ...modifyUserDto, _id }); return responseForm(200, {}); } catch (error) { @@ -96,9 +89,4 @@ export class UserController { throw error; } } - - // @Post() - // createUser(@Body() createUserDto: CreateUserDto) { - // this.usersService.createUser(createUserDto); - // } } diff --git a/server/apps/api/src/user/user.module.ts b/server/apps/api/src/user/user.module.ts index 7a7310b2..8726018d 100644 --- a/server/apps/api/src/user/user.module.ts +++ b/server/apps/api/src/user/user.module.ts @@ -2,15 +2,14 @@ import { Module } from '@nestjs/common'; import { UserController } from '@user/user.controller'; import { UserService } from '@user/user.service'; import { MongooseModule } from '@nestjs/mongoose'; -import { WinstonModule } from 'nest-winston'; import { User, UserSchema } from '@schemas/user.schema'; import { AuthModule } from '../auth/auth.module'; import { UserRepository } from '@repository/user.repository'; -import { AuthController } from '../auth/auth.controller'; @Module({ imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), AuthModule], controllers: [UserController], providers: [UserService, UserRepository], + exports: [MongooseModule], }) export class UserModule {} diff --git a/server/apps/api/src/user/user.service.ts b/server/apps/api/src/user/user.service.ts index 32508f9b..2526a47c 100644 --- a/server/apps/api/src/user/user.service.ts +++ b/server/apps/api/src/user/user.service.ts @@ -1,8 +1,6 @@ import { BadRequestException, ConflictException, Injectable, Param } from '@nestjs/common'; -import { FollowerDto } from '@user/dto/follower.dto'; import { UserRepository } from '@repository/user.repository'; -import { getUserBasicInfo } from '@user/dto/user-basic-info.dto'; -import { ModifyUserDto } from '@user/dto/modify-user.dto'; +import { FollowerDto, ModifyUserDto, getUserBasicInfo } from './dto'; @Injectable() export class UserService { diff --git a/server/apps/api/test/community.service.spec.ts b/server/apps/api/test/community.service.spec.ts new file mode 100644 index 00000000..36870c3c --- /dev/null +++ b/server/apps/api/test/community.service.spec.ts @@ -0,0 +1,54 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getModelToken } from '@nestjs/mongoose'; +import * as _ from 'lodash'; +import { CommunityService } from '@api/src/community/community.service'; +import { Community } from '@schemas/community.schema'; +import { CommunityRepository } from '@repository/community.repository'; +import { user1 } from '@mock/user.mock'; +import { UserRepository } from '@repository/user.repository'; +import { communityDto1 } from '@mock/community.mock'; +import { UserModule } from '@user/user.module'; +import { User } from '@schemas/user.schema'; + +describe('[Community Service]', () => { + let communityService: CommunityService; + let communityRepository: CommunityRepository; + let userRepository: UserRepository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CommunityService, + CommunityRepository, + UserRepository, + { + provide: getModelToken(Community.name), + useFactory: () => {}, + }, + { + provide: getModelToken(User.name), + useFactory: () => {}, + }, + ], + }).compile(); + + communityService = module.get(CommunityService); + communityRepository = module.get(CommunityRepository); + userRepository = module.get(UserRepository); + }); + + it('should be defined', () => { + expect(communityService).toBeDefined(); + }); + + describe('[createCommunity] 커뮤니티 생성', () => { + it('커뮤니티 생성 정상 동작', async () => { + const community1 = _.cloneDeep(user1); + community1.users = [user1._id]; + jest.spyOn(userRepository, 'findById').mockResolvedValue(user1); + jest.spyOn(communityRepository, 'create').mockResolvedValue(community1); + const result = await communityService.createCommunity(communityDto1); + expect(result).toEqual(community1); + }); + }); +}); diff --git a/server/dao/repository/channel.repository.ts b/server/dao/repository/channel.repository.ts new file mode 100644 index 00000000..fd182f90 --- /dev/null +++ b/server/dao/repository/channel.repository.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { Channel, ChannelDocument } from '@schemas/channel.schema'; +import { CreateChannelDto } from '@api/src/channel/dto'; + +@Injectable() +export class ChannelRepository { + constructor(@InjectModel(Channel.name) private channelModel: Model) {} + + async create(createChannelDto: CreateChannelDto) { + return await this.channelModel.create(createChannelDto); + } + + async findOne(condition: any) { + return await this.channelModel.findOne(condition); + } +} diff --git a/server/dao/repository/community.repository.ts b/server/dao/repository/community.repository.ts new file mode 100644 index 00000000..5376e0f5 --- /dev/null +++ b/server/dao/repository/community.repository.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { Community, CommunityDocument } from '@schemas/community.schema'; +import { CreateCommunityDto } from '@api/src/community/dto/create-community.dto'; + +@Injectable() +export class CommunityRepository { + constructor(@InjectModel(Community.name) private communityModel: Model) {} + + async create(createCommunityDto: CreateCommunityDto) { + const result = await this.communityModel.create(createCommunityDto); + return (result as any)._doc; + } + + async findById(_id: string) { + return await this.communityModel.findById(_id); + } + + async addArrAtArr(filter, attribute, appendArr) { + const addArr = {}; + addArr[attribute] = { $each: appendArr }; + return await this.communityModel.findOneAndUpdate(filter, { $addToSet: addArr }, { new: true }); + // console.log('pass'); + } + + async findOne(condition: any) { + return await this.communityModel.findOne(condition); + } + + async updateOne(filter, updateField) { + await this.communityModel.updateOne(filter, updateField); + } + + async findAndUpdateOne(filter, updateField) { + return await this.communityModel.findOneAndUpdate(filter, updateField, { new: true }); + } +} diff --git a/server/dao/repository/user.repository.ts b/server/dao/repository/user.repository.ts index 60d99b9d..66e7536d 100644 --- a/server/dao/repository/user.repository.ts +++ b/server/dao/repository/user.repository.ts @@ -33,7 +33,31 @@ export class UserRepository { await this.userModel.updateOne(filter, { $push: appendElement }); } + async updateObject(filter, appendElement) { + return await this.userModel.updateOne(filter, { $set: appendElement }); + } + + async deleteObject(filter, appendElement) { + await this.userModel.updateOne(filter, { $unset: appendElement }); + } + async deleteElementAtArr(filter, removeElement) { await this.userModel.updateOne(filter, { $pullAll: removeElement }); } + + async deleteElementAtArr2(_id, removeElement) { + await this.userModel.findByIdAndUpdate(_id, { $pullAll: removeElement }, { new: true }); + } + + async addArrAtArr(filter, attribute, appendArr) { + const addArr = {}; + addArr[attribute] = { $each: appendArr }; + return await this.userModel.findByIdAndUpdate(filter, { $addToSet: addArr }, { new: true }); + } + + // async set(filter, obj) { + // const user = new User(); + // + // this.userModel.find(filter).communities.set(); + // } } diff --git a/server/dao/schemas/channel.schema.ts b/server/dao/schemas/channel.schema.ts index 605b3d0d..5d77e202 100644 --- a/server/dao/schemas/channel.schema.ts +++ b/server/dao/schemas/channel.schema.ts @@ -1,30 +1,28 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { Document } from 'mongoose'; +import mongoose, { Document } from 'mongoose'; import { CHANNEL_TYPE } from '@utils/def'; -import { IsBoolean, IsIn, IsNotEmpty, IsString } from 'class-validator'; +import { IsBoolean, IsIn, IsNotEmpty, IsString, Length } from 'class-validator'; export type ChannelDocument = Channel & Document; -@Schema() +@Schema({ timestamps: true }) export class Channel { @Prop({ required: true, - unique: true, }) @IsString() @IsNotEmpty() + @Length(2, 20) name: string; @Prop({ required: true, - unique: true, }) @IsString() communityId: string; @Prop({ required: true, - unique: true, }) @IsString() @IsNotEmpty() @@ -53,5 +51,8 @@ export class Channel { @Prop() @IsString() chatLists: string[]; + + @Prop({ type: mongoose.Schema.Types.Date }) + deletedAt: Date; } export const ChannelSchema = SchemaFactory.createForClass(Channel); diff --git a/server/dao/schemas/community.schema.ts b/server/dao/schemas/community.schema.ts index 87b231f8..e6fe5806 100644 --- a/server/dao/schemas/community.schema.ts +++ b/server/dao/schemas/community.schema.ts @@ -4,7 +4,7 @@ import { IsNotEmpty, IsString } from 'class-validator'; export type CommunityDocument = Community & Document; -@Schema() +@Schema({ timestamps: true }) export class Community { @Prop() @IsString() @@ -13,7 +13,6 @@ export class Community { @Prop({ required: true, - unique: true, }) @IsString() @IsNotEmpty() @@ -27,12 +26,6 @@ export class Community { @IsString() profileUrl: string; - @Prop({ default: new Date(), type: mongoose.Schema.Types.Date }) - createdAt: Date; - - @Prop({ default: new Date(), type: mongoose.Schema.Types.Date }) - updatedAt: Date; - @Prop({ type: mongoose.Schema.Types.Date }) deletedAt: Date; diff --git a/server/dao/schemas/user.schema.ts b/server/dao/schemas/user.schema.ts index b7ec5881..9c0597f3 100644 --- a/server/dao/schemas/user.schema.ts +++ b/server/dao/schemas/user.schema.ts @@ -1,11 +1,11 @@ -import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Prop, raw, Schema, SchemaFactory } from '@nestjs/mongoose'; import { IsIn, IsString } from 'class-validator'; import mongoose, { Document } from 'mongoose'; import { STATUS } from '@utils/def'; export type UserDocument = User & Document; -@Schema() +@Schema({ timestamps: true }) export class User { @Prop() name: string; @@ -49,12 +49,6 @@ export class User { @Prop({ default: new Date(), type: mongoose.Schema.Types.Date }) lastLogin: Date; - @Prop({ default: new Date(), type: mongoose.Schema.Types.Date }) - createdAt: Date; - - @Prop({ default: new Date(), type: mongoose.Schema.Types.Date }) - updatedAt: Date; - @Prop({ type: mongoose.Schema.Types.Date }) deletedAt: Date; @@ -66,7 +60,13 @@ export class User { @IsString() followers: string[]; - @Prop() - communities: string[]; + @Prop( + raw({ + type: Map, + of: new mongoose.Schema({ _id: { type: String }, channels: { type: Map, of: Date } }), + }), + ) + communities; } + export const UserSchema = SchemaFactory.createForClass(User); diff --git a/server/tsconfig.json b/server/tsconfig.json index b43f558a..28fdce23 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -23,6 +23,7 @@ "@utils/*": ["utils/*"], "@custom_pipe/*": ["custom_pipe/*"], "@user/*": ["apps/api/src/user/*"], + "@community/*": ["apps/api/src/community/*"], "@api/*": ["apps/api/*"], "@mock/*": ["__mock__/*"], } diff --git a/server/utils/addObjectForm.ts b/server/utils/addObjectForm.ts new file mode 100644 index 00000000..1514c0ed --- /dev/null +++ b/server/utils/addObjectForm.ts @@ -0,0 +1,6 @@ +export function addChannelToUserForm(communityId, channelId) { + const newChannel = {}; + newChannel[`communities.${communityId.toString()}.channels.${channelId.toString()}`] = new Date(); + + return newChannel; +} diff --git a/shared/lib/user.ts b/shared/lib/user.ts index 2b1bf864..ba778278 100644 --- a/shared/lib/user.ts +++ b/shared/lib/user.ts @@ -1,15 +1,10 @@ +export type UserStatus = 'online' | 'offline' | 'afk'; + export interface User { _id: string; id: string; nickname: string; - status: string; + status: UserStatus; profileUrl: string; descrption: string; } - -export interface GetUsersReponse { - statusCode: number; - result: { - users: User[]; - }; -} diff --git a/yarn.lock b/yarn.lock index e4da8cb0..ad0153df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1844,6 +1844,11 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@faker-js/faker@^7.6.0": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-7.6.0.tgz#9ea331766084288634a9247fcd8b84f16ff4ba07" + integrity sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw== + "@graphql-tools/merge@8.3.11": version "8.3.11" resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-8.3.11.tgz#f5eab764e8d7032c1b7e32d5dc6dea5b2f5bb21e" @@ -2529,6 +2534,14 @@ multer "1.4.4-lts.1" tslib "2.4.1" +"@nestjs/platform-socket.io@^9.2.0": + version "9.2.0" + resolved "https://registry.yarnpkg.com/@nestjs/platform-socket.io/-/platform-socket.io-9.2.0.tgz#5194a13d4ef5c70b32b2bcc64379e07674ddf0ab" + integrity sha512-ttxXtqHV3Cpk5AfZOxfE8urILV5oLBpG21vdyqUHiL0YDuhHdc2tBz5GKSYAfsWefmVeQQiBAV9dqaa23Rf0nQ== + dependencies: + socket.io "4.5.3" + tslib "2.4.1" + "@nestjs/schematics@^9.0.0": version "9.0.3" resolved "https://registry.yarnpkg.com/@nestjs/schematics/-/schematics-9.0.3.tgz#175218350fb3829c9a903e980046a11950310e24" @@ -2547,6 +2560,15 @@ dependencies: tslib "2.4.1" +"@nestjs/websockets@^9.2.0": + version "9.2.0" + resolved "https://registry.yarnpkg.com/@nestjs/websockets/-/websockets-9.2.0.tgz#cbe8d446eff653d9c63234ef396ccc1ea031e875" + integrity sha512-AbG4eN9p9O6QmNSOWsk0lrA+CtHkrdDkogcl1sGyTrg+LRd6IUlkaTu9fFK9Hl6o7bs2ieGgDmxAvl+Xd156Aw== + dependencies: + iterare "1.2.1" + object-hash "3.0.0" + tslib "2.4.1" + "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3": version "2.1.8-no-fsevents.3" resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz#323d72dd25103d0c4fbdce89dadf574a787b1f9b" @@ -2782,6 +2804,11 @@ "@types/node" ">=12.0.0" axios "^0.21.4" +"@socket.io/component-emitter@~3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553" + integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg== + "@tanstack/match-sorter-utils@8.1.1": version "8.1.1" resolved "https://registry.yarnpkg.com/@tanstack/match-sorter-utils/-/match-sorter-utils-8.1.1.tgz#895f407813254a46082a6bbafad9b39b943dc834" @@ -2964,6 +2991,11 @@ resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.2.tgz#66ad9331f63fe8a3d3d9d8c6e3906dd10f6446e8" integrity sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog== +"@types/cors@^2.8.12": + version "2.8.12" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080" + integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw== + "@types/debug@^4.1.7": version "4.1.7" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82" @@ -3125,7 +3157,7 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== -"@types/node@*", "@types/node@>=12.0.0", "@types/node@>=8.9.0": +"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=12.0.0", "@types/node@>=8.9.0": version "18.11.9" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4" integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg== @@ -3196,6 +3228,13 @@ dependencies: "@types/react" "*" +"@types/react-modal@^3.13.1": + version "3.13.1" + resolved "https://registry.yarnpkg.com/@types/react-modal/-/react-modal-3.13.1.tgz#5b9845c205fccc85d9a77966b6e16dc70a60825a" + integrity sha512-iY/gPvTDIy6Z+37l+ibmrY+GTV4KQTHcCyR5FIytm182RQS69G5ps4PH2FxtC7bAQ2QRHXMevsBgck7IQruHNg== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@^18.0.25": version "18.0.25" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.25.tgz#8b1dcd7e56fe7315535a4af25435e0bb55c8ae44" @@ -3242,6 +3281,13 @@ dependencies: "@types/node" "*" +"@types/socket.io@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/socket.io/-/socket.io-3.0.2.tgz#606c9639e3f93bb8454cba8f5f0a283d47917759" + integrity sha512-pu0sN9m5VjCxBZVK8hW37ZcMe8rjn4HHggBN5CbaRTvFwv5jOmuIRZEuddsBPa9Th0ts0SIo3Niukq+95cMBbQ== + dependencies: + socket.io "*" + "@types/sockjs@^0.3.33": version "0.3.33" resolved "https://registry.yarnpkg.com/@types/sockjs/-/sockjs-0.3.33.tgz#570d3a0b99ac995360e3136fd6045113b1bd236f" @@ -3640,6 +3686,11 @@ acorn@^8.0.4, acorn@^8.1.0, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.7.1, acorn@^8.8 resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.1.tgz#0a3f9cbecc4ec3bea6f0a80b66ae8dd2da250b73" integrity sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA== +add-px-to-style@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/add-px-to-style/-/add-px-to-style-1.0.0.tgz#d0c135441fa8014a8137904531096f67f28f263a" + integrity sha512-YMyxSlXpPjD8uWekCQGuN40lV4bnZagUwqa2m/uFv1z/tNImSk9fnXVMUI5qwME/zzI3MMQRvjZ+69zyfSSyew== + agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -4069,6 +4120,11 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +base64id@2.0.0, base64id@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6" + integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== + batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -4650,7 +4706,7 @@ cookie@0.5.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== -cookie@^0.4.1, cookie@^0.4.2: +cookie@^0.4.1, cookie@^0.4.2, cookie@~0.4.1: version "0.4.2" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== @@ -4689,7 +4745,7 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== -cors@2.8.5: +cors@2.8.5, cors@~2.8.5: version "2.8.5" resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== @@ -4899,7 +4955,7 @@ debug@2.6.9, debug@^2.6.9: dependencies: ms "2.0.0" -debug@4, debug@4.x, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: +debug@4, debug@4.x, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -5118,6 +5174,15 @@ dom-converter@^0.2.0: dependencies: utila "~0.4" +dom-css@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/dom-css/-/dom-css-2.1.0.tgz#fdbc2d5a015d0a3e1872e11472bbd0e7b9e6a202" + integrity sha512-w9kU7FAbaSh3QKijL6n59ofAhkkmMJ31GclJIz/vyQdjogfyxcB6Zf8CZyibOERI5o0Hxz30VmJS7+7r5fEj2Q== + dependencies: + add-px-to-style "1.0.0" + prefix-style "2.0.1" + to-camel-case "1.0.0" + dom-serializer@^1.0.1: version "1.4.1" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" @@ -5242,6 +5307,27 @@ end-of-stream@^1.1.0: dependencies: once "^1.4.0" +engine.io-parser@~5.0.3: + version "5.0.4" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.0.4.tgz#0b13f704fa9271b3ec4f33112410d8f3f41d0fc0" + integrity sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg== + +engine.io@~6.2.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.2.1.tgz#e3f7826ebc4140db9bbaa9021ad6b1efb175878f" + integrity sha512-ECceEFcAaNRybd3lsGQKas3ZlMVjN3cyWwMP25D2i0zWfyiytVbTpRPa34qrr+FHddtpBVOmq4H/DCv1O0lZRA== + dependencies: + "@types/cookie" "^0.4.1" + "@types/cors" "^2.8.12" + "@types/node" ">=10.0.0" + accepts "~1.3.4" + base64id "2.0.0" + cookie "~0.4.1" + cors "~2.8.5" + debug "~4.3.1" + engine.io-parser "~5.0.3" + ws "~8.2.3" + enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0, enhanced-resolve@^5.7.0: version "5.10.0" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz#0dc579c3bb2a1032e357ac45b8f3a6f3ad4fb1e6" @@ -5748,6 +5834,11 @@ execa@^5.0.0: signal-exit "^3.0.3" strip-final-newline "^2.0.0" +exenv@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d" + integrity sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw== + exit@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" @@ -8091,7 +8182,7 @@ logform@^2.3.2, logform@^2.4.0: safe-stable-stringify "^2.3.1" triple-beam "^1.3.0" -loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -8946,6 +9037,11 @@ pause@0.0.1: resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" integrity sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg== +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -9270,6 +9366,11 @@ postcss@^8.4.17, postcss@^8.4.18, postcss@^8.4.19: picocolors "^1.0.0" source-map-js "^1.0.2" +prefix-style@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/prefix-style/-/prefix-style-2.0.1.tgz#66bba9a870cfda308a5dc20e85e9120932c95a06" + integrity sha512-gdr1MBNVT0drzTq95CbSNdsrBDoHGlb2aDJP/FoY+1e+jSDPOb1Cv554gH2MGiSr2WTcXi/zu+NaFzfcHQkfBQ== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -9346,7 +9447,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.8.1: +prop-types@^15.5.10, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -9413,6 +9514,13 @@ quick-lru@^5.1.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== +raf@^3.1.0: + version "3.4.1" + resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" + integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== + dependencies: + performance-now "^2.1.0" + randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -9435,6 +9543,15 @@ raw-body@2.5.1: iconv-lite "0.4.24" unpipe "1.0.0" +react-custom-scrollbars-2@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/react-custom-scrollbars-2/-/react-custom-scrollbars-2-4.5.0.tgz#cff18e7368bce9d570aea0be780045eda392c745" + integrity sha512-/z0nWAeXfMDr4+OXReTpYd1Atq9kkn4oI3qxq3iMXGQx1EEfwETSqB8HTAvg1X7dEqcCachbny1DRNGlqX5bDQ== + dependencies: + dom-css "^2.0.0" + prop-types "^15.5.10" + raf "^3.1.0" + react-dom@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" @@ -9463,6 +9580,21 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-lifecycles-compat@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== + +react-modal@^3.16.1: + version "3.16.1" + resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.16.1.tgz#34018528fc206561b1a5467fc3beeaddafb39b2b" + integrity sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg== + dependencies: + exenv "^1.2.0" + prop-types "^15.7.2" + react-lifecycles-compat "^3.0.0" + warning "^4.0.3" + react-refresh@^0.14.0: version "0.14.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" @@ -10040,6 +10172,31 @@ smart-buffer@^4.2.0: resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== +socket.io-adapter@~2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.4.0.tgz#b50a4a9ecdd00c34d4c8c808224daa1a786152a6" + integrity sha512-W4N+o69rkMEGVuk2D/cvca3uYsvGlMwsySWV447y99gUPghxq42BxqLNMndb+a1mm/5/7NeXVQS7RLa2XyXvYg== + +socket.io-parser@~4.2.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.1.tgz#01c96efa11ded938dcb21cbe590c26af5eff65e5" + integrity sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + +socket.io@*, socket.io@4.5.3, socket.io@^4.5.3: + version "4.5.3" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.5.3.tgz#44dffea48d7f5aa41df4a66377c386b953bc521c" + integrity sha512-zdpnnKU+H6mOp7nYRXH4GNv1ux6HL6+lHL8g7Ds7Lj8CkdK1jJK/dlwsKDculbyOHifcJ0Pr/yeXnZQ5GeFrcg== + dependencies: + accepts "~1.3.4" + base64id "~2.0.0" + debug "~4.3.2" + engine.io "~6.2.0" + socket.io-adapter "~2.4.0" + socket.io-parser "~4.2.0" + sockjs@^0.3.24: version "0.3.24" resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce" @@ -10553,11 +10710,23 @@ tmpl@1.0.5: resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== +to-camel-case@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-camel-case/-/to-camel-case-1.0.0.tgz#1a56054b2f9d696298ce66a60897322b6f423e46" + integrity sha512-nD8pQi5H34kyu1QDMFjzEIYqk0xa9Alt6ZfrdEMuHCFOfTLhDG5pgTu/aAM9Wt9lXILwlXmWP43b8sav0GNE8Q== + dependencies: + to-space-case "^1.0.0" + to-fast-properties@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== +to-no-case@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/to-no-case/-/to-no-case-1.0.2.tgz#c722907164ef6b178132c8e69930212d1b4aa16a" + integrity sha512-Z3g735FxuZY8rodxV4gH7LxClE4H0hTIyHNIHdk+vpQxjLm0cwnKXq/OFVZ76SOQmto7txVcwSCwkU5kqp+FKg== + to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" @@ -10565,6 +10734,13 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +to-space-case@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-space-case/-/to-space-case-1.0.0.tgz#b052daafb1b2b29dc770cea0163e5ec0ebc9fc17" + integrity sha512-rLdvwXZ39VOn1IxGL3V6ZstoTbwLRckQmn/U8ZDLuWwIXNpuZDhQ3AiRUlhTbOXFVE9C+dR51wM0CBDhk31VcA== + dependencies: + to-no-case "^1.0.0" + toidentifier@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" @@ -10926,6 +11102,13 @@ walker@^1.0.8: dependencies: makeerror "1.0.12" +warning@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" + integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + dependencies: + loose-envify "^1.0.0" + watchpack@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" @@ -11309,6 +11492,11 @@ ws@^5.2.0: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== +ws@~8.2.3: + version "8.2.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba" + integrity sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA== + xml-name-validator@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835"