507 lines
24 KiB
C#
507 lines
24 KiB
C#
using AutoMapper;
|
|
using Marco.Pms.DataAccess.Data;
|
|
using Marco.Pms.Model.DocumentManager;
|
|
using Marco.Pms.Model.Dtos.DocumentManager;
|
|
using Marco.Pms.Model.Entitlements;
|
|
using Marco.Pms.Model.Filters;
|
|
using Marco.Pms.Model.Utilities;
|
|
using Marco.Pms.Model.ViewModels.Activities;
|
|
using Marco.Pms.Model.ViewModels.DocumentManager;
|
|
using Marco.Pms.Services.Service;
|
|
using MarcoBMS.Services.Helpers;
|
|
using MarcoBMS.Services.Service;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.CodeAnalysis;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using Document = Marco.Pms.Model.DocumentManager.Document;
|
|
|
|
namespace Marco.Pms.Services.Controllers
|
|
{
|
|
[Route("api/[controller]")]
|
|
[ApiController]
|
|
[Authorize]
|
|
public class DocumentController : ControllerBase
|
|
{
|
|
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
|
|
private readonly IServiceScopeFactory _serviceScope;
|
|
private readonly UserHelper _userHelper;
|
|
private readonly ILoggingService _logger;
|
|
private readonly IMapper _mapper;
|
|
private readonly Guid tenantId;
|
|
|
|
private static readonly Guid ProjectEntity = Guid.Parse("c8fe7115-aa27-43bc-99f4-7b05fabe436e");
|
|
private static readonly Guid EmployeeEntity = Guid.Parse("dbb9555a-7a0c-40f2-a9ed-f0463f1ceed7");
|
|
|
|
public DocumentController(IDbContextFactory<ApplicationDbContext> dbContextFactory,
|
|
IServiceScopeFactory serviceScope,
|
|
UserHelper userHelper,
|
|
ILoggingService logger,
|
|
IMapper mapper)
|
|
{
|
|
_dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory));
|
|
_serviceScope = serviceScope ?? throw new ArgumentNullException(nameof(serviceScope));
|
|
_userHelper = userHelper ?? throw new ArgumentNullException(nameof(userHelper));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
_mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
|
|
tenantId = userHelper.GetTenantId();
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Fetch documents for a given entity (Project/Employee) with filtering, searching, and pagination.
|
|
/// </summary>
|
|
[HttpGet("list/{entityTypeId}/entity/{entityId}")]
|
|
public async Task<IActionResult> GetDocumentList(Guid entityTypeId, Guid entityId, [FromQuery] string? filter, [FromQuery] string? searchString,
|
|
[FromQuery] bool isActive = true, [FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 20)
|
|
{
|
|
using var scope = _serviceScope.CreateScope();
|
|
await using var _context = await _dbContextFactory.CreateDbContextAsync();
|
|
|
|
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
|
|
try
|
|
{
|
|
_logger.LogInfo("Fetching documents for EntityTypeId: {EntityTypeId}, EntityId: {EntityId}", entityTypeId, entityId);
|
|
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
|
|
// Check global permission
|
|
var hasViewPermission = await _permission.HasPermission(PermissionsMaster.ViewDocument, loggedInEmployee.Id);
|
|
if (!hasViewPermission && loggedInEmployee.Id != entityId)
|
|
{
|
|
_logger.LogWarning("Access Denied for Employee {EmployeeId} on EntityId {EntityId}", loggedInEmployee.Id, entityId);
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse("Access Denied.", "You do not have permission to view documents", 403));
|
|
}
|
|
|
|
// Validate entity type
|
|
if (ProjectEntity != entityTypeId && EmployeeEntity != entityTypeId)
|
|
{
|
|
_logger.LogWarning("Invalid EntityTypeId: {EntityTypeId}", entityTypeId);
|
|
return NotFound(ApiResponse<object>.ErrorResponse("Entity type not found", "Entity Type not found in database", 404));
|
|
}
|
|
|
|
// Project permission check
|
|
if (ProjectEntity == entityTypeId)
|
|
{
|
|
var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, entityId);
|
|
if (!hasProjectPermission)
|
|
{
|
|
_logger.LogWarning("Employee {EmployeeId} does not have project access for ProjectId {ProjectId}", loggedInEmployee.Id, entityId);
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse("Access Denied.", "You do not have permission to access project documents", 403));
|
|
}
|
|
}
|
|
// Employee validation
|
|
else if (EmployeeEntity == entityTypeId)
|
|
{
|
|
var isEmployeeExists = await _context.Employees
|
|
.AnyAsync(e => e.Id == entityId && e.TenantId == tenantId);
|
|
|
|
if (!isEmployeeExists)
|
|
{
|
|
_logger.LogWarning("Employee {EmployeeId} not found for Tenant {TenantId}", entityId, tenantId);
|
|
return NotFound(ApiResponse<object>.ErrorResponse("Employee not found", "Employee not found in database", 404));
|
|
}
|
|
}
|
|
|
|
// Base Query (with includes to avoid lazy loading)
|
|
IQueryable<DocumentAttachment> documentQuery = _context.DocumentAttachments
|
|
.AsNoTracking() // Optimization: Read-only query
|
|
.Include(da => da.UploadedBy)
|
|
.ThenInclude(e => e!.JobRole)
|
|
.Include(da => da.DocumentType!)
|
|
.ThenInclude(dt => dt.DocumentCategory)
|
|
.Where(da => da.DocumentType != null &&
|
|
da.DocumentType.DocumentCategory != null &&
|
|
da.DocumentType.DocumentCategory.EntityTypeId == entityTypeId &&
|
|
da.IsActive == isActive &&
|
|
da.TenantId == tenantId);
|
|
|
|
// Apply filter if provided
|
|
var documentFilter = TryDeserializeFilter(filter);
|
|
if (documentFilter != null)
|
|
{
|
|
_logger.LogInfo("Applying document filters for EntityId {EntityId}", entityId);
|
|
|
|
if (documentFilter.IsVerified != null)
|
|
documentQuery = documentQuery.Where(da => da.IsVerified == documentFilter.IsVerified);
|
|
|
|
if (documentFilter.DocumentCategoryIds?.Any() ?? false)
|
|
documentQuery = documentQuery.Where(da => documentFilter.DocumentCategoryIds.Contains(da.DocumentType!.DocumentCategoryId));
|
|
|
|
if (documentFilter.DocumentTypeIds?.Any() ?? false)
|
|
documentQuery = documentQuery.Where(da => documentFilter.DocumentTypeIds.Contains(da.DocumentTypeId));
|
|
|
|
if (documentFilter.DocumentTagIds?.Any() ?? false)
|
|
{
|
|
var filteredIds = await _context.AttachmentTagMappings
|
|
.AsNoTracking()
|
|
.Where(at => at.DocumentTag != null && documentFilter.DocumentTagIds.Contains(at.DocumentTag.Id) && at.TenantId == tenantId)
|
|
.Select(at => at.AttachmentId)
|
|
.ToListAsync();
|
|
|
|
documentQuery = documentQuery.Where(da => filteredIds.Contains(da.Id));
|
|
}
|
|
|
|
if (documentFilter.UploadedByIds?.Any() ?? false)
|
|
documentQuery = documentQuery.Where(da => documentFilter.UploadedByIds.Contains(da.UploadedById));
|
|
|
|
// Date Range Filtering (Uploaded vs Verified date)
|
|
if (documentFilter.StartDate != null && documentFilter.EndDate != null)
|
|
{
|
|
if (documentFilter.IsUploadedAt)
|
|
{
|
|
documentQuery = documentQuery.Where(da =>
|
|
da.UpdatedAt.HasValue &&
|
|
da.UpdatedAt.Value.Date >= documentFilter.StartDate.Value.Date &&
|
|
da.UpdatedAt.Value.Date <= documentFilter.EndDate.Value.Date);
|
|
}
|
|
else
|
|
{
|
|
documentQuery = documentQuery.Where(da =>
|
|
da.VerifiedAt.HasValue &&
|
|
da.VerifiedAt.Value.Date >= documentFilter.StartDate.Value.Date &&
|
|
da.VerifiedAt.Value.Date <= documentFilter.EndDate.Value.Date);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
documentQuery = documentQuery.Where(da => da.IsCurrentVersion);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Default: only latest version
|
|
documentQuery = documentQuery.Where(da => da.IsCurrentVersion);
|
|
}
|
|
|
|
// Apply search filter
|
|
if (!string.IsNullOrWhiteSpace(searchString))
|
|
{
|
|
documentQuery = documentQuery.Where(da =>
|
|
da.Name.Contains(searchString) ||
|
|
(da.DocumentId != null && da.DocumentId.Contains(searchString))
|
|
);
|
|
}
|
|
|
|
// Apply pagination & ordering
|
|
var documents = await documentQuery
|
|
.OrderByDescending(t => t.UploadedAt)
|
|
.Skip((pageNumber - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.ToListAsync();
|
|
|
|
var documentIds = documents.Select(da => da.Id).ToList();
|
|
|
|
// Get versions for the selected documents
|
|
var versions = await _context.AttachmentVersionMappings
|
|
.AsNoTracking()
|
|
.Where(av => documentIds.Contains(av.ChildAttachmentId) && av.TenantId == tenantId)
|
|
.ToListAsync();
|
|
|
|
// Map to ViewModel
|
|
var response = documents.Select(doc =>
|
|
{
|
|
var version = versions.FirstOrDefault(v => v.ChildAttachmentId == doc.Id);
|
|
var vm = _mapper.Map<DocumentListVM>(doc);
|
|
vm.ParentAttachmentId = version?.ParentAttachmentId;
|
|
vm.Version = version?.Version ?? 1;
|
|
return vm;
|
|
}).ToList();
|
|
|
|
_logger.LogInfo("Fetched {Count} documents for EntityId {EntityId}", response.Count, entityId);
|
|
|
|
return Ok(ApiResponse<object>.SuccessResponse(response, "Document list fetched successfully", 200));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error fetching documents for EntityId {EntityId}", entityId);
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse("Internal Server Error", ex.Message, 500));
|
|
}
|
|
}
|
|
|
|
|
|
// GET api/<DocumentController>/5
|
|
[HttpGet("{id}")]
|
|
public async Task Get(int id)
|
|
{
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
|
|
//string preSignedUrl = _s3Service.GeneratePreSignedUrl(objectKey);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Uploads a document attachment for an Employee or Project.
|
|
/// Validates permissions, document type, entity existence, tags, and uploads to S3.
|
|
/// </summary>
|
|
[HttpPost("upload")]
|
|
public async Task<IActionResult> UploadDocument([FromBody] DocumentAttachmentDto model)
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
using var scope = _serviceScope.CreateScope();
|
|
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggingService>();
|
|
logger.LogInfo("Document upload initiated for EntityId: {EntityId}, DocumentTypeId: {DocumentTypeId}", model.EntityId, model.DocumentTypeId);
|
|
|
|
try
|
|
{
|
|
// Get logged in user
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
|
|
// Permission check
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
var hasUploadPermission = await permissionService.HasPermission(PermissionsMaster.UploadDocument, loggedInEmployee.Id);
|
|
|
|
if (!hasUploadPermission && loggedInEmployee.Id != model.EntityId)
|
|
{
|
|
logger.LogWarning("Access Denied. User {UserId} tried to upload document for {EntityId}", loggedInEmployee.Id, model.EntityId);
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse("Access Denied.", "You do not have permission to upload this document", 403));
|
|
}
|
|
|
|
// Validate Document Type
|
|
var documentType = await dbContext.DocumentTypeMasters
|
|
.Include(dt => dt.DocumentCategory)
|
|
.FirstOrDefaultAsync(dt => dt.Id == model.DocumentTypeId && dt.TenantId == tenantId && dt.DocumentCategory != null);
|
|
|
|
if (documentType == null)
|
|
{
|
|
logger.LogWarning("DocumentTypeId {DocumentTypeId} not found for Tenant {TenantId}", model.DocumentTypeId, tenantId);
|
|
return NotFound(ApiResponse<object>.ErrorResponse("Document Type not found", "Document Type not found in database", 404));
|
|
}
|
|
|
|
// Document ID validation
|
|
if (documentType.IsMandatory && string.IsNullOrWhiteSpace(model.DocumentId))
|
|
{
|
|
logger.LogWarning("Mandatory DocumentId missing for DocumentTypeId: {DocumentTypeId}", documentType.Id);
|
|
return BadRequest(ApiResponse<object>.ErrorResponse("Document ID missing", "User must provide the document ID for this document", 400));
|
|
}
|
|
|
|
if (documentType.IsValidationRequired && !string.IsNullOrWhiteSpace(model.DocumentId) && !string.IsNullOrWhiteSpace(documentType.RegexExpression))
|
|
{
|
|
if (!Regex.IsMatch(model.DocumentId, documentType.RegexExpression))
|
|
{
|
|
logger.LogWarning("Invalid DocumentId format for DocumentTypeId: {DocumentTypeId}, Provided: {DocumentId}", documentType.Id, model.DocumentId);
|
|
return BadRequest(ApiResponse<object>.ErrorResponse("Invalid Document ID", "Provided document ID is not valid", 400));
|
|
}
|
|
}
|
|
|
|
// Verify if Employee/Project exists
|
|
var entityType = documentType.DocumentCategory!.EntityTypeId;
|
|
|
|
bool entityExists = false;
|
|
if (entityType.Equals(EmployeeEntity))
|
|
{
|
|
entityExists = await dbContext.Employees.AnyAsync(e => e.Id == model.EntityId && e.TenantId == tenantId);
|
|
}
|
|
else if (entityType.Equals(ProjectEntity))
|
|
{
|
|
entityExists = await dbContext.Projects.AnyAsync(p => p.Id == model.EntityId && p.TenantId == tenantId);
|
|
}
|
|
|
|
if (!entityExists)
|
|
{
|
|
logger.LogWarning("Entity not found. EntityType: {EntityType}, EntityId: {EntityId}", entityType, model.EntityId);
|
|
return NotFound(ApiResponse<object>.ErrorResponse($"{(entityType == EmployeeEntity ? "Employee" : "Project")} Not Found", "Entity not found in database", 404));
|
|
}
|
|
|
|
// Map DTO to DB entity
|
|
var attachment = _mapper.Map<DocumentAttachment>(model);
|
|
attachment.UploadedAt = DateTime.UtcNow;
|
|
attachment.UploadedById = loggedInEmployee.Id;
|
|
attachment.TenantId = tenantId;
|
|
|
|
// Validate Attachment
|
|
if (model.Attachment.FileSize > documentType.MaxSizeAllowedInMB)
|
|
{
|
|
logger.LogWarning("File size {FileSize} exceeded max allowed {MaxSize}MB", model.Attachment.FileSize, documentType.MaxSizeAllowedInMB);
|
|
return BadRequest(ApiResponse<object>.ErrorResponse("File size limit exceeded", $"Max allowed {documentType.MaxSizeAllowedInMB} MB.", 400));
|
|
}
|
|
|
|
string base64 = model.Attachment.Base64Data?.Split(',').LastOrDefault() ?? "";
|
|
if (string.IsNullOrWhiteSpace(base64))
|
|
{
|
|
logger.LogWarning("Missing Base64 data for attachment.");
|
|
return BadRequest(ApiResponse<object>.ErrorResponse("Base64 data missing", "File data required", 400));
|
|
}
|
|
|
|
var s3Service = scope.ServiceProvider.GetRequiredService<S3UploadService>();
|
|
var fileType = s3Service.GetContentTypeFromBase64(base64);
|
|
|
|
var validContentTypes = documentType.AllowedContentType.Split(',').ToList();
|
|
if (!validContentTypes.Contains(fileType))
|
|
{
|
|
logger.LogWarning("Unsupported file type {FileType} for DocumentType {DocumentTypeId}", fileType, documentType.Id);
|
|
return BadRequest(ApiResponse<object>.ErrorResponse("Unsupported file type", $"Unsupported file type: {fileType}", 400));
|
|
}
|
|
|
|
// Generate S3 ObjectKey/FileName
|
|
string folderName = entityType == EmployeeEntity ? "EmployeeDocuments" : "ProjectDocuments";
|
|
string fileName = s3Service.GenerateFileName(fileType, tenantId, folderName);
|
|
string objectKey = entityType == EmployeeEntity
|
|
? $"tenant-{tenantId}/Employee/{model.EntityId}/{folderName}/{fileName}"
|
|
: $"tenant-{tenantId}/project-{model.EntityId}/{folderName}/{fileName}";
|
|
|
|
// Fire-and-forget upload
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
await s3Service.UploadFileAsync(base64, fileType, objectKey);
|
|
logger.LogInfo("File uploaded successfully to S3: {ObjectKey}", objectKey);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "S3 upload failed for {ObjectKey}.", objectKey);
|
|
}
|
|
});
|
|
|
|
// Create Document record
|
|
var document = new Document
|
|
{
|
|
BatchId = Guid.NewGuid(),
|
|
UploadedById = loggedInEmployee.Id,
|
|
FileName = model.Attachment.FileName ?? fileName,
|
|
ContentType = model.Attachment.ContentType ?? fileType,
|
|
S3Key = objectKey,
|
|
FileSize = model.Attachment.FileSize,
|
|
UploadedAt = DateTime.UtcNow,
|
|
TenantId = tenantId
|
|
};
|
|
|
|
dbContext.Documents.Add(document);
|
|
|
|
attachment.DocumentDataId = document.Id;
|
|
|
|
dbContext.DocumentAttachments.Add(attachment);
|
|
|
|
//Process Versioning
|
|
|
|
var versionMapping = new AttachmentVersionMapping
|
|
{
|
|
ParentAttachmentId = attachment.Id,
|
|
ChildAttachmentId = attachment.Id,
|
|
Version = 1,
|
|
TenantId = tenantId
|
|
};
|
|
dbContext.AttachmentVersionMappings.Add(versionMapping);
|
|
|
|
// Process Tags
|
|
if (model.Tags?.Any() == true)
|
|
{
|
|
var names = model.Tags.Select(t => t.Name).ToList();
|
|
var existingTags = await dbContext.DocumentTagMasters
|
|
.Where(t => names.Contains(t.Name) && t.TenantId == tenantId)
|
|
.ToListAsync();
|
|
|
|
var attachmentTagMappings = new List<AttachmentTagMapping>();
|
|
|
|
foreach (var tag in model.Tags.Where(t => t.IsActive))
|
|
{
|
|
var existingTag = existingTags.FirstOrDefault(t => t.Name == tag.Name);
|
|
var tagEntity = existingTag ?? new DocumentTagMaster
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = tag.Name,
|
|
Description = tag.Name,
|
|
TenantId = tenantId
|
|
};
|
|
|
|
if (existingTag == null)
|
|
{
|
|
dbContext.DocumentTagMasters.Add(tagEntity);
|
|
}
|
|
|
|
attachmentTagMappings.Add(new AttachmentTagMapping
|
|
{
|
|
DocumentTagId = tagEntity.Id,
|
|
AttachmentId = attachment.Id,
|
|
TenantId = tenantId
|
|
});
|
|
}
|
|
|
|
dbContext.AttachmentTagMappings.AddRange(attachmentTagMappings);
|
|
}
|
|
|
|
|
|
await dbContext.SaveChangesAsync();
|
|
|
|
logger.LogInfo("Document uploaded successfully. AttachmentId: {AttachmentId}, DocumentId: {DocumentId}", attachment.Id, document.Id);
|
|
|
|
var response = _mapper.Map<DocumentListVM>(attachment);
|
|
response.UploadedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
|
|
response.ParentAttachmentId = versionMapping.ParentAttachmentId;
|
|
response.Version = versionMapping.Version;
|
|
|
|
return Ok(ApiResponse<object>.SuccessResponse(response, "Document added successfully", 200));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Unexpected error during document upload.");
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse("Internal Server Error", "An error occurred while uploading the document", 500));
|
|
}
|
|
}
|
|
|
|
|
|
// PUT api/<DocumentController>/5
|
|
[HttpPut("{id}")]
|
|
public async Task Put(int id, [FromBody] string value)
|
|
{
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
}
|
|
|
|
// DELETE api/<DocumentController>/5
|
|
[HttpDelete("{id}")]
|
|
public async Task Delete(int id)
|
|
{
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deserializes the filter string, handling multiple potential formats (e.g., direct JSON vs. escaped JSON string).
|
|
/// </summary>
|
|
/// <param name="filter">The JSON filter string from the request.</param>
|
|
/// <returns>An <see cref="TenantFilter"/> object or null if deserialization fails.</returns>
|
|
|
|
private DocumentFilter? TryDeserializeFilter(string? filter)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(filter))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
|
DocumentFilter? documentFilter = null;
|
|
|
|
try
|
|
{
|
|
// First, try to deserialize directly. This is the expected case (e.g., from a web client).
|
|
documentFilter = JsonSerializer.Deserialize<DocumentFilter>(filter, options);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
_logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeFilter), filter);
|
|
|
|
// If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients).
|
|
try
|
|
{
|
|
// Unescape the string first, then deserialize the result.
|
|
string unescapedJsonString = JsonSerializer.Deserialize<string>(filter, options) ?? "";
|
|
if (!string.IsNullOrWhiteSpace(unescapedJsonString))
|
|
{
|
|
documentFilter = JsonSerializer.Deserialize<DocumentFilter>(unescapedJsonString, options);
|
|
}
|
|
}
|
|
catch (JsonException ex1)
|
|
{
|
|
// If both attempts fail, log the final error and return null.
|
|
_logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeFilter), filter);
|
|
return null;
|
|
}
|
|
}
|
|
return documentFilter;
|
|
}
|
|
}
|
|
}
|