├── .dockerignore ├── .env.template ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── .sequelizerc ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── entrypoint.sh ├── images ├── clean-architecture.png ├── conways-law.svg ├── database-schema.svg ├── demo.png ├── logo.png ├── subdomains.png ├── use-cases-messy.svg └── use-cases-refactored.svg ├── jest.config.js ├── nodemon.json ├── package-lock.json ├── package.json ├── public ├── admin │ └── README.md └── app │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── public │ ├── _redirects │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ ├── src │ ├── App.sass │ ├── App.test.tsx │ ├── App.tsx │ ├── assets │ │ ├── img │ │ │ └── logo │ │ │ │ └── brick.png │ │ └── styles │ │ │ └── variables.sass │ ├── config │ │ ├── api.tsx │ │ └── siteMetaData.tsx │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ ├── modules │ │ ├── forum │ │ │ ├── components │ │ │ │ ├── comments │ │ │ │ │ ├── components │ │ │ │ │ │ ├── Editor.tsx │ │ │ │ │ │ └── PostSubmission.tsx │ │ │ │ │ ├── index.js │ │ │ │ │ └── styles │ │ │ │ │ │ ├── Comments.sass │ │ │ │ │ │ ├── Editor.sass │ │ │ │ │ │ └── PostSubmission.sass │ │ │ │ └── posts │ │ │ │ │ ├── filters │ │ │ │ │ ├── components │ │ │ │ │ │ └── PostFilters.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles │ │ │ │ │ │ └── PostFilters.sass │ │ │ │ │ ├── points │ │ │ │ │ ├── assets │ │ │ │ │ │ └── arrow.svg │ │ │ │ │ ├── components │ │ │ │ │ │ ├── PointHover.tsx │ │ │ │ │ │ └── Points.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles │ │ │ │ │ │ ├── Point.sass │ │ │ │ │ │ └── PointHover.sass │ │ │ │ │ ├── post │ │ │ │ │ ├── components │ │ │ │ │ │ ├── PostComment.tsx │ │ │ │ │ │ ├── PostCommentAuthorAndText.tsx │ │ │ │ │ │ ├── PostMeta.tsx │ │ │ │ │ │ └── PostSummary.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── styles │ │ │ │ │ │ ├── PostComment.sass │ │ │ │ │ │ ├── PostMeta.sass │ │ │ │ │ │ └── PostSummary.sass │ │ │ │ │ └── postRow │ │ │ │ │ ├── components │ │ │ │ │ └── PostRow.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles │ │ │ │ │ └── PostRow.sass │ │ │ ├── dtos │ │ │ │ ├── commentDTO.ts │ │ │ │ ├── memberDTO.ts │ │ │ │ └── postDTO.ts │ │ │ ├── hocs │ │ │ │ └── withVoting.tsx │ │ │ ├── models │ │ │ │ ├── Comment.ts │ │ │ │ ├── Member.ts │ │ │ │ └── Post.ts │ │ │ ├── redux │ │ │ │ ├── actionCreators.tsx │ │ │ │ ├── actions.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── operators │ │ │ │ │ ├── createReplyToComment.tsx │ │ │ │ │ ├── createReplyToPost.tsx │ │ │ │ │ ├── downvoteComment.tsx │ │ │ │ │ ├── downvotePost.tsx │ │ │ │ │ ├── getCommentByCommentId.tsx │ │ │ │ │ ├── getCommentReplies.tsx │ │ │ │ │ ├── getComments.tsx │ │ │ │ │ ├── getPopularPosts.tsx │ │ │ │ │ ├── getPostBySlug.tsx │ │ │ │ │ ├── getRecentPosts.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── submitPost.tsx │ │ │ │ │ ├── upvoteComment.tsx │ │ │ │ │ └── upvotePost.tsx │ │ │ │ ├── reducers.tsx │ │ │ │ └── states.tsx │ │ │ ├── services │ │ │ │ ├── commentService.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── postService.tsx │ │ │ └── utils │ │ │ │ ├── CommentUtil.tsx │ │ │ │ └── PostUtil.ts │ │ └── users │ │ │ ├── components │ │ │ ├── onboarding │ │ │ │ └── onboardTemplate │ │ │ │ │ ├── components │ │ │ │ │ └── OnboardTemplate.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles │ │ │ │ │ └── OnboardTemplate.sass │ │ │ └── profileButton │ │ │ │ ├── components │ │ │ │ └── ProfileButton.tsx │ │ │ │ └── index.tsx │ │ │ ├── dtos │ │ │ ├── loginDTO.ts │ │ │ └── userDTO.ts │ │ │ ├── hocs │ │ │ ├── withLoginHandling.tsx │ │ │ ├── withLogoutHandling.tsx │ │ │ └── withUsersService.tsx │ │ │ ├── models │ │ │ ├── tokens.ts │ │ │ └── user.ts │ │ │ ├── redux │ │ │ ├── actionCreators.tsx │ │ │ ├── actions.tsx │ │ │ ├── index.tsx │ │ │ ├── operators │ │ │ │ ├── createUser.tsx │ │ │ │ ├── getUserProfile.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── login.tsx │ │ │ │ └── logout.tsx │ │ │ ├── reducers.tsx │ │ │ └── states.tsx │ │ │ └── services │ │ │ ├── authService.ts │ │ │ ├── index.ts │ │ │ └── userService.ts │ ├── pages │ │ ├── comment.tsx │ │ ├── discussion.tsx │ │ ├── index.tsx │ │ ├── join.tsx │ │ ├── login.tsx │ │ ├── member.tsx │ │ └── submit.tsx │ ├── react-app-env.d.ts │ ├── serviceWorker.ts │ └── shared │ │ ├── components │ │ ├── button │ │ │ ├── components │ │ │ │ ├── Button.tsx │ │ │ │ └── SubmitButton.tsx │ │ │ ├── index.tsx │ │ │ └── styles │ │ │ │ ├── Button.sass │ │ │ │ └── SubmitButton.sass │ │ ├── header │ │ │ ├── assets │ │ │ │ └── arrow.svg │ │ │ ├── components │ │ │ │ ├── BackNavigation.tsx │ │ │ │ ├── Header.tsx │ │ │ │ └── Logo.tsx │ │ │ ├── index.tsx │ │ │ └── styles │ │ │ │ ├── BackNavigation.sass │ │ │ │ ├── Header.sass │ │ │ │ └── Logo.sass │ │ ├── loader │ │ │ ├── components │ │ │ │ ├── FullPageLoader.tsx │ │ │ │ └── Loader.tsx │ │ │ ├── index.tsx │ │ │ └── styles │ │ │ │ └── FullPageLoader.sass │ │ └── text-input │ │ │ ├── components │ │ │ └── TextInput.tsx │ │ │ ├── index.tsx │ │ │ └── styles │ │ │ └── TextInput.sass │ │ ├── core │ │ ├── Either.ts │ │ └── Result.ts │ │ ├── infra │ │ ├── redux │ │ │ ├── configureStore.tsx │ │ │ └── startupScript.tsx │ │ ├── router │ │ │ ├── AuthenticatedRoute.tsx │ │ │ └── UnauthenticatedRoute.tsx │ │ └── services │ │ │ ├── APIErrorMessage.tsx │ │ │ ├── APIResponse.tsx │ │ │ └── BaseAPI.tsx │ │ ├── layout │ │ ├── Layout.sass │ │ ├── Layout.tsx │ │ └── index.tsx │ │ └── utils │ │ ├── DateUtil.tsx │ │ ├── ReduxUtils.ts │ │ └── TextUtil.tsx │ ├── tsconfig.json │ └── yarn.lock ├── scripts └── db │ ├── create.js │ └── delete.js ├── src ├── config │ ├── auth.ts │ └── index.ts ├── index.ts ├── modules │ ├── forum │ │ ├── domain │ │ │ ├── comment.ts │ │ │ ├── commentDetails.ts │ │ │ ├── commentId.ts │ │ │ ├── commentText.ts │ │ │ ├── commentVote.ts │ │ │ ├── commentVotes.ts │ │ │ ├── comments.ts │ │ │ ├── events │ │ │ │ ├── commentPosted.ts │ │ │ │ ├── commentVotesChanged.ts │ │ │ │ ├── memberCreated.ts │ │ │ │ ├── postCreated.ts │ │ │ │ └── postVotesChanged.ts │ │ │ ├── member.spec.ts │ │ │ ├── member.ts │ │ │ ├── memberDetails.ts │ │ │ ├── memberId.ts │ │ │ ├── post.ts │ │ │ ├── postDetails.ts │ │ │ ├── postId.ts │ │ │ ├── postLink.ts │ │ │ ├── postSlug.spec.ts │ │ │ ├── postSlug.ts │ │ │ ├── postText.ts │ │ │ ├── postTitle.ts │ │ │ ├── postType.ts │ │ │ ├── postVote.ts │ │ │ ├── postVotes.ts │ │ │ ├── services │ │ │ │ ├── index.ts │ │ │ │ ├── postService.spec.ts │ │ │ │ └── postService.ts │ │ │ └── vote.ts │ │ ├── dtos │ │ │ ├── commentDTO.ts │ │ │ ├── memberDTO.ts │ │ │ └── postDTO.ts │ │ ├── infra │ │ │ └── http │ │ │ │ └── routes │ │ │ │ ├── comment.ts │ │ │ │ ├── index.ts │ │ │ │ ├── member.ts │ │ │ │ └── post.ts │ │ ├── mappers │ │ │ ├── commentDetailsMap.ts │ │ │ ├── commentMap.ts │ │ │ ├── commentVoteMap.ts │ │ │ ├── memberDetailsMap.ts │ │ │ ├── memberIdMap.ts │ │ │ ├── memberMap.ts │ │ │ ├── postDetailsMap.ts │ │ │ ├── postMap.ts │ │ │ └── postVoteMap.ts │ │ ├── repos │ │ │ ├── commentRepo.ts │ │ │ ├── commentVotesRepo.ts │ │ │ ├── implementations │ │ │ │ ├── commentRepo.ts │ │ │ │ ├── sequelizeCommentVotesRepo.ts │ │ │ │ ├── sequelizeMemberRepo.ts │ │ │ │ ├── sequelizePostRepo.ts │ │ │ │ └── sequelizePostVotesRepo.ts │ │ │ ├── index.ts │ │ │ ├── memberRepo.ts │ │ │ ├── postRepo.ts │ │ │ └── postVotesRepo.ts │ │ ├── subscriptions │ │ │ ├── afterCommentPosted.ts │ │ │ ├── afterCommentVotesChanged.ts │ │ │ ├── afterPostVotesChanged.ts │ │ │ ├── afterUserCreated.ts │ │ │ └── index.ts │ │ └── useCases │ │ │ ├── comments │ │ │ ├── downvoteComment │ │ │ │ ├── DownvoteComment.ts │ │ │ │ ├── DownvoteCommentController.ts │ │ │ │ ├── DownvoteCommentDTO.ts │ │ │ │ ├── DownvoteCommentErrors.ts │ │ │ │ ├── DownvoteCommentResponse.ts │ │ │ │ └── index.ts │ │ │ ├── getCommentByCommentId │ │ │ │ ├── GetCommentByCommentId.ts │ │ │ │ ├── GetCommentByCommentIdController.ts │ │ │ │ ├── GetCommentByCommentIdErrors.ts │ │ │ │ ├── GetCommentByCommentIdRequestDTO.ts │ │ │ │ ├── GetCommentByCommentIdResponseDTO.ts │ │ │ │ └── index.ts │ │ │ ├── getCommentsByPostSlug │ │ │ │ ├── GetCommentsByPostSlug.ts │ │ │ │ ├── GetCommentsByPostSlugController.ts │ │ │ │ ├── GetCommentsByPostSlugErrors.ts │ │ │ │ ├── GetCommentsByPostSlugRequestDTO.ts │ │ │ │ ├── GetCommentsByPostSlugResponseDTO.ts │ │ │ │ └── index.ts │ │ │ ├── replyToComment │ │ │ │ ├── ReplyToComment.ts │ │ │ │ ├── ReplyToCommentController.ts │ │ │ │ ├── ReplyToCommentDTO.ts │ │ │ │ ├── ReplyToCommentErrors.ts │ │ │ │ └── index.ts │ │ │ ├── replyToPost │ │ │ │ ├── ReplyToPost.ts │ │ │ │ ├── ReplyToPostController.ts │ │ │ │ ├── ReplyToPostDTO.ts │ │ │ │ ├── ReplyToPostErrors.ts │ │ │ │ └── index.ts │ │ │ ├── updateCommentStats │ │ │ │ ├── UpdateCommentStats.ts │ │ │ │ ├── UpdateCommentStatsDTO.ts │ │ │ │ └── index.ts │ │ │ └── upvoteComment │ │ │ │ ├── UpvoteComment.ts │ │ │ │ ├── UpvoteCommentController.ts │ │ │ │ ├── UpvoteCommentDTO.ts │ │ │ │ ├── UpvoteCommentErrors.ts │ │ │ │ ├── UpvoteCommentResonse.ts │ │ │ │ └── index.ts │ │ │ ├── members │ │ │ ├── createMember │ │ │ │ ├── CreateMember.ts │ │ │ │ ├── CreateMemberDTO.ts │ │ │ │ ├── CreateMemberErrors.ts │ │ │ │ └── index.ts │ │ │ ├── getCurrentMember │ │ │ │ ├── GetCurrentMemberController.ts │ │ │ │ └── index.ts │ │ │ └── getMemberByUserName │ │ │ │ ├── GetMemberByUserName.ts │ │ │ │ ├── GetMemberByUserNameController.ts │ │ │ │ ├── GetMemberByUserNameDTO.ts │ │ │ │ ├── GetMemberByUserNameErrors.ts │ │ │ │ ├── GetMemberByUserNameResponseDTO.ts │ │ │ │ └── index.ts │ │ │ └── post │ │ │ ├── createPost │ │ │ ├── CreatePost.ts │ │ │ ├── CreatePostController.ts │ │ │ ├── CreatePostDTO.ts │ │ │ ├── CreatePostErrors.ts │ │ │ └── index.ts │ │ │ ├── downvotePost │ │ │ ├── DownvotePost.ts │ │ │ ├── DownvotePostController.ts │ │ │ ├── DownvotePostDTO.ts │ │ │ ├── DownvotePostErrors.ts │ │ │ ├── DownvotePostResponse.ts │ │ │ └── index.ts │ │ │ ├── editPost │ │ │ ├── EditPost.ts │ │ │ ├── EditPostController.ts │ │ │ ├── EditPostDTO.ts │ │ │ ├── EditPostErrors.ts │ │ │ ├── EditPostResponse.ts │ │ │ └── index.ts │ │ │ ├── getPopularPosts │ │ │ ├── GetPopularPosts.ts │ │ │ ├── GetPopularPostsController.ts │ │ │ ├── GetPopularPostsRequestDTO.ts │ │ │ ├── GetPopularPostsResponseDTO.ts │ │ │ └── index.ts │ │ │ ├── getPostBySlug │ │ │ ├── GetPostBySlug.ts │ │ │ ├── GetPostBySlugController.ts │ │ │ ├── GetPostBySlugDTO.ts │ │ │ ├── GetPostBySlugErrors.ts │ │ │ └── index.ts │ │ │ ├── getRecentPosts │ │ │ ├── GetRecentPosts.ts │ │ │ ├── GetRecentPostsController.ts │ │ │ ├── GetRecentPostsRequestDTO.ts │ │ │ ├── GetRecentPostsResponseDTO.ts │ │ │ └── index.ts │ │ │ ├── updatePostStats │ │ │ ├── UpdatePostStats.ts │ │ │ ├── UpdatePostStatsDTO.ts │ │ │ ├── UpdatePostStatsErrors.ts │ │ │ └── index.ts │ │ │ └── upvotePost │ │ │ ├── UpvotePost.ts │ │ │ ├── UpvotePostController.ts │ │ │ ├── UpvotePostDTO.ts │ │ │ ├── UpvotePostErrors.ts │ │ │ ├── UpvotePostResponse.ts │ │ │ └── index.ts │ └── users │ │ ├── domain │ │ ├── emailVerificationToken.ts │ │ ├── events │ │ │ ├── emailVerified.ts │ │ │ ├── userCreated.ts │ │ │ ├── userDeleted.ts │ │ │ └── userLoggedIn.ts │ │ ├── jwt.ts │ │ ├── user.ts │ │ ├── userEmail.spec.ts │ │ ├── userEmail.ts │ │ ├── userId.ts │ │ ├── userName.ts │ │ └── userPassword.ts │ │ ├── dtos │ │ └── userDTO.ts │ │ ├── infra │ │ └── http │ │ │ ├── models │ │ │ └── decodedRequest.ts │ │ │ └── routes │ │ │ └── index.ts │ │ ├── mappers │ │ └── userMap.ts │ │ ├── repos │ │ ├── implementations │ │ │ └── sequelizeUserRepo.ts │ │ ├── index.ts │ │ └── userRepo.ts │ │ ├── services │ │ ├── authService.ts │ │ ├── index.ts │ │ └── redis │ │ │ ├── abstractRedisClient.ts │ │ │ ├── redisAuthService.ts │ │ │ └── redisConnection.ts │ │ └── useCases │ │ ├── README.md │ │ ├── createUser │ │ ├── CreateUserController.ts │ │ ├── CreateUserDTO.ts │ │ ├── CreateUserErrors.ts │ │ ├── CreateUserResponse.ts │ │ ├── CreateUserUseCase.ts │ │ └── index.ts │ │ ├── deleteUser │ │ ├── DeleteUserController.ts │ │ ├── DeleteUserDTO.ts │ │ ├── DeleteUserErrors.ts │ │ ├── DeleteUserUseCase.ts │ │ └── index.ts │ │ ├── getCurrentUser │ │ ├── GetCurrentUserController.ts │ │ └── index.ts │ │ ├── getUserByUserName │ │ ├── GetUserByUserName.ts │ │ ├── GetUserByUserNameController.ts │ │ ├── GetUserByUserNameDTO.ts │ │ ├── GetUserByUserNameErrors.ts │ │ └── index.ts │ │ ├── login │ │ ├── LoginController.ts │ │ ├── LoginDTO.ts │ │ ├── LoginErrors.ts │ │ ├── LoginUseCase.ts │ │ └── index.ts │ │ ├── logout │ │ ├── LogoutController.ts │ │ ├── LogoutDTO.ts │ │ ├── LogoutErrors.ts │ │ ├── LogoutUseCase.ts │ │ └── index.ts │ │ └── refreshAccessToken │ │ ├── RefreshAccessToken.ts │ │ ├── RefreshAccessTokenController.ts │ │ ├── RefreshAccessTokenDTO.ts │ │ ├── RefreshAccessTokenErrors.ts │ │ └── index.ts └── shared │ ├── core │ ├── AppError.ts │ ├── Guard.ts │ ├── Result.ts │ ├── UseCase.ts │ ├── UseCaseError.ts │ └── WithChanges.ts │ ├── domain │ ├── AggregateRoot.ts │ ├── DomainService.ts │ ├── Entity.ts │ ├── Identifier.ts │ ├── UniqueEntityID.ts │ ├── ValueObject.ts │ ├── WatchedList.ts │ └── events │ │ ├── DomainEvents.ts │ │ ├── IDomainEvent.ts │ │ └── IHandle.ts │ ├── infra │ ├── Mapper.ts │ ├── database │ │ └── sequelize │ │ │ ├── config │ │ │ └── config.js │ │ │ ├── hooks │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── migrations │ │ │ └── 20191004134636-initial-migration.ts │ │ │ ├── models │ │ │ ├── BaseUser.ts │ │ │ ├── Comment.ts │ │ │ ├── CommentVote.ts │ │ │ ├── Member.ts │ │ │ ├── Post.ts │ │ │ ├── PostVote.ts │ │ │ └── index.ts │ │ │ └── runner.ts │ └── http │ │ ├── api │ │ └── v1.ts │ │ ├── app.ts │ │ ├── graphql │ │ ├── forum.ts │ │ ├── server.ts │ │ ├── test.js │ │ └── users.ts │ │ ├── index.ts │ │ ├── models │ │ └── BaseController.ts │ │ └── utils │ │ └── Middleware.ts │ └── utils │ ├── FlowUtils.ts │ └── TextUtils.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | .vscode -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | 2 | DDD_FORUM_IS_PRODUCTION= 3 | DDD_FORUM_APP_SECRET=defaultappsecret 4 | DDD_FORUM_REDIS_URL= 5 | DDD_FORUM_REDIS_PORT= 6 | DDD_FORUM_DB_USER=chun 7 | DDD_FORUM_DB_PASS=12345678 8 | DDD_FORUM_DB_HOST= 9 | DDD_FORUM_DB_DEV_DB_NAME=data_dev 10 | DDD_FORUM_DB_TEST_DB_NAME=data_test 11 | DDD_FORUM_DB_PROD_DB_NAME=data_prod 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaVersion": 12 15 | }, 16 | "plugins": [ 17 | "@typescript-eslint", 18 | "prettier" 19 | ], 20 | "rules": { 21 | "prettier/prettier": "error", 22 | "@typescript-eslint/no-explicit-any": "off" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .idea 3 | .DS_Store 4 | node_modules 5 | build 6 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | 2 | var path = require('path') 3 | 4 | module.exports = { 5 | 'config': path.resolve('build', 'shared', 'infra', 'database', 'sequelize', 'config', 'config.js'), 6 | 'migrations-path': path.resolve('build', 'shared', 'infra', 'database', 'sequelize', 'migrations'), 7 | 'models-path': path.resolve('build', 'shared', 'infra', 'database', 'sequelize', 'models'), 8 | 'seeders-path': path.resolve('build', 'shared', 'infra', 'database', 'sequelize', 'seeders'), 9 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12.18.4 2 | 3 | WORKDIR /usr/src/ddd 4 | 5 | RUN apt-get update && apt-get install -y netcat 6 | 7 | ENV path /usr/src/ddd/node_modules/.bin:$PATH 8 | 9 | COPY . /usr/src/ddd 10 | 11 | RUN npm i -g dotenv-cli 12 | RUN npm i 13 | 14 | RUN cd public/app && npm i 15 | 16 | RUN chmod +x entrypoint.sh 17 | 18 | CMD ["./entrypoint.sh"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dyarlen Iber 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | web: 5 | container_name: ddd_app 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | ports: 10 | - 3000:3000 11 | - 5000:5000 12 | environment: 13 | - DDD_FORUM_APP_SECRET=${DDD_FORUM_APP_SECRET} 14 | - DDD_FORUM_REDIS_URL=ddd_forum_redis 15 | - DDD_FORUM_REDIS_PORT=6379 16 | - DDD_FORUM_DB_HOST=ddd_forum_mysql 17 | - DDD_FORUM_DB_USER=${DDD_FORUM_DB_USER} 18 | - DDD_FORUM_DB_PASS=${DDD_FORUM_DB_PASS} 19 | - DDD_FORUM_DB_DEV_DB_NAME=${DDD_FORUM_DB_DEV_DB_NAME} 20 | depends_on: 21 | - mysql 22 | - redis 23 | 24 | mysql: 25 | container_name: ddd_forum_mysql 26 | command: --default-authentication-plugin=mysql_native_password 27 | image: mysql:latest 28 | ports: 29 | - 3306:3306 30 | environment: 31 | - MYSQL_ROOT_PASSWORD=rootpwd 32 | - MYSQL_DATABASE=${DDD_FORUM_DB_DEV_DB_NAME} 33 | - MYSQL_USER=${DDD_FORUM_DB_USER} 34 | - MYSQL_PASSWORD=${DDD_FORUM_DB_PASS} 35 | 36 | adminer: 37 | image: adminer 38 | depends_on: 39 | - mysql 40 | ports: 41 | - 8080:8080 42 | 43 | redis: 44 | container_name: ddd_forum_redis 45 | image: redis:latest 46 | ports: 47 | - 6379:6379 48 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Waitin for mysql to start..." 4 | 5 | while ! nc -z ddd_forum_mysql 3306; do 6 | sleep 0.1 7 | done 8 | 9 | echo "MySQL started" 10 | 11 | npm run db:create:dev 12 | npm run migrate:dev 13 | npm run start:both -------------------------------------------------------------------------------- /images/clean-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyarleniber/typescript-ddd-forum/ec457803d7547bc527c0353a3876a363880a2cc7/images/clean-architecture.png -------------------------------------------------------------------------------- /images/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyarleniber/typescript-ddd-forum/ec457803d7547bc527c0353a3876a363880a2cc7/images/demo.png -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyarleniber/typescript-ddd-forum/ec457803d7547bc527c0353a3876a363880a2cc7/images/logo.png -------------------------------------------------------------------------------- /images/subdomains.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyarleniber/typescript-ddd-forum/ec457803d7547bc527c0353a3876a363880a2cc7/images/subdomains.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; 6 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": ".ts,.js", 4 | "ignore": [], 5 | "exec": "ts-node ./src/index.ts" 6 | } 7 | -------------------------------------------------------------------------------- /public/admin/README.md: -------------------------------------------------------------------------------- 1 | 2 | > If we wanted to build an admin front-end, we could put it here. -------------------------------------------------------------------------------- /public/app/.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 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /public/app/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 80 6 | } -------------------------------------------------------------------------------- /public/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/jest": "24.0.18", 7 | "@types/lodash": "^4.14.141", 8 | "@types/node": "^12.7.8", 9 | "@types/react": "^16.9.4", 10 | "@types/react-dom": "^16.9.1", 11 | "axios": "^0.19.0", 12 | "lodash": "^4.17.15", 13 | "moment": "^2.24.0", 14 | "node-sass": "^4.12.0", 15 | "psl": "^1.4.0", 16 | "react": "^16.10.1", 17 | "react-dom": "^16.10.1", 18 | "react-helmet": "^5.2.1", 19 | "react-loader-spinner": "^3.1.4", 20 | "react-quill": "^1.3.3", 21 | "react-redux": "^7.1.1", 22 | "react-router-dom": "^5.1.2", 23 | "react-scripts": "3.1.2", 24 | "react-toastify": "^5.4.0", 25 | "redux": "^4.0.4", 26 | "redux-thunk": "^2.3.0", 27 | "typescript": "^3.6.3" 28 | }, 29 | "scripts": { 30 | "start": "react-scripts start", 31 | "build": "react-scripts build", 32 | "test": "react-scripts test", 33 | "eject": "react-scripts eject" 34 | }, 35 | "eslintConfig": { 36 | "extends": "react-app" 37 | }, 38 | "browserslist": { 39 | "production": [ 40 | ">0.2%", 41 | "not dead", 42 | "not op_mini all" 43 | ], 44 | "development": [ 45 | "last 1 chrome version", 46 | "last 1 firefox version", 47 | "last 1 safari version" 48 | ] 49 | }, 50 | "devDependencies": { 51 | "@types/moment": "^2.13.0", 52 | "@types/react-helmet": "^5.0.11", 53 | "@types/react-redux": "^7.1.4", 54 | "@types/react-router-dom": "^5.1.0", 55 | "prettier": "^2.0.4" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /public/app/public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /public/app/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyarleniber/typescript-ddd-forum/ec457803d7547bc527c0353a3876a363880a2cc7/public/app/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/app/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyarleniber/typescript-ddd-forum/ec457803d7547bc527c0353a3876a363880a2cc7/public/app/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyarleniber/typescript-ddd-forum/ec457803d7547bc527c0353a3876a363880a2cc7/public/app/public/favicon.ico -------------------------------------------------------------------------------- /public/app/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyarleniber/typescript-ddd-forum/ec457803d7547bc527c0353a3876a363880a2cc7/public/app/public/logo192.png -------------------------------------------------------------------------------- /public/app/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyarleniber/typescript-ddd-forum/ec457803d7547bc527c0353a3876a363880a2cc7/public/app/public/logo512.png -------------------------------------------------------------------------------- /public/app/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 | -------------------------------------------------------------------------------- /public/app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /public/app/src/App.sass: -------------------------------------------------------------------------------- 1 | 2 | @import "./assets/styles/variables" 3 | 4 | html 5 | background: white; 6 | position: absolute; 7 | top: 0; 8 | left: 0; 9 | right: 0; 10 | bottom: 0; 11 | 12 | body 13 | background: #F9F9F9; 14 | border-radius: 0.25rem; 15 | margin: 1.5rem; 16 | font-family: 'Roboto Mono', monospace; 17 | height: 100%; 18 | 19 | #root 20 | background: #F9F9F9; 21 | border: #F9F9F9; 22 | border-radius: 0.25rem; 23 | margin-bottom: 1.5rem; 24 | 25 | .flex 26 | display: flex; 27 | 28 | .flex-row 29 | flex-direction: row; 30 | 31 | .flex-center 32 | align-items: center; 33 | 34 | .flex-even 35 | justify-content: space-evenly; 36 | 37 | .flex-between 38 | justify-content: space-between; 39 | 40 | pre 41 | font-weight: normal; 42 | -------------------------------------------------------------------------------- /public/app/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /public/app/src/App.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import { BrowserRouter as Router, Route } from "react-router-dom"; 4 | import './App.sass'; 5 | import IndexPage from './pages'; 6 | import DiscussionPage from './pages/discussion'; 7 | import CommentPage from './pages/comment'; 8 | import LoginPage from './pages/login'; 9 | import JoinPage from './pages/join'; 10 | import AuthenticatedRoute from './shared/infra/router/AuthenticatedRoute'; 11 | import SubmitPage from './pages/submit'; 12 | import MemberPage from './pages/member'; 13 | 14 | const App: React.FC = () => { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | 28 | export default App; 29 | 30 | -------------------------------------------------------------------------------- /public/app/src/assets/img/logo/brick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyarleniber/typescript-ddd-forum/ec457803d7547bc527c0353a3876a363880a2cc7/public/app/src/assets/img/logo/brick.png -------------------------------------------------------------------------------- /public/app/src/assets/styles/variables.sass: -------------------------------------------------------------------------------- 1 | 2 | $mobile-breakpoint-large: 925px; 3 | 4 | $mobile-breakpoint-medium: 600px; 5 | 6 | $mobile-breakpoint-small: 450px; -------------------------------------------------------------------------------- /public/app/src/config/api.tsx: -------------------------------------------------------------------------------- 1 | 2 | const isProduction = process.env.NODE_ENV === 'production' 3 | 4 | const devApiConfig = { 5 | baseUrl: 'http://localhost:5000/api/v1' 6 | } 7 | 8 | const prodApiConfig = { 9 | baseUrl: 'https://dddforum.herokuapp.com/api/v1' 10 | } 11 | 12 | const apiConfig = isProduction ? prodApiConfig : devApiConfig; 13 | 14 | export { apiConfig }; -------------------------------------------------------------------------------- /public/app/src/config/siteMetaData.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | const siteMetaData = { 4 | title: 'Domain-Driven Designers | A forum to discuss Domain-Driven Design' 5 | } 6 | 7 | export { 8 | siteMetaData 9 | } -------------------------------------------------------------------------------- /public/app/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /public/app/src/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import './index.css'; 5 | import App from './App'; 6 | import * as serviceWorker from './serviceWorker'; 7 | import { Provider } from 'react-redux' 8 | import configureStore from './shared/infra/redux/configureStore'; 9 | import { initialReduxStartupScript } from './shared/infra/redux/startupScript'; 10 | 11 | const store = configureStore(); 12 | 13 | //@ts-ignore 14 | initialReduxStartupScript(store); 15 | 16 | ReactDOM.render( 17 | 18 | 19 | 20 | , 21 | document.getElementById('root')); 22 | 23 | // If you want your app to work offline and load faster, you can change 24 | // unregister() to register() below. Note this comes with some pitfalls. 25 | // Learn more about service workers: https://bit.ly/CRA-PWA 26 | serviceWorker.unregister(); 27 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/comments/index.js: -------------------------------------------------------------------------------- 1 | import { Comments } from "./components/Comments"; 2 | 3 | export { 4 | Comments 5 | } -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/comments/styles/Comments.sass: -------------------------------------------------------------------------------- 1 | .comments-container 2 | 3 | input 4 | max-width: 300px; 5 | 6 | pre 7 | margin-bottom: 0; -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/comments/styles/Editor.sass: -------------------------------------------------------------------------------- 1 | .editor 2 | margin-bottom: 1rem; 3 | 4 | .quill 5 | border-radius: 3px; 6 | border: solid 3px; 7 | 8 | > div 9 | border: none; 10 | 11 | .ql-toolbar 12 | border-bottom: solid 3px; 13 | 14 | .ql-editor 15 | min-height: 90px; 16 | transition: 0.2s all; 17 | 18 | &:hover 19 | background: #fbfbfb; 20 | 21 | .ql-editor.ql-blank::before 22 | font-size: 0.9rem; 23 | font-style: normal; 24 | 25 | .ql-snow .ql-editor pre.ql-syntax 26 | font-family: 'Consolas' 27 | 28 | p, u, ul, li, b 29 | font-size: 0.9rem; -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/comments/styles/PostSubmission.sass: -------------------------------------------------------------------------------- 1 | 2 | .post-submission 3 | 4 | .choice 5 | opacity: 0.3; 6 | cursor: pointer; 7 | 8 | &.active 9 | opacity: 1; 10 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/filters/components/PostFilters.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import "../styles/PostFilters.sass" 4 | 5 | export type PostFilterType = 'POPULAR' | 'NEW'; 6 | 7 | interface FilterProps { 8 | activeFilter: PostFilterType; 9 | filterType: PostFilterType; 10 | onClick: (activeFilter: PostFilterType) => void; 11 | text: string; 12 | } 13 | 14 | const Filter:React.FC = (props) => ( 15 |
props.onClick(props.filterType)} 17 | className={`post-filter ${props.activeFilter === props.filterType ? 'active' : ''}`}> 18 | {props.text} 19 |
20 | ) 21 | 22 | interface PostFilterProps { 23 | activeFilter: PostFilterType; 24 | onClick: (activeFilter: PostFilterType) => void; 25 | } 26 | 27 | const PostFilters: React.FC = (props) => ( 28 |
29 | 35 | 41 |
42 | ) 43 | 44 | export default PostFilters; -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/filters/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import PostFilters from "./components/PostFilters"; 3 | 4 | export { 5 | PostFilters 6 | } -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/filters/styles/PostFilters.sass: -------------------------------------------------------------------------------- 1 | 2 | .post-filters 3 | display: flex; 4 | 5 | .post-filter 6 | cursor: pointer; 7 | font-size: 1.5rem; 8 | font-weight: 500; 9 | margin-right: 1rem; 10 | opacity: 0.3; 11 | 12 | &.active 13 | opacity: 1; 14 | 15 | &:nth-child(1) 16 | border-right: solid 2px black; 17 | padding-right: 1rem; -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/points/assets/arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/points/components/PointHover.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import { Link } from "react-router-dom"; 4 | import "../styles/PointHover.sass" 5 | 6 | interface PostPointsProps { 7 | isHover: boolean; 8 | } 9 | 10 | const PointHover: React.FC = (props) => ( 11 |
12 |

13 | Want to vote? You need to sign up 14 | Here 15 |

16 |
17 | ) 18 | 19 | export default PointHover; -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/points/components/Points.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import "../styles/Point.sass" 4 | import arrowSvg from "../assets/arrow.svg" 5 | import PointHover from './PointHover' 6 | 7 | interface PostPointsProps { 8 | points: number; 9 | onUpvoteClicked: () => void; 10 | onDownvoteClicked: () => void; 11 | isLoggedIn: boolean; 12 | } 13 | 14 | const Points: React.FC = (props) => { 15 | const [isHover, setHover] = React.useState(false) 16 | 17 | return ( 18 |
19 |
props.onUpvoteClicked()} 21 | className="points-img-container upvote" 22 | onMouseEnter={() => setHover(true)} 23 | onMouseLeave={() => setHover(false)} 24 | > 25 | {!props.isLoggedIn && } 26 | 27 |
28 |
{props.points}
29 |
props.onDownvoteClicked()} 31 | className="points-img-container downvote" 32 | onMouseEnter={() => setHover(true)} 33 | onMouseLeave={() => setHover(false)} 34 | > 35 | 36 |
37 |
38 | ) 39 | } 40 | 41 | export default Points; -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/points/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import Points from "./components/Points"; 3 | 4 | export { 5 | Points 6 | } -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/points/styles/Point.sass: -------------------------------------------------------------------------------- 1 | 2 | .post-points 3 | margin-right: 1rem; 4 | text-align: center; 5 | min-width: 2rem; 6 | font-weight: bold; 7 | position: relative; 8 | 9 | .points-img-container 10 | cursor: pointer; 11 | 12 | img 13 | max-width: 11px; 14 | 15 | .downvote 16 | transform: rotate(180deg); -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/points/styles/PointHover.sass: -------------------------------------------------------------------------------- 1 | 2 | .post-points-hover 3 | background-color: #111; 4 | position: absolute; 5 | width: 100px; 6 | color: #fff; 7 | padding: .5rem; 8 | font-size: .6rem; 9 | left: -7.5rem; 10 | top: 5px; 11 | border-radius: 5px; 12 | border-color: transparent; 13 | transition: all .2s ease-in-out; 14 | transform: scale(0); 15 | transform-origin: 100% 50%; 16 | 17 | &.is-hover 18 | transform: scale(1.1); 19 | 20 | &:after 21 | display: block; 22 | width: 0px; 23 | height: 0px; 24 | border-top: 10px solid transparent; 25 | border-bottom: 10px solid transparent; 26 | border-left: 10px solid #111; 27 | content: ''; 28 | position: absolute; 29 | right: -8px; 30 | transform: translateY(-50%); 31 | top: 50%; 32 | 33 | p 34 | margin: 0; 35 | 36 | a 37 | color: rgba(255, 255, 255, .8); 38 | 39 | &:hover 40 | color: #fff; -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/post/components/PostComment.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import { Link } from "react-router-dom"; 4 | import "../styles/PostComment.sass" 5 | import { Comment } from '../../../../models/Comment' 6 | import PostCommentAuthorAndText from './PostCommentAuthorAndText'; 7 | import { Points } from '../../points'; 8 | 9 | interface PostCommentProps extends Comment { 10 | onUpvoteClicked: () => void; 11 | onDownvoteClicked: () => void; 12 | isLoggedIn: boolean; 13 | } 14 | 15 | const PostComment: React.FC = (props) => ( 16 |
17 | props.onUpvoteClicked()} 20 | onDownvoteClicked={() => props.onDownvoteClicked()} 21 | isLoggedIn={props.isLoggedIn} 22 | /> 23 |
24 |
25 | 28 | reply 29 |
30 |
31 | {props.childComments.length !== 0 && props.childComments.map((c, i) => ( 32 | 39 | ))} 40 |
41 |
42 |
43 | ) 44 | 45 | export default PostComment; -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/post/components/PostCommentAuthorAndText.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import moment from 'moment' 4 | import { Comment } from '../../../../models/Comment' 5 | 6 | interface PostCommentAuthorAndTextProps extends Comment { 7 | 8 | } 9 | 10 | const PostCommentAuthorAndText: React.FC = (props) => ( 11 |
12 |
13 | {props.member.username} | {moment(props.createdAt).fromNow()} 14 |
15 |

16 |
17 | ) 18 | 19 | export default PostCommentAuthorAndText -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/post/components/PostMeta.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import moment from 'moment'; 4 | import { Link } from "react-router-dom"; 5 | import { Post } from '../../../../models/Post' 6 | import "../styles/PostMeta.sass" 7 | 8 | interface PostMetaProps extends Post { 9 | includeLink?: boolean; 10 | } 11 | 12 | const PostMeta: React.FC = (props) => ( 13 |
14 | {props.includeLink === false ? '' : "{props.title}" {props.link ? [link] : ''}} 15 |
16 | {moment(props.createdAt).fromNow()} | {`by `} {props.postAuthor} | {`${props.numComments} comments`} 17 |
18 |
19 | ) 20 | 21 | export default PostMeta; -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/post/components/PostSummary.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import "../styles/PostSummary.sass" 4 | import PostMeta from './PostMeta' 5 | import { Post } from '../../../../models/Post' 6 | import { TextUtil } from '../../../../../../shared/utils/TextUtil' 7 | 8 | interface PostProps extends Post { 9 | 10 | } 11 | 12 | const PostSummary: React.FC = (props) => ( 13 |
14 | 15 | {!!props.text ? ( 16 | 22 | ) 23 | 24 | export default PostSummary; 25 | 26 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/post/index.ts: -------------------------------------------------------------------------------- 1 | import PostSummary from './components/PostSummary'; 2 | 3 | export { PostSummary }; 4 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/post/styles/PostComment.sass: -------------------------------------------------------------------------------- 1 | 2 | .comment 3 | display: flex; 4 | 5 | .post-comment-container 6 | width: 100%; 7 | 8 | .post-comment 9 | background: white; 10 | border: solid 3px #00000030; 11 | padding: 1rem; 12 | border-radius: 0.25rem; 13 | margin-bottom: 1.5rem; 14 | 15 | .comment-meta 16 | opacity: 0.5; 17 | 18 | .comment-text 19 | font-weight: bold; 20 | 21 | .reply-button 22 | text-decoration: underline; 23 | cursor: pointer; 24 | 25 | a 26 | color: inherit; 27 | 28 | .indent 29 | margin-left: 1.5rem; 30 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/post/styles/PostMeta.sass: -------------------------------------------------------------------------------- 1 | 2 | .post-row-content 3 | 4 | .link 5 | font-size: 0.9rem; 6 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/post/styles/PostSummary.sass: -------------------------------------------------------------------------------- 1 | 2 | .post 3 | 4 | 5 | .link 6 | background: #6a69ff; 7 | color: #ffffff; 8 | border-radius: 2px; 9 | font-weight: bold; 10 | padding: 1rem; 11 | margin-top: 1rem; 12 | border: solid 3px #222263; 13 | cursor: pointer; 14 | display: block; 15 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/postRow/components/PostRow.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import "../styles/PostRow.sass" 4 | import { Post } from '../../../../models/Post'; 5 | import { Points } from '../../points'; 6 | import PostMeta from '../../post/components/PostMeta'; 7 | 8 | interface PostRowProps extends Post { 9 | onUpvoteClicked: () => void; 10 | onDownvoteClicked: () => void; 11 | isLoggedIn: boolean; 12 | } 13 | 14 | const PostRow: React.FC = (props) => ( 15 |
16 | props.onUpvoteClicked()} 18 | onDownvoteClicked={() => props.onDownvoteClicked()} 19 | points={props.points} 20 | isLoggedIn={props.isLoggedIn} 21 | /> 22 | 23 |
24 | ) 25 | 26 | export default PostRow; -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/postRow/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import PostRow from "./components/PostRow"; 3 | 4 | export { 5 | PostRow 6 | } -------------------------------------------------------------------------------- /public/app/src/modules/forum/components/posts/postRow/styles/PostRow.sass: -------------------------------------------------------------------------------- 1 | 2 | .post-row 3 | display: flex; 4 | align-items: center; 5 | margin-bottom: 0.5rem; 6 | 7 | .post-row-content 8 | 9 | .title 10 | font-size: 1.25rem; 11 | padding: 0; 12 | margin-top: 1rem; 13 | margin-bottom: 0.25rem; 14 | font-weight: 500; 15 | cursor: pointer; 16 | text-decoration: none; 17 | color: inherit; 18 | display: block; -------------------------------------------------------------------------------- /public/app/src/modules/forum/dtos/commentDTO.ts: -------------------------------------------------------------------------------- 1 | import { MemberDTO } from './memberDTO'; 2 | 3 | export interface CommentDTO { 4 | postSlug: string; 5 | commentId: string; 6 | parentCommentId?: string; 7 | text: string; 8 | member: MemberDTO; 9 | createdAt: string | Date; 10 | childComments: CommentDTO[]; 11 | postTitle: string; 12 | points: number; 13 | } 14 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/dtos/memberDTO.ts: -------------------------------------------------------------------------------- 1 | import { UserDTO } from '../../users/dtos/userDTO'; 2 | 3 | export interface MemberDTO { 4 | reputation: number; 5 | user: UserDTO; 6 | } 7 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/dtos/postDTO.ts: -------------------------------------------------------------------------------- 1 | import { MemberDTO } from './memberDTO'; 2 | import { PostType } from '../models/Post'; 3 | 4 | export interface PostDTO { 5 | slug: string; 6 | title: string; 7 | createdAt: string | Date; 8 | memberPostedBy: MemberDTO; 9 | numComments: number; 10 | points: number; 11 | text: string; 12 | type: PostType; 13 | link: string; 14 | wasUpvotedByMe: boolean; 15 | wasDownvotedByMe: boolean; 16 | } 17 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/hocs/withVoting.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import { IForumOperations } from '../redux/operators'; 4 | import { ForumState } from '../redux/states'; 5 | 6 | interface withVotingProps extends IForumOperations { 7 | users: ForumState; 8 | } 9 | 10 | function withVoting (WrappedComponent: any) { 11 | class HOC extends React.Component { 12 | constructor (props: withVotingProps) { 13 | super(props) 14 | } 15 | 16 | handleUpvoteComment (commentId: string) { 17 | this.props.upvoteComment(commentId); 18 | } 19 | 20 | handleDownvoteComment (commentId: string) { 21 | this.props.downvoteComment(commentId); 22 | } 23 | 24 | handleUpvotePost (postSlug: string) { 25 | this.props.upvotePost(postSlug) 26 | } 27 | 28 | handleDownvotePost (postSlug: string) { 29 | this.props.downvotePost(postSlug); 30 | } 31 | 32 | render () { 33 | return ( 34 | this.handleUpvoteComment(commentId)} 36 | downvoteComment={(commentId: string) => this.handleDownvoteComment(commentId)} 37 | upvotePost={(slug: string) => this.handleUpvotePost(slug)} 38 | downvotePost={(slug: string) => this.handleDownvotePost(slug)} 39 | {...this.props} 40 | /> 41 | ); 42 | } 43 | } 44 | return HOC; 45 | } 46 | 47 | export default withVoting; -------------------------------------------------------------------------------- /public/app/src/modules/forum/models/Comment.ts: -------------------------------------------------------------------------------- 1 | import { Member } from './Member'; 2 | 3 | export interface Comment { 4 | postSlug: string; 5 | commentId: string; 6 | parentCommentId?: string; 7 | text: string; 8 | member: Member; 9 | createdAt: string | Date; 10 | childComments: Comment[]; 11 | postTitle: string; 12 | points: number; 13 | } 14 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/models/Member.ts: -------------------------------------------------------------------------------- 1 | export interface Member { 2 | username: string; 3 | reputation: number; 4 | } 5 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/models/Post.ts: -------------------------------------------------------------------------------- 1 | export type PostType = 'text' | 'link'; 2 | 3 | export interface Post { 4 | slug: string; 5 | title: string; 6 | createdAt: string | Date; 7 | postAuthor: string; 8 | numComments: number; 9 | points: number; 10 | type: PostType; 11 | text: string; 12 | link: string; 13 | wasUpvotedByMe: boolean; 14 | wasDownvotedByMe: boolean; 15 | } 16 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/redux/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import reducer from "./reducers"; 3 | 4 | export { default as actions } from "./actions"; 5 | export { default as actionCreators } from "./actionCreators"; 6 | export { default as operators } from "./operators"; 7 | export { default as states } from "./states"; 8 | 9 | export default reducer; 10 | 11 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/redux/operators/createReplyToComment.tsx: -------------------------------------------------------------------------------- 1 | 2 | import * as actionCreators from '../actionCreators' 3 | import { commentService } from '../../services'; 4 | 5 | function createReplyToComment (comment: string, parentCommentId: string, slug: string) { 6 | return async (dispatch: any) => { 7 | 8 | dispatch(actionCreators.creatingReplyToComment()); 9 | 10 | const result = await commentService.createReplyToComment(comment, parentCommentId, slug); 11 | 12 | if (result.isLeft()) { 13 | const error: string = result.value; 14 | dispatch(actionCreators.creatingReplyToCommentFailure(error)) 15 | } else { 16 | dispatch(actionCreators.creatingReplyToCommentSuccess()); 17 | } 18 | }; 19 | } 20 | 21 | export { createReplyToComment }; 22 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/redux/operators/createReplyToPost.tsx: -------------------------------------------------------------------------------- 1 | 2 | import * as actionCreators from '../actionCreators' 3 | import { commentService } from '../../services'; 4 | 5 | function createReplyToPost (text: string, slug: string) { 6 | return async (dispatch: any) => { 7 | 8 | dispatch(actionCreators.creatingReplyToPost()); 9 | 10 | const result = await commentService.createReplyToPost(text, slug); 11 | 12 | if (result.isLeft()) { 13 | const error: string = result.value; 14 | dispatch(actionCreators.creatingReplyToPostFailure(error)) 15 | } else { 16 | dispatch(actionCreators.creatingReplyToPostSuccess()); 17 | } 18 | }; 19 | } 20 | 21 | export { createReplyToPost }; 22 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/redux/operators/downvoteComment.tsx: -------------------------------------------------------------------------------- 1 | 2 | import * as actionCreators from '../actionCreators' 3 | import { commentService } from '../../services'; 4 | 5 | function downvoteComment (commentId: string) { 6 | return async (dispatch: any) => { 7 | 8 | dispatch(actionCreators.downvotingComment()); 9 | 10 | const result = await commentService.downvoteComment(commentId); 11 | 12 | if (result.isLeft()) { 13 | const error: string = result.value; 14 | dispatch(actionCreators.downvotingCommentFailure(error)) 15 | } else { 16 | dispatch(actionCreators.downvotingCommentSuccess(commentId)); 17 | } 18 | }; 19 | } 20 | 21 | export { downvoteComment }; 22 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/redux/operators/downvotePost.tsx: -------------------------------------------------------------------------------- 1 | 2 | import * as actionCreators from '../actionCreators' 3 | import { postService } from '../../services'; 4 | 5 | function downvotePost (postSlug: string) { 6 | return async (dispatch: any) => { 7 | 8 | dispatch(actionCreators.downvotingPost()); 9 | 10 | const result = await postService.downvotePost(postSlug); 11 | 12 | if (result.isLeft()) { 13 | const error: string = result.value; 14 | dispatch(actionCreators.downvotingPostFailure(error)) 15 | } else { 16 | dispatch(actionCreators.downvotingPostSuccess(postSlug)); 17 | } 18 | }; 19 | } 20 | 21 | export { downvotePost }; 22 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/redux/operators/getCommentByCommentId.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | import * as actionCreators from '../actionCreators' 4 | import { commentService } from '../../services'; 5 | import { Comment } from '../../models/Comment'; 6 | 7 | function getCommentByCommentId (commentId: string) { 8 | return async (dispatch: any) => { 9 | 10 | dispatch(actionCreators.gettingCommentByCommentId()); 11 | 12 | const result = await commentService.getCommentByCommentId(commentId); 13 | 14 | if (result.isLeft()) { 15 | const error: string = result.value; 16 | dispatch(actionCreators.gettingCommentByCommentIdFailure(error)) 17 | } else { 18 | const comment: Comment = result.value.getValue(); 19 | dispatch(actionCreators.gettingCommentByCommentIdSuccess(comment)); 20 | } 21 | }; 22 | } 23 | 24 | export { getCommentByCommentId }; 25 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/redux/operators/getCommentReplies.tsx: -------------------------------------------------------------------------------- 1 | 2 | import * as actionCreators from '../actionCreators' 3 | import { commentService } from '../../services'; 4 | import { CommentUtil } from '../../utils/CommentUtil'; 5 | 6 | function getCommentReplies (slug: string, commentId: string, offset?: number) { 7 | return async (dispatch: any, getState: Function) => { 8 | 9 | dispatch(actionCreators.gettingComments()); 10 | 11 | const result = await commentService.getCommentsBySlug(slug, offset); 12 | 13 | if (result.isLeft()) { 14 | const error: string = result.value; 15 | dispatch(actionCreators.gettingCommentsFailure(error)) 16 | } else { 17 | const comments = result.value.getValue(); 18 | 19 | const commentThread = CommentUtil.getThread(commentId, comments); 20 | 21 | dispatch(actionCreators.gettingCommentsSuccess(commentThread)); 22 | } 23 | }; 24 | } 25 | 26 | export { getCommentReplies }; 27 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/redux/operators/getComments.tsx: -------------------------------------------------------------------------------- 1 | 2 | import * as actionCreators from '../actionCreators' 3 | import { commentService } from '../../services'; 4 | import { CommentUtil } from '../../utils/CommentUtil'; 5 | 6 | function getComments (slug: string, offset?: number) { 7 | return async (dispatch: any, getState: Function) => { 8 | 9 | dispatch(actionCreators.gettingComments()); 10 | 11 | const result = await commentService.getCommentsBySlug(slug, offset); 12 | 13 | if (result.isLeft()) { 14 | const error: string = result.value; 15 | dispatch(actionCreators.gettingCommentsFailure(error)) 16 | } else { 17 | const comments = result.value.getValue(); 18 | 19 | const sortedComments = CommentUtil.getSortedComments(comments); 20 | 21 | dispatch(actionCreators.gettingCommentsSuccess(sortedComments)); 22 | } 23 | }; 24 | } 25 | 26 | export { getComments }; 27 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/redux/operators/getPopularPosts.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | import * as actionCreators from '../actionCreators' 4 | import { postService } from '../../services'; 5 | import { Post } from '../../models/Post'; 6 | 7 | function getPopularPosts (offset?: number) { 8 | return async (dispatch: any) => { 9 | 10 | dispatch(actionCreators.getPopularPosts()); 11 | 12 | const result = await postService.getPopularPosts(offset); 13 | 14 | if (result.isLeft()) { 15 | const error: string = result.value; 16 | dispatch(actionCreators.getPopularPostsFailure(error)) 17 | } else { 18 | const posts: Post[] = result.value.getValue(); 19 | dispatch(actionCreators.getPopularPostsSuccess(posts)); 20 | } 21 | }; 22 | } 23 | 24 | export { getPopularPosts }; 25 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/redux/operators/getPostBySlug.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | import * as actionCreators from '../actionCreators' 4 | import { postService } from '../../services'; 5 | import { Post } from '../../models/Post'; 6 | 7 | function getPostBySlug (slug: string) { 8 | return async (dispatch: any) => { 9 | 10 | dispatch(actionCreators.gettingPostBySlug()); 11 | 12 | const result = await postService.getPostBySlug(slug); 13 | 14 | if (result.isLeft()) { 15 | const error: string = result.value; 16 | dispatch(actionCreators.gettingPostBySlugFailure(error)) 17 | } else { 18 | const post: Post = result.value.getValue(); 19 | dispatch(actionCreators.gettingPostBySlugSuccess(post)); 20 | } 21 | }; 22 | } 23 | 24 | export { getPostBySlug }; 25 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/redux/operators/getRecentPosts.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | import * as actionCreators from '../actionCreators' 4 | import { postService } from '../../services'; 5 | import { Post } from '../../models/Post'; 6 | 7 | function getRecentPosts (offset?: number) { 8 | return async (dispatch: any) => { 9 | 10 | dispatch(actionCreators.getRecentPosts()); 11 | 12 | const result = await postService.getRecentPosts(offset); 13 | 14 | if (result.isLeft()) { 15 | const error: string = result.value; 16 | dispatch(actionCreators.getRecentPostsFailure(error)) 17 | } else { 18 | const posts: Post[] = result.value.getValue(); 19 | dispatch(actionCreators.getRecentPostsSuccess( 20 | posts.sort((a, b) => Number(new Date(b.createdAt)) - Number(new Date(a.createdAt))) 21 | )); 22 | } 23 | }; 24 | } 25 | 26 | export { getRecentPosts }; 27 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/redux/operators/submitPost.tsx: -------------------------------------------------------------------------------- 1 | 2 | import * as actionCreators from '../actionCreators' 3 | import { postService } from '../../services'; 4 | import { PostType } from '../../models/Post'; 5 | 6 | function submitPost (title: string, type: PostType, text?: string, link?: string) { 7 | return async (dispatch: any) => { 8 | 9 | dispatch(actionCreators.submittingPost()); 10 | 11 | const result = await postService.createPost(title, type, text, link); 12 | 13 | if (result.isLeft()) { 14 | const error: string = result.value; 15 | dispatch(actionCreators.submittingPostFailure(error)) 16 | } else { 17 | dispatch(actionCreators.submittingPostSuccess()); 18 | } 19 | }; 20 | } 21 | 22 | export { submitPost }; 23 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/redux/operators/upvoteComment.tsx: -------------------------------------------------------------------------------- 1 | 2 | import * as actionCreators from '../actionCreators' 3 | import { commentService } from '../../services'; 4 | 5 | function upvoteComment (commentId: string) { 6 | return async (dispatch: any) => { 7 | 8 | dispatch(actionCreators.upvotingComment()); 9 | 10 | const result = await commentService.upvoteComment(commentId); 11 | 12 | if (result.isLeft()) { 13 | const error: string = result.value; 14 | dispatch(actionCreators.upvotingCommentFailure(error)) 15 | } else { 16 | dispatch(actionCreators.upvotingCommentSuccess(commentId)); 17 | } 18 | }; 19 | } 20 | 21 | export { upvoteComment }; 22 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/redux/operators/upvotePost.tsx: -------------------------------------------------------------------------------- 1 | 2 | import * as actionCreators from '../actionCreators' 3 | import { postService } from '../../services'; 4 | 5 | function upvotePost (postSlug: string) { 6 | return async (dispatch: any) => { 7 | 8 | dispatch(actionCreators.upvotingComment()); 9 | 10 | const result = await postService.upvotePost(postSlug); 11 | 12 | if (result.isLeft()) { 13 | const error: string = result.value; 14 | dispatch(actionCreators.upvotingPostFailure(error)) 15 | } else { 16 | dispatch(actionCreators.upvotingPostSuccess(postSlug)); 17 | } 18 | }; 19 | } 20 | 21 | export { upvotePost }; 22 | -------------------------------------------------------------------------------- /public/app/src/modules/forum/services/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { PostService } from "./postService"; 3 | import { authService } from "../../users/services"; 4 | import { CommentService } from "./commentService"; 5 | 6 | const commentService = new CommentService( 7 | authService 8 | ) 9 | 10 | const postService = new PostService( 11 | authService 12 | ) 13 | 14 | export { postService, commentService }; -------------------------------------------------------------------------------- /public/app/src/modules/forum/utils/PostUtil.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '../models/Post'; 2 | import { PostDTO } from '../dtos/postDTO'; 3 | 4 | export class PostUtil { 5 | public static maxTextLength: number = 10000; 6 | public static minTextLength: number = 20; 7 | 8 | public static maxTitleLength: number = 85; 9 | public static minTitleLength: number = 2; 10 | 11 | public static maxLinkLength: number = 500; 12 | public static minLinkLength: number = 8; 13 | 14 | public static computePostAfterUpvote(post: Post): Post { 15 | return { 16 | ...post, 17 | wasUpvotedByMe: post.wasUpvotedByMe ? false : true, 18 | points: post.wasUpvotedByMe ? post.points - 1 : post.points + 1 19 | }; 20 | } 21 | 22 | public static computePostAfterDownvote(post: Post): Post { 23 | return { 24 | ...post, 25 | wasDownvotedByMe: post.wasDownvotedByMe ? false : true, 26 | points: post.wasDownvotedByMe ? post.points + 1 : post.points - 1 27 | }; 28 | } 29 | 30 | public static toViewModel(dto: PostDTO): Post { 31 | return { 32 | slug: dto.slug, 33 | title: dto.title, 34 | createdAt: dto.createdAt, 35 | postAuthor: dto.memberPostedBy.user.username, 36 | numComments: dto.numComments, 37 | points: dto.points, 38 | type: dto.type, 39 | text: dto.text, 40 | link: dto.link, 41 | wasUpvotedByMe: dto.wasUpvotedByMe, 42 | wasDownvotedByMe: dto.wasDownvotedByMe 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/app/src/modules/users/components/onboarding/onboardTemplate/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import OnboardTemplate from "./components/OnboardTemplate"; 3 | 4 | export { 5 | OnboardTemplate 6 | } -------------------------------------------------------------------------------- /public/app/src/modules/users/components/onboarding/onboardTemplate/styles/OnboardTemplate.sass: -------------------------------------------------------------------------------- 1 | .onboard-container 2 | max-width: 500px; 3 | margin: 0 auto; 4 | 5 | .title 6 | font-size: 1.25rem; 7 | font-weight: bold; 8 | 9 | .submit-container 10 | display: flex; 11 | flex-direction: row; 12 | align-items: center; 13 | justify-content: flex-end; 14 | 15 | .message 16 | margin-right: 1rem; 17 | 18 | p 19 | margin: 0; 20 | padding: 0; 21 | -------------------------------------------------------------------------------- /public/app/src/modules/users/components/profileButton/components/ProfileButton.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import { Button } from '../../../../../shared/components/button' 4 | 5 | interface ProfileButtonProps { 6 | isLoggedIn: boolean; 7 | username: string 8 | onLogout: () => void; 9 | } 10 | 11 | const ProfileButton: React.FC = (props) => { 12 | return props.isLoggedIn ? ( 13 | 18 | ) 19 | 20 | export default SubmitButton; -------------------------------------------------------------------------------- /public/app/src/shared/components/button/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import Button from "./components/Button"; 3 | import SubmitButton from "./components/SubmitButton"; 4 | 5 | export { 6 | Button, 7 | SubmitButton 8 | } -------------------------------------------------------------------------------- /public/app/src/shared/components/button/styles/Button.sass: -------------------------------------------------------------------------------- 1 | 2 | .button 3 | background: black; 4 | color: white; 5 | padding: 0.5rem; 6 | min-width: 5rem; 7 | text-align: right; 8 | cursor: pointer; 9 | padding-left: 2rem; -------------------------------------------------------------------------------- /public/app/src/shared/components/button/styles/SubmitButton.sass: -------------------------------------------------------------------------------- 1 | .submit-button 2 | padding: 1rem; 3 | border: none; 4 | background: #6a69ff; 5 | border-radius: 3px; 6 | color: white; 7 | font-weight: 500; 8 | font-size: 1rem; 9 | cursor: pointer; 10 | border-bottom: solid 9px darken(#6a69ff, 10%); 11 | 12 | &:hover 13 | background: darken(#6a69ff, 3%) 14 | 15 | &.negative 16 | background: #ea5f8b; 17 | border-bottom: solid 3px darken(#ea5f8b, 10%); 18 | 19 | &:hover 20 | background: darken(#ea5f8b, 3%) -------------------------------------------------------------------------------- /public/app/src/shared/components/header/assets/arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/app/src/shared/components/header/components/BackNavigation.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import arrow from '../assets/arrow.svg' 3 | import { Link } from 'react-router-dom' 4 | import "../styles/BackNavigation.sass" 5 | 6 | interface BackNavigationProps { 7 | to: string; 8 | text: string; 9 | } 10 | 11 | const BackNavigation:React.FC = (props) => ( 12 | 13 |

{props.text}

14 | 15 | ) 16 | 17 | export default BackNavigation; -------------------------------------------------------------------------------- /public/app/src/shared/components/header/components/Header.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import { Logo } from '..'; 4 | import "../styles/Header.sass" 5 | import { Link } from 'react-router-dom'; 6 | import { Points } from '../../../../modules/forum/components/posts/points'; 7 | 8 | interface HeaderProps { 9 | title: string; 10 | subtitle?: string; 11 | isUpvotable?: boolean; 12 | onUpvoteClicked?: Function; 13 | onDownvoteClicked?: Function; 14 | points?: number; 15 | isLoggedIn?: boolean; 16 | } 17 | 18 | const Header: React.FC = (props) => ( 19 |
20 | 21 | {props.isUpvotable && props.onUpvoteClicked ? props.onUpvoteClicked() : ''} 23 | onDownvoteClicked={() => props.onDownvoteClicked ? props.onDownvoteClicked() : ''} 24 | points={props.points as number} 25 | isLoggedIn={props.isLoggedIn || false} 26 | />} 27 |
28 |

{props.title}

29 |

{props.subtitle}

30 |
31 | submit 32 |
33 |
34 |
35 | ) 36 | 37 | export default Header; -------------------------------------------------------------------------------- /public/app/src/shared/components/header/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import "../styles/Logo.sass" 4 | import logo from "../../../../assets/img/logo/brick.png" 5 | 6 | const Logo = () => ( 7 |
8 | 9 |
10 | ) 11 | 12 | export default Logo; -------------------------------------------------------------------------------- /public/app/src/shared/components/header/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import Logo from "./components/Logo"; 3 | import BackNavigation from "./components/BackNavigation" 4 | 5 | export { 6 | Logo, 7 | BackNavigation 8 | } 9 | -------------------------------------------------------------------------------- /public/app/src/shared/components/header/styles/BackNavigation.sass: -------------------------------------------------------------------------------- 1 | 2 | .back-nav 3 | display: flex; 4 | align-items: center; 5 | 6 | .arrow-container 7 | margin-right: 1rem; -------------------------------------------------------------------------------- /public/app/src/shared/components/header/styles/Header.sass: -------------------------------------------------------------------------------- 1 | 2 | @import "../../../../assets/styles/variables" 3 | 4 | .header 5 | display: flex; 6 | flex-direction: row; 7 | align-items: center; 8 | justify-content: space-around; 9 | 10 | .header-links 11 | margin-top: 0.5rem; 12 | 13 | .logo-container 14 | margin-right: 2rem; 15 | 16 | @media (max-width: $mobile-breakpoint-large) 17 | margin-right: 0.5rem; 18 | 19 | img 20 | max-width: 3rem; 21 | 22 | .content-container 23 | width: 100%; 24 | padding-right: 1.5em; 25 | 26 | h1, 27 | p 28 | padding: 0; 29 | margin: 0; 30 | 31 | -------------------------------------------------------------------------------- /public/app/src/shared/components/header/styles/Logo.sass: -------------------------------------------------------------------------------- 1 | .logo-container 2 | > img 3 | max-height: 70px; -------------------------------------------------------------------------------- /public/app/src/shared/components/loader/components/FullPageLoader.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | //@ts-ignore 4 | import Loader from 'react-loader-spinner' 5 | import "../styles/FullPageLoader.sass" 6 | 7 | const FullPageLoader = () => ( 8 |
9 | 15 |
16 | ) 17 | 18 | export default FullPageLoader; -------------------------------------------------------------------------------- /public/app/src/shared/components/loader/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | //@ts-ignore 4 | import RLoader from 'react-loader-spinner' 5 | 6 | const Loader = () => ( 7 | 13 | ) 14 | 15 | export default Loader; -------------------------------------------------------------------------------- /public/app/src/shared/components/loader/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import FullPageLoader from "./components/FullPageLoader"; 3 | import Loader from "./components/Loader"; 4 | 5 | export { 6 | FullPageLoader, 7 | Loader 8 | } -------------------------------------------------------------------------------- /public/app/src/shared/components/loader/styles/FullPageLoader.sass: -------------------------------------------------------------------------------- 1 | 2 | .full-page-loader 3 | position: absolute; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | bottom: 0; 8 | background: #00000024; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; -------------------------------------------------------------------------------- /public/app/src/shared/components/text-input/components/TextInput.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import "../styles/TextInput.sass" 4 | 5 | interface TextInputProps { 6 | placeholder: string; 7 | onChange: (val: string) => void; 8 | type: string; 9 | } 10 | 11 | const TextInput: React.FC = ({ placeholder, onChange, type }) => ( 12 | onChange(e.target.value)} 17 | /> 18 | ) 19 | 20 | export default TextInput; -------------------------------------------------------------------------------- /public/app/src/shared/components/text-input/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import TextInput from './components/TextInput' 3 | 4 | export { 5 | TextInput 6 | } -------------------------------------------------------------------------------- /public/app/src/shared/components/text-input/styles/TextInput.sass: -------------------------------------------------------------------------------- 1 | .text-input 2 | display: block; 3 | padding: 0.6rem 1rem 0.6rem 1rem; 4 | border-radius: 3px; 5 | border: solid 3px #000000c9; 6 | width: 100%; 7 | margin-bottom: 0.5rem; 8 | color: #000000d6; 9 | transition: 0.2s all; 10 | font-size: 1rem; 11 | box-sizing: border-box; 12 | 13 | &:hover 14 | background: #fbfbfb; -------------------------------------------------------------------------------- /public/app/src/shared/core/Either.ts: -------------------------------------------------------------------------------- 1 | export type Either = Left | Right; 2 | 3 | export class Left { 4 | readonly value: L; 5 | 6 | constructor(value: L) { 7 | this.value = value; 8 | } 9 | 10 | isLeft(): this is Left { 11 | return true; 12 | } 13 | 14 | isRight(): this is Right { 15 | return false; 16 | } 17 | } 18 | 19 | export class Right { 20 | readonly value: A; 21 | 22 | constructor(value: A) { 23 | this.value = value; 24 | } 25 | 26 | isLeft(): this is Left { 27 | return false; 28 | } 29 | 30 | isRight(): this is Right { 31 | return true; 32 | } 33 | } 34 | 35 | export const left = (l: L): Either => { 36 | return new Left(l); 37 | }; 38 | 39 | export const right = (a: A): Either => { 40 | return new Right(a); 41 | }; 42 | -------------------------------------------------------------------------------- /public/app/src/shared/core/Result.ts: -------------------------------------------------------------------------------- 1 | export class Result { 2 | public isSuccess: boolean; 3 | public isFailure: boolean; 4 | public error: T | string; 5 | private _value: T; 6 | 7 | public constructor(isSuccess: boolean, error?: T | string | null, value?: T) { 8 | if (isSuccess && error) { 9 | throw new Error( 10 | 'InvalidOperation: A result cannot be successful and contain an error' 11 | ); 12 | } 13 | if (!isSuccess && !error) { 14 | throw new Error( 15 | 'InvalidOperation: A failing result needs to contain an error message' 16 | ); 17 | } 18 | 19 | this.isSuccess = isSuccess; 20 | this.isFailure = !isSuccess; 21 | this.error = error as T; 22 | this._value = value as T; 23 | 24 | Object.freeze(this); 25 | } 26 | 27 | public getValue(): T { 28 | if (!this.isSuccess) { 29 | console.log(this.error); 30 | throw new Error( 31 | "Can't get the value of an error result. Use 'errorValue' instead." 32 | ); 33 | } 34 | 35 | return this._value; 36 | } 37 | 38 | public errorValue(): T { 39 | return this.error as T; 40 | } 41 | 42 | public static ok(value?: U): Result { 43 | return new Result(true, null, value); 44 | } 45 | 46 | public static fail(error: string): Result { 47 | return new Result(false, error); 48 | } 49 | 50 | public static combine(results: Result[]): Result { 51 | for (let result of results) { 52 | if (result.isFailure) return result; 53 | } 54 | return Result.ok(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /public/app/src/shared/infra/redux/configureStore.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { createStore, applyMiddleware, combineReducers, compose } from 'redux'; 3 | import thunk from 'redux-thunk'; 4 | import users from '../../../modules/users/redux/reducers'; 5 | import forum from '../../../modules/forum/redux/reducers'; 6 | 7 | const reducers = { 8 | users, 9 | forum 10 | } 11 | 12 | export default function configureStore(initialState={}) { 13 | return createStore( 14 | combineReducers({ 15 | ...reducers 16 | }), 17 | initialState, 18 | compose( 19 | applyMiddleware(thunk), 20 | (window as any).devToolsExtension ? (window as any).devToolsExtension() : (f: any) => f 21 | ) 22 | ); 23 | } -------------------------------------------------------------------------------- /public/app/src/shared/infra/redux/startupScript.tsx: -------------------------------------------------------------------------------- 1 | import { Store } from "redux" 2 | import { getUserProfile } from "../../../modules/users/redux/operators" 3 | 4 | function initialReduxStartupScript (store: Store) : void { 5 | //@ts-ignore 6 | store.dispatch(getUserProfile(store.dispatch)) 7 | } 8 | 9 | export { 10 | initialReduxStartupScript 11 | } -------------------------------------------------------------------------------- /public/app/src/shared/infra/router/AuthenticatedRoute.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import { Redirect, Route } from 'react-router-dom' 4 | import { UsersState } from '../../../modules/users/redux/states'; 5 | //@ts-ignore 6 | import { connect } from "react-redux"; 7 | import { bindActionCreators } from "redux"; 8 | import * as usersOperators from '../../../modules/users/redux/operators' 9 | 10 | interface AuthenticatedRouteProps { 11 | users: UsersState; 12 | component: any; 13 | path: any; 14 | } 15 | 16 | const AuthenticatedRoute: React.FC = ({ users, component: Component, ...rest }) => { 17 | // Add your own authentication on the below line. 18 | const isLoggedIn = users.isAuthenticated; 19 | 20 | return ( 21 | 24 | isLoggedIn ? ( 25 | 26 | ) : ( 27 | 28 | ) 29 | } 30 | /> 31 | ) 32 | } 33 | 34 | function mapStateToProps ({ users }: { users: UsersState }) { 35 | return { 36 | users 37 | }; 38 | } 39 | 40 | function mapActionCreatorsToProps(dispatch: any) { 41 | return bindActionCreators( 42 | { 43 | ...usersOperators, 44 | }, dispatch); 45 | } 46 | 47 | export default connect(mapStateToProps, mapActionCreatorsToProps)( 48 | AuthenticatedRoute 49 | ); -------------------------------------------------------------------------------- /public/app/src/shared/infra/router/UnauthenticatedRoute.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import { Redirect, Route } from 'react-router-dom' 4 | //@ts-ignore 5 | import { connect } from "react-redux"; 6 | import { bindActionCreators } from "redux"; 7 | import * as usersOperators from '../../../modules/users/redux/operators' 8 | import { UsersState } from '../../../modules/users/redux/states'; 9 | 10 | interface UnAuthenticatedRouteProps { 11 | users: UsersState; 12 | component: any; 13 | path: any; 14 | } 15 | 16 | /** 17 | * This route is only visible to users who are not currently authenticted. 18 | */ 19 | 20 | const UnauthenticatedRoute: React.FC = ({ users, component: Component, ...rest }) => { 21 | // Add your own authentication on the below line. 22 | const isLoggedIn = users.isAuthenticated; 23 | 24 | return ( 25 | 28 | !isLoggedIn ? ( 29 | 30 | ) : ( 31 | 32 | ) 33 | } 34 | /> 35 | ) 36 | } 37 | 38 | function mapStateToProps ({ users }: { users: UsersState }) { 39 | return { 40 | users 41 | }; 42 | } 43 | 44 | function mapActionCreatorsToProps(dispatch: any) { 45 | return bindActionCreators( 46 | { 47 | ...usersOperators, 48 | }, dispatch); 49 | } 50 | 51 | export default connect(mapStateToProps, mapActionCreatorsToProps)( 52 | UnauthenticatedRoute 53 | ); -------------------------------------------------------------------------------- /public/app/src/shared/infra/services/APIErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | 2 | export type APIErrorMessage = string; -------------------------------------------------------------------------------- /public/app/src/shared/infra/services/APIResponse.tsx: -------------------------------------------------------------------------------- 1 | import { APIErrorMessage } from "./APIErrorMessage"; 2 | import { Either } from "../../core/Either"; 3 | import { Result } from "../../core/Result"; 4 | 5 | export type APIResponse = Either>; -------------------------------------------------------------------------------- /public/app/src/shared/layout/Layout.sass: -------------------------------------------------------------------------------- 1 | 2 | .app-layout 3 | max-width: 800px; 4 | margin: 0 auto; 5 | padding-top: 1rem; 6 | padding-bottom: 1rem; 7 | 8 | .app-layout-inner 9 | // background: #F9F9F9; 10 | // border-radius: 0.25rem; 11 | 12 | .header-container 13 | @media (max-width: 500px) 14 | flex-direction: column-reverse; 15 | 16 | .header 17 | margin-top: 1rem; 18 | flex-direction: column; 19 | text-align: center; 20 | padding-left: 1rem; 21 | padding-right: 1rem; 22 | 23 | h1 24 | font-size: 1.5rem; 25 | 26 | p 27 | font-size: 1rem; 28 | 29 | body 30 | @media (max-width: 500px) 31 | margin: 0.5rem !important; 32 | 33 | .app-layout 34 | padding: 1rem; -------------------------------------------------------------------------------- /public/app/src/shared/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import Helmet from 'react-helmet'; 4 | import { ToastContainer } from 'react-toastify'; 5 | import 'react-toastify/dist/ReactToastify.css'; 6 | import "react-loader-spinner/dist/loader/css/react-spinner-loader.css" 7 | import "./Layout.sass" 8 | import { siteMetaData } from '../../config/siteMetaData'; 9 | import withUsersService from '../../modules/users/hocs/withUsersService'; 10 | import { UsersService } from '../../modules/users/services/userService'; 11 | 12 | interface LayoutProps { 13 | usersService: UsersService; 14 | } 15 | 16 | class Layout extends React.Component { 17 | constructor (props: LayoutProps) { 18 | super(props); 19 | } 20 | 21 | render () { 22 | return ( 23 |
24 |
25 | { 26 | //@ts-ignore 27 | 28 | {siteMetaData.title} 29 | {/* TODO: The rest */} 30 | 31 | 32 | 33 | } 34 | 35 | {this.props.children} 36 |
37 |
38 | ) 39 | } 40 | } 41 | 42 | export default withUsersService(Layout); -------------------------------------------------------------------------------- /public/app/src/shared/layout/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import Layout from "./Layout"; 3 | 4 | export { 5 | Layout 6 | } -------------------------------------------------------------------------------- /public/app/src/shared/utils/TextUtil.tsx: -------------------------------------------------------------------------------- 1 | 2 | //@ts-ignore 3 | import psl from 'psl' 4 | 5 | export class TextUtil { 6 | 7 | public static validateEmail(email: string) { 8 | var re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; 9 | return re.test(String(email).toLowerCase()); 10 | } 11 | 12 | public static atLeast (text: string, length: number): boolean { 13 | if (!!text === false || text.length >= length) return false; 14 | return true; 15 | } 16 | 17 | public static atMost (text: string, length: number): boolean { 18 | if (!!text === false || text.length <= length) return false; 19 | return true; 20 | } 21 | 22 | public static getDomainNameFromUrl (url: string): string { 23 | if (!!url === false) return ""; 24 | var hostname; 25 | //find & remove protocol (http, ftp, etc.) and get hostname 26 | 27 | if (url.indexOf("//") > -1) { 28 | hostname = url.split('/')[2]; 29 | } 30 | else { 31 | hostname = url.split('/')[0]; 32 | } 33 | 34 | //find & remove port number 35 | hostname = hostname.split(':')[0]; 36 | //find & remove "?" 37 | hostname = hostname.split('?')[0]; 38 | 39 | return psl.get(hostname); 40 | } 41 | } -------------------------------------------------------------------------------- /public/app/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 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /scripts/db/create.js: -------------------------------------------------------------------------------- 1 | const mysql = require('mysql2'); 2 | 3 | require('dotenv').config(); 4 | 5 | const { 6 | DDD_FORUM_DB_USER, 7 | DDD_FORUM_DB_PASS, 8 | DDD_FORUM_DB_HOST, 9 | DDD_FORUM_DB_DEV_DB_NAME, 10 | DDD_FORUM_DB_TEST_DB_NAME, 11 | NODE_ENV 12 | } = process.env; 13 | 14 | const dbName = NODE_ENV === "development" 15 | ? DDD_FORUM_DB_DEV_DB_NAME 16 | : DDD_FORUM_DB_TEST_DB_NAME 17 | 18 | const connection = mysql.createConnection({ 19 | host: DDD_FORUM_DB_HOST, 20 | user: DDD_FORUM_DB_USER, 21 | password: DDD_FORUM_DB_PASS 22 | }); 23 | 24 | connection.connect((err) => { 25 | if (err) throw err; 26 | connection.query(`CREATE DATABASE ${dbName}`, (err, result) => { 27 | 28 | if (err && err.code === "ER_DB_CREATE_EXISTS") { 29 | console.log('Db already created'); 30 | process.exit(0); 31 | } 32 | 33 | if (err) { 34 | throw err; 35 | } 36 | 37 | console.log('Created db'); 38 | process.exit(0); 39 | }) 40 | }) -------------------------------------------------------------------------------- /scripts/db/delete.js: -------------------------------------------------------------------------------- 1 | const mysql = require('mysql2'); 2 | 3 | require('dotenv').config(); 4 | 5 | const { 6 | DDD_FORUM_DB_USER, 7 | DDD_FORUM_DB_PASS, 8 | DDD_FORUM_DB_HOST, 9 | DDD_FORUM_DB_DEV_DB_NAME, 10 | DDD_FORUM_DB_TEST_DB_NAME, 11 | NODE_ENV 12 | } = process.env; 13 | 14 | const dbName = NODE_ENV === "development" 15 | ? DDD_FORUM_DB_DEV_DB_NAME 16 | : DDD_FORUM_DB_TEST_DB_NAME; 17 | 18 | const connection = mysql.createConnection({ 19 | host: DDD_FORUM_DB_HOST, 20 | user: DDD_FORUM_DB_USER, 21 | password: DDD_FORUM_DB_PASS 22 | }); 23 | 24 | connection.connect((err) => { 25 | if (err) throw err; 26 | connection.query(`DROP SCHEMA ${dbName}`, (err, result) => { 27 | if (err && err.code === "ER_DB_DROP_EXISTS") { 28 | console.log("Already deleted"); 29 | process.exit(0); 30 | } 31 | 32 | if (err) throw err; 33 | 34 | console.log('Deleted db'); 35 | process.exit(0); 36 | }) 37 | }) -------------------------------------------------------------------------------- /src/config/auth.ts: -------------------------------------------------------------------------------- 1 | const authConfig = { 2 | secret: process.env.DDD_FORUM_APP_SECRET, 3 | tokenExpiryTime: 300, // seconds => 5 minutes 4 | redisServerPort: process.env.DDD_FORUM_REDIS_PORT || 6379, 5 | redisServerURL: process.env.DDD_FORUM_REDIS_URL, 6 | redisConnectionString: process.env.REDIS_URL, 7 | }; 8 | 9 | export { authConfig }; 10 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import { authConfig } from "./auth"; 2 | 3 | const isProduction = process.env.DDD_FORUM_IS_PRODUCTION === "true"; 4 | 5 | export { isProduction, authConfig }; 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Infra 2 | import "./shared/infra/http/app"; 3 | import "./shared/infra/database/sequelize"; 4 | 5 | // Subscriptions 6 | import "./modules/forum/subscriptions"; 7 | -------------------------------------------------------------------------------- /src/modules/forum/domain/commentId.ts: -------------------------------------------------------------------------------- 1 | import { UniqueEntityID } from "../../../shared/domain/UniqueEntityID"; 2 | import { Result } from "../../../shared/core/Result"; 3 | import { Entity } from "../../../shared/domain/Entity"; 4 | 5 | export class CommentId extends Entity { 6 | get id(): UniqueEntityID { 7 | return this._id; 8 | } 9 | 10 | private constructor(id?: UniqueEntityID) { 11 | super(null, id); 12 | } 13 | 14 | public static create(id?: UniqueEntityID): Result { 15 | return Result.ok(new CommentId(id)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/forum/domain/commentText.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from "../../../shared/domain/ValueObject"; 2 | import { Result } from "../../../shared/core/Result"; 3 | import { Guard } from "../../../shared/core/Guard"; 4 | 5 | interface CommentTextProps { 6 | value: string; 7 | } 8 | 9 | export class CommentText extends ValueObject { 10 | public static minLength: number = 2; 11 | public static maxLength: number = 10000; 12 | 13 | get value(): string { 14 | return this.props.value; 15 | } 16 | 17 | private constructor(props: CommentTextProps) { 18 | super(props); 19 | } 20 | 21 | public static create(props: CommentTextProps): Result { 22 | const nullGuardResult = Guard.againstNullOrUndefined( 23 | props.value, 24 | "commentText" 25 | ); 26 | 27 | if (!nullGuardResult.succeeded) { 28 | return Result.fail(nullGuardResult.message); 29 | } 30 | 31 | const minGuardResult = Guard.againstAtLeast(this.minLength, props.value); 32 | const maxGuardResult = Guard.againstAtMost(this.maxLength, props.value); 33 | 34 | if (!minGuardResult.succeeded) { 35 | return Result.fail(minGuardResult.message); 36 | } 37 | 38 | if (!maxGuardResult.succeeded) { 39 | return Result.fail(maxGuardResult.message); 40 | } 41 | 42 | return Result.ok(new CommentText(props)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/modules/forum/domain/commentVotes.ts: -------------------------------------------------------------------------------- 1 | import { CommentVote } from "./commentVote"; 2 | import { WatchedList } from "../../../shared/domain/WatchedList"; 3 | 4 | export class CommentVotes extends WatchedList { 5 | private constructor(initialVotes: CommentVote[]) { 6 | super(initialVotes); 7 | } 8 | 9 | public compareItems(a: CommentVote, b: CommentVote): boolean { 10 | return a.equals(b); 11 | } 12 | 13 | public static create(initialVotes?: CommentVote[]): CommentVotes { 14 | return new CommentVotes(initialVotes ? initialVotes : []); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/forum/domain/comments.ts: -------------------------------------------------------------------------------- 1 | import { WatchedList } from "../../../shared/domain/WatchedList"; 2 | import { Comment } from "./comment"; 3 | 4 | export class Comments extends WatchedList { 5 | private constructor(initialVotes: Comment[]) { 6 | super(initialVotes); 7 | } 8 | 9 | public compareItems(a: Comment, b: Comment): boolean { 10 | return a.equals(b); 11 | } 12 | 13 | public static create(comments?: Comment[]): Comments { 14 | return new Comments(comments ? comments : []); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/forum/domain/events/commentPosted.ts: -------------------------------------------------------------------------------- 1 | import { IDomainEvent } from "../../../../shared/domain/events/IDomainEvent"; 2 | import { UniqueEntityID } from "../../../../shared/domain/UniqueEntityID"; 3 | import { Comment } from "../comment"; 4 | import { Post } from "../post"; 5 | 6 | export class CommentPosted implements IDomainEvent { 7 | public dateTimeOccurred: Date; 8 | public post: Post; 9 | public comment: Comment; 10 | 11 | constructor(post: Post, comment: Comment) { 12 | this.dateTimeOccurred = new Date(); 13 | this.post = post; 14 | this.comment = comment; 15 | } 16 | 17 | getAggregateId(): UniqueEntityID { 18 | return this.post.id; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/forum/domain/events/commentVotesChanged.ts: -------------------------------------------------------------------------------- 1 | import { IDomainEvent } from "../../../../shared/domain/events/IDomainEvent"; 2 | import { UniqueEntityID } from "../../../../shared/domain/UniqueEntityID"; 3 | import { Post } from "../post"; 4 | import { Comment } from "../comment"; 5 | 6 | export class CommentVotesChanged implements IDomainEvent { 7 | public dateTimeOccurred: Date; 8 | public post: Post; 9 | public comment: Comment; 10 | 11 | constructor(post: Post, comment: Comment) { 12 | this.dateTimeOccurred = new Date(); 13 | this.post = post; 14 | this.comment = comment; 15 | } 16 | 17 | getAggregateId(): UniqueEntityID { 18 | return this.post.id; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/forum/domain/events/memberCreated.ts: -------------------------------------------------------------------------------- 1 | import { IDomainEvent } from "../../../../shared/domain/events/IDomainEvent"; 2 | import { UniqueEntityID } from "../../../../shared/domain/UniqueEntityID"; 3 | import { Member } from "../member"; 4 | 5 | export class MemberCreated implements IDomainEvent { 6 | public dateTimeOccurred: Date; 7 | public member: Member; 8 | 9 | constructor(member: Member) { 10 | this.dateTimeOccurred = new Date(); 11 | this.member = member; 12 | } 13 | 14 | getAggregateId(): UniqueEntityID { 15 | return this.member.id; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/forum/domain/events/postCreated.ts: -------------------------------------------------------------------------------- 1 | import { IDomainEvent } from "../../../../shared/domain/events/IDomainEvent"; 2 | import { UniqueEntityID } from "../../../../shared/domain/UniqueEntityID"; 3 | import { Post } from "../post"; 4 | 5 | export class PostCreated implements IDomainEvent { 6 | public dateTimeOccurred: Date; 7 | public post: Post; 8 | 9 | constructor(post: Post) { 10 | this.dateTimeOccurred = new Date(); 11 | this.post = post; 12 | } 13 | 14 | getAggregateId(): UniqueEntityID { 15 | return this.post.id; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/forum/domain/events/postVotesChanged.ts: -------------------------------------------------------------------------------- 1 | import { IDomainEvent } from "../../../../shared/domain/events/IDomainEvent"; 2 | import { UniqueEntityID } from "../../../../shared/domain/UniqueEntityID"; 3 | import { Post } from "../post"; 4 | import { PostVote } from "../postVote"; 5 | 6 | export class PostVotesChanged implements IDomainEvent { 7 | public dateTimeOccurred: Date; 8 | public post: Post; 9 | public vote: PostVote; 10 | 11 | constructor(post: Post, vote: PostVote) { 12 | this.dateTimeOccurred = new Date(); 13 | this.post = post; 14 | this.vote = vote; 15 | } 16 | 17 | getAggregateId(): UniqueEntityID { 18 | return this.post.id; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/forum/domain/member.spec.ts: -------------------------------------------------------------------------------- 1 | test("basic", () => { 2 | expect(1).toBe(1); 3 | }); 4 | 5 | test("basic again", () => { 6 | expect(2).toBe(2); 7 | }); 8 | -------------------------------------------------------------------------------- /src/modules/forum/domain/memberId.ts: -------------------------------------------------------------------------------- 1 | import { UniqueEntityID } from "../../../shared/domain/UniqueEntityID"; 2 | import { Result } from "../../../shared/core/Result"; 3 | import { Entity } from "../../../shared/domain/Entity"; 4 | 5 | export class MemberId extends Entity { 6 | get id(): UniqueEntityID { 7 | return this._id; 8 | } 9 | 10 | private constructor(id?: UniqueEntityID) { 11 | super(null, id); 12 | } 13 | 14 | public static create(id?: UniqueEntityID): Result { 15 | return Result.ok(new MemberId(id)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/forum/domain/postId.ts: -------------------------------------------------------------------------------- 1 | import { UniqueEntityID } from "../../../shared/domain/UniqueEntityID"; 2 | import { Result } from "../../../shared/core/Result"; 3 | import { Entity } from "../../../shared/domain/Entity"; 4 | 5 | export class PostId extends Entity { 6 | get id(): UniqueEntityID { 7 | return this._id; 8 | } 9 | 10 | private constructor(id?: UniqueEntityID) { 11 | super(null, id); 12 | } 13 | 14 | public static create(id?: UniqueEntityID): Result { 15 | return Result.ok(new PostId(id)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/forum/domain/postLink.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from "../../../shared/domain/ValueObject"; 2 | import { Result } from "../../../shared/core/Result"; 3 | import { Guard } from "../../../shared/core/Guard"; 4 | import { TextUtils } from "../../../shared/utils/TextUtils"; 5 | 6 | interface PostLinkProps { 7 | url: string; 8 | } 9 | 10 | export class PostLink extends ValueObject { 11 | get url(): string { 12 | return this.props.url; 13 | } 14 | 15 | private constructor(props: PostLinkProps) { 16 | super(props); 17 | } 18 | 19 | public static create(props: PostLinkProps): Result { 20 | const nullGuard = Guard.againstNullOrUndefined(props.url, "url"); 21 | 22 | if (!nullGuard.succeeded) { 23 | return Result.fail(nullGuard.message); 24 | } 25 | 26 | if (!TextUtils.validateWebURL(props.url)) { 27 | return Result.fail(`Url {${props.url}} is not valid.`); 28 | } 29 | 30 | return Result.ok(new PostLink(props)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/modules/forum/domain/postSlug.spec.ts: -------------------------------------------------------------------------------- 1 | import { PostSlug } from "./postSlug"; 2 | import { Result } from "../../../shared/core/Result"; 3 | import { PostTitle } from "./postTitle"; 4 | 5 | let postSlug: PostSlug; 6 | let postSlugOrError: Result; 7 | let postTitle: PostTitle; 8 | let postTitleOrError: Result; 9 | 10 | test("Should be able to create a post slug", () => { 11 | postTitleOrError = PostTitle.create({ value: "HTML Developers" }); 12 | expect(postTitleOrError.isSuccess).toBe(true); 13 | postTitle = postTitleOrError.getValue(); 14 | postSlugOrError = PostSlug.create(postTitle); 15 | expect(postSlugOrError.isSuccess).toBe(true); 16 | postSlug = postSlugOrError.getValue(); 17 | expect(postSlug.value).toContain("html-developers"); 18 | }); 19 | 20 | test("Should be able to parse out any bad characters not suitable for a slug", () => { 21 | postTitleOrError = PostTitle.create({ value: "K^ha^l#il^^#'s Job" }); 22 | expect(postTitleOrError.isSuccess).toBe(true); 23 | postTitle = postTitleOrError.getValue(); 24 | postSlugOrError = PostSlug.create(postTitle); 25 | expect(postSlugOrError.isSuccess).toBe(true); 26 | postSlug = postSlugOrError.getValue(); 27 | expect(postSlug.value).toContain("khalils-job"); 28 | }); 29 | -------------------------------------------------------------------------------- /src/modules/forum/domain/postText.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from "../../../shared/domain/ValueObject"; 2 | import { Result } from "../../../shared/core/Result"; 3 | import { Guard } from "../../../shared/core/Guard"; 4 | 5 | interface PostTextProps { 6 | value: string; 7 | } 8 | 9 | export class PostText extends ValueObject { 10 | public static minLength: number = 2; 11 | public static maxLength: number = 10000; 12 | 13 | get value(): string { 14 | return this.props.value; 15 | } 16 | 17 | private constructor(props: PostTextProps) { 18 | super(props); 19 | } 20 | 21 | public static create(props: PostTextProps): Result { 22 | const nullGuardResult = Guard.againstNullOrUndefined( 23 | props.value, 24 | "postText" 25 | ); 26 | 27 | if (!nullGuardResult.succeeded) { 28 | return Result.fail(nullGuardResult.message); 29 | } 30 | 31 | const minGuardResult = Guard.againstAtLeast(this.minLength, props.value); 32 | const maxGuardResult = Guard.againstAtMost(this.maxLength, props.value); 33 | 34 | if (!minGuardResult.succeeded) { 35 | return Result.fail(minGuardResult.message); 36 | } 37 | 38 | if (!maxGuardResult.succeeded) { 39 | return Result.fail(maxGuardResult.message); 40 | } 41 | 42 | return Result.ok(new PostText(props)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/modules/forum/domain/postTitle.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from "../../../shared/domain/ValueObject"; 2 | import { Result } from "../../../shared/core/Result"; 3 | import { Guard } from "../../../shared/core/Guard"; 4 | 5 | interface PostTitleProps { 6 | value: string; 7 | } 8 | 9 | export class PostTitle extends ValueObject { 10 | public static minLength: number = 2; 11 | public static maxLength: number = 85; 12 | 13 | get value(): string { 14 | return this.props.value; 15 | } 16 | 17 | private constructor(props: PostTitleProps) { 18 | super(props); 19 | } 20 | 21 | public static create(props: PostTitleProps): Result { 22 | const nullGuardResult = Guard.againstNullOrUndefined( 23 | props.value, 24 | "postTitle" 25 | ); 26 | 27 | if (!nullGuardResult.succeeded) { 28 | return Result.fail(nullGuardResult.message); 29 | } 30 | 31 | const minGuardResult = Guard.againstAtLeast(this.minLength, props.value); 32 | const maxGuardResult = Guard.againstAtMost(this.maxLength, props.value); 33 | 34 | if (!minGuardResult.succeeded) { 35 | return Result.fail(minGuardResult.message); 36 | } 37 | 38 | if (!maxGuardResult.succeeded) { 39 | return Result.fail(maxGuardResult.message); 40 | } 41 | 42 | return Result.ok(new PostTitle(props)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/modules/forum/domain/postType.ts: -------------------------------------------------------------------------------- 1 | export type PostType = "text" | "link"; 2 | -------------------------------------------------------------------------------- /src/modules/forum/domain/postVotes.ts: -------------------------------------------------------------------------------- 1 | import { PostVote } from "./postVote"; 2 | import { WatchedList } from "../../../shared/domain/WatchedList"; 3 | 4 | export class PostVotes extends WatchedList { 5 | private constructor(initialVotes: PostVote[]) { 6 | super(initialVotes); 7 | } 8 | 9 | public compareItems(a: PostVote, b: PostVote): boolean { 10 | return a.equals(b); 11 | } 12 | 13 | public static create(initialVotes?: PostVote[]): PostVotes { 14 | return new PostVotes(initialVotes ? initialVotes : []); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/forum/domain/services/index.ts: -------------------------------------------------------------------------------- 1 | import { PostService } from "./postService"; 2 | 3 | const postService = new PostService(); 4 | 5 | export { postService }; 6 | -------------------------------------------------------------------------------- /src/modules/forum/domain/vote.ts: -------------------------------------------------------------------------------- 1 | export type VoteType = "UPVOTE" | "DOWNVOTE"; 2 | -------------------------------------------------------------------------------- /src/modules/forum/dtos/commentDTO.ts: -------------------------------------------------------------------------------- 1 | import { MemberDTO } from "./memberDTO"; 2 | 3 | export interface CommentDTO { 4 | postSlug: string; 5 | postTitle: string; 6 | commentId: string; 7 | parentCommentId?: string; 8 | text: string; 9 | member: MemberDTO; 10 | createdAt: string | Date; 11 | childComments: CommentDTO[]; 12 | points: number; 13 | wasUpvotedByMe: boolean; 14 | wasDownvotedByMe: boolean; 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/forum/dtos/memberDTO.ts: -------------------------------------------------------------------------------- 1 | import { UserDTO } from "../../users/dtos/userDTO"; 2 | 3 | export interface MemberDTO { 4 | reputation: number; 5 | user: UserDTO; 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/forum/dtos/postDTO.ts: -------------------------------------------------------------------------------- 1 | import { MemberDTO } from "./memberDTO"; 2 | import { PostType } from "../domain/postType"; 3 | 4 | export interface PostDTO { 5 | slug: string; 6 | title: string; 7 | createdAt: string | Date; 8 | memberPostedBy: MemberDTO; 9 | numComments: number; 10 | points: number; 11 | text: string; 12 | link: string; 13 | type: PostType; 14 | wasUpvotedByMe: boolean; 15 | wasDownvotedByMe: boolean; 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/forum/infra/http/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { memberRouter } from "./member"; 2 | import { commentRouter } from "./comment"; 3 | import { postRouter } from "./post"; 4 | 5 | export { memberRouter, commentRouter, postRouter }; 6 | -------------------------------------------------------------------------------- /src/modules/forum/infra/http/routes/member.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { getMemberByUserNameController } from "../../../useCases/members/getMemberByUserName"; 3 | import { getCurrentMemberController } from "../../../useCases/members/getCurrentMember"; 4 | 5 | const memberRouter = express.Router(); 6 | 7 | memberRouter.get("/me", (req, res) => 8 | getCurrentMemberController.execute(req, res) 9 | ); 10 | 11 | memberRouter.get("/:username", (req, res) => 12 | getMemberByUserNameController.execute(req, res) 13 | ); 14 | 15 | export { memberRouter }; 16 | -------------------------------------------------------------------------------- /src/modules/forum/infra/http/routes/post.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { middleware } from "../../../../../shared/infra/http"; 3 | import { createPostController } from "../../../useCases/post/createPost"; 4 | import { getRecentPostsController } from "../../../useCases/post/getRecentPosts"; 5 | import { getPostBySlugController } from "../../../useCases/post/getPostBySlug"; 6 | import { getPopularPostsController } from "../../../useCases/post/getPopularPosts"; 7 | import { upvotePostController } from "../../../useCases/post/upvotePost"; 8 | import { downvotePostController } from "../../../useCases/post/downvotePost"; 9 | 10 | const postRouter = express.Router(); 11 | 12 | postRouter.post("/", middleware.ensureAuthenticated(), (req, res) => 13 | createPostController.execute(req, res) 14 | ); 15 | 16 | postRouter.get( 17 | "/recent", 18 | middleware.includeDecodedTokenIfExists(), 19 | (req, res) => getRecentPostsController.execute(req, res) 20 | ); 21 | 22 | postRouter.get( 23 | "/popular", 24 | middleware.includeDecodedTokenIfExists(), 25 | (req, res) => getPopularPostsController.execute(req, res) 26 | ); 27 | 28 | postRouter.get("/", middleware.includeDecodedTokenIfExists(), (req, res) => 29 | getPostBySlugController.execute(req, res) 30 | ); 31 | 32 | postRouter.post("/upvote", middleware.ensureAuthenticated(), (req, res) => 33 | upvotePostController.execute(req, res) 34 | ); 35 | 36 | postRouter.post("/downvote", middleware.ensureAuthenticated(), (req, res) => 37 | downvotePostController.execute(req, res) 38 | ); 39 | 40 | export { postRouter }; 41 | -------------------------------------------------------------------------------- /src/modules/forum/mappers/commentVoteMap.ts: -------------------------------------------------------------------------------- 1 | import { Mapper } from "../../../shared/infra/Mapper"; 2 | import { CommentVote } from "../domain/commentVote"; 3 | import { MemberId } from "../domain/memberId"; 4 | import { UniqueEntityID } from "../../../shared/domain/UniqueEntityID"; 5 | import { CommentId } from "../domain/commentId"; 6 | import { VoteType } from "../domain/vote"; 7 | 8 | export class CommentVoteMap implements Mapper { 9 | public static toDomain(raw: any): CommentVote { 10 | const voteType: VoteType = raw.type; 11 | 12 | const commentVoteOrError = CommentVote.create( 13 | { 14 | memberId: MemberId.create(new UniqueEntityID(raw.member_id)).getValue(), 15 | commentId: CommentId.create( 16 | new UniqueEntityID(raw.comment_id) 17 | ).getValue(), 18 | type: voteType, 19 | }, 20 | new UniqueEntityID(raw.comment_vote_id) 21 | ); 22 | 23 | commentVoteOrError.isFailure ? console.log(commentVoteOrError.error) : ""; 24 | 25 | return commentVoteOrError.isSuccess ? commentVoteOrError.getValue() : null; 26 | } 27 | 28 | public static toPersistence(vote: CommentVote): any { 29 | return { 30 | comment_vote_id: vote.id.toString(), 31 | comment_id: vote.commentId.id.toString(), 32 | member_id: vote.memberId.id.toString(), 33 | type: vote.type, 34 | }; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/forum/mappers/memberDetailsMap.ts: -------------------------------------------------------------------------------- 1 | import { Mapper } from "../../../shared/infra/Mapper"; 2 | import { MemberDetails } from "../domain/memberDetails"; 3 | import { UserName } from "../../users/domain/userName"; 4 | import { MemberDTO } from "../dtos/memberDTO"; 5 | 6 | export class MemberDetailsMap implements Mapper { 7 | public static toDomain(raw: any): MemberDetails { 8 | const userNameOrError = UserName.create({ name: raw.BaseUser.username }); 9 | 10 | const memberDetailsOrError = MemberDetails.create({ 11 | reputation: raw.reputation, 12 | username: userNameOrError.getValue(), 13 | }); 14 | 15 | memberDetailsOrError.isFailure 16 | ? console.log(memberDetailsOrError.error) 17 | : ""; 18 | 19 | return memberDetailsOrError.isSuccess 20 | ? memberDetailsOrError.getValue() 21 | : null; 22 | } 23 | 24 | public static toDTO(memberDetails: MemberDetails): MemberDTO { 25 | return { 26 | reputation: memberDetails.reputation, 27 | user: { 28 | username: memberDetails.username.value, 29 | }, 30 | }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/modules/forum/mappers/memberIdMap.ts: -------------------------------------------------------------------------------- 1 | import { Mapper } from "../../../shared/infra/Mapper"; 2 | import { UniqueEntityID } from "../../../shared/domain/UniqueEntityID"; 3 | import { MemberId } from "../domain/memberId"; 4 | 5 | export class MemberIdMap implements Mapper { 6 | public static toDomain(rawMember: any): MemberId { 7 | const memberIdOrError = MemberId.create( 8 | new UniqueEntityID(rawMember.member_id) 9 | ); 10 | return memberIdOrError.isSuccess ? memberIdOrError.getValue() : null; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/forum/mappers/memberMap.ts: -------------------------------------------------------------------------------- 1 | import { Mapper } from "../../../shared/infra/Mapper"; 2 | import { Member } from "../domain/member"; 3 | import { MemberDTO } from "../dtos/memberDTO"; 4 | import { UniqueEntityID } from "../../../shared/domain/UniqueEntityID"; 5 | import { UserName } from "../../users/domain/userName"; 6 | import { UserId } from "../../users/domain/userId"; 7 | 8 | export class MemberMap implements Mapper { 9 | public static toDomain(raw: any): Member { 10 | const userNameOrError = UserName.create({ name: raw.BaseUser.username }); 11 | const userIdOrError = UserId.create( 12 | new UniqueEntityID(raw.BaseUser.base_user_id) 13 | ); 14 | 15 | const memberOrError = Member.create( 16 | { 17 | username: userNameOrError.getValue(), 18 | reputation: raw.reputation, 19 | userId: userIdOrError.getValue(), 20 | }, 21 | new UniqueEntityID(raw.member_id) 22 | ); 23 | 24 | memberOrError.isFailure ? console.log(memberOrError.error) : ""; 25 | 26 | return memberOrError.isSuccess ? memberOrError.getValue() : null; 27 | } 28 | 29 | public static toPersistence(member: Member): any { 30 | return { 31 | member_id: member.memberId.id.toString(), 32 | member_base_id: member.userId.id.toString(), 33 | reputation: member.reputation, 34 | }; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/forum/mappers/postVoteMap.ts: -------------------------------------------------------------------------------- 1 | import { Mapper } from "../../../shared/infra/Mapper"; 2 | import { PostVote } from "../domain/postVote"; 3 | import { MemberId } from "../domain/memberId"; 4 | import { UniqueEntityID } from "../../../shared/domain/UniqueEntityID"; 5 | import { PostId } from "../domain/postId"; 6 | import { VoteType } from "../domain/vote"; 7 | 8 | export class PostVoteMap implements Mapper { 9 | public static toDomain(raw: any): PostVote { 10 | const voteType: VoteType = raw.type; 11 | 12 | const postVoteOrError = PostVote.create( 13 | { 14 | memberId: MemberId.create(new UniqueEntityID(raw.member_id)).getValue(), 15 | postId: PostId.create(new UniqueEntityID(raw.post_id)).getValue(), 16 | type: voteType, 17 | }, 18 | new UniqueEntityID(raw.post_vote_id) 19 | ); 20 | 21 | postVoteOrError.isFailure ? console.log(postVoteOrError.error) : ""; 22 | 23 | return postVoteOrError.isSuccess ? postVoteOrError.getValue() : null; 24 | } 25 | 26 | public static toPersistence(vote: PostVote): any { 27 | return { 28 | post_vote_id: vote.id.toString(), 29 | post_id: vote.postId.id.toString(), 30 | member_id: vote.memberId.id.toString(), 31 | type: vote.type, 32 | }; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/modules/forum/repos/commentRepo.ts: -------------------------------------------------------------------------------- 1 | import { Comment } from "../domain/comment"; 2 | import { CommentDetails } from "../domain/commentDetails"; 3 | import { CommentId } from "../domain/commentId"; 4 | import { MemberId } from "../domain/memberId"; 5 | 6 | export interface ICommentRepo { 7 | exists(commentId: string): Promise; 8 | getCommentDetailsByPostSlug( 9 | slug: string, 10 | memberId?: MemberId, 11 | offset?: number 12 | ): Promise; 13 | getCommentDetailsByCommentId( 14 | commentId: string, 15 | memberId?: MemberId 16 | ): Promise; 17 | getCommentByCommentId(commentId: string): Promise; 18 | save(comment: Comment): Promise; 19 | saveBulk(comments: Comment[]): Promise; 20 | deleteComment(commentId: CommentId): Promise; 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/forum/repos/commentVotesRepo.ts: -------------------------------------------------------------------------------- 1 | import { CommentVote } from "../domain/commentVote"; 2 | import { MemberId } from "../domain/memberId"; 3 | import { CommentId } from "../domain/commentId"; 4 | import { CommentVotes } from "../domain/commentVotes"; 5 | import { VoteType } from "../domain/vote"; 6 | import { PostId } from "../domain/postId"; 7 | 8 | export interface ICommentVotesRepo { 9 | exists( 10 | commentId: CommentId, 11 | memberId: MemberId, 12 | voteType: VoteType 13 | ): Promise; 14 | getVotesForCommentByMemberId( 15 | commentId: CommentId, 16 | memberId: MemberId 17 | ): Promise; 18 | countUpvotesForCommentByCommentId(comment: CommentId): Promise; 19 | countDownvotesForCommentByCommentId(comment: CommentId): Promise; 20 | countAllPostCommentUpvotes(postId: PostId): Promise; 21 | countAllPostCommentDownvotes(postId: PostId): Promise; 22 | countAllPostCommentUpvotesExcludingOP(postId: PostId): Promise; 23 | countAllPostCommentDownvotesExcludingOP(postId: PostId): Promise; 24 | saveBulk(votes: CommentVotes): Promise; 25 | save(vote: CommentVote): Promise; 26 | delete(vote: CommentVote): Promise; 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/forum/repos/index.ts: -------------------------------------------------------------------------------- 1 | import { MemberRepo } from "./implementations/sequelizeMemberRepo"; 2 | import models from "../../../shared/infra/database/sequelize/models"; 3 | import { PostRepo } from "./implementations/sequelizePostRepo"; 4 | import { CommentRepo } from "./implementations/commentRepo"; 5 | import { PostVotesRepo } from "./implementations/sequelizePostVotesRepo"; 6 | import { CommentVotesRepo } from "./implementations/sequelizeCommentVotesRepo"; 7 | 8 | const commentVotesRepo = new CommentVotesRepo(models); 9 | const postVotesRepo = new PostVotesRepo(models); 10 | const memberRepo = new MemberRepo(models); 11 | const commentRepo = new CommentRepo(models, commentVotesRepo); 12 | const postRepo = new PostRepo(models, commentRepo, postVotesRepo); 13 | 14 | export { memberRepo, postRepo, commentRepo, postVotesRepo, commentVotesRepo }; 15 | -------------------------------------------------------------------------------- /src/modules/forum/repos/memberRepo.ts: -------------------------------------------------------------------------------- 1 | import { Member } from "../domain/member"; 2 | import { MemberDetails } from "../domain/memberDetails"; 3 | import { MemberId } from "../domain/memberId"; 4 | 5 | export interface IMemberRepo { 6 | exists(userId: string): Promise; 7 | getMemberByUserId(userId: string): Promise; 8 | getMemberIdByUserId(userId: string): Promise; 9 | getMemberByUserName(username: string): Promise; 10 | getMemberDetailsByUserName(username: string): Promise; 11 | getMemberDetailsByPostLinkOrSlug(slug: string): Promise; 12 | save(member: Member): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/forum/repos/postRepo.ts: -------------------------------------------------------------------------------- 1 | import { Post } from "../domain/post"; 2 | import { PostId } from "../domain/postId"; 3 | import { PostDetails } from "../domain/postDetails"; 4 | 5 | export interface IPostRepo { 6 | getPostDetailsBySlug(slug: string): Promise; 7 | getPostBySlug(slug: string): Promise; 8 | getRecentPosts(offset?: number): Promise; 9 | getPopularPosts(offset?: number): Promise; 10 | getNumberOfCommentsByPostId(postId: PostId | string): Promise; 11 | getPostByPostId(postId: PostId | string): Promise; 12 | exists(postId: PostId): Promise; 13 | save(post: Post): Promise; 14 | delete(postId: PostId): Promise; 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/forum/repos/postVotesRepo.ts: -------------------------------------------------------------------------------- 1 | import { PostVote } from "../domain/postVote"; 2 | import { MemberId } from "../domain/memberId"; 3 | import { PostId } from "../domain/postId"; 4 | import { VoteType } from "../domain/vote"; 5 | import { PostVotes } from "../domain/postVotes"; 6 | 7 | export interface IPostVotesRepo { 8 | exists( 9 | postId: PostId, 10 | memberId: MemberId, 11 | voteType: VoteType 12 | ): Promise; 13 | getVotesForPostByMemberId( 14 | postId: PostId, 15 | memberId: MemberId 16 | ): Promise; 17 | countPostUpvotesByPostId(postId: PostId): Promise; 18 | countPostDownvotesByPostId(postId: PostId): Promise; 19 | saveBulk(votes: PostVotes): Promise; 20 | save(votes: PostVote): Promise; 21 | delete(vote: PostVote): Promise; 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/forum/subscriptions/afterCommentPosted.ts: -------------------------------------------------------------------------------- 1 | import { IHandle } from "../../../shared/domain/events/IHandle"; 2 | import { DomainEvents } from "../../../shared/domain/events/DomainEvents"; 3 | import { CommentPosted } from "../domain/events/commentPosted"; 4 | import { UpdatePostStats } from "../useCases/post/updatePostStats/UpdatePostStats"; 5 | 6 | export class AfterCommentPosted implements IHandle { 7 | private updatePostStats: UpdatePostStats; 8 | 9 | constructor(updatePostStats: UpdatePostStats) { 10 | this.setupSubscriptions(); 11 | this.updatePostStats = updatePostStats; 12 | } 13 | 14 | setupSubscriptions(): void { 15 | // Register to the domain event 16 | DomainEvents.register(this.onCommentPosted.bind(this), CommentPosted.name); 17 | } 18 | 19 | private async onCommentPosted(event: CommentPosted): Promise { 20 | try { 21 | await this.updatePostStats.execute({ 22 | postId: event.post.postId.id.toString(), 23 | }); 24 | console.log( 25 | `[AfterCommentPosted]: Updated post stats for {${event.post.title.value}}` 26 | ); 27 | } catch (err) { 28 | console.log( 29 | `[AfterCommentPosted]: Failed to update post stats for {${event.post.title.value}}` 30 | ); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/forum/subscriptions/afterPostVotesChanged.ts: -------------------------------------------------------------------------------- 1 | import { IHandle } from "../../../shared/domain/events/IHandle"; 2 | import { DomainEvents } from "../../../shared/domain/events/DomainEvents"; 3 | import { UpdatePostStats } from "../useCases/post/updatePostStats/UpdatePostStats"; 4 | import { CommentVotesChanged } from "../domain/events/commentVotesChanged"; 5 | import { PostId } from "../domain/postId"; 6 | import { PostVotesChanged } from "../domain/events/postVotesChanged"; 7 | 8 | export class AfterPostVotesChanged implements IHandle { 9 | private updatePostStats: UpdatePostStats; 10 | 11 | constructor(updatePostStats: UpdatePostStats) { 12 | this.setupSubscriptions(); 13 | this.updatePostStats = updatePostStats; 14 | } 15 | 16 | setupSubscriptions(): void { 17 | // Register to the domain event 18 | DomainEvents.register( 19 | this.onPostVotesChanged.bind(this), 20 | PostVotesChanged.name 21 | ); 22 | } 23 | 24 | private async onPostVotesChanged(event: CommentVotesChanged): Promise { 25 | let postId: PostId = event.post.postId; 26 | try { 27 | // Then, update the post stats 28 | await this.updatePostStats.execute({ postId: postId.id.toString() }); 29 | console.log( 30 | `[AfterPostVotesChanged]: Updated votes on postId={${postId.id.toString()}}` 31 | ); 32 | } catch (err) { 33 | console.log(err); 34 | console.log( 35 | `[AfterPostVotesChanged]: Failed to update votes on postId={${postId.id.toString()}}` 36 | ); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/modules/forum/subscriptions/afterUserCreated.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../../users/domain/user"; 2 | import { UserCreated } from "../../users/domain/events/userCreated"; 3 | import { IHandle } from "../../../shared/domain/events/IHandle"; 4 | import { CreateMember } from "../useCases/members/createMember/CreateMember"; 5 | import { DomainEvents } from "../../../shared/domain/events/DomainEvents"; 6 | 7 | export class AfterUserCreated implements IHandle { 8 | private createMember: CreateMember; 9 | 10 | constructor(createMember: CreateMember) { 11 | this.setupSubscriptions(); 12 | this.createMember = createMember; 13 | } 14 | 15 | setupSubscriptions(): void { 16 | // Register to the domain event 17 | DomainEvents.register(this.onUserCreated.bind(this), UserCreated.name); 18 | } 19 | 20 | private async onUserCreated(event: UserCreated): Promise { 21 | const { user } = event; 22 | 23 | try { 24 | await this.createMember.execute({ userId: user.userId.id.toString() }); 25 | console.log( 26 | `[AfterUserCreated]: Successfully executed CreateMember use case AfterUserCreated` 27 | ); 28 | } catch (err) { 29 | console.log( 30 | `[AfterUserCreated]: Failed to execute CreateMember use case AfterUserCreated.` 31 | ); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/modules/forum/subscriptions/index.ts: -------------------------------------------------------------------------------- 1 | import { createMember } from "../useCases/members/createMember"; 2 | import { AfterUserCreated } from "./afterUserCreated"; 3 | import { AfterCommentPosted } from "./afterCommentPosted"; 4 | import { updatePostStats } from "../useCases/post/updatePostStats"; 5 | import { AfterCommentVotesChanged } from "./afterCommentVotesChanged"; 6 | import { updateCommentStats } from "../useCases/comments/updateCommentStats"; 7 | import { AfterPostVotesChanged } from "./afterPostVotesChanged"; 8 | 9 | // Subscriptions 10 | new AfterUserCreated(createMember); 11 | new AfterCommentPosted(updatePostStats); 12 | new AfterCommentVotesChanged(updatePostStats, updateCommentStats); 13 | new AfterPostVotesChanged(updatePostStats); 14 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/downvoteComment/DownvoteCommentDTO.ts: -------------------------------------------------------------------------------- 1 | export interface DownvoteCommentDTO { 2 | userId: string; 3 | commentId: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/downvoteComment/DownvoteCommentErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../../shared/core/Result"; 3 | 4 | export namespace DownvoteCommentErrors { 5 | export class MemberNotFoundError extends Result { 6 | constructor() { 7 | super(false, { 8 | message: `Couldn't find a member to downvote the comment.`, 9 | } as UseCaseError); 10 | } 11 | } 12 | 13 | export class CommentNotFoundError extends Result { 14 | constructor(commentId: string) { 15 | super(false, { 16 | message: `Couldn't find a comment with id {${commentId}}.`, 17 | } as UseCaseError); 18 | } 19 | } 20 | 21 | export class PostNotFoundError extends Result { 22 | constructor(commentId: string) { 23 | super(false, { 24 | message: `Couldn't find a post for comment {${commentId}}.`, 25 | } as UseCaseError); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/downvoteComment/DownvoteCommentResponse.ts: -------------------------------------------------------------------------------- 1 | import { Either, Result } from "../../../../../shared/core/Result"; 2 | 3 | import { AppError } from "../../../../../shared/core/AppError"; 4 | import { DownvoteCommentErrors } from "./DownvoteCommentErrors"; 5 | 6 | export type DownvoteCommentResponse = Either< 7 | | DownvoteCommentErrors.CommentNotFoundError 8 | | DownvoteCommentErrors.MemberNotFoundError 9 | | DownvoteCommentErrors.PostNotFoundError 10 | | AppError.UnexpectedError 11 | | Result, 12 | Result 13 | >; 14 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/downvoteComment/index.ts: -------------------------------------------------------------------------------- 1 | import { DownvoteComment } from "./DownvoteComment"; 2 | import { 3 | postRepo, 4 | memberRepo, 5 | commentRepo, 6 | commentVotesRepo, 7 | } from "../../../repos"; 8 | import { postService } from "../../../domain/services"; 9 | import { DownvoteCommentController } from "./DownvoteCommentController"; 10 | 11 | const downvoteComment = new DownvoteComment( 12 | postRepo, 13 | memberRepo, 14 | commentRepo, 15 | commentVotesRepo, 16 | postService 17 | ); 18 | 19 | const downvoteCommentController = new DownvoteCommentController( 20 | downvoteComment 21 | ); 22 | 23 | export { downvoteComment, downvoteCommentController }; 24 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/getCommentByCommentId/GetCommentByCommentIdErrors.ts: -------------------------------------------------------------------------------- 1 | import { Result } from "../../../../../shared/core/Result"; 2 | import { UseCaseError } from "../../../../../shared/core/UseCaseError"; 3 | 4 | export namespace GetCommentByCommentIdErrors { 5 | export class CommentNotFoundError extends Result { 6 | constructor(commentId: string) { 7 | super(false, { 8 | message: `Couldn't find a comment by comment id {${commentId}}.`, 9 | } as UseCaseError); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/getCommentByCommentId/GetCommentByCommentIdRequestDTO.ts: -------------------------------------------------------------------------------- 1 | export interface GetCommentByCommentIdRequestDTO { 2 | commentId: string; 3 | userId?: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/getCommentByCommentId/GetCommentByCommentIdResponseDTO.ts: -------------------------------------------------------------------------------- 1 | import { CommentDTO } from "../../../dtos/commentDTO"; 2 | 3 | export interface GetCommentByCommentIdResponseDTO { 4 | comment: CommentDTO; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/getCommentByCommentId/index.ts: -------------------------------------------------------------------------------- 1 | import { GetCommentByCommentId } from "./GetCommentByCommentId"; 2 | import { commentRepo, memberRepo } from "../../../repos"; 3 | import { GetCommentByCommentIdController } from "./GetCommentByCommentIdController"; 4 | 5 | const getCommentByCommentId = new GetCommentByCommentId( 6 | commentRepo, 7 | memberRepo 8 | ); 9 | 10 | const getCommentByCommentIdController = new GetCommentByCommentIdController( 11 | getCommentByCommentId 12 | ); 13 | 14 | export { getCommentByCommentId, getCommentByCommentIdController }; 15 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/getCommentsByPostSlug/GetCommentsByPostSlugErrors.ts: -------------------------------------------------------------------------------- 1 | import { Result } from "../../../../../shared/core/Result"; 2 | import { UseCaseError } from "../../../../../shared/core/UseCaseError"; 3 | 4 | export namespace GetCommentsByPostSlugErrors { 5 | export class PostNotFoundError extends Result { 6 | constructor(slug: string) { 7 | super(false, { 8 | message: `Couldn't find a post by slug {${slug}}.`, 9 | } as UseCaseError); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/getCommentsByPostSlug/GetCommentsByPostSlugRequestDTO.ts: -------------------------------------------------------------------------------- 1 | export interface GetCommentsByPostSlugRequestDTO { 2 | slug: string; 3 | offset?: number; 4 | userId?: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/getCommentsByPostSlug/GetCommentsByPostSlugResponseDTO.ts: -------------------------------------------------------------------------------- 1 | import { CommentDTO } from "../../../dtos/commentDTO"; 2 | 3 | export interface GetCommentsByPostSlugResponseDTO { 4 | comments: CommentDTO[]; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/getCommentsByPostSlug/index.ts: -------------------------------------------------------------------------------- 1 | import { GetCommentsByPostSlug } from "./GetCommentsByPostSlug"; 2 | import { commentRepo, memberRepo } from "../../../repos"; 3 | import { GetCommentsByPostSlugController } from "./GetCommentsByPostSlugController"; 4 | 5 | const getCommentsByPostSlug = new GetCommentsByPostSlug( 6 | commentRepo, 7 | memberRepo 8 | ); 9 | 10 | const getCommentsByPostSlugController = new GetCommentsByPostSlugController( 11 | getCommentsByPostSlug 12 | ); 13 | 14 | export { getCommentsByPostSlug, getCommentsByPostSlugController }; 15 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/replyToComment/ReplyToCommentDTO.ts: -------------------------------------------------------------------------------- 1 | export interface ReplyToCommentDTO { 2 | slug: string; 3 | userId: string; 4 | comment: string; 5 | parentCommentId: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/replyToComment/ReplyToCommentErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../../shared/core/Result"; 3 | 4 | export namespace ReplyToCommentErrors { 5 | export class PostNotFoundError extends Result { 6 | constructor(slug: string) { 7 | super(false, { 8 | message: `Couldn't find a post by slug {${slug}}.`, 9 | } as UseCaseError); 10 | } 11 | } 12 | 13 | export class CommentNotFoundError extends Result { 14 | constructor(commentId: string) { 15 | super(false, { 16 | message: `Couldn't find a comment by commentId {${commentId}}.`, 17 | } as UseCaseError); 18 | } 19 | } 20 | 21 | export class MemberNotFoundError extends Result { 22 | constructor(userId: string) { 23 | super(false, { 24 | message: `Couldn't find a member by userId {${userId}}.`, 25 | } as UseCaseError); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/replyToComment/index.ts: -------------------------------------------------------------------------------- 1 | import { ReplyToComment } from "./ReplyToComment"; 2 | import { memberRepo, postRepo, commentRepo } from "../../../repos"; 3 | import { ReplyToCommentController } from "./ReplyToCommentController"; 4 | import { postService } from "../../../domain/services"; 5 | 6 | const replyToComment = new ReplyToComment( 7 | memberRepo, 8 | postRepo, 9 | commentRepo, 10 | postService 11 | ); 12 | 13 | const replyToCommentController = new ReplyToCommentController(replyToComment); 14 | 15 | export { replyToComment, replyToCommentController }; 16 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/replyToPost/ReplyToPostController.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from "../../../../../shared/infra/http/models/BaseController"; 2 | import { ReplyToPost } from "./ReplyToPost"; 3 | import { DecodedExpressRequest } from "../../../../users/infra/http/models/decodedRequest"; 4 | import { ReplyToPostDTO } from "./ReplyToPostDTO"; 5 | import { ReplyToPostErrors } from "./ReplyToPostErrors"; 6 | import { TextUtils } from "../../../../../shared/utils/TextUtils"; 7 | import * as express from "express"; 8 | 9 | export class ReplyToPostController extends BaseController { 10 | private useCase: ReplyToPost; 11 | 12 | constructor(useCase: ReplyToPost) { 13 | super(); 14 | this.useCase = useCase; 15 | } 16 | 17 | async executeImpl( 18 | req: DecodedExpressRequest, 19 | res: express.Response 20 | ): Promise { 21 | const { userId } = req.decoded; 22 | 23 | const dto: ReplyToPostDTO = { 24 | comment: TextUtils.sanitize(req.body.comment), 25 | userId: userId, 26 | slug: req.query.slug, 27 | }; 28 | 29 | try { 30 | const result = await this.useCase.execute(dto); 31 | 32 | if (result.isLeft()) { 33 | const error = result.value; 34 | 35 | switch (error.constructor) { 36 | case ReplyToPostErrors.PostNotFoundError: 37 | return this.notFound(res, error.errorValue().message); 38 | default: 39 | return this.fail(res, error.errorValue().message); 40 | } 41 | } else { 42 | return this.ok(res); 43 | } 44 | } catch (err) { 45 | return this.fail(res, err); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/replyToPost/ReplyToPostDTO.ts: -------------------------------------------------------------------------------- 1 | export interface ReplyToPostDTO { 2 | slug: string; 3 | userId: string; 4 | comment: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/replyToPost/ReplyToPostErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../../shared/core/Result"; 3 | 4 | export namespace ReplyToPostErrors { 5 | export class PostNotFoundError extends Result { 6 | constructor(slug: string) { 7 | super(false, { 8 | message: `Couldn't find a post by slug {${slug}}.`, 9 | } as UseCaseError); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/replyToPost/index.ts: -------------------------------------------------------------------------------- 1 | import { ReplyToPost } from "./ReplyToPost"; 2 | import { memberRepo, postRepo } from "../../../repos"; 3 | import { ReplyToPostController } from "./ReplyToPostController"; 4 | 5 | const replyToPost = new ReplyToPost(memberRepo, postRepo); 6 | 7 | const replyToPostController = new ReplyToPostController(replyToPost); 8 | 9 | export { replyToPost, replyToPostController }; 10 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/updateCommentStats/UpdateCommentStatsDTO.ts: -------------------------------------------------------------------------------- 1 | import { CommentId } from "../../../domain/commentId"; 2 | 3 | export interface UpdateCommentStatsDTO { 4 | commentId: CommentId; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/updateCommentStats/index.ts: -------------------------------------------------------------------------------- 1 | import { UpdateCommentStats } from "./UpdateCommentStats"; 2 | import { commentRepo, commentVotesRepo } from "../../../repos"; 3 | 4 | const updateCommentStats = new UpdateCommentStats( 5 | commentRepo, 6 | commentVotesRepo 7 | ); 8 | 9 | export { updateCommentStats }; 10 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/upvoteComment/UpvoteCommentDTO.ts: -------------------------------------------------------------------------------- 1 | export interface UpvoteCommentDTO { 2 | userId: string; 3 | commentId: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/upvoteComment/UpvoteCommentErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../../shared/core/Result"; 3 | 4 | export namespace UpvoteCommentErrors { 5 | export class MemberNotFoundError extends Result { 6 | constructor() { 7 | super(false, { 8 | message: `Couldn't find a member to upvote the post.`, 9 | } as UseCaseError); 10 | } 11 | } 12 | 13 | export class CommentNotFoundError extends Result { 14 | constructor(commentId: string) { 15 | super(false, { 16 | message: `Couldn't find a comment with id {${commentId}}.`, 17 | } as UseCaseError); 18 | } 19 | } 20 | 21 | export class PostNotFoundError extends Result { 22 | constructor(commentId: string) { 23 | super(false, { 24 | message: `Couldn't find a post for comment {${commentId}}.`, 25 | } as UseCaseError); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/upvoteComment/UpvoteCommentResonse.ts: -------------------------------------------------------------------------------- 1 | import { Either, Result } from "../../../../../shared/core/Result"; 2 | import { UpvoteCommentErrors } from "./UpvoteCommentErrors"; 3 | import { AppError } from "../../../../../shared/core/AppError"; 4 | import { UpvotePostErrors } from "../../post/upvotePost/UpvotePostErrors"; 5 | 6 | export type UpvoteCommentResponse = Either< 7 | | UpvotePostErrors.PostNotFoundError 8 | | UpvoteCommentErrors.CommentNotFoundError 9 | | UpvoteCommentErrors.MemberNotFoundError 10 | | AppError.UnexpectedError 11 | | Result, 12 | Result 13 | >; 14 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/comments/upvoteComment/index.ts: -------------------------------------------------------------------------------- 1 | import { UpvoteComment } from "./UpvoteComment"; 2 | import { 3 | postRepo, 4 | memberRepo, 5 | commentRepo, 6 | commentVotesRepo, 7 | } from "../../../repos"; 8 | import { postService } from "../../../domain/services"; 9 | import { UpvoteCommentController } from "./UpvoteCommentController"; 10 | 11 | const upvoteComment = new UpvoteComment( 12 | postRepo, 13 | memberRepo, 14 | commentRepo, 15 | commentVotesRepo, 16 | postService 17 | ); 18 | 19 | const upvoteCommentController = new UpvoteCommentController(upvoteComment); 20 | 21 | export { upvoteComment, upvoteCommentController }; 22 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/members/createMember/CreateMemberDTO.ts: -------------------------------------------------------------------------------- 1 | export interface CreateMemberDTO { 2 | userId: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/members/createMember/CreateMemberErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../../shared/core/Result"; 3 | 4 | export namespace CreateMemberErrors { 5 | export class UserDoesntExistError extends Result { 6 | constructor(baseUserId: string) { 7 | super(false, { 8 | message: `A user for user id ${baseUserId} doesn't exist or was deleted.`, 9 | } as UseCaseError); 10 | } 11 | } 12 | 13 | export class MemberAlreadyExistsError extends Result { 14 | constructor(baseUserId: string) { 15 | super(false, { 16 | message: `Member for ${baseUserId} already exists.`, 17 | } as UseCaseError); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/members/createMember/index.ts: -------------------------------------------------------------------------------- 1 | import { CreateMember } from "./CreateMember"; 2 | import { userRepo } from "../../../../users/repos"; 3 | import { memberRepo } from "../../../repos"; 4 | 5 | const createMember = new CreateMember(userRepo, memberRepo); 6 | 7 | export { createMember }; 8 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/members/getCurrentMember/GetCurrentMemberController.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from "../../../../../shared/infra/http/models/BaseController"; 2 | import { GetMemberByUserName } from "../getMemberByUserName/GetMemberByUserName"; 3 | import { DecodedExpressRequest } from "../../../../users/infra/http/models/decodedRequest"; 4 | import { GetMemberByUserNameResponseDTO } from "../getMemberByUserName/GetMemberByUserNameResponseDTO"; 5 | import { MemberDetailsMap } from "../../../mappers/memberDetailsMap"; 6 | import * as express from "express"; 7 | 8 | export class GetCurrentMemberController extends BaseController { 9 | private useCase: GetMemberByUserName; 10 | 11 | constructor(useCase: GetMemberByUserName) { 12 | super(); 13 | this.useCase = useCase; 14 | } 15 | 16 | async executeImpl( 17 | req: DecodedExpressRequest, 18 | res: express.Response 19 | ): Promise { 20 | const { username } = req.decoded; 21 | 22 | try { 23 | const result = await this.useCase.execute({ username }); 24 | 25 | if (result.isLeft()) { 26 | return this.fail(res, result.value.errorValue().message); 27 | } else { 28 | const memberDetails = result.value.getValue(); 29 | 30 | return this.ok(res, { 31 | member: MemberDetailsMap.toDTO(memberDetails), 32 | }); 33 | } 34 | } catch (err) { 35 | return this.fail(res, err); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/members/getCurrentMember/index.ts: -------------------------------------------------------------------------------- 1 | import { GetCurrentMemberController } from "./GetCurrentMemberController"; 2 | import { getMemberByUserName } from "../getMemberByUserName"; 3 | 4 | const getCurrentMemberController = new GetCurrentMemberController( 5 | getMemberByUserName 6 | ); 7 | 8 | export { getCurrentMemberController }; 9 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/members/getMemberByUserName/GetMemberByUserName.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from "../../../../../shared/core/UseCase"; 2 | import { IMemberRepo } from "../../../repos/memberRepo"; 3 | import { GetMemberByUserNameDTO } from "./GetMemberByUserNameDTO"; 4 | import { Either, Result, left, right } from "../../../../../shared/core/Result"; 5 | import { AppError } from "../../../../../shared/core/AppError"; 6 | import { GetMemberByUserNameErrors } from "./GetMemberByUserNameErrors"; 7 | import { MemberDetails } from "../../../domain/memberDetails"; 8 | 9 | type Response = Either< 10 | GetMemberByUserNameErrors.MemberNotFoundError | AppError.UnexpectedError, 11 | Result 12 | >; 13 | 14 | export class GetMemberByUserName 15 | implements UseCase> 16 | { 17 | private memberRepo: IMemberRepo; 18 | 19 | constructor(memberRepo: IMemberRepo) { 20 | this.memberRepo = memberRepo; 21 | } 22 | 23 | public async execute(request: GetMemberByUserNameDTO): Promise { 24 | let memberDetails: MemberDetails; 25 | const { username } = request; 26 | 27 | try { 28 | try { 29 | memberDetails = await this.memberRepo.getMemberDetailsByUserName( 30 | username 31 | ); 32 | } catch (err) { 33 | return left( 34 | new GetMemberByUserNameErrors.MemberNotFoundError(username) 35 | ); 36 | } 37 | 38 | return right(Result.ok(memberDetails)); 39 | } catch (err) { 40 | return left(new AppError.UnexpectedError(err)); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/members/getMemberByUserName/GetMemberByUserNameDTO.ts: -------------------------------------------------------------------------------- 1 | export interface GetMemberByUserNameDTO { 2 | username: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/members/getMemberByUserName/GetMemberByUserNameErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../../shared/core/Result"; 3 | 4 | export namespace GetMemberByUserNameErrors { 5 | export class MemberNotFoundError extends Result { 6 | constructor(username: string) { 7 | super(false, { 8 | message: `Couldn't find a member with the username ${username}`, 9 | } as UseCaseError); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/members/getMemberByUserName/GetMemberByUserNameResponseDTO.ts: -------------------------------------------------------------------------------- 1 | import { MemberDTO } from "../../../dtos/memberDTO"; 2 | 3 | export interface GetMemberByUserNameResponseDTO { 4 | member: MemberDTO; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/members/getMemberByUserName/index.ts: -------------------------------------------------------------------------------- 1 | import { GetMemberByUserName } from "./GetMemberByUserName"; 2 | import { memberRepo } from "../../../repos"; 3 | import { GetMemberByUserNameController } from "./GetMemberByUserNameController"; 4 | 5 | const getMemberByUserName = new GetMemberByUserName(memberRepo); 6 | 7 | const getMemberByUserNameController = new GetMemberByUserNameController( 8 | getMemberByUserName 9 | ); 10 | 11 | export { getMemberByUserName, getMemberByUserNameController }; 12 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/createPost/CreatePostDTO.ts: -------------------------------------------------------------------------------- 1 | import { PostType } from "../../../domain/postType"; 2 | 3 | export interface CreatePostDTO { 4 | userId: string; 5 | title: string; 6 | text: string; 7 | link: string; 8 | postType: PostType; 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/createPost/CreatePostErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../../shared/core/Result"; 3 | 4 | export namespace CreatePostErrors { 5 | export class MemberDoesntExistError extends Result { 6 | constructor() { 7 | super(false, { 8 | message: `A forum member doesn't exist for this account.`, 9 | } as UseCaseError); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/createPost/index.ts: -------------------------------------------------------------------------------- 1 | import { CreatePost } from "./CreatePost"; 2 | import { postRepo, memberRepo } from "../../../repos"; 3 | import { CreatePostController } from "./CreatePostController"; 4 | 5 | const createPost = new CreatePost(postRepo, memberRepo); 6 | const createPostController = new CreatePostController(createPost); 7 | 8 | export { createPost, createPostController }; 9 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/downvotePost/DownvotePostDTO.ts: -------------------------------------------------------------------------------- 1 | export interface DownvotePostDTO { 2 | userId: string; 3 | slug: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/downvotePost/DownvotePostErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../../shared/core/Result"; 3 | 4 | export namespace DownvotePostErrors { 5 | export class MemberNotFoundError extends Result { 6 | constructor() { 7 | super(false, { 8 | message: `Couldn't find a member to upvote the post.`, 9 | } as UseCaseError); 10 | } 11 | } 12 | 13 | export class PostNotFoundError extends Result { 14 | constructor(slug: string) { 15 | super(false, { 16 | message: `Couldn't find a post by slug {${slug}}.`, 17 | } as UseCaseError); 18 | } 19 | } 20 | 21 | export class AlreadyDownvotedError extends Result { 22 | constructor(postId: string, memberId: string) { 23 | super(false, { 24 | message: `This post was already downvoted, postId {${postId}}, memberId {${memberId}}.`, 25 | } as UseCaseError); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/downvotePost/DownvotePostResponse.ts: -------------------------------------------------------------------------------- 1 | import { Either, Result } from "../../../../../shared/core/Result"; 2 | import { DownvotePostErrors } from "./DownvotePostErrors"; 3 | import { AppError } from "../../../../../shared/core/AppError"; 4 | 5 | export type DownvotePostResponse = Either< 6 | | DownvotePostErrors.MemberNotFoundError 7 | | DownvotePostErrors.AlreadyDownvotedError 8 | | DownvotePostErrors.PostNotFoundError 9 | | AppError.UnexpectedError 10 | | Result, 11 | Result 12 | >; 13 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/downvotePost/index.ts: -------------------------------------------------------------------------------- 1 | import { DownvotePost } from "./DownvotePost"; 2 | import { memberRepo, postRepo, postVotesRepo } from "../../../repos"; 3 | import { postService } from "../../../domain/services"; 4 | import { DownvotePostController } from "./DownvotePostController"; 5 | 6 | const downvotePost = new DownvotePost( 7 | memberRepo, 8 | postRepo, 9 | postVotesRepo, 10 | postService 11 | ); 12 | 13 | const downvotePostController = new DownvotePostController(downvotePost); 14 | 15 | export { downvotePost, downvotePostController }; 16 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/editPost/EditPostController.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyarleniber/typescript-ddd-forum/ec457803d7547bc527c0353a3876a363880a2cc7/src/modules/forum/useCases/post/editPost/EditPostController.ts -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/editPost/EditPostDTO.ts: -------------------------------------------------------------------------------- 1 | export interface EditPostDTO { 2 | postId: string; 3 | title?: string; 4 | text?: string; 5 | link?: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/editPost/EditPostErrors.ts: -------------------------------------------------------------------------------- 1 | import { Result } from "../../../../../shared/core/Result"; 2 | import { UseCaseError } from "../../../../../shared/core/UseCaseError"; 3 | import { PostId } from "../../../domain/postId"; 4 | 5 | export namespace EditPostErrors { 6 | export class PostNotFoundError extends Result { 7 | constructor(id: string) { 8 | super(false, { 9 | message: `Couldn't find a post by id {${id}}.`, 10 | } as UseCaseError); 11 | } 12 | } 13 | 14 | export class InvalidPostTypeOperationError extends Result { 15 | constructor() { 16 | super(false, { 17 | message: `If a post is a text post, we can only edit the text. If it's a link post, we can only edit the link.`, 18 | } as UseCaseError); 19 | } 20 | } 21 | 22 | export class PostSealedError extends Result { 23 | constructor() { 24 | super(false, { 25 | message: `If a post has comments, it's sealed and cannot be edited.`, 26 | } as UseCaseError); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/editPost/EditPostResponse.ts: -------------------------------------------------------------------------------- 1 | import { Either, Result } from "../../../../../shared/core/Result"; 2 | import { AppError } from "../../../../../shared/core/AppError"; 3 | import { EditPostErrors } from "./EditPostErrors"; 4 | 5 | export type EditPostResponse = Either< 6 | EditPostErrors.PostNotFoundError | AppError.UnexpectedError | Result, 7 | Result 8 | >; 9 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/editPost/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyarleniber/typescript-ddd-forum/ec457803d7547bc527c0353a3876a363880a2cc7/src/modules/forum/useCases/post/editPost/index.ts -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/getPopularPosts/GetPopularPosts.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from "../../../../../shared/core/UseCase"; 2 | import { GetPopularPostsRequestDTO } from "./GetPopularPostsRequestDTO"; 3 | import { Either, Result, left, right } from "../../../../../shared/core/Result"; 4 | import { AppError } from "../../../../../shared/core/AppError"; 5 | import { PostDetails } from "../../../domain/postDetails"; 6 | import { IPostRepo } from "../../../repos/postRepo"; 7 | import { MemberId } from "../../../domain/memberId"; 8 | import { IMemberRepo } from "../../../repos/memberRepo"; 9 | 10 | type Response = Either>; 11 | 12 | export class GetPopularPosts 13 | implements UseCase> 14 | { 15 | private postRepo: IPostRepo; 16 | 17 | constructor(postRepo: IPostRepo) { 18 | this.postRepo = postRepo; 19 | } 20 | 21 | public async execute(req: GetPopularPostsRequestDTO): Promise { 22 | try { 23 | const posts = await this.postRepo.getPopularPosts(req.offset); 24 | return right(Result.ok(posts)); 25 | } catch (err) { 26 | return left(new AppError.UnexpectedError(err)); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/getPopularPosts/GetPopularPostsRequestDTO.ts: -------------------------------------------------------------------------------- 1 | export interface GetPopularPostsRequestDTO { 2 | offset?: number; 3 | userId?: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/getPopularPosts/GetPopularPostsResponseDTO.ts: -------------------------------------------------------------------------------- 1 | import { PostDTO } from "../../../dtos/postDTO"; 2 | 3 | export interface GetPopularPostsResponseDTO { 4 | posts: PostDTO[]; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/getPopularPosts/index.ts: -------------------------------------------------------------------------------- 1 | import { GetPopularPosts } from "./GetPopularPosts"; 2 | import { postRepo } from "../../../repos"; 3 | import { GetPopularPostsController } from "./GetPopularPostsController"; 4 | 5 | const getPopularPosts = new GetPopularPosts(postRepo); 6 | 7 | const getPopularPostsController = new GetPopularPostsController( 8 | getPopularPosts 9 | ); 10 | 11 | export { getPopularPosts, getPopularPostsController }; 12 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/getPostBySlug/GetPostBySlug.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from "../../../../../shared/core/UseCase"; 2 | import { IPostRepo } from "../../../repos/postRepo"; 3 | import { PostDetails } from "../../../domain/postDetails"; 4 | import { Either, Result, left, right } from "../../../../../shared/core/Result"; 5 | import { AppError } from "../../../../../shared/core/AppError"; 6 | import { GetPostBySlugErrors } from "./GetPostBySlugErrors"; 7 | import { GetPostBySlugDTO } from "./GetPostBySlugDTO"; 8 | 9 | type Response = Either< 10 | GetPostBySlugErrors.PostNotFoundError | AppError.UnexpectedError, 11 | Result 12 | >; 13 | 14 | export class GetPostBySlug implements UseCase> { 15 | private postRepo: IPostRepo; 16 | 17 | constructor(postRepo: IPostRepo) { 18 | this.postRepo = postRepo; 19 | } 20 | 21 | public async execute(req: GetPostBySlugDTO): Promise { 22 | let postDetails: PostDetails; 23 | const { slug } = req; 24 | 25 | try { 26 | try { 27 | postDetails = await this.postRepo.getPostDetailsBySlug(slug); 28 | } catch (err) { 29 | return left(new GetPostBySlugErrors.PostNotFoundError(slug)); 30 | } 31 | 32 | return right(Result.ok(postDetails)); 33 | } catch (err) { 34 | return left(new AppError.UnexpectedError(err)); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/getPostBySlug/GetPostBySlugController.ts: -------------------------------------------------------------------------------- 1 | import { PostDetailsMap } from "../../../mappers/postDetailsMap"; 2 | import { GetPostBySlug } from "./GetPostBySlug"; 3 | import { GetPostBySlugDTO } from "./GetPostBySlugDTO"; 4 | import { BaseController } from "../../../../../shared/infra/http/models/BaseController"; 5 | import { PostDTO } from "../../../dtos/postDTO"; 6 | import * as express from "express"; 7 | import { DecodedExpressRequest } from "../../../../users/infra/http/models/decodedRequest"; 8 | 9 | export class GetPostBySlugController extends BaseController { 10 | private useCase: GetPostBySlug; 11 | 12 | constructor(useCase: GetPostBySlug) { 13 | super(); 14 | this.useCase = useCase; 15 | } 16 | 17 | async executeImpl( 18 | req: DecodedExpressRequest, 19 | res: express.Response 20 | ): Promise { 21 | const dto: GetPostBySlugDTO = { 22 | slug: req.query.slug, 23 | }; 24 | 25 | try { 26 | const result = await this.useCase.execute(dto); 27 | 28 | if (result.isLeft()) { 29 | const error = result.value; 30 | 31 | switch (error.constructor) { 32 | default: 33 | return this.fail(res, error.errorValue().message); 34 | } 35 | } else { 36 | const postDetails = result.value.getValue(); 37 | return this.ok<{ post: PostDTO }>(res, { 38 | post: PostDetailsMap.toDTO(postDetails), 39 | }); 40 | } 41 | } catch (err) { 42 | return this.fail(res, err); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/getPostBySlug/GetPostBySlugDTO.ts: -------------------------------------------------------------------------------- 1 | export interface GetPostBySlugDTO { 2 | slug: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/getPostBySlug/GetPostBySlugErrors.ts: -------------------------------------------------------------------------------- 1 | import { Result } from "../../../../../shared/core/Result"; 2 | import { UseCaseError } from "../../../../../shared/core/UseCaseError"; 3 | 4 | export namespace GetPostBySlugErrors { 5 | export class PostNotFoundError extends Result { 6 | constructor(slug: string) { 7 | super(false, { 8 | message: `Couldn't find a post by slug {${slug}}.`, 9 | } as UseCaseError); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/getPostBySlug/index.ts: -------------------------------------------------------------------------------- 1 | import { GetPostBySlug } from "./GetPostBySlug"; 2 | import { postRepo } from "../../../repos"; 3 | import { GetPostBySlugController } from "./GetPostBySlugController"; 4 | 5 | const getPostBySlug = new GetPostBySlug(postRepo); 6 | const getPostBySlugController = new GetPostBySlugController(getPostBySlug); 7 | 8 | export { getPostBySlug, getPostBySlugController }; 9 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/getRecentPosts/GetRecentPosts.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from "../../../../../shared/core/UseCase"; 2 | import { GetRecentPostsRequestDTO } from "./GetRecentPostsRequestDTO"; 3 | import { Either, Result, left, right } from "../../../../../shared/core/Result"; 4 | import { AppError } from "../../../../../shared/core/AppError"; 5 | import { PostDetails } from "../../../domain/postDetails"; 6 | import { IPostRepo } from "../../../repos/postRepo"; 7 | 8 | type Response = Either>; 9 | 10 | export class GetRecentPosts 11 | implements UseCase> 12 | { 13 | private postRepo: IPostRepo; 14 | 15 | constructor(postRepo: IPostRepo) { 16 | this.postRepo = postRepo; 17 | } 18 | 19 | public async execute(req: GetRecentPostsRequestDTO): Promise { 20 | try { 21 | const posts = await this.postRepo.getRecentPosts(req.offset); 22 | return right(Result.ok(posts)); 23 | } catch (err) { 24 | return left(new AppError.UnexpectedError(err)); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/getRecentPosts/GetRecentPostsRequestDTO.ts: -------------------------------------------------------------------------------- 1 | export interface GetRecentPostsRequestDTO { 2 | userId?: string; 3 | offset?: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/getRecentPosts/GetRecentPostsResponseDTO.ts: -------------------------------------------------------------------------------- 1 | import { PostDTO } from "../../../dtos/postDTO"; 2 | 3 | export interface GetRecentPostsResponseDTO { 4 | posts: PostDTO[]; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/getRecentPosts/index.ts: -------------------------------------------------------------------------------- 1 | import { GetRecentPosts } from "./GetRecentPosts"; 2 | import { postRepo, memberRepo } from "../../../repos"; 3 | import { GetRecentPostsController } from "./GetRecentPostsController"; 4 | 5 | const getRecentPosts = new GetRecentPosts(postRepo); 6 | const getRecentPostsController = new GetRecentPostsController(getRecentPosts); 7 | 8 | export { getRecentPosts, getRecentPostsController }; 9 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/updatePostStats/UpdatePostStatsDTO.ts: -------------------------------------------------------------------------------- 1 | export interface UpdatePostStatsDTO { 2 | postId: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/updatePostStats/UpdatePostStatsErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../../shared/core/Result"; 3 | 4 | export namespace UpdatePostStatsErrors { 5 | export class PostNotFoundError extends Result { 6 | constructor(postId: string) { 7 | super(false, { 8 | message: `Couldn't find a post by postId {${postId}}.`, 9 | } as UseCaseError); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/updatePostStats/index.ts: -------------------------------------------------------------------------------- 1 | import { UpdatePostStats } from "./UpdatePostStats"; 2 | import { postRepo, postVotesRepo, commentVotesRepo } from "../../../repos"; 3 | 4 | const updatePostStats = new UpdatePostStats( 5 | postRepo, 6 | postVotesRepo, 7 | commentVotesRepo 8 | ); 9 | 10 | export { updatePostStats }; 11 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/upvotePost/UpvotePostDTO.ts: -------------------------------------------------------------------------------- 1 | export interface UpvotePostDTO { 2 | userId: string; 3 | slug: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/upvotePost/UpvotePostErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../../shared/core/Result"; 3 | 4 | export namespace UpvotePostErrors { 5 | export class MemberNotFoundError extends Result { 6 | constructor() { 7 | super(false, { 8 | message: `Couldn't find a member to upvote the post.`, 9 | } as UseCaseError); 10 | } 11 | } 12 | 13 | export class PostNotFoundError extends Result { 14 | constructor(slug: string) { 15 | super(false, { 16 | message: `Couldn't find a post by slug {${slug}}.`, 17 | } as UseCaseError); 18 | } 19 | } 20 | 21 | export class AlreadyUpvotedError extends Result { 22 | constructor(postId: string, memberId: string) { 23 | super(false, { 24 | message: `This post was already upvoted postId {${postId}}, memberId {${memberId}}.`, 25 | } as UseCaseError); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/upvotePost/UpvotePostResponse.ts: -------------------------------------------------------------------------------- 1 | import { Either, Result } from "../../../../../shared/core/Result"; 2 | import { UpvotePostErrors } from "./UpvotePostErrors"; 3 | import { AppError } from "../../../../../shared/core/AppError"; 4 | 5 | export type UpvotePostResponse = Either< 6 | | UpvotePostErrors.MemberNotFoundError 7 | | UpvotePostErrors.AlreadyUpvotedError 8 | | UpvotePostErrors.PostNotFoundError 9 | | AppError.UnexpectedError 10 | | Result, 11 | Result 12 | >; 13 | -------------------------------------------------------------------------------- /src/modules/forum/useCases/post/upvotePost/index.ts: -------------------------------------------------------------------------------- 1 | import { UpvotePost } from "./UpvotePost"; 2 | import { memberRepo, postRepo, postVotesRepo } from "../../../repos"; 3 | import { postService } from "../../../domain/services"; 4 | import { UpvotePostController } from "./UpvotePostController"; 5 | 6 | const upvotePost = new UpvotePost( 7 | memberRepo, 8 | postRepo, 9 | postVotesRepo, 10 | postService 11 | ); 12 | 13 | const upvotePostController = new UpvotePostController(upvotePost); 14 | 15 | export { upvotePost, upvotePostController }; 16 | -------------------------------------------------------------------------------- /src/modules/users/domain/events/emailVerified.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../user"; 2 | import { IDomainEvent } from "../../../../shared/domain/events/IDomainEvent"; 3 | import { UniqueEntityID } from "../../../../shared/domain/UniqueEntityID"; 4 | 5 | export class EmailVerified implements IDomainEvent { 6 | public dateTimeOccurred: Date; 7 | public user: User; 8 | 9 | constructor(user: User) { 10 | this.dateTimeOccurred = new Date(); 11 | this.user = user; 12 | } 13 | 14 | public getAggregateId(): UniqueEntityID { 15 | return this.user.id; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/users/domain/events/userCreated.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../user"; 2 | import { IDomainEvent } from "../../../../shared/domain/events/IDomainEvent"; 3 | import { UniqueEntityID } from "../../../../shared/domain/UniqueEntityID"; 4 | 5 | export class UserCreated implements IDomainEvent { 6 | public dateTimeOccurred: Date; 7 | public user: User; 8 | 9 | constructor(user: User) { 10 | this.dateTimeOccurred = new Date(); 11 | this.user = user; 12 | } 13 | 14 | getAggregateId(): UniqueEntityID { 15 | return this.user.id; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/users/domain/events/userDeleted.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../user"; 2 | import { IDomainEvent } from "../../../../shared/domain/events/IDomainEvent"; 3 | import { UniqueEntityID } from "../../../../shared/domain/UniqueEntityID"; 4 | 5 | export class UserDeleted implements IDomainEvent { 6 | public dateTimeOccurred: Date; 7 | public user: User; 8 | 9 | constructor(user: User) { 10 | this.dateTimeOccurred = new Date(); 11 | this.user = user; 12 | } 13 | 14 | getAggregateId(): UniqueEntityID { 15 | return this.user.id; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/users/domain/events/userLoggedIn.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../user"; 2 | import { IDomainEvent } from "../../../../shared/domain/events/IDomainEvent"; 3 | import { UniqueEntityID } from "../../../../shared/domain/UniqueEntityID"; 4 | 5 | export class UserLoggedIn implements IDomainEvent { 6 | public dateTimeOccurred: Date; 7 | public user: User; 8 | 9 | constructor(user: User) { 10 | this.dateTimeOccurred = new Date(); 11 | this.user = user; 12 | } 13 | 14 | public getAggregateId(): UniqueEntityID { 15 | return this.user.id; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/users/domain/jwt.ts: -------------------------------------------------------------------------------- 1 | export interface JWTClaims { 2 | userId: string; 3 | isEmailVerified: boolean; 4 | email: string; 5 | username: string; 6 | adminUser: boolean; 7 | } 8 | 9 | export type JWTToken = string; 10 | 11 | export type SessionId = string; 12 | 13 | export type RefreshToken = string; 14 | -------------------------------------------------------------------------------- /src/modules/users/domain/userEmail.spec.ts: -------------------------------------------------------------------------------- 1 | import { UserEmail } from "./userEmail"; 2 | import { Result } from "../../../shared/core/Result"; 3 | 4 | let email: UserEmail | undefined; 5 | let emailOrError: Result; 6 | 7 | test("Should be able to create a valid email", () => { 8 | emailOrError = UserEmail.create("khalil@apollographql.com"); 9 | expect(emailOrError.isSuccess).toBe(true); 10 | email = emailOrError.getValue(); 11 | expect(email.value).toBe("khalil@apollographql.com"); 12 | }); 13 | 14 | test("Should fail to create an invalid email", () => { 15 | emailOrError = UserEmail.create("notvalid"); 16 | expect(emailOrError.isSuccess).toBe(false); 17 | }); 18 | -------------------------------------------------------------------------------- /src/modules/users/domain/userEmail.ts: -------------------------------------------------------------------------------- 1 | import { Result } from "../../../shared/core/Result"; 2 | import { ValueObject } from "../../../shared/domain/ValueObject"; 3 | 4 | export interface UserEmailProps { 5 | value: string; 6 | } 7 | 8 | export class UserEmail extends ValueObject { 9 | get value(): string { 10 | return this.props.value; 11 | } 12 | 13 | private constructor(props: UserEmailProps) { 14 | super(props); 15 | } 16 | 17 | private static isValidEmail(email: string) { 18 | const re = 19 | /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; 20 | return re.test(email); 21 | } 22 | 23 | private static format(email: string): string { 24 | return email.trim().toLowerCase(); 25 | } 26 | 27 | public static create(email: string): Result { 28 | if (!this.isValidEmail(email)) { 29 | return Result.fail("Email address not valid"); 30 | } else { 31 | return Result.ok(new UserEmail({ value: this.format(email) })); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/modules/users/domain/userId.ts: -------------------------------------------------------------------------------- 1 | import { UniqueEntityID } from "../../../shared/domain/UniqueEntityID"; 2 | import { Result } from "../../../shared/core/Result"; 3 | import { Entity } from "../../../shared/domain/Entity"; 4 | 5 | export class UserId extends Entity { 6 | get id(): UniqueEntityID { 7 | return this._id; 8 | } 9 | 10 | private constructor(id?: UniqueEntityID) { 11 | super(null, id); 12 | } 13 | 14 | public static create(id?: UniqueEntityID): Result { 15 | return Result.ok(new UserId(id)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/users/domain/userName.ts: -------------------------------------------------------------------------------- 1 | import { Result } from "../../../shared/core/Result"; 2 | import { ValueObject } from "../../../shared/domain/ValueObject"; 3 | import { Guard } from "../../../shared/core/Guard"; 4 | 5 | interface UserNameProps { 6 | name: string; 7 | } 8 | 9 | export class UserName extends ValueObject { 10 | public static maxLength = 15; 11 | public static minLength = 2; 12 | 13 | get value(): string { 14 | return this.props.name; 15 | } 16 | 17 | private constructor(props: UserNameProps) { 18 | super(props); 19 | } 20 | 21 | public static create(props: UserNameProps): Result { 22 | const usernameResult = Guard.againstNullOrUndefined(props.name, "username"); 23 | if (!usernameResult.succeeded) { 24 | return Result.fail(usernameResult.message); 25 | } 26 | 27 | const minLengthResult = Guard.againstAtLeast(this.minLength, props.name); 28 | if (!minLengthResult.succeeded) { 29 | return Result.fail(minLengthResult.message); 30 | } 31 | 32 | const maxLengthResult = Guard.againstAtMost(this.maxLength, props.name); 33 | if (!maxLengthResult.succeeded) { 34 | return Result.fail(minLengthResult.message); 35 | } 36 | 37 | return Result.ok(new UserName(props)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/modules/users/dtos/userDTO.ts: -------------------------------------------------------------------------------- 1 | export interface UserDTO { 2 | username: string; 3 | isEmailVerified?: boolean; 4 | isAdminUser?: boolean; 5 | isDeleted?: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/users/infra/http/models/decodedRequest.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { JWTClaims } from "../../../domain/jwt"; 3 | 4 | export interface DecodedExpressRequest extends express.Request { 5 | decoded: JWTClaims; 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/users/infra/http/routes/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { createUserController } from "../../../useCases/createUser"; 3 | import { deleteUserController } from "../../../useCases/deleteUser"; 4 | import { getUserByUserNameController } from "../../../useCases/getUserByUserName"; 5 | import { loginController } from "../../../useCases/login"; 6 | import { middleware } from "../../../../../shared/infra/http"; 7 | import { getCurrentUserController } from "../../../useCases/getCurrentUser"; 8 | import { refreshAccessTokenController } from "../../../useCases/refreshAccessToken"; 9 | import { logoutController } from "../../../useCases/logout"; 10 | 11 | const userRouter = express.Router(); 12 | 13 | userRouter.post("/", (req, res) => createUserController.execute(req, res)); 14 | 15 | userRouter.get("/me", middleware.ensureAuthenticated(), (req, res) => 16 | getCurrentUserController.execute(req, res) 17 | ); 18 | 19 | userRouter.post("/login", (req, res) => loginController.execute(req, res)); 20 | 21 | userRouter.post("/logout", middleware.ensureAuthenticated(), (req, res) => 22 | logoutController.execute(req, res) 23 | ); 24 | 25 | userRouter.post("/token/refresh", (req, res) => 26 | refreshAccessTokenController.execute(req, res) 27 | ); 28 | 29 | userRouter.delete("/:userId", middleware.ensureAuthenticated(), (req, res) => 30 | deleteUserController.execute(req, res) 31 | ); 32 | 33 | userRouter.get("/:username", middleware.ensureAuthenticated(), (req, res) => 34 | getUserByUserNameController.execute(req, res) 35 | ); 36 | 37 | export { userRouter }; 38 | -------------------------------------------------------------------------------- /src/modules/users/repos/index.ts: -------------------------------------------------------------------------------- 1 | import { SequelizeUserRepo } from "./implementations/sequelizeUserRepo"; 2 | import models from "../../../shared/infra/database/sequelize/models"; 3 | 4 | const userRepo = new SequelizeUserRepo(models); 5 | 6 | export { userRepo }; 7 | -------------------------------------------------------------------------------- /src/modules/users/repos/userRepo.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../domain/user"; 2 | import { UserEmail } from "../domain/userEmail"; 3 | import { UserName } from "../domain/userName"; 4 | 5 | export interface IUserRepo { 6 | exists(userEmail: UserEmail): Promise; 7 | getUserByUserId(userId: string): Promise; 8 | getUserByUserName(userName: UserName | string): Promise; 9 | save(user: User): Promise; 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/users/services/authService.ts: -------------------------------------------------------------------------------- 1 | import { JWTToken, JWTClaims, RefreshToken } from "../domain/jwt"; 2 | import { User } from "../domain/user"; 3 | 4 | export interface IAuthService { 5 | signJWT(props: JWTClaims): JWTToken; 6 | decodeJWT(token: string): Promise; 7 | createRefreshToken(): RefreshToken; 8 | getTokens(username: string): Promise; 9 | saveAuthenticatedUser(user: User): Promise; 10 | deAuthenticateUser(username: string): Promise; 11 | refreshTokenExists(refreshToken: RefreshToken): Promise; 12 | getUserNameFromRefreshToken(refreshToken: RefreshToken): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/users/services/index.ts: -------------------------------------------------------------------------------- 1 | import { redisConnection } from "./redis/redisConnection"; 2 | import { RedisAuthService } from "./redis/redisAuthService"; 3 | 4 | const authService = new RedisAuthService(redisConnection); 5 | 6 | // authService.getTokens('khalilstemmler@gmail.com') 7 | // .then((t) => console.log(t)) 8 | // .catch((err) => console.log(err)) 9 | 10 | export { authService }; 11 | -------------------------------------------------------------------------------- /src/modules/users/services/redis/redisConnection.ts: -------------------------------------------------------------------------------- 1 | import redis from "redis"; 2 | import { Redis } from "redis"; 3 | import { authConfig, isProduction } from "../../../../config"; 4 | 5 | const port = authConfig.redisServerPort; 6 | const host = authConfig.redisServerURL; 7 | const redisConnection: Redis = isProduction 8 | ? redis.createClient(authConfig.redisConnectionString) 9 | : redis.createClient(port, host); // creates a new client 10 | 11 | redisConnection.on("connect", () => { 12 | console.log(`[Redis]: Connected to redis server at ${host}:${port}`); 13 | }); 14 | 15 | export { redisConnection }; 16 | -------------------------------------------------------------------------------- /src/modules/users/useCases/README.md: -------------------------------------------------------------------------------- 1 | 2 | # User Use Cases 3 | 4 | > This is where all the use cases (application services) for the users subdomain belongs 5 | 6 | -------------------------------------------------------------------------------- /src/modules/users/useCases/createUser/CreateUserDTO.ts: -------------------------------------------------------------------------------- 1 | export interface CreateUserDTO { 2 | username: string; 3 | email: string; 4 | password: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/users/useCases/createUser/CreateUserErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../shared/core/Result"; 3 | 4 | export namespace CreateUserErrors { 5 | export class EmailAlreadyExistsError extends Result { 6 | constructor(email: string) { 7 | super(false, { 8 | message: `The email ${email} associated for this account already exists`, 9 | } as UseCaseError); 10 | } 11 | } 12 | 13 | export class UsernameTakenError extends Result { 14 | constructor(username: string) { 15 | super(false, { 16 | message: `The username ${username} was already taken`, 17 | } as UseCaseError); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/users/useCases/createUser/CreateUserResponse.ts: -------------------------------------------------------------------------------- 1 | import { Either, Result } from "../../../../shared/core/Result"; 2 | import { CreateUserErrors } from "./CreateUserErrors"; 3 | import { AppError } from "../../../../shared/core/AppError"; 4 | 5 | export type CreateUserResponse = Either< 6 | | CreateUserErrors.EmailAlreadyExistsError 7 | | CreateUserErrors.UsernameTakenError 8 | | AppError.UnexpectedError 9 | | Result, 10 | Result 11 | >; 12 | -------------------------------------------------------------------------------- /src/modules/users/useCases/createUser/index.ts: -------------------------------------------------------------------------------- 1 | import { CreateUserUseCase } from "./CreateUserUseCase"; 2 | import { CreateUserController } from "./CreateUserController"; 3 | import { userRepo } from "../../repos"; 4 | 5 | const createUserUseCase = new CreateUserUseCase(userRepo); 6 | const createUserController = new CreateUserController(createUserUseCase); 7 | 8 | export { createUserUseCase, createUserController }; 9 | -------------------------------------------------------------------------------- /src/modules/users/useCases/deleteUser/DeleteUserController.ts: -------------------------------------------------------------------------------- 1 | import { DeleteUserUseCase } from "./DeleteUserUseCase"; 2 | import { DeleteUserDTO } from "./DeleteUserDTO"; 3 | import { DeleteUserErrors } from "./DeleteUserErrors"; 4 | import { BaseController } from "../../../../shared/infra/http/models/BaseController"; 5 | import * as express from "express"; 6 | import { DecodedExpressRequest } from "../../infra/http/models/decodedRequest"; 7 | 8 | export class DeleteUserController extends BaseController { 9 | private useCase: DeleteUserUseCase; 10 | 11 | constructor(useCase: DeleteUserUseCase) { 12 | super(); 13 | this.useCase = useCase; 14 | } 15 | 16 | async executeImpl( 17 | req: DecodedExpressRequest, 18 | res: express.Response 19 | ): Promise { 20 | const dto: DeleteUserDTO = req.body as DeleteUserDTO; 21 | 22 | try { 23 | const result = await this.useCase.execute(dto); 24 | 25 | if (result.isLeft()) { 26 | const error = result.value; 27 | 28 | switch (error.constructor) { 29 | case DeleteUserErrors.UserNotFoundError: 30 | return this.notFound(res, error.errorValue().message); 31 | default: 32 | return this.fail(res, error.errorValue().message); 33 | } 34 | } else { 35 | return this.ok(res); 36 | } 37 | } catch (err) { 38 | return this.fail(res, err); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/modules/users/useCases/deleteUser/DeleteUserDTO.ts: -------------------------------------------------------------------------------- 1 | export interface DeleteUserDTO { 2 | userId: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/users/useCases/deleteUser/DeleteUserErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../shared/core/Result"; 3 | 4 | export namespace DeleteUserErrors { 5 | export class UserNotFoundError extends Result { 6 | constructor() { 7 | super(false, { 8 | message: `User not found`, 9 | } as UseCaseError); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/users/useCases/deleteUser/DeleteUserUseCase.ts: -------------------------------------------------------------------------------- 1 | import { DeleteUserDTO } from "./DeleteUserDTO"; 2 | import { DeleteUserErrors } from "./DeleteUserErrors"; 3 | import { Either, Result, left, right } from "../../../../shared/core/Result"; 4 | import { AppError } from "../../../../shared/core/AppError"; 5 | import { IUserRepo } from "../../repos/userRepo"; 6 | import { UseCase } from "../../../../shared/core/UseCase"; 7 | 8 | type Response = Either< 9 | AppError.UnexpectedError | DeleteUserErrors.UserNotFoundError, 10 | Result 11 | >; 12 | 13 | export class DeleteUserUseCase 14 | implements UseCase> 15 | { 16 | private userRepo: IUserRepo; 17 | 18 | constructor(userRepo: IUserRepo) { 19 | this.userRepo = userRepo; 20 | } 21 | 22 | public async execute(request: DeleteUserDTO): Promise { 23 | try { 24 | const user = await this.userRepo.getUserByUserId(request.userId); 25 | const userFound = !!user === true; 26 | 27 | if (!userFound) { 28 | return left(new DeleteUserErrors.UserNotFoundError()); 29 | } 30 | 31 | user.delete(); 32 | 33 | await this.userRepo.save(user); 34 | 35 | return right(Result.ok()); 36 | } catch (err) { 37 | return left(new AppError.UnexpectedError(err)); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/users/useCases/deleteUser/index.ts: -------------------------------------------------------------------------------- 1 | import { DeleteUserUseCase } from "./DeleteUserUseCase"; 2 | import { DeleteUserController } from "./DeleteUserController"; 3 | import { userRepo } from "../../repos"; 4 | 5 | const deleteUserUseCase = new DeleteUserUseCase(userRepo); 6 | const deleteUserController = new DeleteUserController(deleteUserUseCase); 7 | 8 | export { deleteUserUseCase, deleteUserController }; 9 | -------------------------------------------------------------------------------- /src/modules/users/useCases/getCurrentUser/GetCurrentUserController.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from "../../../../shared/infra/http/models/BaseController"; 2 | import { DecodedExpressRequest } from "../../infra/http/models/decodedRequest"; 3 | import { GetUserByUserName } from "../getUserByUserName/GetUserByUserName"; 4 | import { UserMap } from "../../mappers/userMap"; 5 | import * as express from "express"; 6 | 7 | export class GetCurrentUserController extends BaseController { 8 | private useCase: GetUserByUserName; 9 | 10 | constructor(useCase: GetUserByUserName) { 11 | super(); 12 | this.useCase = useCase; 13 | } 14 | 15 | async executeImpl( 16 | req: DecodedExpressRequest, 17 | res: express.Response 18 | ): Promise { 19 | const { username } = req.decoded; 20 | 21 | try { 22 | const result = await this.useCase.execute({ username }); 23 | 24 | if (result.isLeft()) { 25 | return this.fail(res, result.value.errorValue().message); 26 | } else { 27 | const user = result.value.getValue(); 28 | return this.ok(res, { 29 | user: UserMap.toDTO(user), 30 | }); 31 | } 32 | } catch (err) { 33 | return this.fail(res, err); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/users/useCases/getCurrentUser/index.ts: -------------------------------------------------------------------------------- 1 | import { GetCurrentUserController } from "./GetCurrentUserController"; 2 | import { getUserByUserName } from "../getUserByUserName"; 3 | 4 | const getCurrentUserController = new GetCurrentUserController( 5 | getUserByUserName 6 | ); 7 | 8 | export { getCurrentUserController }; 9 | -------------------------------------------------------------------------------- /src/modules/users/useCases/getUserByUserName/GetUserByUserNameController.ts: -------------------------------------------------------------------------------- 1 | import { GetUserByUserNameErrors } from "./GetUserByUserNameErrors"; 2 | import { GetUserByUserNameDTO } from "./GetUserByUserNameDTO"; 3 | import { GetUserByUserName } from "./GetUserByUserName"; 4 | import { BaseController } from "../../../../shared/infra/http/models/BaseController"; 5 | import * as express from "express"; 6 | import { DecodedExpressRequest } from "../../infra/http/models/decodedRequest"; 7 | 8 | export class GetUserByUserNameController extends BaseController { 9 | private useCase: GetUserByUserName; 10 | 11 | constructor(useCase: GetUserByUserName) { 12 | super(); 13 | this.useCase = useCase; 14 | } 15 | 16 | async executeImpl( 17 | req: DecodedExpressRequest, 18 | res: express.Response 19 | ): Promise { 20 | const dto: GetUserByUserNameDTO = req.body as GetUserByUserNameDTO; 21 | 22 | try { 23 | const result = await this.useCase.execute(dto); 24 | 25 | if (result.isLeft()) { 26 | const error = result.value; 27 | 28 | switch (error.constructor) { 29 | case GetUserByUserNameErrors.UserNotFoundError: 30 | return this.notFound(res, error.errorValue().message); 31 | default: 32 | return this.fail(res, error.errorValue().message); 33 | } 34 | } else { 35 | return this.ok(res); 36 | } 37 | } catch (err) { 38 | return this.fail(res, err); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/modules/users/useCases/getUserByUserName/GetUserByUserNameDTO.ts: -------------------------------------------------------------------------------- 1 | export interface GetUserByUserNameDTO { 2 | username: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/users/useCases/getUserByUserName/GetUserByUserNameErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../shared/core/Result"; 3 | 4 | export namespace GetUserByUserNameErrors { 5 | export class UserNotFoundError extends Result { 6 | constructor(username: string) { 7 | super(false, { 8 | message: `No user with the username ${username} was found`, 9 | } as UseCaseError); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/users/useCases/getUserByUserName/index.ts: -------------------------------------------------------------------------------- 1 | import { GetUserByUserName } from "./GetUserByUserName"; 2 | import { GetUserByUserNameController } from "./GetUserByUserNameController"; 3 | import { userRepo } from "../../repos"; 4 | 5 | const getUserByUserName = new GetUserByUserName(userRepo); 6 | 7 | const getUserByUserNameController = new GetUserByUserNameController( 8 | getUserByUserName 9 | ); 10 | 11 | export { getUserByUserName, getUserByUserNameController }; 12 | -------------------------------------------------------------------------------- /src/modules/users/useCases/login/LoginDTO.ts: -------------------------------------------------------------------------------- 1 | import { JWTToken, RefreshToken } from "../../domain/jwt"; 2 | 3 | export interface LoginDTO { 4 | username: string; 5 | password: string; 6 | } 7 | 8 | export interface LoginDTOResponse { 9 | accessToken: JWTToken; 10 | refreshToken: RefreshToken; 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/users/useCases/login/LoginErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../shared/core/Result"; 3 | 4 | export namespace LoginUseCaseErrors { 5 | export class UserNameDoesntExistError extends Result { 6 | constructor() { 7 | super(false, { 8 | message: `Username or password incorrect.`, 9 | } as UseCaseError); 10 | } 11 | } 12 | 13 | export class PasswordDoesntMatchError extends Result { 14 | constructor() { 15 | super(false, { 16 | message: `Password doesnt match error.`, 17 | } as UseCaseError); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/users/useCases/login/index.ts: -------------------------------------------------------------------------------- 1 | import { LoginUserUseCase } from "./LoginUseCase"; 2 | import { LoginController } from "./LoginController"; 3 | import { authService } from "../../services"; 4 | import { userRepo } from "../../repos"; 5 | 6 | async function test() { 7 | // const username = await authService.getUserNameFromRefreshToken(`*${"KbeS2mf9r4Sq1na6NI6QlTPSp1Fx7tVEQbNeXmFyrgiCvMUYgiy3p45V03gCluo320xMt1N6yef1VPT2cXRzlM8BMznPecTI4ofykUNdkYFGIlNreLVvCP8GFyyDSJ49A27qcFbPLnPQg9hJfZHH4vtT2b4Yi8k1blj30PbfqZ252dFiC4yYeKR5nbYX4l78ThuDK7hmwp9M2WzxoiitIoQkthe0AA8jIyL1ra8DypLaiddULNOI7n5JygEibAcf"}*`) 8 | // console.log(username); 9 | // await authService.clearAllSessions('stemmlerjs4') 10 | // const user = await userRepo.getUserByUserName('stemmlerjs4'); 11 | // const accessToken = authService.signJWT({ 12 | // username: user.username.value, 13 | // email: user.email.value, 14 | // isEmailVerified: user.isEmailVerified, 15 | // userId: user.userId.toString(), 16 | // adminUser: user.isAdminUser, 17 | // }); 18 | // const refreshToken = authService 19 | // .createRefreshToken(); 20 | // user.setAccessToken(accessToken, refreshToken); 21 | // await authService.saveAuthenticatedUser(user); 22 | // console.log('Done'); 23 | // const tokens = await authService.getTokens('stemmlerjs'); 24 | // console.log(tokens); 25 | } 26 | 27 | test(); 28 | 29 | const loginUseCase = new LoginUserUseCase(userRepo, authService); 30 | const loginController = new LoginController(loginUseCase); 31 | 32 | export { loginController, loginUseCase }; 33 | -------------------------------------------------------------------------------- /src/modules/users/useCases/logout/LogoutController.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from "../../../../shared/infra/http/models/BaseController"; 2 | import { DecodedExpressRequest } from "../../infra/http/models/decodedRequest"; 3 | import { LogoutUseCase } from "./LogoutUseCase"; 4 | import * as express from "express"; 5 | 6 | export class LogoutController extends BaseController { 7 | private useCase: LogoutUseCase; 8 | 9 | constructor(useCase: LogoutUseCase) { 10 | super(); 11 | this.useCase = useCase; 12 | } 13 | 14 | async executeImpl( 15 | req: DecodedExpressRequest, 16 | res: express.Response 17 | ): Promise { 18 | const { userId } = req.decoded; 19 | 20 | try { 21 | const result = await this.useCase.execute({ userId }); 22 | 23 | if (result.isLeft()) { 24 | return this.fail(res, result.value.errorValue().message); 25 | } else { 26 | return this.ok(res); 27 | } 28 | } catch (err) { 29 | return this.fail(res, err); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/modules/users/useCases/logout/LogoutDTO.ts: -------------------------------------------------------------------------------- 1 | export interface LogoutDTO { 2 | userId: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/users/useCases/logout/LogoutErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../shared/core/Result"; 3 | 4 | export namespace LogoutErrors { 5 | export class UserNotFoundOrDeletedError extends Result { 6 | constructor() { 7 | super(false, { 8 | message: `User not found or doesn't exist anymore.`, 9 | } as UseCaseError); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/users/useCases/logout/LogoutUseCase.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from "../../../../shared/core/UseCase"; 2 | import { IUserRepo } from "../../repos/userRepo"; 3 | import { IAuthService } from "../../services/authService"; 4 | import { Either, left, Result, right } from "../../../../shared/core/Result"; 5 | import { LogoutDTO } from "./LogoutDTO"; 6 | import { AppError } from "../../../../shared/core/AppError"; 7 | import { User } from "../../domain/user"; 8 | import { LogoutErrors } from "./LogoutErrors"; 9 | 10 | type Response = Either>; 11 | 12 | export class LogoutUseCase implements UseCase> { 13 | private userRepo: IUserRepo; 14 | private authService: IAuthService; 15 | 16 | constructor(userRepo: IUserRepo, authService: IAuthService) { 17 | this.userRepo = userRepo; 18 | this.authService = authService; 19 | } 20 | 21 | public async execute(request: LogoutDTO): Promise { 22 | let user: User; 23 | const { userId } = request; 24 | 25 | try { 26 | try { 27 | user = await this.userRepo.getUserByUserId(userId); 28 | } catch (err) { 29 | return left(new LogoutErrors.UserNotFoundOrDeletedError()); 30 | } 31 | 32 | await this.authService.deAuthenticateUser(user.username.value); 33 | 34 | return right(Result.ok()); 35 | } catch (err) { 36 | return left(new AppError.UnexpectedError(err)); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/modules/users/useCases/logout/index.ts: -------------------------------------------------------------------------------- 1 | import { LogoutUseCase } from "./LogoutUseCase"; 2 | import { userRepo } from "../../repos"; 3 | import { authService } from "../../services"; 4 | import { LogoutController } from "./LogoutController"; 5 | 6 | const logoutUseCase = new LogoutUseCase(userRepo, authService); 7 | const logoutController = new LogoutController(logoutUseCase); 8 | 9 | export { logoutUseCase, logoutController }; 10 | -------------------------------------------------------------------------------- /src/modules/users/useCases/refreshAccessToken/RefreshAccessTokenDTO.ts: -------------------------------------------------------------------------------- 1 | import { RefreshToken } from "../../domain/jwt"; 2 | 3 | export interface RefreshAccessTokenDTO { 4 | refreshToken: RefreshToken; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/users/useCases/refreshAccessToken/RefreshAccessTokenErrors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from "../../../../shared/core/UseCaseError"; 2 | import { Result } from "../../../../shared/core/Result"; 3 | 4 | export namespace RefreshAccessTokenErrors { 5 | export class RefreshTokenNotFound extends Result { 6 | constructor() { 7 | super(false, { 8 | message: `Refresh token doesn't exist`, 9 | } as UseCaseError); 10 | } 11 | } 12 | 13 | export class UserNotFoundOrDeletedError extends Result { 14 | constructor() { 15 | super(false, { 16 | message: `User not found or doesn't exist anymore.`, 17 | } as UseCaseError); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/users/useCases/refreshAccessToken/index.ts: -------------------------------------------------------------------------------- 1 | import { RefreshAccessToken } from "./RefreshAccessToken"; 2 | import { userRepo } from "../../repos"; 3 | import { authService } from "../../services"; 4 | import { RefreshAccessTokenController } from "./RefreshAccessTokenController"; 5 | 6 | const refreshAccessToken = new RefreshAccessToken(userRepo, authService); 7 | 8 | const refreshAccessTokenController = new RefreshAccessTokenController( 9 | refreshAccessToken 10 | ); 11 | 12 | export { refreshAccessToken, refreshAccessTokenController }; 13 | -------------------------------------------------------------------------------- /src/shared/core/AppError.ts: -------------------------------------------------------------------------------- 1 | import { Result } from "./Result"; 2 | import { UseCaseError } from "./UseCaseError"; 3 | 4 | export namespace AppError { 5 | export class UnexpectedError extends Result { 6 | public constructor(err: any) { 7 | super(false, { 8 | message: `An unexpected error occurred.`, 9 | error: err, 10 | } as UseCaseError); 11 | console.log(`[AppError]: An unexpected error occurred`); 12 | console.error(err); 13 | } 14 | 15 | public static create(err: any): UnexpectedError { 16 | return new UnexpectedError(err); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/shared/core/UseCase.ts: -------------------------------------------------------------------------------- 1 | export interface UseCase { 2 | execute(request?: IRequest): Promise | IResponse; 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/core/UseCaseError.ts: -------------------------------------------------------------------------------- 1 | interface IUseCaseError { 2 | message: string; 3 | } 4 | 5 | export abstract class UseCaseError implements IUseCaseError { 6 | public readonly message: string; 7 | 8 | constructor(message: string) { 9 | this.message = message; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/core/WithChanges.ts: -------------------------------------------------------------------------------- 1 | import { Result } from "./Result"; 2 | 3 | export interface WithChanges { 4 | changes: Changes; 5 | } 6 | 7 | export class Changes { 8 | private changes: Result[]; 9 | 10 | constructor() { 11 | this.changes = []; 12 | } 13 | 14 | public addChange(result: Result): void { 15 | this.changes.push(result); 16 | } 17 | 18 | public getChangeResult(): Result { 19 | return Result.combine(this.changes); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/shared/domain/DomainService.ts: -------------------------------------------------------------------------------- 1 | export interface DomainService {} 2 | -------------------------------------------------------------------------------- /src/shared/domain/Entity.ts: -------------------------------------------------------------------------------- 1 | import { UniqueEntityID } from "./UniqueEntityID"; 2 | 3 | const isEntity = (v: any): v is Entity => { 4 | return v instanceof Entity; 5 | }; 6 | 7 | export abstract class Entity { 8 | protected readonly _id: UniqueEntityID; 9 | public readonly props: T; 10 | 11 | constructor(props: T, id?: UniqueEntityID) { 12 | this._id = id ? id : new UniqueEntityID(); 13 | this.props = props; 14 | } 15 | 16 | public equals(object?: Entity): boolean { 17 | if (object == null || object == undefined) { 18 | return false; 19 | } 20 | 21 | if (this === object) { 22 | return true; 23 | } 24 | 25 | if (!isEntity(object)) { 26 | return false; 27 | } 28 | 29 | return this._id.equals(object._id); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/shared/domain/Identifier.ts: -------------------------------------------------------------------------------- 1 | export class Identifier { 2 | constructor(private value: T) { 3 | this.value = value; 4 | } 5 | 6 | equals(id?: Identifier): boolean { 7 | if (id === null || id === undefined) { 8 | return false; 9 | } 10 | if (!(id instanceof this.constructor)) { 11 | return false; 12 | } 13 | return id.toValue() === this.value; 14 | } 15 | 16 | toString(): string { 17 | return String(this.value); 18 | } 19 | 20 | /** 21 | * Return raw value of identifier 22 | */ 23 | toValue(): T { 24 | return this.value; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/shared/domain/UniqueEntityID.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from "uuid"; 2 | import { Identifier } from "./Identifier"; 3 | 4 | export class UniqueEntityID extends Identifier { 5 | constructor(id?: string | number) { 6 | super(id ? id : uuid()); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/domain/ValueObject.ts: -------------------------------------------------------------------------------- 1 | interface ValueObjectProps { 2 | [index: string]: any; 3 | } 4 | 5 | /** 6 | * @desc ValueObjects are objects that we determine their 7 | * equality through their structrual property. 8 | */ 9 | export abstract class ValueObject { 10 | public props: T; 11 | 12 | constructor(props: T) { 13 | const baseProps: any = { 14 | ...props, 15 | }; 16 | 17 | this.props = baseProps; 18 | } 19 | 20 | public equals(vo?: ValueObject): boolean { 21 | if (vo === null || vo === undefined) { 22 | return false; 23 | } 24 | if (vo.props === undefined) { 25 | return false; 26 | } 27 | return JSON.stringify(this.props) === JSON.stringify(vo.props); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/shared/domain/events/IDomainEvent.ts: -------------------------------------------------------------------------------- 1 | import { UniqueEntityID } from "../UniqueEntityID"; 2 | 3 | export interface IDomainEvent { 4 | dateTimeOccurred: Date; 5 | getAggregateId(): UniqueEntityID; 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/domain/events/IHandle.ts: -------------------------------------------------------------------------------- 1 | import { IDomainEvent } from "./IDomainEvent"; 2 | 3 | export interface IHandle { 4 | setupSubscriptions(): void; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/infra/Mapper.ts: -------------------------------------------------------------------------------- 1 | export interface Mapper {} 2 | -------------------------------------------------------------------------------- /src/shared/infra/database/sequelize/index.ts: -------------------------------------------------------------------------------- 1 | import "./hooks"; 2 | -------------------------------------------------------------------------------- /src/shared/infra/database/sequelize/models/BaseUser.ts: -------------------------------------------------------------------------------- 1 | export default (sequelize, DataTypes) => { 2 | const BaseUser = sequelize.define( 3 | "base_user", 4 | { 5 | base_user_id: { 6 | type: DataTypes.UUID, 7 | defaultValue: DataTypes.UUIDV4, 8 | allowNull: false, 9 | primaryKey: true, 10 | }, 11 | user_email: { 12 | type: DataTypes.STRING(250), 13 | allowNull: false, 14 | unique: true, 15 | }, 16 | is_email_verified: { 17 | type: DataTypes.BOOLEAN, 18 | allowNull: false, 19 | defaultValue: false, 20 | }, 21 | is_admin_user: { 22 | type: DataTypes.BOOLEAN, 23 | allowNull: false, 24 | defaultValue: false, 25 | }, 26 | is_deleted: { 27 | type: DataTypes.BOOLEAN, 28 | allowNull: false, 29 | defaultValue: false, 30 | }, 31 | username: { 32 | type: DataTypes.STRING(250), 33 | allowNull: false, 34 | }, 35 | user_password: { 36 | type: DataTypes.STRING, 37 | allowNull: true, 38 | defaultValue: null, 39 | }, 40 | }, 41 | { 42 | timestamps: true, 43 | underscored: true, 44 | tableName: "base_user", 45 | indexes: [{ unique: true, fields: ["user_email"] }], 46 | } 47 | ); 48 | 49 | BaseUser.associate = (models) => { 50 | BaseUser.hasOne(models.Member, { as: "Member", foreignKey: "member_id" }); 51 | }; 52 | 53 | return BaseUser; 54 | }; 55 | -------------------------------------------------------------------------------- /src/shared/infra/database/sequelize/models/CommentVote.ts: -------------------------------------------------------------------------------- 1 | export default (sequelize, DataTypes) => { 2 | const CommentVote = sequelize.define( 3 | "comment_vote", 4 | { 5 | comment_vote_id: { 6 | type: DataTypes.UUID, 7 | defaultValue: DataTypes.UUIDV4, 8 | allowNull: false, 9 | primaryKey: true, 10 | }, 11 | comment_id: { 12 | type: DataTypes.UUID, 13 | allowNull: false, 14 | references: { 15 | model: "comment", 16 | key: "comment_id", 17 | }, 18 | onDelete: "cascade", 19 | onUpdate: "cascade", 20 | }, 21 | member_id: { 22 | type: DataTypes.UUID, 23 | allowNull: false, 24 | references: { 25 | model: "member", 26 | key: "member_id", 27 | }, 28 | onDelete: "cascade", 29 | onUpdate: "cascade", 30 | }, 31 | type: { 32 | type: DataTypes.STRING(10), 33 | allowNull: false, 34 | }, 35 | }, 36 | { 37 | timestamps: true, 38 | underscored: true, 39 | tableName: "comment_vote", 40 | } 41 | ); 42 | 43 | CommentVote.associate = (models) => { 44 | CommentVote.belongsTo(models.Member, { 45 | foreignKey: "member_id", 46 | targetKey: "member_id", 47 | as: "Member", 48 | }); 49 | CommentVote.belongsTo(models.Comment, { 50 | foreignKey: "comment_id", 51 | targetKey: "comment_id", 52 | as: "Comment", 53 | }); 54 | }; 55 | 56 | return CommentVote; 57 | }; 58 | -------------------------------------------------------------------------------- /src/shared/infra/database/sequelize/models/Member.ts: -------------------------------------------------------------------------------- 1 | export default (sequelize, DataTypes) => { 2 | const Member = sequelize.define( 3 | "member", 4 | { 5 | member_id: { 6 | type: DataTypes.UUID, 7 | defaultValue: DataTypes.UUIDV4, 8 | allowNull: false, 9 | primaryKey: true, 10 | }, 11 | member_base_id: { 12 | type: DataTypes.UUID, 13 | allowNull: false, 14 | primaryKey: true, 15 | references: { 16 | model: "base_user", 17 | key: "base_user_id", 18 | }, 19 | onDelete: "cascade", 20 | onUpdate: "cascade", 21 | }, 22 | reputation: { 23 | type: DataTypes.INTEGER, 24 | allowNull: false, 25 | defaultValue: 0, 26 | }, 27 | }, 28 | { 29 | timestamps: true, 30 | underscored: true, 31 | tableName: "member", 32 | } 33 | ); 34 | 35 | Member.associate = (models) => { 36 | Member.belongsTo(models.BaseUser, { 37 | foreignKey: "member_base_id", 38 | targetKey: "base_user_id", 39 | as: "BaseUser", 40 | }); 41 | Member.hasMany(models.Post, { foreignKey: "member_id", as: "Post" }); 42 | }; 43 | 44 | return Member; 45 | }; 46 | -------------------------------------------------------------------------------- /src/shared/infra/database/sequelize/models/PostVote.ts: -------------------------------------------------------------------------------- 1 | export default (sequelize, DataTypes) => { 2 | const PostVote = sequelize.define( 3 | "post_vote", 4 | { 5 | post_vote_id: { 6 | type: DataTypes.UUID, 7 | defaultValue: DataTypes.UUIDV4, 8 | allowNull: false, 9 | primaryKey: true, 10 | }, 11 | post_id: { 12 | type: DataTypes.UUID, 13 | allowNull: false, 14 | references: { 15 | model: "post", 16 | key: "post_id", 17 | }, 18 | onDelete: "cascade", 19 | onUpdate: "cascade", 20 | }, 21 | member_id: { 22 | type: DataTypes.UUID, 23 | allowNull: false, 24 | references: { 25 | model: "member", 26 | key: "member_id", 27 | }, 28 | onDelete: "cascade", 29 | onUpdate: "cascade", 30 | }, 31 | type: { 32 | type: DataTypes.STRING(10), 33 | allowNull: false, 34 | }, 35 | }, 36 | { 37 | timestamps: true, 38 | underscored: true, 39 | tableName: "post_vote", 40 | } 41 | ); 42 | 43 | PostVote.associate = (models) => { 44 | PostVote.belongsTo(models.Member, { 45 | foreignKey: "member_id", 46 | targetKey: "member_id", 47 | as: "Member", 48 | }); 49 | PostVote.belongsTo(models.Post, { 50 | foreignKey: "post_id", 51 | targetKey: "post_id", 52 | as: "Post", 53 | }); 54 | }; 55 | 56 | return PostVote; 57 | }; 58 | -------------------------------------------------------------------------------- /src/shared/infra/database/sequelize/runner.ts: -------------------------------------------------------------------------------- 1 | async function runner(promises) { 2 | for (let command of promises) { 3 | try { 4 | await command(); 5 | } catch (err) { 6 | if (err.original) { 7 | /** 8 | * This is an error that we can run into while seeding the same 9 | * data. It's passable. 10 | */ 11 | 12 | if (err.original.code == "ER_DUP_ENTRY") { 13 | console.log(`>>> Passable error occurred: ER_DUP_ENTRY`); 14 | } else if (err.original.code == "ER_DUP_FIELDNAME") { 15 | /** 16 | * This is an error that we can run into where the same 17 | * field name already exists. 18 | */ 19 | console.log(`>>> Passable error occurred: ER_DUP_FIELDNAME`); 20 | } else if (err.original.code == "ER_CANT_DROP_FIELD_OR_KEY") { 21 | /** 22 | * If the field doesn't exist and we're trying to drop it, 23 | * that's cool. We can pass this. 24 | */ 25 | console.log(`>>> Passable error occurred: ER_CANT_DROP_FIELD_OR_KEY`); 26 | } else if (err.name == "SequelizeUnknownConstraintError") { 27 | console.log( 28 | `>>> Passable error. Trying to remove constraint that's already been removed.` 29 | ); 30 | } else { 31 | /** 32 | * Any other error 33 | */ 34 | console.log(err); 35 | throw new Error(err); 36 | } 37 | } else { 38 | console.log(err); 39 | throw new Error(err); 40 | } 41 | } 42 | } 43 | } 44 | 45 | export default { 46 | run: runner, 47 | }; 48 | -------------------------------------------------------------------------------- /src/shared/infra/http/api/v1.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { userRouter } from "../../../../modules/users/infra/http/routes"; 3 | import { 4 | memberRouter, 5 | commentRouter, 6 | } from "../../../../modules/forum/infra/http/routes"; 7 | import { postRouter } from "../../../../modules/forum/infra/http/routes/post"; 8 | 9 | const v1Router = express.Router(); 10 | 11 | v1Router.get("/", (req, res) => { 12 | return res.json({ message: "Yo! we're up" }); 13 | }); 14 | 15 | v1Router.use("/users", userRouter); 16 | v1Router.use("/members", memberRouter); 17 | v1Router.use("/posts", postRouter); 18 | v1Router.use("/comments", commentRouter); 19 | 20 | export { v1Router }; 21 | -------------------------------------------------------------------------------- /src/shared/infra/http/app.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import bodyParser from "body-parser"; 3 | import morgan from "morgan"; 4 | import cors from "cors"; 5 | import helmet from "helmet"; 6 | import compression from "compression"; 7 | import { v1Router } from "./api/v1"; 8 | import { isProduction } from "../../../config"; 9 | 10 | const origin = { 11 | // origin: isProduction ? 'https://dddforum.com' : '*', 12 | origin: "*", 13 | }; 14 | 15 | const app = express(); 16 | 17 | app.use(bodyParser.json()); 18 | app.use(bodyParser.urlencoded({ extended: true })); 19 | app.use(cors(origin)); 20 | app.use(compression()); 21 | app.use(helmet()); 22 | app.use(morgan("combined")); 23 | 24 | app.use("/api/v1", v1Router); 25 | 26 | const port = process.env.PORT || 5000; 27 | 28 | app.listen(port, () => { 29 | console.log(`[App]: Listening on port ${port}`); 30 | }); 31 | -------------------------------------------------------------------------------- /src/shared/infra/http/graphql/forum.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "apollo-server-express"; 2 | 3 | export const typeDefs = gql` 4 | enum PostType { 5 | text 6 | link 7 | } 8 | 9 | type Post { 10 | slug: String 11 | title: String 12 | createdAt: DateTime 13 | memberPostedBy: Member 14 | numComments: Int 15 | points: Int 16 | text: String 17 | link: String 18 | type: PostType 19 | } 20 | 21 | type Member { 22 | memberId: String 23 | reputation: Int 24 | user: User 25 | } 26 | 27 | type PostCollectionResult { 28 | cursor: String! 29 | hasMore: Boolean! 30 | launches: [Launch]! 31 | } 32 | 33 | extend type Query { 34 | postById(id: ID!): Post 35 | postBySlug(slug: String!): Post 36 | popularPosts(pageSize: Int, after: String): PostCollectionResult 37 | recentPosts(pageSize: Int, after: String): PostCollectionResult 38 | memberById(id: String!): Member 39 | } 40 | 41 | extend type Mutation { 42 | createPost(input: CreatePostInput!): CreatePostPayload 43 | createMember(input: CreateMemberInput!): CreateMemberPayload 44 | } 45 | `; 46 | -------------------------------------------------------------------------------- /src/shared/infra/http/graphql/test.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyarleniber/typescript-ddd-forum/ec457803d7547bc527c0353a3876a363880a2cc7/src/shared/infra/http/graphql/test.js -------------------------------------------------------------------------------- /src/shared/infra/http/graphql/users.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "apollo-server-express"; 2 | 3 | export const typeDefs = gql` 4 | type User { 5 | username: String 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /src/shared/infra/http/index.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from "./utils/Middleware"; 2 | import { authService } from "../../../modules/users/services"; 3 | 4 | const middleware = new Middleware(authService); 5 | 6 | export { middleware }; 7 | -------------------------------------------------------------------------------- /src/shared/utils/FlowUtils.ts: -------------------------------------------------------------------------------- 1 | export class FlowUtils { 2 | public static delay(ms: number) { 3 | return new Promise((resolve) => setTimeout(resolve, ms)); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/TextUtils.ts: -------------------------------------------------------------------------------- 1 | import validator from "validator"; 2 | import { JSDOM } from "jsdom"; 3 | import DOMPurify from "dompurify"; 4 | const { window } = new JSDOM(""); 5 | const domPurify = DOMPurify(window); 6 | 7 | export class TextUtils { 8 | public static sanitize(unsafeText: string): string { 9 | return domPurify.sanitize(unsafeText); 10 | } 11 | 12 | public static validateWebURL(url: string): boolean { 13 | return validator.isURL(url); 14 | } 15 | 16 | public static validateEmailAddress(email: string) { 17 | var re = 18 | /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; 19 | return re.test(String(email).toLowerCase()); 20 | } 21 | 22 | public static createRandomNumericString(numberDigits: number): string { 23 | const chars = "0123456789"; 24 | let value = ""; 25 | 26 | for (let i = numberDigits; i > 0; --i) { 27 | value += chars[Math.round(Math.random() * (chars.length - 1))]; 28 | } 29 | 30 | return value; 31 | } 32 | } 33 | --------------------------------------------------------------------------------