From d2af05b6a04b9b8f28b9fec912abeac2275ad513 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Fri, 29 Aug 2025 11:29:04 +0530 Subject: [PATCH] Added the update document API --- .../DocumentManager/DocumentAttachmentDto.cs | 11 + .../Controllers/DocumentController.cs | 344 ++++++++++++++++-- 2 files changed, 331 insertions(+), 24 deletions(-) diff --git a/Marco.Pms.Model/Dtos/DocumentManager/DocumentAttachmentDto.cs b/Marco.Pms.Model/Dtos/DocumentManager/DocumentAttachmentDto.cs index 64ef351..72a047b 100644 --- a/Marco.Pms.Model/Dtos/DocumentManager/DocumentAttachmentDto.cs +++ b/Marco.Pms.Model/Dtos/DocumentManager/DocumentAttachmentDto.cs @@ -12,4 +12,15 @@ namespace Marco.Pms.Model.Dtos.DocumentManager public required FileUploadModel Attachment { get; set; } public List? Tags { get; set; } } + + public class UpdateDocumentAttachmentDto + { + public required Guid Id { get; set; } + public required string Name { get; set; } + public string? DocumentId { get; set; } + public required string Description { get; set; } + public FileUploadModel? Attachment { get; set; } + public List? Tags { get; set; } + } } + diff --git a/Marco.Pms.Services/Controllers/DocumentController.cs b/Marco.Pms.Services/Controllers/DocumentController.cs index 3e3b80b..b6da516 100644 --- a/Marco.Pms.Services/Controllers/DocumentController.cs +++ b/Marco.Pms.Services/Controllers/DocumentController.cs @@ -1,9 +1,11 @@ using AutoMapper; using Marco.Pms.DataAccess.Data; +using Marco.Pms.Helpers.Utility; 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.MongoDBModels.Utility; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.DocumentManager; @@ -34,6 +36,7 @@ namespace Marco.Pms.Services.Controllers private static readonly Guid ProjectEntity = Guid.Parse("c8fe7115-aa27-43bc-99f4-7b05fabe436e"); private static readonly Guid EmployeeEntity = Guid.Parse("dbb9555a-7a0c-40f2-a9ed-f0463f1ceed7"); + private static readonly string Collection = "DocumentModificationLog"; public DocumentController(IDbContextFactory dbContextFactory, IServiceScopeFactory serviceScope, @@ -54,7 +57,7 @@ namespace Marco.Pms.Services.Controllers /// Fetch documents for a given entity (Project/Employee) with filtering, searching, and pagination. /// [HttpGet("list/{entityTypeId}/entity/{entityId}")] - public async Task GetDocumentList(Guid entityTypeId, Guid entityId, [FromQuery] string? filter, [FromQuery] string? searchString, + public async Task GetDocumentListAsync(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(); @@ -237,13 +240,12 @@ namespace Marco.Pms.Services.Controllers /// Validates permissions, document type, entity existence, tags, and uploads to S3. /// [HttpPost("upload")] - public async Task UploadDocument([FromBody] DocumentAttachmentDto model) + public async Task UploadDocumentAsync([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); + _logger.LogInfo("Document upload initiated for EntityId: {EntityId}, DocumentTypeId: {DocumentTypeId}", model.EntityId, model.DocumentTypeId); try { @@ -256,7 +258,7 @@ namespace Marco.Pms.Services.Controllers if (!hasUploadPermission && loggedInEmployee.Id != model.EntityId) { - logger.LogWarning("Access Denied. User {UserId} tried to upload document for {EntityId}", 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)); } @@ -267,14 +269,14 @@ namespace Marco.Pms.Services.Controllers if (documentType == null) { - logger.LogWarning("DocumentTypeId {DocumentTypeId} not found for Tenant {TenantId}", model.DocumentTypeId, tenantId); + _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); + _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)); } @@ -282,7 +284,7 @@ namespace Marco.Pms.Services.Controllers { if (!Regex.IsMatch(model.DocumentId, documentType.RegexExpression)) { - logger.LogWarning("Invalid DocumentId format for DocumentTypeId: {DocumentTypeId}, Provided: {DocumentId}", documentType.Id, model.DocumentId); + _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)); } } @@ -302,7 +304,7 @@ namespace Marco.Pms.Services.Controllers if (!entityExists) { - logger.LogWarning("Entity not found. EntityType: {EntityType}, EntityId: {EntityId}", entityType, model.EntityId); + _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)); } @@ -310,19 +312,20 @@ namespace Marco.Pms.Services.Controllers var attachment = _mapper.Map(model); attachment.UploadedAt = DateTime.UtcNow; attachment.UploadedById = loggedInEmployee.Id; + attachment.IsCurrentVersion = true; 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); + _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."); + _logger.LogWarning("Missing Base64 data for attachment."); return BadRequest(ApiResponse.ErrorResponse("Base64 data missing", "File data required", 400)); } @@ -332,7 +335,7 @@ namespace Marco.Pms.Services.Controllers var validContentTypes = documentType.AllowedContentType.Split(',').ToList(); if (!validContentTypes.Contains(fileType)) { - logger.LogWarning("Unsupported file type {FileType} for DocumentType {DocumentTypeId}", fileType, documentType.Id); + _logger.LogWarning("Unsupported file type {FileType} for DocumentType {DocumentTypeId}", fileType, documentType.Id); return BadRequest(ApiResponse.ErrorResponse("Unsupported file type", $"Unsupported file type: {fileType}", 400)); } @@ -346,6 +349,7 @@ namespace Marco.Pms.Services.Controllers // Fire-and-forget upload _ = Task.Run(async () => { + var logger = scope.ServiceProvider.GetRequiredService(); try { await s3Service.UploadFileAsync(base64, fileType, objectKey); @@ -427,7 +431,7 @@ namespace Marco.Pms.Services.Controllers await dbContext.SaveChangesAsync(); - logger.LogInfo("Document uploaded successfully. AttachmentId: {AttachmentId}, DocumentId: {DocumentId}", attachment.Id, document.Id); + _logger.LogInfo("Document uploaded successfully. AttachmentId: {AttachmentId}, DocumentId: {DocumentId}", attachment.Id, document.Id); var response = _mapper.Map(attachment); response.UploadedBy = _mapper.Map(loggedInEmployee); @@ -438,19 +442,313 @@ namespace Marco.Pms.Services.Controllers } catch (Exception ex) { - logger.LogError(ex, "Unexpected error during document upload."); + _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) + [HttpPut("edit/{id}")] + public async Task UpdateDocumentAsync(Guid id, [FromBody] UpdateDocumentAttachmentDto model) { - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + // Logger initialization at the start for consistent logging + using var scope = _serviceScope.CreateScope(); + _logger.LogInfo("Start UpdateDocument API for AttachmentId: {AttachmentId}", id); + + try + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + + // Get logged-in employee details + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + var permissionService = scope.ServiceProvider.GetRequiredService(); + var hasUploadPermission = await permissionService.HasPermission(PermissionsMaster.UploadDocument, loggedInEmployee.Id); + + // Fetch the existing attachment + var oldAttachment = await dbContext.DocumentAttachments + .Include(da => da.DocumentType) + .ThenInclude(dt => dt!.DocumentCategory) + .FirstOrDefaultAsync(da => da.Id == id && da.IsCurrentVersion && da.TenantId == tenantId); + + if (oldAttachment == null) + { + _logger.LogWarning("Attachment not found for Id: {AttachmentId}", id); + return NotFound(ApiResponse.ErrorResponse("Attachment not found", "Attachment not found in database", 404)); + } + + // Permission check: ensure uploader is authorized + if (!hasUploadPermission && loggedInEmployee.Id != oldAttachment.EntityId) + { + _logger.LogWarning("Access denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + return StatusCode(403, ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to upload this document", 403)); + } + + // Validate the document type + var documentType = oldAttachment.DocumentType; + if (documentType == null) + { + _logger.LogWarning("Document type not found for AttachmentId: {AttachmentId}", id); + return NotFound(ApiResponse.ErrorResponse("Document Type not found", "Document Type not found in database", 404)); + } + + // Mandatory DocumentID check + if (documentType.IsMandatory && string.IsNullOrWhiteSpace(model.DocumentId)) + { + _logger.LogWarning("Document ID missing for mandatory DocumentTypeId: {DocumentTypeId}", documentType.Id); + return BadRequest(ApiResponse.ErrorResponse("Document ID missing", "User must provide the document ID for this document", 400)); + } + + // DocumentID Regex validation + if (documentType.IsValidationRequired && !string.IsNullOrWhiteSpace(model.DocumentId) && !string.IsNullOrWhiteSpace(documentType.RegexExpression)) + { + if (!Regex.IsMatch(model.DocumentId, documentType.RegexExpression)) + { + _logger.LogWarning("Provided document ID does not match regex for DocumentTypeId: {DocumentTypeId}", documentType.Id); + return BadRequest(ApiResponse.ErrorResponse("Invalid Document ID", "Provided document ID is not valid", 400)); + } + } + + // Validate entity existence and project-level permission + var entityType = documentType.DocumentCategory!.EntityTypeId; + bool entityExists; + if (entityType.Equals(EmployeeEntity)) + { + entityExists = await dbContext.Employees.AnyAsync(e => e.Id == oldAttachment.EntityId && e.TenantId == tenantId); + } + else if (entityType.Equals(ProjectEntity)) + { + entityExists = await dbContext.Projects.AnyAsync(p => p.Id == oldAttachment.EntityId && p.TenantId == tenantId); + if (entityExists) + { + entityExists = await permissionService.HasProjectPermission(loggedInEmployee, oldAttachment.EntityId); + } + } + else + { + entityExists = false; + } + + if (!entityExists) + { + _logger.LogWarning("Entity not found (Employee/Project) for AttachmentId: {AttachmentId}", id); + return NotFound(ApiResponse.ErrorResponse($"{(entityType == EmployeeEntity ? "Employee" : "Project")} Not Found", "Entity not found in database", 404)); + } + + // Prepare for versioning + var oldVersionMapping = await dbContext.AttachmentVersionMappings + .FirstOrDefaultAsync(av => av.ChildAttachmentId == oldAttachment.Id && av.TenantId == tenantId); + + var updateLogHelper = scope.ServiceProvider.GetRequiredService(); + var existingEntityBson = updateLogHelper.EntityToBsonDocument(oldAttachment); + + DocumentAttachment newAttachment; + AttachmentVersionMapping newVersionMapping; + + if (model.Attachment != null) + { + // File size check + if (model.Attachment.FileSize > documentType.MaxSizeAllowedInMB) + { + _logger.LogWarning("Attachment exceeded max file size for DocumentTypeId: {DocumentTypeId}", documentType.Id); + return BadRequest(ApiResponse.ErrorResponse("File size limit exceeded", $"Max allowed {documentType.MaxSizeAllowedInMB} MB.", 400)); + } + + // Base64 validation + string base64 = model.Attachment.Base64Data?.Split(',').LastOrDefault() ?? ""; + if (string.IsNullOrWhiteSpace(base64)) + { + _logger.LogWarning("Base64 data missing for new attachment"); + return BadRequest(ApiResponse.ErrorResponse("Base64 data missing", "File data required", 400)); + } + + // Content type verification + 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}", fileType); + return BadRequest(ApiResponse.ErrorResponse("Unsupported file type", $"Unsupported file type: {fileType}", 400)); + } + + // S3 keys and folder structure + string folderName = entityType == EmployeeEntity ? "EmployeeDocuments" : "ProjectDocuments"; + string fileName = s3Service.GenerateFileName(fileType, tenantId, folderName); + string objectKey = entityType == EmployeeEntity + ? $"tenant-{tenantId}/Employee/{oldAttachment.EntityId}/{folderName}/{fileName}" + : $"tenant-{tenantId}/project-{oldAttachment.EntityId}/{folderName}/{fileName}"; + + // Asynchronous S3 upload with logging + _ = Task.Run(async () => + { + var logger = scope.ServiceProvider.GetRequiredService(); + 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); + + // Record new document attachment as the current version + var attachment = new DocumentAttachment + { + Name = model.Name, + DocumentId = model.DocumentId, + Description = model.Description, + IsCurrentVersion = true, + EntityId = oldAttachment.EntityId, + DocumentDataId = document.Id, + UploadedAt = DateTime.UtcNow, + UploadedById = loggedInEmployee.Id, + DocumentTypeId = oldAttachment.DocumentTypeId, + TenantId = oldAttachment.TenantId + }; + + dbContext.DocumentAttachments.Add(attachment); + + // Mark old version as not current + oldAttachment.IsCurrentVersion = false; + + // Version mapping + AttachmentVersionMapping versionMapping; + if (oldVersionMapping != null) + { + versionMapping = new AttachmentVersionMapping + { + ParentAttachmentId = oldVersionMapping.ParentAttachmentId, + ChildAttachmentId = attachment.Id, + Version = (oldVersionMapping.Version + 1), + TenantId = tenantId + }; + } + else + { + versionMapping = new AttachmentVersionMapping + { + ParentAttachmentId = attachment.Id, + ChildAttachmentId = attachment.Id, + Version = 1, + TenantId = tenantId + }; + } + dbContext.AttachmentVersionMappings.Add(versionMapping); + + newAttachment = attachment; + newVersionMapping = versionMapping; + _logger.LogInfo("Created new current version for AttachmentId: {AttachmentId}", attachment.Id); + } + else + { + // Update attachment metadata only (no file upload) + oldAttachment.Name = model.Name; + oldAttachment.DocumentId = model.DocumentId; + oldAttachment.Description = model.Description; + if (oldAttachment.IsVerified == true) + { + oldAttachment.IsVerified = false; + _logger.LogInfo("Reset verification flag for AttachmentId: {AttachmentId}", oldAttachment.Id); + } + oldAttachment.UpdatedAt = DateTime.UtcNow; + oldAttachment.UpdatedById = loggedInEmployee.Id; + + newAttachment = oldAttachment; + newVersionMapping = oldVersionMapping ?? new AttachmentVersionMapping(); + _logger.LogInfo("Attachment metadata updated for AttachmentId: {AttachmentId}", oldAttachment.Id); + } + + // Tag management + 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(); + var oldTagNames = await dbContext.AttachmentTagMappings + .Include(dt => dt.DocumentTag) + .Where(dt => dt.DocumentTag != null && dt.AttachmentId == newAttachment.Id && dt.TenantId == tenantId) + .Select(dt => dt.DocumentTag!.Name) + .ToListAsync(); + + foreach (var tag in model.Tags.Where(t => t.IsActive && !oldTagNames.Contains(t.Name))) + { + 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 = newAttachment.Id, + TenantId = tenantId + }); + } + + dbContext.AttachmentTagMappings.AddRange(attachmentTagMappings); + _logger.LogInfo("Tags processed for AttachmentId: {AttachmentId}", newAttachment.Id); + } + + // Persist changes to database + await dbContext.SaveChangesAsync(); + _logger.LogInfo("Database changes committed for AttachmentId: {AttachmentId}", newAttachment.Id); + + // Update logs + await updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = oldAttachment.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, Collection); + _logger.LogInfo("Update logs pushed for AttachmentId: {AttachmentId}", oldAttachment.Id); + + // Prepare response + var response = _mapper.Map(newAttachment); + response.UploadedBy = _mapper.Map(loggedInEmployee); + response.ParentAttachmentId = newVersionMapping.ParentAttachmentId; + response.Version = newVersionMapping.Version; + _logger.LogInfo("API completed successfully for AttachmentId: {AttachmentId}", newAttachment.Id); + + return Ok(ApiResponse.SuccessResponse(response, "Document added successfully", 200)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception occurred while updating document for AttachmentId: {AttachmentId}", id); + return StatusCode(500, ApiResponse.ErrorResponse("Exception occured", "Exception occured while the updating document", 500)); + } } + // DELETE api//5 [HttpDelete("{id}")] public async Task Delete(int id) @@ -458,11 +756,7 @@ namespace Marco.Pms.Services.Controllers 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. + #region =================================================================== Helper Functions =================================================================== private DocumentFilter? TryDeserializeFilter(string? filter) { @@ -502,5 +796,7 @@ namespace Marco.Pms.Services.Controllers } return documentFilter; } + + #endregion } } -- 2.43.0