diff --git a/.gitignore b/.gitignore index 37268a71..dd00dcc8 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ lerna-debug.log* # IDE - VSCode .vscode/* +.claude !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json @@ -47,4 +48,4 @@ lerna-debug.log* # Prisma /prisma/.env -/prisma/migrations/dev \ No newline at end of file +/prisma/migrations/dev diff --git a/package.json b/package.json index c8f83e22..ae1ca8a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nestjs-template", - "version": "0.1.0", + "version": "2.0.1", "description": "A comprehensive NestJS template with Clean Architecture, DDD, CQRS, JWT authentication, role-based authorization, email verification, PostgreSQL, and Prisma ORM", "author": "Valerii Checha", "email": "chechavalera@gmail.com", diff --git a/prisma/migrations/20250919080323_init/migration.sql b/prisma/migrations/20250919080323_init/migration.sql new file mode 100644 index 00000000..0beb85d7 --- /dev/null +++ b/prisma/migrations/20250919080323_init/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "File" ( + "id" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "originalName" TEXT NOT NULL, + "path" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "bucket" TEXT NOT NULL, + "userId" TEXT, + "isPublic" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "File_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "File_userId_idx" ON "File"("userId"); + +-- AddForeignKey +ALTER TABLE "File" ADD CONSTRAINT "File_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/seed.ts b/prisma/seed.ts index d67aeb28..aa53df36 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,5 +1,6 @@ import { PrismaClient } from '@prisma/client'; import * as bcrypt from 'bcrypt'; +import { ResourceType, ActionType } from '../src/core/value-objects/resource-action.vo'; // Roles const roles = [ @@ -20,62 +21,80 @@ const permissions = [ { name: 'user:read', description: 'Can read user information', - resource: 'user', - action: 'read', + resource: ResourceType.USER, + action: ActionType.READ, }, { - name: 'user:write', - description: 'Can create and update user information', - resource: 'user', - action: 'write', + name: 'user:create', + description: 'Can create users', + resource: ResourceType.USER, + action: ActionType.CREATE, + }, + { + name: 'user:update', + description: 'Can update user information', + resource: ResourceType.USER, + action: ActionType.UPDATE, }, { name: 'user:delete', description: 'Can delete users', - resource: 'user', - action: 'delete', + resource: ResourceType.USER, + action: ActionType.DELETE, }, { name: 'role:read', description: 'Can read role information', - resource: 'role', - action: 'read', + resource: ResourceType.ROLE, + action: ActionType.READ, + }, + { + name: 'role:create', + description: 'Can create roles', + resource: ResourceType.ROLE, + action: ActionType.CREATE, }, { - name: 'role:write', - description: 'Can create and update roles', - resource: 'role', - action: 'write', + name: 'role:update', + description: 'Can update roles', + resource: ResourceType.ROLE, + action: ActionType.UPDATE, }, { name: 'role:delete', description: 'Can delete roles', - resource: 'role', - action: 'delete', + resource: ResourceType.ROLE, + action: ActionType.DELETE, }, { - name: 'storage:write', + name: 'storage:create', description: 'Can upload files', - resource: 'file', - action: 'write', + resource: ResourceType.STORAGE, + action: ActionType.CREATE, }, { name: 'storage:read', description: 'Can read file information', - resource: 'file', - action: 'read', + resource: ResourceType.STORAGE, + action: ActionType.READ, + }, + { + name: 'storage:update', + description: 'Can update file information', + resource: ResourceType.STORAGE, + action: ActionType.UPDATE, }, { name: 'storage:delete', description: 'Can delete files', - resource: 'file', - action: 'delete', + resource: ResourceType.STORAGE, + action: ActionType.DELETE, }, { - name: 'storage:manage', - description: 'Can update file information', - resource: 'file', - action: 'manage', + name: 'audit:read', + description: 'Can read audit logs', + resource: ResourceType.AUDIT, + action: ActionType.READ, }, ]; @@ -83,17 +102,20 @@ const permissions = [ const rolePermissionsMap = { admin: [ 'user:read', - 'user:write', + 'user:create', + 'user:update', 'user:delete', 'role:read', - 'role:write', + 'role:create', + 'role:update', 'role:delete', - 'storage:write', + 'storage:create', 'storage:read', + 'storage:update', 'storage:delete', - 'storage:manage', + 'audit:read', ], - user: ['user:read', 'storage:manage', 'storage:write', 'storage:read'], + user: ['user:read', 'storage:create', 'storage:read', 'storage:update'], }; // Default admin user diff --git a/src/application/commands/auth/disable-2fa.command.ts b/src/application/commands/auth/disable-2fa.command.ts index 467aa816..8cd206d3 100644 --- a/src/application/commands/auth/disable-2fa.command.ts +++ b/src/application/commands/auth/disable-2fa.command.ts @@ -1,7 +1,7 @@ import { ICommand, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { Injectable } from '@nestjs/common'; import { AuthService } from '@core/services/auth.service'; -import { IUserBaseResponse } from '@application/dtos/responses/user.response'; +import { UserBaseResponse } from '@application/dtos'; import { UserMapper } from '@application/mappers/user.mapper'; export class Disable2FACommand implements ICommand { @@ -11,11 +11,11 @@ export class Disable2FACommand implements ICommand { @Injectable() @CommandHandler(Disable2FACommand) export class Disable2FACommandHandler - implements ICommandHandler + implements ICommandHandler { constructor(private readonly authService: AuthService) {} - async execute(command: Disable2FACommand): Promise { + async execute(command: Disable2FACommand): Promise { const { userId } = command; // Disable 2FA for the user diff --git a/src/application/commands/auth/login.command.spec.ts b/src/application/commands/auth/login.command.spec.ts index 072c8f8f..b2f4fef9 100644 --- a/src/application/commands/auth/login.command.spec.ts +++ b/src/application/commands/auth/login.command.spec.ts @@ -11,7 +11,7 @@ import { Email } from '@core/value-objects/email.vo'; import { FirstName, LastName } from '@core/value-objects/name.vo'; import { Role } from '@core/entities/role.entity'; import { Permission } from '@core/entities/permission.entity'; -import { ResourceAction, ActionType } from '@core/value-objects/resource-action.vo'; +import { ResourceAction, ActionType, ResourceType } from '@core/value-objects/resource-action.vo'; import { I18nService } from 'nestjs-i18n'; import { LoggerService } from '@infrastructure/logger/logger.service'; import { ROLE_REPOSITORY } from '@shared/constants/tokens'; @@ -305,11 +305,11 @@ describe('LoginCommandHandler', () => { // Create roles with permissions for repository responses const userRoleWithPermissions = createRoleWithPermissions(); - const adminResourceAction = new ResourceAction('user', ActionType.WRITE); + const adminResourceAction = new ResourceAction(ResourceType.USER, ActionType.UPDATE); const adminPermission = Permission.fromData({ id: '550e8400-e29b-41d4-a716-446655440004', resourceAction: adminResourceAction, - description: 'Can write user details', + description: 'Can update user details', createdAt: new Date(), updatedAt: new Date(), }); @@ -351,7 +351,7 @@ describe('LoginCommandHandler', () => { // Check that permissions from both roles are included expect(tokenProvider.generateTokens).toHaveBeenCalledWith( user, - expect.arrayContaining(['user:read', 'user:write']), + expect.arrayContaining(['user:read', 'user:update']), true, ); diff --git a/src/application/commands/auth/login.command.ts b/src/application/commands/auth/login.command.ts index 7d297930..59788410 100644 --- a/src/application/commands/auth/login.command.ts +++ b/src/application/commands/auth/login.command.ts @@ -1,6 +1,5 @@ import { ICommand, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { LoginDto } from '@application/dtos/auth/login.dto'; -import { AuthResponse } from '@application/dtos/responses/user.response'; +import { LoginRequest, AuthResponse } from '@application/dtos'; import { UnauthorizedException, Injectable, Inject } from '@nestjs/common'; import { UserService } from '@core/services/user.service'; import { AuthService } from '@core/services/auth.service'; @@ -12,7 +11,7 @@ import { ROLE_REPOSITORY } from '@shared/constants/tokens'; import { LoggerService } from '@infrastructure/logger/logger.service'; export class LoginCommand implements ICommand { - constructor(public readonly loginDto: LoginDto) {} + constructor(public readonly loginDto: LoginRequest) {} } @Injectable() diff --git a/src/application/commands/auth/refresh-token.command.spec.ts b/src/application/commands/auth/refresh-token.command.spec.ts index c8657ae3..8eb245a0 100644 --- a/src/application/commands/auth/refresh-token.command.spec.ts +++ b/src/application/commands/auth/refresh-token.command.spec.ts @@ -15,7 +15,7 @@ import { UserId } from '@core/value-objects/user-id.vo'; import { Token } from '@core/value-objects/token.vo'; import { Role } from '@core/entities/role.entity'; import { Permission } from '@core/entities/permission.entity'; -import { ResourceAction, ActionType } from '@core/value-objects/resource-action.vo'; +import { ResourceAction, ActionType, ResourceType } from '@core/value-objects/resource-action.vo'; import { USER_REPOSITORY, ROLE_REPOSITORY } from '@shared/constants/tokens'; // Mock UUID generation @@ -275,11 +275,11 @@ describe('RefreshTokenCommandHandler', () => { // Create roles with permissions for repository responses const userRoleWithPermissions = createRoleWithPermissions(); - const adminResourceAction = new ResourceAction('user', ActionType.WRITE); + const adminResourceAction = new ResourceAction(ResourceType.USER, ActionType.UPDATE); const adminPermission = Permission.fromData({ id: '550e8400-e29b-41d4-a716-446655440004', resourceAction: adminResourceAction, - description: 'Can write user details', + description: 'Can update user details', createdAt: new Date(), updatedAt: new Date(), }); @@ -316,7 +316,7 @@ describe('RefreshTokenCommandHandler', () => { // Check that JWT was signed with both permissions expect(jwtService.sign).toHaveBeenCalledWith( expect.objectContaining({ - permissions: expect.arrayContaining(['user:read', 'user:write']), + permissions: expect.arrayContaining(['user:read', 'user:update']), }), expect.any(Object), ); diff --git a/src/application/commands/auth/refresh-token.command.ts b/src/application/commands/auth/refresh-token.command.ts index 0caef777..a91670e7 100644 --- a/src/application/commands/auth/refresh-token.command.ts +++ b/src/application/commands/auth/refresh-token.command.ts @@ -1,6 +1,5 @@ import { ICommand, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { RefreshTokenDto } from '@application/dtos/auth/refresh-token.dto'; -import { IAuthRefreshTokenResponse } from '@application/dtos/responses/user.response'; +import { RefreshTokenRequest, RefreshTokenResponse } from '@application/dtos'; import { UnauthorizedException, Injectable, Inject } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; @@ -11,7 +10,7 @@ import { v4 as uuidv4 } from 'uuid'; import { USER_REPOSITORY, ROLE_REPOSITORY } from '@shared/constants/tokens'; export class RefreshTokenCommand implements ICommand { - constructor(public readonly refreshTokenDto: RefreshTokenDto) {} + constructor(public readonly refreshTokenDto: RefreshTokenRequest) {} } @Injectable() @@ -27,7 +26,7 @@ export class RefreshTokenCommandHandler implements ICommandHandler { + async execute(command: RefreshTokenCommand): Promise { const { refreshToken } = command.refreshTokenDto; // Validate refresh token diff --git a/src/application/commands/auth/register-user.command.ts b/src/application/commands/auth/register-user.command.ts index d164e02a..76d5699c 100644 --- a/src/application/commands/auth/register-user.command.ts +++ b/src/application/commands/auth/register-user.command.ts @@ -1,12 +1,11 @@ import { ICommand, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { RegisterDto } from '@application/dtos/auth/register.dto'; -import { IUserBaseResponse } from '@application/dtos/responses/user.response'; +import { RegisterRequest, UserBaseResponse } from '@application/dtos'; import { Injectable } from '@nestjs/common'; import { UserService } from '@core/services/user.service'; import { UserMapper } from '@application/mappers/user.mapper'; export class RegisterUserCommand implements ICommand { - constructor(public readonly registerDto: RegisterDto) {} + constructor(public readonly registerDto: RegisterRequest) {} } @Injectable() @@ -14,7 +13,7 @@ export class RegisterUserCommand implements ICommand { export class RegisterUserCommandHandler implements ICommandHandler { constructor(private readonly userService: UserService) {} - async execute(command: RegisterUserCommand): Promise { + async execute(command: RegisterUserCommand): Promise { const { email, password, firstName, lastName } = command.registerDto; const user = await this.userService.createUser(email, password, firstName, lastName); diff --git a/src/application/commands/auth/request-password-reset.command.ts b/src/application/commands/auth/request-password-reset.command.ts index 19a7a04b..18ccb1d3 100644 --- a/src/application/commands/auth/request-password-reset.command.ts +++ b/src/application/commands/auth/request-password-reset.command.ts @@ -1,12 +1,12 @@ import { ICommand, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { RequestPasswordResetDto } from '@application/dtos/auth/password-reset.dto'; +import { RequestPasswordResetRequest } from '@application/dtos'; import { Injectable } from '@nestjs/common'; import { AuthService } from '@core/services/auth.service'; import { EmailProvider } from '@presentation/modules/auth/providers/email.provider'; import { EntityNotFoundException } from '@core/exceptions/domain-exceptions'; export class RequestPasswordResetCommand implements ICommand { - constructor(public readonly dto: RequestPasswordResetDto) {} + constructor(public readonly dto: RequestPasswordResetRequest) {} } @Injectable() diff --git a/src/application/commands/auth/reset-password.command.ts b/src/application/commands/auth/reset-password.command.ts index 1e2f978f..6a7eadce 100644 --- a/src/application/commands/auth/reset-password.command.ts +++ b/src/application/commands/auth/reset-password.command.ts @@ -1,5 +1,5 @@ import { ICommand, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { ResetPasswordDto } from '@application/dtos/auth/password-reset.dto'; +import { ResetPasswordRequest } from '@application/dtos'; import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common'; import { AuthService } from '@core/services/auth.service'; import { UserService } from '@core/services/user.service'; @@ -10,7 +10,7 @@ import { } from '@core/exceptions/domain-exceptions'; export class ResetPasswordCommand implements ICommand { - constructor(public readonly dto: ResetPasswordDto) {} + constructor(public readonly dto: ResetPasswordRequest) {} } @Injectable() diff --git a/src/application/commands/auth/send-verification-email.command.ts b/src/application/commands/auth/send-verification-email.command.ts index d805ea86..17a55733 100644 --- a/src/application/commands/auth/send-verification-email.command.ts +++ b/src/application/commands/auth/send-verification-email.command.ts @@ -1,11 +1,11 @@ import { ICommand, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { SendVerificationEmailDto } from '@application/dtos/auth/email-verification.dto'; +import { SendVerificationEmailRequest } from '@application/dtos'; import { Injectable } from '@nestjs/common'; import { AuthService } from '@core/services/auth.service'; import { EmailProvider } from '@presentation/modules/auth/providers/email.provider'; export class SendVerificationEmailCommand implements ICommand { - constructor(public readonly dto: SendVerificationEmailDto) {} + constructor(public readonly dto: SendVerificationEmailRequest) {} } @Injectable() diff --git a/src/application/commands/auth/verify-email.command.ts b/src/application/commands/auth/verify-email.command.ts index 984e2afa..9b06a837 100644 --- a/src/application/commands/auth/verify-email.command.ts +++ b/src/application/commands/auth/verify-email.command.ts @@ -1,5 +1,5 @@ import { ICommand, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { VerifyEmailDto } from '@application/dtos/auth/email-verification.dto'; +import { VerifyEmailRequest, AuthTokenResponse } from '@application/dtos'; import { Injectable, Inject, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; @@ -7,18 +7,17 @@ import { v4 as uuidv4 } from 'uuid'; import { AuthService } from '@core/services/auth.service'; import { IUserRepository } from '@core/repositories/user.repository.interface'; import { IRoleRepository } from '@core/repositories/role.repository.interface'; -import { IAuthTokenResponse } from '@application/dtos/responses/user.response'; import { UserMapper } from '@application/mappers/user.mapper'; import { USER_REPOSITORY, ROLE_REPOSITORY } from '@shared/constants/tokens'; export class VerifyEmailCommand implements ICommand { - constructor(public readonly dto: VerifyEmailDto) {} + constructor(public readonly dto: VerifyEmailRequest) {} } @Injectable() @CommandHandler(VerifyEmailCommand) export class VerifyEmailCommandHandler - implements ICommandHandler + implements ICommandHandler { constructor( private readonly authService: AuthService, @@ -30,7 +29,7 @@ export class VerifyEmailCommandHandler private readonly roleRepository: IRoleRepository, ) {} - async execute(command: VerifyEmailCommand): Promise { + async execute(command: VerifyEmailCommand): Promise { const { email, code } = command.dto; // Verify the email code diff --git a/src/application/commands/auth/verify-otp.command.ts b/src/application/commands/auth/verify-otp.command.ts index 427018d3..f0c14a8a 100644 --- a/src/application/commands/auth/verify-otp.command.ts +++ b/src/application/commands/auth/verify-otp.command.ts @@ -1,6 +1,5 @@ import { ICommand, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { VerifyOtpDto } from '@application/dtos/auth/verify-otp.dto'; -import { IAuthTokenResponse } from '@application/dtos/responses/user.response'; +import { VerifyOtpRequest, AuthTokenResponse } from '@application/dtos'; import { UnauthorizedException, Injectable, Inject } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; @@ -13,7 +12,7 @@ import { USER_REPOSITORY } from '@shared/constants/tokens'; export class VerifyOtpCommand implements ICommand { constructor( public readonly userId: string, - public readonly verifyOtpDto: VerifyOtpDto, + public readonly verifyOtpDto: VerifyOtpRequest, ) {} } @@ -28,7 +27,7 @@ export class VerifyOtpCommandHandler implements ICommandHandler { + async execute(command: VerifyOtpCommand): Promise { const { userId, verifyOtpDto } = command; // Verify OTP diff --git a/src/application/commands/role/assign-permission.command.ts b/src/application/commands/role/assign-permission.command.ts index 51c6e949..c35c324e 100644 --- a/src/application/commands/role/assign-permission.command.ts +++ b/src/application/commands/role/assign-permission.command.ts @@ -1,6 +1,6 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { RoleService } from '@core/services/role.service'; -import { RoleDetailResponse } from '@application/dtos/responses/role.response'; +import { RoleDetailResponse } from '@application/dtos'; import { IRoleRepository } from '@core/repositories/role.repository.interface'; import { Inject } from '@nestjs/common'; import { RoleMapper } from '@application/mappers/role.mapper'; diff --git a/src/application/commands/role/create-role.command.ts b/src/application/commands/role/create-role.command.ts index 8d974f90..37723f8d 100644 --- a/src/application/commands/role/create-role.command.ts +++ b/src/application/commands/role/create-role.command.ts @@ -1,7 +1,7 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { Inject } from '@nestjs/common'; import { RoleService } from '@core/services/role.service'; -import { RoleDetailResponse } from '@application/dtos/responses/role.response'; +import { RoleDetailResponse } from '@application/dtos'; import { IRoleRepository } from '@core/repositories/role.repository.interface'; import { RoleMapper } from '@application/mappers/role.mapper'; import { ROLE_REPOSITORY } from '@shared/constants/tokens'; diff --git a/src/application/commands/role/delete-role.command.ts b/src/application/commands/role/delete-role.command.ts index f0840866..7598da27 100644 --- a/src/application/commands/role/delete-role.command.ts +++ b/src/application/commands/role/delete-role.command.ts @@ -2,7 +2,10 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { RoleService } from '@core/services/role.service'; export class DeleteRoleCommand { - constructor(public readonly id: string) {} + constructor( + public readonly id: string, + public readonly deleterId?: string, // ID of the user performing the deletion + ) {} } @CommandHandler(DeleteRoleCommand) @@ -10,8 +13,8 @@ export class DeleteRoleCommandHandler implements ICommandHandler { - const { id } = command; + const { id, deleterId } = command; - return this.roleService.deleteRole(id); + return this.roleService.deleteRole(id, deleterId); } } diff --git a/src/application/commands/role/remove-permission.command.ts b/src/application/commands/role/remove-permission.command.ts index 51c8d46a..88911523 100644 --- a/src/application/commands/role/remove-permission.command.ts +++ b/src/application/commands/role/remove-permission.command.ts @@ -1,6 +1,6 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { RoleService } from '@core/services/role.service'; -import { RoleDetailResponse } from '@application/dtos/responses/role.response'; +import { RoleDetailResponse } from '@application/dtos'; import { IRoleRepository } from '@core/repositories/role.repository.interface'; import { Inject } from '@nestjs/common'; import { RoleMapper } from '@application/mappers/role.mapper'; diff --git a/src/application/commands/role/update-role.command.ts b/src/application/commands/role/update-role.command.ts index 2fdf3f9a..feb13d43 100644 --- a/src/application/commands/role/update-role.command.ts +++ b/src/application/commands/role/update-role.command.ts @@ -1,6 +1,6 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { RoleService } from '@core/services/role.service'; -import { RoleDetailResponse } from '@application/dtos/responses/role.response'; +import { RoleDetailResponse } from '@application/dtos'; import { IRoleRepository } from '@core/repositories/role.repository.interface'; import { Inject } from '@nestjs/common'; import { RoleMapper } from '@application/mappers/role.mapper'; diff --git a/src/application/commands/storage/update-file-access.command.ts b/src/application/commands/storage/update-file-access.command.ts index 2fadd7e6..00121fc7 100644 --- a/src/application/commands/storage/update-file-access.command.ts +++ b/src/application/commands/storage/update-file-access.command.ts @@ -2,7 +2,7 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { NotFoundException, UnauthorizedException } from '@nestjs/common'; import { StorageService } from '@core/services/storage.service'; import { FileMapper } from '../../mappers/file.mapper'; -import { FileResponseDto } from '../../dtos/responses/file.response'; +import { FileResponse } from '@application/dtos'; export class UpdateFileAccessCommand { constructor( @@ -14,14 +14,14 @@ export class UpdateFileAccessCommand { @CommandHandler(UpdateFileAccessCommand) export class UpdateFileAccessCommandHandler - implements ICommandHandler + implements ICommandHandler { constructor( private readonly storageService: StorageService, private readonly fileMapper: FileMapper, ) {} - async execute(command: UpdateFileAccessCommand): Promise { + async execute(command: UpdateFileAccessCommand): Promise { const { fileId, isPublic, userId } = command; const file = await this.storageService.getFileById(fileId); diff --git a/src/application/commands/storage/upload-file.command.ts b/src/application/commands/storage/upload-file.command.ts index 2c1071e6..0bf5f0d7 100644 --- a/src/application/commands/storage/upload-file.command.ts +++ b/src/application/commands/storage/upload-file.command.ts @@ -1,7 +1,7 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { StorageService, IStorageFile } from '@core/services/storage.service'; import { FileMapper } from '../../mappers/file.mapper'; -import { FileResponseDto } from '../../dtos/responses/file.response'; +import { FileResponse } from '@application/dtos'; export class UploadFileCommand { constructor( @@ -11,15 +11,13 @@ export class UploadFileCommand { } @CommandHandler(UploadFileCommand) -export class UploadFileCommandHandler - implements ICommandHandler -{ +export class UploadFileCommandHandler implements ICommandHandler { constructor( private readonly storageService: StorageService, private readonly fileMapper: FileMapper, ) {} - async execute(command: UploadFileCommand): Promise { + async execute(command: UploadFileCommand): Promise { const { file, userId } = command; const fileEntity = await this.storageService.uploadFile(file, userId); diff --git a/src/application/commands/user/activate-user.command.ts b/src/application/commands/user/activate-user.command.ts index 43c58ba0..df5c7c48 100644 --- a/src/application/commands/user/activate-user.command.ts +++ b/src/application/commands/user/activate-user.command.ts @@ -1,6 +1,6 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { UserService } from '@core/services/user.service'; -import { IUserBaseResponse } from '@application/dtos/responses/user.response'; +import { UserBaseResponse } from '@application/dtos'; export class ActivateUserCommand { constructor( @@ -11,11 +11,11 @@ export class ActivateUserCommand { @CommandHandler(ActivateUserCommand) export class ActivateUserCommandHandler - implements ICommandHandler + implements ICommandHandler { constructor(private readonly userService: UserService) {} - async execute(command: ActivateUserCommand): Promise { + async execute(command: ActivateUserCommand): Promise { const { userId, active } = command; let user; diff --git a/src/application/commands/user/assign-role.command.ts b/src/application/commands/user/assign-role.command.ts index bfcefe22..e1d67ae5 100644 --- a/src/application/commands/user/assign-role.command.ts +++ b/src/application/commands/user/assign-role.command.ts @@ -1,25 +1,26 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { UserService } from '@core/services/user.service'; -import { IUserDetailResponse } from '@application/dtos/responses/user.response'; +import { UserDetailResponse } from '@application/dtos'; import { UserMapper } from '@application/mappers/user.mapper'; export class AssignRoleCommand { constructor( public readonly userId: string, public readonly roleId: string, + public readonly assignerId?: string, // ID of the user performing the assignment ) {} } @CommandHandler(AssignRoleCommand) export class AssignRoleCommandHandler - implements ICommandHandler + implements ICommandHandler { constructor(private readonly userService: UserService) {} - async execute(command: AssignRoleCommand): Promise { - const { userId, roleId } = command; + async execute(command: AssignRoleCommand): Promise { + const { userId, roleId, assignerId } = command; - const user = await this.userService.assignRoleToUser(userId, roleId); + const user = await this.userService.assignRoleToUser(userId, roleId, assignerId); // Use the mapper to convert to response DTO return UserMapper.toDetailResponse(user); diff --git a/src/application/commands/user/remove-role.command.ts b/src/application/commands/user/remove-role.command.ts index c56e7823..e4c83feb 100644 --- a/src/application/commands/user/remove-role.command.ts +++ b/src/application/commands/user/remove-role.command.ts @@ -1,6 +1,6 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { UserService } from '@core/services/user.service'; -import { IUserDetailResponse } from '@application/dtos/responses/user.response'; +import { UserDetailResponse } from '@application/dtos'; import { UserMapper } from '@application/mappers/user.mapper'; export class RemoveRoleCommand { @@ -12,11 +12,11 @@ export class RemoveRoleCommand { @CommandHandler(RemoveRoleCommand) export class RemoveRoleCommandHandler - implements ICommandHandler + implements ICommandHandler { constructor(private readonly userService: UserService) {} - async execute(command: RemoveRoleCommand): Promise { + async execute(command: RemoveRoleCommand): Promise { const { userId, roleId } = command; const user = await this.userService.removeRoleFromUser(userId, roleId); diff --git a/src/application/commands/user/update-user.command.ts b/src/application/commands/user/update-user.command.ts index 85450302..0ac810e6 100644 --- a/src/application/commands/user/update-user.command.ts +++ b/src/application/commands/user/update-user.command.ts @@ -1,6 +1,6 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { UserService } from '@core/services/user.service'; -import { IUserBaseResponse } from '@application/dtos/responses/user.response'; +import { UserBaseResponse } from '@application/dtos'; import { UserMapper } from '@application/mappers/user.mapper'; export class UpdateUserCommand { @@ -14,11 +14,11 @@ export class UpdateUserCommand { @CommandHandler(UpdateUserCommand) export class UpdateUserCommandHandler - implements ICommandHandler + implements ICommandHandler { constructor(private readonly userService: UserService) {} - async execute(command: UpdateUserCommand): Promise { + async execute(command: UpdateUserCommand): Promise { const { userId, firstName, lastName, email } = command; const user = await this.userService.updateUserDetails(userId, firstName, lastName, email); diff --git a/src/application/dtos/auth/email-verification.dto.ts b/src/application/dtos/auth/email-verification.dto.ts deleted file mode 100644 index b885f820..00000000 --- a/src/application/dtos/auth/email-verification.dto.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, IsString, Length } from 'class-validator'; - -export class SendVerificationEmailDto { - @ApiProperty({ - description: 'The email address to send verification code to', - example: 'user@example.com', - }) - @IsEmail() - @IsNotEmpty() - email!: string; -} - -export class VerifyEmailDto { - @ApiProperty({ - description: 'The email address to verify', - example: 'user@example.com', - }) - @IsEmail() - @IsNotEmpty() - email!: string; - - @ApiProperty({ - description: 'The verification code', - example: '123456', - }) - @IsString() - @IsNotEmpty() - @Length(6, 6) - code!: string; -} diff --git a/src/application/dtos/auth/login.dto.ts b/src/application/dtos/auth/login.dto.ts deleted file mode 100644 index 04ecf92b..00000000 --- a/src/application/dtos/auth/login.dto.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class LoginDto { - @ApiProperty({ - description: 'User email address', - example: 'user@example.com', - }) - @IsEmail() - @IsNotEmpty() - email!: string; - - @ApiProperty({ - description: 'User password', - example: 'Password123!', - minLength: 8, - }) - @IsString() - @IsNotEmpty() - @MinLength(8) - password!: string; -} diff --git a/src/application/dtos/auth/password-reset.dto.ts b/src/application/dtos/auth/password-reset.dto.ts deleted file mode 100644 index 3c78e8c2..00000000 --- a/src/application/dtos/auth/password-reset.dto.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, IsString, MinLength, Matches } from 'class-validator'; - -export class RequestPasswordResetDto { - @ApiProperty({ - description: 'The email address of the account', - example: 'user@example.com', - }) - @IsEmail() - @IsNotEmpty() - email!: string; -} - -export class ResetPasswordDto { - @ApiProperty({ - description: 'The password reset token received via email', - example: 'e12e3b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c', - }) - @IsString() - @IsNotEmpty() - token!: string; - - @ApiProperty({ - description: 'The new password', - example: 'StrongP@ssw0rd123', - }) - @IsString() - @IsNotEmpty() - @MinLength(8) - @Matches(/((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/, { - message: - 'Password must contain at least 1 uppercase letter, 1 lowercase letter, and 1 number or special character', - }) - newPassword!: string; -} diff --git a/src/application/dtos/auth/refresh-token.dto.ts b/src/application/dtos/auth/refresh-token.dto.ts deleted file mode 100644 index bbc3e4a6..00000000 --- a/src/application/dtos/auth/refresh-token.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IsNotEmpty, IsString } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class RefreshTokenDto { - @ApiProperty({ - description: 'Refresh token', - example: '550e8400-e29b-41d4-a716-446655440000', - }) - @IsString() - @IsNotEmpty() - refreshToken!: string; -} diff --git a/src/application/dtos/auth/register.dto.ts b/src/application/dtos/auth/register.dto.ts deleted file mode 100644 index ffcb7499..00000000 --- a/src/application/dtos/auth/register.dto.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { IsEmail, IsNotEmpty, IsString, MinLength, Matches } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class RegisterDto { - @ApiProperty({ - description: 'User email address', - example: 'user@example.com', - }) - @IsEmail() - @IsNotEmpty() - email!: string; - - @ApiProperty({ - description: 'User password', - example: 'Password123!', - minLength: 8, - }) - @IsString() - @IsNotEmpty() - @MinLength(8) - @Matches( - /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*(),.?":{}|<>_])[A-Za-z\d!@#$%^&*(),.?":{}|<>_]{8,}$/, - { - message: - 'Password must be at least 8 characters long and include at least one uppercase letter, one lowercase letter, one number, and one special character', - }, - ) - password!: string; - - @ApiProperty({ - description: 'User first name', - example: 'John', - }) - @IsString() - @IsNotEmpty() - firstName!: string; - - @ApiProperty({ - description: 'User last name', - example: 'Doe', - }) - @IsString() - @IsNotEmpty() - lastName!: string; -} diff --git a/src/application/dtos/auth/setup-2fa.dto.ts b/src/application/dtos/auth/setup-2fa.dto.ts deleted file mode 100644 index 39d63507..00000000 --- a/src/application/dtos/auth/setup-2fa.dto.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; - -export class Setup2FADto { - @ApiProperty({ - description: 'The user ID', - example: '550e8400-e29b-41d4-a716-446655440000', - }) - @IsString() - @IsNotEmpty() - userId!: string; -} - -export class Verify2FADto { - @ApiProperty({ - description: 'The user ID', - example: '550e8400-e29b-41d4-a716-446655440000', - }) - @IsString() - @IsNotEmpty() - userId!: string; - - @ApiProperty({ - description: 'The 2FA verification token', - example: '123456', - }) - @IsString() - @IsNotEmpty() - token!: string; -} - -export class Disable2FADto { - @ApiProperty({ - description: 'The user ID', - example: '550e8400-e29b-41d4-a716-446655440000', - }) - @IsString() - @IsNotEmpty() - userId!: string; -} - -export class Generate2FADto { - @ApiProperty({ - description: 'The user ID', - example: '550e8400-e29b-41d4-a716-446655440000', - }) - @IsString() - @IsNotEmpty() - userId!: string; -} diff --git a/src/application/dtos/auth/verify-otp.dto.ts b/src/application/dtos/auth/verify-otp.dto.ts deleted file mode 100644 index b500d1e6..00000000 --- a/src/application/dtos/auth/verify-otp.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IsNotEmpty, IsString, Length } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class VerifyOtpDto { - @ApiProperty({ - description: 'One-time password (6 digits)', - example: '123456', - }) - @IsString() - @IsNotEmpty() - @Length(6, 6) - otp!: string; -} diff --git a/src/application/dtos/index.ts b/src/application/dtos/index.ts new file mode 100644 index 00000000..e9046a6b --- /dev/null +++ b/src/application/dtos/index.ts @@ -0,0 +1,3 @@ +export * from './requests'; +export * from './responses'; +export * from './types'; diff --git a/src/application/dtos/requests/auth/email-verification.request.ts b/src/application/dtos/requests/auth/email-verification.request.ts new file mode 100644 index 00000000..6b3175a3 --- /dev/null +++ b/src/application/dtos/requests/auth/email-verification.request.ts @@ -0,0 +1,37 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsString, Length, Matches } from 'class-validator'; + +export class SendVerificationEmailRequest { + @ApiProperty({ + description: 'The email address to send verification code to', + example: 'user@example.com', + format: 'email', + }) + @IsEmail({}, { message: 'Please provide a valid email address' }) + @IsNotEmpty({ message: 'Email is required' }) + email!: string; +} + +export class VerifyEmailRequest { + @ApiProperty({ + description: 'The email address to verify', + example: 'user@example.com', + format: 'email', + }) + @IsEmail({}, { message: 'Please provide a valid email address' }) + @IsNotEmpty({ message: 'Email is required' }) + email!: string; + + @ApiProperty({ + description: 'The 6-digit verification code sent to email', + example: '123456', + minLength: 6, + maxLength: 6, + pattern: '^[0-9]{6}$', + }) + @IsString({ message: 'Verification code must be a string' }) + @IsNotEmpty({ message: 'Verification code is required' }) + @Length(6, 6, { message: 'Verification code must be exactly 6 digits' }) + @Matches(/^[0-9]{6}$/, { message: 'Verification code must contain only 6 digits' }) + code!: string; +} diff --git a/src/application/dtos/requests/auth/index.ts b/src/application/dtos/requests/auth/index.ts new file mode 100644 index 00000000..7c08f19e --- /dev/null +++ b/src/application/dtos/requests/auth/index.ts @@ -0,0 +1,6 @@ +export * from './login.request'; +export * from './register.request'; +export * from './refresh-token.request'; +export * from './verify-otp.request'; +export * from './password-reset.request'; +export * from './email-verification.request'; diff --git a/src/application/dtos/requests/auth/login.request.ts b/src/application/dtos/requests/auth/login.request.ts new file mode 100644 index 00000000..a2695d37 --- /dev/null +++ b/src/application/dtos/requests/auth/login.request.ts @@ -0,0 +1,24 @@ +import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class LoginRequest { + @ApiProperty({ + description: 'User email address', + example: 'user@example.com', + format: 'email', + }) + @IsEmail({}, { message: 'Please provide a valid email address' }) + @IsNotEmpty({ message: 'Email is required' }) + email!: string; + + @ApiProperty({ + description: 'User password', + example: 'Password123!', + minLength: 8, + format: 'password', + }) + @IsString({ message: 'Password must be a string' }) + @IsNotEmpty({ message: 'Password is required' }) + @MinLength(8, { message: 'Password must be at least 8 characters long' }) + password!: string; +} diff --git a/src/application/dtos/requests/auth/password-reset.request.ts b/src/application/dtos/requests/auth/password-reset.request.ts new file mode 100644 index 00000000..c2ea0e1f --- /dev/null +++ b/src/application/dtos/requests/auth/password-reset.request.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsString, MinLength, Matches } from 'class-validator'; + +export class RequestPasswordResetRequest { + @ApiProperty({ + description: 'The email address of the account to reset password for', + example: 'user@example.com', + format: 'email', + }) + @IsEmail({}, { message: 'Please provide a valid email address' }) + @IsNotEmpty({ message: 'Email is required' }) + email!: string; +} + +export class ResetPasswordRequest { + @ApiProperty({ + description: 'The password reset token received via email', + example: 'e12e3b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c', + }) + @IsString({ message: 'Token must be a string' }) + @IsNotEmpty({ message: 'Token is required' }) + token!: string; + + @ApiProperty({ + description: + 'The new password. Must contain at least 8 characters with uppercase, lowercase, number and special character', + example: 'NewPassword123!', + minLength: 8, + format: 'password', + }) + @IsString({ message: 'New password must be a string' }) + @IsNotEmpty({ message: 'New password is required' }) + @MinLength(8, { message: 'New password must be at least 8 characters long' }) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, { + message: + 'New password must contain at least one uppercase letter, one lowercase letter, one number, and one special character', + }) + newPassword!: string; +} diff --git a/src/application/dtos/requests/auth/refresh-token.request.ts b/src/application/dtos/requests/auth/refresh-token.request.ts new file mode 100644 index 00000000..d11bfe00 --- /dev/null +++ b/src/application/dtos/requests/auth/refresh-token.request.ts @@ -0,0 +1,14 @@ +import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class RefreshTokenRequest { + @ApiProperty({ + description: 'Refresh token used to generate new access token', + example: '550e8400-e29b-41d4-a716-446655440000', + format: 'uuid', + }) + @IsString({ message: 'Refresh token must be a string' }) + @IsNotEmpty({ message: 'Refresh token is required' }) + @IsUUID('4', { message: 'Refresh token must be a valid UUID' }) + refreshToken!: string; +} diff --git a/src/application/dtos/requests/auth/register.request.ts b/src/application/dtos/requests/auth/register.request.ts new file mode 100644 index 00000000..9af22bc1 --- /dev/null +++ b/src/application/dtos/requests/auth/register.request.ts @@ -0,0 +1,69 @@ +import { + IsEmail, + IsNotEmpty, + IsString, + MinLength, + Matches, + IsOptional, + IsArray, + IsUUID, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class RegisterRequest { + @ApiProperty({ + description: 'User email address', + example: 'user@example.com', + format: 'email', + }) + @IsEmail({}, { message: 'Please provide a valid email address' }) + @IsNotEmpty({ message: 'Email is required' }) + email!: string; + + @ApiProperty({ + description: + 'User password. Must contain at least 8 characters with uppercase, lowercase, number and special character', + example: 'Password123!', + minLength: 8, + format: 'password', + }) + @IsString({ message: 'Password must be a string' }) + @IsNotEmpty({ message: 'Password is required' }) + @MinLength(8, { message: 'Password must be at least 8 characters long' }) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, { + message: + 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character', + }) + password!: string; + + @ApiProperty({ + description: 'User first name', + example: 'John', + minLength: 1, + maxLength: 50, + }) + @IsString({ message: 'First name must be a string' }) + @IsNotEmpty({ message: 'First name is required' }) + firstName!: string; + + @ApiProperty({ + description: 'User last name', + example: 'Doe', + minLength: 1, + maxLength: 50, + }) + @IsString({ message: 'Last name must be a string' }) + @IsNotEmpty({ message: 'Last name is required' }) + lastName!: string; + + @ApiPropertyOptional({ + description: 'List of role IDs to assign to the user', + example: ['550e8400-e29b-41d4-a716-446655440000'], + type: [String], + isArray: true, + }) + @IsArray({ message: 'Role IDs must be an array' }) + @IsUUID('4', { each: true, message: 'Each role ID must be a valid UUID' }) + @IsOptional() + roleIds?: string[]; +} diff --git a/src/application/dtos/requests/auth/verify-otp.request.ts b/src/application/dtos/requests/auth/verify-otp.request.ts new file mode 100644 index 00000000..3f049936 --- /dev/null +++ b/src/application/dtos/requests/auth/verify-otp.request.ts @@ -0,0 +1,17 @@ +import { IsNotEmpty, IsString, Length, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class VerifyOtpRequest { + @ApiProperty({ + description: 'One-time password (6 digits)', + example: '123456', + minLength: 6, + maxLength: 6, + pattern: '^[0-9]{6}$', + }) + @IsString({ message: 'OTP must be a string' }) + @IsNotEmpty({ message: 'OTP is required' }) + @Length(6, 6, { message: 'OTP must be exactly 6 digits' }) + @Matches(/^[0-9]{6}$/, { message: 'OTP must contain only 6 digits' }) + otp!: string; +} diff --git a/src/application/dtos/requests/index.ts b/src/application/dtos/requests/index.ts new file mode 100644 index 00000000..a1a8c012 --- /dev/null +++ b/src/application/dtos/requests/index.ts @@ -0,0 +1,4 @@ +export * from './auth'; +export * from './user'; +export * from './role'; +export * from './storage'; diff --git a/src/application/dtos/requests/role/create-role.request.ts b/src/application/dtos/requests/role/create-role.request.ts new file mode 100644 index 00000000..a4723247 --- /dev/null +++ b/src/application/dtos/requests/role/create-role.request.ts @@ -0,0 +1,57 @@ +import { + IsNotEmpty, + IsString, + IsOptional, + IsBoolean, + IsArray, + IsUUID, + MinLength, + MaxLength, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateRoleRequest { + @ApiProperty({ + description: 'Role name (unique identifier)', + example: 'admin', + minLength: 3, + maxLength: 50, + }) + @IsString({ message: 'Role name must be a string' }) + @IsNotEmpty({ message: 'Role name is required' }) + @MinLength(3, { message: 'Role name must be at least 3 characters long' }) + @MaxLength(50, { message: 'Role name cannot exceed 50 characters' }) + name!: string; + + @ApiProperty({ + description: 'Role description explaining its purpose and permissions', + example: 'Administrator role with full system access', + minLength: 10, + maxLength: 200, + }) + @IsString({ message: 'Role description must be a string' }) + @IsNotEmpty({ message: 'Role description is required' }) + @MinLength(10, { message: 'Role description must be at least 10 characters long' }) + @MaxLength(200, { message: 'Role description cannot exceed 200 characters' }) + description!: string; + + @ApiPropertyOptional({ + description: 'Whether this role should be assigned to new users by default', + example: false, + default: false, + }) + @IsBoolean({ message: 'isDefault must be a boolean value' }) + @IsOptional() + isDefault?: boolean; + + @ApiPropertyOptional({ + description: 'List of permission IDs to assign to this role', + example: ['550e8400-e29b-41d4-a716-446655440000'], + type: [String], + isArray: true, + }) + @IsArray({ message: 'Permission IDs must be an array' }) + @IsUUID('4', { each: true, message: 'Each permission ID must be a valid UUID' }) + @IsOptional() + permissionIds?: string[]; +} diff --git a/src/application/dtos/requests/role/index.ts b/src/application/dtos/requests/role/index.ts new file mode 100644 index 00000000..cb37e24b --- /dev/null +++ b/src/application/dtos/requests/role/index.ts @@ -0,0 +1,2 @@ +export * from './create-role.request'; +export * from './update-role.request'; diff --git a/src/application/dtos/requests/role/update-role.request.ts b/src/application/dtos/requests/role/update-role.request.ts new file mode 100644 index 00000000..286268de --- /dev/null +++ b/src/application/dtos/requests/role/update-role.request.ts @@ -0,0 +1,36 @@ +import { IsOptional, IsString, IsBoolean, MinLength, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateRoleRequest { + @ApiPropertyOptional({ + description: 'Role name (unique identifier)', + example: 'moderator', + minLength: 3, + maxLength: 50, + }) + @IsString({ message: 'Role name must be a string' }) + @MinLength(3, { message: 'Role name must be at least 3 characters long' }) + @MaxLength(50, { message: 'Role name cannot exceed 50 characters' }) + @IsOptional() + name?: string; + + @ApiPropertyOptional({ + description: 'Role description explaining its purpose and permissions', + example: 'Moderator role with content management access', + minLength: 10, + maxLength: 200, + }) + @IsString({ message: 'Role description must be a string' }) + @MinLength(10, { message: 'Role description must be at least 10 characters long' }) + @MaxLength(200, { message: 'Role description cannot exceed 200 characters' }) + @IsOptional() + description?: string; + + @ApiPropertyOptional({ + description: 'Whether this role should be assigned to new users by default', + example: false, + }) + @IsBoolean({ message: 'isDefault must be a boolean value' }) + @IsOptional() + isDefault?: boolean; +} diff --git a/src/application/dtos/requests/storage/index.ts b/src/application/dtos/requests/storage/index.ts new file mode 100644 index 00000000..8657039c --- /dev/null +++ b/src/application/dtos/requests/storage/index.ts @@ -0,0 +1 @@ +export * from './update-file-access.request'; diff --git a/src/application/dtos/requests/storage/update-file-access.request.ts b/src/application/dtos/requests/storage/update-file-access.request.ts new file mode 100644 index 00000000..0f89faa5 --- /dev/null +++ b/src/application/dtos/requests/storage/update-file-access.request.ts @@ -0,0 +1,12 @@ +import { IsBoolean, IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateFileAccessRequest { + @ApiProperty({ + description: 'Whether the file should be publicly accessible (true) or private (false)', + example: true, + }) + @IsBoolean({ message: 'isPublic must be a boolean value' }) + @IsNotEmpty({ message: 'isPublic is required' }) + isPublic!: boolean; +} diff --git a/src/application/dtos/requests/user/activate-user.request.ts b/src/application/dtos/requests/user/activate-user.request.ts new file mode 100644 index 00000000..97c34900 --- /dev/null +++ b/src/application/dtos/requests/user/activate-user.request.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsNotEmpty } from 'class-validator'; + +export class ActivateUserRequest { + @ApiProperty({ + description: 'User activation status (true to activate, false to deactivate)', + example: true, + }) + @IsBoolean({ message: 'Active status must be a boolean value' }) + @IsNotEmpty({ message: 'Active status is required' }) + active!: boolean; +} diff --git a/src/application/dtos/requests/user/assign-role.request.ts b/src/application/dtos/requests/user/assign-role.request.ts new file mode 100644 index 00000000..3d5ad606 --- /dev/null +++ b/src/application/dtos/requests/user/assign-role.request.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsUUID } from 'class-validator'; + +export class AssignRoleRequest { + @ApiProperty({ + description: 'Role ID to assign to the user', + example: '550e8400-e29b-41d4-a716-446655440000', + format: 'uuid', + }) + @IsUUID('4', { message: 'Role ID must be a valid UUID' }) + @IsNotEmpty({ message: 'Role ID is required' }) + roleId!: string; +} diff --git a/src/application/dtos/requests/user/change-password.request.ts b/src/application/dtos/requests/user/change-password.request.ts new file mode 100644 index 00000000..9bf64b4f --- /dev/null +++ b/src/application/dtos/requests/user/change-password.request.ts @@ -0,0 +1,29 @@ +import { IsNotEmpty, IsString, MinLength, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ChangePasswordRequest { + @ApiProperty({ + description: 'Current password for verification', + example: 'OldPassword123!', + format: 'password', + }) + @IsString({ message: 'Current password must be a string' }) + @IsNotEmpty({ message: 'Current password is required' }) + currentPassword!: string; + + @ApiProperty({ + description: + 'New password. Must contain at least 8 characters with uppercase, lowercase, number and special character', + example: 'NewPassword123!', + minLength: 8, + format: 'password', + }) + @IsString({ message: 'New password must be a string' }) + @IsNotEmpty({ message: 'New password is required' }) + @MinLength(8, { message: 'New password must be at least 8 characters long' }) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, { + message: + 'New password must contain at least one uppercase letter, one lowercase letter, one number, and one special character', + }) + newPassword!: string; +} diff --git a/src/application/dtos/requests/user/index.ts b/src/application/dtos/requests/user/index.ts new file mode 100644 index 00000000..62101d2a --- /dev/null +++ b/src/application/dtos/requests/user/index.ts @@ -0,0 +1,4 @@ +export * from './update-user.request'; +export * from './change-password.request'; +export * from './assign-role.request'; +export * from './activate-user.request'; diff --git a/src/application/dtos/requests/user/update-user.request.ts b/src/application/dtos/requests/user/update-user.request.ts new file mode 100644 index 00000000..0d03f5c9 --- /dev/null +++ b/src/application/dtos/requests/user/update-user.request.ts @@ -0,0 +1,33 @@ +import { IsOptional, IsString, IsEmail, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateUserRequest { + @ApiPropertyOptional({ + description: 'User first name', + example: 'John', + maxLength: 50, + }) + @IsString({ message: 'First name must be a string' }) + @MaxLength(50, { message: 'First name cannot exceed 50 characters' }) + @IsOptional() + firstName?: string; + + @ApiPropertyOptional({ + description: 'User last name', + example: 'Doe', + maxLength: 50, + }) + @IsString({ message: 'Last name must be a string' }) + @MaxLength(50, { message: 'Last name cannot exceed 50 characters' }) + @IsOptional() + lastName?: string; + + @ApiPropertyOptional({ + description: 'User email address', + example: 'john.doe@example.com', + format: 'email', + }) + @IsEmail({}, { message: 'Please provide a valid email address' }) + @IsOptional() + email?: string; +} diff --git a/src/application/dtos/responses/auth/auth.response.ts b/src/application/dtos/responses/auth/auth.response.ts new file mode 100644 index 00000000..ded17bfa --- /dev/null +++ b/src/application/dtos/responses/auth/auth.response.ts @@ -0,0 +1,134 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UserRoleResponse { + @ApiProperty({ + description: 'Role ID', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + id!: string; + + @ApiProperty({ + description: 'Role name', + example: 'admin', + }) + name!: string; +} + +export class UserAuthResponse { + @ApiProperty({ + description: 'User ID', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + id!: string; + + @ApiProperty({ + description: 'User email address', + example: 'user@example.com', + }) + email!: string; + + @ApiProperty({ + description: 'User first name', + example: 'John', + }) + firstName!: string; + + @ApiProperty({ + description: 'User last name', + example: 'Doe', + }) + lastName!: string; + + @ApiProperty({ + description: 'Whether user email is verified', + example: true, + required: false, + }) + emailVerified?: boolean; + + @ApiProperty({ + description: 'User roles', + type: [UserRoleResponse], + }) + roles!: UserRoleResponse[]; +} + +export class AuthTokenResponse { + @ApiProperty({ + description: 'JWT access token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + accessToken!: string; + + @ApiProperty({ + description: 'Refresh token for getting new access tokens', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + refreshToken!: string; + + @ApiProperty({ + description: 'User information', + type: UserAuthResponse, + }) + user!: UserAuthResponse; +} + +export class OtpRequiredResponse { + @ApiProperty({ + description: 'Indicates that OTP verification is required', + example: true, + }) + requiresOtp!: true; + + @ApiProperty({ + description: 'User ID for OTP verification', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + userId!: string; + + @ApiProperty({ + description: 'Message explaining OTP requirement', + example: 'Please provide your 2FA code to complete login', + }) + message!: string; +} + +export class EmailVerificationRequiredResponse { + @ApiProperty({ + description: 'Indicates that email verification is required', + example: true, + }) + requiresEmailVerification!: true; + + @ApiProperty({ + description: 'User ID for email verification', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + userId!: string; + + @ApiProperty({ + description: 'Email address that needs verification', + example: 'user@example.com', + }) + email!: string; + + @ApiProperty({ + description: 'Message explaining email verification requirement', + example: 'Please verify your email address to complete registration', + }) + message!: string; +} + +export class RefreshTokenResponse { + @ApiProperty({ + description: 'New JWT access token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + accessToken!: string; + + @ApiProperty({ + description: 'New refresh token', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + refreshToken!: string; +} diff --git a/src/application/dtos/responses/auth/index.ts b/src/application/dtos/responses/auth/index.ts new file mode 100644 index 00000000..9f5bdb3a --- /dev/null +++ b/src/application/dtos/responses/auth/index.ts @@ -0,0 +1,13 @@ +export * from './auth.response'; + +import { + AuthTokenResponse, + OtpRequiredResponse, + EmailVerificationRequiredResponse, +} from './auth.response'; + +// Union type for authentication responses +export type AuthResponse = + | AuthTokenResponse + | OtpRequiredResponse + | EmailVerificationRequiredResponse; diff --git a/src/application/dtos/responses/file.response.ts b/src/application/dtos/responses/file.response.ts deleted file mode 100644 index 98e52d92..00000000 --- a/src/application/dtos/responses/file.response.ts +++ /dev/null @@ -1,15 +0,0 @@ -export class FileResponseDto { - id!: string; - filename!: string; - originalName!: string; - mimeType!: string; - size!: number; - isPublic!: boolean; - url!: string; - createdAt!: Date; - updatedAt!: Date; - - constructor(partial: Partial) { - Object.assign(this, partial); - } -} diff --git a/src/application/dtos/responses/health.response.ts b/src/application/dtos/responses/health.response.ts deleted file mode 100644 index 43126ca4..00000000 --- a/src/application/dtos/responses/health.response.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Health response interfaces - -export interface IHealthResponse { - status: string; - timestamp: string; - uptime: number; - environment: string; - version: string; -} - -export interface IDatabaseHealthResponse { - status: string; - database: string; - timestamp: string; -} - -export interface IReadinessResponse { - status: string; - timestamp: string; - checks: { - database: string; - config: string; - }; -} - -export interface ILivenessResponse { - status: string; - timestamp: string; - uptime: number; -} - -export interface IHealthCheckDetail { - name: string; - status: 'ok' | 'error'; - message?: string; - duration?: number; -} - -export interface IComprehensiveHealthResponse { - status: 'ok' | 'degraded' | 'down'; - timestamp: string; - uptime: number; - environment: string; - version: string; - checks: IHealthCheckDetail[]; -} diff --git a/src/application/dtos/responses/health/health.response.ts b/src/application/dtos/responses/health/health.response.ts new file mode 100644 index 00000000..b9970fc3 --- /dev/null +++ b/src/application/dtos/responses/health/health.response.ts @@ -0,0 +1,266 @@ +import { ApiProperty } from '@nestjs/swagger'; + +interface IHealthServiceInfo { + status: 'up' | 'down'; + message?: string; + responseTime?: number; + metadata?: Record; +} + +interface IHealthErrorInfo { + message: string; + stack?: string; + code?: string; + details?: Record; +} + +export class HealthCheckResponse { + @ApiProperty({ + description: 'Overall health status', + example: 'ok', + enum: ['ok', 'error'], + }) + status!: 'ok' | 'error'; + + @ApiProperty({ + description: 'Health check timestamp', + example: '2024-01-01T00:00:00.000Z', + required: false, + }) + timestamp?: string; + + @ApiProperty({ + description: 'System uptime in seconds', + example: 123456, + required: false, + }) + uptime?: number; + + @ApiProperty({ + description: 'Application environment', + example: 'production', + required: false, + }) + environment?: string; + + @ApiProperty({ + description: 'Application version', + example: '1.0.0', + required: false, + }) + version?: string; + + @ApiProperty({ + description: 'Health check details by service', + example: { + database: { status: 'up' }, + redis: { status: 'up' }, + }, + required: false, + }) + info?: Record; + + @ApiProperty({ + description: 'Error details if any service is down', + example: {}, + required: false, + }) + error?: Record; + + @ApiProperty({ + description: 'Detailed service information including response times', + example: { + database: { status: 'up', responseTime: 5 }, + redis: { status: 'up', responseTime: 2 }, + }, + required: false, + }) + details?: Record; +} + +export class DatabaseHealthResponse { + @ApiProperty({ + description: 'Database health status', + example: 'ok', + enum: ['ok', 'error'], + }) + status!: 'ok' | 'error'; + + @ApiProperty({ + description: 'Database connection status', + example: 'connected', + }) + database!: string; + + @ApiProperty({ + description: 'Health check timestamp', + example: '2024-01-01T00:00:00.000Z', + required: false, + }) + timestamp?: string; + + @ApiProperty({ + description: 'Database connection details', + example: { database: { status: 'up' } }, + required: false, + }) + info?: Record; + + @ApiProperty({ + description: 'Database error details if connection failed', + required: false, + }) + error?: Record; + + @ApiProperty({ + description: 'Detailed database information', + example: { database: { status: 'up', responseTime: 5 } }, + required: false, + }) + details?: Record; +} + +export class ReadinessResponse { + @ApiProperty({ + description: 'Readiness check status', + example: 'ready', + enum: ['ok', 'error', 'ready'], + }) + status!: 'ok' | 'error' | 'ready'; + + @ApiProperty({ + description: 'Health check timestamp', + example: '2024-01-01T00:00:00.000Z', + required: false, + }) + timestamp?: string; + + @ApiProperty({ + description: 'Readiness check results', + example: { database: 'ok', config: 'ok' }, + required: false, + }) + checks?: Record; + + @ApiProperty({ + description: 'Readiness check details', + example: { database: { status: 'up' } }, + required: false, + }) + info?: Record; + + @ApiProperty({ + description: 'Error details if readiness check failed', + required: false, + }) + error?: Record; + + @ApiProperty({ + description: 'Detailed readiness information', + example: { database: { status: 'up' } }, + required: false, + }) + details?: Record; +} + +export class LivenessResponse { + @ApiProperty({ + description: 'Liveness check status', + example: 'alive', + enum: ['ok', 'error', 'alive'], + }) + status!: 'ok' | 'error' | 'alive'; + + @ApiProperty({ + description: 'Health check timestamp', + example: '2024-01-01T00:00:00.000Z', + required: false, + }) + timestamp?: string; + + @ApiProperty({ + description: 'System uptime in seconds', + example: 123456, + required: false, + }) + uptime?: number; + + @ApiProperty({ + description: 'Liveness check details', + example: { memory_heap: { status: 'up' } }, + required: false, + }) + info?: Record; + + @ApiProperty({ + description: 'Error details if liveness check failed', + required: false, + }) + error?: Record; + + @ApiProperty({ + description: 'Detailed liveness information', + example: { memory_heap: { status: 'up' } }, + required: false, + }) + details?: Record; +} + +export class HealthCheckDetailResponse { + @ApiProperty({ + description: 'Service status', + example: 'up', + enum: ['up', 'down', 'ok', 'error'], + }) + status!: 'up' | 'down' | 'ok' | 'error'; + + @ApiProperty({ + description: 'Response time in milliseconds', + example: 5, + required: false, + }) + responseTime?: number; + + [key: string]: string | number | boolean | undefined; +} + +export class ComprehensiveHealthResponse { + @ApiProperty({ + description: 'Overall system health status', + example: 'ok', + enum: ['ok', 'degraded', 'down'], + }) + status!: 'ok' | 'degraded' | 'down'; + + @ApiProperty({ + description: 'Timestamp of health check', + example: '2024-01-01T00:00:00.000Z', + }) + timestamp!: string; + + @ApiProperty({ + description: 'Uptime in seconds', + example: 123456, + }) + uptime!: number; + + @ApiProperty({ + description: 'Application environment', + example: 'production', + required: false, + }) + environment?: string; + + @ApiProperty({ + description: 'Application version', + example: '1.0.0', + required: false, + }) + version?: string; + + @ApiProperty({ + description: 'Individual health check results', + type: [HealthCheckDetailResponse], + }) + checks!: HealthCheckDetailResponse[]; +} diff --git a/src/application/dtos/responses/health/index.ts b/src/application/dtos/responses/health/index.ts new file mode 100644 index 00000000..df32d28d --- /dev/null +++ b/src/application/dtos/responses/health/index.ts @@ -0,0 +1 @@ +export * from './health.response'; diff --git a/src/application/dtos/responses/index.ts b/src/application/dtos/responses/index.ts new file mode 100644 index 00000000..dbca7d34 --- /dev/null +++ b/src/application/dtos/responses/index.ts @@ -0,0 +1,5 @@ +export * from './auth'; +export * from './user'; +export * from './role'; +export * from './storage'; +export * from './health'; diff --git a/src/application/dtos/responses/role.response.ts b/src/application/dtos/responses/role.response.ts deleted file mode 100644 index 6406f298..00000000 --- a/src/application/dtos/responses/role.response.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; - -// Basic Permission Response dto -export class PermissionResponse { - @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) - id!: string; - - @ApiProperty({ example: 'user:read' }) - name!: string; - - @ApiProperty({ example: 'Can read user information' }) - description!: string; - - @ApiProperty({ example: 'user' }) - resource!: string; - - @ApiProperty({ example: 'read' }) - action!: string; -} - -// Basic Role Response dto -export class RoleBaseResponse { - @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) - id!: string; - - @ApiProperty({ example: 'admin' }) - name!: string; - - @ApiProperty({ example: 'Administrator role with full access' }) - description!: string; - - @ApiProperty({ example: false }) - isDefault!: boolean; -} - -// Detailed Role Response with permissions -export class RoleDetailResponse extends RoleBaseResponse { - @ApiProperty({ type: [PermissionResponse] }) - permissions!: PermissionResponse[]; - - @ApiProperty({ example: '2023-01-01T00:00:00.000Z' }) - createdAt!: Date; - - @ApiProperty({ example: '2023-01-01T00:00:00.000Z' }) - updatedAt!: Date; -} diff --git a/src/application/dtos/responses/role/index.ts b/src/application/dtos/responses/role/index.ts new file mode 100644 index 00000000..7001054f --- /dev/null +++ b/src/application/dtos/responses/role/index.ts @@ -0,0 +1 @@ +export * from './role.response'; diff --git a/src/application/dtos/responses/role/role.response.ts b/src/application/dtos/responses/role/role.response.ts new file mode 100644 index 00000000..0a9a0d30 --- /dev/null +++ b/src/application/dtos/responses/role/role.response.ts @@ -0,0 +1,76 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PermissionResponse { + @ApiProperty({ + description: 'Permission ID', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + id!: string; + + @ApiProperty({ + description: 'Permission name (resource:action format)', + example: 'user:read', + }) + name!: string; + + @ApiProperty({ + description: 'Permission description', + example: 'Can read user information', + }) + description!: string; + + @ApiProperty({ + description: 'Resource type this permission applies to', + example: 'user', + }) + resource!: string; + + @ApiProperty({ + description: 'Action type this permission allows', + example: 'read', + }) + action!: string; +} + +export class RoleDetailResponse { + @ApiProperty({ + description: 'Role ID', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + id!: string; + + @ApiProperty({ + description: 'Role name', + example: 'admin', + }) + name!: string; + + @ApiProperty({ + description: 'Role description', + example: 'Administrator role with full system access', + }) + description!: string; + + @ApiProperty({ + description: 'Whether this is the default role for new users', + example: false, + }) + isDefault!: boolean; + @ApiProperty({ + description: 'Permissions assigned to this role', + type: [PermissionResponse], + }) + permissions!: PermissionResponse[]; + + @ApiProperty({ + description: 'Role creation timestamp', + example: '2024-01-01T00:00:00.000Z', + }) + createdAt!: Date; + + @ApiProperty({ + description: 'Role last update timestamp', + example: '2024-01-01T12:00:00.000Z', + }) + updatedAt!: Date; +} diff --git a/src/application/dtos/responses/storage/file.response.ts b/src/application/dtos/responses/storage/file.response.ts new file mode 100644 index 00000000..a7a3c046 --- /dev/null +++ b/src/application/dtos/responses/storage/file.response.ts @@ -0,0 +1,66 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class FileResponse { + @ApiProperty({ + description: 'File ID', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + id!: string; + + @ApiProperty({ + description: 'File name on the server', + example: 'document_1704067200000.pdf', + }) + filename!: string; + + @ApiProperty({ + description: 'Original file name from upload', + example: 'important-document.pdf', + }) + originalName!: string; + + @ApiProperty({ + description: 'File MIME type', + example: 'application/pdf', + }) + mimeType!: string; + + @ApiProperty({ + description: 'File size in bytes', + example: 1024000, + }) + size!: number; + + @ApiProperty({ + description: 'Whether the file is publicly accessible', + example: false, + }) + isPublic!: boolean; + + @ApiProperty({ + description: 'File access URL', + example: 'https://example.com/files/550e8400-e29b-41d4-a716-446655440000', + }) + url!: string; + + @ApiProperty({ + description: 'File upload timestamp', + example: '2024-01-01T00:00:00.000Z', + }) + createdAt!: Date; + + @ApiProperty({ + description: 'File last update timestamp', + example: '2024-01-01T12:00:00.000Z', + }) + updatedAt!: Date; + + constructor(data?: Partial) { + if (data) { + Object.assign(this, data); + } + } +} + +// Legacy alias for backward compatibility +export type FileResponseDto = FileResponse; diff --git a/src/application/dtos/responses/storage/index.ts b/src/application/dtos/responses/storage/index.ts new file mode 100644 index 00000000..01ec2c7b --- /dev/null +++ b/src/application/dtos/responses/storage/index.ts @@ -0,0 +1 @@ +export * from './file.response'; diff --git a/src/application/dtos/responses/user.response.ts b/src/application/dtos/responses/user.response.ts deleted file mode 100644 index 7b82ac2c..00000000 --- a/src/application/dtos/responses/user.response.ts +++ /dev/null @@ -1,66 +0,0 @@ -// User response interfaces - -export interface IUserRoleResponse { - id: string; - name: string; -} - -export interface IUserBaseResponse { - id: string; - email: string; - firstName: string; - lastName: string; - emailVerified?: boolean; -} - -export interface IUserDetailResponse extends IUserBaseResponse { - isActive: boolean; - otpEnabled: boolean; - lastLoginAt?: Date; - roles: IUserRoleResponse[]; - createdAt: Date; - updatedAt: Date; -} - -export interface IUserWithAuthResponse extends IUserBaseResponse { - roles: IUserRoleResponse[]; -} - -export interface IAuthTokenResponse { - accessToken: string; - refreshToken: string; - user: IUserWithAuthResponse; -} - -export interface IOtpRequiredResponse { - requiresOtp: true; - userId: string; - message: string; -} - -export interface IEmailVerificationRequiredResponse { - requiresEmailVerification: true; - userId: string; - email: string; - message: string; -} - -export interface IAuthRefreshTokenResponse { - accessToken: string; - refreshToken: string; -} - -export interface IJwtPayload { - sub: string; - email: string; - emailVerified?: boolean; - roles: string[]; - permissions?: string[]; - iat?: number; - exp?: number; -} - -export type AuthResponse = - | IAuthTokenResponse - | IOtpRequiredResponse - | IEmailVerificationRequiredResponse; diff --git a/src/application/dtos/responses/user/index.ts b/src/application/dtos/responses/user/index.ts new file mode 100644 index 00000000..b2b31a7e --- /dev/null +++ b/src/application/dtos/responses/user/index.ts @@ -0,0 +1 @@ +export * from './user.response'; diff --git a/src/application/dtos/responses/user/user.response.ts b/src/application/dtos/responses/user/user.response.ts new file mode 100644 index 00000000..a5e3b00e --- /dev/null +++ b/src/application/dtos/responses/user/user.response.ts @@ -0,0 +1,88 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { UserRoleResponse } from '../auth/auth.response'; + +export class UserRoleDetailResponse extends UserRoleResponse { + @ApiProperty({ + description: 'Role description', + example: 'Administrator role with full access', + }) + description!: string; + + @ApiProperty({ + description: 'Whether this is the default role for new users', + example: false, + }) + isDefault!: boolean; +} + +export class UserBaseResponse { + @ApiProperty({ + description: 'User ID', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + id!: string; + + @ApiProperty({ + description: 'User email address', + example: 'user@example.com', + }) + email!: string; + + @ApiProperty({ + description: 'User first name', + example: 'John', + }) + firstName!: string; + + @ApiProperty({ + description: 'User last name', + example: 'Doe', + }) + lastName!: string; + + @ApiProperty({ + description: 'Whether user email is verified', + example: true, + required: false, + }) + emailVerified?: boolean; +} + +export class UserDetailResponse extends UserBaseResponse { + @ApiProperty({ + description: 'Whether the user account is active', + example: true, + }) + isActive!: boolean; + + @ApiProperty({ + description: 'Whether two-factor authentication is enabled', + example: false, + }) + otpEnabled!: boolean; + + @ApiProperty({ + description: 'Last login timestamp', + example: '2024-01-01T12:00:00.000Z', + required: false, + }) + lastLoginAt?: Date; + + @ApiProperty({ + description: 'User roles with details', + type: [UserRoleDetailResponse], + }) + roles!: UserRoleDetailResponse[]; + + @ApiProperty({ + description: 'Account creation timestamp', + example: '2024-01-01T00:00:00.000Z', + }) + createdAt!: Date; + + @ApiProperty({ + description: 'Last account update timestamp', + example: '2024-01-01T12:00:00.000Z', + }) + updatedAt!: Date; +} diff --git a/src/application/dtos/role/create-role.dto.ts b/src/application/dtos/role/create-role.dto.ts deleted file mode 100644 index 74596da4..00000000 --- a/src/application/dtos/role/create-role.dto.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { IsNotEmpty, IsString, IsOptional, IsBoolean, IsArray } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class CreateRoleDto { - @ApiProperty({ - description: 'Role name', - example: 'admin', - }) - @IsString() - @IsNotEmpty() - name!: string; - - @ApiProperty({ - description: 'Role description', - example: 'Administrator role with full access', - }) - @IsString() - @IsNotEmpty() - description!: string; - - @ApiPropertyOptional({ - description: 'Whether this role is the default for new users', - example: false, - }) - @IsBoolean() - @IsOptional() - isDefault?: boolean; - - @ApiPropertyOptional({ - description: 'List of permission IDs to assign to the role', - example: ['550e8400-e29b-41d4-a716-446655440000', '550e8400-e29b-41d4-a716-446655440001'], - type: [String], - }) - @IsArray() - @IsOptional() - permissionIds?: string[]; -} diff --git a/src/application/dtos/role/update-role.dto.ts b/src/application/dtos/role/update-role.dto.ts deleted file mode 100644 index 1c5bb8a2..00000000 --- a/src/application/dtos/role/update-role.dto.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { IsOptional, IsString, IsBoolean } from 'class-validator'; -import { ApiPropertyOptional } from '@nestjs/swagger'; - -export class UpdateRoleDto { - @ApiPropertyOptional({ - description: 'Role name', - example: 'moderator', - }) - @IsString() - @IsOptional() - name?: string; - - @ApiPropertyOptional({ - description: 'Role description', - example: 'Moderator role with limited access', - }) - @IsString() - @IsOptional() - description?: string; - - @ApiPropertyOptional({ - description: 'Whether this role is the default for new users', - example: true, - }) - @IsBoolean() - @IsOptional() - isDefault?: boolean; -} diff --git a/src/application/dtos/storage/update-file-access.dto.ts b/src/application/dtos/storage/update-file-access.dto.ts deleted file mode 100644 index ecfead62..00000000 --- a/src/application/dtos/storage/update-file-access.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsBoolean, IsNotEmpty, IsString } from 'class-validator'; - -export class UpdateFileAccessDto { - @IsString() - @IsNotEmpty() - fileId!: string; - - @IsBoolean() - @IsNotEmpty() - isPublic!: boolean; -} diff --git a/src/application/dtos/storage/upload-file.dto.ts b/src/application/dtos/storage/upload-file.dto.ts deleted file mode 100644 index 7f7daeaa..00000000 --- a/src/application/dtos/storage/upload-file.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsOptional, IsString } from 'class-validator'; - -export class UploadFileDto { - @IsString() - @IsOptional() - folder?: string; -} diff --git a/src/application/dtos/types/index.ts b/src/application/dtos/types/index.ts new file mode 100644 index 00000000..555556f7 --- /dev/null +++ b/src/application/dtos/types/index.ts @@ -0,0 +1 @@ +export * from './jwt-payload.interface'; diff --git a/src/application/dtos/types/jwt-payload.interface.ts b/src/application/dtos/types/jwt-payload.interface.ts new file mode 100644 index 00000000..e7e2322a --- /dev/null +++ b/src/application/dtos/types/jwt-payload.interface.ts @@ -0,0 +1,9 @@ +export interface IJwtPayload { + sub: string; + email: string; + emailVerified?: boolean; + roles: string[]; + permissions?: string[]; + iat?: number; + exp?: number; +} diff --git a/src/application/dtos/user/activate-user.dto.ts b/src/application/dtos/user/activate-user.dto.ts deleted file mode 100644 index dd053096..00000000 --- a/src/application/dtos/user/activate-user.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsNotEmpty } from 'class-validator'; - -export class ActivateUserDto { - @ApiProperty({ - description: 'User activation status', - example: true, - }) - @IsBoolean() - @IsNotEmpty() - active!: boolean; -} diff --git a/src/application/dtos/user/assign-role.dto.ts b/src/application/dtos/user/assign-role.dto.ts deleted file mode 100644 index 68f79028..00000000 --- a/src/application/dtos/user/assign-role.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsUUID } from 'class-validator'; - -export class AssignRoleDto { - @ApiProperty({ - description: 'Role ID to assign to user', - example: '550e8400-e29b-41d4-a716-446655440000', - }) - @IsUUID() - @IsNotEmpty() - roleId!: string; -} diff --git a/src/application/dtos/user/change-password.dto.ts b/src/application/dtos/user/change-password.dto.ts deleted file mode 100644 index 325b4955..00000000 --- a/src/application/dtos/user/change-password.dto.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { IsNotEmpty, IsString, MinLength, Matches } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class ChangePasswordDto { - @ApiProperty({ - description: 'Current password', - example: 'OldPassword123!', - }) - @IsString() - @IsNotEmpty() - currentPassword!: string; - - @ApiProperty({ - description: 'New password', - example: 'NewPassword123!', - minLength: 8, - }) - @IsString() - @IsNotEmpty() - @MinLength(8) - @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, { - message: - 'Password must be at least 8 characters long and include at least one uppercase letter, one lowercase letter, one number, and one special character', - }) - newPassword!: string; -} diff --git a/src/application/dtos/user/create-user.dto.ts b/src/application/dtos/user/create-user.dto.ts deleted file mode 100644 index 3887cca5..00000000 --- a/src/application/dtos/user/create-user.dto.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - IsEmail, - IsNotEmpty, - IsString, - MinLength, - Matches, - IsOptional, - IsArray, -} from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class CreateUserDto { - @ApiProperty({ - description: 'User email address', - example: 'user@example.com', - }) - @IsEmail() - @IsNotEmpty() - email!: string; - - @ApiProperty({ - description: 'User password', - example: 'Password123!', - minLength: 8, - }) - @IsString() - @IsNotEmpty() - @MinLength(8) - @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, { - message: - 'Password must be at least 8 characters long and include at least one uppercase letter, one lowercase letter, one number, and one special character', - }) - password!: string; - - @ApiProperty({ - description: 'User first name', - example: 'John', - }) - @IsString() - @IsNotEmpty() - firstName!: string; - - @ApiProperty({ - description: 'User last name', - example: 'Doe', - }) - @IsString() - @IsNotEmpty() - lastName!: string; - - @ApiPropertyOptional({ - description: 'List of role IDs to assign to the user', - example: ['550e8400-e29b-41d4-a716-446655440000', '550e8400-e29b-41d4-a716-446655440001'], - type: [String], - }) - @IsArray() - @IsOptional() - roleIds?: string[]; -} diff --git a/src/application/dtos/user/update-user.dto.ts b/src/application/dtos/user/update-user.dto.ts deleted file mode 100644 index d5ed8652..00000000 --- a/src/application/dtos/user/update-user.dto.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { IsOptional, IsString, IsEmail } from 'class-validator'; -import { ApiPropertyOptional } from '@nestjs/swagger'; - -export class UpdateUserDto { - @ApiPropertyOptional({ - description: 'User first name', - example: 'John', - }) - @IsString() - @IsOptional() - firstName?: string; - - @ApiPropertyOptional({ - description: 'User last name', - example: 'Doe', - }) - @IsString() - @IsOptional() - lastName?: string; - - @ApiPropertyOptional({ - description: 'User email', - example: 'john.doe@example.com', - }) - @IsEmail() - @IsOptional() - email?: string; -} diff --git a/src/application/mappers/file.mapper.ts b/src/application/mappers/file.mapper.ts index 755e70a5..1426b720 100644 --- a/src/application/mappers/file.mapper.ts +++ b/src/application/mappers/file.mapper.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { FileResponseDto } from '../dtos/responses/file.response'; +import { FileResponse } from '@application/dtos'; import { File } from '@core/entities/file.entity'; import { StorageService } from '@core/services/storage.service'; @@ -11,7 +11,7 @@ export class FileMapper { private readonly configService: ConfigService, ) {} - async toResponseDto(file: File): Promise { + async toResponseDto(file: File): Promise { let url: string; if (file.isPublic) { @@ -21,7 +21,7 @@ export class FileMapper { url = fileUrl || ''; } - return new FileResponseDto({ + return new FileResponse({ id: file.id, filename: file.filename, originalName: file.originalName, @@ -34,8 +34,8 @@ export class FileMapper { }); } - async toResponseDtoList(files: File[]): Promise { - const dtos: FileResponseDto[] = []; + async toResponseDtoList(files: File[]): Promise { + const dtos: FileResponse[] = []; for (const file of files) { dtos.push(await this.toResponseDto(file)); } diff --git a/src/application/mappers/role.mapper.ts b/src/application/mappers/role.mapper.ts index fadb0ec6..908f5462 100644 --- a/src/application/mappers/role.mapper.ts +++ b/src/application/mappers/role.mapper.ts @@ -1,6 +1,6 @@ import { Role } from '@core/entities/role.entity'; import { Permission } from '@core/entities/permission.entity'; -import { RoleDetailResponse, PermissionResponse } from '@application/dtos/responses/role.response'; +import { RoleDetailResponse, PermissionResponse } from '@application/dtos'; export class RoleMapper { /** diff --git a/src/application/mappers/user.mapper.ts b/src/application/mappers/user.mapper.ts index b9bf4f3f..003e9c03 100644 --- a/src/application/mappers/user.mapper.ts +++ b/src/application/mappers/user.mapper.ts @@ -1,27 +1,29 @@ import { User } from '@core/entities/user.entity'; import { Role } from '@core/entities/role.entity'; import { - IUserBaseResponse, - IUserDetailResponse, - IUserRoleResponse, - IUserWithAuthResponse, -} from '@application/dtos/responses/user.response'; + UserBaseResponse, + UserDetailResponse, + UserAuthResponse, + UserRoleDetailResponse, +} from '@application/dtos'; export class UserMapper { /** - * Maps a Role entity to a IUserRoleResponse DTO + * Maps a Role entity to a UserRoleDetailResponse DTO */ - static toRoleResponse(role: Role): IUserRoleResponse { + static toRoleResponse(role: Role): UserRoleDetailResponse { return { id: role.id.getValue(), name: role.name, + description: role.description, + isDefault: role.isDefault, }; } /** - * Maps a User entity to a IUserBaseResponse DTO + * Maps a User entity to a UserBaseResponse DTO */ - static toBaseResponse(user: User, emailVerified: boolean = false): IUserBaseResponse { + static toBaseResponse(user: User, emailVerified: boolean = false): UserBaseResponse { return { id: user.id.getValue(), email: user.email.getValue(), @@ -32,9 +34,9 @@ export class UserMapper { } /** - * Maps a User entity to a IUserDetailResponse DTO + * Maps a User entity to a UserDetailResponse DTO */ - static toDetailResponse(user: User, emailVerified: boolean = false): IUserDetailResponse { + static toDetailResponse(user: User, emailVerified: boolean = false): UserDetailResponse { return { ...this.toBaseResponse(user, emailVerified), isActive: user.isActive, @@ -47,9 +49,9 @@ export class UserMapper { } /** - * Maps a User entity to a IUserWithAuthResponse DTO + * Maps a User entity to a UserAuthResponse DTO */ - static toAuthResponse(user: User, emailVerified: boolean = false): IUserWithAuthResponse { + static toAuthResponse(user: User, emailVerified: boolean = false): UserAuthResponse { return { ...this.toBaseResponse(user, emailVerified), roles: user.roles?.map(role => this.toRoleResponse(role)) || [], diff --git a/src/application/queries/health/get-database-health.query.ts b/src/application/queries/health/get-database-health.query.ts index 4ae4eae1..5aa666db 100644 --- a/src/application/queries/health/get-database-health.query.ts +++ b/src/application/queries/health/get-database-health.query.ts @@ -1,6 +1,6 @@ import { IQuery, IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { Injectable } from '@nestjs/common'; -import { IDatabaseHealthResponse } from '@application/dtos/responses/health.response'; +import { DatabaseHealthResponse } from '@application/dtos'; import { HealthService } from '@core/services/health.service'; export class GetDatabaseHealthQuery implements IQuery {} @@ -10,7 +10,7 @@ export class GetDatabaseHealthQuery implements IQuery {} export class GetDatabaseHealthQueryHandler implements IQueryHandler { constructor(private readonly healthService: HealthService) {} - async execute(): Promise { + async execute(): Promise { return this.healthService.getDatabaseHealth(); } } diff --git a/src/application/queries/health/get-health.query.ts b/src/application/queries/health/get-health.query.ts index e5a0085d..6bf6c6bc 100644 --- a/src/application/queries/health/get-health.query.ts +++ b/src/application/queries/health/get-health.query.ts @@ -1,6 +1,6 @@ import { IQuery, IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { Injectable } from '@nestjs/common'; -import { IHealthResponse } from '@application/dtos/responses/health.response'; +import { HealthCheckResponse } from '@application/dtos'; import { HealthService } from '@core/services/health.service'; export class GetHealthQuery implements IQuery {} @@ -10,7 +10,7 @@ export class GetHealthQuery implements IQuery {} export class GetHealthQueryHandler implements IQueryHandler { constructor(private readonly healthService: HealthService) {} - async execute(): Promise { + async execute(): Promise { return this.healthService.getHealth(); } } diff --git a/src/application/queries/health/get-liveness.query.ts b/src/application/queries/health/get-liveness.query.ts index 7b4edabc..c132384b 100644 --- a/src/application/queries/health/get-liveness.query.ts +++ b/src/application/queries/health/get-liveness.query.ts @@ -1,6 +1,6 @@ import { IQuery, IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { Injectable } from '@nestjs/common'; -import { ILivenessResponse } from '@application/dtos/responses/health.response'; +import { LivenessResponse } from '@application/dtos'; import { HealthService } from '@core/services/health.service'; export class GetLivenessQuery implements IQuery {} @@ -10,7 +10,7 @@ export class GetLivenessQuery implements IQuery {} export class GetLivenessQueryHandler implements IQueryHandler { constructor(private readonly healthService: HealthService) {} - async execute(): Promise { + async execute(): Promise { return this.healthService.getLiveness(); } } diff --git a/src/application/queries/health/get-readiness.query.ts b/src/application/queries/health/get-readiness.query.ts index becd469e..9b81c169 100644 --- a/src/application/queries/health/get-readiness.query.ts +++ b/src/application/queries/health/get-readiness.query.ts @@ -1,6 +1,6 @@ import { IQuery, IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { Injectable } from '@nestjs/common'; -import { IReadinessResponse } from '@application/dtos/responses/health.response'; +import { ReadinessResponse } from '@application/dtos'; import { HealthService } from '@core/services/health.service'; export class GetReadinessQuery implements IQuery {} @@ -10,7 +10,7 @@ export class GetReadinessQuery implements IQuery {} export class GetReadinessQueryHandler implements IQueryHandler { constructor(private readonly healthService: HealthService) {} - async execute(): Promise { + async execute(): Promise { return this.healthService.getReadiness(); } } diff --git a/src/application/queries/role/get-role.query.ts b/src/application/queries/role/get-role.query.ts index ec609139..b3f922ac 100644 --- a/src/application/queries/role/get-role.query.ts +++ b/src/application/queries/role/get-role.query.ts @@ -1,7 +1,7 @@ import { IQuery, IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { Inject } from '@nestjs/common'; import { IRoleRepository } from '@core/repositories/role.repository.interface'; -import { RoleDetailResponse } from '@application/dtos/responses/role.response'; +import { RoleDetailResponse } from '@application/dtos'; import { EntityNotFoundException } from '@core/exceptions/domain-exceptions'; import { RoleMapper } from '@application/mappers/role.mapper'; import { ROLE_REPOSITORY } from '@shared/constants/tokens'; diff --git a/src/application/queries/role/get-roles.query.ts b/src/application/queries/role/get-roles.query.ts index ad29b45b..1976f5c0 100644 --- a/src/application/queries/role/get-roles.query.ts +++ b/src/application/queries/role/get-roles.query.ts @@ -1,7 +1,7 @@ import { IQuery, IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { Inject } from '@nestjs/common'; import { IRoleRepository } from '@core/repositories/role.repository.interface'; -import { RoleDetailResponse } from '@application/dtos/responses/role.response'; +import { RoleDetailResponse } from '@application/dtos'; import { RoleMapper } from '@application/mappers/role.mapper'; import { ROLE_REPOSITORY } from '@shared/constants/tokens'; diff --git a/src/application/queries/storage/get-file.query.ts b/src/application/queries/storage/get-file.query.ts index f9ee8096..e01ba2d6 100644 --- a/src/application/queries/storage/get-file.query.ts +++ b/src/application/queries/storage/get-file.query.ts @@ -2,7 +2,7 @@ import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { NotFoundException, UnauthorizedException } from '@nestjs/common'; import { StorageService } from '@core/services/storage.service'; import { FileMapper } from '../../mappers/file.mapper'; -import { FileResponseDto } from '../../dtos/responses/file.response'; +import { FileResponse } from '@application/dtos'; export class GetFileQuery { constructor( @@ -12,13 +12,13 @@ export class GetFileQuery { } @QueryHandler(GetFileQuery) -export class GetFileQueryHandler implements IQueryHandler { +export class GetFileQueryHandler implements IQueryHandler { constructor( private readonly storageService: StorageService, private readonly fileMapper: FileMapper, ) {} - async execute(query: GetFileQuery): Promise { + async execute(query: GetFileQuery): Promise { const { fileId, userId } = query; const file = await this.storageService.getFileById(fileId); diff --git a/src/application/queries/storage/get-user-files.query.ts b/src/application/queries/storage/get-user-files.query.ts index b97ed55d..02665c79 100644 --- a/src/application/queries/storage/get-user-files.query.ts +++ b/src/application/queries/storage/get-user-files.query.ts @@ -1,22 +1,20 @@ import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { StorageService } from '@core/services/storage.service'; import { FileMapper } from '../../mappers/file.mapper'; -import { FileResponseDto } from '../../dtos/responses/file.response'; +import { FileResponse } from '@application/dtos'; export class GetUserFilesQuery { constructor(public readonly userId: string) {} } @QueryHandler(GetUserFilesQuery) -export class GetUserFilesQueryHandler - implements IQueryHandler -{ +export class GetUserFilesQueryHandler implements IQueryHandler { constructor( private readonly storageService: StorageService, private readonly fileMapper: FileMapper, ) {} - async execute(query: GetUserFilesQuery): Promise { + async execute(query: GetUserFilesQuery): Promise { const { userId } = query; const files = await this.storageService.getFilesByUserId(userId); diff --git a/src/application/queries/user/get-user.query.ts b/src/application/queries/user/get-user.query.ts index 81f9c0f8..91a20c1a 100644 --- a/src/application/queries/user/get-user.query.ts +++ b/src/application/queries/user/get-user.query.ts @@ -1,7 +1,7 @@ import { IQuery, IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { NotFoundException, Injectable, Inject } from '@nestjs/common'; import { IUserRepository } from '@core/repositories/user.repository.interface'; -import { IUserDetailResponse } from '@application/dtos/responses/user.response'; +import { UserDetailResponse } from '@application/dtos'; import { UserMapper } from '@application/mappers/user.mapper'; import { USER_REPOSITORY } from '@shared/constants/tokens'; @@ -17,7 +17,7 @@ export class GetUserQueryHandler implements IQueryHandler { private readonly userRepository: IUserRepository, ) {} - async execute(query: GetUserQuery): Promise { + async execute(query: GetUserQuery): Promise { const { userId } = query; const user = await this.userRepository.findById(userId); diff --git a/src/application/queries/user/get-users.query.ts b/src/application/queries/user/get-users.query.ts index 1ae83e8e..9085d99e 100644 --- a/src/application/queries/user/get-users.query.ts +++ b/src/application/queries/user/get-users.query.ts @@ -1,7 +1,7 @@ import { IQuery, IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { Injectable, Inject } from '@nestjs/common'; import { IUserRepository } from '@core/repositories/user.repository.interface'; -import { IUserDetailResponse } from '@application/dtos/responses/user.response'; +import { UserDetailResponse } from '@application/dtos'; import { UserMapper } from '@application/mappers/user.mapper'; import { USER_REPOSITORY } from '@shared/constants/tokens'; @@ -15,7 +15,7 @@ export class GetUsersQueryHandler implements IQueryHandler { private readonly userRepository: IUserRepository, ) {} - async execute(): Promise { + async execute(): Promise { const users = await this.userRepository.findAll(); // Use the mapper to convert each user to response DTO diff --git a/src/core/aggregates/README.md b/src/core/aggregates/README.md deleted file mode 100644 index 71adec2c..00000000 --- a/src/core/aggregates/README.md +++ /dev/null @@ -1,141 +0,0 @@ -# Domain Aggregates - -This document defines the aggregate boundaries for our domain model. Aggregates are consistency boundaries that ensure data integrity and encapsulate business rules. - -## Aggregate Definitions - -### 1. User Aggregate - -**Aggregate Root**: `User` -**Entities**: `User` -**Value Objects**: `UserId`, `Email`, `FirstName`, `LastName` - -**Boundaries**: -- The User aggregate manages user identity, authentication status, and profile information -- User roles are referenced by ID but managed by the Role aggregate -- The aggregate ensures consistency for user state changes (activation, deactivation, profile updates) - -**Invariants**: -- A user must have a valid email address -- A user must have at least one role (enforced at the application layer) -- Admin users should have 2FA enabled (business rule) -- User email must be unique across the system - -**Operations**: -- Create user account -- Update profile information -- Activate/deactivate account -- Enable/disable two-factor authentication -- Change password -- Assign/remove roles (coordination with Role aggregate) - -### 2. Role Aggregate - -**Aggregate Root**: `Role` -**Entities**: `Role` -**Value Objects**: `RoleId` - -**Boundaries**: -- The Role aggregate manages role definitions and permission assignments -- Permissions are referenced by ID but can be managed independently -- The aggregate ensures consistency for role permission assignments - -**Invariants**: -- Role names must be unique -- Default roles cannot be deleted -- Admin roles require specific permissions -- Roles must have at least one permission (except default role) - -**Operations**: -- Create role -- Update role details -- Assign/remove permissions -- Set/unset as default role -- Delete role (if eligible) - -### 3. Permission Aggregate - -**Aggregate Root**: `Permission` -**Entities**: `Permission` -**Value Objects**: `PermissionId`, `PermissionName`, `ResourceAction` - -**Boundaries**: -- The Permission aggregate manages permission definitions -- Permissions are atomic and immutable once created -- Resource and action combinations must be unique - -**Invariants**: -- Permission names must be unique -- Resource-action combinations must be valid -- System permissions cannot be deleted - -**Operations**: -- Create permission -- Update permission description -- Delete permission (if not system-critical) - -### 4. File Aggregate - -**Aggregate Root**: `File` -**Entities**: `File` -**Value Objects**: `FileId` - -**Boundaries**: -- The File aggregate manages file metadata and access control -- File ownership and access permissions are managed within this aggregate -- Physical file storage is handled by infrastructure services - -**Invariants**: -- Files must have an owner (User) -- File access levels must be valid -- File paths must be unique - -**Operations**: -- Upload file -- Update file access permissions -- Delete file -- Transfer ownership - -## Aggregate Relationships - -### User ↔ Role Relationship -- **Type**: Many-to-Many -- **Management**: User aggregate holds role references, Role aggregate is independent -- **Consistency**: Eventual consistency - role changes propagate via domain events -- **Coordination**: Application services coordinate role assignments - -### Role ↔ Permission Relationship -- **Type**: Many-to-Many -- **Management**: Role aggregate manages permission assignments -- **Consistency**: Strong consistency within Role aggregate -- **Coordination**: Permission existence validated at application layer - -### User ↔ File Relationship -- **Type**: One-to-Many (ownership) -- **Management**: File aggregate holds user reference -- **Consistency**: Eventual consistency - user changes propagate via domain events -- **Coordination**: Application services coordinate file operations - -## Aggregate Design Principles - -1. **Single Responsibility**: Each aggregate has a clear, focused responsibility -2. **Consistency Boundaries**: Aggregates maintain strong consistency internally -3. **Loose Coupling**: Aggregates communicate through domain events -4. **Reference by ID**: Aggregates reference each other by ID, not direct object references -5. **Transaction Boundaries**: One aggregate per transaction (generally) - -## Domain Events for Aggregate Coordination - -- `UserRegisteredEvent` → Role assignment coordination -- `UserActivatedEvent` → File access coordination -- `UserRoleAssignedEvent` → Permission cache invalidation -- `RolePermissionUpdatedEvent` → User permission cache refresh -- `FileUploadedEvent` → Storage service coordination - -## Implementation Guidelines - -1. **Repository Pattern**: One repository per aggregate root -2. **Application Services**: Coordinate between aggregates -3. **Domain Events**: Handle cross-aggregate consistency -4. **Specifications**: Validate business rules across aggregates -5. **Factory Methods**: Create consistent aggregate states \ No newline at end of file diff --git a/src/core/core.module.ts b/src/core/core.module.ts index 005372d8..30116f96 100644 --- a/src/core/core.module.ts +++ b/src/core/core.module.ts @@ -1,8 +1,5 @@ import { Module } from '@nestjs/common'; -import { DomainEventService } from './services/domain-event.service'; -import { DomainValidationService } from './services/domain-validation.service'; import { UserAuthorizationService } from './services/user-authorization.service'; -import { ApplicationEventService } from './services/application-event.service'; import { HealthService } from './services/health.service'; import { LoggerModule } from '@infrastructure/logger/logger.module'; import { ConfigModule } from '@nestjs/config'; @@ -14,19 +11,7 @@ import { PrismaModule } from '@infrastructure/database/prisma/prisma.module'; */ @Module({ imports: [LoggerModule, ConfigModule, PrismaModule], - providers: [ - DomainEventService, - DomainValidationService, - UserAuthorizationService, - ApplicationEventService, - HealthService, - ], - exports: [ - DomainEventService, - DomainValidationService, - UserAuthorizationService, - ApplicationEventService, - HealthService, - ], + providers: [UserAuthorizationService, HealthService], + exports: [UserAuthorizationService, HealthService], }) export class CoreModule {} diff --git a/src/core/entities/role.entity.ts b/src/core/entities/role.entity.ts index 4576478b..b45824e3 100644 --- a/src/core/entities/role.entity.ts +++ b/src/core/entities/role.entity.ts @@ -1,7 +1,6 @@ import { Permission } from './permission.entity'; import { RoleId } from '@core/value-objects/role-id.vo'; import { PermissionId } from '@core/value-objects/permission-id.vo'; -import { AggregateRoot } from '@core/events/domain-event.base'; import { CannotDeleteDefaultRoleException, PermissionAlreadyAssignedException, @@ -10,7 +9,7 @@ import { import { CanAssignPermissionToRoleSpecification } from '@core/specifications/role.specifications'; import { PermissionsCollection } from '@core/value-objects/collections/permissions.collection'; -export class Role extends AggregateRoot { +export class Role { private readonly _id: RoleId; private _name: string; private _description: string; @@ -26,7 +25,6 @@ export class Role extends AggregateRoot { isDefault: boolean = false, createdAt?: Date, ) { - super(); this.validateName(name); this.validateDescription(description); diff --git a/src/core/entities/user.entity.ts b/src/core/entities/user.entity.ts index a490229f..f469e1d4 100644 --- a/src/core/entities/user.entity.ts +++ b/src/core/entities/user.entity.ts @@ -3,19 +3,6 @@ import { Email } from '@core/value-objects/email.vo'; import { FirstName, LastName } from '@core/value-objects/name.vo'; import { UserId } from '@core/value-objects/user-id.vo'; import { RoleId } from '@core/value-objects/role-id.vo'; -import { AggregateRoot } from '@core/events/domain-event.base'; -import { - UserRegisteredEvent, - UserActivatedEvent, - UserDeactivatedEvent, - UserRoleAssignedEvent, - UserRoleRemovedEvent, - UserPasswordChangedEvent, - UserEmailChangedEvent, - UserTwoFactorEnabledEvent, - UserTwoFactorDisabledEvent, - UserLastLoginUpdatedEvent, -} from '@core/events/user.events'; import { UserNotEligibleForRoleException, UserAlreadyHasRoleException, @@ -26,7 +13,7 @@ import { import { CanAssignRoleSpecification } from '@core/specifications/user.specifications'; import { RolesCollection } from '@core/value-objects/collections/roles.collection'; -export class User extends AggregateRoot { +export class User { private readonly _id: UserId; private _email: Email; private _passwordHash: string; @@ -49,7 +36,6 @@ export class User extends AggregateRoot { isActive: boolean = true, createdAt?: Date, ) { - super(); this._id = id; this._email = email; this._passwordHash = passwordHash; @@ -72,10 +58,6 @@ export class User extends AggregateRoot { const userId = UserId.create(); const user = new User(userId, email, passwordHash, firstName, lastName); - user.addDomainEvent( - new UserRegisteredEvent(userId, email.getValue(), firstName.getValue(), lastName.getValue()), - ); - return user; } @@ -174,7 +156,6 @@ export class User extends AggregateRoot { this._isActive = true; this._updatedAt = new Date(); - this.addDomainEvent(new UserActivatedEvent(this._id)); } deactivate(): void { @@ -184,7 +165,6 @@ export class User extends AggregateRoot { this._isActive = false; this._updatedAt = new Date(); - this.addDomainEvent(new UserDeactivatedEvent(this._id)); } enableTwoFactor(secret: string): void { @@ -199,7 +179,6 @@ export class User extends AggregateRoot { this._otpEnabled = true; this._otpSecret = secret; this._updatedAt = new Date(); - this.addDomainEvent(new UserTwoFactorEnabledEvent(this._id)); } disableTwoFactor(): void { @@ -210,7 +189,6 @@ export class User extends AggregateRoot { this._otpEnabled = false; this._otpSecret = undefined; this._updatedAt = new Date(); - this.addDomainEvent(new UserTwoFactorDisabledEvent(this._id)); } // Aliases for backward compatibility @@ -238,7 +216,6 @@ export class User extends AggregateRoot { this._roles.push(role); this._updatedAt = new Date(); - this.addDomainEvent(new UserRoleAssignedEvent(this._id, role.id, role.name)); } removeRole(roleId: RoleId): void { @@ -257,7 +234,6 @@ export class User extends AggregateRoot { this._roles = this._roles.filter(r => !r.id.equals(roleId)); this._updatedAt = new Date(); - this.addDomainEvent(new UserRoleRemovedEvent(this._id, roleId, roleToRemove.name)); } changeEmail(newEmail: Email): void { @@ -269,10 +245,9 @@ export class User extends AggregateRoot { return; // Same email, no change needed } - const oldEmail = this._email.getValue(); + const _oldEmail = this._email.getValue(); this._email = newEmail; this._updatedAt = new Date(); - this.addDomainEvent(new UserEmailChangedEvent(this._id, oldEmail, newEmail.getValue())); } changePassword(newPasswordHash: string): void { @@ -286,14 +261,12 @@ export class User extends AggregateRoot { this._passwordHash = newPasswordHash; this._updatedAt = new Date(); - this.addDomainEvent(new UserPasswordChangedEvent(this._id)); } updateLastLogin(): void { const now = new Date(); this._lastLoginAt = now; this._updatedAt = now; - this.addDomainEvent(new UserLastLoginUpdatedEvent(this._id, now)); } updateProfile(firstName?: FirstName, lastName?: LastName): void { diff --git a/src/core/events/domain-event.base.ts b/src/core/events/domain-event.base.ts deleted file mode 100644 index dd12781c..00000000 --- a/src/core/events/domain-event.base.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { v4 as uuidv4 } from 'uuid'; - -/** - * Base class for all domain events - */ -export abstract class DomainEvent { - public readonly eventId: string; - public readonly occurredOn: Date; - public readonly eventVersion: number; - - constructor(version: number = 1) { - this.eventId = uuidv4(); - this.occurredOn = new Date(); - this.eventVersion = version; - } - - abstract getEventName(): string; -} - -/** - * Mixin for entities that can raise domain events - */ -export abstract class AggregateRoot { - private _domainEvents: DomainEvent[] = []; - - protected addDomainEvent(event: DomainEvent): void { - this._domainEvents.push(event); - } - - getDomainEvents(): DomainEvent[] { - return [...this._domainEvents]; - } - - clearDomainEvents(): void { - this._domainEvents = []; - } - - hasDomainEvents(): boolean { - return this._domainEvents.length > 0; - } -} diff --git a/src/core/events/user.events.ts b/src/core/events/user.events.ts deleted file mode 100644 index 46ae24fd..00000000 --- a/src/core/events/user.events.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { DomainEvent } from './domain-event.base'; -import { UserId } from '@core/value-objects/user-id.vo'; -import { RoleId } from '@core/value-objects/role-id.vo'; - -export class UserRegisteredEvent extends DomainEvent { - constructor( - public readonly userId: UserId, - public readonly email: string, - public readonly firstName: string, - public readonly lastName: string, - ) { - super(); - } - - getEventName(): string { - return 'user.registered'; - } -} - -export class UserActivatedEvent extends DomainEvent { - constructor(public readonly userId: UserId) { - super(); - } - - getEventName(): string { - return 'user.activated'; - } -} - -export class UserDeactivatedEvent extends DomainEvent { - constructor(public readonly userId: UserId) { - super(); - } - - getEventName(): string { - return 'user.deactivated'; - } -} - -export class UserRoleAssignedEvent extends DomainEvent { - constructor( - public readonly userId: UserId, - public readonly roleId: RoleId, - public readonly roleName: string, - ) { - super(); - } - - getEventName(): string { - return 'user.role.assigned'; - } -} - -export class UserRoleRemovedEvent extends DomainEvent { - constructor( - public readonly userId: UserId, - public readonly roleId: RoleId, - public readonly roleName: string, - ) { - super(); - } - - getEventName(): string { - return 'user.role.removed'; - } -} - -export class UserPasswordChangedEvent extends DomainEvent { - constructor(public readonly userId: UserId) { - super(); - } - - getEventName(): string { - return 'user.password.changed'; - } -} - -export class UserEmailChangedEvent extends DomainEvent { - constructor( - public readonly userId: UserId, - public readonly oldEmail: string, - public readonly newEmail: string, - ) { - super(); - } - - getEventName(): string { - return 'user.email.changed'; - } -} - -export class UserTwoFactorEnabledEvent extends DomainEvent { - constructor(public readonly userId: UserId) { - super(); - } - - getEventName(): string { - return 'user.two_factor.enabled'; - } -} - -export class UserTwoFactorDisabledEvent extends DomainEvent { - constructor(public readonly userId: UserId) { - super(); - } - - getEventName(): string { - return 'user.two_factor.disabled'; - } -} - -export class UserLastLoginUpdatedEvent extends DomainEvent { - constructor( - public readonly userId: UserId, - public readonly loginTime: Date, - ) { - super(); - } - - getEventName(): string { - return 'user.last_login.updated'; - } -} diff --git a/src/core/services/application-event.service.ts b/src/core/services/application-event.service.ts deleted file mode 100644 index 108b6c3b..00000000 --- a/src/core/services/application-event.service.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; -import { DomainEventService } from './domain-event.service'; -import { - UserRegisteredEvent, - UserActivatedEvent, - UserRoleAssignedEvent, - UserTwoFactorEnabledEvent, -} from '@core/events/user.events'; -import { LoggerService } from '@infrastructure/logger/logger.service'; -import { User } from '@core/entities/user.entity'; - -/** - * Application Event Service that registers domain event handlers - * This demonstrates how to use the DomainEventService in practice - */ -@Injectable() -export class ApplicationEventService implements OnModuleInit { - constructor( - private readonly domainEventService: DomainEventService, - private readonly logger: LoggerService, - ) {} - - onModuleInit() { - this.registerEventHandlers(); - } - - private registerEventHandlers(): void { - // Register handler for user registration - this.domainEventService.registerHandler( - 'UserRegisteredEvent', - async (event: UserRegisteredEvent) => { - this.logger.log({ - message: 'User registered', - userId: event.userId.getValue(), - email: event.email, - eventId: event.eventId, - }); - // Here you could add additional side effects like: - // - Send welcome email - // - Create user profile - // - Initialize user preferences - }, - ); - - // Register handler for user activation - this.domainEventService.registerHandler( - 'UserActivatedEvent', - async (event: UserActivatedEvent) => { - this.logger.log({ - message: 'User activated', - userId: event.userId.getValue(), - eventId: event.eventId, - }); - // Here you could add additional side effects like: - // - Send activation confirmation email - // - Enable user features - // - Update analytics - }, - ); - - // Register handler for role assignment - this.domainEventService.registerHandler( - 'UserRoleAssignedEvent', - async (event: UserRoleAssignedEvent) => { - this.logger.log({ - message: 'Role assigned to user', - userId: event.userId.getValue(), - roleId: event.roleId.getValue(), - roleName: event.roleName, - eventId: event.eventId, - }); - // Here you could add additional side effects like: - // - Invalidate permission cache - // - Send notification to admin - // - Update user session - }, - ); - - // Register handler for 2FA enablement - this.domainEventService.registerHandler( - 'UserTwoFactorEnabledEvent', - async (event: UserTwoFactorEnabledEvent) => { - this.logger.log({ - message: 'Two-factor authentication enabled', - userId: event.userId.getValue(), - eventId: event.eventId, - }); - // Here you could add additional side effects like: - // - Send security notification email - // - Update security audit log - // - Generate backup codes - }, - ); - } - - /** - * Dispatch events from a user entity (example usage) - */ - async dispatchUserEvents(user: User): Promise { - await this.domainEventService.dispatchEventsFromAggregate(user); - } -} diff --git a/src/core/services/domain-event.service.ts b/src/core/services/domain-event.service.ts deleted file mode 100644 index c3aac908..00000000 --- a/src/core/services/domain-event.service.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DomainEvent, AggregateRoot } from '@core/events/domain-event.base'; - -/** - * Domain Event Service for managing and dispatching domain events - * This is a core DDD pattern for handling side effects and maintaining loose coupling - */ -@Injectable() -export class DomainEventService { - private readonly eventHandlers = new Map Promise>>(); - - /** - * Register an event handler for a specific event type - */ - registerHandler( - eventName: string, - handler: (event: T) => Promise, - ): void { - if (!this.eventHandlers.has(eventName)) { - this.eventHandlers.set(eventName, []); - } - - this.eventHandlers.get(eventName)!.push(handler as (event: DomainEvent) => Promise); - } - - /** - * Dispatch all domain events from an aggregate root - */ - async dispatchEventsFromAggregate(aggregate: AggregateRoot): Promise { - const events = aggregate.getDomainEvents(); - - // Clear events from aggregate to prevent double processing - aggregate.clearDomainEvents(); - - // Process events sequentially to maintain consistency - for (const event of events) { - await this.dispatchEvent(event); - } - } - - /** - * Dispatch multiple aggregates' events in a transaction-like manner - */ - async dispatchEventsFromAggregates(aggregates: AggregateRoot[]): Promise { - // Collect all events first - const allEvents: DomainEvent[] = []; - - for (const aggregate of aggregates) { - allEvents.push(...aggregate.getDomainEvents()); - aggregate.clearDomainEvents(); - } - - // Process all events - for (const event of allEvents) { - await this.dispatchEvent(event); - } - } - - /** - * Dispatch a single domain event - */ - async dispatchEvent(event: DomainEvent): Promise { - const handlers = this.eventHandlers.get(event.getEventName()) || []; - - // Execute all handlers for this event type - const promises = handlers.map(handler => handler(event)); - - try { - await Promise.all(promises); - } catch (error) { - // Log error but don't throw to prevent transaction rollback - console.error(`Error handling domain event ${event.getEventName()}:`, error); - // In a real application, you'd use proper logging and potentially - // implement a dead letter queue or retry mechanism - } - } - - /** - * Get all registered event types - */ - getRegisteredEventTypes(): string[] { - return Array.from(this.eventHandlers.keys()); - } - - /** - * Clear all handlers (useful for testing) - */ - clearAllHandlers(): void { - this.eventHandlers.clear(); - } -} diff --git a/src/core/services/domain-validation.service.ts b/src/core/services/domain-validation.service.ts deleted file mode 100644 index d973a88f..00000000 --- a/src/core/services/domain-validation.service.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { User } from '@core/entities/user.entity'; -import { Role } from '@core/entities/role.entity'; -import { Permission } from '@core/entities/permission.entity'; -import { Email } from '@core/value-objects/email.vo'; -import { Password } from '@core/value-objects/password.vo'; -import { - ActiveUserSpecification, - CompleteUserAccountSpecification, - AdminUserSpecification, -} from '@core/specifications/user.specifications'; -import { - DefaultRoleSpecification, - AdminRoleSpecification, - HasMinimumPermissionsSpecification, -} from '@core/specifications/role.specifications'; -import { BusinessRuleValidationException } from '@core/exceptions/domain-exceptions'; - -/** - * Domain Validation Service for complex business rule validation - * Encapsulates cross-entity validation logic using specifications - */ -@Injectable() -export class DomainValidationService { - /** - * Validate user account completeness and business rules - */ - validateUserAccount(user: User): ValidationResult { - const result = new ValidationResult(); - - // Check account completeness - const completeAccountSpec = new CompleteUserAccountSpecification(); - if (!completeAccountSpec.isSatisfiedBy(user)) { - result.addError('User account is incomplete. Missing required information.'); - } - - // Validate email format (additional to entity validation) - try { - new Email(user.email.getValue()); - } catch (_) { - result.addError('User email format is invalid.'); - } - - // Validate user has at least one role - if (user.roles.length === 0) { - result.addError('User must have at least one role assigned.'); - } - - // Admin users must have 2FA enabled - const adminSpec = new AdminUserSpecification(); - if (adminSpec.isSatisfiedBy(user) && !user.otpEnabled) { - result.addWarning('Admin users should have two-factor authentication enabled.'); - } - - return result; - } - - /** - * Validate role configuration and business rules - */ - validateRole(role: Role): ValidationResult { - const result = new ValidationResult(); - - // Check minimum permissions for non-default roles - const defaultRoleSpec = new DefaultRoleSpecification(); - const minPermissionsSpec = new HasMinimumPermissionsSpecification(1); - - if (!defaultRoleSpec.isSatisfiedBy(role) && !minPermissionsSpec.isSatisfiedBy(role)) { - result.addError('Non-default roles must have at least one permission.'); - } - - // Admin roles should have substantial permissions - const adminRoleSpec = new AdminRoleSpecification(); - const minAdminPermissionsSpec = new HasMinimumPermissionsSpecification(5); - - if (adminRoleSpec.isSatisfiedBy(role) && !minAdminPermissionsSpec.isSatisfiedBy(role)) { - result.addWarning('Admin roles should have at least 5 permissions for proper functionality.'); - } - - // Validate role name conventions - if (role.name.length < 3) { - result.addError('Role name must be at least 3 characters long.'); - } - - if (role.name.toLowerCase().includes('admin') && !adminRoleSpec.isSatisfiedBy(role)) { - result.addWarning('Role name suggests admin privileges but lacks admin permissions.'); - } - - return result; - } - - /** - * Validate permission assignment business rules - */ - validatePermissionAssignment(role: Role, permission: Permission): ValidationResult { - const result = new ValidationResult(); - - // Check for conflicting permissions - const conflictingPermissions = this.findConflictingPermissions(role, permission); - if (conflictingPermissions.length > 0) { - result.addWarning( - `Permission may conflict with existing permissions: ${conflictingPermissions.join(', ')}`, - ); - } - - // Validate permission scope - if (this.isSystemCriticalPermission(permission)) { - const adminRoleSpec = new AdminRoleSpecification(); - if (!adminRoleSpec.isSatisfiedBy(role)) { - result.addError('System-critical permissions can only be assigned to admin roles.'); - } - } - - return result; - } - - /** - * Validate password complexity beyond basic requirements - */ - validatePasswordComplexity(password: string): ValidationResult { - const result = new ValidationResult(); - - try { - new Password(password); // Basic validation - } catch (error) { - result.addError(error.message); - - return result; - } - - // Additional complexity checks - if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/.test(password)) { - result.addWarning( - 'Password should contain uppercase, lowercase, numbers, and special characters for maximum security.', - ); - } - - // Check for common patterns - if (this.hasCommonPatterns(password)) { - result.addWarning('Password contains common patterns that may reduce security.'); - } - - return result; - } - - /** - * Validate business rule compliance for user role assignment - */ - validateRoleAssignment(user: User, role: Role): ValidationResult { - const result = new ValidationResult(); - - // User must be active - const activeUserSpec = new ActiveUserSpecification(); - if (!activeUserSpec.isSatisfiedBy(user)) { - result.addError('Cannot assign roles to inactive users.'); - } - - // Check role compatibility - const adminRoleSpec = new AdminRoleSpecification(); - if (adminRoleSpec.isSatisfiedBy(role)) { - // Validate admin role assignment requirements - if (!user.isEligibleForAdminRole()) { - result.addError('User is not eligible for admin role assignment.'); - } - - if (!user.otpEnabled) { - result.addWarning('Admin role assignment recommended with 2FA enabled.'); - } - } - - // Check for role conflicts - const conflictingRoles = this.findConflictingRoles(user.roles, role); - if (conflictingRoles.length > 0) { - result.addWarning(`Role may conflict with existing roles: ${conflictingRoles.join(', ')}`); - } - - return result; - } - - // Private helper methods - private findConflictingPermissions(role: Role, newPermission: Permission): string[] { - const conflicts: string[] = []; - - // Define permission conflicts (in a real app, this might come from configuration) - const conflictRules = new Map([ - ['user:delete', ['user:create', 'user:update']], // Deletion might conflict with creation/update workflows - ['system:shutdown', ['system:startup']], // System state conflicts - ]); - - const newPermissionName = newPermission.getPermissionName(); - const conflictList = conflictRules.get(newPermissionName) || []; - - for (const existingPermission of role.permissions) { - if (conflictList.includes(existingPermission.getPermissionName())) { - conflicts.push(existingPermission.getPermissionName()); - } - } - - return conflicts; - } - - private findConflictingRoles(existingRoles: Role[], newRole: Role): string[] { - const conflicts: string[] = []; - - // Define role conflicts - const roleConflicts = new Map([ - ['admin', ['guest', 'readonly']], - ['editor', ['readonly']], - ]); - - const newRoleName = newRole.name.toLowerCase(); - const conflictList = roleConflicts.get(newRoleName) || []; - - for (const existingRole of existingRoles) { - if (conflictList.includes(existingRole.name.toLowerCase())) { - conflicts.push(existingRole.name); - } - } - - return conflicts; - } - - private isSystemCriticalPermission(permission: Permission): boolean { - const criticalActions = ['delete', 'shutdown', 'configure']; - const criticalResources = ['system', 'database', 'security']; - - return ( - criticalActions.includes(permission.getAction().toLowerCase()) || - criticalResources.includes(permission.getResource().toLowerCase()) - ); - } - - private hasCommonPatterns(password: string): boolean { - const commonPatterns = [ - /123/, // Sequential numbers - /abc/i, // Sequential letters - /password/i, // Common word - /qwerty/i, // Keyboard pattern - /(.)\1{2,}/, // Repeated characters - ]; - - return commonPatterns.some(pattern => pattern.test(password)); - } -} - -/** - * Validation result container - */ -export class ValidationResult { - private readonly errors: string[] = []; - private readonly warnings: string[] = []; - - addError(message: string): void { - this.errors.push(message); - } - - addWarning(message: string): void { - this.warnings.push(message); - } - - get isValid(): boolean { - return this.errors.length === 0; - } - - get hasWarnings(): boolean { - return this.warnings.length > 0; - } - - getErrors(): string[] { - return [...this.errors]; - } - - getWarnings(): string[] { - return [...this.warnings]; - } - - getAllMessages(): string[] { - return [...this.errors, ...this.warnings]; - } - - throwIfInvalid(): void { - if (!this.isValid) { - throw new BusinessRuleValidationException(this.errors.join('; ')); - } - } -} diff --git a/src/core/services/health.service.ts b/src/core/services/health.service.ts index 09d3cc94..2ce0c65d 100644 --- a/src/core/services/health.service.ts +++ b/src/core/services/health.service.ts @@ -3,13 +3,13 @@ import { ConfigService } from '@nestjs/config'; import { PrismaService } from '@infrastructure/database/prisma/prisma.service'; import { LoggerService } from '@infrastructure/logger/logger.service'; import { - IHealthResponse, - IDatabaseHealthResponse, - IReadinessResponse, - ILivenessResponse, - IHealthCheckDetail, - IComprehensiveHealthResponse, -} from '@application/dtos/responses/health.response'; + HealthCheckResponse, + DatabaseHealthResponse, + ReadinessResponse, + LivenessResponse, + HealthCheckDetailResponse, + ComprehensiveHealthResponse, +} from '@application/dtos'; import { HealthCheckException, DatabaseConnectionException, @@ -31,7 +31,7 @@ export class HealthService { /** * Get basic application health status */ - async getHealth(): Promise { + async getHealth(): Promise { this.logger.debug('Performing basic health check'); return { @@ -46,7 +46,7 @@ export class HealthService { /** * Get database health status with connection validation */ - async getDatabaseHealth(): Promise { + async getDatabaseHealth(): Promise { this.logger.debug('Performing database health check'); try { @@ -74,7 +74,7 @@ export class HealthService { /** * Kubernetes readiness probe - comprehensive service readiness */ - async getReadiness(): Promise { + async getReadiness(): Promise { this.logger.debug('Performing readiness check'); try { @@ -119,7 +119,7 @@ export class HealthService { /** * Kubernetes liveness probe - basic service availability */ - async getLiveness(): Promise { + async getLiveness(): Promise { this.logger.debug('Performing liveness check'); // Liveness should be lightweight - just verify the process is responsive @@ -133,10 +133,10 @@ export class HealthService { /** * Comprehensive health check with detailed information */ - async getComprehensiveHealth(): Promise { + async getComprehensiveHealth(): Promise { this.logger.debug('Performing comprehensive health check'); - const checks: IHealthCheckDetail[] = []; + const checks: HealthCheckDetailResponse[] = []; let overallStatus: 'ok' | 'degraded' | 'down' = 'ok'; // Database check @@ -230,7 +230,7 @@ export class HealthService { private async performHealthCheck( name: string, checkFn: () => Promise, - ): Promise { + ): Promise { const startTime = Date.now(); try { diff --git a/src/core/services/permission.service.ts b/src/core/services/permission.service.ts deleted file mode 100644 index 69a7ff8f..00000000 --- a/src/core/services/permission.service.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Injectable, Inject } from '@nestjs/common'; -import { Permission } from '../entities/permission.entity'; -import { IPermissionRepository } from '../repositories/permission.repository.interface'; -import { - EntityNotFoundException, - EntityAlreadyExistsException, -} from '@core/exceptions/domain-exceptions'; -import { ResourceAction, ActionType } from '@core/value-objects/resource-action.vo'; -import { PERMISSION_REPOSITORY } from '@shared/constants/tokens'; - -@Injectable() -export class PermissionService { - constructor( - @Inject(PERMISSION_REPOSITORY) - private readonly permissionRepository: IPermissionRepository, - ) {} - - async createPermission( - name: string, - description: string, - resource: string, - action: string, - ): Promise { - // Check if permission already exists - const existingPermission = await this.permissionRepository.findByName(name); - if (existingPermission) { - throw new EntityAlreadyExistsException('Permission', 'name'); - } - - // Create ResourceAction value object - const resourceAction = new ResourceAction(resource, action as ActionType); - - // Create a new permission with the ResourceAction value object - const permission = Permission.create(resourceAction, description); - - return this.permissionRepository.create(permission); - } - - async updatePermission( - id: string, - name?: string, - description?: string, - resource?: string, - action?: string, - ): Promise { - const permission = await this.permissionRepository.findById(id); - if (!permission) { - throw new EntityNotFoundException('Permission', id); - } - - if (name) { - const existingPermission = await this.permissionRepository.findByName(name); - if (existingPermission && existingPermission.id.getValue() !== id) { - throw new EntityAlreadyExistsException('Permission', 'name'); - } - // We'll need to update resourceAction if name changes - // Permission name is derived from resource and action, handled by entity - } - - if (description) { - permission.updateDescription(description); - } - - // If either resource or action changes, create a new ResourceAction - if (resource || action) { - const newResource = resource || permission.resourceAction.getResource(); - const newAction = (action as ActionType) || permission.resourceAction.getAction(); - - const _newResourceAction = new ResourceAction(newResource, newAction); - // Note: Permission entity doesn't support changing resourceAction after creation - // This would require creating a new permission - } - - // Entity handles updating timestamps - - return this.permissionRepository.update(permission); - } - - async deletePermission(id: string): Promise { - const permission = await this.permissionRepository.findById(id); - if (!permission) { - throw new EntityNotFoundException('Permission', id); - } - - return this.permissionRepository.delete(id); - } -} diff --git a/src/core/services/role.service.ts b/src/core/services/role.service.ts index 2a7cec68..7dcc32f0 100644 --- a/src/core/services/role.service.ts +++ b/src/core/services/role.service.ts @@ -2,13 +2,15 @@ import { Injectable, Inject } from '@nestjs/common'; import { Role } from '../entities/role.entity'; import { IRoleRepository } from '../repositories/role.repository.interface'; import { IPermissionRepository } from '../repositories/permission.repository.interface'; +import { IUserRepository } from '../repositories/user.repository.interface'; import { EntityNotFoundException, EntityAlreadyExistsException, ForbiddenActionException, } from '@core/exceptions/domain-exceptions'; import { PermissionId } from '@core/value-objects/permission-id.vo'; -import { ROLE_REPOSITORY, PERMISSION_REPOSITORY } from '@shared/constants/tokens'; +import { ROLE_REPOSITORY, PERMISSION_REPOSITORY, USER_REPOSITORY } from '@shared/constants/tokens'; +import { UserAuthorizationService } from './user-authorization.service'; @Injectable() export class RoleService { @@ -17,6 +19,9 @@ export class RoleService { private readonly roleRepository: IRoleRepository, @Inject(PERMISSION_REPOSITORY) private readonly permissionRepository: IPermissionRepository, + @Inject(USER_REPOSITORY) + private readonly userRepository: IUserRepository, + private readonly userAuthorizationService: UserAuthorizationService, ) {} async createRole(name: string, description: string, isDefault: boolean = false): Promise { @@ -108,12 +113,25 @@ export class RoleService { return this.roleRepository.update(role); } - async deleteRole(id: string): Promise { + async deleteRole(id: string, deleterId?: string): Promise { const role = await this.roleRepository.findById(id); if (!role) { throw new EntityNotFoundException('Role', id); } + // If deleterId is provided, check authorization + if (deleterId) { + const deleterUser = await this.userRepository.findById(deleterId); + if (!deleterUser) { + throw new EntityNotFoundException('User', deleterId); + } + + // Check if the deleter can delete this role + if (!this.userAuthorizationService.canDeleteRole(deleterUser, role)) { + throw new ForbiddenActionException('You are not authorized to delete this role'); + } + } + if (role.isDefault) { throw new ForbiddenActionException('Cannot delete the default role'); } diff --git a/src/core/services/user-authorization.service.ts b/src/core/services/user-authorization.service.ts index 6db9df60..dd2203bd 100644 --- a/src/core/services/user-authorization.service.ts +++ b/src/core/services/user-authorization.service.ts @@ -7,7 +7,6 @@ import { AdminUserSpecification, UserHasPermissionSpecification, CanAssignRoleSpecification, - EligibleForAdminRoleSpecification, CompleteUserAccountSpecification, } from '@core/specifications/user.specifications'; import { @@ -66,7 +65,7 @@ export class UserAuthorizationService { // Additional rule: only super admins can assign admin roles const adminRoleSpec = new AdminRoleSpecification(); if (adminRoleSpec.isSatisfiedBy(role)) { - const assignerPermissionSpec = new UserHasPermissionSpecification('user:assign-admin-role'); + const assignerPermissionSpec = new UserHasPermissionSpecification('role:update'); return assignerPermissionSpec.isSatisfiedBy(assignerUser); } @@ -110,61 +109,4 @@ export class UserAuthorizationService { return hasPermissionSpec.isSatisfiedBy(user); } - - /** - * Check if a user can become an admin - */ - canBecomeAdmin(user: User): boolean { - const activeUserSpec = new ActiveUserSpecification(); - const eligibleForAdminSpec = new EligibleForAdminRoleSpecification(); - const completeAccountSpec = new CompleteUserAccountSpecification(); - - // Combine specifications for admin eligibility - const adminEligibilitySpec = activeUserSpec.and(eligibleForAdminSpec).and(completeAccountSpec); - - return adminEligibilitySpec.isSatisfiedBy(user); - } - - /** - * Get security level for a user (for audit logging) - */ - getUserSecurityLevel(user: User): 'low' | 'medium' | 'high' | 'critical' { - const activeUserSpec = new ActiveUserSpecification(); - const twoFactorSpec = new TwoFactorEnabledSpecification(); - const adminUserSpec = new AdminUserSpecification(); - - if (!activeUserSpec.isSatisfiedBy(user)) { - return 'low'; - } - - if (adminUserSpec.isSatisfiedBy(user)) { - if (twoFactorSpec.isSatisfiedBy(user)) { - return 'critical'; - } - - return 'high'; - } - - if (twoFactorSpec.isSatisfiedBy(user)) { - return 'medium'; - } - - return 'low'; - } - - /** - * Check if user access should be logged (for compliance) - */ - shouldLogAccess(user: User, resource: string): boolean { - const adminUserSpec = new AdminUserSpecification(); - const sensitiveResources = ['user', 'role', 'permission', 'audit', 'system']; - - // Always log admin user access - if (adminUserSpec.isSatisfiedBy(user)) { - return true; - } - - // Log access to sensitive resources - return sensitiveResources.includes(resource.toLowerCase()); - } } diff --git a/src/core/services/user.service.spec.ts b/src/core/services/user.service.spec.ts index 9f4b741d..ea5d2351 100644 --- a/src/core/services/user.service.spec.ts +++ b/src/core/services/user.service.spec.ts @@ -7,7 +7,7 @@ import { createMockUserRepository, createMockRoleRepository, } from '../../test/mocks/repositories.factory'; -import { DomainValidationService, ValidationResult } from './domain-validation.service'; +import { UserAuthorizationService } from './user-authorization.service'; // Tokens import { USER_REPOSITORY, ROLE_REPOSITORY } from '@shared/constants/tokens'; @@ -30,36 +30,29 @@ jest.mock('bcrypt', () => ({ compare: jest.fn().mockResolvedValue(true), })); -// Mock DomainValidationService -const createMockDomainValidationService = () => ({ - validatePasswordComplexity: jest.fn().mockReturnValue({ - isValid: true, - throwIfInvalid: jest.fn(), - }), - validateRoleAssignment: jest.fn().mockReturnValue({ - isValid: true, - throwIfInvalid: jest.fn(), - }), +// Mock UserAuthorizationService +const createMockUserAuthorizationService = () => ({ + canAssignRole: jest.fn().mockReturnValue(true), }); describe('UserService', () => { let service: UserService; let userRepository; let roleRepository; - let domainValidationService; + let userAuthorizationService; beforeEach(async () => { // Create fresh mocks for each test userRepository = createMockUserRepository(); roleRepository = createMockRoleRepository(); - domainValidationService = createMockDomainValidationService(); + userAuthorizationService = createMockUserAuthorizationService(); const module: TestingModule = await Test.createTestingModule({ providers: [ UserService, { provide: USER_REPOSITORY, useValue: userRepository }, { provide: ROLE_REPOSITORY, useValue: roleRepository }, - { provide: DomainValidationService, useValue: domainValidationService }, + { provide: UserAuthorizationService, useValue: userAuthorizationService }, ], }).compile(); diff --git a/src/core/services/user.service.ts b/src/core/services/user.service.ts index 0e7fee84..718931b0 100644 --- a/src/core/services/user.service.ts +++ b/src/core/services/user.service.ts @@ -8,12 +8,13 @@ import { EntityNotFoundException, EntityAlreadyExistsException, AuthenticationException, + ForbiddenActionException, } from '@core/exceptions/domain-exceptions'; import { Email } from '@core/value-objects/email.vo'; import { Password } from '@core/value-objects/password.vo'; import { FirstName, LastName } from '@core/value-objects/name.vo'; import { RoleId } from '@core/value-objects/role-id.vo'; -import { DomainValidationService } from './domain-validation.service'; +import { UserAuthorizationService } from './user-authorization.service'; @Injectable() export class UserService { @@ -22,7 +23,7 @@ export class UserService { private readonly userRepository: IUserRepository, @Inject(ROLE_REPOSITORY) private readonly roleRepository: IRoleRepository, - private readonly domainValidationService: DomainValidationService, + private readonly userAuthorizationService: UserAuthorizationService, ) {} async createUser( @@ -154,12 +155,7 @@ export class UserService { } } - // Validate password complexity using domain validation service - const passwordValidation = - this.domainValidationService.validatePasswordComplexity(newPasswordStr); - passwordValidation.throwIfInvalid(); - - // Validate new password using value object + // Validate new password using value object (includes complexity validation) const newPassword = new Password(newPasswordStr); user.changePassword(await this.hashPassword(newPassword.getValue())); @@ -168,9 +164,9 @@ export class UserService { return this.userRepository.update(user); } - async assignRoleToUser(userId: string, roleId: string): Promise { - const user = await this.userRepository.findById(userId); - if (!user) { + async assignRoleToUser(userId: string, roleId: string, assignerId?: string): Promise { + const targetUser = await this.userRepository.findById(userId); + if (!targetUser) { throw new EntityNotFoundException('User', userId); } @@ -179,16 +175,23 @@ export class UserService { throw new EntityNotFoundException('Role', roleId); } - // Validate role assignment using domain validation service - const roleAssignmentValidation = this.domainValidationService.validateRoleAssignment( - user, - role, - ); - roleAssignmentValidation.throwIfInvalid(); + // If assignerId is provided, check authorization + if (assignerId) { + const assignerUser = await this.userRepository.findById(assignerId); + if (!assignerUser) { + throw new EntityNotFoundException('User', assignerId); + } - user.addRole(role); + // Check if the assigner can assign this role + if (!this.userAuthorizationService.canAssignRole(assignerUser, targetUser, role)) { + throw new ForbiddenActionException('You are not authorized to assign this role'); + } + } - return this.userRepository.update(user); + // Role assignment validation is handled by the User entity's addRole method + targetUser.addRole(role); + + return this.userRepository.update(targetUser); } async removeRoleFromUser(userId: string, roleId: string): Promise { diff --git a/src/core/specifications/user.specifications.ts b/src/core/specifications/user.specifications.ts index 18450ddd..b86e7a91 100644 --- a/src/core/specifications/user.specifications.ts +++ b/src/core/specifications/user.specifications.ts @@ -1,7 +1,6 @@ import { Specification } from './specification.base'; import { User } from '@core/entities/user.entity'; import { Role } from '@core/entities/role.entity'; -import { RoleId } from '@core/value-objects/role-id.vo'; /** * Specification to check if a user is active @@ -21,19 +20,6 @@ export class TwoFactorEnabledSpecification extends Specification { } } -/** - * Specification to check if a user has a specific role - */ -export class UserHasRoleSpecification extends Specification { - constructor(private readonly roleId: RoleId) { - super(); - } - - isSatisfiedBy(user: User): boolean { - return user.hasRole(this.roleId); - } -} - /** * Specification to check if a user has admin privileges */ @@ -91,17 +77,6 @@ export class CanAssignRoleSpecification extends Specification { } } -/** - * Specification to check if a user can be deactivated - */ -export class CanDeactivateUserSpecification extends Specification { - isSatisfiedBy(user: User): boolean { - // Business rule: Active users can be deactivated - // Additional rules can be added here (e.g., cannot deactivate last admin) - return user.isActive; - } -} - /** * Specification to check if a user account is complete */ diff --git a/src/core/value-objects/resource-action.vo.ts b/src/core/value-objects/resource-action.vo.ts index 28a626ee..a96c1155 100644 --- a/src/core/value-objects/resource-action.vo.ts +++ b/src/core/value-objects/resource-action.vo.ts @@ -1,24 +1,34 @@ import { InvalidValueObjectException } from '@core/exceptions/domain-exceptions'; +export enum ResourceType { + USER = 'user', + ROLE = 'role', + STORAGE = 'storage', + AUDIT = 'audit', +} + export enum ActionType { READ = 'read', - WRITE = 'write', + CREATE = 'create', + UPDATE = 'update', DELETE = 'delete', - MANAGE = 'manage', } export class ResourceAction { private readonly resource: string; private readonly action: ActionType; - constructor(resource: string, action: ActionType | string) { - if (!this.isValidResource(resource)) { + constructor(resource: ResourceType | string, action: ActionType | string) { + const resourceValue = + typeof resource === 'string' ? this.parseResourceType(resource) : resource; + + if (!this.isValidResource(resourceValue)) { throw new InvalidValueObjectException('Invalid resource name'); } const actionValue = typeof action === 'string' ? this.parseActionType(action) : action; - this.resource = resource.toLowerCase(); + this.resource = resourceValue.toLowerCase(); this.action = actionValue; } @@ -27,6 +37,13 @@ export class ResourceAction { return /^[a-z0-9-]+$/.test(resource) && resource.length > 0; } + private parseResourceType(resource: string): ResourceType { + if (Object.values(ResourceType).includes(resource as ResourceType)) { + return resource as ResourceType; + } + throw new InvalidValueObjectException('Invalid resource type'); + } + private parseActionType(action: string): ActionType { if (Object.values(ActionType).includes(action as ActionType)) { return action as ActionType; @@ -41,14 +58,4 @@ export class ResourceAction { getAction(): ActionType { return this.action; } - - getPermissionName(): string { - return `${this.resource}:${this.action}`; - } - - equals(resourceAction: ResourceAction): boolean { - return ( - this.resource === resourceAction.getResource() && this.action === resourceAction.getAction() - ); - } } diff --git a/src/presentation/guards/auth.guard.ts b/src/presentation/guards/auth.guard.ts deleted file mode 100644 index d4daf3e4..00000000 --- a/src/presentation/guards/auth.guard.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { - Injectable, - CanActivate, - ExecutionContext, - ForbiddenException, - UnauthorizedException, -} from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { ROLES_KEY } from '@shared/decorators/roles.decorator'; -import { PERMISSIONS_KEY } from '@shared/decorators/permissions.decorator'; - -@Injectable() -export class AuthGuard implements CanActivate { - constructor(private reflector: Reflector) {} - - async canActivate(context: ExecutionContext): Promise { - const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ - context.getHandler(), - context.getClass(), - ]); - - const requiredPermissions = this.reflector.getAllAndOverride(PERMISSIONS_KEY, [ - context.getHandler(), - context.getClass(), - ]); - - // If no role or permission requirements, allow access - if (!requiredRoles && !requiredPermissions) { - return true; - } - - const request = context.switchToHttp().getRequest(); - const user = request.user; - - if (!user) { - throw new UnauthorizedException('User not authenticated'); - } - - // Check roles if required - if (requiredRoles && requiredRoles.length > 0) { - const userRoles = user.roles || []; - const hasRequiredRole = requiredRoles.some(role => userRoles.includes(role)); - - if (!hasRequiredRole) { - throw new ForbiddenException( - `User does not have required role: ${requiredRoles.join(', ')}`, - ); - } - } - - // Check permissions if required - if (requiredPermissions && requiredPermissions.length > 0) { - const userPermissions = user.permissions || []; - const hasRequiredPermission = requiredPermissions.some(permission => - userPermissions.includes(permission), - ); - - if (!hasRequiredPermission) { - throw new ForbiddenException( - `User does not have required permission: ${requiredPermissions.join(', ')}`, - ); - } - } - - return true; - } -} diff --git a/src/presentation/guards/permissions.guard.ts b/src/presentation/guards/permissions.guard.ts index f64b04c1..e1ad2c8f 100644 --- a/src/presentation/guards/permissions.guard.ts +++ b/src/presentation/guards/permissions.guard.ts @@ -22,7 +22,7 @@ export class PermissionsGuard implements CanActivate { return false; } - // Check for resource and action metadata + // Check for resource and action metadata (method level) const resource = this.reflector.get('resource', context.getHandler()); const action = this.reflector.get('action', context.getHandler()); @@ -30,14 +30,20 @@ export class PermissionsGuard implements CanActivate { return this.userAuthorizationService.canAccessResource(user, resource, action); } - // Check for admin access requirement - const requiresAdmin = this.reflector.get('admin', context.getHandler()); + // Check for admin access requirement (check both class and method level) + const requiresAdmin = this.reflector.getAllAndOverride('admin', [ + context.getHandler(), + context.getClass(), + ]); if (requiresAdmin) { return this.userAuthorizationService.canAccessAdminFeatures(user); } - // Check for sensitive operation requirement - const requiresSensitive = this.reflector.get('sensitive', context.getHandler()); + // Check for sensitive operation requirement (check both class and method level) + const requiresSensitive = this.reflector.getAllAndOverride('sensitive', [ + context.getHandler(), + context.getClass(), + ]); if (requiresSensitive) { return this.userAuthorizationService.canPerformSensitiveOperations(user); } diff --git a/src/presentation/guards/roles.guard.ts b/src/presentation/guards/roles.guard.ts deleted file mode 100644 index 17742607..00000000 --- a/src/presentation/guards/roles.guard.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from './auth.guard'; - -/** - * RolesGuard is now an alias for AuthGuard, which has been enhanced - * to handle both role-based and permission-based access control. - * - * This guard is kept for backward compatibility. - */ -@Injectable() -export class RolesGuard extends AuthGuard {} diff --git a/src/presentation/modules/admin/admin.controller.ts b/src/presentation/modules/admin/admin.controller.ts index 8066c9e6..dfeb5564 100644 --- a/src/presentation/modules/admin/admin.controller.ts +++ b/src/presentation/modules/admin/admin.controller.ts @@ -6,6 +6,7 @@ import { PermissionsGuard } from '@presentation/guards/permissions.guard'; import { RequiresAdmin } from '@shared/decorators/admin.decorator'; import { RequiresSensitive } from '@shared/decorators/sensitive.decorator'; import { RequiresResourceAction } from '@shared/decorators/resource-action.decorator'; +import { ResourceType, ActionType } from '@core/value-objects/resource-action.vo'; @ApiTags('admin') @Controller('admin') @@ -50,7 +51,7 @@ export class AdminController { @Get('audit-logs') @HttpCode(HttpStatus.OK) - @RequiresResourceAction('audit', 'read') + @RequiresResourceAction(ResourceType.AUDIT, ActionType.READ) @ApiOperation({ summary: 'Get audit logs (Requires specific permission)' }) @ApiResponse({ status: HttpStatus.OK, description: 'Returns audit logs' }) @ApiResponse({ diff --git a/src/presentation/modules/auth/auth.controller.ts b/src/presentation/modules/auth/auth.controller.ts index 34d0e2b7..7ba65491 100644 --- a/src/presentation/modules/auth/auth.controller.ts +++ b/src/presentation/modules/auth/auth.controller.ts @@ -3,18 +3,17 @@ import { CommandBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; // DTOs -import { RegisterDto } from '@application/dtos/auth/register.dto'; -import { LoginDto } from '@application/dtos/auth/login.dto'; -import { VerifyOtpDto } from '@application/dtos/auth/verify-otp.dto'; -import { RefreshTokenDto } from '@application/dtos/auth/refresh-token.dto'; import { - SendVerificationEmailDto, - VerifyEmailDto, -} from '@application/dtos/auth/email-verification.dto'; -import { - RequestPasswordResetDto, - ResetPasswordDto, -} from '@application/dtos/auth/password-reset.dto'; + RegisterRequest, + LoginRequest, + VerifyOtpRequest, + RefreshTokenRequest, + SendVerificationEmailRequest, + VerifyEmailRequest, + RequestPasswordResetRequest, + ResetPasswordRequest, + IJwtPayload, +} from '@application/dtos'; // Commands import { RegisterUserCommand } from '@application/commands/auth/register-user.command'; @@ -32,7 +31,6 @@ import { ResetPasswordCommand } from '@application/commands/auth/reset-password. import { Public } from '@shared/decorators/public.decorator'; import { CurrentUser } from '@shared/decorators/current-user.decorator'; import { SkipThrottle, Throttle } from '@shared/decorators/throttle.decorator'; -import { IJwtPayload } from '@application/dtos/responses/user.response'; @ApiTags('auth') @Throttle(60, 5) // 5 requests per minute @@ -47,7 +45,7 @@ export class AuthController { @ApiResponse({ status: HttpStatus.CREATED, description: 'User successfully registered' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input data' }) @ApiResponse({ status: HttpStatus.CONFLICT, description: 'User with this email already exists' }) - async register(@Body() registerDto: RegisterDto) { + async register(@Body() registerDto: RegisterRequest) { return this.commandBus.execute(new RegisterUserCommand(registerDto)); } @@ -61,7 +59,7 @@ export class AuthController { 'User successfully authenticated. Returns access token, refresh token, and user data. May return OTP requirement if 2FA is enabled.', }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: 'Invalid credentials' }) - async login(@Body() loginDto: LoginDto) { + async login(@Body() loginDto: LoginRequest) { return this.commandBus.execute(new LoginCommand(loginDto)); } @@ -83,7 +81,7 @@ export class AuthController { }, }, }) - async verifyOtp(@Body('userId') userId: string, @Body() verifyOtpDto: VerifyOtpDto) { + async verifyOtp(@Body('userId') userId: string, @Body() verifyOtpDto: VerifyOtpRequest) { return this.commandBus.execute(new VerifyOtpCommand(userId, verifyOtpDto)); } @@ -96,7 +94,7 @@ export class AuthController { description: 'Token refreshed successfully. Returns new access token and refresh token.', }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: 'Invalid refresh token' }) - async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) { + async refreshToken(@Body() refreshTokenDto: RefreshTokenRequest) { return this.commandBus.execute(new RefreshTokenCommand(refreshTokenDto)); } @@ -131,7 +129,7 @@ export class AuthController { @ApiOperation({ summary: 'Send email verification code' }) @ApiResponse({ status: HttpStatus.OK, description: 'Verification email sent successfully' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid email format' }) - async sendVerificationEmail(@Body() sendVerificationEmailDto: SendVerificationEmailDto) { + async sendVerificationEmail(@Body() sendVerificationEmailDto: SendVerificationEmailRequest) { return this.commandBus.execute(new SendVerificationEmailCommand(sendVerificationEmailDto)); } @@ -152,7 +150,7 @@ export class AuthController { status: HttpStatus.BAD_REQUEST, description: 'Invalid or expired verification code', }) - async verifyEmail(@Body() verifyEmailDto: VerifyEmailDto) { + async verifyEmail(@Body() verifyEmailDto: VerifyEmailRequest) { return this.commandBus.execute(new VerifyEmailCommand(verifyEmailDto)); } @@ -178,7 +176,7 @@ export class AuthController { @ApiOperation({ summary: 'Request a password reset email' }) @ApiResponse({ status: HttpStatus.OK, description: 'Password reset email sent successfully' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid email format' }) - async requestPasswordReset(@Body() requestPasswordResetDto: RequestPasswordResetDto) { + async requestPasswordReset(@Body() requestPasswordResetDto: RequestPasswordResetRequest) { return this.commandBus.execute(new RequestPasswordResetCommand(requestPasswordResetDto)); } @@ -189,7 +187,7 @@ export class AuthController { @ApiResponse({ status: HttpStatus.OK, description: 'Password reset successfully' }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: 'Invalid or expired token' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid password format' }) - async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) { + async resetPassword(@Body() resetPasswordDto: ResetPasswordRequest) { return this.commandBus.execute(new ResetPasswordCommand(resetPasswordDto)); } } diff --git a/src/presentation/modules/auth/strategies/jwt.strategy.ts b/src/presentation/modules/auth/strategies/jwt.strategy.ts index c38815b1..bc9d036a 100644 --- a/src/presentation/modules/auth/strategies/jwt.strategy.ts +++ b/src/presentation/modules/auth/strategies/jwt.strategy.ts @@ -3,8 +3,9 @@ import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; import { IUserRepository } from '@core/repositories/user.repository.interface'; -import { IJwtPayload } from '@application/dtos/responses/user.response'; +import { IJwtPayload } from '@application/dtos'; import { USER_REPOSITORY } from '@shared/constants/tokens'; +import { User } from '@core/entities/user.entity'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -20,7 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - async validate(payload: IJwtPayload): Promise { + async validate(payload: IJwtPayload): Promise { // Check if the user still exists const user = await this.userRepository.findById(payload.sub); @@ -29,12 +30,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) { throw new UnauthorizedException('User no longer active or not found'); } - // Return the payload with roles and permissions which will be injected into the request object - return { - sub: payload.sub, - email: payload.email, - roles: payload.roles || [], - permissions: payload.permissions || [], - }; + // Return the complete User entity which will be injected into the request object + // This ensures that guards like PermissionsGuard have access to all user methods + // including hasPermission() and the isActive property + return user; } } diff --git a/src/presentation/modules/health/health.controller.ts b/src/presentation/modules/health/health.controller.ts index aab76fb7..20e95880 100644 --- a/src/presentation/modules/health/health.controller.ts +++ b/src/presentation/modules/health/health.controller.ts @@ -13,11 +13,11 @@ import { GetLivenessQuery } from '@application/queries/health/get-liveness.query // Response interfaces import { - IHealthResponse, - IDatabaseHealthResponse, - IReadinessResponse, - ILivenessResponse, -} from '@application/dtos/responses/health.response'; + HealthCheckResponse, + DatabaseHealthResponse, + ReadinessResponse, + LivenessResponse, +} from '@application/dtos'; @ApiTags('health') @Controller('health') @@ -42,7 +42,7 @@ export class HealthController { }, }, }) - async getHealth(): Promise { + async getHealth(): Promise { return this.queryBus.execute(new GetHealthQuery()); } @@ -66,7 +66,7 @@ export class HealthController { status: HttpStatus.SERVICE_UNAVAILABLE, description: 'Database is unhealthy', }) - async getDatabaseHealth(): Promise { + async getDatabaseHealth(): Promise { return this.queryBus.execute(new GetDatabaseHealthQuery()); } @@ -96,7 +96,7 @@ export class HealthController { status: HttpStatus.SERVICE_UNAVAILABLE, description: 'Service is not ready', }) - async getReadiness(): Promise { + async getReadiness(): Promise { return this.queryBus.execute(new GetReadinessQuery()); } @@ -116,7 +116,7 @@ export class HealthController { }, }, }) - async getLiveness(): Promise { + async getLiveness(): Promise { return this.queryBus.execute(new GetLivenessQuery()); } } diff --git a/src/presentation/modules/role/role.controller.ts b/src/presentation/modules/role/role.controller.ts index 9aea2947..e82cbce8 100644 --- a/src/presentation/modules/role/role.controller.ts +++ b/src/presentation/modules/role/role.controller.ts @@ -15,12 +15,12 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@ne // Guards & Decorators import { PermissionsGuard } from '@presentation/guards/permissions.guard'; -import { RequirePermissions } from '@shared/decorators/permissions.decorator'; -import { CanWrite, CanDelete } from '@shared/decorators/resource-permissions.decorator'; +import { RequiresResourceAction } from '@shared/decorators/resource-action.decorator'; +import { CurrentUser } from '@shared/decorators/current-user.decorator'; +import { IJwtPayload, CreateRoleRequest, UpdateRoleRequest } from '@application/dtos'; +import { ResourceType, ActionType } from '@core/value-objects/resource-action.vo'; // DTOs -import { CreateRoleDto } from '@application/dtos/role/create-role.dto'; -import { UpdateRoleDto } from '@application/dtos/role/update-role.dto'; // Queries import { GetRolesQuery } from '@application/queries/role/get-roles.query'; @@ -36,7 +36,6 @@ import { RemovePermissionCommand } from '@application/commands/role/remove-permi @ApiTags('roles') @Controller('roles') @UseGuards(PermissionsGuard) -@RequirePermissions('role:read') @ApiBearerAuth('JWT-auth') export class RoleController { constructor( @@ -45,36 +44,38 @@ export class RoleController { ) {} @Get() + @RequiresResourceAction(ResourceType.ROLE, ActionType.READ) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Get all roles (Admin only)' }) + @ApiOperation({ summary: 'Get all roles (Requires role:read permission)' }) @ApiResponse({ status: HttpStatus.OK, description: 'Returns a list of all roles' }) - @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'User does not have admin role' }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Insufficient permissions' }) async getAllRoles() { return this.queryBus.execute(new GetRolesQuery()); } @Get(':id') + @RequiresResourceAction(ResourceType.ROLE, ActionType.READ) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Get role by ID (Admin only)' }) + @ApiOperation({ summary: 'Get role by ID (Requires role:read permission)' }) @ApiParam({ name: 'id', description: 'Role ID', example: '550e8400-e29b-41d4-a716-446655440000' }) @ApiResponse({ status: HttpStatus.OK, description: 'Returns role information' }) @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Role not found' }) - @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'User does not have admin role' }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Insufficient permissions' }) async getRoleById(@Param('id') id: string) { return this.queryBus.execute(new GetRoleQuery(id)); } @Post() - @CanWrite('role') + @RequiresResourceAction(ResourceType.ROLE, ActionType.CREATE) @HttpCode(HttpStatus.CREATED) - @ApiOperation({ summary: 'Create new role (Requires role:write permission)' }) + @ApiOperation({ summary: 'Create new role (Requires role:create permission)' }) @ApiResponse({ status: HttpStatus.CREATED, description: 'Role created successfully' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input data' }) @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'User does not have required permission', }) - async createRole(@Body() createRoleDto: CreateRoleDto) { + async createRole(@Body() createRoleDto: CreateRoleRequest) { return this.commandBus.execute( new CreateRoleCommand( createRoleDto.name, @@ -86,9 +87,9 @@ export class RoleController { } @Put(':id') - @CanWrite('role') + @RequiresResourceAction(ResourceType.ROLE, ActionType.UPDATE) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Update role by ID (Requires role:write permission)' }) + @ApiOperation({ summary: 'Update role by ID (Requires role:update permission)' }) @ApiParam({ name: 'id', description: 'Role ID', example: '550e8400-e29b-41d4-a716-446655440000' }) @ApiResponse({ status: HttpStatus.OK, description: 'Role updated successfully' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input data' }) @@ -97,7 +98,7 @@ export class RoleController { status: HttpStatus.FORBIDDEN, description: 'User does not have required permission', }) - async updateRole(@Param('id') id: string, @Body() updateRoleDto: UpdateRoleDto) { + async updateRole(@Param('id') id: string, @Body() updateRoleDto: UpdateRoleRequest) { return this.commandBus.execute( new UpdateRoleCommand( id, @@ -109,7 +110,7 @@ export class RoleController { } @Delete(':id') - @CanDelete('role') + @RequiresResourceAction(ResourceType.ROLE, ActionType.DELETE) @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Delete role by ID (Requires role:delete permission)' }) @ApiParam({ name: 'id', description: 'Role ID', example: '550e8400-e29b-41d4-a716-446655440000' }) @@ -119,16 +120,16 @@ export class RoleController { status: HttpStatus.FORBIDDEN, description: 'User does not have required permission', }) - async deleteRole(@Param('id') id: string) { - await this.commandBus.execute(new DeleteRoleCommand(id)); + async deleteRole(@Param('id') id: string, @CurrentUser() currentUser: IJwtPayload) { + await this.commandBus.execute(new DeleteRoleCommand(id, currentUser.sub)); return { message: 'Role deleted successfully' }; } @Post(':roleId/permissions/:permissionId') - @CanWrite('role') + @RequiresResourceAction(ResourceType.ROLE, ActionType.UPDATE) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Assign permission to role (Requires role:write permission)' }) + @ApiOperation({ summary: 'Assign permission to role (Requires role:update permission)' }) @ApiParam({ name: 'roleId', description: 'Role ID', @@ -153,9 +154,9 @@ export class RoleController { } @Delete(':roleId/permissions/:permissionId') - @CanWrite('role') + @RequiresResourceAction(ResourceType.ROLE, ActionType.UPDATE) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Remove permission from role (Requires role:write permission)' }) + @ApiOperation({ summary: 'Remove permission from role (Requires role:update permission)' }) @ApiParam({ name: 'roleId', description: 'Role ID', diff --git a/src/presentation/modules/role/role.module.ts b/src/presentation/modules/role/role.module.ts index a8a8bee3..37a2163c 100644 --- a/src/presentation/modules/role/role.module.ts +++ b/src/presentation/modules/role/role.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; -import { ROLE_REPOSITORY, PERMISSION_REPOSITORY } from '@shared/constants/tokens'; +import { ROLE_REPOSITORY, PERMISSION_REPOSITORY, USER_REPOSITORY } from '@shared/constants/tokens'; // Controllers import { RoleController } from './role.controller'; @@ -8,12 +8,12 @@ import { RoleController } from './role.controller'; // Repositories import { RoleRepository } from '@infrastructure/repositories/role.repository'; import { PermissionRepository } from '@infrastructure/repositories/permission.repository'; +import { UserRepository } from '@infrastructure/repositories/user.repository'; import { PrismaModule } from '@infrastructure/database/prisma/prisma.module'; import { CoreModule } from '@core/core.module'; // Services import { RoleService } from '@core/services/role.service'; -import { PermissionService } from '@core/services/permission.service'; // Query Handlers import { GetRolesQueryHandler } from '@application/queries/role/get-roles.query'; @@ -42,7 +42,6 @@ const commandHandlers = [ providers: [ // Services RoleService, - PermissionService, // Repository tokens { @@ -53,6 +52,10 @@ const commandHandlers = [ provide: PERMISSION_REPOSITORY, useClass: PermissionRepository, }, + { + provide: USER_REPOSITORY, + useClass: UserRepository, + }, // Query handlers ...queryHandlers, diff --git a/src/presentation/modules/storage/storage.controller.ts b/src/presentation/modules/storage/storage.controller.ts index 8d00884a..66e37a92 100644 --- a/src/presentation/modules/storage/storage.controller.ts +++ b/src/presentation/modules/storage/storage.controller.ts @@ -11,6 +11,8 @@ import { ParseFilePipe, MaxFileSizeValidator, FileTypeValidator, + UseGuards, + HttpStatus, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; @@ -21,21 +23,25 @@ import { ApiOperation, ApiParam, ApiBearerAuth, + ApiResponse, } from '@nestjs/swagger'; +import { PermissionsGuard } from '@presentation/guards/permissions.guard'; +import { RequiresResourceAction } from '@shared/decorators/resource-action.decorator'; import { CurrentUser } from '@shared/decorators/current-user.decorator'; +import { ResourceType, ActionType } from '@core/value-objects/resource-action.vo'; import { UploadFileCommand } from '@application/commands/storage/upload-file.command'; import { DeleteFileCommand } from '@application/commands/storage/delete-file.command'; import { UpdateFileAccessCommand } from '@application/commands/storage/update-file-access.command'; import { GetFileQuery } from '@application/queries/storage/get-file.query'; import { GetUserFilesQuery } from '@application/queries/storage/get-user-files.query'; -import { UpdateFileAccessDto } from '@application/dtos/storage/update-file-access.dto'; -import { FileResponseDto } from '@application/dtos/responses/file.response'; -import { IJwtPayload } from '@application/dtos/responses/user.response'; +import { UpdateFileAccessRequest, FileResponse, IJwtPayload } from '@application/dtos'; @ApiTags('storage') @Controller('storage') +@UseGuards(PermissionsGuard) +@ApiBearerAuth('JWT-auth') export class StorageController { constructor( private readonly commandBus: CommandBus, @@ -43,8 +49,10 @@ export class StorageController { ) {} @Post('upload') - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Upload a file' }) + @RequiresResourceAction(ResourceType.STORAGE, ActionType.CREATE) + @ApiOperation({ summary: 'Upload a file (Requires storage:create permission)' }) + @ApiResponse({ status: HttpStatus.CREATED, description: 'File uploaded successfully' }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Insufficient permissions' }) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { @@ -71,7 +79,7 @@ export class StorageController { ) file: Express.Multer.File, @CurrentUser() user: IJwtPayload, - ): Promise { + ): Promise { const storageFile = { buffer: file.buffer, originalname: file.originalname, @@ -83,44 +91,53 @@ export class StorageController { } @Get(':id') - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get file by ID' }) + @RequiresResourceAction(ResourceType.STORAGE, ActionType.READ) + @ApiOperation({ summary: 'Get file by ID (Requires storage:read permission)' }) @ApiParam({ name: 'id', description: 'File ID' }) - async getFile( - @Param('id') id: string, - @CurrentUser() user: IJwtPayload, - ): Promise { + @ApiResponse({ status: HttpStatus.OK, description: 'File retrieved successfully' }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Insufficient permissions' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'File not found' }) + async getFile(@Param('id') id: string, @CurrentUser() user: IJwtPayload): Promise { return this.queryBus.execute(new GetFileQuery(id, user.sub)); } @Get('user/files') - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get all files for the current user' }) - async getUserFiles(@CurrentUser() user: IJwtPayload): Promise { + @RequiresResourceAction(ResourceType.STORAGE, ActionType.READ) + @ApiOperation({ + summary: 'Get all files for the current user (Requires storage:read permission)', + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Files retrieved successfully' }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Insufficient permissions' }) + async getUserFiles(@CurrentUser() user: IJwtPayload): Promise { return this.queryBus.execute(new GetUserFilesQuery(user.sub)); } @Delete(':id') - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Delete a file' }) + @RequiresResourceAction(ResourceType.STORAGE, ActionType.DELETE) + @ApiOperation({ summary: 'Delete a file (Requires storage:delete permission)' }) @ApiParam({ name: 'id', description: 'File ID' }) + @ApiResponse({ status: HttpStatus.OK, description: 'File deleted successfully' }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Insufficient permissions' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'File not found' }) async deleteFile(@Param('id') id: string, @CurrentUser() user: IJwtPayload): Promise { return this.commandBus.execute(new DeleteFileCommand(id, user.sub)); } - @Patch('access') - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Update file access (public/private)' }) + @Patch(':id/access') + @RequiresResourceAction(ResourceType.STORAGE, ActionType.UPDATE) + @ApiOperation({ + summary: 'Update file access (public/private) - Requires storage:update permission', + }) + @ApiResponse({ status: HttpStatus.OK, description: 'File access updated successfully' }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Insufficient permissions' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'File not found' }) async updateFileAccess( - @Body() updateFileAccessDto: UpdateFileAccessDto, + @Param('id') id: string, + @Body() updateFileAccessDto: UpdateFileAccessRequest, @CurrentUser() user: IJwtPayload, - ): Promise { + ): Promise { return this.commandBus.execute( - new UpdateFileAccessCommand( - updateFileAccessDto.fileId, - updateFileAccessDto.isPublic, - user.sub, - ), + new UpdateFileAccessCommand(id, updateFileAccessDto.isPublic, user.sub), ); } } diff --git a/src/presentation/modules/storage/storage.module.ts b/src/presentation/modules/storage/storage.module.ts index f8beed2d..01b578f3 100644 --- a/src/presentation/modules/storage/storage.module.ts +++ b/src/presentation/modules/storage/storage.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { StorageController } from './storage.controller'; import { StorageModule as InfrastructureStorageModule } from '../../../infrastructure/storage/storage.module'; +import { CoreModule } from '@core/core.module'; // Commands import { UploadFileCommandHandler } from '@application/commands/storage/upload-file.command'; @@ -24,7 +25,7 @@ const CommandHandlers = [ const QueryHandlers = [GetFileQueryHandler, GetUserFilesQueryHandler]; @Module({ - imports: [CqrsModule, InfrastructureStorageModule.register({ global: true })], + imports: [CqrsModule, CoreModule, InfrastructureStorageModule.register({ global: true })], controllers: [StorageController], providers: [...CommandHandlers, ...QueryHandlers, FileMapper], }) diff --git a/src/presentation/modules/user/user.controller.ts b/src/presentation/modules/user/user.controller.ts index 33dd667d..ea2e9b3a 100644 --- a/src/presentation/modules/user/user.controller.ts +++ b/src/presentation/modules/user/user.controller.ts @@ -15,17 +15,19 @@ import { QueryBus, CommandBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger'; // Guards & Decorators -import { RolesGuard } from '@presentation/guards/roles.guard'; -import { Roles } from '@shared/decorators/roles.decorator'; -import { RolesEnum } from '@shared/constants/roles.constants'; +import { PermissionsGuard } from '@presentation/guards/permissions.guard'; +import { RequiresResourceAction } from '@shared/decorators/resource-action.decorator'; import { CurrentUser } from '@shared/decorators/current-user.decorator'; +import { ResourceType, ActionType } from '@core/value-objects/resource-action.vo'; // DTOs -import { CreateUserDto } from '@application/dtos/user/create-user.dto'; -import { UpdateUserDto } from '@application/dtos/user/update-user.dto'; -import { ChangePasswordDto } from '@application/dtos/user/change-password.dto'; -import { ActivateUserDto } from '@application/dtos/user/activate-user.dto'; -import { AssignRoleDto } from '@application/dtos/user/assign-role.dto'; +import { + UpdateUserRequest, + ChangePasswordRequest, + ActivateUserRequest, + AssignRoleRequest, + IJwtPayload, +} from '@application/dtos'; // Queries import { GetUserQuery } from '@application/queries/user/get-user.query'; @@ -38,11 +40,10 @@ import { ActivateUserCommand } from '@application/commands/user/activate-user.co import { AssignRoleCommand } from '@application/commands/user/assign-role.command'; import { RemoveRoleCommand } from '@application/commands/user/remove-role.command'; import { VerifyPasswordCommand } from '@application/commands/user/verify-password.command'; -import { IJwtPayload } from '@application/dtos/responses/user.response'; @ApiTags('users') @Controller('users') -@UseGuards(RolesGuard) +@UseGuards(PermissionsGuard) @ApiBearerAuth('JWT-auth') export class UserController { constructor( @@ -51,49 +52,37 @@ export class UserController { ) {} @Get() - @Roles(RolesEnum.ADMIN) + @RequiresResourceAction(ResourceType.USER, ActionType.READ) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Get all users (Admin only)' }) + @ApiOperation({ summary: 'Get all users (Requires user:read permission)' }) @ApiResponse({ status: HttpStatus.OK, description: 'Returns a list of all users' }) - @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'User does not have admin role' }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Insufficient permissions' }) async getAllUsers() { return this.queryBus.execute(new GetUsersQuery()); } @Get(':id') - @Roles(RolesEnum.ADMIN) + @RequiresResourceAction(ResourceType.USER, ActionType.READ) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Get user by ID (Admin only)' }) + @ApiOperation({ summary: 'Get user by ID (Requires user:read permission)' }) @ApiParam({ name: 'id', description: 'User ID', example: '550e8400-e29b-41d4-a716-446655440000' }) @ApiResponse({ status: HttpStatus.OK, description: 'Returns user information' }) @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'User not found' }) - @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'User does not have admin role' }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Insufficient permissions' }) async getUserById(@Param('id') id: string) { return this.queryBus.execute(new GetUserQuery(id)); } - @Post() - @Roles(RolesEnum.ADMIN) - @HttpCode(HttpStatus.CREATED) - @ApiOperation({ summary: 'Create new user (Admin only)' }) - @ApiResponse({ status: HttpStatus.CREATED, description: 'User created successfully' }) - @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input data' }) - @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'User does not have admin role' }) - async createUser(@Body() _createUserDto: CreateUserDto) { - // This would normally use a command - return { message: 'User created successfully' }; - } - @Put(':id') - @Roles(RolesEnum.ADMIN) + @RequiresResourceAction(ResourceType.USER, ActionType.UPDATE) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Update user by ID (Admin only)' }) + @ApiOperation({ summary: 'Update user by ID (Requires user:update permission)' }) @ApiParam({ name: 'id', description: 'User ID', example: '550e8400-e29b-41d4-a716-446655440000' }) @ApiResponse({ status: HttpStatus.OK, description: 'User updated successfully' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input data' }) @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'User not found' }) - @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'User does not have admin role' }) - async updateUser(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Insufficient permissions' }) + async updateUser(@Param('id') id: string, @Body() updateUserDto: UpdateUserRequest) { return this.commandBus.execute( new UpdateUserCommand( id, @@ -111,7 +100,7 @@ export class UserController { @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input data' }) async updateCurrentUserProfile( @CurrentUser() user: IJwtPayload, - @Body() updateUserDto: UpdateUserDto, + @Body() updateUserDto: UpdateUserRequest, ) { return this.commandBus.execute( new UpdateUserCommand( @@ -124,28 +113,28 @@ export class UserController { } @Delete(':id') - @Roles(RolesEnum.ADMIN) + @RequiresResourceAction(ResourceType.USER, ActionType.DELETE) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Delete user by ID (Admin only)' }) + @ApiOperation({ summary: 'Delete user by ID (Requires user:delete permission)' }) @ApiParam({ name: 'id', description: 'User ID', example: '550e8400-e29b-41d4-a716-446655440000' }) @ApiResponse({ status: HttpStatus.OK, description: 'User deleted successfully' }) @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'User not found' }) - @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'User does not have admin role' }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Insufficient permissions' }) async deleteUser(@Param('id') _id: string) { // This would normally use a command return { message: 'User deleted successfully' }; } @Post(':id/change-password') - @Roles(RolesEnum.ADMIN) + @RequiresResourceAction(ResourceType.USER, ActionType.UPDATE) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Change user password (Admin only)' }) + @ApiOperation({ summary: 'Change user password (Requires user:update permission)' }) @ApiParam({ name: 'id', description: 'User ID', example: '550e8400-e29b-41d4-a716-446655440000' }) @ApiResponse({ status: HttpStatus.OK, description: 'Password changed successfully' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input data' }) @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'User not found' }) - @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'User does not have admin role' }) - async changePassword(@Param('id') id: string, @Body() changePasswordDto: ChangePasswordDto) { + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Insufficient permissions' }) + async changePassword(@Param('id') id: string, @Body() changePasswordDto: ChangePasswordRequest) { await this.commandBus.execute( new ChangePasswordCommand( id, @@ -165,7 +154,7 @@ export class UserController { @ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: 'Current password is incorrect' }) async changeCurrentUserPassword( @CurrentUser() user: IJwtPayload, - @Body() changePasswordDto: ChangePasswordDto, + @Body() changePasswordDto: ChangePasswordRequest, ) { await this.commandBus.execute( new ChangePasswordCommand( @@ -193,35 +182,41 @@ export class UserController { } @Patch(':id/activate') - @Roles(RolesEnum.ADMIN) + @RequiresResourceAction(ResourceType.USER, ActionType.UPDATE) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Activate or deactivate user (Admin only)' }) + @ApiOperation({ summary: 'Activate or deactivate user (Requires user:update permission)' }) @ApiParam({ name: 'id', description: 'User ID', example: '550e8400-e29b-41d4-a716-446655440000' }) @ApiResponse({ status: HttpStatus.OK, description: 'User activation status updated' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input data' }) @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'User not found' }) - @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'User does not have admin role' }) - async activateUser(@Param('id') id: string, @Body() activateUserDto: ActivateUserDto) { + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Insufficient permissions' }) + async activateUser(@Param('id') id: string, @Body() activateUserDto: ActivateUserRequest) { return this.commandBus.execute(new ActivateUserCommand(id, activateUserDto.active)); } @Post(':id/roles') - @Roles(RolesEnum.ADMIN) + @RequiresResourceAction(ResourceType.USER, ActionType.UPDATE) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Assign role to user (Admin only)' }) + @ApiOperation({ summary: 'Assign role to user (Requires user:update permission)' }) @ApiParam({ name: 'id', description: 'User ID', example: '550e8400-e29b-41d4-a716-446655440000' }) @ApiResponse({ status: HttpStatus.OK, description: 'Role assigned successfully' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input data' }) @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'User or role not found' }) - @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'User does not have admin role' }) - async assignRoleToUser(@Param('id') id: string, @Body() assignRoleDto: AssignRoleDto) { - return this.commandBus.execute(new AssignRoleCommand(id, assignRoleDto.roleId)); + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Insufficient permissions' }) + async assignRoleToUser( + @Param('id') id: string, + @Body() assignRoleDto: AssignRoleRequest, + @CurrentUser() currentUser: IJwtPayload, + ) { + return this.commandBus.execute( + new AssignRoleCommand(id, assignRoleDto.roleId, currentUser.sub), + ); } @Delete(':id/roles/:roleId') - @Roles(RolesEnum.ADMIN) + @RequiresResourceAction(ResourceType.USER, ActionType.UPDATE) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Remove role from user (Admin only)' }) + @ApiOperation({ summary: 'Remove role from user (Requires user:update permission)' }) @ApiParam({ name: 'id', description: 'User ID', example: '550e8400-e29b-41d4-a716-446655440000' }) @ApiParam({ name: 'roleId', @@ -230,7 +225,7 @@ export class UserController { }) @ApiResponse({ status: HttpStatus.OK, description: 'Role removed successfully' }) @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'User not found' }) - @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'User does not have admin role' }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Insufficient permissions' }) async removeRoleFromUser(@Param('id') id: string, @Param('roleId') roleId: string) { return this.commandBus.execute(new RemoveRoleCommand(id, roleId)); } diff --git a/src/shared/constants/roles.constants.ts b/src/shared/constants/roles.constants.ts deleted file mode 100644 index 272e5b8a..00000000 --- a/src/shared/constants/roles.constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum RolesEnum { - ADMIN = 'admin', - USER = 'user', -} diff --git a/src/shared/decorators/permissions.decorator.ts b/src/shared/decorators/permissions.decorator.ts deleted file mode 100644 index 09988276..00000000 --- a/src/shared/decorators/permissions.decorator.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; - -export const PERMISSIONS_KEY = 'permissions'; -export const RequirePermissions = (...permissions: string[]) => - SetMetadata(PERMISSIONS_KEY, permissions); diff --git a/src/shared/decorators/resource-action.decorator.ts b/src/shared/decorators/resource-action.decorator.ts index 27a6fe14..15b709fc 100644 --- a/src/shared/decorators/resource-action.decorator.ts +++ b/src/shared/decorators/resource-action.decorator.ts @@ -1,14 +1,15 @@ import { SetMetadata } from '@nestjs/common'; +import { ResourceType, ActionType } from '@core/value-objects/resource-action.vo'; /** * Decorator to specify resource and action for permission checking * Used with the enhanced PermissionsGuard * - * @param resource - The resource being accessed (e.g., 'user', 'role') - * @param action - The action being performed (e.g., 'create', 'delete') + * @param resource - The resource being accessed (ResourceType enum) + * @param action - The action being performed (ActionType enum) */ export const RequiresResourceAction = - (resource: string, action: string) => + (resource: ResourceType, action: ActionType) => (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => { SetMetadata('resource', resource)(target, propertyKey, descriptor); SetMetadata('action', action)(target, propertyKey, descriptor); diff --git a/src/shared/decorators/resource-permissions.decorator.ts b/src/shared/decorators/resource-permissions.decorator.ts deleted file mode 100644 index 7a3ec916..00000000 --- a/src/shared/decorators/resource-permissions.decorator.ts +++ /dev/null @@ -1,32 +0,0 @@ -// No imports needed -import { RequirePermissions } from './permissions.decorator'; - -/** - * Helper decorator for requiring read permission on a resource - */ -export const CanRead = (resource: string) => RequirePermissions(`${resource}:read`); - -/** - * Helper decorator for requiring write permission on a resource - */ -export const CanWrite = (resource: string) => RequirePermissions(`${resource}:write`); - -/** - * Helper decorator for requiring delete permission on a resource - */ -export const CanDelete = (resource: string) => RequirePermissions(`${resource}:delete`); - -/** - * Helper decorator for requiring multiple permissions on a resource - */ -export const ResourcePermissions = (resource: string, actions: string[]) => { - const permissions = actions.map(action => `${resource}:${action}`); - - return RequirePermissions(...permissions); -}; - -/** - * Helper decorator for requiring full access (read, write, delete) on a resource - */ -export const FullResourceAccess = (resource: string) => - ResourcePermissions(resource, ['read', 'write', 'delete']); diff --git a/src/shared/decorators/roles.decorator.ts b/src/shared/decorators/roles.decorator.ts deleted file mode 100644 index 12cedc71..00000000 --- a/src/shared/decorators/roles.decorator.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; -import { RolesEnum } from '@shared/constants/roles.constants'; - -export const ROLES_KEY = 'roles'; -export const Roles = (...roles: RolesEnum[]) => SetMetadata(ROLES_KEY, roles); diff --git a/tsconfig.json b/tsconfig.json index 270a59f4..559d4fea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,5 +26,12 @@ "@shared/*": ["src/shared/*"] } }, - "exclude": ["**/*.spec.ts", "**/*.test.ts", "test/**/*"] + "exclude": [ + "node_modules", + "dist", + "test/**/*", + "**/*.spec.ts", + "**/*.test.ts", + "**/*.d.ts" // Exclude .d.ts files from compilation + ] }