Initial commit
This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# ENVIRONMENT can be development, production, test, or etc...
|
||||||
|
# Setting to production will disable swagger docs & will enable secure HTTP-only cookies
|
||||||
|
ENVIRONMENT=development
|
||||||
|
# Postgres connection string
|
||||||
|
POSTGRES_URL="postgresql://username:password@localhost:5432/express-starter?schema=public"
|
||||||
|
# JWT session token secret
|
||||||
|
JWT_SECRET=express-starter-session
|
||||||
|
# JWT session token duration in seconds (default is 7,200 seconds or 2 hours)
|
||||||
|
JWT_DURATION=7200
|
||||||
|
# JWT refresh token duration in seconds (default is 14,400 seconds or 4 hours)
|
||||||
|
JWT_REFRESH_DURATION=14400
|
||||||
|
# JWT refresh token secret
|
||||||
|
JWT_REFRESH_SECRET=express-starter-refresh
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
.env
|
||||||
|
/src/generated/prisma
|
||||||
|
|
||||||
|
coverage
|
||||||
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.organizeImports.biome": "explicit",
|
||||||
|
"source.fixAll.biome": "explicit"
|
||||||
|
}
|
||||||
|
}
|
||||||
82
ARCHITECTURE.md
Normal file
82
ARCHITECTURE.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Project Architecture
|
||||||
|
|
||||||
|
This document outlines the architectural decisions, project structure, and key patterns used in the `express-starter` project.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The project follows a **Modular Monolith** architecture combined with **Clean Architecture** (also known as Hexagonal Architecture or Ports and Adapters) principles. This ensures separation of concerns, maintainability, and testability by isolating business logic from infrastructure details.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
The source code is located in the `src` directory and is organized into two main categories:
|
||||||
|
|
||||||
|
- **`src/modules`**: Contains feature-specific modules (e.g., `users`, `auth`). Each module is a self-contained vertical slice of the application.
|
||||||
|
- **`src/shared`**: Contains code shared across multiple modules, such as base classes, utilities, and common infrastructure.
|
||||||
|
|
||||||
|
### Module Anatomy (`src/modules/<module-name>`)
|
||||||
|
|
||||||
|
Each module follows a strict layering strategy:
|
||||||
|
|
||||||
|
1. **`domain/`**: The core business logic.
|
||||||
|
- **Entities**: Pure TypeScript interfaces/classes defining the domain models (e.g., `UserEntity`).
|
||||||
|
- **Repository Interfaces**: Contracts defining how data is accessed (e.g., `IUsersRepository`).
|
||||||
|
- **Domain Services**: Business logic that doesn't fit into a single entity.
|
||||||
|
- *Dependencies*: This layer has **zero** dependencies on outer layers or external libraries (except strictly utility libraries).
|
||||||
|
|
||||||
|
2. **`use-cases/`**: Application-specific business rules.
|
||||||
|
- Contains classes that orchestrate the flow of data to and from the domain entities.
|
||||||
|
- Implements specific user actions (e.g., `RegisterUserUseCase`, `UserSignup`).
|
||||||
|
- *Dependencies*: Depends on the `domain` layer.
|
||||||
|
|
||||||
|
3. **`infrastructure/`**: Implementation details and adapters.
|
||||||
|
- **`persistence/`**: Database implementations of repositories (e.g., `UsersPostgresRepository`), database models, and mappers.
|
||||||
|
- **`http/`**: HTTP controllers, routes, and request validation schemas (Zod).
|
||||||
|
- **`di/`**: Dependency Injection configuration (InversifyJS modules).
|
||||||
|
- *Dependencies*: Depends on `domain` and `use-cases`.
|
||||||
|
|
||||||
|
### Shared Kernel (`src/shared`)
|
||||||
|
|
||||||
|
- **`core/`**: Core interfaces and types (e.g., `IBaseRepository`, `FilterQuery`).
|
||||||
|
- **`infrastructure/`**: Shared infrastructure implementations.
|
||||||
|
- **`persistence/postgres/`**: Base repository implementations and database connection logic.
|
||||||
|
- **`http/`**: Base router, HTTP utilities, and middlewares (e.g., `requestLogger`, `attachRequestContext`).
|
||||||
|
- **`crypto/`**: Cryptographic utilities (e.g., hashing, token generation).
|
||||||
|
- **`logger/`**: Logging service configuration.
|
||||||
|
- **`di/`**: Global DI container setup.
|
||||||
|
|
||||||
|
## Key Technologies & Patterns
|
||||||
|
|
||||||
|
### Dependency Injection (DI)
|
||||||
|
The project uses **InversifyJS** for dependency injection.
|
||||||
|
- **Container**: A global `appContainer` is defined in `src/shared/infrastructure/di/Container.ts`.
|
||||||
|
- **Modules**: Each feature module exports a `ContainerModule` (e.g., `UsersDIModule`) which binds interfaces to implementations.
|
||||||
|
- **Usage**: Dependencies are injected into classes (Repositories, Use Cases) using the `@inject` decorator.
|
||||||
|
|
||||||
|
### Repository Pattern
|
||||||
|
Data access is abstracted using the Repository Pattern.
|
||||||
|
- **Interface**: Defined in the Domain layer (e.g., `IUsersRepository`).
|
||||||
|
- **Implementation**: Defined in the Infrastructure layer (e.g., `UsersPostgresRepository`).
|
||||||
|
- **Base Repository**: A generic `PostgresBaseRepository` in `shared` provides common CRUD operations.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
**Zod** is used for runtime validation, particularly for HTTP request bodies in the controller/route layer.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
**Postgres** is the underlying database. The project uses **Prisma ORM** for schema definition, migrations, and type-safe database access. The repository layer wraps Prisma Client calls.
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
The `auth` module handles user authentication.
|
||||||
|
- **Strategy**: Likely uses JWT (JSON Web Tokens) for stateless authentication.
|
||||||
|
- **Crypto**: Shared crypto services handle password hashing (e.g., Argon2 or Bcrypt) and token signing.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
A typical request flows as follows:
|
||||||
|
|
||||||
|
1. **HTTP Request** hits a Route defined in `infrastructure/http`.
|
||||||
|
2. **Middlewares** (Global/Route-specific) run (e.g., logging, context attachment).
|
||||||
|
3. **Validation** validates the request payload using Zod.
|
||||||
|
4. **Use Case** is resolved from the DI container and executed.
|
||||||
|
5. **Repository** is called by the Use Case to fetch/save data.
|
||||||
|
6. **Domain Entity** is returned/manipulated.
|
||||||
|
7. **Response** is sent back to the client.
|
||||||
101
CODE_STYLE.md
Normal file
101
CODE_STYLE.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Code Style Guide
|
||||||
|
|
||||||
|
This document outlines the code style, naming conventions, and architectural patterns used in this project. Adhering to these guidelines ensures consistency and maintainability across the codebase.
|
||||||
|
|
||||||
|
## 1. General Principles
|
||||||
|
|
||||||
|
- **Clean Architecture**: The project follows Clean Architecture principles, separating concerns into Domain, Use Cases, and Infrastructure layers.
|
||||||
|
- **Dependency Injection**: `inversify` is used for dependency injection. Dependencies are injected via constructor injection. In the case for route handlers and middlewares, dependencies are injected or retrieved via the `Container` or `ContainerModule`'s `get<T>(id: Symbol)` method.
|
||||||
|
- **ES Modules**: The project uses native ES Modules. All local imports must include the `.js` extension.
|
||||||
|
|
||||||
|
## 2. Naming Conventions
|
||||||
|
|
||||||
|
### 2.1. Files and Folders
|
||||||
|
|
||||||
|
- **Folders**: Use `kebab-case` (e.g., `use-cases`, `hello-world`, `infrastructure`).
|
||||||
|
- **Files**:
|
||||||
|
- **General**: Use `kebab-case` (e.g., `register-user.ts`).
|
||||||
|
- **Module Domain Files**: Often prefixed with the entity/module name (e.g., `users.entity.ts`, `users.repo.ts`, `users.service.ts`).
|
||||||
|
- **Shared Core Interfaces**: PascalCase, matching the interface name (e.g., `IUseCase.ts`, `IBaseRepository.ts`).
|
||||||
|
- **Domain Errors**: PascalCase (e.g., `InvalidEmailFormat.ts`, `UserNotFound.ts`).
|
||||||
|
- **DI Modules**: `kebab-case` with `.di` suffix (e.g., `users.di.ts`).
|
||||||
|
|
||||||
|
### 2.2. Code Identifiers
|
||||||
|
|
||||||
|
- **Classes**: PascalCase (e.g., `UserEntity`, `RegisterUserUseCase`).
|
||||||
|
- **Interfaces**: PascalCase with `I` prefix (e.g., `IUseCase`, `IUsersRepository`).
|
||||||
|
- **Exception**: Module augmentation for external libraries (e.g., `Request` in Express).
|
||||||
|
- **Types**: PascalCase (e.g., `RegisterUserDTO`, `FilterCriteria`).
|
||||||
|
- **Variables & Properties**: camelCase (e.g., `userRepo`, `email`, `createdAt`).
|
||||||
|
- **Functions & Methods**: camelCase (e.g., `execute`, `save`, `hashPassword`).
|
||||||
|
- **Constants**: UPPER_SNAKE_CASE for global constants; camelCase for local constants.
|
||||||
|
- **DI Symbols**: PascalCase (e.g., `UsersDomain`, `SharedDomain`).
|
||||||
|
|
||||||
|
## 3. Project Structure
|
||||||
|
|
||||||
|
The project is organized into `modules` and `shared` directories.
|
||||||
|
|
||||||
|
### 3.1. Modules (`src/modules`)
|
||||||
|
|
||||||
|
Each feature module (e.g., `users`, `auth`) is self-contained and follows a layered structure:
|
||||||
|
|
||||||
|
- **`domain/`**: Contains business logic, entities, repository interfaces, and domain errors.
|
||||||
|
- Entities: `*.entity.ts`
|
||||||
|
- Repository Interfaces: `*.repo.ts`
|
||||||
|
- Errors: `errors/*.ts`
|
||||||
|
- **`use-cases/`**: Contains application logic.
|
||||||
|
- Use Case Classes: `kebab-case.ts` (implement `IUseCase`)
|
||||||
|
- DTOs: Defined within the use case file or separately.
|
||||||
|
- **`infrastructure/`**: Contains implementation details.
|
||||||
|
- Persistence: `*.prisma.repo.ts`, `*.in-memory.repo.ts`
|
||||||
|
- DI: `di/*.di.ts`
|
||||||
|
- HTTP: `http/*.routes.ts`
|
||||||
|
|
||||||
|
### 3.2. Shared (`src/shared`)
|
||||||
|
|
||||||
|
Contains code shared across modules:
|
||||||
|
|
||||||
|
- **`core/`**: Base interfaces and abstract classes (e.g., `IUseCase`, `IBaseRepository`).
|
||||||
|
- **`infrastructure/`**: Shared infrastructure implementations (e.g., `crypto`, `logger`, `prisma`).
|
||||||
|
- **`application/`**: Application-level ports and services.
|
||||||
|
|
||||||
|
## 4. Coding Standards
|
||||||
|
|
||||||
|
### 4.1. Imports
|
||||||
|
|
||||||
|
- **Extensions**: You **MUST** use the `.js` extension for all local imports.
|
||||||
|
```typescript
|
||||||
|
import { UserEntity } from "./users.entity.js"; // Correct
|
||||||
|
import { UserEntity } from "./users.entity"; // Incorrect
|
||||||
|
```
|
||||||
|
- **Path Aliases**: Use `@/` to refer to the `src` directory.
|
||||||
|
```typescript
|
||||||
|
import { IUseCase } from "@/shared/core/IUseCase.js";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2. Types & Interfaces
|
||||||
|
|
||||||
|
- Prefer **Interfaces** for defining contracts (repositories, services).
|
||||||
|
- Prefer **Types** for DTOs and data structures (e.g., `RegisterUserDTO`).
|
||||||
|
- **No Enums**: Avoid TypeScript `enum`. Use union types or constant objects instead (enforced by Biome).
|
||||||
|
|
||||||
|
### 4.3. Error Handling
|
||||||
|
|
||||||
|
- Use custom error classes extending `Error` for domain-specific errors.
|
||||||
|
- Place domain errors in `domain/errors/`.
|
||||||
|
|
||||||
|
### 4.4. Asynchronous Code
|
||||||
|
|
||||||
|
- Use `async/await` for asynchronous operations.
|
||||||
|
- Avoid raw Promises (`.then()`, `.catch()`) where `await` can be used.
|
||||||
|
|
||||||
|
## 5. Tooling
|
||||||
|
|
||||||
|
- **Linter/Formatter**: [Biome](https://biomejs.dev/) is used for linting and formatting.
|
||||||
|
- Indentation: 2 spaces.
|
||||||
|
- Quotes: Double quotes.
|
||||||
|
- No explicit `any`.
|
||||||
|
- Run `yarn lint` to lint the codebase.
|
||||||
|
- Run `yarn format` to format the codebase.
|
||||||
|
- These two commands should be ran before committing.
|
||||||
|
- **Testing**: Vitest (implied by `*.spec.ts` files).
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Lance
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
174
README.md
Normal file
174
README.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Express Starter Template
|
||||||
|
|
||||||
|
A robust **Modular Monolith** template for building scalable Node.js applications using **TypeScript**, **Express**, and **Clean Architecture** principles.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
- **Modular Architecture**: Vertical slice architecture ensuring separation of concerns and scalability.
|
||||||
|
- **Clean Architecture**: Domain-centric design with clear boundaries between Domain, Use Cases, and Infrastructure.
|
||||||
|
- **Type Safety**: Built with **TypeScript** in `nodenext` mode for modern ESM support.
|
||||||
|
- **Dependency Injection**: Powered by **InversifyJS** for loose coupling and testability.
|
||||||
|
- **Database**: **PostgreSQL** integration using **Prisma ORM** for type-safe database access and schema management.
|
||||||
|
- **Validation**: Runtime request validation using **Zod**.
|
||||||
|
- **Linting & Formatting**: Fast and efficient tooling with **Biome**.
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: Node.js (>= 22.18.0)
|
||||||
|
- **Framework**: Express.js
|
||||||
|
- **Language**: TypeScript
|
||||||
|
- **DI Container**: InversifyJS
|
||||||
|
- **Database**: PostgreSQL + Prisma ORM
|
||||||
|
- **Validation**: Zod
|
||||||
|
- **Testing**: Vitest
|
||||||
|
- **Tooling**: Biome, tsx, Swagger
|
||||||
|
|
||||||
|
For the first version, I'm planning of just using Express.js and InversifyJS. In the future, I plan on using the [InversifyJS framework with the Express v5 adapter](https://inversify.io/framework/docs/introduction/getting-started/) as another branch.
|
||||||
|
|
||||||
|
The `inversify-express-utils` package is already deprecated so the focus should be on the new framework package instead.
|
||||||
|
|
||||||
|
## 🏁 Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js >= 22.18.0
|
||||||
|
- npm or yarn
|
||||||
|
- PostgreSQL instance
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd express-starter
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set up environment variables:
|
||||||
|
Create a `.env` file in the root directory (refer to `.env.example` if available, otherwise configure your DB connection details).
|
||||||
|
|
||||||
|
4. Create the initial Prisma migration:
|
||||||
|
> Note: Run this command every time you make changes to the Prisma schema.
|
||||||
|
```bash
|
||||||
|
yarn prisma:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Generate Prisma Client:
|
||||||
|
> Note: Run this command every time you make changes to the Prisma schema.
|
||||||
|
```bash
|
||||||
|
yarn prisma:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Start the development server:
|
||||||
|
```bash
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Scripts
|
||||||
|
|
||||||
|
- `yarn dev`: Start the development server with hot-reloading (using `tsx`).
|
||||||
|
- `yarn build`: Build the project for production.
|
||||||
|
- `yarn start`: Start the production server.
|
||||||
|
- `yarn lint`: Lint the codebase using Biome.
|
||||||
|
- `yarn format`: Format the codebase using Biome.
|
||||||
|
- `yarn test`: Run unit tests using Vitest.
|
||||||
|
- `yarn coverage`: Run tests with coverage reporting.
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
The project uses **Vitest** for unit and integration testing.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
yarn coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
Tests are **co-located** with the source code they test. This keeps tests close to the implementation and makes it easier to maintain.
|
||||||
|
|
||||||
|
- **File Naming**: `*.spec.ts`
|
||||||
|
- **Location**: Same directory as the source file (e.g., `src/modules/users/domain/users.entity.ts` -> `src/modules/users/domain/users.entity.spec.ts`).
|
||||||
|
|
||||||
|
### Writing Tests
|
||||||
|
|
||||||
|
We use **Vitest** as our test runner, which provides a Jest-compatible API.
|
||||||
|
|
||||||
|
1. **Domain Entities**: Test business logic and invariants within entities.
|
||||||
|
- *Example*: `src/modules/users/domain/users.entity.spec.ts`
|
||||||
|
2. **Use Cases**: Test application logic by mocking dependencies (Repositories, Services) using `vi.fn()` or `vi.spyOn()`.
|
||||||
|
- *Example*: `src/modules/auth/use-cases/login-user.spec.ts`
|
||||||
|
3. **Shared Infrastructure**: Test generic implementations (fakes) to ensure they behave correctly.
|
||||||
|
- *Example*: `src/shared/infrastructure/persistence/fakes/InMemoryRepository.spec.ts`
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
The project uses **Swagger UI** to visualize and interact with the API's resources.
|
||||||
|
|
||||||
|
### Accessing Swagger UI
|
||||||
|
|
||||||
|
Start the development server (`yarn dev`) and navigate to:
|
||||||
|
`http://localhost:3000/docs`
|
||||||
|
|
||||||
|
This endpoint will only be available if the env var `ENVIRONMENT` is not `production`.
|
||||||
|
|
||||||
|
### Defining Routes
|
||||||
|
|
||||||
|
We use `swagger-jsdoc` to generate the OpenAPI specification from JSDoc comments directly in the route files.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /hello-world:
|
||||||
|
* get:
|
||||||
|
* tags:
|
||||||
|
* - Hello World
|
||||||
|
* summary: Greet the user
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Success
|
||||||
|
* content:
|
||||||
|
* text/plain:
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
*/
|
||||||
|
router.get("/", (req, res) => {
|
||||||
|
res.send("Hello world!");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📂 Project Structure
|
||||||
|
|
||||||
|
The project is organized into modules and shared components. For a detailed deep-dive into the architecture, please refer to [ARCHITECTURE.md](./ARCHITECTURE.md).
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── modules/ # Feature-specific modules (e.g., users, auth)
|
||||||
|
│ └── users/
|
||||||
|
│ ├── domain/ # Entities & Repository Interfaces
|
||||||
|
│ ├── use-cases/ # Application Business Rules
|
||||||
|
│ └── infrastructure/ # DB, HTTP, DI implementations
|
||||||
|
├── shared/ # Shared kernel, base classes, utilities
|
||||||
|
│ ├── core/
|
||||||
|
│ └── infrastructure/
|
||||||
|
└── app.ts # Application entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License.
|
||||||
53
biome.json
Normal file
53
biome.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": false,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": false
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false,
|
||||||
|
"includes": ["src/**", "!src/generated"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 80
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"style": {
|
||||||
|
"noEnum": "error"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "error",
|
||||||
|
"noEmptyInterface": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double"
|
||||||
|
},
|
||||||
|
"parser": {
|
||||||
|
"unsafeParameterDecoratorsEnabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"formatter": {
|
||||||
|
"indentStyle": "space",
|
||||||
|
"lineWidth": 80
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
package.json
Normal file
55
package.json
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "express-starter",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"lint": "biome lint",
|
||||||
|
"format": "biome format --fix",
|
||||||
|
"build": "tsc --build && tsc-alias",
|
||||||
|
"start": "node dist/app.js",
|
||||||
|
"dev": "tsx watch src/app.ts",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"test": "vitest",
|
||||||
|
"coverage": "vitest run --coverage"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.3.11",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^25.0.8",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
|
"@vitest/coverage-v8": "^4.0.17",
|
||||||
|
"prisma": "^7.2.0",
|
||||||
|
"tsc-alias": "^1.8.16",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite-tsconfig-paths": "^6.0.4",
|
||||||
|
"vitest": "^4.0.17"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/adapter-pg": "^7.2.0",
|
||||||
|
"@prisma/client": "^7.2.0",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"inversify": "^7.11.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"swagger-jsdoc": "^6.2.8",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"uuidv7": "^1.1.0",
|
||||||
|
"zod": "^4.3.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22.18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
prisma.config.ts
Normal file
14
prisma.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: env("POSTGRES_URL"),
|
||||||
|
},
|
||||||
|
});
|
||||||
57
src/app.ts
Normal file
57
src/app.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import express from "express";
|
||||||
|
import swaggerUi from "swagger-ui-express";
|
||||||
|
import "dotenv/config";
|
||||||
|
import type { Container } from "inversify";
|
||||||
|
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
|
||||||
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
|
import { appContainer } from "@/shared/infrastructure/di/Container.js";
|
||||||
|
import { errorHandler } from "@/shared/infrastructure/http/error-handlers/catchAll.js";
|
||||||
|
import { requestLogger } from "@/shared/infrastructure/http/middlewares/requestLogger.js";
|
||||||
|
import { stripHeaders } from "@/shared/infrastructure/http/middlewares/stripHeaders.js";
|
||||||
|
import baseRoutes from "@/shared/infrastructure/http/routes.js";
|
||||||
|
import { ConsoleLogger } from "@/shared/infrastructure/logger/ConsoleLogger.js";
|
||||||
|
import { PrismaClientWrapper } from "@/shared/infrastructure/persistence/prisma/PrismaClientWrapper.js";
|
||||||
|
import { swaggerSpec } from "@/swagger.js";
|
||||||
|
import type { ILogger } from "./shared/application/ports/ILogger.js";
|
||||||
|
import { zodValidationHandler } from "./shared/infrastructure/http/error-handlers/zodValidation.js";
|
||||||
|
|
||||||
|
function bootstrap() {
|
||||||
|
// DI setup
|
||||||
|
configure(appContainer);
|
||||||
|
const configService = appContainer.get<IConfigService>(
|
||||||
|
SharedDomain.IConfigService,
|
||||||
|
);
|
||||||
|
const logger = appContainer.get<ILogger>(SharedDomain.ILogger);
|
||||||
|
|
||||||
|
// web server setup
|
||||||
|
const app = express();
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(requestLogger);
|
||||||
|
app.use(stripHeaders);
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(baseRoutes);
|
||||||
|
app.use(zodValidationHandler);
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
if (configService.isDevelopment()) {
|
||||||
|
app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||||
|
logger.info({
|
||||||
|
message: "Serving Swagger docs at /docs",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configure(appContainer: Container) {
|
||||||
|
appContainer.bind(PrismaClientWrapper).toSelf().inSingletonScope();
|
||||||
|
appContainer.bind(SharedDomain.ILogger).to(ConsoleLogger).inSingletonScope();
|
||||||
|
}
|
||||||
|
|
||||||
|
// run the app
|
||||||
|
bootstrap().listen(3000, () => {
|
||||||
|
const logger = appContainer.get<ILogger>(SharedDomain.ILogger);
|
||||||
|
logger.info({
|
||||||
|
message: "Server running on port 3000",
|
||||||
|
});
|
||||||
|
});
|
||||||
10
src/modules/auth/infrastructure/di/auth.di.ts
Normal file
10
src/modules/auth/infrastructure/di/auth.di.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ContainerModule } from "inversify";
|
||||||
|
import { LoginUserUseCase } from "../../use-cases/login-user.js";
|
||||||
|
import { RefreshSessionUseCase } from "../../use-cases/refresh-session.js";
|
||||||
|
import { UserSignupUseCase } from "../../use-cases/user-signup.js";
|
||||||
|
|
||||||
|
export const AuthDIModule = new ContainerModule(({ bind }) => {
|
||||||
|
bind(LoginUserUseCase).toSelf().inTransientScope();
|
||||||
|
bind(RefreshSessionUseCase).toSelf().inTransientScope();
|
||||||
|
bind(UserSignupUseCase).toSelf().inTransientScope();
|
||||||
|
});
|
||||||
185
src/modules/auth/infrastructure/http/auth.routes.ts
Normal file
185
src/modules/auth/infrastructure/http/auth.routes.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import z from "zod";
|
||||||
|
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
|
||||||
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
|
import { appContainer } from "@/shared/infrastructure/di/Container.js";
|
||||||
|
import { requireAuth } from "@/shared/infrastructure/http/middlewares/requireAuth.js";
|
||||||
|
import { respondWithGenericError } from "@/shared/infrastructure/http/responses/respondWithGenericError.js";
|
||||||
|
import { LoginUserUseCase } from "../../use-cases/login-user.js";
|
||||||
|
import { RefreshSessionUseCase } from "../../use-cases/refresh-session.js";
|
||||||
|
import { UserSignupUseCase } from "../../use-cases/user-signup.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* components:
|
||||||
|
* schemas:
|
||||||
|
* LoginRequest:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* email:
|
||||||
|
* type: string
|
||||||
|
* format: email
|
||||||
|
* password:
|
||||||
|
* type: string
|
||||||
|
* format: password
|
||||||
|
*/
|
||||||
|
const LoginRequestSchema = z.object({
|
||||||
|
email: z.email(),
|
||||||
|
password: z.string().min(6),
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /auth/login:
|
||||||
|
* post:
|
||||||
|
* tags:
|
||||||
|
* - Auth
|
||||||
|
* summary: Login a user
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/LoginRequest'
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Login successful
|
||||||
|
* 401:
|
||||||
|
* description: Invalid credentials
|
||||||
|
*/
|
||||||
|
router.post("/login", async (req, res) => {
|
||||||
|
const configService = appContainer.get<IConfigService>(
|
||||||
|
SharedDomain.IConfigService,
|
||||||
|
);
|
||||||
|
const { email, password } = LoginRequestSchema.parse(req.body);
|
||||||
|
const useCase = appContainer.get(LoginUserUseCase);
|
||||||
|
const { token, refreshToken } = await useCase.execute({ email, password });
|
||||||
|
|
||||||
|
if (token && refreshToken) {
|
||||||
|
res.cookie("token", token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: !configService.isDevelopment,
|
||||||
|
sameSite: "strict",
|
||||||
|
});
|
||||||
|
res.cookie("refreshToken", refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: !configService.isDevelopment,
|
||||||
|
sameSite: "strict",
|
||||||
|
});
|
||||||
|
res.status(200).send();
|
||||||
|
} else {
|
||||||
|
respondWithGenericError({
|
||||||
|
res,
|
||||||
|
response: {
|
||||||
|
message: "Invalid credentials",
|
||||||
|
},
|
||||||
|
statusCode: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* components:
|
||||||
|
* schemas:
|
||||||
|
* RegisterRequest:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* email:
|
||||||
|
* type: string
|
||||||
|
* format: email
|
||||||
|
* password:
|
||||||
|
* type: string
|
||||||
|
* format: password
|
||||||
|
*/
|
||||||
|
const RegisterRequestSchema = z.object({
|
||||||
|
email: z.email(),
|
||||||
|
password: z.string().min(6),
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /auth/register:
|
||||||
|
* post:
|
||||||
|
* tags:
|
||||||
|
* - Auth
|
||||||
|
* summary: Register a user
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/RegisterRequest'
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Register successful
|
||||||
|
*/
|
||||||
|
router.post("/register", async (req, res) => {
|
||||||
|
const { email, password } = RegisterRequestSchema.parse(req.body);
|
||||||
|
const useCase = appContainer.get(UserSignupUseCase);
|
||||||
|
await useCase.execute({ email, password });
|
||||||
|
res.status(200).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /auth/refresh:
|
||||||
|
* post:
|
||||||
|
* tags:
|
||||||
|
* - Auth
|
||||||
|
* summary: Refresh the current user session
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Refresh successful
|
||||||
|
*/
|
||||||
|
router.post("/refresh", requireAuth, async (req, res) => {
|
||||||
|
const configService = appContainer.get<IConfigService>(
|
||||||
|
SharedDomain.IConfigService,
|
||||||
|
);
|
||||||
|
const useCase = appContainer.get(RefreshSessionUseCase);
|
||||||
|
const { token, refreshToken } = await useCase.execute({
|
||||||
|
refreshToken: req.cookies.refreshToken,
|
||||||
|
});
|
||||||
|
if (token && refreshToken) {
|
||||||
|
res.cookie("token", token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: !configService.isDevelopment,
|
||||||
|
sameSite: "strict",
|
||||||
|
});
|
||||||
|
res.cookie("refreshToken", refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: !configService.isDevelopment,
|
||||||
|
sameSite: "strict",
|
||||||
|
});
|
||||||
|
res.status(200).send();
|
||||||
|
} else {
|
||||||
|
respondWithGenericError({
|
||||||
|
res,
|
||||||
|
response: {
|
||||||
|
message: "Invalid refresh token",
|
||||||
|
},
|
||||||
|
statusCode: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /auth/logout:
|
||||||
|
* post:
|
||||||
|
* tags:
|
||||||
|
* - Auth
|
||||||
|
* summary: Logout the current user session
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Logout successful
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
*/
|
||||||
|
router.post("/logout", requireAuth, async (_req, res) => {
|
||||||
|
res.clearCookie("token");
|
||||||
|
res.clearCookie("refreshToken");
|
||||||
|
res.status(200).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
116
src/modules/auth/use-cases/login-user.spec.ts
Normal file
116
src/modules/auth/use-cases/login-user.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { UserEntity } from "@/modules/users/domain/users.entity.js";
|
||||||
|
import { UsersInMemoryRepository } from "@/modules/users/infrastructure/fakes/users.in-memory.repo.js";
|
||||||
|
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
|
||||||
|
import type { ILogger } from "@/shared/application/ports/ILogger.js";
|
||||||
|
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
|
||||||
|
import { LoginUserUseCase } from "./login-user.js";
|
||||||
|
|
||||||
|
describe("Auth - Login user", () => {
|
||||||
|
let usersRepo: UsersInMemoryRepository;
|
||||||
|
const MockCryptoService = vi.fn(
|
||||||
|
class implements ICryptoService {
|
||||||
|
randomId = vi.fn().mockReturnValue("2");
|
||||||
|
hashPassword = vi.fn().mockResolvedValue("hashed-password");
|
||||||
|
comparePassword = vi.fn().mockResolvedValue(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const MockTokenService = vi.fn(
|
||||||
|
class implements ITokenService {
|
||||||
|
generateToken = vi.fn().mockReturnValue("token");
|
||||||
|
generateRefreshToken = vi.fn().mockReturnValue("refresh-token");
|
||||||
|
getSession = vi.fn().mockReturnValue({
|
||||||
|
userId: "1",
|
||||||
|
email: "test@example.com",
|
||||||
|
isVerified: true,
|
||||||
|
loginDate: new Date(),
|
||||||
|
});
|
||||||
|
validateRefreshToken = vi.fn().mockReturnValue({ userId: "1" });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const MockLogger = vi.fn(
|
||||||
|
class implements ILogger {
|
||||||
|
info = vi.fn();
|
||||||
|
error = vi.fn();
|
||||||
|
warn = vi.fn();
|
||||||
|
debug = vi.fn();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const cryptoService = new MockCryptoService();
|
||||||
|
const tokenService = new MockTokenService();
|
||||||
|
const logger = new MockLogger();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
usersRepo = new UsersInMemoryRepository();
|
||||||
|
usersRepo.save(
|
||||||
|
new UserEntity("1", "test@example.com", "password", true, new Date()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should login a user", async () => {
|
||||||
|
const findOneSpy = vi.spyOn(usersRepo, "findOne");
|
||||||
|
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
|
||||||
|
const useCase = new LoginUserUseCase(
|
||||||
|
usersRepo,
|
||||||
|
cryptoService,
|
||||||
|
tokenService,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
const result = await useCase.execute({
|
||||||
|
email: "test@example.com",
|
||||||
|
password: "password",
|
||||||
|
});
|
||||||
|
expect(findOneSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(generateTokenSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toEqual({
|
||||||
|
token: "token",
|
||||||
|
refreshToken: "refresh-token",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not login a user if the user is not found", async () => {
|
||||||
|
const findOneSpy = vi.spyOn(usersRepo, "findOne");
|
||||||
|
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
|
||||||
|
const useCase = new LoginUserUseCase(
|
||||||
|
usersRepo,
|
||||||
|
cryptoService,
|
||||||
|
tokenService,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
const result = await useCase.execute({
|
||||||
|
email: "test2@example.com",
|
||||||
|
password: "password",
|
||||||
|
});
|
||||||
|
expect(findOneSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
|
||||||
|
expect(result).toEqual({
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not login a user if the password is invalid", async () => {
|
||||||
|
const findOneSpy = vi.spyOn(usersRepo, "findOne");
|
||||||
|
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
|
||||||
|
const useCase = new LoginUserUseCase(
|
||||||
|
usersRepo,
|
||||||
|
cryptoService,
|
||||||
|
tokenService,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
const result = await useCase.execute({
|
||||||
|
email: "test@example.com",
|
||||||
|
password: "password2",
|
||||||
|
});
|
||||||
|
expect(findOneSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
|
||||||
|
expect(result).toEqual({
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/modules/auth/use-cases/login-user.ts
Normal file
71
src/modules/auth/use-cases/login-user.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { inject, injectable, optional } from "inversify";
|
||||||
|
import type { IUsersRepository } from "@/modules/users/domain/users.repo.js";
|
||||||
|
import { UsersDomain } from "@/modules/users/domain/users.symbols.js";
|
||||||
|
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
|
||||||
|
import type {
|
||||||
|
ILogger,
|
||||||
|
IRequestContext,
|
||||||
|
} from "@/shared/application/ports/ILogger.js";
|
||||||
|
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
|
||||||
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
|
import type { IUseCase } from "@/shared/core/IUseCase.js";
|
||||||
|
|
||||||
|
export type LoginUserDTO = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
export type LoginUserResult = {
|
||||||
|
token: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@injectable()
|
||||||
|
export class LoginUserUseCase
|
||||||
|
implements IUseCase<LoginUserDTO, LoginUserResult>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
@inject(UsersDomain.IUserRepository)
|
||||||
|
private readonly userRepository: IUsersRepository,
|
||||||
|
@inject(SharedDomain.ICryptoService)
|
||||||
|
private readonly cryptoService: ICryptoService,
|
||||||
|
@inject(SharedDomain.ITokenService)
|
||||||
|
private readonly tokenService: ITokenService,
|
||||||
|
@inject(SharedDomain.ILogger)
|
||||||
|
private readonly logger: ILogger,
|
||||||
|
@inject(SharedDomain.IRequestContext)
|
||||||
|
@optional()
|
||||||
|
private readonly requestContext?: IRequestContext,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(dto: LoginUserDTO): Promise<LoginUserResult> {
|
||||||
|
const user = await this.userRepository.findOne({ email: dto.email });
|
||||||
|
if (!user) {
|
||||||
|
this.logger.error({
|
||||||
|
message: "Invalid credentials",
|
||||||
|
module: "LoginUserUseCase",
|
||||||
|
context: this.requestContext,
|
||||||
|
});
|
||||||
|
return { token: null, refreshToken: null };
|
||||||
|
}
|
||||||
|
const isPasswordValid = await this.cryptoService.comparePassword(
|
||||||
|
dto.password,
|
||||||
|
user.password,
|
||||||
|
);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
this.logger.error({
|
||||||
|
message: "Invalid credentials",
|
||||||
|
module: "LoginUserUseCase",
|
||||||
|
context: this.requestContext,
|
||||||
|
});
|
||||||
|
return { token: null, refreshToken: null };
|
||||||
|
}
|
||||||
|
const token = this.tokenService.generateToken(user);
|
||||||
|
const refreshToken = this.tokenService.generateRefreshToken(user);
|
||||||
|
this.logger.info({
|
||||||
|
message: "User logged in",
|
||||||
|
module: "LoginUserUseCase",
|
||||||
|
context: this.requestContext,
|
||||||
|
});
|
||||||
|
return { token, refreshToken };
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/modules/auth/use-cases/refresh-session.spec.ts
Normal file
97
src/modules/auth/use-cases/refresh-session.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { UserEntity } from "@/modules/users/domain/users.entity.js";
|
||||||
|
import { UsersInMemoryRepository } from "@/modules/users/infrastructure/fakes/users.in-memory.repo.js";
|
||||||
|
import type { ILogger } from "@/shared/application/ports/ILogger.js";
|
||||||
|
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
|
||||||
|
import { RefreshSessionUseCase } from "./refresh-session.js";
|
||||||
|
|
||||||
|
describe("Auth - Refresh session", () => {
|
||||||
|
let usersRepo: UsersInMemoryRepository;
|
||||||
|
const MockTokenService = vi.fn(
|
||||||
|
class implements ITokenService {
|
||||||
|
generateToken = vi.fn().mockReturnValue("token");
|
||||||
|
generateRefreshToken = vi.fn().mockReturnValue("refresh-token");
|
||||||
|
getSession = vi.fn().mockReturnValue({
|
||||||
|
userId: "1",
|
||||||
|
email: "test@example.com",
|
||||||
|
isVerified: true,
|
||||||
|
loginDate: new Date(),
|
||||||
|
});
|
||||||
|
validateRefreshToken = vi.fn((refreshToken) => {
|
||||||
|
if (refreshToken === "refresh-token") {
|
||||||
|
return { userId: "1" };
|
||||||
|
}
|
||||||
|
if (refreshToken === "non-existant-user") {
|
||||||
|
return { userId: "2" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const MockLogger = vi.fn(
|
||||||
|
class implements ILogger {
|
||||||
|
info = vi.fn();
|
||||||
|
error = vi.fn();
|
||||||
|
warn = vi.fn();
|
||||||
|
debug = vi.fn();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const tokenService = new MockTokenService();
|
||||||
|
const logger = new MockLogger();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
usersRepo = new UsersInMemoryRepository();
|
||||||
|
usersRepo.save(
|
||||||
|
new UserEntity("1", "test@example.com", "password", true, new Date()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should refresh a session", async () => {
|
||||||
|
const findOneSpy = vi.spyOn(usersRepo, "findOne");
|
||||||
|
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
|
||||||
|
const useCase = new RefreshSessionUseCase(usersRepo, tokenService, logger);
|
||||||
|
const result = await useCase.execute({
|
||||||
|
refreshToken: "refresh-token",
|
||||||
|
});
|
||||||
|
expect(findOneSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(generateTokenSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toEqual({
|
||||||
|
token: "token",
|
||||||
|
refreshToken: "refresh-token",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not refresh when refresh token is invalid", async () => {
|
||||||
|
const findOneSpy = vi.spyOn(usersRepo, "findOne");
|
||||||
|
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
|
||||||
|
const useCase = new RefreshSessionUseCase(usersRepo, tokenService, logger);
|
||||||
|
const result = await useCase.execute({
|
||||||
|
refreshToken: "invalid-refresh-token",
|
||||||
|
});
|
||||||
|
expect(findOneSpy).toHaveBeenCalledTimes(0);
|
||||||
|
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
|
||||||
|
expect(result).toEqual({
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not refresh when user id does not exist", async () => {
|
||||||
|
const findOneSpy = vi.spyOn(usersRepo, "findOne");
|
||||||
|
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
|
||||||
|
const useCase = new RefreshSessionUseCase(usersRepo, tokenService, logger);
|
||||||
|
const result = await useCase.execute({
|
||||||
|
refreshToken: "non-existant-user",
|
||||||
|
});
|
||||||
|
expect(findOneSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
|
||||||
|
expect(result).toEqual({
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
66
src/modules/auth/use-cases/refresh-session.ts
Normal file
66
src/modules/auth/use-cases/refresh-session.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { inject, injectable, optional } from "inversify";
|
||||||
|
import type { IUsersRepository } from "@/modules/users/domain/users.repo.js";
|
||||||
|
import { UsersDomain } from "@/modules/users/domain/users.symbols.js";
|
||||||
|
import type {
|
||||||
|
ILogger,
|
||||||
|
IRequestContext,
|
||||||
|
} from "@/shared/application/ports/ILogger.js";
|
||||||
|
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
|
||||||
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
|
import type { IUseCase } from "@/shared/core/IUseCase.js";
|
||||||
|
|
||||||
|
export type RefreshSessionDTO = {
|
||||||
|
refreshToken: string;
|
||||||
|
};
|
||||||
|
export type RefreshSessionResult = {
|
||||||
|
token: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@injectable()
|
||||||
|
export class RefreshSessionUseCase
|
||||||
|
implements IUseCase<RefreshSessionDTO, RefreshSessionResult>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
@inject(UsersDomain.IUserRepository)
|
||||||
|
private readonly userRepository: IUsersRepository,
|
||||||
|
@inject(SharedDomain.ITokenService)
|
||||||
|
private readonly tokenService: ITokenService,
|
||||||
|
@inject(SharedDomain.ILogger)
|
||||||
|
private readonly logger: ILogger,
|
||||||
|
@inject(SharedDomain.IRequestContext)
|
||||||
|
@optional()
|
||||||
|
private readonly requestContext?: IRequestContext,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(dto: RefreshSessionDTO): Promise<RefreshSessionResult> {
|
||||||
|
const refreshData = this.tokenService.validateRefreshToken(
|
||||||
|
dto.refreshToken,
|
||||||
|
);
|
||||||
|
if (!refreshData?.userId) {
|
||||||
|
this.logger.error({
|
||||||
|
message: "Invalid refresh token",
|
||||||
|
module: "RefreshSessionUseCase",
|
||||||
|
context: this.requestContext,
|
||||||
|
});
|
||||||
|
return { token: null, refreshToken: null };
|
||||||
|
}
|
||||||
|
const user = await this.userRepository.findOne({ id: refreshData.userId });
|
||||||
|
if (!user) {
|
||||||
|
this.logger.error({
|
||||||
|
message: "Invalid refresh token",
|
||||||
|
module: "RefreshSessionUseCase",
|
||||||
|
context: this.requestContext,
|
||||||
|
});
|
||||||
|
return { token: null, refreshToken: null };
|
||||||
|
}
|
||||||
|
const token = this.tokenService.generateToken(user);
|
||||||
|
const refreshToken = this.tokenService.generateRefreshToken(user);
|
||||||
|
this.logger.info({
|
||||||
|
message: "Session refreshed",
|
||||||
|
module: "RefreshSessionUseCase",
|
||||||
|
context: this.requestContext,
|
||||||
|
});
|
||||||
|
return { token, refreshToken };
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/modules/auth/use-cases/user-signup.spec.ts
Normal file
75
src/modules/auth/use-cases/user-signup.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { UserEntity } from "@/modules/users/domain/users.entity.js";
|
||||||
|
import { UsersInMemoryRepository } from "@/modules/users/infrastructure/fakes/users.in-memory.repo.js";
|
||||||
|
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
|
||||||
|
import { UserSignupUseCase } from "./user-signup.js";
|
||||||
|
|
||||||
|
describe("Auth - User signup", () => {
|
||||||
|
let usersRepo: UsersInMemoryRepository;
|
||||||
|
const MockCryptoService = vi.fn(
|
||||||
|
class implements ICryptoService {
|
||||||
|
randomId = vi.fn().mockReturnValue("1");
|
||||||
|
hashPassword = vi.fn().mockResolvedValue("hashed-password");
|
||||||
|
comparePassword = vi.fn().mockResolvedValue(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const cryptoService: ICryptoService = new MockCryptoService();
|
||||||
|
let useCase: UserSignupUseCase;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
usersRepo = new UsersInMemoryRepository();
|
||||||
|
useCase = new UserSignupUseCase(usersRepo, cryptoService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should signup a user", async () => {
|
||||||
|
const saveSpy = vi.spyOn(usersRepo, "save");
|
||||||
|
const findOneSpy = vi.spyOn(usersRepo, "findOne");
|
||||||
|
const result = await useCase.execute({
|
||||||
|
email: "test@example.com",
|
||||||
|
password: "password",
|
||||||
|
});
|
||||||
|
expect(cryptoService.randomId).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cryptoService.hashPassword).toHaveBeenCalledTimes(1);
|
||||||
|
expect(saveSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(findOneSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await usersRepo.findAll({
|
||||||
|
email: "test@example.com",
|
||||||
|
})
|
||||||
|
).data,
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await usersRepo.findAll({
|
||||||
|
email: "test@example.com",
|
||||||
|
})
|
||||||
|
).data,
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw an error if the user already exists", async () => {
|
||||||
|
// setup
|
||||||
|
await usersRepo.save(
|
||||||
|
new UserEntity(
|
||||||
|
"1",
|
||||||
|
"test@example.com",
|
||||||
|
"hashed-password",
|
||||||
|
true,
|
||||||
|
new Date(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// act
|
||||||
|
await expect(
|
||||||
|
useCase.execute({
|
||||||
|
email: "test@example.com",
|
||||||
|
password: "password",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("User already exists");
|
||||||
|
});
|
||||||
|
});
|
||||||
38
src/modules/auth/use-cases/user-signup.ts
Normal file
38
src/modules/auth/use-cases/user-signup.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { inject, injectable } from "inversify";
|
||||||
|
import { UserEntity } from "@/modules/users/domain/users.entity.js";
|
||||||
|
import type { IUsersRepository } from "@/modules/users/domain/users.repo.js";
|
||||||
|
import { UsersDomain } from "@/modules/users/domain/users.symbols.js";
|
||||||
|
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
|
||||||
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
|
import type { IUseCase } from "@/shared/core/IUseCase.js";
|
||||||
|
|
||||||
|
export type UserSignupDTO = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@injectable()
|
||||||
|
export class UserSignupUseCase implements IUseCase<UserSignupDTO> {
|
||||||
|
constructor(
|
||||||
|
@inject(UsersDomain.IUserRepository)
|
||||||
|
private readonly usersRepository: IUsersRepository,
|
||||||
|
@inject(SharedDomain.ICryptoService)
|
||||||
|
private readonly cryptoService: ICryptoService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(dto: UserSignupDTO): Promise<void> {
|
||||||
|
const user = await this.usersRepository.findOne({ email: dto.email });
|
||||||
|
if (user) {
|
||||||
|
throw new Error("User already exists");
|
||||||
|
}
|
||||||
|
const hashedPassword = await this.cryptoService.hashPassword(dto.password);
|
||||||
|
const userEntity = new UserEntity(
|
||||||
|
this.cryptoService.randomId(),
|
||||||
|
dto.email,
|
||||||
|
hashedPassword,
|
||||||
|
false,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
await this.usersRepository.save(userEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /hello-world:
|
||||||
|
* get:
|
||||||
|
* tags:
|
||||||
|
* - Hello World
|
||||||
|
* summary: Greet the user
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Tells whether the user is logged in or not
|
||||||
|
* content:
|
||||||
|
* text/plain:
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
*/
|
||||||
|
router.get("/", (req, res) => {
|
||||||
|
const { session, currentUser } = req;
|
||||||
|
|
||||||
|
if (!session || !currentUser) {
|
||||||
|
return res.send("Hello world! You are not logged in.");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(`Hello ${currentUser.email}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
5
src/modules/users/domain/errors/InvalidEmailFormat.ts
Normal file
5
src/modules/users/domain/errors/InvalidEmailFormat.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export class InvalidEmailFormat extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Invalid email format");
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/modules/users/domain/errors/InvalidPassword.ts
Normal file
5
src/modules/users/domain/errors/InvalidPassword.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export class InvalidPassword extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Invalid password");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export class NewPasswordMustBeDifferent extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("New password must be different from the old password");
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/modules/users/domain/errors/UserNotFound.ts
Normal file
5
src/modules/users/domain/errors/UserNotFound.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export class UserNotFound extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("User not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/modules/users/domain/users.entity.spec.ts
Normal file
126
src/modules/users/domain/users.entity.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import { InvalidEmailFormat } from "./errors/InvalidEmailFormat.js";
|
||||||
|
import { InvalidPassword } from "./errors/InvalidPassword.js";
|
||||||
|
import { NewPasswordMustBeDifferent } from "./errors/NewPasswordMustBeDifferent.js";
|
||||||
|
import { UserEntity } from "./users.entity.js";
|
||||||
|
|
||||||
|
describe("Users - UserEntity", () => {
|
||||||
|
test("should create a user entity", () => {
|
||||||
|
const user = new UserEntity(
|
||||||
|
"1",
|
||||||
|
"test@example.com",
|
||||||
|
"password",
|
||||||
|
false,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(user.id).toBe("1");
|
||||||
|
expect(user.email).toBe("test@example.com");
|
||||||
|
expect(user.password).toBe("password");
|
||||||
|
expect(user.isVerified).toBe(false);
|
||||||
|
expect(user.createdAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw an error if the email is invalid", () => {
|
||||||
|
expect(() => {
|
||||||
|
new UserEntity("1", "test", "password", false, new Date());
|
||||||
|
}).toThrowError(InvalidEmailFormat);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw an error if the password is invalid", () => {
|
||||||
|
expect(() => {
|
||||||
|
new UserEntity("1", "test@example.com", "", false, new Date());
|
||||||
|
}).toThrowError(InvalidPassword);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should get account age in seconds", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date(2000, 1, 1, 13, 0, 0));
|
||||||
|
const user = new UserEntity(
|
||||||
|
"1",
|
||||||
|
"test@example.com",
|
||||||
|
"password",
|
||||||
|
false,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
// advance time by 5 seconds
|
||||||
|
vi.setSystemTime(new Date(2000, 1, 1, 13, 0, 5));
|
||||||
|
expect(user.getAccountAge()).toBe(5);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should change email successfully", () => {
|
||||||
|
const user = new UserEntity(
|
||||||
|
"1",
|
||||||
|
"test@example.com",
|
||||||
|
"password",
|
||||||
|
false,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
user.changeEmail("test2@example.com");
|
||||||
|
expect(user.email).toBe("test2@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw an error if the new email is invalid", () => {
|
||||||
|
const user = new UserEntity(
|
||||||
|
"1",
|
||||||
|
"test@example.com",
|
||||||
|
"password",
|
||||||
|
false,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
expect(() => {
|
||||||
|
user.changeEmail("test");
|
||||||
|
}).toThrowError(InvalidEmailFormat);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should change password successfully", () => {
|
||||||
|
const user = new UserEntity(
|
||||||
|
"1",
|
||||||
|
"test@example.com",
|
||||||
|
"password",
|
||||||
|
false,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
user.changePassword("password2");
|
||||||
|
expect(user.password).toBe("password2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw an error if the new password is the same as the old password", () => {
|
||||||
|
const user = new UserEntity(
|
||||||
|
"1",
|
||||||
|
"test@example.com",
|
||||||
|
"password",
|
||||||
|
false,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
expect(() => {
|
||||||
|
user.changePassword("password");
|
||||||
|
}).toThrowError(NewPasswordMustBeDifferent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw an error if the new password is invalid", () => {
|
||||||
|
const user = new UserEntity(
|
||||||
|
"1",
|
||||||
|
"test@example.com",
|
||||||
|
"password",
|
||||||
|
false,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
expect(() => {
|
||||||
|
user.changePassword("");
|
||||||
|
}).toThrowError(InvalidPassword);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should set verified status successfully", () => {
|
||||||
|
const user = new UserEntity(
|
||||||
|
"1",
|
||||||
|
"test@example.com",
|
||||||
|
"password",
|
||||||
|
false,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
user.setVerifiedStatus(true);
|
||||||
|
expect(user.isVerified).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/modules/users/domain/users.entity.ts
Normal file
49
src/modules/users/domain/users.entity.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { InvalidEmailFormat } from "./errors/InvalidEmailFormat.js";
|
||||||
|
import { InvalidPassword } from "./errors/InvalidPassword.js";
|
||||||
|
import { NewPasswordMustBeDifferent } from "./errors/NewPasswordMustBeDifferent.js";
|
||||||
|
|
||||||
|
export class UserEntity {
|
||||||
|
constructor(
|
||||||
|
public id: string,
|
||||||
|
public email: string,
|
||||||
|
public password: string,
|
||||||
|
public isVerified: boolean,
|
||||||
|
public createdAt: Date,
|
||||||
|
) {
|
||||||
|
if (!email.includes("@")) {
|
||||||
|
throw new InvalidEmailFormat();
|
||||||
|
}
|
||||||
|
if (password.length === 0) {
|
||||||
|
throw new InvalidPassword();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the age of the account in seconds
|
||||||
|
* @returns account age in seconds
|
||||||
|
*/
|
||||||
|
getAccountAge() {
|
||||||
|
return (Date.now() - this.createdAt.getTime()) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeEmail(newEmail: string) {
|
||||||
|
if (!newEmail.includes("@")) {
|
||||||
|
throw new InvalidEmailFormat();
|
||||||
|
}
|
||||||
|
this.email = newEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
changePassword(newHashedPassword: string) {
|
||||||
|
if (this.password === newHashedPassword) {
|
||||||
|
throw new NewPasswordMustBeDifferent();
|
||||||
|
}
|
||||||
|
if (newHashedPassword.length === 0) {
|
||||||
|
throw new InvalidPassword();
|
||||||
|
}
|
||||||
|
this.password = newHashedPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVerifiedStatus(verifiedStatus: boolean) {
|
||||||
|
this.isVerified = verifiedStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/modules/users/domain/users.repo.ts
Normal file
4
src/modules/users/domain/users.repo.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import type { IBaseRepository } from "@/shared/core/IBaseRepository.js";
|
||||||
|
import type { UserEntity } from "./users.entity.js";
|
||||||
|
|
||||||
|
export interface IUsersRepository extends IBaseRepository<UserEntity> {}
|
||||||
15
src/modules/users/domain/users.service.ts
Normal file
15
src/modules/users/domain/users.service.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/** biome-ignore-all lint/suspicious/noEmptyInterface: This is a placeholder reference file. If your feature does not require a domain service, you can remove this file. */
|
||||||
|
/** biome-ignore-all lint/correctness/noUnusedPrivateClassMembers: This is a placeholder reference file. If your feature does not require a domain service, you can remove this file. */
|
||||||
|
import { inject, injectable } from "inversify";
|
||||||
|
import type { IUsersRepository } from "./users.repo.js";
|
||||||
|
import { UsersDomain } from "./users.symbols.js";
|
||||||
|
|
||||||
|
export interface IUsersService {}
|
||||||
|
|
||||||
|
@injectable()
|
||||||
|
export class UsersService implements IUsersService {
|
||||||
|
constructor(
|
||||||
|
@inject(UsersDomain.IUserRepository)
|
||||||
|
private readonly userRepo: IUsersRepository,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
4
src/modules/users/domain/users.symbols.ts
Normal file
4
src/modules/users/domain/users.symbols.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const UsersDomain = {
|
||||||
|
IUserRepository: Symbol.for("IUserRepository"),
|
||||||
|
IUserService: Symbol.for("IUsersService"),
|
||||||
|
};
|
||||||
14
src/modules/users/infrastructure/di/users.di.ts
Normal file
14
src/modules/users/infrastructure/di/users.di.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { ContainerModule } from "inversify";
|
||||||
|
import type { IUsersRepository } from "../../domain/users.repo.js";
|
||||||
|
import type { IUsersService } from "../../domain/users.service.js";
|
||||||
|
import { UsersService } from "../../domain/users.service.js";
|
||||||
|
import { UsersDomain } from "../../domain/users.symbols.js";
|
||||||
|
import { RegisterUserUseCase } from "../../use-cases/register-user.js";
|
||||||
|
import { UsersPrismaRepository } from "../persistence/users.prisma.repo.js";
|
||||||
|
|
||||||
|
export const UsersDIModule = new ContainerModule(({ bind }) => {
|
||||||
|
bind<IUsersRepository>(UsersDomain.IUserRepository).to(UsersPrismaRepository);
|
||||||
|
bind<IUsersService>(UsersDomain.IUserService).to(UsersService);
|
||||||
|
|
||||||
|
bind(RegisterUserUseCase).toSelf().inTransientScope();
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { InMemoryRepository } from "@/shared/infrastructure/persistence/fakes/InMemoryRepository.js";
|
||||||
|
import type { UserEntity } from "../../domain/users.entity.js";
|
||||||
|
import type { IUsersRepository } from "../../domain/users.repo.js";
|
||||||
|
|
||||||
|
export class UsersInMemoryRepository
|
||||||
|
extends InMemoryRepository<UserEntity>
|
||||||
|
implements IUsersRepository {}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { inject, injectable } from "inversify";
|
||||||
|
import type { User } from "@/generated/prisma/client.js";
|
||||||
|
import type { UserWhereInput } from "@/generated/prisma/models.js";
|
||||||
|
import type {
|
||||||
|
FilterCriteria,
|
||||||
|
PaginationOptions,
|
||||||
|
WithPagination,
|
||||||
|
} from "@/shared/core/IBaseRepository.js";
|
||||||
|
import {
|
||||||
|
type PrismaClient,
|
||||||
|
PrismaClientWrapper,
|
||||||
|
} from "@/shared/infrastructure/persistence/prisma/PrismaClientWrapper.js";
|
||||||
|
import { UserEntity } from "../../domain/users.entity.js";
|
||||||
|
import type { IUsersRepository } from "../../domain/users.repo.js";
|
||||||
|
|
||||||
|
/** MAPPERS */
|
||||||
|
export function fromDomain(userEntity: UserEntity): User {
|
||||||
|
return {
|
||||||
|
id: userEntity.id,
|
||||||
|
email: userEntity.email,
|
||||||
|
password: userEntity.password,
|
||||||
|
isVerified: userEntity.isVerified,
|
||||||
|
createdAt: userEntity.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function toDomain(userModel: User): UserEntity {
|
||||||
|
return new UserEntity(
|
||||||
|
userModel.id,
|
||||||
|
userModel.email,
|
||||||
|
userModel.password,
|
||||||
|
userModel.isVerified,
|
||||||
|
userModel.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function toUserModelFilter(
|
||||||
|
criteria: FilterCriteria<UserEntity>,
|
||||||
|
): FilterCriteria<User> {
|
||||||
|
const result: FilterCriteria<User> = {};
|
||||||
|
if (criteria.id !== undefined) result.id = criteria.id;
|
||||||
|
if (criteria.email !== undefined) result.email = criteria.email;
|
||||||
|
if (criteria.password !== undefined) result.password = criteria.password;
|
||||||
|
if (criteria.createdAt !== undefined) result.createdAt = criteria.createdAt;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@injectable()
|
||||||
|
export class UsersPrismaRepository implements IUsersRepository {
|
||||||
|
private readonly prisma: PrismaClient;
|
||||||
|
constructor(
|
||||||
|
@inject(PrismaClientWrapper)
|
||||||
|
private readonly prismaClientWrapper: PrismaClientWrapper,
|
||||||
|
) {
|
||||||
|
this.prisma = this.prismaClientWrapper.getClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(
|
||||||
|
criteria: FilterCriteria<UserEntity>,
|
||||||
|
): Promise<UserEntity | null> {
|
||||||
|
const where: UserWhereInput = {};
|
||||||
|
const modelFilter = toUserModelFilter(criteria);
|
||||||
|
if (modelFilter.id) {
|
||||||
|
where.id = modelFilter.id;
|
||||||
|
}
|
||||||
|
if (modelFilter.email) {
|
||||||
|
where.email = modelFilter.email;
|
||||||
|
}
|
||||||
|
const model = await this.prisma.user.findFirst({ where });
|
||||||
|
return model ? toDomain(model) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<UserEntity | null> {
|
||||||
|
const row = await this.prisma.user.findUnique({ where: { id } });
|
||||||
|
return row ? toDomain(row) : null;
|
||||||
|
}
|
||||||
|
async findAll(
|
||||||
|
criteria?: FilterCriteria<UserEntity>,
|
||||||
|
paginationOptions?: PaginationOptions,
|
||||||
|
): Promise<WithPagination<UserEntity>> {
|
||||||
|
const where: UserWhereInput = {};
|
||||||
|
const modelFilter = criteria ? toUserModelFilter(criteria) : {};
|
||||||
|
if (modelFilter.id) {
|
||||||
|
where.id = modelFilter.id;
|
||||||
|
}
|
||||||
|
if (modelFilter.email) {
|
||||||
|
where.email = modelFilter.email;
|
||||||
|
}
|
||||||
|
const models = paginationOptions
|
||||||
|
? await this.prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
take: paginationOptions.limit,
|
||||||
|
skip: paginationOptions.offset,
|
||||||
|
})
|
||||||
|
: await this.prisma.user.findMany({ where });
|
||||||
|
const total = await this.prisma.user.count({ where });
|
||||||
|
return {
|
||||||
|
data: models.map(toDomain),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async save(entity: UserEntity): Promise<UserEntity | null> {
|
||||||
|
const model = await this.prisma.user.upsert({
|
||||||
|
where: { id: entity.id },
|
||||||
|
create: fromDomain(entity),
|
||||||
|
update: fromDomain(entity),
|
||||||
|
});
|
||||||
|
return model ? toDomain(model) : null;
|
||||||
|
}
|
||||||
|
generateId(): string {
|
||||||
|
return this.prismaClientWrapper.generateId();
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/modules/users/use-cases/register-user.spec.ts
Normal file
5
src/modules/users/use-cases/register-user.spec.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
|
test("adds 1 + 2 to equal 3", () => {
|
||||||
|
expect(1 + 2).toBe(3);
|
||||||
|
});
|
||||||
41
src/modules/users/use-cases/register-user.ts
Normal file
41
src/modules/users/use-cases/register-user.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { inject, injectable } from "inversify";
|
||||||
|
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
|
||||||
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
|
import type { IUseCase } from "@/shared/core/IUseCase.js";
|
||||||
|
import { UserEntity } from "../domain/users.entity.js";
|
||||||
|
import type { IUsersRepository } from "../domain/users.repo.js";
|
||||||
|
import { UsersDomain } from "../domain/users.symbols.js";
|
||||||
|
|
||||||
|
export type RegisterUserDTO = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
isVerified?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
@injectable()
|
||||||
|
export class RegisterUserUseCase implements IUseCase<RegisterUserDTO> {
|
||||||
|
constructor(
|
||||||
|
@inject(UsersDomain.IUserRepository)
|
||||||
|
private readonly userRepo: IUsersRepository,
|
||||||
|
@inject(SharedDomain.ICryptoService)
|
||||||
|
private readonly cryptoService: ICryptoService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(inputDto: RegisterUserDTO): Promise<void> {
|
||||||
|
const user = await this.userRepo.findOne({ email: inputDto.email });
|
||||||
|
if (user) {
|
||||||
|
throw new Error("User already exists");
|
||||||
|
}
|
||||||
|
const hashedPassword = await this.cryptoService.hashPassword(
|
||||||
|
inputDto.password,
|
||||||
|
);
|
||||||
|
const newUser = new UserEntity(
|
||||||
|
this.userRepo.generateId(),
|
||||||
|
inputDto.email,
|
||||||
|
hashedPassword,
|
||||||
|
inputDto.isVerified ?? false,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
await this.userRepo.save(newUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/shared/application/ports/IConfigService.ts
Normal file
4
src/shared/application/ports/IConfigService.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface IConfigService {
|
||||||
|
get(key: string): string;
|
||||||
|
isDevelopment(): boolean;
|
||||||
|
}
|
||||||
5
src/shared/application/ports/ICryptoService.ts
Normal file
5
src/shared/application/ports/ICryptoService.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface ICryptoService {
|
||||||
|
hashPassword(password: string): Promise<string>;
|
||||||
|
comparePassword(password: string, hash: string): Promise<boolean>;
|
||||||
|
randomId(): string;
|
||||||
|
}
|
||||||
22
src/shared/application/ports/ILogger.ts
Normal file
22
src/shared/application/ports/ILogger.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export interface IRequestContext {
|
||||||
|
route: string;
|
||||||
|
method: string;
|
||||||
|
userAgent: string;
|
||||||
|
ip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogMessage = {
|
||||||
|
message: string;
|
||||||
|
module?: string;
|
||||||
|
context?: IRequestContext | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ErrorLogMessage = LogMessage & {
|
||||||
|
error?: Error | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ILogger {
|
||||||
|
info(message: LogMessage): void;
|
||||||
|
error(message: ErrorLogMessage): void;
|
||||||
|
warn(message: LogMessage): void;
|
||||||
|
}
|
||||||
18
src/shared/application/ports/ITokenService.ts
Normal file
18
src/shared/application/ports/ITokenService.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { UserEntity } from "@/modules/users/domain/users.entity.js";
|
||||||
|
|
||||||
|
export interface ISession {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
isVerified: boolean;
|
||||||
|
loginDate: Date;
|
||||||
|
}
|
||||||
|
export interface IRefreshData {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITokenService {
|
||||||
|
generateToken(user: UserEntity): string;
|
||||||
|
generateRefreshToken(user: UserEntity): string;
|
||||||
|
getSession(token: string): ISession | null;
|
||||||
|
validateRefreshToken(refreshToken: string): IRefreshData | null;
|
||||||
|
}
|
||||||
7
src/shared/application/ports/shared.symbols.ts
Normal file
7
src/shared/application/ports/shared.symbols.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const SharedDomain = {
|
||||||
|
IConfigService: Symbol.for("IConfigService"),
|
||||||
|
ICryptoService: Symbol.for("ICryptoService"),
|
||||||
|
ILogger: Symbol.for("ILogger"),
|
||||||
|
ITokenService: Symbol.for("ITokenService"),
|
||||||
|
IRequestContext: Symbol.for("IRequestContext"),
|
||||||
|
};
|
||||||
40
src/shared/core/IBaseRepository.ts
Normal file
40
src/shared/core/IBaseRepository.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export type FilterRange<T> = {
|
||||||
|
from?: T;
|
||||||
|
to?: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper type to determine the filter value based on the property type
|
||||||
|
type FilterValue<T> = T extends string
|
||||||
|
? string
|
||||||
|
: T extends number
|
||||||
|
? number | FilterRange<number>
|
||||||
|
: T extends Date
|
||||||
|
? Date | FilterRange<Date>
|
||||||
|
: T extends boolean
|
||||||
|
? boolean
|
||||||
|
: never; // Exclude types that are not string, number, Date, or boolean
|
||||||
|
|
||||||
|
export type FilterCriteria<T> = {
|
||||||
|
[K in keyof T]?: FilterValue<T[K]> | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaginationOptions = {
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WithPagination<T> = {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IBaseRepository<T> {
|
||||||
|
findOne(criteria: FilterCriteria<T>): Promise<T | null>;
|
||||||
|
findById(id: string): Promise<T | null>;
|
||||||
|
findAll(
|
||||||
|
criteria?: FilterCriteria<T>,
|
||||||
|
paginationOptions?: PaginationOptions,
|
||||||
|
): Promise<WithPagination<T>>;
|
||||||
|
save(entity: T): Promise<T | null>;
|
||||||
|
generateId(): string;
|
||||||
|
}
|
||||||
3
src/shared/core/IUseCase.ts
Normal file
3
src/shared/core/IUseCase.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface IUseCase<TInput = object, TOutput = void> {
|
||||||
|
execute(inputDto?: TInput): Promise<TOutput>;
|
||||||
|
}
|
||||||
14
src/shared/core/errors/ValidationError.ts
Normal file
14
src/shared/core/errors/ValidationError.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
type Issue = {
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ValidationError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public issues: Issue[],
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ValidationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/shared/infrastructure/config/EnvConfigService.ts
Normal file
13
src/shared/infrastructure/config/EnvConfigService.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
|
||||||
|
|
||||||
|
export class EnvConfigService implements IConfigService {
|
||||||
|
get(key: string): string {
|
||||||
|
return process.env[key] ?? "";
|
||||||
|
}
|
||||||
|
isDevelopment(): boolean {
|
||||||
|
return (
|
||||||
|
process.env.ENVIRONMENT !== "production" ||
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/shared/infrastructure/crypto/BcryptService.ts
Normal file
15
src/shared/infrastructure/crypto/BcryptService.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { uuidv7 } from "uuidv7";
|
||||||
|
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
|
||||||
|
|
||||||
|
export class BcryptService implements ICryptoService {
|
||||||
|
hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, 10);
|
||||||
|
}
|
||||||
|
comparePassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
randomId(): string {
|
||||||
|
return uuidv7();
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/shared/infrastructure/crypto/JwtService.ts
Normal file
142
src/shared/infrastructure/crypto/JwtService.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { inject, injectable } from "inversify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import z from "zod";
|
||||||
|
import type { UserEntity } from "@/modules/users/domain/users.entity.js";
|
||||||
|
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
|
||||||
|
import type { ILogger } from "@/shared/application/ports/ILogger.js";
|
||||||
|
import type {
|
||||||
|
IRefreshData,
|
||||||
|
ISession,
|
||||||
|
ITokenService,
|
||||||
|
} from "@/shared/application/ports/ITokenService.js";
|
||||||
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
|
import { appContainer } from "../di/Container.js";
|
||||||
|
|
||||||
|
const JWTSessionSchema = z.object({
|
||||||
|
sub: z.string(),
|
||||||
|
email: z.string(),
|
||||||
|
isVerified: z.boolean(),
|
||||||
|
iat: z.number(),
|
||||||
|
exp: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const JWTRefreshSchema = z.object({
|
||||||
|
sub: z.string(),
|
||||||
|
iat: z.number(),
|
||||||
|
exp: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
@injectable()
|
||||||
|
export class JwtService implements ITokenService {
|
||||||
|
constructor(
|
||||||
|
@inject(SharedDomain.IConfigService)
|
||||||
|
private readonly configService: IConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
generateToken(
|
||||||
|
user: UserEntity,
|
||||||
|
additionalClaims?: Record<string, string | boolean | number>,
|
||||||
|
): string {
|
||||||
|
const duration = Number.parseInt(
|
||||||
|
this.configService.get("JWT_DURATION"),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const secret = this.configService.get("JWT_SECRET");
|
||||||
|
const claims = {
|
||||||
|
iat: Math.ceil(Date.now() / 1000),
|
||||||
|
exp: Math.ceil(Date.now() / 1000) + duration,
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
isVerified: user.isVerified,
|
||||||
|
...additionalClaims,
|
||||||
|
};
|
||||||
|
const jwtClaims = JWTSessionSchema.parse(claims);
|
||||||
|
|
||||||
|
const token = jwt.sign(jwtClaims, secret, {
|
||||||
|
algorithm: "HS256",
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
generateRefreshToken(user: UserEntity): string {
|
||||||
|
const duration = Number.parseInt(
|
||||||
|
this.configService.get("JWT_REFRESH_DURATION"),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const secret = this.configService.get("JWT_REFRESH_SECRET");
|
||||||
|
const claims = {
|
||||||
|
iat: Math.ceil(Date.now() / 1000),
|
||||||
|
exp: Math.ceil(Date.now() / 1000) + duration,
|
||||||
|
sub: user.id,
|
||||||
|
};
|
||||||
|
const jwtClaims = JWTRefreshSchema.parse(claims);
|
||||||
|
const token = jwt.sign(jwtClaims, secret, {
|
||||||
|
algorithm: "HS256",
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
getSession(token: string): ISession | null {
|
||||||
|
const secret = this.configService.get("JWT_SECRET");
|
||||||
|
const logger = appContainer.get<ILogger>(SharedDomain.ILogger);
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, secret);
|
||||||
|
const decodedToken = JWTSessionSchema.parse(decoded);
|
||||||
|
if (decodedToken.exp < Date.now() / 1000) {
|
||||||
|
logger.error({
|
||||||
|
message: "Token expired",
|
||||||
|
module: "JwtService",
|
||||||
|
error: new Error("Token expired"),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const session = {
|
||||||
|
userId: decodedToken.sub,
|
||||||
|
email: decodedToken.email,
|
||||||
|
isVerified: decodedToken.isVerified,
|
||||||
|
loginDate: new Date(decodedToken.iat * 1000),
|
||||||
|
};
|
||||||
|
return session;
|
||||||
|
} catch (error) {
|
||||||
|
let errorInstance: Error | undefined;
|
||||||
|
if (error instanceof Error) {
|
||||||
|
errorInstance = error;
|
||||||
|
}
|
||||||
|
logger.error({
|
||||||
|
message: "Invalid token",
|
||||||
|
module: "JwtService",
|
||||||
|
error: errorInstance,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateRefreshToken(refreshToken: string): IRefreshData | null {
|
||||||
|
const secret = this.configService.get("JWT_REFRESH_SECRET");
|
||||||
|
const logger = appContainer.get<ILogger>(SharedDomain.ILogger);
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(refreshToken, secret);
|
||||||
|
const decodedToken = JWTRefreshSchema.parse(decoded);
|
||||||
|
if (decodedToken.exp < Date.now() / 1000) {
|
||||||
|
logger.error({
|
||||||
|
message: "Token expired",
|
||||||
|
module: "JwtService",
|
||||||
|
error: new Error("Token expired"),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const refreshData = {
|
||||||
|
userId: decodedToken.sub,
|
||||||
|
};
|
||||||
|
return refreshData;
|
||||||
|
} catch (error) {
|
||||||
|
let errorInstance: Error | undefined;
|
||||||
|
if (error instanceof Error) {
|
||||||
|
errorInstance = error;
|
||||||
|
}
|
||||||
|
logger.error({
|
||||||
|
message: "Invalid refresh token",
|
||||||
|
module: "JwtService",
|
||||||
|
error: errorInstance,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/shared/infrastructure/di/Container.ts
Normal file
12
src/shared/infrastructure/di/Container.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Container } from "inversify";
|
||||||
|
import { AuthDIModule } from "@/modules/auth/infrastructure/di/auth.di.js";
|
||||||
|
import { UsersDIModule } from "@/modules/users/infrastructure/di/users.di.js";
|
||||||
|
import { SharedDIModule } from "./shared.di.js";
|
||||||
|
|
||||||
|
const appContainer = new Container();
|
||||||
|
|
||||||
|
appContainer.load(SharedDIModule);
|
||||||
|
appContainer.load(AuthDIModule);
|
||||||
|
appContainer.load(UsersDIModule);
|
||||||
|
|
||||||
|
export { appContainer };
|
||||||
14
src/shared/infrastructure/di/shared.di.ts
Normal file
14
src/shared/infrastructure/di/shared.di.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { ContainerModule } from "inversify";
|
||||||
|
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
|
||||||
|
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
|
||||||
|
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
|
||||||
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
|
import { EnvConfigService } from "../config/EnvConfigService.js";
|
||||||
|
import { BcryptService } from "../crypto/BcryptService.js";
|
||||||
|
import { JwtService } from "../crypto/JwtService.js";
|
||||||
|
|
||||||
|
export const SharedDIModule = new ContainerModule(({ bind }) => {
|
||||||
|
bind<ICryptoService>(SharedDomain.ICryptoService).to(BcryptService);
|
||||||
|
bind<ITokenService>(SharedDomain.ITokenService).to(JwtService);
|
||||||
|
bind<IConfigService>(SharedDomain.IConfigService).to(EnvConfigService);
|
||||||
|
});
|
||||||
44
src/shared/infrastructure/http/error-handlers/catchAll.ts
Normal file
44
src/shared/infrastructure/http/error-handlers/catchAll.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { ErrorRequestHandler } from "express";
|
||||||
|
import type { ILogger } from "@/shared/application/ports/ILogger.js";
|
||||||
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
|
import { ValidationError } from "@/shared/core/errors/ValidationError.js";
|
||||||
|
import { appContainer } from "../../di/Container.js";
|
||||||
|
import { respondWithGenericError } from "../responses/respondWithGenericError.js";
|
||||||
|
import { respondWithValidationError } from "../responses/respondWithValidationError.js";
|
||||||
|
|
||||||
|
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
|
||||||
|
const logger = appContainer.get<ILogger>(SharedDomain.ILogger, {
|
||||||
|
optional: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger?.error({
|
||||||
|
message: err.message,
|
||||||
|
error: err,
|
||||||
|
module: "errorHandler",
|
||||||
|
context: {
|
||||||
|
route: req.originalUrl,
|
||||||
|
method: req.method,
|
||||||
|
userAgent: req.headers["user-agent"] ?? "n/a",
|
||||||
|
ip: req.ip ?? "n/a",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (err instanceof ValidationError) {
|
||||||
|
respondWithValidationError({
|
||||||
|
res,
|
||||||
|
response: {
|
||||||
|
message: err.message,
|
||||||
|
issues: err.issues,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
respondWithGenericError({
|
||||||
|
res,
|
||||||
|
response: {
|
||||||
|
message: err.message,
|
||||||
|
},
|
||||||
|
statusCode: 500,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
import type { ILogger } from "@/shared/application/ports/ILogger.js";
|
||||||
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
|
import { ValidationError } from "@/shared/core/errors/ValidationError.js";
|
||||||
|
import { appContainer } from "../../di/Container.js";
|
||||||
|
|
||||||
|
export const zodValidationHandler = (
|
||||||
|
err: ZodError,
|
||||||
|
req: Request,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) => {
|
||||||
|
const logger = appContainer.get<ILogger>(SharedDomain.ILogger, {
|
||||||
|
optional: true,
|
||||||
|
});
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
const issues = err.issues.map((issue) => ({
|
||||||
|
field: issue.path[0]?.toString() ?? "root",
|
||||||
|
message: issue.message,
|
||||||
|
}));
|
||||||
|
const validationError = new ValidationError("Validation error", issues);
|
||||||
|
logger?.error({
|
||||||
|
message: "ZodError caught!",
|
||||||
|
module: "zodValidationHandler",
|
||||||
|
context: {
|
||||||
|
route: req.originalUrl,
|
||||||
|
method: req.method,
|
||||||
|
userAgent: req.headers["user-agent"] ?? "n/a",
|
||||||
|
ip: req.ip ?? "n/a",
|
||||||
|
},
|
||||||
|
error: validationError,
|
||||||
|
});
|
||||||
|
next(validationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
next(err);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
|
import { appContainer } from "@/shared/infrastructure/di/Container.js";
|
||||||
|
|
||||||
|
export const attachRequestContext = (
|
||||||
|
req: Request,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) => {
|
||||||
|
appContainer.bind(SharedDomain.IRequestContext).toConstantValue({
|
||||||
|
route: req.originalUrl,
|
||||||
|
method: req.method,
|
||||||
|
userAgent: req.headers["user-agent"],
|
||||||
|
ip: req.ip,
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
appContainer.unbind(SharedDomain.IRequestContext);
|
||||||
|
};
|
||||||
37
src/shared/infrastructure/http/middlewares/attachSession.ts
Normal file
37
src/shared/infrastructure/http/middlewares/attachSession.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import type { IUsersRepository } from "@/modules/users/domain/users.repo.js";
|
||||||
|
import { UsersDomain } from "@/modules/users/domain/users.symbols.js";
|
||||||
|
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
|
||||||
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
|
import { appContainer } from "../../di/Container.js";
|
||||||
|
|
||||||
|
export const attachSession = async (
|
||||||
|
req: Request,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) => {
|
||||||
|
const tokenService = appContainer.get<ITokenService>(
|
||||||
|
SharedDomain.ITokenService,
|
||||||
|
);
|
||||||
|
const userRepo = appContainer.get<IUsersRepository>(
|
||||||
|
UsersDomain.IUserRepository,
|
||||||
|
);
|
||||||
|
const token = req.cookies.token;
|
||||||
|
if (!token) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = tokenService.getSession(token);
|
||||||
|
if (!session) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentUser = await userRepo.findOne({ id: session.userId });
|
||||||
|
if (!currentUser) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.session = session;
|
||||||
|
req.currentUser = currentUser;
|
||||||
|
next();
|
||||||
|
};
|
||||||
26
src/shared/infrastructure/http/middlewares/requestLogger.ts
Normal file
26
src/shared/infrastructure/http/middlewares/requestLogger.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import type { ILogger } from "@/shared/application/ports/ILogger.js";
|
||||||
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
|
import { appContainer } from "../../di/Container.js";
|
||||||
|
|
||||||
|
export const requestLogger = (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) => {
|
||||||
|
res.on("finish", () => {
|
||||||
|
const logger = appContainer.get<ILogger>(SharedDomain.ILogger);
|
||||||
|
logger.info({
|
||||||
|
message: res.statusCode.toString(),
|
||||||
|
module: "requestLogger",
|
||||||
|
context: {
|
||||||
|
route: req.originalUrl,
|
||||||
|
method: req.method,
|
||||||
|
userAgent: req.headers["user-agent"] ?? "n/a",
|
||||||
|
ip: req.ip ?? "n/a",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
19
src/shared/infrastructure/http/middlewares/requireAuth.ts
Normal file
19
src/shared/infrastructure/http/middlewares/requireAuth.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { respondWithGenericError } from "../responses/respondWithGenericError.js";
|
||||||
|
|
||||||
|
export const requireAuth = (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) => {
|
||||||
|
if (!req.session) {
|
||||||
|
return respondWithGenericError({
|
||||||
|
res,
|
||||||
|
response: {
|
||||||
|
message: "Unauthorized",
|
||||||
|
},
|
||||||
|
statusCode: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
14
src/shared/infrastructure/http/middlewares/stripHeaders.ts
Normal file
14
src/shared/infrastructure/http/middlewares/stripHeaders.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
|
const HEADERS_TO_STRIP = ["x-powered-by"];
|
||||||
|
|
||||||
|
export const stripHeaders = (
|
||||||
|
_req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) => {
|
||||||
|
HEADERS_TO_STRIP.forEach((header) => {
|
||||||
|
res.removeHeader(header);
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
};
|
||||||
9
src/shared/infrastructure/http/request.ts
Normal file
9
src/shared/infrastructure/http/request.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { UserEntity } from "@/modules/users/domain/users.entity.js";
|
||||||
|
import type { ISession } from "@/shared/application/ports/ITokenService.js";
|
||||||
|
|
||||||
|
declare module "express-serve-static-core" {
|
||||||
|
interface Request {
|
||||||
|
session?: ISession;
|
||||||
|
currentUser?: UserEntity;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Response } from "express";
|
||||||
|
|
||||||
|
export type GenericErrorResponse = {
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RespondWithGenericErrorParams = {
|
||||||
|
res: Response;
|
||||||
|
response: GenericErrorResponse;
|
||||||
|
statusCode: number;
|
||||||
|
};
|
||||||
|
export function respondWithGenericError({
|
||||||
|
res,
|
||||||
|
response,
|
||||||
|
statusCode,
|
||||||
|
}: RespondWithGenericErrorParams) {
|
||||||
|
res.status(statusCode).json(response);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Response } from "express";
|
||||||
|
|
||||||
|
export type ValidationErrorResponse = {
|
||||||
|
message: string;
|
||||||
|
issues: {
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
type RespondWithValidationErrorParams = {
|
||||||
|
res: Response;
|
||||||
|
response: ValidationErrorResponse;
|
||||||
|
};
|
||||||
|
export function respondWithValidationError({
|
||||||
|
res,
|
||||||
|
response,
|
||||||
|
}: RespondWithValidationErrorParams) {
|
||||||
|
res.status(400).json(response);
|
||||||
|
}
|
||||||
15
src/shared/infrastructure/http/routes.ts
Normal file
15
src/shared/infrastructure/http/routes.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import express from "express";
|
||||||
|
import authRoutes from "@/modules/auth/infrastructure/http/auth.routes.js";
|
||||||
|
import helloWorldRoutes from "@/modules/hello-world/infrastructure/http/hello-world.routes.js";
|
||||||
|
import { attachRequestContext } from "./middlewares/attachRequestContext.js";
|
||||||
|
import { attachSession } from "./middlewares/attachSession.js";
|
||||||
|
|
||||||
|
const routes = express.Router();
|
||||||
|
|
||||||
|
routes.use(attachRequestContext);
|
||||||
|
routes.use(attachSession);
|
||||||
|
|
||||||
|
routes.use("/auth", authRoutes);
|
||||||
|
routes.use("/hello-world", helloWorldRoutes);
|
||||||
|
|
||||||
|
export default routes;
|
||||||
30
src/shared/infrastructure/logger/ConsoleLogger.ts
Normal file
30
src/shared/infrastructure/logger/ConsoleLogger.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type {
|
||||||
|
ILogger,
|
||||||
|
LogMessage,
|
||||||
|
} from "@/shared/application/ports/ILogger.js";
|
||||||
|
|
||||||
|
function messageBuilder({ message, module, context }: LogMessage): string {
|
||||||
|
const fullMessage = [message];
|
||||||
|
const localDateTime = new Date().toLocaleString();
|
||||||
|
if (context) {
|
||||||
|
fullMessage.push(`(${context.ip}) [${context.method} ${context.route}]`);
|
||||||
|
}
|
||||||
|
if (module) {
|
||||||
|
fullMessage.push(`[${module}]`);
|
||||||
|
}
|
||||||
|
return `[${localDateTime}] ${fullMessage.reverse().join(" ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConsoleLogger implements ILogger {
|
||||||
|
info(message: LogMessage): void {
|
||||||
|
console.log(messageBuilder(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: LogMessage): void {
|
||||||
|
console.error(messageBuilder(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: LogMessage): void {
|
||||||
|
console.warn(messageBuilder(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { InMemoryRepository } from "./InMemoryRepository.js";
|
||||||
|
|
||||||
|
type TestEntity = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
age: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
class TestRepository extends InMemoryRepository<TestEntity> {}
|
||||||
|
|
||||||
|
describe("InMemoryRepository (Generic) - Comprehensive Tests", () => {
|
||||||
|
let repo: TestRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = new TestRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Persistence Operations", () => {
|
||||||
|
it("should save and find an entity by ID", async () => {
|
||||||
|
const id = repo.generateId();
|
||||||
|
const entity: TestEntity = {
|
||||||
|
id,
|
||||||
|
name: "Test",
|
||||||
|
age: 25,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await repo.save(entity);
|
||||||
|
const found = await repo.findById(id);
|
||||||
|
|
||||||
|
expect(found).toEqual(entity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update an existing entity with the same ID", async () => {
|
||||||
|
const id = repo.generateId();
|
||||||
|
const entity: TestEntity = {
|
||||||
|
id,
|
||||||
|
name: "Old Name",
|
||||||
|
age: 25,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await repo.save(entity);
|
||||||
|
|
||||||
|
// Modify and save again
|
||||||
|
entity.name = "New Name";
|
||||||
|
await repo.save(entity);
|
||||||
|
|
||||||
|
const found = await repo.findById(id);
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
expect(found?.name).toBe("New Name");
|
||||||
|
|
||||||
|
// Ensure no duplicate records
|
||||||
|
const all = await repo.findAll();
|
||||||
|
expect(all.total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null if finding by non-existent ID", async () => {
|
||||||
|
const found = await repo.findById("non-existent-id");
|
||||||
|
expect(found).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Query Operations", () => {
|
||||||
|
it("should find one entity by filter criteria", async () => {
|
||||||
|
await repo.save({
|
||||||
|
id: "1",
|
||||||
|
name: "Unique",
|
||||||
|
age: 25,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
await repo.save({
|
||||||
|
id: "2",
|
||||||
|
name: "Other",
|
||||||
|
age: 30,
|
||||||
|
isActive: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = await repo.findOne({ name: "Unique" });
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
expect(found?.id).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null if findOne matches nothing", async () => {
|
||||||
|
const found = await repo.findOne({ name: "NonExistent" });
|
||||||
|
expect(found).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Exact Match Filtering", () => {
|
||||||
|
it("should filter by string (exact match)", async () => {
|
||||||
|
await repo.save({
|
||||||
|
id: "1",
|
||||||
|
name: "Alice",
|
||||||
|
age: 25,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
await repo.save({
|
||||||
|
id: "2",
|
||||||
|
name: "Bob",
|
||||||
|
age: 30,
|
||||||
|
isActive: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await repo.findAll({ name: "Alice" });
|
||||||
|
expect(result.data).toHaveLength(1);
|
||||||
|
expect(result.data.at(0)?.name).toBe("Alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by number (exact match)", async () => {
|
||||||
|
await repo.save({
|
||||||
|
id: "1",
|
||||||
|
name: "Alice",
|
||||||
|
age: 25,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
await repo.save({
|
||||||
|
id: "2",
|
||||||
|
name: "Bob",
|
||||||
|
age: 30,
|
||||||
|
isActive: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await repo.findAll({ age: 25 });
|
||||||
|
expect(result.data).toHaveLength(1);
|
||||||
|
expect(result.data.at(0)?.age).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by boolean (true)", async () => {
|
||||||
|
await repo.save({
|
||||||
|
id: "1",
|
||||||
|
name: "Alice",
|
||||||
|
age: 25,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
await repo.save({
|
||||||
|
id: "2",
|
||||||
|
name: "Bob",
|
||||||
|
age: 30,
|
||||||
|
isActive: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await repo.findAll({ isActive: true });
|
||||||
|
expect(result.data).toHaveLength(1);
|
||||||
|
expect(result.data.at(0)?.isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by boolean (false)", async () => {
|
||||||
|
await repo.save({
|
||||||
|
id: "1",
|
||||||
|
name: "Alice",
|
||||||
|
age: 25,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
await repo.save({
|
||||||
|
id: "2",
|
||||||
|
name: "Bob",
|
||||||
|
age: 30,
|
||||||
|
isActive: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await repo.findAll({ isActive: false });
|
||||||
|
expect(result.data).toHaveLength(1);
|
||||||
|
expect(result.data.at(0)?.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by Date (exact match)", async () => {
|
||||||
|
const date1 = new Date("2023-01-01T00:00:00Z");
|
||||||
|
const date2 = new Date("2023-01-02T00:00:00Z");
|
||||||
|
|
||||||
|
await repo.save({
|
||||||
|
id: "1",
|
||||||
|
name: "Alice",
|
||||||
|
age: 25,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: date1,
|
||||||
|
});
|
||||||
|
await repo.save({
|
||||||
|
id: "2",
|
||||||
|
name: "Bob",
|
||||||
|
age: 30,
|
||||||
|
isActive: false,
|
||||||
|
createdAt: date2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await repo.findAll({ createdAt: date1 });
|
||||||
|
expect(result.data).toHaveLength(1);
|
||||||
|
expect(result.data.at(0)?.createdAt).toEqual(date1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore undefined filter properties", async () => {
|
||||||
|
await repo.save({
|
||||||
|
id: "1",
|
||||||
|
name: "Alice",
|
||||||
|
age: 25,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
await repo.save({
|
||||||
|
id: "2",
|
||||||
|
name: "Bob",
|
||||||
|
age: 30,
|
||||||
|
isActive: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await repo.findAll({ name: undefined });
|
||||||
|
expect(result.data).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Range Filtering - Number", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await repo.save({
|
||||||
|
id: "1",
|
||||||
|
name: "Kid",
|
||||||
|
age: 10,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
await repo.save({
|
||||||
|
id: "2",
|
||||||
|
name: "Teen",
|
||||||
|
age: 15,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
await repo.save({
|
||||||
|
id: "3",
|
||||||
|
name: "Adult",
|
||||||
|
age: 25,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by number range (from only - tail case)", async () => {
|
||||||
|
// age >= 15
|
||||||
|
const result = await repo.findAll({ age: { from: 15 } });
|
||||||
|
expect(result.data).toHaveLength(2); // Teen, Adult
|
||||||
|
expect(result.data.map((i) => i.age).sort()).toEqual([15, 25]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by number range (to only - head case)", async () => {
|
||||||
|
// age <= 15
|
||||||
|
const result = await repo.findAll({ age: { to: 15 } });
|
||||||
|
expect(result.data).toHaveLength(2); // Kid, Teen
|
||||||
|
expect(result.data.map((i) => i.age).sort()).toEqual([10, 15]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by number range (from and to - middle case)", async () => {
|
||||||
|
// 12 <= age <= 20
|
||||||
|
const result = await repo.findAll({ age: { from: 12, to: 20 } });
|
||||||
|
expect(result.data).toHaveLength(1); // Teen
|
||||||
|
expect(result.data.at(0)?.age).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Range Filtering - Date", () => {
|
||||||
|
const d1 = new Date("2023-01-01T10:00:00Z");
|
||||||
|
const d2 = new Date("2023-01-02T10:00:00Z");
|
||||||
|
const d3 = new Date("2023-01-03T10:00:00Z");
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await repo.save({
|
||||||
|
id: "1",
|
||||||
|
name: "First",
|
||||||
|
age: 20,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: d1,
|
||||||
|
});
|
||||||
|
await repo.save({
|
||||||
|
id: "2",
|
||||||
|
name: "Second",
|
||||||
|
age: 20,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: d2,
|
||||||
|
});
|
||||||
|
await repo.save({
|
||||||
|
id: "3",
|
||||||
|
name: "Third",
|
||||||
|
age: 20,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: d3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by date range (from only - tail case)", async () => {
|
||||||
|
// date >= d2
|
||||||
|
const result = await repo.findAll({ createdAt: { from: d2 } });
|
||||||
|
expect(result.data).toHaveLength(2); // Second, Third
|
||||||
|
expect(result.data.map((i) => i.createdAt.getTime()).sort()).toEqual([
|
||||||
|
d2.getTime(),
|
||||||
|
d3.getTime(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by date range (to only - head case)", async () => {
|
||||||
|
// date <= d2
|
||||||
|
const result = await repo.findAll({ createdAt: { to: d2 } });
|
||||||
|
expect(result.data).toHaveLength(2); // First, Second
|
||||||
|
expect(result.data.map((i) => i.createdAt.getTime()).sort()).toEqual([
|
||||||
|
d1.getTime(),
|
||||||
|
d2.getTime(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by date range (from and to - middle case)", async () => {
|
||||||
|
// d1 <= date <= d2
|
||||||
|
const result = await repo.findAll({ createdAt: { from: d1, to: d2 } });
|
||||||
|
expect(result.data).toHaveLength(2); // First, Second
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Pagination with Filtering", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create 10 items
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
await repo.save({
|
||||||
|
id: i.toString(),
|
||||||
|
name: i % 2 === 0 ? "Even" : "Odd",
|
||||||
|
age: i * 10, // 10, 20, ..., 100
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(`2023-01-${i.toString().padStart(2, "0")}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should paginate exact match results", async () => {
|
||||||
|
// Filter: name = "Even" (5 items: 2, 4, 6, 8, 10)
|
||||||
|
// Page 1: limit 2 -> [2, 4]
|
||||||
|
const page1 = await repo.findAll(
|
||||||
|
{ name: "Even" },
|
||||||
|
{ offset: 0, limit: 2 },
|
||||||
|
);
|
||||||
|
expect(page1.total).toBe(5);
|
||||||
|
expect(page1.data).toHaveLength(2);
|
||||||
|
expect(page1.data.at(0)?.id).toBe("2");
|
||||||
|
expect(page1.data.at(1)?.id).toBe("4");
|
||||||
|
|
||||||
|
// Page 2: offset 2, limit 2 -> [6, 8]
|
||||||
|
const page2 = await repo.findAll(
|
||||||
|
{ name: "Even" },
|
||||||
|
{ offset: 2, limit: 2 },
|
||||||
|
);
|
||||||
|
expect(page2.data).toHaveLength(2);
|
||||||
|
expect(page2.data.at(0)?.id).toBe("6");
|
||||||
|
expect(page2.data.at(1)?.id).toBe("8");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should paginate number range results", async () => {
|
||||||
|
// Filter: age >= 50 (6 items: 50, 60, 70, 80, 90, 100)
|
||||||
|
// Page 1: limit 3 -> [50, 60, 70]
|
||||||
|
const result = await repo.findAll(
|
||||||
|
{ age: { from: 50 } },
|
||||||
|
{ offset: 0, limit: 3 },
|
||||||
|
);
|
||||||
|
expect(result.total).toBe(6);
|
||||||
|
expect(result.data).toHaveLength(3);
|
||||||
|
expect(result.data.map((i) => i.age)).toEqual([50, 60, 70]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should paginate date range results", async () => {
|
||||||
|
// Filter: date <= 2023-01-05 (5 items: 1, 2, 3, 4, 5)
|
||||||
|
const targetDate = new Date("2023-01-05");
|
||||||
|
// Page 2: offset 2, limit 2 -> [3, 4]
|
||||||
|
const result = await repo.findAll(
|
||||||
|
{ createdAt: { to: targetDate } },
|
||||||
|
{ offset: 2, limit: 2 },
|
||||||
|
);
|
||||||
|
expect(result.total).toBe(5);
|
||||||
|
expect(result.data).toHaveLength(2);
|
||||||
|
expect(result.data.at(0)?.id).toBe("3");
|
||||||
|
expect(result.data.at(1)?.id).toBe("4");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Utility Operations", () => {
|
||||||
|
it("should generate unique IDs", () => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
ids.add(repo.generateId());
|
||||||
|
}
|
||||||
|
expect(ids.size).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type {
|
||||||
|
FilterCriteria,
|
||||||
|
FilterRange,
|
||||||
|
IBaseRepository,
|
||||||
|
PaginationOptions,
|
||||||
|
WithPagination,
|
||||||
|
} from "@/shared/core/IBaseRepository.js";
|
||||||
|
|
||||||
|
export class InMemoryRepository<T extends { id: string }>
|
||||||
|
implements IBaseRepository<T>
|
||||||
|
{
|
||||||
|
protected items: T[] = [];
|
||||||
|
|
||||||
|
async save(entity: T): Promise<T | null> {
|
||||||
|
const index = this.items.findIndex((item) => item.id === entity.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.items[index] = entity;
|
||||||
|
} else {
|
||||||
|
this.items.push(entity);
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<T | null> {
|
||||||
|
return this.items.find((item) => item.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(criteria: FilterCriteria<T>): Promise<T | null> {
|
||||||
|
const filtered = this.applyFilters(this.items, criteria);
|
||||||
|
return filtered[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(
|
||||||
|
criteria?: FilterCriteria<T>,
|
||||||
|
paginationOptions?: PaginationOptions,
|
||||||
|
): Promise<WithPagination<T>> {
|
||||||
|
let filtered = this.items;
|
||||||
|
|
||||||
|
if (criteria) {
|
||||||
|
filtered = this.applyFilters(filtered, criteria);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = filtered.length;
|
||||||
|
|
||||||
|
if (paginationOptions) {
|
||||||
|
const { offset, limit } = paginationOptions;
|
||||||
|
filtered = filtered.slice(offset, offset + limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: filtered,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
generateId(): string {
|
||||||
|
return randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected applyFilters(items: T[], criteria: FilterCriteria<T>): T[] {
|
||||||
|
return items.filter((item) => {
|
||||||
|
return Object.entries(criteria).every(([key, value]) => {
|
||||||
|
const itemValue = item[key as keyof T];
|
||||||
|
|
||||||
|
if (value === undefined) return true;
|
||||||
|
|
||||||
|
// Handle Date Range
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
("from" in value || "to" in value) &&
|
||||||
|
((value as FilterRange<Date>).from instanceof Date ||
|
||||||
|
(value as FilterRange<Date>).to instanceof Date) &&
|
||||||
|
itemValue instanceof Date
|
||||||
|
) {
|
||||||
|
const range = value as FilterRange<Date>;
|
||||||
|
if (range.from && itemValue < range.from) return false;
|
||||||
|
if (range.to && itemValue > range.to) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Number Range
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
(typeof (value as FilterRange<number>).from === "number" ||
|
||||||
|
typeof (value as FilterRange<number>).to === "number") &&
|
||||||
|
typeof itemValue === "number"
|
||||||
|
) {
|
||||||
|
const range = value as FilterRange<number>;
|
||||||
|
if (range.from !== undefined && itemValue < range.from) return false;
|
||||||
|
if (range.to !== undefined && itemValue > range.to) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Exact Match
|
||||||
|
return itemValue === value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { inject, injectable } from "inversify";
|
||||||
|
import { uuidv7 } from "uuidv7";
|
||||||
|
import { PrismaClient as PrismaClientLib } from "@/generated/prisma/client.js";
|
||||||
|
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
|
||||||
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
|
|
||||||
|
export type PrismaClient = PrismaClientLib;
|
||||||
|
|
||||||
|
@injectable()
|
||||||
|
export class PrismaClientWrapper {
|
||||||
|
private readonly client: PrismaClientLib;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@inject(SharedDomain.IConfigService)
|
||||||
|
private readonly configService: IConfigService,
|
||||||
|
) {
|
||||||
|
const connectionString = this.configService.get("POSTGRES_URL");
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({
|
||||||
|
connectionString,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client = new PrismaClientLib({
|
||||||
|
adapter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getClient(): PrismaClientLib {
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateId(): string {
|
||||||
|
return uuidv7();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/swagger.ts
Normal file
14
src/swagger.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import swaggerJSDoc from "swagger-jsdoc";
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
definition: {
|
||||||
|
openapi: "3.0.0",
|
||||||
|
info: {
|
||||||
|
title: "Express-Starter",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
apis: ["./src/**/*.ts"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const swaggerSpec = swaggerJSDoc(config);
|
||||||
52
tsconfig.json
Normal file
52
tsconfig.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
// Visit https://aka.ms/tsconfig to read more about this file
|
||||||
|
"compilerOptions": {
|
||||||
|
// File Layout
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
// Environment Settings
|
||||||
|
// See also https://aka.ms/tsconfig/module
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
// For nodejs:
|
||||||
|
// "lib": ["esnext"],
|
||||||
|
// "types": ["node"],
|
||||||
|
// and npm install -D @types/node
|
||||||
|
// Other Outputs
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
// Stricter Typechecking Options
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
// Style Options
|
||||||
|
// "noImplicitReturns": true,
|
||||||
|
// "noImplicitOverride": true,
|
||||||
|
// "noUnusedLocals": true,
|
||||||
|
// "noUnusedParameters": true,
|
||||||
|
// "noFallthroughCasesInSwitch": true,
|
||||||
|
// "noPropertyAccessFromIndexSignature": true,
|
||||||
|
// Recommended Options
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
// "jsx": "react-jsx",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"moduleDetection": "auto",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"prisma.config.ts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import tsConfigPaths from 'vite-tsconfig-paths'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tsConfigPaths()],
|
||||||
|
test: {
|
||||||
|
// ... Specify options here.
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user