3311 lines
164 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ApplicationDbContext> _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<ApplicationDbContext> 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<UtilityMongoDBHelper>();
}
#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 = 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<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();
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<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)
.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<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)
.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<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)
.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<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);
}
}
public async Task<ApiResponse<object>> 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<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 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<object>.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<object>.ErrorResponse("An internal server error occurred.", ExceptionMapper(ex), 500);
}
}
/// <summary>
/// Fetches filter options (Buckets and Contact Categories) based on user permissions
/// for a given tenant.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="loggedInEmployee">The employee making the request.</param>
/// <returns>ApiResponse with Buckets and Contact Categories.</returns>
public async Task<ApiResponse<object>> 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<Guid>? 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<object>.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<object>.ErrorResponse("An error occurred while fetching filters", 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)
{
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<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);
if (string.IsNullOrWhiteSpace(createContact.Description))
{
contact.Description = string.Empty;
}
if (string.IsNullOrWhiteSpace(createContact.Designation))
{
contact.Designation = string.Empty;
}
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();
var _firebase = scope.ServiceProvider.GetRequiredService<IFirebaseService>();
_ = 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<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)
{
using var scope = _serviceScopeFactory.CreateScope();
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 = _mapper.Map<Contact>(updateContact);
if (string.IsNullOrWhiteSpace(updateContact.Designation))
{
updatedContact.Designation = string.Empty;
}
if (string.IsNullOrWhiteSpace(updateContact.Description))
{
updatedContact.Description = string.Empty;
}
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<UpdateLogsObject> phoneUpdateLogs = new List<UpdateLogsObject>();
List<UpdateLogsObject> emailUpdateLogs = new List<UpdateLogsObject>();
// ---------------------- 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<ContactPhone>(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<ContactEmail>(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<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
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<IFirebaseService>();
_ = 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<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);
}
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<object>.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<object>.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<IFirebaseService>();
_ = 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<object>.SuccessResponse(new { }, active ? "Contact is activated successfully" : "Contact is deleted successfully", 200);
}
#endregion
#endregion
#region =================================================================== Contact Notes APIs ===================================================================
public async Task<ApiResponse<object>> 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<object>.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<object>.ErrorResponse("Access Denied", "You don't have access to view notes.", 403);
}
// Build base query
IQueryable<ContactNote> 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<Guid>? 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<object>.SuccessResponse(new
{
CurrentPage = pageNumber,
TotalPages = 0,
Data = new List<ContactNoteVM>()
}, "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<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
{
// 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<ContactNote> 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<List<ContactNoteVM>>(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<object>.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<object>.ErrorResponse("Internal Server Error", "An error occurred while fetching notes. Please try again later.", 500);
}
}
/// <summary>
/// Fetches all notes associated with a given contact, subject to permission checks and contact-bucket mappings.
/// </summary>
/// <param name="id">The contact ID.</param>
/// <param name="tenantId">The tenant ID of the current user.</param>
/// <param name="active">Whether to filter for active notes only.</param>
/// <param name="loggedInEmployee">The currently logged in employee object.</param>
/// <returns>Returns a list of contact notes wrapped in ApiResponse.</returns>
public async Task<ApiResponse<object>> 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<object>.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<object>.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<object>.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<ContactNote> 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<DirectoryUpdateLog> 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<ContactNoteVM> noteVMs = _mapper.Map<List<ContactNoteVM>>(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<object>.SuccessResponse(
noteVMs,
$"{noteVMs.Count} contact-notes record(s) fetched successfully",
StatusCodes.Status200OK);
}
/// <summary>
/// Fetches filter objects (CreatedBy employees and Organizations) for Contact Notes
/// accessible by the logged-in employee, based on permissions.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="loggedInEmployee">The employee requesting filters.</param>
/// <returns>ApiResponse containing CreatedBy and Organizations filter options.</returns>
public async Task<ApiResponse<object>> 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<Guid>? 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 its 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<object>.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<object>.ErrorResponse("An error occurred while fetching filters", 500);
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="noteDto">The DTO containing the note details.</param>
/// <param name="tenantId">The tenant identifier to which the contact belongs.</param>
/// <param name="loggedInEmployee">The logged-in employee attempting the action.</param>
/// <returns>ApiResponse containing the created note details or error information.</returns>
public async Task<ApiResponse<object>> 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<object>.ErrorResponse("Empty payload.", "Request body cannot be null.", 400);
}
try
{
var (hasAdminPermission, hasManagerPermission, hasUserPermission) = await CheckPermissionsAsync(loggedInEmployee.Id);
var bucketIds = await _context.ContactBucketMappings.AsNoTracking().Where(cb => cb.ContactId == noteDto.ContactId).Select(cb => cb.BucketId).ToListAsync();
var hasContactAccess = await _context.EmployeeBucketMappings.AsNoTracking().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<object>.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<object>.ErrorResponse("Contact not found.", "The specified contact does not exist.", 404);
}
// Map DTO -> Entity using AutoMapper
ContactNote note = _mapper.Map<ContactNote>(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<ContactNoteVM>(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<IFirebaseService>();
_ = 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<object>.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<object>.ErrorResponse("An unexpected error occurred.", ex.Message, 500);
}
}
/// <summary>
/// Updates an existing contact note and logs changes
/// both in relational DB (SQL) and update logs (possibly MongoDB).
/// </summary>
/// <param name="id">The note ID that needs to be updated.</param>
/// <param name="noteDto">DTO with updated note data.</param>
/// <returns>Standardized ApiResponse with updated note or error details.</returns>
public async Task<ApiResponse<object>> 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<object>.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<object>.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<object>.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<object>.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<ContactNoteVM>(contactNote);
noteVM.UpdatedAt = contactNote.UpdatedAt;
noteVM.UpdatedBy = _mapper.Map<BasicEmployeeVM>(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<IFirebaseService>();
_ = 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<object>.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<object>.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<object>.ErrorResponse("Failed to update note", "An unexpected error occurred while saving note.", 500);
}
}
/// <summary>
/// Soft deletes (or restores) a contact note by updating its active status.
/// Also pushes an update log entry in SQL and Mongo (audit trail).
/// </summary>
/// <param name="id">ID of the contact note to delete/restore.</param>
/// <param name="active">Flag to set note as active or inactive.</param>
/// <param name="tenantId">Tenant identifier of the logged-in user.</param>
/// <param name="loggedInEmployee">The employee performing this action.</param>
/// <returns>ApiResponse with success or error details.</returns>
public async Task<ApiResponse<object>> 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<object>.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<object>.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<object>.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<IFirebaseService>();
_ = 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<object>.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<object>.ErrorResponse("Failed to delete note",
"An unexpected error occurred while deleting/restoring the note.",
500);
}
}
#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)
.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<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);
using var scope = _serviceScopeFactory.CreateScope();
var _firebase = scope.ServiceProvider.GetRequiredService<IFirebaseService>();
_ = 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<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)
.AsNoTracking()
.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);
}
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<AssignBucketVM>(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<IFirebaseService>();
_ = 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<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>> AssignBucketAsync(Guid bucketId, List<AssignBucketDto> 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<object>.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<object>.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<object>.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<Guid> assignedEmployeeIds = new List<Guid>();
List<Guid> removedEmployeeIds = new List<Guid>();
// 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<object>.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<AssignBucketVM>(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<IFirebaseService>();
_ = 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<object>.SuccessResponse(bucketVm, "Bucket details updated successfully", 200);
}
public async Task<ApiResponse<object>> 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<object>.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<object>.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<object>.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<IFirebaseService>();
_ = 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<object>.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<object>.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)
{
// Run all permission checks in parallel.
var hasAdminTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.DirectoryAdmin, employeeId);
});
var hasManagerTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.DirectoryManager, employeeId);
});
var hasUserTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await 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<ContactFilterDto>(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<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(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<ContactNoteFilter>(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<string>(filter, options) ?? "";
if (!string.IsNullOrWhiteSpace(unescapedJsonString))
{
expenseFilter = JsonSerializer.Deserialize<ContactNoteFilter>(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<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
}
}