using AutoMapper; using FirebaseAdmin.Messaging; using Marco.Pms.DataAccess.Data; using Marco.Pms.Helpers.Utility; using Marco.Pms.Model.Directory; using Marco.Pms.Model.Dtos.Directory; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Mapper; using Marco.Pms.Model.MongoDBModels.Utility; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.Directory; using Marco.Pms.Model.ViewModels.Master; using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; using System.Text.Json; namespace Marco.Pms.Services.Service { public class DirectoryService : IDirectoryService { private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly ILoggingService _logger; private readonly UserHelper _userHelper; private readonly IMapper _mapper; private readonly UtilityMongoDBHelper _updateLogsHelper; private static readonly string contactCollection = "ContactModificationLog"; private static readonly string contactPhoneCollection = "ContactPhoneModificationLog"; private static readonly string contactEmailCollection = "ContactEmailModificationLog"; private static readonly string bucketCollection = "BucketModificationLog"; private static readonly string contactNoteCollection = "ContactNoteModificationLog"; public DirectoryService( IDbContextFactory dbContextFactory, ApplicationDbContext context, ILoggingService logger, IServiceScopeFactory serviceScopeFactory, UserHelper userHelper, IMapper mapper) { _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); _context = context ?? throw new ArgumentNullException(nameof(context)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory)); _userHelper = userHelper ?? throw new ArgumentNullException(nameof(userHelper)); _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); using var scope = serviceScopeFactory.CreateScope(); _updateLogsHelper = scope.ServiceProvider.GetRequiredService(); } #region =================================================================== Contact APIs =================================================================== #region =================================================================== Contact Get APIs =================================================================== /// /// Retrieves a paginated list of contacts based on permissions, search criteria, and filters. /// /// A search term to filter contacts by name, organization, email, phone, or tag. /// A JSON string representing ContactFilterDto for advanced filtering. /// Optional project ID to filter contacts assigned to a specific project. /// Boolean to filter for active or inactive contacts. /// The number of records per page. /// The current page number. /// The ID of the tenant to which the contacts belong. /// The employee making the request, used for permission checks. /// An ApiResponse containing the paginated list of contacts or an error. public async Task> GetListOfContactsAsync(string? search, string? filter, Guid? projectId, bool active, int pageSize, int pageNumber, Guid tenantId, Employee loggedInEmployee) { Guid loggedInEmployeeId = loggedInEmployee.Id; _logger.LogInfo( "Attempting to fetch contact list for Tenant {TenantId} by Employee {EmployeeId}. Search: '{Search}', Filter: '{Filter}'", tenantId, loggedInEmployeeId, search ?? "", filter ?? ""); try { // Use a single DbContext for the entire operation to ensure consistency. await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); // Step 1: Perform initial permission checks in parallel. var (hasAdminPermission, hasManagerPermission, hasUserPermission) = await CheckPermissionsAsync(loggedInEmployeeId); // Step 2: Build the core IQueryable with all filtering logic applied on the server. // This is the most critical optimization. var contactQuery = dbContext.Contacts.Where(c => c.TenantId == tenantId && c.IsActive == active); // --- Permission-based Filtering --- if (!hasAdminPermission) { if (hasManagerPermission || hasUserPermission) { // User can see contacts in buckets they created or are assigned to. var employeeBucketIds = await dbContext.EmployeeBucketMappings .Where(eb => eb.EmployeeId == loggedInEmployeeId) .Select(eb => eb.BucketId) .ToListAsync(); var accessibleBucketIds = await dbContext.Buckets .Where(b => b.TenantId == tenantId && (b.CreatedByID == loggedInEmployeeId || employeeBucketIds.Contains(b.Id))) .Select(b => b.Id) .ToListAsync(); contactQuery = contactQuery.Where(c => dbContext.ContactBucketMappings.Any(cbm => cbm.ContactId == c.Id && accessibleBucketIds.Contains(cbm.BucketId))); } else { _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get contact list due to lack of permissions.", loggedInEmployeeId); return ApiResponse.SuccessResponse("Access Denied", "You do not have permission to view any contacts.", 403); } } // --- Advanced Filtering from 'filter' parameter --- ContactFilterDto? contactFilter = TryDeserializeContactFilter(filter); if (contactFilter != null) { if (contactFilter.BucketIds?.Any() ?? false) { // Note: Permission filtering is already applied. Here we further restrict by the user's filter choice. contactQuery = contactQuery.Where(c => dbContext.ContactBucketMappings.Any(cbm => cbm.ContactId == c.Id && contactFilter.BucketIds.Contains(cbm.BucketId))); } if (contactFilter.CategoryIds?.Any() ?? false) { contactQuery = contactQuery.Where(c => c.ContactCategoryId.HasValue && contactFilter.CategoryIds.Contains(c.ContactCategoryId.Value)); } } // --- Standard Filtering --- if (projectId != null) { contactQuery = contactQuery.Where(c => dbContext.ContactProjectMappings.Any(cpm => cpm.ContactId == c.Id && cpm.ProjectId == projectId)); } // --- Search Term Filtering --- if (!string.IsNullOrWhiteSpace(search)) { var searchTermLower = search.ToLower(); contactQuery = contactQuery.Where(c => (c.Name != null && c.Name.ToLower().Contains(searchTermLower)) || (c.Organization != null && c.Organization.ToLower().Contains(searchTermLower)) || dbContext.ContactsEmails.Any(e => e.ContactId == c.Id && e.EmailAddress.ToLower().Contains(searchTermLower)) || dbContext.ContactsPhones.Any(p => p.ContactId == c.Id && p.PhoneNumber.ToLower().Contains(searchTermLower)) || dbContext.ContactTagMappings.Any(ctm => ctm.ContactId == c.Id && ctm.ContactTag != null && ctm.ContactTag.Name.ToLower().Contains(searchTermLower)) ); } // Step 3: Get the total count for pagination before applying Skip/Take. var totalCount = await contactQuery.CountAsync(); if (totalCount == 0) { return ApiResponse.SuccessResponse(new { TotalPages = 0, CurrentPage = pageNumber, PageSize = pageSize, Data = new List() }, "No contacts found matching the criteria.", 200); } // Step 4: Apply ordering and pagination to get the primary Contact entities for the current page. var contacts = await contactQuery .OrderBy(c => c.Name) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .Include(c => c.ContactCategory) // Include direct navigation properties if they exist .ToListAsync(); var finalContactIds = contacts.Select(c => c.Id).ToList(); // Step 5: Batch-fetch all related data for only the contacts on the current page. // This is highly efficient and avoids the N+1 problem. var phonesTask = Task.Run(async () => { await using var taskDbContext = await _dbContextFactory.CreateDbContextAsync(); return await taskDbContext.ContactsPhones .Where(p => finalContactIds.Contains(p.ContactId) && p.TenantId == tenantId) .ToListAsync(); }); var emailsTask = Task.Run(async () => { await using var taskDbContext = await _dbContextFactory.CreateDbContextAsync(); return await taskDbContext.ContactsEmails .Where(e => finalContactIds.Contains(e.ContactId) && e.TenantId == tenantId) .ToListAsync(); }); var tagsTask = Task.Run(async () => { await using var taskDbContext = await _dbContextFactory.CreateDbContextAsync(); return await taskDbContext.ContactTagMappings .Include(t => t.ContactTag) .Where(t => finalContactIds.Contains(t.ContactId)) .ToListAsync(); }); var projectsTask = Task.Run(async () => { await using var taskDbContext = await _dbContextFactory.CreateDbContextAsync(); return await taskDbContext.ContactProjectMappings .Where(p => finalContactIds.Contains(p.ContactId)) .ToListAsync(); }); var bucketsTask = Task.Run(async () => { await using var taskDbContext = await _dbContextFactory.CreateDbContextAsync(); return await taskDbContext.ContactBucketMappings .Where(b => finalContactIds.Contains(b.ContactId)) .ToListAsync(); }); // Now, await all the independent, thread-safe tasks. await Task.WhenAll(phonesTask, emailsTask, tagsTask, projectsTask, bucketsTask); // Use Lookups for efficient in-memory mapping. var phonesLookup = (phonesTask.Result).ToLookup(p => p.ContactId); var emailsLookup = (emailsTask.Result).ToLookup(e => e.ContactId); var tagsLookup = (tagsTask.Result).ToLookup(t => t.ContactId); var projectsLookup = (projectsTask.Result).ToLookup(p => p.ContactId); var bucketsLookup = (bucketsTask.Result).ToLookup(b => b.ContactId); // Step 6: Map entities to ViewModels, populating with the pre-fetched related data. var list = contacts.Select(c => { var contactVM = _mapper.Map(c); contactVM.ContactPhones = _mapper.Map>(phonesLookup[c.Id]); contactVM.ContactEmails = _mapper.Map>(emailsLookup[c.Id]); contactVM.Tags = _mapper.Map>(tagsLookup[c.Id].Select(ctm => ctm.ContactTag)); contactVM.ProjectIds = projectsLookup[c.Id].Select(p => p.ProjectId).ToList(); contactVM.BucketIds = bucketsLookup[c.Id].Select(b => b.BucketId).ToList(); return contactVM; }).ToList(); var totalPages = (int)Math.Ceiling((double)totalCount / pageSize); // Step 7: Construct and return the final response. var response = new { TotalPages = totalPages, CurrentPage = pageNumber, TotalRecords = totalCount, PageSize = pageSize, Data = list }; _logger.LogInfo("{Count} contacts were fetched successfully for Tenant {TenantId} by Employee {EmployeeId}", list.Count, tenantId, loggedInEmployeeId); return ApiResponse.SuccessResponse(response, $"{list.Count} contacts fetched successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "An unexpected error occurred while fetching contact list for Tenant {TenantId} by Employee {EmployeeId}", tenantId, loggedInEmployeeId); return ApiResponse.ErrorResponse("An internal error occurred.", ExceptionMapper(ex), 500); } } public async Task> GetListOfContactsOld(string? search, bool active, ContactFilterDto? filterDto, Guid? projectId) { Guid tenantId = _userHelper.GetTenantId(); var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var assignedRoleIds = await _context.EmployeeRoleMappings.Where(r => r.EmployeeId == LoggedInEmployee.Id).Select(r => r.RoleId).ToListAsync(); var permissionIds = await _context.RolePermissionMappings.Where(rp => assignedRoleIds.Contains(rp.ApplicationRoleId)).Select(rp => rp.FeaturePermissionId).Distinct().ToListAsync(); List? employeeBuckets = await _context.EmployeeBucketMappings.Where(eb => eb.EmployeeId == LoggedInEmployee.Id).ToListAsync(); List bucketIds = employeeBuckets.Select(c => c.BucketId).ToList(); if (permissionIds.Contains(PermissionsMaster.DirectoryAdmin)) { var buckets = await _context.Buckets.Where(b => b.TenantId == tenantId).ToListAsync(); bucketIds = buckets.Select(b => b.Id).ToList(); } else if (permissionIds.Contains(PermissionsMaster.DirectoryAdmin) || permissionIds.Contains(PermissionsMaster.DirectoryUser)) { var buckets = await _context.Buckets.Where(b => b.CreatedByID == LoggedInEmployee.Id).ToListAsync(); var createdBucketIds = buckets.Select(b => b.Id).ToList(); bucketIds.AddRange(createdBucketIds); bucketIds = bucketIds.Distinct().ToList(); } else { _logger.LogWarning("Employee {EmployeeId} attemped to access a contacts, but do not have permission", LoggedInEmployee.Id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 403); } List filterbucketIds = bucketIds; if (filterDto != null && filterDto.BucketIds != null && filterDto.BucketIds.Count > 0) { filterbucketIds = filterDto.BucketIds; } List? contactBuckets = await _context.ContactBucketMappings.Where(cb => bucketIds.Contains(cb.BucketId)).ToListAsync(); List contactIds = contactBuckets.Where(b => filterbucketIds.Contains(b.BucketId)).Select(cb => cb.ContactId).ToList(); List contacts = new List(); var contactProjects = await _context.ContactProjectMappings.Where(p => contactIds.Contains(p.ContactId)).ToListAsync(); if (projectId != null && projectId != Guid.Empty) { contactProjects = contactProjects.Where(p => p.ProjectId == projectId).ToList(); contactIds = contactProjects.Select(p => p.ContactId).Distinct().ToList(); } if (filterDto != null && filterDto.CategoryIds != null && filterDto.CategoryIds.Count > 0) { var categoryIds = filterDto.CategoryIds; contacts = await _context.Contacts.Include(c => c.ContactCategory).Where(c => contactIds.Contains(c.Id) && categoryIds.Contains(c.ContactCategoryId ?? Guid.Empty) && c.TenantId == tenantId && c.IsActive == active).ToListAsync(); } else { contacts = await _context.Contacts.Include(c => c.ContactCategory).Where(c => contactIds.Contains(c.Id) && c.TenantId == tenantId && c.IsActive == active).ToListAsync(); } var phoneNo = await _context.ContactsPhones.Where(p => contactIds.Contains(p.ContactId)).ToListAsync(); var Emails = await _context.ContactsEmails.Where(E => contactIds.Contains(E.ContactId)).ToListAsync(); var Tags = await _context.ContactTagMappings.Where(t => contactIds.Contains(t.ContactId)).ToListAsync(); List TagIds = Tags.Select(t => t.ContactTagId).ToList(); var TagList = await _context.ContactTagMasters.Where(t => TagIds.Contains(t.Id)).ToListAsync(); if (search != null && search != string.Empty) { List filteredContactIds = new List(); phoneNo = phoneNo.Where(p => Compare(p.PhoneNumber, search)).ToList(); filteredContactIds = phoneNo.Select(p => p.ContactId).ToList(); Emails = Emails.Where(e => Compare(e.EmailAddress, search)).ToList(); filteredContactIds.AddRange(Emails.Select(e => e.ContactId).ToList()); filteredContactIds = filteredContactIds.Distinct().ToList(); contacts = contacts.Where(c => Compare(c.Name, search) || Compare(c.Organization, search) || filteredContactIds.Contains(c.Id)).ToList(); } List list = new List(); foreach (var contact in contacts) { ContactVM contactVM = new ContactVM(); List contactEmailVms = new List(); List contactPhoneVms = new List(); List conatctTagVms = new List(); var phones = phoneNo.Where(p => p.ContactId == contact.Id).ToList(); var emails = Emails.Where(e => e.ContactId == contact.Id).ToList(); var tagMappingss = Tags.Where(t => t.ContactId == contact.Id).ToList(); var projectMapping = contactProjects.Where(p => p.ContactId == contact.Id).ToList(); var bucketMapping = contactBuckets.Where(b => b.ContactId == contact.Id).ToList(); if (emails != null && emails.Count > 0) { foreach (var email in emails) { ContactEmailVM emailVM = new ContactEmailVM(); emailVM = email.ToContactEmailVMFromContactEmail(); contactEmailVms.Add(emailVM); } } if (phones != null && phones.Count > 0) { foreach (var phone in phones) { ContactPhoneVM phoneVM = new ContactPhoneVM(); phoneVM = phone.ToContactPhoneVMFromContactPhone(); contactPhoneVms.Add(phoneVM); } } if (tagMappingss != null && tagMappingss.Count > 0) { foreach (var tagMapping in tagMappingss) { ContactTagVM tagVM = new ContactTagVM(); ; var tag = TagList.Find(t => t.Id == tagMapping.ContactTagId); tagVM = tag != null ? tag.ToContactTagVMFromContactTagMaster() : new ContactTagVM(); conatctTagVms.Add(tagVM); } } contactVM = contact.ToContactVMFromContact(); if (projectMapping != null && projectMapping.Count > 0) { contactVM.ProjectIds = projectMapping.Select(p => p.ProjectId).ToList(); } if (bucketMapping != null && bucketMapping.Count > 0) { contactVM.BucketIds = bucketMapping.Select(p => p.BucketId).ToList(); } contactVM.ContactEmails = contactEmailVms; contactVM.ContactPhones = contactPhoneVms; contactVM.Tags = conatctTagVms; list.Add(contactVM); } _logger.LogInfo("{count} contacts are fetched by Employee with ID {LoggedInEmployeeId}", list.Count, LoggedInEmployee.Id); return ApiResponse.SuccessResponse(list, System.String.Format("{0} contacts fetched successfully", list.Count), 200); } public async Task> GetContactsListByBucketIdAsync(Guid bucketId, Guid tenantId, Employee loggedInEmployee) { // Validate incoming bucket ID if (bucketId == Guid.Empty) { _logger.LogInfo("Employee ID {EmployeeId} sent an empty Bucket ID.", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Bucket ID is empty", "Bucket ID is empty", 400); } // Check permissions of logged-in employee var (hasAdminPermission, hasManagerPermission, hasUserPermission) = await CheckPermissionsAsync(loggedInEmployee.Id); if (!hasAdminPermission && !hasManagerPermission && !hasUserPermission) { _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get contacts list for tenant {TenantId} due to insufficient permissions.", loggedInEmployee.Id, tenantId); return ApiResponse.ErrorResponse("Access Denied", "You do not have permission to perform this action.", 403); } // Use dbContextFactory to create new DbContext instances for parallel calls using var context = _dbContextFactory.CreateDbContext(); // Confirm that the bucket exists and belongs to tenant var bucket = await context.Buckets.FirstOrDefaultAsync(b => b.Id == bucketId && b.TenantId == tenantId); if (bucket == null) { _logger.LogInfo("Employee ID {EmployeeId} attempted to access non-existent bucket ID {BucketId}.", loggedInEmployee.Id, bucketId); return ApiResponse.ErrorResponse("Bucket not found", "Bucket not found", 404); } // Load employee-bucket mappings for this bucket to check access var employeeBuckets = await context.EmployeeBucketMappings.Where(em => em.BucketId == bucketId).ToListAsync(); EmployeeBucketMapping? employeeBucket = hasAdminPermission ? employeeBuckets.FirstOrDefault() : employeeBuckets.FirstOrDefault(eb => eb.EmployeeId == loggedInEmployee.Id); if (employeeBucket == null) { _logger.LogInfo("Employee ID {EmployeeId} does not have access to bucket ID {BucketId}.", loggedInEmployee.Id, bucketId); return ApiResponse.ErrorResponse("You do not have access to this bucket.", "Unauthorized access to bucket.", 403); } // Fetch all contact-bucket mappings for this bucket var contactBucketMappings = await context.ContactBucketMappings.Where(cb => cb.BucketId == bucketId).ToListAsync(); if (contactBucketMappings.Count == 0) { _logger.LogInfo("No contacts found in bucket ID {BucketId} for employee {EmployeeId}.", bucketId, loggedInEmployee.Id); return ApiResponse.SuccessResponse(new List(), "No contacts available in this bucket.", 200); } var contactIds = contactBucketMappings.Select(cb => cb.ContactId).Distinct().ToList(); // Parallel fetching of related data via independent contexts to improve performance var contactTask = Task.Run(async () => { await using var _dbContext = await _dbContextFactory.CreateDbContextAsync(); return await _dbContext.Contacts.Include(c => c.ContactCategory) .Where(c => contactIds.Contains(c.Id) && c.IsActive).ToListAsync(); }); var phoneTask = Task.Run(async () => { await using var _dbContext = await _dbContextFactory.CreateDbContextAsync(); return await _dbContext.ContactsPhones.Where(p => contactIds.Contains(p.ContactId)).ToListAsync(); }); var emailTask = Task.Run(async () => { await using var _dbContext = await _dbContextFactory.CreateDbContextAsync(); return await _dbContext.ContactsEmails.Where(e => contactIds.Contains(e.ContactId)).ToListAsync(); }); var tagTask = Task.Run(async () => { await using var _dbContext = await _dbContextFactory.CreateDbContextAsync(); return await _dbContext.ContactTagMappings.Where(ct => contactIds.Contains(ct.ContactId)).ToListAsync(); }); var contactProjectTask = Task.Run(async () => { await using var _dbContext = await _dbContextFactory.CreateDbContextAsync(); return await _dbContext.ContactProjectMappings.Where(cp => contactIds.Contains(cp.ContactId)).ToListAsync(); }); var contactBucketTask = Task.Run(async () => { await using var _dbContext = await _dbContextFactory.CreateDbContextAsync(); return await _dbContext.ContactBucketMappings.Where(cb => contactIds.Contains(cb.ContactId)).ToListAsync(); }); await Task.WhenAll(contactTask); var contacts = contactTask.Result; var phones = phoneTask.Result; var emails = emailTask.Result; var tags = tagTask.Result; var contactProjects = contactProjectTask.Result; var contactBuckets = contactBucketTask.Result; // Load tag metadata if tags found List tagMasters = new List(); if (tags.Count > 0) { var tagIds = tags.Select(t => t.ContactTagId).Distinct().ToList(); tagMasters = await context.ContactTagMasters.Where(tm => tagIds.Contains(tm.Id)).ToListAsync(); } var contactVMs = new List(); // Build contact view models from data foreach (var contact in contacts) { // Transform phones var phoneVMs = phones.Where(p => p.ContactId == contact.Id).Select(p => _mapper.Map(p)).ToList(); // Transform emails var emailVMs = emails.Where(e => e.ContactId == contact.Id).Select(e => _mapper.Map(e)).ToList(); // Transform tags var contactTagMappings = tags.Where(t => t.ContactId == contact.Id); var tagVMs = new List(); foreach (var ct in contactTagMappings) { var tagMaster = tagMasters.Find(tm => tm.Id == ct.ContactTagId); if (tagMaster != null) { tagVMs.Add(_mapper.Map(tagMaster)); } } // Get project and bucket mappings for the contact var projectIds = contactProjects.Where(cp => cp.ContactId == contact.Id).Select(cp => cp.ProjectId).ToList(); var bucketIds = contactBuckets.Where(cb => cb.ContactId == contact.Id).Select(cb => cb.BucketId).ToList(); // Create the contact VM var contactVM = _mapper.Map(contact); contactVM.ContactPhones = phoneVMs; contactVM.ContactEmails = emailVMs; contactVM.Tags = tagVMs; contactVM.ProjectIds = projectIds; contactVM.BucketIds = bucketIds; contactVMs.Add(contactVM); } _logger.LogInfo("{Count} contacts from Bucket {BucketId} fetched successfully by Employee {EmployeeId}.", contactVMs.Count, bucketId, loggedInEmployee.Id); return ApiResponse.SuccessResponse(contactVMs, $"{contactVMs.Count} contacts fetched successfully.", 200); } public async Task> GetContactProfileAsync(Guid id, Guid tenantId, Employee loggedInEmployee) { Guid loggedInEmployeeId = loggedInEmployee.Id; if (id == Guid.Empty) { _logger.LogInfo("Employee ID {EmployeeId} sent an empty contact id", loggedInEmployeeId); return ApiResponse.ErrorResponse("Contact ID is empty", "Contact ID is empty", 400); } try { // Use a single DbContext for the entire operation to ensure consistency. await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); // Step 1: Perform initial permission checks in parallel. var (hasAdminPermission, hasManagerPermission, hasUserPermission) = await CheckPermissionsAsync(loggedInEmployeeId); if (!hasAdminPermission && !hasManagerPermission && !hasUserPermission) { _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get contact profile due to lack of permissions.", loggedInEmployeeId); return ApiResponse.ErrorResponse("Access Denied", "You do not have permission to view contact.", 403); } Contact? contact = await dbContext.Contacts .AsNoTracking() // Use AsNoTracking for read-only operations to improve performance. .Include(c => c.ContactCategory) .Include(c => c.CreatedBy) .ThenInclude(e => e!.JobRole) .Include(c => c.UpdatedBy) .ThenInclude(e => e!.JobRole) .FirstOrDefaultAsync(c => c.Id == id && c.IsActive && c.TenantId == tenantId); if (contact == null) { _logger.LogWarning("Employee with ID {LoggedInEmployeeId} tries to update contact with ID {ContactId} is not found in database", loggedInEmployeeId); return ApiResponse.ErrorResponse("Contact not found", "Contact not found", 404); } ContactProfileVM contactVM = _mapper.Map(contact); var phonesTask = Task.Run(async () => { await using var taskDbContext = await _dbContextFactory.CreateDbContextAsync(); return await taskDbContext.ContactsPhones .AsNoTracking() .Where(p => p.ContactId == contact.Id && p.TenantId == tenantId) .Select(p => _mapper.Map(p)) .ToListAsync(); }); var emailsTask = Task.Run(async () => { await using var taskDbContext = await _dbContextFactory.CreateDbContextAsync(); return await taskDbContext.ContactsEmails .AsNoTracking() .Where(e => e.ContactId == contact.Id && e.TenantId == tenantId) .Select(e => _mapper.Map(e)) .ToListAsync(); }); var contactProjectsTask = Task.Run(async () => { await using var taskDbContext = await _dbContextFactory.CreateDbContextAsync(); return await taskDbContext.ContactProjectMappings .AsNoTracking() .Include(cp => cp.Project) .Where(cp => cp.ContactId == contact.Id && cp.Project != null && cp.Project.TenantId == tenantId) .Select(cp => new BasicProjectVM { Id = cp.Project!.Id, Name = cp.Project.Name }) .ToListAsync(); }); var contactBucketsTask = Task.Run(async () => { await using var taskDbContext = await _dbContextFactory.CreateDbContextAsync(); var bucketQuery = taskDbContext.ContactBucketMappings .AsNoTracking() .Include(cb => cb.Bucket) .ThenInclude(b => b!.CreatedBy) .ThenInclude(e => e!.JobRole) .Where(cb => cb.ContactId == contact.Id && cb.Bucket != null && cb.Bucket.TenantId == tenantId); if (hasAdminPermission) { return await bucketQuery .Select(cb => _mapper.Map(cb.Bucket)) .ToListAsync(); } List employeeBucketIds = await taskDbContext.EmployeeBucketMappings .AsNoTracking() .Where(eb => eb.EmployeeId == loggedInEmployeeId) .Select(eb => eb.BucketId) .ToListAsync(); return await bucketQuery .Where(cb => employeeBucketIds.Contains(cb.BucketId)) .Select(cb => _mapper.Map(cb.Bucket)) .ToListAsync(); }); var contactTagsTask = Task.Run(async () => { await using var taskDbContext = await _dbContextFactory.CreateDbContextAsync(); return await taskDbContext.ContactTagMappings .AsNoTracking() .Include(ct => ct.ContactTag) .Where(ct => ct.ContactId == contact.Id && ct.ContactTag != null && ct.ContactTag.TenantId == tenantId) .Select(ct => _mapper.Map(ct.ContactTag)) .ToListAsync(); }); var contactNotesTask = Task.Run(async () => { await using var taskDbContext = await _dbContextFactory.CreateDbContextAsync(); return await taskDbContext.ContactNotes .AsNoTracking() .Include(cn => cn.Createdby) .ThenInclude(e => e!.JobRole) .Include(cn => cn.UpdatedBy) .ThenInclude(e => e!.JobRole) .Include(cn => cn.Contact) .Where(cn => cn.ContactId == contact.Id && cn.Createdby != null && cn.Createdby.TenantId == tenantId) .Select(cn => _mapper.Map(cn)) .ToListAsync(); }); await Task.WhenAll(phonesTask, emailsTask, contactProjectsTask, contactBucketsTask, contactTagsTask, contactNotesTask); contactVM.ContactPhones = phonesTask.Result; contactVM.ContactEmails = emailsTask.Result; contactVM.Tags = contactTagsTask.Result; contactVM.Buckets = contactBucketsTask.Result; contactVM.Projects = contactProjectsTask.Result; contactVM.Notes = contactNotesTask.Result; _logger.LogInfo("Employee ID {EmployeeId} fetched profile of contact {ContactId}", loggedInEmployeeId, contact.Id); return ApiResponse.SuccessResponse(contactVM, "Contact profile fetched successfully"); } catch (Exception ex) { _logger.LogError(ex, "An unexpected error occurred while fetching contact profile for Tenant {TenantId} by Employee {EmployeeId}", tenantId, loggedInEmployeeId); return ApiResponse.ErrorResponse("An internal error occurred.", ExceptionMapper(ex), 500); } } /// /// Asynchronously retrieves a distinct list of organization names for a given tenant. /// /// The unique identifier of the tenant. /// The employee making the request, used for permission checks. /// /// An ApiResponse containing the list of organization names on success, /// or an error response with appropriate status codes (403 for Forbidden, 500 for internal errors). /// public async Task> GetOrganizationListAsync(Guid tenantId, Employee loggedInEmployee) { // --- Parameter Validation --- // Fail fast if essential parameters are not provided. ArgumentNullException.ThrowIfNull(loggedInEmployee); var employeeId = loggedInEmployee.Id; try { // --- 1. Permission Check --- // Verify that the employee has at least one of the required permissions to view this data. // This prevents unauthorized data access early in the process. var (hasAdminPermission, hasManagerPermission, hasUserPermission) = await CheckPermissionsAsync(employeeId); if (!hasAdminPermission && !hasManagerPermission && !hasUserPermission) { // Log the specific denial reason for security auditing. _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get organization list for tenant {TenantId} due to lack of permissions.", employeeId, tenantId); // Return a strongly-typed error response. return ApiResponse.ErrorResponse("Access Denied", "You do not have permission to perform this action.", 403); } // --- 2. Database Query --- // Build and execute the database query efficiently. _logger.LogDebug("Fetching organization list for Tenant {TenantId} by Employee {EmployeeId}", tenantId, employeeId); var organizationList = await _context.Contacts // Filter contacts by the specified tenant to ensure data isolation and Filter out contacts that do not have an organization name to ensure data quality. .Where(c => c.TenantId == tenantId && !string.IsNullOrEmpty(c.Organization)) // Project only the 'Organization' column. This is a major performance optimization // as it avoids loading entire 'Contact' entities into memory. .Select(c => c.Organization) // Let the database perform the distinct operation, which is highly efficient. .Distinct() // Execute the query asynchronously and materialize the results into a list. .ToListAsync(); // --- 3. Success Response --- // Log the successful operation with key details. _logger.LogInfo("Successfully fetched {OrganizationCount} distinct organizations for Tenant {TenantId} for employee {EmployeeId}", organizationList.Count, tenantId, employeeId); // Return a strongly-typed success response with the data and a descriptive message. return ApiResponse.SuccessResponse( organizationList, $"{organizationList.Count} unique organization(s) found.", 200 ); } catch (Exception ex) { // --- 4. Exception Handling --- // Log the full exception details for effective debugging, including context. _logger.LogError(ex, "An unexpected error occurred while fetching organization list for Tenant {TenantId} by Employee {EmployeeId}", tenantId, employeeId); // Return a generic, strongly-typed error response to the client to avoid leaking implementation details. return ApiResponse.ErrorResponse("An internal server error occurred.", ExceptionMapper(ex), 500); } } public async Task> GetDesignationListAsync(Guid tenantId, Employee loggedInEmployee) { // --- Parameter Validation --- // Fail fast if essential parameters are not provided. ArgumentNullException.ThrowIfNull(loggedInEmployee); var employeeId = loggedInEmployee.Id; try { // --- 1. Permission Check --- // Verify that the employee has at least one of the required permissions to view this data. // This prevents unauthorized data access early in the process. var (hasAdminPermission, hasManagerPermission, hasUserPermission) = await CheckPermissionsAsync(employeeId); if (!hasAdminPermission && !hasManagerPermission && !hasUserPermission) { // Log the specific denial reason for security auditing. _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get designations list for tenant {TenantId} due to lack of permissions.", employeeId, tenantId); // Return a strongly-typed error response. return ApiResponse.ErrorResponse("Access Denied", "You do not have permission to perform this action.", 403); } // --- 2. Database Query --- // Build and execute the database query efficiently. _logger.LogDebug("Fetching designations list for Tenant {TenantId} by Employee {EmployeeId}", tenantId, employeeId); var designationsList = await _context.Contacts // Filter contacts by the specified tenant to ensure data isolation and Filter out contacts that do not have an designations name to ensure data quality. .Where(c => c.TenantId == tenantId && !string.IsNullOrEmpty(c.Designation)) // Project only the 'Designation' column. This is a major performance optimization // as it avoids loading entire 'Contact' entities into memory. .Select(c => c.Designation) // Let the database perform the distinct operation, which is highly efficient. .Distinct() // Execute the query asynchronously and materialize the results into a list. .ToListAsync(); // --- 3. Success Response --- // Log the successful operation with key details. _logger.LogInfo("Successfully fetched {DesignationsCount} distinct designations for Tenant {TenantId} for employee {EmployeeId}", designationsList.Count, tenantId, employeeId); // Return a strongly-typed success response with the data and a descriptive message. return ApiResponse.SuccessResponse( designationsList, $"{designationsList.Count} unique designation(s) found.", 200 ); } catch (Exception ex) { // --- 4. Exception Handling --- // Log the full exception details for effective debugging, including context. _logger.LogError(ex, "An unexpected error occurred while fetching designation list for Tenant {TenantId} by Employee {EmployeeId}", tenantId, employeeId); // Return a generic, strongly-typed error response to the client to avoid leaking implementation details. return ApiResponse.ErrorResponse("An internal server error occurred.", ExceptionMapper(ex), 500); } } /// /// Fetches filter options (Buckets and Contact Categories) based on user permissions /// for a given tenant. /// /// The tenant ID. /// The employee making the request. /// ApiResponse with Buckets and Contact Categories. public async Task> GetContactFilterObjectAsync(Guid tenantId, Employee loggedInEmployee) { try { _logger.LogInfo("Started fetching contact filters for TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); // Step 1: Determine accessible bucket IDs based on permissions var (hasAdminPermission, hasManagerPermission, hasUserPermission) = await CheckPermissionsAsync(loggedInEmployee.Id); List? accessibleBucketIds = null; if (hasAdminPermission) { // Admin → Has access to all buckets in the tenant accessibleBucketIds = await _context.Buckets .Where(b => b.TenantId == tenantId) .Select(b => b.Id) .ToListAsync(); _logger.LogDebug("Admin access granted. Fetched {Count} tenant buckets.", accessibleBucketIds.Count); } else if (hasManagerPermission || hasUserPermission) { // Manager/User → Only mapped buckets accessibleBucketIds = await _context.EmployeeBucketMappings .Where(eb => eb.EmployeeId == loggedInEmployee.Id) .Select(eb => eb.BucketId) .ToListAsync(); _logger.LogDebug("Manager/User access granted. Fetched {Count} mapped buckets for EmployeeId: {EmployeeId}", accessibleBucketIds.Count, loggedInEmployee.Id); } else { _logger.LogWarning("No permissions found for EmployeeId: {EmployeeId}. Returning empty bucket list.", loggedInEmployee.Id); } // Step 2: Fetch available buckets (accessible OR personally created by the employee) var buckets = await _context.Buckets .Where(b => (accessibleBucketIds != null && accessibleBucketIds.Contains(b.Id) && b.TenantId == tenantId) || b.CreatedByID == loggedInEmployee.Id) .Select(b => new { Id = b.Id, Name = b.Name }) .OrderBy(b => b.Name) .Distinct() // Ensures no duplicates (LINQ to Entities distinct works with anonymous types) .ToListAsync(); accessibleBucketIds = buckets.Select(b => b.Id).ToList(); _logger.LogInfo("Fetched {Count} buckets for EmployeeId: {EmployeeId}", buckets.Count, loggedInEmployee.Id); // Step 3: Fetch contact categories mapped to the retrieved buckets var contactCategories = await _context.ContactBucketMappings .AsNoTracking() // Optimized since we are reading only .Include(cb => cb.Contact) .ThenInclude(c => c!.ContactCategory) .Where(cb => accessibleBucketIds.Contains(cb.BucketId) && cb.Contact != null && cb.Contact!.ContactCategory != null) .Select(cb => new { Id = cb.Contact!.ContactCategory!.Id, Name = cb.Contact.ContactCategory.Name }) .OrderBy(cc => cc.Name) .Distinct() .ToListAsync(); _logger.LogInfo("{Count} contact categories fetched for EmployeeId: {EmployeeId}", contactCategories.Count, loggedInEmployee.Id); // Step 4: Prepare response payload var response = new { Buckets = buckets, ContactCategories = contactCategories }; _logger.LogInfo("Successfully returning filters for TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.SuccessResponse(response, "Filters for contact fetched successfully", 200); } catch (Exception ex) { _logger.LogError(ex, "Error while fetching contact filters for TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("An error occurred while fetching filters", 500); } } #endregion #region =================================================================== Contact Post APIs =================================================================== /// /// Creates a new contact along with its associated details such as phone numbers, emails, tags, and project/bucket mappings. /// This operation is performed within a single database transaction to ensure data integrity. /// /// The DTO containing the details for the new contact. /// The ID of the tenant to which the contact belongs. /// The employee performing the action. /// An ApiResponse containing the newly created contact's view model or an error. public async Task> CreateContactAsync(CreateContactDto createContact, Guid tenantId, Employee loggedInEmployee) { using var scope = _serviceScopeFactory.CreateScope(); Guid loggedInEmployeeId = loggedInEmployee.Id; if (string.IsNullOrWhiteSpace(createContact.Name) || string.IsNullOrWhiteSpace(createContact.Organization) || !(createContact.BucketIds?.Any() ?? false)) { _logger.LogWarning("Validation failed for CreateContactAsync. Payload missing required fields. Triggered by Employee: {LoggedInEmployeeId}", loggedInEmployeeId); return ApiResponse.ErrorResponse("Payload is missing required fields: Name, Organization, and at least one BucketId are required.", "Invalid Payload", 400); } using var transaction = await _context.Database.BeginTransactionAsync(); _logger.LogInfo("Starting transaction to create contact for Tenant {TenantId} by Employee {EmployeeId}", tenantId, loggedInEmployeeId); try { var contact = _mapper.Map(createContact); contact.CreatedAt = DateTime.UtcNow; contact.CreatedById = loggedInEmployeeId; contact.TenantId = tenantId; _context.Contacts.Add(contact); // --- Process Phones --- var existingPhoneList = await _context.ContactsPhones .Where(p => p.TenantId == tenantId && p.IsPrimary) .Select(p => p.PhoneNumber) .ToListAsync(); var existingPhones = new HashSet(existingPhoneList); var phoneVMs = ProcessContactPhones(createContact, contact, existingPhones); // --- Process Emails --- var existingEmailList = await _context.ContactsEmails .Where(e => e.TenantId == tenantId && e.IsPrimary) .Select(e => e.EmailAddress) .ToListAsync(); var existingEmails = new HashSet(existingEmailList, StringComparer.OrdinalIgnoreCase); var emailVMs = ProcessContactEmails(createContact, contact, existingEmails); // --- Process Tags --- var tenantTags = await _context.ContactTagMasters .Where(t => t.TenantId == tenantId) .ToDictionaryAsync(t => t.Name.ToLowerInvariant(), t => t); var tagVMs = ProcessTags(createContact, contact, tenantTags); // --- Process Mappings --- var contactBucketMappings = await ProcessBucketMappingsAsync(createContact, contact, tenantId); var projectMappings = await ProcessProjectMappingsAsync(createContact, contact, tenantId); // --- Final Save and Commit --- try { var changesCount = await _context.SaveChangesAsync(); await transaction.CommitAsync(); _logger.LogInfo( "Successfully created Contact {ContactId} with {Count} related records for Tenant {TenantId} by Employee {EmployeeId}. Transaction committed.", contact.Id, changesCount - 1, tenantId, loggedInEmployeeId); } catch (DbUpdateException dbEx) { await transaction.RollbackAsync(); _logger.LogError(dbEx, "Database exception during contact creation for Tenant {TenantId}. Transaction rolled back.", tenantId); return ApiResponse.ErrorResponse("An internal database error occurred.", ExceptionMapper(dbEx), 500); } // --- Construct and Return Response --- var contactVM = _mapper.Map(contact); contactVM.ContactPhones = phoneVMs; contactVM.ContactEmails = emailVMs; contactVM.Tags = tagVMs; contactVM.BucketIds = contactBucketMappings.Select(cb => cb.BucketId).ToList(); contactVM.ProjectIds = projectMappings.Select(cp => cp.ProjectId).ToList(); var _firebase = scope.ServiceProvider.GetRequiredService(); _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; var notification = new Notification { Title = "New Contact Created", Body = $"New Contact \"{contact.Name}\" is created by {name} in your bucket" }; await _firebase.SendContactAsync(contact.Id, contactVM.BucketIds, notification, tenantId); }); return ApiResponse.SuccessResponse(contactVM, "Contact created successfully.", 201); } catch (Exception ex) { _logger.LogError(ex, "An unexpected exception occurred during contact creation for Tenant {TenantId}. Transaction rolled back.", tenantId); return ApiResponse.ErrorResponse("An unexpected internal error occurred.", ExceptionMapper(ex), 500); } } #endregion #region =================================================================== Contact Put APIs =================================================================== public async Task> UpdateContactAsync(Guid id, UpdateContactDto updateContact, Guid tenantId, Employee loggedInEmployee) { using var scope = _serviceScopeFactory.CreateScope(); if (updateContact == null) { _logger.LogWarning("Employee {EmployeeId} sent empty payload for updating contact.", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Empty payload", "User sent empty payload", 400); } // Ensure payload ID matches path ID if (updateContact.Id != id) { _logger.LogWarning("Employee {EmployeeId} sent mismatched contact IDs. Path: {PathId}, Payload: {PayloadId}", loggedInEmployee.Id, id, updateContact.Id); return ApiResponse.ErrorResponse("Invalid data", "Payload ID does not match path parameter", 400); } using var context = _dbContextFactory.CreateDbContext(); // Retrieve the contact with tracking disabled for initial existence and permission check var contact = await context.Contacts .AsNoTracking() .FirstOrDefaultAsync(c => c.Id == id && c.IsActive && c.TenantId == tenantId); if (contact == null) { _logger.LogWarning("Employee {EmployeeId} attempted to update non-existing contact {ContactId}", loggedInEmployee.Id, id); return ApiResponse.ErrorResponse("Contact not found", "Contact not found", 404); } // Validate permissions var (hasAdminPermission, hasManagerPermission, hasUserPermission) = await CheckPermissionsAsync(loggedInEmployee.Id); // Determine accessible bucket IDs for this employee List bucketIds; if (hasAdminPermission) { bucketIds = await context.Buckets .Where(b => b.TenantId == tenantId) .Select(b => b.Id) .ToListAsync(); } else if (hasManagerPermission || hasUserPermission) { var employeeBucketIds = await context.EmployeeBucketMappings .Where(eb => eb.EmployeeId == loggedInEmployee.Id) .Select(eb => eb.BucketId) .ToListAsync(); var createdBucketIds = await context.Buckets .Where(b => b.CreatedByID == loggedInEmployee.Id) .Select(b => b.Id) .ToListAsync(); bucketIds = employeeBucketIds.Concat(createdBucketIds).Distinct().ToList(); } else { _logger.LogWarning("Employee {EmployeeId} does not have permission to update contact {ContactId}", loggedInEmployee.Id, id); return ApiResponse.ErrorResponse("Unauthorized", "You do not have permission", 403); } // Fetch contact bucket mappings accessible by the user var contactBuckets = await context.ContactBucketMappings .Where(cb => cb.ContactId == contact.Id && bucketIds.Contains(cb.BucketId)) .ToListAsync(); // Refresh bucket IDs to only those relevant to this contact & permissions var accessibleBucketIds = contactBuckets.Select(cb => cb.BucketId).Distinct().ToHashSet(); // Update the main contact object from DTO var updatedContact = _mapper.Map(updateContact); updatedContact.TenantId = tenantId; updatedContact.CreatedAt = contact.CreatedAt; updatedContact.CreatedById = contact.CreatedById; updatedContact.UpdatedById = loggedInEmployee.Id; updatedContact.UpdatedAt = DateTime.UtcNow; // Attach updated contact (tracked entity) context.Contacts.Update(updatedContact); // Prepare parallel tasks for retrieving related collections in independent DbContext instances var phonesTask = Task.Run(async () => { using var ctx = _dbContextFactory.CreateDbContext(); return await ctx.ContactsPhones.AsNoTracking().Where(p => p.ContactId == contact.Id).ToListAsync(); }); var emailsTask = Task.Run(async () => { using var ctx = _dbContextFactory.CreateDbContext(); return await ctx.ContactsEmails.AsNoTracking().Where(e => e.ContactId == contact.Id).ToListAsync(); }); var tagsTask = Task.Run(async () => { using var ctx = _dbContextFactory.CreateDbContext(); return await ctx.ContactTagMappings.AsNoTracking().Where(t => t.ContactId == contact.Id).ToListAsync(); }); var projectsTask = Task.Run(async () => { using var ctx = _dbContextFactory.CreateDbContext(); return await ctx.ContactProjectMappings.AsNoTracking().Where(p => p.ContactId == contact.Id).ToListAsync(); }); // Await all tasks to complete await Task.WhenAll(phonesTask, emailsTask, tagsTask, projectsTask); var phones = phonesTask.Result; var emails = emailsTask.Result; var contactTags = tagsTask.Result; var contactProjects = projectsTask.Result; var phoneIds = phones.Select(p => p.Id).ToHashSet(); var emailIds = emails.Select(e => e.Id).ToHashSet(); var tagIds = contactTags.Select(t => t.ContactTagId).Distinct().ToHashSet(); var projectIds = contactProjects.Select(p => p.ProjectId).Distinct().ToHashSet(); // Fetch all tags for this tenant for name checks var allTags = await context.ContactTagMasters.Where(t => t.TenantId == tenantId).ToListAsync(); var tagNameLookup = allTags.ToDictionary(t => t.Name.ToLowerInvariant(), t => t); var contactObject = _updateLogsHelper.EntityToBsonDocument(contact); var contactUpdateLog = new UpdateLogsObject { EntityId = contact.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = contactObject, UpdatedAt = DateTime.UtcNow }; List phoneUpdateLogs = new List(); List emailUpdateLogs = new List(); // ---------------------- Update Phones ----------------------- if (updateContact.ContactPhones != null) { var updatedPhoneIds = updateContact.ContactPhones.Select(p => p.Id).Where(id => id != null && id != Guid.Empty).ToHashSet(); foreach (var phoneDto in updateContact.ContactPhones) { var phoneEntity = _mapper.Map(phoneDto); phoneEntity.TenantId = tenantId; phoneEntity.ContactId = contact.Id; var existingPhone = phones.FirstOrDefault(p => p.Id == phoneEntity.Id); if (phoneDto.Id != null && phoneDto.Id != Guid.Empty && existingPhone != null) { var phoneObject = _updateLogsHelper.EntityToBsonDocument(existingPhone); phoneUpdateLogs.Add(new UpdateLogsObject { EntityId = existingPhone.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = phoneObject, UpdatedAt = DateTime.UtcNow }); context.ContactsPhones.Update(phoneEntity); } else { context.ContactsPhones.Add(phoneEntity); } } // Remove phones not updated in payload foreach (var phone in phones) { if (!updatedPhoneIds.Contains(phone.Id)) { var phoneObject = _updateLogsHelper.EntityToBsonDocument(phone); phoneUpdateLogs.Add(new UpdateLogsObject { EntityId = phone.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = phoneObject, UpdatedAt = DateTime.UtcNow }); context.ContactsPhones.Remove(phone); } } } else if (phones.Any()) { context.ContactsPhones.RemoveRange(phones); } // ---------------------- Update Emails ----------------------- if (updateContact.ContactEmails != null) { var updatedEmailIds = updateContact.ContactEmails.Select(e => e.Id).Where(id => id != null && id != Guid.Empty).ToHashSet(); foreach (var emailDto in updateContact.ContactEmails) { var emailEntity = _mapper.Map(emailDto); emailEntity.TenantId = tenantId; emailEntity.ContactId = contact.Id; var existingEmail = emails.FirstOrDefault(e => e.Id == emailEntity.Id); if (emailDto.Id != null && emailDto.Id != Guid.Empty && existingEmail != null) { var emailObject = _updateLogsHelper.EntityToBsonDocument(existingEmail); emailUpdateLogs.Add(new UpdateLogsObject { EntityId = existingEmail.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = emailObject, UpdatedAt = DateTime.UtcNow }); context.ContactsEmails.Update(emailEntity); } else { context.ContactsEmails.Add(emailEntity); } } // Remove emails not updated in payload foreach (var email in emails) { if (!updatedEmailIds.Contains(email.Id)) { var emailObject = _updateLogsHelper.EntityToBsonDocument(email); phoneUpdateLogs.Add(new UpdateLogsObject { EntityId = email.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = emailObject, UpdatedAt = DateTime.UtcNow }); context.ContactsEmails.Remove(email); } } } else if (emails.Any()) { context.ContactsEmails.RemoveRange(emails); } // ---------------------- Update Buckets ----------------------- if (updateContact.BucketIds != null) { var incomingBucketIds = updateContact.BucketIds.ToHashSet(); // Add newly added bucket mappings if accessible by user foreach (var bucketId in incomingBucketIds) { if (!accessibleBucketIds.Contains(bucketId) && bucketIds.Contains(bucketId)) { context.ContactBucketMappings.Add(new ContactBucketMapping { BucketId = bucketId, ContactId = contact.Id }); } } // Remove bucket mappings removed in payload foreach (var contactBucket in contactBuckets) { if (!incomingBucketIds.Contains(contactBucket.BucketId)) { context.ContactBucketMappings.Remove(contactBucket); } } } else if (contactBuckets.Any()) { context.ContactBucketMappings.RemoveRange(contactBuckets); } // ---------------------- Update Projects ----------------------- if (updateContact.ProjectIds != null) { var incomingProjectIds = updateContact.ProjectIds.ToHashSet(); foreach (var projectId in incomingProjectIds) { if (!projectIds.Contains(projectId)) { context.ContactProjectMappings.Add(new ContactProjectMapping { ProjectId = projectId, ContactId = contact.Id, TenantId = tenantId }); } } foreach (var projectMapping in contactProjects) { if (!incomingProjectIds.Contains(projectMapping.ProjectId)) { context.ContactProjectMappings.Remove(projectMapping); } } } else if (contactProjects.Any()) { context.ContactProjectMappings.RemoveRange(contactProjects); } // ---------------------- Update Tags ----------------------- if (updateContact.Tags != null) { var updatedTagIds = updateContact.Tags.Select(t => t.Id).Where(id => id != null && id != Guid.Empty).ToHashSet(); foreach (var tagDto in updateContact.Tags) { var lowerName = tagDto.Name.Trim().ToLowerInvariant(); bool existsByName = !string.IsNullOrWhiteSpace(lowerName) && tagNameLookup.ContainsKey(lowerName); bool idNotExistsInMapping = !updatedTagIds.Contains(tagDto.Id ?? Guid.Empty); if (existsByName && idNotExistsInMapping) { // Use existing tag by name var existingTag = tagNameLookup[lowerName]; context.ContactTagMappings.Add(new ContactTagMapping { ContactId = contact.Id, ContactTagId = tagDto.Id ?? existingTag.Id }); } else if (tagDto.Id == null || tagDto.Id == Guid.Empty) { // Create new tag master and mapping var newTagMaster = new ContactTagMaster { Name = tagDto.Name, Description = tagDto.Name, TenantId = tenantId }; context.ContactTagMasters.Add(newTagMaster); // Mapping will use newTagMaster.Id once saved (EF will fix after SaveChanges) context.ContactTagMappings.Add(new ContactTagMapping { ContactId = contact.Id, ContactTagId = newTagMaster.Id }); } } // Remove tag mappings no longer present foreach (var contactTagMapping in contactTags) { if (!updatedTagIds.Contains(contactTagMapping.ContactTagId)) { context.ContactTagMappings.Remove(contactTagMapping); } } } else if (contactTags.Any()) { context.ContactTagMappings.RemoveRange(contactTags); } // ---------------------- Add Update Log ----------------------- context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog { RefereanceId = contact.Id, UpdatedById = loggedInEmployee.Id, UpdateAt = DateTime.UtcNow }); // Save all changes once here try { await context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException ex) { _logger.LogError(ex, "Concurrency conflict while employee {EmployeeId} was updating contact {ContactId}", loggedInEmployee.Id, contact.Id); return ApiResponse.ErrorResponse("Concurrency conflict", "This contact was updated by another user. Please reload and try again.", 409); } // Reload updated contact and related data for response, using a fresh context var responseTask = Task.Run(async () => { using var responseContext = _dbContextFactory.CreateDbContext(); var reloadedContact = await responseContext.Contacts .Include(c => c.ContactCategory) .FirstOrDefaultAsync(c => c.Id == id && c.IsActive && c.TenantId == tenantId) ?? new Contact(); var responsePhones = await responseContext.ContactsPhones.AsNoTracking().Where(p => p.ContactId == reloadedContact.Id).ToListAsync(); var responseEmails = await responseContext.ContactsEmails.AsNoTracking().Where(e => e.ContactId == reloadedContact.Id).ToListAsync(); var responseContactTags = await responseContext.ContactTagMappings.AsNoTracking().Where(t => t.ContactId == reloadedContact.Id).ToListAsync(); var responseContactBuckets = await responseContext.ContactBucketMappings.AsNoTracking().Where(cb => cb.ContactId == reloadedContact.Id).ToListAsync(); var responseContactProjects = await responseContext.ContactProjectMappings.AsNoTracking().Where(cp => cp.ContactId == reloadedContact.Id).ToListAsync(); var tagIdsForResponse = responseContactTags.Select(t => t.ContactTagId).Distinct().ToList(); var tagsForResponse = await responseContext.ContactTagMasters.Where(t => tagIdsForResponse.Contains(t.Id)).ToListAsync(); // Map entities to view models var response = reloadedContact.ToContactVMFromContact(); response.ContactPhones = responsePhones.Select(p => p.ToContactPhoneVMFromContactPhone()).ToList(); response.ContactEmails = responseEmails.Select(e => e.ToContactEmailVMFromContactEmail()).ToList(); response.Tags = responseContactTags.Select(ctm => { var tag = tagsForResponse.Find(t => t.Id == ctm.ContactTagId); return tag != null ? tag.ToContactTagVMFromContactTagMaster() : new ContactTagVM(); }).ToList(); response.BucketIds = responseContactBuckets.Select(cb => cb.BucketId).ToList(); response.ProjectIds = responseContactProjects.Select(cp => cp.ProjectId).ToList(); return response; }); var contactUpdateLogTask = Task.Run(async () => { await _updateLogsHelper.PushToUpdateLogsAsync(contactUpdateLog, contactCollection); }); var phoneUpdateLogTask = Task.Run(async () => { await _updateLogsHelper.PushListToUpdateLogsAsync(phoneUpdateLogs, contactPhoneCollection); }); var emailUpdateLogTask = Task.Run(async () => { await _updateLogsHelper.PushListToUpdateLogsAsync(emailUpdateLogs, contactEmailCollection); }); await Task.WhenAll(responseTask, contactUpdateLogTask, phoneUpdateLogTask, emailUpdateLogTask); var contactVM = responseTask.Result; _logger.LogInfo("Contact {ContactId} successfully updated by employee {EmployeeId}.", contact.Id, loggedInEmployee.Id); var _firebase = scope.ServiceProvider.GetRequiredService(); _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. if (contactVM.BucketIds?.Any() ?? false) { var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; var notification = new Notification { Title = $"Contact Updated - \"{contact.Name}\"", Body = $"Contact \"{contact.Name}\" is updated by {name} in your bucket" }; await _firebase.SendContactAsync(updateContact.Id, contactVM.BucketIds, notification, tenantId); } }); return ApiResponse.SuccessResponse(contactVM, "Contact Updated Successfully", 200); } #endregion #region =================================================================== Contact Delete APIs =================================================================== public async Task> DeleteContactAsync(Guid id, bool active, Guid tenantId, Employee loggedInEmployee) { // Validate the contact id if (id == Guid.Empty) { _logger.LogWarning("Employee ID {EmployeeId} attempted to delete with an empty contact ID.", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Contact ID is empty", "Contact ID is empty", 400); } var bucketIds = await _context.ContactBucketMappings.Where(cb => cb.ContactId == id).Select(cb => cb.BucketId).ToListAsync(); var hasContactAccess = await _context.EmployeeBucketMappings.AnyAsync(eb => bucketIds.Contains(eb.BucketId) && eb.EmployeeId == loggedInEmployee.Id); if (hasContactAccess) { _logger.LogWarning("Employee {EmployeeId} does not have permission to delete contact {ContactId}", loggedInEmployee.Id, id); return ApiResponse.ErrorResponse("Unauthorized", "You do not have permission", 403); } // Try to find the contact for the given tenant Contact? contact = await _context.Contacts.FirstOrDefaultAsync(c => c.Id == id && c.TenantId == tenantId); if (contact == null) { _logger.LogWarning("Employee ID {EmployeeId} attempted to delete contact ID {ContactId}, but it was not found.", loggedInEmployee.Id, id); return ApiResponse.ErrorResponse("Contact not found", "Contact not found", 404); } var contactObject = _updateLogsHelper.EntityToBsonDocument(contact); // Update the contact's active status (soft delete or activate) contact.IsActive = active; // Log the update in the directory update logs _context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog { RefereanceId = contact.Id, UpdatedById = loggedInEmployee.Id, UpdateAt = DateTime.UtcNow }); // Save changes to the database await _context.SaveChangesAsync(); await _updateLogsHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = contact.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = contactObject, UpdatedAt = DateTime.UtcNow }, contactCollection); _logger.LogInfo("Contact ID {ContactId} has been {(DeletedOrActivated)} by Employee ID {EmployeeId}.", id, active ? "activated" : "deleted", loggedInEmployee.Id); using var scope = _serviceScopeFactory.CreateScope(); var _firebase = scope.ServiceProvider.GetRequiredService(); _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. if (bucketIds.Any()) { var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; Notification notification; if (active) { notification = new Notification { Title = $"Contact restored - \"{contact.Name}\"", Body = $"Contact \"{contact.Name}\" is restored by {name} in your bucket" }; } else { notification = new Notification { Title = $"Contact Deleted - \"{contact.Name}\"", Body = $"Contact \"{contact.Name}\" is deleted by {name} in your bucket" }; } await _firebase.SendContactAsync(contact.Id, bucketIds, notification, tenantId); } }); return ApiResponse.SuccessResponse(new { }, active ? "Contact is activated successfully" : "Contact is deleted successfully", 200); } #endregion #endregion #region =================================================================== Contact Notes APIs =================================================================== public async Task> GetListOFAllNotesAsync(Guid? projectId, string? searchString, string? filter, int pageSize, int pageNumber, Guid tenantId, Employee loggedInEmployee) { _logger.LogInfo("Initiating GetListOFAllNotesAsync. TenantId: {TenantId}, ProjectId: {ProjectId}, PageSize: {PageSize}, PageNumber: {PageNumber}, EmployeeId: {EmployeeId}", tenantId, projectId ?? Guid.Empty, pageSize, pageNumber, loggedInEmployee.Id); // Ensure user context is present if (loggedInEmployee.Id == Guid.Empty || tenantId == Guid.Empty) { _logger.LogWarning("Unauthorized: LoggedInEmployee is null."); return ApiResponse.ErrorResponse("Unauthorized", "Employee not found.", 403); } try { // Use a context instance per method call for safety in parallel scenarios await using var context = _dbContextFactory.CreateDbContext(); // Permission checks (parallel as they're independent) var (hasAdminPermission, hasManagerPermission, hasUserPermission) = await CheckPermissionsAsync(loggedInEmployee.Id); // Access control if (!hasAdminPermission && !hasManagerPermission && !hasUserPermission) { _logger.LogWarning("Access Denied. EmployeeId: {EmployeeId}, TenantId: {TenantId}", loggedInEmployee.Id, tenantId); return ApiResponse.ErrorResponse("Access Denied", "You don't have access to view notes.", 403); } // Build base query IQueryable notesQuery = context.ContactNotes .Include(cn => cn.UpdatedBy) .ThenInclude(e => e!.JobRole) .Include(cn => cn.Createdby) .ThenInclude(e => e!.JobRole) .Include(cn => cn.Contact) .Where(cn => cn.TenantId == tenantId); // Fetch associated contact IDs for project (if filtering by project) List? projectContactIds = null; if (projectId.HasValue) { projectContactIds = await context.ContactProjectMappings .Where(pc => pc.ProjectId == projectId.Value) .Select(pc => pc.ContactId) .ToListAsync(); } if (!hasAdminPermission) // Manager/User filtering { _logger.LogInfo("Non-admin user. Applying bucket-based filtering. EmployeeId: {EmployeeId}", loggedInEmployee.Id); // Get assigned bucket IDs var assignedBucketIds = await context.EmployeeBucketMappings .Where(eb => eb.EmployeeId == loggedInEmployee.Id) .Select(eb => eb.BucketId) .ToListAsync(); if (!assignedBucketIds.Any()) { _logger.LogInfo("No assigned buckets for user: {EmployeeId}", loggedInEmployee.Id); return ApiResponse.SuccessResponse(new { CurrentPage = pageNumber, TotalPages = 0, Data = new List() }, "No notes found based on assigned buckets.", 200); } // Contacts based on assigned buckets, further filtered by project (if provided) var contactBucketQuery = context.ContactBucketMappings .Where(cb => assignedBucketIds.Contains(cb.BucketId)); if (projectContactIds != null) { contactBucketQuery = contactBucketQuery.Where(cb => projectContactIds.Contains(cb.ContactId)); } var contactIds = await contactBucketQuery.Select(cb => cb.ContactId).Distinct().ToListAsync(); if (!contactIds.Any()) { _logger.LogInfo("No contacts found for assigned buckets for user: {EmployeeId}", loggedInEmployee.Id); return ApiResponse.SuccessResponse(new { CurrentPage = pageNumber, TotalPages = 0, Data = new List() }, "No notes found for associated contacts.", 200); } notesQuery = notesQuery.Where(cn => contactIds.Contains(cn.ContactId)); } else { // Admin: If project specified, filter notes further if (projectContactIds != null) notesQuery = notesQuery.Where(cn => projectContactIds.Contains(cn.ContactId)); } // --- Advanced Filtering from 'filter' parameter --- ContactNoteFilter? contactNoteFilter = TryDeserializeContactNoteFilter(filter); if (contactNoteFilter != null) { if (contactNoteFilter.CreatedByIds?.Any() ?? false) { notesQuery = notesQuery.Where(cn => contactNoteFilter.CreatedByIds.Contains(cn.CreatedById)); } if (contactNoteFilter.Organizations?.Any() ?? false) { notesQuery = notesQuery.Where(cn => cn.Contact != null && contactNoteFilter.Organizations.Contains(cn.Contact.Organization)); } } // --- Search Term Filtering --- if (!string.IsNullOrWhiteSpace(searchString)) { var searchTermLower = searchString.ToLower(); notesQuery = notesQuery.Where(c => (c.Contact != null && c.Contact.Name.ToLower().Contains(searchTermLower)) || (c.Contact != null && c.Contact.Organization != null && c.Contact.Organization.ToLower().Contains(searchTermLower)) || c.Note.ToLower().Contains(searchTermLower) ); } // Pagination safeguard pageSize = pageSize < 1 ? 25 : pageSize; pageNumber = pageNumber < 1 ? 1 : pageNumber; // Accurate pagination metadata int totalRecords = await notesQuery.CountAsync(); int totalPages = (int)Math.Ceiling(totalRecords / (double)pageSize); // Fetch paginated, ordered results List notes = await notesQuery .OrderByDescending(cn => cn.UpdatedAt ?? cn.CreatedAt) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(); _logger.LogInfo("Notes fetched: {Count}, Page: {PageNumber}/{TotalPages}, EmployeeId: {EmployeeId}, TenantId: {TenantId}", notes.Count, pageNumber, totalPages, loggedInEmployee.Id, tenantId); // In-memory mapping to ViewModel var noteVms = _mapper.Map>(notes); var response = new { CurrentPage = pageNumber, PageSize = pageSize, TotalPages = totalPages, TotalRecords = totalRecords, Data = noteVms }; _logger.LogInfo("Notes mapped to ViewModel for TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.SuccessResponse(response, $"{noteVms.Count} notes fetched successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "Exception occurred in GetListOFAllNotesAsync. TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while fetching notes. Please try again later.", 500); } } /// /// Fetches all notes associated with a given contact, subject to permission checks and contact-bucket mappings. /// /// The contact ID. /// The tenant ID of the current user. /// Whether to filter for active notes only. /// The currently logged in employee object. /// Returns a list of contact notes wrapped in ApiResponse. public async Task> GetNoteListByContactIdAsync(Guid id, bool active, Guid tenantId, Employee loggedInEmployee) { // Step 1: Permission Validation var (hasAdminPermission, hasManagerPermission, hasUserPermission) = await CheckPermissionsAsync(loggedInEmployee.Id); if (!hasAdminPermission && !hasManagerPermission && !hasUserPermission) { _logger.LogWarning( "Access Denied. EmployeeId: {EmployeeId}, TenantId: {TenantId}. No permissions granted.", loggedInEmployee.Id, tenantId); return ApiResponse.ErrorResponse( "Access Denied", "You don't have access to view notes.", StatusCodes.Status403Forbidden); } // Step 2: Validate Contact Exists Contact? contact = await _context.Contacts .AsNoTracking() // optimization: no tracking needed .FirstOrDefaultAsync(c => c.Id == id && c.IsActive && c.TenantId == tenantId); if (contact == null) { _logger.LogWarning( "Employee {EmployeeId} attempted to fetch notes for Contact {ContactId}, but the contact was not found. TenantId: {TenantId}", loggedInEmployee.Id, id, tenantId); return ApiResponse.ErrorResponse( "Contact not found", "Contact not found", StatusCodes.Status404NotFound); } // Step 3: Bucket-level Security Checks (Non-admin users) if (!hasAdminPermission) { var employeeBucketIds = await _context.EmployeeBucketMappings .AsNoTracking() .Where(em => em.EmployeeId == loggedInEmployee.Id) .Select(em => em.BucketId) .ToListAsync(); bool hasContactAccess = await _context.ContactBucketMappings .AsNoTracking() .AnyAsync(cb => employeeBucketIds.Contains(cb.BucketId) && cb.ContactId == contact.Id); if (!hasContactAccess) { _logger.LogWarning( "Access Denied. EmployeeId: {EmployeeId}, TenantId: {TenantId}. No bucket access for ContactId {ContactId}", loggedInEmployee.Id, tenantId, contact.Id); return ApiResponse.ErrorResponse( "Access Denied", "You don't have access to view notes.", StatusCodes.Status403Forbidden); } } // Step 4: Fetch Notes var notesQuery = _context.ContactNotes .Include(n => n.Createdby) // Eager load creator .ThenInclude(e => e!.JobRole) .Include(n => n.UpdatedBy) // Eager load updater .ThenInclude(e => e!.JobRole) .Where(n => n.ContactId == contact.Id && n.TenantId == tenantId); if (active) notesQuery = notesQuery.Where(n => n.IsActive); List notes = await notesQuery .AsNoTracking() // reduce EF overhead .ToListAsync(); // Step 5: Fetch Update Logs in one DB call var noteIds = notes.Select(n => n.Id).ToList(); List updateLogs = new(); if (noteIds.Count > 0) // only fetch logs if needed { updateLogs = await _context.DirectoryUpdateLogs .Include(l => l.Employee) .ThenInclude(e => e!.JobRole) .AsNoTracking() .Where(l => noteIds.Contains(l.RefereanceId)) .ToListAsync(); } // Step 6: Map Entities to ViewModels List noteVMs = _mapper.Map>(notes); // Step 7: Final Log + Response _logger.LogInfo( "Employee {EmployeeId} successfully fetched {Count} notes for Contact {ContactId} in Tenant {TenantId}", loggedInEmployee.Id, noteVMs.Count, id, tenantId); return ApiResponse.SuccessResponse( noteVMs, $"{noteVMs.Count} contact-notes record(s) fetched successfully", StatusCodes.Status200OK); } /// /// Fetches filter objects (CreatedBy employees and Organizations) for Contact Notes /// accessible by the logged-in employee, based on permissions. /// /// The tenant ID. /// The employee requesting filters. /// ApiResponse containing CreatedBy and Organizations filter options. public async Task> GetContactNotesFilterObjectAsync(Guid tenantId, Employee loggedInEmployee) { try { _logger.LogInfo("Started fetching Contact Notes filters for TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); // Step 1: Fetch accessible bucket IDs based on permissions var (hasAdminPermission, hasManagerPermission, hasUserPermission) = await CheckPermissionsAsync(loggedInEmployee.Id); List? accessibleBucketIds = null; if (hasAdminPermission) { // Admin → Access to all buckets in the tenant accessibleBucketIds = await _context.Buckets .Where(b => b.TenantId == tenantId) .Select(b => b.Id) .ToListAsync(); _logger.LogDebug("Admin access granted. Found {Count} buckets.", accessibleBucketIds.Count); } else if (hasManagerPermission || hasUserPermission) { // Manager/User → Access to mapped buckets only accessibleBucketIds = await _context.EmployeeBucketMappings .Where(eb => eb.EmployeeId == loggedInEmployee.Id) .Select(eb => eb.BucketId) .ToListAsync(); _logger.LogDebug("Manager/User access granted. Found {Count} mapped buckets for EmployeeId: {EmployeeId}.", accessibleBucketIds.Count, loggedInEmployee.Id); } else { _logger.LogWarning("No permissions found for EmployeeId: {EmployeeId}. Returning empty filters.", loggedInEmployee.Id); } // Step 2: Fetch Contact IDs from ContactBucketMappings var contactIds = await _context.ContactBucketMappings .Where(cb => accessibleBucketIds != null && accessibleBucketIds.Contains(cb.BucketId)) .Select(cb => cb.ContactId) .Distinct() // ensures no duplicate contact IDs .ToListAsync(); _logger.LogInfo("Fetched {Count} contact Ids from accessible buckets.", contactIds.Count); // Step 3: Fetch Contacts related to Contact Notes for those contactIds var contacts = await _context.ContactNotes .AsNoTracking() // no need to track since it’s for read-only filters .Include(cn => cn.Contact) .ThenInclude(c => c!.CreatedBy) .Where(cn => contactIds.Contains(cn.ContactId) && cn.Contact != null && cn.Contact.CreatedBy != null && cn.TenantId == tenantId) .Select(cn => cn.Contact!) .Distinct() // avoid duplicate contacts from multiple notes .ToListAsync(); _logger.LogInfo("Fetched {Count} unique contacts with notes.", contacts.Count); // Step 4: Build organization filters var organizations = contacts .Where(c => !string.IsNullOrEmpty(c.Organization)) // filter out null/empty orgs .Select(c => new { Id = c.Organization, // Using organization string as unique identifier Name = c.Organization }) .Distinct() .OrderBy(o => o.Name) .ToList(); _logger.LogInfo("Extracted {Count} unique organizations from contacts.", organizations.Count); // Step 5: Build CreatedBy filters (employees who created the contacts) var createdBy = contacts .Select(c => new { Id = c.CreatedBy!.Id, Name = $"{c.CreatedBy.FirstName} {c.CreatedBy.LastName}".Trim() }) .Distinct() .OrderBy(e => e.Name) .ToList(); _logger.LogInfo("Extracted {Count} unique CreatedBy employees from contacts.", createdBy.Count); // Step 6: Build response var response = new { CreatedBy = createdBy, Organizations = organizations }; _logger.LogInfo("Successfully returning Contact Notes filters for TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.SuccessResponse(response, "Filters for contact notes fetched successfully", 200); } catch (Exception ex) { _logger.LogError(ex, "Error occurred while fetching Contact Notes filters for TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("An error occurred while fetching filters", 500); } } /// /// Creates a note for a given contact under the specified tenant. /// Ensures that the contact exists and belongs to the tenant before adding the note. /// /// The DTO containing the note details. /// The tenant identifier to which the contact belongs. /// The logged-in employee attempting the action. /// ApiResponse containing the created note details or error information. public async Task> CreateContactNoteAsync(CreateContactNoteDto noteDto, Guid tenantId, Employee loggedInEmployee) { // Validate request payload if (noteDto == null) { _logger.LogWarning( "Employee {EmployeeId} attempted to create a note with an empty payload.", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Empty payload.", "Request body cannot be null.", 400); } try { var bucketIds = await _context.ContactBucketMappings.Where(cb => cb.ContactId == noteDto.ContactId).Select(cb => cb.BucketId).ToListAsync(); var hasContactAccess = await _context.EmployeeBucketMappings.AnyAsync(eb => bucketIds.Contains(eb.BucketId) && eb.EmployeeId == loggedInEmployee.Id); if (hasContactAccess) { _logger.LogWarning("Employee {EmployeeId} does not have permission to delete contact {ContactId}", loggedInEmployee.Id, noteDto.ContactId); return ApiResponse.ErrorResponse("Unauthorized", "You do not have permission", 403); } // Check if the contact exists and is active for this tenant Contact? contact = await _context.Contacts .AsNoTracking() // optimization for read-only query .FirstOrDefaultAsync(c => c.Id == noteDto.ContactId && c.IsActive && c.TenantId == tenantId); if (contact == null) { _logger.LogWarning( "Employee {EmployeeId} attempted to add a note to Contact {ContactId}, but it was not found for tenant {TenantId}.", loggedInEmployee.Id, noteDto.ContactId, tenantId); return ApiResponse.ErrorResponse("Contact not found.", "The specified contact does not exist.", 404); } // Map DTO -> Entity using AutoMapper ContactNote note = _mapper.Map(noteDto); note.CreatedById = loggedInEmployee.Id; note.TenantId = tenantId; // Save new note await _context.ContactNotes.AddAsync(note); await _context.SaveChangesAsync(); // Map Entity -> ViewModel ContactNoteVM noteVM = _mapper.Map(note); _logger.LogInfo( "Employee {EmployeeId} successfully added a note (NoteId: {NoteId}) to Contact {ContactId} for Tenant {TenantId}.", loggedInEmployee.Id, note.Id, contact.Id, tenantId); using var scope = _serviceScopeFactory.CreateScope(); var _firebase = scope.ServiceProvider.GetRequiredService(); _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; var notification = new Notification { Title = $"New note added at Contact - \"{contact.Name}\"", Body = $"New note added at Contact \"{contact.Name}\" by {name} in your bucket" }; await _firebase.SendContactNoteAsync(contact.Id, bucketIds, notification, tenantId); }); return ApiResponse.SuccessResponse(noteVM, "Note added successfully.", 200); } catch (Exception ex) { // Log unexpected errors to troubleshoot _logger.LogError( ex, "Unexpected error occurred while Employee {EmployeeId} attempted to add a note for Contact {ContactId} in Tenant {TenantId}.", loggedInEmployee.Id, noteDto.ContactId, tenantId); return ApiResponse.ErrorResponse("An unexpected error occurred.", ex.Message, 500); } } /// /// Updates an existing contact note and logs changes /// both in relational DB (SQL) and update logs (possibly MongoDB). /// /// The note ID that needs to be updated. /// DTO with updated note data. /// Standardized ApiResponse with updated note or error details. public async Task> UpdateContactNoteAsync(Guid id, UpdateContactNoteDto noteDto, Guid tenantId, Employee loggedInEmployee) { // Validation: check null payload or mismatched ID if (noteDto == null || id != noteDto.Id) { _logger.LogWarning("Employee {EmployeeId} sent invalid or null payload. RouteId: {RouteId}, PayloadId: {PayloadId}", loggedInEmployee.Id, id, noteDto?.Id ?? Guid.Empty); return ApiResponse.ErrorResponse("Invalid or empty payload", "Invalid or empty payload", 400); } var (hasAdminPermission, hasManagerPermission, hasUserPermission) = await CheckPermissionsAsync(loggedInEmployee.Id); var bucketIds = await _context.ContactBucketMappings.Where(cb => cb.ContactId == noteDto.ContactId).Select(cb => cb.BucketId).ToListAsync(); var hasContactAccess = await _context.EmployeeBucketMappings.AnyAsync(eb => bucketIds.Contains(eb.BucketId) && eb.EmployeeId == loggedInEmployee.Id); if (!hasAdminPermission && hasContactAccess) { _logger.LogWarning("Employee {EmployeeId} does not have permission to delete contact {ContactId}", loggedInEmployee.Id, noteDto.ContactId); return ApiResponse.ErrorResponse("Unauthorized", "You do not have permission", 403); } // Check if the contact belongs to this tenant Contact? contact = await _context.Contacts .AsNoTracking() .FirstOrDefaultAsync(c => c.Id == noteDto.ContactId && c.TenantId == tenantId); if (contact == null) { _logger.LogWarning("Employee {EmployeeId} attempted to update note {NoteId} for Contact {ContactId}, but the contact was not found in Tenant {TenantId}.", loggedInEmployee.Id, noteDto.Id, noteDto.ContactId, tenantId); return ApiResponse.ErrorResponse("Contact not found", "Contact not found", 404); } // Fetch the contact note to be updated ContactNote? contactNote = await _context.ContactNotes .Include(cn => cn.Createdby) .ThenInclude(e => e!.JobRole) .Include(cn => cn.Contact) .FirstOrDefaultAsync(n => n.Id == noteDto.Id && n.ContactId == contact.Id && n.IsActive); if (contactNote == null) { _logger.LogWarning("Employee {EmployeeId} attempted to update Note {NoteId} for Contact {ContactId}, but the note was not found or inactive.", loggedInEmployee.Id, noteDto.Id, noteDto.ContactId); return ApiResponse.ErrorResponse("Note not found", "Note not found", 404); } // Capture old state for change-log before updating var oldObject = _updateLogsHelper.EntityToBsonDocument(contactNote); // Apply updates contactNote.Note = noteDto.Note; contactNote.UpdatedById = loggedInEmployee.Id; contactNote.UpdatedAt = DateTime.UtcNow; // Save change log into relational logs _context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog { RefereanceId = id, UpdatedById = loggedInEmployee.Id, UpdateAt = DateTime.UtcNow }); // Wrap in try-catch for robustness try { await _context.SaveChangesAsync(); // Map to ViewModel for output ContactNoteVM noteVM = _mapper.Map(contactNote); noteVM.UpdatedAt = contactNote.UpdatedAt; noteVM.UpdatedBy = _mapper.Map(loggedInEmployee); // Push audit log asynchronously (Mongo / NoSQL Logs) await _updateLogsHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = contactNote.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = oldObject, UpdatedAt = DateTime.UtcNow }, contactNoteCollection); // Success log _logger.LogInfo("Employee {EmployeeId} successfully updated Note {NoteId} for Contact {ContactId} at {UpdatedAt}", loggedInEmployee.Id, noteVM.Id, contact.Id, noteVM.UpdatedAt); using var scope = _serviceScopeFactory.CreateScope(); var _firebase = scope.ServiceProvider.GetRequiredService(); _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; var notification = new Notification { Title = $"Note updated at Contact - \"{contact.Name}\"", Body = $"Note updated at Contact \"{contact.Name}\" by {name} in your bucket" }; await _firebase.SendContactNoteAsync(contact.Id, bucketIds, notification, tenantId); }); return ApiResponse.SuccessResponse(noteVM, "Note updated successfully", 200); } catch (DbUpdateException ex) { _logger.LogError(ex, "Database Exception occurred while updating Note {NoteId} for Contact {ContactId} by Employee {EmployeeId}", noteDto.Id, noteDto.ContactId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Failed to update note", "An unexpected error occurred while saving note.", 500); } catch (Exception ex) { _logger.LogError(ex, "Exception occurred while updating Note {NoteId} for Contact {ContactId} by Employee {EmployeeId}", noteDto.Id, noteDto.ContactId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Failed to update note", "An unexpected error occurred while saving note.", 500); } } /// /// Soft deletes (or restores) a contact note by updating its active status. /// Also pushes an update log entry in SQL and Mongo (audit trail). /// /// ID of the contact note to delete/restore. /// Flag to set note as active or inactive. /// Tenant identifier of the logged-in user. /// The employee performing this action. /// ApiResponse with success or error details. public async Task> DeleteContactNoteAsync(Guid id, bool active, Guid tenantId, Employee loggedInEmployee) { // Lookup note within the tenant ContactNote? note = await _context.ContactNotes .FirstOrDefaultAsync(n => n.Id == id && n.TenantId == tenantId); if (note == null) { // Log missing resource _logger.LogWarning("Employee {EmployeeId} attempted to delete Note {NoteId}, but it was not found in Tenant {TenantId}", loggedInEmployee.Id, id, tenantId); return ApiResponse.ErrorResponse("Note not found", "Note not found", 404); } var bucketIds = await _context.ContactBucketMappings.Where(cb => cb.ContactId == note.ContactId).Select(cb => cb.BucketId).ToListAsync(); var hasContactAccess = await _context.EmployeeBucketMappings.AnyAsync(eb => bucketIds.Contains(eb.BucketId) && eb.EmployeeId == loggedInEmployee.Id); if (hasContactAccess) { _logger.LogWarning("Employee {EmployeeId} does not have permission to delete contact {ContactId}", loggedInEmployee.Id, note.ContactId); return ApiResponse.ErrorResponse("Unauthorized", "You do not have permission", 403); } // Check if the contact belongs to this tenant Contact? contact = await _context.Contacts .AsNoTracking() .FirstOrDefaultAsync(c => c.Id == note.ContactId && c.TenantId == tenantId); if (contact == null) { _logger.LogWarning("Employee {EmployeeId} attempted to update note {NoteId} for Contact {ContactId}, but the contact was not found in Tenant {TenantId}.", loggedInEmployee.Id, note.Id, note.ContactId, tenantId); return ApiResponse.ErrorResponse("Contact not found", "Contact not found", 404); } // Capture old state for audit logging var oldObject = _updateLogsHelper.EntityToBsonDocument(note); // Update note metadata var currentTime = DateTime.UtcNow; note.IsActive = active; // soft delete (false) or restore (true) note.UpdatedById = loggedInEmployee.Id; note.UpdatedAt = currentTime; // Add relational update log entry _context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog { RefereanceId = id, UpdatedById = loggedInEmployee.Id, UpdateAt = currentTime }); try { // Save SQL changes await _context.SaveChangesAsync(); // Push audit log (Mongo / NoSQL) await _updateLogsHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = note.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = oldObject, UpdatedAt = currentTime }, contactNoteCollection); // Log success — distinguish delete vs restore if (!active) { _logger.LogInfo("Employee {EmployeeId} soft deleted Note {NoteId} at {Timestamp}", loggedInEmployee.Id, id, currentTime); } else { _logger.LogInfo("Employee {EmployeeId} restored Note {NoteId} at {Timestamp}", loggedInEmployee.Id, id, currentTime); } using var scope = _serviceScopeFactory.CreateScope(); var _firebase = scope.ServiceProvider.GetRequiredService(); _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; Notification notification; if (active) { notification = new Notification { Title = $"Note restored at Contact - \"{contact.Name}\"", Body = $"Note restored at Contact \"{contact.Name}\" by {name} in your bucket" }; } else { notification = new Notification { Title = $"Note deleted at Contact - \"{contact.Name}\"", Body = $"Note deleted at Contact \"{contact.Name}\" by {name} in your bucket" }; } await _firebase.SendContactNoteAsync(contact.Id, bucketIds, notification, tenantId); }); return ApiResponse.SuccessResponse(new { }, active ? "Note restored successfully" : "Note deleted successfully", 200); } catch (Exception ex) { _logger.LogError(ex, "Error while updating Note {NoteId} (delete/restore) in Tenant {TenantId} by Employee {EmployeeId}", id, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Failed to delete note", "An unexpected error occurred while deleting/restoring the note.", 500); } } #endregion #region =================================================================== Bucket APIs =================================================================== public async Task> GetBucketListAsync(Guid tenantId, Employee loggedInEmployee) { _logger.LogInfo("Started fetching bucket list for Employee {EmployeeId} in Tenant {TenantId}", loggedInEmployee.Id, tenantId); // Check permissions early var (hasAdminPermission, hasManagerPermission, hasUserPermission) = await CheckPermissionsAsync(loggedInEmployee.Id); if (!hasAdminPermission && !hasManagerPermission && !hasUserPermission) { _logger.LogWarning("Employee {EmployeeId} attempted to access buckets without permission", loggedInEmployee.Id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 403); } List bucketList; List bucketIds; if (hasAdminPermission) { // Admin gets all buckets for the tenant bucketList = await _context.Buckets .Include(b => b.CreatedBy) .ThenInclude(e => e!.JobRole) .Where(b => b.TenantId == tenantId) .ToListAsync(); bucketIds = bucketList.Select(b => b.Id).ToList(); } else { // Manager or user: fetch employee bucket mappings and buckets accordingly var employeeBuckets = await _context.EmployeeBucketMappings .Where(b => b.EmployeeId == loggedInEmployee.Id) .ToListAsync(); bucketIds = employeeBuckets.Select(b => b.BucketId).ToList(); bucketList = await _context.Buckets .Include(b => b.CreatedBy) .ThenInclude(e => e!.JobRole) .Where(b => bucketIds.Contains(b.Id) || b.CreatedByID == loggedInEmployee.Id) .ToListAsync(); bucketIds = bucketList.Select(b => b.Id).ToList(); } if (!bucketList.Any()) { _logger.LogInfo("No buckets found for Employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.SuccessResponse(new List(), "No buckets found", 200); } // Fetch related data in parallel using Task.Run with separate contexts to avoid concurrency issues var employeeBucketMappingsTask = Task.Run(async () => { using var context = _dbContextFactory.CreateDbContext(); return await context.EmployeeBucketMappings .Where(b => bucketIds.Contains(b.BucketId)) .ToListAsync(); }); var contactBucketMappingsTask = Task.Run(async () => { using var context = _dbContextFactory.CreateDbContext(); return await context.ContactBucketMappings .Where(cb => bucketIds.Contains(cb.BucketId)) .ToListAsync(); }); await Task.WhenAll(employeeBucketMappingsTask, contactBucketMappingsTask); var employeeBucketMappings = employeeBucketMappingsTask.Result; var contactBucketMappings = contactBucketMappingsTask.Result; var bucketVMs = new List(); // Prepare view models for each bucket foreach (var bucket in bucketList) { var mappedEmployees = employeeBucketMappings .Where(eb => eb.BucketId == bucket.Id) .Select(eb => eb.EmployeeId) .ToList(); if (bucket.CreatedBy != null) { mappedEmployees.Add(bucket.CreatedBy.Id); } var contactCount = contactBucketMappings.Count(cb => cb.BucketId == bucket.Id); var bucketVM = bucket.ToAssignBucketVMFromBucket(); bucketVM.EmployeeIds = mappedEmployees.Distinct().ToList(); bucketVM.NumberOfContacts = contactCount; bucketVMs.Add(bucketVM); } _logger.LogInfo("Fetched {BucketCount} buckets for Employee {EmployeeId} successfully", bucketVMs.Count, loggedInEmployee.Id); return ApiResponse.SuccessResponse(bucketVMs, $"{bucketVMs.Count} buckets fetched successfully", 200); } public async Task> CreateBucketAsync(CreateBucketDto bucketDto, Guid tenantId, Employee loggedInEmployee) { if (bucketDto == null) { _logger.LogWarning("Employee with ID {LoggedInEmployeeId} sent empty payload", loggedInEmployee.Id); return ApiResponse.ErrorResponse("User sent empty Payload", "User sent empty Payload", 400); } try { // Check permissions for the logged-in employee in parallel var permissionTask = CheckPermissionsAsync(loggedInEmployee.Id); // Check if a bucket with the same name already exists (case insensitive) var existingBucketTask = _context.Buckets.FirstOrDefaultAsync(b => b.Name.ToLower() == bucketDto.Name.ToLower()); await Task.WhenAll(permissionTask, existingBucketTask); var (hasAdminPermission, hasManagerPermission, hasUserPermission) = permissionTask.Result; var existingBucket = existingBucketTask.Result; // If the user does not have any of the required permissions, deny access if (!hasAdminPermission && !hasManagerPermission && !hasUserPermission) { _logger.LogWarning("Employee {EmployeeId} attempted to create bucket without permission", loggedInEmployee.Id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 403); } // If bucket with the same name exists, return conflict response if (existingBucket != null) { _logger.LogWarning("Employee {EmployeeId} attempted to create an existing bucket with name '{BucketName}'", loggedInEmployee.Id, bucketDto.Name); return ApiResponse.ErrorResponse("Bucket already exists", "Bucket already exists", 409); } // Create new bucket entity var newBucket = new Bucket { Name = bucketDto.Name, Description = bucketDto.Description, CreatedAt = DateTime.UtcNow, CreatedByID = loggedInEmployee.Id, TenantId = tenantId }; // Add bucket to context _context.Buckets.Add(newBucket); // Create mapping between employee and bucket var employeeBucketMapping = new EmployeeBucketMapping { EmployeeId = loggedInEmployee.Id, BucketId = newBucket.Id }; // Add employee-bucket mapping to context _context.EmployeeBucketMappings.Add(employeeBucketMapping); // Save changes to DB await _context.SaveChangesAsync(); // Load the newly created bucket including creator info for response var createdBucket = await _context.Buckets .Include(b => b.CreatedBy) .ThenInclude(e => e!.JobRole) .FirstOrDefaultAsync(b => b.Id == newBucket.Id); var bucketVM = _mapper.Map(createdBucket); _logger.LogInfo("Employee {EmployeeId} successfully created bucket {BucketId}", loggedInEmployee.Id, newBucket.Id); using var scope = _serviceScopeFactory.CreateScope(); var _firebase = scope.ServiceProvider.GetRequiredService(); _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; var notification = new Notification { Title = "New Bucket created", Body = $"New Bucket created \"{newBucket.Name}\" by {name}" }; await _firebase.SendBucketAsync(newBucket.Id, notification, tenantId); }); return ApiResponse.SuccessResponse(bucketVM, "Bucket created successfully", 200); } catch (Exception ex) { // Log unexpected exceptions _logger.LogError(ex, "Error occurred while employee {EmployeeId} was creating a bucket", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Internal server error", "Internal server error", 500); } } public async Task> UpdateBucketAsync(Guid id, UpdateBucketDto bucketDto, Guid tenantId, Employee loggedInEmployee) { if (bucketDto == null || id != bucketDto.Id) { _logger.LogWarning("Employee with ID {LoggedInEmployeeId} sent invalid or empty payload for bucket update.", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Invalid or empty payload", "Invalid or empty payload", 400); } try { // Check user permissions in parallel var permissionTask = CheckPermissionsAsync(loggedInEmployee.Id); // Use IDbContextFactory to create separate contexts for parallel DB calls using var employeeBucketContext = _dbContextFactory.CreateDbContext(); using var bucketContext = _dbContextFactory.CreateDbContext(); using var contactBucketContext = _dbContextFactory.CreateDbContext(); // Load employee buckets where BucketId == id in parallel var employeeBucketsTask = employeeBucketContext.EmployeeBucketMappings .Where(eb => eb.EmployeeId == loggedInEmployee.Id) .ToListAsync(); // Load the bucket with CreatedBy for given id and tenantId var bucketTask = bucketContext.Buckets .Include(b => b.CreatedBy) .ThenInclude(e => e!.JobRole) .FirstOrDefaultAsync(b => b.Id == bucketDto.Id && b.TenantId == tenantId); // Await all parallel tasks await Task.WhenAll(permissionTask, employeeBucketsTask, bucketTask); var (hasAdminPermission, hasManagerPermission, hasUserPermission) = permissionTask.Result; var employeeBuckets = employeeBucketsTask.Result; var bucket = bucketTask.Result; // Validate bucket exists if (bucket == null) { _logger.LogWarning("Employee ID {LoggedInEmployeeId} attempted to update bucket {BucketId}, but it was not found in database.", loggedInEmployee.Id, bucketDto.Id); return ApiResponse.ErrorResponse("Bucket not found", "Bucket not found", 404); } // Determine if employee has access to update the bucket var bucketIdsForEmployee = employeeBuckets.Select(eb => eb.BucketId).ToList(); bool hasAccess = false; if (hasAdminPermission) { hasAccess = true; } else if (hasManagerPermission && bucketIdsForEmployee.Contains(id)) { hasAccess = true; } else if (hasUserPermission && bucket.CreatedByID == loggedInEmployee.Id) { hasAccess = true; } if (!hasAccess) { _logger.LogWarning("Employee ID {LoggedInEmployeeId} attempted to update bucket {BucketId} without sufficient permissions.", loggedInEmployee.Id, bucket.Id); return ApiResponse.ErrorResponse("You don't have permission to access this bucket", "You don't have permission to access this bucket", 403); } var bucketObject = _updateLogsHelper.EntityToBsonDocument(bucket); // Update bucket properties safely bucket.Name = bucketDto.Name ?? string.Empty; bucket.Description = bucketDto.Description ?? string.Empty; // Log the update attempt in directory update logs _context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog { RefereanceId = bucketDto.Id, UpdatedById = loggedInEmployee.Id, UpdateAt = DateTime.UtcNow }); // Save changes to bucket and logs _context.Buckets.Update(bucket); await _context.SaveChangesAsync(); // Now load contacts related to the bucket using a separate context for parallelism var contactBuckets = await contactBucketContext.ContactBucketMappings .Where(cb => cb.BucketId == bucket.Id) .ToListAsync(); // Prepare view model to return AssignBucketVM bucketVM = _mapper.Map(bucket); bucketVM.EmployeeIds = employeeBuckets.Where(eb => eb.BucketId == bucket.Id).Select(eb => eb.EmployeeId).ToList(); bucketVM.NumberOfContacts = contactBuckets.Count; await _updateLogsHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = bucket.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = bucketObject, UpdatedAt = DateTime.UtcNow }, bucketCollection); _logger.LogInfo("Employee ID {LoggedInEmployeeId} successfully updated bucket ID {BucketId}.", loggedInEmployee.Id, bucket.Id); using var scope = _serviceScopeFactory.CreateScope(); var _firebase = scope.ServiceProvider.GetRequiredService(); _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; var notification = new Notification { Title = $"Bucket updated - \"{bucket.Name}\"", Body = $"Bucket updated \"{bucket.Name}\" by {name}" }; await _firebase.SendBucketAsync(bucket.Id, notification, tenantId); }); return ApiResponse.SuccessResponse(bucketVM, "Bucket updated successfully", 200); } catch (Exception ex) { _logger.LogError(ex, "An error occurred while employee ID {LoggedInEmployeeId} attempted to update bucket ID {BucketId}.", loggedInEmployee.Id, id); return ApiResponse.ErrorResponse("An unexpected error occurred. Please try again later.", "Internal server error", 500); } } public async Task> AssignBucketAsync(Guid bucketId, List assignBuckets, Guid tenantId, Employee loggedInEmployee) { // Validate input payload if (assignBuckets == null || bucketId == Guid.Empty) { _logger.LogWarning("Employee with ID {EmployeeId} sent empty or invalid payload.", loggedInEmployee.Id); return ApiResponse.ErrorResponse("User sent empty or invalid payload", "User sent empty or invalid payload", 400); } // Check permissions of the logged-in employee var (hasAdminPermission, hasManagerPermission, hasUserPermission) = await CheckPermissionsAsync(loggedInEmployee.Id); // Load the bucket with related CreatedBy and validate tenant var bucket = await _context.Buckets .Include(b => b.CreatedBy) .ThenInclude(e => e!.JobRole) .FirstOrDefaultAsync(b => b.Id == bucketId && b.TenantId == tenantId); if (bucket == null) { _logger.LogWarning("Employee ID {EmployeeId} attempted to update bucket {BucketId} but it was not found.", loggedInEmployee.Id, bucketId); return ApiResponse.ErrorResponse("Bucket not found", "Bucket not found", 404); } // Load EmployeeBucketMappings related to the bucket var employeeBuckets = await _context.EmployeeBucketMappings .Where(eb => eb.BucketId == bucketId) .ToListAsync(); var employeeBucketIds = employeeBuckets.Select(eb => eb.EmployeeId).ToHashSet(); // Check access permissions to the bucket bool hasAccess = false; if (hasAdminPermission) { hasAccess = true; } else if (hasManagerPermission && employeeBucketIds.Contains(loggedInEmployee.Id)) { hasAccess = true; } else if (hasUserPermission && bucket.CreatedByID == loggedInEmployee.Id) { hasAccess = true; } if (!hasAccess) { _logger.LogWarning("Employee {EmployeeId} attempted to access bucket {BucketId} without permission.", loggedInEmployee.Id, bucketId); return ApiResponse.ErrorResponse("You don't have permission to access this bucket", "You don't have permission to access this bucket", 403); } // Load active employees tenant-wide var activeEmployeeIds = await _context.Employees .Where(e => e.TenantId == tenantId && e.IsActive) .Select(e => e.Id) .ToListAsync(); int assignedEmployeesCount = 0; int removedEmployeesCount = 0; List assignedEmployeeIds = new List(); List removedEmployeeIds = new List(); // Process each assignment request foreach (var assignBucket in assignBuckets) { if (!activeEmployeeIds.Contains(assignBucket.EmployeeId)) { // Skip employee IDs that are not active or not in tenant _logger.LogWarning("Skipping inactive or non-tenant employee ID {EmployeeId} in assignment.", assignBucket.EmployeeId); continue; } if (assignBucket.IsActive) { // Add mapping if not already exists if (!employeeBucketIds.Contains(assignBucket.EmployeeId)) { var newMapping = new EmployeeBucketMapping { EmployeeId = assignBucket.EmployeeId, BucketId = bucketId }; _context.EmployeeBucketMappings.Add(newMapping); assignedEmployeesCount++; assignedEmployeeIds.Add(assignBucket.EmployeeId); } } else { // Remove mapping if it exists var existingMapping = employeeBuckets.FirstOrDefault(eb => eb.EmployeeId == assignBucket.EmployeeId); if (existingMapping != null && bucket.CreatedByID != assignBucket.EmployeeId) { removedEmployeeIds.Add(existingMapping.EmployeeId); _context.EmployeeBucketMappings.Remove(existingMapping); removedEmployeesCount++; } } } // Add directory update log _context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog { RefereanceId = bucketId, UpdatedById = loggedInEmployee.Id, UpdateAt = DateTime.UtcNow }); // Save changes with error handling try { await _context.SaveChangesAsync(); } catch (DbUpdateException ex) { _logger.LogError(ex, "Failed to save changes while assigning bucket {BucketId} by employee {EmployeeId}.", bucketId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Failed to update bucket assignments", "An error occurred while updating bucket assignments", 500); } // Reload mappings and contacts for the updated bucket var employeeBucketMappingTask = Task.Run(async () => { using (var context = _dbContextFactory.CreateDbContext()) { return await context.EmployeeBucketMappings.Where(eb => eb.BucketId == bucket.Id).ToListAsync(); } }); var contactBucketMappingTask = Task.Run(async () => { using (var context = _dbContextFactory.CreateDbContext()) { return await context.ContactBucketMappings.Where(eb => eb.BucketId == bucket.Id).ToListAsync(); } }); await Task.WhenAll(employeeBucketMappingTask, contactBucketMappingTask); var employeeBucketMappings = employeeBucketMappingTask.Result; var contactBucketMappings = contactBucketMappingTask.Result; // Prepare view model response var bucketVm = _mapper.Map(bucket); bucketVm.EmployeeIds = employeeBucketMappings.Select(eb => eb.EmployeeId).ToList(); bucketVm.NumberOfContacts = contactBucketMappings.Count; // Log assignment and removal actions if (assignedEmployeesCount > 0) { _logger.LogInfo("Employee {EmployeeId} assigned bucket {BucketId} to {Count} employees.", loggedInEmployee.Id, bucketId, assignedEmployeesCount); } if (removedEmployeesCount > 0) { _logger.LogInfo("Employee {EmployeeId} removed {Count} employees from bucket {BucketId}.", loggedInEmployee.Id, removedEmployeesCount, bucketId); } using var scope = _serviceScopeFactory.CreateScope(); var _firebase = scope.ServiceProvider.GetRequiredService(); _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; if (assignedEmployeeIds.Any()) { var notification = new Notification { Title = "You have assigned to Bucket", Body = $"You have assigned to bucket \"{bucket.Name}\" by {name}" }; await _firebase.SendAssignBucketAsync(assignedEmployeeIds, notification, tenantId); } if (removedEmployeeIds.Any()) { var notification = new Notification { Title = "You have removed from Bucket", Body = $"You have removed from bucket \"{bucket.Name}\" by {name}" }; await _firebase.SendAssignBucketAsync(removedEmployeeIds, notification, tenantId); } }); return ApiResponse.SuccessResponse(bucketVm, "Bucket details updated successfully", 200); } public async Task> DeleteBucketAsync(Guid id, Guid tenantId, Employee loggedInEmployee) { try { // Fetch the bucket in the main context to verify existence and tenant scope var bucket = await _context.Buckets.FirstOrDefaultAsync(b => b.Id == id && b.TenantId == tenantId); if (bucket == null) { _logger.LogWarning("Employee {EmployeeId} attempted to delete bucket {BucketId}, bucket not found for tenant {TenantId}.", loggedInEmployee.Id, id, tenantId); // Returning success response to maintain idempotency return ApiResponse.SuccessResponse(new { }, "Bucket deleted successfully", 200); } // Run parallel tasks to fetch related mappings using separate DbContext instances var employeeBucketMappingsTask = Task.Run(async () => { using var context = _dbContextFactory.CreateDbContext(); return await context.EmployeeBucketMappings .Where(eb => eb.BucketId == bucket.Id) .ToListAsync(); }); var contactBucketMappingsTask = Task.Run(async () => { using var context = _dbContextFactory.CreateDbContext(); return await context.ContactBucketMappings .Where(cb => cb.BucketId == bucket.Id) .ToListAsync(); }); // Await both tasks concurrently await Task.WhenAll(employeeBucketMappingsTask, contactBucketMappingsTask); var employeeBucketMappings = employeeBucketMappingsTask.Result; var contactBucketMappings = contactBucketMappingsTask.Result; // Check if bucket has any contacts mapped - cannot delete in this state if (contactBucketMappings.Any()) { _logger.LogInfo("Employee {EmployeeId} attempted to delete bucket {BucketId} but bucket contains contacts, deletion blocked.", loggedInEmployee.Id, bucket.Id); return ApiResponse.ErrorResponse("This bucket cannot be deleted because it contains contacts.", "This bucket cannot be deleted", 400); } // Check permissions of the logged-in employee var (hasAdminPermission, hasManagerPermission, hasUserPermission) = await CheckPermissionsAsync(loggedInEmployee.Id); var accessibleBucket = (Bucket?)null; // Get bucket IDs for which the employee has mapping association var employeeBucketIds = employeeBucketMappings .Where(eb => eb.EmployeeId == loggedInEmployee.Id) .Select(eb => eb.BucketId) .ToList(); // Determine if employee has permission to delete this bucket if (hasAdminPermission) { accessibleBucket = bucket; } else if (hasManagerPermission && employeeBucketIds.Contains(bucket.Id)) { accessibleBucket = bucket; } else if (hasUserPermission && bucket.CreatedByID == loggedInEmployee.Id) { accessibleBucket = bucket; } if (accessibleBucket == null) { _logger.LogWarning("Employee {EmployeeId} attempted to delete bucket {BucketId} without sufficient permissions.", loggedInEmployee.Id, bucket.Id); return ApiResponse.ErrorResponse("You don't have permission to access this bucket.", "Permission denied", 403); } var bucketObject = _updateLogsHelper.EntityToBsonDocument(bucket); // Remove related employee bucket mappings _context.EmployeeBucketMappings.RemoveRange(employeeBucketMappings); // Remove the bucket itself _context.Buckets.Remove(bucket); // Log deletion action _context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog { RefereanceId = bucket.Id, UpdatedById = loggedInEmployee.Id, UpdateAt = DateTime.UtcNow }); await _context.SaveChangesAsync(); _logger.LogInfo("Employee {EmployeeId} deleted bucket {BucketId} along with related employee bucket mappings.", loggedInEmployee.Id, bucket.Id); await _updateLogsHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = bucket.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = bucketObject, UpdatedAt = DateTime.UtcNow }, bucketCollection); using var scope = _serviceScopeFactory.CreateScope(); var _firebase = scope.ServiceProvider.GetRequiredService(); _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; var notification = new Notification { Title = $"Bucket deleted - \"{bucket.Name}\"", Body = $"Bucket deleted \"{bucket.Name}\" by {name}" }; await _firebase.SendBucketAsync(bucket.Id, notification, tenantId); }); return ApiResponse.SuccessResponse(new { }, "Bucket deleted successfully", 200); } catch (Exception ex) { // Log the exception with error level _logger.LogError(ex, "An error occurred while employee {EmployeeId} attempted to delete bucket {BucketId}.", loggedInEmployee.Id, id); return ApiResponse.ErrorResponse("An unexpected error occurred while deleting the bucket.", "Internal Server Error", 500); } } #endregion #region =================================================================== Helper Functions =================================================================== private async Task<(bool hasAdmin, bool hasManager, bool hasUser)> CheckPermissionsAsync(Guid employeeId) { // Scoping the service provider ensures services are disposed of correctly. using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); // Run all permission checks in parallel. var hasAdminTask = permissionService.HasPermission(PermissionsMaster.DirectoryAdmin, employeeId); var hasManagerTask = permissionService.HasPermission(PermissionsMaster.DirectoryManager, employeeId); var hasUserTask = permissionService.HasPermission(PermissionsMaster.DirectoryUser, employeeId); await Task.WhenAll(hasAdminTask, hasManagerTask, hasUserTask); return (hasAdminTask.Result, hasManagerTask.Result, hasUserTask.Result); } private bool Compare(string sentence, string search) { sentence = sentence.Trim().ToLower(); search = search.Trim().ToLower(); // Check for exact substring bool result = sentence.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0; return result; } private ContactFilterDto? TryDeserializeContactFilter(string? filter) { if (string.IsNullOrWhiteSpace(filter)) { return null; } var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; ContactFilterDto? expenseFilter = null; try { // First, try to deserialize directly. This is the expected case (e.g., from a web client). expenseFilter = JsonSerializer.Deserialize(filter, options); } catch (JsonException ex) { _logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeContactFilter), filter); // If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients). try { // Unescape the string first, then deserialize the result. string unescapedJsonString = JsonSerializer.Deserialize(filter, options) ?? ""; if (!string.IsNullOrWhiteSpace(unescapedJsonString)) { expenseFilter = JsonSerializer.Deserialize(unescapedJsonString, options); } } catch (JsonException ex1) { // If both attempts fail, log the final error and return null. _logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeContactFilter), filter); return null; } } return expenseFilter; } private ContactNoteFilter? TryDeserializeContactNoteFilter(string? filter) { if (string.IsNullOrWhiteSpace(filter)) { return null; } var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; ContactNoteFilter? expenseFilter = null; try { // First, try to deserialize directly. This is the expected case (e.g., from a web client). expenseFilter = JsonSerializer.Deserialize(filter, options); } catch (JsonException ex) { _logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeContactNoteFilter), filter); // If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients). try { // Unescape the string first, then deserialize the result. string unescapedJsonString = JsonSerializer.Deserialize(filter, options) ?? ""; if (!string.IsNullOrWhiteSpace(unescapedJsonString)) { expenseFilter = JsonSerializer.Deserialize(unescapedJsonString, options); } } catch (JsonException ex1) { // If both attempts fail, log the final error and return null. _logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeContactNoteFilter), filter); return null; } } return expenseFilter; } private static object ExceptionMapper(Exception ex) { return new { Message = ex.Message, StackTrace = ex.StackTrace, Source = ex.Source, InnerException = new { Message = ex.InnerException?.Message, StackTrace = ex.InnerException?.StackTrace, Source = ex.InnerException?.Source, } }; } // --- Helper Methods for Readability and Separation of Concerns --- private List ProcessContactPhones(CreateContactDto dto, Contact contact, ISet existingPhones) { if (!(dto.ContactPhones?.Any() ?? false)) return new List(); var newPhones = dto.ContactPhones .Where(p => !string.IsNullOrWhiteSpace(p.PhoneNumber) && existingPhones.Add(p.PhoneNumber)) // .Add returns true if the item was added (i.e., not present) .Select(pDto => { var phone = _mapper.Map(pDto); phone.ContactId = contact.Id; phone.TenantId = contact.TenantId; // Ensure tenant is set on child entities return phone; }).ToList(); _context.ContactsPhones.AddRange(newPhones); _logger.LogInfo("Adding {Count} new phone numbers for Contact {ContactId}.", newPhones.Count, contact.Id); return newPhones.Select(p => _mapper.Map(p)).ToList(); } private List ProcessContactEmails(CreateContactDto dto, Contact contact, ISet existingEmails) { if (!(dto.ContactEmails?.Any() ?? false)) return new List(); var newEmails = dto.ContactEmails .Where(e => !string.IsNullOrWhiteSpace(e.EmailAddress) && existingEmails.Add(e.EmailAddress)) // HashSet handles case-insensitivity set in constructor .Select(eDto => { var email = _mapper.Map(eDto); email.ContactId = contact.Id; email.TenantId = contact.TenantId; return email; }).ToList(); _context.ContactsEmails.AddRange(newEmails); _logger.LogInfo("Adding {Count} new email addresses for Contact {ContactId}.", newEmails.Count, contact.Id); return newEmails.Select(e => _mapper.Map(e)).ToList(); } private List ProcessTags(CreateContactDto dto, Contact contact, IDictionary tenantTags) { if (!(dto.Tags?.Any() ?? false)) return new List(); var tagVMs = new List(); var newTagMappings = new List(); foreach (var tagName in dto.Tags.Select(t => t.Name).Distinct(StringComparer.OrdinalIgnoreCase)) { if (string.IsNullOrWhiteSpace(tagName)) continue; var normalizedTag = tagName.ToLowerInvariant(); if (!tenantTags.TryGetValue(normalizedTag, out var tagMaster)) { // Tag does not exist, create it. tagMaster = new ContactTagMaster { Name = tagName, Description = tagName, TenantId = contact.TenantId }; _context.ContactTagMasters.Add(tagMaster); tenantTags.Add(normalizedTag, tagMaster); // Add to dictionary to handle duplicates in the input list _logger.LogDebug("Creating new tag '{TagName}' for Tenant {TenantId}.", tagName, contact.TenantId); } newTagMappings.Add(new ContactTagMapping { ContactId = contact.Id, ContactTag = tagMaster }); tagVMs.Add(_mapper.Map(tagMaster)); } _context.ContactTagMappings.AddRange(newTagMappings); _logger.LogInfo("Adding {Count} tag mappings for Contact {ContactId}.", newTagMappings.Count, contact.Id); return tagVMs; } private async Task> ProcessBucketMappingsAsync(CreateContactDto dto, Contact contact, Guid tenantId) { if (!(dto.BucketIds?.Any() ?? false)) return new List(); var validBucketIds = await _context.Buckets .Where(b => dto.BucketIds.Contains(b.Id) && b.TenantId == tenantId) .Select(b => b.Id) .ToListAsync(); var mappings = validBucketIds.Select(bucketId => new ContactBucketMapping { BucketId = bucketId, ContactId = contact.Id }).ToList(); _context.ContactBucketMappings.AddRange(mappings); _logger.LogInfo("Adding {Count} bucket mappings for Contact {ContactId}.", mappings.Count, contact.Id); return mappings; } private async Task> ProcessProjectMappingsAsync(CreateContactDto dto, Contact contact, Guid tenantId) { if (!(dto.ProjectIds?.Any() ?? false)) return new List(); var validProjectIds = await _context.Projects .Where(p => dto.ProjectIds.Contains(p.Id) && p.TenantId == tenantId) .Select(p => p.Id) .ToListAsync(); var mappings = validProjectIds.Select(projectId => new ContactProjectMapping { ProjectId = projectId, ContactId = contact.Id, TenantId = tenantId }).ToList(); _context.ContactProjectMappings.AddRange(mappings); _logger.LogInfo("Adding {Count} project mappings for Contact {ContactId}.", mappings.Count, contact.Id); return mappings; } #endregion } }