2141 lines
112 KiB
C#

using AutoMapper;
using Marco.Pms.DataAccess.Data;
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.Utilities;
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<ApplicationDbContext> _dbContextFactory;
private readonly ApplicationDbContext _context;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILoggingService _logger;
private readonly UserHelper _userHelper;
private readonly IMapper _mapper;
private readonly PermissionServices _permissionServices;
public DirectoryService(
IDbContextFactory<ApplicationDbContext> dbContextFactory,
ApplicationDbContext context,
ILoggingService logger,
IServiceScopeFactory serviceScopeFactory,
UserHelper userHelper,
IMapper mapper,
PermissionServices permissionServices)
{
_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));
_permissionServices = permissionServices ?? throw new ArgumentNullException(nameof(permissionServices));
}
#region =================================================================== Contact APIs ===================================================================
#region =================================================================== Contact Get APIs ===================================================================
/// <summary>
/// Retrieves a paginated list of contacts based on permissions, search criteria, and filters.
/// </summary>
/// <param name="search">A search term to filter contacts by name, organization, email, phone, or tag.</param>
/// <param name="filter">A JSON string representing ContactFilterDto for advanced filtering.</param>
/// <param name="projectId">Optional project ID to filter contacts assigned to a specific project.</param>
/// <param name="active">Boolean to filter for active or inactive contacts.</param>
/// <param name="pageSize">The number of records per page.</param>
/// <param name="pageNumber">The current page number.</param>
/// <param name="tenantId">The ID of the tenant to which the contacts belong.</param>
/// <param name="loggedInEmployee">The employee making the request, used for permission checks.</param>
/// <returns>An ApiResponse containing the paginated list of contacts or an error.</returns>
public async Task<ApiResponse<object>> 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<Contact> 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<object>.SuccessResponse("Access Denied", "You do not have permission to view any contacts.", 403);
}
}
// --- Advanced Filtering from 'filter' parameter ---
ContactFilterDto? contactFilter = TryDeserializeFilter(filter);
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<object>.SuccessResponse(new { TotalPages = 0, CurrentPage = pageNumber, PageSize = pageSize, Data = new List<ContactVM>() }, "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<ContactVM>(c);
contactVM.ContactPhones = _mapper.Map<List<ContactPhoneVM>>(phonesLookup[c.Id]);
contactVM.ContactEmails = _mapper.Map<List<ContactEmailVM>>(emailsLookup[c.Id]);
contactVM.Tags = _mapper.Map<List<ContactTagVM>>(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();
// Step 7: Construct and return the final response.
var response = new
{
TotalPages = (int)Math.Ceiling((double)totalCount / pageSize),
CurrentPage = pageNumber,
PageSize = pageSize,
Data = list
};
_logger.LogInfo("{Count} contacts were fetched successfully for Tenant {TenantId} by Employee {EmployeeId}", list.Count, tenantId, loggedInEmployeeId);
return ApiResponse<object>.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<object>.ErrorResponse("An internal error occurred.", ExceptionMapper(ex), 500);
}
}
public async Task<ApiResponse<object>> 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<EmployeeBucketMapping>? employeeBuckets = await _context.EmployeeBucketMappings.Where(eb => eb.EmployeeId == LoggedInEmployee.Id).ToListAsync();
List<Guid> 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<object>.ErrorResponse("You don't have permission", "You don't have permission", 403);
}
List<Guid> filterbucketIds = bucketIds;
if (filterDto != null && filterDto.BucketIds != null && filterDto.BucketIds.Count > 0)
{
filterbucketIds = filterDto.BucketIds;
}
List<ContactBucketMapping>? contactBuckets = await _context.ContactBucketMappings.Where(cb => bucketIds.Contains(cb.BucketId)).ToListAsync();
List<Guid> contactIds = contactBuckets.Where(b => filterbucketIds.Contains(b.BucketId)).Select(cb => cb.ContactId).ToList();
List<Contact> contacts = new List<Contact>();
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<Guid> 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<Guid> filteredContactIds = new List<Guid>();
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<ContactVM> list = new List<ContactVM>();
foreach (var contact in contacts)
{
ContactVM contactVM = new ContactVM();
List<ContactEmailVM> contactEmailVms = new List<ContactEmailVM>();
List<ContactPhoneVM> contactPhoneVms = new List<ContactPhoneVM>();
List<ContactTagVM> conatctTagVms = new List<ContactTagVM>();
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<object>.SuccessResponse(list, System.String.Format("{0} contacts fetched successfully", list.Count), 200);
}
public async Task<ApiResponse<object>> 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<object>.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<object>.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<object>.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<object>.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<object>.SuccessResponse(new List<ContactVM>(), "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<ContactTagMaster> tagMasters = new List<ContactTagMaster>();
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<ContactVM>();
// 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<ContactPhoneVM>(p)).ToList();
// Transform emails
var emailVMs = emails.Where(e => e.ContactId == contact.Id).Select(e => _mapper.Map<ContactEmailVM>(e)).ToList();
// Transform tags
var contactTagMappings = tags.Where(t => t.ContactId == contact.Id);
var tagVMs = new List<ContactTagVM>();
foreach (var ct in contactTagMappings)
{
var tagMaster = tagMasters.Find(tm => tm.Id == ct.ContactTagId);
if (tagMaster != null)
{
tagVMs.Add(_mapper.Map<ContactTagVM>(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<ContactVM>(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<object>.SuccessResponse(contactVMs, $"{contactVMs.Count} contacts fetched successfully.", 200);
}
public async Task<ApiResponse<object>> 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<object>.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<object>.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)
.Include(c => c.UpdatedBy)
.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<object>.ErrorResponse("Contact not found", "Contact not found", 404);
}
ContactProfileVM contactVM = _mapper.Map<ContactProfileVM>(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<ContactPhoneVM>(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<ContactEmailVM>(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)
.Where(cb => cb.ContactId == contact.Id && cb.Bucket != null && cb.Bucket.TenantId == tenantId);
if (hasAdminPermission)
{
return await bucketQuery
.Select(cb => _mapper.Map<BucketVM>(cb.Bucket))
.ToListAsync();
}
List<Guid> 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<BucketVM>(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<ContactTagVM>(ct.ContactTag))
.ToListAsync();
});
var contactNotesTask = Task.Run(async () =>
{
await using var taskDbContext = await _dbContextFactory.CreateDbContextAsync();
return await taskDbContext.ContactNotes
.AsNoTracking()
.Include(cn => cn.Createdby)
.Include(cn => cn.UpdatedBy)
.Include(cn => cn.Contact)
.Where(cn => cn.ContactId == contact.Id && cn.Createdby != null && cn.Createdby.TenantId == tenantId)
.Select(cn => _mapper.Map<ContactNoteVM>(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<object>.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<object>.ErrorResponse("An internal error occurred.", ExceptionMapper(ex), 500);
}
}
/// <summary>
/// Asynchronously retrieves a distinct list of organization names for a given tenant.
/// </summary>
/// <param name="tenantId">The unique identifier of the tenant.</param>
/// <param name="loggedInEmployee">The employee making the request, used for permission checks.</param>
/// <returns>
/// 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).
/// </returns>
public async Task<ApiResponse<object>> 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<object>.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<object>.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<object>.ErrorResponse("An internal server error occurred.", ExceptionMapper(ex), 500);
}
}
#endregion
#region =================================================================== Contact Post APIs ===================================================================
/// <summary>
/// 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.
/// </summary>
/// <param name="createContact">The DTO containing the details for the new contact.</param>
/// <param name="tenantId">The ID of the tenant to which the contact belongs.</param>
/// <param name="loggedInEmployee">The employee performing the action.</param>
/// <returns>An ApiResponse containing the newly created contact's view model or an error.</returns>
public async Task<ApiResponse<object>> CreateContactAsync(CreateContactDto createContact, Guid tenantId, Employee loggedInEmployee)
{
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<object>.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<Contact>(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<string>(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<string>(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<object>.ErrorResponse("An internal database error occurred.", ExceptionMapper(dbEx), 500);
}
// --- Construct and Return Response ---
var contactVM = _mapper.Map<ContactVM>(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();
return ApiResponse<object>.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<object>.ErrorResponse("An unexpected internal error occurred.", ExceptionMapper(ex), 500);
}
}
#endregion
#region =================================================================== Contact Put APIs ===================================================================
public async Task<ApiResponse<object>> UpdateContactAsync(Guid id, UpdateContactDto updateContact, Guid tenantId, Employee loggedInEmployee)
{
if (updateContact == null)
{
_logger.LogWarning("Employee {EmployeeId} sent empty payload for updating contact.", loggedInEmployee.Id);
return ApiResponse<object>.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<object>.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<object>.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<Guid> 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<object>.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 = updateContact.ToContactFromUpdateContactDto(tenantId, contact);
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);
// ---------------------- 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 = phoneDto.ToContactPhoneFromUpdateContactPhoneDto(tenantId, contact.Id);
if (phoneDto.Id != null && phoneDto.Id != Guid.Empty && phoneIds.Contains(phoneEntity.Id))
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))
{
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 = emailDto.ToContactEmailFromUpdateContactEmailDto(tenantId, contact.Id);
if (emailDto.Id != null && emailDto.Id != Guid.Empty && emailIds.Contains(emailEntity.Id))
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))
{
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<object>.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
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 contactVM = reloadedContact.ToContactVMFromContact();
contactVM.ContactPhones = responsePhones.Select(p => p.ToContactPhoneVMFromContactPhone()).ToList();
contactVM.ContactEmails = responseEmails.Select(e => e.ToContactEmailVMFromContactEmail()).ToList();
contactVM.Tags = responseContactTags.Select(ctm =>
{
var tag = tagsForResponse.Find(t => t.Id == ctm.ContactTagId);
return tag != null ? tag.ToContactTagVMFromContactTagMaster() : new ContactTagVM();
}).ToList();
contactVM.BucketIds = responseContactBuckets.Select(cb => cb.BucketId).ToList();
contactVM.ProjectIds = responseContactProjects.Select(cp => cp.ProjectId).ToList();
_logger.LogInfo("Contact {ContactId} successfully updated by employee {EmployeeId}.", contact.Id, loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(contactVM, "Contact Updated Successfully", 200);
}
#endregion
#region =================================================================== Contact Delete APIs ===================================================================
public async Task<ApiResponse<object>> 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<object>.ErrorResponse("Contact ID is empty", "Contact ID is empty", 400);
}
// 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<object>.ErrorResponse("Contact not found", "Contact not found", 404);
}
// 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();
_logger.LogInfo("Contact ID {ContactId} has been {(DeletedOrActivated)} by Employee ID {EmployeeId}.", id, active ? "activated" : "deleted", loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(new { }, active ? "Contact is activated successfully" : "Contact is deleted successfully", 200);
}
#endregion
#endregion
#region =================================================================== Contact Notes APIs ===================================================================
/// <summary>
/// Retrieves a paginated list of contact notes based on user permissions.
/// </summary>
/// <param name="pageSize">The number of items per page.</param>
/// <param name="pageNumber">The current page number.</param>
/// <returns>An ApiResponse containing the paginated notes or an error message.</returns>
public async Task<ApiResponse<object>> GetListOFAllNotes(Guid? projectId, int pageSize, int pageNumber)
{
_logger.LogInfo("Attempting to fetch list of all notes. PageSize: {PageSize}, PageNumber: {PageNumber}", pageSize, pageNumber);
Guid tenantId = _userHelper.GetTenantId();
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
List<Guid>? projectContactIds = null;
if (loggedInEmployee == null)
{
_logger.LogWarning("GetListOFAllNotes: LoggedInEmployee is null. Cannot proceed.");
return ApiResponse<object>.ErrorResponse("Unauthorized", "Employee not found.", 403);
}
// --- Permission Checks ---
var hasAdminPermission = await _permissionServices.HasPermission(PermissionsMaster.DirectoryAdmin, loggedInEmployee.Id);
var hasManagerPermission = await _permissionServices.HasPermission(PermissionsMaster.DirectoryAdmin, loggedInEmployee.Id);
var hasUserPermission = await _permissionServices.HasPermission(PermissionsMaster.DirectoryUser, loggedInEmployee.Id);
IQueryable<ContactNote> notesQuery = _context.ContactNotes
.Include(cn => cn.UpdatedBy)
.Include(cn => cn.Createdby) // Assuming 'CreatedBy' (PascalCase)
.Include(cn => cn.Contact)
.Where(cn => cn.TenantId == tenantId)
.AsQueryable(); // Start building the query
if (!hasAdminPermission && !(hasManagerPermission || hasUserPermission))
{
_logger.LogWarning("GetListOFAllNotes: User {EmployeeId} does not have required permissions to access notes for TenantId: {TenantId}", loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Access Denied", "You don't have access to view notes.", 403);
}
if (projectId != null)
{
projectContactIds = await _context.ContactProjectMappings
.Where(pc => pc.ProjectId == projectId)
.Select(pc => pc.ContactId)
.ToListAsync();
}
if (!hasAdminPermission) // If not an admin, apply additional filtering
{
_logger.LogInfo("GetListOFAllNotes: User {EmployeeId} is not an admin. Applying manager/user specific filters.", loggedInEmployee.Id);
var assignedBucketIds = await _context.EmployeeBucketMappings
.Where(eb => eb.EmployeeId == loggedInEmployee.Id)
.Select(eb => eb.BucketId)
.ToListAsync();
if (!assignedBucketIds.Any())
{
_logger.LogInfo("GetListOFAllNotes: User {EmployeeId} has no assigned buckets. Returning empty list.", loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(new { CurrentPage = pageNumber, TotalPages = 0, Data = new List<ContactNoteVM>() }, "No notes found based on assigned buckets.", 200);
}
List<Guid>? contactIds = null;
if (projectContactIds == null)
{
contactIds = await _context.ContactBucketMappings
.Where(cb => assignedBucketIds.Contains(cb.BucketId))
.Select(cb => cb.ContactId)
.ToListAsync();
}
else
{
contactIds = await _context.ContactBucketMappings
.Where(cb => assignedBucketIds.Contains(cb.BucketId) && projectContactIds.Contains(cb.ContactId))
.Select(cb => cb.ContactId)
.ToListAsync();
}
if (!contactIds.Any())
{
_logger.LogInfo("GetListOFAllNotes: No contacts found for assigned buckets for user {EmployeeId}. Returning empty list.", loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(new { CurrentPage = pageNumber, TotalPages = 0, Data = new List<ContactNoteVM>() }, "No notes found for associated contacts.", 200);
}
notesQuery = notesQuery.Where(cn => contactIds.Contains(cn.ContactId));
}
else
{
if (projectContactIds != null)
{
notesQuery = notesQuery.Where(cn => projectContactIds.Contains(cn.ContactId));
}
}
// --- Pagination Logic ---
// Ensure pageSize and pageNumber are valid
pageSize = pageSize < 1 ? 25 : pageSize; // Default to 25 if less than 1
pageNumber = pageNumber < 1 ? 1 : pageNumber; // Default to 1 if less than 1
// Get total count BEFORE applying Skip/Take for accurate pagination metadata
int totalRecords = await notesQuery.CountAsync();
int totalPages = (int)Math.Ceiling((double)totalRecords / pageSize);
int skip = (pageNumber - 1) * pageSize;
// --- Apply Ordering and Pagination in the database ---
List<ContactNote> notes = await notesQuery
.OrderByDescending(cn => (cn.UpdatedAt != null ? cn.UpdatedAt : cn.CreatedAt)) // Order by updated date or created date
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
_logger.LogInfo("GetListOFAllNotes: Fetched {Count} notes for page {PageNumber} of {TotalPages} total pages. Total records: {TotalRecords}.",
notes.Count, pageNumber, totalPages, totalRecords);
// --- Map to ViewModel (in-memory) ---
// This mapping is done in memory because ToBasicEmployeeVMFromEmployee() is likely a C# method
// that cannot be translated to SQL by Entity Framework.
List<ContactNoteVM> noteVMS = notes
.Select(cn => cn.ToContactNoteVMFromContactNote())
.ToList();
var response = new
{
CurrentPage = pageNumber,
PageSize = pageSize, // Include pageSize in response for client clarity
TotalPages = totalPages,
TotalRecords = totalRecords, // Add total records for client
Data = noteVMS
};
_logger.LogInfo("GetListOFAllNotes: Successfully retrieved notes and mapped to ViewModel for TenantId: {TenantId}.", tenantId);
return ApiResponse<object>.SuccessResponse(response, $"{noteVMS.Count} notes fetched successfully.", 200);
}
public async Task<ApiResponse<object>> GetNoteListByContactId(Guid id, bool active)
{
Guid tenantId = _userHelper.GetTenantId();
var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
Contact? contact = await _context.Contacts.FirstOrDefaultAsync(c => c.Id == id && c.IsActive && c.TenantId == tenantId);
if (contact != null)
{
List<ContactNote> notes = new List<ContactNote>();
if (active)
{
notes = await _context.ContactNotes
.Include(n => n.Createdby)
.Include(n => n.UpdatedBy)
.Where(n => n.ContactId == contact.Id && n.IsActive && n.TenantId == tenantId)
.ToListAsync();
}
else
{
notes = await _context.ContactNotes
.Include(n => n.Createdby)
.Include(n => n.UpdatedBy)
.Where(n => n.ContactId == contact.Id && n.TenantId == tenantId)
.ToListAsync();
}
var noteIds = notes.Select(n => n.Id).ToList();
List<DirectoryUpdateLog>? updateLogs = await _context.DirectoryUpdateLogs.Include(l => l.Employee).Where(l => noteIds.Contains(l.RefereanceId)).ToListAsync();
//List<ContactNoteVM>? noteVMs = new List<ContactNoteVM>();
List<ContactNoteVM>? noteVMs = notes.Select(n => n.ToContactNoteVMFromContactNote()).ToList();
_logger.LogInfo("{count} contact-notes record from contact {ContactId} fetched by Employee {EmployeeId}", noteVMs.Count, id, LoggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(noteVMs, $"{noteVMs.Count} contact-notes record fetched successfully", 200);
}
_logger.LogWarning("Employee with ID {LoggedInEmployeeId} attempted to fetch a list notes from contact with ID {ContactId}, but the contact was not found in the database.", LoggedInEmployee.Id, id);
return ApiResponse<object>.ErrorResponse("Contact not found", "Contact not found", 404);
}
public async Task<ApiResponse<object>> CreateContactNote(CreateContactNoteDto noteDto)
{
Guid tenantId = _userHelper.GetTenantId();
var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
if (noteDto != null)
{
Contact? contact = await _context.Contacts.FirstOrDefaultAsync(c => c.Id == noteDto.ContactId && c.IsActive && c.TenantId == tenantId);
if (contact != null)
{
ContactNote note = noteDto.ToContactNoteFromCreateContactNoteDto(tenantId, LoggedInEmployee.Id);
_context.ContactNotes.Add(note);
await _context.SaveChangesAsync();
ContactNoteVM noteVM = note.ToContactNoteVMFromContactNote();
_logger.LogInfo("Employee {EmployeeId} Added note at contact {ContactId}", LoggedInEmployee.Id, contact.Id);
return ApiResponse<object>.SuccessResponse(noteVM, "Note added successfully", 200);
}
_logger.LogWarning("Employee with ID {LoggedInEmployeeId} attempted to add a note to contact with ID {ContactId}, but the contact was not found in the database.", LoggedInEmployee.Id, noteDto.ContactId);
return ApiResponse<object>.ErrorResponse("Contact not found", "Contact not found", 404);
}
_logger.LogWarning("Employee with ID {LoggedInEmployeeId} sended empty payload", LoggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("User Send empty Payload", "User Send empty Payload", 400);
}
public async Task<ApiResponse<object>> UpdateContactNote(Guid id, UpdateContactNoteDto noteDto)
{
Guid tenantId = _userHelper.GetTenantId();
var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
if (noteDto != null && id == noteDto.Id)
{
Contact? contact = await _context.Contacts.FirstOrDefaultAsync(c => c.Id == noteDto.ContactId && c.TenantId == tenantId);
if (contact != null)
{
ContactNote? contactNote = await _context.ContactNotes.Include(cn => cn.Createdby).Include(cn => cn.Contact).FirstOrDefaultAsync(n => n.Id == noteDto.Id && n.ContactId == contact.Id && n.IsActive);
if (contactNote != null)
{
contactNote.Note = noteDto.Note;
contactNote.UpdatedById = LoggedInEmployee.Id;
contactNote.UpdatedAt = DateTime.UtcNow;
_context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog
{
RefereanceId = id,
UpdatedById = LoggedInEmployee.Id,
UpdateAt = DateTime.UtcNow
});
await _context.SaveChangesAsync();
ContactNoteVM noteVM = contactNote.ToContactNoteVMFromContactNote();
noteVM.UpdatedAt = DateTime.UtcNow;
noteVM.UpdatedBy = LoggedInEmployee.ToBasicEmployeeVMFromEmployee();
_logger.LogInfo("Employee {EmployeeId} updated note {NoteId} at contact {ContactId}", LoggedInEmployee.Id, noteVM.Id, contact.Id);
return ApiResponse<object>.SuccessResponse(noteVM, "Note updated successfully", 200);
}
_logger.LogWarning("Employee with ID {LoggedInEmployeeId} attempted to update a note {NoteId} to contact with ID {ContactId}, but the Note was not found in the database.", LoggedInEmployee.Id, noteDto.Id, noteDto.ContactId);
return ApiResponse<object>.ErrorResponse("Note not found", "Note not found", 404);
}
_logger.LogWarning("Employee with ID {LoggedInEmployeeId} attempted to update a note {NoteId} to contact with ID {ContactId}, but the contact was not found in the database.", LoggedInEmployee.Id, noteDto.Id, noteDto.ContactId);
return ApiResponse<object>.ErrorResponse("Contact not found", "Contact not found", 404);
}
_logger.LogWarning("Employee with ID {LoggedInEmployeeId} sended empty payload", LoggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("User Send empty Payload", "User Send empty Payload", 400);
}
public async Task<ApiResponse<object>> DeleteContactNote(Guid id, bool active)
{
Guid tenantId = _userHelper.GetTenantId();
var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
ContactNote? note = await _context.ContactNotes.FirstOrDefaultAsync(n => n.Id == id && n.TenantId == tenantId);
if (note != null)
{
note.IsActive = active;
note.UpdatedById = LoggedInEmployee.Id;
note.UpdatedAt = DateTime.UtcNow;
_context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog
{
RefereanceId = id,
UpdatedById = LoggedInEmployee.Id,
UpdateAt = DateTime.UtcNow
});
await _context.SaveChangesAsync();
_logger.LogInfo("Employee {EmployeeId} deleted note {NoteId}", LoggedInEmployee.Id, id);
}
_logger.LogWarning("Employee {EmployeeId} tries to delete contact note {NoteId} but not found in database", LoggedInEmployee.Id, id);
return ApiResponse<object>.SuccessResponse(new { }, "Note deleted successfully", 200);
}
#endregion
#region =================================================================== Bucket APIs ===================================================================
public async Task<ApiResponse<object>> 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<object>.ErrorResponse("You don't have permission", "You don't have permission", 403);
}
List<Bucket> bucketList;
List<Guid> bucketIds;
if (hasAdminPermission)
{
// Admin gets all buckets for the tenant
bucketList = await _context.Buckets
.Include(b => b.CreatedBy)
.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)
.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<object>.SuccessResponse(new List<AssignBucketVM>(), "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<AssignBucketVM>();
// 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<object>.SuccessResponse(bucketVMs, $"{bucketVMs.Count} buckets fetched successfully", 200);
}
public async Task<ApiResponse<object>> CreateBucketAsync(CreateBucketDto bucketDto, Guid tenantId, Employee loggedInEmployee)
{
if (bucketDto == null)
{
_logger.LogWarning("Employee with ID {LoggedInEmployeeId} sent empty payload", loggedInEmployee.Id);
return ApiResponse<object>.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<object>.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<object>.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<BucketVM>(createdBucket);
_logger.LogInfo("Employee {EmployeeId} successfully created bucket {BucketId}", loggedInEmployee.Id, newBucket.Id);
return ApiResponse<object>.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<object>.ErrorResponse("Internal server error", "Internal server error", 500);
}
}
public async Task<ApiResponse<object>> 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<object>.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<object>.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<object>.ErrorResponse("You don't have permission to access this bucket", "You don't have permission to access this bucket", 403);
}
// 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
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<AssignBucketVM>(bucket);
bucketVM.EmployeeIds = employeeBuckets.Where(eb => eb.BucketId == bucket.Id).Select(eb => eb.EmployeeId).ToList();
bucketVM.NumberOfContacts = contactBuckets.Count;
_logger.LogInfo("Employee ID {LoggedInEmployeeId} successfully updated bucket ID {BucketId}.", loggedInEmployee.Id, bucket.Id);
return ApiResponse<object>.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<object>.ErrorResponse("An unexpected error occurred. Please try again later.", "Internal server error", 500);
}
}
public async Task<ApiResponse<object>> AssignBucket(Guid bucketId, List<AssignBucketDto> assignBuckets)
{
Guid tenantId = _userHelper.GetTenantId();
var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
if (assignBuckets != null && bucketId != Guid.Empty)
{
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();
Bucket? bucket = await _context.Buckets.Include(b => b.CreatedBy).FirstOrDefaultAsync(b => b.Id == bucketId && b.TenantId == tenantId);
if (bucket == null)
{
_logger.LogWarning("Employee ID {LoggedInEmployeeId} attempted to update a bucket but not found in database.", LoggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Bucket not found", "Bucket not found", 404);
}
var employeeBuckets = await _context.EmployeeBucketMappings.Where(eb => eb.BucketId == bucketId).ToListAsync();
var bucketIds = employeeBuckets.Where(eb => eb.EmployeeId == LoggedInEmployee.Id).Select(eb => eb.BucketId).ToList();
var employeeBucketIds = employeeBuckets.Select(eb => eb.EmployeeId).ToList();
Bucket? accessableBucket = null;
if (permissionIds.Contains(PermissionsMaster.DirectoryAdmin))
{
accessableBucket = bucket;
}
else if (permissionIds.Contains(PermissionsMaster.DirectoryAdmin) && bucketIds.Contains(bucketId))
{
accessableBucket = bucket;
}
else if (permissionIds.Contains(PermissionsMaster.DirectoryUser))
{
if (bucket.CreatedByID == LoggedInEmployee.Id)
{
accessableBucket = bucket;
}
}
if (accessableBucket == null)
{
_logger.LogWarning("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id);
return ApiResponse<object>.ErrorResponse("You don't have permission to access this bucket", "You don't have permission to access this bucket", 403);
}
var employeeIds = await _context.Employees.Where(e => e.TenantId == tenantId && e.IsActive).Select(e => e.Id).ToListAsync();
int assignedEmployee = 0;
int removededEmployee = 0;
foreach (var assignBucket in assignBuckets)
{
if (employeeIds.Contains(assignBucket.EmployeeId))
{
if (assignBucket.IsActive && !employeeBucketIds.Contains(assignBucket.EmployeeId))
{
EmployeeBucketMapping employeeBucketMapping = new EmployeeBucketMapping
{
EmployeeId = assignBucket.EmployeeId,
BucketId = bucketId
};
_context.EmployeeBucketMappings.Add(employeeBucketMapping);
assignedEmployee += 1;
}
else if (!assignBucket.IsActive)
{
EmployeeBucketMapping? employeeBucketMapping = employeeBuckets.FirstOrDefault(eb => eb.BucketId == bucketId && eb.EmployeeId == assignBucket.EmployeeId);
if (employeeBucketMapping != null)
{
_context.EmployeeBucketMappings.Remove(employeeBucketMapping);
removededEmployee += 1;
}
}
}
}
_context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog
{
RefereanceId = bucketId,
UpdatedById = LoggedInEmployee.Id,
UpdateAt = DateTime.UtcNow
});
await _context.SaveChangesAsync();
AssignBucketVM bucketVM = bucket.ToAssignBucketVMFromBucket();
List<EmployeeBucketMapping> employeeBucketMappings = await _context.EmployeeBucketMappings.Where(eb => eb.BucketId == bucket.Id).ToListAsync();
List<ContactBucketMapping> contactBuckets = await _context.ContactBucketMappings.Where(eb => eb.BucketId == bucket.Id).ToListAsync();
employeeIds = employeeBucketMappings.Select(eb => eb.EmployeeId).ToList();
bucketVM.EmployeeIds = employeeIds;
bucketVM.NumberOfContacts = contactBuckets.Count;
if (assignedEmployee > 0)
{
_logger.LogInfo("Employee {EmployeeId} assigned bucket {BucketId} to {conut} number of employees", LoggedInEmployee.Id, bucketId, assignedEmployee);
}
if (removededEmployee > 0)
{
_logger.LogWarning("Employee {EmployeeId} removed {conut} number of employees from bucket {BucketId}", LoggedInEmployee.Id, removededEmployee, bucketId);
}
return ApiResponse<object>.SuccessResponse(bucketVM, "Details updated successfully", 200);
}
_logger.LogWarning("Employee with ID {LoggedInEmployeeId} sended empty payload", LoggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("User Send empty Payload", "User Send empty Payload", 400);
}
public async Task<ApiResponse<object>> DeleteBucket(Guid id)
{
Guid tenantId = _userHelper.GetTenantId();
var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
Bucket? bucket = await _context.Buckets.FirstOrDefaultAsync(n => n.Id == id && n.TenantId == tenantId);
if (bucket != null)
{
List<EmployeeBucketMapping>? employeeBuckets = await _context.EmployeeBucketMappings.Where(eb => eb.BucketId == id).ToListAsync();
List<ContactBucketMapping>? contactBuckets = await _context.ContactBucketMappings.Where(eb => eb.BucketId == id).ToListAsync();
if (contactBuckets.Any())
{
_logger.LogInfo("Employee {EmployeeId} attempted to deleted bucket {BucketId},but bucket have contacts in it.", LoggedInEmployee.Id, id);
return ApiResponse<object>.ErrorResponse("This bucket can not be deleted", "This bucket can not be deleted", 400);
}
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();
var bucketIds = employeeBuckets.Where(eb => eb.EmployeeId == LoggedInEmployee.Id).Select(eb => eb.BucketId).ToList();
Bucket? accessableBucket = null;
if (permissionIds.Contains(PermissionsMaster.DirectoryAdmin))
{
accessableBucket = bucket;
}
else if (permissionIds.Contains(PermissionsMaster.DirectoryAdmin) && bucketIds.Contains(id))
{
accessableBucket = bucket;
}
else if (permissionIds.Contains(PermissionsMaster.DirectoryUser))
{
if (bucket.CreatedByID == LoggedInEmployee.Id)
{
accessableBucket = bucket;
}
}
if (accessableBucket == null)
{
_logger.LogWarning("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id);
return ApiResponse<object>.ErrorResponse("You don't have permission to access this bucket", "You don't have permission to access this bucket", 403);
}
_context.EmployeeBucketMappings.RemoveRange(employeeBuckets);
_context.Buckets.Remove(bucket);
_context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog
{
RefereanceId = id,
UpdatedById = LoggedInEmployee.Id,
UpdateAt = DateTime.UtcNow
});
await _context.SaveChangesAsync();
_logger.LogInfo("Employee {EmployeeId} deleted bucket {BucketId} and related entries", LoggedInEmployee.Id, id);
return ApiResponse<object>.SuccessResponse(new { }, "Bucket deleted successfully", 200);
}
_logger.LogWarning("Employee {EmployeeId} tries to delete bucket {BucketId} but not found in database", LoggedInEmployee.Id, id);
return ApiResponse<object>.SuccessResponse(new { }, "Bucket deleted successfully", 200);
}
#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<PermissionServices>();
// 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? TryDeserializeFilter(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<ContactFilterDto>(filter, options);
}
catch (JsonException ex)
{
_logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeFilter), 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<string>(filter, options) ?? "";
if (!string.IsNullOrWhiteSpace(unescapedJsonString))
{
expenseFilter = JsonSerializer.Deserialize<ContactFilterDto>(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(TryDeserializeFilter), 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<ContactPhoneVM> ProcessContactPhones(CreateContactDto dto, Contact contact, ISet<string> existingPhones)
{
if (!(dto.ContactPhones?.Any() ?? false)) return new List<ContactPhoneVM>();
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<ContactPhone>(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<ContactPhoneVM>(p)).ToList();
}
private List<ContactEmailVM> ProcessContactEmails(CreateContactDto dto, Contact contact, ISet<string> existingEmails)
{
if (!(dto.ContactEmails?.Any() ?? false)) return new List<ContactEmailVM>();
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<ContactEmail>(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<ContactEmailVM>(e)).ToList();
}
private List<ContactTagVM> ProcessTags(CreateContactDto dto, Contact contact, IDictionary<string, ContactTagMaster> tenantTags)
{
if (!(dto.Tags?.Any() ?? false)) return new List<ContactTagVM>();
var tagVMs = new List<ContactTagVM>();
var newTagMappings = new List<ContactTagMapping>();
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<ContactTagVM>(tagMaster));
}
_context.ContactTagMappings.AddRange(newTagMappings);
_logger.LogInfo("Adding {Count} tag mappings for Contact {ContactId}.", newTagMappings.Count, contact.Id);
return tagVMs;
}
private async Task<List<ContactBucketMapping>> ProcessBucketMappingsAsync(CreateContactDto dto, Contact contact, Guid tenantId)
{
if (!(dto.BucketIds?.Any() ?? false)) return new List<ContactBucketMapping>();
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<List<ContactProjectMapping>> ProcessProjectMappingsAsync(CreateContactDto dto, Contact contact, Guid tenantId)
{
if (!(dto.ProjectIds?.Any() ?? false)) return new List<ContactProjectMapping>();
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
}
}