558 lines
27 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.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();
}
// GET: api/<DocumentController>
[HttpGet("list/{entityTypeId}")]
public async Task<IActionResult> Get(Guid entityTypeId, [FromQuery] Guid entityId, [FromQuery] string filter,
[FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 20)
{
using var scope = _serviceScope.CreateScope();
await using var _context = await _dbContextFactory.CreateDbContextAsync();
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
var isViewPermission = await _permission.HasPermission(PermissionsMaster.ViewDocument, loggedInEmployee.Id);
if (!isViewPermission)
{
return StatusCode(403, ApiResponse<object>.ErrorResponse("Access Denied.", "You do not have permission to upload document", 403));
}
if (ProjectEntity != entityTypeId && EmployeeEntity != entityTypeId)
{
return NotFound(ApiResponse<object>.ErrorResponse("Entity type not found", "Entity Type not found in database", 404));
}
if (ProjectEntity == entityTypeId)
{
var isHasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, entityId);
if (!isHasProjectPermission)
{
return StatusCode(403, ApiResponse<object>.ErrorResponse("Access Denied.", "You do not have permission to upload document to this project", 403));
}
}
else if (EmployeeEntity == entityTypeId)
{
var isEmployeeExists = await _context.Employees.AnyAsync(e => e.Id == entityId && e.TenantId == tenantId);
if (!isEmployeeExists)
{
return NotFound(ApiResponse<object>.ErrorResponse("Employee not found", "Employee not found in database", 404));
}
}
var documentQuery = _context.DocumentAttachments
.Include(da => da.DocumentType)
.ThenInclude(dt => dt!.DocumentCategory)
.Where(da => da.DocumentType != null &&
da.DocumentType.DocumentCategory != null &&
da.DocumentType.DocumentCategory.EntityTypeId == entityTypeId);
var documents = await documentQuery
.OrderByDescending(t => t.UploadedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return Ok(ApiResponse<object>.SuccessResponse(documents, "Document list fetched successfully", 200));
}
// GET api/<DocumentController>/5
[HttpGet("{id}")]
public async Task Get(int id)
{
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
//string preSignedUrl = _s3Service.GeneratePreSignedUrl(objectKey);
}
// POST api/<DocumentController>
[HttpPost]
public async Task<IActionResult> Post([FromBody] DocumentAttachmentDto model)
{
using var scope = _serviceScope.CreateScope();
await using var _context = await _dbContextFactory.CreateDbContextAsync();
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
var isUploadPermission = await _permission.HasPermission(PermissionsMaster.UploadDocument, loggedInEmployee.Id);
if (!isUploadPermission || loggedInEmployee.Id != model.EntityId)
{
return StatusCode(403, ApiResponse<object>.ErrorResponse("Access Denied.", "You do not have permission to upload document", 403));
}
var documentType = await _context.DocumentTypeMasters
.Include(dt => dt.DocumentCategory)
.FirstOrDefaultAsync(dt => dt.Id == model.DocumentTypeId && dt.TenantId == tenantId && dt.DocumentCategory != null);
if (documentType == null)
{
return NotFound(ApiResponse<object>.ErrorResponse("Document Type not found in database", "Document Type not found in database", 404));
}
if (documentType.IsMandatory && string.IsNullOrWhiteSpace(model.DocumentId))
{
return BadRequest(ApiResponse<object>.ErrorResponse("Document ID is missing", "User must provide the document ID fro this document", 400));
}
if (documentType.IsValidationRequired && !string.IsNullOrWhiteSpace(model.DocumentId))
{
bool isValid = Regex.IsMatch(model.DocumentId, documentType.RegexExpression ?? "");
if (!isValid)
{
return BadRequest(ApiResponse<object>.ErrorResponse("Invaid Document ID", "Provided document ID is not valid", 400));
}
}
var employeeExistTask = Task.Run(async () =>
{
if (documentType.DocumentCategory!.EntityTypeId == EmployeeEntity)
{
await using var _dbContext = await _dbContextFactory.CreateDbContextAsync();
return await _dbContext.Employees.AnyAsync(e => e.Id == model.EntityId && e.TenantId == tenantId);
}
return false;
});
var projectExistTask = Task.Run(async () =>
{
if (documentType.DocumentCategory!.EntityTypeId == ProjectEntity)
{
await using var _dbContext = await _dbContextFactory.CreateDbContextAsync();
return await _dbContext.Projects.AnyAsync(p => p.Id == model.EntityId && p.TenantId == tenantId);
}
return false;
});
await Task.WhenAll(employeeExistTask, projectExistTask);
var employeeExist = employeeExistTask.Result;
var projectExist = projectExistTask.Result;
if (documentType.DocumentCategory!.EntityTypeId == EmployeeEntity && !employeeExist)
{
return NotFound(ApiResponse<object>.ErrorResponse("Employee Not Found", "Employee not found in database", 404));
}
if (documentType.DocumentCategory.EntityTypeId == ProjectEntity && !projectExist)
{
return NotFound(ApiResponse<object>.ErrorResponse("Project Not Found", "Project not found in database", 404));
}
var documentDetails = _mapper.Map<DocumentAttachment>(model);
documentDetails.UploadedAt = DateTime.UtcNow;
documentDetails.UploadedById = loggedInEmployee.Id;
documentDetails.TenantId = tenantId;
var _s3Service = scope.ServiceProvider.GetRequiredService<S3UploadService>();
var batchId = Guid.NewGuid();
List<Document> documents = new List<Document>();
if (model.Attachment.FileSize > documentType.MaxSizeAllowedInMB)
{
return BadRequest(ApiResponse<object>.ErrorResponse("File size limit exceeded",
$"File size exceeded. Maximum allowed is {documentType.MaxSizeAllowedInMB} MB.", 400));
}
string base64 = model.Attachment.Base64Data?.Split(',').LastOrDefault() ?? "";
if (string.IsNullOrWhiteSpace(base64))
return BadRequest(ApiResponse<object>.ErrorResponse("Base64 data is missing", "Image data missing", 400));
var fileType = _s3Service.GetContentTypeFromBase64(base64);
var validContentType = documentType.AllowedContentType.Split(',').ToList();
if (!validContentType.Contains(fileType))
{
return BadRequest(ApiResponse<object>.ErrorResponse("Unsupported file type.",
$"Unsupported file type. {fileType}", 400));
}
string? fileName = null;
string? objectKey = null;
if (documentType.DocumentCategory!.EntityTypeId == EmployeeEntity)
{
fileName = _s3Service.GenerateFileName(fileType, tenantId, "EmployeeDocuments");
objectKey = $"tenant-{tenantId}/Employee/{model.EntityId}/EmployeeDocuments/{fileName}";
}
else if (documentType.DocumentCategory!.EntityTypeId == ProjectEntity)
{
fileName = _s3Service.GenerateFileName(fileType, tenantId, "ProjectDocuments");
objectKey = $"tenant-{tenantId}/project-{model.EntityId}/ProjectDocuments/{fileName}";
}
if (!string.IsNullOrWhiteSpace(objectKey) && !string.IsNullOrWhiteSpace(fileName))
{
_ = Task.Run(async () =>
{
var _s3UploadService = scope.ServiceProvider.GetRequiredService<S3UploadService>();
var _threadLogger = scope.ServiceProvider.GetRequiredService<ILoggingService>();
await _s3UploadService.UploadFileAsync(base64, fileType, objectKey!);
_threadLogger.LogInfo("File stored successfully {ObjectKey}", objectKey!);
});
Document document = new Document
{
BatchId = batchId,
UploadedById = loggedInEmployee.Id,
FileName = model.Attachment.FileName ?? fileName,
ContentType = model.Attachment.ContentType,
S3Key = objectKey,
FileSize = model.Attachment.FileSize,
UploadedAt = DateTime.UtcNow,
TenantId = tenantId
};
_context.Documents.Add(document);
documentDetails.DocumentDataId = document.Id;
}
_context.DocumentAttachments.Add(documentDetails);
if (model.Tags != null && model.Tags.Any())
{
var names = model.Tags.Select(t => t.Name).ToList();
var existingTags = await _context.DocumentTagMasters.Where(t => names.Contains(t.Name) && t.TenantId == tenantId).ToListAsync();
List<AttachmentTagMapping> attachmentTagMappings = new List<AttachmentTagMapping>();
foreach (var tag in model.Tags)
{
var existingTag = existingTags.FirstOrDefault(t => t.Name == tag.Name);
if (existingTag != null && tag.IsActive)
{
AttachmentTagMapping attachmentTagMapping = new AttachmentTagMapping
{
DocumentTagId = existingTag.Id,
AttachmentId = documentDetails.Id,
TenantId = tenantId
};
attachmentTagMappings.Add(attachmentTagMapping);
}
else if (existingTag == null && tag.IsActive)
{
var newTag = new DocumentTagMaster
{
Id = Guid.NewGuid(),
Name = tag.Name,
Description = tag.Name,
TenantId = tenantId
};
_context.DocumentTagMasters.Add(newTag);
AttachmentTagMapping attachmentTagMapping = new AttachmentTagMapping
{
DocumentTagId = newTag.Id,
AttachmentId = documentDetails.Id,
TenantId = tenantId
};
attachmentTagMappings.Add(attachmentTagMapping);
}
}
_context.AttachmentTagMappings.AddRange(attachmentTagMappings);
}
await _context.SaveChangesAsync();
var response = _mapper.Map<DocumentListVM>(documentDetails);
return Ok(ApiResponse<object>.SuccessResponse(response, "Document Added Successfully", 200));
}
/// <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))
{
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 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);
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;
}
}
}