├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── pre-commit └── pre-push ├── .lintstagedrc ├── LICENSE ├── README.md ├── biome.json ├── docker-compose.yml ├── package-lock.json ├── package.json ├── packages ├── core │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── serviceLocator.ts │ │ ├── shared │ │ │ ├── config │ │ │ │ └── environments.ts │ │ │ ├── errors │ │ │ │ ├── ApplicationError.ts │ │ │ │ ├── DomainError.ts │ │ │ │ ├── InfrastructureError.ts │ │ │ │ ├── PersistenceCorruptionError.ts │ │ │ │ └── ValidationError.ts │ │ │ ├── index.ts │ │ │ └── infra │ │ │ │ └── connections │ │ │ │ └── MongoConnection.ts │ │ └── users │ │ │ ├── application │ │ │ ├── dto │ │ │ │ ├── UserDTOMapper.ts │ │ │ │ ├── UserInputDTO.ts │ │ │ │ └── UserOutputDTO.ts │ │ │ ├── errors │ │ │ │ └── UserApplicationError.ts │ │ │ └── use-cases │ │ │ │ ├── AddNewUserUseCase.ts │ │ │ │ ├── ListUsersUseCase.ts │ │ │ │ └── __tests__ │ │ │ │ ├── AddNewUserUseCase.test.ts │ │ │ │ └── ListUsersUseCase.test.ts │ │ │ ├── domain │ │ │ ├── entities │ │ │ │ ├── User.ts │ │ │ │ └── __tests__ │ │ │ │ │ └── User.test.ts │ │ │ ├── errors │ │ │ │ └── UserValidationError.ts │ │ │ ├── repositories │ │ │ │ └── UserRepository.ts │ │ │ └── value-objects │ │ │ │ ├── Address.value.ts │ │ │ │ ├── Email.value.ts │ │ │ │ ├── Id.value.ts │ │ │ │ ├── Password.value.ts │ │ │ │ └── __tests__ │ │ │ │ ├── Address.value.test.ts │ │ │ │ ├── Email.value.test.ts │ │ │ │ ├── Id.value.test.ts │ │ │ │ └── Password.value.test.ts │ │ │ ├── index.ts │ │ │ ├── infra │ │ │ ├── database │ │ │ │ ├── UserDBModel.ts │ │ │ │ ├── UserDatabaseRepository.ts │ │ │ │ └── UserPersistenceMapper.ts │ │ │ ├── errors │ │ │ │ └── UserInfraError.ts │ │ │ └── memory │ │ │ │ └── UserInMemoryRepository.ts │ │ │ └── presentation │ │ │ ├── UsersPresenter.ts │ │ │ ├── UsersView.ts │ │ │ └── __tests__ │ │ │ └── UserPresenter.test.ts │ └── tsconfig.json ├── react-app │ ├── .gitignore │ ├── README.md │ ├── next.config.ts │ ├── package.json │ ├── public │ │ ├── file.svg │ │ ├── globe.svg │ │ ├── next.svg │ │ ├── vercel.svg │ │ └── window.svg │ ├── src │ │ ├── app │ │ │ ├── favicon.ico │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ └── users │ │ │ ├── actions │ │ │ └── userActions.ts │ │ │ ├── controllers │ │ │ └── UsersController.ts │ │ │ └── views │ │ │ ├── UsersView.module.css │ │ │ ├── UsersView.tsx │ │ │ └── components │ │ │ ├── AddUserModal.module.css │ │ │ ├── AddUserModal.tsx │ │ │ ├── SuccessMessage.module.css │ │ │ ├── SuccessMessage.tsx │ │ │ ├── UsersList.module.css │ │ │ └── UsersList.tsx │ └── tsconfig.json └── terminal-app │ ├── package.json │ ├── src │ ├── UsersTerminalView.ts │ └── index.ts │ └── tsconfig.json ├── tsconfig.base.json └── vitest.config.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [carlosazaustre] 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | pages: write 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup Node 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 20 26 | cache: npm 27 | 28 | - name: Install dependencies 29 | run: npm ci 30 | 31 | - name: Run lint 32 | run: npm run lint 33 | 34 | - name: Run tests 35 | run: npm test 36 | 37 | - name: Generate coverage report 38 | run: npm run test:coverage 39 | 40 | - name: Upload coverage report to Github Pages 41 | if: github.ref == 'refs/heads/main' 42 | uses: peaceiris/actions-gh-pages@v4 43 | with: 44 | github_token: ${{ secrets.GITHUB_TOKEN }} 45 | publish_dir: ./coverage 46 | 47 | - name: Build 48 | run: npm run build 49 | 50 | - name: Notify on failure 51 | if: failure() 52 | uses: rtCamp/action-slack-notify@v2 53 | env: 54 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 55 | SLACK_CHANNEL: notificaciones 56 | SLACK_COLOR: danger 57 | SLACK_ICON: https://github.com/rtCamp.png?size=48 58 | SLACK_MESSAGE: 'CI Pipeline failed :x:' 59 | SLACK_TITLE: CI Failure 60 | SLACK_USERNAME: CI Bot -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log 4 | yarn-debug.log 5 | yarn-error.log 6 | .pnpm-debug.log 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/releases 10 | !.yarn/plugins 11 | !.yarn/sdks 12 | !.yarn/versions 13 | 14 | # TypeScript 15 | *.tsbuildinfo 16 | dist/ 17 | lib/ 18 | build/ 19 | out/ 20 | 21 | # Testing 22 | coverage/ 23 | .vitest-cache/ 24 | .nyc_output/ 25 | 26 | # Environment variables 27 | .env 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # Editor directories and files 34 | .vscode/* 35 | !.vscode/extensions.json 36 | !.vscode/settings.json 37 | !.vscode/tasks.json 38 | !.vscode/launch.json 39 | .idea/ 40 | *.suo 41 | *.ntvs* 42 | *.njsproj 43 | *.sln 44 | *.sw? 45 | .DS_Store 46 | Thumbs.db 47 | 48 | # Logs 49 | logs 50 | *.log 51 | 52 | # Cache 53 | .cache/ 54 | .turbo 55 | .qodo 56 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | npm run lint 2 | npm test 3 | npm run build -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,ts,jsx,tsx,json}": ["biome check --no-errors-on-unmatched"] 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Carlos Azaustre 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clean Architecture - "Users" Kata 2 | 3 | ## General Description 4 | 5 | ### Functional Requirements 6 | 7 | - Display list of users. 8 | - Add a new user. 9 | - A user must contain name, email, and password as required fields. 10 | - Email must be a valid email. 11 | - Password must be at least 8 characters, with at least one letter and one number. 12 | - The app should show an error if trying to add two users with the same email. 13 | - **Extra Challenge**: 14 | - Add address as part of the required information for the user (address, zip code, city). 15 | - Only one user per domain can exist (1 user with gmail, etc...). 16 | 17 | ### Development Platform 18 | 19 | - The UI will be a command line or terminal app. 20 | - All data in memory, no persistence between executions. 21 | - The data source could be an API or Database in the future. 22 | - The UI could change in the future to a web app, mobile app, or desktop app. 23 | - Business rules must be validated through unit tests. 24 | - In tests, dependencies will be replaced by manual fake dependencies. 25 | - Equality tests must also be created for value objects and entities. 26 | - Use MVP for the presentation layer. 27 | 28 | ## Part 1 - Entities 29 | 30 | - Define entities and value objects. 31 | - Business rules must be tested by creating unit tests. 32 | - Rules: 33 | 34 | - The user must have name, email, and password as required fields. 35 | - Email must be a valid email. 36 | - Password must have a minimum length of 8 characters, have at least one letter and one number. 37 | - Two instances of the same email must be equal. 38 | - Two instances of the same password must be equal in a comparison. 39 | - Two instances of user, with the same id, must be equal in a comparison. 40 | 41 | - **Extra Challenge**: 42 | - Add address as part of the required information for the user (address, zip code, city). 43 | 44 | ## Part 2 - Use Cases 45 | 46 | - Define the use cases, repositories for: 47 | - Displaying list of users. 48 | - Adding a new user. 49 | - Application rules must be tested by creating unit tests. 50 | - In unit tests, you can use manual fake objects. 51 | - Rules: 52 | - The application must not allow adding two users with the same email. 53 | - **Extra Challenge**: 54 | - Only one user per domain can exist (1 user with gmail, etc...). 55 | 56 | ## Part 3 - UI 57 | - Create a console application that invokes the use cases. 58 | - Use MVP 59 | - Define the presenter and the UI 60 | - The presenter must be conditioned by the UI it adapts 61 | 62 | ## Part 4 63 | - Modify the app to have persistence between executions using a database, locally stored file, or another solution. Maintain the domain and presentation layers without modifications. 64 | - Replace the console or terminal UI with a technology of your preference, keeping the domain and data layers unmodified. 65 | - Only the corresponding adapters should change and the composition root if necessary. 66 | - Add address as part of the necessary information for the user (address, zip code, city) and create the necessary tests. 67 | - Only one user per domain can exist (1 user with gmail.com, 1 user with outlook.com, etc.) and create the necessary tests. -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": ["dist", ".next", "node_modules", "coverage", "build", "**/*.test.ts"] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "space", 15 | "indentWidth": 2, 16 | "lineWidth": 100 17 | }, 18 | "organizeImports": { 19 | "enabled": true 20 | }, 21 | "linter": { 22 | "enabled": true, 23 | "rules": { 24 | "recommended": true 25 | } 26 | }, 27 | "javascript": { 28 | "formatter": { 29 | "quoteStyle": "double", 30 | "semicolons": "always" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mongodb: 3 | image: mongo:7 4 | container_name: users-kata-mongodb 5 | ports: 6 | - "27017:27017" 7 | environment: 8 | MONGO_INITDB_DATABASE: users-kata 9 | volumes: 10 | - mongodb-data:/data/db 11 | 12 | volumes: 13 | mongodb-data: -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "users-kata-ts", 4 | "version": "1.0.0", 5 | "description": "A kata to learn clean architecture. It is a users listing terminal app.", 6 | "keywords": ["clean", "architecture", "typescript", "ddd", "tdd", "solid", "mvp"], 7 | "author": "Carlos Azaustre (https://carlosazaustre.es)", 8 | "workspaces": ["packages/*"], 9 | "scripts": { 10 | "test": "npm run test --workspaces", 11 | "test:coverage": "npm run test --workspaces -- --coverage", 12 | "lint": "npm run lint --workspaces", 13 | "build": "npm run build --workspaces", 14 | "prepare": "husky", 15 | "start:terminal": "npm run dev:db --workspace @users-kata-ts/terminal-app", 16 | "start:terminal:memory": "npm run dev:memory --workspace @users-kata-ts/terminal-app", 17 | "start:web": "npm run dev --workspace @users-kata-ts/react-app" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/carlosazaustre/users-kata-ts.git" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/carlosazaustre/users-kata-ts/issues" 25 | }, 26 | "homepage": "https://github.com/carlosazaustre/users-kata-ts#readme", 27 | "dependencies": { 28 | "mongodb": "^6.16.0", 29 | "readline": "^1.3.0", 30 | "typescript": "^5.8.3", 31 | "vitest": "^3.1.4" 32 | }, 33 | "devDependencies": { 34 | "@biomejs/biome": "1.9.4", 35 | "@types/node": "^22.15.21", 36 | "@types/jest": "^29.5.14", 37 | "@vitest/coverage-v8": "^3.1.4", 38 | "husky": "^9.1.7", 39 | "lint-staged": "^16.0.0", 40 | "ts-node": "^10.9.2", 41 | "typescript": "^5.8.3", 42 | "vitest": "^3.1.4" 43 | }, 44 | "license": "MIT" 45 | } 46 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@users-kata-ts/core", 3 | "version": "1.0.0", 4 | "description": "Kata Users Core, this project contains all reusable contents and its logic.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": ["dist"], 8 | "scripts": { 9 | "test": "vitest", 10 | "test:coverage": "vitest --coverage", 11 | "lint": "biome check", 12 | "build": "tsc", 13 | "start": "npm run build && node dist/index.js", 14 | "prepare": "husky install" 15 | }, 16 | "dependencies": { 17 | "mongodb": "^6.16.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | // Barrel file for Core package 2 | export * from "./users"; 3 | export type { UserInputDTO, UserOutputDTO } from "./users/"; 4 | export * from "./shared"; 5 | export * from "./serviceLocator"; 6 | -------------------------------------------------------------------------------- /packages/core/src/serviceLocator.ts: -------------------------------------------------------------------------------- 1 | import { config } from "./shared/config/environments"; 2 | import { MongoConnection } from "./shared/infra/connections/MongoConnection"; 3 | import { AddNewUserUseCase } from "./users/application/use-cases/AddNewUserUseCase"; 4 | import { ListUsersUseCase } from "./users/application/use-cases/ListUsersUseCase"; 5 | import type { UserRepository } from "./users/domain/repositories/UserRepository"; 6 | import { UserDatabaseRepository } from "./users/infra/database/UserDatabaseRepository"; 7 | import { UserInMemoryRepository } from "./users/infra/memory/UserInMemoryRepository"; 8 | import { UsersPresenter } from "./users/presentation/UsersPresenter"; 9 | import type { UsersView } from "./users/presentation/UsersView"; 10 | 11 | /** 12 | * Creates a singleton factory function that ensures only one instance of type T is created. 13 | * 14 | * @template T - The type of the singleton instance to be created 15 | * @param factory - A function that creates and returns an instance of type T or a Promise of T 16 | * @returns A function that returns a Promise of the singleton instance, creating it on first call if necessary 17 | * 18 | */ 19 | function createSingleton(factory: () => T | Promise): () => Promise { 20 | let instance: T | null = null; 21 | return async () => { 22 | if (!instance) { 23 | instance = await factory(); 24 | } 25 | return instance; 26 | }; 27 | } 28 | 29 | const getUserRepository = createSingleton(async () => { 30 | if (config.useDatabase) { 31 | const client = await MongoConnection.getClient(); 32 | return new UserDatabaseRepository(client, config.mongodb.dbName); 33 | } 34 | return new UserInMemoryRepository(); 35 | }); 36 | 37 | const getAddNewUserUseCase = createSingleton(async () => { 38 | const repository = await getUserRepository(); 39 | return new AddNewUserUseCase(repository); 40 | }); 41 | 42 | const getListUsersUseCase = createSingleton(async () => { 43 | const repository = await getUserRepository(); 44 | return new ListUsersUseCase(repository); 45 | }); 46 | 47 | async function getUsersPresenter(view: UsersView) { 48 | const provideAddNewUserUseCase = await getAddNewUserUseCase(); 49 | const provideListUsersUseCase = await getListUsersUseCase(); 50 | 51 | return new UsersPresenter(view, provideAddNewUserUseCase, provideListUsersUseCase); 52 | } 53 | 54 | async function cleanup() { 55 | if (config.useDatabase) { 56 | await MongoConnection.disconnect(); 57 | } 58 | } 59 | 60 | export const serviceLocator = { 61 | getUsersPresenter, 62 | getAddNewUserUseCase, 63 | getListUsersUseCase, 64 | cleanup, 65 | }; 66 | -------------------------------------------------------------------------------- /packages/core/src/shared/config/environments.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | mongodb: { 3 | url: process.env.MONGO_URL || "mongodb://localhost:27017", 4 | dbName: process.env.MONGO_DB_NAME || "users-kata", 5 | }, 6 | useDatabase: process.env.USE_DATABASE === "true", 7 | }; 8 | -------------------------------------------------------------------------------- /packages/core/src/shared/errors/ApplicationError.ts: -------------------------------------------------------------------------------- 1 | export class ApplicationError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = "ApplicationError"; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/core/src/shared/errors/DomainError.ts: -------------------------------------------------------------------------------- 1 | export class DomainError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = "DomainError"; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/core/src/shared/errors/InfrastructureError.ts: -------------------------------------------------------------------------------- 1 | export class InfrastructureError extends Error { 2 | constructor( 3 | message: string, 4 | public readonly cause?: Error | undefined, 5 | ) { 6 | super(message); 7 | this.name = "InfrastructureError"; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/src/shared/errors/PersistenceCorruptionError.ts: -------------------------------------------------------------------------------- 1 | import { InfrastructureError } from "./InfrastructureError"; 2 | 3 | export class PersistenceCorruptionError extends InfrastructureError { 4 | constructor( 5 | public readonly entity: string, 6 | public readonly errorCause: Error | string, 7 | // biome-ignore lint/suspicious/noExplicitAny: 8 | public readonly rawData?: any, 9 | ) { 10 | super( 11 | `Corrupt data found for entity "${entity}": ${errorCause instanceof Error ? errorCause.message : errorCause}`, 12 | errorCause instanceof Error ? errorCause : undefined, 13 | ); 14 | this.name = "PersistenceCorruptionError"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/core/src/shared/errors/ValidationError.ts: -------------------------------------------------------------------------------- 1 | import { DomainError } from "./DomainError"; 2 | 3 | export class ValidationError extends DomainError { 4 | constructor( 5 | public readonly field: string, 6 | public readonly details: string, 7 | ) { 8 | super(`Validation error on field '${field}': ${details}`); 9 | this.name = "ValidationError"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/core/src/shared/index.ts: -------------------------------------------------------------------------------- 1 | // Barrel file for the Shared module 2 | export * from "./config/environments"; 3 | export * from "./infra/connections/MongoConnection"; 4 | -------------------------------------------------------------------------------- /packages/core/src/shared/infra/connections/MongoConnection.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient } from "mongodb"; 2 | import { config } from "../../config/environments"; 3 | 4 | let client: MongoClient | null = null; 5 | 6 | export const MongoConnection = { 7 | getClient: async (): Promise => { 8 | if (!client) { 9 | const url = config.mongodb.url; 10 | client = new MongoClient(url); 11 | await client.connect(); 12 | console.log("Connected to MongoDB"); 13 | } 14 | 15 | return client; 16 | }, 17 | 18 | disconnect: async (): Promise => { 19 | if (client) { 20 | await client.close(); 21 | client = null; 22 | console.log("Disconnected from MongoDB"); 23 | } 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /packages/core/src/users/application/dto/UserDTOMapper.ts: -------------------------------------------------------------------------------- 1 | import type { User } from "../../domain/entities/User"; 2 | import type { UserOutputDTO } from "./UserOutputDTO"; 3 | 4 | export const UserDTOMapper = { 5 | toOutputDTO: (user: User): UserOutputDTO => { 6 | return { 7 | username: user.username, 8 | email: user.email.value, 9 | address: user.address.value.address, 10 | city: user.address.value.city, 11 | zipCode: user.address.value.zipCode, 12 | }; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /packages/core/src/users/application/dto/UserInputDTO.ts: -------------------------------------------------------------------------------- 1 | export interface UserInputDTO { 2 | username: string; 3 | email: string; 4 | password: string; 5 | address: string; 6 | city: string; 7 | zipCode: string; 8 | } 9 | -------------------------------------------------------------------------------- /packages/core/src/users/application/dto/UserOutputDTO.ts: -------------------------------------------------------------------------------- 1 | export interface UserOutputDTO { 2 | username: string; 3 | email: string; 4 | address?: string; 5 | city?: string; 6 | zipCode?: string; 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/src/users/application/errors/UserApplicationError.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationError } from "../../../shared/errors/ApplicationError"; 2 | 3 | export class UserApplicationError extends ApplicationError { 4 | constructor(message: string) { 5 | super(message); 6 | this.name = "UserApplicationError"; 7 | } 8 | } 9 | 10 | export class EmailAlreadyInUseError extends UserApplicationError { 11 | constructor(email: string) { 12 | super(`Email "${email}" is already in use.`); 13 | this.name = "EmailAlreadyInUseError"; 14 | } 15 | } 16 | 17 | export class EmailDomainAlreadyInUseError extends UserApplicationError { 18 | constructor(emailDomain: string) { 19 | super(`Email domain "${emailDomain}" is already in use.`); 20 | this.name = "EmailDomainAlreadyInUseError"; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/core/src/users/application/use-cases/AddNewUserUseCase.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../../domain/entities/User"; 2 | import { UserValidationError } from "../../domain/errors/UserValidationError"; 3 | import type { UserRepository } from "../../domain/repositories/UserRepository"; 4 | import { Address } from "../../domain/value-objects/Address.value"; 5 | import { Email } from "../../domain/value-objects/Email.value"; 6 | import { Id } from "../../domain/value-objects/Id.value"; 7 | import { Password } from "../../domain/value-objects/Password.value"; 8 | import { UserDTOMapper } from "../dto/UserDTOMapper"; 9 | import type { UserInputDTO } from "../dto/UserInputDTO"; 10 | import type { UserOutputDTO } from "../dto/UserOutputDTO"; 11 | import { 12 | EmailAlreadyInUseError, 13 | EmailDomainAlreadyInUseError, 14 | } from "../errors/UserApplicationError"; 15 | 16 | /* 17 | * Add New User (Use Case) 18 | * This use case is responsible for adding a new user to the system. 19 | * Application Rules: 20 | * - The application must not allow adding two users with the same email. 21 | * - Only one user per domain can exist (1 user with gmail, etc...). 22 | */ 23 | 24 | export class AddNewUserUseCase { 25 | constructor(private readonly usersRepository: UserRepository) {} 26 | 27 | async execute( 28 | userData: UserInputDTO, 29 | ): Promise<{ user?: UserOutputDTO; errors?: (UserValidationError | Error)[] }> { 30 | const errors: (UserValidationError | EmailAlreadyInUseError | EmailDomainAlreadyInUseError)[] = 31 | []; 32 | 33 | // Validating input data 34 | const emailOrError = Email.create(userData.email); 35 | if (emailOrError instanceof UserValidationError) errors.push(emailOrError); 36 | 37 | const passwordOrError = Password.create(userData.password); 38 | if (passwordOrError instanceof UserValidationError) errors.push(passwordOrError); 39 | 40 | const { address, city, zipCode } = userData; 41 | const addressOrError = Address.create({ address, city, zipCode }); 42 | if (addressOrError instanceof UserValidationError) errors.push(addressOrError); 43 | 44 | if (errors.length > 0) { 45 | return { errors }; 46 | } 47 | 48 | // Validating Business Rules 49 | const emailVO = emailOrError as Email; 50 | 51 | const existingEmailUser = await this.usersRepository.findByEmail(emailVO); 52 | if (existingEmailUser) errors.push(new EmailAlreadyInUseError(emailVO.value)); 53 | 54 | const emailDomain = emailVO.getDomain(); 55 | const existingEmailDomain = await this.usersRepository.findByDomain(emailDomain); 56 | if (existingEmailDomain) errors.push(new EmailDomainAlreadyInUseError(emailDomain)); 57 | 58 | if (errors.length > 0) { 59 | return { errors }; 60 | } 61 | 62 | // Creating User Entity and saving it 63 | const userId = Id.generate(); 64 | const userOrError = User.create({ 65 | id: userId as Id, 66 | username: userData.username, 67 | email: emailVO, 68 | password: passwordOrError as Password, 69 | address: addressOrError as Address, 70 | }); 71 | 72 | if (userOrError instanceof UserValidationError) { 73 | errors.push(userOrError); 74 | return { errors }; 75 | } 76 | 77 | const userSaved = await this.usersRepository.save(userOrError as User); 78 | const userDTO = UserDTOMapper.toOutputDTO(userSaved); 79 | return { user: userDTO }; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/core/src/users/application/use-cases/ListUsersUseCase.ts: -------------------------------------------------------------------------------- 1 | import type { UserRepository } from "../../domain/repositories/UserRepository"; 2 | import { UserDTOMapper } from "../dto/UserDTOMapper"; 3 | import type { UserOutputDTO } from "../dto/UserOutputDTO"; 4 | 5 | /** 6 | * List Users Use Case 7 | * This use case is responsible for retrieving the list of users from the system. 8 | * It does not enforce any business rules, it simply fetches the data. 9 | */ 10 | 11 | export class ListUsersUseCase { 12 | constructor(private userRepository: UserRepository) {} 13 | 14 | async execute(): Promise { 15 | const users = await this.userRepository.findAll(); 16 | 17 | return users.map((user) => UserDTOMapper.toOutputDTO(user)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/core/src/users/application/use-cases/__tests__/AddNewUserUseCase.test.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | import { User } from "../../../domain/entities/User"; 3 | import { Id } from "../../../domain/value-objects/Id.value"; 4 | import { Email } from "../../../domain/value-objects/Email.value"; 5 | import { Password } from "../../../domain/value-objects/Password.value"; 6 | import { Address } from "../../../domain/value-objects/Address.value"; 7 | import { AddNewUserUseCase } from "./../AddNewUserUseCase"; 8 | import { EmailAlreadyInUseError, EmailDomainAlreadyInUseError } from "../../errors/UserApplicationError"; 9 | 10 | describe("AddNewUser (UseCase)", () => { 11 | const userData = { 12 | id: "z5txh1ka", 13 | username: "testuser", 14 | email: "test@mail.com", 15 | password: "password123", 16 | address: "123 Test St", 17 | city: "Test City", 18 | zipCode: "12345", 19 | }; 20 | 21 | const userEntity = User.create({ 22 | id: Id.generate(userData.id) as Id, 23 | username: userData.username, 24 | email: Email.create(userData.email) as Email, 25 | password: Password.create(userData.password) as Password, 26 | address: Address.create({ 27 | address: userData.address, 28 | city: userData.city, 29 | zipCode: userData.zipCode, 30 | }) as Address, 31 | }) as User; 32 | 33 | let addNewUserUseCase: AddNewUserUseCase; 34 | let userRepositoryMock: any; 35 | 36 | beforeEach(() => { 37 | userRepositoryMock = { 38 | findByEmail: vi.fn(), 39 | findByDomain: vi.fn(), 40 | save: vi.fn(), 41 | }; 42 | addNewUserUseCase = new AddNewUserUseCase(userRepositoryMock); 43 | }); 44 | 45 | afterEach(() => { 46 | vi.clearAllMocks(); 47 | }); 48 | 49 | it("should add a new user", async () => { 50 | userRepositoryMock.findByEmail.mockResolvedValueOnce(null); 51 | userRepositoryMock.save.mockResolvedValueOnce(userEntity); 52 | 53 | const result = await addNewUserUseCase.execute(userData); 54 | expect(result.user?.username).toBe(userData.username); 55 | expect(result.user?.email).toBe(userData.email); 56 | expect(result.user).not.toHaveProperty("id"); 57 | expect(result.user).not.toHaveProperty("password"); 58 | }); 59 | 60 | it("should not let a user be added if the email has a domain that is already stored", async () => { 61 | userRepositoryMock.findByDomain.mockResolvedValueOnce(userEntity); 62 | 63 | const userDataWithSameDomain = { 64 | username: "testuser2", 65 | email: "samedomain@mail.com", 66 | password: "password123", 67 | address: "456 Another St", 68 | city: "Another City", 69 | zipCode: "67890", 70 | }; 71 | 72 | const result = await addNewUserUseCase.execute(userDataWithSameDomain); 73 | expect(result.errors).toContainEqual( 74 | new EmailDomainAlreadyInUseError("mail.com"), 75 | ); 76 | expect(userRepositoryMock.findByDomain).toHaveBeenCalledWith("mail.com"); 77 | expect(userRepositoryMock.save).not.toHaveBeenCalled(); 78 | expect(result.user).toBeUndefined(); 79 | }); 80 | 81 | it ("should not allow adding a user when the email is already in use", async () => { 82 | userRepositoryMock.findByEmail.mockResolvedValueOnce(userEntity); 83 | 84 | const result = await addNewUserUseCase.execute(userData); 85 | expect(result.errors).toContainEqual( 86 | new EmailAlreadyInUseError(userData.email), 87 | ); 88 | expect(userRepositoryMock.findByEmail).toHaveBeenCalledWith( 89 | Email.create(userData.email) as Email, 90 | ); 91 | expect(userRepositoryMock.save).not.toHaveBeenCalled(); 92 | expect(result.user).toBeUndefined(); 93 | }); 94 | 95 | it("should add a new user with a different domain", async () => { 96 | userRepositoryMock.findByDomain.mockResolvedValueOnce(null); 97 | 98 | const userDataWithDifferentDomain = { 99 | id: "qb2hxcba", 100 | username: "testuser2", 101 | email: "otherdomain@outlook.com", 102 | password: "password123", 103 | address: "", 104 | city: "", 105 | zipCode: "", 106 | }; 107 | const userWithDifferentDomain = User.create({ 108 | id: Id.generate(userDataWithDifferentDomain.id) as Id, 109 | username: userDataWithDifferentDomain.username, 110 | email: Email.create(userDataWithDifferentDomain.email) as Email, 111 | password: Password.create(userDataWithDifferentDomain.password) as Password, 112 | address: Address.create({ 113 | address: "Default Address", 114 | city: "Default City", 115 | zipCode: "00000", 116 | }) as Address, 117 | }) as User; 118 | 119 | userRepositoryMock.save.mockResolvedValueOnce(userWithDifferentDomain); 120 | const result = await addNewUserUseCase.execute(userDataWithDifferentDomain); 121 | expect(result.user).toBeDefined(); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /packages/core/src/users/application/use-cases/__tests__/ListUsersUseCase.test.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | import { User } from "../../../domain/entities/User"; 3 | import { Id } from "../../../domain/value-objects/Id.value"; 4 | import { Email } from "../../../domain/value-objects/Email.value"; 5 | import { Password } from "../../../domain/value-objects/Password.value"; 6 | import { Address } from "../../../domain/value-objects/Address.value"; 7 | import { ListUsersUseCase } from "../ListUsersUseCase"; 8 | 9 | describe("ListUsers (UseCase)", () => { 10 | const userData1 = { 11 | id: "bp9jbjbx", 12 | username: "testuser", 13 | email: "test@mail.com", 14 | password: "password123", 15 | address: "123 Test St", 16 | city: "Test City", 17 | zipCode: "12345", 18 | }; 19 | 20 | const userData2 = { 21 | id: "z5txh1ka", 22 | username: "testuser2", 23 | email: "test2@mail.com", 24 | password: "password123", 25 | address: "123 Test St", 26 | city: "Test City", 27 | zipCode: "12345", 28 | }; 29 | 30 | const userEntity1 = User.create({ 31 | id: Id.generate(userData1.id) as Id, 32 | username: userData1.username, 33 | email: Email.create(userData1.email) as Email, 34 | password: Password.create(userData1.password) as Password, 35 | address: Address.create({ 36 | address: userData1.address, 37 | city: userData1.city, 38 | zipCode: userData1.zipCode, 39 | }) as Address, 40 | }) as User; 41 | const userEntity2 = User.create({ 42 | id: Id.generate(userData2.id) as Id, 43 | username: userData2.username, 44 | email: Email.create(userData2.email) as Email, 45 | password: Password.create(userData2.password) as Password, 46 | address: Address.create({ 47 | address: userData2.address, 48 | city: userData2.city, 49 | zipCode: userData2.zipCode, 50 | }) as Address, 51 | }) as User; 52 | 53 | let listUsersUseCase: ListUsersUseCase; 54 | let userRepositoryMock: any; 55 | 56 | beforeEach(() => { 57 | userRepositoryMock = { 58 | findAll: vi.fn(), 59 | }; 60 | listUsersUseCase = new ListUsersUseCase(userRepositoryMock); 61 | }); 62 | 63 | it("should return a list of users", async () => { 64 | userRepositoryMock.findAll.mockResolvedValueOnce([userEntity1, userEntity2]); 65 | 66 | const result = await listUsersUseCase.execute(); 67 | 68 | expect(userRepositoryMock.findAll).toHaveBeenCalled(); 69 | expect(result).toBeInstanceOf(Array); 70 | expect(result.length).toBe(2); 71 | expect(result[0].username).toBe(userData1.username); 72 | expect(result[0].email).toBe(userData1.email); 73 | expect(result[1].username).toBe(userData2.username); 74 | expect(result[1].email).toBe(userData2.email); 75 | }); 76 | 77 | it("should return an empty list if no users are found", async () => { 78 | userRepositoryMock.findAll.mockResolvedValueOnce([]); 79 | 80 | const result = await listUsersUseCase.execute(); 81 | 82 | expect(userRepositoryMock.findAll).toHaveBeenCalled(); 83 | expect(result).toEqual([]); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /packages/core/src/users/domain/entities/User.ts: -------------------------------------------------------------------------------- 1 | import { EmptyUsernameError, type UserValidationError } from "../errors/UserValidationError"; 2 | import type { Address } from "../value-objects/Address.value"; 3 | import type { Email } from "../value-objects/Email.value"; 4 | import type { Id } from "../value-objects/Id.value"; 5 | import type { Password } from "../value-objects/Password.value"; 6 | 7 | export interface UserData { 8 | id: Id; 9 | username: string; 10 | email: Email; 11 | password: Password; 12 | address: Address; 13 | } 14 | 15 | /** 16 | * User Entity 17 | * This entity represents a user in the system. 18 | * Rules: 19 | * - A user must have a unique ID. (Value Object: Id) 20 | * - A user must have a username. 21 | * - A user must have an email. (Unique, Value Object: Email) 22 | * - A user must have a password. (Value Object: Password) 23 | * - A user may have an address. (Optionally, Value Object: Address) 24 | */ 25 | 26 | export class User { 27 | private constructor( 28 | public readonly id: Id, 29 | public readonly username: string, 30 | public readonly email: Email, 31 | public readonly password: Password, 32 | public readonly address: Address, 33 | ) {} 34 | 35 | public static create(data: UserData): User | UserValidationError { 36 | if (!data.username || data.username.trim() === "") { 37 | return new EmptyUsernameError(); 38 | } 39 | 40 | const { id, username, email, password, address } = data; 41 | return new User(id, username, email, password, address); 42 | } 43 | 44 | public toString(): string { 45 | return JSON.stringify({ 46 | id: this.id.toString(), 47 | username: this.username, 48 | email: this.email.toString(), 49 | password: this.password.toString(), 50 | address: this.address.toString(), 51 | }); 52 | } 53 | 54 | public equals(other: User): boolean { 55 | return this.id.equals(other.id); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/core/src/users/domain/entities/__tests__/User.test.ts: -------------------------------------------------------------------------------- 1 | import { REGEX_ID } from "../../value-objects/Id.value"; 2 | import { User } from "../User"; 3 | import { Id } from "../../value-objects/Id.value"; 4 | import { Email } from "../../value-objects/Email.value"; 5 | import { Password } from "../../value-objects/Password.value"; 6 | import { Address } from "../../value-objects/Address.value"; 7 | import { EmptyUsernameError } from "../../errors/UserValidationError"; 8 | 9 | describe("User (Entity)", () => { 10 | const id = Id.generate("z5txh1ka") as Id; 11 | const idEmpty = Id.generate() as Id; 12 | const email = Email.create("cazaustre@gmail.com") as Email; 13 | const password = Password.create("password123") as Password; 14 | const address = Address.create({ 15 | address: "123 Main St", 16 | city: "Madrid", 17 | zipCode: "28001", 18 | }) as Address; 19 | 20 | const emptyAddress = Address.create({ 21 | address: "", 22 | city: "", 23 | zipCode: "", 24 | }) as Address; 25 | 26 | it("should return an error if the username is empty", () => { 27 | const userResult = User.create({ 28 | id, 29 | username: "", 30 | email, 31 | password, 32 | address: emptyAddress, 33 | }); 34 | expect(userResult).toBeInstanceOf(EmptyUsernameError); 35 | if (userResult instanceof EmptyUsernameError) { 36 | expect(userResult.field).toBe("username"); 37 | expect(userResult.details).toBe("Username cannot be empty"); 38 | } 39 | }); 40 | 41 | it("should return the email domain from the user", () => { 42 | const userResult = User.create({ 43 | id, 44 | username: "Carlos", 45 | email, 46 | password, 47 | address: emptyAddress, 48 | }); 49 | 50 | expect(userResult).toBeInstanceOf(User); 51 | if (userResult instanceof User) { 52 | expect(userResult.email.getDomain()).toBe("gmail.com"); 53 | } 54 | }); 55 | 56 | it("should create a valid user without provide an Id", () => { 57 | const userResult = User.create({ 58 | id: idEmpty, 59 | username: "Carlos", 60 | email, 61 | password, 62 | address, 63 | }); 64 | 65 | expect(userResult).toBeInstanceOf(User); 66 | if (userResult instanceof User) { 67 | expect(userResult.id.value).toBeDefined(); 68 | expect(REGEX_ID.test(userResult.id.value)).toBe(true); 69 | expect(userResult.username).toBe("Carlos"); 70 | expect(userResult.email.value).toBe("cazaustre@gmail.com"); 71 | expect(userResult.password.value).toBe("password123"); 72 | } 73 | }); 74 | 75 | it("should create a valid user with an Id", () => { 76 | const userResult = User.create({ 77 | id, 78 | username: "Carlos", 79 | email, 80 | password, 81 | address: emptyAddress, 82 | }); 83 | 84 | expect(userResult).toBeInstanceOf(User); 85 | if (userResult instanceof User) { 86 | expect(REGEX_ID.test(userResult.id.value)).toBe(true); 87 | expect(userResult.id.value).toBe("z5txh1ka"); 88 | expect(userResult.username).toBe("Carlos"); 89 | expect(userResult.email.value).toBe("cazaustre@gmail.com"); 90 | expect(userResult.password.value).toBe("password123"); 91 | } 92 | }); 93 | 94 | it("should create a valid user with an address", () => { 95 | const userResultWithAddress = User.create({ 96 | id, 97 | username: "Carlos", 98 | email, 99 | password, 100 | address, 101 | }); 102 | 103 | expect(userResultWithAddress).toBeInstanceOf(User); 104 | if (userResultWithAddress instanceof User) { 105 | expect(userResultWithAddress.address).toBeInstanceOf(Address); 106 | expect(userResultWithAddress.address.value.address).toBe("123 Main St"); 107 | expect(userResultWithAddress.address.value.city).toBe("Madrid"); 108 | expect(userResultWithAddress.address.value.zipCode).toBe("28001"); 109 | } 110 | }); 111 | 112 | it("should equal for two users with the same Id", () => { 113 | const user1 = User.create({ 114 | id, 115 | username: "Usuario1", 116 | email, 117 | password, 118 | address: emptyAddress, 119 | }); 120 | const user2 = User.create({ 121 | id, 122 | username: "Usuario2", 123 | email, 124 | password, 125 | address, 126 | }); 127 | 128 | expect(user1).toBeInstanceOf(User); 129 | expect(user2).toBeInstanceOf(User); 130 | if (user1 instanceof User && user2 instanceof User) { 131 | expect(user1.equals(user2)).toBe(true); 132 | } 133 | }); 134 | 135 | it("should not equal for two users with different Ids", () => { 136 | const user1 = User.create({ 137 | id, 138 | username: "Usuario1", 139 | email, 140 | password, 141 | address: emptyAddress, 142 | }); 143 | const user2 = User.create({ 144 | id: Id.generate("a1b2c3d4") as Id, 145 | username: "Usuario1", 146 | email, 147 | password, 148 | address: emptyAddress, 149 | }); 150 | 151 | expect(user1).toBeInstanceOf(User); 152 | expect(user2).toBeInstanceOf(User); 153 | if (user1 instanceof User && user2 instanceof User) { 154 | expect(user1.equals(user2)).toBe(false); 155 | } 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /packages/core/src/users/domain/errors/UserValidationError.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../shared/errors/ValidationError"; 2 | 3 | export class UserValidationError extends ValidationError { 4 | constructor(field: string, details: string) { 5 | super(field, details); 6 | this.name = "UserValidationError"; 7 | } 8 | } 9 | 10 | // Specific User Validation Errors 11 | export class EmptyUsernameError extends UserValidationError { 12 | constructor() { 13 | super("username", "Username cannot be empty"); 14 | this.name = "EmptyUsernameError"; 15 | } 16 | } 17 | 18 | // Specific Password Validation Errors 19 | export class EmptyPasswordError extends UserValidationError { 20 | constructor() { 21 | super("password", "Password cannot be empty"); 22 | this.name = "EmptyPasswordError"; 23 | } 24 | } 25 | 26 | export class InvalidFormatPasswordError extends UserValidationError { 27 | constructor() { 28 | super( 29 | "password", 30 | "Password must be at least 8 characters long and contain at least one letter and one digit", 31 | ); 32 | this.name = "InvalidFormatPasswordError"; 33 | } 34 | } 35 | 36 | // Specific Id Validation Errors 37 | export class InvalidIdFormatError extends UserValidationError { 38 | constructor() { 39 | super("id", "ID must be a valid ID (8 characters long)"); 40 | this.name = "InvalidIdFormatError"; 41 | } 42 | } 43 | 44 | // Specific Email Validation Errors 45 | export class EmptyEmailError extends UserValidationError { 46 | constructor() { 47 | super("email", "Email cannot be empty"); 48 | this.name = "EmptyEmailError"; 49 | } 50 | } 51 | 52 | export class InvalidEmailFormatError extends UserValidationError { 53 | constructor() { 54 | super("email", "Email must be a valid email address"); 55 | this.name = "InvalidEmailFormatError"; 56 | } 57 | } 58 | 59 | // Specific Address Validation Errors 60 | export class InvalidZipCodeFormatError extends UserValidationError { 61 | constructor() { 62 | super("zipCode", "Zip code must be a valid zip code (5 digits)"); 63 | this.name = "InvalidZipCodeFormatError"; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/core/src/users/domain/repositories/UserRepository.ts: -------------------------------------------------------------------------------- 1 | import type { User } from "../entities/User"; 2 | import type { Email } from "../value-objects/Email.value"; 3 | import type { Id } from "../value-objects/Id.value"; 4 | 5 | export interface UserRepository { 6 | findById(id: Id): Promise; 7 | findByEmail(email: Email): Promise; 8 | findByDomain(domain: string): Promise; 9 | findAll(): Promise; 10 | save(user: User): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /packages/core/src/users/domain/value-objects/Address.value.ts: -------------------------------------------------------------------------------- 1 | import { InvalidZipCodeFormatError, type UserValidationError } from "../errors/UserValidationError"; 2 | 3 | const REGEX_ZIPCODE = /^\d{5}$/; 4 | 5 | export interface AddressProps { 6 | address: string | undefined; 7 | city: string | undefined; 8 | zipCode: string | undefined; 9 | } 10 | 11 | /* 12 | * Address Value Object 13 | * Rules: 14 | * - Address must contain a street, city, and zip code 15 | * - ZIP code must be exactly 5 characters long 16 | */ 17 | 18 | export class Address { 19 | private constructor(public readonly value: AddressProps) {} 20 | 21 | public static create(props: AddressProps): Address | UserValidationError { 22 | if (props.zipCode && !REGEX_ZIPCODE.test(props.zipCode)) { 23 | return new InvalidZipCodeFormatError(); 24 | } 25 | 26 | return new Address({ 27 | address: props.address || "", 28 | city: props.city || "", 29 | zipCode: props.zipCode ? props.zipCode.trim() : "", 30 | }); 31 | } 32 | 33 | public toString(): string { 34 | return JSON.stringify(this.value); 35 | } 36 | 37 | public equals(other: Address): boolean { 38 | return ( 39 | this.value.address === other.value.address && 40 | this.value.city === other.value.city && 41 | this.value.zipCode === other.value.zipCode 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/core/src/users/domain/value-objects/Email.value.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EmptyEmailError, 3 | InvalidEmailFormatError, 4 | type UserValidationError, 5 | } from "../errors/UserValidationError"; 6 | 7 | // RFC 5321 compliant regex for email validation 8 | const EMAIL_PATTERN = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; 9 | 10 | /* 11 | * Email Value Object 12 | * Rules: 13 | * - Email must be a valid email address (RFC 5321 compliant) 14 | * - Email cannot be empty 15 | * - Email must be formatted to lowercase 16 | * - Email must be trimmed 17 | */ 18 | 19 | export class Email { 20 | private constructor(public readonly value: string) {} 21 | 22 | public static create(email: string): Email | UserValidationError { 23 | if (!email || email.trim() === "") { 24 | return new EmptyEmailError(); 25 | } 26 | if (!EMAIL_PATTERN.test(email)) { 27 | return new InvalidEmailFormatError(); 28 | } 29 | 30 | const normalizedEmail = email.trim().toLowerCase(); 31 | return new Email(normalizedEmail); 32 | } 33 | 34 | public getDomain(): string { 35 | const domain = this.value.split("@")[1]; 36 | return domain; 37 | } 38 | 39 | public toString(): string { 40 | return this.value; 41 | } 42 | 43 | public equals(other: Email): boolean { 44 | return this.value === other.value; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/core/src/users/domain/value-objects/Id.value.ts: -------------------------------------------------------------------------------- 1 | import { InvalidIdFormatError, type UserValidationError } from "../errors/UserValidationError"; 2 | 3 | export const REGEX_ID = /^[a-zA-Z0-9]{8}$/; 4 | 5 | /* 6 | * ID Value Object 7 | * Rules: 8 | * - ID must be a valid ID (8 characters long) 9 | * - Generate a new ID if not provided, using Math.random() for simplicity and 10 | * to avoid use external libraries like crypto or uiid 11 | */ 12 | 13 | export class Id { 14 | private constructor(public readonly value: string) {} 15 | 16 | public static generate(id?: string): Id | UserValidationError { 17 | const idValue = id ?? Math.random().toString(36).substring(2, 10); 18 | 19 | if (REGEX_ID.test(idValue) === false) { 20 | return new InvalidIdFormatError(); 21 | } 22 | 23 | return new Id(idValue); 24 | } 25 | 26 | public toString(): string { 27 | return this.value; 28 | } 29 | 30 | public equals(other: Id): boolean { 31 | return this.value === other.value; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/core/src/users/domain/value-objects/Password.value.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EmptyPasswordError, 3 | InvalidFormatPasswordError, 4 | type UserValidationError, 5 | } from "../errors/UserValidationError"; 6 | 7 | const PASSWORD_REGEX = /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z\d]{8,}$/; 8 | 9 | /* 10 | * Password Value Object 11 | * Rules: 12 | * - Password must be at least 8 characters long 13 | * - Password must contain at least one letter and one digit 14 | * - Password cannot be empty 15 | */ 16 | 17 | export class Password { 18 | private constructor(public readonly value: string) {} 19 | 20 | public static create(password: string): Password | UserValidationError { 21 | if (!password || password.trim() === "") { 22 | return new EmptyPasswordError(); 23 | } 24 | if (PASSWORD_REGEX.test(password) === false) { 25 | return new InvalidFormatPasswordError(); 26 | } 27 | 28 | return new Password(password.trim()); 29 | } 30 | 31 | public toString(): string { 32 | return this.value; 33 | } 34 | 35 | public equals(other: Password): boolean { 36 | return this.value === other.value; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/core/src/users/domain/value-objects/__tests__/Address.value.test.ts: -------------------------------------------------------------------------------- 1 | import { Address } from "../Address.value"; 2 | import { InvalidZipCodeFormatError } from "../../errors/UserValidationError"; 3 | 4 | describe("Address (Value Object)", () => { 5 | const validAddress = { 6 | address: "123 Main St", 7 | city: "Springfield", 8 | zipCode: "10001", 9 | }; 10 | 11 | const invalidAddress = { 12 | address: "123 Main St", 13 | city: "Springfield", 14 | zipCode: "9999", 15 | }; 16 | 17 | it("should return an error for an invalid ZIP address", () => { 18 | const addressResult = Address.create(invalidAddress); 19 | expect(addressResult).toBeInstanceOf(InvalidZipCodeFormatError); 20 | if (addressResult instanceof InvalidZipCodeFormatError) { 21 | expect(addressResult.field).toBe("zipCode"); 22 | expect(addressResult.details).toBe("Zip code must be a valid zip code (5 digits)"); 23 | } 24 | }); 25 | 26 | it("should create a valid address", () => { 27 | const addressResult = Address.create(validAddress); 28 | expect(addressResult).toBeInstanceOf(Address); 29 | if (addressResult instanceof Address) { 30 | expect(addressResult.value).toEqual(validAddress); 31 | } 32 | }); 33 | 34 | it("should create an empty address if no values are provided", () => { 35 | const addressResult = Address.create({ 36 | address: undefined, 37 | city: undefined, 38 | zipCode: undefined, 39 | }); 40 | 41 | expect(addressResult).toBeInstanceOf(Address); 42 | if (addressResult instanceof Address) { 43 | expect(addressResult.value).toEqual({ 44 | address: "", 45 | city: "", 46 | zipCode: "", 47 | }); 48 | } 49 | }); 50 | 51 | it("should return a string representation of the address", () => { 52 | const addressResult = Address.create(validAddress); 53 | expect(addressResult).toBeInstanceOf(Address); 54 | if (addressResult instanceof Address) { 55 | expect(addressResult.toString()).toBe(JSON.stringify(validAddress)); 56 | } 57 | }); 58 | 59 | it("should be equal for the same address", () => { 60 | const address1 = Address.create(validAddress); 61 | const address2 = Address.create(validAddress); 62 | expect(address1).toBeInstanceOf(Address); 63 | expect(address2).toBeInstanceOf(Address); 64 | if (address1 instanceof Address && address2 instanceof Address) { 65 | expect(address1.equals(address2)).toBe(true); 66 | } 67 | }); 68 | 69 | it("should not be equal for different addresses", () => { 70 | const address1 = Address.create(validAddress); 71 | const address2 = Address.create({ 72 | address: "456 Elm St", 73 | city: "Springfield", 74 | zipCode: "10001", 75 | }); 76 | expect(address1).toBeInstanceOf(Address); 77 | expect(address2).toBeInstanceOf(Address); 78 | if (address1 instanceof Address && address2 instanceof Address) { 79 | expect(address1.equals(address2)).toBe(false); 80 | } 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /packages/core/src/users/domain/value-objects/__tests__/Email.value.test.ts: -------------------------------------------------------------------------------- 1 | import { EmptyEmailError, InvalidEmailFormatError } from "../../errors/UserValidationError"; 2 | import { Email } from "../Email.value"; 3 | 4 | describe("Email (Value Object)", () => { 5 | const validEmail = "cazaustre@gmail.com"; 6 | const invalidEmail = "invalid-email"; 7 | const emptyEmail = ""; 8 | 9 | it("should return an error for an empty email", () => { 10 | const emailResult = Email.create(emptyEmail); 11 | expect(emailResult).toBeInstanceOf(EmptyEmailError); 12 | if (emailResult instanceof EmptyEmailError) { 13 | expect(emailResult.field).toBe("email"); 14 | expect(emailResult.details).toBe("Email cannot be empty"); 15 | } 16 | }); 17 | 18 | it("Should return an error for an invalid email", () => { 19 | const emailResult = Email.create(invalidEmail); 20 | expect(emailResult).toBeInstanceOf(InvalidEmailFormatError); 21 | if (emailResult instanceof InvalidEmailFormatError) { 22 | expect(emailResult.field).toBe("email"); 23 | expect(emailResult.details).toBe("Email must be a valid email address"); 24 | } 25 | }); 26 | 27 | it("should create a valid email", () => { 28 | const emailResult = Email.create(validEmail); 29 | expect(emailResult).toBeInstanceOf(Email); 30 | if (emailResult instanceof Email) { 31 | expect(emailResult.value).toBe(validEmail.toLowerCase()); 32 | } 33 | }); 34 | 35 | it("should lowercase the email", () => { 36 | const emailResult = Email.create("CAZAUSTRE@GMAIL.COM"); 37 | expect(emailResult).toBeInstanceOf(Email); 38 | if (emailResult instanceof Email) { 39 | expect(emailResult.value).toBe(validEmail); 40 | } 41 | }); 42 | 43 | it("should return the domain of the email", () => { 44 | const emailResult = Email.create(validEmail); 45 | expect(emailResult).toBeInstanceOf(Email); 46 | if (emailResult instanceof Email) { 47 | expect(emailResult.getDomain()).toBe("gmail.com"); 48 | } 49 | }); 50 | 51 | it("should be equal for the same email", () => { 52 | const email1 = Email.create(validEmail); 53 | const email2 = Email.create(validEmail); 54 | expect(email1).toBeInstanceOf(Email); 55 | expect(email2).toBeInstanceOf(Email); 56 | if (email1 instanceof Email && email2 instanceof Email) { 57 | expect(email1.equals(email2)).toBe(true); 58 | } 59 | }); 60 | 61 | it("should not be equal for different emails", () => { 62 | const email1 = Email.create(validEmail); 63 | const email2 = Email.create("otheremail@a.com"); 64 | expect(email1).toBeInstanceOf(Email); 65 | expect(email2).toBeInstanceOf(Email); 66 | if (email1 instanceof Email && email2 instanceof Email) { 67 | expect(email1.equals(email2)).toBe(false); 68 | } 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /packages/core/src/users/domain/value-objects/__tests__/Id.value.test.ts: -------------------------------------------------------------------------------- 1 | import { Id, REGEX_ID } from "../Id.value"; 2 | import { InvalidIdFormatError } from "../../errors/UserValidationError"; 3 | 4 | describe("Id (Value Object)", () => { 5 | // ID format is 8 characters using Math.random().toString(36).substring(2, 10) 6 | const validId = "6eck39s4" 7 | const invalidId = "a"; 8 | 9 | it("should generate a valid ID if ID is not provided", () => { 10 | const idResult = Id.generate(); 11 | expect(idResult).toBeInstanceOf(Id); 12 | if (idResult instanceof Id) { 13 | expect(REGEX_ID.test(idResult.value)).toBe(true); 14 | } 15 | }); 16 | 17 | it("should validate the ID if ID is provided", () => { 18 | const idResult = Id.generate(validId); 19 | expect(idResult).toBeInstanceOf(Id); 20 | if (idResult instanceof Id) { 21 | expect(idResult.value).toBe(validId); 22 | expect(REGEX_ID.test(idResult.value)).toBe(true); 23 | } 24 | }); 25 | 26 | it("should return an error for invalid ID", () => { 27 | const idResult = Id.generate(invalidId); 28 | expect(idResult).toBeInstanceOf(InvalidIdFormatError); 29 | if (idResult instanceof InvalidIdFormatError) { 30 | expect(idResult.field).toBe("id"); 31 | expect(idResult.details).toBe("ID must be a valid ID (8 characters long)"); 32 | } 33 | }); 34 | 35 | it("should be equal for the same ID", () => { 36 | const id1 = Id.generate(validId); 37 | const id2 = Id.generate(validId); 38 | expect(id1).toBeInstanceOf(Id); 39 | expect(id2).toBeInstanceOf(Id); 40 | if (id1 instanceof Id && id2 instanceof Id) { 41 | expect(id1.equals(id2)).toBe(true); 42 | } 43 | }); 44 | 45 | it("should not be equal for different valid IDs", () => { 46 | const id1 = Id.generate(validId); 47 | const id2 = Id.generate("12345678"); 48 | expect(id1).toBeInstanceOf(Id); 49 | expect(id2).toBeInstanceOf(Id); 50 | if (id1 instanceof Id && id2 instanceof Id) { 51 | expect(id1.equals(id2)).toBe(false); 52 | } 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /packages/core/src/users/domain/value-objects/__tests__/Password.value.test.ts: -------------------------------------------------------------------------------- 1 | import { Password } from "../Password.value"; 2 | import { 3 | EmptyPasswordError, 4 | InvalidFormatPasswordError 5 | } from "../../errors/UserValidationError"; 6 | 7 | describe("Password (Value Object)", () => { 8 | const validPassword = "password123"; 9 | const invalidPassword = "short"; 10 | const emptyPassword = ""; 11 | 12 | it("should return an error for empty password", () => { 13 | const passwordResult = Password.create(emptyPassword); 14 | expect(passwordResult).toBeInstanceOf(EmptyPasswordError); 15 | if (passwordResult instanceof EmptyPasswordError) { 16 | expect(passwordResult.field).toBe("password"); 17 | expect(passwordResult.details).toBe("Password cannot be empty"); 18 | } 19 | }); 20 | 21 | it("should return an error for invalid password format", () => { 22 | const passwordResult = Password.create(invalidPassword); 23 | expect(passwordResult).toBeInstanceOf(InvalidFormatPasswordError); 24 | if (passwordResult instanceof InvalidFormatPasswordError) { 25 | expect(passwordResult.field).toBe("password"); 26 | expect(passwordResult.details).toBe("Password must be at least 8 characters long and contain at least one letter and one digit"); 27 | } 28 | }); 29 | 30 | it("should create a valid password", () => { 31 | const passwordResult = Password.create(validPassword); 32 | expect(passwordResult).toBeInstanceOf(Password); 33 | if (passwordResult instanceof Password) { 34 | expect(passwordResult.value).toBe(validPassword); 35 | } 36 | }); 37 | 38 | it("should be equal for the same password", () => { 39 | const password1 = Password.create(validPassword); 40 | const password2 = Password.create(validPassword); 41 | expect(password1).toBeInstanceOf(Password); 42 | expect(password2).toBeInstanceOf(Password); 43 | if (password1 instanceof Password && password2 instanceof Password) { 44 | expect(password1.equals(password2)).toBe(true); 45 | } 46 | }); 47 | 48 | it("should not be equal for different passwords", () => { 49 | const password1 = Password.create(validPassword); 50 | const password2 = Password.create("anotherPassword123"); 51 | expect(password1).toBeInstanceOf(Password); 52 | expect(password2).toBeInstanceOf(Password); 53 | if (password1 instanceof Password && password2 instanceof Password) { 54 | expect(password1.equals(password2)).toBe(false); 55 | } 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /packages/core/src/users/index.ts: -------------------------------------------------------------------------------- 1 | // Barrel file for the Users module 2 | export * from "./presentation/UsersView"; 3 | export * from "./presentation/UsersPresenter"; 4 | export type { UserInputDTO } from "./application/dto/UserInputDTO"; 5 | export type { UserOutputDTO } from "./application/dto/UserOutputDTO"; 6 | -------------------------------------------------------------------------------- /packages/core/src/users/infra/database/UserDBModel.ts: -------------------------------------------------------------------------------- 1 | export interface UserModel { 2 | id: string; 3 | username: string; 4 | email: string; 5 | password: string; 6 | address?: string; 7 | city?: string; 8 | zipCode?: string; 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/src/users/infra/database/UserDatabaseRepository.ts: -------------------------------------------------------------------------------- 1 | import type { Collection, Db, MongoClient } from "mongodb"; 2 | import type { User } from "../../domain/entities/User"; 3 | import type { UserRepository } from "../../domain/repositories/UserRepository"; 4 | import type { Email } from "../../domain/value-objects/Email.value"; 5 | import type { Id } from "../../domain/value-objects/Id.value"; 6 | import { UserPersistenceOnDbCorruptionError } from "../errors/UserInfraError"; 7 | import type { UserModel } from "./UserDBModel"; 8 | import { UserPersistenceMapper } from "./UserPersistenceMapper"; 9 | 10 | export class UserDatabaseRepository implements UserRepository { 11 | private db: Db; 12 | private collection: Collection; 13 | 14 | constructor( 15 | private readonly client: MongoClient, 16 | private readonly dbName: string, 17 | ) { 18 | this.db = this.client.db(this.dbName); 19 | this.collection = this.db.collection("users"); 20 | } 21 | 22 | async findById(id: Id): Promise { 23 | const document = await this.collection.findOne({ id: id.value }); 24 | if (!document) { 25 | return null; 26 | } 27 | 28 | const userOrError = UserPersistenceMapper.toDomain(document) as User; 29 | if (userOrError instanceof Error) { 30 | throw new UserPersistenceOnDbCorruptionError( 31 | `Failed to map User document with id ${id.value} to User entity: ${userOrError.message}`, 32 | document, 33 | ); 34 | } 35 | 36 | return userOrError as User; 37 | } 38 | 39 | async findByEmail(email: Email): Promise { 40 | const document = await this.collection.findOne({ email: email.value }); 41 | if (!document) { 42 | return null; 43 | } 44 | 45 | const userOrError = UserPersistenceMapper.toDomain(document); 46 | if (userOrError instanceof Error) { 47 | throw new UserPersistenceOnDbCorruptionError( 48 | `Failed to map User document with email ${email.value} to User entity: ${userOrError.message}`, 49 | document, 50 | ); 51 | } 52 | 53 | return userOrError as User; 54 | } 55 | 56 | async findByDomain(domain: string): Promise { 57 | const regex = new RegExp(`@${domain}$`, "i"); 58 | const document = await this.collection.findOne({ email: { $regex: regex } }); 59 | if (!document) { 60 | return null; 61 | } 62 | 63 | const userOrError = UserPersistenceMapper.toDomain(document); 64 | if (userOrError instanceof Error) { 65 | throw new UserPersistenceOnDbCorruptionError( 66 | `Failed to map User document with domain ${domain} to User entity: ${userOrError.message}`, 67 | document, 68 | ); 69 | } 70 | 71 | return userOrError as User; 72 | } 73 | 74 | async findAll(): Promise { 75 | const documents = await this.collection.find({}).toArray(); 76 | 77 | return documents.map((doc) => { 78 | const userOrError = UserPersistenceMapper.toDomain(doc); 79 | if (userOrError instanceof Error) { 80 | throw new UserPersistenceOnDbCorruptionError( 81 | `Failed to map User document with id ${doc.id} to User entity: ${userOrError.message}`, 82 | doc, 83 | ); 84 | } 85 | 86 | return userOrError as User; 87 | }); 88 | } 89 | 90 | async save(user: User): Promise { 91 | const document = UserPersistenceMapper.toPersistence(user); 92 | if (document instanceof Error) { 93 | throw new UserPersistenceOnDbCorruptionError( 94 | `Failed to map User entity with id ${user.id.value} to User document: ${document.message}`, 95 | user, 96 | ); 97 | } 98 | 99 | await this.collection.insertOne(document); 100 | return user; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /packages/core/src/users/infra/database/UserPersistenceMapper.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../../domain/entities/User"; 2 | import { Address } from "../../domain/value-objects/Address.value"; 3 | import { Email } from "../../domain/value-objects/Email.value"; 4 | import { Id } from "../../domain/value-objects/Id.value"; 5 | import { Password } from "../../domain/value-objects/Password.value"; 6 | import type { UserModel } from "./UserDBModel"; 7 | 8 | export const UserPersistenceMapper = { 9 | toDomain: (raw: UserModel): User | Error => { 10 | const IdOrError = Id.generate(raw.id); 11 | const emailOrError = Email.create(raw.email); 12 | const passwordOrError = Password.create(raw.password); 13 | const addressOrError = Address.create({ 14 | address: raw.address, 15 | city: raw.city, 16 | zipCode: raw.zipCode, 17 | }); 18 | 19 | if (IdOrError instanceof Error) return IdOrError; 20 | if (emailOrError instanceof Error) return emailOrError; 21 | if (passwordOrError instanceof Error) return passwordOrError; 22 | if (addressOrError instanceof Error) return addressOrError; 23 | 24 | return User.create({ 25 | id: IdOrError, 26 | username: raw.username, 27 | email: emailOrError, 28 | password: passwordOrError, 29 | address: addressOrError, 30 | }); 31 | }, 32 | 33 | toPersistence: (user: User): UserModel => { 34 | return { 35 | id: user.id.value, 36 | username: user.username, 37 | email: user.email.value, 38 | password: user.password.value, 39 | address: user.address.value.address, 40 | city: user.address.value.city, 41 | zipCode: user.address.value.zipCode, 42 | }; 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /packages/core/src/users/infra/errors/UserInfraError.ts: -------------------------------------------------------------------------------- 1 | import { PersistenceCorruptionError } from "../../../shared/errors/PersistenceCorruptionError"; 2 | 3 | export class UserPersistenceOnDbCorruptionError extends PersistenceCorruptionError { 4 | constructor( 5 | public readonly errorCause: Error | string, 6 | // biome-ignore lint/suspicious/noExplicitAny: 7 | public readonly rawData?: any, 8 | ) { 9 | super("User", errorCause, rawData); 10 | this.name = "UserPersistenceOnDBCorruptionError"; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/core/src/users/infra/memory/UserInMemoryRepository.ts: -------------------------------------------------------------------------------- 1 | import type { User } from "../../domain/entities/User"; 2 | import type { UserRepository } from "../../domain/repositories/UserRepository"; 3 | import type { Email } from "../../domain/value-objects/Email.value"; 4 | import type { Id } from "../../domain/value-objects/Id.value"; 5 | 6 | export class UserInMemoryRepository implements UserRepository { 7 | private users: User[] = []; 8 | 9 | async findById(id: Id): Promise { 10 | return this.users.find((user) => user.id.equals(id)) || null; 11 | } 12 | 13 | async findByEmail(email: Email): Promise { 14 | return this.users.find((user) => user.email.equals(email)) || null; 15 | } 16 | 17 | async findByDomain(domain: string): Promise { 18 | return ( 19 | this.users.find( 20 | (user) => user.email.value.split("@")[1].toLowerCase() === domain.toLowerCase(), 21 | ) || null 22 | ); 23 | } 24 | 25 | async findAll(): Promise { 26 | return this.users; 27 | } 28 | 29 | async save(user: User): Promise { 30 | this.users.push(user); 31 | return user; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/core/src/users/presentation/UsersPresenter.ts: -------------------------------------------------------------------------------- 1 | import type { UserOutputDTO as UserDataView } from "../application/dto/UserOutputDTO"; 2 | import type { AddNewUserUseCase } from "../application/use-cases/AddNewUserUseCase"; 3 | import type { ListUsersUseCase } from "../application/use-cases/ListUsersUseCase"; 4 | import type { UsersView } from "./UsersView"; 5 | 6 | export class UsersPresenter { 7 | constructor( 8 | private view: UsersView, 9 | private addNewUserUseCase: AddNewUserUseCase, 10 | private listUsersUseCase: ListUsersUseCase, 11 | ) { 12 | this.view.onClose(() => { 13 | this.view.showGoodbyeMessage(); 14 | process.exit(0); 15 | }); 16 | } 17 | 18 | /** 19 | * Initializes the users presenter. 20 | * Shows a welcome message in the view and loads the user list. 21 | */ 22 | async start(): Promise { 23 | this.view.showWelcomeMessage(); 24 | await this.menu(); 25 | } 26 | 27 | /** 28 | * Main loop that handles the user interface interactions. 29 | * It lists users and allows adding new users. 30 | */ 31 | async menu(): Promise { 32 | await this.handleListUsers(); 33 | 34 | while (true) { 35 | const action = await this.view.requestInput( 36 | "\nWhat would you like to do? (1: Add user, 2: List users, 3: Exit)", 37 | ); 38 | 39 | switch (action) { 40 | case "1": 41 | await this.handleAddNewUser(); 42 | break; 43 | case "2": 44 | await this.handleListUsers(); 45 | break; 46 | case "3": 47 | this.view.showGoodbyeMessage(); 48 | process.exit(0); 49 | break; 50 | default: 51 | this.view.showError("Invalid option. Please choose 1, 2, or 3."); 52 | } 53 | } 54 | } 55 | 56 | /** 57 | * Handles the process of listing users by executing the list users use case. 58 | * If an error occurs, it shows the error message. 59 | */ 60 | async handleListUsers(): Promise { 61 | try { 62 | const result = await this.listUsersUseCase.execute(); 63 | 64 | if ("errors" in result && result.errors) { 65 | const errorMessages = Array.isArray(result.errors) 66 | ? result.errors.join(", ") 67 | : result.errors; 68 | this.view.showError(`Failed to list users: ${errorMessages}`); 69 | return; 70 | } 71 | 72 | if ("users" in result && result.users) { 73 | this.view.showUserList(result.users as UserDataView[]); 74 | } else { 75 | // biome-ignore lint/suspicious/noExplicitAny: 76 | this.view.showUserList(result as any); 77 | } 78 | } catch (error: unknown) { 79 | const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; 80 | this.view.showError(`Failed to list users: ${errorMessage}`); 81 | } 82 | } 83 | 84 | /** 85 | * Handles the process of adding a new user by requesting user data from the view 86 | * and executing the add new user use case. 87 | * If an error occurs, it shows the error message and retries the process. 88 | */ 89 | async handleAddNewUser(): Promise { 90 | try { 91 | const userData = await this.view.requestUserData(); 92 | const result = await this.addNewUserUseCase.execute(userData); 93 | 94 | if ("errors" in result && result.errors) { 95 | const errorMessages = Array.isArray(result.errors) 96 | ? result.errors.join("\n") 97 | : result.errors; 98 | this.view.showError(`Failed to add user:\n${errorMessages}`); 99 | await this.view.requestInput("Press Enter to continue..."); 100 | return; 101 | } 102 | 103 | if ("user" in result && result.user) { 104 | await this.view.requestInput("User added successfully! Press Enter to continue..."); 105 | await this.handleListUsers(); 106 | } 107 | } catch (error: unknown) { 108 | const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; 109 | this.view.showError(`Unexpected error: ${errorMessage}`); 110 | await this.view.requestInput("Press Enter to continue..."); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /packages/core/src/users/presentation/UsersView.ts: -------------------------------------------------------------------------------- 1 | import type { UserInputDTO as UserDataInput } from "../../users/application/dto/UserInputDTO"; 2 | import type { UserOutputDTO as UserDataView } from "../../users/application/dto/UserOutputDTO"; 3 | 4 | export interface UsersView { 5 | onClose(handler: () => void): void; 6 | showWelcomeMessage(): void; 7 | showGoodbyeMessage(): void; 8 | showError(message: string): void; 9 | requestInput(prompt: string): Promise; 10 | requestUserData(): Promise; 11 | showUserList(users: UserDataView[] | undefined): void; 12 | } 13 | -------------------------------------------------------------------------------- /packages/core/src/users/presentation/__tests__/UserPresenter.test.ts: -------------------------------------------------------------------------------- 1 | import { UsersPresenter } from '../UsersPresenter'; 2 | import { vi } from 'vitest'; 3 | 4 | describe('UsersPresenter', () => { 5 | let viewMock: any; 6 | let addNewUserUseCaseMock: any; 7 | let listUsersUseCaseMock: any; 8 | let presenter: UsersPresenter; 9 | let processExitSpy: any; 10 | 11 | beforeEach(() => { 12 | processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { 13 | throw new Error('process.exit called'); 14 | }); 15 | viewMock = { 16 | showWelcomeMessage: vi.fn(), 17 | showGoodbyeMessage: vi.fn(), 18 | showError: vi.fn(), 19 | showUserList: vi.fn(), 20 | onClose: vi.fn(), 21 | requestInput: vi.fn(), 22 | requestUserData: vi.fn(), 23 | }; 24 | addNewUserUseCaseMock = { 25 | execute: vi.fn(), 26 | }; 27 | listUsersUseCaseMock = { 28 | execute: vi.fn(), 29 | }; 30 | presenter = new UsersPresenter( 31 | viewMock, 32 | addNewUserUseCaseMock, 33 | listUsersUseCaseMock 34 | ); 35 | }); 36 | 37 | afterEach(() => { 38 | processExitSpy.mockRestore(); 39 | }); 40 | 41 | it('should initialize and show welcome message', async () => { 42 | viewMock.requestInput.mockResolvedValueOnce('3'); 43 | 44 | try { 45 | await presenter.start(); 46 | } catch(error) { 47 | expect(error.message).toBe('process.exit called'); 48 | } 49 | 50 | expect(viewMock.showWelcomeMessage).toHaveBeenCalled(); 51 | expect(viewMock.showGoodbyeMessage).toHaveBeenCalled(); 52 | expect(processExitSpy).toHaveBeenCalledWith(0); 53 | }); 54 | 55 | it('should handle adding a new user', async () => { 56 | const userData = { 57 | username: 'testuser', 58 | email: 'test@mail.com', 59 | password: 'password123', 60 | address: '123 Test St', 61 | city: 'Test City', 62 | zipCode: '12345', 63 | }; 64 | const result = { 65 | user: { 66 | username: userData.username, 67 | email: userData.email, 68 | address: userData.address, 69 | city: userData.city, 70 | zipCode: userData.zipCode, 71 | }, 72 | }; 73 | 74 | viewMock.requestUserData = vi.fn().mockResolvedValue(userData); 75 | addNewUserUseCaseMock.execute.mockResolvedValue(result); 76 | 77 | await presenter.handleAddNewUser(); 78 | 79 | expect(viewMock.requestUserData).toHaveBeenCalled(); 80 | expect(addNewUserUseCaseMock.execute).toHaveBeenCalledWith(userData); 81 | }); 82 | }); -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "baseUrl": ".", 7 | "paths": { 8 | "@core/*": ["src/*"], 9 | "@core": ["src/index.ts"] 10 | } 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /packages/react-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /packages/react-app/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /packages/react-app/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /packages/react-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@users-kata-ts/react-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "npm run docker:up && USE_DATABASE=true next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "test": "echo 'No tests specified' && exit 0", 10 | "lint": "biome check", 11 | "docker:up": "docker compose up -d", 12 | "docker:down": "docker compose down" 13 | }, 14 | "dependencies": { 15 | "react": "^19.0.0", 16 | "react-dom": "^19.0.0", 17 | "next": "15.3.2", 18 | "@users-kata-ts/core": "*" 19 | }, 20 | "devDependencies": { 21 | "typescript": "^5", 22 | "@types/node": "^20", 23 | "@types/react": "^19", 24 | "@types/react-dom": "^19" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/react-app/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/react-app/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/react-app/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/react-app/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/react-app/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/react-app/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carlosazaustre/kata-users-ts/3feb2a5e692ee4285f08e30b00bf080b2abafcc3/packages/react-app/src/app/favicon.ico -------------------------------------------------------------------------------- /packages/react-app/src/app/globals.css: -------------------------------------------------------------------------------- 1 | /* Reset y estilos base */ 2 | *, 3 | *::before, 4 | *::after { 5 | box-sizing: border-box; 6 | } 7 | 8 | * { 9 | margin: 0; 10 | } 11 | 12 | html { 13 | font-size: 16px; 14 | line-height: 1.5; 15 | -webkit-font-smoothing: antialiased; 16 | -moz-osx-font-smoothing: grayscale; 17 | } 18 | 19 | body { 20 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", 21 | "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; 22 | background-color: #f8fafc; 23 | color: #1f2937; 24 | line-height: 1.6; 25 | } 26 | 27 | /* Elementos de formulario */ 28 | input, 29 | button, 30 | textarea, 31 | select { 32 | font: inherit; 33 | } 34 | 35 | button { 36 | cursor: pointer; 37 | } 38 | 39 | button:disabled { 40 | cursor: not-allowed; 41 | } 42 | 43 | /* Elementos interactivos */ 44 | a { 45 | color: #3b82f6; 46 | text-decoration: none; 47 | } 48 | 49 | a:hover { 50 | text-decoration: underline; 51 | } 52 | 53 | /* Accesibilidad */ 54 | :focus-visible { 55 | outline: 2px solid #3b82f6; 56 | outline-offset: 2px; 57 | } 58 | 59 | /* Utilidades */ 60 | .sr-only { 61 | position: absolute; 62 | width: 1px; 63 | height: 1px; 64 | padding: 0; 65 | margin: -1px; 66 | overflow: hidden; 67 | clip: rect(0, 0, 0, 0); 68 | white-space: nowrap; 69 | border: 0; 70 | } 71 | 72 | /* Scrollbar personalizada */ 73 | ::-webkit-scrollbar { 74 | width: 8px; 75 | } 76 | 77 | ::-webkit-scrollbar-track { 78 | background: #f1f5f9; 79 | } 80 | 81 | ::-webkit-scrollbar-thumb { 82 | background: #cbd5e1; 83 | border-radius: 4px; 84 | } 85 | 86 | ::-webkit-scrollbar-thumb:hover { 87 | background: #94a3b8; 88 | } 89 | 90 | /* Animaciones reducidas para usuarios que las prefieren */ 91 | @media (prefers-reduced-motion: reduce) { 92 | *, 93 | *::before, 94 | *::after { 95 | animation-duration: 0.01ms !important; 96 | animation-iteration-count: 1 !important; 97 | transition-duration: 0.01ms !important; 98 | scroll-behavior: auto !important; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /packages/react-app/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { type ReactNode, Suspense } from "react"; 3 | import { SuccessMessage } from "../users/views/components/SuccessMessage"; 4 | import "./globals.css"; 5 | 6 | export const metadata: Metadata = { 7 | title: "Gestión de Usuarios", 8 | description: "Sistema de gestión de usuarios con Next.js", 9 | }; 10 | 11 | export default function RootLayout({ 12 | children, 13 | }: { 14 | children: ReactNode; 15 | }) { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | {children} 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /packages/react-app/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { UsersController } from "../users/controllers/UsersController"; 2 | import { UsersView } from "../users/views/UsersView"; 3 | 4 | export default async function UsersPage() { 5 | const users = await UsersController.getUsers(); 6 | 7 | return ; 8 | } 9 | -------------------------------------------------------------------------------- /packages/react-app/src/users/actions/userActions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import type { UserInputDTO as UserDataInput } from "@users-kata-ts/core"; 4 | import { revalidatePath } from "next/cache"; 5 | import { UsersController } from "../controllers/UsersController"; 6 | 7 | export async function addUserAction(userData: UserDataInput) { 8 | try { 9 | const newUser = await UsersController.addUser(userData); 10 | if (Array.isArray(newUser) && newUser.every((error) => typeof error === "string")) { 11 | return { success: false, errors: newUser }; 12 | } 13 | 14 | revalidatePath("/users"); 15 | return { success: true, data: newUser }; 16 | } catch (error) { 17 | return { 18 | success: false, 19 | error: error instanceof Error ? error.message : "Failed to add user", 20 | }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/react-app/src/users/controllers/UsersController.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type UserInputDTO as UserDataInput, 3 | type UserOutputDTO as UserDataView, 4 | serviceLocator, 5 | } from "@users-kata-ts/core"; 6 | 7 | export const UsersController = { 8 | getUsers: async (): Promise => { 9 | try { 10 | const listUsersUseCase = await serviceLocator.getListUsersUseCase(); 11 | const users = await listUsersUseCase.execute(); 12 | return users; 13 | } catch (error) { 14 | console.error("Error fetching users:", error); 15 | throw new Error("Failed to fetch users"); 16 | } 17 | }, 18 | 19 | addUser: async (userData: UserDataInput): Promise => { 20 | try { 21 | const addUserUseCase = await serviceLocator.getAddNewUserUseCase(); 22 | const result = await addUserUseCase.execute(userData); 23 | 24 | if (result.errors && result.errors.length > 0) { 25 | return result.errors; 26 | } 27 | if (result.user) { 28 | return result.user; 29 | } 30 | 31 | throw new Error("Unexpected response: no user or errors returned"); 32 | } catch (error) { 33 | console.error("Error adding user:", error); 34 | if (error instanceof Error) { 35 | throw error; 36 | } 37 | throw new Error("Failed to add user"); 38 | } 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /packages/react-app/src/users/views/UsersView.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | background: #f8fafc; 4 | padding: 20px; 5 | } 6 | 7 | .header { 8 | text-align: center; 9 | margin-bottom: 40px; 10 | padding: 40px 20px; 11 | background: white; 12 | border-radius: 16px; 13 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 14 | border: 1px solid #e5e7eb; 15 | } 16 | 17 | .header h1 { 18 | margin: 0 0 12px 0; 19 | font-size: 32px; 20 | font-weight: 800; 21 | color: #1f2937; 22 | background: linear-gradient(135deg, #3b82f6, #8b5cf6); 23 | -webkit-background-clip: text; 24 | -webkit-text-fill-color: transparent; 25 | background-clip: text; 26 | } 27 | 28 | .welcomeMessage { 29 | margin: 0; 30 | font-size: 16px; 31 | color: #6b7280; 32 | font-weight: 500; 33 | } 34 | 35 | .main { 36 | max-width: 1200px; 37 | margin: 0 auto; 38 | } 39 | 40 | .actions { 41 | margin-bottom: 24px; 42 | display: flex; 43 | justify-content: flex-end; 44 | } 45 | 46 | .primaryButton { 47 | background: #3b82f6; 48 | color: white; 49 | border: none; 50 | padding: 12px 24px; 51 | border-radius: 8px; 52 | font-weight: 600; 53 | cursor: pointer; 54 | transition: all 0.2s; 55 | font-size: 14px; 56 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 57 | } 58 | 59 | .primaryButton:hover:not(:disabled) { 60 | background: #2563eb; 61 | transform: translateY(-1px); 62 | box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); 63 | } 64 | 65 | .primaryButton:disabled { 66 | opacity: 0.5; 67 | cursor: not-allowed; 68 | transform: none; 69 | } 70 | 71 | .errorMessage { 72 | background: #fef2f2; 73 | border: 1px solid #fecaca; 74 | color: #dc2626; 75 | padding: 16px; 76 | border-radius: 8px; 77 | margin-bottom: 24px; 78 | display: flex; 79 | align-items: center; 80 | gap: 12px; 81 | font-size: 14px; 82 | font-weight: 500; 83 | } 84 | 85 | .errorIcon { 86 | flex-shrink: 0; 87 | font-size: 16px; 88 | } 89 | 90 | @media (max-width: 768px) { 91 | .container { 92 | padding: 10px; 93 | } 94 | 95 | .header { 96 | padding: 30px 20px; 97 | margin-bottom: 30px; 98 | } 99 | 100 | .header h1 { 101 | font-size: 24px; 102 | } 103 | 104 | .welcomeMessage { 105 | font-size: 14px; 106 | } 107 | 108 | .actions { 109 | justify-content: stretch; 110 | } 111 | 112 | .primaryButton { 113 | width: 100%; 114 | justify-content: center; 115 | } 116 | } 117 | 118 | @media (max-width: 480px) { 119 | .header h1 { 120 | font-size: 20px; 121 | } 122 | 123 | .welcomeMessage { 124 | font-size: 13px; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /packages/react-app/src/users/views/UsersView.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { 4 | UserInputDTO as UserDataInput, 5 | UserOutputDTO as UserDataView, 6 | } from "@users-kata-ts/core"; 7 | import { useState } from "react"; 8 | import { addUserAction } from "../actions/userActions"; 9 | import styles from "./UsersView.module.css"; 10 | import { AddUserModal } from "./components/AddUserModal"; 11 | import { UsersList } from "./components/UsersList"; 12 | 13 | interface UsersViewProps { 14 | initialUsers?: UserDataView[]; 15 | } 16 | 17 | export function UsersView({ initialUsers = [] }: UsersViewProps) { 18 | const [users, setUsers] = useState(initialUsers); 19 | const [isAddingUser, setIsAddingUser] = useState(false); 20 | const [errorMessage, setErrorMessage] = useState(""); 21 | const [isProcessing, setIsProcessing] = useState(false); 22 | 23 | const handleAddUser = () => { 24 | setIsAddingUser(true); 25 | }; 26 | 27 | const handleUserDataSubmit = async (userData: UserDataInput) => { 28 | setIsProcessing(true); 29 | setErrorMessage(""); 30 | 31 | try { 32 | const result = await addUserAction(userData); 33 | 34 | if (result.success && result.data) { 35 | setUsers((prevUsers) => [...prevUsers, result.data as UserDataView]); 36 | setIsAddingUser(false); 37 | } else { 38 | setErrorMessage(result.error || "Failed to add user"); 39 | } 40 | } catch (error) { 41 | setErrorMessage("An unexpected error occurred while adding the user."); 42 | } finally { 43 | setIsProcessing(false); 44 | } 45 | }; 46 | 47 | const handleUserDataCancel = () => { 48 | setIsAddingUser(false); 49 | setErrorMessage(""); 50 | }; 51 | 52 | const handleClearError = () => { 53 | setErrorMessage(""); 54 | }; 55 | 56 | return ( 57 |
58 |
59 |

Gestión de Usuarios

60 |

61 | 🙌 ¡Bienvenido al Sistema de Gestión de Usuarios! 🙌 62 |

63 |
64 | 65 | {errorMessage && ( 66 |
67 | ⚠️ 68 | {errorMessage} 69 |
70 | )} 71 | 72 |
73 |
74 | 82 |
83 | 84 | 85 | 86 | {isAddingUser && ( 87 | 92 | )} 93 |
94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /packages/react-app/src/users/views/components/AddUserModal.module.css: -------------------------------------------------------------------------------- 1 | /* El ::backdrop es el pseudo-elemento que cubre toda la pantalla */ 2 | .modalOverlay::backdrop { 3 | background: rgba(0, 0, 0, 0.5); 4 | backdrop-filter: blur(4px); 5 | } 6 | 7 | /* El dialog en sí mismo debe estar centrado */ 8 | .modalOverlay { 9 | position: fixed; 10 | margin: auto; 11 | border: none; 12 | padding: 0; 13 | max-width: calc(100vw - 40px); 14 | max-height: calc(100vh - 40px); 15 | overflow: visible; 16 | } 17 | 18 | .modal { 19 | background: white; 20 | border-radius: 16px; 21 | box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25); 22 | width: 100%; 23 | max-width: 500px; 24 | max-height: 90vh; 25 | overflow-y: auto; 26 | padding: 32px; 27 | animation: modalAppear 0.3s ease-out; 28 | } 29 | 30 | @keyframes modalAppear { 31 | from { 32 | opacity: 0; 33 | transform: scale(0.9) translateY(-20px); 34 | } 35 | to { 36 | opacity: 1; 37 | transform: scale(1) translateY(0); 38 | } 39 | } 40 | 41 | .modal h2 { 42 | margin: 0 0 24px 0; 43 | font-size: 24px; 44 | font-weight: 700; 45 | color: #1f2937; 46 | } 47 | 48 | .formFieldset { 49 | border: none; 50 | padding: 0; 51 | margin: 0; 52 | } 53 | 54 | .formFieldset:disabled { 55 | opacity: 0.6; 56 | pointer-events: none; 57 | } 58 | 59 | .formGroup { 60 | margin-bottom: 20px; 61 | } 62 | 63 | .formGroup label { 64 | display: block; 65 | margin-bottom: 6px; 66 | font-weight: 500; 67 | color: #374151; 68 | font-size: 14px; 69 | } 70 | 71 | .formGroup input { 72 | width: 100%; 73 | padding: 12px 16px; 74 | border: 2px solid #e5e7eb; 75 | border-radius: 8px; 76 | font-size: 14px; 77 | transition: border-color 0.2s, box-shadow 0.2s; 78 | box-sizing: border-box; 79 | } 80 | 81 | .formGroup input:focus { 82 | outline: none; 83 | border-color: #3b82f6; 84 | box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); 85 | } 86 | 87 | .formGroup input:invalid { 88 | border-color: #ef4444; 89 | } 90 | 91 | .formRow { 92 | display: grid; 93 | grid-template-columns: 1fr 1fr; 94 | gap: 16px; 95 | } 96 | 97 | .formActions { 98 | display: flex; 99 | gap: 12px; 100 | justify-content: flex-end; 101 | margin-top: 32px; 102 | padding-top: 24px; 103 | border-top: 1px solid #e5e7eb; 104 | } 105 | 106 | .cancelButton { 107 | padding: 12px 24px; 108 | border: 2px solid #e5e7eb; 109 | background: white; 110 | color: #6b7280; 111 | border-radius: 8px; 112 | font-weight: 500; 113 | cursor: pointer; 114 | transition: all 0.2s; 115 | font-size: 14px; 116 | } 117 | 118 | .cancelButton:hover:not(:disabled) { 119 | border-color: #d1d5db; 120 | background: #f9fafb; 121 | } 122 | 123 | .cancelButton:disabled { 124 | opacity: 0.5; 125 | cursor: not-allowed; 126 | } 127 | 128 | .submitButton { 129 | padding: 12px 24px; 130 | border: none; 131 | background: #3b82f6; 132 | color: white; 133 | border-radius: 8px; 134 | font-weight: 500; 135 | cursor: pointer; 136 | transition: background-color 0.2s; 137 | font-size: 14px; 138 | } 139 | 140 | .submitButton:hover:not(:disabled) { 141 | background: #2563eb; 142 | } 143 | 144 | .submitButton:disabled { 145 | opacity: 0.5; 146 | cursor: not-allowed; 147 | } 148 | 149 | .srOnly { 150 | position: absolute; 151 | width: 1px; 152 | height: 1px; 153 | padding: 0; 154 | margin: -1px; 155 | overflow: hidden; 156 | clip: rect(0, 0, 0, 0); 157 | white-space: nowrap; 158 | border: 0; 159 | } 160 | 161 | @media (max-width: 640px) { 162 | .modalOverlay { 163 | max-width: calc(100vw - 20px); 164 | max-height: calc(100vh - 20px); 165 | } 166 | 167 | .modal { 168 | padding: 24px; 169 | max-height: 90vh; 170 | } 171 | 172 | .modal h2 { 173 | font-size: 20px; 174 | margin-bottom: 20px; 175 | } 176 | 177 | .formRow { 178 | grid-template-columns: 1fr; 179 | gap: 0; 180 | } 181 | 182 | .formActions { 183 | flex-direction: column-reverse; 184 | } 185 | 186 | .cancelButton, 187 | .submitButton { 188 | width: 100%; 189 | justify-content: center; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /packages/react-app/src/users/views/components/AddUserModal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { UserInputDTO as UserDataInput } from "@users-kata-ts/core"; 4 | import { useEffect, useRef, useState } from "react"; 5 | import styles from "./AddUserModal.module.css"; 6 | 7 | interface AddUserModalProps { 8 | onSubmit: (userData: UserDataInput) => void; 9 | onCancel: () => void; 10 | isProcessing?: boolean; 11 | } 12 | 13 | export function AddUserModal({ onSubmit, onCancel, isProcessing = false }: AddUserModalProps) { 14 | const dialogRef = useRef(null); 15 | const [formData, setFormData] = useState({ 16 | username: "", 17 | email: "", 18 | password: "", 19 | address: "", 20 | city: "", 21 | zipCode: "", 22 | }); 23 | 24 | useEffect(() => { 25 | const dialog = dialogRef.current; 26 | if (dialog && !dialog.open) { 27 | dialog.showModal(); 28 | } 29 | }, []); 30 | 31 | const handleSubmit = (event: React.FormEvent) => { 32 | event.preventDefault(); 33 | if (!isProcessing) { 34 | onSubmit(formData); 35 | } 36 | }; 37 | 38 | const handleChange = (event: React.ChangeEvent) => { 39 | setFormData((prev) => ({ 40 | ...prev, 41 | [event.target.name]: event.target.value, 42 | })); 43 | }; 44 | 45 | const handleOverlayClick = (event: React.MouseEvent) => { 46 | // Detectar si el clic fue en el backdrop (fuera del contenido) 47 | if (event.target === event.currentTarget && !isProcessing) { 48 | onCancel(); 49 | } 50 | }; 51 | 52 | const handleEscapeKey = (event: React.KeyboardEvent) => { 53 | if (event.key === "Escape" && !isProcessing) { 54 | event.preventDefault(); 55 | onCancel(); 56 | } 57 | }; 58 | 59 | return ( 60 | 68 |
e.stopPropagation()} 71 | onKeyDown={(e) => e.stopPropagation()} 72 | tabIndex={-1} 73 | > 74 | 75 |
76 |
77 |
78 | 79 | 88 |
89 | 90 |
91 | 92 | 101 |
102 | 103 |
104 | 105 | 116 | 117 | Mínimo 6 caracteres 118 | 119 |
120 | 121 |
122 | 123 | 130 |
131 | 132 |
133 |
134 | 135 | 142 |
143 | 144 |
145 | 146 | 156 | 157 | 5 dígitos numéricos 158 | 159 |
160 |
161 |
162 | 163 |
164 | 173 | 181 |
182 |
183 |
184 |
185 | ); 186 | } 187 | -------------------------------------------------------------------------------- /packages/react-app/src/users/views/components/SuccessMessage.module.css: -------------------------------------------------------------------------------- 1 | .successMessage { 2 | position: fixed; 3 | top: 20px; 4 | right: 20px; 5 | background-color: #10b981; 6 | color: #ffffff; 7 | padding: 16px 24px; 8 | border-radius: 8px; 9 | box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); 10 | display: flex; 11 | align-items: center; 12 | gap: 12px; 13 | max-width: 400px; 14 | z-index: 1001; 15 | animation: slideIn 0.3s ease-out; 16 | } 17 | 18 | @keyframes slideIn { 19 | from { 20 | transform: translateX(100%); 21 | opacity: 0; 22 | } 23 | to { 24 | transform: translateX(0); 25 | opacity: 1; 26 | } 27 | } 28 | 29 | .successIcon { 30 | font-size: 20px; 31 | flex-shrink: 0; 32 | } 33 | 34 | .successText { 35 | flex: 1; 36 | font-weight: 500; 37 | } 38 | 39 | .closeButton { 40 | background: none; 41 | border: none; 42 | color: #ffffff; 43 | font-size: 20px; 44 | cursor: pointer; 45 | padding: 0; 46 | line-height: 1; 47 | opacity: 0.8; 48 | transition: opacity 0.2s; 49 | } 50 | 51 | .closeButton:hover { 52 | opacity: 1; 53 | } 54 | 55 | .closeButton:focus { 56 | outline: 2px solid #ffffff; 57 | outline-offset: 2px; 58 | border-radius: 4px; 59 | } 60 | 61 | @media (max-width: 640px) { 62 | .successMessage { 63 | top: 10px; 64 | right: 10px; 65 | left: 10px; 66 | max-width: none; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/react-app/src/users/views/components/SuccessMessage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSearchParams } from "next/navigation"; 4 | import { useEffect, useState } from "react"; 5 | import styles from "./SuccessMessage.module.css"; 6 | 7 | export function SuccessMessage() { 8 | const searchParams = useSearchParams(); 9 | const success = searchParams.get("success"); 10 | const [message, setMessage] = useState(""); 11 | 12 | useEffect(() => { 13 | if (success === "user-added") { 14 | setMessage("Usuario añadido correctamente"); 15 | const timer = setTimeout(() => { 16 | setMessage(""); 17 | window.history.replaceState(null, "", window.location.pathname); 18 | }, 5000); 19 | return () => clearTimeout(timer); 20 | } 21 | }, [success]); 22 | 23 | if (!message) return null; 24 | 25 | return ( 26 |
27 | 28 | {message} 29 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /packages/react-app/src/users/views/components/UsersList.module.css: -------------------------------------------------------------------------------- 1 | .tableContainer { 2 | background: white; 3 | border-radius: 12px; 4 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 5 | overflow: hidden; 6 | border: 1px solid #e5e7eb; 7 | } 8 | 9 | .table { 10 | width: 100%; 11 | border-collapse: collapse; 12 | font-size: 14px; 13 | } 14 | 15 | .table thead { 16 | background: #f9fafb; 17 | } 18 | 19 | .table th { 20 | padding: 16px; 21 | text-align: left; 22 | font-weight: 600; 23 | color: #374151; 24 | border-bottom: 1px solid #e5e7eb; 25 | font-size: 13px; 26 | text-transform: uppercase; 27 | letter-spacing: 0.05em; 28 | } 29 | 30 | .table td { 31 | padding: 16px; 32 | border-bottom: 1px solid #f3f4f6; 33 | color: #1f2937; 34 | } 35 | 36 | .table tbody tr:hover { 37 | background: #f9fafb; 38 | } 39 | 40 | .table tbody tr:last-child td { 41 | border-bottom: none; 42 | } 43 | 44 | .emptyState { 45 | text-align: center; 46 | padding: 60px 20px; 47 | background: white; 48 | border-radius: 12px; 49 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 50 | border: 1px solid #e5e7eb; 51 | } 52 | 53 | .emptyIcon { 54 | font-size: 48px; 55 | margin-bottom: 16px; 56 | opacity: 0.6; 57 | } 58 | 59 | .emptyState h3 { 60 | margin: 0; 61 | color: #6b7280; 62 | font-weight: 500; 63 | font-size: 16px; 64 | } 65 | 66 | @media (max-width: 768px) { 67 | .tableContainer { 68 | overflow-x: auto; 69 | } 70 | 71 | .table { 72 | min-width: 600px; 73 | } 74 | 75 | .table th, 76 | .table td { 77 | padding: 12px 8px; 78 | font-size: 13px; 79 | } 80 | 81 | .emptyState { 82 | padding: 40px 20px; 83 | } 84 | 85 | .emptyIcon { 86 | font-size: 36px; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /packages/react-app/src/users/views/components/UsersList.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { UserOutputDTO as UserDataView } from "@users-kata-ts/core"; 4 | import styles from "./UsersList.module.css"; 5 | 6 | interface UsersListProps { 7 | users: UserDataView[]; 8 | } 9 | 10 | export function UsersList({ users }: UsersListProps) { 11 | if (users.length === 0) { 12 | return ( 13 |
14 |
👥
15 |

No hay usuarios registrados

16 |
17 | ); 18 | } 19 | 20 | return ( 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {users.map((user) => ( 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ))} 43 | 44 |
Lista de usuarios registrados
UsuarioEmailDirecciónCiudadCódigo Postal
{user.username}{user.email}{user.address}{user.city}{user.zipCode}
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /packages/react-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /packages/terminal-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@users-kata-ts/terminal-app", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "lint": "biome check", 7 | "test": "echo 'No tests specified'", 8 | "build": "tsc", 9 | "start": "npm run build && node dist/index.js", 10 | "dev:db": "npm run docker:up && USE_DATABASE=true ts-node src/index.ts", 11 | "dev:memory": "USE_DATABASE=false ts-node src/index.ts", 12 | "docker:up": "docker compose up -d", 13 | "docker:down": "docker compose down" 14 | }, 15 | "dependencies": { 16 | "@users-kata-ts/core": "*", 17 | "readline": "^1.3.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/terminal-app/src/UsersTerminalView.ts: -------------------------------------------------------------------------------- 1 | import type { Interface as ReadlineInterface } from "node:readline"; 2 | import type { 3 | UserInputDTO as UsersDataInput, 4 | UserOutputDTO as UsersDataView, 5 | UsersView, 6 | } from "@users-kata-ts/core"; 7 | 8 | export class UsersTerminalView implements UsersView { 9 | constructor( 10 | private readonly rl: ReadlineInterface, 11 | private closeHandler: () => void = () => {}, 12 | ) { 13 | this.rl.addListener("close", () => { 14 | this.closeHandler(); 15 | }); 16 | } 17 | 18 | onClose(handler: () => void): void { 19 | this.closeHandler = handler; 20 | } 21 | 22 | showWelcomeMessage(): void { 23 | console.clear(); 24 | console.log("🙌 Welcome to the User Management System! 🙌"); 25 | console.log("You can add new users and view the list of existing users."); 26 | console.log("Please follow the prompts to get started."); 27 | console.log("Press Ctrl+C to exit."); 28 | } 29 | 30 | showGoodbyeMessage(): void { 31 | console.log("\n----------------------------------------------------------"); 32 | console.log("👋 Goodbye! Thank you for using the User Management System!"); 33 | console.log("----------------------------------------------------------"); 34 | } 35 | 36 | showError(message: string): void { 37 | console.log("\n------------------------------------------------------------"); 38 | console.log(`🔴 Error: ${message}`); 39 | console.log("------------------------------------------------------------"); 40 | } 41 | 42 | async requestInput(prompt: string): Promise { 43 | return new Promise((resolve) => { 44 | this.rl.question(`${prompt}: `, (input) => { 45 | resolve(input); 46 | }); 47 | }); 48 | } 49 | 50 | async requestUserData(): Promise { 51 | const username = await this.requestInput("Enter username"); 52 | const email = await this.requestInput("Enter email"); 53 | const password = await this.requestInput("Enter password"); 54 | const address = await this.requestInput("Enter address"); 55 | const city = await this.requestInput("Enter city"); 56 | const zipCode = await this.requestInput("Enter zip code"); 57 | 58 | return { 59 | username, 60 | email, 61 | password, 62 | address, 63 | city, 64 | zipCode, 65 | }; 66 | } 67 | 68 | showUserList(users: UsersDataView[]): void { 69 | console.log("------------------------------------------------------------"); 70 | console.log("👤 User List:"); 71 | if (users.length === 0) { 72 | console.log("No users found 🥲"); 73 | } else { 74 | users.forEach((user, index) => { 75 | console.log( 76 | `${index + 1}. ${user.username} - <${user.email}> - ${user.address}, ${user.city} ${user.zipCode}`, 77 | ); 78 | }); 79 | } 80 | console.log("------------------------------------------------------------"); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/terminal-app/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as readline from "node:readline"; 2 | import { serviceLocator } from "@users-kata-ts/core"; 3 | import { UsersTerminalView } from "./UsersTerminalView"; 4 | 5 | async function main() { 6 | const { stdin: input, stdout: output } = process; 7 | 8 | const rl = readline.createInterface({ input, output }); 9 | const usersView = new UsersTerminalView(rl); 10 | const usersPresenter = await serviceLocator.getUsersPresenter(usersView); 11 | 12 | // Handle graceful shutdown 13 | process.on("SIGINT", async () => { 14 | await serviceLocator.cleanup(); 15 | console.log("\nExiting..."); 16 | process.exit(0); 17 | }); 18 | 19 | await usersPresenter.start(); 20 | } 21 | 22 | main().catch(async (error) => { 23 | console.error("Application Error:", error); 24 | await serviceLocator.cleanup(); 25 | process.exit(1); 26 | }); 27 | -------------------------------------------------------------------------------- /packages/terminal-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "baseUrl": "." 7 | }, 8 | "include": ["src/**/*"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["ES2020"], 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "resolveJsonModule": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "declaration": true, 17 | "declarationMap": true, 18 | "sourceMap": true, 19 | "removeComments": true, 20 | "allowSyntheticDefaultImports": true, 21 | "isolatedModules": true 22 | }, 23 | "include": ["src/**/*.ts"], 24 | "exclude": ["node_modules", "dist", "coverage", "**/*.test.ts", "**/*.spec.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { configDefaults, defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: "node", 7 | css: true, 8 | exclude: [...configDefaults.exclude, "**/e2e/**"], // Example: Exclude e2e tests 9 | coverage: { 10 | provider: "v8", // Use Vite's default coverage provider 11 | reporter: ["text", "json", "html"], 12 | }, 13 | }, 14 | }); 15 | --------------------------------------------------------------------------------