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 _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 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/ [HttpGet("list/{entityTypeId}")] public async Task 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(); var isViewPermission = await _permission.HasPermission(PermissionsMaster.ViewDocument, loggedInEmployee.Id); if (!isViewPermission) { return StatusCode(403, ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to upload document", 403)); } if (ProjectEntity != entityTypeId && EmployeeEntity != entityTypeId) { return NotFound(ApiResponse.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.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.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.SuccessResponse(documents, "Document list fetched successfully", 200)); } // GET api//5 [HttpGet("{id}")] public async Task Get(int id) { var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); //string preSignedUrl = _s3Service.GeneratePreSignedUrl(objectKey); } // POST api/ [HttpPost] public async Task 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(); var isUploadPermission = await _permission.HasPermission(PermissionsMaster.UploadDocument, loggedInEmployee.Id); if (!isUploadPermission || loggedInEmployee.Id != model.EntityId) { return StatusCode(403, ApiResponse.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.ErrorResponse("Document Type not found in database", "Document Type not found in database", 404)); } if (documentType.IsMandatory && string.IsNullOrWhiteSpace(model.DocumentId)) { return BadRequest(ApiResponse.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.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.ErrorResponse("Employee Not Found", "Employee not found in database", 404)); } if (documentType.DocumentCategory.EntityTypeId == ProjectEntity && !projectExist) { return NotFound(ApiResponse.ErrorResponse("Project Not Found", "Project not found in database", 404)); } var documentDetails = _mapper.Map(model); documentDetails.UploadedAt = DateTime.UtcNow; documentDetails.UploadedById = loggedInEmployee.Id; documentDetails.TenantId = tenantId; var _s3Service = scope.ServiceProvider.GetRequiredService(); var batchId = Guid.NewGuid(); List documents = new List(); if (model.Attachment.FileSize > documentType.MaxSizeAllowedInMB) { return BadRequest(ApiResponse.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.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.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(); var _threadLogger = scope.ServiceProvider.GetRequiredService(); 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 attachmentTagMappings = new List(); 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(documentDetails); return Ok(ApiResponse.SuccessResponse(response, "Document Added Successfully", 200)); } /// /// Uploads a document attachment for an Employee or Project. /// Validates permissions, document type, entity existence, tags, and uploads to S3. /// [HttpPost("upload")] public async Task UploadDocument([FromBody] DocumentAttachmentDto model) { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); using var scope = _serviceScope.CreateScope(); var logger = scope.ServiceProvider.GetRequiredService(); 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(); 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.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.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.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.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.ErrorResponse($"{(entityType == EmployeeEntity ? "Employee" : "Project")} Not Found", "Entity not found in database", 404)); } // Map DTO to DB entity var attachment = _mapper.Map(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.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.ErrorResponse("Base64 data missing", "File data required", 400)); } var s3Service = scope.ServiceProvider.GetRequiredService(); 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.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(); 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(attachment); return Ok(ApiResponse.SuccessResponse(response, "Document added successfully", 200)); } catch (Exception ex) { logger.LogError(ex, "Unexpected error during document upload."); return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while uploading the document", 500)); } } // PUT api//5 [HttpPut("{id}")] public async Task Put(int id, [FromBody] string value) { var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); } // DELETE api//5 [HttpDelete("{id}")] public async Task Delete(int id) { var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); } /// /// Deserializes the filter string, handling multiple potential formats (e.g., direct JSON vs. escaped JSON string). /// /// The JSON filter string from the request. /// An object or null if deserialization fails. 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(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(filter, options) ?? ""; if (!string.IsNullOrWhiteSpace(unescapedJsonString)) { documentFilter = JsonSerializer.Deserialize(unescapedJsonString, options); } } catch (JsonException ex1) { // If both attempts fail, log the final error and return null. _logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeFilter), filter); return null; } } return documentFilter; } } }