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