using AutoMapper; using AutoMapper.QueryableExtensions; using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Organization; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.OrganizationModel; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.Master; using Marco.Pms.Model.ViewModels.Organization; using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Helpers; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Service; using Microsoft.EntityFrameworkCore; using System.Linq.Dynamic.Core; namespace Marco.Pms.Services.Service { public class OrganizationService : IOrganizationService { private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; private readonly IServiceScopeFactory _serviceScope; private readonly IMapper _mapper; private readonly ILoggingService _logger; private static readonly Guid PMCProvider = Guid.Parse("b1877a3b-8832-47b1-bbe3-dc7e98672f49"); private static readonly Guid ServiceProvider = Guid.Parse("5ee49bcd-b6d3-482f-9aaf-484afe04abec"); private static readonly Guid SubContractorProvider = Guid.Parse("a283356a-9b02-4029-afb7-e65c703efdd4"); private static readonly Guid superTenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26"); public OrganizationService(IDbContextFactory dbContextFactory, ApplicationDbContext context, IServiceScopeFactory serviceScope, ILoggingService logger, IMapper mapper) { _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); _context = context ?? throw new ArgumentNullException(nameof(context)); _serviceScope = serviceScope ?? throw new ArgumentNullException(nameof(serviceScope)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); } #region =================================================================== Get Functions =================================================================== public async Task> GetOrganizarionListAsync(string? searchString, long? sprid, bool active, int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId, Guid loggedOrganizationId) { _logger.LogDebug("Fetching organization list. SearchString: {SearchString}, SPRID: {SPRID}, Active: {Active}, Page: {PageNumber}, Size: {PageSize}", searchString ?? "", sprid ?? 0, active, pageNumber, pageSize); // Base query filtering by active status IQueryable organizationQuery = _context.Organizations.Where(o => o.IsActive == active); if (sprid.HasValue) { // Filter by SPRID if provided organizationQuery = organizationQuery.Where(o => o.SPRID == sprid.Value); _logger.LogDebug("Filtering organizations by SPRID: {SPRID}", sprid.Value); } else { // Get organization IDs mapped to current tenant that are active var organizationIdsTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.TenantOrgMappings .Where(to => to.TenantId == tenantId && to.IsActive) .Select(to => to.OrganizationId) .ToListAsync(); }); var tenantTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Tenants .FirstOrDefaultAsync(t => t.Id == tenantId && t.IsActive); }); await Task.WhenAll(organizationIdsTask, tenantTask); var organizationIds = organizationIdsTask.Result; var tenant = tenantTask.Result; if (tenant == null) { _logger.LogWarning("Tenant {TenantId} is not found", tenantId); return ApiResponse.ErrorResponse("Tenant not found", "Tenant not found in database", 404); } organizationIds.Add(tenant.OrganizationId); organizationIds = organizationIds.Distinct().ToList(); organizationQuery = organizationQuery.Where(o => organizationIds.Contains(o.Id)); _logger.LogDebug("Filtering organizations by tenant's mapped IDs count: {Count}", organizationIds.Count); if (!string.IsNullOrWhiteSpace(searchString)) { // Filter by search string on organization name -- extend here if needed organizationQuery = organizationQuery.Where(o => o.Name.Contains(searchString)); _logger.LogDebug("Filtering organizations by search string: {SearchString}", searchString); } } // Get total count for pagination var totalCount = await organizationQuery.CountAsync(); var totalPages = (int)Math.Ceiling((double)totalCount / pageSize); // Fetch page of organizations sorted by name var organizations = await organizationQuery .OrderBy(o => o.Name) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(); if (!organizations.Any() && !sprid.HasValue) { organizations = await _context.Tenants.AsNoTracking() .Include(t => t.Organization) .Where(t => t.Id == tenantId && t.OrganizationId == loggedInEmployee.OrganizationId && t.Organization != null && t.IsActive) .Select(t => t.Organization!).ToListAsync(); } // Collect creator and updater employee IDs var createdByIds = organizations.Where(o => o.CreatedById != null).Select(o => o.CreatedById!.Value).Distinct().ToList(); var updatedByIds = organizations.Where(o => o.UpdatedById != null).Select(o => o.UpdatedById!.Value).Distinct().ToList(); // Fetch corresponding employee details in one query var employeeIds = createdByIds.Union(updatedByIds).ToList(); var employees = await _context.Employees.Where(e => employeeIds.Contains(e.Id)).ToListAsync(); // Map data to view models including created and updated by employees var vm = organizations.Select(o => { var orgVm = _mapper.Map(o); orgVm.CreatedBy = employees.Where(e => e.Id == o.CreatedById).Select(e => _mapper.Map(e)).FirstOrDefault(); orgVm.UpdatedBy = employees.Where(e => e.Id == o.UpdatedById).Select(e => _mapper.Map(e)).FirstOrDefault(); return orgVm; }).ToList(); var response = new { CurrentPage = pageNumber, TotalPages = totalPages, TotalEntities = totalCount, Data = vm, }; _logger.LogInfo("Fetched {Count} organizations (Page {PageNumber} of {TotalPages})", vm.Count, pageNumber, totalPages); return ApiResponse.SuccessResponse(response, "Successfully fetched the organization list", 200); } /// /// Retrieves a paginated, searchable list of organizations. /// Optimized for performance using DB Projections and AsNoTracking. /// /// Optional keyword to filter organizations by name. /// The requested page number (1-based). /// The number of records per page (Max 50). /// The current user context for security filtering. /// Cancellation token to cancel operations if the client disconnects. /// A paginated list of BasicOrganizationVm. public async Task> GetOrganizationBasicListAsync(Guid? id, string? searchString, int pageNumber, int pageSize, Employee loggedInEmployee, CancellationToken ct = default) { try { // 1. VALIDATION _logger.LogInfo("Fetching Organization list. Page: {Page}, Size: {Size}, Search: {Search}, User: {UserId}", pageNumber, pageSize, searchString ?? "", loggedInEmployee.Id); // 2. QUERY BUILDING // Use AsNoTracking() for read-only scenarios to reduce overhead. var query = _context.Organizations.AsNoTracking() .Where(o => o.IsActive); // 3. SECURITY FILTER (Multi-Tenancy) // Enterprise Rule: Always filter by the logged-in user's Tenant/Permissions. // Assuming loggedInEmployee has a TenantId or OrganizationId // query = query.Where(o => o.TenantId == loggedInEmployee.TenantId); // 4. DYNAMIC FILTERING if (!string.IsNullOrWhiteSpace(searchString)) { var searchTrimmed = searchString.Trim(); query = query.Where(o => o.Name.Contains(searchTrimmed)); } if (id.HasValue) { query = query.Where(o => o.Id == id.Value); } // 5. COUNT TOTALS (Efficiently) // Count the total records matching the filter BEFORE applying pagination var totalCount = await query.CountAsync(ct); // 6. FETCH DATA (With Projection) // CRITICAL OPTIMIZATION: Use .ProjectTo or .Select BEFORE .ToListAsync. // This ensures SQL only fetches the columns needed for BasicOrganizationVm, // rather than fetching the whole Entity and discarding data in memory. var items = await query .OrderBy(o => o.Name) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ProjectTo(_mapper.ConfigurationProvider) // Requires AutoMapper.QueryableExtensions .ToListAsync(ct); // 7. PREPARE RESPONSE var totalPages = (int)Math.Ceiling((double)totalCount / pageSize); var pagedResult = new { CurrentPage = pageNumber, PageSize = pageSize, TotalPages = totalPages, TotalCount = totalCount, HasPrevious = pageNumber > 1, HasNext = pageNumber < totalPages, Data = items }; _logger.LogInfo("Successfully fetched {Count} organizations out of {Total}.", items.Count, totalCount); return ApiResponse.SuccessResponse(pagedResult, "Organization list fetched successfully.", 200); } catch (OperationCanceledException) { // Handle client disconnection gracefully _logger.LogWarning("Organization list fetch was cancelled by the client."); return ApiResponse.ErrorResponse("Request Cancelled", "The operation was cancelled.", 499); } catch (Exception ex) { _logger.LogError(ex, "Failed to fetch organization list. User: {UserId}, Error: {Message}", loggedInEmployee.Id, ex.Message); return ApiResponse.ErrorResponse("Data Fetch Failed", "An unexpected error occurred while retrieving the organization list.", 500); } } public async Task> GetOrganizationDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId, Guid loggedOrganizationId) { _logger.LogDebug("Started fetching details for OrganizationId: {OrganizationId}", id); try { // Fetch the organization entity by Id var organization = await _context.Organizations.FirstOrDefaultAsync(o => o.Id == id); if (organization == null) { _logger.LogWarning("Organization not found for OrganizationId: {OrganizationId}", id); return ApiResponse.ErrorResponse("Organization not found", "Organization not found", 404); } // Fetch CreatedBy employee (with JobRole) var createdByTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Employees .Include(e => e.JobRole) .FirstOrDefaultAsync(e => e.Id == organization.CreatedById); }); // Fetch UpdatedBy employee (with JobRole) var updatedByTask = Task.Run(async () => { if (organization.UpdatedById.HasValue) { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Employees .Include(e => e.JobRole) .FirstOrDefaultAsync(e => e.Id == organization.UpdatedById); } return null; }); // Fetch the organization's service mappings and corresponding services var orgServiceMappingTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.OrgServiceMappings .Include(os => os.Service) .Where(os => os.OrganizationId == id).ToListAsync(); }); // Fetch active employees in the organization var employeeListTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Employees .Where(e => e.OrganizationId == id && e.IsActive).ToListAsync(); }); await Task.WhenAll(createdByTask, updatedByTask, orgServiceMappingTask, employeeListTask); var createdByEmployee = createdByTask.Result; var updatedByEmployee = updatedByTask.Result; var orgServiceMappings = orgServiceMappingTask.Result; var employeeList = employeeListTask.Result; var activeEmployeeCount = employeeList.Count; var activeApplicationUserCount = employeeList.Count(e => e.HasApplicationAccess); // Start query for projects mapped to this organization (including project and service info) var baseProjectOrgMappingQuery = _context.ProjectOrgMappings .Include(po => po.ProjectService) .ThenInclude(ps => ps!.Service) .Include(po => po.ProjectService) .ThenInclude(ps => ps!.Project) .Where(po => po.OrganizationId == id && po.ProjectService != null); // If logged-in employee is not from the requested organization, restrict projects to those also mapped to their org List projectOrgMappings; if (loggedInEmployee.OrganizationId != id) { var projectIds = await _context.ProjectOrgMappings .Include(po => po.ProjectService) .Where(po => po.OrganizationId == loggedInEmployee.OrganizationId && po.ProjectService != null) .Select(po => po.ProjectService!.ProjectId) .ToListAsync(); projectOrgMappings = await baseProjectOrgMappingQuery .Where(po => projectIds.Contains(po.ProjectService!.ProjectId)) .ToListAsync(); } else { projectOrgMappings = await baseProjectOrgMappingQuery.ToListAsync(); } // Map results to output view model var response = _mapper.Map(organization); response.ActiveApplicationUserCount = activeApplicationUserCount; response.ActiveEmployeeCount = activeEmployeeCount; response.CreatedBy = _mapper.Map(createdByEmployee); response.UpdatedBy = _mapper.Map(updatedByEmployee); response.Projects = _mapper.Map>(projectOrgMappings.Select(po => po.ProjectService).ToList()); response.Services = orgServiceMappings.Where(os => os.Service != null).Select(os => os.Service!).ToList(); _logger.LogInfo("Fetched organization details for OrganizationId: {OrganizationId}, Employee count: {EmployeeCount}, App user count: {AppUserCount}, Project count: {ProjectCount}", id, activeEmployeeCount, activeApplicationUserCount, response.Projects.Count); return ApiResponse.SuccessResponse(response, "Successfully fetched the organization details", 200); } catch (DbUpdateException dbEx) { _logger.LogError(dbEx, "Database exception while fetching details for OrganizationId: {OrganizationId}", id); return ApiResponse.ErrorResponse("Internal error", "A database exception occurred", 500); } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception while fetching details for OrganizationId: {OrganizationId}", id); return ApiResponse.ErrorResponse("Internal error", "An internal exception occurred", 500); } } /// /// Retrieves the active organization hierarchy list for a specified employee within a given tenant. /// /// ID of the employee whose hierarchy is requested. /// Logged-in employee making the request (for audit/logging). /// Tenant ID for multi-tenant filtering. /// Organization ID of the logged-in employee (for access validation). /// ApiResponse containing the list of organization hierarchy view models or error details. public async Task> GetOrganizationHierarchyListAsync(Guid employeeId, Employee loggedInEmployee, Guid tenantId, Guid loggedOrganizationId) { // Validate input IDs if (tenantId == Guid.Empty || loggedOrganizationId == Guid.Empty) { _logger.LogWarning("Access denied: Invalid tenantId or loggedOrganizationId. TenantId: {TenantId}, OrganizationId: {OrganizationId}", tenantId, loggedOrganizationId); return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant or organization identifier.", 403); } try { // Query to get active organization hierarchies, including related navigation properties for richer data var organizationHierarchies = await _context.OrganizationHierarchies .Include(oh => oh.Employee).ThenInclude(e => e!.JobRole) .Include(oh => oh.AssignedBy).ThenInclude(e => e!.JobRole) .Include(oh => oh.ReportTo).ThenInclude(e => e!.JobRole) .AsNoTracking() .Where(oh => oh.EmployeeId == employeeId && oh.IsActive && oh.TenantId == tenantId) .OrderByDescending(oh => oh.AssignedAt) .ToListAsync(); // Check if any records found if (!organizationHierarchies.Any()) { _logger.LogWarning("No active organization hierarchy found for EmployeeId: {EmployeeId} in TenantId: {TenantId}.", employeeId, tenantId); return ApiResponse.SuccessResponse(new List(), "No active superiors found.", 200); } // Map entities to view models var response = _mapper.Map>(organizationHierarchies); _logger.LogInfo("Fetched {Count} active superiors for EmployeeId: {EmployeeId} in TenantId: {TenantId}.", response.Count, employeeId, tenantId); return ApiResponse.SuccessResponse(response, $"{response.Count} superior(s) fetched successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "An error occurred while fetching organization hierarchy list for EmployeeId: {EmployeeId} in TenantId: {TenantId}.", employeeId, tenantId); return ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while processing your request.", 500); } } #endregion #region =================================================================== Post Functions =================================================================== public async Task> CreateOrganizationAsync(CreateOrganizationDto model, Employee loggedInEmployee, Guid tenantId, Guid loggedOrganizationId) { using var scope = _serviceScope.CreateScope(); // Create scope for scoped services await using var transaction = await _context.Database.BeginTransactionAsync(); try { // Concurrent permission check and organization existence check var hasPermissionTask = Task.Run(async () => { var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.AddOrganization, loggedInEmployee.Id); }); var isPrimaryOrganizationTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Tenants.AnyAsync(t => t.OrganizationId == loggedInEmployee.OrganizationId); }); await Task.WhenAll(hasPermissionTask, isPrimaryOrganizationTask); bool hasPermission = hasPermissionTask.Result; bool isPrimaryOrganization = isPrimaryOrganizationTask.Result; // Check user access permission if (!hasPermission && !isPrimaryOrganization) { _logger.LogWarning("User {EmployeeId} attempted to create a new organization without permission", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "You do not have permission to create new organization.", 403); } // Get last SPRID and increment for new organization var lastOrganization = await _context.Organizations.OrderByDescending(sp => sp.SPRID).FirstOrDefaultAsync(); long lastSPRID = lastOrganization?.SPRID ?? 5400; // Map DTO to entity and set defaults Organization organization = _mapper.Map(model); organization.SPRID = lastSPRID + 1; organization.CreatedAt = DateTime.UtcNow; organization.CreatedById = loggedInEmployee.Id; organization.IsActive = true; _context.Organizations.Add(organization); // Create mapping for organization tenant var newOrganizationTenantMapping = new TenantOrgMapping { OrganizationId = organization.Id, SPRID = organization.SPRID, AssignedDate = DateTime.UtcNow, IsActive = true, AssignedById = loggedInEmployee.Id, TenantId = tenantId }; _context.TenantOrgMappings.Add(newOrganizationTenantMapping); //// Prepare user creation for identity //var user = new ApplicationUser //{ // UserName = model.Email, // Email = model.Email, // EmailConfirmed = true //}; //var configuration = scope.ServiceProvider.GetRequiredService(); //var userManager = scope.ServiceProvider.GetRequiredService>(); ////Create Identity user with a default password (recommend to improve password handling) //var result = await userManager.CreateAsync(user, "User@123"); //if (!result.Succeeded) //{ // _logger.LogWarning("Failed to create identity user for email {Email}: {Errors}", model.Email, result.Errors); // return ApiResponse.ErrorResponse("Failed to create user", result.Errors, 400); //} //// Get admin job role or fallback role of the tenant //var jobRole = await _context.JobRoles.FirstOrDefaultAsync(jr => jr.Name == "Admin" && jr.TenantId == tenantId) // ?? await _context.JobRoles.FirstOrDefaultAsync(jr => jr.TenantId == tenantId); //// Parse full name safely (consider improving split logic for multi-part names) //var fullName = model.ContactPerson.Split(' ', StringSplitOptions.RemoveEmptyEntries); //Employee newEmployee = new Employee //{ // FirstName = fullName.Length > 0 ? fullName[0] : string.Empty, // LastName = fullName.Length > 1 ? fullName[^1] : string.Empty, // Email = model.Email, // PermanentAddress = model.Address, // CurrentAddress = model.Address, // PhoneNumber = model.ContactNumber, // ApplicationUserId = user.Id, // JobRoleId = jobRole?.Id ?? Guid.Empty, // IsActive = true, // IsSystem = false, // IsPrimary = true, // OrganizationId = organization.Id, // HasApplicationAccess = true //}; //_context.Employees.Add(newEmployee); // Map organization services if (model.ServiceIds?.Any() ?? false) { var serviceOrgMappings = model.ServiceIds.Select(s => new OrgServiceMapping { ServiceId = s, OrganizationId = organization.Id }).ToList(); _context.OrgServiceMappings.AddRange(serviceOrgMappings); } // Persist all changes await _context.SaveChangesAsync(); await transaction.CommitAsync(); // Prepare response DTO var response = _mapper.Map(organization); response.CreatedBy = _mapper.Map(loggedInEmployee); return ApiResponse.SuccessResponse(response, "Successfully created the organization", 200); } catch (DbUpdateException dbEx) { await transaction.RollbackAsync(); _logger.LogError(dbEx, "Database exception occurred while creating organization"); return ApiResponse.ErrorResponse("Internal error", "A database exception occurred", 500); } catch (Exception ex) { _logger.LogError(ex, "Unexpected exception occurred while creating organization"); return ApiResponse.ErrorResponse("Internal error", "An unexpected error occurred", 500); } } public async Task> AssignOrganizationToProjectAsync(AssignOrganizationDto model, Employee loggedInEmployee, Guid tenantId, Guid loggedOrganizationId) { _logger.LogDebug("Started assigning organization {OrganizationId} to project {ProjectId} with service IDs {@ServiceIds}", model.OrganizationId, model.ProjectId, model.ServiceIds); // Begin a database transaction await using var transaction = await _context.Database.BeginTransactionAsync(); try { var today = DateTime.UtcNow.Date; // Fetch all needed entities concurrently using the single context var projectServicesTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ProjectServiceMappings .Where(sp => model.ServiceIds.Contains(sp.ServiceId) && sp.ProjectId == model.ProjectId && sp.IsActive).ToListAsync(); }); var projectOrgMappingsTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ProjectOrgMappings .Include(po => po.ProjectService) .Where(po => po.ProjectService != null && model.ServiceIds.Contains(po.ProjectService.ServiceId) && po.ProjectService.ProjectId == model.ProjectId).ToListAsync(); }); var serviceTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ServiceMasters.Where(s => model.ServiceIds.Contains(s.Id) && s.TenantId == tenantId).ToListAsync(); }); var orgTypeTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.OrgTypeMasters.FirstOrDefaultAsync(o => o.Id == model.OrganizationId); }); var organizationTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Organizations.FirstOrDefaultAsync(o => o.Id == model.OrganizationId); }); var parentOrgTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Organizations.FirstOrDefaultAsync(o => o.Id == model.ParentOrganizationId); }); var projectTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Projects.FirstOrDefaultAsync(p => p.Id == model.ProjectId); }); var isPMCTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Projects.AnyAsync(p => p.Id == model.ProjectId && p.PMCId == loggedInEmployee.OrganizationId); }); var isServiceProviderTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ProjectOrgMappings.AnyAsync(p => p.Id == model.ProjectId && p.OrganizationId == loggedInEmployee.OrganizationId && p.OrganizationTypeId == ServiceProvider); }); await Task.WhenAll(projectTask, organizationTask, parentOrgTask, serviceTask, orgTypeTask, projectServicesTask, projectOrgMappingsTask, isPMCTask, isServiceProviderTask); var project = projectTask.Result; var organization = organizationTask.Result; var parentOrganization = parentOrgTask.Result; var services = serviceTask.Result; var organizationType = orgTypeTask.Result; var projectServices = projectServicesTask.Result; var projectOrganizations = projectOrgMappingsTask.Result; var isPMC = isPMCTask.Result; var isServiceProvider = isServiceProviderTask.Result; // Validation checks if (organization == null) { _logger.LogWarning("Organization with ID {OrganizationId} not found.", model.OrganizationId); return ApiResponse.ErrorResponse("Organization not found", "Organization not found in database", 404); } if (project == null) { _logger.LogWarning("Project with ID {ProjectId} not found.", model.ProjectId); return ApiResponse.ErrorResponse("Project not found", "Project not found in database", 404); } if (services == null || !services.Any()) { _logger.LogWarning("No services found for Service IDs {@ServiceIds}.", model.ServiceIds); return ApiResponse.ErrorResponse("Project Service not found", "Project Service not found in database", 404); } // Check whether mapping exists between service provider organization and tenant var serviceProviderTenantMapping = await _context.TenantOrgMappings .FirstOrDefaultAsync(spt => spt.OrganizationId == model.OrganizationId && spt.TenantId == project.TenantId && spt.IsActive); if (serviceProviderTenantMapping == null) { var newMapping = new TenantOrgMapping { OrganizationId = organization.Id, SPRID = organization.SPRID, AssignedDate = today, IsActive = true, AssignedById = loggedInEmployee.Id, TenantId = project.TenantId }; _context.TenantOrgMappings.Add(newMapping); _logger.LogInfo("Created new TenantOrgMapping for OrganizationId {OrganizationId} and TenantId {TenantId}", organization.Id, project.TenantId); } // Access control validations if (isPMC && model.OrganizationTypeId != ServiceProvider && model.OrganizationTypeId != SubContractorProvider) { _logger.LogWarning("PMCs cannot assign organization type {OrganizationTypeId}. UserId: {UserId}", model.OrganizationTypeId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "You don't have access to assign this type of organization", 403); } if (isServiceProvider && model.OrganizationTypeId == ServiceProvider) { _logger.LogWarning("Service providers cannot assign organization type {OrganizationTypeId}. UserId: {UserId}", model.OrganizationTypeId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "You don't have access to assign this type of organization", 403); } var newProjectOrgMappings = new List(); var newProjectServiceMappings = new List(); // Loop through each service to create mappings foreach (var serviceId in model.ServiceIds) { var service = services.FirstOrDefault(s => s.Id == serviceId); if (service == null) { _logger.LogWarning("Service with ID {ServiceId} not found.", serviceId); return ApiResponse.ErrorResponse("Service not found", "Service not found in database", 404); } var projectService = projectServices.FirstOrDefault(ps => ps.ServiceId == serviceId); if (projectService == null) { projectService = new ProjectServiceMapping { Id = Guid.NewGuid(), ProjectId = project.Id, ServiceId = serviceId, TenantId = project.TenantId, PlannedStartDate = project.StartDate ?? today, PlannedEndDate = project.EndDate ?? today, ActualStartDate = today, IsActive = true }; newProjectServiceMappings.Add(projectService); } // Check if the organization is already assigned for this service var existingAssignment = projectOrganizations.FirstOrDefault(po => po.ProjectService != null && po.ProjectService.ProjectId == project.Id && po.ProjectService.ServiceId == serviceId && po.OrganizationId == model.OrganizationId); if (existingAssignment != null) { _logger.LogWarning("Organization {OrganizationId} is already assigned to project {ProjectId} for service {ServiceId}.", model.OrganizationId, project.Id, serviceId); return ApiResponse.ErrorResponse("Organization already assigned", "Organization is already assigned to this project and service", 409); } // Prepare new project-org mapping var projectOrgMapping = new ProjectOrgMapping { ProjectServiceId = projectService.Id, OrganizationId = model.OrganizationId, OrganizationTypeId = model.OrganizationTypeId, ParentOrganizationId = model.ParentOrganizationId ?? loggedInEmployee.OrganizationId, AssignedDate = today, AssignedById = loggedInEmployee.Id, TenantId = project.TenantId }; newProjectOrgMappings.Add(projectOrgMapping); } // Save new project service mappings if any if (newProjectServiceMappings.Any()) { _context.ProjectServiceMappings.AddRange(newProjectServiceMappings); await _context.SaveChangesAsync(); _logger.LogInfo("Added {Count} new ProjectServiceMappings for ProjectId {ProjectId}.", newProjectServiceMappings.Count, project.Id); } // Save new project organization mappings _context.ProjectOrgMappings.AddRange(newProjectOrgMappings); await _context.SaveChangesAsync(); // Commit transaction await transaction.CommitAsync(); _logger.LogInfo("Assigned organization {OrganizationId} to project {ProjectId} successfully.", model.OrganizationId, model.ProjectId); // Prepare response view models var organizationVm = _mapper.Map(organization); var parentOrganizationVm = _mapper.Map(parentOrganization); var projectVm = _mapper.Map(project); var response = services.Select(s => new AssignOrganizationVm { Project = projectVm, OrganizationType = organizationType, Organization = organizationVm, ParentOrganization = parentOrganizationVm, Service = _mapper.Map(s) }).ToList(); await AssignApplicationRoleToOrganization(organization.Id, project.TenantId, loggedOrganizationId); return ApiResponse.SuccessResponse(response, "Organization successfully assigned to the project", 200); } catch (DbUpdateException dbEx) { await transaction.RollbackAsync(); _logger.LogError(dbEx, "Database exception occurred while assigning organization {OrganizationId} to project {ProjectId}", model.OrganizationId, model.ProjectId); return ApiResponse.ErrorResponse("Internal error", "A database exception occurred", 500); } catch (Exception ex) { await transaction.RollbackAsync(); _logger.LogError(ex, "Unhandled exception occurred while assigning organization {OrganizationId} to project {ProjectId}", model.OrganizationId, model.ProjectId); return ApiResponse.ErrorResponse("Internal error", "An internal exception occurred", 500); } } public async Task> AssignOrganizationToTenantAsync(Guid organizationId, Employee loggedInEmployee, Guid tenantId, Guid loggedOrganizationId) { _logger.LogInfo("Started assigning organization {OrganizationId} to tenant {TenantId}", organizationId, tenantId); // Begin a database transaction await using var transaction = await _context.Database.BeginTransactionAsync(); try { // Fetch existing tenant-organization mapping if any var organizationTenantMapping = await _context.TenantOrgMappings .FirstOrDefaultAsync(spt => spt.OrganizationId == organizationId && spt.TenantId == tenantId && spt.IsActive); // Fetch the organization details var organization = await _context.Organizations.FirstOrDefaultAsync(o => o.Id == organizationId); // Validate organization existence if (organization == null) { _logger.LogWarning("Organization with ID {OrganizationId} not found.", organizationId); return ApiResponse.ErrorResponse("Organization not found", "Organization not found in database", 404); } if (organizationTenantMapping != null) { _logger.LogInfo("Organization {OrganizationId} is already assigned to tenant {TenantId}. No action taken.", organizationId, tenantId); // Commit transaction anyway to complete scope cleanly (optional) await transaction.RollbackAsync(); return ApiResponse.ErrorResponse("Organization is already assigned to tenant", "Organization is already assigned to tenant", 409); } // Create new tenant-organization mapping if none exists var newMapping = new TenantOrgMapping { OrganizationId = organization.Id, SPRID = organization.SPRID, AssignedDate = DateTime.UtcNow, IsActive = true, AssignedById = loggedInEmployee.Id, TenantId = tenantId }; _context.TenantOrgMappings.Add(newMapping); await _context.SaveChangesAsync(); await transaction.CommitAsync(); _logger.LogInfo("Assigned organization {OrganizationId} to tenant {TenantId} successfully.", organizationId, tenantId); // Prepare response view model var response = _mapper.Map(organization); await AssignApplicationRoleToOrganization(organization.Id, tenantId, loggedOrganizationId); return ApiResponse.SuccessResponse(response, "Organization has been assigned to tenant", 200); } catch (DbUpdateException dbEx) { await transaction.RollbackAsync(); _logger.LogError(dbEx, "Database exception occurred while assigning organization {OrganizationId} to tenant {TenantId}.", organizationId, tenantId); return ApiResponse.ErrorResponse("Internal error", "A database exception has occurred", 500); } catch (Exception ex) { await transaction.RollbackAsync(); _logger.LogError(ex, "Unhandled exception occurred while assigning organization {OrganizationId} to tenant {TenantId}.", organizationId, tenantId); return ApiResponse.ErrorResponse("Internal error", "An internal exception has occurred", 500); } } /// /// Atomically manage the organization hierarchy for an employee: add, deactivate, and audit changes. /// /// Employee GUID to manage hierarchies for. /// List of hierarchy changes (DTOs). /// Current user performing the operation. /// Tenant context for multi-tenancy support. /// Current logged-in organization context. /// Standardized ApiResponse with updated hierarchy or error details. public async Task> ManageOrganizationHierarchyAsync(Guid employeeId, List model, Employee loggedInEmployee, Guid tenantId, Guid loggedOrganizationId) { // Validate required parameters early to avoid wasted DB calls if (tenantId == Guid.Empty || loggedOrganizationId == Guid.Empty) { _logger.LogWarning("Unauthorized attempt: Invalid tenant or organization IDs. TenantId: {TenantId}, OrgId: {OrgId}", tenantId, loggedOrganizationId); return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant or organization context.", 403); } if (model == null || model.Count == 0) { _logger.LogInfo("No data provided for employee {EmployeeId} hierarchy update.", employeeId); return ApiResponse.ErrorResponse("No hierarchy data provided.", "No hierarchy data provided.", 400); } var primaryHierarchies = model.Where(oh => oh.IsPrimary && oh.IsActive).ToList(); // Check if multiple primary hierarchies are provided for the employee if (primaryHierarchies.Count > 1) { // Log a warning indicating multiple primary hierarchies are not allowed _logger.LogWarning("Multiple primary hierarchy entries detected for employee {EmployeeId}. Only one primary hierarchy is allowed.", employeeId); // Return a bad request response with a clear, user-friendly message and an error code return ApiResponse.ErrorResponse( "Multiple primary hierarchies detected. Only one primary hierarchy is permitted per employee.", "Multiple primary hierarchies detected. Only one primary hierarchy is permitted per employee.", 400); } try { // Fetch current active hierarchies for employee and tenant status, no tracking needed since we will update selectively var existingHierarchies = await _context.OrganizationHierarchies .Where(oh => oh.EmployeeId == employeeId && oh.IsActive && oh.TenantId == tenantId) .ToListAsync(); var newEntries = new List(); var deactivateEntries = new List(); var auditLogs = new List(); // Cache primary hierarchy for quick reference to enforce business rules about one primary per employee var existingPrimary = existingHierarchies.FirstOrDefault(oh => oh.IsPrimary); // Process each input model item intelligently foreach (var dto in model) { var matchingEntry = existingHierarchies .FirstOrDefault(oh => oh.ReportToId == dto.ReportToId && oh.IsPrimary == dto.IsPrimary); if (dto.IsActive) { // Add new entry if none exists if (matchingEntry == null) { // Enforce primary uniqueness by checking if a primary exists and whether client intends to deactivate the old one if (dto.IsPrimary && existingPrimary != null) { var intendedPrimaryDeactivation = model.Any(m => m.IsPrimary && !m.IsActive && m.ReportToId == existingPrimary.ReportToId); if (!intendedPrimaryDeactivation) { _logger.LogWarning("Attempt to assign a second primary hierarchy for employee {EmployeeId} without deactivating current one.", employeeId); continue; // Skip this to maintain data integrity } } newEntries.Add(new OrganizationHierarchy { Id = Guid.NewGuid(), EmployeeId = employeeId, ReportToId = dto.ReportToId, IsPrimary = dto.IsPrimary, IsActive = true, AssignedAt = DateTime.UtcNow, AssignedById = loggedInEmployee.Id, TenantId = tenantId }); _logger.LogInfo("Prepared new active hierarchy link: EmployeeId {EmployeeId}, ReportsTo {ReportToId}, Primary {Primary}", employeeId, dto.ReportToId, dto.IsPrimary); } } else { // Deactivate existing entry if found and allowed if (matchingEntry != null) { if (dto.IsPrimary) { // Confirm alternative primary exists on active state to avoid orphan primary state var alternativePrimaryExists = model.Any(m => m.IsPrimary && m.IsActive && m.ReportToId != dto.ReportToId); if (!alternativePrimaryExists) { _logger.LogWarning("Attempt to deactivate sole primary hierarchy for employee {EmployeeId} prevented.", employeeId); continue; // Skip deactivation to avoid orphan primary } } matchingEntry.IsActive = false; deactivateEntries.Add(matchingEntry); auditLogs.Add(new OrgHierarchyLog { Id = Guid.NewGuid(), OrganizationHierarchyId = matchingEntry.Id, ReAssignedAt = DateTime.UtcNow, ReAssignedById = loggedInEmployee.Id, TenantId = tenantId }); _logger.LogInfo("Marked hierarchy for deactivation: EmployeeId {EmployeeId}, ReportsTo {ReportToId}", employeeId, dto.ReportToId); } } } // Batch database operations for insertions and updates if (newEntries.Any()) _context.OrganizationHierarchies.AddRange(newEntries); if (deactivateEntries.Any()) _context.OrganizationHierarchies.UpdateRange(deactivateEntries); if (auditLogs.Any()) _context.OrgHierarchyLogs.AddRange(auditLogs); await _context.SaveChangesAsync(); // Reload updated active hierarchy with related entities to respond with fresh data var updatedHierarchy = await _context.OrganizationHierarchies .Include(o => o.Employee).ThenInclude(e => e!.JobRole) .Include(o => o.ReportTo).ThenInclude(e => e!.JobRole) .Include(o => o.AssignedBy).ThenInclude(e => e!.JobRole) .AsNoTracking() .Where(oh => oh.EmployeeId == employeeId && oh.IsActive && oh.TenantId == tenantId) .OrderByDescending(oh => oh.AssignedAt) .ToListAsync(); var response = _mapper.Map>(updatedHierarchy); _logger.LogInfo("Organization hierarchy update completed for employee {EmployeeId}. NewEntries: {NewCount}, DeactivatedEntries: {DeactivatedCount}, LogsCreated: {LogCount}.", employeeId, newEntries.Count, deactivateEntries.Count, auditLogs.Count); return ApiResponse.SuccessResponse(response, $"{response.Count} active superior(s) retrieved and updated successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "Exception while managing organization hierarchy for employee {EmployeeId} in tenant {TenantId}.", employeeId, tenantId); return ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred while processing the request.", 500); } } #endregion #region =================================================================== Put Functions =================================================================== public async Task> UpdateOrganiationAsync(Guid id, UpdateOrganizationDto model, Employee loggedInEmployee, Guid tenantId, Guid loggedOrganizationId) { try { _logger.LogDebug("Started updating organization OrganizationId: {OrganizationId} by EmployeeId: {EmployeeId}", id, loggedInEmployee.Id); // Check if the user is a tenant-level employee and restrict editing to their own org var isTenantEmployee = await _context.Tenants.AnyAsync(t => t.Id == tenantId && t.OrganizationId == loggedInEmployee.OrganizationId); if (!isTenantEmployee && loggedInEmployee.OrganizationId != id) { _logger.LogWarning("Access denied. Tenant-level employee {EmployeeId} attempted to update another organization (OrganizationId: {OrganizationId})", loggedInEmployee.Id, id); return ApiResponse.ErrorResponse("Access Denied", "User does not have permission to update the organization", 403); } // Fetch the active organization entity var organization = await _context.Organizations.FirstOrDefaultAsync(o => o.Id == id && o.IsActive); if (organization == null) { _logger.LogWarning("Organization with Id {OrganizationId} not found or inactive.", id); return ApiResponse.ErrorResponse("Organization not found", "Organization not found", 404); } // Update basic organization fields organization.Name = model.Name; organization.ContactPerson = model.ContactPerson; organization.Address = model.Address; organization.GSTNumber = model.GSTNumber; organization.ContactNumber = model.ContactNumber; organization.UpdatedById = loggedInEmployee.Id; organization.UpdatedAt = DateTime.UtcNow; //// Fetch the primary active employee of the organization //var employee = await _context.Employees.FirstOrDefaultAsync(e => e.OrganizationId == id && e.IsPrimary && e.IsActive); //if (employee == null) //{ // _logger.LogWarning("Primary employee not found for OrganizationId: {OrganizationId}", id); // return ApiResponse.ErrorResponse("Primary employee not found", "Primary employee not found", 404); //} //// Split contact person's name into first and last names //var fullName = (model.ContactPerson ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries); //employee.FirstName = fullName.Length > 0 ? fullName[0] : string.Empty; //employee.LastName = fullName.Length > 1 ? fullName[^1] : string.Empty; //employee.CurrentAddress = model.Address; //employee.PermanentAddress = model.Address; //employee.PhoneNumber = model.ContactNumber; // Update organization's service mappings if service IDs are provided if (model.ServiceIds?.Any() ?? false) { // Fetch existing service mappings (as no tracking for diff logic) var orgServiceMappings = await _context.OrgServiceMappings .AsNoTracking() .Where(os => os.OrganizationId == id) .ToListAsync(); var existedServiceIds = orgServiceMappings.Select(os => os.ServiceId).ToList(); // Determine new service mappings to add var newServiceIds = model.ServiceIds.Except(existedServiceIds).ToList(); var orgServicesToDelete = orgServiceMappings .Where(s => !model.ServiceIds.Contains(s.ServiceId)) .ToList(); // Add new service mappings if (newServiceIds.Any()) { var newMappings = newServiceIds.Select(sid => new OrgServiceMapping { OrganizationId = id, ServiceId = sid }); await _context.OrgServiceMappings.AddRangeAsync(newMappings); } // Remove deleted service mappings if (orgServicesToDelete.Any()) { _context.OrgServiceMappings.RemoveRange(orgServicesToDelete); } } await _context.SaveChangesAsync(); var response = _mapper.Map(organization); var createdByEmployee = await _context.Employees.Include(e => e.JobRole).FirstOrDefaultAsync(e => e.Id == organization.CreatedById); response.CreatedBy = _mapper.Map(createdByEmployee); response.UpdatedBy = _mapper.Map(loggedInEmployee); _logger.LogInfo("Successfully updated organization OrganizationId: {OrganizationId} by EmployeeId: {EmployeeId}", id, loggedInEmployee.Id); return ApiResponse.SuccessResponse(response, "Organization updated Successfully", 200); } catch (DbUpdateException dbEx) { _logger.LogError(dbEx, "Database exception occurred while updating OrganizationId: {OrganizationId}", id); return ApiResponse.ErrorResponse("Internal error", "A database exception occurred", 500); } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception occurred while updating OrganizationId: {OrganizationId}", id); return ApiResponse.ErrorResponse("Internal error", "An internal exception occurred", 500); } } #endregion #region =================================================================== Delete Functions =================================================================== //public async Task> DeleteServiceProviderAsync(Guid id, bool active, Employee loggedInEmployee, Guid tenantId, Guid loggedOrganizationId) //{ // using var scope = _serviceScope.CreateScope(); // var message = active ? "Restore" : "Delete"; // var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // var client = await _context.Clients.FirstOrDefaultAsync(c => c.PrimaryEmployeeId == loggedInEmployee.Id && c.TenantId == tenantId); // if (!(loggedInEmployee.ApplicationUser?.IsRootUser ?? false) && !loggedInEmployee.IsPrimary && client == null) // { // return StatusCode(403, ApiResponse.ErrorResponse("Access Denied", $"You do not have permission to {message}d service provider.", 403)); // } // var serviceProvider = await _context.ServiceProviders.FirstOrDefaultAsync(sp => sp.Id == id); // if (serviceProvider == null) // { // return NotFound(ApiResponse.ErrorResponse("Service Provider not Found", "Service Provider not Found in database", 404)); // } // if (serviceProvider.IsActive == active) // { // return BadRequest(ApiResponse.ErrorResponse($"Service Provider is already {message}d", $"Service Provider is already {message}d", 400)); // } // var employeeIds = await _context.Employees.Where(e => e.ServiceProviderId == id).Select(e => e.Id).ToListAsync(); // var isPendingTask = await _context.TaskMembers.AnyAsync(tm => employeeIds.Contains(tm.EmployeeId)); // if (isPendingTask && !active) // { // return BadRequest(ApiResponse.ErrorResponse("There is an unfinshed task, Service provider cannot be deleted", "There is an unfinshed task, Service provider cannot be deleted", 400)); // } // serviceProvider.IsActive = active; // if (!active) // { // var servicePeroviderTenant = await _context.ServiceProviderTenantMappings.AsNoTracking().Where(spt => spt.ServiceProviderId == id && spt.IsActive).ToListAsync(); // var newServiceProviderTenant = servicePeroviderTenant.Select(spt => // { // spt.IsActive = false; // return spt; // }).ToList(); // _context.ServiceProviderTenantMappings.UpdateRange(newServiceProviderTenant); // } // await _context.SaveChangesAsync(); // return Ok(ApiResponse.SuccessResponse(new { }, $"Service Provider is {message}d", 200)); //} #endregion #region =================================================================== Helper Functions =================================================================== private async Task AssignApplicationRoleToOrganization(Guid organizationId, Guid tenantId, Guid loggedOrganizationId) { if (loggedOrganizationId == organizationId) { return; } using var scope = _serviceScope.CreateScope(); var rootEmployee = await _context.Employees .Include(e => e.ApplicationUser) .FirstOrDefaultAsync(e => e.ApplicationUser != null && e.ApplicationUser.IsRootUser.HasValue && e.ApplicationUser.IsRootUser.Value && e.OrganizationId == organizationId && e.IsPrimary); if (rootEmployee == null) { return; } string serviceProviderRoleName = "Service Provider Role"; var serviceProviderRole = await _context.ApplicationRoles.FirstOrDefaultAsync(ar => ar.Role == serviceProviderRoleName && ar.TenantId == tenantId); if (serviceProviderRole == null) { serviceProviderRole = new Model.Roles.ApplicationRole { Id = Guid.NewGuid(), Role = serviceProviderRoleName, Description = serviceProviderRoleName, IsSystem = true, TenantId = tenantId }; _context.ApplicationRoles.Add(serviceProviderRole); var rolePermissionMappigs = new List { new RolePermissionMappings { ApplicationRoleId = serviceProviderRole.Id, FeaturePermissionId = PermissionsMaster.ViewProject }, new RolePermissionMappings { ApplicationRoleId = serviceProviderRole.Id, FeaturePermissionId = PermissionsMaster.ViewProjectInfra }, new RolePermissionMappings { ApplicationRoleId = serviceProviderRole.Id, FeaturePermissionId = PermissionsMaster.ViewTask }, new RolePermissionMappings { ApplicationRoleId = serviceProviderRole.Id, FeaturePermissionId = PermissionsMaster.ViewAllEmployees }, new RolePermissionMappings { ApplicationRoleId = serviceProviderRole.Id, FeaturePermissionId = PermissionsMaster.TeamAttendance }, new RolePermissionMappings { ApplicationRoleId = serviceProviderRole.Id, FeaturePermissionId = PermissionsMaster.AssignRoles }, new RolePermissionMappings { ApplicationRoleId = serviceProviderRole.Id, FeaturePermissionId = PermissionsMaster.ManageProjectInfra }, new RolePermissionMappings { ApplicationRoleId = serviceProviderRole.Id, FeaturePermissionId = PermissionsMaster.AssignAndReportProgress }, new RolePermissionMappings { ApplicationRoleId = serviceProviderRole.Id, FeaturePermissionId = PermissionsMaster.AddAndEditTask } }; _context.RolePermissionMappings.AddRange(rolePermissionMappigs); } _context.EmployeeRoleMappings.Add(new EmployeeRoleMapping { EmployeeId = rootEmployee.Id, RoleId = serviceProviderRole.Id, IsEnabled = true, TenantId = tenantId }); var _cache = scope.ServiceProvider.GetRequiredService(); await _cache.ClearAllPermissionIdsByEmployeeID(rootEmployee.Id, tenantId); } #endregion } }