Organization_Management #142

Merged
ashutosh.nehete merged 92 commits from Organization_Management into main 2025-09-30 09:05:14 +00:00
4 changed files with 342 additions and 3 deletions
Showing only changes of commit c5da400e6b - Show all commits

View File

@ -9,8 +9,8 @@
public string? Email { get; set; } public string? Email { get; set; }
public required string Gender { get; set; } public required string Gender { get; set; }
public required string BirthDate { get; set; } public required DateTime BirthDate { get; set; }
public required string JoiningDate { get; set; } public required DateTime JoiningDate { get; set; }
public required string PermanentAddress { get; set; } public required string PermanentAddress { get; set; }
public required string CurrentAddress { get; set; } public required string CurrentAddress { get; set; }
@ -19,6 +19,8 @@
public string? EmergencyPhoneNumber { get; set; } public string? EmergencyPhoneNumber { get; set; }
public string? EmergencyContactPerson { get; set; } public string? EmergencyContactPerson { get; set; }
public Guid JobRoleId { get; set; } public Guid JobRoleId { get; set; }
public required Guid OrganizationId { get; set; }
public required bool HasApplicationAccess { get; set; }
} }
public class MobileUserManageDto public class MobileUserManageDto
{ {
@ -30,6 +32,7 @@
public required string Gender { get; set; } public required string Gender { get; set; }
public Guid JobRoleId { get; set; } public Guid JobRoleId { get; set; }
public string? ProfileImage { get; set; } public string? ProfileImage { get; set; }
public required Guid OrganizationId { get; set; }
} }
} }

View File

@ -46,6 +46,7 @@ namespace MarcoBMS.Services.Controllers
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly IProjectServices _projectServices; private readonly IProjectServices _projectServices;
private readonly Guid tenantId; private readonly Guid tenantId;
private readonly Guid organizationId;
public EmployeeController(IServiceScopeFactory serviceScope, public EmployeeController(IServiceScopeFactory serviceScope,
@ -76,6 +77,7 @@ namespace MarcoBMS.Services.Controllers
_projectServices = projectServices; _projectServices = projectServices;
_mapper = mapper; _mapper = mapper;
tenantId = _userHelper.GetTenantId(); tenantId = _userHelper.GetTenantId();
organizationId = _userHelper.GetCurrentOrganizationId();
} }
[HttpGet] [HttpGet]
@ -315,7 +317,7 @@ namespace MarcoBMS.Services.Controllers
} }
[HttpPost("manage")] [HttpPost("old/manage")]
public async Task<IActionResult> CreateUser([FromBody] CreateUserDto model) public async Task<IActionResult> CreateUser([FromBody] CreateUserDto model)
{ {
Guid tenantId = _userHelper.GetTenantId(); Guid tenantId = _userHelper.GetTenantId();
@ -448,6 +450,211 @@ namespace MarcoBMS.Services.Controllers
return Ok(ApiResponse<object>.SuccessResponse("Success.", responsemessage, 200)); return Ok(ApiResponse<object>.SuccessResponse("Success.", responsemessage, 200));
} }
[HttpPost("manage")]
public async Task<IActionResult> 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<object>.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<object>.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<object>.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<object>.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<object>.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<object>.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<EmployeeVM>(existingEmployee);
responseMessage = "Employee Updated Successfully";
_logger.LogInfo("Employee updated. EmployeeId={EmployeeId}, Org={OrgId}", employeeId, existingEmployee.OrganizationId);
}
else
{
// Create flow
var newEmployee = _mapper.Map<Employee>(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<EmployeeVM>(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<object>.SuccessResponse(employeeVM, responseMessage, 200));
}
catch (DbUpdateException dbEx)
{
await transaction.RollbackAsync();
_logger.LogError(dbEx, "Database exception occurred while managing employee");
return StatusCode(500, ApiResponse<object>.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<object>.ErrorResponse(
"Internal exception occurred",
"Internal exception has occurred", 500));
}
}
}
[HttpPost("manage-mobile")] [HttpPost("manage-mobile")]
public async Task<IActionResult> CreateUserMoblie([FromBody] MobileUserManageDto model) public async Task<IActionResult> CreateUserMoblie([FromBody] MobileUserManageDto model)
{ {
@ -527,6 +734,126 @@ namespace MarcoBMS.Services.Controllers
} }
} }
[HttpPost("app/manage")]
public async Task<IActionResult> 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<object>.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<object>.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<object>.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<object>.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<EmployeeVM>(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<object>.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<object>.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<EmployeeVM>(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<object>.SuccessResponse(employeeVM, "Employee updated successfully", 200));
}
}
[HttpDelete("{id}")] [HttpDelete("{id}")]
public async Task<IActionResult> SuspendEmployee(Guid id, [FromQuery] bool active = false) public async Task<IActionResult> SuspendEmployee(Guid id, [FromQuery] bool active = false)
{ {

View File

@ -26,6 +26,11 @@ namespace MarcoBMS.Services.Helpers
var tenant = _httpContextAccessor.HttpContext?.User.FindFirst("TenantId")?.Value; var tenant = _httpContextAccessor.HttpContext?.User.FindFirst("TenantId")?.Value;
return (tenant != null ? Guid.Parse(tenant) : Guid.Empty); 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<Tenant?> GetCurrentTenant() public async Task<Tenant?> GetCurrentTenant()
{ {
var tenantId = _httpContextAccessor.HttpContext?.User.FindFirst("TenantId")?.Value; var tenantId = _httpContextAccessor.HttpContext?.User.FindFirst("TenantId")?.Value;

View File

@ -6,6 +6,7 @@ using Marco.Pms.Model.Dtos.Activities;
using Marco.Pms.Model.Dtos.AppMenu; using Marco.Pms.Model.Dtos.AppMenu;
using Marco.Pms.Model.Dtos.Directory; using Marco.Pms.Model.Dtos.Directory;
using Marco.Pms.Model.Dtos.DocumentManager; using Marco.Pms.Model.Dtos.DocumentManager;
using Marco.Pms.Model.Dtos.Employees;
using Marco.Pms.Model.Dtos.Expenses; using Marco.Pms.Model.Dtos.Expenses;
using Marco.Pms.Model.Dtos.Master; using Marco.Pms.Model.Dtos.Master;
using Marco.Pms.Model.Dtos.Organization; using Marco.Pms.Model.Dtos.Organization;
@ -177,6 +178,9 @@ namespace Marco.Pms.Services.MappingProfiles
#region ======================================================= Employee ======================================================= #region ======================================================= Employee =======================================================
CreateMap<Employee, EmployeeVM>(); CreateMap<Employee, EmployeeVM>();
CreateMap<CreateUserDto, Employee>();
CreateMap<MobileUserManageDto, Employee>();
CreateMap<Employee, BasicEmployeeVM>() CreateMap<Employee, BasicEmployeeVM>()
.ForMember( .ForMember(
dest => dest.JobRoleName, dest => dest.JobRoleName,