diff --git a/README.md b/README.md index c1dda0c..3177797 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,4 @@ -# 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. +# Cedar CMS (Backend Monolith) ## ๐Ÿ Getting Started @@ -40,7 +13,7 @@ The `inversify-express-utils` package is already deprecated so the focus should 1. Clone the repository: ```bash git clone - cd express-starter + cd cedar-api ``` 2. Install dependencies: @@ -51,10 +24,10 @@ The `inversify-express-utils` package is already deprecated so the focus should 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. +4. Setup the database with the codebase's schema: + > Note: Run `yarn prisma:migrate` every time you make changes to the Prisma schema. ```bash - yarn prisma:migrate + yarn prisma:push ``` 5. Generate Prisma Client: @@ -77,6 +50,9 @@ The `inversify-express-utils` package is already deprecated so the focus should - `yarn format`: Format the codebase using Biome. - `yarn test`: Run unit tests using Vitest. - `yarn coverage`: Run tests with coverage reporting. +- `yarn prisma:push`: Pushes the state of the Prisma schema to the database without migrations. +- `yarn prisma:migrate`: Creates a new migration while reruning the existing migration history. (use for development only) +- `yarn prisma:generate`: Generates the Prisma Client with updated schema. ## ๐Ÿงช Testing diff --git a/package.json b/package.json index 95f3857..edfd496 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "express-starter", + "name": "cedar-api", "version": "1.0.0", "license": "MIT", "private": true, @@ -12,6 +12,7 @@ "dev": "tsx watch src/app.ts", "prisma:migrate": "prisma migrate dev", "prisma:generate": "prisma generate", + "prisma:push": "prisma db push", "test": "vitest", "coverage": "vitest run --coverage" }, diff --git a/prisma.config.ts b/prisma.config.ts index b1b2df3..97275c9 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -4,7 +4,7 @@ import "dotenv/config"; import { defineConfig, env } from "prisma/config"; export default defineConfig({ - schema: "prisma/schema.prisma", + schema: "prisma/schema", migrations: { path: "prisma/migrations", }, diff --git a/prisma/schema/auth.prisma b/prisma/schema/auth.prisma new file mode 100644 index 0000000..9503304 --- /dev/null +++ b/prisma/schema/auth.prisma @@ -0,0 +1,21 @@ +model AuthIdentity { + id String @id @default(uuid()) + email String @unique + password String + isVerified Boolean @default(false) + createdAt DateTime @default(now()) + organizationMemberships OrganizationUserMembership[] + verifications AuthVerification[] + sentInvitations OrganizationInvitation[] +} + +model AuthVerification { + id String @id @default(uuid()) + identityId String + magicToken String + createdAt DateTime @default(now()) + acceptedAt DateTime? + isAccepted Boolean @default(false) + isRevoked Boolean @default(false) + identity AuthIdentity @relation(fields: [identityId], references: [id]) +} diff --git a/prisma/schema/organizations.prisma b/prisma/schema/organizations.prisma new file mode 100644 index 0000000..c14ee21 --- /dev/null +++ b/prisma/schema/organizations.prisma @@ -0,0 +1,36 @@ +model OrganizationInvitation { + id String @id @default(uuid()) + senderId String + organizationId String + inviteToken String + emailRecipient String + createdAt DateTime @default(now()) + acceptedAt DateTime? + isAccepted Boolean @default(false) + isRevoked Boolean @default(false) + organization Organization @relation(fields: [organizationId], references: [id]) + inviteSender AuthIdentity @relation(fields: [senderId], references: [id]) +} + +model Organization { + id String @id @default(uuid()) + name String + createdAt DateTime @default(now()) + organizationUsers OrganizationUserMembership[] + invitations OrganizationInvitation[] +} + +model OrganizationUserMembership { + id String @id @default(uuid()) + organizationId String + identityId String + createdAt DateTime @default(now()) + organization Organization @relation(fields: [organizationId], references: [id]) + identity AuthIdentity @relation(fields: [identityId], references: [id]) +} + +enum OrganizationRole { + OWNER + ADMIN + MEMBER +} diff --git a/prisma/schema/schema.prisma b/prisma/schema/schema.prisma new file mode 100644 index 0000000..b5ce726 --- /dev/null +++ b/prisma/schema/schema.prisma @@ -0,0 +1,9 @@ +generator client { + provider = "prisma-client" + output = "../../src/generated/prisma" + previewFeatures = ["relationJoins"] +} + +datasource db { + provider = "postgresql" +} diff --git a/src/modules/auth/application/query-service.ts b/src/modules/auth/application/query-service.ts new file mode 100644 index 0000000..3e681c1 --- /dev/null +++ b/src/modules/auth/application/query-service.ts @@ -0,0 +1,30 @@ +import type { DataOnlyDto } from "@/shared/core/DataOnlyDto.js"; +import type { + FilterCriteria, + PaginationOptions, + PaginationResult, +} from "@/shared/core/IBaseRepository.js"; +import type { AuthIdentityEntity } from "../domain/auth-identity.entity.js"; +import type { AuthVerificationEntity } from "../domain/auth-verifications.entity.js"; + +export type AuthIdentityDto = Omit< + DataOnlyDto, + "verifications" | "password" +>; +export type AuthVerificationDto = DataOnlyDto; + +export interface IAuthQueryService { + findIdentities( + filters?: FilterCriteria, + pagination?: PaginationOptions, + ): Promise>; + findById(id: string): Promise; + findByEmail(id: string): Promise; + getVerificationsByIdentityId( + identityId: string, + ): Promise; + getIdentityIdFromVerificationId( + verificationId: string, + ): Promise; + getIdentityIdFromMagicToken(magicToken: string): Promise; +} diff --git a/src/modules/auth/use-cases/user-signup.spec.ts b/src/modules/auth/application/use-cases/create-identity.spec.ts similarity index 57% rename from src/modules/auth/use-cases/user-signup.spec.ts rename to src/modules/auth/application/use-cases/create-identity.spec.ts index f2f7a7d..91cd013 100644 --- a/src/modules/auth/use-cases/user-signup.spec.ts +++ b/src/modules/auth/application/use-cases/create-identity.spec.ts @@ -1,11 +1,12 @@ 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"; +import { AuthIdentityEntity } from "../../domain/auth-identity.entity.js"; +import { IdentityAlreadyExists } from "../../domain/errors/IdentityAlreadyExists.js"; +import { AuthIdentityInMemoryRepository } from "../../infrastructure/persistence/fakes/auth.in-memory.repo.js"; +import { CreateIdentityUseCase } from "./create-identity.js"; -describe("Auth - User signup", () => { - let usersRepo: UsersInMemoryRepository; +describe("Auth - Create identity", () => { + let identityRepo: AuthIdentityInMemoryRepository; const MockCryptoService = vi.fn( class implements ICryptoService { randomId = vi.fn().mockReturnValue("1"); @@ -14,20 +15,20 @@ describe("Auth - User signup", () => { }, ); const cryptoService: ICryptoService = new MockCryptoService(); - let useCase: UserSignupUseCase; + let useCase: CreateIdentityUseCase; beforeEach(() => { - usersRepo = new UsersInMemoryRepository(); - useCase = new UserSignupUseCase(usersRepo, cryptoService); + identityRepo = new AuthIdentityInMemoryRepository(); + useCase = new CreateIdentityUseCase(identityRepo, cryptoService); }); afterEach(() => { vi.resetAllMocks(); }); - test("should signup a user", async () => { - const saveSpy = vi.spyOn(usersRepo, "save"); - const findOneSpy = vi.spyOn(usersRepo, "findOne"); + test("should create an identity", async () => { + const saveSpy = vi.spyOn(identityRepo, "save"); + const findOneSpy = vi.spyOn(identityRepo, "findOne"); const result = await useCase.execute({ email: "test@example.com", password: "password", @@ -39,37 +40,36 @@ describe("Auth - User signup", () => { expect(result).toBeUndefined(); expect( ( - await usersRepo.findAll({ + await identityRepo.findAll({ email: "test@example.com", }) ).data, ).toHaveLength(1); expect( ( - await usersRepo.findAll({ + await identityRepo.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( + test("should throw an error if an identity with the same email exists", async () => { + await identityRepo.save( + new AuthIdentityEntity( "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"); + ).rejects.toThrow(IdentityAlreadyExists); }); }); diff --git a/src/modules/auth/application/use-cases/create-identity.ts b/src/modules/auth/application/use-cases/create-identity.ts new file mode 100644 index 0000000..149f1f1 --- /dev/null +++ b/src/modules/auth/application/use-cases/create-identity.ts @@ -0,0 +1,42 @@ +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 { AuthDomain } from "../../domain/auth.symbols.js"; +import { AuthIdentityEntity } from "../../domain/auth-identity.entity.js"; +import type { IAuthIdentityRepository } from "../../domain/auth-identity.repo.js"; +import { IdentityAlreadyExists } from "../../domain/errors/IdentityAlreadyExists.js"; + +export type CreateIdentityDTO = { + email: string; + password: string; +}; + +@injectable() +export class CreateIdentityUseCase implements IUseCase { + constructor( + @inject(AuthDomain.IAuthIdentityRepository) + private readonly authIdentityRepository: IAuthIdentityRepository, + @inject(SharedDomain.ICryptoService) + private readonly cryptoService: ICryptoService, + ) {} + + async execute(dto: CreateIdentityDTO): Promise { + const user = await this.authIdentityRepository.findOne({ + email: dto.email, + }); + if (user) { + throw new IdentityAlreadyExists(); + } + const hashedPassword = await this.cryptoService.hashPassword(dto.password); + const userEntity = new AuthIdentityEntity( + this.cryptoService.randomId(), + dto.email, + hashedPassword, + false, + new Date(), + [], + ); + await this.authIdentityRepository.save(userEntity); + } +} diff --git a/src/modules/auth/use-cases/login-user.spec.ts b/src/modules/auth/application/use-cases/create-session.spec.ts similarity index 60% rename from src/modules/auth/use-cases/login-user.spec.ts rename to src/modules/auth/application/use-cases/create-session.spec.ts index 4ae6c50..4b140aa 100644 --- a/src/modules/auth/use-cases/login-user.spec.ts +++ b/src/modules/auth/application/use-cases/create-session.spec.ts @@ -1,13 +1,14 @@ 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"; +import { AuthIdentityEntity } from "../../domain/auth-identity.entity.js"; +import { InvalidCredentials } from "../../domain/errors/InvalidCredentials.js"; +import { AuthIdentityInMemoryRepository } from "../../infrastructure/persistence/fakes/auth.in-memory.repo.js"; +import { CreateSessionUseCase } from "./create-session.js"; -describe("Auth - Login user", () => { - let usersRepo: UsersInMemoryRepository; +describe("Auth - Create session", () => { + let identityRepo: AuthIdentityInMemoryRepository; const MockCryptoService = vi.fn( class implements ICryptoService { randomId = vi.fn().mockReturnValue("2"); @@ -41,9 +42,16 @@ describe("Auth - Login user", () => { const logger = new MockLogger(); beforeEach(() => { - usersRepo = new UsersInMemoryRepository(); - usersRepo.save( - new UserEntity("1", "test@example.com", "password", true, new Date()), + identityRepo = new AuthIdentityInMemoryRepository(); + identityRepo.save( + new AuthIdentityEntity( + "1", + "test@example.com", + "password", + true, + new Date(), + [], + ), ); }); @@ -51,11 +59,11 @@ describe("Auth - Login user", () => { vi.resetAllMocks(); }); - test("should login a user", async () => { - const findOneSpy = vi.spyOn(usersRepo, "findOne"); + test("should create a session", async () => { + const findOneSpy = vi.spyOn(identityRepo, "findOne"); const generateTokenSpy = vi.spyOn(tokenService, "generateToken"); - const useCase = new LoginUserUseCase( - usersRepo, + const useCase = new CreateSessionUseCase( + identityRepo, cryptoService, tokenService, logger, @@ -72,45 +80,41 @@ describe("Auth - Login user", () => { }); }); - test("should not login a user if the user is not found", async () => { - const findOneSpy = vi.spyOn(usersRepo, "findOne"); + test("should create a session if the identity is not found", async () => { + const findOneSpy = vi.spyOn(identityRepo, "findOne"); const generateTokenSpy = vi.spyOn(tokenService, "generateToken"); - const useCase = new LoginUserUseCase( - usersRepo, + const useCase = new CreateSessionUseCase( + identityRepo, cryptoService, tokenService, logger, ); - const result = await useCase.execute({ - email: "test2@example.com", - password: "password", - }); + await expect( + useCase.execute({ + email: "test2@example.com", + password: "password", + }), + ).rejects.toThrow(InvalidCredentials); 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"); + test("should create a session if the password is invalid", async () => { + const findOneSpy = vi.spyOn(identityRepo, "findOne"); const generateTokenSpy = vi.spyOn(tokenService, "generateToken"); - const useCase = new LoginUserUseCase( - usersRepo, + const useCase = new CreateSessionUseCase( + identityRepo, cryptoService, tokenService, logger, ); - const result = await useCase.execute({ - email: "test@example.com", - password: "password2", - }); + await expect( + useCase.execute({ + email: "test@example.com", + password: "password2", + }), + ).rejects.toThrow(InvalidCredentials); expect(findOneSpy).toHaveBeenCalledTimes(1); expect(generateTokenSpy).toHaveBeenCalledTimes(0); - expect(result).toEqual({ - token: null, - refreshToken: null, - }); }); }); diff --git a/src/modules/auth/use-cases/login-user.ts b/src/modules/auth/application/use-cases/create-session.ts similarity index 64% rename from src/modules/auth/use-cases/login-user.ts rename to src/modules/auth/application/use-cases/create-session.ts index 61c686c..7ea27bb 100644 --- a/src/modules/auth/use-cases/login-user.ts +++ b/src/modules/auth/application/use-cases/create-session.ts @@ -1,6 +1,4 @@ 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, @@ -9,23 +7,26 @@ import type { 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"; +import { AuthDomain } from "../../domain/auth.symbols.js"; +import type { IAuthIdentityRepository } from "../../domain/auth-identity.repo.js"; +import { InvalidCredentials } from "../../domain/errors/InvalidCredentials.js"; -export type LoginUserDTO = { +export type CreateSessionDTO = { email: string; password: string; }; -export type LoginUserResult = { +export type CreateSessionResult = { token: string | null; refreshToken: string | null; }; @injectable() -export class LoginUserUseCase - implements IUseCase +export class CreateSessionUseCase + implements IUseCase { constructor( - @inject(UsersDomain.IUserRepository) - private readonly userRepository: IUsersRepository, + @inject(AuthDomain.IAuthIdentityRepository) + private readonly authIdentityRepository: IAuthIdentityRepository, @inject(SharedDomain.ICryptoService) private readonly cryptoService: ICryptoService, @inject(SharedDomain.ITokenService) @@ -37,15 +38,17 @@ export class LoginUserUseCase private readonly requestContext?: IRequestContext, ) {} - async execute(dto: LoginUserDTO): Promise { - const user = await this.userRepository.findOne({ email: dto.email }); + async execute(dto: CreateSessionDTO): Promise { + const user = await this.authIdentityRepository.findOne({ + email: dto.email, + }); if (!user) { this.logger.error({ message: "Invalid credentials", - module: "LoginUserUseCase", + module: "CreateSessionUseCase", context: this.requestContext, }); - return { token: null, refreshToken: null }; + throw new InvalidCredentials(); } const isPasswordValid = await this.cryptoService.comparePassword( dto.password, @@ -54,16 +57,16 @@ export class LoginUserUseCase if (!isPasswordValid) { this.logger.error({ message: "Invalid credentials", - module: "LoginUserUseCase", + module: "CreateSessionUseCase", context: this.requestContext, }); - return { token: null, refreshToken: null }; + throw new InvalidCredentials(); } const token = this.tokenService.generateToken(user); const refreshToken = this.tokenService.generateRefreshToken(user); this.logger.info({ - message: "User logged in", - module: "LoginUserUseCase", + message: "Logged in.", + module: "CreateSessionUseCase", context: this.requestContext, }); return { token, refreshToken }; diff --git a/src/modules/auth/use-cases/refresh-session.spec.ts b/src/modules/auth/application/use-cases/refresh-session.spec.ts similarity index 60% rename from src/modules/auth/use-cases/refresh-session.spec.ts rename to src/modules/auth/application/use-cases/refresh-session.spec.ts index 5e2563b..5c42336 100644 --- a/src/modules/auth/use-cases/refresh-session.spec.ts +++ b/src/modules/auth/application/use-cases/refresh-session.spec.ts @@ -1,28 +1,29 @@ 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 { AuthIdentityEntity } from "../../domain/auth-identity.entity.js"; +import { InvalidSession } from "../../domain/errors/InvalidSession.js"; +import { AuthIdentityInMemoryRepository } from "../../infrastructure/persistence/fakes/auth.in-memory.repo.js"; import { RefreshSessionUseCase } from "./refresh-session.js"; describe("Auth - Refresh session", () => { - let usersRepo: UsersInMemoryRepository; + let identityRepo: AuthIdentityInMemoryRepository; const MockTokenService = vi.fn( class implements ITokenService { generateToken = vi.fn().mockReturnValue("token"); generateRefreshToken = vi.fn().mockReturnValue("refresh-token"); getSession = vi.fn().mockReturnValue({ - userId: "1", + identityId: "1", email: "test@example.com", isVerified: true, loginDate: new Date(), }); validateRefreshToken = vi.fn((refreshToken) => { if (refreshToken === "refresh-token") { - return { userId: "1" }; + return { identityId: "1" }; } if (refreshToken === "non-existant-user") { - return { userId: "2" }; + return { identityId: "2" }; } return null; }); @@ -40,9 +41,16 @@ describe("Auth - Refresh session", () => { const logger = new MockLogger(); beforeEach(() => { - usersRepo = new UsersInMemoryRepository(); - usersRepo.save( - new UserEntity("1", "test@example.com", "password", true, new Date()), + identityRepo = new AuthIdentityInMemoryRepository(); + identityRepo.save( + new AuthIdentityEntity( + "1", + "test@example.com", + "password", + true, + new Date(), + [], + ), ); }); @@ -51,9 +59,13 @@ describe("Auth - Refresh session", () => { }); test("should refresh a session", async () => { - const findOneSpy = vi.spyOn(usersRepo, "findOne"); + const findOneSpy = vi.spyOn(identityRepo, "findOne"); const generateTokenSpy = vi.spyOn(tokenService, "generateToken"); - const useCase = new RefreshSessionUseCase(usersRepo, tokenService, logger); + const useCase = new RefreshSessionUseCase( + identityRepo, + tokenService, + logger, + ); const result = await useCase.execute({ refreshToken: "refresh-token", }); @@ -66,32 +78,36 @@ describe("Auth - Refresh session", () => { }); test("should not refresh when refresh token is invalid", async () => { - const findOneSpy = vi.spyOn(usersRepo, "findOne"); + const findOneSpy = vi.spyOn(identityRepo, "findOne"); const generateTokenSpy = vi.spyOn(tokenService, "generateToken"); - const useCase = new RefreshSessionUseCase(usersRepo, tokenService, logger); - const result = await useCase.execute({ - refreshToken: "invalid-refresh-token", - }); + const useCase = new RefreshSessionUseCase( + identityRepo, + tokenService, + logger, + ); + await expect( + useCase.execute({ + refreshToken: "invalid-refresh-token", + }), + ).rejects.toThrow(InvalidSession); 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 findOneSpy = vi.spyOn(identityRepo, "findOne"); const generateTokenSpy = vi.spyOn(tokenService, "generateToken"); - const useCase = new RefreshSessionUseCase(usersRepo, tokenService, logger); - const result = await useCase.execute({ - refreshToken: "non-existant-user", - }); + const useCase = new RefreshSessionUseCase( + identityRepo, + tokenService, + logger, + ); + await expect( + useCase.execute({ + refreshToken: "non-existant-user", + }), + ).rejects.toThrow(InvalidSession); expect(findOneSpy).toHaveBeenCalledTimes(1); expect(generateTokenSpy).toHaveBeenCalledTimes(0); - expect(result).toEqual({ - token: null, - refreshToken: null, - }); }); }); diff --git a/src/modules/auth/use-cases/refresh-session.ts b/src/modules/auth/application/use-cases/refresh-session.ts similarity index 77% rename from src/modules/auth/use-cases/refresh-session.ts rename to src/modules/auth/application/use-cases/refresh-session.ts index 02e0782..2e1ea59 100644 --- a/src/modules/auth/use-cases/refresh-session.ts +++ b/src/modules/auth/application/use-cases/refresh-session.ts @@ -1,6 +1,4 @@ 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, @@ -8,6 +6,9 @@ import type { 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"; +import { AuthDomain } from "../../domain/auth.symbols.js"; +import type { IAuthIdentityRepository } from "../../domain/auth-identity.repo.js"; +import { InvalidSession } from "../../domain/errors/InvalidSession.js"; export type RefreshSessionDTO = { refreshToken: string; @@ -22,8 +23,8 @@ export class RefreshSessionUseCase implements IUseCase { constructor( - @inject(UsersDomain.IUserRepository) - private readonly userRepository: IUsersRepository, + @inject(AuthDomain.IAuthIdentityRepository) + private readonly userRepository: IAuthIdentityRepository, @inject(SharedDomain.ITokenService) private readonly tokenService: ITokenService, @inject(SharedDomain.ILogger) @@ -37,22 +38,24 @@ export class RefreshSessionUseCase const refreshData = this.tokenService.validateRefreshToken( dto.refreshToken, ); - if (!refreshData?.userId) { + if (!refreshData?.identityId) { this.logger.error({ message: "Invalid refresh token", module: "RefreshSessionUseCase", context: this.requestContext, }); - return { token: null, refreshToken: null }; + throw new InvalidSession(); } - const user = await this.userRepository.findOne({ id: refreshData.userId }); + const user = await this.userRepository.findOne({ + id: refreshData.identityId, + }); if (!user) { this.logger.error({ message: "Invalid refresh token", module: "RefreshSessionUseCase", context: this.requestContext, }); - return { token: null, refreshToken: null }; + throw new InvalidSession(); } const token = this.tokenService.generateToken(user); const refreshToken = this.tokenService.generateRefreshToken(user); diff --git a/src/modules/auth/domain/auth-identity.entity.spec.ts b/src/modules/auth/domain/auth-identity.entity.spec.ts new file mode 100644 index 0000000..e5d0cc6 --- /dev/null +++ b/src/modules/auth/domain/auth-identity.entity.spec.ts @@ -0,0 +1,203 @@ +import { describe, expect, test, vi } from "vitest"; +import { AuthIdentityEntity } from "./auth-identity.entity.js"; +import { AuthVerificationEntity } from "./auth-verifications.entity.js"; +import { IdentityAlreadyVerified } from "./errors/IdentityAlreadyVerified.js"; +import { InvalidEmailFormat } from "./errors/InvalidEmailFormat.js"; +import { InvalidMagicToken } from "./errors/InvalidMagicToken.js"; +import { InvalidPassword } from "./errors/InvalidPassword.js"; +import { NewPasswordMustBeDifferent } from "./errors/NewPasswordMustBeDifferent.js"; + +describe("Auth - AuthIdentityEntity", () => { + test("should create a user entity", () => { + const identity = new AuthIdentityEntity( + "1", + "test@example.com", + "password", + false, + new Date(), + [], + ); + expect(identity).toBeDefined(); + expect(identity.id).toBe("1"); + expect(identity.email).toBe("test@example.com"); + expect(identity.password).toBe("password"); + expect(identity.isVerified).toBe(false); + expect(identity.createdAt).toBeInstanceOf(Date); + }); + + test("should throw an error if the email is invalid", () => { + expect(() => { + new AuthIdentityEntity("1", "test", "password", false, new Date(), []); + }).toThrowError(InvalidEmailFormat); + }); + + test("should throw an error if the password is invalid", () => { + expect(() => { + new AuthIdentityEntity( + "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 identity = new AuthIdentityEntity( + "1", + "test@example.com", + "password", + false, + new Date(), + [], + ); + // advance time by 5 seconds + vi.setSystemTime(new Date(2000, 1, 1, 13, 0, 5)); + expect(identity.getAccountAge()).toBe(5); + vi.useRealTimers(); + }); + + test("should change email successfully", () => { + const user = new AuthIdentityEntity( + "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 identity = new AuthIdentityEntity( + "1", + "test@example.com", + "password", + false, + new Date(), + [], + ); + expect(() => { + identity.changeEmail("test"); + }).toThrowError(InvalidEmailFormat); + }); + + test("should change password successfully", () => { + const identity = new AuthIdentityEntity( + "1", + "test@example.com", + "password", + false, + new Date(), + [], + ); + identity.changePassword("password2"); + expect(identity.password).toBe("password2"); + }); + + test("should verify via magic token successfully", () => { + const verification = new AuthVerificationEntity( + "verification-1", + "identity-1", + "token-1", + new Date(), + null, + false, + false, + ); + const identity = new AuthIdentityEntity( + "identity-1", + "user@example.com", + "password", + false, + new Date(), + [verification], + ); + + identity.verifyViaMagicToken("token-1"); + + expect(identity.isVerified).toBeTruthy(); + }); + + test("should throw an error if the new password is the same as the old password", () => { + const identity = new AuthIdentityEntity( + "1", + "test@example.com", + "password", + false, + new Date(), + [], + ); + expect(() => { + identity.changePassword("password"); + }).toThrowError(NewPasswordMustBeDifferent); + }); + + test("should throw an error if the new password is invalid", () => { + const identity = new AuthIdentityEntity( + "1", + "test@example.com", + "password", + false, + new Date(), + [], + ); + expect(() => { + identity.changePassword(""); + }).toThrowError(InvalidPassword); + }); + + test("should throw an error if the magic token is invalid", () => { + const verification = new AuthVerificationEntity( + "verification-1", + "identity-1", + "token-1", + new Date(), + null, + false, + false, + ); + const identity = new AuthIdentityEntity( + "identity-1", + "user@example.com", + "password", + false, + new Date(), + [verification], + ); + + expect(() => { + identity.verifyViaMagicToken("invalid-token"); + }).toThrow(InvalidMagicToken); + }); + + test("should throw an error if the user is already verified", () => { + const verification = new AuthVerificationEntity( + "verification-1", + "identity-1", + "token-1", + new Date(), + null, + false, + false, + ); + const identity = new AuthIdentityEntity( + "identity-1", + "user@example.com", + "password", + true, + new Date(), + [verification], + ); + + expect(() => { + identity.verifyViaMagicToken("token-1"); + }).toThrow(IdentityAlreadyVerified); + }); +}); diff --git a/src/modules/users/domain/users.entity.ts b/src/modules/auth/domain/auth-identity.entity.ts similarity index 57% rename from src/modules/users/domain/users.entity.ts rename to src/modules/auth/domain/auth-identity.entity.ts index 88638c5..ace0c0a 100644 --- a/src/modules/users/domain/users.entity.ts +++ b/src/modules/auth/domain/auth-identity.entity.ts @@ -1,14 +1,18 @@ +import type { AuthVerificationEntity } from "./auth-verifications.entity.js"; +import { IdentityAlreadyVerified } from "./errors/IdentityAlreadyVerified.js"; import { InvalidEmailFormat } from "./errors/InvalidEmailFormat.js"; +import { InvalidMagicToken } from "./errors/InvalidMagicToken.js"; import { InvalidPassword } from "./errors/InvalidPassword.js"; import { NewPasswordMustBeDifferent } from "./errors/NewPasswordMustBeDifferent.js"; -export class UserEntity { +export class AuthIdentityEntity { constructor( public id: string, public email: string, public password: string, public isVerified: boolean, public createdAt: Date, + public verifications: AuthVerificationEntity[], ) { if (!email.includes("@")) { throw new InvalidEmailFormat(); @@ -18,14 +22,28 @@ export class UserEntity { } } - /** - * Returns the age of the account in seconds - * @returns account age in seconds - */ getAccountAge() { return (Date.now() - this.createdAt.getTime()) / 1000; } + verifyViaMagicToken(magicToken: string) { + if (this.isVerified) { + throw new IdentityAlreadyVerified(); + } + let verification: AuthVerificationEntity | null = null; + for (const entity of this.verifications) { + if (entity.magicToken === magicToken) { + verification = entity; + break; + } + } + if (verification === null) { + throw new InvalidMagicToken(); + } + verification.accept(); + this.isVerified = true; + } + changeEmail(newEmail: string) { if (!newEmail.includes("@")) { throw new InvalidEmailFormat(); @@ -42,8 +60,4 @@ export class UserEntity { } this.password = newHashedPassword; } - - setVerifiedStatus(verifiedStatus: boolean) { - this.isVerified = verifiedStatus; - } } diff --git a/src/modules/auth/domain/auth-identity.repo.ts b/src/modules/auth/domain/auth-identity.repo.ts new file mode 100644 index 0000000..e12e9c9 --- /dev/null +++ b/src/modules/auth/domain/auth-identity.repo.ts @@ -0,0 +1,5 @@ +import type { IBaseRepository } from "@/shared/core/IBaseRepository.js"; +import type { AuthIdentityEntity } from "./auth-identity.entity.js"; + +export interface IAuthIdentityRepository + extends IBaseRepository {} diff --git a/src/modules/auth/domain/auth-verifications.entity.spec.ts b/src/modules/auth/domain/auth-verifications.entity.spec.ts new file mode 100644 index 0000000..a17b8f6 --- /dev/null +++ b/src/modules/auth/domain/auth-verifications.entity.spec.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "vitest"; +import { AuthVerificationEntity } from "./auth-verifications.entity.js"; +import { VerificationAlreadyAccepted } from "./errors/VerificationAlreadyAccepted.js"; +import { VerificationAlreadyRevoked } from "./errors/VerificationAlreadyRevoked.js"; + +describe("Users - UserVerificationEntity", () => { + test("should create a user verification", () => { + const authVerification = new AuthVerificationEntity( + "1", + "identity-1", + "token-1", + new Date(), + null, + false, + false, + ); + + expect(authVerification).toBeDefined(); + expect(authVerification.id).toBe("1"); + expect(authVerification.identityId).toBe("identity-1"); + expect(authVerification.magicToken).toBe("token-1"); + expect(authVerification.createdAt).toBeInstanceOf(Date); + expect(authVerification.acceptedAt).toBeNull(); + expect(authVerification.isAccepted).toBeFalsy(); + expect(authVerification.isRevoked).toBeFalsy(); + expect(authVerification.isVerified()).toBeFalsy(); + }); + + test("should accept user verification", () => { + const authVerification = new AuthVerificationEntity( + "1", + "identity-1", + "token-1", + new Date(), + null, + false, + false, + ); + + authVerification.accept(); + + expect(authVerification).toBeDefined(); + expect(authVerification.id).toBe("1"); + expect(authVerification.identityId).toBe("identity-1"); + expect(authVerification.magicToken).toBe("token-1"); + expect(authVerification.createdAt).toBeInstanceOf(Date); + expect(authVerification.acceptedAt).toBeInstanceOf(Date); + expect(authVerification.isAccepted).toBeTruthy(); + expect(authVerification.isRevoked).toBeFalsy(); + expect(authVerification.isVerified()).toBeTruthy(); + }); + + test("should revoke user verification (not yet accepted)", () => { + const userVerification = new AuthVerificationEntity( + "1", + "identity-1", + "token-1", + new Date(), + null, + false, + false, + ); + + userVerification.revoke(); + + expect(userVerification).toBeDefined(); + expect(userVerification.id).toBe("1"); + expect(userVerification.identityId).toBe("identity-1"); + expect(userVerification.magicToken).toBe("token-1"); + expect(userVerification.createdAt).toBeInstanceOf(Date); + expect(userVerification.acceptedAt).toBeNull(); + expect(userVerification.isAccepted).toBeFalsy(); + expect(userVerification.isRevoked).toBeTruthy(); + expect(userVerification.isVerified()).toBeFalsy(); + }); + + test("should revoke user verification (already accepted)", () => { + const authVerification = new AuthVerificationEntity( + "1", + "identity-1", + "token-1", + new Date(), + null, + false, + false, + ); + + authVerification.accept(); + authVerification.revoke(); + + expect(authVerification).toBeDefined(); + expect(authVerification.id).toBe("1"); + expect(authVerification.identityId).toBe("identity-1"); + expect(authVerification.magicToken).toBe("token-1"); + expect(authVerification.createdAt).toBeInstanceOf(Date); + expect(authVerification.acceptedAt).toBeInstanceOf(Date); + expect(authVerification.isAccepted).toBeTruthy(); + expect(authVerification.isRevoked).toBeTruthy(); + expect(authVerification.isVerified()).toBeFalsy(); + }); + + test("should throw an error when trying to accept while already accepted", () => { + const authVerification = new AuthVerificationEntity( + "1", + "identity-1", + "token-1", + new Date(), + new Date(), + true, + false, + ); + + expect(() => { + authVerification.accept(); + }).toThrowError(VerificationAlreadyAccepted); + }); + + test("should throw an error when trying to revoke while already revoked", () => { + const notAccepted = new AuthVerificationEntity( + "1", + "identity-1", + "token-1", + new Date(), + null, + false, + true, + ); + const accepted = new AuthVerificationEntity( + "1", + "identity-1", + "token-1", + new Date(), + new Date(), + true, + true, + ); + + expect(() => { + notAccepted.revoke(); + }).toThrowError(VerificationAlreadyRevoked); + expect(() => { + accepted.revoke(); + }).toThrowError(VerificationAlreadyRevoked); + }); +}); diff --git a/src/modules/auth/domain/auth-verifications.entity.ts b/src/modules/auth/domain/auth-verifications.entity.ts new file mode 100644 index 0000000..7cbdee3 --- /dev/null +++ b/src/modules/auth/domain/auth-verifications.entity.ts @@ -0,0 +1,33 @@ +import { VerificationAlreadyAccepted } from "./errors/VerificationAlreadyAccepted.js"; +import { VerificationAlreadyRevoked } from "./errors/VerificationAlreadyRevoked.js"; + +export class AuthVerificationEntity { + constructor( + public id: string, + public identityId: string, + public magicToken: string, + public createdAt: Date, + public acceptedAt: Date | null, + public isAccepted: boolean, + public isRevoked: boolean, + ) {} + + isVerified(): boolean { + return this.isAccepted && !this.isRevoked; + } + + accept(): void { + if (this.isAccepted) { + throw new VerificationAlreadyAccepted(); + } + this.isAccepted = true; + this.acceptedAt = new Date(); + } + + revoke(): void { + if (this.isRevoked) { + throw new VerificationAlreadyRevoked(); + } + this.isRevoked = true; + } +} diff --git a/src/modules/auth/domain/auth.symbols.ts b/src/modules/auth/domain/auth.symbols.ts new file mode 100644 index 0000000..b945973 --- /dev/null +++ b/src/modules/auth/domain/auth.symbols.ts @@ -0,0 +1,5 @@ +export const AuthDomain = { + // IAuthIdentityGateway: Symbol.for("IAuthIdentityGateway"), + IAuthIdentityRepository: Symbol.for("IAuthIdentityRepository"), + IAuthIdentityQueryService: Symbol.for("IAuthIdentityQueryService"), +}; diff --git a/src/modules/auth/domain/errors/IdentityAlreadyExists.ts b/src/modules/auth/domain/errors/IdentityAlreadyExists.ts new file mode 100644 index 0000000..df19ee9 --- /dev/null +++ b/src/modules/auth/domain/errors/IdentityAlreadyExists.ts @@ -0,0 +1,5 @@ +export class IdentityAlreadyExists extends Error { + constructor() { + super("Identity already exists"); + } +} diff --git a/src/modules/auth/domain/errors/IdentityAlreadyVerified.ts b/src/modules/auth/domain/errors/IdentityAlreadyVerified.ts new file mode 100644 index 0000000..1dfae61 --- /dev/null +++ b/src/modules/auth/domain/errors/IdentityAlreadyVerified.ts @@ -0,0 +1,5 @@ +export class IdentityAlreadyVerified extends Error { + constructor() { + super("Identity already verified"); + } +} diff --git a/src/modules/auth/domain/errors/IdentityNotFound.ts b/src/modules/auth/domain/errors/IdentityNotFound.ts new file mode 100644 index 0000000..249762f --- /dev/null +++ b/src/modules/auth/domain/errors/IdentityNotFound.ts @@ -0,0 +1,5 @@ +export class IdentityNotFound extends Error { + constructor() { + super("Identity not found"); + } +} diff --git a/src/modules/auth/domain/errors/InvalidCredentials.ts b/src/modules/auth/domain/errors/InvalidCredentials.ts index e69de29..0c61cf0 100644 --- a/src/modules/auth/domain/errors/InvalidCredentials.ts +++ b/src/modules/auth/domain/errors/InvalidCredentials.ts @@ -0,0 +1,5 @@ +export class InvalidCredentials extends Error { + constructor() { + super("Invalid credentails."); + } +} diff --git a/src/modules/users/domain/errors/InvalidEmailFormat.ts b/src/modules/auth/domain/errors/InvalidEmailFormat.ts similarity index 100% rename from src/modules/users/domain/errors/InvalidEmailFormat.ts rename to src/modules/auth/domain/errors/InvalidEmailFormat.ts diff --git a/src/modules/auth/domain/errors/InvalidMagicToken.ts b/src/modules/auth/domain/errors/InvalidMagicToken.ts new file mode 100644 index 0000000..8578088 --- /dev/null +++ b/src/modules/auth/domain/errors/InvalidMagicToken.ts @@ -0,0 +1,5 @@ +export class InvalidMagicToken extends Error { + constructor() { + super("Invalid magic token"); + } +} diff --git a/src/modules/users/domain/errors/InvalidPassword.ts b/src/modules/auth/domain/errors/InvalidPassword.ts similarity index 100% rename from src/modules/users/domain/errors/InvalidPassword.ts rename to src/modules/auth/domain/errors/InvalidPassword.ts diff --git a/src/modules/auth/domain/errors/InvalidSession.ts b/src/modules/auth/domain/errors/InvalidSession.ts new file mode 100644 index 0000000..ec58d3a --- /dev/null +++ b/src/modules/auth/domain/errors/InvalidSession.ts @@ -0,0 +1,5 @@ +export class InvalidSession extends Error { + constructor() { + super("Invalid session."); + } +} diff --git a/src/modules/users/domain/errors/NewPasswordMustBeDifferent.ts b/src/modules/auth/domain/errors/NewPasswordMustBeDifferent.ts similarity index 100% rename from src/modules/users/domain/errors/NewPasswordMustBeDifferent.ts rename to src/modules/auth/domain/errors/NewPasswordMustBeDifferent.ts diff --git a/src/modules/auth/domain/errors/VerificationAlreadyAccepted.ts b/src/modules/auth/domain/errors/VerificationAlreadyAccepted.ts new file mode 100644 index 0000000..3cf9d50 --- /dev/null +++ b/src/modules/auth/domain/errors/VerificationAlreadyAccepted.ts @@ -0,0 +1,5 @@ +export class VerificationAlreadyAccepted extends Error { + constructor() { + super("Verification was already accepted."); + } +} diff --git a/src/modules/auth/domain/errors/VerificationAlreadyRevoked.ts b/src/modules/auth/domain/errors/VerificationAlreadyRevoked.ts new file mode 100644 index 0000000..65c2744 --- /dev/null +++ b/src/modules/auth/domain/errors/VerificationAlreadyRevoked.ts @@ -0,0 +1,5 @@ +export class VerificationAlreadyRevoked extends Error { + constructor() { + super("Verification was already revoked."); + } +} diff --git a/src/modules/auth/infrastructure/di/auth.di.ts b/src/modules/auth/infrastructure/di/auth.di.ts index be35ff9..3b6058e 100644 --- a/src/modules/auth/infrastructure/di/auth.di.ts +++ b/src/modules/auth/infrastructure/di/auth.di.ts @@ -1,10 +1,16 @@ 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"; +import { CreateIdentityUseCase } from "../../application/use-cases/create-identity.js"; +import { CreateSessionUseCase } from "../../application/use-cases/create-session.js"; +import { RefreshSessionUseCase } from "../../application/use-cases/refresh-session.js"; +import { AuthDomain } from "../../domain/auth.symbols.js"; +import { AuthIdentityPrismaRepository } from "../persistence/auth.prisma.repo.js"; +import { AuthPrismaQueryService } from "../persistence/auth.prisma.service.js"; export const AuthDIModule = new ContainerModule(({ bind }) => { - bind(LoginUserUseCase).toSelf().inTransientScope(); + bind(AuthDomain.IAuthIdentityRepository).to(AuthIdentityPrismaRepository); + bind(AuthDomain.IAuthIdentityQueryService).to(AuthPrismaQueryService); + + bind(CreateSessionUseCase).toSelf().inTransientScope(); bind(RefreshSessionUseCase).toSelf().inTransientScope(); - bind(UserSignupUseCase).toSelf().inTransientScope(); + bind(CreateIdentityUseCase).toSelf().inTransientScope(); }); diff --git a/src/modules/auth/infrastructure/http/auth.routes.ts b/src/modules/auth/infrastructure/http/auth.routes.ts index 18b6e55..b22f73a 100644 --- a/src/modules/auth/infrastructure/http/auth.routes.ts +++ b/src/modules/auth/infrastructure/http/auth.routes.ts @@ -5,9 +5,9 @@ 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"; +import { CreateIdentityUseCase } from "../../application/use-cases/create-identity.js"; +import { CreateSessionUseCase } from "../../application/use-cases/create-session.js"; +import { RefreshSessionUseCase } from "../../application/use-cases/refresh-session.js"; const router = Router(); @@ -35,7 +35,7 @@ const LoginRequestSchema = z.object({ * post: * tags: * - Auth - * summary: Login a user + * summary: Login via an identity and create a session * requestBody: * required: true * content: @@ -53,7 +53,7 @@ router.post("/login", async (req, res) => { SharedDomain.IConfigService, ); const { email, password } = LoginRequestSchema.parse(req.body); - const useCase = appContainer.get(LoginUserUseCase); + const useCase = appContainer.get(CreateSessionUseCase); const { token, refreshToken } = await useCase.execute({ email, password }); if (token && refreshToken) { @@ -116,7 +116,7 @@ const RegisterRequestSchema = z.object({ */ router.post("/register", async (req, res) => { const { email, password } = RegisterRequestSchema.parse(req.body); - const useCase = appContainer.get(UserSignupUseCase); + const useCase = appContainer.get(CreateIdentityUseCase); await useCase.execute({ email, password }); res.status(200).send(); }); diff --git a/src/modules/auth/infrastructure/persistence/auth.prisma.mappers.ts b/src/modules/auth/infrastructure/persistence/auth.prisma.mappers.ts new file mode 100644 index 0000000..1d98cfd --- /dev/null +++ b/src/modules/auth/infrastructure/persistence/auth.prisma.mappers.ts @@ -0,0 +1,69 @@ +import type { + AuthIdentity, + AuthVerification, + PrismaClient, +} from "@/generated/prisma/client.js"; +import type { IAsyncMapper, IMapper } from "@/shared/core/IMapper.js"; +import { AuthIdentityEntity } from "../../domain/auth-identity.entity.js"; +import { AuthVerificationEntity } from "../../domain/auth-verifications.entity.js"; + +export class AuthIdentityPrismaMapper + implements IAsyncMapper +{ + private authVerificationMapper: AuthVerificationPrismaMapper; + + constructor(private prisma: PrismaClient) { + this.authVerificationMapper = new AuthVerificationPrismaMapper(); + } + async toDomain(model: AuthIdentity): Promise { + const verificationModels = await this.prisma.authVerification.findMany({ + where: { + id: model.id, + }, + }); + return new AuthIdentityEntity( + model.id, + model.email, + model.password, + model.isVerified, + model.createdAt, + verificationModels.map(this.authVerificationMapper.toDomain), + ); + } + async toModel(entity: AuthIdentityEntity): Promise { + return { + id: entity.id, + email: entity.email, + password: entity.password, + isVerified: entity.isVerified, + createdAt: entity.createdAt, + }; + } +} + +export class AuthVerificationPrismaMapper + implements IMapper +{ + toDomain(model: AuthVerification): AuthVerificationEntity { + return new AuthVerificationEntity( + model.id, + model.identityId, + model.magicToken, + model.createdAt, + model.acceptedAt, + model.isAccepted, + model.isRevoked, + ); + } + toModel(entity: AuthVerificationEntity): AuthVerification { + return { + id: entity.id, + identityId: entity.identityId, + magicToken: entity.magicToken, + createdAt: entity.createdAt, + acceptedAt: entity.acceptedAt, + isAccepted: entity.isAccepted, + isRevoked: entity.isRevoked, + }; + } +} diff --git a/src/modules/auth/infrastructure/persistence/auth.prisma.repo.ts b/src/modules/auth/infrastructure/persistence/auth.prisma.repo.ts new file mode 100644 index 0000000..972d971 --- /dev/null +++ b/src/modules/auth/infrastructure/persistence/auth.prisma.repo.ts @@ -0,0 +1,190 @@ +import { inject, injectable } from "inversify"; +import type { AuthIdentity } from "@/generated/prisma/client.js"; +import type { + AuthIdentityModel, + AuthIdentityWhereInput, + DateTimeFilter, +} from "@/generated/prisma/models.js"; +import type { + FilterCriteria, + PaginationOptions, + PaginationResult, +} from "@/shared/core/IBaseRepository.js"; +import { + type PrismaClient, + PrismaClientWrapper, +} from "@/shared/infrastructure/persistence/prisma/PrismaClientWrapper.js"; +import type { AuthIdentityEntity } from "../../domain/auth-identity.entity.js"; +import type { IAuthIdentityRepository } from "../../domain/auth-identity.repo.js"; +import { + AuthIdentityPrismaMapper, + AuthVerificationPrismaMapper, +} from "./auth.prisma.mappers.js"; + +@injectable() +export class AuthIdentityPrismaRepository implements IAuthIdentityRepository { + private readonly prisma: PrismaClient; + private identityMapper: AuthIdentityPrismaMapper; + private verificationMapper: AuthVerificationPrismaMapper; + constructor( + @inject(PrismaClientWrapper) + private readonly prismaClientWrapper: PrismaClientWrapper, + ) { + this.prisma = this.prismaClientWrapper.getClient(); + this.identityMapper = new AuthIdentityPrismaMapper(this.prisma); + this.verificationMapper = new AuthVerificationPrismaMapper(); + } + + private toModelFilter( + criteria: FilterCriteria, + ): FilterCriteria { + const result: FilterCriteria = {}; + 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; + } + + private getWhereInput( + modelFilter: FilterCriteria, + ): AuthIdentityWhereInput { + const where: AuthIdentityWhereInput = {}; + if (modelFilter.id !== undefined) { + where.id = modelFilter.id; + } + if (modelFilter.email !== undefined) { + where.email = modelFilter.email; + } + if (modelFilter.isVerified !== undefined) { + where.isVerified = modelFilter.isVerified; + } + if (modelFilter.createdAt !== undefined) { + if (modelFilter.createdAt instanceof Date) { + where.createdAt = modelFilter.createdAt; + } else { + const range: DateTimeFilter<"User"> = {}; + if (modelFilter.createdAt.from !== undefined) { + range.gte = modelFilter.createdAt.from; + } + if (modelFilter.createdAt.to !== undefined) { + range.lte = modelFilter.createdAt.to; + } + } + } + + return where; + } + + async findOne( + criteria: FilterCriteria, + ): Promise { + const modelFilter = this.toModelFilter(criteria); + const where = this.getWhereInput(modelFilter); + const model = await this.prisma.authIdentity.findFirst({ + where, + include: { + verifications: true, + }, + }); + return model ? await this.identityMapper.toDomain(model) : null; + } + + async findById(id: string): Promise { + const model = await this.prisma.authIdentity.findUnique({ + where: { id }, + include: { verifications: true }, + }); + return model ? await this.identityMapper.toDomain(model) : null; + } + async findAll( + criteria?: FilterCriteria, + paginationOptions?: PaginationOptions, + ): Promise> { + const modelFilter = criteria ? this.toModelFilter(criteria) : {}; + const where = this.getWhereInput(modelFilter); + const models = paginationOptions + ? await this.prisma.authIdentity.findMany({ + where, + take: paginationOptions.limit, + skip: paginationOptions.offset, + include: { + verifications: true, + }, + }) + : await this.prisma.authIdentity.findMany({ + where, + include: { + verifications: true, + }, + }); + const total = await this.prisma.authIdentity.count({ where }); + return { + data: await Promise.all(models.map(this.identityMapper.toDomain)), + total, + }; + } + async save(entity: AuthIdentityEntity): Promise { + const verificationSnapshot = await this.prisma.authVerification.findMany({ + where: { identityId: entity.id }, + }); + const model = await this.prisma.authIdentity.upsert({ + where: { id: entity.id }, + create: await this.identityMapper.toModel(entity), + update: await this.identityMapper.toModel(entity), + include: { + verifications: true, + }, + }); + + // remove verification objects that are no longer present in entity + const removedIds = verificationSnapshot + .map((snapshot) => snapshot.id) + .filter( + (id) => + !entity.verifications + .map((verificaiton) => verificaiton.id) + .includes(id), + ); + await this.prisma.authVerification.deleteMany({ + where: { + id: { + in: removedIds, + }, + }, + }); + + // create verification objects that were previously not present in db snapshot + const addedEntities = entity.verifications.filter( + (verification) => + !verificationSnapshot + .map((snapshot) => snapshot.id) + .includes(verification.id), + ); + await this.prisma.authVerification.createMany({ + data: addedEntities.map(this.verificationMapper.toModel), + }); + + // update models that exist in both db snapshot and entity + const possiblyUpdatedModels = verificationSnapshot.filter((snapshot) => + entity.verifications + .map((verificaiton) => verificaiton.id) + .includes(snapshot.id), + ); + await Promise.all( + possiblyUpdatedModels.map(async (model) => { + await this.prisma.authVerification.update({ + where: { + id: model.id, + }, + data: model, + }); + }), + ); + + return model ? this.identityMapper.toDomain(model) : null; + } + generateId(): string { + return this.prismaClientWrapper.generateId(); + } +} diff --git a/src/modules/auth/infrastructure/persistence/auth.prisma.service.ts b/src/modules/auth/infrastructure/persistence/auth.prisma.service.ts new file mode 100644 index 0000000..86fac43 --- /dev/null +++ b/src/modules/auth/infrastructure/persistence/auth.prisma.service.ts @@ -0,0 +1,183 @@ +import { inject, injectable } from "inversify"; +import type { PrismaClient } from "@/generated/prisma/client.js"; +import type { + AuthIdentityWhereInput, + DateTimeFilter, +} from "@/generated/prisma/models.js"; +import type { + FilterCriteria, + PaginationOptions, + PaginationResult, +} from "@/shared/core/IBaseRepository.js"; +import { PrismaClientWrapper } from "@/shared/infrastructure/persistence/prisma/PrismaClientWrapper.js"; +import type { + AuthIdentityDto, + AuthVerificationDto, + IAuthQueryService, +} from "../../application/query-service.js"; +import { IdentityNotFound } from "../../domain/errors/IdentityNotFound.js"; + +@injectable() +export class AuthPrismaQueryService implements IAuthQueryService { + private readonly prisma: PrismaClient; + constructor( + @inject(PrismaClientWrapper) + prismaClientWrapper: PrismaClientWrapper, + ) { + this.prisma = prismaClientWrapper.getClient(); + } + private getWhereInput( + modelFilter: FilterCriteria, + ): AuthIdentityWhereInput { + const where: AuthIdentityWhereInput = {}; + if (modelFilter.id !== undefined) { + where.id = modelFilter.id; + } + if (modelFilter.email !== undefined) { + where.email = modelFilter.email; + } + if (modelFilter.isVerified !== undefined) { + where.isVerified = modelFilter.isVerified; + } + if (modelFilter.createdAt !== undefined) { + if (modelFilter.createdAt instanceof Date) { + where.createdAt = modelFilter.createdAt; + } else { + const range: DateTimeFilter<"User"> = {}; + if (modelFilter.createdAt.from !== undefined) { + range.gte = modelFilter.createdAt.from; + } + if (modelFilter.createdAt.to !== undefined) { + range.lte = modelFilter.createdAt.to; + } + } + } + + return where; + } + + async findIdentities( + filters?: FilterCriteria, + paginationOptions?: PaginationOptions, + ): Promise> { + const where = filters ? this.getWhereInput(filters) : {}; + const models = paginationOptions + ? await this.prisma.authIdentity.findMany({ + where, + skip: paginationOptions.offset, + take: paginationOptions.limit, + }) + : await this.prisma.authIdentity.findMany({ + where, + }); + const total = await this.prisma.authIdentity.count({ where }); + + const data: AuthIdentityDto[] = models.map( + ({ id, email, isVerified, createdAt }) => ({ + id, + email, + isVerified, + createdAt, + }), + ); + + return { + data, + total, + }; + } + + async findById(id: string): Promise { + const model = await this.prisma.authIdentity.findFirst({ + where: { + id, + }, + }); + + if (model === null) { + throw new IdentityNotFound(); + } + + return { + id: model.id, + email: model.email, + isVerified: model.isVerified, + createdAt: model.createdAt, + }; + } + + async findByEmail(email: string): Promise { + const model = await this.prisma.authIdentity.findFirst({ + where: { + email, + }, + }); + + if (model === null) { + throw new IdentityNotFound(); + } + + return { + id: model.id, + email: model.email, + isVerified: model.isVerified, + createdAt: model.createdAt, + }; + } + + async getVerificationsByIdentityId( + identityId: string, + ): Promise { + const models = await this.prisma.authVerification.findMany({ + where: { + identityId, + }, + }); + + return models.map( + ({ + id, + identityId, + magicToken, + createdAt, + acceptedAt, + isAccepted, + isRevoked, + }) => ({ + id, + identityId, + magicToken, + createdAt, + acceptedAt, + isAccepted, + isRevoked, + }), + ); + } + + async getIdentityIdFromVerificationId( + verificationId: string, + ): Promise { + const model = await this.prisma.authVerification.findFirst({ + where: { + id: verificationId, + }, + }); + if (model === null) { + throw new IdentityNotFound(); + } + return model.identityId; + } + + async getIdentityIdFromMagicToken(magicToken: string): Promise { + const model = await this.prisma.authVerification.findFirst({ + where: { + magicToken, + }, + }); + if (model === null) { + throw new IdentityNotFound(); + } + return model.identityId; + } +} diff --git a/src/modules/auth/infrastructure/persistence/fakes/auth.in-memory.query-service.spec.ts b/src/modules/auth/infrastructure/persistence/fakes/auth.in-memory.query-service.spec.ts new file mode 100644 index 0000000..317f9d6 --- /dev/null +++ b/src/modules/auth/infrastructure/persistence/fakes/auth.in-memory.query-service.spec.ts @@ -0,0 +1,203 @@ +import { randomUUID } from "node:crypto"; +import { beforeEach, describe, expect, test } from "vitest"; +import { AuthIdentityEntity } from "../../../domain/auth-identity.entity.js"; +import type { AuthVerificationEntity } from "../../../domain/auth-verifications.entity.js"; +import { IdentityNotFound } from "../../../domain/errors/IdentityNotFound.js"; +import { AuthInMemoryQueryService } from "./auth.in-memory.query-service.js"; +import { AuthIdentityInMemoryRepository } from "./auth.in-memory.repo.js"; + +describe("AuthInMemoryQueryService", () => { + let queryService: AuthInMemoryQueryService; + let repo: AuthIdentityInMemoryRepository; + + beforeEach(() => { + repo = new AuthIdentityInMemoryRepository(); + queryService = new AuthInMemoryQueryService(repo); + new AuthInMemoryQueryService(); + }); + + const createIdentity = ( + overrides: Partial = {}, + ): AuthIdentityEntity => { + return new AuthIdentityEntity( + overrides.id ?? randomUUID(), + overrides.email ?? "test@example.com", + overrides.password ?? "password", + overrides.isVerified ?? false, + overrides.createdAt ?? new Date(), + overrides.verifications ?? [], + ); + }; + + const createVerification = ( + overrides: Partial = {}, + ): AuthVerificationEntity => { + return { + id: overrides.id ?? randomUUID(), + identityId: overrides.identityId ?? randomUUID(), + magicToken: overrides.magicToken ?? randomUUID(), + createdAt: overrides.createdAt ?? new Date(), + acceptedAt: overrides.acceptedAt ?? null, + isAccepted: overrides.isAccepted ?? false, + isRevoked: overrides.isRevoked ?? false, + isVerified: () => false, // Mock implementation if needed + accept: () => {}, + revoke: () => {}, + } as AuthVerificationEntity; + }; + + test("findIdentities - should return all identities if no filters provided", async () => { + const identity1 = createIdentity({ email: "identity1@example.com" }); + const identity2 = createIdentity({ email: "identity2@example.com" }); + await repo.save(identity1); + await repo.save(identity2); + + const result = await queryService.findIdentities(); + + expect(result.total).toBe(2); + expect(result.data).toHaveLength(2); + expect(result.data.at(0)?.id).toBe(identity1.id); + expect(result.data.at(1)?.id).toBe(identity2.id); + }); + + test("findIdentities - should filter identities by email", async () => { + const identity1 = createIdentity({ email: "identity1@example.com" }); + const identity2 = createIdentity({ email: "identity2@example.com" }); + await repo.save(identity1); + await repo.save(identity2); + + const result = await queryService.findIdentities({ + email: "identity1@example.com", + }); + + expect(result.total).toBe(1); + expect(result.data.at(0)?.id).toBe(identity1.id); + }); + + test("findOneById - should return identity if found", async () => { + const identity = createIdentity(); + await repo.save(identity); + + const result = await queryService.findById(identity.id); + + expect(result.id).toBe(identity.id); + }); + + test("findOneById - should throw IdentityNotFound if not found", async () => { + await expect(queryService.findById("non-existent-id")).rejects.toThrow( + IdentityNotFound, + ); + }); + + test("findByEmail - should return identity if found", async () => { + const identity = createIdentity(); + await repo.save(identity); + + const result = await queryService.findByEmail(identity.email); + + expect(result.id).toBe(identity.id); + }); + + test("findByEmail - should throw IdentityNotFound if not found", async () => { + await expect( + queryService.findByEmail("non-existent-email@example.com"), + ).rejects.toThrow(IdentityNotFound); + }); + + test("getVerificationsByIdentityId - should return verifications for identity", async () => { + const verification = createVerification(); + const identity = createIdentity({ verifications: [verification] }); + await repo.save(identity); + + const result = await queryService.getVerificationsByIdentityId(identity.id); + + expect(result).toHaveLength(1); + expect(result.at(0)?.id).toBe(verification.id); + }); + + test("getVerificationsByIdentityId - should return empty array if identity not found", async () => { + const result = + await queryService.getVerificationsByIdentityId("non-existent"); + expect(result).toEqual([]); + }); + + test("getIdentityIdFromVerificationId - should return identityId if verification found", async () => { + const identity = createIdentity(); + const verification = createVerification({ + identityId: identity.id, + }); + identity.verifications.push(verification); + await repo.save(identity); + + const result = await queryService.getIdentityIdFromVerificationId( + verification.id, + ); + + expect(result).toBe(identity.id); + }); + + test("getIdentityIdFromVerificationId - should return null if verification not found", async () => { + await expect( + queryService.getIdentityIdFromVerificationId("non-existent"), + ).resolves.toBeNull(); + }); + + test("getIdentityIdFromVerificationId - should return null if verification id does not exist even if other verifications exist", async () => { + const identity = createIdentity(); + const verification = createVerification({ + identityId: identity.id, + }); + identity.verifications.push(verification); + await repo.save(identity); + + const result = await queryService.getIdentityIdFromVerificationId( + "non-existent-verification-id", + ); + + expect(result).toBeNull(); + }); + + test("getIdentityIdFromVerificationId - should return null if verification exists but identityId does not exist", async () => { + const identity = createIdentity(); + const verification = createVerification({ + identityId: "non-existent-identity-id", + }); + identity.verifications.push(verification); + await repo.save(identity); + + const result = await queryService.getIdentityIdFromVerificationId( + verification.id, + ); + + expect(result).toBeNull(); + }); + + test("getIdentityIdFromMagicToken - should return identityId if magic token found", async () => { + const identity = createIdentity(); + const verification = createVerification({ + identityId: identity.id, + magicToken: "magic-token", + }); + identity.verifications.push(verification); + await repo.save(identity); + + const result = + await queryService.getIdentityIdFromMagicToken("magic-token"); + + expect(result).toBe(identity.id); + }); + + test("getIdentityIdFromMagicToken - should return null if magic token not found", async () => { + const identity = createIdentity(); + const verification = createVerification({ + identityId: identity.id, + magicToken: "magic-token", + }); + identity.verifications.push(verification); + await repo.save(identity); + + await expect( + queryService.getIdentityIdFromMagicToken("non-existent"), + ).resolves.toBeNull(); + }); +}); diff --git a/src/modules/auth/infrastructure/persistence/fakes/auth.in-memory.query-service.ts b/src/modules/auth/infrastructure/persistence/fakes/auth.in-memory.query-service.ts new file mode 100644 index 0000000..174b0b5 --- /dev/null +++ b/src/modules/auth/infrastructure/persistence/fakes/auth.in-memory.query-service.ts @@ -0,0 +1,127 @@ +import type { + AuthIdentityDto, + AuthVerificationDto, + IAuthQueryService, +} from "@/modules/auth/application/query-service.js"; +import { IdentityNotFound } from "@/modules/auth/domain/errors/IdentityNotFound.js"; +import type { + FilterCriteria, + PaginationOptions, + PaginationResult, +} from "@/shared/core/IBaseRepository.js"; +import { AuthIdentityInMemoryRepository } from "./auth.in-memory.repo.js"; + +export class AuthInMemoryQueryService implements IAuthQueryService { + private repo: AuthIdentityInMemoryRepository; + + constructor(usersInMemoryRepository?: AuthIdentityInMemoryRepository) { + this.repo = usersInMemoryRepository ?? new AuthIdentityInMemoryRepository(); + } + + async findIdentities( + filters?: FilterCriteria, + pagination?: PaginationOptions, + ): Promise> { + const items = await this.repo.findAll(filters, pagination); + return { + data: items.data.map((user) => ({ + id: user.id, + email: user.email, + isVerified: user.isVerified, + createdAt: user.createdAt, + })), + total: items.total, + }; + } + + async findById(id: string): Promise { + const identity = await this.repo.findById(id); + if (!identity) { + throw new IdentityNotFound(); + } + return { + id: identity.id, + email: identity.email, + isVerified: identity.isVerified, + createdAt: identity.createdAt, + }; + } + + async findByEmail(email: string): Promise { + const identity = await this.repo.findOne({ + email, + }); + if (!identity) { + throw new IdentityNotFound(); + } + return { + id: identity.id, + email: identity.email, + isVerified: identity.isVerified, + createdAt: identity.createdAt, + }; + } + + async getVerificationsByIdentityId( + identityId: string, + ): Promise { + const identity = await this.repo.findById(identityId); + if (!identity) { + return []; + } + return identity.verifications.map((v) => ({ + id: v.id, + identityId: v.identityId, + magicToken: v.magicToken, + createdAt: v.createdAt, + acceptedAt: v.acceptedAt, + isAccepted: v.isAccepted, + isRevoked: v.isRevoked, + })); + } + + async getIdentityIdFromVerificationId( + verificationId: string, + ): Promise { + let identityId: string | null = null; + + // simulate separate verification repo/table + for (const user of this.repo.items) { + const verification = user.verifications.find( + (v) => v.id === verificationId, + ); + if (verification) { + identityId = verification.identityId; + break; + } + } + if (identityId === null) return null; + + // via the identity id, look through the identity table + for (const identity of this.repo.items) { + if (identity.id === identityId) { + return identity.id; + } + } + return null; + } + + async getIdentityIdFromMagicToken( + magicToken: string, + ): Promise { + let identityId: string | null = null; + + // simulate separate verification repo/table + for (const identity of this.repo.items) { + const verification = identity.verifications.find( + (v) => v.magicToken === magicToken, + ); + if (verification) { + identityId = verification.identityId; + break; + } + } + + return identityId; + } +} diff --git a/src/modules/auth/infrastructure/persistence/fakes/auth.in-memory.repo.ts b/src/modules/auth/infrastructure/persistence/fakes/auth.in-memory.repo.ts new file mode 100644 index 0000000..4f9f039 --- /dev/null +++ b/src/modules/auth/infrastructure/persistence/fakes/auth.in-memory.repo.ts @@ -0,0 +1,7 @@ +import type { AuthIdentityEntity } from "@/modules/auth/domain/auth-identity.entity.js"; +import type { IAuthIdentityRepository } from "@/modules/auth/domain/auth-identity.repo.js"; +import { InMemoryRepository } from "@/shared/infrastructure/persistence/fakes/InMemoryRepository.js"; + +export class AuthIdentityInMemoryRepository + extends InMemoryRepository + implements IAuthIdentityRepository {} diff --git a/src/modules/auth/use-cases/user-signup.ts b/src/modules/auth/use-cases/user-signup.ts deleted file mode 100644 index 2f0bb28..0000000 --- a/src/modules/auth/use-cases/user-signup.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 { - constructor( - @inject(UsersDomain.IUserRepository) - private readonly usersRepository: IUsersRepository, - @inject(SharedDomain.ICryptoService) - private readonly cryptoService: ICryptoService, - ) {} - - async execute(dto: UserSignupDTO): Promise { - 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); - } -} diff --git a/src/modules/hello-world/infrastructure/http/hello-world.routes.ts b/src/modules/hello-world/infrastructure/http/hello-world.routes.ts index bfff21d..abccab9 100644 --- a/src/modules/hello-world/infrastructure/http/hello-world.routes.ts +++ b/src/modules/hello-world/infrastructure/http/hello-world.routes.ts @@ -17,13 +17,13 @@ const router = Router(); * type: string */ router.get("/", (req, res) => { - const { session, currentUser } = req; + const { session, currentIdentity } = req; - if (!session || !currentUser) { + if (!session || !currentIdentity) { return res.send("Hello world! You are not logged in."); } - res.send(`Hello ${currentUser.email}`); + res.send(`Hello ${currentIdentity.email}`); }); export default router; diff --git a/src/modules/user/domain/user.symbols.ts b/src/modules/user/domain/user.symbols.ts new file mode 100644 index 0000000..5bc3a0f --- /dev/null +++ b/src/modules/user/domain/user.symbols.ts @@ -0,0 +1 @@ +export const UserDomain = {}; diff --git a/src/modules/user/infrastructure/di/user.di.ts b/src/modules/user/infrastructure/di/user.di.ts new file mode 100644 index 0000000..d0bb838 --- /dev/null +++ b/src/modules/user/infrastructure/di/user.di.ts @@ -0,0 +1,3 @@ +import { ContainerModule } from "inversify"; +// biome-ignore lint/correctness/noUnusedFunctionParameters: This bounded context is empty. +export const UserDIModule = new ContainerModule(({ bind }) => {}); diff --git a/src/modules/users/domain/errors/UserNotFound.ts b/src/modules/users/domain/errors/UserNotFound.ts deleted file mode 100644 index c61d3df..0000000 --- a/src/modules/users/domain/errors/UserNotFound.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class UserNotFound extends Error { - constructor() { - super("User not found"); - } -} diff --git a/src/modules/users/domain/users.entity.spec.ts b/src/modules/users/domain/users.entity.spec.ts deleted file mode 100644 index 54cfe29..0000000 --- a/src/modules/users/domain/users.entity.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -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); - }); -}); diff --git a/src/modules/users/domain/users.repo.ts b/src/modules/users/domain/users.repo.ts deleted file mode 100644 index bffe403..0000000 --- a/src/modules/users/domain/users.repo.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { IBaseRepository } from "@/shared/core/IBaseRepository.js"; -import type { UserEntity } from "./users.entity.js"; - -export interface IUsersRepository extends IBaseRepository {} diff --git a/src/modules/users/domain/users.service.ts b/src/modules/users/domain/users.service.ts deleted file mode 100644 index 72f6b53..0000000 --- a/src/modules/users/domain/users.service.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** 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, - ) {} -} diff --git a/src/modules/users/domain/users.symbols.ts b/src/modules/users/domain/users.symbols.ts deleted file mode 100644 index be55ab5..0000000 --- a/src/modules/users/domain/users.symbols.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const UsersDomain = { - IUserRepository: Symbol.for("IUserRepository"), - IUserService: Symbol.for("IUsersService"), -}; diff --git a/src/modules/users/infrastructure/di/users.di.ts b/src/modules/users/infrastructure/di/users.di.ts deleted file mode 100644 index 8b0bfdd..0000000 --- a/src/modules/users/infrastructure/di/users.di.ts +++ /dev/null @@ -1,14 +0,0 @@ -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(UsersDomain.IUserRepository).to(UsersPrismaRepository); - bind(UsersDomain.IUserService).to(UsersService); - - bind(RegisterUserUseCase).toSelf().inTransientScope(); -}); diff --git a/src/modules/users/infrastructure/fakes/users.in-memory.repo.ts b/src/modules/users/infrastructure/fakes/users.in-memory.repo.ts deleted file mode 100644 index 7039f55..0000000 --- a/src/modules/users/infrastructure/fakes/users.in-memory.repo.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 - implements IUsersRepository {} diff --git a/src/modules/users/infrastructure/persistence/users.prisma.repo.ts b/src/modules/users/infrastructure/persistence/users.prisma.repo.ts deleted file mode 100644 index b3da159..0000000 --- a/src/modules/users/infrastructure/persistence/users.prisma.repo.ts +++ /dev/null @@ -1,111 +0,0 @@ -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, -): FilterCriteria { - const result: FilterCriteria = {}; - 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, - ): Promise { - 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 { - const row = await this.prisma.user.findUnique({ where: { id } }); - return row ? toDomain(row) : null; - } - async findAll( - criteria?: FilterCriteria, - paginationOptions?: PaginationOptions, - ): Promise> { - 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 { - 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(); - } -} diff --git a/src/modules/users/use-cases/register-user.spec.ts b/src/modules/users/use-cases/register-user.spec.ts deleted file mode 100644 index d3b08e2..0000000 --- a/src/modules/users/use-cases/register-user.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { expect, test } from "vitest"; - -test("adds 1 + 2 to equal 3", () => { - expect(1 + 2).toBe(3); -}); diff --git a/src/modules/users/use-cases/register-user.ts b/src/modules/users/use-cases/register-user.ts deleted file mode 100644 index c9c8b68..0000000 --- a/src/modules/users/use-cases/register-user.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 { - constructor( - @inject(UsersDomain.IUserRepository) - private readonly userRepo: IUsersRepository, - @inject(SharedDomain.ICryptoService) - private readonly cryptoService: ICryptoService, - ) {} - - async execute(inputDto: RegisterUserDTO): Promise { - 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); - } -} diff --git a/src/shared/application/ports/ITokenService.ts b/src/shared/application/ports/ITokenService.ts index b97c4f1..042ed17 100644 --- a/src/shared/application/ports/ITokenService.ts +++ b/src/shared/application/ports/ITokenService.ts @@ -1,18 +1,21 @@ -import type { UserEntity } from "@/modules/users/domain/users.entity.js"; +import type { AuthIdentityDto } from "@/modules/auth/application/query-service.js"; export interface ISession { - userId: string; + identityId: string; email: string; isVerified: boolean; loginDate: Date; } export interface IRefreshData { - userId: string; + identityId: string; } export interface ITokenService { - generateToken(user: UserEntity): string; - generateRefreshToken(user: UserEntity): string; + generateToken( + identity: AuthIdentityDto, + additionalClaims?: Record, + ): string; + generateRefreshToken(identity: AuthIdentityDto): string; getSession(token: string): ISession | null; validateRefreshToken(refreshToken: string): IRefreshData | null; } diff --git a/src/shared/core/DataOnlyDto.ts b/src/shared/core/DataOnlyDto.ts new file mode 100644 index 0000000..ec60b22 --- /dev/null +++ b/src/shared/core/DataOnlyDto.ts @@ -0,0 +1,7 @@ +/** + * Extracts only the non-function properties from a class type. + */ +/** biome-ignore-all lint/suspicious/noExplicitAny: Any is required to catch all callables. */ +export type DataOnlyDto = { + [K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K]; +}; diff --git a/src/shared/core/IBaseRepository.ts b/src/shared/core/IBaseRepository.ts index e47981e..8189fd5 100644 --- a/src/shared/core/IBaseRepository.ts +++ b/src/shared/core/IBaseRepository.ts @@ -1,3 +1,5 @@ +import type { DataOnlyDto } from "./DataOnlyDto.js"; + export type FilterRange = { from?: T; to?: T; @@ -15,7 +17,7 @@ type FilterValue = T extends string : never; // Exclude types that are not string, number, Date, or boolean export type FilterCriteria = { - [K in keyof T]?: FilterValue | undefined; + [K in keyof DataOnlyDto]?: FilterValue[K]> | undefined; }; export type PaginationOptions = { @@ -23,7 +25,7 @@ export type PaginationOptions = { limit: number; }; -export type WithPagination = { +export type PaginationResult = { data: T[]; total: number; }; @@ -34,7 +36,7 @@ export interface IBaseRepository { findAll( criteria?: FilterCriteria, paginationOptions?: PaginationOptions, - ): Promise>; + ): Promise>; save(entity: T): Promise; generateId(): string; } diff --git a/src/shared/core/IMapper.ts b/src/shared/core/IMapper.ts new file mode 100644 index 0000000..8ed2397 --- /dev/null +++ b/src/shared/core/IMapper.ts @@ -0,0 +1,9 @@ +export interface IMapper { + toDomain(model: TPersistenceModel): TDomainEntity; + toModel(entity: TDomainEntity): TPersistenceModel; +} + +export interface IAsyncMapper { + toDomain(model: TPersistenceModel): Promise; + toModel(entity: TDomainEntity): Promise; +} diff --git a/src/shared/core/LazyRelation.ts b/src/shared/core/LazyRelation.ts new file mode 100644 index 0000000..4ee0351 --- /dev/null +++ b/src/shared/core/LazyRelation.ts @@ -0,0 +1,15 @@ +import type { IBaseRepository } from "./IBaseRepository.js"; + +export class LazyRelation< + TEntity, + TRepository extends IBaseRepository, +> { + constructor( + public readonly id: string, + private readonly source: TRepository, + ) {} + + async get(): Promise { + return this.source.findById(this.id); + } +} diff --git a/src/shared/core/LazyRelationMany.ts b/src/shared/core/LazyRelationMany.ts new file mode 100644 index 0000000..4345589 --- /dev/null +++ b/src/shared/core/LazyRelationMany.ts @@ -0,0 +1,24 @@ +import type { FilterCriteria, IBaseRepository } from "./IBaseRepository.js"; + +type BaseEntity = { + id: string; +}; + +export class LazyRelationMany< + TEntity extends BaseEntity, + TRepository extends IBaseRepository, +> { + constructor( + public readonly id: string, + public readonly targetId: keyof TEntity, + private readonly source: TRepository, + ) {} + + async get(): Promise { + return ( + await this.source.findAll({ + [this.targetId]: this.id, + } as FilterCriteria) + ).data; + } +} diff --git a/src/shared/infrastructure/crypto/JwtService.ts b/src/shared/infrastructure/crypto/JwtService.ts index 2635acd..881ca63 100644 --- a/src/shared/infrastructure/crypto/JwtService.ts +++ b/src/shared/infrastructure/crypto/JwtService.ts @@ -1,7 +1,7 @@ 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 { AuthIdentityDto } from "@/modules/auth/application/query-service.js"; import type { IConfigService } from "@/shared/application/ports/IConfigService.js"; import type { ILogger } from "@/shared/application/ports/ILogger.js"; import type { @@ -34,7 +34,7 @@ export class JwtService implements ITokenService { ) {} generateToken( - user: UserEntity, + identity: AuthIdentityDto, additionalClaims?: Record, ): string { const duration = Number.parseInt( @@ -45,9 +45,9 @@ export class JwtService implements ITokenService { const claims = { iat: Math.ceil(Date.now() / 1000), exp: Math.ceil(Date.now() / 1000) + duration, - sub: user.id, - email: user.email, - isVerified: user.isVerified, + sub: identity.id, + email: identity.email, + isVerified: identity.isVerified, ...additionalClaims, }; const jwtClaims = JWTSessionSchema.parse(claims); @@ -57,7 +57,7 @@ export class JwtService implements ITokenService { }); return token; } - generateRefreshToken(user: UserEntity): string { + generateRefreshToken(identity: AuthIdentityDto): string { const duration = Number.parseInt( this.configService.get("JWT_REFRESH_DURATION"), 10, @@ -66,7 +66,7 @@ export class JwtService implements ITokenService { const claims = { iat: Math.ceil(Date.now() / 1000), exp: Math.ceil(Date.now() / 1000) + duration, - sub: user.id, + sub: identity.id, }; const jwtClaims = JWTRefreshSchema.parse(claims); const token = jwt.sign(jwtClaims, secret, { @@ -89,7 +89,7 @@ export class JwtService implements ITokenService { return null; } const session = { - userId: decodedToken.sub, + identityId: decodedToken.sub, email: decodedToken.email, isVerified: decodedToken.isVerified, loginDate: new Date(decodedToken.iat * 1000), @@ -123,7 +123,7 @@ export class JwtService implements ITokenService { return null; } const refreshData = { - userId: decodedToken.sub, + identityId: decodedToken.sub, }; return refreshData; } catch (error) { diff --git a/src/shared/infrastructure/di/Container.ts b/src/shared/infrastructure/di/Container.ts index 396ccb6..cf1c37d 100644 --- a/src/shared/infrastructure/di/Container.ts +++ b/src/shared/infrastructure/di/Container.ts @@ -1,12 +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 { UserDIModule } from "@/modules/user/infrastructure/di/user.di.js"; import { SharedDIModule } from "./shared.di.js"; const appContainer = new Container(); appContainer.load(SharedDIModule); appContainer.load(AuthDIModule); -appContainer.load(UsersDIModule); +appContainer.load(UserDIModule); export { appContainer }; diff --git a/src/shared/infrastructure/http/middlewares/attachSession.ts b/src/shared/infrastructure/http/middlewares/attachSession.ts index 2e7c709..e8413b0 100644 --- a/src/shared/infrastructure/http/middlewares/attachSession.ts +++ b/src/shared/infrastructure/http/middlewares/attachSession.ts @@ -1,6 +1,6 @@ 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 { IAuthQueryService } from "@/modules/auth/application/query-service.js"; +import { AuthDomain } from "@/modules/auth/domain/auth.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"; @@ -13,8 +13,8 @@ export const attachSession = async ( const tokenService = appContainer.get( SharedDomain.ITokenService, ); - const userRepo = appContainer.get( - UsersDomain.IUserRepository, + const authQueryService = appContainer.get( + AuthDomain.IAuthIdentityQueryService, ); const token = req.cookies.token; if (!token) { @@ -26,12 +26,12 @@ export const attachSession = async ( next(); return; } - const currentUser = await userRepo.findOne({ id: session.userId }); - if (!currentUser) { + const currentIdentity = await authQueryService.findById(session.identityId); + if (!currentIdentity) { next(); return; } req.session = session; - req.currentUser = currentUser; + req.currentIdentity = currentIdentity; next(); }; diff --git a/src/shared/infrastructure/http/request.ts b/src/shared/infrastructure/http/request.ts index b54ad1c..501fc26 100644 --- a/src/shared/infrastructure/http/request.ts +++ b/src/shared/infrastructure/http/request.ts @@ -1,9 +1,9 @@ -import type { UserEntity } from "@/modules/users/domain/users.entity.js"; +import type { AuthIdentityDto } from "@/modules/auth/application/query-service.js"; import type { ISession } from "@/shared/application/ports/ITokenService.js"; declare module "express-serve-static-core" { interface Request { session?: ISession; - currentUser?: UserEntity; + currentIdentity?: AuthIdentityDto; } } diff --git a/src/shared/infrastructure/logger/ConsoleLogger.ts b/src/shared/infrastructure/logger/ConsoleLogger.ts index b983e93..574fc17 100644 --- a/src/shared/infrastructure/logger/ConsoleLogger.ts +++ b/src/shared/infrastructure/logger/ConsoleLogger.ts @@ -1,4 +1,5 @@ import type { + ErrorLogMessage, ILogger, LogMessage, } from "@/shared/application/ports/ILogger.js"; @@ -20,8 +21,9 @@ export class ConsoleLogger implements ILogger { console.log(messageBuilder(message)); } - error(message: LogMessage): void { + error(message: ErrorLogMessage): void { console.error(messageBuilder(message)); + console.error(message.error); } warn(message: LogMessage): void { diff --git a/src/shared/infrastructure/persistence/fakes/InMemoryRepository.spec.ts b/src/shared/infrastructure/persistence/fakes/InMemoryRepository.spec.ts index 3e64ca9..2e5a8f9 100644 --- a/src/shared/infrastructure/persistence/fakes/InMemoryRepository.spec.ts +++ b/src/shared/infrastructure/persistence/fakes/InMemoryRepository.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import { InMemoryRepository } from "./InMemoryRepository.js"; type TestEntity = { @@ -18,385 +18,479 @@ describe("InMemoryRepository (Generic) - Comprehensive Tests", () => { 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(), - }; + test("Persistence Operations - 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); + 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(); - }); + expect(found).toEqual(entity); }); - 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(), - }); + test("Persistence Operations - 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(), + }; - const found = await repo.findOne({ name: "Unique" }); - expect(found).toBeDefined(); - expect(found?.id).toBe("1"); - }); + await repo.save(entity); - it("should return null if findOne matches nothing", async () => { - const found = await repo.findOne({ name: "NonExistent" }); - expect(found).toBeNull(); - }); + // 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); }); - 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); - }); + test("Persistence Operations - should return null if finding by non-existent ID", async () => { + const found = await repo.findById("non-existent-id"); + expect(found).toBeNull(); }); - 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(), - }); + test("Query Operations - 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(), }); - 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); - }); + const found = await repo.findOne({ name: "Unique" }); + expect(found).toBeDefined(); + expect(found?.id).toBe("1"); }); - describe("Range Filtering - Date", () => { + test("Query Operations - should return null if findOne matches nothing", async () => { + const found = await repo.findOne({ name: "NonExistent" }); + expect(found).toBeNull(); + }); + + test("Exact Match Filtering - 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"); + }); + + test("Exact Match Filtering - 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); + }); + + test("Exact Match Filtering - 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); + }); + + test("Exact Match Filtering - 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); + }); + + test("Exact Match Filtering - 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); + }); + + test("Exact Match Filtering - 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); + }); + + test("Range Filtering - Number - should filter by number range (from only - tail case)", 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(), + }); + + // 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]); + }); + + test("Range Filtering - Number - should filter by number range (to only - head case)", 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(), + }); + + // 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]); + }); + + test("Range Filtering - Number - should filter by number range (from and to - middle case)", 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(), + }); + + // 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); + }); + + test("Range Filtering - Date - should filter by date range (from only - tail case)", async () => { 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, - }); + 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 - }); + // 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(), + ]); }); - 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")}`), - }); - } + test("Range Filtering - Date - should filter by date range (to only - head case)", async () => { + 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"); + + 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 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"); - }); + // 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(), + ]); }); - describe("Utility Operations", () => { - it("should generate unique IDs", () => { - const ids = new Set(); - for (let i = 0; i < 1000; i++) { - ids.add(repo.generateId()); - } - expect(ids.size).toBe(1000); + test("Range Filtering - Date - should filter by date range (from and to - middle case)", async () => { + 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"); + + 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, + }); + + // d1 <= date <= d2 + const result = await repo.findAll({ createdAt: { from: d1, to: d2 } }); + expect(result.data).toHaveLength(2); // First, Second + expect(result.data.at(0)?.id).toBe("1"); + expect(result.data.at(1)?.id).toBe("2"); + }); + + test("Pagination with Filtering - should paginate exact match results", 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")}`), + }); + } + + // 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"); + }); + + test("Pagination with Filtering - should paginate number range results", 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")}`), + }); + } + + // 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]); + }); + + test("Pagination with Filtering - should paginate date range results", 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")}`), + }); + } + + // 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"); + }); + + test("Utility Operations - should generate unique IDs", () => { + const ids = new Set(); + for (let i = 0; i < 1000; i++) { + ids.add(repo.generateId()); + } + expect(ids.size).toBe(1000); }); }); diff --git a/src/shared/infrastructure/persistence/fakes/InMemoryRepository.ts b/src/shared/infrastructure/persistence/fakes/InMemoryRepository.ts index 72048b9..bf65b83 100644 --- a/src/shared/infrastructure/persistence/fakes/InMemoryRepository.ts +++ b/src/shared/infrastructure/persistence/fakes/InMemoryRepository.ts @@ -4,13 +4,13 @@ import type { FilterRange, IBaseRepository, PaginationOptions, - WithPagination, + PaginationResult, } from "@/shared/core/IBaseRepository.js"; export class InMemoryRepository implements IBaseRepository { - protected items: T[] = []; + public items: T[] = []; async save(entity: T): Promise { const index = this.items.findIndex((item) => item.id === entity.id); @@ -34,7 +34,7 @@ export class InMemoryRepository async findAll( criteria?: FilterCriteria, paginationOptions?: PaginationOptions, - ): Promise> { + ): Promise> { let filtered = this.items; if (criteria) { diff --git a/src/shared/infrastructure/persistence/prisma/PrismaClientWrapper.ts b/src/shared/infrastructure/persistence/prisma/PrismaClientWrapper.ts index 922138f..acd12c3 100644 --- a/src/shared/infrastructure/persistence/prisma/PrismaClientWrapper.ts +++ b/src/shared/infrastructure/persistence/prisma/PrismaClientWrapper.ts @@ -1,15 +1,15 @@ 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 { PrismaClient } 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; +export { PrismaClient }; @injectable() export class PrismaClientWrapper { - private readonly client: PrismaClientLib; + private readonly client: PrismaClient; constructor( @inject(SharedDomain.IConfigService) @@ -21,12 +21,12 @@ export class PrismaClientWrapper { connectionString, }); - this.client = new PrismaClientLib({ + this.client = new PrismaClient({ adapter, }); } - getClient(): PrismaClientLib { + getClient(): PrismaClient { return this.client; } diff --git a/src/swagger.ts b/src/swagger.ts index 9530ebb..deb258f 100644 --- a/src/swagger.ts +++ b/src/swagger.ts @@ -4,7 +4,7 @@ const config = { definition: { openapi: "3.0.0", info: { - title: "Express-Starter", + title: "Cedar CMS", version: "1.0.0", }, }, diff --git a/tsconfig.json b/tsconfig.json index 5e3a23c..2b4fab3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -47,6 +47,6 @@ "exclude": [ "prisma.config.ts", "vitest.config.ts", - "dist" + "dist", ] } \ No newline at end of file