diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 00000000..a53b1131 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,19 @@ +# Database Configuration +# Set to 'true' to use in-memory database (no Postgres needed) +# Set to 'false' or remove to use Postgres +USE_IN_MEMORY_DB=true + +# Postgres Configuration (when USE_IN_MEMORY_DB=false) +# DATABASE_URL=postgresql://user:password@localhost:5432/dbname + +# JWT Configuration +# JWT_SECRET=your-secret-key-here +# JWT_EXPIRES_IN=1d + +# Auth Configuration +# Available providers: credentials +AUTH_PROVIDER=credentials + +# Server Configuration +# PORT=3000 +# NODE_ENV=development diff --git a/apps/api/src/modules/auth/application/ports/in/SignInUseCasePort.ts b/apps/api/src/modules/auth/application/ports/in/SignInUseCasePort.ts deleted file mode 100644 index 12207b23..00000000 --- a/apps/api/src/modules/auth/application/ports/in/SignInUseCasePort.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Command } from '@nestjs/cqrs'; - -export interface LoginCommand { - email: string; - password: string; -} - -export interface LoginUserData { - id: string; - email: string; - firstName: string | null; - lastName: string | null; - timezone?: string; - status: 'active' | 'inactive'; - createdAt: string | null | undefined; - updatedAt: string | null | undefined; -} - -export interface LoginResult { - user: LoginUserData; - accessToken: string; - refreshToken: string; -} - -export class SignInUseCasePort extends Command { - constructor( - public readonly email: string, - public readonly password: string - ) { - super(); - } -} diff --git a/apps/api/src/modules/auth/application/ports/in/SignInWithProviderPort.ts b/apps/api/src/modules/auth/application/ports/in/SignInWithProviderPort.ts new file mode 100644 index 00000000..7382975e --- /dev/null +++ b/apps/api/src/modules/auth/application/ports/in/SignInWithProviderPort.ts @@ -0,0 +1,12 @@ +import { UseCase } from '@shared/application/UseCase'; +import type { AuthResult } from '../../../domain/types/AuthResult'; + +export interface SignInWithProviderDto { + email: string; + password: string; +} + +export abstract class SignInWithProviderPort extends UseCase< + SignInWithProviderDto, + AuthResult +> {} diff --git a/apps/api/src/modules/auth/application/ports/in/SignUpWithProviderPort.ts b/apps/api/src/modules/auth/application/ports/in/SignUpWithProviderPort.ts new file mode 100644 index 00000000..b16b5b18 --- /dev/null +++ b/apps/api/src/modules/auth/application/ports/in/SignUpWithProviderPort.ts @@ -0,0 +1,14 @@ +import { UseCase } from '@shared/application/UseCase'; +import type { AuthResult } from '../../../domain/types/AuthResult'; + +export interface SignUpWithProviderDto { + email: string; + password: string; + firstName: string; + lastName: string; +} + +export abstract class SignUpWithProviderPort extends UseCase< + SignUpWithProviderDto, + AuthResult +> {} diff --git a/apps/api/src/modules/auth/application/ports/out/AuthConfigPort.ts b/apps/api/src/modules/auth/application/ports/out/AuthConfigPort.ts new file mode 100644 index 00000000..b5d80205 --- /dev/null +++ b/apps/api/src/modules/auth/application/ports/out/AuthConfigPort.ts @@ -0,0 +1,5 @@ +import type { ProviderType } from '../../../domain/types/ProviderType'; + +export abstract class AuthConfigPort { + abstract getProvider(): ProviderType; +} diff --git a/apps/api/src/modules/auth/application/ports/out/AuthProviderFactoryPort.ts b/apps/api/src/modules/auth/application/ports/out/AuthProviderFactoryPort.ts new file mode 100644 index 00000000..f599440f --- /dev/null +++ b/apps/api/src/modules/auth/application/ports/out/AuthProviderFactoryPort.ts @@ -0,0 +1,6 @@ +import type { ProviderType } from '../../../domain/types/ProviderType'; +import type { AuthProviderPort } from './AuthProviderPort'; + +export abstract class AuthProviderFactoryPort { + abstract getProvider(providerType: ProviderType): AuthProviderPort; +} diff --git a/apps/api/src/modules/auth/application/ports/out/AuthProviderPort.ts b/apps/api/src/modules/auth/application/ports/out/AuthProviderPort.ts new file mode 100644 index 00000000..c94c33f1 --- /dev/null +++ b/apps/api/src/modules/auth/application/ports/out/AuthProviderPort.ts @@ -0,0 +1,20 @@ +import type { AuthResult } from '../../../domain/types/AuthResult'; +import type { ProviderType } from '../../../domain/types/ProviderType'; + +export interface SignInData { + email: string; + password: string; +} + +export interface SignUpData { + email: string; + password: string; + firstName: string; + lastName: string; +} + +export abstract class AuthProviderPort { + abstract getProviderType(): ProviderType; + abstract signIn(data: SignInData): Promise; + abstract signUp(data: SignUpData): Promise; +} diff --git a/apps/api/src/modules/auth/application/ports/out/GetUserByEmail.ts b/apps/api/src/modules/auth/application/ports/out/GetUserByEmail.ts deleted file mode 100644 index 46b2e951..00000000 --- a/apps/api/src/modules/auth/application/ports/out/GetUserByEmail.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Command } from '@nestjs/cqrs'; -import { AuthUser } from '../../../domain/entities/AuthUser'; - -export interface GetUserByEmailCommand { - email: string; - password: string; -} - -export class GetUserByEmailPort extends Command { - constructor( - public readonly email: string, - public readonly password: string - ) { - super(); - } -} diff --git a/apps/api/src/modules/auth/application/use-case/SignInUseCase.ts b/apps/api/src/modules/auth/application/use-case/SignInUseCase.ts deleted file mode 100644 index 5fac943f..00000000 --- a/apps/api/src/modules/auth/application/use-case/SignInUseCase.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { UnauthorizedException } from '@nestjs/common'; -import { CommandHandler, ICommandHandler, QueryBus } from '@nestjs/cqrs'; -import { LoginCommand, SignInUseCasePort } from '../ports/in/SignInUseCasePort'; -import { GetUserByEmailPort } from '../ports/out/GetUserByEmail'; -import { PasswordHasher } from '../ports/out/PasswordHasher'; -import { TokenGenerator } from '../ports/out/TokenGenerator'; - -@CommandHandler(SignInUseCasePort) -export class SignInUseCase implements ICommandHandler { - constructor( - private readonly queryBus: QueryBus, - private readonly passwordService: PasswordHasher, - private readonly tokenGenerator: TokenGenerator - ) {} - - async execute(command: LoginCommand) { - const user = await this.queryBus.execute( - new GetUserByEmailPort(command.email, command.password) - ); - - if (!user) { - throw new UnauthorizedException('Invalid credentials'); - } - - if (!user.isActive()) { - throw new UnauthorizedException('Account is disabled'); - } - - if (!user.passwordHash) { - throw new UnauthorizedException('Invalid credentials'); - } - - const isPasswordValid = await this.passwordService.verify( - command.password, - user.passwordHash.getValue() - ); - - if (!isPasswordValid) { - throw new UnauthorizedException('Invalid credentials'); - } - - const payload = { userId: user.id, email: user.email }; - const [accessToken, refreshToken] = await Promise.all([ - this.tokenGenerator.generate(payload), - this.tokenGenerator.generateRefresh(), - ]); - - return { - user: { - id: user.id, - email: user.email, - firstName: null, - lastName: null, - timezone: undefined, - status: user.status.getValue(), - createdAt: undefined, - updatedAt: undefined, - }, - accessToken, - refreshToken, - }; - } -} diff --git a/apps/api/src/modules/auth/application/use-case/SignInWithProviderUseCase.ts b/apps/api/src/modules/auth/application/use-case/SignInWithProviderUseCase.ts new file mode 100644 index 00000000..5a2cd9c7 --- /dev/null +++ b/apps/api/src/modules/auth/application/use-case/SignInWithProviderUseCase.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { + SignInWithProviderPort, + type SignInWithProviderDto, +} from '../ports/in/SignInWithProviderPort'; +import type { AuthResult } from '../../domain/types/AuthResult'; +import { AuthProviderFactoryPort } from '../ports/out/AuthProviderFactoryPort'; +import { AuthConfigPort } from '../ports/out/AuthConfigPort'; + +@Injectable() +export class SignInWithProviderUseCase implements SignInWithProviderPort { + constructor( + private readonly providerFactory: AuthProviderFactoryPort, + private readonly authConfig: AuthConfigPort + ) {} + + async execute(dto: SignInWithProviderDto): Promise { + const providerType = this.authConfig.getProvider(); + const provider = this.providerFactory.getProvider(providerType); + return provider.signIn({ + email: dto.email, + password: dto.password, + }); + } +} diff --git a/apps/api/src/modules/auth/application/use-case/SignUpWithProviderUseCase.ts b/apps/api/src/modules/auth/application/use-case/SignUpWithProviderUseCase.ts new file mode 100644 index 00000000..b9aca69f --- /dev/null +++ b/apps/api/src/modules/auth/application/use-case/SignUpWithProviderUseCase.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { + SignUpWithProviderPort, + type SignUpWithProviderDto, +} from '../ports/in/SignUpWithProviderPort'; +import type { AuthResult } from '../../domain/types/AuthResult'; +import { AuthProviderFactoryPort } from '../ports/out/AuthProviderFactoryPort'; +import { AuthConfigPort } from '../ports/out/AuthConfigPort'; + +@Injectable() +export class SignUpWithProviderUseCase implements SignUpWithProviderPort { + constructor( + private readonly providerFactory: AuthProviderFactoryPort, + private readonly authConfig: AuthConfigPort + ) {} + + async execute(dto: SignUpWithProviderDto): Promise { + const providerType = this.authConfig.getProvider(); + const provider = this.providerFactory.getProvider(providerType); + return provider.signUp({ + email: dto.email, + password: dto.password, + firstName: dto.firstName, + lastName: dto.lastName, + }); + } +} diff --git a/apps/api/src/modules/auth/domain/types/AuthResult.ts b/apps/api/src/modules/auth/domain/types/AuthResult.ts new file mode 100644 index 00000000..1713f82e --- /dev/null +++ b/apps/api/src/modules/auth/domain/types/AuthResult.ts @@ -0,0 +1,14 @@ +export interface AuthResult { + user: { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + timezone?: string; + status: 'active' | 'inactive'; + createdAt: string | null | undefined; + updatedAt: string | null | undefined; + }; + accessToken: string; + refreshToken: string; +} diff --git a/apps/api/src/modules/auth/domain/types/ProviderType.ts b/apps/api/src/modules/auth/domain/types/ProviderType.ts new file mode 100644 index 00000000..012b81ef --- /dev/null +++ b/apps/api/src/modules/auth/domain/types/ProviderType.ts @@ -0,0 +1,5 @@ +export enum ProviderType { + CREDENTIALS = 'credentials', + GOOGLE = 'google', + GITHUB = 'github', +} diff --git a/apps/api/src/modules/auth/infrastructure/config/AuthConfig.ts b/apps/api/src/modules/auth/infrastructure/config/AuthConfig.ts new file mode 100644 index 00000000..1fc4f251 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/config/AuthConfig.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { ProviderType } from '../../domain/types/ProviderType'; +import { AuthConfigPort } from '../../application/ports/out/AuthConfigPort'; + +@Injectable() +export class AuthConfig implements AuthConfigPort { + private readonly provider: ProviderType; + + constructor() { + const configuredProvider = process.env.AUTH_PROVIDER || 'credentials'; + + if (!configuredProvider) { + throw new Error('AUTH_PROVIDER environment variable is required'); + } + + if ( + !Object.values(ProviderType).includes(configuredProvider as ProviderType) + ) { + throw new Error( + `Invalid AUTH_PROVIDER: ${configuredProvider}. Must be one of: ${Object.values(ProviderType).join(', ')}` + ); + } + + this.provider = configuredProvider as ProviderType; + } + + getProvider(): ProviderType { + return this.provider; + } +} diff --git a/apps/api/src/modules/auth/infrastructure/mediators/GetUserByEmail.ts b/apps/api/src/modules/auth/infrastructure/mediators/GetUserByEmail.ts deleted file mode 100644 index 4e80a06b..00000000 --- a/apps/api/src/modules/auth/infrastructure/mediators/GetUserByEmail.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ICommandHandler, QueryBus } from '@nestjs/cqrs'; -import { GetUserByEmailQuery } from '../../../shared/user/GetUserByEmail'; -import { - GetUserByEmailCommand, - GetUserByEmailPort, -} from '../../application/ports/out/GetUserByEmail'; -import { AuthUser } from '../../domain/entities/AuthUser'; -import { UserStatusEnum } from '../../domain/value-objects/UserStatus'; - -export class GetUserByEmailAdapter - implements ICommandHandler -{ - constructor(private readonly queryBus: QueryBus) {} - - async execute(command: GetUserByEmailCommand) { - const result = await this.queryBus.execute( - new GetUserByEmailQuery(command.email) - ); - if (!result) return null; - - return AuthUser.restore({ - id: result.id, - email: result.email, - passwordHash: result.passwordHash, - status: result.status as UserStatusEnum, - }); - } -} diff --git a/apps/api/src/modules/auth/infrastructure/providers/AuthProviderFactoryImpl.ts b/apps/api/src/modules/auth/infrastructure/providers/AuthProviderFactoryImpl.ts new file mode 100644 index 00000000..0a283b16 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/providers/AuthProviderFactoryImpl.ts @@ -0,0 +1,26 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { ProviderType } from '../../domain/types/ProviderType'; +import { AuthProviderPort } from '../../application/ports/out/AuthProviderPort'; +import { AuthProviderFactoryPort } from '../../application/ports/out/AuthProviderFactoryPort'; +import { CredentialsAuthProvider } from './CredentialsAuthProvider'; + +@Injectable() +export class AuthProviderFactoryImpl extends AuthProviderFactoryPort { + constructor(private readonly credentialsProvider: CredentialsAuthProvider) { + super(); + } + + getProvider(providerType: ProviderType): AuthProviderPort { + switch (providerType) { + case ProviderType.CREDENTIALS: + return this.credentialsProvider; + case ProviderType.GOOGLE: + case ProviderType.GITHUB: + throw new BadRequestException( + `Provider ${providerType} is not yet implemented` + ); + default: + throw new BadRequestException(`Unknown provider: ${providerType}`); + } + } +} diff --git a/apps/api/src/modules/auth/infrastructure/providers/CredentialsAuthProvider.ts b/apps/api/src/modules/auth/infrastructure/providers/CredentialsAuthProvider.ts new file mode 100644 index 00000000..9851df40 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/providers/CredentialsAuthProvider.ts @@ -0,0 +1,123 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { QueryBus, CommandBus } from '@nestjs/cqrs'; +import { + AuthProviderPort, + type SignInData, + type SignUpData, +} from '../../application/ports/out/AuthProviderPort'; +import { ProviderType } from '../../domain/types/ProviderType'; +import type { AuthResult } from '../../domain/types/AuthResult'; +import { PasswordHasher } from '../../application/ports/out/PasswordHasher'; +import { TokenGenerator } from '../../application/ports/out/TokenGenerator'; +import { GetUserByEmailQuery } from '../../../shared/user/GetUserByEmail'; +import { CreateUserCommand } from '../../../shared/user/CreateUser'; +import { AuthUser } from '../../domain/entities/AuthUser'; +import { UserStatusEnum } from '../../domain/value-objects/UserStatus'; + +@Injectable() +export class CredentialsAuthProvider extends AuthProviderPort { + constructor( + private readonly queryBus: QueryBus, + private readonly commandBus: CommandBus, + private readonly passwordHasher: PasswordHasher, + private readonly tokenGenerator: TokenGenerator + ) { + super(); + } + + getProviderType(): ProviderType { + return ProviderType.CREDENTIALS; + } + + async signIn(data: SignInData): Promise { + const userDTO = await this.queryBus.execute( + new GetUserByEmailQuery(data.email) + ); + + if (!userDTO) { + throw new UnauthorizedException('Invalid credentials'); + } + + const user = AuthUser.restore({ + id: userDTO.id, + email: userDTO.email, + passwordHash: userDTO.passwordHash, + status: userDTO.status as UserStatusEnum, + }); + + if (!user.isActive()) { + throw new UnauthorizedException('Account is disabled'); + } + + if (!user.passwordHash) { + throw new UnauthorizedException('Invalid credentials'); + } + + const isPasswordValid = await this.passwordHasher.verify( + data.password, + user.passwordHash.getValue() + ); + + if (!isPasswordValid) { + throw new UnauthorizedException('Invalid credentials'); + } + + const payload = { userId: user.id, email: user.email }; + const [accessToken, refreshToken] = await Promise.all([ + this.tokenGenerator.generate(payload), + this.tokenGenerator.generateRefresh(), + ]); + + return { + user: { + id: user.id, + email: user.email, + firstName: null, + lastName: null, + timezone: undefined, + status: user.status.getValue(), + createdAt: undefined, + updatedAt: undefined, + }, + accessToken, + refreshToken, + }; + } + + async signUp(data: SignUpData): Promise { + const hashedPassword = await this.passwordHasher.hash(data.password); + + const createdUser = await this.commandBus.execute( + new CreateUserCommand( + data.email, + hashedPassword, + data.firstName, + data.lastName + ) + ); + + const payload = { + userId: createdUser.id, + email: createdUser.email, + }; + const [accessToken, refreshToken] = await Promise.all([ + this.tokenGenerator.generate(payload), + this.tokenGenerator.generateRefresh(), + ]); + + return { + user: { + id: createdUser.id, + email: createdUser.email, + firstName: createdUser.firstName || null, + lastName: createdUser.lastName || null, + timezone: createdUser.timezone, + status: createdUser.status as UserStatusEnum, + createdAt: null, + updatedAt: null, + }, + accessToken, + refreshToken, + }; + } +} diff --git a/apps/api/src/modules/auth/infrastructure/web/AuthController.ts b/apps/api/src/modules/auth/infrastructure/web/AuthController.ts index 6fd347bc..78daee4d 100644 --- a/apps/api/src/modules/auth/infrastructure/web/AuthController.ts +++ b/apps/api/src/modules/auth/infrastructure/web/AuthController.ts @@ -1,33 +1,82 @@ import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { CommandBus } from '@nestjs/cqrs'; -import { authDTO, type TSignInInput, type TSignInResponse } from '@repo/schemas'; -import { SignInUseCasePort } from '../../application/ports/in/SignInUseCasePort'; -import { ZodApiBody, ZodApiResponse } from '../../../../shared/decorators/zodSwagger'; +import { SignInWithProviderPort } from '../../application/ports/in/SignInWithProviderPort'; +import { SignUpWithProviderPort } from '../../application/ports/in/SignUpWithProviderPort'; +import { + SignInWithProviderRequest, + type TSignInWithProviderRequest, +} from './dto/SignInWithProviderRequest'; +import { + SignUpWithProviderRequest, + type TSignUpWithProviderRequest, +} from './dto/SignUpWithProviderRequest'; +import { + authWithProviderResponseSchema, + type TAuthWithProviderResponse, +} from './dto/AuthWithProviderResponse'; +import { ZodApiBody, ZodApiResponse } from '@shared/decorators/zodSwagger'; @ApiTags('auth') @Controller('auth') export class AuthController { - constructor(private readonly commandBus: CommandBus) {} + constructor( + private readonly signInUseCase: SignInWithProviderPort, + private readonly signUpUseCase: SignUpWithProviderPort, + ) {} - @Post('/sign-in') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'User sign in' }) - @ZodApiBody(authDTO.signInInput) - @ZodApiResponse(200, authDTO.signInResponse, 'SignIn successful') - @ApiResponse({ status: 401, description: 'Invalid credentials' }) - async signIn(@Body() dto: TSignInInput): Promise { - const result = await this.commandBus.execute( - new SignInUseCasePort(dto.email, dto.password), - ); + @Post('/sign-in') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'User sign in', + description: + 'Authenticate a user with the configured authentication provider. Returns user data and authentication tokens.', + }) + @ZodApiBody(SignInWithProviderRequest.schema) + @ZodApiResponse( + 200, + authWithProviderResponseSchema, + 'SignIn successful - returns user and tokens', + ) + @ApiResponse({ status: 401, description: 'Invalid credentials' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + async signIn( + @Body() dto: TSignInWithProviderRequest, + ): Promise { + const result = await this.signInUseCase.execute( + SignInWithProviderRequest.toDto(dto), + ); - return { - success: true, - data: { - user: result.user, - accessToken: result.accessToken, - refreshToken: result.refreshToken, - }, - }; - } + return { + success: true, + data: result, + }; + } + + @Post('/sign-up') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'User sign up', + description: + 'Register a new user with the configured authentication provider. Returns user data and authentication tokens.', + }) + @ZodApiBody(SignUpWithProviderRequest.schema) + @ZodApiResponse( + 201, + authWithProviderResponseSchema, + 'SignUp successful - returns user and tokens', + ) + @ApiResponse({ status: 409, description: 'User already exists' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + async signUp( + @Body() dto: TSignUpWithProviderRequest, + ): Promise { + const result = await this.signUpUseCase.execute( + SignUpWithProviderRequest.toDto(dto), + ); + + return { + success: true, + data: result, + }; + } } diff --git a/apps/api/src/modules/auth/infrastructure/web/dto/AuthWithProviderResponse.ts b/apps/api/src/modules/auth/infrastructure/web/dto/AuthWithProviderResponse.ts new file mode 100644 index 00000000..8ede92de --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/web/dto/AuthWithProviderResponse.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +export const authWithProviderResponseSchema = z.object({ + success: z.literal(true), + data: z.object({ + user: z.object({ + id: z.string(), + email: z.string(), + firstName: z.string().nullable(), + lastName: z.string().nullable(), + timezone: z.string().optional(), + status: z.enum(['active', 'inactive']), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), + }), + accessToken: z.string(), + refreshToken: z.string(), + }), +}); + +export type TAuthWithProviderResponse = z.infer< + typeof authWithProviderResponseSchema +>; diff --git a/apps/api/src/modules/auth/infrastructure/web/dto/SignInWithProviderRequest.ts b/apps/api/src/modules/auth/infrastructure/web/dto/SignInWithProviderRequest.ts new file mode 100644 index 00000000..b4b3f0b3 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/web/dto/SignInWithProviderRequest.ts @@ -0,0 +1,21 @@ +import { authDTO } from '@repo/schemas'; +import { z } from 'zod'; +import { createZodDto } from '@shared/utils/createZodDto'; +import type { SignInWithProviderDto } from '../../../application/ports/in/SignInWithProviderPort'; + +const signInWithProviderSchema = authDTO.signInInput; + +export type TSignInWithProviderRequest = z.infer< + typeof signInWithProviderSchema +>; + +export class SignInWithProviderRequest extends createZodDto( + signInWithProviderSchema, +) { + static toDto(req: TSignInWithProviderRequest): SignInWithProviderDto { + return { + email: req.email, + password: req.password, + }; + } +} diff --git a/apps/api/src/modules/auth/infrastructure/web/dto/SignUpWithProviderRequest.ts b/apps/api/src/modules/auth/infrastructure/web/dto/SignUpWithProviderRequest.ts new file mode 100644 index 00000000..94076bdb --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/web/dto/SignUpWithProviderRequest.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { createZodDto } from '@shared/utils/createZodDto'; +import type { SignUpWithProviderDto } from '../../../application/ports/in/SignUpWithProviderPort'; + +const signUpWithProviderSchema = z.object({ + email: z.email(), + password: z.string().min(8), + firstName: z.string().min(1), + lastName: z.string().min(1), +}); + +export type TSignUpWithProviderRequest = z.infer< + typeof signUpWithProviderSchema +>; + +export class SignUpWithProviderRequest extends createZodDto( + signUpWithProviderSchema +) { + static toDto(req: TSignUpWithProviderRequest): SignUpWithProviderDto { + return { + email: req.email, + password: req.password, + firstName: req.firstName, + lastName: req.lastName, + }; + } +} diff --git a/apps/api/src/modules/auth/module.ts b/apps/api/src/modules/auth/module.ts index 419043be..23e86e78 100644 --- a/apps/api/src/modules/auth/module.ts +++ b/apps/api/src/modules/auth/module.ts @@ -1,21 +1,40 @@ import { Module } from '@nestjs/common'; -import { SignInUseCase } from './application/use-case/SignInUseCase'; +import { CqrsModule } from '@nestjs/cqrs'; import { TokenGenerator } from './application/ports/out/TokenGenerator'; import { PasswordHasher } from './application/ports/out/PasswordHasher'; -import { GetUserByEmailPort } from './application/ports/out/GetUserByEmail'; import { AuthController } from './infrastructure/web/AuthController'; import { JwtTokenGenerator } from './infrastructure/security/JwtTokenGenerator'; import { PasswordHasherAdapter } from './infrastructure/security/PasswordHasher'; -import { GetUserByEmailAdapter } from './infrastructure/mediators/GetUserByEmail'; +import { SignInWithProviderPort } from './application/ports/in/SignInWithProviderPort'; +import { SignUpWithProviderPort } from './application/ports/in/SignUpWithProviderPort'; +import { SignInWithProviderUseCase } from './application/use-case/SignInWithProviderUseCase'; +import { SignUpWithProviderUseCase } from './application/use-case/SignUpWithProviderUseCase'; +import { AuthProviderFactoryPort } from './application/ports/out/AuthProviderFactoryPort'; +import { AuthProviderFactoryImpl } from './infrastructure/providers/AuthProviderFactoryImpl'; +import { CredentialsAuthProvider } from './infrastructure/providers/CredentialsAuthProvider'; +import { AuthConfigPort } from './application/ports/out/AuthConfigPort'; +import { AuthConfig } from './infrastructure/config/AuthConfig'; @Module({ - imports: [], - providers: [ - SignInUseCase, - { provide: GetUserByEmailPort, useClass: GetUserByEmailAdapter }, - { provide: TokenGenerator, useClass: JwtTokenGenerator }, - { provide: PasswordHasher, useClass: PasswordHasherAdapter }, - ], - controllers: [AuthController], + imports: [CqrsModule], + providers: [ + { provide: AuthConfigPort, useClass: AuthConfig }, + { provide: TokenGenerator, useClass: JwtTokenGenerator }, + { provide: PasswordHasher, useClass: PasswordHasherAdapter }, + { + provide: SignInWithProviderPort, + useClass: SignInWithProviderUseCase, + }, + { + provide: SignUpWithProviderPort, + useClass: SignUpWithProviderUseCase, + }, + { + provide: AuthProviderFactoryPort, + useClass: AuthProviderFactoryImpl, + }, + CredentialsAuthProvider, + ], + controllers: [AuthController], }) export class AuthModule {} diff --git a/apps/api/src/modules/shared/user/CreateUser.ts b/apps/api/src/modules/shared/user/CreateUser.ts new file mode 100644 index 00000000..b1c8add4 --- /dev/null +++ b/apps/api/src/modules/shared/user/CreateUser.ts @@ -0,0 +1,21 @@ +import { Command } from '@nestjs/cqrs'; + +export class CreateUserCommand extends Command { + constructor( + public readonly email: string, + public readonly password: string, + public readonly firstName?: string, + public readonly lastName?: string + ) { + super(); + } +} + +export interface CreateUserResultDTO { + id: string; + email: string; + firstName?: string; + lastName?: string; + timezone?: string; + status: string; +} diff --git a/apps/api/src/modules/user/infrastructure/services/CreateUserService.ts b/apps/api/src/modules/user/infrastructure/services/CreateUserService.ts new file mode 100644 index 00000000..6e6c89df --- /dev/null +++ b/apps/api/src/modules/user/infrastructure/services/CreateUserService.ts @@ -0,0 +1,44 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { ConflictException } from '@nestjs/common'; +import { CreateUserCommand } from '../../../shared/user/CreateUser'; +import { UserRepository } from '../../application/ports/out/UserRepository'; +import { Email } from '../../domain/value-objects/Email'; +import { UserStatus } from '../../domain/value-objects/UserStatus'; +import { NewUser } from '../../domain/NewUser'; +import { GetUserByEmailPort } from '../../application/ports/in/GetUserByEmailPort'; + +@CommandHandler(CreateUserCommand) +export class CreateUserService implements ICommandHandler { + constructor( + private readonly getUserByEmail: GetUserByEmailPort, + private readonly userRepository: UserRepository + ) {} + + async execute(command: CreateUserCommand) { + const email = Email.create(command.email); + + const existing = await this.getUserByEmail.execute(email.value); + if (existing) { + throw new ConflictException('User already exists'); + } + + const newUser = NewUser.create({ + email, + passwordHash: command.password, + firstName: command.firstName || null, + lastName: command.lastName || null, + status: UserStatus.active(), + }); + + const createdUser = await this.userRepository.create(newUser); + + return { + id: createdUser.id.value, + email: createdUser.email.value, + firstName: createdUser.firstName ?? undefined, + lastName: createdUser.lastName ?? undefined, + timezone: createdUser.timezone ?? undefined, + status: createdUser.status.getValue(), + }; + } +} diff --git a/apps/api/src/modules/user/module.ts b/apps/api/src/modules/user/module.ts index f624a8d8..cd026646 100644 --- a/apps/api/src/modules/user/module.ts +++ b/apps/api/src/modules/user/module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; import { CreateUserPort } from './application/ports/in/CreateUserPort'; import { UserRepository } from './application/ports/out/UserRepository'; import { CreateUserUseCase } from './application/use-cases/CreateUserUseCase'; @@ -7,17 +8,21 @@ import { UserController } from './infrastructure/web/UserController'; import { GetUserByEmailUseCase } from './application/use-cases/GetUserByEmailUseCase'; import { GetUserByEmailPort } from './application/ports/in/GetUserByEmailPort'; import { GetUserByEmailService } from './infrastructure/services/GetUserByEmailService'; - -console.log(GetUserByEmailService); +import { CreateUserService } from './infrastructure/services/CreateUserService'; @Module({ + imports: [CqrsModule], providers: [ GetUserByEmailService, + CreateUserService, { provide: CreateUserPort, useClass: CreateUserUseCase }, - { provide: UserRepository, useClass: UserRepositoryAdapter }, + { + provide: UserRepository, + useClass: UserRepositoryAdapter, + }, { provide: GetUserByEmailPort, useClass: GetUserByEmailUseCase }, ], controllers: [UserController], - exports: [CreateUserPort, GetUserByEmailPort], + exports: [CreateUserPort, GetUserByEmailPort, UserRepository], }) export class UsersModule {} diff --git a/apps/api/src/shared/application/UseCase.ts b/apps/api/src/shared/application/UseCase.ts new file mode 100644 index 00000000..263ecfc2 --- /dev/null +++ b/apps/api/src/shared/application/UseCase.ts @@ -0,0 +1,3 @@ +export abstract class UseCase { + abstract execute(input: TInput): Promise; +} diff --git a/apps/api/src/test/auth/sign-in.test.ts b/apps/api/src/test/auth/sign-in.test.ts new file mode 100644 index 00000000..a9b7dcd3 --- /dev/null +++ b/apps/api/src/test/auth/sign-in.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SignInWithProviderUseCase } from '../../modules/auth/application/use-case/SignInWithProviderUseCase'; +import { AuthProviderFactoryPort } from '../../modules/auth/application/ports/out/AuthProviderFactoryPort'; +import { AuthConfig } from '../../modules/auth/infrastructure/config/AuthConfig'; +import { AuthProviderPort } from '../../modules/auth/application/ports/out/AuthProviderPort'; + +describe('SignInWithProviderUseCase', () => { + let providerFactory: Pick; + let authConfig: Pick; + let mockProvider: Record>; + let useCase: SignInWithProviderUseCase; + + beforeEach(() => { + mockProvider = { + signIn: vi.fn(), + signUp: vi.fn(), + getProviderType: vi.fn(), + }; + + providerFactory = { getProvider: vi.fn().mockReturnValue(mockProvider) }; + authConfig = { getProvider: vi.fn().mockReturnValue('Credentials') }; + + useCase = new SignInWithProviderUseCase( + providerFactory as any, + authConfig as any + ); + }); + + it('should call sign in on the provider', async () => { + mockProvider.signIn.mockResolvedValue({ + accessToken: 'token123', + userId: 'u1', + email: 'a@b.com', + }); + + const result = await useCase.execute({ + email: 'a@b.com', + password: 'password123', + }); + + expect(authConfig.getProvider).toHaveBeenCalled(); + expect(providerFactory.getProvider).toHaveBeenCalledWith('Credentials'); + expect(mockProvider.signIn).toHaveBeenCalledWith({ + email: 'a@b.com', + password: 'password123', + }); + expect(result).toEqual({ + accessToken: 'token123', + userId: 'u1', + email: 'a@b.com', + }); + }); + + it('should throw an error if provider sign in fails', async () => { + mockProvider.signIn.mockRejectedValue(new Error('Invalid credentials')); + + await expect( + useCase.execute({ + email: 'a@b.com', + password: 'wrong_password', + }) + ).rejects.toThrow('Invalid credentials'); + }); +}); diff --git a/apps/api/src/test/auth/sign-up.test.ts b/apps/api/src/test/auth/sign-up.test.ts new file mode 100644 index 00000000..bd8dab30 --- /dev/null +++ b/apps/api/src/test/auth/sign-up.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SignUpWithProviderUseCase } from '../../modules/auth/application/use-case/SignUpWithProviderUseCase'; +import { AuthProviderFactoryPort } from '../../modules/auth/application/ports/out/AuthProviderFactoryPort'; +import { AuthConfig } from '../../modules/auth/infrastructure/config/AuthConfig'; +import { AuthProviderPort } from '../../modules/auth/application/ports/out/AuthProviderPort'; + +describe('SignUpWithProviderUseCase', () => { + let providerFactory: Pick; + let authConfig: Pick; + let mockProvider: Record>; + let useCase: SignUpWithProviderUseCase; + + beforeEach(() => { + mockProvider = { + signIn: vi.fn(), + signUp: vi.fn(), + getProviderType: vi.fn(), + }; + + providerFactory = { getProvider: vi.fn().mockReturnValue(mockProvider) }; + authConfig = { getProvider: vi.fn().mockReturnValue('Credentials') }; + + useCase = new SignUpWithProviderUseCase( + providerFactory as any, + authConfig as any + ); + }); + + it('should call sign up on the provider', async () => { + mockProvider.signUp.mockResolvedValue({ + accessToken: 'token123', + userId: 'u1', + email: 'a@b.com', + }); + + const result = await useCase.execute({ + email: 'a@b.com', + password: 'password123', + firstName: 'John', + lastName: 'Doe', + }); + + expect(authConfig.getProvider).toHaveBeenCalled(); + expect(providerFactory.getProvider).toHaveBeenCalledWith('Credentials'); + expect(mockProvider.signUp).toHaveBeenCalledWith({ + email: 'a@b.com', + password: 'password123', + firstName: 'John', + lastName: 'Doe', + }); + expect(result).toEqual({ + accessToken: 'token123', + userId: 'u1', + email: 'a@b.com', + }); + }); + + it('should throw an error if provider sign up fails due to existing user', async () => { + mockProvider.signUp.mockRejectedValue(new Error('User already exists')); + + await expect( + useCase.execute({ + email: 'a@b.com', + password: 'password123', + firstName: 'John', + lastName: 'Doe', + }) + ).rejects.toThrow('User already exists'); + }); +}); diff --git a/apps/api/src/test/users/users.test.ts b/apps/api/src/test/users/users.test.ts index 5695643d..8870e1a6 100644 --- a/apps/api/src/test/users/users.test.ts +++ b/apps/api/src/test/users/users.test.ts @@ -1,98 +1,125 @@ -// sign-in.usecase.spec.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { UnauthorizedException } from '@nestjs/common'; -import { QueryBus } from '@nestjs/cqrs'; -import { SignInUseCase } from '../../modules/auth/application/use-case/SignInUseCase'; +import { ConflictException } from '@nestjs/common'; +import { CreateUserService } from '../../modules/user/infrastructure/services/CreateUserService'; +import { GetUserByEmailPort } from '../../modules/user/application/ports/in/GetUserByEmailPort'; +import { UserRepository } from '../../modules/user/application/ports/out/UserRepository'; -function mockUser(overrides: Partial = {}) { +function mockUserEntity(overrides: Partial = {}) { return { - id: 'u1', - email: 'a@b.com', - roleId: 'r1', - password: { getValue: () => 'hashed' }, - isActive: () => true, + id: { value: 'u1' }, + email: { value: 'a@b.com' }, + firstName: 'John', + lastName: 'Doe', + timezone: 'UTC', + status: { getValue: () => 'ACTIVE' }, ...overrides, }; } -describe('SignInUseCase', () => { - let queryBus: Pick; - let passwordHasher: { verify: ReturnType }; - let tokenGenerator: { generate: ReturnType }; - - let useCase: SignInUseCase; +describe('CreateUserService', () => { + let getUserByEmail: Pick; + let userRepository: Pick; + let service: CreateUserService; beforeEach(() => { - queryBus = { execute: vi.fn() }; - passwordHasher = { verify: vi.fn() }; - tokenGenerator = { generate: vi.fn() }; - - useCase = new SignInUseCase( - queryBus as any, - passwordHasher as any, - tokenGenerator as any + getUserByEmail = { execute: vi.fn() }; + userRepository = { + create: vi.fn(), + findByEmail: vi.fn(), + }; + + service = new CreateUserService( + getUserByEmail as any, + userRepository as any ); }); - it('Sign In: Invalid email', async () => { - (queryBus.execute as any).mockResolvedValue(null); + it('should throw ConflictException if user already exists', async () => { + (getUserByEmail.execute as ReturnType).mockResolvedValue( + mockUserEntity() + ); - await expect( - useCase.execute({ email: 'a@b.com', password: 'pw' }) - ).rejects.toBeInstanceOf(UnauthorizedException); + const command = { + email: 'a@b.com', + password: 'password123', + firstName: 'John', + lastName: 'Doe', + } as any; - expect(queryBus.execute).toHaveBeenCalledTimes(1); - expect(passwordHasher.verify).not.toHaveBeenCalled(); - expect(tokenGenerator.generate).not.toHaveBeenCalled(); + await expect(service.execute(command)).rejects.toBeInstanceOf( + ConflictException + ); + expect(getUserByEmail.execute).toHaveBeenCalledWith('a@b.com'); + expect(userRepository.create).not.toHaveBeenCalled(); }); - it('Sign In: Disabled user', async () => { - (queryBus.execute as any).mockResolvedValue( - mockUser({ isActive: () => false }) + it('should successfully create a new user', async () => { + (getUserByEmail.execute as ReturnType).mockResolvedValue( + null ); - await expect( - useCase.execute({ email: 'a@b.com', password: 'pw' }) - ).rejects.toMatchObject({ message: 'Account is disabled' }); + const createdUser = mockUserEntity(); + (userRepository.create as ReturnType).mockResolvedValue( + createdUser + ); - expect(passwordHasher.verify).not.toHaveBeenCalled(); - }); + const command = { + email: 'a@b.com', + password: 'password123', + firstName: 'John', + lastName: 'Doe', + } as any; - it('Sign In: Invalid password', async () => { - (queryBus.execute as any).mockResolvedValue(mockUser()); - passwordHasher.verify.mockResolvedValue(false); + const result = await service.execute(command); - await expect( - useCase.execute({ email: 'a@b.com', password: 'pw' }) - ).rejects.toMatchObject({ message: 'Invalid credentials' }); + expect(getUserByEmail.execute).toHaveBeenCalledWith('a@b.com'); + expect(userRepository.create).toHaveBeenCalled(); - expect(passwordHasher.verify).toHaveBeenCalledWith('pw', 'hashed'); - expect(tokenGenerator.generate).not.toHaveBeenCalled(); + // Check returned DTO maps correctly + expect(result).toEqual({ + id: 'u1', + email: 'a@b.com', + firstName: 'John', + lastName: 'Doe', + timezone: 'UTC', + status: 'ACTIVE', + }); }); - it('Sign In: Success', async () => { - const user = mockUser(); - (queryBus.execute as any).mockResolvedValue(user); - passwordHasher.verify.mockResolvedValue(true); - tokenGenerator.generate.mockResolvedValue('token123'); + it('should successfully create a new user without first/last names', async () => { + (getUserByEmail.execute as ReturnType).mockResolvedValue( + null + ); - const result = await useCase.execute({ - email: user.email, - password: 'pw', + const createdUser = mockUserEntity({ + firstName: null, + lastName: null, + timezone: null, }); + (userRepository.create as ReturnType).mockResolvedValue( + createdUser + ); - expect(result).toEqual({ - accessToken: 'token123', - userId: user.id, - email: user.email, - }); + const command = { + email: 'a@b.com', + password: 'password123', + firstName: '', + lastName: '', + } as any; - expect(queryBus.execute).toHaveBeenCalledTimes(1); - expect(passwordHasher.verify).toHaveBeenCalledWith('pw', 'hashed'); - expect(tokenGenerator.generate).toHaveBeenCalledWith({ - userId: user.id, - email: user.email, - roleId: user.roleId, + const result = await service.execute(command); + + expect(getUserByEmail.execute).toHaveBeenCalledWith('a@b.com'); + expect(userRepository.create).toHaveBeenCalled(); + + // Check returned DTO maps correctly with undefineds + expect(result).toEqual({ + id: 'u1', + email: 'a@b.com', + firstName: undefined, + lastName: undefined, + timezone: undefined, + status: 'ACTIVE', }); }); }); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index f27d1521..bb755d3d 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -5,7 +5,7 @@ "resolvePackageJsonExports": true, "esModuleInterop": true, "isolatedModules": true, - // "baseUrl": "./", + "baseUrl": "./", "declaration": true, "removeComments": true, "emitDecoratorMetadata": true, @@ -20,10 +20,10 @@ "forceConsistentCasingInFileNames": true, "noImplicitAny": false, "strictBindCallApply": false, - "noFallthroughCasesInSwitch": false - // "paths": { - // "@modules/": ["./src/modules"], - // "@shared/": ["./src/shared"] - // } + "noFallthroughCasesInSwitch": false, + "paths": { + "@shared/*": ["./src/shared/*"], + "@modules/*": ["./src/modules/*"] + } } }