From c5da400e6ba48062c0bc2949b99bfdef842dcc6e Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Wed, 24 Sep 2025 13:06:51 +0530 Subject: [PATCH] Enhanced the manage employe API --- .../Dtos/Employees/CreateUserDto.cs | 7 +- .../Controllers/EmployeeController.cs | 329 +++++++++++++++++- Marco.Pms.Services/Helpers/UserHelper.cs | 5 + .../MappingProfiles/MappingProfile.cs | 4 + 4 files changed, 342 insertions(+), 3 deletions(-) diff --git a/Marco.Pms.Model/Dtos/Employees/CreateUserDto.cs b/Marco.Pms.Model/Dtos/Employees/CreateUserDto.cs index 98d4c16..ad27e8c 100644 --- a/Marco.Pms.Model/Dtos/Employees/CreateUserDto.cs +++ b/Marco.Pms.Model/Dtos/Employees/CreateUserDto.cs @@ -9,8 +9,8 @@ public string? Email { get; set; } public required string Gender { get; set; } - public required string BirthDate { get; set; } - public required string JoiningDate { get; set; } + public required DateTime BirthDate { get; set; } + public required DateTime JoiningDate { get; set; } public required string PermanentAddress { get; set; } public required string CurrentAddress { get; set; } @@ -19,6 +19,8 @@ public string? EmergencyPhoneNumber { get; set; } public string? EmergencyContactPerson { get; set; } public Guid JobRoleId { get; set; } + public required Guid OrganizationId { get; set; } + public required bool HasApplicationAccess { get; set; } } public class MobileUserManageDto { @@ -30,6 +32,7 @@ public required string Gender { get; set; } public Guid JobRoleId { get; set; } public string? ProfileImage { get; set; } + public required Guid OrganizationId { get; set; } } } diff --git a/Marco.Pms.Services/Controllers/EmployeeController.cs b/Marco.Pms.Services/Controllers/EmployeeController.cs index 505cee0..3dcdbee 100644 --- a/Marco.Pms.Services/Controllers/EmployeeController.cs +++ b/Marco.Pms.Services/Controllers/EmployeeController.cs @@ -46,6 +46,7 @@ namespace MarcoBMS.Services.Controllers private readonly IMapper _mapper; private readonly IProjectServices _projectServices; private readonly Guid tenantId; + private readonly Guid organizationId; public EmployeeController(IServiceScopeFactory serviceScope, @@ -76,6 +77,7 @@ namespace MarcoBMS.Services.Controllers _projectServices = projectServices; _mapper = mapper; tenantId = _userHelper.GetTenantId(); + organizationId = _userHelper.GetCurrentOrganizationId(); } [HttpGet] @@ -315,7 +317,7 @@ namespace MarcoBMS.Services.Controllers } - [HttpPost("manage")] + [HttpPost("old/manage")] public async Task CreateUser([FromBody] CreateUserDto model) { Guid tenantId = _userHelper.GetTenantId(); @@ -448,6 +450,211 @@ namespace MarcoBMS.Services.Controllers return Ok(ApiResponse.SuccessResponse("Success.", responsemessage, 200)); } + [HttpPost("manage")] + public async Task CreateEmployeeAsync([FromBody] CreateUserDto model) + { + // Correlation and context capture for logs + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + { + if (model == null) + { + _logger.LogWarning("Model is null in CreateEmployeeAsync"); + return BadRequest(ApiResponse.ErrorResponse("Invalid payload", "Request body is required", 400)); + } + + // Basic validation + if (model.HasApplicationAccess && string.IsNullOrWhiteSpace(model.Email)) + { + _logger.LogWarning("Application access requested but email is missing"); + return BadRequest(ApiResponse.ErrorResponse("Invalid email", "Application users must have a valid email", 400)); + } + + await using var transaction = await _context.Database.BeginTransactionAsync(); + try + { + // Load existing employee if updating, constrained by organization scope + Employee? existingEmployee = null; + if (model.Id.HasValue && model.Id.Value != Guid.Empty) + { + existingEmployee = await _context.Employees + .FirstOrDefaultAsync(e => e.Id == model.Id && e.OrganizationId == model.OrganizationId); + if (existingEmployee == null) + { + _logger.LogInfo("Employee not found for update. Id={EmployeeId}, Org={OrgId}", model.Id, model.OrganizationId); + return NotFound(ApiResponse.ErrorResponse("Employee not found", "Employee not found in database", 404)); + } + } + + // Identity user creation path (only if needed) + ApplicationUser? identityUserToCreate = null; + ApplicationUser? createdIdentityUser = null; + + if (model.HasApplicationAccess) + { + // Only attempt identity resolution/creation if email supplied and either: + // - Creating new employee, or + // - Updating but existing employee does not have ApplicationUserId + var needsIdentity = string.IsNullOrWhiteSpace(existingEmployee?.ApplicationUserId); + if (needsIdentity && !string.IsNullOrWhiteSpace(model.Email)) + { + var existingUser = await _userManager.FindByEmailAsync(model.Email); + if (existingUser == null) + { + // Seat check only when provisioning a new identity user + var isSeatsAvailable = await _generalHelper.CheckSeatsRemainingAsync(tenantId); + if (!isSeatsAvailable) + { + _logger.LogWarning("Maximum users reached for Tenant {TenantId}. Cannot create identity user for {Email}", tenantId, model.Email); + return BadRequest(ApiResponse.ErrorResponse( + "Maximum number of users reached. Cannot add new user", + "Maximum number of users reached. Cannot add new user", 400)); + } + + identityUserToCreate = new ApplicationUser + { + UserName = model.Email, + Email = model.Email, + EmailConfirmed = true + }; + } + else + { + // If identity exists, re-use it; do not re-create + createdIdentityUser = existingUser; + } + } + } + + // For create path: enforce uniqueness of employee email if applicable to business rules + // Consider adding a unique filtered index: (OrganizationId, Email) WHERE Email IS NOT NULL + if (!model.Id.HasValue || model.Id == Guid.Empty) + { + if (!string.IsNullOrWhiteSpace(model.Email)) + { + var emailExists = await _context.Employees + .AnyAsync(e => e.Email == model.Email && e.OrganizationId == model.OrganizationId); + if (emailExists) + { + _logger.LogInfo("Employee email already exists in org. Email={Email}, Org={OrgId}", model.Email, model.OrganizationId); + return StatusCode(403, ApiResponse.ErrorResponse( + "Employee with email already exists", + "Employee with this email already exists", 403)); + } + } + } + + // Create identity user if needed + if (identityUserToCreate != null && !string.IsNullOrWhiteSpace(identityUserToCreate.Email)) + { + var createResult = await _userManager.CreateAsync(identityUserToCreate, "User@123"); + if (!createResult.Succeeded) + { + _logger.LogWarning("Failed to create identity user for {Email}. Errors={Errors}", + identityUserToCreate.Email, + string.Join(", ", createResult.Errors.Select(e => $"{e.Code}:{e.Description}"))); + return BadRequest(ApiResponse.ErrorResponse("Failed to create user", createResult.Errors, 400)); + } + + createdIdentityUser = identityUserToCreate; + _logger.LogInfo("Identity user created. IdentityUserId={UserId}, Email={Email}", + createdIdentityUser.Id, createdIdentityUser.Email); + } + + // Prepare reset link sender helper + async Task SendResetIfApplicableAsync(ApplicationUser u, string firstName) + { + var token = await _userManager.GeneratePasswordResetTokenAsync(u); + var resetLink = $"{_configuration["AppSettings:WebFrontendUrl"]}/reset-password?token={WebUtility.UrlEncode(token)}"; + await _emailSender.SendResetPasswordEmailOnRegister(u.Email ?? "", firstName, resetLink); + _logger.LogInfo("Reset password email queued. Email={Email}", u.Email ?? ""); + } + + Guid employeeId; + EmployeeVM employeeVM; + string responseMessage; + + if (existingEmployee != null) + { + // Update flow + _mapper.Map(model, existingEmployee); + + if (createdIdentityUser != null && !string.IsNullOrWhiteSpace(createdIdentityUser.Email)) + { + existingEmployee.ApplicationUserId = createdIdentityUser.Id; + await SendResetIfApplicableAsync(createdIdentityUser, existingEmployee.FirstName ?? "User"); + } + + await _context.SaveChangesAsync(); + + employeeId = existingEmployee.Id; + employeeVM = _mapper.Map(existingEmployee); + responseMessage = "Employee Updated Successfully"; + + _logger.LogInfo("Employee updated. EmployeeId={EmployeeId}, Org={OrgId}", employeeId, existingEmployee.OrganizationId); + } + else + { + // Create flow + var newEmployee = _mapper.Map(model); + newEmployee.IsSystem = false; + newEmployee.IsActive = true; + newEmployee.IsPrimary = false; + + if (createdIdentityUser != null && !string.IsNullOrWhiteSpace(createdIdentityUser.Email)) + { + newEmployee.ApplicationUserId = createdIdentityUser.Id; + await SendResetIfApplicableAsync(createdIdentityUser, newEmployee.FirstName ?? "User"); + } + + await _context.Employees.AddAsync(newEmployee); + await _context.SaveChangesAsync(); + + employeeId = newEmployee.Id; + employeeVM = _mapper.Map(newEmployee); + responseMessage = "Employee Created Successfully"; + + _logger.LogInfo("Employee created. EmployeeId={EmployeeId}, Org={OrgId}", employeeId, newEmployee.OrganizationId); + } + + await transaction.CommitAsync(); + + // SignalR notification + var notification = new + { + LoggedInUserId = loggedInEmployee.Id, + Keyword = "Employee", + EmployeeId = employeeId + }; + + // Consider broadcasting to tenant/organization group instead of Clients.All to avoid cross-tenant leaks: + // await _signalR.Clients.Group($"org:{model.OrganizationId}").SendAsync("NotificationEventHandler", notification); + await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + + _logger.LogInfo("Notification broadcasted for EmployeeId={EmployeeId}", employeeId); + + return Ok(ApiResponse.SuccessResponse(employeeVM, responseMessage, 200)); + } + catch (DbUpdateException dbEx) + { + await transaction.RollbackAsync(); + _logger.LogError(dbEx, "Database exception occurred while managing employee"); + return StatusCode(500, ApiResponse.ErrorResponse( + "Internal exception occurred", + "Internal database exception has occurred", 500)); + } + catch (Exception ex) + { + await transaction.RollbackAsync(); + _logger.LogError(ex, "Unhandled exception occurred while managing employee"); + return StatusCode(500, ApiResponse.ErrorResponse( + "Internal exception occurred", + "Internal exception has occurred", 500)); + } + } + } + + [HttpPost("manage-mobile")] public async Task CreateUserMoblie([FromBody] MobileUserManageDto model) { @@ -527,6 +734,126 @@ namespace MarcoBMS.Services.Controllers } } + [HttpPost("app/manage")] + public async Task CreateUserMobileAsync([FromBody] MobileUserManageDto model) + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + if (tenantId == Guid.Empty) + { + _logger.LogWarning("Tenant resolution failed in CreateUserMobile"); // structured warning + return StatusCode(403, ApiResponse.ErrorResponse("Unauthorized tenant context", "Unauthorized", 403)); + } + + if (model is null) + { + _logger.LogWarning("Null payload in CreateUserMobile for Tenant {TenantId}", tenantId); // validation log + return BadRequest(ApiResponse.ErrorResponse("Invalid data", "Invalid data", 400)); + } + + if (string.IsNullOrWhiteSpace(model.FirstName) || string.IsNullOrWhiteSpace(model.PhoneNumber)) + { + _logger.LogWarning("Missing required fields FirstName/Phone for Tenant {TenantId}", tenantId); // validation log + return BadRequest(ApiResponse.ErrorResponse("First name and phone number are required.", "Required fields missing", 400)); + } + + // Strict Base64 parse + byte[]? imageBytes = null; + if (!string.IsNullOrWhiteSpace(model.ProfileImage)) + { + try + { + imageBytes = Convert.FromBase64String(model.ProfileImage); + } + catch (FormatException ex) + { + _logger.LogError(ex, "Invalid base64 image in CreateUserMobile for Tenant {TenantId}", tenantId); // input issue + return BadRequest(ApiResponse.ErrorResponse("Invalid image format.", "Invalid image", 400)); + } + } + if (model.Id == null || model.Id == Guid.Empty) + { + // Create path: map only allowed fields + var employee = new Employee + { + Id = Guid.NewGuid(), + TenantId = tenantId, + FirstName = model.FirstName.Trim(), + LastName = model.LastName?.Trim(), + Gender = model.Gender, + PhoneNumber = model.PhoneNumber, + JoiningDate = model.JoiningDate, + JobRoleId = model.JobRoleId, + Photo = imageBytes, + OrganizationId = model.OrganizationId + }; + + await _context.Employees.AddAsync(employee); + await _context.SaveChangesAsync(); + + var employeeVM = _mapper.Map(employee); + + var notification = new + { + LoggedInUserId = loggedInEmployee?.Id, + Keyword = "Employee", + EmployeeId = employee.Id + }; + + // Consider broadcasting to tenant/organization group instead of Clients.All to avoid cross-tenant leaks: + // await _signalR.Clients.Group($"org:{model.OrganizationId}").SendAsync("NotificationEventHandler", notification); + await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + + _logger.LogInfo("Employee {EmployeeId} created in Tenant {TenantId}", employee.Id, tenantId); // success + return Ok(ApiResponse.SuccessResponse(employeeVM, "Employee created successfully", 200)); + } + else + { + // Update path: fetch scoped to tenant + var employeeId = model.Id.Value; + var existingEmployee = await _context.Employees + .FirstOrDefaultAsync(e => e.Id == employeeId && e.TenantId == tenantId); // tenant-safe lookup + + if (existingEmployee is null) + { + _logger.LogWarning("Update attempted for missing Employee {EmployeeId} in Tenant {TenantId}", employeeId, tenantId); // not found + return NotFound(ApiResponse.ErrorResponse("Employee not found", "Not found", 404)); + } + + // Update allowed fields only + existingEmployee.FirstName = model.FirstName.Trim(); + existingEmployee.LastName = model.LastName?.Trim(); + existingEmployee.Gender = model.Gender; + existingEmployee.PhoneNumber = model.PhoneNumber; + existingEmployee.JoiningDate = model.JoiningDate; + existingEmployee.JobRoleId = model.JobRoleId; + existingEmployee.OrganizationId = model.OrganizationId; + + if (imageBytes != null) + { + existingEmployee.Photo = imageBytes; + } + + await _context.SaveChangesAsync(); + + var employeeVM = _mapper.Map(existingEmployee); + + var notification = new + { + LoggedInUserId = loggedInEmployee?.Id, + Keyword = "Employee", + EmployeeId = employeeId + }; + + // Consider broadcasting to tenant/organization group instead of Clients.All to avoid cross-tenant leaks: + // await _signalR.Clients.Group($"org:{model.OrganizationId}").SendAsync("NotificationEventHandler", notification); + await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + + _logger.LogInfo("Employee {EmployeeId} updated in Tenant {TenantId}", existingEmployee.Id, tenantId); // success + return Ok(ApiResponse.SuccessResponse(employeeVM, "Employee updated successfully", 200)); + } + } + + [HttpDelete("{id}")] public async Task SuspendEmployee(Guid id, [FromQuery] bool active = false) { diff --git a/Marco.Pms.Services/Helpers/UserHelper.cs b/Marco.Pms.Services/Helpers/UserHelper.cs index 0b93db4..49d3fb7 100644 --- a/Marco.Pms.Services/Helpers/UserHelper.cs +++ b/Marco.Pms.Services/Helpers/UserHelper.cs @@ -26,6 +26,11 @@ namespace MarcoBMS.Services.Helpers var tenant = _httpContextAccessor.HttpContext?.User.FindFirst("TenantId")?.Value; return (tenant != null ? Guid.Parse(tenant) : Guid.Empty); } + public Guid GetCurrentOrganizationId() + { + var tenant = _httpContextAccessor.HttpContext?.User.FindFirst("OrganizationId")?.Value; + return (tenant != null ? Guid.Parse(tenant) : Guid.Empty); + } public async Task GetCurrentTenant() { var tenantId = _httpContextAccessor.HttpContext?.User.FindFirst("TenantId")?.Value; diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index aa79f33..bb256a9 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -6,6 +6,7 @@ using Marco.Pms.Model.Dtos.Activities; using Marco.Pms.Model.Dtos.AppMenu; using Marco.Pms.Model.Dtos.Directory; using Marco.Pms.Model.Dtos.DocumentManager; +using Marco.Pms.Model.Dtos.Employees; using Marco.Pms.Model.Dtos.Expenses; using Marco.Pms.Model.Dtos.Master; using Marco.Pms.Model.Dtos.Organization; @@ -177,6 +178,9 @@ namespace Marco.Pms.Services.MappingProfiles #region ======================================================= Employee ======================================================= CreateMap(); + CreateMap(); + CreateMap(); + CreateMap() .ForMember( dest => dest.JobRoleName,