Initial commit

This commit is contained in:
Lance
2026-01-16 15:33:40 +08:00
committed by GitHub
commit 037e36f4f4
67 changed files with 5589 additions and 0 deletions

13
.env.example Normal file
View 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
View File

@@ -0,0 +1,8 @@
dist
node_modules
tsconfig.tsbuildinfo
.env
/src/generated/prisma
coverage

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.tabSize": 2,
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
}
}

82
ARCHITECTURE.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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",
});
});

View 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();
});

View 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;

View 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,
});
});
});

View 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 };
}
}

View 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,
});
});
});

View 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 };
}
}

View 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");
});
});

View 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);
}
}

View File

@@ -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;

View File

@@ -0,0 +1,5 @@
export class InvalidEmailFormat extends Error {
constructor() {
super("Invalid email format");
}
}

View File

@@ -0,0 +1,5 @@
export class InvalidPassword extends Error {
constructor() {
super("Invalid password");
}
}

View File

@@ -0,0 +1,5 @@
export class NewPasswordMustBeDifferent extends Error {
constructor() {
super("New password must be different from the old password");
}
}

View File

@@ -0,0 +1,5 @@
export class UserNotFound extends Error {
constructor() {
super("User not found");
}
}

View 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);
});
});

View 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;
}
}

View 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> {}

View 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,
) {}
}

View File

@@ -0,0 +1,4 @@
export const UsersDomain = {
IUserRepository: Symbol.for("IUserRepository"),
IUserService: Symbol.for("IUsersService"),
};

View 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();
});

View File

@@ -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 {}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,5 @@
import { expect, test } from "vitest";
test("adds 1 + 2 to equal 3", () => {
expect(1 + 2).toBe(3);
});

View 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);
}
}

View File

@@ -0,0 +1,4 @@
export interface IConfigService {
get(key: string): string;
isDevelopment(): boolean;
}

View File

@@ -0,0 +1,5 @@
export interface ICryptoService {
hashPassword(password: string): Promise<string>;
comparePassword(password: string, hash: string): Promise<boolean>;
randomId(): string;
}

View 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;
}

View 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;
}

View 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"),
};

View 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;
}

View File

@@ -0,0 +1,3 @@
export interface IUseCase<TInput = object, TOutput = void> {
execute(inputDto?: TInput): Promise<TOutput>;
}

View 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";
}
}

View 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"
);
}
}

View 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();
}
}

View 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;
}
}
}

View 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 };

View 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);
});

View 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,
});
};

View File

@@ -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);
};

View File

@@ -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);
};

View 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();
};

View 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();
};

View 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();
};

View 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();
};

View 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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View 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;

View 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));
}
}

View File

@@ -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);
});
});
});

View File

@@ -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;
});
});
}
}

View File

@@ -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
View 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
View 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
View 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.
},
})

2710
yarn.lock Normal file

File diff suppressed because it is too large Load Diff