├── .cspell.json
├── .cspell
├── aws.txt
├── nodejs.txt
└── project.txt
├── .dockerignore
├── .editorconfig
├── .github
├── PULL_REQUEST_TEMPLATE.md
├── images
│ └── logo.png
└── workflows
│ ├── ci.yaml
│ ├── deploy-to-develop.yaml
│ └── release.yaml
├── .gitignore
├── .nvmrc
├── LICENSE
├── Makefile
├── README.md
├── apps
├── app
│ ├── .eslintrc.js
│ ├── .prettierrc.cjs
│ ├── Dockerfile
│ ├── app.ts
│ ├── controller
│ │ ├── getApiDoc
│ │ │ ├── getApiDoc.ts
│ │ │ ├── index.ts
│ │ │ └── redoc.html
│ │ ├── getSwaggerJson
│ │ │ ├── getSwaggerJson.ts
│ │ │ ├── index.ts
│ │ │ └── swagger.json
│ │ ├── healthCheck.ts
│ │ └── index.ts
│ ├── jest.config.js
│ ├── local.ts
│ ├── package.json
│ ├── route.ts
│ ├── server.ts
│ ├── tests
│ │ ├── api-doc.spec.ts
│ │ ├── health-check.spec.ts
│ │ └── swagger-json.spec.ts
│ ├── tsconfig.json
│ ├── types
│ │ └── global.d.ts
│ └── yarn.lock
├── article
│ ├── .eslintrc.js
│ ├── .prettierrc.cjs
│ ├── Dockerfile
│ ├── app.ts
│ ├── controller
│ │ ├── addComment.ts
│ │ ├── createArticle.ts
│ │ ├── deleteArticle.ts
│ │ ├── deleteComment.ts
│ │ ├── favoriteArticle.ts
│ │ ├── getArticle.ts
│ │ ├── getArticleTags.ts
│ │ ├── getArticles.ts
│ │ ├── getComments.ts
│ │ ├── getFeedArticles.ts
│ │ ├── index.ts
│ │ ├── unfavoriteArticle.ts
│ │ └── updateArticle.ts
│ ├── dto
│ │ ├── DtoArticle.ts
│ │ ├── DtoComment.ts
│ │ └── index.ts
│ ├── jest.config.js
│ ├── local.ts
│ ├── package.json
│ ├── route.ts
│ ├── schema
│ │ ├── addCommentBody.ts
│ │ ├── createArticleBody.ts
│ │ ├── getArticleFeedQuery.ts
│ │ ├── getArticlesQuery.ts
│ │ ├── getCommentsQuery.ts
│ │ ├── index.ts
│ │ └── updateArticleBody.ts
│ ├── server.ts
│ ├── service
│ │ ├── ApiAddComments
│ │ │ ├── ApiAddComments.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── ApiCreateArticle
│ │ │ ├── ApiCreateArticle.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── ApiDeleteArticle
│ │ │ ├── ApiDeleteArticle.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── ApiDeleteComment
│ │ │ ├── ApiDeleteComment.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── ApiFavoriteArticle
│ │ │ ├── ApiFavoriteArticle.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── ApiFeedArticles
│ │ │ ├── ApiFeedArticles.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── ApiGetArticle
│ │ │ ├── ApiGetArticle.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── ApiGetComments
│ │ │ ├── ApiGetComments.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── ApiGetTags
│ │ │ ├── ApiGetTags.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── ApiListArticles
│ │ │ ├── ApiListArticles.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── ApiUnfavoriteArticle
│ │ │ ├── ApiUnfavoriteArticle.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── ApiUpdateArticle
│ │ │ ├── ApiUpdateArticle.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── Factory.ts
│ │ └── index.ts
│ ├── tests
│ │ ├── basic
│ │ │ ├── create-article.spec.ts
│ │ │ ├── delete-article.spec.ts
│ │ │ ├── feed-articles.spec.ts
│ │ │ ├── get-article.spec.ts
│ │ │ ├── list-articles.spec.ts
│ │ │ └── update-article.spec.ts
│ │ ├── comments
│ │ │ ├── add-comments.spec.ts
│ │ │ ├── delete-comment.spec.ts
│ │ │ └── get-comments.spec.ts
│ │ ├── favorite
│ │ │ ├── favorite-article.spec.ts
│ │ │ └── unfavorite-article.spec.ts
│ │ └── tag
│ │ │ └── get-tags.spec.ts
│ ├── tsconfig.json
│ └── types
│ │ └── global.d.ts
├── local
│ ├── .eslintrc.js
│ ├── .prettierrc.cjs
│ ├── Dockerfile
│ ├── local.ts
│ ├── package.json
│ ├── tsconfig.json
│ └── types
│ │ └── global.d.ts
└── user
│ ├── .eslintrc.js
│ ├── .prettierrc.cjs
│ ├── Dockerfile
│ ├── app.ts
│ ├── constants
│ ├── ErrorCodes.ts
│ └── index.ts
│ ├── controller
│ ├── followUser.ts
│ ├── getCurrentUser.ts
│ ├── getUserProfile.ts
│ ├── index.ts
│ ├── login.ts
│ ├── registration.ts
│ ├── unfollowUser.ts
│ └── updateUser.ts
│ ├── dto
│ ├── DtoProfile.ts
│ ├── DtoUser.ts
│ └── index.ts
│ ├── jest.config.js
│ ├── local.ts
│ ├── package.json
│ ├── route.ts
│ ├── schema
│ ├── index.ts
│ ├── loginBody.ts
│ ├── registrationBody.ts
│ └── updateUserBody.ts
│ ├── server.ts
│ ├── service
│ ├── ApiFollowUser
│ │ ├── ApiFollowUser.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── ApiGetCurrentUser
│ │ ├── ApiGetCurrentUser.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── ApiGetProfile
│ │ ├── ApiGetProfile.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── ApiRegistration
│ │ ├── ApiRegistration.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── ApiUnfollowUser
│ │ ├── ApiUnfollowUser.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── ApiUpdateUser
│ │ ├── ApiUpdateUser.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── ApiUserLogin
│ │ ├── ApiUserLogin.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── Factory.ts
│ └── index.ts
│ ├── tests
│ ├── basic
│ │ ├── get-current-user.spec.ts
│ │ ├── get-profile.spec.ts
│ │ ├── login.spec.ts
│ │ ├── registration.spec.ts
│ │ └── update-user.spec.ts
│ └── follow
│ │ ├── follow-user.spec.ts
│ │ └── unfollow-user.spec.ts
│ ├── tsconfig.json
│ └── types
│ └── global.d.ts
├── codecov.yml
├── config
├── ci.json
├── custom-environment-variables.json
├── default.json
├── develop.json
├── prod.json
└── test.json
├── docker-compose.yml
├── infra
├── .eslintrc.json
├── .prettierignore
├── .prettierrc.json
├── Makefile
├── cdk.json
├── config
│ ├── custom-environment-variables.json
│ ├── default.json
│ ├── develop.json
│ └── prod.json
├── constants
│ ├── Lambdas.ts
│ ├── Secrets.ts
│ ├── Stacks.ts
│ ├── constants.ts
│ └── index.ts
├── main.ts
├── package.json
├── stacks
│ ├── ApiGatewayStack
│ │ ├── ApiGatewayStack.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── LambdaStack
│ │ ├── LambdaStack.ts
│ │ ├── constants
│ │ │ ├── FileExcludeList.ts
│ │ │ └── index.ts
│ │ ├── index.ts
│ │ ├── types.ts
│ │ └── utils
│ │ │ ├── getEnvironmentVariables.ts
│ │ │ └── index.ts
│ └── index.ts
├── tsconfig.json
├── utils
│ ├── config
│ │ ├── config.ts
│ │ ├── index.ts
│ │ └── types.ts
│ └── index.ts
└── yarn.lock
├── package.json
├── packages
├── config
│ ├── .eslintrc.json
│ ├── .prettierrc.json
│ ├── jest.config.js
│ ├── package.json
│ └── tsconfig.base.json
├── core
│ ├── .eslintrc.js
│ ├── .prettierrc.cjs
│ ├── database
│ │ ├── DbArticle
│ │ │ ├── DbArticle.ts
│ │ │ ├── dto
│ │ │ │ ├── DbDtoArticle.ts
│ │ │ │ ├── DbDtoArticleComment.ts
│ │ │ │ ├── DbDtoArticleCommentWithProfile.ts
│ │ │ │ ├── DbDtoArticleTag.ts
│ │ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── DbFactory.ts
│ │ ├── DbUser
│ │ │ ├── DbUser.ts
│ │ │ ├── dto
│ │ │ │ ├── DbDtoProfile.ts
│ │ │ │ ├── DbDtoUser.ts
│ │ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── Dockerfile
│ │ ├── index.ts
│ │ └── knex
│ │ │ ├── index.ts
│ │ │ ├── knex.ts
│ │ │ ├── knexfile.ts
│ │ │ ├── migrations
│ │ │ ├── 0001_create-user-table.ts
│ │ │ ├── 0002_create-task-table.ts
│ │ │ ├── 0003_create-user-follow-table.ts
│ │ │ ├── 0004_create-article-table.ts
│ │ │ ├── 0005_create-article-comment-table.ts
│ │ │ ├── 0006_create-article-favorite-table.ts
│ │ │ └── 0007_create-article-tag-table.ts
│ │ │ ├── seeds
│ │ │ └── .gitkeep
│ │ │ └── types.ts
│ ├── index.ts
│ ├── jest.config.js
│ ├── package.json
│ ├── repository
│ │ ├── RepoArticle
│ │ │ ├── RepoArticle.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── RepoFactory.ts
│ │ ├── RepoUser
│ │ │ ├── RepoUser.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ └── index.ts
│ ├── service
│ │ ├── ServiceFactory.ts
│ │ ├── article
│ │ │ ├── ArticleService.ts
│ │ │ ├── constants
│ │ │ │ ├── ArticleErrorCodes.ts
│ │ │ │ └── index.ts
│ │ │ ├── errors
│ │ │ │ ├── ArticleAlreadyFavoritedError.ts
│ │ │ │ ├── ArticleError.ts
│ │ │ │ ├── ArticleNotFoundError.ts
│ │ │ │ ├── ArticleNotYetFavoritedError.ts
│ │ │ │ ├── ArticleTitleAlreadyTakenError.ts
│ │ │ │ └── index.ts
│ │ │ ├── implementations
│ │ │ │ ├── CreateArticleCommentHandler
│ │ │ │ │ ├── CreateArticleCommentHandler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── CreateArticleHandler
│ │ │ │ │ ├── CreateArticleHandler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── CreateArticleTagsHandler
│ │ │ │ │ ├── CreateArticleTagsHandler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── FavoriteArticleHandler
│ │ │ │ │ ├── FavoriteArticleHandler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── GetArticleCommentsHandler
│ │ │ │ │ ├── GetArticleCommentsHandler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── UpdateArticleHandler
│ │ │ │ │ ├── UpdateArticleHandler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── types.ts
│ │ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── auth
│ │ │ ├── AuthService.ts
│ │ │ ├── constants
│ │ │ │ ├── AuthErrorCodes.ts
│ │ │ │ └── index.ts
│ │ │ ├── errors
│ │ │ │ ├── AuthError.ts
│ │ │ │ ├── InvalidTokenError.ts
│ │ │ │ ├── PasswordNotMatchError.ts
│ │ │ │ ├── PasswordRequirementsNotMetError.ts
│ │ │ │ └── index.ts
│ │ │ ├── implementations
│ │ │ │ ├── AccessTokenHandler
│ │ │ │ │ ├── AccessTokenHandler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── PasswordHandler
│ │ │ │ │ ├── PasswordHandler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── types.ts
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ ├── index.ts
│ │ └── user
│ │ │ ├── UserService.ts
│ │ │ ├── constants
│ │ │ ├── UserErrorCode.ts
│ │ │ └── index.ts
│ │ │ ├── errors
│ │ │ ├── InvalidFollowError.ts
│ │ │ ├── UserError.ts
│ │ │ ├── UserExistError.ts
│ │ │ ├── UserNotFoundError.ts
│ │ │ └── index.ts
│ │ │ ├── implementations
│ │ │ ├── CreateUserHandler
│ │ │ │ ├── CreateUserHandler.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── types.ts
│ │ │ ├── FollowHandler
│ │ │ │ ├── FollowHandler.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── types.ts
│ │ │ ├── UpdateUserHandler
│ │ │ │ ├── UpdateUserHandler.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── types.ts
│ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ ├── tests
│ │ ├── article
│ │ │ ├── comment.spec.ts
│ │ │ ├── create-article.spec.ts
│ │ │ ├── favorite.spec.ts
│ │ │ ├── get-article.spec.ts
│ │ │ ├── list.spec.ts
│ │ │ ├── tags.spec.ts
│ │ │ └── update-article.spec.ts
│ │ ├── auth
│ │ │ ├── access-token.spec.ts
│ │ │ └── password.spec.ts
│ │ └── user
│ │ │ ├── create-user.spec.ts
│ │ │ ├── following.spec.ts
│ │ │ └── update-user.spec.ts
│ ├── tsconfig.json
│ ├── types
│ │ ├── RecordStatus.ts
│ │ ├── UserStatus.ts
│ │ ├── global.d.ts
│ │ └── index.ts
│ └── utils
│ │ ├── error
│ │ ├── AppError
│ │ │ ├── AppError.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ └── index.ts
│ │ ├── getObjectId.ts
│ │ └── index.ts
├── middleware
│ ├── .eslintrc.js
│ ├── .prettierrc.cjs
│ ├── auth.ts
│ ├── configureGlobalExceptionHandler
│ │ ├── configureGlobalExceptionHandler.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── configureLambda
│ │ ├── configureLambda.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── configureMiddlewares
│ │ ├── configureMiddlewares.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── index.ts
│ ├── jest.config.js
│ ├── package.json
│ ├── tsconfig.json
│ └── types
│ │ └── global.d.ts
├── types
│ ├── .eslintrc.js
│ ├── .prettierrc.cjs
│ ├── NodeEnv.ts
│ ├── index.ts
│ ├── package.json
│ └── tsconfig.json
└── utils
│ ├── .eslintrc.js
│ ├── .prettierrc.cjs
│ ├── config
│ ├── config.ts
│ ├── index.ts
│ └── types.ts
│ ├── error
│ ├── ApiError.ts
│ ├── ApiErrorBadRequest.ts
│ ├── ApiErrorConflict.ts
│ ├── ApiErrorForbidden.ts
│ ├── ApiErrorInternalServerError.ts
│ ├── ApiErrorMethodNotAllowed.ts
│ ├── ApiErrorNotFound.ts
│ ├── ApiErrorNotImplemented.ts
│ ├── ApiErrorRequestTimeout.ts
│ ├── ApiErrorServiceUnavailable.ts
│ ├── ApiErrorTooManyRequests.ts
│ ├── ApiErrorUnauthorized.ts
│ ├── ApiErrorUnprocessableEntity.ts
│ ├── constants
│ │ └── http-error.json
│ └── index.ts
│ ├── index.ts
│ ├── jest.config.js
│ ├── logger
│ ├── formats
│ │ ├── capitalizeLevel.ts
│ │ ├── cleanStack.ts
│ │ ├── customPrintf.ts
│ │ ├── environment.ts
│ │ ├── index.ts
│ │ ├── json.ts
│ │ └── label.ts
│ ├── index.ts
│ └── logger.ts
│ ├── package.json
│ └── tsconfig.json
├── scripts
├── api-test.sh
├── clean.sh
└── wait-for-readiness.sh
├── tests
└── integration
│ ├── postman-collections
│ └── full.json
│ └── testing-environment.json
├── turbo.json
└── yarn.lock
/.cspell.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
3 | "version": "0.2",
4 | "language": "en",
5 | "useGitignore": true,
6 | "caseSensitive": false,
7 | "dictionaries": [
8 | "typescript",
9 | "node",
10 | "html",
11 | "css",
12 | "bash",
13 | "npm",
14 | "filetypes",
15 | "fonts",
16 | "project",
17 | "nodejs"
18 | ],
19 | "ignorePaths": [
20 | "package.json",
21 | "pnpm-lock.yaml"
22 | ],
23 | "dictionaryDefinitions": [
24 | {
25 | "name": "project",
26 | "path": "./.cspell/project.txt",
27 | "description": "Wordings specific to this project and will not be shared with other projects"
28 | },
29 | {
30 | "name": "aws",
31 | "path": "./.cspell/aws.txt",
32 | "description": "Keywords specific to the AWS project, including the names of AWS services and the terms used within them"
33 | },
34 | {
35 | "name": "nodejs",
36 | "path": "./.cspell/nodejs.txt",
37 | "description": "Keywords specific to the Node.js project, including the names of node_modules and the terms used within them"
38 | }
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/.cspell/aws.txt:
--------------------------------------------------------------------------------
1 | apigatewayv2
2 | certificatemanager
3 | codepipeline
4 | amazonaws
5 | codebuild
6 | apigateway
7 | dbname
8 | secretsmanager
9 | arn
10 | arns
11 |
--------------------------------------------------------------------------------
/.cspell/nodejs.txt:
--------------------------------------------------------------------------------
1 | apiurl
2 | cloudwatchlogs
3 | codegen
4 | colorfied
5 | colorify
6 | knexfile
7 | localstack
8 | logform
9 | longtext
10 | luxon
11 | mediumtext
12 | middlewares
13 | millis
14 | openapi
15 | packagejson
16 | prerequest
17 | redoc
18 | reduxjs
19 | sinonjs
20 | Snyk
21 | sqlstring
22 | tinytext
23 | trivago
24 | unflatten
25 | vendia
26 | graphiql
27 | uncolorize
28 | cicd
29 |
--------------------------------------------------------------------------------
/.cspell/project.txt:
--------------------------------------------------------------------------------
1 | codecov
2 | favorited
3 | favoriting
4 | jwtid
5 | kenyip
6 | realworld
7 | signin
8 | uncategorized
9 | unfavorited
10 | unfavoriting
11 | unfollow
12 | unfollowed
13 | unfollowing
14 | unfollows
15 | kenyip
16 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Ignore node_modules and dependencies
2 | node_modules
3 | bower_components
4 |
5 | # Ignore logs and temporary files
6 | *.log
7 | *.tmp
8 | *.swp
9 |
10 | # Ignore build directories
11 | dist
12 | build
13 | out
14 |
15 | # Ignore configuration files and environment files
16 | .env
17 | .env.local
18 | .DS_Store
19 |
20 | # Ignore Docker-related files if not needed
21 | Dockerfile.*
22 | docker-compose.*
23 |
24 | # Ignore Git and version control metadata
25 | .git
26 | .gitlab-ci.yml
27 |
28 | # Ignore IDE/Editor-specific files
29 | .vscode
30 | .idea
31 | *.iml
32 |
33 | # Ignore test-related files
34 | coverage
35 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | charset = utf-8
6 | indent_size = 2
7 | indent_style = space
8 | trim_trailing_whitespace = true
9 |
10 | [*.yml]
11 | indent_size = 2
12 | indent_style = space
13 |
14 | [*.yaml]
15 | indent_size = 2
16 | indent_style = space
17 |
18 | [*.md]
19 | max_line_length = off
20 | trim_trailing_whitespace = false
21 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | # Summary
2 |
3 |
4 | ## Type of Change
5 | - [ ] 🐛 Bug fix (resolves an issue without affecting existing functionality)
6 | - [ ] ✨ New feature (adds functionality without breaking existing behavior)
7 | - [ ] 💥 Breaking change (alters existing behavior or introduces incompatibilities)
8 | - [ ] 📝 Documentation update (changes to documentation or README)
9 |
10 | ## List of Changes
11 |
12 |
13 | 1.
14 | 2.
15 | 3.
16 |
17 | ## Testing Details
18 |
19 |
20 | 1.
21 | 2.
22 | 3.
23 |
24 |
25 | ## Merge Workflow Checklist
26 |
27 | ### For New Features / Fixes
28 | - [ ] Ensure the PR targets the `develop` branch.
29 | - [ ] Use a descriptive PR title with relevant issue/ticket references (if applicable).
30 | - [ ] Include relevant design documents, requirements, or tickets in the PR.
31 | - [ ] Provide screenshots, logs, cURL requests, or backend call samples where relevant.
32 | - [ ] Follow the **Squash Commits** strategy, ensuring the commit message matches the PR title and follows internal contribution guidelines.
33 |
34 | ### Release Workflow
35 | - [ ] Set the PR target to `main` (source branch: `staging`).
36 | - [ ] Use **Merge Commits** with the message: `Release [Develop → Master]`.
37 |
--------------------------------------------------------------------------------
/.github/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kenyipp/realworld-nodejs-example-app/94a864e4e458a5e0ae2b07d3cf762791769e8690/.github/images/logo.png
--------------------------------------------------------------------------------
/.github/workflows/deploy-to-develop.yaml:
--------------------------------------------------------------------------------
1 | name: Deploy to Develop
2 |
3 | on:
4 | workflow_run:
5 | workflows: ['Continuous Integration'] # Triggers after the specified workflow completes
6 | types:
7 | - completed
8 |
9 | jobs:
10 | deploy:
11 | name: Deploy to Develop
12 | environment:
13 | name: develop
14 | runs-on: ubuntu-latest
15 | if: |
16 | github.event.workflow_run.conclusion == 'success' &&
17 | contains(github.event.workflow_run.head_branch, 'develop')
18 |
19 | steps:
20 | - name: Trigger AWS CodePipeline
21 | run:
22 | aws codepipeline start-pipeline-execution --name ${{
23 | vars.CODE_BUILD_PIPELINE }} --region ${{ vars.AWS_REGION }}
24 | env:
25 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
26 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
27 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v20.18.0
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Ken Yip
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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | test-build:
2 | yarn clean && yarn build
3 | node ./apps/users/cron.js
4 |
5 | qa:
6 | yarn prettify && yarn check-types && yarn lint:fix
7 |
8 | size-check:
9 | echo "Installing the production dependencies..."
10 | yarn install --production --frozen-lockfile --silent
11 | du -sh node_modules/* | sort -hr
12 | echo "Installing the development dependencies..."
13 | yarn install --frozen-lockfile --silent
14 |
15 | reset-head:
16 | git checkout develop
17 | git fetch origin
18 | git reset --hard origin/develop
19 |
20 | spell-check:
21 | ./node_modules/cspell/bin.mjs ./apps/* ./packages/* --no-progress -u
22 |
--------------------------------------------------------------------------------
/apps/app/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const defaultConfig = require('@conduit/config/.eslintrc.json');
2 |
3 | module.exports = {
4 | ...defaultConfig,
5 | parserOptions: {
6 | project: './tsconfig.json'
7 | },
8 | rules: {
9 | ...defaultConfig.rules,
10 | 'import/no-extraneous-dependencies': 'off'
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/apps/app/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = require('@conduit/config/.prettierrc.json');
2 |
--------------------------------------------------------------------------------
/apps/app/Dockerfile:
--------------------------------------------------------------------------------
1 | #
2 | # Base image for the server
3 | #
4 | FROM node:18 AS base
5 |
6 | ARG WORKSPACE_SCOPE=@conduit/app
7 |
8 | # Set working directory
9 | WORKDIR /app
10 | RUN yarn global add turbo
11 |
12 | # Copy the project files
13 | COPY . .
14 |
15 | # Use the build argument in the turbo prune command
16 | RUN turbo prune --scope=$WORKSPACE_SCOPE --docker
17 |
18 | #
19 | # Installer image
20 | # Add lockfile and package.json's of isolated sub-workspace
21 | #
22 | FROM base AS installer
23 |
24 | # Set working directory
25 | WORKDIR /app
26 |
27 | # Copy necessary files to the installer stage
28 | COPY .gitignore .gitignore
29 | COPY --from=base /app/out/json/ .
30 | COPY --from=base /app/out/yarn.lock ./yarn.lock
31 |
32 | # Install dependencies
33 | RUN yarn install
34 |
35 | # Build the project using the variable scope
36 | COPY --from=base /app/out/full/ .
37 | RUN turbo run build --filter=$WORKSPACE_SCOPE...
38 |
--------------------------------------------------------------------------------
/apps/app/app.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 |
3 | import {
4 | configureGlobalExceptionHandler,
5 | configureMiddlewares
6 | } from '@conduit/middleware';
7 |
8 | import { router } from './route';
9 |
10 | export const app = express();
11 |
12 | configureMiddlewares({ app });
13 |
14 | app.use(router);
15 |
16 | configureGlobalExceptionHandler({ app });
17 |
--------------------------------------------------------------------------------
/apps/app/controller/getApiDoc/getApiDoc.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 | import fs from 'fs';
3 | import path from 'path';
4 |
5 | const redocHtml = fs.readFileSync(path.join(__dirname, './redoc.html'), 'utf8');
6 |
7 | export const getApiDoc: RequestHandler = async (_req, res) => {
8 | res.send(redocHtml);
9 | };
10 |
--------------------------------------------------------------------------------
/apps/app/controller/getApiDoc/index.ts:
--------------------------------------------------------------------------------
1 | export * from './getApiDoc';
2 |
--------------------------------------------------------------------------------
/apps/app/controller/getApiDoc/redoc.html:
--------------------------------------------------------------------------------
1 |
2 |
Conduit API Documentation
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/apps/app/controller/getSwaggerJson/getSwaggerJson.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import swagger from './swagger.json';
4 |
5 | export const getSwaggerJson: RequestHandler = async (_req, res) => {
6 | res.json(swagger);
7 | };
8 |
--------------------------------------------------------------------------------
/apps/app/controller/getSwaggerJson/index.ts:
--------------------------------------------------------------------------------
1 | export * from './getSwaggerJson';
2 |
--------------------------------------------------------------------------------
/apps/app/controller/healthCheck.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | export const healthCheck: RequestHandler = async (_req, res) => {
4 | res.send('OK');
5 | };
6 |
--------------------------------------------------------------------------------
/apps/app/controller/index.ts:
--------------------------------------------------------------------------------
1 | export * from './getApiDoc';
2 | export * from './getSwaggerJson';
3 | export * from './healthCheck';
4 |
--------------------------------------------------------------------------------
/apps/app/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('jest').Config} */
2 |
3 | module.exports = require('@conduit/config/jest.config');
4 |
--------------------------------------------------------------------------------
/apps/app/local.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 |
3 | import { logger } from '@conduit/utils';
4 |
5 | import { app } from './app';
6 |
7 | app.listen(3100, () => {
8 | logger.info('User server is running on http://localhost:3100', { label: 'App' });
9 | });
10 |
--------------------------------------------------------------------------------
/apps/app/route.ts:
--------------------------------------------------------------------------------
1 | import Router from 'express-promise-router';
2 |
3 | import { getApiDoc, getSwaggerJson, healthCheck } from './controller';
4 |
5 | export const router = Router();
6 |
7 | router.get('/api/health-check', healthCheck);
8 |
9 | router.get('/swagger.json', getSwaggerJson);
10 |
11 | router.get('/', getApiDoc);
12 |
--------------------------------------------------------------------------------
/apps/app/server.ts:
--------------------------------------------------------------------------------
1 | import { configureLambda } from '@conduit/middleware';
2 |
3 | import { app } from './app';
4 |
5 | export const handler = configureLambda({ app });
6 |
--------------------------------------------------------------------------------
/apps/app/tests/api-doc.spec.ts:
--------------------------------------------------------------------------------
1 | import supertest from 'supertest';
2 |
3 | import { app } from '../app';
4 |
5 | const request = supertest(app);
6 |
7 | describe('GET /', () => {
8 | it('should be able to retrieve the api documentation', async () => {
9 | const response = await request.get('/');
10 | expect(response.status).toBe(200);
11 | expect(response.header['content-type']).toBe('text/html; charset=utf-8');
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/apps/app/tests/health-check.spec.ts:
--------------------------------------------------------------------------------
1 | import supertest from 'supertest';
2 |
3 | import { app } from '../app';
4 |
5 | const request = supertest(app);
6 |
7 | describe('GET /api/health-check', () => {
8 | it('should be able to retrieve the health check', async () => {
9 | const response = await request.get('/api/health-check');
10 | expect(response.status).toBe(200);
11 | });
12 | });
13 |
--------------------------------------------------------------------------------
/apps/app/tests/swagger-json.spec.ts:
--------------------------------------------------------------------------------
1 | import supertest from 'supertest';
2 |
3 | import { app } from '../app';
4 |
5 | const request = supertest(app);
6 |
7 | describe('GET /swagger-json', () => {
8 | it('should be able to retrieve the swagger.json', async () => {
9 | const response = await request.get('/swagger.json');
10 | expect(response.status).toBe(200);
11 | expect(response.header['content-type']).toBe('application/json; charset=utf-8');
12 | expect(response.body).toHaveProperty('openapi');
13 | expect(response.body).toHaveProperty('info');
14 | expect(response.body).toHaveProperty('paths');
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/apps/app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "extends": "@conduit/config/tsconfig.base.json",
4 | "compilerOptions": {
5 | "sourceMap": false
6 | },
7 | "include": ["./**/*.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/apps/app/types/global.d.ts:
--------------------------------------------------------------------------------
1 | import '@conduit/core/types/global';
2 |
--------------------------------------------------------------------------------
/apps/article/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const defaultConfig = require('@conduit/config/.eslintrc.json');
2 |
3 | module.exports = {
4 | ...defaultConfig,
5 | parserOptions: {
6 | project: './tsconfig.json'
7 | },
8 | rules: {
9 | ...defaultConfig.rules,
10 | 'import/no-extraneous-dependencies': 'off'
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/apps/article/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = require('@conduit/config/.prettierrc.json');
2 |
--------------------------------------------------------------------------------
/apps/article/Dockerfile:
--------------------------------------------------------------------------------
1 | #
2 | # Base image for the server
3 | #
4 | FROM node:18 AS base
5 |
6 | ARG WORKSPACE_SCOPE=@conduit/article
7 |
8 | # Set working directory
9 | WORKDIR /app
10 | RUN yarn global add turbo
11 |
12 | # Copy the project files
13 | COPY . .
14 |
15 | # Use the build argument in the turbo prune command
16 | RUN turbo prune --scope=$WORKSPACE_SCOPE --docker
17 |
18 | #
19 | # Installer image
20 | # Add lockfile and package.json's of isolated sub-workspace
21 | #
22 | FROM base AS installer
23 |
24 | # Set working directory
25 | WORKDIR /app
26 |
27 | # Copy necessary files to the installer stage
28 | COPY .gitignore .gitignore
29 | COPY --from=base /app/out/json/ .
30 | COPY --from=base /app/out/yarn.lock ./yarn.lock
31 |
32 | # Install dependencies
33 | RUN yarn install
34 |
35 | # Build the project using the variable scope
36 | COPY --from=base /app/out/full/ .
37 | RUN turbo run build --filter=$WORKSPACE_SCOPE...
38 |
--------------------------------------------------------------------------------
/apps/article/app.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 |
3 | import {
4 | configureGlobalExceptionHandler,
5 | configureMiddlewares
6 | } from '@conduit/middleware';
7 |
8 | import { router } from './route';
9 |
10 | export const app = express();
11 |
12 | configureMiddlewares({ app });
13 |
14 | app.use(router);
15 |
16 | configureGlobalExceptionHandler({ app });
17 |
--------------------------------------------------------------------------------
/apps/article/controller/addComment.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import {
4 | ApiErrorInternalServerError,
5 | ApiErrorUnprocessableEntity
6 | } from '@conduit/utils';
7 |
8 | import { addCommentBodySchema } from '../schema';
9 | import { Factory } from '../service/Factory';
10 |
11 | const factory = new Factory();
12 | const apiAddComments = factory.newApiAddComments();
13 |
14 | export const addComment: RequestHandler = async (
15 | req,
16 | res
17 | ) => {
18 | const { user } = req;
19 | const { slug } = req.params;
20 |
21 | const { value: input, error } = addCommentBodySchema.validate(req.body);
22 | if (error) {
23 | throw new ApiErrorUnprocessableEntity({
24 | message:
25 | 'Invalid or missing data in the request body. Please ensure all required fields are included and in the correct format.',
26 | cause: error
27 | });
28 | }
29 |
30 | const comment = input.comment.body;
31 |
32 | if (!user || !slug) {
33 | throw new ApiErrorInternalServerError({
34 | cause: new Error('Missing required parameters. Check router settings.')
35 | });
36 | }
37 |
38 | const response = await apiAddComments.execute({
39 | slug,
40 | userId: user.id,
41 | body: comment
42 | });
43 | res.json(response);
44 | };
45 |
46 | interface Body {
47 | comment: {
48 | body: string;
49 | };
50 | }
51 |
52 | interface Params {
53 | slug: string;
54 | }
55 |
--------------------------------------------------------------------------------
/apps/article/controller/createArticle.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import {
4 | ApiErrorInternalServerError,
5 | ApiErrorUnprocessableEntity
6 | } from '@conduit/utils';
7 |
8 | import { createArticleBodySchema } from '../schema';
9 | import { Factory } from '../service/Factory';
10 |
11 | const factory = new Factory();
12 | const apiCreateArticle = factory.newApiCreateArticle();
13 |
14 | export const createArticle: RequestHandler = async (
15 | req,
16 | res
17 | ) => {
18 | const { user } = req;
19 |
20 | const { value: input, error } = createArticleBodySchema.validate(req.body);
21 | if (error) {
22 | throw new ApiErrorUnprocessableEntity({
23 | message:
24 | 'Invalid or missing data in the request body. Please ensure all required fields are included and in the correct format.',
25 | cause: error
26 | });
27 | }
28 |
29 | if (!user) {
30 | throw new ApiErrorInternalServerError({
31 | cause: new Error('Missing required parameters. Check router settings.')
32 | });
33 | }
34 |
35 | const { title, description, body, tagList } = input.article;
36 |
37 | const response = await apiCreateArticle.execute({
38 | title,
39 | description,
40 | body,
41 | tagList,
42 | author: user
43 | });
44 | res.json(response);
45 | };
46 |
47 | interface Body {
48 | article: {
49 | title: string;
50 | description: string;
51 | body: string;
52 | tagList: string[];
53 | };
54 | }
55 |
--------------------------------------------------------------------------------
/apps/article/controller/deleteArticle.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import { ApiErrorInternalServerError } from '@conduit/utils';
4 |
5 | import { Factory } from '../service/Factory';
6 |
7 | const factory = new Factory();
8 | const apiDeleteArticle = factory.newApiDeleteArticle();
9 |
10 | export const deleteArticle: RequestHandler<
11 | Params,
12 | unknown,
13 | unknown,
14 | unknown
15 | > = async (req, res) => {
16 | const { user } = req;
17 | const { slug } = req.params;
18 | if (!user || !slug) {
19 | throw new ApiErrorInternalServerError({
20 | cause: new Error('Missing required parameters. Check router settings.')
21 | });
22 | }
23 | const response = await apiDeleteArticle.execute({ slug, userId: user.id });
24 | res.json(response);
25 | };
26 |
27 | interface Params {
28 | slug: string;
29 | }
30 |
--------------------------------------------------------------------------------
/apps/article/controller/deleteComment.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import { ApiErrorInternalServerError } from '@conduit/utils';
4 |
5 | import { Factory } from '../service/Factory';
6 |
7 | const factory = new Factory();
8 | const apiDeleteComment = factory.newApiDeleteComment();
9 |
10 | export const deleteComment: RequestHandler<
11 | Params,
12 | unknown,
13 | unknown,
14 | unknown
15 | > = async (req, res) => {
16 | const { user } = req;
17 | const { id: commentId } = req.params;
18 | if (!user || !commentId) {
19 | throw new ApiErrorInternalServerError({
20 | cause: new Error('Missing required parameters. Check router settings.')
21 | });
22 | }
23 | const response = await apiDeleteComment.execute({ commentId, userId: user.id });
24 | res.json(response);
25 | };
26 |
27 | interface Params {
28 | id: string;
29 | }
30 |
--------------------------------------------------------------------------------
/apps/article/controller/favoriteArticle.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import { ApiErrorInternalServerError } from '@conduit/utils';
4 |
5 | import { Factory } from '../service/Factory';
6 |
7 | const factory = new Factory();
8 | const apiFavoriteArticle = factory.newApiFavoriteArticle();
9 |
10 | export const favoriteArticle: RequestHandler<
11 | Params,
12 | unknown,
13 | unknown,
14 | unknown
15 | > = async (req, res) => {
16 | const { user } = req;
17 | const { slug } = req.params;
18 | if (!user || !slug) {
19 | throw new ApiErrorInternalServerError({
20 | cause: new Error('Missing required parameters. Check router settings.')
21 | });
22 | }
23 | const response = await apiFavoriteArticle.execute({ slug, userId: user.id });
24 | res.json(response);
25 | };
26 |
27 | interface Params {
28 | slug: string;
29 | }
30 |
--------------------------------------------------------------------------------
/apps/article/controller/getArticle.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express';
2 |
3 | import { ApiErrorInternalServerError } from '@conduit/utils';
4 |
5 | import { Factory } from '../service/Factory';
6 |
7 | const factory = new Factory();
8 | const apiGetArticle = factory.newApiGetArticle();
9 |
10 | export const getArticle = async (
11 | req: Request,
12 | res: Response
13 | ) => {
14 | const { user } = req;
15 | const { slug } = req.params;
16 | if (!slug) {
17 | throw new ApiErrorInternalServerError({
18 | cause: new Error('Missing required parameters. Check router settings.')
19 | });
20 | }
21 | const response = await apiGetArticle.execute({ slug, userId: user?.id });
22 | res.json(response);
23 | };
24 |
25 | interface Params {
26 | slug: string;
27 | }
28 |
--------------------------------------------------------------------------------
/apps/article/controller/getArticleTags.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import { Factory } from '../service/Factory';
4 |
5 | const factory = new Factory();
6 | const apiGetTags = factory.newApiGetTags();
7 |
8 | export const getArticleTags: RequestHandler = async (_req, res) => {
9 | const response = await apiGetTags.execute();
10 | res.json(response);
11 | };
12 |
--------------------------------------------------------------------------------
/apps/article/controller/getArticles.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import { ApiErrorUnprocessableEntity } from '@conduit/utils';
4 |
5 | import { getArticlesQuerySchema } from '../schema';
6 | import { Factory } from '../service/Factory';
7 |
8 | const factory = new Factory();
9 | const apiListArticles = factory.newApiListArticles();
10 |
11 | export const getArticles: RequestHandler = async (
12 | req,
13 | res
14 | ) => {
15 | const { user } = req;
16 | const { value, error } = getArticlesQuerySchema.validate(req.query);
17 | if (error) {
18 | throw new ApiErrorUnprocessableEntity({
19 | message:
20 | 'Invalid or missing data in the request body. Please ensure all required fields are included and in the correct format.',
21 | cause: error
22 | });
23 | }
24 |
25 | const { tag, author, favorited, limit, offset } = value;
26 |
27 | const response = await apiListArticles.execute({
28 | tag,
29 | author,
30 | favorited,
31 | limit,
32 | offset,
33 | userId: user?.id
34 | });
35 | res.json(response);
36 | };
37 |
38 | export interface Query {
39 | tag?: string;
40 | author?: string;
41 | favorited?: string;
42 | limit: number;
43 | offset: number;
44 | }
45 |
--------------------------------------------------------------------------------
/apps/article/controller/getComments.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import {
4 | ApiErrorInternalServerError,
5 | ApiErrorUnprocessableEntity
6 | } from '@conduit/utils';
7 |
8 | import { getCommentsQuerySchema } from '../schema';
9 | import { Factory } from '../service/Factory';
10 |
11 | const factory = new Factory();
12 | const apiGetComments = factory.newApiGetComments();
13 |
14 | export const getComments: RequestHandler = async (
15 | req,
16 | res
17 | ) => {
18 | const { user } = req;
19 | const { slug } = req.params;
20 | const { value, error } = getCommentsQuerySchema.validate(req.query);
21 | if (error) {
22 | throw new ApiErrorUnprocessableEntity({
23 | message:
24 | 'Invalid or missing data in the request body. Please ensure all required fields are included and in the correct format.',
25 | cause: error
26 | });
27 | }
28 | const { limit, offset } = value;
29 |
30 | if (!slug) {
31 | throw new ApiErrorInternalServerError({
32 | cause: new Error('Missing required parameters. Check router settings.')
33 | });
34 | }
35 |
36 | const response = await apiGetComments.execute({
37 | slug,
38 | userId: user?.id,
39 | limit,
40 | offset
41 | });
42 | res.json(response);
43 | };
44 |
45 | interface Params {
46 | slug: string;
47 | }
48 |
49 | interface Query {
50 | limit: number;
51 | offset: number;
52 | }
53 |
--------------------------------------------------------------------------------
/apps/article/controller/getFeedArticles.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import {
4 | ApiErrorInternalServerError,
5 | ApiErrorUnprocessableEntity
6 | } from '@conduit/utils';
7 |
8 | import { getArticleFeedQuerySchema } from '../schema';
9 | import { Factory } from '../service/Factory';
10 |
11 | const factory = new Factory();
12 | const apiFeedArticles = factory.newApiFeedArticles();
13 |
14 | export const getFeedArticles: RequestHandler<
15 | unknown,
16 | unknown,
17 | unknown,
18 | Query
19 | > = async (req, res) => {
20 | const { value, error } = getArticleFeedQuerySchema.validate(req.query);
21 | if (error) {
22 | throw new ApiErrorUnprocessableEntity({
23 | message:
24 | 'Invalid or missing data in the request body. Please ensure all required fields are included and in the correct format.',
25 | cause: error
26 | });
27 | }
28 | const { user } = req;
29 | if (!user) {
30 | throw new ApiErrorInternalServerError({
31 | cause: new Error('Missing required parameters. Check router settings.')
32 | });
33 | }
34 |
35 | const { limit, offset } = value;
36 | const response = await apiFeedArticles.execute({
37 | limit,
38 | offset,
39 | userId: user.id
40 | });
41 | res.json(response);
42 | };
43 |
44 | interface Query {
45 | limit: number;
46 | offset: number;
47 | }
48 |
--------------------------------------------------------------------------------
/apps/article/controller/index.ts:
--------------------------------------------------------------------------------
1 | export * from './addComment';
2 | export * from './createArticle';
3 | export * from './deleteArticle';
4 | export * from './deleteComment';
5 | export * from './favoriteArticle';
6 | export * from './getArticle';
7 | export * from './getArticles';
8 | export * from './getArticleTags';
9 | export * from './getComments';
10 | export * from './getFeedArticles';
11 | export * from './unfavoriteArticle';
12 | export * from './updateArticle';
13 |
--------------------------------------------------------------------------------
/apps/article/controller/unfavoriteArticle.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import { ApiErrorInternalServerError } from '@conduit/utils';
4 |
5 | import { Factory } from '../service/Factory';
6 |
7 | const factory = new Factory();
8 | const apiUnfavoriteArticle = factory.newApiUnfavoriteArticle();
9 |
10 | export const unfavoriteArticle: RequestHandler<
11 | Params,
12 | unknown,
13 | unknown,
14 | unknown
15 | > = async (req, res) => {
16 | const { user } = req;
17 | const { slug } = req.params;
18 | if (!user || !slug) {
19 | throw new ApiErrorInternalServerError({
20 | cause: new Error('Missing required parameters. Check router settings.')
21 | });
22 | }
23 | const response = await apiUnfavoriteArticle.execute({ slug, userId: user.id });
24 | res.json(response);
25 | };
26 |
27 | interface Params {
28 | slug: string;
29 | }
30 |
--------------------------------------------------------------------------------
/apps/article/controller/updateArticle.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import {
4 | ApiErrorInternalServerError,
5 | ApiErrorUnprocessableEntity
6 | } from '@conduit/utils';
7 |
8 | import { updateArticleBodySchema } from '../schema';
9 | import { Factory } from '../service/Factory';
10 |
11 | const factory = new Factory();
12 | const apiUpdateArticle = factory.newApiUpdateArticle();
13 |
14 | export const updateArticle: RequestHandler = async (
15 | req,
16 | res
17 | ) => {
18 | const { user } = req;
19 | const { slug } = req.params;
20 | if (!user || !slug) {
21 | throw new ApiErrorInternalServerError({
22 | cause: new Error('Missing required parameters. Check router settings.')
23 | });
24 | }
25 |
26 | const { value, error } = updateArticleBodySchema.validate(req.body);
27 |
28 | if (error) {
29 | throw new ApiErrorUnprocessableEntity({
30 | message:
31 | 'Invalid or missing data in the request body. Please ensure all required fields are included and in the correct format.',
32 | cause: error
33 | });
34 | }
35 |
36 | const { title, description, body } = value.article;
37 |
38 | const response = await apiUpdateArticle.execute({
39 | slug,
40 | userId: user.id,
41 | title,
42 | description,
43 | body
44 | });
45 | res.json(response);
46 | };
47 |
48 | interface Body {
49 | article: {
50 | title?: string;
51 | description?: string;
52 | body?: string;
53 | };
54 | }
55 |
56 | interface Params {
57 | slug: string;
58 | }
59 |
--------------------------------------------------------------------------------
/apps/article/dto/DtoArticle.ts:
--------------------------------------------------------------------------------
1 | export class DtoArticle {
2 | slug: string;
3 | title: string;
4 | description: string;
5 | body: string;
6 | tagList: string[];
7 | createdAt: Date;
8 | updatedAt: Date;
9 | favorited: boolean;
10 | favoritesCount: number;
11 | author: {
12 | username: string;
13 | bio?: string;
14 | image?: string;
15 | following: boolean;
16 | };
17 |
18 | constructor({
19 | slug,
20 | title,
21 | description,
22 | body,
23 | tagList,
24 | createdAt,
25 | updatedAt,
26 | favorited,
27 | favoritesCount,
28 | author
29 | }: DtoArticleConstructor) {
30 | this.slug = slug;
31 | this.title = title;
32 | this.description = description;
33 | this.body = body;
34 | this.tagList = tagList;
35 | this.createdAt = createdAt;
36 | this.updatedAt = updatedAt;
37 | this.favorited = favorited;
38 | this.favoritesCount = favoritesCount;
39 | this.author = {
40 | username: author.username,
41 | bio: author.bio,
42 | image: author.image,
43 | following: author.following
44 | };
45 | }
46 | }
47 |
48 | interface DtoArticleConstructor {
49 | slug: string;
50 | title: string;
51 | description: string;
52 | body: string;
53 | tagList: string[];
54 | createdAt: Date;
55 | updatedAt: Date;
56 | favorited: boolean;
57 | favoritesCount: number;
58 | author: {
59 | username: string;
60 | bio?: string;
61 | image?: string;
62 | following: boolean;
63 | };
64 | }
65 |
--------------------------------------------------------------------------------
/apps/article/dto/DtoComment.ts:
--------------------------------------------------------------------------------
1 | export class DtoComment {
2 | id: string;
3 | createdAt: Date;
4 | updatedAt: Date;
5 | body: string;
6 | author: {
7 | username: string;
8 | bio?: string;
9 | image?: string;
10 | following: boolean;
11 | };
12 |
13 | constructor({ id, createdAt, updatedAt, body, author }: DtoCommentConstructor) {
14 | this.id = id;
15 | this.createdAt = createdAt;
16 | this.updatedAt = updatedAt;
17 | this.body = body;
18 | this.author = {
19 | username: author.username,
20 | bio: author.bio,
21 | image: author.image,
22 | following: author.following
23 | };
24 | }
25 | }
26 |
27 | interface DtoCommentConstructor {
28 | id: string;
29 | createdAt: Date;
30 | updatedAt: Date;
31 | body: string;
32 | author: {
33 | username: string;
34 | bio?: string;
35 | image?: string;
36 | following: boolean;
37 | };
38 | }
39 |
--------------------------------------------------------------------------------
/apps/article/dto/index.ts:
--------------------------------------------------------------------------------
1 | export * from './DtoArticle';
2 | export * from './DtoComment';
3 |
--------------------------------------------------------------------------------
/apps/article/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('jest').Config} */
2 |
3 | module.exports = require('@conduit/config/jest.config');
4 |
--------------------------------------------------------------------------------
/apps/article/local.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 |
3 | import { logger } from '@conduit/utils';
4 |
5 | import { app } from './app';
6 |
7 | app.listen(3200, () => {
8 | logger.info('User server is running on http://localhost:3200', { label: 'App' });
9 | });
10 |
--------------------------------------------------------------------------------
/apps/article/route.ts:
--------------------------------------------------------------------------------
1 | import Router from 'express-promise-router';
2 |
3 | import { auth, authRequired } from '@conduit/middleware';
4 |
5 | import {
6 | addComment,
7 | createArticle,
8 | deleteArticle,
9 | deleteComment,
10 | favoriteArticle,
11 | getArticle,
12 | getArticleTags,
13 | getArticles,
14 | getComments,
15 | getFeedArticles,
16 | unfavoriteArticle,
17 | updateArticle
18 | } from './controller';
19 |
20 | export const router = Router();
21 |
22 | router
23 | .route('/api/articles/:slug/comments')
24 | .get(auth, getComments)
25 | .post(authRequired, addComment);
26 |
27 | router.delete('/api/articles/:slug/comments/:id', authRequired, deleteComment);
28 |
29 | router
30 | .route('/api/articles/:slug/favorite')
31 | .post(authRequired, favoriteArticle)
32 | .delete(authRequired, unfavoriteArticle);
33 |
34 | router.get('/api/articles/feed', authRequired, getFeedArticles);
35 |
36 | router
37 | .route('/api/articles/:slug')
38 | .get(auth, getArticle)
39 | .put(authRequired, updateArticle)
40 | .delete(authRequired, deleteArticle);
41 |
42 | router
43 | .route('/api/articles')
44 | .get(auth, getArticles)
45 | .post(authRequired, createArticle);
46 |
47 | router.get('/api/tags', getArticleTags);
48 |
49 | router
50 | .route('/api/articles/:slug/favorite')
51 | .post(authRequired, favoriteArticle)
52 | .delete(authRequired, unfavoriteArticle);
53 |
--------------------------------------------------------------------------------
/apps/article/schema/addCommentBody.ts:
--------------------------------------------------------------------------------
1 | import Joi from 'joi';
2 |
3 | export const addCommentBodySchema = Joi.object({
4 | comment: Joi.object({
5 | body: Joi.string()
6 | .description(
7 | 'This field contains the text of the comment that you want to create.'
8 | )
9 | .required()
10 | }).required()
11 | }).required();
12 |
--------------------------------------------------------------------------------
/apps/article/schema/createArticleBody.ts:
--------------------------------------------------------------------------------
1 | import Joi from 'joi';
2 |
3 | export const createArticleBodySchema = Joi.object({
4 | article: Joi.object({
5 | title: Joi.string()
6 | .description(
7 | 'This field specifies the title of the article that you want to create.'
8 | )
9 | .required(),
10 | description: Joi.string()
11 | .description(
12 | 'This field provides a brief summary or introduction to the article.'
13 | )
14 | .required(),
15 | body: Joi.string()
16 | .description(
17 | 'This field contains the main content of the article, and should provides more detailed information on the topic.'
18 | )
19 | .required(),
20 | tagList: Joi.array()
21 | .items(Joi.string())
22 | .description(
23 | 'One or more tags to help users find the article easily. Tags are specified as an array of strings.'
24 | )
25 | .empty(null)
26 | .default([])
27 | }).required()
28 | }).required();
29 |
--------------------------------------------------------------------------------
/apps/article/schema/getArticleFeedQuery.ts:
--------------------------------------------------------------------------------
1 | import Joi from 'joi';
2 |
3 | export const getArticleFeedQuerySchema = Joi.object({
4 | limit: Joi.number().description('The numbers of items to return').default(20),
5 | offset: Joi.number()
6 | .description(
7 | 'The number of items to skip before starting to collect the result set'
8 | )
9 | .default(0)
10 | });
11 |
--------------------------------------------------------------------------------
/apps/article/schema/getArticlesQuery.ts:
--------------------------------------------------------------------------------
1 | import Joi from 'joi';
2 |
3 | export const getArticlesQuerySchema = Joi.object({
4 | limit: Joi.number().description('The numbers of items to return').default(20),
5 | offset: Joi.number()
6 | .description(
7 | 'The number of items to skip before starting to collect the result set'
8 | )
9 | .default(0),
10 | tag: Joi.string()
11 | .description('A string representing the tag by which to filter the articles')
12 | .empty(null),
13 | author: Joi.string()
14 | .description(
15 | 'A string representing the username of the author by which to filter the articles'
16 | )
17 | .empty(null),
18 | favorited: Joi.string()
19 | .description(
20 | 'A string representing the username of the user who favorited the articles to be included in the result set'
21 | )
22 | .empty(null)
23 | })
24 | .optional()
25 | .default({});
26 |
--------------------------------------------------------------------------------
/apps/article/schema/getCommentsQuery.ts:
--------------------------------------------------------------------------------
1 | import Joi from 'joi';
2 |
3 | export const getCommentsQuerySchema = Joi.object({
4 | limit: Joi.number().description('The numbers of items to return').default(10),
5 | offset: Joi.number()
6 | .description(
7 | 'The number of items to skip before starting to collect the result set'
8 | )
9 | .default(0)
10 | });
11 |
--------------------------------------------------------------------------------
/apps/article/schema/index.ts:
--------------------------------------------------------------------------------
1 | export * from './addCommentBody';
2 | export * from './createArticleBody';
3 | export * from './getArticleFeedQuery';
4 | export * from './getArticlesQuery';
5 | export * from './updateArticleBody';
6 | export * from './getCommentsQuery';
7 |
--------------------------------------------------------------------------------
/apps/article/schema/updateArticleBody.ts:
--------------------------------------------------------------------------------
1 | import Joi from 'joi';
2 |
3 | export const updateArticleBodySchema = Joi.object({
4 | article: Joi.object({
5 | title: Joi.string()
6 | .description(
7 | 'This field specifies the title of the article that you want to create.'
8 | )
9 | .empty(null),
10 | description: Joi.string()
11 | .description(
12 | 'This field provides a brief summary or introduction to the article.'
13 | )
14 | .empty(null),
15 | body: Joi.string()
16 | .description(
17 | 'This field contains the main content of the article, and should provides more detailed information on the topic.'
18 | )
19 | .empty(null)
20 | }).required()
21 | }).required();
22 |
--------------------------------------------------------------------------------
/apps/article/server.ts:
--------------------------------------------------------------------------------
1 | import { configureLambda } from '@conduit/middleware';
2 |
3 | import { app } from './app';
4 |
5 | export const handler = configureLambda({ app });
6 |
--------------------------------------------------------------------------------
/apps/article/service/ApiAddComments/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiAddComments } from './ApiAddComments';
2 |
--------------------------------------------------------------------------------
/apps/article/service/ApiAddComments/types.ts:
--------------------------------------------------------------------------------
1 | import { ArticleService } from '@conduit/core';
2 |
3 | import { DtoComment } from '../../dto';
4 |
5 | export interface ApiAddCommentsConstructor {
6 | articleService: ArticleService;
7 | }
8 |
9 | export interface ApiAddCommentsInput {
10 | slug: string;
11 | body: string;
12 | userId: string;
13 | }
14 |
15 | export type ApiAddCommentsOutput = Promise<{
16 | comment: DtoComment;
17 | }>;
18 |
--------------------------------------------------------------------------------
/apps/article/service/ApiCreateArticle/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiCreateArticle } from './ApiCreateArticle';
2 | export { ApiCreateArticleInput, ApiCreateArticleOutput } from './types';
3 |
--------------------------------------------------------------------------------
/apps/article/service/ApiCreateArticle/types.ts:
--------------------------------------------------------------------------------
1 | import { ArticleService, DbDtoUser } from '@conduit/core';
2 |
3 | import { DtoArticle } from '../../dto';
4 |
5 | export interface ApiCreateArticleConstructor {
6 | articleService: ArticleService;
7 | }
8 |
9 | export interface ApiCreateArticleInput {
10 | title: string;
11 | description: string;
12 | body: string;
13 | tagList: string[];
14 | author: DbDtoUser;
15 | }
16 |
17 | export type ApiCreateArticleOutput = Promise<{
18 | article: DtoArticle;
19 | }>;
20 |
--------------------------------------------------------------------------------
/apps/article/service/ApiDeleteArticle/ApiDeleteArticle.ts:
--------------------------------------------------------------------------------
1 | import { ArticleNotFoundError, ArticleService } from '@conduit/core/service';
2 | import { logger } from '@conduit/utils';
3 | import {
4 | ApiError,
5 | ApiErrorForbidden,
6 | ApiErrorInternalServerError,
7 | ApiErrorNotFound
8 | } from '@conduit/utils/error';
9 |
10 | import {
11 | ApiDeleteArticleConstructor,
12 | ApiDeleteArticleInput,
13 | ApiDeleteArticleOutput
14 | } from './types';
15 |
16 | export class ApiDeleteArticle {
17 | private articleService: ArticleService;
18 |
19 | constructor({ articleService }: ApiDeleteArticleConstructor) {
20 | this.articleService = articleService;
21 | }
22 |
23 | async execute({ slug, userId }: ApiDeleteArticleInput): ApiDeleteArticleOutput {
24 | try {
25 | const article = await this.articleService.getArticleBySlug({
26 | slug
27 | });
28 | if (!article) {
29 | throw new ArticleNotFoundError({ slug });
30 | }
31 | if (article.userId !== userId) {
32 | throw new ApiErrorForbidden({
33 | message: 'You are not able to delete article that do not belong to you.'
34 | });
35 | }
36 | await this.articleService.deleteArticleById({ id: article.id });
37 | } catch (error) {
38 | throw this.convertErrorToApiError(error);
39 | }
40 | }
41 |
42 | private convertErrorToApiError(error: any) {
43 | if (error instanceof ApiError) {
44 | return error;
45 | }
46 | if (error instanceof ArticleNotFoundError) {
47 | throw new ApiErrorNotFound({
48 | message: error.message,
49 | cause: error
50 | });
51 | }
52 | logger.error(error);
53 | return new ApiErrorInternalServerError({});
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/apps/article/service/ApiDeleteArticle/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiDeleteArticle } from './ApiDeleteArticle';
2 |
--------------------------------------------------------------------------------
/apps/article/service/ApiDeleteArticle/types.ts:
--------------------------------------------------------------------------------
1 | import { ArticleService } from '@conduit/core';
2 |
3 | export interface ApiDeleteArticleConstructor {
4 | articleService: ArticleService;
5 | }
6 |
7 | export interface ApiDeleteArticleInput {
8 | slug: string;
9 | userId: string;
10 | }
11 |
12 | export type ApiDeleteArticleOutput = Promise;
13 |
--------------------------------------------------------------------------------
/apps/article/service/ApiDeleteComment/ApiDeleteComment.ts:
--------------------------------------------------------------------------------
1 | import { ArticleService } from '@conduit/core/service';
2 | import {
3 | ApiError,
4 | ApiErrorForbidden,
5 | ApiErrorInternalServerError,
6 | ApiErrorNotFound
7 | } from '@conduit/utils/error';
8 |
9 | import {
10 | ApiDeleteCommentConstructor,
11 | ApiDeleteCommentInput,
12 | ApiDeleteCommentOutput
13 | } from './types';
14 |
15 | export class ApiDeleteComment {
16 | private articleService: ArticleService;
17 |
18 | constructor({ articleService }: ApiDeleteCommentConstructor) {
19 | this.articleService = articleService;
20 | }
21 |
22 | async execute({
23 | commentId,
24 | userId
25 | }: ApiDeleteCommentInput): ApiDeleteCommentOutput {
26 | try {
27 | const comment = await this.articleService.getArticleCommentById({
28 | id: commentId
29 | });
30 |
31 | if (!comment) {
32 | throw new ApiErrorNotFound({
33 | message: "The requested article's comment was not found."
34 | });
35 | }
36 |
37 | if (userId !== comment.author.id) {
38 | throw new ApiErrorForbidden({
39 | message: 'You are not able to delete comments that do not belong to you.'
40 | });
41 | }
42 |
43 | await this.articleService.deleteArticleById({ id: commentId });
44 | } catch (error) {
45 | throw this.convertErrorToApiError(error);
46 | }
47 | }
48 |
49 | private convertErrorToApiError(error: any) {
50 | if (error instanceof ApiError) {
51 | return error;
52 | }
53 | return new ApiErrorInternalServerError({});
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/apps/article/service/ApiDeleteComment/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiDeleteComment } from './ApiDeleteComment';
2 | export { ApiDeleteCommentInput, ApiDeleteCommentOutput } from './types';
3 |
--------------------------------------------------------------------------------
/apps/article/service/ApiDeleteComment/types.ts:
--------------------------------------------------------------------------------
1 | import { ArticleService } from '@conduit/core/service';
2 |
3 | export interface ApiDeleteCommentConstructor {
4 | articleService: ArticleService;
5 | }
6 |
7 | export interface ApiDeleteCommentInput {
8 | commentId: string;
9 | userId: string;
10 | }
11 |
12 | export type ApiDeleteCommentOutput = Promise;
13 |
--------------------------------------------------------------------------------
/apps/article/service/ApiFavoriteArticle/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiFavoriteArticle } from './ApiFavoriteArticle';
2 |
--------------------------------------------------------------------------------
/apps/article/service/ApiFavoriteArticle/types.ts:
--------------------------------------------------------------------------------
1 | import { ArticleService } from '@conduit/core';
2 |
3 | import { DtoArticle } from '../../dto';
4 |
5 | export interface ApiFavoriteArticleConstructor {
6 | articleService: ArticleService;
7 | }
8 |
9 | export interface ApiFavoriteArticleInput {
10 | slug: string;
11 | userId: string;
12 | }
13 |
14 | export type ApiFavoriteArticleOutput = Promise<{ article: DtoArticle }>;
15 |
--------------------------------------------------------------------------------
/apps/article/service/ApiFeedArticles/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiFeedArticles } from './ApiFeedArticles';
2 |
--------------------------------------------------------------------------------
/apps/article/service/ApiFeedArticles/types.ts:
--------------------------------------------------------------------------------
1 | import { ArticleService } from '@conduit/core';
2 |
3 | import { DtoArticle } from '../../dto';
4 |
5 | export interface ApiFeedArticlesConstructor {
6 | articleService: ArticleService;
7 | }
8 |
9 | export interface ApiFeedArticlesInput {
10 | limit: number;
11 | offset: number;
12 | userId: string;
13 | }
14 |
15 | export type ApiFeedArticlesOutput = Promise<{
16 | articles: DtoArticle[];
17 | articlesCount: number;
18 | }>;
19 |
--------------------------------------------------------------------------------
/apps/article/service/ApiGetArticle/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiGetArticle } from './ApiGetArticle';
2 |
--------------------------------------------------------------------------------
/apps/article/service/ApiGetArticle/types.ts:
--------------------------------------------------------------------------------
1 | import { ArticleService } from '@conduit/core';
2 |
3 | import { DtoArticle } from '../../dto';
4 |
5 | export interface ApiGetArticleConstructor {
6 | articleService: ArticleService;
7 | }
8 |
9 | export interface ApiGetArticleInput {
10 | slug: string;
11 | userId?: string;
12 | }
13 |
14 | export type ApiGetArticleOutput = Promise<{
15 | article: DtoArticle;
16 | }>;
17 |
--------------------------------------------------------------------------------
/apps/article/service/ApiGetComments/ApiGetComments.ts:
--------------------------------------------------------------------------------
1 | import { ArticleService } from '@conduit/core/service';
2 | import { logger } from '@conduit/utils';
3 | import {
4 | ApiError,
5 | ApiErrorInternalServerError,
6 | ApiErrorNotFound
7 | } from '@conduit/utils/error';
8 |
9 | import { DtoComment } from '../../dto';
10 | import {
11 | ApiGetCommentsConstructor,
12 | ApiGetCommentsInput,
13 | ApiGetCommentsOutput
14 | } from './types';
15 |
16 | export class ApiGetComments {
17 | private articleService: ArticleService;
18 |
19 | constructor({ articleService }: ApiGetCommentsConstructor) {
20 | this.articleService = articleService;
21 | }
22 |
23 | async execute({
24 | slug,
25 | userId,
26 | limit,
27 | offset
28 | }: ApiGetCommentsInput): ApiGetCommentsOutput {
29 | try {
30 | const article = await this.articleService.getArticleBySlug({
31 | slug
32 | });
33 | if (!article) {
34 | throw new ApiErrorNotFound({});
35 | }
36 | const { comments, count } =
37 | await this.articleService.getArticleCommentsByArticleId({
38 | articleId: article.id,
39 | requestingUserId: userId,
40 | limit,
41 | offset
42 | });
43 | return {
44 | comments: comments.map((comment) => new DtoComment(comment)),
45 | count
46 | };
47 | } catch (error) {
48 | logger.error(error);
49 | throw this.convertErrorToApiError(error);
50 | }
51 | }
52 |
53 | private convertErrorToApiError(error: any) {
54 | if (error instanceof ApiError) {
55 | return error;
56 | }
57 | return new ApiErrorInternalServerError({});
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/apps/article/service/ApiGetComments/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiGetComments } from './ApiGetComments';
2 |
--------------------------------------------------------------------------------
/apps/article/service/ApiGetComments/types.ts:
--------------------------------------------------------------------------------
1 | import { ArticleService } from '@conduit/core';
2 |
3 | import { DtoComment } from '../../dto';
4 |
5 | export interface ApiGetCommentsConstructor {
6 | articleService: ArticleService;
7 | }
8 |
9 | export interface ApiGetCommentsInput {
10 | slug: string;
11 | limit: number;
12 | offset: number;
13 | userId?: string;
14 | }
15 |
16 | export type ApiGetCommentsOutput = Promise<{
17 | comments: DtoComment[];
18 | count: number;
19 | }>;
20 |
--------------------------------------------------------------------------------
/apps/article/service/ApiGetTags/ApiGetTags.ts:
--------------------------------------------------------------------------------
1 | import { ArticleService } from '@conduit/core/service';
2 | import { ApiErrorInternalServerError } from '@conduit/utils';
3 |
4 | import { ApiGetTagsConstructor, ApiGetTagsOutput } from './types';
5 |
6 | export class ApiGetTags {
7 | private articleService: ArticleService;
8 |
9 | constructor({ articleService }: ApiGetTagsConstructor) {
10 | this.articleService = articleService;
11 | }
12 |
13 | async execute(): ApiGetTagsOutput {
14 | try {
15 | const tags = await this.articleService.getAvailableTags();
16 | return { tags };
17 | } catch (error) {
18 | throw new ApiErrorInternalServerError({});
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/apps/article/service/ApiGetTags/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiGetTags } from './ApiGetTags';
2 |
--------------------------------------------------------------------------------
/apps/article/service/ApiGetTags/types.ts:
--------------------------------------------------------------------------------
1 | import { ArticleService } from '@conduit/core';
2 |
3 | export interface ApiGetTagsConstructor {
4 | articleService: ArticleService;
5 | }
6 |
7 | export type ApiGetTagsOutput = Promise<{
8 | tags: string[];
9 | }>;
10 |
--------------------------------------------------------------------------------
/apps/article/service/ApiListArticles/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiListArticles } from './ApiListArticles';
2 |
--------------------------------------------------------------------------------
/apps/article/service/ApiListArticles/types.ts:
--------------------------------------------------------------------------------
1 | import { ArticleService } from '@conduit/core/service';
2 |
3 | import { DtoArticle } from '../../dto';
4 |
5 | export interface ApiListArticlesConstructor {
6 | articleService: ArticleService;
7 | }
8 |
9 | export interface ApiListArticlesInput {
10 | tag?: string;
11 | author?: string;
12 | favorited?: string;
13 | offset: number;
14 | limit: number;
15 | userId?: string;
16 | }
17 |
18 | export type ApiListArticlesOutput = Promise<{
19 | articles: DtoArticle[];
20 | articlesCount: number;
21 | }>;
22 |
--------------------------------------------------------------------------------
/apps/article/service/ApiUnfavoriteArticle/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiUnfavoriteArticle } from './ApiUnfavoriteArticle';
2 |
--------------------------------------------------------------------------------
/apps/article/service/ApiUnfavoriteArticle/types.ts:
--------------------------------------------------------------------------------
1 | import { ArticleService } from '@conduit/core';
2 |
3 | import { DtoArticle } from '../../dto';
4 |
5 | export interface ApiUnfavoriteArticleConstructor {
6 | articleService: ArticleService;
7 | }
8 |
9 | export interface ApiUnfavoriteArticleInput {
10 | slug: string;
11 | userId: string;
12 | }
13 |
14 | export type ApiUnfavoriteArticleOutput = Promise<{ article: DtoArticle }>;
15 |
--------------------------------------------------------------------------------
/apps/article/service/ApiUpdateArticle/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiUpdateArticle } from './ApiUpdateArticle';
2 |
--------------------------------------------------------------------------------
/apps/article/service/ApiUpdateArticle/types.ts:
--------------------------------------------------------------------------------
1 | import { ArticleService, DbDtoArticle } from '@conduit/core';
2 |
3 | import { DtoArticle } from '../../dto';
4 |
5 | export interface ApiUpdateArticleConstructor {
6 | articleService: ArticleService;
7 | }
8 |
9 | export interface ApiUpdateArticleInput {
10 | slug: string;
11 | userId: string;
12 | title?: string;
13 | description?: string;
14 | body?: string;
15 | }
16 |
17 | export type ApiUpdateArticleOutput = Promise<{
18 | article: DtoArticle;
19 | }>;
20 |
21 | /**
22 | *
23 | * function: getArticleBySlug
24 | *
25 | */
26 | export interface GetArticleBySlugInput {
27 | slug: string;
28 | }
29 |
30 | export type GetArticleBySlugOutput = Promise;
31 |
32 | /**
33 | *
34 | * function: validateInput
35 | *
36 | */
37 | export interface ValidateInputInput {
38 | authorId: string;
39 | userId: string;
40 | title?: string;
41 | description?: string;
42 | body?: string;
43 | }
44 |
45 | export type ValidateInputOutput = void;
46 |
47 | /**
48 | *
49 | * function: getUpdatedArticle
50 | *
51 | */
52 | export interface GetUpdatedArticleInput {
53 | articleId: string;
54 | }
55 |
56 | export type GetUpdatedArticleOutput = Promise;
57 |
--------------------------------------------------------------------------------
/apps/article/service/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ApiAddComments';
2 | export * from './ApiCreateArticle';
3 | export * from './ApiDeleteArticle';
4 | export * from './ApiDeleteComment';
5 | export * from './ApiFavoriteArticle';
6 | export * from './ApiFeedArticles';
7 | export * from './ApiGetArticle';
8 | export * from './ApiGetComments';
9 | export * from './ApiGetTags';
10 | export * from './ApiListArticles';
11 | export * from './ApiUnfavoriteArticle';
12 | export * from './ApiUpdateArticle';
13 |
--------------------------------------------------------------------------------
/apps/article/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "extends": "@conduit/config/tsconfig.base.json",
4 | "compilerOptions": {
5 | "sourceMap": false
6 | },
7 | "include": ["./**/*.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/apps/article/types/global.d.ts:
--------------------------------------------------------------------------------
1 | import '@conduit/core/types/global';
2 |
--------------------------------------------------------------------------------
/apps/local/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const defaultConfig = require('@conduit/config/.eslintrc.json');
2 |
3 | module.exports = {
4 | ...defaultConfig,
5 | parserOptions: {
6 | project: './tsconfig.json'
7 | },
8 | rules: {
9 | ...defaultConfig.rules,
10 | 'import/no-extraneous-dependencies': 'off'
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/apps/local/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = require('@conduit/config/.prettierrc.json');
2 |
--------------------------------------------------------------------------------
/apps/local/Dockerfile:
--------------------------------------------------------------------------------
1 | #
2 | # Base image for the server
3 | #
4 | FROM node:18 AS base
5 |
6 | ARG WORKSPACE_SCOPE=@conduit/local
7 |
8 | # Set working directory
9 | WORKDIR /app
10 | RUN yarn global add turbo
11 |
12 | # Copy the project files
13 | COPY . .
14 |
15 | # Use the build argument in the turbo prune command
16 | RUN turbo prune --scope=$WORKSPACE_SCOPE --docker
17 |
18 | #
19 | # Installer image
20 | # Add lockfile and package.json's of isolated sub-workspace
21 | #
22 | FROM base AS installer
23 |
24 | # Set working directory
25 | WORKDIR /app
26 |
27 | # Copy necessary files to the installer stage
28 | COPY .gitignore .gitignore
29 | COPY --from=base /app/out/json/ .
30 | COPY --from=base /app/out/yarn.lock ./yarn.lock
31 |
32 | # Install dependencies
33 | RUN yarn install
34 |
35 | # Build the project using the variable scope
36 | COPY --from=base /app/out/full/ .
37 | RUN turbo run build --filter=$WORKSPACE_SCOPE...
38 |
--------------------------------------------------------------------------------
/apps/local/local.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 |
3 | import express from 'express';
4 |
5 | import { app as appApp } from '@conduit/app/app';
6 | import { app as articleApp } from '@conduit/article/app';
7 | import {
8 | configureGlobalExceptionHandler,
9 | configureMiddlewares
10 | } from '@conduit/middleware';
11 | import { app as userApp } from '@conduit/user/app';
12 | import { logger } from '@conduit/utils';
13 |
14 | export const app = express();
15 |
16 | configureMiddlewares({ app, skipOnLocal: false });
17 |
18 | app.use(appApp);
19 | app.use(articleApp);
20 | app.use(userApp);
21 |
22 | configureGlobalExceptionHandler({ app, skipOnLocal: false });
23 |
24 | const port = process.env.PORT || 3100;
25 |
26 | app.listen(port, () => {
27 | logger.info(`Server is running on http://localhost:${port}`);
28 | });
29 |
--------------------------------------------------------------------------------
/apps/local/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@conduit/local",
3 | "version": "1.0.0",
4 | "private": true,
5 | "description": "",
6 | "license": "MIT",
7 | "author": {
8 | "name": "Ken Yip",
9 | "email": "ken20206@gmail.com",
10 | "url": "https://kenyip.cc"
11 | },
12 | "main": "index.js",
13 | "workspaces": [
14 | "../app",
15 | "../user",
16 | "../article",
17 | "../../packages/middleware",
18 | "../../packages/utils"
19 | ],
20 | "scripts": {
21 | "build": "tsc",
22 | "check-types": "tsc --skipLibCheck --noEmit",
23 | "dev": "NODE_CONFIG_DIR='../../config' NODE_ENV=ci ts-node-dev --no-notify --exit-child --respawn --transpile-only --ignore-watch node_modules ./local.ts",
24 | "lint": "eslint .",
25 | "lint:fix": "eslint --fix .",
26 | "prettify": "prettier --write .",
27 | "start": "NODE_CONFIG_DIR='../../config' node local.js",
28 | "start:ci": "NODE_CONFIG_DIR='../../config' pm2 start local.js"
29 | },
30 | "dependencies": {
31 | "@conduit/app": "*",
32 | "@conduit/article": "*",
33 | "@conduit/middleware": "*",
34 | "@conduit/user": "*",
35 | "@conduit/utils": "*",
36 | "dotenv": "^16.4.5",
37 | "express": "^4.21.1"
38 | },
39 | "devDependencies": {
40 | "eslint": "^8.19.0",
41 | "eslint-config-airbnb-base": "^15.0.0",
42 | "eslint-config-airbnb-typescript": "^17.0.0",
43 | "eslint-config-prettier": "^9.0.0",
44 | "eslint-plugin-import": "^2.26.0",
45 | "eslint-plugin-jest": "^26.5.3",
46 | "eslint-plugin-prettier": "^5.2.1",
47 | "pm2": "^5.3.0",
48 | "prettier": "^3.3.3",
49 | "prettier-plugin-packagejson": "^2.5.3",
50 | "ts-node": "^10.9.1",
51 | "ts-node-dev": "^2.0.0",
52 | "typescript": "^5.6.3"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/apps/local/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "extends": "@conduit/config/tsconfig.base.json",
4 | "compilerOptions": {
5 | "sourceMap": false
6 | },
7 | "include": ["./**/*.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/apps/local/types/global.d.ts:
--------------------------------------------------------------------------------
1 | import '@conduit/core/types/global';
2 |
--------------------------------------------------------------------------------
/apps/user/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const defaultConfig = require('@conduit/config/.eslintrc.json');
2 |
3 | module.exports = {
4 | ...defaultConfig,
5 | parserOptions: {
6 | project: './tsconfig.json'
7 | },
8 | rules: {
9 | ...defaultConfig.rules,
10 | 'import/no-extraneous-dependencies': 'off'
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/apps/user/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = require('@conduit/config/.prettierrc.json');
2 |
--------------------------------------------------------------------------------
/apps/user/Dockerfile:
--------------------------------------------------------------------------------
1 | #
2 | # Base image for the server
3 | #
4 | FROM node:18 AS base
5 |
6 | ARG WORKSPACE_SCOPE=@conduit/user
7 |
8 | # Set working directory
9 | WORKDIR /app
10 | RUN yarn global add turbo
11 |
12 | # Copy the project files
13 | COPY . .
14 |
15 | # Use the build argument in the turbo prune command
16 | RUN turbo prune --scope=$WORKSPACE_SCOPE --docker
17 |
18 | #
19 | # Installer image
20 | # Add lockfile and package.json's of isolated sub-workspace
21 | #
22 | FROM base AS installer
23 |
24 | # Set working directory
25 | WORKDIR /app
26 |
27 | # Copy necessary files to the installer stage
28 | COPY .gitignore .gitignore
29 | COPY --from=base /app/out/json/ .
30 | COPY --from=base /app/out/yarn.lock ./yarn.lock
31 |
32 | # Install dependencies
33 | RUN yarn install
34 |
35 | # Build the project using the variable scope
36 | COPY --from=base /app/out/full/ .
37 | RUN turbo run build --filter=$WORKSPACE_SCOPE...
38 |
--------------------------------------------------------------------------------
/apps/user/app.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 |
3 | import {
4 | configureGlobalExceptionHandler,
5 | configureMiddlewares
6 | } from '@conduit/middleware';
7 |
8 | import { router } from './route';
9 |
10 | export const app = express();
11 |
12 | configureMiddlewares({ app });
13 |
14 | app.use(router);
15 |
16 | configureGlobalExceptionHandler({ app });
17 |
--------------------------------------------------------------------------------
/apps/user/constants/ErrorCodes.ts:
--------------------------------------------------------------------------------
1 | export enum ErrorCodes {
2 | UnprocessableContent = 'general_unprocessable_content',
3 | PasswordRequirementsNotMet = 'auth_password_requirements_not_met'
4 | }
5 |
--------------------------------------------------------------------------------
/apps/user/constants/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ErrorCodes';
2 |
--------------------------------------------------------------------------------
/apps/user/controller/followUser.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import { ApiErrorInternalServerError } from '@conduit/utils';
4 |
5 | import { Factory } from '../service';
6 |
7 | const factory = new Factory();
8 | const apiFollowUser = factory.newApiFollowUser();
9 |
10 | export const followUser: RequestHandler = async (
11 | req,
12 | res
13 | ) => {
14 | const { user } = req;
15 | const { username } = req.params;
16 |
17 | if (username === undefined || user === undefined) {
18 | throw new ApiErrorInternalServerError({});
19 | }
20 |
21 | const response = await apiFollowUser.execute({ username, userId: user.id });
22 | res.json(response);
23 | };
24 |
25 | interface Params {
26 | username: string;
27 | }
28 |
--------------------------------------------------------------------------------
/apps/user/controller/getCurrentUser.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import { ApiErrorUnauthorized } from '@conduit/utils';
4 |
5 | import { Factory } from '../service/Factory';
6 |
7 | const factory = new Factory();
8 | const apiGetCurrentUser = factory.newApiGetCurrentUser();
9 |
10 | export const getCurrentUser: RequestHandler = async (req, res) => {
11 | const { user } = req;
12 | if (user === undefined) {
13 | throw new ApiErrorUnauthorized({});
14 | }
15 | const response = await apiGetCurrentUser.execute({ user });
16 | res.json(response);
17 | };
18 |
--------------------------------------------------------------------------------
/apps/user/controller/getUserProfile.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import { ApiErrorInternalServerError } from '@conduit/utils';
4 |
5 | import { Factory } from '../service';
6 |
7 | const factory = new Factory();
8 | const apiGetUserProfile = factory.newApiGetProfile();
9 |
10 | export const getUserProfile: RequestHandler<
11 | Params,
12 | unknown,
13 | unknown,
14 | unknown
15 | > = async (req, res) => {
16 | const { user } = req;
17 | const { username } = req.params;
18 |
19 | if (username === undefined) {
20 | throw new ApiErrorInternalServerError({});
21 | }
22 |
23 | const response = await apiGetUserProfile.execute({ username, userId: user?.id });
24 | res.json(response);
25 | };
26 |
27 | interface Params {
28 | username: string;
29 | }
30 |
--------------------------------------------------------------------------------
/apps/user/controller/index.ts:
--------------------------------------------------------------------------------
1 | export * from './login';
2 | export * from './registration';
3 | export * from './updateUser';
4 | export * from './getCurrentUser';
5 | export * from './followUser';
6 | export * from './unfollowUser';
7 | export * from './getUserProfile';
8 |
--------------------------------------------------------------------------------
/apps/user/controller/login.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import { ApiErrorUnprocessableEntity } from '@conduit/utils';
4 |
5 | import { loginBodySchema } from '../schema';
6 | import { Factory } from '../service/Factory';
7 |
8 | const factory = new Factory();
9 | const apiUserLogin = factory.newApiUserLogin();
10 |
11 | export const login: RequestHandler = async (
12 | req,
13 | res
14 | ) => {
15 | const { value: input, error } = loginBodySchema.validate(req.body);
16 |
17 | if (error) {
18 | throw new ApiErrorUnprocessableEntity({
19 | message:
20 | 'Invalid or missing data in the request body. Please ensure all required fields are included and in the correct format.',
21 | cause: error
22 | });
23 | }
24 |
25 | const response = await apiUserLogin.execute(input.user);
26 | res.json(response);
27 | };
28 |
29 | interface Body {
30 | user: {
31 | email: string;
32 | password: string;
33 | };
34 | }
35 |
--------------------------------------------------------------------------------
/apps/user/controller/registration.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import { ApiErrorUnprocessableEntity } from '@conduit/utils';
4 |
5 | import { registrationBodySchema } from '../schema';
6 | import { Factory } from '../service/Factory';
7 |
8 | const factory = new Factory();
9 | const apiRegistration = factory.newApiRegistration();
10 |
11 | export const registration: RequestHandler = async (
12 | req,
13 | res
14 | ) => {
15 | const { value: input, error } = registrationBodySchema.validate(req.body);
16 | if (error) {
17 | throw new ApiErrorUnprocessableEntity({
18 | message:
19 | 'Invalid or missing data in the request body. Please ensure all required fields are included and in the correct format.',
20 | cause: error
21 | });
22 | }
23 | const response = await apiRegistration.execute(input.user);
24 | res.json(response);
25 | };
26 |
27 | interface Body {
28 | user: {
29 | username: string;
30 | email: string;
31 | password: string;
32 | };
33 | }
34 |
--------------------------------------------------------------------------------
/apps/user/controller/unfollowUser.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import { ApiErrorInternalServerError } from '@conduit/utils';
4 |
5 | import { Factory } from '../service';
6 |
7 | const factory = new Factory();
8 | const apiUnfollowUser = factory.newApiUnfollowUser();
9 |
10 | export const unfollowUser: RequestHandler<
11 | Params,
12 | unknown,
13 | unknown,
14 | unknown
15 | > = async (req, res) => {
16 | const { user } = req;
17 | const { username } = req.params;
18 |
19 | if (username === undefined || user === undefined) {
20 | throw new ApiErrorInternalServerError({});
21 | }
22 |
23 | const response = await apiUnfollowUser.execute({ username, userId: user.id });
24 | res.json(response);
25 | };
26 |
27 | interface Params {
28 | username: string;
29 | }
30 |
--------------------------------------------------------------------------------
/apps/user/controller/updateUser.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 |
3 | import {
4 | ApiErrorInternalServerError,
5 | ApiErrorUnprocessableEntity
6 | } from '@conduit/utils';
7 |
8 | import { updateUserBodySchema } from '../schema';
9 | import { Factory } from '../service/Factory';
10 |
11 | const factory = new Factory();
12 | const apiUpdateUser = factory.newApiUpdateUser();
13 |
14 | export const updateUser: RequestHandler = async (
15 | req,
16 | res
17 | ) => {
18 | const { value: input, error } = updateUserBodySchema.validate(req.body);
19 | if (error || Object.keys(input.user).length === 0) {
20 | throw new ApiErrorUnprocessableEntity({
21 | message:
22 | 'Invalid or missing data in the request body. Please ensure all required fields are included and in the correct format.',
23 | cause: error
24 | });
25 | }
26 |
27 | const { user } = req;
28 | if (!user) {
29 | throw new ApiErrorInternalServerError({
30 | message: 'User not found in request object.'
31 | });
32 | }
33 | const userId = user.id;
34 | const { username, email, bio, image, password } = input.user;
35 |
36 | const response = await apiUpdateUser.execute({
37 | userId,
38 | username,
39 | email,
40 | bio,
41 | image,
42 | password
43 | });
44 |
45 | res.json(response);
46 | };
47 |
48 | interface Body {
49 | user: {
50 | username?: string;
51 | email?: string;
52 | bio?: string;
53 | image?: string;
54 | password?: string;
55 | };
56 | }
57 |
--------------------------------------------------------------------------------
/apps/user/dto/DtoProfile.ts:
--------------------------------------------------------------------------------
1 | export class DtoProfile {
2 | username: string;
3 | bio?: string;
4 | image?: string;
5 | following: boolean;
6 |
7 | constructor({ username, bio, image, following }: DtoProfileConstructor) {
8 | this.username = username;
9 | this.bio = bio;
10 | this.image = image;
11 | this.following = following;
12 | }
13 | }
14 |
15 | interface DtoProfileConstructor {
16 | username: string;
17 | bio?: string;
18 | image?: string;
19 | following: boolean;
20 | }
21 |
--------------------------------------------------------------------------------
/apps/user/dto/DtoUser.ts:
--------------------------------------------------------------------------------
1 | export class DtoUser {
2 | username: string;
3 | email: string;
4 | bio?: string;
5 | image?: string;
6 | token?: string;
7 |
8 | constructor({ username, email, bio, image, token }: DtoUserConstructor) {
9 | this.username = username;
10 | this.email = email;
11 | this.bio = bio;
12 | this.image = image;
13 | this.token = token;
14 | }
15 | }
16 |
17 | interface DtoUserConstructor {
18 | username: string;
19 | email: string;
20 | bio?: string;
21 | image?: string;
22 | token?: string;
23 | }
24 |
--------------------------------------------------------------------------------
/apps/user/dto/index.ts:
--------------------------------------------------------------------------------
1 | export * from './DtoProfile';
2 | export * from './DtoUser';
3 |
--------------------------------------------------------------------------------
/apps/user/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('jest').Config} */
2 |
3 | module.exports = require('@conduit/config/jest.config');
4 |
--------------------------------------------------------------------------------
/apps/user/local.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 |
3 | import { logger } from '@conduit/utils';
4 |
5 | import { app } from './app';
6 |
7 | app.listen(3300, () => {
8 | logger.info('User server is running on http://localhost:3300', { label: 'App' });
9 | });
10 |
--------------------------------------------------------------------------------
/apps/user/route.ts:
--------------------------------------------------------------------------------
1 | import Router from 'express-promise-router';
2 |
3 | import { auth, authRequired } from '@conduit/middleware';
4 |
5 | import {
6 | followUser,
7 | getCurrentUser,
8 | getUserProfile,
9 | login,
10 | registration,
11 | unfollowUser,
12 | updateUser
13 | } from './controller';
14 |
15 | export const router = Router();
16 |
17 | router.post('/api/users/login', login);
18 | router.post('/api/users', registration);
19 |
20 | router
21 | .route('/api/user')
22 | .put(authRequired, updateUser)
23 | .get(authRequired, getCurrentUser);
24 |
25 | router.get('/api/profiles/:username', auth, getUserProfile);
26 |
27 | router
28 | .route('/api/profiles/:username/follow')
29 | .post(authRequired, followUser)
30 | .delete(authRequired, unfollowUser);
31 |
--------------------------------------------------------------------------------
/apps/user/schema/index.ts:
--------------------------------------------------------------------------------
1 | export * from './loginBody';
2 | export * from './registrationBody';
3 | export * from './updateUserBody';
4 |
--------------------------------------------------------------------------------
/apps/user/schema/loginBody.ts:
--------------------------------------------------------------------------------
1 | import Joi from 'joi';
2 |
3 | export const loginBodySchema = Joi.object({
4 | user: Joi.object({
5 | email: Joi.string()
6 | .description("A string representing the user's username")
7 | .required(),
8 | password: Joi.string()
9 | .description("A string representing the user's password")
10 | .required()
11 | }).required()
12 | }).required();
13 |
--------------------------------------------------------------------------------
/apps/user/schema/registrationBody.ts:
--------------------------------------------------------------------------------
1 | import Joi from 'joi';
2 |
3 | export const registrationBodySchema = Joi.object({
4 | user: Joi.object({
5 | username: Joi.string()
6 | .description("A string representing the user's desired username")
7 | .required(),
8 | email: Joi.string()
9 | .description("A string representing the user's email address")
10 | .required(),
11 | password: Joi.string()
12 | .description("A string representing the user's desired password")
13 | .required()
14 | }).required()
15 | }).required();
16 |
--------------------------------------------------------------------------------
/apps/user/schema/updateUserBody.ts:
--------------------------------------------------------------------------------
1 | import Joi from 'joi';
2 |
3 | export const updateUserBodySchema = Joi.object({
4 | user: Joi.object({
5 | username: Joi.string()
6 | .description("A string representing the user's desired username")
7 | .empty(null),
8 | email: Joi.string()
9 | .email()
10 | .description("A string representing the user's email address")
11 | .empty(null),
12 | password: Joi.string()
13 | .description("A string representing the user's desired password")
14 | .empty(null),
15 | bio: Joi.string()
16 | .description("A string that represents the user's bio or description.")
17 | .empty(null),
18 | image: Joi.string()
19 | .description("A string that represents the user's profile image")
20 | .empty(null)
21 | }).required()
22 | }).required();
23 |
--------------------------------------------------------------------------------
/apps/user/server.ts:
--------------------------------------------------------------------------------
1 | import { configureLambda } from '@conduit/middleware';
2 |
3 | import { app } from './app';
4 |
5 | export const handler = configureLambda({ app });
6 |
--------------------------------------------------------------------------------
/apps/user/service/ApiFollowUser/ApiFollowUser.ts:
--------------------------------------------------------------------------------
1 | import { InvalidFollowError, UserNotFoundError, UserService } from '@conduit/core';
2 | import {
3 | ApiErrorBadRequest,
4 | ApiErrorInternalServerError,
5 | ApiErrorNotFound
6 | } from '@conduit/utils';
7 |
8 | import {
9 | ApiFollowUserConstructor,
10 | ApiFollowUserInput,
11 | ApiFollowUserOutput
12 | } from './types';
13 |
14 | export class ApiFollowUser {
15 | private userService: UserService;
16 |
17 | constructor({ userService }: ApiFollowUserConstructor) {
18 | this.userService = userService;
19 | }
20 |
21 | async execute({ userId, username }: ApiFollowUserInput): ApiFollowUserOutput {
22 | try {
23 | await this.userService.followUser({
24 | followerId: userId,
25 | followingUsername: username
26 | });
27 |
28 | const profile = await this.userService.getUserProfile({
29 | requestingUserId: userId,
30 | username
31 | });
32 |
33 | if (!profile) {
34 | throw new ApiErrorInternalServerError({
35 | cause: new Error(`Failed to get profile for username ${username}`)
36 | });
37 | }
38 |
39 | return {
40 | profile
41 | };
42 | } catch (error) {
43 | if (error instanceof UserNotFoundError) {
44 | throw new ApiErrorNotFound({ cause: error });
45 | }
46 | if (error instanceof InvalidFollowError) {
47 | throw new ApiErrorBadRequest({ cause: error });
48 | }
49 | throw error;
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/apps/user/service/ApiFollowUser/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ApiFollowUser';
2 |
--------------------------------------------------------------------------------
/apps/user/service/ApiFollowUser/types.ts:
--------------------------------------------------------------------------------
1 | import { DbDtoProfile, UserService } from '@conduit/core';
2 |
3 | export interface ApiFollowUserConstructor {
4 | userService: UserService;
5 | }
6 |
7 | export interface ApiFollowUserInput {
8 | username: string;
9 | userId: string;
10 | }
11 |
12 | export type ApiFollowUserOutput = Promise<{
13 | profile: DbDtoProfile;
14 | }>;
15 |
--------------------------------------------------------------------------------
/apps/user/service/ApiGetCurrentUser/ApiGetCurrentUser.ts:
--------------------------------------------------------------------------------
1 | import { UserStatus } from '@conduit/core';
2 | import { ApiErrorForbidden } from '@conduit/utils';
3 |
4 | import { DtoUser } from '../../dto';
5 | import { ApiGetCurrentUserInput, ApiGetCurrentUserOutput } from './types';
6 |
7 | export class ApiGetCurrentUser {
8 | async execute({ user }: ApiGetCurrentUserInput): ApiGetCurrentUserOutput {
9 | if (user.recordStatus === UserStatus.Banned) {
10 | throw new ApiErrorForbidden({
11 | message:
12 | 'Sorry, your account has been banned. You can no longer access our services. If you think this is a mistake, please contact our support team. Thank you.'
13 | });
14 | }
15 | const dtoUser = new DtoUser({
16 | username: user.username,
17 | email: user.email,
18 | bio: user.bio,
19 | image: user.image
20 | });
21 | return {
22 | user: dtoUser
23 | };
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/apps/user/service/ApiGetCurrentUser/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiGetCurrentUser } from './ApiGetCurrentUser';
2 | export { ApiGetCurrentUserInput, ApiGetCurrentUserOutput } from './types';
3 |
--------------------------------------------------------------------------------
/apps/user/service/ApiGetCurrentUser/types.ts:
--------------------------------------------------------------------------------
1 | import { DbDtoUser } from '@conduit/core';
2 |
3 | import { DtoUser } from '../../dto';
4 |
5 | export interface ApiGetCurrentUserInput {
6 | user: DbDtoUser;
7 | }
8 |
9 | export type ApiGetCurrentUserOutput = Promise<{ user: DtoUser }>;
10 |
--------------------------------------------------------------------------------
/apps/user/service/ApiGetProfile/ApiGetProfile.ts:
--------------------------------------------------------------------------------
1 | import { UserService } from '@conduit/core';
2 | import { ApiErrorNotFound } from '@conduit/utils';
3 |
4 | import {
5 | ApiGetProfileConstructor,
6 | ApiGetProfileInput,
7 | ApiGetProfileOutput
8 | } from './types';
9 |
10 | export class ApiGetProfile {
11 | private userService: UserService;
12 |
13 | constructor({ userService }: ApiGetProfileConstructor) {
14 | this.userService = userService;
15 | }
16 |
17 | async execute({ userId, username }: ApiGetProfileInput): ApiGetProfileOutput {
18 | const profile = await this.userService.getUserProfile({
19 | requestingUserId: userId,
20 | username
21 | });
22 | if (!profile) {
23 | throw new ApiErrorNotFound({
24 | cause: new Error(`Username ${username} not found`)
25 | });
26 | }
27 | return {
28 | profile
29 | };
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/apps/user/service/ApiGetProfile/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ApiGetProfile';
2 |
--------------------------------------------------------------------------------
/apps/user/service/ApiGetProfile/types.ts:
--------------------------------------------------------------------------------
1 | import { UserService } from '@conduit/core';
2 |
3 | import { DtoProfile } from '../../dto';
4 |
5 | export interface ApiGetProfileConstructor {
6 | userService: UserService;
7 | }
8 |
9 | export interface ApiGetProfileInput {
10 | username: string;
11 | userId?: string;
12 | }
13 |
14 | export type ApiGetProfileOutput = Promise<{
15 | profile: DtoProfile;
16 | }>;
17 |
--------------------------------------------------------------------------------
/apps/user/service/ApiRegistration/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiRegistration } from './ApiRegistration';
2 | export { ApiRegistrationInput, ApiRegistrationOutput } from './types';
3 |
--------------------------------------------------------------------------------
/apps/user/service/ApiRegistration/types.ts:
--------------------------------------------------------------------------------
1 | import { AuthService, UserService } from '@conduit/core';
2 |
3 | import { DtoUser } from '../../dto';
4 |
5 | export interface ApiRegistrationConstructor {
6 | userService: UserService;
7 | authService: AuthService;
8 | }
9 |
10 | export interface ApiRegistrationInput {
11 | username: string;
12 | email: string;
13 | password: string;
14 | bio?: string;
15 | image?: string;
16 | }
17 |
18 | export type ApiRegistrationOutput = Promise<{
19 | user: DtoUser;
20 | }>;
21 |
--------------------------------------------------------------------------------
/apps/user/service/ApiUnfollowUser/ApiUnfollowUser.ts:
--------------------------------------------------------------------------------
1 | import { InvalidFollowError, UserNotFoundError, UserService } from '@conduit/core';
2 | import {
3 | ApiErrorBadRequest,
4 | ApiErrorInternalServerError,
5 | ApiErrorNotFound
6 | } from '@conduit/utils';
7 |
8 | import {
9 | ApiUnfollowUserConstructor,
10 | ApiUnfollowUserInput,
11 | ApiUnfollowUserOutput
12 | } from './types';
13 |
14 | export class ApiUnfollowUser {
15 | private userService: UserService;
16 |
17 | constructor({ userService }: ApiUnfollowUserConstructor) {
18 | this.userService = userService;
19 | }
20 |
21 | async execute({ userId, username }: ApiUnfollowUserInput): ApiUnfollowUserOutput {
22 | try {
23 | await this.userService.unfollowUser({
24 | followerId: userId,
25 | followingUsername: username
26 | });
27 |
28 | const profile = await this.userService.getUserProfile({
29 | requestingUserId: userId,
30 | username
31 | });
32 |
33 | if (!profile) {
34 | throw new ApiErrorInternalServerError({
35 | cause: new Error(`Failed to get profile for username ${username}`)
36 | });
37 | }
38 |
39 | return {
40 | profile
41 | };
42 | } catch (error) {
43 | if (error instanceof UserNotFoundError) {
44 | throw new ApiErrorNotFound({ cause: error });
45 | }
46 | if (error instanceof InvalidFollowError) {
47 | throw new ApiErrorBadRequest({ cause: error });
48 | }
49 | throw error;
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/apps/user/service/ApiUnfollowUser/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ApiUnfollowUser';
2 |
--------------------------------------------------------------------------------
/apps/user/service/ApiUnfollowUser/types.ts:
--------------------------------------------------------------------------------
1 | import { DbDtoProfile, UserService } from '@conduit/core';
2 |
3 | export interface ApiUnfollowUserConstructor {
4 | userService: UserService;
5 | }
6 |
7 | export interface ApiUnfollowUserInput {
8 | username: string;
9 | userId: string;
10 | }
11 |
12 | export type ApiUnfollowUserOutput = Promise<{
13 | profile: DbDtoProfile;
14 | }>;
15 |
--------------------------------------------------------------------------------
/apps/user/service/ApiUpdateUser/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiUpdateUser } from './ApiUpdateUser';
2 | export { ApiUpdateUserInput, ApiUpdateUserOutput } from './types';
3 |
--------------------------------------------------------------------------------
/apps/user/service/ApiUpdateUser/types.ts:
--------------------------------------------------------------------------------
1 | import { UserService } from '@conduit/core';
2 |
3 | import { DtoUser } from '../../dto';
4 |
5 | export interface ApiUpdateUserConstructor {
6 | userService: UserService;
7 | }
8 |
9 | export interface ApiUpdateUserInput {
10 | userId: string;
11 | username?: string;
12 | email?: string;
13 | bio?: string;
14 | image?: string;
15 | password?: string;
16 | }
17 |
18 | export type ApiUpdateUserOutput = Promise<{ user: DtoUser }>;
19 |
--------------------------------------------------------------------------------
/apps/user/service/ApiUserLogin/index.ts:
--------------------------------------------------------------------------------
1 | export { ApiUserLogin } from './ApiUserLogin';
2 | export { ApiUserLoginInput, ApiUserLoginOutput } from './types';
3 |
--------------------------------------------------------------------------------
/apps/user/service/ApiUserLogin/types.ts:
--------------------------------------------------------------------------------
1 | import { AuthService, DbDtoUser, UserService } from '@conduit/core';
2 |
3 | import { DtoUser } from '../../dto';
4 |
5 | export interface ApiUserLoginConstructor {
6 | authService: AuthService;
7 | userService: UserService;
8 | }
9 |
10 | export interface ApiUserLoginInput {
11 | email: string;
12 | password: string;
13 | }
14 |
15 | export type ApiUserLoginOutput = Promise<{
16 | user: DtoUser;
17 | }>;
18 |
19 | /**
20 | *
21 | * function: getUserByEmail
22 | *
23 | */
24 | export interface GetUserByEmailInput {
25 | email: string;
26 | }
27 |
28 | export type GetUserByEmailOutput = Promise;
29 |
--------------------------------------------------------------------------------
/apps/user/service/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ApiRegistration';
2 | export * from './ApiUserLogin';
3 |
4 | export { Factory } from './Factory';
5 |
--------------------------------------------------------------------------------
/apps/user/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "extends": "@conduit/config/tsconfig.base.json",
4 | "compilerOptions": {
5 | "sourceMap": false
6 | },
7 | "include": ["./**/*.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/apps/user/types/global.d.ts:
--------------------------------------------------------------------------------
1 | import '@conduit/core/types/global';
2 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | codecov:
2 | branch: master
3 |
4 | coverage:
5 | precision: 2
6 | round: nearest
7 | range: 80..100
8 | status:
9 | project:
10 | default:
11 | target: auto
12 | threshold: 0.1%
13 | if_not_found: success
14 | informational: false
15 | only_pulls: false
16 | core:
17 | paths:
18 | - ./packages/core/**
19 | user:
20 | paths:
21 | - ./apps/server/user/**
22 | article:
23 | paths:
24 | - ./apps/server/article/**
25 |
--------------------------------------------------------------------------------
/config/ci.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodeEnv": "ci",
3 | "mode": "local",
4 | "domain": "localhost",
5 | "auth": {
6 | "expiresIn": "1d",
7 | "jwtSecret": "how_do_you_turn_this_on"
8 | },
9 | "database": {
10 | "conduit": {
11 | "host": "localhost",
12 | "port": "3306",
13 | "user": "mysql",
14 | "password": "mysql",
15 | "database": "conduit"
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/config/custom-environment-variables.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodeEnv": "NODE_ENV",
3 | "domain": "DOMAIN",
4 | "auth": {
5 | "expiresIn": "AUTH_EXPIRES_IN",
6 | "jwtSecret": "AUTH_JWT_SECRET"
7 | },
8 | "database": {
9 | "conduit": {
10 | "host": "DATABASE_HOST",
11 | "port": "DATABASE_PORT",
12 | "user": "DATABASE_USER",
13 | "password": "DATABASE_PASSWORD",
14 | "database": "DATABASE_NAME"
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/config/default.json:
--------------------------------------------------------------------------------
1 | {
2 | "mode": "lambda",
3 | "auth": {
4 | "expiresIn": "1d"
5 | },
6 | "database": {
7 | "conduit": {
8 | "port": "3306"
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/config/develop.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodeEnv": "develop"
3 | }
4 |
--------------------------------------------------------------------------------
/config/prod.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodeEnv": "prod"
3 | }
4 |
--------------------------------------------------------------------------------
/config/test.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodeEnv": "test",
3 | "auth": {
4 | "expiresIn": "1d",
5 | "jwtSecret": "how_do_you_turn_this_on"
6 | },
7 | "database": {
8 | "conduit": {
9 | "host": "db",
10 | "port": "3306",
11 | "user": "user",
12 | "password": "password",
13 | "database": "db"
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | server:
5 | container_name: conduit-local
6 | build:
7 | context: .
8 | dockerfile: ./apps/local/Dockerfile
9 | ports:
10 | - "3100:3100"
11 | command: node apps/local/local.js
12 | environment:
13 | - NODE_ENV=develop
14 | - MODE=local
15 | - DOMAIN=localhost
16 | - AUTH_JWT_SECRET=how-do-you-turn-this-on-in-2024
17 | - AUTH_JWT_EXPIRES_IN=1d
18 | - DATABASE_HOST=conduit-mysql
19 | - DATABASE_PORT=3306
20 | - DATABASE_USER=conduit
21 | - DATABASE_PASSWORD=how-do-you-turn-this-on-twice
22 | - DATABASE_NAME=conduit-local
23 | depends_on:
24 | - mysql
25 | - db-migration
26 |
27 | mysql:
28 | image: mysql:8.0
29 | container_name: conduit-mysql
30 | environment:
31 | MYSQL_ROOT_PASSWORD: how-do-you-turn-this-on
32 | MYSQL_DATABASE: conduit-local
33 | MYSQL_USER: conduit
34 | MYSQL_PASSWORD: how-do-you-turn-this-on-twice
35 | ports:
36 | - "3306:3306"
37 |
38 | db-migration:
39 | container_name: conduit-db-migration
40 | command: ["yarn", "db:migrate"]
41 | build:
42 | context: .
43 | dockerfile: ./packages/core/database/Dockerfile
44 | environment:
45 | - NODE_ENV=develop
46 | - DATABASE_HOST=conduit-mysql
47 | - DATABASE_PORT=3306
48 | - DATABASE_USER=conduit
49 | - DATABASE_PASSWORD=how-do-you-turn-this-on-twice
50 | - DATABASE_NAME=conduit-local
51 | depends_on:
52 | - mysql
53 |
--------------------------------------------------------------------------------
/infra/.prettierignore:
--------------------------------------------------------------------------------
1 | cdk.out
2 |
--------------------------------------------------------------------------------
/infra/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 85,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": true,
6 | "singleQuote": true,
7 | "quoteProps": "as-needed",
8 | "jsxSingleQuote": false,
9 | "trailingComma": "none",
10 | "bracketSpacing": true,
11 | "jsxBracketSameLine": false,
12 | "arrowParens": "always",
13 | "proseWrap": "always",
14 | "plugins": [
15 | "prettier-plugin-packagejson",
16 | "@trivago/prettier-plugin-sort-imports"
17 | ],
18 | "importOrder": ["^dotenv/config$", "", "^[./]"],
19 | "importOrderSeparation": true,
20 | "importOrderSortSpecifiers": true,
21 | "importOrderCaseInsensitive": false
22 | }
23 |
--------------------------------------------------------------------------------
/infra/Makefile:
--------------------------------------------------------------------------------
1 | qa:
2 | yarn prettify && yarn lint:fix
3 |
--------------------------------------------------------------------------------
/infra/cdk.json:
--------------------------------------------------------------------------------
1 | {
2 | "app": "ts-node main.ts"
3 | }
4 |
--------------------------------------------------------------------------------
/infra/config/custom-environment-variables.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodeEnv": "NODE_ENV",
3 | "aws": {
4 | "region": "AWS_REGION",
5 | "accountId": "AWS_ACCOUNT_ID",
6 | "arn": {
7 | "role": {
8 | "lambda": "AWS_ROLE_LAMBDA_ARN"
9 | }
10 | }
11 | },
12 | "github": {
13 | "repository": "GITHUB_REPOSITORY",
14 | "owner": "GITHUB_OWNER"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/infra/config/default.json:
--------------------------------------------------------------------------------
1 | {
2 | "aws": {
3 | "region": "us-east-1"
4 | },
5 | "github": {
6 | "repository": "realworld-nodejs-example-app",
7 | "owner": "kenyipp"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/infra/config/develop.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodeEnv": "develop"
3 | }
4 |
--------------------------------------------------------------------------------
/infra/config/prod.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodeEnv": "prod"
3 | }
4 |
--------------------------------------------------------------------------------
/infra/constants/Lambdas.ts:
--------------------------------------------------------------------------------
1 | import { ResourcePrefix } from './constants';
2 |
3 | export const Lambdas = {
4 | UserServerFunction: `${ResourcePrefix}-user-server-lambda`,
5 | AppServerFunction: `${ResourcePrefix}-app-server-lambda`,
6 | ArticleServerFunction: `${ResourcePrefix}-article-server-lambda`,
7 | TestingCron: `${ResourcePrefix}-testing-cron-lambda`
8 | };
9 |
--------------------------------------------------------------------------------
/infra/constants/Secrets.ts:
--------------------------------------------------------------------------------
1 | import { config } from '../utils';
2 |
3 | export const Secrets = {
4 | GithubToken: 'conduit/github-token',
5 | DatabaseConfig: `conduit/${config.nodeEnv}/database`,
6 | JwtSecret: `conduit/${config.nodeEnv}/jwt-token`,
7 | DomainCert: `conduit/${config.nodeEnv}/acm-certificate`
8 | };
9 |
--------------------------------------------------------------------------------
/infra/constants/Stacks.ts:
--------------------------------------------------------------------------------
1 | import { ResourcePrefix } from './constants';
2 |
3 | export const Stacks = {
4 | ApiGateway: `${ResourcePrefix}-api-gateway-stack`,
5 | Lambda: `${ResourcePrefix}-lambda-stack`
6 | };
7 |
--------------------------------------------------------------------------------
/infra/constants/constants.ts:
--------------------------------------------------------------------------------
1 | import { config } from '../utils';
2 |
3 | const app = 'conduit-api';
4 | const { accountId } = config.aws;
5 |
6 | export const ResourcePrefix = `${app}-${config.nodeEnv}-${accountId}`;
7 |
--------------------------------------------------------------------------------
/infra/constants/index.ts:
--------------------------------------------------------------------------------
1 | export * from './constants';
2 | export * from './Lambdas';
3 | export * from './Stacks';
4 | export * from './Secrets';
5 |
--------------------------------------------------------------------------------
/infra/main.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 |
3 | import { CdkGraph, FilterPreset } from '@aws/pdk/cdk-graph';
4 | import {
5 | CdkGraphDiagramPlugin,
6 | DiagramFormat
7 | } from '@aws/pdk/cdk-graph-plugin-diagram';
8 | import { App, Environment } from 'aws-cdk-lib';
9 |
10 | import { Stacks } from './constants';
11 | import { ApiGatewayStack, LambdaStack } from './stacks';
12 | import { config } from './utils';
13 |
14 | // eslint-disable-next-line no-void, func-names
15 | void (async function () {
16 | const app = new App();
17 |
18 | const env: Environment = {
19 | region: config.aws.region,
20 | account: config.aws.accountId
21 | };
22 |
23 | const lambdaStack = new LambdaStack(app, Stacks.Lambda, {
24 | env,
25 | roleArn: config.aws.arn.role.lambda
26 | });
27 |
28 | new ApiGatewayStack(app, Stacks.ApiGateway, {
29 | env,
30 | userFunctionArn: lambdaStack.userServerFunction.functionArn,
31 | appFunctionArn: lambdaStack.appServerFunction.functionArn,
32 | articleServerFunctionArn: lambdaStack.articleServerFunction.functionArn
33 | });
34 |
35 | // Generate a diagram for the whole architecture
36 | const group = new CdkGraph(app, {
37 | plugins: [
38 | new CdkGraphDiagramPlugin({
39 | diagrams: [
40 | {
41 | name: 'conduit-api-stack-diagram',
42 | title: 'Conduit Api Stack Diagram',
43 | format: DiagramFormat.PNG,
44 | theme: 'light',
45 | filterPlan: {
46 | preset: FilterPreset.COMPACT
47 | }
48 | }
49 | ]
50 | })
51 | ]
52 | });
53 |
54 | app.synth();
55 |
56 | await group.report();
57 | })();
58 |
--------------------------------------------------------------------------------
/infra/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@conduit/lamba-infra",
3 | "version": "1.0.0",
4 | "description": "",
5 | "license": "ISC",
6 | "author": "ken.yip",
7 | "main": "main.ts",
8 | "scripts": {
9 | "cdk:list": "cdk list",
10 | "check-types": "tsc --skipLibCheck --noEmit",
11 | "deploy": "cdk deploy --require-approval never",
12 | "lint": "eslint .",
13 | "lint:fix": "eslint . --fix",
14 | "prettify": "prettier --write .",
15 | "synth": "cdk synth"
16 | },
17 | "dependencies": {
18 | "@aws/pdk": "^0.25.0",
19 | "aws-cdk": "2.161.1",
20 | "aws-cdk-lib": "2.161.1",
21 | "config": "^3.3.12",
22 | "constructs": "^10.4.1",
23 | "dotenv": "^16.4.5"
24 | },
25 | "devDependencies": {
26 | "@trivago/prettier-plugin-sort-imports": "^4.3.0",
27 | "@types/config": "^3.3.5",
28 | "@types/node": "^22.7.5",
29 | "@typescript-eslint/eslint-plugin": "^5.30.7",
30 | "@typescript-eslint/parser": "^5.30.7",
31 | "eslint": "^8.19.0",
32 | "eslint-config-airbnb-base": "^15.0.0",
33 | "eslint-config-airbnb-typescript": "^17.0.0",
34 | "eslint-config-prettier": "^9.0.0",
35 | "eslint-plugin-import": "^2.26.0",
36 | "eslint-plugin-jest": "^26.5.3",
37 | "eslint-plugin-prettier": "^5.2.1",
38 | "prettier": "^3.3.3",
39 | "prettier-plugin-packagejson": "^2.5.3",
40 | "ts-node": "^10.9.1",
41 | "typescript": "^5.6.3"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/infra/stacks/ApiGatewayStack/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ApiGatewayStack';
2 |
--------------------------------------------------------------------------------
/infra/stacks/LambdaStack/constants/FileExcludeList.ts:
--------------------------------------------------------------------------------
1 | export const FileExcludeList = [
2 | '.cspell',
3 | '.cspell.json',
4 | '.env',
5 | '.prettierrc.cjs',
6 | '.eslintrc.json',
7 | '.prettierrc.json',
8 | 'cdk.out',
9 | 'infra',
10 | 'scripts',
11 | '.editorconfig',
12 | '.turbo',
13 | '.gitignore',
14 | 'LICENSE',
15 | 'Makefile',
16 | 'turbo.json',
17 | 'tsconfig.json',
18 | '**/*.ts' // Exclude all TypeScript files from the bundle
19 | ];
20 |
--------------------------------------------------------------------------------
/infra/stacks/LambdaStack/constants/index.ts:
--------------------------------------------------------------------------------
1 | export * from './FileExcludeList';
2 |
--------------------------------------------------------------------------------
/infra/stacks/LambdaStack/index.ts:
--------------------------------------------------------------------------------
1 | export * from './LambdaStack';
2 |
--------------------------------------------------------------------------------
/infra/stacks/LambdaStack/types.ts:
--------------------------------------------------------------------------------
1 | import { StackProps } from 'aws-cdk-lib';
2 | import { IRole } from 'aws-cdk-lib/aws-iam';
3 | import { FunctionProps, Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda';
4 |
5 | export interface LambdaStackProps extends StackProps {
6 | roleArn: string;
7 | }
8 |
9 | export type GlobalProps = Required<
10 | Pick<
11 | FunctionProps,
12 | | 'runtime'
13 | | 'code'
14 | | 'role'
15 | | 'timeout'
16 | | 'memorySize'
17 | | 'tracing'
18 | | 'environment'
19 | >
20 | >;
21 |
22 | /**
23 | *
24 | * function: getGlobalFunctionProps
25 | *
26 | */
27 | export interface GetGlobalFunctionPropsInput {
28 | role: IRole;
29 | }
30 |
31 | export type GetGlobalFunctionPropsOutput = GlobalProps;
32 |
33 | /**
34 | *
35 | * function: convertArnsToCdkResources
36 | *
37 | */
38 | export interface ConvertArnsToCdkResourcesInput {
39 | roleArn: string;
40 | }
41 |
42 | export interface ConvertArnsToCdkResourcesOutput {
43 | role: IRole;
44 | }
45 |
46 | /**
47 | *
48 | * function: createAppServerFunction
49 | *
50 | */
51 | export type CreateAppServerFunctionOutput = LambdaFunction;
52 |
53 | /**
54 | *
55 | * function: createUserServerFunction
56 | *
57 | */
58 | export type CreateUserServerFunctionOutput = LambdaFunction;
59 |
60 | /**
61 | *
62 | * function: createArticleServerFunction
63 | *
64 | */
65 | export type CreateArticleServerFunctionOutput = LambdaFunction;
66 |
--------------------------------------------------------------------------------
/infra/stacks/LambdaStack/utils/getEnvironmentVariables.ts:
--------------------------------------------------------------------------------
1 | import { config } from '../../../utils';
2 |
3 | export const getEnvironmentVariables = (vars: string[]) =>
4 | vars.reduce(
5 | (acc, curr) => {
6 | acc[curr] = process.env[curr];
7 | return acc;
8 | },
9 | {
10 | NODE_ENV: config.nodeEnv
11 | }
12 | );
13 |
--------------------------------------------------------------------------------
/infra/stacks/LambdaStack/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './getEnvironmentVariables';
2 |
--------------------------------------------------------------------------------
/infra/stacks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './LambdaStack';
2 | export * from './ApiGatewayStack';
3 |
--------------------------------------------------------------------------------
/infra/utils/config/config.ts:
--------------------------------------------------------------------------------
1 | import nodeConfig from 'config';
2 |
3 | import { Config } from './types';
4 |
5 | export const config: Config = nodeConfig.util.toObject();
6 |
--------------------------------------------------------------------------------
/infra/utils/config/index.ts:
--------------------------------------------------------------------------------
1 | export * from './config';
2 |
--------------------------------------------------------------------------------
/infra/utils/config/types.ts:
--------------------------------------------------------------------------------
1 | export interface Config {
2 | nodeEnv: 'develop' | 'prod';
3 | aws: {
4 | region: string;
5 | accountId: string;
6 | arn: {
7 | role: {
8 | lambda: string;
9 | };
10 | };
11 | };
12 | github: {
13 | owner: string;
14 | repository: string;
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/infra/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './config';
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "conduit",
3 | "version": "1.0.0",
4 | "private": true,
5 | "description": "Example Node (Express + Knex) codebase containing real world examples that adheres to the RealWorld API spec",
6 | "main": "index.js",
7 | "scripts": {
8 | "start": "turbo start",
9 | "start:ci": "turbo start:ci",
10 | "build": "turbo build",
11 | "test": "turbo test --parallel",
12 | "test:coverage": "turbo test:coverage --parallel",
13 | "check-types": "turbo check-types",
14 | "dev": "turbo dev --filter=@conduit/local",
15 | "deploy": "cd ./infra && yarn deploy",
16 | "clean": "./scripts/clean.sh",
17 | "merge-coverage": "istanbul-merge --out .nyc_output/coverage.json ./**/**/coverage/coverage-final.json && nyc report --reporter=lcov --reporter=text",
18 | "prettify": "turbo prettify",
19 | "lint": "turbo lint",
20 | "lint:fix": "turbo lint:fix",
21 | "db:migrate": "turbo db:migrate",
22 | "spell-check": "cspell \"**\" --no-progress"
23 | },
24 | "author": {
25 | "name": "Ken Yip",
26 | "email": "ken20206@gmail.com",
27 | "url": "https://kenyip.cc"
28 | },
29 | "license": "ISC",
30 | "packageManager": "yarn@1.22.19",
31 | "workspaces": [
32 | "apps/*",
33 | "packages/*"
34 | ],
35 | "devDependencies": {
36 | "cspell": "^8.15.2",
37 | "istanbul-merge": "^2.0.0",
38 | "nyc": "^17.1.0",
39 | "turbo": "^2.2.3"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/packages/config/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 85,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": true,
6 | "singleQuote": true,
7 | "quoteProps": "as-needed",
8 | "jsxSingleQuote": false,
9 | "trailingComma": "none",
10 | "bracketSpacing": true,
11 | "jsxBracketSameLine": false,
12 | "arrowParens": "always",
13 | "proseWrap": "always",
14 | "plugins": [
15 | "prettier-plugin-packagejson",
16 | "@trivago/prettier-plugin-sort-imports"
17 | ],
18 | "importOrder": [
19 | "^dotenv/config$",
20 | "",
21 | "^@conduit/(.*)$",
22 | "^[./]"
23 | ],
24 | "importOrderSeparation": true,
25 | "importOrderSortSpecifiers": true,
26 | "importOrderCaseInsensitive": false
27 | }
28 |
--------------------------------------------------------------------------------
/packages/config/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('jest').Config} */
2 |
3 | const config = {
4 | preset: 'ts-jest',
5 | verbose: false,
6 | modulePathIgnorePatterns: ['/aws/scripts/', '/node_modules/'],
7 | testEnvironment: 'node'
8 | };
9 |
10 | module.exports = config;
11 |
--------------------------------------------------------------------------------
/packages/config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@conduit/config",
3 | "version": "1.0.0",
4 | "private": true,
5 | "description": "The config module of this monorepository contains various configurations, including TypeScript, ESLint, Jest, and others.",
6 | "keywords": [
7 | "conduit",
8 | "config"
9 | ],
10 | "repository": {
11 | "type": "git",
12 | "url": "https://github.com/kenyipp/realworld-nodejs-example-app/tree/master/packages/config"
13 | },
14 | "license": "MIT",
15 | "author": {
16 | "name": "Ken Yip",
17 | "email": "ken20206@gmail.com",
18 | "url": "https://kenyip.cc"
19 | },
20 | "scripts": {
21 | "prettify": "prettier --write ."
22 | },
23 | "devDependencies": {
24 | "@jest/types": "^29.6.3",
25 | "@trivago/prettier-plugin-sort-imports": "^4.3.0",
26 | "@typescript-eslint/eslint-plugin": "^5.30.7",
27 | "@typescript-eslint/parser": "^5.30.7",
28 | "eslint": "^8.19.0",
29 | "eslint-config-airbnb-base": "^15.0.0",
30 | "eslint-config-airbnb-typescript": "^17.0.0",
31 | "eslint-config-prettier": "^9.0.0",
32 | "eslint-plugin-import": "^2.26.0",
33 | "eslint-plugin-jest": "^26.5.3",
34 | "eslint-plugin-prettier": "^5.2.1",
35 | "prettier": "^3.3.3",
36 | "prettier-plugin-packagejson": "^2.5.3"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/packages/core/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const defaultConfig = require('@conduit/config/.eslintrc.json');
2 |
3 | module.exports = {
4 | ...defaultConfig,
5 | parserOptions: {
6 | project: './tsconfig.json'
7 | },
8 | rules: {
9 | ...defaultConfig.rules,
10 | 'import/no-extraneous-dependencies': 'off'
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/packages/core/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = require('@conduit/config/.prettierrc.json');
2 |
--------------------------------------------------------------------------------
/packages/core/database/DbArticle/dto/DbDtoArticleComment.ts:
--------------------------------------------------------------------------------
1 | import { RecordStatus } from '@conduit/core/types';
2 |
3 | export class DbDtoArticleComment {
4 | id: string;
5 | body: string;
6 | author: {
7 | id: string;
8 | username: string;
9 | bio?: string;
10 | image?: string;
11 | following: boolean;
12 | };
13 |
14 | recordStatus: RecordStatus;
15 | createdAt: Date;
16 | updatedAt: Date;
17 |
18 | constructor({
19 | id,
20 | body,
21 | userId,
22 | userName,
23 | userBio,
24 | userImage,
25 | isFollowing,
26 | recordStatus,
27 | createdAt,
28 | updatedAt
29 | }: DbDtoArticleCommentConstructor) {
30 | this.id = id;
31 | this.body = body;
32 | this.author = {
33 | id: userId,
34 | username: userName,
35 | bio: userBio,
36 | image: userImage,
37 | following: isFollowing
38 | };
39 | this.recordStatus = recordStatus;
40 | this.createdAt = createdAt;
41 | this.updatedAt = updatedAt;
42 | }
43 | }
44 |
45 | interface DbDtoArticleCommentConstructor {
46 | id: string;
47 | body: string;
48 | userId: string;
49 | userName: string;
50 | userBio?: string;
51 | userImage?: string;
52 | isFollowing: boolean;
53 | recordStatus: RecordStatus;
54 | createdAt: Date;
55 | updatedAt: Date;
56 | }
57 |
--------------------------------------------------------------------------------
/packages/core/database/DbArticle/dto/DbDtoArticleCommentWithProfile.ts:
--------------------------------------------------------------------------------
1 | import { DbDtoProfile } from '@conduit/core/database';
2 |
3 | export class DbDtoArticleCommentWithProfile {
4 | id: string;
5 | createdAt: Date;
6 | updatedAt: Date;
7 | body: string;
8 | author: DbDtoProfile;
9 |
10 | constructor({
11 | id,
12 | createdAt,
13 | updatedAt,
14 | body,
15 | author
16 | }: DbDtoArticleCommentWithProfileConstructor) {
17 | this.id = id;
18 | this.createdAt = createdAt;
19 | this.updatedAt = updatedAt;
20 | this.body = body;
21 | this.author = new DbDtoProfile(author);
22 | }
23 | }
24 |
25 | interface DbDtoArticleCommentWithProfileConstructor {
26 | id: string;
27 | createdAt: Date;
28 | updatedAt: Date;
29 | body: string;
30 | author: {
31 | id: string;
32 | username: string;
33 | bio?: string;
34 | image?: string;
35 | following: boolean;
36 | };
37 | }
38 |
--------------------------------------------------------------------------------
/packages/core/database/DbArticle/dto/DbDtoArticleTag.ts:
--------------------------------------------------------------------------------
1 | import { RecordStatus } from '@conduit/core/types';
2 |
3 | export class DbDtoArticleTag {
4 | id: string;
5 | articleId: string;
6 | tag: string;
7 | recordStatus: RecordStatus;
8 | createdAt: Date;
9 | updatedAt: Date;
10 |
11 | constructor({
12 | id,
13 | articleId,
14 | tag,
15 | recordStatus,
16 | createdAt,
17 | updatedAt
18 | }: DbDtoArticleTagConstructor) {
19 | this.id = id;
20 | this.articleId = articleId;
21 | this.tag = tag;
22 | this.recordStatus = recordStatus;
23 | this.createdAt = createdAt;
24 | this.updatedAt = updatedAt;
25 | }
26 | }
27 |
28 | interface DbDtoArticleTagConstructor {
29 | id: string;
30 | articleId: string;
31 | tag: string;
32 | recordStatus: RecordStatus;
33 | createdAt: Date;
34 | updatedAt: Date;
35 | }
36 |
--------------------------------------------------------------------------------
/packages/core/database/DbArticle/dto/index.ts:
--------------------------------------------------------------------------------
1 | export * from './DbDtoArticle';
2 | export * from './DbDtoArticleComment';
3 | export * from './DbDtoArticleTag';
4 | export * from './DbDtoArticleCommentWithProfile';
5 |
--------------------------------------------------------------------------------
/packages/core/database/DbArticle/index.ts:
--------------------------------------------------------------------------------
1 | export { DbArticle } from './DbArticle';
2 | export * from './dto';
3 |
--------------------------------------------------------------------------------
/packages/core/database/DbFactory.ts:
--------------------------------------------------------------------------------
1 | import { DbArticle } from './DbArticle';
2 | import { DbUser } from './DbUser';
3 |
4 | /**
5 | *
6 | * A factory for creating new instances of database entities.
7 | *
8 | */
9 | export class DbFactory {
10 | newDbUser(): DbUser {
11 | const dbUser = new DbUser();
12 | return dbUser;
13 | }
14 |
15 | newDbArticle(): DbArticle {
16 | const dbArticle = new DbArticle();
17 | return dbArticle;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/core/database/DbUser/dto/DbDtoProfile.ts:
--------------------------------------------------------------------------------
1 | export class DbDtoProfile {
2 | id: string;
3 | username: string;
4 | bio?: string;
5 | image?: string;
6 | following: boolean;
7 |
8 | constructor({ id, username, bio, image, following }: DbDtoProfileConstructor) {
9 | this.id = id;
10 | this.username = username;
11 | this.bio = bio;
12 | this.image = image;
13 | this.following = typeof following === 'number' ? following > 0 : following;
14 | }
15 | }
16 |
17 | export interface DbDtoProfileConstructor {
18 | id: string;
19 | username: string;
20 | bio?: string;
21 | image?: string;
22 | following: boolean | number;
23 | }
24 |
--------------------------------------------------------------------------------
/packages/core/database/DbUser/dto/DbDtoUser.ts:
--------------------------------------------------------------------------------
1 | import { UserStatus } from '@conduit/core/types';
2 |
3 | export class DbDtoUser {
4 | id: string;
5 | username: string;
6 | email: string;
7 | bio?: string;
8 | image?: string;
9 | hash: string;
10 | recordStatus: UserStatus;
11 | createdAt: Date;
12 | updatedAt: Date;
13 |
14 | constructor({
15 | id,
16 | username,
17 | email,
18 | bio,
19 | image,
20 | hash,
21 | recordStatus,
22 | createdAt,
23 | updatedAt
24 | }: DbDtoUserConstructor) {
25 | this.id = id;
26 | this.username = username;
27 | this.email = email;
28 | this.bio = bio;
29 | this.image = image;
30 | this.hash = hash;
31 | this.recordStatus = recordStatus;
32 | this.createdAt = createdAt;
33 | this.updatedAt = updatedAt;
34 | }
35 | }
36 |
37 | interface DbDtoUserConstructor {
38 | id: string;
39 | username: string;
40 | email: string;
41 | bio?: string;
42 | image?: string;
43 | hash: string;
44 | recordStatus: UserStatus;
45 | createdAt: Date;
46 | updatedAt: Date;
47 | }
48 |
--------------------------------------------------------------------------------
/packages/core/database/DbUser/dto/index.ts:
--------------------------------------------------------------------------------
1 | export * from './DbDtoProfile';
2 | export * from './DbDtoUser';
3 |
--------------------------------------------------------------------------------
/packages/core/database/DbUser/index.ts:
--------------------------------------------------------------------------------
1 | export { DbUser } from './DbUser';
2 | export * from './dto';
3 |
--------------------------------------------------------------------------------
/packages/core/database/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:18
2 |
3 | # Set working directory
4 | WORKDIR /app
5 | RUN yarn global add turbo
6 |
7 | COPY . .
8 |
9 | RUN yarn install
10 |
--------------------------------------------------------------------------------
/packages/core/database/index.ts:
--------------------------------------------------------------------------------
1 | export { DbFactory } from './DbFactory';
2 | export * from './DbUser';
3 | export * from './DbArticle';
4 | export { dangerouslyResetDb } from './knex';
5 |
--------------------------------------------------------------------------------
/packages/core/database/knex/index.ts:
--------------------------------------------------------------------------------
1 | export * from './knex';
2 |
--------------------------------------------------------------------------------
/packages/core/database/knex/knex.ts:
--------------------------------------------------------------------------------
1 | import Knex from 'knex';
2 |
3 | import { NodeEnv } from '@conduit/types';
4 | import { config } from '@conduit/utils';
5 |
6 | import { knexConfig as KnexConfig } from './knexfile';
7 |
8 | const knexConfig = KnexConfig[config.nodeEnv ?? NodeEnv.Test];
9 |
10 | if (!knexConfig) {
11 | throw new Error(`Invalid node environment - ${config.nodeEnv}`);
12 | }
13 |
14 | export const knex = Knex(knexConfig);
15 |
16 | export const dangerouslyResetDb = async () => {
17 | if (config.nodeEnv !== NodeEnv.Test && config.nodeEnv !== NodeEnv.CI) {
18 | throw new Error(
19 | `This function should only be called in the test or CI environment (current: ${config.nodeEnv})`
20 | );
21 | }
22 | await knex.migrate.rollback(undefined, true);
23 | await knex.migrate.latest();
24 | await knex.seed.run();
25 | };
26 |
--------------------------------------------------------------------------------
/packages/core/database/knex/knexfile.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 |
3 | import path from 'path';
4 |
5 | import { config } from '@conduit/utils';
6 |
7 | import { KnexConfig } from './types';
8 |
9 | export const knexConfig: KnexConfig = {
10 | test: {
11 | client: 'better-sqlite3',
12 | connection: {
13 | filename: ':memory:'
14 | },
15 | useNullAsDefault: true,
16 | migrations: {
17 | directory: path.join(__dirname, './migrations')
18 | },
19 | seeds: {
20 | directory: path.join(__dirname, './seeds')
21 | }
22 | },
23 | ci: {
24 | client: 'mysql2',
25 | connection: config.database.conduit
26 | },
27 | develop: {
28 | client: 'mysql2',
29 | connection: config.database.conduit
30 | },
31 | prod: {
32 | client: 'mysql2',
33 | connection: config.database.conduit
34 | }
35 | };
36 |
37 | export default knexConfig;
38 |
--------------------------------------------------------------------------------
/packages/core/database/knex/migrations/0001_create-user-table.ts:
--------------------------------------------------------------------------------
1 | import { type Knex } from 'knex';
2 |
3 | export async function up(knex: Knex): Promise {
4 | return knex.schema.createTable('user', (table) => {
5 | table.string('user_id', 64).primary();
6 | table.string('username', 64).unique().notNullable();
7 | table.string('email', 250).unique().notNullable();
8 | table.text('bio');
9 | table.text('image');
10 | table.string('hash', 64).notNullable();
11 | table
12 | .enum('record_status', ['active', 'banned'])
13 | .notNullable()
14 | .defaultTo('active');
15 | table.timestamps(true, true);
16 | });
17 | }
18 |
19 | export async function down(knex: Knex): Promise {
20 | await knex.schema.dropTable('user');
21 | }
22 |
--------------------------------------------------------------------------------
/packages/core/database/knex/migrations/0003_create-user-follow-table.ts:
--------------------------------------------------------------------------------
1 | import { type Knex } from 'knex';
2 |
3 | export async function up(knex: Knex): Promise {
4 | return knex.schema.createTable('user_follow', (table) => {
5 | table
6 | .string('user_follow_id', 64)
7 | .comment('Unique identifier for each following record')
8 | .primary();
9 | table.string('follower_id', 64).comment('ID of the user who is following');
10 | table.string('following_id', 64).comment('ID of the user who is being followed');
11 | table.string('record_status', 10).defaultTo('active');
12 | table.timestamps(true, true);
13 | table.unique(['follower_id', 'following_id'], {
14 | indexName: 'UX-user_following-follower_id-following_id'
15 | });
16 | });
17 | }
18 |
19 | export async function down(knex: Knex): Promise {
20 | await knex.schema.dropTable('user_follow');
21 | }
22 |
--------------------------------------------------------------------------------
/packages/core/database/knex/migrations/0004_create-article-table.ts:
--------------------------------------------------------------------------------
1 | import { type Knex } from 'knex';
2 |
3 | export async function up(knex: Knex): Promise {
4 | return knex.schema.createTable('article', (table) => {
5 | table.string('article_id', 64).primary();
6 | table.string('title', 120);
7 | table.string('slug', 120).unique().index('UX-article-slug');
8 | table.string('description', 250);
9 | table.text('body');
10 | table
11 | .string('user_id', 64)
12 | .references('user_id')
13 | .inTable('user')
14 | .index('FK-article-user_id-user-user_id');
15 | table.string('record_status', 10).defaultTo('active');
16 | table.timestamps(true, true);
17 | });
18 | }
19 |
20 | export async function down(knex: Knex): Promise {
21 | await knex.schema.dropTable('article');
22 | }
23 |
--------------------------------------------------------------------------------
/packages/core/database/knex/migrations/0005_create-article-comment-table.ts:
--------------------------------------------------------------------------------
1 | import { type Knex } from 'knex';
2 |
3 | export async function up(knex: Knex): Promise {
4 | return knex.schema.createTable('article_comment', (table) => {
5 | table.string('article_comment_id', 64).primary();
6 | table
7 | .string('article_id', 64)
8 | .references('article_id')
9 | .inTable('article')
10 | .index('FK-article_comment-article_id-article-article_id');
11 | table.text('body');
12 | table
13 | .string('user_id', 64)
14 | .references('user_id')
15 | .inTable('user')
16 | .index('FK-article_comment-user_id-user-user_id');
17 | table.string('record_status', 10).defaultTo('active');
18 | table.timestamps(true, true);
19 | });
20 | }
21 |
22 | export async function down(knex: Knex): Promise {
23 | await knex.schema.dropTable('article_comment');
24 | }
25 |
--------------------------------------------------------------------------------
/packages/core/database/knex/migrations/0006_create-article-favorite-table.ts:
--------------------------------------------------------------------------------
1 | import { type Knex } from 'knex';
2 |
3 | export async function up(knex: Knex): Promise {
4 | return knex.schema.createTable('article_favorite', (table) => {
5 | table.string('article_favorite_id', 64).primary();
6 | table
7 | .string('user_id', 64)
8 | .references('user_id')
9 | .inTable('user')
10 | .index('FK-article_favorite-user_id-user-user_id');
11 | table
12 | .string('article_id', 64)
13 | .references('article_id')
14 | .inTable('article')
15 | .index('FK-article_favorite-article_id-article-article_id');
16 | table.string('record_status', 10).defaultTo('active');
17 | table.timestamps(true, true);
18 | table.unique(['user_id', 'article_id'], {
19 | indexName: 'UX-article_favorite-user_id-article_id'
20 | });
21 | });
22 | }
23 |
24 | export async function down(knex: Knex): Promise {
25 | await knex.schema.dropTable('article_favorite');
26 | }
27 |
--------------------------------------------------------------------------------
/packages/core/database/knex/migrations/0007_create-article-tag-table.ts:
--------------------------------------------------------------------------------
1 | import { type Knex } from 'knex';
2 |
3 | export async function up(knex: Knex): Promise {
4 | return knex.schema.createTable('article_tag', (table) => {
5 | table.string('article_tag_id', 64).primary();
6 | table.string('article_id', 64);
7 | table.string('tag', 150).index('IX-article_tag_tag');
8 | table.string('record_status', 10).defaultTo('active');
9 | table.timestamps(true, true);
10 |
11 | table.unique(['article_id', 'tag']);
12 | });
13 | }
14 |
15 | export async function down(knex: Knex): Promise {
16 | await knex.schema.dropTable('article_tag');
17 | }
18 |
--------------------------------------------------------------------------------
/packages/core/database/knex/seeds/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kenyipp/realworld-nodejs-example-app/94a864e4e458a5e0ae2b07d3cf762791769e8690/packages/core/database/knex/seeds/.gitkeep
--------------------------------------------------------------------------------
/packages/core/database/knex/types.ts:
--------------------------------------------------------------------------------
1 | import type { Knex } from 'knex';
2 |
3 | export type KnexConfig = Record;
4 |
--------------------------------------------------------------------------------
/packages/core/index.ts:
--------------------------------------------------------------------------------
1 | export * from './service';
2 | export * from './utils';
3 | export * from './types';
4 | export * from './database';
5 |
--------------------------------------------------------------------------------
/packages/core/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('jest').Config} */
2 |
3 | module.exports = require('@conduit/config/jest.config');
4 |
--------------------------------------------------------------------------------
/packages/core/repository/RepoArticle/index.ts:
--------------------------------------------------------------------------------
1 | export * from './RepoArticle';
2 |
--------------------------------------------------------------------------------
/packages/core/repository/RepoFactory.ts:
--------------------------------------------------------------------------------
1 | import { DbFactory } from '@conduit/core/database';
2 |
3 | import { RepoArticle } from './RepoArticle';
4 | import { RepoUser } from './RepoUser';
5 |
6 | /**
7 | *
8 | * A factory for creating new instances of repository classes.
9 | *
10 | */
11 | export class RepoFactory {
12 | private dbFactory: DbFactory = new DbFactory();
13 |
14 | newRepoUser(): RepoUser {
15 | const dbUser = this.dbFactory.newDbUser();
16 | return new RepoUser({ dbUser });
17 | }
18 |
19 | newRepoArticle(): RepoArticle {
20 | const dbArticle = this.dbFactory.newDbArticle();
21 | return new RepoArticle({ dbArticle });
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/packages/core/repository/RepoUser/index.ts:
--------------------------------------------------------------------------------
1 | export * from './RepoUser';
2 |
--------------------------------------------------------------------------------
/packages/core/repository/RepoUser/types.ts:
--------------------------------------------------------------------------------
1 | import { DbDtoProfile, DbDtoUser, DbUser } from '@conduit/core/database';
2 |
3 | export interface RepoUserConstructor {
4 | dbUser: DbUser;
5 | }
6 |
7 | export {
8 | CreateUserInput,
9 | CreateUserOutput,
10 | GetIsUserExistsInput,
11 | GetIsUserExistsOutput,
12 | UpdateUserByIdInput,
13 | UpdateUserByIdOutput,
14 | FollowUserInput,
15 | FollowUserOutput,
16 | UnfollowUserInput,
17 | UnfollowUserOutput
18 | } from '@conduit/core/database/DbUser/types';
19 |
20 | /**
21 | *
22 | * function: getUserById
23 | *
24 | */
25 | export interface GetUserByIdInput {
26 | id: string;
27 | }
28 |
29 | export type GetUserByIdOutput = Promise;
30 |
31 | /**
32 | *
33 | * function: getUserByEmail
34 | *
35 | */
36 | export interface GetUserByEmailInput {
37 | email: string;
38 | }
39 |
40 | export type GetUserByEmailOutput = Promise;
41 |
42 | /**
43 | *
44 | * function: getUserProfile
45 | *
46 | */
47 | export interface GetUserProfileInput {
48 | username: string;
49 | requestingUserId?: string;
50 | }
51 |
52 | export type GetUserProfileOutput = Promise;
53 |
--------------------------------------------------------------------------------
/packages/core/repository/index.ts:
--------------------------------------------------------------------------------
1 | export * from './RepoUser';
2 | export * from './RepoArticle';
3 |
4 | export { RepoFactory } from './RepoFactory';
5 |
--------------------------------------------------------------------------------
/packages/core/service/ServiceFactory.ts:
--------------------------------------------------------------------------------
1 | import { RepoFactory } from '@conduit/core/repository';
2 |
3 | import { ArticleService } from './article';
4 | import { AuthService } from './auth';
5 | import { UserService } from './user';
6 |
7 | export class ServiceFactory {
8 | private readonly repoFactory: RepoFactory = new RepoFactory();
9 |
10 | newAuthService(): AuthService {
11 | return new AuthService();
12 | }
13 |
14 | newArticleService(): ArticleService {
15 | const repoArticle = this.repoFactory.newRepoArticle();
16 | return new ArticleService({ repoArticle });
17 | }
18 |
19 | newUserService(): UserService {
20 | const repoUser = this.repoFactory.newRepoUser();
21 | const authService = this.newAuthService();
22 | return new UserService({ authService, repoUser });
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/core/service/article/constants/ArticleErrorCodes.ts:
--------------------------------------------------------------------------------
1 | export enum ArticleErrorCodes {
2 | Generic = 'ARTICLE_GENERIC',
3 | ArticleTitleTaken = 'ARTICLE_TITLE_TAKEN',
4 | ArticleNotFound = 'ARTICLE_NOT_FOUND',
5 | ArticleCommentNotFound = 'ARTICLE_COMMENT_NOT_FOUND',
6 | ArticleAlreadyFavorited = 'ARTICLE_ALREADY_FAVORITED',
7 | ArticleNotYetFavorited = 'ARTICLE_NOT_YET_FAVORITED'
8 | }
9 |
--------------------------------------------------------------------------------
/packages/core/service/article/constants/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ArticleErrorCodes';
2 |
--------------------------------------------------------------------------------
/packages/core/service/article/errors/ArticleAlreadyFavoritedError.ts:
--------------------------------------------------------------------------------
1 | import { ArticleErrorCodes } from '../constants';
2 | import { ArticleError } from './ArticleError';
3 |
4 | export class ArticleAlreadyFavoritedError extends ArticleError {
5 | constructor({ userId, articleId }: ArticleAlreadyFavoritedErrorConstructor) {
6 | super({
7 | code: ArticleErrorCodes.ArticleAlreadyFavorited,
8 | message: 'Article is already favorited by the user',
9 | details: [userId, articleId]
10 | });
11 | }
12 | }
13 |
14 | interface ArticleAlreadyFavoritedErrorConstructor {
15 | userId: string;
16 | articleId: string;
17 | }
18 |
--------------------------------------------------------------------------------
/packages/core/service/article/errors/ArticleError.ts:
--------------------------------------------------------------------------------
1 | import { AppError } from '../../../utils';
2 | import { ArticleErrorCodes } from '../constants';
3 |
4 | export class ArticleError extends AppError {
5 | constructor({
6 | code = ArticleErrorCodes.Generic,
7 | message,
8 | details,
9 | cause
10 | }: ArticleErrorConstructor) {
11 | super({
12 | code,
13 | message,
14 | details,
15 | cause
16 | });
17 | }
18 | }
19 |
20 | export interface ArticleErrorConstructor {
21 | message?: string;
22 | code?: ArticleErrorCodes;
23 | details?: any[];
24 | cause?: Error;
25 | }
26 |
--------------------------------------------------------------------------------
/packages/core/service/article/errors/ArticleNotFoundError.ts:
--------------------------------------------------------------------------------
1 | import { ArticleErrorCodes } from '../constants';
2 | import { ArticleError } from './ArticleError';
3 |
4 | export class ArticleNotFoundError extends ArticleError {
5 | constructor({ slug }: ArticleNotFoundErrorConstructor) {
6 | super({
7 | code: ArticleErrorCodes.ArticleNotFound,
8 | message: 'The requested article was not found.',
9 | details: slug ? [slug] : []
10 | });
11 | }
12 | }
13 |
14 | interface ArticleNotFoundErrorConstructor {
15 | slug?: string;
16 | }
17 |
--------------------------------------------------------------------------------
/packages/core/service/article/errors/ArticleNotYetFavoritedError.ts:
--------------------------------------------------------------------------------
1 | import { ArticleErrorCodes } from '../constants';
2 | import { ArticleError } from './ArticleError';
3 |
4 | export class ArticleNotYetFavoritedError extends ArticleError {
5 | constructor({ userId, articleId }: ArticleNotYetFavoritedErrorConstructor) {
6 | super({
7 | code: ArticleErrorCodes.ArticleNotYetFavorited,
8 | message: 'Article is not yet favorited by the user',
9 | details: [userId, articleId]
10 | });
11 | }
12 | }
13 |
14 | interface ArticleNotYetFavoritedErrorConstructor {
15 | userId: string;
16 | articleId: string;
17 | }
18 |
--------------------------------------------------------------------------------
/packages/core/service/article/errors/ArticleTitleAlreadyTakenError.ts:
--------------------------------------------------------------------------------
1 | import { ArticleErrorCodes } from '../constants';
2 | import { ArticleError } from './ArticleError';
3 |
4 | export class ArticleTitleAlreadyTakenError extends ArticleError {
5 | constructor({ title }: ArticleTitleAlreadyTakenErrorConstructor) {
6 | super({
7 | code: ArticleErrorCodes.ArticleTitleTaken,
8 | message: `The title "${title}" is already taken. Please choose a different title.`,
9 | details: [title]
10 | });
11 | }
12 | }
13 |
14 | interface ArticleTitleAlreadyTakenErrorConstructor {
15 | title: string;
16 | }
17 |
--------------------------------------------------------------------------------
/packages/core/service/article/errors/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ArticleError';
2 | export * from './ArticleNotFoundError';
3 | export * from './ArticleTitleAlreadyTakenError';
4 | export * from './ArticleNotYetFavoritedError';
5 | export * from './ArticleAlreadyFavoritedError';
6 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/CreateArticleCommentHandler/CreateArticleCommentHandler.ts:
--------------------------------------------------------------------------------
1 | import { RepoArticle } from '@conduit/core/repository';
2 |
3 | import { ArticleNotFoundError } from '../../errors';
4 | import {
5 | CreateArticleCommentHandlerConstructor,
6 | CreateArticleCommentInput,
7 | CreateArticleCommentOutput,
8 | ValidateArticleInput,
9 | ValidateArticleOutput
10 | } from './types';
11 |
12 | export class CreateArticleCommentHandler {
13 | private repoArticle: RepoArticle;
14 |
15 | constructor({ repoArticle }: CreateArticleCommentHandlerConstructor) {
16 | this.repoArticle = repoArticle;
17 | }
18 |
19 | async execute({
20 | articleId,
21 | body,
22 | userId
23 | }: CreateArticleCommentInput): CreateArticleCommentOutput {
24 | await this.validateArticle({ articleId });
25 | const commentId = await this.repoArticle.createArticleComment({
26 | articleId,
27 | body,
28 | userId
29 | });
30 | return commentId;
31 | }
32 |
33 | private async validateArticle({
34 | articleId
35 | }: ValidateArticleInput): ValidateArticleOutput {
36 | const article = await this.repoArticle.getArticleById({
37 | id: articleId
38 | });
39 | if (!article) {
40 | throw new ArticleNotFoundError({});
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/CreateArticleCommentHandler/index.ts:
--------------------------------------------------------------------------------
1 | export { CreateArticleCommentHandler } from './CreateArticleCommentHandler';
2 | export { CreateArticleCommentInput, CreateArticleCommentOutput } from './types';
3 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/CreateArticleCommentHandler/types.ts:
--------------------------------------------------------------------------------
1 | import { RepoArticle } from '@conduit/core/repository';
2 |
3 | export interface CreateArticleCommentHandlerConstructor {
4 | repoArticle: RepoArticle;
5 | }
6 |
7 | export interface CreateArticleCommentInput {
8 | articleId: string;
9 | body: string;
10 | userId: string;
11 | }
12 |
13 | export type CreateArticleCommentOutput = Promise;
14 |
15 | /**
16 | *
17 | * function: validateArticle
18 | *
19 | */
20 | export interface ValidateArticleInput {
21 | articleId: string;
22 | }
23 |
24 | export type ValidateArticleOutput = Promise;
25 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/CreateArticleHandler/CreateArticleHandler.ts:
--------------------------------------------------------------------------------
1 | import slugify from 'slugify';
2 |
3 | import { RepoArticle } from '@conduit/core/repository';
4 |
5 | import { ArticleTitleAlreadyTakenError } from '../../errors';
6 | import {
7 | CreateArticleHandlerConstructor,
8 | CreateArticleInput,
9 | CreateArticleOutput,
10 | ValidateIfArticleExistInput,
11 | ValidateIfArticleExistOutput
12 | } from './types';
13 |
14 | export class CreateArticleHandler {
15 | private repoArticle: RepoArticle;
16 |
17 | constructor({ repoArticle }: CreateArticleHandlerConstructor) {
18 | this.repoArticle = repoArticle;
19 | }
20 |
21 | async execute({
22 | title,
23 | description,
24 | body,
25 | userId
26 | }: CreateArticleInput): CreateArticleOutput {
27 | await this.validateIfArticleExist({ title });
28 | const slug = slugify(title);
29 | const articleId = await this.repoArticle.createArticle({
30 | title,
31 | slug,
32 | description,
33 | body,
34 | userId
35 | });
36 | return articleId;
37 | }
38 |
39 | private async validateIfArticleExist({
40 | title
41 | }: ValidateIfArticleExistInput): ValidateIfArticleExistOutput {
42 | const slug = slugify(title);
43 | const article = await this.repoArticle.getArticleBySlug({ slug });
44 | if (article) {
45 | throw new ArticleTitleAlreadyTakenError({ title });
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/CreateArticleHandler/index.ts:
--------------------------------------------------------------------------------
1 | export { CreateArticleHandler } from './CreateArticleHandler';
2 | export { CreateArticleInput, CreateArticleOutput } from './types';
3 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/CreateArticleHandler/types.ts:
--------------------------------------------------------------------------------
1 | import { RepoArticle } from '@conduit/core/repository';
2 |
3 | export interface CreateArticleHandlerConstructor {
4 | repoArticle: RepoArticle;
5 | }
6 |
7 | export interface CreateArticleInput {
8 | title: string;
9 | description: string;
10 | body: string;
11 | userId: string;
12 | }
13 |
14 | export type CreateArticleOutput = Promise;
15 |
16 | /**
17 | *
18 | * function: validateIfArticleExist
19 | *
20 | */
21 | export interface ValidateIfArticleExistInput {
22 | title: string;
23 | }
24 |
25 | export type ValidateIfArticleExistOutput = Promise;
26 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/CreateArticleTagsHandler/CreateArticleTagsHandler.ts:
--------------------------------------------------------------------------------
1 | import { RepoArticle } from '@conduit/core/repository';
2 |
3 | import { ArticleNotFoundError } from '../../errors';
4 | import {
5 | CreateArticleTagsHandlerConstructor,
6 | CreateTagsForArticleInput,
7 | CreateTagsForArticleOutput,
8 | ValidateArticleIdInput,
9 | ValidateArticleIdOutput
10 | } from './types';
11 |
12 | export class CreateArticleTagsHandler {
13 | private repoArticle: RepoArticle;
14 |
15 | constructor({ repoArticle }: CreateArticleTagsHandlerConstructor) {
16 | this.repoArticle = repoArticle;
17 | }
18 |
19 | async execute({
20 | articleId,
21 | tagList
22 | }: CreateTagsForArticleInput): CreateTagsForArticleOutput {
23 | if (tagList.length < 1) {
24 | return;
25 | }
26 | await this.validateArticleId({ articleId });
27 | await this.repoArticle.createTagsForArticle({ articleId, tags: tagList });
28 | }
29 |
30 | private async validateArticleId({
31 | articleId
32 | }: ValidateArticleIdInput): ValidateArticleIdOutput {
33 | const article = await this.repoArticle.getArticleById({
34 | id: articleId
35 | });
36 | if (!article) {
37 | throw new ArticleNotFoundError({});
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/CreateArticleTagsHandler/index.ts:
--------------------------------------------------------------------------------
1 | export { CreateArticleTagsHandler } from './CreateArticleTagsHandler';
2 | export { CreateTagsForArticleInput, CreateTagsForArticleOutput } from './types';
3 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/CreateArticleTagsHandler/types.ts:
--------------------------------------------------------------------------------
1 | import { RepoArticle } from '@conduit/core/repository';
2 |
3 | export interface CreateArticleTagsHandlerConstructor {
4 | repoArticle: RepoArticle;
5 | }
6 |
7 | /**
8 | *
9 | * function: createTagsForArticle
10 | *
11 | */
12 | export interface CreateTagsForArticleInput {
13 | articleId: string;
14 | tagList: string[];
15 | }
16 |
17 | export type CreateTagsForArticleOutput = Promise;
18 |
19 | /**
20 | *
21 | * function: validateArticleId
22 | *
23 | */
24 | export interface ValidateArticleIdInput {
25 | articleId: string;
26 | }
27 |
28 | export type ValidateArticleIdOutput = Promise;
29 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/FavoriteArticleHandler/FavoriteArticleHandler.ts:
--------------------------------------------------------------------------------
1 | import { RepoArticle } from '@conduit/core/repository';
2 |
3 | import {
4 | ArticleAlreadyFavoritedError,
5 | ArticleNotFoundError,
6 | ArticleNotYetFavoritedError
7 | } from '../../errors';
8 | import {
9 | FavoriteArticleHandlerConstructor,
10 | FavoriteArticleInput,
11 | FavoriteArticleOutput,
12 | UnfavoriteArticleInput,
13 | UnfavoriteArticleOutput
14 | } from './types';
15 |
16 | export class FavoriteArticleHandler {
17 | private repoArticle: RepoArticle;
18 |
19 | constructor({ repoArticle }: FavoriteArticleHandlerConstructor) {
20 | this.repoArticle = repoArticle;
21 | }
22 |
23 | async favorite({
24 | userId,
25 | articleId
26 | }: FavoriteArticleInput): FavoriteArticleOutput {
27 | const article = await this.repoArticle.getArticleById({
28 | id: articleId,
29 | requestingUserId: userId
30 | });
31 | if (!article) {
32 | throw new ArticleNotFoundError({});
33 | }
34 | if (article.favorited) {
35 | throw new ArticleAlreadyFavoritedError({ userId, articleId });
36 | }
37 | await this.repoArticle.favoriteArticle({ userId, articleId });
38 | }
39 |
40 | async unfavorite({
41 | userId,
42 | articleId
43 | }: UnfavoriteArticleInput): UnfavoriteArticleOutput {
44 | const article = await this.repoArticle.getArticleById({
45 | id: articleId,
46 | requestingUserId: userId
47 | });
48 | if (!article) {
49 | throw new ArticleNotFoundError({});
50 | }
51 | if (!article.favorited) {
52 | throw new ArticleNotYetFavoritedError({ userId, articleId });
53 | }
54 | await this.repoArticle.unfavoriteArticle({ articleId, userId });
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/FavoriteArticleHandler/index.ts:
--------------------------------------------------------------------------------
1 | export { FavoriteArticleHandler } from './FavoriteArticleHandler';
2 | export {
3 | FavoriteArticleInput,
4 | FavoriteArticleOutput,
5 | UnfavoriteArticleInput,
6 | UnfavoriteArticleOutput
7 | } from './types';
8 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/FavoriteArticleHandler/types.ts:
--------------------------------------------------------------------------------
1 | import { RepoArticle } from '@conduit/core/repository';
2 |
3 | export interface FavoriteArticleHandlerConstructor {
4 | repoArticle: RepoArticle;
5 | }
6 |
7 | /**
8 | *
9 | * function: favoriteArticle
10 | *
11 | */
12 | export interface FavoriteArticleInput {
13 | userId: string;
14 | articleId: string;
15 | }
16 |
17 | export type FavoriteArticleOutput = Promise;
18 |
19 | /**
20 | *
21 | * function: unfavoriteArticle
22 | *
23 | */
24 | export interface UnfavoriteArticleInput {
25 | userId: string;
26 | articleId: string;
27 | }
28 |
29 | export type UnfavoriteArticleOutput = Promise;
30 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/GetArticleCommentsHandler/GetArticleCommentsHandler.ts:
--------------------------------------------------------------------------------
1 | import { RepoArticle } from '@conduit/core/repository';
2 |
3 | import {
4 | GetArticleCommentsByArticleIdInput,
5 | GetArticleCommentsByArticleIdOutput,
6 | GetArticleCommentsHandlerConstructor
7 | } from './types';
8 |
9 | export class GetArticleCommentsHandler {
10 | private repoArticle: RepoArticle;
11 |
12 | constructor({ repoArticle }: GetArticleCommentsHandlerConstructor) {
13 | this.repoArticle = repoArticle;
14 | }
15 |
16 | async execute({
17 | articleId,
18 | limit,
19 | offset,
20 | requestingUserId
21 | }: GetArticleCommentsByArticleIdInput): GetArticleCommentsByArticleIdOutput {
22 | const comments = await this.repoArticle.getArticleCommentsByArticleId({
23 | articleId,
24 | limit,
25 | offset,
26 | requestingUserId
27 | });
28 | const count = await this.repoArticle.countArticleCommentsByArticleId({
29 | articleId
30 | });
31 | return {
32 | comments,
33 | count
34 | };
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/GetArticleCommentsHandler/index.ts:
--------------------------------------------------------------------------------
1 | export { GetArticleCommentsHandler } from './GetArticleCommentsHandler';
2 | export {
3 | GetArticleCommentsByArticleIdInput,
4 | GetArticleCommentsByArticleIdOutput
5 | } from './types';
6 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/GetArticleCommentsHandler/types.ts:
--------------------------------------------------------------------------------
1 | import { DbDtoArticleCommentWithProfile } from '@conduit/core/database';
2 | import { RepoArticle } from '@conduit/core/repository';
3 |
4 | export interface GetArticleCommentsHandlerConstructor {
5 | repoArticle: RepoArticle;
6 | }
7 |
8 | export interface GetArticleCommentsByArticleIdInput {
9 | articleId: string;
10 | limit: number;
11 | offset: number;
12 | requestingUserId?: string;
13 | }
14 |
15 | export type GetArticleCommentsByArticleIdOutput = Promise<{
16 | comments: DbDtoArticleCommentWithProfile[];
17 | count: number;
18 | }>;
19 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/UpdateArticleHandler/UpdateArticleHandler.ts:
--------------------------------------------------------------------------------
1 | import slugify from 'slugify';
2 |
3 | import { RepoArticle } from '@conduit/core/repository';
4 |
5 | import { ArticleNotFoundError, ArticleTitleAlreadyTakenError } from '../../errors';
6 | import {
7 | UpdateArticleByIdInput,
8 | UpdateArticleByIdOutput,
9 | UpdateArticleHandlerConstructor,
10 | ValidateIfArticleExistInput,
11 | ValidateIfArticleExistOutput
12 | } from './types';
13 |
14 | export class UpdateArticleHandler {
15 | private repoArticle: RepoArticle;
16 |
17 | constructor({ repoArticle }: UpdateArticleHandlerConstructor) {
18 | this.repoArticle = repoArticle;
19 | }
20 |
21 | async execute({
22 | id,
23 | title,
24 | description,
25 | body
26 | }: UpdateArticleByIdInput): UpdateArticleByIdOutput {
27 | const article = await this.repoArticle.getArticleById({ id });
28 | if (!article) {
29 | throw new ArticleNotFoundError({});
30 | }
31 |
32 | let slug: string | undefined;
33 | if (title && article.title !== title) {
34 | await this.validateIfArticleExist({ title });
35 | slug = slugify(title);
36 | }
37 |
38 | await this.repoArticle.updateArticleById({
39 | id,
40 | title,
41 | slug,
42 | description,
43 | body
44 | });
45 | }
46 |
47 | private async validateIfArticleExist({
48 | title
49 | }: ValidateIfArticleExistInput): ValidateIfArticleExistOutput {
50 | const slug = slugify(title);
51 | const article = await this.repoArticle.getArticleBySlug({ slug });
52 | if (article) {
53 | throw new ArticleTitleAlreadyTakenError({ title });
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/UpdateArticleHandler/index.ts:
--------------------------------------------------------------------------------
1 | export { UpdateArticleHandler } from './UpdateArticleHandler';
2 | export { UpdateArticleByIdInput, UpdateArticleByIdOutput } from './types';
3 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/UpdateArticleHandler/types.ts:
--------------------------------------------------------------------------------
1 | import { RepoArticle } from '@conduit/core/repository';
2 |
3 | export interface UpdateArticleHandlerConstructor {
4 | repoArticle: RepoArticle;
5 | }
6 |
7 | export interface UpdateArticleByIdInput {
8 | id: string;
9 | title?: string;
10 | description?: string;
11 | body?: string;
12 | }
13 |
14 | export type UpdateArticleByIdOutput = Promise;
15 |
16 | /**
17 | *
18 | * function: validateIfArticleExist
19 | *
20 | */
21 | export interface ValidateIfArticleExistInput {
22 | title: string;
23 | }
24 |
25 | export type ValidateIfArticleExistOutput = Promise;
26 |
--------------------------------------------------------------------------------
/packages/core/service/article/implementations/index.ts:
--------------------------------------------------------------------------------
1 | export * from './CreateArticleCommentHandler';
2 | export * from './CreateArticleHandler';
3 | export * from './CreateArticleTagsHandler';
4 | export * from './GetArticleCommentsHandler';
5 | export * from './FavoriteArticleHandler';
6 | export * from './UpdateArticleHandler';
7 |
--------------------------------------------------------------------------------
/packages/core/service/article/index.ts:
--------------------------------------------------------------------------------
1 | export { ArticleService } from './ArticleService';
2 | export * from './errors';
3 |
--------------------------------------------------------------------------------
/packages/core/service/article/types.ts:
--------------------------------------------------------------------------------
1 | import { RepoArticle } from '@conduit/core/repository';
2 |
3 | export interface ArticleServiceConstructor {
4 | repoArticle: RepoArticle;
5 | }
6 |
7 | export {
8 | GetArticleByIdInput,
9 | GetArticleByIdOutput,
10 | GetArticleBySlugInput,
11 | GetArticleBySlugOutput,
12 | DeleteArticleByIdInput,
13 | DeleteArticleByIdOutput,
14 | GetTagsByArticleIdsInput,
15 | GetTagsByArticleIdsOutput,
16 | GetAvailableTagsOutput,
17 | GetArticleCommentsByArticleIdInput,
18 | GetArticleCommentsByArticleIdOutput,
19 | CountArticleCommentsByArticleIdInput,
20 | CountArticleCommentsByArticleIdOutput,
21 | GetArticleCommentByIdInput,
22 | GetArticleCommentByIdOutput,
23 | CountArticlesInput,
24 | CountArticlesOutput,
25 | GetArticlesInput,
26 | GetArticlesOutput,
27 | GetTagsByArticleIdInput,
28 | GetTagsByArticleIdOutput,
29 | DeleteArticleCommentByIdInput,
30 | DeleteArticleCommentByIdOutput
31 | } from '@conduit/core/repository/RepoArticle/types';
32 |
--------------------------------------------------------------------------------
/packages/core/service/auth/AuthService.ts:
--------------------------------------------------------------------------------
1 | import {
2 | AccessTokenHandler,
3 | ComparePasswordInput,
4 | ComparePasswordOutput,
5 | EncryptPasswordInput,
6 | EncryptPasswordOutput,
7 | GenerateAccessTokenInput,
8 | GenerateAccessTokenOutput,
9 | PasswordHandler,
10 | VerifyAccessTokenInput,
11 | VerifyAccessTokenOutput
12 | } from './implementations';
13 |
14 | export class AuthService {
15 | private passwordHandler: PasswordHandler;
16 | private accessTokenHandler: AccessTokenHandler;
17 |
18 | constructor() {
19 | this.passwordHandler = new PasswordHandler();
20 | this.accessTokenHandler = new AccessTokenHandler();
21 | }
22 |
23 | generateAccessToken(input: GenerateAccessTokenInput): GenerateAccessTokenOutput {
24 | return this.accessTokenHandler.generateAccessToken(input);
25 | }
26 |
27 | verifyAccessToken(input: VerifyAccessTokenInput): VerifyAccessTokenOutput {
28 | return this.accessTokenHandler.verifyAccessToken(input);
29 | }
30 |
31 | async encryptPassword(input: EncryptPasswordInput): EncryptPasswordOutput {
32 | return this.passwordHandler.encryptPassword(input);
33 | }
34 |
35 | async comparePassword(input: ComparePasswordInput): ComparePasswordOutput {
36 | await this.passwordHandler.comparePassword(input);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/packages/core/service/auth/constants/AuthErrorCodes.ts:
--------------------------------------------------------------------------------
1 | export enum AuthErrorCodes {
2 | Generic = 'AUTH_GENERIC',
3 | PasswordNotMatch = 'AUTH_PASSWORD_NOT_MATCH',
4 | PasswordRequirementsNotMetError = 'AUTH_PASSWORD_REQUIREMENTS_NOT_MET',
5 | InvalidToken = 'AUTH_INVALID_TOKEN'
6 | }
7 |
--------------------------------------------------------------------------------
/packages/core/service/auth/constants/index.ts:
--------------------------------------------------------------------------------
1 | export * from './AuthErrorCodes';
2 |
--------------------------------------------------------------------------------
/packages/core/service/auth/errors/AuthError.ts:
--------------------------------------------------------------------------------
1 | import { AppError } from '../../../utils';
2 | import { AuthErrorCodes } from '../constants';
3 |
4 | export class AuthError extends AppError {
5 | constructor({
6 | code = AuthErrorCodes.Generic,
7 | message,
8 | details,
9 | cause
10 | }: AuthErrorConstructor) {
11 | super({
12 | code,
13 | message,
14 | details,
15 | cause
16 | });
17 | }
18 | }
19 |
20 | export interface AuthErrorConstructor {
21 | message?: string;
22 | code?: AuthErrorCodes;
23 | details?: any[];
24 | cause?: Error;
25 | }
26 |
--------------------------------------------------------------------------------
/packages/core/service/auth/errors/InvalidTokenError.ts:
--------------------------------------------------------------------------------
1 | import { AuthErrorCodes } from '../constants';
2 | import { AuthError } from './AuthError';
3 |
4 | export class InvalidTokenError extends AuthError {
5 | constructor({ message, cause }: InvalidTokenErrorConstructor) {
6 | super({
7 | code: AuthErrorCodes.InvalidToken,
8 | message:
9 | message ??
10 | 'Sorry, your login is invalid. Please try again or contact support for help.',
11 | cause
12 | });
13 | }
14 | }
15 |
16 | interface InvalidTokenErrorConstructor {
17 | message?: string;
18 | cause?: Error;
19 | }
20 |
--------------------------------------------------------------------------------
/packages/core/service/auth/errors/PasswordNotMatchError.ts:
--------------------------------------------------------------------------------
1 | import { AuthErrorCodes } from '../constants';
2 | import { AuthError } from './AuthError';
3 |
4 | export class PasswordNotMatchError extends AuthError {
5 | constructor() {
6 | super({
7 | code: AuthErrorCodes.PasswordNotMatch,
8 | message: 'Passwords do not match. Please try again.'
9 | });
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/packages/core/service/auth/errors/PasswordRequirementsNotMetError.ts:
--------------------------------------------------------------------------------
1 | import { AuthErrorCodes } from '../constants';
2 | import { AuthError } from './AuthError';
3 |
4 | export class PasswordRequirementsNotMetError extends AuthError {
5 | constructor({ details }: PasswordRequirementsNotMetErrorConstructor) {
6 | super({
7 | code: AuthErrorCodes.PasswordRequirementsNotMetError,
8 | message:
9 | 'Password requirements not met. Your password must be at least 6 characters long and contain at least one letter and one digit.',
10 | details
11 | });
12 | }
13 | }
14 |
15 | interface PasswordRequirementsNotMetErrorConstructor {
16 | details?: string[];
17 | }
18 |
--------------------------------------------------------------------------------
/packages/core/service/auth/errors/index.ts:
--------------------------------------------------------------------------------
1 | export * from './AuthError';
2 | export * from './PasswordNotMatchError';
3 | export * from './PasswordRequirementsNotMetError';
4 | export * from './InvalidTokenError';
5 |
--------------------------------------------------------------------------------
/packages/core/service/auth/implementations/AccessTokenHandler/index.ts:
--------------------------------------------------------------------------------
1 | export { AccessTokenHandler } from './AccessTokenHandler';
2 | export {
3 | VerifyAccessTokenInput,
4 | VerifyAccessTokenOutput,
5 | VerifyRefreshTokenInput,
6 | VerifyRefreshTokenOutput,
7 | GenerateAccessTokenInput,
8 | GenerateAccessTokenOutput
9 | } from './types';
10 |
--------------------------------------------------------------------------------
/packages/core/service/auth/implementations/AccessTokenHandler/types.ts:
--------------------------------------------------------------------------------
1 | export interface JwtPayload {
2 | userId: string;
3 | aud?: string;
4 | iss: string;
5 | sub: string;
6 | jti: string;
7 | version?: string;
8 | }
9 |
10 | export interface RefreshTokenPayload {
11 | userId: string;
12 | loginId: string;
13 | version?: string;
14 | }
15 |
16 | /**
17 | *
18 | * function: generateAccessToken
19 | *
20 | */
21 | export interface GenerateAccessTokenInput {
22 | userId: string;
23 | loginId: string;
24 | }
25 |
26 | export type GenerateAccessTokenOutput = string;
27 |
28 | /**
29 | *
30 | * function: generateRefreshToken
31 | *
32 | */
33 | export interface GenerateRefreshTokenInput {
34 | userId: string;
35 | loginId: string;
36 | }
37 |
38 | export type GenerateRefreshTokenOutput = string;
39 |
40 | /**
41 | *
42 | * function: verifyAccessToken
43 | *
44 | */
45 | export interface VerifyAccessTokenInput {
46 | accessToken: string;
47 | }
48 |
49 | export type VerifyAccessTokenOutput = string;
50 |
51 | /**
52 | *
53 | * function: verifyRefreshToken
54 | *
55 | */
56 | export interface VerifyRefreshTokenInput {
57 | refreshToken: string;
58 | }
59 |
60 | export type VerifyRefreshTokenOutput = RefreshTokenPayload;
61 |
--------------------------------------------------------------------------------
/packages/core/service/auth/implementations/PasswordHandler/PasswordHandler.ts:
--------------------------------------------------------------------------------
1 | import { compare, hash } from 'bcryptjs';
2 |
3 | import {
4 | PasswordNotMatchError,
5 | PasswordRequirementsNotMetError
6 | } from '../../errors';
7 | import {
8 | ComparePasswordInput,
9 | ComparePasswordOutput,
10 | EncryptPasswordInput,
11 | EncryptPasswordOutput,
12 | ValidatePasswordInput,
13 | ValidatePasswordOutput
14 | } from './types';
15 |
16 | export class PasswordHandler {
17 | async encryptPassword({ password }: EncryptPasswordInput): EncryptPasswordOutput {
18 | this.validatePassword({ password });
19 | const hashed = await hash(password, 10);
20 | return hashed;
21 | }
22 |
23 | async comparePassword({
24 | password,
25 | encryptedPassword
26 | }: ComparePasswordInput): ComparePasswordOutput {
27 | const matched = await compare(password, encryptedPassword);
28 | if (!matched) {
29 | throw new PasswordNotMatchError();
30 | }
31 | }
32 |
33 | private validatePassword({
34 | password
35 | }: ValidatePasswordInput): ValidatePasswordOutput {
36 | const details: string[] = [];
37 | if (password.length < 6) {
38 | details.push('The password must be at least 6 characters long');
39 | }
40 | // Regular expression to match passwords with at least one letter and one digit
41 | const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d).+$/;
42 | if (!passwordRegex.test(password)) {
43 | details.push('The password must contain at least one letter and one digit');
44 | }
45 | if (details.length > 0) {
46 | throw new PasswordRequirementsNotMetError({ details });
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/packages/core/service/auth/implementations/PasswordHandler/index.ts:
--------------------------------------------------------------------------------
1 | export { PasswordHandler } from './PasswordHandler';
2 | export {
3 | ComparePasswordInput,
4 | ComparePasswordOutput,
5 | EncryptPasswordInput,
6 | EncryptPasswordOutput
7 | } from './types';
8 |
--------------------------------------------------------------------------------
/packages/core/service/auth/implementations/PasswordHandler/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * function: encryptPassword
4 | *
5 | */
6 | export interface EncryptPasswordInput {
7 | password: string;
8 | }
9 |
10 | export type EncryptPasswordOutput = Promise;
11 |
12 | /**
13 | *
14 | * function: comparePassword
15 | *
16 | */
17 | export interface ComparePasswordInput {
18 | password: string;
19 | encryptedPassword: string;
20 | }
21 |
22 | export type ComparePasswordOutput = Promise;
23 |
24 | /**
25 | *
26 | * function: validatePassword
27 | *
28 | */
29 | export interface ValidatePasswordInput {
30 | password: string;
31 | }
32 |
33 | export type ValidatePasswordOutput = void;
34 |
--------------------------------------------------------------------------------
/packages/core/service/auth/implementations/index.ts:
--------------------------------------------------------------------------------
1 | export * from './AccessTokenHandler';
2 | export * from './PasswordHandler';
3 |
--------------------------------------------------------------------------------
/packages/core/service/auth/index.ts:
--------------------------------------------------------------------------------
1 | export { AuthService } from './AuthService';
2 | export * from './errors';
3 |
--------------------------------------------------------------------------------
/packages/core/service/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ServiceFactory';
2 | export * from './auth';
3 | export * from './user';
4 | export * from './article';
5 |
--------------------------------------------------------------------------------
/packages/core/service/user/constants/UserErrorCode.ts:
--------------------------------------------------------------------------------
1 | export enum UserErrorCodes {
2 | Generic = 'USER_GENERIC',
3 | UserExisted = 'USER_EXISTED',
4 | UserNotFound = 'USER_NOT_FOUND',
5 | InvalidFollow = 'USER_INVALID_FOLLOW'
6 | }
7 |
--------------------------------------------------------------------------------
/packages/core/service/user/constants/index.ts:
--------------------------------------------------------------------------------
1 | export * from './UserErrorCode';
2 |
--------------------------------------------------------------------------------
/packages/core/service/user/errors/InvalidFollowError.ts:
--------------------------------------------------------------------------------
1 | import { UserErrorCodes } from '../constants';
2 | import { UserError } from './UserError';
3 |
4 | export class InvalidFollowError extends UserError {
5 | constructor({ message }: InvalidFollowErrorConstructor) {
6 | super({
7 | code: UserErrorCodes.InvalidFollow,
8 | message
9 | });
10 | }
11 | }
12 |
13 | interface InvalidFollowErrorConstructor {
14 | message: string;
15 | }
16 |
--------------------------------------------------------------------------------
/packages/core/service/user/errors/UserError.ts:
--------------------------------------------------------------------------------
1 | import { AppError } from '../../../utils';
2 | import { UserErrorCodes } from '../constants';
3 |
4 | export class UserError extends AppError {
5 | constructor({
6 | code = UserErrorCodes.Generic,
7 | message,
8 | details,
9 | cause
10 | }: UserErrorConstructor) {
11 | super({
12 | code,
13 | message,
14 | details,
15 | cause
16 | });
17 | }
18 | }
19 |
20 | export interface UserErrorConstructor {
21 | message?: string;
22 | code?: UserErrorCodes;
23 | details?: any[];
24 | cause?: Error;
25 | }
26 |
--------------------------------------------------------------------------------
/packages/core/service/user/errors/UserExistError.ts:
--------------------------------------------------------------------------------
1 | import { UserErrorCodes } from '../constants';
2 | import { UserError } from './UserError';
3 |
4 | export class UserExistError extends UserError {
5 | public type: 'email' | 'username';
6 |
7 | constructor({ type }: UserExistErrorConstructor) {
8 | super({
9 | code: UserErrorCodes.UserExisted,
10 | message:
11 | type === 'email'
12 | ? 'The provided email is already registered. Please use a different email.'
13 | : 'The provided username is already taken. Please use a different username.'
14 | });
15 | this.type = type;
16 | }
17 | }
18 |
19 | interface UserExistErrorConstructor {
20 | type: 'email' | 'username';
21 | }
22 |
--------------------------------------------------------------------------------
/packages/core/service/user/errors/UserNotFoundError.ts:
--------------------------------------------------------------------------------
1 | import { UserErrorCodes } from '../constants';
2 | import { UserError } from './UserError';
3 |
4 | export class UserNotFoundError extends UserError {
5 | constructor({ message }: UserNotFoundErrorConstructor) {
6 | super({
7 | code: UserErrorCodes.UserNotFound,
8 | message:
9 | message ??
10 | 'Sorry, we could not find an user with that information. Please try again with a different email or username'
11 | });
12 | }
13 | }
14 |
15 | export interface UserNotFoundErrorConstructor {
16 | message?: string;
17 | }
18 |
--------------------------------------------------------------------------------
/packages/core/service/user/errors/index.ts:
--------------------------------------------------------------------------------
1 | export * from './UserError';
2 | export * from './InvalidFollowError';
3 | export * from './UserExistError';
4 | export * from './UserNotFoundError';
5 |
--------------------------------------------------------------------------------
/packages/core/service/user/implementations/CreateUserHandler/CreateUserHandler.ts:
--------------------------------------------------------------------------------
1 | import { RepoUser } from '@conduit/core/repository';
2 | import { AuthService } from '@conduit/core/service/auth';
3 |
4 | import { UserExistError } from '../../errors';
5 | import {
6 | CreateUserHandlerConstructor,
7 | CreateUserInput,
8 | CreateUserOutput,
9 | ValidateInputInput,
10 | ValidateInputOutput
11 | } from './types';
12 |
13 | export class CreateUserHandler {
14 | private readonly authService: AuthService;
15 | private readonly repoUser: RepoUser;
16 |
17 | constructor({ authService, repoUser }: CreateUserHandlerConstructor) {
18 | this.authService = authService;
19 | this.repoUser = repoUser;
20 | }
21 |
22 | async createUser({
23 | email,
24 | username,
25 | password,
26 | image,
27 | bio
28 | }: CreateUserInput): CreateUserOutput {
29 | await this.validateInput({ email, username });
30 | // Encrypt password
31 | const encryptedPassword = await this.authService.encryptPassword({ password });
32 | // Create user in database and return userId
33 | const userId = await this.repoUser.createUser({
34 | email,
35 | username,
36 | bio,
37 | image,
38 | hash: encryptedPassword
39 | });
40 | return userId;
41 | }
42 |
43 | private async validateInput({
44 | email,
45 | username
46 | }: ValidateInputInput): ValidateInputOutput {
47 | const { exists, emailExists } = await this.repoUser.getIsUserExists({
48 | email,
49 | username
50 | });
51 | if (exists) {
52 | throw new UserExistError({
53 | type: emailExists ? 'email' : 'username'
54 | });
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/packages/core/service/user/implementations/CreateUserHandler/index.ts:
--------------------------------------------------------------------------------
1 | export { CreateUserHandler } from './CreateUserHandler';
2 | export { CreateUserInput, CreateUserOutput } from './types';
3 |
--------------------------------------------------------------------------------
/packages/core/service/user/implementations/CreateUserHandler/types.ts:
--------------------------------------------------------------------------------
1 | import { RepoUser } from '@conduit/core/repository';
2 | import { AuthService } from '@conduit/core/service';
3 |
4 | export interface CreateUserHandlerConstructor {
5 | repoUser: RepoUser;
6 | authService: AuthService;
7 | }
8 |
9 | export interface CreateUserInput {
10 | email: string;
11 | username: string;
12 | password: string;
13 | image?: string;
14 | bio?: string;
15 | }
16 |
17 | export type CreateUserOutput = Promise;
18 |
19 | /**
20 | *
21 | * function: validateInput
22 | *
23 | */
24 | export interface ValidateInputInput {
25 | email: string;
26 | username: string;
27 | }
28 |
29 | export type ValidateInputOutput = Promise;
30 |
--------------------------------------------------------------------------------
/packages/core/service/user/implementations/FollowHandler/index.ts:
--------------------------------------------------------------------------------
1 | export { FollowHandler } from './FollowHandler';
2 | export {
3 | FollowUserInput,
4 | FollowUserOutput,
5 | UnfollowUserInput,
6 | UnfollowUserOutput
7 | } from './types';
8 |
--------------------------------------------------------------------------------
/packages/core/service/user/implementations/FollowHandler/types.ts:
--------------------------------------------------------------------------------
1 | import { RepoUser } from '@conduit/core/repository';
2 |
3 | export interface FollowHandlerConstructor {
4 | repoUser: RepoUser;
5 | }
6 |
7 | /**
8 | *
9 | * function: followUser
10 | *
11 | */
12 | export interface FollowUserInput {
13 | followerId: string;
14 | followingUsername: string;
15 | }
16 |
17 | export type FollowUserOutput = Promise;
18 |
19 | /**
20 | *
21 | * function: unfollowUser
22 | *
23 | */
24 | export interface UnfollowUserInput {
25 | followerId: string;
26 | followingUsername: string;
27 | }
28 |
29 | export type UnfollowUserOutput = Promise;
30 |
--------------------------------------------------------------------------------
/packages/core/service/user/implementations/UpdateUserHandler/index.ts:
--------------------------------------------------------------------------------
1 | export { UpdateUserHandler } from './UpdateUserHandler';
2 | export { UpdateUserInput, UpdateUserOutput } from './types';
3 |
--------------------------------------------------------------------------------
/packages/core/service/user/implementations/UpdateUserHandler/types.ts:
--------------------------------------------------------------------------------
1 | import { RepoUser } from '@conduit/core/repository';
2 | import { AuthService } from '@conduit/core/service';
3 |
4 | export interface UpdateUserHandlerConstructor {
5 | authService: AuthService;
6 | repoUser: RepoUser;
7 | }
8 |
9 | export interface UpdateUserInput {
10 | id: string;
11 | email?: string;
12 | username?: string;
13 | password?: string;
14 | image?: string;
15 | bio?: string;
16 | }
17 |
18 | export type UpdateUserOutput = Promise;
19 |
20 | /**
21 | *
22 | * function: validateUserExistInput
23 | *
24 | */
25 | export interface ValidateUserExistInput {
26 | userId: string;
27 | email: string;
28 | username: string;
29 | }
30 |
31 | export type ValidateUserExistOutput = Promise;
32 |
--------------------------------------------------------------------------------
/packages/core/service/user/implementations/index.ts:
--------------------------------------------------------------------------------
1 | export * from './CreateUserHandler';
2 | export * from './UpdateUserHandler';
3 | export * from './FollowHandler';
4 |
--------------------------------------------------------------------------------
/packages/core/service/user/index.ts:
--------------------------------------------------------------------------------
1 | export { UserService } from './UserService';
2 | export * from './errors';
3 |
--------------------------------------------------------------------------------
/packages/core/service/user/types.ts:
--------------------------------------------------------------------------------
1 | import { RepoUser } from '@conduit/core/repository';
2 |
3 | import { AuthService } from '../auth';
4 |
5 | export interface UserServiceConstructor {
6 | repoUser: RepoUser;
7 | authService: AuthService;
8 | }
9 |
--------------------------------------------------------------------------------
/packages/core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "extends": "@conduit/config/tsconfig.base.json",
4 | "compilerOptions": {
5 | "sourceMap": false,
6 | "outDir": "./",
7 | "paths": {
8 | "@conduit/core/*": ["./*"]
9 | }
10 | },
11 | "include": ["./**/*.ts"],
12 | "paths": {
13 | "@conduit/core/*": ["./*"]
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/core/types/RecordStatus.ts:
--------------------------------------------------------------------------------
1 | export enum RecordStatus {
2 | Active = 'active',
3 | Deleted = 'deleted'
4 | }
5 |
--------------------------------------------------------------------------------
/packages/core/types/UserStatus.ts:
--------------------------------------------------------------------------------
1 | export enum UserStatus {
2 | Active = 'active',
3 | Banned = 'banned'
4 | }
5 |
--------------------------------------------------------------------------------
/packages/core/types/global.d.ts:
--------------------------------------------------------------------------------
1 | import { DbDtoUser } from '@conduit/core';
2 |
3 | declare global {
4 | namespace Express {
5 | interface Request {
6 | user?: DbDtoUser;
7 | }
8 | }
9 | }
10 |
11 | // If this file has no import/export statements (i.e. is a script)
12 | // convert it into a module by adding an empty export statement.
13 | export {};
14 |
--------------------------------------------------------------------------------
/packages/core/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from './UserStatus';
2 | export * from './RecordStatus';
3 |
--------------------------------------------------------------------------------
/packages/core/utils/error/AppError/AppError.ts:
--------------------------------------------------------------------------------
1 | import stringify from 'fast-json-stable-stringify';
2 | import { serializeError } from 'serialize-error';
3 |
4 | import { AppErrorProps } from './types';
5 |
6 | export class AppError extends Error {
7 | public readonly code?: string;
8 | public readonly details?: any[];
9 | public readonly cause?: Error;
10 |
11 | constructor(props: AppErrorProps) {
12 | super(props.message);
13 | this.code = props.code;
14 | this.details = props.details;
15 | this.cause = props.cause;
16 | }
17 |
18 | static assert(
19 | condition: boolean,
20 | code?: string,
21 | message?: string,
22 | details?: any[]
23 | ): asserts condition {
24 | if (!condition) {
25 | throw new AppError({
26 | message,
27 | code,
28 | details
29 | });
30 | }
31 | }
32 |
33 | public toJSON() {
34 | return stringify(serializeError(this));
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/packages/core/utils/error/AppError/index.ts:
--------------------------------------------------------------------------------
1 | export { AppError } from './AppError';
2 |
--------------------------------------------------------------------------------
/packages/core/utils/error/AppError/types.ts:
--------------------------------------------------------------------------------
1 | export interface AppErrorProps {
2 | message?: string;
3 | code?: string;
4 | details?: any[];
5 | cause?: Error;
6 | }
7 |
--------------------------------------------------------------------------------
/packages/core/utils/error/index.ts:
--------------------------------------------------------------------------------
1 | export { AppError } from './AppError';
2 |
--------------------------------------------------------------------------------
/packages/core/utils/getObjectId.ts:
--------------------------------------------------------------------------------
1 | import { v4 } from 'uuid';
2 |
3 | export const getObjectId = () => v4();
4 |
--------------------------------------------------------------------------------
/packages/core/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './error';
2 | export * from './getObjectId';
3 |
--------------------------------------------------------------------------------
/packages/middleware/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const defaultConfig = require('@conduit/config/.eslintrc.json');
2 |
3 | module.exports = {
4 | ...defaultConfig,
5 | parserOptions: {
6 | project: './tsconfig.json'
7 | }
8 | };
9 |
--------------------------------------------------------------------------------
/packages/middleware/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = require('@conduit/config/.prettierrc.json');
2 |
--------------------------------------------------------------------------------
/packages/middleware/configureGlobalExceptionHandler/index.ts:
--------------------------------------------------------------------------------
1 | export * from './configureGlobalExceptionHandler';
2 |
--------------------------------------------------------------------------------
/packages/middleware/configureGlobalExceptionHandler/types.ts:
--------------------------------------------------------------------------------
1 | import { Express } from 'express';
2 |
3 | export interface ConfigureGlobalExceptionHandler {
4 | (input: { app: Express; skipOnLocal?: boolean }): void;
5 | }
6 |
--------------------------------------------------------------------------------
/packages/middleware/configureLambda/configureLambda.ts:
--------------------------------------------------------------------------------
1 | import configureServerlessExpress from '@vendia/serverless-express';
2 |
3 | import { ConfigureLambda } from './types';
4 |
5 | export const configureLambda: ConfigureLambda = ({ app }) =>
6 | configureServerlessExpress({
7 | app,
8 | logSettings: {
9 | level: process.env.LOG_LEVEL || 'info'
10 | }
11 | });
12 |
--------------------------------------------------------------------------------
/packages/middleware/configureLambda/index.ts:
--------------------------------------------------------------------------------
1 | export * from './configureLambda';
2 |
--------------------------------------------------------------------------------
/packages/middleware/configureLambda/types.ts:
--------------------------------------------------------------------------------
1 | import configureServerlessExpress from '@vendia/serverless-express';
2 | import { Express } from 'express';
3 |
4 | export interface ConfigureLambda {
5 | (input: {
6 | app: Express;
7 | }): ReturnType>;
8 | }
9 |
--------------------------------------------------------------------------------
/packages/middleware/configureMiddlewares/index.ts:
--------------------------------------------------------------------------------
1 | export * from './configureMiddlewares';
2 |
--------------------------------------------------------------------------------
/packages/middleware/configureMiddlewares/types.ts:
--------------------------------------------------------------------------------
1 | import { Express } from 'express';
2 |
3 | export interface ConfigureMiddlewares {
4 | (input: { app: Express; skipOnLocal?: boolean }): void;
5 | }
6 |
--------------------------------------------------------------------------------
/packages/middleware/index.ts:
--------------------------------------------------------------------------------
1 | export * from './auth';
2 | export * from './configureLambda';
3 | export * from './configureMiddlewares';
4 | export * from './configureGlobalExceptionHandler';
5 |
--------------------------------------------------------------------------------
/packages/middleware/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('jest').Config} */
2 |
3 | module.exports = require('@conduit/config/jest.config');
4 |
--------------------------------------------------------------------------------
/packages/middleware/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "extends": "@conduit/config/tsconfig.base.json",
4 | "compilerOptions": {
5 | "sourceMap": false
6 | },
7 | "include": ["./**/*.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/middleware/types/global.d.ts:
--------------------------------------------------------------------------------
1 | import '@conduit/core/types/global';
2 |
--------------------------------------------------------------------------------
/packages/types/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const defaultConfig = require('@conduit/config/.eslintrc.json');
2 |
3 | module.exports = {
4 | ...defaultConfig,
5 | parserOptions: {
6 | project: './tsconfig.json'
7 | }
8 | };
9 |
--------------------------------------------------------------------------------
/packages/types/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = require('@conduit/config/.prettierrc.json');
2 |
--------------------------------------------------------------------------------
/packages/types/NodeEnv.ts:
--------------------------------------------------------------------------------
1 | export enum NodeEnv {
2 | Production = 'prod',
3 | Development = 'develop',
4 | Test = 'test',
5 | CI = 'ci'
6 | }
7 |
--------------------------------------------------------------------------------
/packages/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from './NodeEnv';
2 |
--------------------------------------------------------------------------------
/packages/types/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@conduit/types",
3 | "version": "1.0.0",
4 | "private": true,
5 | "description": "The types module in this monorepository defines commonly used types shared across all packages and applications.",
6 | "keywords": [
7 | "conduit",
8 | "types"
9 | ],
10 | "repository": {
11 | "type": "git",
12 | "url": "https://github.com/kenyipp/realworld-nodejs-example-app/tree/master/packages/types"
13 | },
14 | "license": "MIT",
15 | "author": {
16 | "name": "Ken Yip",
17 | "email": "ken20206@gmail.com",
18 | "url": "https://kenyip.cc"
19 | },
20 | "scripts": {
21 | "build": "tsc",
22 | "check-types": "tsc --skipLibCheck --noEmit",
23 | "lint": "eslint .",
24 | "lint:fix": "eslint --fix .",
25 | "prettify": "prettier --write ."
26 | },
27 | "dependencies": {
28 | "@conduit/config": "*"
29 | },
30 | "devDependencies": {
31 | "@jest/types": "^29.6.3",
32 | "@trivago/prettier-plugin-sort-imports": "^4.3.0",
33 | "@typescript-eslint/eslint-plugin": "^5.30.7",
34 | "@typescript-eslint/parser": "^5.30.7",
35 | "eslint": "^8.19.0",
36 | "eslint-config-airbnb-base": "^15.0.0",
37 | "eslint-config-airbnb-typescript": "^17.0.0",
38 | "eslint-config-prettier": "^9.0.0",
39 | "eslint-plugin-import": "^2.26.0",
40 | "eslint-plugin-jest": "^26.5.3",
41 | "eslint-plugin-prettier": "^5.2.1",
42 | "prettier": "^3.3.3",
43 | "prettier-plugin-packagejson": "^2.5.3",
44 | "typescript": "^5.6.3"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/packages/types/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "extends": "@conduit/config/tsconfig.base.json",
4 | "compilerOptions": {
5 | "sourceMap": false,
6 | "outDir": "./"
7 | },
8 | "include": ["./**/*"]
9 | }
10 |
--------------------------------------------------------------------------------
/packages/utils/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const defaultConfig = require('@conduit/config/.eslintrc.json');
2 |
3 | module.exports = {
4 | ...defaultConfig,
5 | parserOptions: {
6 | project: './tsconfig.json'
7 | }
8 | };
9 |
--------------------------------------------------------------------------------
/packages/utils/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = require('@conduit/config/.prettierrc.json');
2 |
--------------------------------------------------------------------------------
/packages/utils/config/config.ts:
--------------------------------------------------------------------------------
1 | import nodeConfig from 'config';
2 |
3 | import { Config } from './types';
4 |
5 | export const config: Config = nodeConfig.util.toObject();
6 |
--------------------------------------------------------------------------------
/packages/utils/config/index.ts:
--------------------------------------------------------------------------------
1 | export * from './config';
2 |
--------------------------------------------------------------------------------
/packages/utils/config/types.ts:
--------------------------------------------------------------------------------
1 | import { NodeEnv } from '@conduit/types';
2 |
3 | export interface Config {
4 | nodeEnv: NodeEnv;
5 | mode: 'local' | 'lambda';
6 | domain: string;
7 | auth: {
8 | expiresIn: string;
9 | jwtSecret: string;
10 | };
11 | aws: {
12 | accountId: string;
13 | region: string;
14 | certificateArn?: string;
15 | };
16 | database: {
17 | conduit: {
18 | host: string;
19 | port: number;
20 | user: string;
21 | password: string;
22 | database: string;
23 | };
24 | };
25 | }
26 |
--------------------------------------------------------------------------------
/packages/utils/error/ApiError.ts:
--------------------------------------------------------------------------------
1 | export class ApiError extends Error {
2 | code: number;
3 | errorType: string;
4 | errorCode?: string;
5 | cause?: Error;
6 | payload?: any;
7 |
8 | constructor({
9 | message,
10 | code,
11 | errorType,
12 | errorCode,
13 | cause,
14 | payload
15 | }: ApiErrorConstructor) {
16 | super(message);
17 | // Ensure the name of this error is the same as the class name
18 | this.name = this.constructor.name;
19 | // This clips the constructor invocation from the stack trace.
20 | // It's not absolutely essential, but it does make the stack trace a little nicer.
21 | Error.captureStackTrace(this, this.constructor);
22 | // Error status code
23 | this.code = code;
24 | // Human readable error type
25 | this.errorType = errorType;
26 | // User defined error code for that Api Error
27 | this.errorCode = errorCode;
28 | this.cause = cause;
29 | this.payload = payload;
30 | }
31 | }
32 |
33 | interface ApiErrorConstructor {
34 | message: string;
35 | code: number;
36 | errorType: string;
37 | errorCode?: string;
38 | cause?: Error;
39 | payload?: any;
40 | }
41 |
--------------------------------------------------------------------------------
/packages/utils/error/ApiErrorBadRequest.ts:
--------------------------------------------------------------------------------
1 | import { ApiError } from './ApiError';
2 | import HttpError from './constants/http-error.json';
3 |
4 | export class ApiErrorBadRequest extends ApiError {
5 | static Config = HttpError[400];
6 |
7 | constructor({
8 | message,
9 | errorCode,
10 | cause,
11 | payload
12 | }: ApiErrorBadRequestConstructor) {
13 | super({
14 | code: 400,
15 | message: message || ApiErrorBadRequest.Config.message,
16 | errorCode,
17 | errorType: ApiErrorBadRequest.Config.type,
18 | cause,
19 | payload
20 | });
21 | }
22 |
23 | static assert({
24 | condition,
25 | message,
26 | errorCode,
27 | cause,
28 | payload
29 | }: AssertInput): void {
30 | if (!condition) {
31 | throw new this({
32 | message,
33 | errorCode,
34 | cause,
35 | payload
36 | });
37 | }
38 | }
39 | }
40 |
41 | interface ApiErrorBadRequestConstructor {
42 | message?: string;
43 | errorCode?: string;
44 | cause?: Error;
45 | payload?: any;
46 | }
47 |
48 | interface AssertInput extends ApiErrorBadRequestConstructor {
49 | condition: boolean;
50 | }
51 |
--------------------------------------------------------------------------------
/packages/utils/error/ApiErrorConflict.ts:
--------------------------------------------------------------------------------
1 | import { ApiError } from './ApiError';
2 | import HttpError from './constants/http-error.json';
3 |
4 | export class ApiErrorConflict extends ApiError {
5 | static Config = HttpError[409];
6 |
7 | constructor({ message, errorCode, cause, payload }: ApiErrorConflictConstructor) {
8 | super({
9 | code: 409,
10 | message: message || ApiErrorConflict.Config.message,
11 | errorCode,
12 | errorType: ApiErrorConflict.Config.type,
13 | cause,
14 | payload
15 | });
16 | }
17 |
18 | static assert({
19 | condition,
20 | message,
21 | errorCode,
22 | cause,
23 | payload
24 | }: AssertInput): void {
25 | if (!condition) {
26 | throw new this({
27 | message,
28 | errorCode,
29 | cause,
30 | payload
31 | });
32 | }
33 | }
34 | }
35 |
36 | interface ApiErrorConflictConstructor {
37 | message?: string;
38 | errorCode?: string;
39 | cause?: Error;
40 | payload?: any;
41 | }
42 |
43 | interface AssertInput extends ApiErrorConflictConstructor {
44 | condition: boolean;
45 | }
46 |
--------------------------------------------------------------------------------
/packages/utils/error/ApiErrorForbidden.ts:
--------------------------------------------------------------------------------
1 | import { ApiError } from './ApiError';
2 | import HttpError from './constants/http-error.json';
3 |
4 | export class ApiErrorForbidden extends ApiError {
5 | static Config = HttpError[403];
6 |
7 | constructor({ message, errorCode, cause, payload }: ApiErrorForbiddenConstructor) {
8 | super({
9 | code: 403,
10 | message: message || ApiErrorForbidden.Config.message,
11 | errorCode,
12 | errorType: ApiErrorForbidden.Config.type,
13 | cause,
14 | payload
15 | });
16 | }
17 |
18 | static assert({
19 | condition,
20 | message,
21 | errorCode,
22 | cause,
23 | payload
24 | }: AssertInput): void {
25 | if (!condition) {
26 | throw new this({
27 | message,
28 | errorCode,
29 | cause,
30 | payload
31 | });
32 | }
33 | }
34 | }
35 |
36 | interface ApiErrorForbiddenConstructor {
37 | message?: string;
38 | errorCode?: string;
39 | cause?: Error;
40 | payload?: any;
41 | }
42 |
43 | interface AssertInput extends ApiErrorForbiddenConstructor {
44 | condition: boolean;
45 | }
46 |
--------------------------------------------------------------------------------
/packages/utils/error/ApiErrorInternalServerError.ts:
--------------------------------------------------------------------------------
1 | import { ApiError } from './ApiError';
2 | import HttpError from './constants/http-error.json';
3 |
4 | export class ApiErrorInternalServerError extends ApiError {
5 | static Config = HttpError[500];
6 |
7 | constructor({
8 | message,
9 | errorCode,
10 | cause,
11 | payload
12 | }: ApiErrorInternalServerErrorConstructor) {
13 | super({
14 | code: 500,
15 | message: message || ApiErrorInternalServerError.Config.message,
16 | errorCode,
17 | errorType: ApiErrorInternalServerError.Config.type,
18 | cause,
19 | payload
20 | });
21 | }
22 |
23 | static assert({
24 | condition,
25 | message,
26 | errorCode,
27 | cause,
28 | payload
29 | }: AssertInput): void {
30 | if (!condition) {
31 | throw new this({
32 | message,
33 | errorCode,
34 | cause,
35 | payload
36 | });
37 | }
38 | }
39 | }
40 |
41 | interface ApiErrorInternalServerErrorConstructor {
42 | message?: string;
43 | errorCode?: string;
44 | cause?: Error;
45 | payload?: any;
46 | }
47 |
48 | interface AssertInput extends ApiErrorInternalServerErrorConstructor {
49 | condition: boolean;
50 | }
51 |
--------------------------------------------------------------------------------
/packages/utils/error/ApiErrorMethodNotAllowed.ts:
--------------------------------------------------------------------------------
1 | import { ApiError } from './ApiError';
2 | import HttpError from './constants/http-error.json';
3 |
4 | export class ApiErrorMethodNotAllowed extends ApiError {
5 | static Config = HttpError[405];
6 |
7 | constructor({
8 | message,
9 | errorCode,
10 | cause,
11 | payload
12 | }: ApiErrorMethodNotAllowedConstructor) {
13 | super({
14 | code: 405,
15 | message: message || ApiErrorMethodNotAllowed.Config.message,
16 | errorCode,
17 | errorType: ApiErrorMethodNotAllowed.Config.type,
18 | cause,
19 | payload
20 | });
21 | }
22 |
23 | static assert({
24 | condition,
25 | message,
26 | errorCode,
27 | cause,
28 | payload
29 | }: AssertInput): void {
30 | if (!condition) {
31 | throw new this({
32 | message,
33 | errorCode,
34 | cause,
35 | payload
36 | });
37 | }
38 | }
39 | }
40 |
41 | interface ApiErrorMethodNotAllowedConstructor {
42 | message?: string;
43 | errorCode?: string;
44 | cause?: Error;
45 | payload?: any;
46 | }
47 |
48 | interface AssertInput extends ApiErrorMethodNotAllowedConstructor {
49 | condition: boolean;
50 | }
51 |
--------------------------------------------------------------------------------
/packages/utils/error/ApiErrorNotFound.ts:
--------------------------------------------------------------------------------
1 | import { ApiError } from './ApiError';
2 | import HttpError from './constants/http-error.json';
3 |
4 | export class ApiErrorNotFound extends ApiError {
5 | static Config = HttpError[404];
6 |
7 | constructor({ message, errorCode, cause, payload }: ApiErrorNotFoundConstructor) {
8 | super({
9 | code: 404,
10 | message: message || ApiErrorNotFound.Config.message,
11 | errorCode,
12 | errorType: ApiErrorNotFound.Config.type,
13 | cause,
14 | payload
15 | });
16 | }
17 |
18 | static assert({
19 | condition,
20 | message,
21 | errorCode,
22 | cause,
23 | payload
24 | }: AssertInput): void {
25 | if (!condition) {
26 | throw new this({
27 | message,
28 | errorCode,
29 | cause,
30 | payload
31 | });
32 | }
33 | }
34 | }
35 |
36 | interface ApiErrorNotFoundConstructor {
37 | message?: string;
38 | errorCode?: string;
39 | cause?: Error;
40 | payload?: any;
41 | }
42 |
43 | interface AssertInput extends ApiErrorNotFoundConstructor {
44 | condition: boolean;
45 | }
46 |
--------------------------------------------------------------------------------
/packages/utils/error/ApiErrorNotImplemented.ts:
--------------------------------------------------------------------------------
1 | import { ApiError } from './ApiError';
2 | import HttpError from './constants/http-error.json';
3 |
4 | export class ApiErrorNotImplemented extends ApiError {
5 | static Config = HttpError[501];
6 |
7 | constructor({
8 | message,
9 | errorCode,
10 | cause,
11 | payload
12 | }: ApiErrorNotImplementedConstructor) {
13 | super({
14 | code: 501,
15 | message: message || ApiErrorNotImplemented.Config.message,
16 | errorCode,
17 | errorType: ApiErrorNotImplemented.Config.type,
18 | cause,
19 | payload
20 | });
21 | }
22 |
23 | static assert({
24 | condition,
25 | message,
26 | errorCode,
27 | cause,
28 | payload
29 | }: AssertInput): void {
30 | if (!condition) {
31 | throw new this({
32 | message,
33 | errorCode,
34 | cause,
35 | payload
36 | });
37 | }
38 | }
39 | }
40 |
41 | interface ApiErrorNotImplementedConstructor {
42 | message?: string;
43 | errorCode?: string;
44 | cause?: Error;
45 | payload?: any;
46 | }
47 |
48 | interface AssertInput extends ApiErrorNotImplementedConstructor {
49 | condition: boolean;
50 | }
51 |
--------------------------------------------------------------------------------
/packages/utils/error/ApiErrorRequestTimeout.ts:
--------------------------------------------------------------------------------
1 | import { ApiError } from './ApiError';
2 | import HttpError from './constants/http-error.json';
3 |
4 | export class ApiErrorRequestTimeout extends ApiError {
5 | static Config = HttpError[408];
6 |
7 | constructor({
8 | message,
9 | errorCode,
10 | cause,
11 | payload
12 | }: ApiErrorRequestTimeoutConstructor) {
13 | super({
14 | code: 408,
15 | message: message || ApiErrorRequestTimeout.Config.message,
16 | errorCode,
17 | errorType: ApiErrorRequestTimeout.Config.type,
18 | cause,
19 | payload
20 | });
21 | }
22 |
23 | static assert({
24 | condition,
25 | message,
26 | errorCode,
27 | cause,
28 | payload
29 | }: AssertInput): void {
30 | if (!condition) {
31 | throw new this({
32 | message,
33 | errorCode,
34 | cause,
35 | payload
36 | });
37 | }
38 | }
39 | }
40 |
41 | interface ApiErrorRequestTimeoutConstructor {
42 | message?: string;
43 | errorCode?: string;
44 | cause?: Error;
45 | payload?: any;
46 | }
47 |
48 | interface AssertInput extends ApiErrorRequestTimeoutConstructor {
49 | condition: boolean;
50 | }
51 |
--------------------------------------------------------------------------------
/packages/utils/error/ApiErrorServiceUnavailable.ts:
--------------------------------------------------------------------------------
1 | import { ApiError } from './ApiError';
2 | import HttpError from './constants/http-error.json';
3 |
4 | export class ApiErrorServiceUnavailable extends ApiError {
5 | static Config = HttpError[503];
6 |
7 | constructor({
8 | message,
9 | errorCode,
10 | cause,
11 | payload
12 | }: ApiErrorServiceUnavailableConstructor) {
13 | super({
14 | code: 503,
15 | message: message || ApiErrorServiceUnavailable.Config.message,
16 | errorCode,
17 | errorType: ApiErrorServiceUnavailable.Config.type,
18 | cause,
19 | payload
20 | });
21 | }
22 |
23 | static assert({
24 | condition,
25 | message,
26 | errorCode,
27 | cause,
28 | payload
29 | }: AssertInput): void {
30 | if (!condition) {
31 | throw new this({
32 | message,
33 | errorCode,
34 | cause,
35 | payload
36 | });
37 | }
38 | }
39 | }
40 |
41 | interface ApiErrorServiceUnavailableConstructor {
42 | message?: string;
43 | errorCode?: string;
44 | cause?: Error;
45 | payload?: any;
46 | }
47 |
48 | interface AssertInput extends ApiErrorServiceUnavailableConstructor {
49 | condition: boolean;
50 | }
51 |
--------------------------------------------------------------------------------
/packages/utils/error/ApiErrorTooManyRequests.ts:
--------------------------------------------------------------------------------
1 | import { ApiError } from './ApiError';
2 | import HttpError from './constants/http-error.json';
3 |
4 | export class ApiErrorTooManyRequests extends ApiError {
5 | static Config = HttpError[429];
6 |
7 | constructor({
8 | message,
9 | errorCode,
10 | cause,
11 | payload
12 | }: ApiErrorTooManyRequestsConstructor) {
13 | super({
14 | code: 429,
15 | message: message || ApiErrorTooManyRequests.Config.message,
16 | errorCode,
17 | errorType: ApiErrorTooManyRequests.Config.type,
18 | cause,
19 | payload
20 | });
21 | }
22 |
23 | static assert({
24 | condition,
25 | message,
26 | errorCode,
27 | cause,
28 | payload
29 | }: AssertInput): void {
30 | if (!condition) {
31 | throw new this({
32 | message,
33 | errorCode,
34 | cause,
35 | payload
36 | });
37 | }
38 | }
39 | }
40 |
41 | interface ApiErrorTooManyRequestsConstructor {
42 | message?: string;
43 | errorCode?: string;
44 | cause?: Error;
45 | payload?: any;
46 | }
47 |
48 | interface AssertInput extends ApiErrorTooManyRequestsConstructor {
49 | condition: boolean;
50 | }
51 |
--------------------------------------------------------------------------------
/packages/utils/error/ApiErrorUnauthorized.ts:
--------------------------------------------------------------------------------
1 | import { ApiError } from './ApiError';
2 | import HttpError from './constants/http-error.json';
3 |
4 | export class ApiErrorUnauthorized extends ApiError {
5 | static Config = HttpError[401];
6 |
7 | constructor({
8 | message,
9 | errorCode,
10 | cause,
11 | payload
12 | }: ApiErrorUnauthorizedConstructor) {
13 | super({
14 | code: 401,
15 | message: message || ApiErrorUnauthorized.Config.message,
16 | errorCode,
17 | errorType: ApiErrorUnauthorized.Config.type,
18 | cause,
19 | payload
20 | });
21 | }
22 |
23 | static assert({
24 | condition,
25 | message,
26 | errorCode,
27 | cause,
28 | payload
29 | }: AssertInput): void {
30 | if (!condition) {
31 | throw new this({
32 | message,
33 | errorCode,
34 | cause,
35 | payload
36 | });
37 | }
38 | }
39 | }
40 |
41 | interface ApiErrorUnauthorizedConstructor {
42 | message?: string;
43 | errorCode?: string;
44 | cause?: Error;
45 | payload?: any;
46 | }
47 |
48 | interface AssertInput extends ApiErrorUnauthorizedConstructor {
49 | condition: boolean;
50 | }
51 |
--------------------------------------------------------------------------------
/packages/utils/error/ApiErrorUnprocessableEntity.ts:
--------------------------------------------------------------------------------
1 | import { ApiError } from './ApiError';
2 | import HttpError from './constants/http-error.json';
3 |
4 | export class ApiErrorUnprocessableEntity extends ApiError {
5 | static Config = HttpError[422];
6 |
7 | constructor({
8 | message,
9 | errorCode,
10 | cause,
11 | payload
12 | }: ApiErrorUnprocessableEntityConstructor) {
13 | super({
14 | code: 422,
15 | message: message || ApiErrorUnprocessableEntity.Config.message,
16 | errorCode,
17 | errorType: ApiErrorUnprocessableEntity.Config.type,
18 | cause,
19 | payload
20 | });
21 | }
22 |
23 | static assert({
24 | condition,
25 | message,
26 | errorCode,
27 | cause,
28 | payload
29 | }: AssertInput): void {
30 | if (!condition) {
31 | throw new this({
32 | message,
33 | errorCode,
34 | cause,
35 | payload
36 | });
37 | }
38 | }
39 | }
40 |
41 | interface ApiErrorUnprocessableEntityConstructor {
42 | message?: string;
43 | errorCode?: string;
44 | cause?: Error;
45 | payload?: any;
46 | }
47 |
48 | interface AssertInput extends ApiErrorUnprocessableEntityConstructor {
49 | condition: boolean;
50 | }
51 |
--------------------------------------------------------------------------------
/packages/utils/error/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ApiError';
2 | export * from './ApiErrorConflict';
3 | export * from './ApiErrorBadRequest';
4 | export * from './ApiErrorForbidden';
5 | export * from './ApiErrorInternalServerError';
6 | export * from './ApiErrorNotFound';
7 | export * from './ApiErrorNotImplemented';
8 | export * from './ApiErrorRequestTimeout';
9 | export * from './ApiErrorServiceUnavailable';
10 | export * from './ApiErrorTooManyRequests';
11 | export * from './ApiErrorUnauthorized';
12 | export * from './ApiErrorUnprocessableEntity';
13 |
--------------------------------------------------------------------------------
/packages/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './config';
2 | export * from './error';
3 | export * from './logger';
4 |
--------------------------------------------------------------------------------
/packages/utils/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('jest').Config} */
2 |
3 | module.exports = require('@conduit/config/jest.config');
4 |
--------------------------------------------------------------------------------
/packages/utils/logger/formats/capitalizeLevel.ts:
--------------------------------------------------------------------------------
1 | import { startCase } from 'lodash';
2 | import { format } from 'winston';
3 |
4 | export const capitalizeLevel = format((info) => {
5 | if (info.level) {
6 | // eslint-disable-next-line no-param-reassign
7 | info.level = startCase(info.level);
8 | }
9 | return info;
10 | });
11 |
--------------------------------------------------------------------------------
/packages/utils/logger/formats/cleanStack.ts:
--------------------------------------------------------------------------------
1 | import cleanStackFn, { Options } from 'clean-stack';
2 | import { isNil } from 'lodash';
3 | import { format } from 'winston';
4 |
5 | export const cleanStack = format((info, options: Options = {}) => {
6 | if (info.message instanceof Error && !isNil(info.message.stack)) {
7 | // eslint-disable-next-line no-param-reassign
8 | info.message.stack = cleanStackFn(info.message.stack, options);
9 | }
10 | return info;
11 | });
12 |
--------------------------------------------------------------------------------
/packages/utils/logger/formats/customPrintf.ts:
--------------------------------------------------------------------------------
1 | import colors from '@colors/colors/safe';
2 | import path from 'path';
3 | import { format } from 'winston';
4 |
5 | const { printf } = format;
6 | const pwd = path.resolve(process.env.PWD || process.cwd());
7 |
8 | const DefaultCustomPrintfOptions = {
9 | hideErrorStack: false
10 | };
11 |
12 | export const customPrintf = (
13 | options: CustomPrintfOptions = DefaultCustomPrintfOptions
14 | ) =>
15 | printf((info) => {
16 | let message = `${info.level} - ${info.message}`;
17 |
18 | if (info.stack && !options.hideErrorStack && process.env.NODE_ENV === 'dev') {
19 | const stackWithColors = info.stack
20 | .split('\n')
21 | .slice(1)
22 | .map((line: string, index: number) => {
23 | if (
24 | index === 0 ||
25 | (!line.includes('node_modules') && line.includes(pwd))
26 | ) {
27 | return line;
28 | }
29 | return colors.grey(line);
30 | })
31 | .join('\n');
32 |
33 | message += `\n${stackWithColors}`;
34 | }
35 | return message;
36 | });
37 |
38 | type CustomPrintfOptions = {
39 | hideErrorStack: boolean;
40 | };
41 |
--------------------------------------------------------------------------------
/packages/utils/logger/formats/environment.ts:
--------------------------------------------------------------------------------
1 | import { format } from 'winston';
2 |
3 | export const environment = format((info) => {
4 | // eslint-disable-next-line no-param-reassign
5 | info.environment = process.env.NODE_ENV || 'development';
6 | return info;
7 | });
8 |
--------------------------------------------------------------------------------
/packages/utils/logger/formats/index.ts:
--------------------------------------------------------------------------------
1 | export * from './cleanStack';
2 | export * from './capitalizeLevel';
3 | export * from './json';
4 | export * from './label';
5 | export * from './customPrintf';
6 | export * from './environment';
7 |
--------------------------------------------------------------------------------
/packages/utils/logger/formats/json.ts:
--------------------------------------------------------------------------------
1 | import stringify from 'safe-stable-stringify';
2 | import { MESSAGE } from 'triple-beam';
3 | import { format } from 'winston';
4 |
5 | export const json = format((info, options) => {
6 | const jsonStringify = stringify.configure(options);
7 | const stringified = jsonStringify(
8 | info,
9 | options.replacer || replacer,
10 | options.space
11 | );
12 | // eslint-disable-next-line no-param-reassign
13 | info[MESSAGE] = stringified;
14 | return info;
15 | });
16 |
17 | function replacer(_key: string, value: any) {
18 | /**
19 | *
20 | * While safe-stable-stringify does support BigInt,
21 | * it doesn't wrap the value in quotes, which can result in a loss of fidelity if the resulting string is parsed.
22 | * However, wrapping BigInts in quotes would be a breaking change for logform,
23 | * so it's currently not implemented.
24 | *
25 | */
26 | if (typeof value === 'bigint') {
27 | return value.toString();
28 | }
29 | return value;
30 | }
31 |
--------------------------------------------------------------------------------
/packages/utils/logger/formats/label.ts:
--------------------------------------------------------------------------------
1 | import colors from '@colors/colors/safe';
2 | import { get } from 'lodash';
3 | import { format } from 'winston';
4 |
5 | const isDevelopment = process.env.NODE_ENV === 'dev';
6 |
7 | const labelMiddleware = format(
8 | (info, options: LabelOptions = DefaultLabelOptions) => {
9 | const {
10 | color = DefaultLabelOptions.color,
11 | labelColor = DefaultLabelOptions.labelColor
12 | } = options;
13 |
14 | const { label, message } = info;
15 |
16 | if (label) {
17 | // eslint-disable-next-line no-param-reassign
18 | info.message = `${
19 | color && isDevelopment && get(colors, labelColor)
20 | ? get(colors, labelColor)(`[${label}]`)
21 | : `[${label}]`
22 | } ${message}`;
23 | }
24 |
25 | return info;
26 | }
27 | );
28 |
29 | export interface LabelOptions {
30 | color: boolean;
31 | labelColor: string;
32 | }
33 |
34 | const DefaultLabelOptions = {
35 | color: false,
36 | labelColor: 'yellow'
37 | };
38 |
39 | export { labelMiddleware as label };
40 |
--------------------------------------------------------------------------------
/packages/utils/logger/index.ts:
--------------------------------------------------------------------------------
1 | export { logger } from './logger';
2 |
--------------------------------------------------------------------------------
/packages/utils/logger/logger.ts:
--------------------------------------------------------------------------------
1 | import winston, { format, transports } from 'winston';
2 |
3 | import {
4 | capitalizeLevel,
5 | cleanStack,
6 | customPrintf,
7 | environment,
8 | label
9 | } from './formats';
10 |
11 | const { errors, json, combine, colorize, uncolorize, metadata } = format;
12 | const { Console } = transports;
13 | const { levels } = winston.config.npm;
14 |
15 | const isDevelopment = process.env.NODE_ENV === 'dev';
16 |
17 | export const logger = winston.createLogger({
18 | level: 'debug',
19 | levels,
20 | // Use the Console transport with a custom format
21 | transports: [
22 | new Console({
23 | format: combine(
24 | label({}),
25 | capitalizeLevel(),
26 | isDevelopment ? colorize() : uncolorize(),
27 | customPrintf()
28 | )
29 | })
30 | ],
31 | // Define the default log format options
32 | format: format.combine(
33 | environment(),
34 | cleanStack({}),
35 | errors({ stack: true }),
36 | json(),
37 | metadata({
38 | key: 'payload',
39 | fillExcept: ['label', 'timestamp', 'message', 'level', 'stack', 'environment']
40 | })
41 | ),
42 | // Don't exit the process when a handled exception occurs
43 | exitOnError: false,
44 | silent: process.env.NODE_ENV === 'test'
45 | });
46 |
--------------------------------------------------------------------------------
/packages/utils/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "extends": "@conduit/config/tsconfig.base.json",
4 | "compilerOptions": {
5 | "sourceMap": false
6 | },
7 | "include": ["./**/*.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/scripts/api-test.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | TEST_COLLECTIONS_LIST=( \
4 | full
5 | )
6 |
7 | for collection in ${TEST_COLLECTIONS_LIST[@]}; do
8 | newman run ./tests/integration/postman-collections/$collection.json \
9 | --bail \
10 | -e "./tests/integration/testing-environment.json"
11 | done
12 |
13 |
--------------------------------------------------------------------------------
/scripts/clean.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Colors for logs
4 | RED='\033[0;31m'
5 | GREEN='\033[0;32m'
6 | YELLOW='\033[0;33m'
7 | BOLD='\033[1m'
8 | NC='\033[0m' # No Color
9 |
10 | # Function to clean up directories
11 | clean_directory() {
12 | local dir_type=$1
13 | local path_pattern=$2
14 |
15 | echo -e "${YELLOW}Searching for '$dir_type' directories ...${NC}"
16 | if find ./ -type d -name "$path_pattern" -exec rm -rf {} +; then
17 | # Removed the log for successful removal
18 | :
19 | else
20 | echo -e "${RED}Error removing '$dir_type' directories.${NC}"
21 | fi
22 | }
23 |
24 | # Clean .turbo directories
25 | clean_directory ".turbo" ".turbo"
26 |
27 | # Clean coverage directories
28 | clean_directory "coverage" "coverage"
29 |
30 | # Clean .nyc_output directories
31 | clean_directory ".nyc_output" ".nyc_output"
32 |
33 | # Final message
34 | echo -e "${BOLD}${GREEN}Cleanup completed for all directories.${NC}"
35 |
--------------------------------------------------------------------------------
/scripts/wait-for-readiness.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -xe
4 |
5 | wait_for_readiness() {
6 | local SERVICE="$1"
7 | local PORT="$2"
8 | local TRY_TIMEOUT=300
9 | local TRY_INTERVAL=1
10 | local REMAINING_TIME=$TRY_TIMEOUT
11 | while ! curl http://localhost:${PORT}/api/health-check -s --include | head -n1 | grep -q 200; do
12 | REMAINING_TIME=$((REMAINING_TIME - TRY_INTERVAL))
13 | if [ $REMAINING_TIME -lt 0 ]; then
14 | echo "Error: '${SERVICE}' did not start in expected duration."
15 | exit 1
16 | fi
17 | echo "Waiting for '${SERVICE}' to start... remaining ${REMAINING_TIME} seconds."
18 | sleep $TRY_INTERVAL
19 | done
20 | echo "The '${SERVICE}' is ready to be tested."
21 | }
22 |
23 | wait_for_readiness 'API Server' 3100
24 |
--------------------------------------------------------------------------------
/tests/integration/testing-environment.json:
--------------------------------------------------------------------------------
1 | {
2 | "values": [
3 | {
4 | "key": "APIURL",
5 | "value": "http://127.0.0.1:3100/api",
6 | "type": "text"
7 | },
8 | {
9 | "key": "EMAIL",
10 | "value": "admin@conduit.com",
11 | "type": "text"
12 | },
13 | {
14 | "key": "PASSWORD",
15 | "value": "abc123",
16 | "type": "text"
17 | },
18 | {
19 | "key": "USERNAME",
20 | "value": "conduit",
21 | "type": "text"
22 | }
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "globalEnv": [
4 | "NODE_ENV"
5 | ],
6 | "cacheDir": ".turbo/cache",
7 | "tasks": {
8 | "test": {
9 | "dependsOn": [
10 | "^test"
11 | ]
12 | },
13 | "test:coverage": {
14 | "dependsOn": [
15 | "^test:coverage"
16 | ]
17 | },
18 | "db:migrate": {
19 | "outputLogs": "full",
20 | "dependsOn": [
21 | "^db:migrate"
22 | ]
23 | },
24 | "deploy": {
25 | "dependsOn": [
26 | "^deploy"
27 | ],
28 | "cache": false
29 | },
30 | "lint": {
31 | "dependsOn": [
32 | "^lint"
33 | ]
34 | },
35 | "lint:fix": {
36 | "dependsOn": [
37 | "^lint:fix"
38 | ]
39 | },
40 | "build": {
41 | "outputLogs": "full",
42 | "dependsOn": [
43 | "^build"
44 | ]
45 | },
46 | "check-types": {
47 | "dependsOn": [
48 | "^check-types"
49 | ]
50 | },
51 | "prettify": {
52 | "dependsOn": [
53 | "^prettify"
54 | ]
55 | },
56 | "dev": {
57 | "persistent": true,
58 | "cache": false
59 | },
60 | "start": {
61 | "persistent": true,
62 | "cache": false
63 | },
64 | "start:ci": {
65 | "persistent": true,
66 | "cache": false
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------