├── README.md ├── client ├── src │ ├── react-app-env.d.ts │ ├── components │ │ ├── header │ │ │ ├── index.ts │ │ │ ├── Header.module.scss │ │ │ ├── Header.test.tsx │ │ │ └── Header.tsx │ │ ├── layout │ │ │ ├── index.ts │ │ │ └── Layout.tsx │ │ ├── navbar │ │ │ ├── index.ts │ │ │ ├── Navbar.test.tsx │ │ │ └── Navbar.tsx │ │ ├── app-info │ │ │ ├── index.ts │ │ │ ├── AppInfo.module.scss │ │ │ ├── AppInfo.test.tsx │ │ │ └── AppInfo.tsx │ │ ├── app-title │ │ │ ├── index.ts │ │ │ ├── AppTitle.module.scss │ │ │ ├── AppTitle.test.tsx │ │ │ └── AppTitle.tsx │ │ ├── common │ │ │ ├── form │ │ │ │ ├── index.ts │ │ │ │ └── Form.tsx │ │ │ ├── form-field │ │ │ │ ├── index.ts │ │ │ │ ├── FormField.tsx │ │ │ │ └── FormField.test.tsx │ │ │ ├── page-title │ │ │ │ ├── index.ts │ │ │ │ ├── PageTitle.module.scss │ │ │ │ ├── PageTitle.test.tsx │ │ │ │ └── PageTitle.tsx │ │ │ └── loading-button │ │ │ │ ├── index.ts │ │ │ │ ├── LoadingButton.test.tsx │ │ │ │ └── LoadingButton.tsx │ │ ├── user-info │ │ │ ├── index.ts │ │ │ ├── UserInfo.tsx │ │ │ └── UserInfo.test.tsx │ │ ├── user-menu │ │ │ ├── index.ts │ │ │ ├── UserMenu.module.scss │ │ │ ├── UserMenu.tsx │ │ │ └── UserMenu.test.tsx │ │ ├── auth-links │ │ │ ├── index.ts │ │ │ ├── AuthLinks.module.scss │ │ │ ├── AuthLinks.test.tsx │ │ │ └── AuthLinks.tsx │ │ ├── rooms │ │ │ ├── room-item │ │ │ │ ├── index.ts │ │ │ │ ├── RoomItem.tsx │ │ │ │ └── RoomItem.test.tsx │ │ │ ├── room-header │ │ │ │ ├── index.ts │ │ │ │ ├── RoomHeader.module.scss │ │ │ │ ├── RoomHeader.tsx │ │ │ │ └── RoomHeader.test.tsx │ │ │ ├── rooms-list │ │ │ │ ├── index.ts │ │ │ │ └── RoomsList.tsx │ │ │ ├── room-controls │ │ │ │ ├── index.ts │ │ │ │ ├── RoomControls.tsx │ │ │ │ └── RoomControls.test.tsx │ │ │ └── room-members │ │ │ │ ├── index.ts │ │ │ │ ├── RoomMembers.tsx │ │ │ │ └── RoomMembers.test.tsx │ │ ├── signin-link │ │ │ ├── index.ts │ │ │ ├── SigninLink.test.tsx │ │ │ └── SigninLink.tsx │ │ ├── signup-link │ │ │ ├── index.ts │ │ │ ├── SignupLink.test.tsx │ │ │ └── SignupLink.tsx │ │ ├── forms │ │ │ ├── signin-form │ │ │ │ ├── index.ts │ │ │ │ └── SigninForm.tsx │ │ │ ├── signup-form │ │ │ │ ├── index.ts │ │ │ │ └── SignupForm.tsx │ │ │ ├── add-room-form │ │ │ │ ├── index.ts │ │ │ │ └── AddRoomForm.tsx │ │ │ ├── add-message-form │ │ │ │ ├── index.ts │ │ │ │ ├── AddMessageForm.module.scss │ │ │ │ └── AddMessageForm.tsx │ │ │ ├── search-room-form │ │ │ │ ├── index.ts │ │ │ │ └── SearchRoomForm.tsx │ │ │ ├── search-user-form │ │ │ │ ├── index.ts │ │ │ │ └── SearchUserForm.tsx │ │ │ ├── update-room-form │ │ │ │ ├── index.ts │ │ │ │ └── UpdateRoomForm.tsx │ │ │ ├── update-profile-form │ │ │ │ ├── index.ts │ │ │ │ └── UpdateProfileForm.tsx │ │ │ └── update-password-form │ │ │ │ ├── index.ts │ │ │ │ └── UpdatePasswordForm.tsx │ │ ├── routing │ │ │ ├── app-router │ │ │ │ ├── index.ts │ │ │ │ └── AppRouter.tsx │ │ │ └── auth-wrapper │ │ │ │ ├── index.ts │ │ │ │ └── AuthWrapper.tsx │ │ ├── messages │ │ │ ├── message-item │ │ │ │ ├── index.ts │ │ │ │ ├── MessageItem.module.scss │ │ │ │ ├── MessageItem.test.tsx │ │ │ │ └── MessageItem.tsx │ │ │ ├── messages-list │ │ │ │ ├── index.ts │ │ │ │ ├── MessagesList.module.scss │ │ │ │ └── MessagesList.tsx │ │ │ └── message-typing-notification │ │ │ │ ├── index.ts │ │ │ │ └── MessageTypingNotification.tsx │ │ └── user-menu-button │ │ │ ├── index.ts │ │ │ ├── UserMenuButton.module.scss │ │ │ ├── UserMenuButton.test.tsx │ │ │ └── UserMenuButton.tsx │ ├── pages │ │ ├── home-page │ │ │ ├── index.ts │ │ │ └── HomePage.tsx │ │ ├── room-page │ │ │ ├── index.ts │ │ │ └── RoomPage.tsx │ │ ├── profile-page │ │ │ ├── index.ts │ │ │ └── ProfilePage.tsx │ │ ├── rooms-page │ │ │ ├── index.ts │ │ │ └── RoomsPage.tsx │ │ ├── signin-page │ │ │ ├── index.ts │ │ │ └── SigninPage.tsx │ │ ├── signup-page │ │ │ ├── index.ts │ │ │ └── SignupPage.tsx │ │ ├── add-room-page │ │ │ ├── index.ts │ │ │ └── AddRoomPage.tsx │ │ └── update-room-page │ │ │ ├── index.ts │ │ │ └── UpdateRoomPage.tsx │ ├── models │ │ ├── auth.ts │ │ ├── message.ts │ │ ├── event.ts │ │ ├── user.ts │ │ └── room.ts │ ├── store │ │ ├── selectors │ │ │ └── authSelectors.ts │ │ ├── api │ │ │ ├── baseApi.ts │ │ │ ├── roomApi.ts │ │ │ ├── userApi.ts │ │ │ ├── authApi.ts │ │ │ └── messageApi.ts │ │ ├── index.ts │ │ └── slices │ │ │ └── authSlice.ts │ ├── environment.d.ts │ ├── App.tsx │ ├── setupTests.ts │ ├── hooks │ │ ├── redux.ts │ │ ├── useActions.ts │ │ ├── useAddRoomForm.ts │ │ ├── useAddMessageForm.ts │ │ ├── useUpdateProfileForm.ts │ │ ├── useRoom.ts │ │ ├── useUpdateRoomForm.ts │ │ ├── useSigninForm.ts │ │ └── useSignupForm.ts │ ├── validation │ │ ├── signinValidation.ts │ │ ├── messageValidation.ts │ │ ├── userValidation.ts │ │ ├── rules │ │ │ └── index.ts │ │ ├── roomValidation.ts │ │ ├── passwordValidation.ts │ │ ├── signupValidation.ts │ │ └── configValidation.ts │ ├── config │ │ └── index.ts │ ├── index.tsx │ ├── routes │ │ └── index.ts │ └── services │ │ └── socketService.ts ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html ├── .gitignore ├── .env.example ├── tsconfig.json ├── package.json └── README.md └── server ├── .prettierrc ├── src ├── auth │ ├── interfaces │ │ ├── jwt-payload.interface.ts │ │ ├── auth-response.interface.ts │ │ └── auth-request.interface.ts │ ├── jwt-auth.guard.ts │ ├── dto │ │ ├── signin-credentials.dto.ts │ │ └── signup-credentials.dto.ts │ ├── jwt.strategy.ts │ ├── auth.module.ts │ ├── auth.controller.ts │ └── auth.service.ts ├── room │ ├── dto │ │ ├── get-rooms.dto.ts │ │ ├── update-room.dto.ts │ │ ├── create-room.dto.ts │ │ └── search-rooms.dto.ts │ ├── room.module.ts │ ├── room.entity.ts │ ├── room.controller.ts │ └── room.service.ts ├── message │ ├── dto │ │ ├── get-messages.dto.ts │ │ └── create-message.dto.ts │ ├── message.module.ts │ ├── message.entity.ts │ ├── message.controller.ts │ └── message.service.ts ├── user │ ├── dto │ │ ├── search-users.dto.ts │ │ ├── update-user.dto.ts │ │ └── update-password.dto.ts │ ├── user.module.ts │ ├── user.entity.ts │ ├── user.controller.ts │ └── user.service.ts ├── common │ ├── utils │ │ └── hash.util.ts │ ├── dto │ │ └── pagination.dto.ts │ └── decorators │ │ └── auth-user.decorator.ts ├── chat │ ├── chat.module.ts │ └── chat.gateway.ts ├── config │ └── typeorm.config.ts ├── swagger │ └── index.ts ├── main.ts ├── app.module.ts └── validation │ └── env.validation.ts ├── tsconfig.build.json ├── nest-cli.json ├── .env.example ├── test ├── jest-e2e.json └── app.e2e-spec.ts ├── .gitignore ├── tsconfig.json ├── .eslintrc.js ├── package.json └── README.md /README.md: -------------------------------------------------------------------------------- 1 | # nestjs-react-chat -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/components/header/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Header"; 2 | -------------------------------------------------------------------------------- /client/src/components/layout/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Layout"; 2 | -------------------------------------------------------------------------------- /client/src/components/navbar/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Navbar"; 2 | -------------------------------------------------------------------------------- /client/src/pages/home-page/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./HomePage"; 2 | -------------------------------------------------------------------------------- /client/src/pages/room-page/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./RoomPage"; 2 | -------------------------------------------------------------------------------- /client/src/components/app-info/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./AppInfo"; 2 | -------------------------------------------------------------------------------- /client/src/components/app-title/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./AppTitle"; 2 | -------------------------------------------------------------------------------- /client/src/components/common/form/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Form"; 2 | -------------------------------------------------------------------------------- /client/src/components/user-info/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./UserInfo"; 2 | -------------------------------------------------------------------------------- /client/src/components/user-menu/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./UserMenu"; 2 | -------------------------------------------------------------------------------- /client/src/pages/profile-page/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ProfilePage"; 2 | -------------------------------------------------------------------------------- /client/src/pages/rooms-page/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./RoomsPage"; 2 | -------------------------------------------------------------------------------- /client/src/pages/signin-page/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./SigninPage"; 2 | -------------------------------------------------------------------------------- /client/src/pages/signup-page/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./SignupPage"; 2 | -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /client/src/components/auth-links/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./AuthLinks"; 2 | -------------------------------------------------------------------------------- /client/src/components/rooms/room-item/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./RoomItem"; 2 | -------------------------------------------------------------------------------- /client/src/components/signin-link/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./SigninLink"; 2 | -------------------------------------------------------------------------------- /client/src/components/signup-link/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./SignupLink"; 2 | -------------------------------------------------------------------------------- /client/src/pages/add-room-page/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./AddRoomPage"; 2 | -------------------------------------------------------------------------------- /client/src/components/app-info/AppInfo.module.scss: -------------------------------------------------------------------------------- 1 | .card { 2 | padding: 2rem; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/components/common/form-field/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./FormField"; 2 | -------------------------------------------------------------------------------- /client/src/components/common/page-title/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./PageTitle"; 2 | -------------------------------------------------------------------------------- /client/src/components/forms/signin-form/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./SigninForm"; 2 | -------------------------------------------------------------------------------- /client/src/components/forms/signup-form/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./SignupForm"; 2 | -------------------------------------------------------------------------------- /client/src/components/rooms/room-header/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./RoomHeader"; 2 | -------------------------------------------------------------------------------- /client/src/components/rooms/rooms-list/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./RoomsList"; 2 | -------------------------------------------------------------------------------- /client/src/components/routing/app-router/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./AppRouter"; 2 | -------------------------------------------------------------------------------- /client/src/components/user-menu/UserMenu.module.scss: -------------------------------------------------------------------------------- 1 | .menu { 2 | padding: 0.5rem; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/pages/update-room-page/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./UpdateRoomPage"; 2 | -------------------------------------------------------------------------------- /client/src/components/forms/add-room-form/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./AddRoomForm"; 2 | -------------------------------------------------------------------------------- /client/src/components/messages/message-item/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./MessageItem"; 2 | -------------------------------------------------------------------------------- /client/src/components/rooms/room-controls/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./RoomControls"; 2 | -------------------------------------------------------------------------------- /client/src/components/rooms/room-members/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./RoomMembers"; 2 | -------------------------------------------------------------------------------- /client/src/components/routing/auth-wrapper/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./AuthWrapper"; 2 | -------------------------------------------------------------------------------- /client/src/components/user-menu-button/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./UserMenuButton"; 2 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/src/components/common/loading-button/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./LoadingButton"; 2 | -------------------------------------------------------------------------------- /client/src/components/forms/add-message-form/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./AddMessageForm"; 2 | -------------------------------------------------------------------------------- /client/src/components/forms/search-room-form/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./SearchRoomForm"; 2 | -------------------------------------------------------------------------------- /client/src/components/forms/search-user-form/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./SearchUserForm"; 2 | -------------------------------------------------------------------------------- /client/src/components/forms/update-room-form/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./UpdateRoomForm"; 2 | -------------------------------------------------------------------------------- /client/src/components/messages/messages-list/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./MessagesList"; 2 | -------------------------------------------------------------------------------- /client/src/components/common/page-title/PageTitle.module.scss: -------------------------------------------------------------------------------- 1 | .title { 2 | font-size: 1.5rem; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/components/forms/update-profile-form/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./UpdateProfileForm"; 2 | -------------------------------------------------------------------------------- /client/src/components/user-menu-button/UserMenuButton.module.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | padding: 0.5rem; 3 | } 4 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk026/nestjs-react-chat/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk026/nestjs-react-chat/HEAD/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk026/nestjs-react-chat/HEAD/client/public/logo512.png -------------------------------------------------------------------------------- /client/src/components/forms/update-password-form/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./UpdatePasswordForm"; 2 | -------------------------------------------------------------------------------- /server/src/auth/interfaces/jwt-payload.interface.ts: -------------------------------------------------------------------------------- 1 | export interface JwtPayload { 2 | userId: number; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/components/app-title/AppTitle.module.scss: -------------------------------------------------------------------------------- 1 | .title { 2 | font-size: 2rem; 3 | 4 | margin-right: 2rem; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/components/messages/message-typing-notification/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./MessageTypingNotification"; 2 | -------------------------------------------------------------------------------- /client/src/components/rooms/room-header/RoomHeader.module.scss: -------------------------------------------------------------------------------- 1 | .room-header { 2 | position: sticky; 3 | top: 2rem; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/components/messages/messages-list/MessagesList.module.scss: -------------------------------------------------------------------------------- 1 | .list { 2 | margin: 0 auto; 3 | max-width: 1000px; 4 | } 5 | -------------------------------------------------------------------------------- /server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /client/src/models/auth.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from "./user"; 2 | 3 | export interface AuthResponse { 4 | user: IUser; 5 | token: string; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/components/header/Header.module.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | position: sticky; 3 | 4 | .container { 5 | align-items: center; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /client/src/store/selectors/authSelectors.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from ".."; 2 | 3 | export const getAuthState = (state: RootState) => state.auth; 4 | -------------------------------------------------------------------------------- /server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /client/src/components/auth-links/AuthLinks.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | margin-left: auto; 3 | 4 | .signup-btn { 5 | margin-right: 1rem; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /server/src/room/dto/get-rooms.dto.ts: -------------------------------------------------------------------------------- 1 | import { PaginationDto } from '../../common/dto/pagination.dto'; 2 | 3 | export class GetRoomsDto extends PaginationDto {} 4 | -------------------------------------------------------------------------------- /server/src/auth/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtAuthGuard extends AuthGuard('jwt') {} 6 | -------------------------------------------------------------------------------- /client/src/components/forms/add-message-form/AddMessageForm.module.scss: -------------------------------------------------------------------------------- 1 | .form { 2 | position: sticky; 3 | bottom: 0; 4 | left: 0; 5 | width: 100%; 6 | padding-top: 1rem; 7 | padding-bottom: 2rem; 8 | } 9 | -------------------------------------------------------------------------------- /server/src/auth/interfaces/auth-response.interface.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../../user/user.entity'; 2 | 3 | export interface AuthResponse { 4 | user: Pick; 5 | token: string; 6 | } 7 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | 3 | DB_HOST=localhost 4 | DB_PORT=5432 5 | DB_NAME=chatdb 6 | DB_USERNAME=username 7 | DB_PASSWORD=password 8 | DB_SYNC=false 9 | 10 | JWT_SECRET=secret 11 | JWT_EXPIRES_IN=1h -------------------------------------------------------------------------------- /server/src/room/dto/update-room.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { CreateRoomDto } from './create-room.dto'; 3 | 4 | export class UpdateRoomDto extends PartialType(CreateRoomDto) {} 5 | -------------------------------------------------------------------------------- /client/src/components/messages/message-item/MessageItem.module.scss: -------------------------------------------------------------------------------- 1 | .message { 2 | display: inline-block; 3 | padding: 1rem; 4 | margin-top: 0.5rem; 5 | margin-bottom: 1rem; 6 | 7 | border-radius: 0.5rem; 8 | } 9 | -------------------------------------------------------------------------------- /client/src/environment.d.ts: -------------------------------------------------------------------------------- 1 | type EnvironmentVariables = 2 | import("./validation/configValidation").EnvironmentVariables; 3 | 4 | declare namespace NodeJS { 5 | interface ProcessEnv extends EnvironmentVariables {} 6 | } 7 | -------------------------------------------------------------------------------- /server/src/message/dto/get-messages.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsInt } from 'class-validator'; 3 | 4 | export class GetMessagesDto { 5 | @IsInt() 6 | @Type(() => Number) 7 | readonly roomId: number; 8 | } 9 | -------------------------------------------------------------------------------- /server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/src/auth/interfaces/auth-request.interface.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | 3 | import { JwtStrategy } from '../jwt.strategy'; 4 | 5 | export interface AuthRequest extends Request { 6 | user: Awaited>; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import Layout from "./components/layout"; 2 | import AppRouter from "./components/routing/app-router"; 3 | 4 | function App() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | 12 | export default App; 13 | -------------------------------------------------------------------------------- /server/src/user/dto/search-users.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | 3 | import { PaginationDto } from '../../common/dto/pagination.dto'; 4 | 5 | export class SearchUsersDto extends PaginationDto { 6 | @IsNotEmpty() 7 | readonly name: string; 8 | } 9 | -------------------------------------------------------------------------------- /client/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /server/src/auth/dto/signin-credentials.dto.ts: -------------------------------------------------------------------------------- 1 | import { OmitType } from '@nestjs/mapped-types'; 2 | import { SignupCredentialsDto } from './signup-credentials.dto'; 3 | 4 | export class SigninCredentialsDto extends OmitType(SignupCredentialsDto, [ 5 | 'name', 6 | ] as const) {} 7 | -------------------------------------------------------------------------------- /client/src/models/message.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from "./user"; 2 | 3 | export interface IMessage { 4 | id: number; 5 | owner: IUser; 6 | roomId: number; 7 | content: string; 8 | } 9 | 10 | export interface AddMessageDto { 11 | content: string; 12 | roomId: number; 13 | } 14 | -------------------------------------------------------------------------------- /server/src/user/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { OmitType, PartialType } from '@nestjs/mapped-types'; 2 | import { SignupCredentialsDto } from 'src/auth/dto/signup-credentials.dto'; 3 | 4 | export class UpdateUserDto extends OmitType(PartialType(SignupCredentialsDto), [ 5 | 'password', 6 | ] as const) {} 7 | -------------------------------------------------------------------------------- /client/src/hooks/redux.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; 2 | import { AppDispatch, RootState } from "../store"; 3 | 4 | export const useAppDispatch = () => useDispatch(); 5 | export const useAppSelector: TypedUseSelectorHook = useSelector; 6 | -------------------------------------------------------------------------------- /server/src/message/dto/create-message.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, IsNotEmpty, MaxLength, MinLength } from 'class-validator'; 2 | 3 | export class CreateMessageDto { 4 | @IsInt() 5 | readonly roomId: number; 6 | 7 | @IsNotEmpty() 8 | @MinLength(1) 9 | @MaxLength(500) 10 | readonly content: string; 11 | } 12 | -------------------------------------------------------------------------------- /server/src/user/dto/update-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, MaxLength, MinLength } from 'class-validator'; 2 | 3 | export class UpdatePasswordDto { 4 | @IsNotEmpty() 5 | readonly oldPassword: string; 6 | 7 | @IsNotEmpty() 8 | @MinLength(8) 9 | @MaxLength(100) 10 | readonly newPassword: string; 11 | } 12 | -------------------------------------------------------------------------------- /client/src/validation/signinValidation.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | export interface SigninFormValues 4 | extends yup.InferType {} 5 | 6 | export const signinValidationSchema = yup.object({ 7 | email: yup.string().email().required(), 8 | password: yup.string().required(), 9 | }); 10 | -------------------------------------------------------------------------------- /server/src/common/utils/hash.util.ts: -------------------------------------------------------------------------------- 1 | import * as bcryptjs from 'bcryptjs'; 2 | 3 | export class Hash { 4 | static generateHash(plainText: string) { 5 | return bcryptjs.hashSync(plainText); 6 | } 7 | 8 | static compare(plainText: string, hash: string) { 9 | return bcryptjs.compareSync(plainText, hash); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /server/src/room/dto/create-room.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, MaxLength, MinLength } from 'class-validator'; 2 | 3 | export class CreateRoomDto { 4 | @IsNotEmpty() 5 | @MinLength(1) 6 | @MaxLength(100) 7 | readonly title: string; 8 | 9 | @IsNotEmpty() 10 | @MinLength(1) 11 | @MaxLength(1000) 12 | readonly description: string; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/components/rooms/room-controls/RoomControls.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Stack } from "@mui/material"; 2 | import { FC } from "react"; 3 | 4 | const RoomControls: FC = () => { 5 | return ( 6 | 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default RoomControls; 14 | -------------------------------------------------------------------------------- /client/src/validation/messageValidation.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | import { messageRules } from "./rules"; 4 | 5 | export interface MessageFormValues 6 | extends yup.InferType {} 7 | 8 | export const messageValidationSchema = yup.object({ 9 | content: yup.string().max(messageRules.content.max).required(), 10 | }); 11 | -------------------------------------------------------------------------------- /server/src/common/dto/pagination.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsInt, IsOptional } from 'class-validator'; 3 | 4 | export class PaginationDto { 5 | @IsOptional() 6 | @IsInt() 7 | @Type(() => Number) 8 | readonly skip?: number; 9 | 10 | @IsOptional() 11 | @IsInt() 12 | @Type(() => Number) 13 | readonly take?: number; 14 | } 15 | -------------------------------------------------------------------------------- /server/src/chat/chat.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MessageModule } from 'src/message/message.module'; 3 | import { AuthModule } from '../auth/auth.module'; 4 | 5 | import { ChatGateway } from './chat.gateway'; 6 | 7 | @Module({ 8 | imports: [AuthModule, MessageModule], 9 | providers: [ChatGateway], 10 | }) 11 | export class ChatModule {} 12 | -------------------------------------------------------------------------------- /client/src/pages/home-page/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | import PageTitle from "../../components/common/page-title"; 4 | import AppInfo from "../../components/app-info"; 5 | 6 | const HomePage: FC = () => { 7 | return ( 8 | <> 9 | HomePage 10 | 11 | 12 | ); 13 | }; 14 | 15 | export default HomePage; 16 | -------------------------------------------------------------------------------- /client/src/components/app-info/AppInfo.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | 3 | import AppInfo from "."; 4 | 5 | it("Should render AppInfo", () => { 6 | const infoText = "Welcome to NestJS/React Websocket Chat App"; 7 | 8 | render(); 9 | 10 | const element = screen.getByText(infoText); 11 | 12 | expect(element).toBeInTheDocument(); 13 | }); 14 | -------------------------------------------------------------------------------- /client/src/components/forms/search-room-form/SearchRoomForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Box, Button, TextField } from "@mui/material"; 3 | 4 | const SearchRoomForm: FC = () => { 5 | return ( 6 | 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default SearchRoomForm; 14 | -------------------------------------------------------------------------------- /client/src/components/forms/search-user-form/SearchUserForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Box, Button, TextField } from "@mui/material"; 3 | 4 | const SearchUserForm: FC = () => { 5 | return ( 6 | 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default SearchUserForm; 14 | -------------------------------------------------------------------------------- /client/src/components/common/page-title/PageTitle.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | 3 | import PageTitle from "."; 4 | 5 | it("Should render PageTitle", () => { 6 | const titleText = "Page Title"; 7 | 8 | render({titleText}); 9 | 10 | const element = screen.getByText(titleText); 11 | 12 | expect(element).toBeInTheDocument(); 13 | }); 14 | -------------------------------------------------------------------------------- /client/src/components/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { FC, PropsWithChildren } from "react"; 2 | import { Container } from "@mui/material"; 3 | 4 | import Header from "../header"; 5 | 6 | const Layout: FC = ({ children }) => { 7 | return ( 8 | <> 9 |
10 | {children} 11 | 12 | ); 13 | }; 14 | 15 | export default Layout; 16 | -------------------------------------------------------------------------------- /server/src/common/decorators/auth-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | import { AuthRequest } from '../../auth/interfaces/auth-request.interface'; 4 | 5 | export const AuthUser = createParamDecorator( 6 | (data: unknown, ctx: ExecutionContext) => { 7 | const req = ctx.switchToHttp().getRequest(); 8 | return req.user; 9 | }, 10 | ); 11 | -------------------------------------------------------------------------------- /client/src/components/rooms/room-members/RoomMembers.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { AvatarGroup } from "@mui/material"; 3 | 4 | import { IUser } from "../../../models/user"; 5 | 6 | interface RoomMembersProps { 7 | members: IUser[]; 8 | } 9 | 10 | const RoomMembers: FC = ({ members }) => { 11 | return RoomMembers; 12 | }; 13 | 14 | export default RoomMembers; 15 | -------------------------------------------------------------------------------- /client/src/models/event.ts: -------------------------------------------------------------------------------- 1 | import { AddMessageDto, IMessage } from "./message"; 2 | 3 | export interface ServerToClientEvents { 4 | message: (data: IMessage) => void; 5 | isTyping: (name: string) => void; 6 | } 7 | 8 | export interface ClientToServerEvents { 9 | message: (data: AddMessageDto) => void; 10 | join: (roomId: number) => void; 11 | leave: (roomId: number) => void; 12 | isTyping: (roomId: number) => void; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/components/navbar/Navbar.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { MemoryRouter } from "react-router-dom"; 3 | 4 | import Navbar from "."; 5 | 6 | it("Should render Navbar", () => { 7 | render( 8 | 9 | 10 | 11 | ); 12 | 13 | const navbar = screen.getByRole("navigation"); 14 | 15 | expect(navbar).toBeInTheDocument(); 16 | }); 17 | -------------------------------------------------------------------------------- /client/src/components/common/loading-button/LoadingButton.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | 3 | import LoadingButton from "."; 4 | 5 | it("Should render LoadingButton", () => { 6 | const buttonText = "Submit"; 7 | 8 | render({buttonText}); 9 | 10 | const element = screen.getByText(buttonText); 11 | 12 | expect(element).toBeInTheDocument(); 13 | }); 14 | -------------------------------------------------------------------------------- /client/src/components/common/page-title/PageTitle.tsx: -------------------------------------------------------------------------------- 1 | import { FC, PropsWithChildren } from "react"; 2 | import { Typography } from "@mui/material"; 3 | 4 | import classes from "./PageTitle.module.scss"; 5 | 6 | const PageTitle: FC = ({ children }) => { 7 | return ( 8 | 9 | {children} 10 | 11 | ); 12 | }; 13 | 14 | export default PageTitle; 15 | -------------------------------------------------------------------------------- /client/src/hooks/useActions.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { ActionCreatorsMapObject, bindActionCreators } from "@reduxjs/toolkit"; 3 | 4 | import { useAppDispatch } from "./redux"; 5 | 6 | export const useActions = (actions: T) => { 7 | const dispatch = useAppDispatch(); 8 | return useMemo( 9 | () => bindActionCreators(actions, dispatch), 10 | [actions, dispatch] 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /server/src/config/typeorm.config.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModuleOptions } from '@nestjs/typeorm'; 2 | 3 | export default (): TypeOrmModuleOptions => ({ 4 | type: 'postgres', 5 | host: process.env.DB_HOST, 6 | port: +process.env.DB_PORT, 7 | username: process.env.DB_USERNAME, 8 | password: process.env.DB_PASSWORD, 9 | database: process.env.DB_NAME, 10 | autoLoadEntities: true, 11 | synchronize: process.env.DB_SYNC === 'true', 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/components/user-menu-button/UserMenuButton.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | 3 | import UserMenuButton from "."; 4 | 5 | it("Should render UserMenuButton", () => { 6 | const onClick = () => {}; 7 | const name = "username"; 8 | 9 | render(); 10 | 11 | const element = screen.getByText(name); 12 | 13 | expect(element).toBeInTheDocument(); 14 | }); 15 | -------------------------------------------------------------------------------- /server/src/room/dto/search-rooms.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsInt, IsOptional, IsString } from 'class-validator'; 3 | import { PaginationDto } from '../../common/dto/pagination.dto'; 4 | 5 | export class SearchRoomsDto extends PaginationDto { 6 | @IsOptional() 7 | @IsString() 8 | readonly title?: string; 9 | 10 | @IsOptional() 11 | @IsInt() 12 | @Type(() => Number) 13 | readonly ownerId?: number; 14 | } 15 | -------------------------------------------------------------------------------- /server/src/room/room.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { RoomController } from './room.controller'; 5 | import { Room } from './room.entity'; 6 | import { RoomService } from './room.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([Room])], 10 | controllers: [RoomController], 11 | providers: [RoomService], 12 | }) 13 | export class RoomModule {} 14 | -------------------------------------------------------------------------------- /client/src/components/app-info/AppInfo.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Card, Typography } from "@mui/material"; 3 | 4 | import classes from "./AppInfo.module.scss"; 5 | 6 | const AppInfo: FC = () => { 7 | return ( 8 | 9 | 10 | Welcome to NestJS/React Websocket Chat App 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default AppInfo; 17 | -------------------------------------------------------------------------------- /client/src/pages/add-room-page/AddRoomPage.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Box } from "@mui/material"; 3 | 4 | import AddRoomForm from "../../components/forms/add-room-form"; 5 | import PageTitle from "../../components/common/page-title"; 6 | 7 | const AddRoomPage: FC = () => { 8 | return ( 9 | 10 | Add new room 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default AddRoomPage; 17 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # environment variables 15 | .env 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | -------------------------------------------------------------------------------- /server/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { UserController } from './user.controller'; 5 | import { User } from './user.entity'; 6 | import { UserService } from './user.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([User])], 10 | controllers: [UserController], 11 | providers: [UserService], 12 | exports: [UserService], 13 | }) 14 | export class UserModule {} 15 | -------------------------------------------------------------------------------- /client/src/models/user.ts: -------------------------------------------------------------------------------- 1 | export interface IUser { 2 | id: number; 3 | name: string; 4 | bio?: string; 5 | email: string; 6 | avatarUrl: string; 7 | } 8 | 9 | export interface UpdateUserDto { 10 | name?: string; 11 | bio?: string; 12 | email?: string; 13 | } 14 | 15 | export interface UpdatePasswordDto { 16 | oldPassword: string; 17 | newPassword: string; 18 | confirmPassword: string; 19 | } 20 | 21 | export interface SearchUsersDto { 22 | name: string; 23 | } 24 | -------------------------------------------------------------------------------- /client/src/pages/signin-page/SigninPage.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | import SigninForm from "../../components/forms/signin-form"; 4 | import SignupLink from "../../components/signup-link"; 5 | import PageTitle from "../../components/common/page-title"; 6 | 7 | const SigninPage: FC = () => { 8 | return ( 9 | <> 10 | Signin 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default SigninPage; 18 | -------------------------------------------------------------------------------- /client/src/pages/signup-page/SignupPage.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | import SignupForm from "../../components/forms/signup-form"; 4 | import SigninLink from "../../components/signin-link"; 5 | import PageTitle from "../../components/common/page-title"; 6 | 7 | const SignupPage: FC = () => { 8 | return ( 9 | <> 10 | Signup 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default SignupPage; 18 | -------------------------------------------------------------------------------- /client/src/components/app-title/AppTitle.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { MemoryRouter } from "react-router-dom"; 3 | 4 | import AppTitle from "."; 5 | 6 | it("Should render AppTitle", () => { 7 | const titleText = "React Chat"; 8 | 9 | render( 10 | 11 | 12 | 13 | ); 14 | 15 | const element = screen.getByText(titleText); 16 | 17 | expect(element).toBeInTheDocument(); 18 | }); 19 | -------------------------------------------------------------------------------- /client/src/validation/userValidation.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | import { userRules } from "./rules"; 4 | 5 | export interface UserFormValues 6 | extends yup.InferType {} 7 | 8 | export const userValidationSchema = yup.object({ 9 | name: yup.string().min(userRules.name.min).max(userRules.name.max).optional(), 10 | bio: yup.string().min(userRules.bio.min).max(userRules.bio.max).optional(), 11 | email: yup.string().email().optional(), 12 | }); 13 | -------------------------------------------------------------------------------- /client/.env.example: -------------------------------------------------------------------------------- 1 | REACT_APP_API_BASE_URL=http://localhost:8080 2 | REACT_APP_API_SOCKET_URL=ws://localhost:8080 3 | REACT_APP_API_SIGNUP_URL=/auth/signup 4 | REACT_APP_API_SIGNIN_URL=/auth/signin 5 | REACT_APP_API_CHECK_AUTH_URL=/auth/check 6 | REACT_APP_API_USERS_URL=/users 7 | REACT_APP_API_SEARCH_USERS_URL=/users/search 8 | REACT_APP_API_PASSWORD_UPDATE_URL=/users/password 9 | REACT_APP_API_MESSAGES_URL=/messages 10 | REACT_APP_API_ROOMS_URL=/rooms 11 | REACT_APP_API_SEARCH_ROOMS_URL=/rooms/search -------------------------------------------------------------------------------- /client/src/components/signin-link/SigninLink.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { MemoryRouter } from "react-router-dom"; 3 | 4 | import SigninLink from "."; 5 | 6 | it("Should render SigninLink", () => { 7 | const linkText = "Signin"; 8 | 9 | render( 10 | 11 | 12 | 13 | ); 14 | 15 | const element = screen.getByText(linkText); 16 | 17 | expect(element).toBeInTheDocument(); 18 | }); 19 | -------------------------------------------------------------------------------- /client/src/components/signup-link/SignupLink.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { MemoryRouter } from "react-router-dom"; 3 | 4 | import SignupLink from "."; 5 | 6 | it("Should render SignupLink", () => { 7 | const linkText = "Signup"; 8 | 9 | render( 10 | 11 | 12 | 13 | ); 14 | 15 | const element = screen.getByText(linkText); 16 | 17 | expect(element).toBeInTheDocument(); 18 | }); 19 | -------------------------------------------------------------------------------- /server/src/swagger/index.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 3 | 4 | export const setupSwagger = (app: INestApplication) => { 5 | const config = new DocumentBuilder() 6 | .setTitle('Chat API') 7 | .setDescription('Chat Rooms API') 8 | .setVersion('1.0') 9 | .addTag('chat') 10 | .build(); 11 | const document = SwaggerModule.createDocument(app, config); 12 | SwaggerModule.setup('api', app, document); 13 | }; 14 | -------------------------------------------------------------------------------- /client/src/models/room.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from "./user"; 2 | 3 | export interface IRoom { 4 | id: number; 5 | owner: IUser; 6 | title: string; 7 | description: string; 8 | members: IUser[]; 9 | } 10 | 11 | export interface AddRoomDto { 12 | title: string; 13 | description: string; 14 | } 15 | 16 | export interface UpdateRoomDto { 17 | id: number; 18 | title?: string; 19 | description?: string; 20 | } 21 | 22 | export interface SearchRoomsDto { 23 | title?: string; 24 | ownerId?: number; 25 | } 26 | -------------------------------------------------------------------------------- /server/src/message/message.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { MessageController } from './message.controller'; 5 | import { Message } from './message.entity'; 6 | import { MessageService } from './message.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([Message])], 10 | controllers: [MessageController], 11 | providers: [MessageService], 12 | exports: [MessageService], 13 | }) 14 | export class MessageModule {} 15 | -------------------------------------------------------------------------------- /client/src/components/signin-link/SigninLink.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { Button, Typography } from "@mui/material"; 4 | 5 | import { Paths } from "../../routes"; 6 | 7 | const SigninLink: FC = () => { 8 | return ( 9 | 10 | Already have an account?{" "} 11 | 14 | 15 | ); 16 | }; 17 | 18 | export default SigninLink; 19 | -------------------------------------------------------------------------------- /client/src/components/signup-link/SignupLink.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { Button, Typography } from "@mui/material"; 4 | 5 | import { Paths } from "../../routes"; 6 | 7 | const SignupLink: FC = () => { 8 | return ( 9 | 10 | Don't have an account?{" "} 11 | 14 | 15 | ); 16 | }; 17 | 18 | export default SignupLink; 19 | -------------------------------------------------------------------------------- /client/src/components/messages/message-typing-notification/MessageTypingNotification.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | interface MessageTypingNotificationProps { 4 | notifications: string[]; 5 | } 6 | 7 | const MessageTypingNotification: FC = ({ 8 | notifications, 9 | }) => { 10 | return ( 11 |
    12 | {notifications.map((notification) => ( 13 |
  • {notification}
  • 14 | ))} 15 |
16 | ); 17 | }; 18 | 19 | export default MessageTypingNotification; 20 | -------------------------------------------------------------------------------- /client/src/pages/rooms-page/RoomsPage.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | import SearchRoomForm from "../../components/forms/search-room-form"; 4 | import RoomsList from "../../components/rooms/rooms-list"; 5 | import { useGetRoomsQuery } from "../../store/api/roomApi"; 6 | 7 | const RoomsPage: FC = () => { 8 | const { data } = useGetRoomsQuery(); 9 | 10 | return ( 11 |
12 | 13 | 14 |
15 | ); 16 | }; 17 | 18 | export default RoomsPage; 19 | -------------------------------------------------------------------------------- /client/src/validation/rules/index.ts: -------------------------------------------------------------------------------- 1 | export const userRules = { 2 | name: { 3 | min: 1, 4 | max: 100, 5 | }, 6 | bio: { 7 | min: 1, 8 | max: 1000, 9 | }, 10 | password: { 11 | min: 8, 12 | max: 100, 13 | }, 14 | }; 15 | 16 | export const messageRules = { 17 | content: { 18 | min: 1, 19 | max: 500, 20 | }, 21 | }; 22 | 23 | export const roomRules = { 24 | title: { 25 | min: 1, 26 | max: 100, 27 | }, 28 | description: { 29 | min: 1, 30 | max: 100, 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /client/src/components/user-menu-button/UserMenuButton.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Button } from "@mui/material"; 3 | 4 | import classes from "./UserMenuButton.module.scss"; 5 | 6 | interface UserMenuButtonProps { 7 | onClick: () => void; 8 | name: string; 9 | } 10 | 11 | const UserMenuButton: FC = ({ onClick, name }) => { 12 | return ( 13 | 16 | ); 17 | }; 18 | 19 | export default UserMenuButton; 20 | -------------------------------------------------------------------------------- /client/src/components/user-info/UserInfo.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Paper, Typography } from "@mui/material"; 3 | 4 | import { IUser } from "../../models/user"; 5 | 6 | interface UserInfoProps { 7 | user: IUser; 8 | } 9 | 10 | const UserInfo: FC = ({ user }) => { 11 | return ( 12 | 13 | {user.name} 14 | {user.bio} 15 | {user.email} 16 | 17 | ); 18 | }; 19 | 20 | export default UserInfo; 21 | -------------------------------------------------------------------------------- /client/src/components/rooms/rooms-list/RoomsList.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Stack } from "@mui/material"; 3 | 4 | import { IRoom } from "../../../models/room"; 5 | import RoomItem from "../room-item"; 6 | 7 | interface RoomsListProps { 8 | rooms: IRoom[]; 9 | } 10 | 11 | const RoomsList: FC = ({ rooms }) => { 12 | return ( 13 | 14 | {rooms.map((room) => ( 15 | 16 | ))} 17 | 18 | ); 19 | }; 20 | 21 | export default RoomsList; 22 | -------------------------------------------------------------------------------- /client/src/validation/roomValidation.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | import { roomRules } from "./rules"; 4 | 5 | export interface RoomFormValues 6 | extends yup.InferType {} 7 | 8 | export const roomValidationSchema = yup.object({ 9 | title: yup 10 | .string() 11 | .min(roomRules.title.min) 12 | .max(roomRules.title.max) 13 | .required(), 14 | description: yup 15 | .string() 16 | .min(roomRules.description.min) 17 | .max(roomRules.description.max) 18 | .required(), 19 | }); 20 | -------------------------------------------------------------------------------- /client/src/components/rooms/room-controls/RoomControls.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | 3 | import RoomControls from "."; 4 | 5 | it("Should render RoomControls", () => { 6 | const leaveButtonText = "Leave"; 7 | const editButtonText = "Edit"; 8 | 9 | render(); 10 | 11 | const leaveButton = screen.getByText(leaveButtonText); 12 | const editButton = screen.getByText(editButtonText); 13 | 14 | expect(leaveButton).toBeInTheDocument(); 15 | expect(editButton).toBeInTheDocument(); 16 | }); 17 | -------------------------------------------------------------------------------- /client/src/components/user-info/UserInfo.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | 3 | import UserInfo from "."; 4 | import { IUser } from "../../models/user"; 5 | 6 | it("Should render UserInfo", () => { 7 | const fakeUser: IUser = { 8 | id: 1, 9 | avatarUrl: "", 10 | email: "test@test.com", 11 | name: "user", 12 | bio: "bio", 13 | }; 14 | 15 | render(); 16 | 17 | const element = screen.getByText(fakeUser.name); 18 | 19 | expect(element).toBeInTheDocument(); 20 | }); 21 | -------------------------------------------------------------------------------- /client/src/components/app-title/AppTitle.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { NavLink } from "react-router-dom"; 3 | import { Typography } from "@mui/material"; 4 | 5 | import { Paths } from "../../routes"; 6 | 7 | import classes from "./AppTitle.module.scss"; 8 | 9 | const AppTitle: FC = () => { 10 | return ( 11 | 17 | React Chat 18 | 19 | ); 20 | }; 21 | 22 | export default AppTitle; 23 | -------------------------------------------------------------------------------- /server/src/auth/dto/signup-credentials.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEmail, 3 | IsNotEmpty, 4 | IsOptional, 5 | MaxLength, 6 | MinLength, 7 | } from 'class-validator'; 8 | 9 | export class SignupCredentialsDto { 10 | @IsNotEmpty() 11 | @MinLength(1) 12 | @MaxLength(100) 13 | readonly name: string; 14 | 15 | @IsOptional() 16 | @MinLength(1) 17 | @MaxLength(1000) 18 | readonly bio?: string; 19 | 20 | @IsEmail() 21 | readonly email: string; 22 | 23 | @IsNotEmpty() 24 | @MinLength(8) 25 | @MaxLength(100) 26 | readonly password: string; 27 | } 28 | -------------------------------------------------------------------------------- /client/src/components/header/Header.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { Provider } from "react-redux"; 3 | import { MemoryRouter } from "react-router-dom"; 4 | 5 | import Header from "."; 6 | import { store } from "../../store"; 7 | 8 | it("Should render Header", () => { 9 | render( 10 | 11 | 12 |
13 | 14 | 15 | ); 16 | 17 | const header = screen.getByTestId("header"); 18 | 19 | expect(header).toBeInTheDocument(); 20 | }); 21 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | # Environment variables 38 | *.env -------------------------------------------------------------------------------- /client/src/validation/passwordValidation.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | import { userRules } from "./rules"; 4 | 5 | export interface PasswordFormValues 6 | extends yup.InferType {} 7 | 8 | export const passwordValidationSchema = yup.object({ 9 | oldPassword: yup.string().required(), 10 | newPassword: yup 11 | .string() 12 | .min(userRules.password.min) 13 | .max(userRules.password.max) 14 | .required(), 15 | confirmPassword: yup 16 | .string() 17 | .required() 18 | .oneOf([yup.ref("newPassword")], "Passwords are not equal"), 19 | }); 20 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client/src/pages/profile-page/ProfilePage.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | import PageTitle from "../../components/common/page-title"; 4 | import UpdateProfileForm from "../../components/forms/update-profile-form"; 5 | import { useAppSelector } from "../../hooks/redux"; 6 | import { getAuthState } from "../../store/selectors/authSelectors"; 7 | 8 | const ProfilePage: FC = () => { 9 | const { user } = useAppSelector(getAuthState); 10 | 11 | return ( 12 | <> 13 | ProfilePage 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default ProfilePage; 20 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { NestFactory } from '@nestjs/core'; 4 | 5 | import { AppModule } from './app.module'; 6 | import { setupSwagger } from './swagger'; 7 | 8 | async function bootstrap() { 9 | const app = await NestFactory.create(AppModule); 10 | app.useGlobalPipes(new ValidationPipe({ transform: true })); 11 | app.enableCors(); 12 | setupSwagger(app); 13 | const configService = app.get(ConfigService); 14 | const port = Number(configService.get('PORT')); 15 | await app.listen(port); 16 | } 17 | bootstrap(); 18 | -------------------------------------------------------------------------------- /client/src/components/common/loading-button/LoadingButton.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, FC } from "react"; 2 | import { Button, CircularProgress } from "@mui/material"; 3 | 4 | interface LoadingButtonProps extends ComponentProps { 5 | isLoading: boolean; 6 | } 7 | 8 | const LoadingButton: FC = ({ isLoading, children }) => { 9 | return ( 10 | 17 | ); 18 | }; 19 | 20 | export default LoadingButton; 21 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/src/message/message.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | ManyToOne, 6 | PrimaryGeneratedColumn, 7 | } from 'typeorm'; 8 | 9 | import { Room } from '../room/room.entity'; 10 | import { User } from '../user/user.entity'; 11 | 12 | @Entity() 13 | export class Message { 14 | @PrimaryGeneratedColumn() 15 | id: number; 16 | 17 | @Column() 18 | content: string; 19 | 20 | @ManyToOne(() => Room, (room) => room.messages) 21 | room: Room; 22 | 23 | @ManyToOne(() => User, (user) => user.messages) 24 | owner: User; 25 | 26 | @CreateDateColumn({ type: 'timestamp' }) 27 | createdAt: Date; 28 | } 29 | -------------------------------------------------------------------------------- /client/src/components/routing/auth-wrapper/AuthWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Navigate, Outlet, useLocation } from "react-router-dom"; 3 | 4 | import { Paths } from "../../../routes"; 5 | import { useAppSelector } from "../../../hooks/redux"; 6 | import { getAuthState } from "../../../store/selectors/authSelectors"; 7 | 8 | const AuthWrapper: FC = () => { 9 | const { isAuth } = useAppSelector(getAuthState); 10 | const location = useLocation(); 11 | 12 | if (isAuth) { 13 | return ; 14 | } 15 | 16 | return ; 17 | }; 18 | 19 | export default AuthWrapper; 20 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /client/src/components/auth-links/AuthLinks.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { MemoryRouter } from "react-router-dom"; 3 | 4 | import AuthLinks from "."; 5 | 6 | it("Should render AuthLinks", () => { 7 | const signupButtonText = "Create Account"; 8 | const signinButtonText = "Signin"; 9 | 10 | render( 11 | 12 | 13 | 14 | ); 15 | 16 | const signupButton = screen.getByText(signupButtonText); 17 | const signinButton = screen.getByText(signinButtonText); 18 | 19 | expect(signupButton).toBeInTheDocument(); 20 | expect(signinButton).toBeInTheDocument(); 21 | }); 22 | -------------------------------------------------------------------------------- /client/src/hooks/useAddRoomForm.ts: -------------------------------------------------------------------------------- 1 | import { yupResolver } from "@hookform/resolvers/yup"; 2 | import { useForm } from "react-hook-form"; 3 | 4 | import { useAddRoomMutation } from "../store/api/roomApi"; 5 | import { 6 | RoomFormValues, 7 | roomValidationSchema, 8 | } from "../validation/roomValidation"; 9 | 10 | export const useAddRoomForm = () => { 11 | const [addRoom, { isLoading }] = useAddRoomMutation(); 12 | const formMethods = useForm({ 13 | mode: "onBlur", 14 | resolver: yupResolver(roomValidationSchema), 15 | }); 16 | 17 | return { 18 | formMethods, 19 | onSubmit: formMethods.handleSubmit(addRoom), 20 | isLoading, 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /client/src/components/messages/messages-list/MessagesList.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Stack } from "@mui/material"; 3 | 4 | import { IMessage } from "../../../models/message"; 5 | import MessageItem from "../message-item"; 6 | 7 | import classes from "./MessagesList.module.scss"; 8 | 9 | interface MessagesListProps { 10 | messages: IMessage[]; 11 | } 12 | 13 | const MessagesList: FC = ({ messages }) => { 14 | return ( 15 | 16 | {messages.map((message) => ( 17 | 18 | ))} 19 | 20 | ); 21 | }; 22 | 23 | export default MessagesList; 24 | -------------------------------------------------------------------------------- /server/src/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt'; 4 | 5 | import { JwtPayload } from './interfaces/jwt-payload.interface'; 6 | 7 | @Injectable() 8 | export class JwtStrategy extends PassportStrategy(Strategy) { 9 | constructor() { 10 | super({ 11 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 12 | ignoreExpiration: false, 13 | secretOrKey: process.env.JWT_SECRET, 14 | } as StrategyOptions); 15 | } 16 | 17 | async validate(payload: JwtPayload) { 18 | return payload.userId; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/src/components/common/form/Form.tsx: -------------------------------------------------------------------------------- 1 | import { FieldValues, FormProvider, UseFormReturn } from "react-hook-form"; 2 | import { Box, BoxProps } from "@mui/material"; 3 | 4 | interface FormProps extends BoxProps { 5 | formMethods: UseFormReturn; 6 | onSubmit: BoxProps["onSubmit"]; 7 | } 8 | 9 | const Form = ({ 10 | formMethods, 11 | onSubmit, 12 | children, 13 | ...props 14 | }: FormProps) => { 15 | return ( 16 | 17 | 18 | {children} 19 | 20 | 21 | ); 22 | }; 23 | 24 | export default Form; 25 | -------------------------------------------------------------------------------- /client/src/config/index.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | baseUrl: process.env.REACT_APP_API_BASE_URL, 3 | socketUrl: process.env.REACT_APP_API_SOCKET_URL, 4 | signupUrl: process.env.REACT_APP_API_SIGNUP_URL, 5 | signinUrl: process.env.REACT_APP_API_SIGNIN_URL, 6 | checkAuthUrl: process.env.REACT_APP_API_CHECK_AUTH_URL, 7 | usersUrl: process.env.REACT_APP_API_USERS_URL, 8 | searchUsersUrl: process.env.REACT_APP_API_SEARCH_USERS_URL, 9 | passwordUpdateUrl: process.env.REACT_APP_API_PASSWORD_UPDATE_URL, 10 | messagesUrl: process.env.REACT_APP_API_MESSAGES_URL, 11 | roomsUrl: process.env.REACT_APP_API_ROOMS_URL, 12 | searchRoomsUrl: process.env.REACT_APP_API_SEARCH_ROOMS_URL, 13 | }; 14 | -------------------------------------------------------------------------------- /client/src/components/common/form-field/FormField.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useFormContext } from "react-hook-form"; 3 | import { TextField, TextFieldProps } from "@mui/material"; 4 | 5 | type FormFieldProps = { name: string } & TextFieldProps; 6 | 7 | const FormField: FC = ({ name, label, ...textFieldProps }) => { 8 | const { 9 | register, 10 | formState: { errors }, 11 | } = useFormContext(); 12 | 13 | return ( 14 | 21 | ); 22 | }; 23 | 24 | export default FormField; 25 | -------------------------------------------------------------------------------- /client/src/components/routing/app-router/AppRouter.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Route, Routes } from "react-router-dom"; 3 | 4 | import { authRoutes, publicRoutes } from "../../../routes"; 5 | import AuthWrapper from "../auth-wrapper"; 6 | 7 | const AppRouter: FC = () => { 8 | return ( 9 | 10 | }> 11 | {authRoutes.map(({ path, Component }) => ( 12 | } /> 13 | ))} 14 | 15 | {publicRoutes.map(({ path, Component }) => ( 16 | } /> 17 | ))} 18 | 19 | ); 20 | }; 21 | 22 | export default AppRouter; 23 | -------------------------------------------------------------------------------- /client/src/components/rooms/room-item/RoomItem.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Button, Card, Typography } from "@mui/material"; 3 | import { Link } from "react-router-dom"; 4 | 5 | import { IRoom } from "../../../models/room"; 6 | import { Paths } from "../../../routes"; 7 | 8 | interface RoomItemProps { 9 | room: IRoom; 10 | } 11 | 12 | const RoomItem: FC = ({ room }) => { 13 | return ( 14 | 15 | {room.title} 16 | {room.description} 17 | 20 | 21 | ); 22 | }; 23 | 24 | export default RoomItem; 25 | -------------------------------------------------------------------------------- /client/src/store/api/baseApi.ts: -------------------------------------------------------------------------------- 1 | import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; 2 | 3 | import { RootState } from ".."; 4 | import { config } from "../../config"; 5 | 6 | export enum HttpMethod { 7 | GET = "GET", 8 | POST = "POST", 9 | PUT = "PUT", 10 | PATCH = "PATCH", 11 | DELETE = "DELETE", 12 | } 13 | 14 | export const baseApi = createApi({ 15 | baseQuery: fetchBaseQuery({ 16 | baseUrl: config.baseUrl, 17 | prepareHeaders: (headers, { getState }) => { 18 | const token = (getState() as RootState).auth.token; 19 | if (token) { 20 | headers.set("Authorization", `Bearer ${token}`); 21 | } 22 | return headers; 23 | }, 24 | }), 25 | endpoints: () => ({}), 26 | }); 27 | -------------------------------------------------------------------------------- /client/src/components/rooms/room-header/RoomHeader.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Stack, Typography } from "@mui/material"; 3 | 4 | import RoomControls from "../room-controls"; 5 | import RoomMembers from "../room-members"; 6 | import { IRoom } from "../../../models/room"; 7 | 8 | import classes from "./RoomHeader.module.scss"; 9 | 10 | interface RoomHeaderProps { 11 | room: IRoom; 12 | } 13 | 14 | const RoomHeader: FC = ({ room }) => { 15 | return ( 16 | 17 | {room.title} 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | export default RoomHeader; 25 | -------------------------------------------------------------------------------- /client/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers, configureStore } from "@reduxjs/toolkit"; 2 | import { baseApi } from "./api/baseApi"; 3 | import { authSlice } from "./slices/authSlice"; 4 | 5 | const rootReducer = combineReducers({ 6 | auth: authSlice.reducer, 7 | [baseApi.reducerPath]: baseApi.reducer, 8 | }); 9 | 10 | export const setupStore = () => 11 | configureStore({ 12 | reducer: rootReducer, 13 | middleware: (getDefaultMiddleware) => 14 | getDefaultMiddleware().concat(baseApi.middleware), 15 | }); 16 | 17 | export const store = setupStore(); 18 | 19 | export type RootState = ReturnType; 20 | export type AppStore = ReturnType; 21 | export type AppDispatch = AppStore["dispatch"]; 22 | -------------------------------------------------------------------------------- /server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /client/src/components/forms/add-room-form/AddRoomForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | import { useAddRoomForm } from "../../../hooks/useAddRoomForm"; 4 | import FormField from "../../common/form-field"; 5 | import LoadingButton from "../../common/loading-button"; 6 | import Form from "../../common/form"; 7 | 8 | const AddRoomForm: FC = () => { 9 | const { formMethods, onSubmit, isLoading } = useAddRoomForm(); 10 | 11 | return ( 12 |
13 | 14 | 15 | Save 16 | 17 | ); 18 | }; 19 | 20 | export default AddRoomForm; 21 | -------------------------------------------------------------------------------- /client/src/components/rooms/room-members/RoomMembers.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | 3 | import RoomMembers from "."; 4 | import { IUser } from "../../../models/user"; 5 | 6 | it("Should render RoomMembers", () => { 7 | const fakeUsers: IUser[] = [ 8 | { 9 | id: 1, 10 | avatarUrl: "", 11 | email: "test@test.com", 12 | name: "user 1", 13 | bio: "bio", 14 | }, 15 | { 16 | id: 1, 17 | avatarUrl: "", 18 | email: "test@test.com", 19 | name: "user 2", 20 | bio: "bio", 21 | }, 22 | ]; 23 | render(); 24 | 25 | const element = screen.getByText("RoomMembers"); 26 | 27 | expect(element).toBeInTheDocument(); 28 | }); 29 | -------------------------------------------------------------------------------- /client/src/components/user-menu/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { Button, Stack } from "@mui/material"; 4 | 5 | import { Paths } from "../../routes"; 6 | import { useActions } from "../../hooks/useActions"; 7 | import { authActions } from "../../store/slices/authSlice"; 8 | 9 | import classes from "./UserMenu.module.scss"; 10 | 11 | const UserMenu: FC = () => { 12 | const { signout } = useActions(authActions); 13 | 14 | return ( 15 | 16 | My Profile 17 | My Rooms 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default UserMenu; 24 | -------------------------------------------------------------------------------- /client/src/validation/signupValidation.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | import { userRules } from "./rules"; 4 | 5 | export interface SignupFormValues 6 | extends yup.InferType {} 7 | 8 | export const signupValidationSchema = yup.object({ 9 | name: yup.string().min(userRules.name.min).max(userRules.name.max).required(), 10 | bio: yup.string().min(userRules.bio.min).max(userRules.bio.max).optional(), 11 | email: yup.string().email().required(), 12 | password: yup 13 | .string() 14 | .min(userRules.password.min) 15 | .max(userRules.password.max) 16 | .required(), 17 | confirmPassword: yup 18 | .string() 19 | .required() 20 | .oneOf([yup.ref("password")], "Passwords are not equal"), 21 | }); 22 | -------------------------------------------------------------------------------- /client/src/components/forms/signin-form/SigninForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | import FormField from "../../common/form-field"; 4 | import LoadingButton from "../../common/loading-button"; 5 | import Form from "../../common/form"; 6 | import { useSigninForm } from "../../../hooks/useSigninForm"; 7 | 8 | const SigninForm: FC = () => { 9 | const { formMethods, onSubmit, isLoading } = useSigninForm(); 10 | 11 | return ( 12 |
13 | 14 | 15 | Submit 16 | 17 | ); 18 | }; 19 | 20 | export default SigninForm; 21 | -------------------------------------------------------------------------------- /client/src/components/auth-links/AuthLinks.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { Button, Stack } from "@mui/material"; 4 | 5 | import { Paths } from "../../routes"; 6 | 7 | import classes from "./AuthLinks.module.scss"; 8 | 9 | const AuthLinks: FC = () => { 10 | return ( 11 | 12 | 20 | 23 | 24 | ); 25 | }; 26 | 27 | export default AuthLinks; 28 | -------------------------------------------------------------------------------- /client/src/components/messages/message-item/MessageItem.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | 3 | import MessageItem from "."; 4 | import { IMessage } from "../../../models/message"; 5 | import { IUser } from "../../../models/user"; 6 | 7 | it("Should render MessageItem", () => { 8 | const fakeUser: IUser = { 9 | id: 1, 10 | avatarUrl: "", 11 | email: "test@test.com", 12 | name: "user", 13 | bio: "bio", 14 | }; 15 | const fakeMessage: IMessage = { 16 | id: 1, 17 | content: "message", 18 | owner: fakeUser, 19 | roomId: 1, 20 | }; 21 | 22 | render(); 23 | 24 | const element = screen.getByText(fakeMessage.content); 25 | 26 | expect(element).toBeInTheDocument(); 27 | }); 28 | -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir : __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { StyledEngineProvider } from "@mui/material"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom/client"; 4 | import { Provider } from "react-redux"; 5 | import { BrowserRouter } from "react-router-dom"; 6 | 7 | import App from "./App"; 8 | import { store } from "./store"; 9 | import { validateConfig } from "./validation/configValidation"; 10 | 11 | validateConfig(); 12 | 13 | const root = ReactDOM.createRoot( 14 | document.getElementById("root") as HTMLElement 15 | ); 16 | root.render( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | -------------------------------------------------------------------------------- /client/src/components/navbar/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Tab, Tabs } from "@mui/material"; 3 | import { NavLink, useLocation, useMatch } from "react-router-dom"; 4 | 5 | import { Paths } from "../../routes"; 6 | 7 | const Navbar: FC = () => { 8 | const { pathname } = useLocation(); 9 | const isRoomMatch = useMatch(Paths.ROOM); 10 | 11 | return ( 12 | 13 | 19 | 25 | 26 | ); 27 | }; 28 | 29 | export default Navbar; 30 | -------------------------------------------------------------------------------- /client/src/components/user-menu/UserMenu.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { MemoryRouter } from "react-router-dom"; 3 | 4 | import UserMenu from "."; 5 | 6 | it("Should render UserMenu", () => { 7 | const profileButtonText = "My Profile"; 8 | const roomsButtonText = "My Rooms"; 9 | const signoutButtonText = "Signout"; 10 | 11 | render( 12 | 13 | 14 | 15 | ); 16 | 17 | const profileButton = screen.getByText(profileButtonText); 18 | const roomsButton = screen.getByText(roomsButtonText); 19 | const signoutButton = screen.getByText(signoutButtonText); 20 | 21 | expect(profileButton).toBeInTheDocument(); 22 | expect(roomsButton).toBeInTheDocument(); 23 | expect(signoutButton).toBeInTheDocument(); 24 | }); 25 | -------------------------------------------------------------------------------- /client/src/components/common/form-field/FormField.test.tsx: -------------------------------------------------------------------------------- 1 | import { FC, PropsWithChildren } from "react"; 2 | import { FormProvider, useForm } from "react-hook-form"; 3 | import { render, screen } from "@testing-library/react"; 4 | 5 | import FormField from "."; 6 | 7 | it("Should render FormField", () => { 8 | const fieldName = "field"; 9 | const fieldLabel = "Test Field"; 10 | 11 | const Wrapper: FC> = ({ children }) => { 12 | const methods = useForm(); 13 | 14 | return {children}; 15 | }; 16 | 17 | render( 18 | 19 | 20 | 21 | ); 22 | 23 | const element = screen.getByText(fieldLabel, { ignore: "span" }); 24 | 25 | expect(element).toBeInTheDocument(); 26 | }); 27 | -------------------------------------------------------------------------------- /server/src/message/message.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; 2 | 3 | import { JwtAuthGuard } from '../auth/jwt-auth.guard'; 4 | import { CreateMessageDto } from './dto/create-message.dto'; 5 | import { GetMessagesDto } from './dto/get-messages.dto'; 6 | import { MessageService } from './message.service'; 7 | 8 | @Controller('messages') 9 | @UseGuards(JwtAuthGuard) 10 | export class MessageController { 11 | constructor(private readonly messageService: MessageService) {} 12 | 13 | @Get() 14 | getMessages(@Query() getMessagesDto: GetMessagesDto) { 15 | return this.messageService.getMessages(getMessagesDto); 16 | } 17 | 18 | @Post() 19 | createMessages(@Body() createMessageDto: CreateMessageDto) { 20 | return this.messageService.createMessage(createMessageDto); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule, JwtModuleOptions } from '@nestjs/jwt'; 3 | import { PassportModule } from '@nestjs/passport'; 4 | import { UserModule } from '../user/user.module'; 5 | 6 | import { AuthController } from './auth.controller'; 7 | import { AuthService } from './auth.service'; 8 | import { JwtStrategy } from './jwt.strategy'; 9 | 10 | @Module({ 11 | imports: [ 12 | UserModule, 13 | PassportModule, 14 | JwtModule.registerAsync({ 15 | useFactory: (): JwtModuleOptions => ({ 16 | secret: process.env.JWT_SECRET, 17 | signOptions: { 18 | expiresIn: process.env.JWT_EXPIRES_IN, 19 | }, 20 | }), 21 | }), 22 | ], 23 | controllers: [AuthController], 24 | providers: [AuthService, JwtStrategy], 25 | }) 26 | export class AuthModule {} 27 | -------------------------------------------------------------------------------- /client/src/components/messages/message-item/MessageItem.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Avatar, Box, Card, Stack, Typography } from "@mui/material"; 3 | 4 | import { IMessage } from "../../../models/message"; 5 | 6 | import classes from "./MessageItem.module.scss"; 7 | 8 | interface MessageItemProps { 9 | message: IMessage; 10 | } 11 | 12 | const MessageItem: FC = ({ message }) => { 13 | return ( 14 | 15 | 16 | 17 | {message.owner.name} 18 | 19 | 20 | {message.content} 21 | 22 | 23 | ); 24 | }; 25 | 26 | export default MessageItem; 27 | -------------------------------------------------------------------------------- /server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | 5 | import typeormConfig from './config/typeorm.config'; 6 | import { validate } from './validation/env.validation'; 7 | import { AuthModule } from './auth/auth.module'; 8 | import { UserModule } from './user/user.module'; 9 | import { RoomModule } from './room/room.module'; 10 | import { MessageModule } from './message/message.module'; 11 | import { ChatModule } from './chat/chat.module'; 12 | 13 | @Module({ 14 | imports: [ 15 | ConfigModule.forRoot({ isGlobal: true, validate }), 16 | TypeOrmModule.forRootAsync({ useFactory: typeormConfig }), 17 | AuthModule, 18 | UserModule, 19 | RoomModule, 20 | MessageModule, 21 | ChatModule, 22 | ], 23 | }) 24 | export class AppModule {} 25 | -------------------------------------------------------------------------------- /client/src/components/forms/add-message-form/AddMessageForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Button } from "@mui/material"; 3 | 4 | import { useAddMessageForm } from "../../../hooks/useAddMessageForm"; 5 | import FormField from "../../common/form-field"; 6 | import classes from "./AddMessageForm.module.scss"; 7 | import Form from "../../common/form"; 8 | 9 | interface AddMessageFormProps { 10 | roomId: number; 11 | } 12 | 13 | const AddMessageForm: FC = ({ roomId }) => { 14 | const { formMethods, onSubmit } = useAddMessageForm(roomId); 15 | 16 | return ( 17 |
22 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default AddMessageForm; 29 | -------------------------------------------------------------------------------- /client/src/components/rooms/room-item/RoomItem.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { MemoryRouter } from "react-router-dom"; 3 | 4 | import RoomItem from "."; 5 | import { IRoom } from "../../../models/room"; 6 | import { IUser } from "../../../models/user"; 7 | 8 | it("Should render RoomItem", () => { 9 | const fakeUser: IUser = { 10 | id: 1, 11 | avatarUrl: "", 12 | email: "test@test.com", 13 | name: "user", 14 | bio: "bio", 15 | }; 16 | const fakeRoom: IRoom = { 17 | id: 1, 18 | description: "room description", 19 | members: [], 20 | owner: fakeUser, 21 | title: "room title", 22 | }; 23 | render( 24 | 25 | 26 | 27 | ); 28 | 29 | const element = screen.getByText(fakeRoom.title); 30 | 31 | expect(element).toBeInTheDocument(); 32 | }); 33 | -------------------------------------------------------------------------------- /client/src/pages/room-page/RoomPage.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Box } from "@mui/material"; 3 | 4 | import { useRoom } from "../../hooks/useRoom"; 5 | import AddMessageForm from "../../components/forms/add-message-form"; 6 | import MessagesList from "../../components/messages/messages-list"; 7 | import RoomHeader from "../../components/rooms/room-header"; 8 | import MessageTypingNotification from "../../components/messages/message-typing-notification"; 9 | 10 | const RoomPage: FC = () => { 11 | const { room, messages, notifications, pageRef, id } = useRoom(); 12 | 13 | return ( 14 | 15 | {room && } 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default RoomPage; 24 | -------------------------------------------------------------------------------- /client/src/components/rooms/room-header/RoomHeader.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { MemoryRouter } from "react-router-dom"; 3 | 4 | import RoomHeader from "."; 5 | import { IRoom } from "../../../models/room"; 6 | import { IUser } from "../../../models/user"; 7 | 8 | it("Should render RoomHeader", () => { 9 | const fakeUser: IUser = { 10 | id: 1, 11 | avatarUrl: "", 12 | email: "test@test.com", 13 | name: "user", 14 | bio: "bio", 15 | }; 16 | const fakeRoom: IRoom = { 17 | id: 1, 18 | description: "room description", 19 | members: [], 20 | owner: fakeUser, 21 | title: "room title", 22 | }; 23 | render( 24 | 25 | 26 | 27 | ); 28 | 29 | const element = screen.getByText(fakeRoom.title); 30 | 31 | expect(element).toBeInTheDocument(); 32 | }); 33 | -------------------------------------------------------------------------------- /client/src/components/forms/update-room-form/UpdateRoomForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | import { IRoom } from "../../../models/room"; 4 | import { useUpdateRoomForm } from "../../../hooks/useUpdateRoomForm"; 5 | import FormField from "../../common/form-field"; 6 | import LoadingButton from "../../common/loading-button"; 7 | import Form from "../../common/form"; 8 | 9 | interface UpdateRoomFormProps { 10 | room: IRoom; 11 | } 12 | 13 | const UpdateRoomForm: FC = ({ room }) => { 14 | const { formMethods, onSubmit, isLoading } = useUpdateRoomForm(room); 15 | 16 | return ( 17 |
18 | 19 | 20 | Update 21 | 22 | ); 23 | }; 24 | 25 | export default UpdateRoomForm; 26 | -------------------------------------------------------------------------------- /client/src/hooks/useAddMessageForm.ts: -------------------------------------------------------------------------------- 1 | import { yupResolver } from "@hookform/resolvers/yup"; 2 | import { useCallback } from "react"; 3 | import { useForm } from "react-hook-form"; 4 | 5 | import { useAddMessageMutation } from "../store/api/messageApi"; 6 | import { 7 | MessageFormValues, 8 | messageValidationSchema, 9 | } from "../validation/messageValidation"; 10 | 11 | export const useAddMessageForm = (roomId: number) => { 12 | const [addMessage] = useAddMessageMutation(); 13 | const formMethods = useForm({ 14 | mode: "onBlur", 15 | resolver: yupResolver(messageValidationSchema), 16 | }); 17 | 18 | const addMessageHandler = useCallback( 19 | ({ content }: MessageFormValues) => { 20 | addMessage({ content, roomId }); 21 | }, 22 | [addMessage, roomId] 23 | ); 24 | 25 | return { 26 | formMethods, 27 | onSubmit: formMethods.handleSubmit(addMessageHandler), 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /client/src/hooks/useUpdateProfileForm.ts: -------------------------------------------------------------------------------- 1 | import { yupResolver } from "@hookform/resolvers/yup"; 2 | import { useForm } from "react-hook-form"; 3 | 4 | import { IUser } from "../models/user"; 5 | import { useUpdateUserMutation } from "../store/api/userApi"; 6 | import { 7 | UserFormValues, 8 | userValidationSchema, 9 | } from "../validation/userValidation"; 10 | 11 | export const useUpdateProfileForm = (user: IUser) => { 12 | const [updateUser, { isLoading }] = useUpdateUserMutation(); 13 | const formMethods = useForm({ 14 | mode: "onBlur", 15 | resolver: yupResolver(userValidationSchema), 16 | }); 17 | 18 | const updateProfileHandler = async (values: UserFormValues) => { 19 | await updateUser({ ...user, ...values }); 20 | formMethods.reset(); 21 | }; 22 | 23 | return { 24 | formMethods, 25 | onSubmit: formMethods.handleSubmit(updateProfileHandler), 26 | isLoading, 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /server/src/message/message.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { CreateMessageDto } from './dto/create-message.dto'; 6 | import { GetMessagesDto } from './dto/get-messages.dto'; 7 | import { Message } from './message.entity'; 8 | 9 | @Injectable() 10 | export class MessageService { 11 | constructor( 12 | @InjectRepository(Message) 13 | private readonly messageRepository: Repository, 14 | ) {} 15 | 16 | getMessages(getMessagesDto: GetMessagesDto) { 17 | return this.messageRepository.findBy({ 18 | room: { id: getMessagesDto.roomId }, 19 | }); 20 | } 21 | 22 | async createMessage(createMessageDto: CreateMessageDto) { 23 | const message = this.messageRepository.create(createMessageDto); 24 | await this.messageRepository.save(message); 25 | return message; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/src/room/room.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | JoinTable, 6 | ManyToMany, 7 | ManyToOne, 8 | OneToMany, 9 | PrimaryGeneratedColumn, 10 | UpdateDateColumn, 11 | } from 'typeorm'; 12 | 13 | import { Message } from '../message/message.entity'; 14 | import { User } from '../user/user.entity'; 15 | 16 | @Entity() 17 | export class Room { 18 | @PrimaryGeneratedColumn() 19 | id: number; 20 | 21 | @Column() 22 | title: string; 23 | 24 | @Column() 25 | description: string; 26 | 27 | @OneToMany(() => Message, (message) => message.room) 28 | messages: Message[]; 29 | 30 | @ManyToOne(() => User, (user) => user.rooms) 31 | owner: User; 32 | 33 | @ManyToMany(() => User, (user) => user.joinedRooms) 34 | @JoinTable() 35 | members: User[]; 36 | 37 | @CreateDateColumn({ type: 'timestamp' }) 38 | createdAt: Date; 39 | 40 | @UpdateDateColumn({ type: 'timestamp' }) 41 | updatedAt: Date; 42 | } 43 | -------------------------------------------------------------------------------- /client/src/hooks/useRoom.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { useParams } from "react-router-dom"; 3 | 4 | import { useGetRoomQuery } from "../store/api/roomApi"; 5 | import { 6 | useGetMessagesQuery, 7 | useGetTypingNotificationsQuery, 8 | } from "../store/api/messageApi"; 9 | 10 | export const useRoom = () => { 11 | const { id: stringId } = useParams<"id">(); 12 | const id = Number(stringId); 13 | const { data: room } = useGetRoomQuery(id); 14 | const { data: messages } = useGetMessagesQuery(id); 15 | const { data: notifications } = useGetTypingNotificationsQuery(); 16 | 17 | const pageRef = useRef(null); 18 | 19 | const scrollToBottom = () => { 20 | pageRef.current?.scrollIntoView({ block: "end" }); 21 | }; 22 | 23 | useEffect(() => { 24 | scrollToBottom(); 25 | }, [messages]); 26 | 27 | return { 28 | room, 29 | messages, 30 | notifications, 31 | pageRef, 32 | id, 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /client/src/validation/configValidation.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | export interface EnvironmentVariables 4 | extends yup.InferType {} 5 | 6 | export const configValidationSchema = yup.object({ 7 | REACT_APP_API_BASE_URL: yup.string().required(), 8 | REACT_APP_API_SOCKET_URL: yup.string().required(), 9 | REACT_APP_API_SIGNUP_URL: yup.string().required(), 10 | REACT_APP_API_SIGNIN_URL: yup.string().required(), 11 | REACT_APP_API_CHECK_AUTH_URL: yup.string().required(), 12 | REACT_APP_API_USERS_URL: yup.string().required(), 13 | REACT_APP_API_SEARCH_USERS_URL: yup.string().required(), 14 | REACT_APP_API_PASSWORD_UPDATE_URL: yup.string().required(), 15 | REACT_APP_API_MESSAGES_URL: yup.string().required(), 16 | REACT_APP_API_ROOMS_URL: yup.string().required(), 17 | REACT_APP_API_SEARCH_ROOMS_URL: yup.string().required(), 18 | }); 19 | 20 | export const validateConfig = () => 21 | configValidationSchema.validateSync(process.env); 22 | -------------------------------------------------------------------------------- /client/src/hooks/useUpdateRoomForm.ts: -------------------------------------------------------------------------------- 1 | import { yupResolver } from "@hookform/resolvers/yup"; 2 | import { useCallback } from "react"; 3 | import { useForm } from "react-hook-form"; 4 | 5 | import { IRoom } from "../models/room"; 6 | import { useUpdateRoomMutation } from "../store/api/roomApi"; 7 | import { 8 | RoomFormValues, 9 | roomValidationSchema, 10 | } from "../validation/roomValidation"; 11 | 12 | export const useUpdateRoomForm = (room: IRoom) => { 13 | const [updateRoom, { isLoading }] = useUpdateRoomMutation(); 14 | const formMethods = useForm({ 15 | mode: "onBlur", 16 | resolver: yupResolver(roomValidationSchema), 17 | }); 18 | 19 | const updateRoomHandler = useCallback( 20 | (values: RoomFormValues) => { 21 | updateRoom({ ...room, ...values }); 22 | }, 23 | [updateRoom, room] 24 | ); 25 | 26 | return { 27 | formMethods, 28 | onSubmit: formMethods.handleSubmit(updateRoomHandler), 29 | isLoading, 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /client/src/components/forms/update-profile-form/UpdateProfileForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | import { IUser } from "../../../models/user"; 4 | import { useUpdateProfileForm } from "../../../hooks/useUpdateProfileForm"; 5 | import FormField from "../../common/form-field"; 6 | import LoadingButton from "../../common/loading-button"; 7 | import Form from "../../common/form"; 8 | 9 | interface UpdateProfileFormProps { 10 | user: IUser; 11 | } 12 | 13 | const UpdateProfileForm: FC = ({ user }) => { 14 | const { formMethods, onSubmit, isLoading } = useUpdateProfileForm(user); 15 | 16 | return ( 17 |
18 | 19 | 20 | 21 | Submit 22 | 23 | ); 24 | }; 25 | 26 | export default UpdateProfileForm; 27 | -------------------------------------------------------------------------------- /client/src/components/forms/signup-form/SignupForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | import FormField from "../../common/form-field"; 4 | import LoadingButton from "../../common/loading-button"; 5 | import Form from "../../common/form"; 6 | import { useSignupForm } from "../../../hooks/useSignupForm"; 7 | 8 | const SignupForm: FC = () => { 9 | const { formMethods, onSubmit, isLoading } = useSignupForm(); 10 | 11 | return ( 12 |
13 | 14 | 15 | 16 | 17 | 22 | Submit 23 | 24 | ); 25 | }; 26 | 27 | export default SignupForm; 28 | -------------------------------------------------------------------------------- /server/src/user/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | ManyToMany, 6 | OneToMany, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 10 | 11 | import { Message } from '../message/message.entity'; 12 | import { Room } from '../room/room.entity'; 13 | 14 | @Entity() 15 | export class User { 16 | @PrimaryGeneratedColumn() 17 | id: number; 18 | 19 | @Column() 20 | name: string; 21 | 22 | @Column({ nullable: true }) 23 | bio: string; 24 | 25 | @Column({ unique: true }) 26 | email: string; 27 | 28 | @Column() 29 | password: string; 30 | 31 | @OneToMany(() => Message, (message) => message.owner) 32 | messages: Message[]; 33 | 34 | @OneToMany(() => Room, (room) => room.owner) 35 | rooms: Room[]; 36 | 37 | @ManyToMany(() => Room, (room) => room.members) 38 | joinedRooms: Room[]; 39 | 40 | @CreateDateColumn({ type: 'timestamp' }) 41 | createdAt: Date; 42 | 43 | @UpdateDateColumn({ type: 'timestamp' }) 44 | updatedAt: Date; 45 | } 46 | -------------------------------------------------------------------------------- /server/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post } from '@nestjs/common'; 2 | 3 | import { AuthResponse } from './interfaces/auth-response.interface'; 4 | import { AuthService } from './auth.service'; 5 | import { SigninCredentialsDto } from './dto/signin-credentials.dto'; 6 | import { SignupCredentialsDto } from './dto/signup-credentials.dto'; 7 | 8 | @Controller('auth') 9 | export class AuthController { 10 | constructor(private readonly authService: AuthService) {} 11 | 12 | @Post('/signup') 13 | signup( 14 | @Body() signupCredentialsDto: SignupCredentialsDto, 15 | ): Promise { 16 | return this.authService.signup(signupCredentialsDto); 17 | } 18 | 19 | @Post('/signin') 20 | signin( 21 | @Body() signinCredentialsDto: SigninCredentialsDto, 22 | ): Promise { 23 | return this.authService.signin(signinCredentialsDto); 24 | } 25 | 26 | @Post('/check') 27 | check(@Body() body: { token: string }) { 28 | return this.authService.check(body); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/src/hooks/useSigninForm.ts: -------------------------------------------------------------------------------- 1 | import { yupResolver } from "@hookform/resolvers/yup"; 2 | import { useEffect } from "react"; 3 | import { useForm } from "react-hook-form"; 4 | import { useNavigate } from "react-router-dom"; 5 | 6 | import { useSigninMutation } from "../store/api/authApi"; 7 | import { 8 | SigninFormValues, 9 | signinValidationSchema, 10 | } from "../validation/signinValidation"; 11 | import { Paths } from "../routes"; 12 | 13 | export const useSigninForm = () => { 14 | const [signin, { isLoading, isSuccess }] = useSigninMutation(); 15 | const formMethods = useForm({ 16 | mode: "onBlur", 17 | resolver: yupResolver(signinValidationSchema), 18 | }); 19 | const navigate = useNavigate(); 20 | 21 | useEffect(() => { 22 | if (isSuccess) { 23 | formMethods.reset(); 24 | navigate(Paths.ROOMS); 25 | } 26 | }, [isSuccess, navigate, formMethods]); 27 | 28 | return { 29 | formMethods, 30 | onSubmit: formMethods.handleSubmit(signin), 31 | isLoading, 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /client/src/hooks/useSignupForm.ts: -------------------------------------------------------------------------------- 1 | import { yupResolver } from "@hookform/resolvers/yup"; 2 | import { useEffect } from "react"; 3 | import { useForm } from "react-hook-form"; 4 | import { useNavigate } from "react-router-dom"; 5 | 6 | import { useSignupMutation } from "../store/api/authApi"; 7 | import { 8 | SignupFormValues, 9 | signupValidationSchema, 10 | } from "../validation/signupValidation"; 11 | import { Paths } from "../routes"; 12 | 13 | export const useSignupForm = () => { 14 | const [signup, { isLoading, isSuccess }] = useSignupMutation(); 15 | const formMethods = useForm({ 16 | mode: "onBlur", 17 | resolver: yupResolver(signupValidationSchema), 18 | }); 19 | const navigate = useNavigate(); 20 | 21 | useEffect(() => { 22 | if (isSuccess) { 23 | formMethods.reset(); 24 | navigate(Paths.ROOMS); 25 | } 26 | }, [isSuccess, navigate, formMethods]); 27 | 28 | return { 29 | formMethods, 30 | onSubmit: formMethods.handleSubmit(signup), 31 | isLoading, 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /server/src/validation/env.validation.ts: -------------------------------------------------------------------------------- 1 | import { plainToInstance } from 'class-transformer'; 2 | import { IsIn, IsNotEmpty, IsNumber, validateSync } from 'class-validator'; 3 | 4 | export class EnvironmentVariables { 5 | @IsNumber() 6 | @IsNotEmpty() 7 | PORT: number; 8 | 9 | @IsNotEmpty() 10 | DB_HOST: string; 11 | 12 | @IsNumber() 13 | @IsNotEmpty() 14 | DB_PORT: number; 15 | 16 | @IsNotEmpty() 17 | DB_NAME: string; 18 | 19 | @IsNotEmpty() 20 | DB_USERNAME: string; 21 | 22 | @IsNotEmpty() 23 | DB_PASSWORD: string; 24 | 25 | @IsIn(['true', 'false']) 26 | DB_SYNC: 'true' | 'false'; 27 | 28 | @IsNotEmpty() 29 | JWT_SECRET: string; 30 | 31 | @IsNotEmpty() 32 | JWT_EXPIRES_IN: string; 33 | } 34 | 35 | export const validate = (config: Record) => { 36 | const validatedConfig = plainToInstance(EnvironmentVariables, config, { 37 | enableImplicitConversion: true, 38 | }); 39 | const errors = validateSync(validatedConfig, { 40 | skipMissingProperties: false, 41 | }); 42 | if (errors.length > 0) { 43 | throw new Error(errors.toString()); 44 | } 45 | return validatedConfig; 46 | }; 47 | -------------------------------------------------------------------------------- /client/src/pages/update-room-page/UpdateRoomPage.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect } from "react"; 2 | import { Box } from "@mui/material"; 3 | import { useNavigate, useParams } from "react-router-dom"; 4 | 5 | import PageTitle from "../../components/common/page-title"; 6 | import UpdateRoomForm from "../../components/forms/update-room-form"; 7 | import { useGetRoomQuery } from "../../store/api/roomApi"; 8 | import { useAppSelector } from "../../hooks/redux"; 9 | import { getAuthState } from "../../store/selectors/authSelectors"; 10 | import { Paths } from "../../routes"; 11 | 12 | const UpdateRoomPage: FC = () => { 13 | const { id: stringId } = useParams<"id">(); 14 | const id = Number(stringId); 15 | const { data: room } = useGetRoomQuery(id); 16 | const { user } = useAppSelector(getAuthState); 17 | const navigate = useNavigate(); 18 | 19 | useEffect(() => { 20 | if (user?.id !== room?.owner.id) { 21 | navigate(Paths.HOME); 22 | } 23 | }, [user, room, navigate]); 24 | 25 | return ( 26 | 27 | Update room 28 | {room && } 29 | 30 | ); 31 | }; 32 | 33 | export default UpdateRoomPage; 34 | -------------------------------------------------------------------------------- /client/src/components/header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | import { AppBar, Stack } from "@mui/material"; 3 | 4 | import { useAppSelector } from "../../hooks/redux"; 5 | import { getAuthState } from "../../store/selectors/authSelectors"; 6 | import AuthLinks from "../auth-links"; 7 | import Navbar from "../navbar"; 8 | import AppTitle from "../app-title"; 9 | import UserMenu from "../user-menu"; 10 | import UserMenuButton from "../user-menu-button"; 11 | 12 | import classes from "./Header.module.scss"; 13 | 14 | const Header: FC = () => { 15 | const [menuIsOpen, setMenuIsOpen] = useState(false); 16 | const { isAuth, user } = useAppSelector(getAuthState); 17 | 18 | const toggleUserMenu = () => setMenuIsOpen((prev) => !prev); 19 | 20 | return ( 21 | 22 | 23 | 24 | {isAuth && } 25 | {isAuth ? ( 26 | 27 | ) : ( 28 | 29 | )} 30 | 31 | {menuIsOpen && } 32 | 33 | ); 34 | }; 35 | 36 | export default Header; 37 | -------------------------------------------------------------------------------- /client/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType } from "react"; 2 | 3 | import HomePage from "../pages/home-page"; 4 | import ProfilePage from "../pages/profile-page"; 5 | import RoomPage from "../pages/room-page"; 6 | import RoomsPage from "../pages/rooms-page"; 7 | import AddRoomPage from "../pages/add-room-page"; 8 | import UpdateRoomPage from "../pages/update-room-page"; 9 | import SigninPage from "../pages/signin-page"; 10 | import SignupPage from "../pages/signup-page"; 11 | 12 | export enum Paths { 13 | HOME = "/", 14 | SIGNUP = "/signup", 15 | SIGNIN = "/signin", 16 | PROFILE = "/profile", 17 | ROOMS = "/rooms", 18 | ROOM = "/rooms/:id", 19 | ADD_ROOM = "/rooms/add", 20 | UPDATE_ROOM = "/rooms/:id/update", 21 | } 22 | 23 | export interface IRoute { 24 | path: Paths; 25 | Component: ComponentType; 26 | } 27 | 28 | export const publicRoutes: IRoute[] = [ 29 | { path: Paths.HOME, Component: HomePage }, 30 | { path: Paths.SIGNUP, Component: SignupPage }, 31 | { path: Paths.SIGNIN, Component: SigninPage }, 32 | ]; 33 | 34 | export const authRoutes: IRoute[] = [ 35 | { path: Paths.PROFILE, Component: ProfilePage }, 36 | { path: Paths.ROOMS, Component: RoomsPage }, 37 | { path: Paths.ROOM, Component: RoomPage }, 38 | { path: Paths.ADD_ROOM, Component: AddRoomPage }, 39 | { path: Paths.UPDATE_ROOM, Component: UpdateRoomPage }, 40 | ]; 41 | -------------------------------------------------------------------------------- /client/src/services/socketService.ts: -------------------------------------------------------------------------------- 1 | import { io, Socket } from "socket.io-client"; 2 | import { config } from "../config"; 3 | import { ClientToServerEvents, ServerToClientEvents } from "../models/event"; 4 | 5 | import { AddMessageDto } from "../models/message"; 6 | 7 | class SocketService { 8 | private readonly socket: Socket = 9 | io(config.socketUrl, { 10 | autoConnect: false, 11 | }); 12 | 13 | connectWithAuthToken(token: string) { 14 | this.socket.auth = { token }; 15 | this.socket.connect(); 16 | } 17 | 18 | disconnect() { 19 | this.socket.disconnect(); 20 | } 21 | 22 | sendMessage(data: AddMessageDto) { 23 | this.socket.emit("message", data); 24 | } 25 | 26 | notifyTyping(roomId: number) { 27 | this.socket.emit("isTyping", roomId); 28 | } 29 | 30 | subscribeToMessages(messageHandler: ServerToClientEvents["message"]) { 31 | this.socket.on("message", messageHandler); 32 | } 33 | 34 | subscribeToTypingNotifications( 35 | typingNotificationsHandler: ServerToClientEvents["isTyping"] 36 | ) { 37 | this.socket.on("isTyping", typingNotificationsHandler); 38 | } 39 | 40 | joinRoom(roomId: number) { 41 | this.socket.emit("join", roomId); 42 | } 43 | 44 | leaveRoom(roomId: number) { 45 | this.socket.emit("leave", roomId); 46 | } 47 | } 48 | 49 | export const socketService = new SocketService(); 50 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-chat", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.10.5", 7 | "@emotion/styled": "^11.10.5", 8 | "@hookform/resolvers": "^2.9.10", 9 | "@mui/material": "^5.10.12", 10 | "@reduxjs/toolkit": "^1.8.6", 11 | "@testing-library/jest-dom": "^5.16.5", 12 | "@testing-library/react": "^13.4.0", 13 | "@testing-library/user-event": "^13.5.0", 14 | "@types/jest": "^27.5.2", 15 | "@types/node": "^16.11.68", 16 | "@types/react": "^18.0.25", 17 | "@types/react-dom": "^18.0.8", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0", 20 | "react-hook-form": "^7.38.0", 21 | "react-redux": "^8.0.4", 22 | "react-router-dom": "^6.4.2", 23 | "react-scripts": "5.0.1", 24 | "sass": "^1.56.1", 25 | "socket.io-client": "^4.5.3", 26 | "typescript": "^4.8.4", 27 | "web-vitals": "^2.1.4", 28 | "yup": "^0.32.11" 29 | }, 30 | "scripts": { 31 | "start": "react-scripts start", 32 | "build": "react-scripts build", 33 | "test": "react-scripts test", 34 | "eject": "react-scripts eject" 35 | }, 36 | "eslintConfig": { 37 | "extends": [ 38 | "react-app", 39 | "react-app/jest" 40 | ] 41 | }, 42 | "browserslist": { 43 | "production": [ 44 | ">0.2%", 45 | "not dead", 46 | "not op_mini all" 47 | ], 48 | "development": [ 49 | "last 1 chrome version", 50 | "last 1 firefox version", 51 | "last 1 safari version" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /client/src/store/api/roomApi.ts: -------------------------------------------------------------------------------- 1 | import { baseApi, HttpMethod } from "./baseApi"; 2 | import { 3 | AddRoomDto, 4 | IRoom, 5 | SearchRoomsDto, 6 | UpdateRoomDto, 7 | } from "../../models/room"; 8 | import { config } from "../../config"; 9 | 10 | export const roomApi = baseApi.injectEndpoints({ 11 | endpoints: (builder) => ({ 12 | getRooms: builder.query({ 13 | query: () => ({ url: config.roomsUrl }), 14 | }), 15 | getRoom: builder.query({ 16 | query: (id) => ({ url: `${config.roomsUrl}/${id}` }), 17 | }), 18 | searchRooms: builder.query({ 19 | query: (params) => ({ 20 | url: config.searchRoomsUrl, 21 | method: HttpMethod.GET, 22 | params, 23 | }), 24 | }), 25 | addRoom: builder.mutation({ 26 | query: (body) => ({ 27 | url: config.roomsUrl, 28 | method: HttpMethod.POST, 29 | body, 30 | }), 31 | }), 32 | updateRoom: builder.mutation({ 33 | query: (body) => ({ 34 | url: `${config.roomsUrl}/${body.id}`, 35 | method: HttpMethod.PUT, 36 | body, 37 | }), 38 | }), 39 | deleteRoom: builder.mutation({ 40 | query: (id) => ({ 41 | url: `${config.roomsUrl}/${id}`, 42 | method: HttpMethod.DELETE, 43 | }), 44 | }), 45 | }), 46 | }); 47 | 48 | export const { 49 | useGetRoomsQuery, 50 | useGetRoomQuery, 51 | useAddRoomMutation, 52 | useUpdateRoomMutation, 53 | } = roomApi; 54 | -------------------------------------------------------------------------------- /client/src/store/slices/authSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | import { IUser } from "../../models/user"; 3 | 4 | import { socketService } from "../../services/socketService"; 5 | import { authApi } from "../api/authApi"; 6 | import { userApi } from "../api/userApi"; 7 | 8 | export interface AuthState { 9 | isAuth: boolean; 10 | token: string | null; 11 | user: IUser | null; 12 | } 13 | 14 | const initialState: AuthState = { 15 | isAuth: false, 16 | token: null, 17 | user: null, 18 | }; 19 | 20 | export const authSlice = createSlice({ 21 | name: "auth", 22 | initialState, 23 | reducers: { 24 | signout(state) { 25 | state.isAuth = false; 26 | state.token = null; 27 | socketService.disconnect(); 28 | }, 29 | }, 30 | extraReducers: (builder) => { 31 | builder.addMatcher( 32 | authApi.endpoints.signup.matchFulfilled, 33 | (state, { payload }) => { 34 | state.isAuth = true; 35 | state.token = payload.token; 36 | state.user = payload.user; 37 | } 38 | ); 39 | builder.addMatcher( 40 | authApi.endpoints.signin.matchFulfilled, 41 | (state, { payload }) => { 42 | state.isAuth = true; 43 | state.token = payload.token; 44 | state.user = payload.user; 45 | } 46 | ); 47 | builder.addMatcher( 48 | userApi.endpoints.updateUser.matchFulfilled, 49 | (state, { payload }) => { 50 | state.user = payload; 51 | } 52 | ); 53 | }, 54 | }); 55 | 56 | export const authActions = authSlice.actions; 57 | -------------------------------------------------------------------------------- /client/src/components/forms/update-password-form/UpdatePasswordForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { FormProvider, useForm } from "react-hook-form"; 3 | import { Box } from "@mui/material"; 4 | import { yupResolver } from "@hookform/resolvers/yup"; 5 | 6 | import { 7 | PasswordFormValues, 8 | passwordValidationSchema, 9 | } from "../../../validation/passwordValidation"; 10 | import { useUpdatePasswordMutation } from "../../../store/api/userApi"; 11 | import FormField from "../../common/form-field"; 12 | import LoadingButton from "../../common/loading-button"; 13 | 14 | const UpdatePasswordForm: FC = () => { 15 | const [updatePassword, { isLoading }] = useUpdatePasswordMutation(); 16 | const methods = useForm({ 17 | mode: "onBlur", 18 | resolver: yupResolver(passwordValidationSchema), 19 | }); 20 | 21 | const updatePasswordHandler = (values: PasswordFormValues) => { 22 | updatePassword(values); 23 | }; 24 | 25 | return ( 26 | 27 | 31 | 32 | 33 | 38 | Submit 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default UpdatePasswordForm; 45 | -------------------------------------------------------------------------------- /client/src/store/api/userApi.ts: -------------------------------------------------------------------------------- 1 | import { baseApi, HttpMethod } from "./baseApi"; 2 | import { 3 | IUser, 4 | SearchUsersDto, 5 | UpdatePasswordDto, 6 | UpdateUserDto, 7 | } from "../../models/user"; 8 | import { config } from "../../config"; 9 | 10 | export const userApi = baseApi.injectEndpoints({ 11 | endpoints: (builder) => ({ 12 | getUsers: builder.query({ 13 | query: (roomId) => ({ url: config.usersUrl, params: { roomId } }), 14 | }), 15 | getUser: builder.query({ 16 | query: (id) => ({ url: `${config.usersUrl}/${id}` }), 17 | }), 18 | searchUsers: builder.query({ 19 | query: (params) => ({ 20 | url: config.searchUsersUrl, 21 | method: HttpMethod.GET, 22 | params, 23 | }), 24 | }), 25 | updateUser: builder.mutation({ 26 | query: (body) => ({ 27 | url: config.usersUrl, 28 | method: HttpMethod.PUT, 29 | body, 30 | }), 31 | }), 32 | updatePassword: builder.mutation({ 33 | query: (body) => ({ 34 | url: config.passwordUpdateUrl, 35 | method: HttpMethod.PUT, 36 | body, 37 | }), 38 | }), 39 | deleteUser: builder.mutation({ 40 | query: (id) => ({ 41 | url: `${config.usersUrl}/${id}`, 42 | method: HttpMethod.DELETE, 43 | }), 44 | }), 45 | }), 46 | }); 47 | 48 | export const { 49 | useGetUsersQuery, 50 | useGetUserQuery, 51 | useUpdateUserMutation, 52 | useUpdatePasswordMutation, 53 | useDeleteUserMutation, 54 | } = userApi; 55 | -------------------------------------------------------------------------------- /server/src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | ParseIntPipe, 8 | Put, 9 | Query, 10 | UseGuards, 11 | } from '@nestjs/common'; 12 | 13 | import { JwtAuthGuard } from '../auth/jwt-auth.guard'; 14 | import { AuthUser } from '../common/decorators/auth-user.decorator'; 15 | import { SearchUsersDto } from './dto/search-users.dto'; 16 | import { UpdatePasswordDto } from './dto/update-password.dto'; 17 | import { UpdateUserDto } from './dto/update-user.dto'; 18 | import { UserService } from './user.service'; 19 | 20 | @Controller('users') 21 | @UseGuards(JwtAuthGuard) 22 | export class UserController { 23 | constructor(private readonly userService: UserService) {} 24 | 25 | @Get() 26 | getUsers() { 27 | return this.userService.getUsers(); 28 | } 29 | 30 | @Get(':id') 31 | getUser(@Param('id', ParseIntPipe) id: number) { 32 | return this.userService.getUser(id); 33 | } 34 | 35 | @Get('search') 36 | searchUsers(@Query() searchUsersDto: SearchUsersDto) { 37 | return this.userService.searchUsers(searchUsersDto); 38 | } 39 | 40 | @Put() 41 | updateUser(@AuthUser() userId: number, @Body() updateUserDto: UpdateUserDto) { 42 | return this.userService.updateUser(userId, updateUserDto); 43 | } 44 | 45 | @Put('/password') 46 | updatePassword( 47 | @AuthUser() id: number, 48 | @Body() updatePasswordDto: UpdatePasswordDto, 49 | ) { 50 | return this.userService.updatePassword(id, updatePasswordDto); 51 | } 52 | 53 | @Delete() 54 | deleteUser(@AuthUser() userId: number) { 55 | return this.userService.deleteUser(userId); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /client/src/store/api/authApi.ts: -------------------------------------------------------------------------------- 1 | import { baseApi, HttpMethod } from "./baseApi"; 2 | import { SigninFormValues } from "../../validation/signinValidation"; 3 | import { SignupFormValues } from "../../validation/signupValidation"; 4 | import { socketService } from "../../services/socketService"; 5 | import { AuthResponse } from "../../models/auth"; 6 | import { config } from "../../config"; 7 | 8 | export const authApi = baseApi.injectEndpoints({ 9 | endpoints: (builder) => ({ 10 | signup: builder.mutation({ 11 | query: (body) => ({ 12 | url: config.signupUrl, 13 | method: HttpMethod.POST, 14 | body, 15 | }), 16 | async onQueryStarted(arg, { queryFulfilled }) { 17 | const token = (await queryFulfilled).data.token; 18 | socketService.connectWithAuthToken(token); 19 | }, 20 | }), 21 | signin: builder.mutation({ 22 | query: (body) => ({ 23 | url: config.signinUrl, 24 | method: HttpMethod.POST, 25 | body, 26 | }), 27 | async onQueryStarted(arg, { queryFulfilled }) { 28 | const token = (await queryFulfilled).data.token; 29 | socketService.connectWithAuthToken(token); 30 | }, 31 | }), 32 | check: builder.mutation({ 33 | query: (token) => ({ 34 | url: config.checkAuthUrl, 35 | method: HttpMethod.POST, 36 | body: { token }, 37 | }), 38 | async onQueryStarted(arg, { queryFulfilled }) { 39 | const token = (await queryFulfilled).data.token; 40 | socketService.connectWithAuthToken(token); 41 | }, 42 | }), 43 | }), 44 | }); 45 | 46 | export const { useSignupMutation, useSigninMutation } = authApi; 47 | -------------------------------------------------------------------------------- /server/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | 4 | import { SigninCredentialsDto } from './dto/signin-credentials.dto'; 5 | import { SignupCredentialsDto } from './dto/signup-credentials.dto'; 6 | import { UserService } from '../user/user.service'; 7 | import { JwtPayload } from './interfaces/jwt-payload.interface'; 8 | import { Hash } from '../common/utils/hash.util'; 9 | 10 | @Injectable() 11 | export class AuthService { 12 | constructor( 13 | private readonly jwtService: JwtService, 14 | private readonly userService: UserService, 15 | ) {} 16 | 17 | async signup(signupCredentialsDto: SignupCredentialsDto) { 18 | const user = await this.userService.createUser(signupCredentialsDto); 19 | const token = this.generateToken(user.id); 20 | return { user, token }; 21 | } 22 | 23 | async signin(signinCredentialsDto: SigninCredentialsDto) { 24 | const user = await this.userService.getUserByEmail( 25 | signinCredentialsDto.email, 26 | ); 27 | if (!user) { 28 | throw new UnauthorizedException('Invalid email or password'); 29 | } 30 | const isPasswordValid = Hash.compare( 31 | signinCredentialsDto.password, 32 | user.password, 33 | ); 34 | if (!isPasswordValid) { 35 | throw new UnauthorizedException('Invalid email or password'); 36 | } 37 | const token = this.generateToken(user.id); 38 | return { user, token }; 39 | } 40 | 41 | check({ token }: { token: string }) { 42 | return token; 43 | } 44 | 45 | generateToken(userId: number) { 46 | const payload: JwtPayload = { userId }; 47 | return this.jwtService.sign(payload); 48 | } 49 | 50 | verifyToken(token: string) { 51 | return this.jwtService.verify(token); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /server/src/room/room.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | ParseIntPipe, 8 | Post, 9 | Put, 10 | Query, 11 | UseGuards, 12 | } from '@nestjs/common'; 13 | 14 | import { AuthUser } from '../common/decorators/auth-user.decorator'; 15 | import { JwtAuthGuard } from '../auth/jwt-auth.guard'; 16 | import { CreateRoomDto } from './dto/create-room.dto'; 17 | import { UpdateRoomDto } from './dto/update-room.dto'; 18 | import { RoomService } from './room.service'; 19 | import { GetRoomsDto } from './dto/get-rooms.dto'; 20 | import { SearchRoomsDto } from './dto/search-rooms.dto'; 21 | 22 | @Controller('rooms') 23 | @UseGuards(JwtAuthGuard) 24 | export class RoomController { 25 | constructor(private readonly roomService: RoomService) {} 26 | 27 | @Get() 28 | getRooms(getRoomsDto: GetRoomsDto) { 29 | return this.roomService.getRooms(getRoomsDto); 30 | } 31 | 32 | @Get(':id') 33 | getRoom(@Param('id', ParseIntPipe) id: number) { 34 | return this.roomService.getRoom(id); 35 | } 36 | 37 | @Get('search') 38 | searchRooms(@Query() searchRoomsDto: SearchRoomsDto) { 39 | return this.roomService.searchRooms(searchRoomsDto); 40 | } 41 | 42 | @Post() 43 | createRoom(@AuthUser() userId: number, @Body() createRoomDto: CreateRoomDto) { 44 | return this.roomService.createRoom(createRoomDto, userId); 45 | } 46 | 47 | @Put(':id') 48 | updateRoom( 49 | @AuthUser() userId: number, 50 | @Param('id', ParseIntPipe) id: number, 51 | @Body() updateRoomDto: UpdateRoomDto, 52 | ) { 53 | return this.roomService.updateRoom(id, updateRoomDto, userId); 54 | } 55 | 56 | @Delete(':id') 57 | deleteRoom( 58 | @AuthUser() userId: number, 59 | @Param('id', ParseIntPipe) id: number, 60 | ) { 61 | return this.roomService.deleteRoom(id, userId); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /client/src/store/api/messageApi.ts: -------------------------------------------------------------------------------- 1 | import { baseApi } from "./baseApi"; 2 | import { AddMessageDto, IMessage } from "../../models/message"; 3 | import { socketService } from "../../services/socketService"; 4 | import { config } from "../../config"; 5 | 6 | export const messageApi = baseApi.injectEndpoints({ 7 | endpoints: (builder) => ({ 8 | getMessages: builder.query({ 9 | query: (roomId) => ({ url: config.messagesUrl, params: { roomId } }), 10 | async onCacheEntryAdded( 11 | arg, 12 | { updateCachedData, cacheDataLoaded, cacheEntryRemoved } 13 | ) { 14 | try { 15 | await cacheDataLoaded; 16 | socketService.joinRoom(arg); 17 | socketService.subscribeToMessages((data: IMessage) => { 18 | updateCachedData((draft) => { 19 | draft.push(data); 20 | }); 21 | }); 22 | } catch (error) { 23 | console.log(error); 24 | } 25 | await cacheEntryRemoved; 26 | }, 27 | }), 28 | addMessage: builder.mutation({ 29 | queryFn: (data) => { 30 | socketService.sendMessage(data); 31 | return { data: null }; 32 | }, 33 | }), 34 | notifyTyping: builder.mutation({ 35 | queryFn: (roomId) => { 36 | socketService.notifyTyping(roomId); 37 | return { data: null }; 38 | }, 39 | }), 40 | getTypingNotifications: builder.query({ 41 | queryFn: () => ({ data: [] }), 42 | async onQueryStarted(arg, { queryFulfilled, updateCachedData }) { 43 | await queryFulfilled; 44 | socketService.subscribeToTypingNotifications((notification) => { 45 | updateCachedData((draft) => { 46 | draft.push(notification); 47 | }); 48 | }); 49 | }, 50 | }), 51 | }), 52 | }); 53 | 54 | export const { 55 | useGetMessagesQuery, 56 | useAddMessageMutation, 57 | useGetTypingNotificationsQuery, 58 | useNotifyTypingMutation, 59 | } = messageApi; 60 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /server/src/chat/chat.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OnGatewayConnection, 3 | OnGatewayDisconnect, 4 | SubscribeMessage, 5 | WebSocketGateway, 6 | } from '@nestjs/websockets'; 7 | import { Socket } from 'socket.io'; 8 | 9 | import { AuthService } from '../auth/auth.service'; 10 | import { MessageService } from '../message/message.service'; 11 | import { CreateMessageDto } from '../message/dto/create-message.dto'; 12 | 13 | @WebSocketGateway({ cors: { origin: '*' } }) 14 | export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { 15 | constructor( 16 | private readonly authService: AuthService, 17 | private readonly messageService: MessageService, 18 | ) {} 19 | 20 | handleConnection(client: Socket) { 21 | const token = client.handshake.auth.token; 22 | const payload = this.authService.verifyToken(token); 23 | 24 | if (!payload) { 25 | client.disconnect(true); 26 | } else { 27 | console.log(`Client ${client.id} connected. Auth token: ${token}`); 28 | } 29 | } 30 | 31 | @SubscribeMessage('join') 32 | handleJoin(client: Socket, roomId: number) { 33 | console.log(`Client ${client.id} joined room: ${roomId}`); 34 | client.join(roomId.toString()); 35 | return roomId; 36 | } 37 | 38 | @SubscribeMessage('leave') 39 | handleLeave(client: Socket, roomId: number) { 40 | console.log(`Client ${client.id} leaved room: ${roomId}`); 41 | client.leave(roomId.toString()); 42 | return roomId; 43 | } 44 | 45 | @SubscribeMessage('message') 46 | async handleMessage(client: Socket, createMessageDto: CreateMessageDto) { 47 | console.log( 48 | `Client ${client.id} sended message: ${createMessageDto.content} to room: ${createMessageDto.roomId}`, 49 | ); 50 | const message = await this.messageService.createMessage(createMessageDto); 51 | client.emit('message', message); 52 | client.to(message.room.toString()).emit('message', message); 53 | } 54 | 55 | @SubscribeMessage('isTyping') 56 | async handleTypingNotification(client: Socket, roomId: CreateMessageDto) { 57 | console.log(`Client ${client.id} typing message to room: ${roomId}`); 58 | client 59 | .to(roomId.toString()) 60 | .emit('isTyping', `${client.id} typing message...`); 61 | } 62 | 63 | handleDisconnect(client: Socket) { 64 | console.log(`Client ${client.id} disconnected`); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /server/src/room/room.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { CreateRoomDto } from './dto/create-room.dto'; 6 | import { GetRoomsDto } from './dto/get-rooms.dto'; 7 | import { SearchRoomsDto } from './dto/search-rooms.dto'; 8 | import { UpdateRoomDto } from './dto/update-room.dto'; 9 | import { Room } from './room.entity'; 10 | 11 | @Injectable() 12 | export class RoomService { 13 | constructor( 14 | @InjectRepository(Room) 15 | private readonly roomRepository: Repository, 16 | ) {} 17 | 18 | getRooms(getRoomsDto: GetRoomsDto) { 19 | return this.roomRepository.find({ 20 | skip: getRoomsDto.skip, 21 | take: getRoomsDto.take, 22 | order: { createdAt: 'DESC' }, 23 | }); 24 | } 25 | 26 | getRoom(id: number) { 27 | return this.roomRepository.findOneBy({ id }); 28 | } 29 | 30 | async searchRooms(searchRoomsDto: SearchRoomsDto) { 31 | const qb = this.roomRepository.createQueryBuilder('rooms'); 32 | if (searchRoomsDto.skip) { 33 | qb.skip(searchRoomsDto.skip); 34 | } 35 | if (searchRoomsDto.take) { 36 | qb.take(searchRoomsDto.take); 37 | } 38 | if (searchRoomsDto.title) { 39 | qb.andWhere('rooms.title ILIKE :title', { 40 | title: `%${searchRoomsDto.title}%`, 41 | }); 42 | } 43 | if (searchRoomsDto.ownerId) { 44 | qb.andWhere('rooms.ownerId = :ownerId', { 45 | ownerId: searchRoomsDto.ownerId, 46 | }); 47 | } 48 | const [items, count] = await qb.getManyAndCount(); 49 | return { items, count }; 50 | } 51 | 52 | async createRoom(createRoomDto: CreateRoomDto, userId: number) { 53 | const room = this.roomRepository.create({ 54 | title: createRoomDto.title, 55 | description: createRoomDto.description, 56 | owner: { id: userId }, 57 | }); 58 | await this.roomRepository.save(room); 59 | } 60 | 61 | async updateRoom(id: number, updateRoomDto: UpdateRoomDto, userId: number) { 62 | const result = await this.roomRepository.update( 63 | { id, owner: { id: userId } }, 64 | updateRoomDto, 65 | ); 66 | if (result.affected === 0) { 67 | throw new NotFoundException(`Room with id ${id} not found`); 68 | } 69 | } 70 | 71 | async deleteRoom(id: number, userId: number) { 72 | const result = await this.roomRepository.delete({ 73 | id, 74 | owner: { id: userId }, 75 | }); 76 | if (result.affected === 0) { 77 | throw new NotFoundException(`Room with id ${id} not found`); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-chat-backend", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "prebuild": "rimraf dist", 7 | "build": "nest build", 8 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 9 | "start": "nest start", 10 | "start:dev": "nest start --watch", 11 | "start:debug": "nest start --debug --watch", 12 | "start:prod": "node dist/main", 13 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 14 | "test": "jest", 15 | "test:watch": "jest --watch", 16 | "test:cov": "jest --coverage", 17 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 18 | "test:e2e": "jest --config ./test/jest-e2e.json" 19 | }, 20 | "dependencies": { 21 | "@nestjs/common": "^9.0.0", 22 | "@nestjs/config": "^2.2.0", 23 | "@nestjs/core": "^9.0.0", 24 | "@nestjs/jwt": "^9.0.0", 25 | "@nestjs/mapped-types": "^1.2.0", 26 | "@nestjs/passport": "^9.0.0", 27 | "@nestjs/platform-express": "^9.0.0", 28 | "@nestjs/platform-socket.io": "^9.1.6", 29 | "@nestjs/swagger": "^6.1.4", 30 | "@nestjs/typeorm": "^9.0.1", 31 | "@nestjs/websockets": "^9.1.6", 32 | "bcryptjs": "^2.4.3", 33 | "class-transformer": "^0.5.1", 34 | "class-validator": "^0.13.2", 35 | "passport": "^0.6.0", 36 | "passport-jwt": "^4.0.0", 37 | "pg": "^8.8.0", 38 | "reflect-metadata": "^0.1.13", 39 | "rimraf": "^3.0.2", 40 | "rxjs": "^7.2.0", 41 | "socket.io": "^4.5.3", 42 | "typeorm": "^0.3.10" 43 | }, 44 | "devDependencies": { 45 | "@nestjs/cli": "^9.0.0", 46 | "@nestjs/schematics": "^9.0.0", 47 | "@nestjs/testing": "^9.0.0", 48 | "@types/bcryptjs": "^2.4.2", 49 | "@types/express": "^4.17.13", 50 | "@types/jest": "28.1.8", 51 | "@types/node": "^16.0.0", 52 | "@types/passport-jwt": "^3.0.7", 53 | "@types/supertest": "^2.0.11", 54 | "@typescript-eslint/eslint-plugin": "^5.0.0", 55 | "@typescript-eslint/parser": "^5.0.0", 56 | "eslint": "^8.0.1", 57 | "eslint-config-prettier": "^8.3.0", 58 | "eslint-plugin-prettier": "^4.0.0", 59 | "jest": "28.1.3", 60 | "prettier": "^2.3.2", 61 | "source-map-support": "^0.5.20", 62 | "supertest": "^6.1.3", 63 | "ts-jest": "28.0.8", 64 | "ts-loader": "^9.2.3", 65 | "ts-node": "^10.0.0", 66 | "tsconfig-paths": "4.1.0", 67 | "typescript": "^4.7.4" 68 | }, 69 | "jest": { 70 | "moduleFileExtensions": [ 71 | "js", 72 | "json", 73 | "ts" 74 | ], 75 | "rootDir": "src", 76 | "testRegex": ".*\\.spec\\.ts$", 77 | "transform": { 78 | "^.+\\.(t|j)s$": "ts-jest" 79 | }, 80 | "collectCoverageFrom": [ 81 | "**/*.(t|j)s" 82 | ], 83 | "coverageDirectory": "../coverage", 84 | "testEnvironment": "node" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /server/src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConflictException, 3 | Injectable, 4 | NotFoundException, 5 | UnauthorizedException, 6 | } from '@nestjs/common'; 7 | import { InjectRepository } from '@nestjs/typeorm'; 8 | import { Repository } from 'typeorm'; 9 | 10 | import { SignupCredentialsDto } from '../auth/dto/signup-credentials.dto'; 11 | import { SearchUsersDto } from './dto/search-users.dto'; 12 | import { UpdatePasswordDto } from './dto/update-password.dto'; 13 | import { UpdateUserDto } from './dto/update-user.dto'; 14 | import { User } from './user.entity'; 15 | import { Hash } from '../common/utils/hash.util'; 16 | 17 | @Injectable() 18 | export class UserService { 19 | constructor( 20 | @InjectRepository(User) 21 | private readonly userRepository: Repository, 22 | ) {} 23 | 24 | getUsers() { 25 | return this.userRepository.find(); 26 | } 27 | 28 | getUser(id: number) { 29 | return this.userRepository.findOneBy({ id }); 30 | } 31 | 32 | searchUsers(searchUsersDto: SearchUsersDto) { 33 | return this.userRepository.find({ 34 | where: { name: searchUsersDto.name }, 35 | skip: searchUsersDto.skip, 36 | take: searchUsersDto.take, 37 | }); 38 | } 39 | 40 | getUserByEmail(email: string) { 41 | return this.userRepository.findOneBy({ email }); 42 | } 43 | 44 | async createUser(signupCredentialsDto: SignupCredentialsDto) { 45 | const foundUser = await this.getUserByEmail(signupCredentialsDto.email); 46 | if (foundUser) { 47 | throw new ConflictException('Email already in use'); 48 | } 49 | const passwordHash = Hash.generateHash(signupCredentialsDto.password); 50 | const user = this.userRepository.create({ 51 | name: signupCredentialsDto.name, 52 | bio: signupCredentialsDto.bio, 53 | email: signupCredentialsDto.email, 54 | password: passwordHash, 55 | }); 56 | await this.userRepository.save(user); 57 | return user; 58 | } 59 | 60 | async updateUser(id: number, updateUserDto: UpdateUserDto) { 61 | const result = await this.userRepository.update(id, updateUserDto); 62 | if (result.affected === 0) { 63 | throw new NotFoundException(`User with id ${id} not found`); 64 | } 65 | } 66 | 67 | async updatePassword(id: number, updatePasswordDto: UpdatePasswordDto) { 68 | const { oldPassword, newPassword } = updatePasswordDto; 69 | const user = await this.userRepository.findOneBy({ id }); 70 | if (!user) { 71 | throw new NotFoundException(`User with id ${id} not found`); 72 | } 73 | const isPasswordValid = Hash.compare(oldPassword, user.password); 74 | if (!isPasswordValid) { 75 | throw new UnauthorizedException('Invalid credentials'); 76 | } 77 | const newPasswordHash = Hash.generateHash(newPassword); 78 | user.password = newPasswordHash; 79 | await this.userRepository.save(user); 80 | } 81 | 82 | async deleteUser(id: number) { 83 | const result = await this.userRepository.delete(id); 84 | if (result.affected === 0) { 85 | throw new NotFoundException(`User with id ${id} not found`); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | --------------------------------------------------------------------------------