├── .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 | --------------------------------------------------------------------------------