From e565a80f7ad837b892a9fd477ea3886931433529 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 31 Jul 2025 18:56:19 +0530 Subject: [PATCH] Optimized the create tenant API --- .../Controllers/TenantController.cs | 150 ++++++++++++------ 1 file changed, 105 insertions(+), 45 deletions(-) diff --git a/Marco.Pms.Services/Controllers/TenantController.cs b/Marco.Pms.Services/Controllers/TenantController.cs index 0d177d3..b1b5c6f 100644 --- a/Marco.Pms.Services/Controllers/TenantController.cs +++ b/Marco.Pms.Services/Controllers/TenantController.cs @@ -3,6 +3,7 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Tenant; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; +using Marco.Pms.Model.Projects; using Marco.Pms.Model.Roles; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; @@ -31,18 +32,22 @@ namespace Marco.Pms.Services.Controllers private readonly ILoggingService _logger; private readonly UserManager _userManager; private readonly IMapper _mapper; + private readonly UserHelper _userHelper; private readonly static Guid activeStatus = Guid.Parse("62b05792-5115-4f99-8ff5-e8374859b191"); + private readonly static string AdminRoleName = "Admin"; public TenantController(IDbContextFactory dbContextFactory, IServiceScopeFactory serviceScopeFactory, ILoggingService logger, UserManager userManager, - IMapper mapper) + IMapper mapper, + UserHelper userHelper) { _dbContextFactory = dbContextFactory; _serviceScopeFactory = serviceScopeFactory; _logger = logger; _userManager = userManager; _mapper = mapper; + _userHelper = userHelper; } // GET: api/ [HttpGet] @@ -60,45 +65,65 @@ namespace Marco.Pms.Services.Controllers // POST api/ [HttpPost("create")] - public async Task Post([FromBody] CreateTenantDto model) + public async Task CreateTenant([FromBody] CreateTenantDto model) { using var scope = _serviceScopeFactory.CreateScope(); await using var _context = await _dbContextFactory.CreateDbContextAsync(); var _configuration = scope.ServiceProvider.GetRequiredService(); var _emailSender = scope.ServiceProvider.GetRequiredService(); + var _permissionService = scope.ServiceProvider.GetRequiredService(); - var userHelper = scope.ServiceProvider.GetRequiredService(); - var loggedInEmployee = await userHelper.GetCurrentEmployeeAsync(); + _logger.LogInfo("Attempting to create a new tenant with organization name: {OrganizationName}", model.OragnizationName); - var permissionService = scope.ServiceProvider.GetRequiredService(); - var hasPermission = await permissionService.HasPermission(PermissionsMaster.ManageTenants, loggedInEmployee.Id); - if (!hasPermission || !(loggedInEmployee.ApplicationUser?.IsRootUser ?? false)) + // 1. --- PERMISSION CHECK --- + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + if (loggedInEmployee == null) { - _logger.LogWarning("User {EmployeeId} attmpted to create new tenant but not have permissions", loggedInEmployee.Id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "User don't have rights for this action", 403)); + // This case should ideally be handled by an [Authorize] attribute, but it's good practice to double-check. + return Unauthorized(ApiResponse.ErrorResponse("Authentication required", "User is not logged in.", 401)); } + var hasPermission = await _permissionService.HasPermission(PermissionsMaster.ManageTenants, loggedInEmployee.Id); + if (!hasPermission || !(loggedInEmployee.ApplicationUser?.IsRootUser ?? false)) + { + _logger.LogWarning("Permission denied: User {EmployeeId} attempted to create a tenant without sufficient rights.", loggedInEmployee.Id); + return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "User does not have the required permissions for this action.", 403)); + } + + // 2. --- VALIDATION --- + // Check if a user with the same email already exists. var existingUser = await _userManager.FindByEmailAsync(model.Email); if (existingUser != null) { - _logger.LogWarning("User {EmployeeId} attempted to create tenant with email {Email} but already exists in database", loggedInEmployee.Id, model.Email); - return StatusCode(409, ApiResponse.ErrorResponse("Tenant can't be created", "User with same email already exists", 409)); + _logger.LogWarning("Tenant creation failed for email {Email}: an application user with this email already exists.", model.Email); + return StatusCode(409, ApiResponse.ErrorResponse("Tenant cannot be created", "A user with the specified email already exists.", 409)); } - var isTenantExists = await _context.Tenants.AnyAsync(t => t.TaxId != null && t.TaxId == model.TaxId); - if (isTenantExists) + + // Check if a tenant with the same Tax ID already exists. + if (!string.IsNullOrWhiteSpace(model.TaxId)) { - _logger.LogWarning("User {EmployeeId} attempted to create tenant with duplicate taxId", loggedInEmployee.Id); - return StatusCode(409, ApiResponse.ErrorResponse("Tenant can't be created", "User with same taxId already exists", 409)); + var isTenantExists = await _context.Tenants.AnyAsync(t => t.TaxId == model.TaxId); + if (isTenantExists) + { + _logger.LogWarning("Tenant creation failed for Tax ID {TaxId}: a tenant with this Tax ID already exists.", model.TaxId); + return StatusCode(409, ApiResponse.ErrorResponse("Tenant cannot be created", "A tenant with the same Tax ID already exists.", 409)); + } } + + // Check if the provided logo is a valid Base64 string. if (!string.IsNullOrWhiteSpace(model.logoImage) && !IsBase64String(model.logoImage)) { - _logger.LogWarning("User {EmployeeId} attempted to create tenant with Invalid logoImage", loggedInEmployee.Id); - return StatusCode(400, ApiResponse.ErrorResponse("Tenant can't be created", "User with same taxId already exists", 400)); + _logger.LogWarning("Tenant creation failed for user {EmployeeId}: The provided logo image was not a valid Base64 string.", loggedInEmployee.Id); + return StatusCode(400, ApiResponse.ErrorResponse("Tenant cannot be created", "The provided logo image is invalid.", 400)); } + + // 3. --- DATABASE TRANSACTION --- + // Use a transaction to ensure all related entities are created successfully or none at all. await using var transaction = await _context.Database.BeginTransactionAsync(); try { + // Create the primary Tenant entity var tenant = new Tenant { Name = model.OragnizationName, @@ -106,7 +131,7 @@ namespace Marco.Pms.Services.Controllers ContactNumber = model.ContactNumber, Email = model.Email, IndustryId = model.IndustryId, - TenantStatusId = activeStatus, + TenantStatusId = activeStatus, // Assuming 'activeStatus' is a defined variable or constant Description = model.Description, OnBoardingDate = model.OnBoardingDate, OragnizationSize = model.OragnizationSize, @@ -118,32 +143,42 @@ namespace Marco.Pms.Services.Controllers DomainName = model.DomainName, IsSuperTenant = false }; - _context.Tenants.Add(tenant); - await _context.SaveChangesAsync(); - var designation = new JobRole - { - Name = "Admin", - Description = "Root degination for tenant only", - TenantId = tenant.Id - }; + // Create the root ApplicationUser for the new tenant var applicationUser = new ApplicationUser { Email = model.Email, - UserName = model.Email, + UserName = model.Email, // Best practice to use email as username for simplicity IsRootUser = true, - EmailConfirmed = true, + EmailConfirmed = true, // Auto-confirming email as it's part of a trusted setup process TenantId = tenant.Id }; - _context.JobRoles.Add(designation); - var result = await _userManager.CreateAsync(applicationUser, "User@123"); + // SECURITY WARNING: Hardcoded passwords are a major vulnerability. + // Replace "User@123" with a securely generated random password. + var initialPassword = "User@123"; // TODO: Replace with password generation service. + var result = await _userManager.CreateAsync(applicationUser, initialPassword); + if (!result.Succeeded) - return BadRequest(ApiResponse.ErrorResponse("Failed to create user", result.Errors, 400)); + { + // If user creation fails, roll back the transaction immediately and return the errors. + await transaction.RollbackAsync(); + var errors = result.Errors.Select(e => e.Description).ToList(); + _logger.LogWarning("Failed to create ApplicationUser for tenant {TenantName}. Errors: {Errors}", model.OragnizationName, string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Failed to create user", errors, 400)); + } - await _context.SaveChangesAsync(); + // Create the default "Admin" Job Role for the tenant + var adminJobRole = new JobRole + { + Name = AdminRoleName, + Description = "Default administrator role for the tenant.", + TenantId = tenant.Id + }; + _context.JobRoles.Add(adminJobRole); + // Create the primary Employee record and link it to the ApplicationUser and JobRole var employeeUser = new Employee { FirstName = model.FirstName, @@ -151,32 +186,57 @@ namespace Marco.Pms.Services.Controllers Email = model.Email, PhoneNumber = model.ContactNumber, ApplicationUserId = applicationUser.Id, - JobRole = designation, + JobRole = adminJobRole, // Link to the newly created role CurrentAddress = model.BillingAddress, TenantId = tenant.Id }; + _context.Employees.Add(employeeUser); + // Create a default project for the new tenant + var project = new Project + { + Name = $"{model.OragnizationName} - Default Project", + ProjectStatusId = Guid.Parse("b74da4c2-d07e-46f2-9919-e75e49b12731"), // Consider using a constant for this GUID + ProjectAddress = model.BillingAddress, + ContactPerson = tenant.ContactName, + TenantId = tenant.Id + }; + _context.Projects.Add(project); + + // All entities are now added to the context. Save them all in a single database operation. await _context.SaveChangesAsync(); + // 4. --- POST-CREATION ACTIONS --- + // Generate a password reset token so the new user can set their own password. + _logger.LogInfo("User {Email} created. Sending password setup email.", applicationUser.Email); var token = await _userManager.GeneratePasswordResetTokenAsync(applicationUser); - var resetLink = $"{_configuration["AppSettings:WebFrontendUrl"]}/reset-password?token={WebUtility.UrlEncode(token)}"; - if (employeeUser.FirstName != null) - { - await _emailSender.SendResetPasswordEmailOnRegister(applicationUser.Email, employeeUser.FirstName, resetLink); - } - var vm = _mapper.Map(tenant); - vm.CreatedBy = _mapper.Map(loggedInEmployee); - return Ok(tenant); + var resetLink = $"{_configuration["AppSettings:WebFrontendUrl"]}/reset-password?token={WebUtility.UrlEncode(token)}&email={WebUtility.UrlEncode(applicationUser.Email)}"; + await _emailSender.SendResetPasswordEmailOnRegister(applicationUser.Email, employeeUser.FirstName, resetLink); + + // Map the result to a ViewModel for the API response. + var tenantVM = _mapper.Map(tenant); + tenantVM.CreatedBy = _mapper.Map(loggedInEmployee); + + // Commit the transaction as all operations were successful. + await transaction.CommitAsync(); + + _logger.LogInfo("Successfully created tenant {TenantId} for organization {OrganizationName}.", tenant.Id, tenant.Name); + return StatusCode(201, ApiResponse.SuccessResponse(tenantVM, "Tenant created successfully.", 201)); } catch (DbUpdateException dbEx) { - _logger.LogError(dbEx, "Database Exception occured while creating tenant"); - return StatusCode(500, ApiResponse.ErrorResponse("Internal Error occured", ExceptionMapper(dbEx), 500)); + await transaction.RollbackAsync(); + // Log the detailed database exception, including the inner exception if available. + _logger.LogError(dbEx, "A database update exception occurred while creating tenant for email {Email}. Inner Exception: {InnerException}", + model.Email, dbEx.InnerException?.Message ?? string.Empty); + return StatusCode(500, ApiResponse.ErrorResponse("An internal database error occurred.", ExceptionMapper(dbEx), 500)); } catch (Exception ex) { - _logger.LogError(ex, "Exception occured while creating tenant"); - return StatusCode(500, ApiResponse.ErrorResponse("Internal Error occured", ExceptionMapper(ex), 500)); + await transaction.RollbackAsync(); + // Log the general exception. + _logger.LogError(ex, "An unexpected exception occurred while creating tenant for email {Email}.", model.Email); + return StatusCode(500, ApiResponse.ErrorResponse("An unexpected internal error occurred.", ExceptionMapper(ex), 500)); } }