From 918f856632f6befa4f1c6d2e793a53e045fe54fa Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Fri, 17 Oct 2025 12:56:33 +0530 Subject: [PATCH] Added the Collection in OFW --- .../Data/ApplicationDbContext.cs | 75 + Marco.Pms.Model/Collection/Invoice.cs | 41 + .../Collection/InvoiceAttachment.cs | 22 + Marco.Pms.Model/Collection/InvoiceComment.cs | 24 + .../Collection/PaymentAdjustmentHead.cs | 12 + .../Collection/ReceivedInvoicePayment.cs | 33 + .../Activities/CreateWorkStatusMasterDto.cs | 4 +- .../Activities/UpdateWorkStatusMasterDto.cs | 4 +- .../Dtos/Collection/InvoiceCommentDto.cs | 8 + Marco.Pms.Model/Dtos/Collection/InvoiceDto.cs | 20 + .../Collection/PaymentAdjustmentHeadDto.cs | 9 + .../Collection/ReceivedInvoicePaymentDto.cs | 13 + .../Dtos/Master/CreateContactCategoryDto.cs | 4 +- .../Dtos/Master/CreateContactTagDto.cs | 2 +- .../Dtos/Master/UpdateContactCategoryDto.cs | 4 +- .../Entitlements/PermissionsMaster.cs | 6 + .../Collection/InvoiceAttachmentVM.cs | 16 + .../ViewModels/Collection/InvoiceCommentVM.cs | 13 + .../ViewModels/Collection/InvoiceDetailsVM.cs | 31 + .../ViewModels/Collection/InvoiceListVM.cs | 27 + .../Collection/PaymentAdjustmentHeadVM.cs | 10 + .../Collection/ReceivedInvoicePaymentVM.cs | 18 + .../Controllers/CollectionController.cs | 1284 +++++++++++++++++ .../Controllers/MasterController.cs | 34 + .../MappingProfiles/MappingProfile.cs | 45 + Marco.Pms.Services/Service/MasterService.cs | 1236 +++++++++++++--- .../ServiceInterfaces/IMasterService.cs | 9 + 27 files changed, 2815 insertions(+), 189 deletions(-) create mode 100644 Marco.Pms.Model/Collection/Invoice.cs create mode 100644 Marco.Pms.Model/Collection/InvoiceAttachment.cs create mode 100644 Marco.Pms.Model/Collection/InvoiceComment.cs create mode 100644 Marco.Pms.Model/Collection/PaymentAdjustmentHead.cs create mode 100644 Marco.Pms.Model/Collection/ReceivedInvoicePayment.cs create mode 100644 Marco.Pms.Model/Dtos/Collection/InvoiceCommentDto.cs create mode 100644 Marco.Pms.Model/Dtos/Collection/InvoiceDto.cs create mode 100644 Marco.Pms.Model/Dtos/Collection/PaymentAdjustmentHeadDto.cs create mode 100644 Marco.Pms.Model/Dtos/Collection/ReceivedInvoicePaymentDto.cs create mode 100644 Marco.Pms.Model/ViewModels/Collection/InvoiceAttachmentVM.cs create mode 100644 Marco.Pms.Model/ViewModels/Collection/InvoiceCommentVM.cs create mode 100644 Marco.Pms.Model/ViewModels/Collection/InvoiceDetailsVM.cs create mode 100644 Marco.Pms.Model/ViewModels/Collection/InvoiceListVM.cs create mode 100644 Marco.Pms.Model/ViewModels/Collection/PaymentAdjustmentHeadVM.cs create mode 100644 Marco.Pms.Model/ViewModels/Collection/ReceivedInvoicePaymentVM.cs create mode 100644 Marco.Pms.Services/Controllers/CollectionController.cs diff --git a/Marco.Pms.DataAccess/Data/ApplicationDbContext.cs b/Marco.Pms.DataAccess/Data/ApplicationDbContext.cs index 5f9e40f..03f5501 100644 --- a/Marco.Pms.DataAccess/Data/ApplicationDbContext.cs +++ b/Marco.Pms.DataAccess/Data/ApplicationDbContext.cs @@ -1,6 +1,7 @@ using Marco.Pms.Model.Activities; using Marco.Pms.Model.AttendanceModule; using Marco.Pms.Model.Authentication; +using Marco.Pms.Model.Collection; using Marco.Pms.Model.Directory; using Marco.Pms.Model.DocumentManager; using Marco.Pms.Model.Employees; @@ -133,6 +134,13 @@ namespace Marco.Pms.DataAccess.Data public DbSet ProjectServiceMappings { get; set; } public DbSet ProjectOrgMappings { get; set; } + // Collection + public DbSet Invoices { get; set; } + public DbSet InvoiceComments { get; set; } + public DbSet InvoiceAttachments { get; set; } + public DbSet ReceivedInvoicePayments { get; set; } + public DbSet PaymentAdjustmentHeads { get; set; } + protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -775,6 +783,65 @@ namespace Marco.Pms.DataAccess.Data } ); + modelBuilder.Entity().HasData( + new PaymentAdjustmentHead + { + Id = Guid.Parse("dbdc047f-a2d2-4db0-b0e6-b9d9f923a0f1"), + Name = "Advance payment", + Description = "An advance payment is a sum paid before receiving goods or services, often to secure a transaction or cover initial costs.", + IsActive = true, + TenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26") + }, + new PaymentAdjustmentHead + { + Id = Guid.Parse("66c3c241-8b52-4327-a5ad-c1faf102583e"), + Name = "Base Amount", + Description = "The base amount refers to the principal sum or original value used as a reference in financial calculations, excluding taxes, fees, or additional charges.", + IsActive = true, + TenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26") + }, + new PaymentAdjustmentHead + { + Id = Guid.Parse("0d70cb2e-827e-44fc-90a5-c2c55ba51ba9"), + Name = "Tax Deducted at Source (TDS)", + Description = "TDS, or Tax Deducted at Source, is a system under the Indian Income Tax Act where tax is deducted at the point of income generation—such as salary, interest, or rent—and remitted to the government to prevent tax evasion and ensure timely collection.", + IsActive = true, + TenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26") + }, + new PaymentAdjustmentHead + { + Id = Guid.Parse("95f35acd-d979-4177-91ea-fd03a00e49ff"), + Name = "Retention", + Description = "Retention refers to a company's ability to keep customers, employees, or profits over time, commonly measured as a percentage and critical for long-term business sustainability and growth.", + IsActive = true, + TenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26") + }, + new PaymentAdjustmentHead + { + Id = Guid.Parse("3f09b19a-8d45-4cf2-be27-f4f09b38b9f7"), + Name = "Tax", + Description = "Tax is a mandatory financial charge imposed by a government on individuals or entities to fund public services and government operations, without direct benefit to the taxpayer.", + IsActive = true, + TenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26") + }, + new PaymentAdjustmentHead + { + Id = Guid.Parse("ec5e6a5f-ce62-44e5-8911-8426bbb4dde8"), + Name = "Penalty", + Description = "A penalty in the context of taxation is a financial sanction imposed by the government on individuals or entities for non-compliance with tax laws, such as late filing, underreporting income, or failure to pay taxes, and is typically calculated as a percentage of the tax due or a fixed amount.", + IsActive = true, + TenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26") + }, + new PaymentAdjustmentHead + { + Id = Guid.Parse("50584332-1cb7-4359-9721-c8ea35040881"), + Name = "Utility fees", + Description = "Utility fees are recurring charges for essential services such as electricity, water, gas, sewage, waste disposal, internet, and telecommunications, typically based on usage and necessary for operating a home or business.", + IsActive = true, + TenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26") + } + ); + modelBuilder.Entity().HasData( new EntityTypeMaster { @@ -1039,6 +1106,7 @@ namespace Marco.Pms.DataAccess.Data // Project Module new Feature { Id = new Guid("53176ebf-c75d-42e5-839f-4508ffac3def"), Description = "Manage Project", Name = "Project Management", ModuleId = new Guid("bf59fd88-b57a-4d67-bf01-3780f385896b"), IsActive = true }, new Feature { Id = new Guid("a4e25142-449b-4334-a6e5-22f70e4732d7"), Description = "Expense Management is the systematic process of tracking, controlling, and reporting business-related expenditures.", Name = "Expense Management", ModuleId = new Guid("bf59fd88-b57a-4d67-bf01-3780f385896b"), IsActive = true }, + new Feature { Id = new Guid("fc586e7d-ed1a-45e5-bb51-9f34af98ec13"), Description = "Collection Management is a feature that enables organizations to track, organize, and manage the status and recovery of receivables or assets efficiently throughout their lifecycle, supporting systematic follow-up and resolution of outstanding accounts.", Name = "Collection Management", ModuleId = new Guid("bf59fd88-b57a-4d67-bf01-3780f385896b"), IsActive = true }, new Feature { Id = new Guid("9d4b5489-2079-40b9-bd77-6e1bf90bc19f"), Description = "Manage Tasks", Name = "Task Management", ModuleId = new Guid("bf59fd88-b57a-4d67-bf01-3780f385896b"), IsActive = true }, // Employee Module @@ -1110,6 +1178,13 @@ namespace Marco.Pms.DataAccess.Data new FeaturePermission { Id = new Guid("ea5a1529-4ee8-4828-80ea-0e23c9d4dd11"), FeatureId = new Guid("a4e25142-449b-4334-a6e5-22f70e4732d7"), IsEnabled = true, Name = "Process", Description = "Allows a user to handle post-approval actions such as recording payments, updating financial records, or marking expenses as reimbursed or settled." }, new FeaturePermission { Id = new Guid("bdee29a2-b73b-402d-8dd1-c4b1f81ccbc3"), FeatureId = new Guid("a4e25142-449b-4334-a6e5-22f70e4732d7"), IsEnabled = true, Name = "Manage", Description = "Allows a user to configure and control system settings, such as managing expense types, payment modes, permissions, and overall workflow rules." }, + // Collection Management Feature + new FeaturePermission { Id = new Guid("dbf17591-09fe-4c93-9e1a-12db8f5cc5de"), FeatureId = new Guid("fc586e7d-ed1a-45e5-bb51-9f34af98ec13"), IsEnabled = true, Name = "Collection Admin", Description = "Collection Admin is a permission that grants a user full administrative control over collections, including creating, editing, managing access, and deleting collections within a system." }, + new FeaturePermission { Id = new Guid("c8d7eea5-4033-4aad-9ebe-76de49896830"), FeatureId = new Guid("fc586e7d-ed1a-45e5-bb51-9f34af98ec13"), IsEnabled = true, Name = "View Collection", Description = "View Collection is a permission that allows users to see and browse assets or items within a collection without making any modifications or edits to its contents." }, + new FeaturePermission { Id = new Guid("b93141fd-dbd3-4051-8f57-bf25d18e3555"), FeatureId = new Guid("fc586e7d-ed1a-45e5-bb51-9f34af98ec13"), IsEnabled = true, Name = "Create Collection", Description = "Authorizes users to create new collections for organizing related resources and managing access" }, + new FeaturePermission { Id = new Guid("455187b4-fef1-41f9-b3d0-025d0b6302c3"), FeatureId = new Guid("fc586e7d-ed1a-45e5-bb51-9f34af98ec13"), IsEnabled = true, Name = "Edit Collection", Description = "Ability to modify collection properties, content, and access rights." }, + new FeaturePermission { Id = new Guid("061d9ccd-85b4-4cb0-be06-2f9f32cebb72"), FeatureId = new Guid("fc586e7d-ed1a-45e5-bb51-9f34af98ec13"), IsEnabled = true, Name = "Add Payment", Description = " Enables entry and processing of payment transactions." }, + // Organization Management Feature new FeaturePermission { Id = new Guid("068cb3c1-49c5-4746-9f29-1fce16e820ac"), FeatureId = new Guid("6d4c82d6-dbce-48ab-b8b8-f785f4d8c914"), IsEnabled = true, Name = "Add Organization", Description = "Allow user to create new organization" }, new FeaturePermission { Id = new Guid("c1ae1363-ab8a-4bd9-a9d1-8c2c6083873a"), FeatureId = new Guid("6d4c82d6-dbce-48ab-b8b8-f785f4d8c914"), IsEnabled = true, Name = "Edit Organization", Description = "Allow the user to update the basic information of the organization" }, diff --git a/Marco.Pms.Model/Collection/Invoice.cs b/Marco.Pms.Model/Collection/Invoice.cs new file mode 100644 index 0000000..a510ca5 --- /dev/null +++ b/Marco.Pms.Model/Collection/Invoice.cs @@ -0,0 +1,41 @@ +using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Projects; +using Marco.Pms.Model.Utilities; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Marco.Pms.Model.Collection +{ + public class Invoice : TenantRelation + { + public Guid Id { get; set; } + public string Title { get; set; } = default!; + public string Description { get; set; } = default!; + public string InvoiceNumber { get; set; } = default!; + public string? EInvoiceNumber { get; set; } + public Guid ProjectId { get; set; } + + [ValidateNever] + [ForeignKey("ProjectId")] + public Project? Project { get; set; } + public DateTime InvoiceDate { get; set; } + public DateTime ClientSubmitedDate { get; set; } + public DateTime ExceptedPaymentDate { get; set; } + public double BasicAmount { get; set; } + public double TaxAmount { get; set; } + public bool IsActive { get; set; } = true; + public bool MarkAsCompleted { get; set; } = true; + public DateTime CreatedAt { get; set; } + public Guid CreatedById { get; set; } + + [ValidateNever] + [ForeignKey("CreatedById")] + public Employee? CreatedBy { get; set; } + public DateTime? UpdatedAt { get; set; } + public Guid? UpdatedById { get; set; } + + [ValidateNever] + [ForeignKey("UpdatedById")] + public Employee? UpdatedBy { get; set; } + } +} diff --git a/Marco.Pms.Model/Collection/InvoiceAttachment.cs b/Marco.Pms.Model/Collection/InvoiceAttachment.cs new file mode 100644 index 0000000..a64ee14 --- /dev/null +++ b/Marco.Pms.Model/Collection/InvoiceAttachment.cs @@ -0,0 +1,22 @@ +using Marco.Pms.Model.DocumentManager; +using Marco.Pms.Model.Utilities; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Marco.Pms.Model.Collection +{ + public class InvoiceAttachment : TenantRelation + { + public Guid Id { get; set; } + public Guid InvoiceId { get; set; } + + [ValidateNever] + [ForeignKey("InvoiceId")] + public Invoice? Invoice { get; set; } + public Guid DocumentId { get; set; } + + [ValidateNever] + [ForeignKey("DocumentId")] + public Document? Document { get; set; } + } +} diff --git a/Marco.Pms.Model/Collection/InvoiceComment.cs b/Marco.Pms.Model/Collection/InvoiceComment.cs new file mode 100644 index 0000000..c33b298 --- /dev/null +++ b/Marco.Pms.Model/Collection/InvoiceComment.cs @@ -0,0 +1,24 @@ +using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Utilities; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Marco.Pms.Model.Collection +{ + public class InvoiceComment : TenantRelation + { + public Guid Id { get; set; } + public string Comment { get; set; } = default!; + public DateTime CreatedAt { get; set; } + public Guid CreatedById { get; set; } + + [ValidateNever] + [ForeignKey("CreatedById")] + public Employee? CreatedBy { get; set; } + public Guid InvoiceId { get; set; } + + [ValidateNever] + [ForeignKey("InvoiceId")] + public Invoice? Invoice { get; set; } + } +} diff --git a/Marco.Pms.Model/Collection/PaymentAdjustmentHead.cs b/Marco.Pms.Model/Collection/PaymentAdjustmentHead.cs new file mode 100644 index 0000000..0439d7f --- /dev/null +++ b/Marco.Pms.Model/Collection/PaymentAdjustmentHead.cs @@ -0,0 +1,12 @@ +using Marco.Pms.Model.Utilities; + +namespace Marco.Pms.Model.Collection +{ + public class PaymentAdjustmentHead : TenantRelation + { + public Guid Id { get; set; } + public string Name { get; set; } = default!; + public string? Description { get; set; } + public bool IsActive { get; set; } = true; + } +} diff --git a/Marco.Pms.Model/Collection/ReceivedInvoicePayment.cs b/Marco.Pms.Model/Collection/ReceivedInvoicePayment.cs new file mode 100644 index 0000000..5c6561e --- /dev/null +++ b/Marco.Pms.Model/Collection/ReceivedInvoicePayment.cs @@ -0,0 +1,33 @@ +using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Utilities; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Marco.Pms.Model.Collection +{ + public class ReceivedInvoicePayment : TenantRelation + { + public Guid Id { get; set; } + public Guid InvoiceId { get; set; } + + [ValidateNever] + [ForeignKey("InvoiceId")] + public Invoice? Invoice { get; set; } + public DateTime PaymentReceivedDate { get; set; } + public string TransactionId { get; set; } = default!; + public double Amount { get; set; } + public string Comment { get; set; } = default!; + public bool IsActive { get; set; } = true; + public Guid PaymentAdjustmentHeadId { get; set; } + + [ValidateNever] + [ForeignKey("PaymentAdjustmentHeadId")] + public PaymentAdjustmentHead? PaymentAdjustmentHead { get; set; } + public DateTime CreatedAt { get; set; } + public Guid CreatedById { get; set; } + + [ValidateNever] + [ForeignKey("CreatedById")] + public Employee? CreatedBy { get; set; } + } +} diff --git a/Marco.Pms.Model/Dtos/Activities/CreateWorkStatusMasterDto.cs b/Marco.Pms.Model/Dtos/Activities/CreateWorkStatusMasterDto.cs index b4ff25f..8b99f62 100644 --- a/Marco.Pms.Model/Dtos/Activities/CreateWorkStatusMasterDto.cs +++ b/Marco.Pms.Model/Dtos/Activities/CreateWorkStatusMasterDto.cs @@ -2,7 +2,7 @@ { public class CreateWorkStatusMasterDto { - public string? Name { get; set; } - public string? Description { get; set; } + public required string Name { get; set; } + public required string Description { get; set; } } } diff --git a/Marco.Pms.Model/Dtos/Activities/UpdateWorkStatusMasterDto.cs b/Marco.Pms.Model/Dtos/Activities/UpdateWorkStatusMasterDto.cs index a40052f..48dca5d 100644 --- a/Marco.Pms.Model/Dtos/Activities/UpdateWorkStatusMasterDto.cs +++ b/Marco.Pms.Model/Dtos/Activities/UpdateWorkStatusMasterDto.cs @@ -3,7 +3,7 @@ public class UpdateWorkStatusMasterDto { public Guid Id { get; set; } - public string? Name { get; set; } - public string? Description { get; set; } + public required string Name { get; set; } + public required string Description { get; set; } } } diff --git a/Marco.Pms.Model/Dtos/Collection/InvoiceCommentDto.cs b/Marco.Pms.Model/Dtos/Collection/InvoiceCommentDto.cs new file mode 100644 index 0000000..8870182 --- /dev/null +++ b/Marco.Pms.Model/Dtos/Collection/InvoiceCommentDto.cs @@ -0,0 +1,8 @@ +namespace Marco.Pms.Model.Dtos.Collection +{ + public class InvoiceCommentDto + { + public required string Comment { get; set; } + public required Guid InvoiceId { get; set; } + } +} \ No newline at end of file diff --git a/Marco.Pms.Model/Dtos/Collection/InvoiceDto.cs b/Marco.Pms.Model/Dtos/Collection/InvoiceDto.cs new file mode 100644 index 0000000..c74674c --- /dev/null +++ b/Marco.Pms.Model/Dtos/Collection/InvoiceDto.cs @@ -0,0 +1,20 @@ +using Marco.Pms.Model.Utilities; + +namespace Marco.Pms.Model.Dtos.Collection +{ + public class InvoiceDto + { + public Guid? Id { get; set; } + public required string Title { get; set; } + public string? Description { get; set; } + public required string InvoiceNumber { get; set; } + public string? EInvoiceNumber { get; set; } + public required Guid ProjectId { get; set; } + public required DateTime InvoiceDate { get; set; } + public required DateTime ClientSubmitedDate { get; set; } + public required DateTime ExceptedPaymentDate { get; set; } + public double BasicAmount { get; set; } + public double TaxAmount { get; set; } + public List? Attachments { get; set; } + } +} diff --git a/Marco.Pms.Model/Dtos/Collection/PaymentAdjustmentHeadDto.cs b/Marco.Pms.Model/Dtos/Collection/PaymentAdjustmentHeadDto.cs new file mode 100644 index 0000000..a4d7dc5 --- /dev/null +++ b/Marco.Pms.Model/Dtos/Collection/PaymentAdjustmentHeadDto.cs @@ -0,0 +1,9 @@ +namespace Marco.Pms.Model.Dtos.Collection +{ + public class PaymentAdjustmentHeadDto + { + public Guid? Id { get; set; } + public required string Name { get; set; } + public string? Description { get; set; } + } +} diff --git a/Marco.Pms.Model/Dtos/Collection/ReceivedInvoicePaymentDto.cs b/Marco.Pms.Model/Dtos/Collection/ReceivedInvoicePaymentDto.cs new file mode 100644 index 0000000..ba2ee83 --- /dev/null +++ b/Marco.Pms.Model/Dtos/Collection/ReceivedInvoicePaymentDto.cs @@ -0,0 +1,13 @@ +namespace Marco.Pms.Model.Dtos.Collection +{ + public class ReceivedInvoicePaymentDto + { + public Guid? Id { get; set; } + public required Guid InvoiceId { get; set; } + public required DateTime PaymentReceivedDate { get; set; } + public required string TransactionId { get; set; } + public required Guid PaymentAdjustmentHeadId { get; set; } + public required double Amount { get; set; } + public required string Comment { get; set; } + } +} diff --git a/Marco.Pms.Model/Dtos/Master/CreateContactCategoryDto.cs b/Marco.Pms.Model/Dtos/Master/CreateContactCategoryDto.cs index 3efc443..0dbe2c7 100644 --- a/Marco.Pms.Model/Dtos/Master/CreateContactCategoryDto.cs +++ b/Marco.Pms.Model/Dtos/Master/CreateContactCategoryDto.cs @@ -2,7 +2,7 @@ { public class CreateContactCategoryDto { - public string? Name { get; set; } - public string? Description { get; set; } + public required string Name { get; set; } + public required string Description { get; set; } } } diff --git a/Marco.Pms.Model/Dtos/Master/CreateContactTagDto.cs b/Marco.Pms.Model/Dtos/Master/CreateContactTagDto.cs index 2fec4ae..fa4dcb3 100644 --- a/Marco.Pms.Model/Dtos/Master/CreateContactTagDto.cs +++ b/Marco.Pms.Model/Dtos/Master/CreateContactTagDto.cs @@ -2,7 +2,7 @@ { public class CreateContactTagDto { - public string? Name { get; set; } + public required string Name { get; set; } public string? Description { get; set; } } } diff --git a/Marco.Pms.Model/Dtos/Master/UpdateContactCategoryDto.cs b/Marco.Pms.Model/Dtos/Master/UpdateContactCategoryDto.cs index 0bd5cc7..6d5e84b 100644 --- a/Marco.Pms.Model/Dtos/Master/UpdateContactCategoryDto.cs +++ b/Marco.Pms.Model/Dtos/Master/UpdateContactCategoryDto.cs @@ -3,7 +3,7 @@ public class UpdateContactCategoryDto { public Guid Id { get; set; } - public string? Name { get; set; } - public string? Description { get; set; } + public required string Name { get; set; } + public required string Description { get; set; } } } diff --git a/Marco.Pms.Model/Entitlements/PermissionsMaster.cs b/Marco.Pms.Model/Entitlements/PermissionsMaster.cs index 0f1d3bf..8ca11b5 100644 --- a/Marco.Pms.Model/Entitlements/PermissionsMaster.cs +++ b/Marco.Pms.Model/Entitlements/PermissionsMaster.cs @@ -47,6 +47,12 @@ public static readonly Guid DownloadDocument = Guid.Parse("404373d0-860f-490e-a575-1c086ffbce1d"); public static readonly Guid VerifyDocument = Guid.Parse("13a1f30f-38d1-41bf-8e7a-b75189aab8e0"); + public static readonly Guid CollectionAdmin = Guid.Parse("dbf17591-09fe-4c93-9e1a-12db8f5cc5de"); + public static readonly Guid ViewCollection = Guid.Parse("c8d7eea5-4033-4aad-9ebe-76de49896830"); + public static readonly Guid CreateCollection = Guid.Parse("b93141fd-dbd3-4051-8f57-bf25d18e3555"); + public static readonly Guid EditCollection = Guid.Parse("455187b4-fef1-41f9-b3d0-025d0b6302c3"); + public static readonly Guid AddPayment = Guid.Parse("061d9ccd-85b4-4cb0-be06-2f9f32cebb72"); + public static readonly Guid AddOrganization = Guid.Parse("068cb3c1-49c5-4746-9f29-1fce16e820ac"); public static readonly Guid EditOrganization = Guid.Parse("c1ae1363-ab8a-4bd9-a9d1-8c2c6083873a"); public static readonly Guid ViewOrganization = Guid.Parse("7a6cf830-0008-4e03-b31d-0d050cb634f4"); diff --git a/Marco.Pms.Model/ViewModels/Collection/InvoiceAttachmentVM.cs b/Marco.Pms.Model/ViewModels/Collection/InvoiceAttachmentVM.cs new file mode 100644 index 0000000..15b45d5 --- /dev/null +++ b/Marco.Pms.Model/ViewModels/Collection/InvoiceAttachmentVM.cs @@ -0,0 +1,16 @@ +using Marco.Pms.Model.ViewModels.Activities; + +namespace Marco.Pms.Model.ViewModels.Collection +{ + public class InvoiceAttachmentVM + { + public Guid Id { get; set; } + public Guid InvoiceId { get; set; } + public Guid DocumentId { get; set; } + public string? FileName { get; set; } + public string? ContentType { get; set; } + public string? PreSignedUrl { get; set; } + public BasicEmployeeVM? UploadedBy { get; set; } + + } +} diff --git a/Marco.Pms.Model/ViewModels/Collection/InvoiceCommentVM.cs b/Marco.Pms.Model/ViewModels/Collection/InvoiceCommentVM.cs new file mode 100644 index 0000000..978efbf --- /dev/null +++ b/Marco.Pms.Model/ViewModels/Collection/InvoiceCommentVM.cs @@ -0,0 +1,13 @@ +using Marco.Pms.Model.ViewModels.Activities; + +namespace Marco.Pms.Model.ViewModels.Collection +{ + public class InvoiceCommentVM + { + public Guid Id { get; set; } + public string Comment { get; set; } = default!; + public DateTime CreatedAt { get; set; } + public BasicEmployeeVM? CreatedBy { get; set; } + public Guid InvoiceId { get; set; } + } +} diff --git a/Marco.Pms.Model/ViewModels/Collection/InvoiceDetailsVM.cs b/Marco.Pms.Model/ViewModels/Collection/InvoiceDetailsVM.cs new file mode 100644 index 0000000..b63ebe5 --- /dev/null +++ b/Marco.Pms.Model/ViewModels/Collection/InvoiceDetailsVM.cs @@ -0,0 +1,31 @@ +using Marco.Pms.Model.ViewModels.Activities; +using Marco.Pms.Model.ViewModels.Projects; + +namespace Marco.Pms.Model.ViewModels.Collection +{ + public class InvoiceDetailsVM + { + public Guid Id { get; set; } + public string Title { get; set; } = default!; + public string Description { get; set; } = default!; + public string InvoiceNumber { get; set; } = default!; + public string? EInvoiceNumber { get; set; } + public BasicProjectVM? Project { get; set; } + public DateTime InvoiceDate { get; set; } + public DateTime ClientSubmitedDate { get; set; } + public DateTime ExceptedPaymentDate { get; set; } + public double BasicAmount { get; set; } + public double TaxAmount { get; set; } + public double BalanceAmount { get; set; } + public bool IsActive { get; set; } = true; + public bool MarkAsCompleted { get; set; } = true; + public DateTime CreatedAt { get; set; } + public BasicEmployeeVM? CreatedBy { get; set; } + public DateTime? UpdatedAt { get; set; } + public BasicEmployeeVM? UpdatedBy { get; set; } + public List? Attachments { get; set; } + public List? ReceivedInvoicePayments { get; set; } + public List? Comments { get; set; } + + } +} diff --git a/Marco.Pms.Model/ViewModels/Collection/InvoiceListVM.cs b/Marco.Pms.Model/ViewModels/Collection/InvoiceListVM.cs new file mode 100644 index 0000000..3889fe8 --- /dev/null +++ b/Marco.Pms.Model/ViewModels/Collection/InvoiceListVM.cs @@ -0,0 +1,27 @@ +using Marco.Pms.Model.ViewModels.Activities; +using Marco.Pms.Model.ViewModels.Projects; + +namespace Marco.Pms.Model.ViewModels.Collection +{ + public class InvoiceListVM + { + public Guid Id { get; set; } + public string Title { get; set; } = default!; + public string Description { get; set; } = default!; + public string InvoiceNumber { get; set; } = default!; + public string? EInvoiceNumber { get; set; } + public BasicProjectVM? Project { get; set; } + public DateTime InvoiceDate { get; set; } + public DateTime ClientSubmitedDate { get; set; } + public DateTime ExceptedPaymentDate { get; set; } + public double BasicAmount { get; set; } + public double TaxAmount { get; set; } + public double BalanceAmount { get; set; } + public bool IsActive { get; set; } + public bool MarkAsCompleted { get; set; } + public DateTime CreatedAt { get; set; } + public BasicEmployeeVM? CreatedBy { get; set; } + public DateTime? UpdatedAt { get; set; } + public BasicEmployeeVM? UpdatedBy { get; set; } + } +} diff --git a/Marco.Pms.Model/ViewModels/Collection/PaymentAdjustmentHeadVM.cs b/Marco.Pms.Model/ViewModels/Collection/PaymentAdjustmentHeadVM.cs new file mode 100644 index 0000000..2bc0922 --- /dev/null +++ b/Marco.Pms.Model/ViewModels/Collection/PaymentAdjustmentHeadVM.cs @@ -0,0 +1,10 @@ +namespace Marco.Pms.Model.ViewModels.Collection +{ + public class PaymentAdjustmentHeadVM + { + public Guid Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } = true; + } +} diff --git a/Marco.Pms.Model/ViewModels/Collection/ReceivedInvoicePaymentVM.cs b/Marco.Pms.Model/ViewModels/Collection/ReceivedInvoicePaymentVM.cs new file mode 100644 index 0000000..d224317 --- /dev/null +++ b/Marco.Pms.Model/ViewModels/Collection/ReceivedInvoicePaymentVM.cs @@ -0,0 +1,18 @@ +using Marco.Pms.Model.ViewModels.Activities; + +namespace Marco.Pms.Model.ViewModels.Collection +{ + public class ReceivedInvoicePaymentVM + { + public Guid Id { get; set; } + public Guid InvoiceId { get; set; } + public DateTime PaymentReceivedDate { get; set; } + public PaymentAdjustmentHeadVM? PaymentAdjustmentHead { get; set; } + public string TransactionId { get; set; } = default!; + public double Amount { get; set; } + public string? Comment { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } + public BasicEmployeeVM? CreatedBy { get; set; } + } +} diff --git a/Marco.Pms.Services/Controllers/CollectionController.cs b/Marco.Pms.Services/Controllers/CollectionController.cs new file mode 100644 index 0000000..2339e87 --- /dev/null +++ b/Marco.Pms.Services/Controllers/CollectionController.cs @@ -0,0 +1,1284 @@ +using AutoMapper; +using Marco.Pms.DataAccess.Data; +using Marco.Pms.Helpers.Utility; +using Marco.Pms.Model.Collection; +using Marco.Pms.Model.DocumentManager; +using Marco.Pms.Model.Dtos.Collection; +using Marco.Pms.Model.Entitlements; +using Marco.Pms.Model.MongoDBModels.Utility; +using Marco.Pms.Model.Utilities; +using Marco.Pms.Model.ViewModels.Activities; +using Marco.Pms.Model.ViewModels.Collection; +using Marco.Pms.Model.ViewModels.Projects; +using Marco.Pms.Services.Service; +using MarcoBMS.Services.Helpers; +using MarcoBMS.Services.Service; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MongoDB.Driver; + +namespace Marco.Pms.Services.Controllers +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class CollectionController : ControllerBase + { + private readonly IDbContextFactory _dbContextFactory; + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly UserHelper _userHelper; + private readonly S3UploadService _s3Service; + private readonly IMapper _mapper; + private readonly ILoggingService _logger; + private readonly Guid tenantId; + public CollectionController(IDbContextFactory dbContextFactory, + IServiceScopeFactory serviceScopeFactory, + S3UploadService s3Service, + UserHelper userhelper, + ILoggingService logger, + IMapper mapper) + { + _dbContextFactory = dbContextFactory; + _serviceScopeFactory = serviceScopeFactory; + _userHelper = userhelper; + _s3Service = s3Service; + _mapper = mapper; + _logger = logger; + tenantId = userhelper.GetTenantId(); + } + + #region =================================================================== Get Functions =================================================================== + + [HttpGet("invoice/list")] + public async Task GetInvoiceListAsync([FromQuery] Guid? projectId, [FromQuery] string? searchString, [FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate, [FromQuery] int pageSize = 20, [FromQuery] int pageNumber = 1 + , [FromQuery] bool isActive = true, [FromQuery] bool isPending = false) + { + _logger.LogInfo( + "Fetching invoice list: Page {PageNumber}, Size {PageSize}, Active={IsActive}, PendingOnly={IsPending}, Search='{SearchString}', From={From}, To={To}", + pageNumber, pageSize, isActive, isPending, searchString ?? "", fromDate?.Date ?? DateTime.MinValue, toDate?.Date ?? DateTime.MaxValue); + + // Get the currently logged-in employee + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // Log starting permission checks + _logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + + // Initiate permission check tasks asynchronously + var adminPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); + }); + + var viewPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.ViewCollection, loggedInEmployee.Id); + }); + + var createPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.CreateCollection, loggedInEmployee.Id); + }); + + var editPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.EditCollection, loggedInEmployee.Id); + }); + + var addPaymentPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.AddPayment, loggedInEmployee.Id); + }); + + // Await all permission checks to complete concurrently + await Task.WhenAll(adminPermissionTask, viewPermissionTask, createPermissionTask, editPermissionTask, addPaymentPermissionTask); + + // Capture permission results + var hasAdminPermission = adminPermissionTask.Result; + var hasViewPermission = viewPermissionTask.Result; + var hasCreatePermission = createPermissionTask.Result; + var hasEditPermission = editPermissionTask.Result; + var hasAddPaymentPermission = addPaymentPermissionTask.Result; + + // Log permission results for audit + _logger.LogInfo("Permission results for EmployeeId {EmployeeId}: Admin={Admin}, View={View}, Create={Create}, Edit={Edit}, Add Payment={AddPayment}", + loggedInEmployee.Id, hasAdminPermission, hasViewPermission, hasCreatePermission, hasEditPermission, hasAddPaymentPermission); + + // Check if user has any relevant permission; if none, deny access + if (!hasAdminPermission && !hasViewPermission && !hasCreatePermission && !hasEditPermission && !hasAddPaymentPermission) + { + _logger.LogWarning("Permission denied for EmployeeId {EmployeeId} - No collection-related permissions found.", loggedInEmployee.Id); + return StatusCode(403, ApiResponse.ErrorResponse( + "Access Denied", + "User does not have permission to access collection data.", + 403)); + } + + // Optionally log success or continue with further processing here + _logger.LogInfo("Permission granted for EmployeeId {EmployeeId} - Proceeding with collection access.", loggedInEmployee.Id); + + await using var _context = await _dbContextFactory.CreateDbContextAsync(); + + // Build base query with required includes and no tracking + var invoicesQuery = _context.Invoices + .Include(i => i.Project) + .Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole) + .Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole) + .Where(i => i.IsActive == isActive && i.TenantId == tenantId) + .AsNoTracking(); // Disable change tracking for read-only query + + // Apply date filter + if (fromDate.HasValue && toDate.HasValue) + { + var fromDateUtc = fromDate.Value.Date; + var toDateUtc = toDate.Value.Date.AddDays(1).AddTicks(-1); // End of day + invoicesQuery = invoicesQuery.Where(i => i.InvoiceDate >= fromDateUtc && i.InvoiceDate <= toDateUtc); + _logger.LogDebug("Applied date filter: {From} to {To}", fromDateUtc, toDateUtc); + } + + // Apply search filter + if (!string.IsNullOrWhiteSpace(searchString)) + { + invoicesQuery = invoicesQuery.Where(i => i.Title.Contains(searchString) || i.InvoiceNumber.Contains(searchString)); + _logger.LogDebug("Applied search filter with term: {SearchString}", searchString); + } + + // Apply project filter + if (projectId.HasValue) + { + invoicesQuery = invoicesQuery.Where(i => i.ProjectId == projectId.Value); + _logger.LogDebug("Applied project filter with term: {ProjectId}", projectId); + } + + // Get total count before pagination + var totalEntites = await invoicesQuery.CountAsync(); + _logger.LogDebug("Total matching invoices: {TotalCount}", totalEntites); + + // Apply sorting and pagination + var invoices = await invoicesQuery + .OrderByDescending(i => i.InvoiceDate) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + if (!invoices.Any()) + { + _logger.LogInfo("No invoices found for the given criteria."); + var emptyResponse = new + { + CurrentPage = pageNumber, + TotalPages = 0, + TotalEntites = 0, + Data = new List() + }; + return Ok(ApiResponse.SuccessResponse(emptyResponse, "No invoices found")); + } + + // Fetch all related payment data in a single query + var invoiceIds = invoices.Select(i => i.Id).ToList(); + var paymentGroups = await _context.ReceivedInvoicePayments + .AsNoTracking() + .Where(rip => invoiceIds.Contains(rip.InvoiceId) && rip.TenantId == tenantId) + .GroupBy(rip => rip.InvoiceId) + .Select(g => new + { + InvoiceId = g.Key, + PaidAmount = g.Sum(rip => rip.Amount) + }) + .ToDictionaryAsync(x => x.InvoiceId, x => x.PaidAmount); + + _logger.LogDebug("Fetched payment data for {Count} invoices", paymentGroups.Count); + + // Map and calculate balance in memory + var results = new List(); + foreach (var invoice in invoices) + { + var totalAmount = invoice.BasicAmount + invoice.TaxAmount; + var paidAmount = paymentGroups.GetValueOrDefault(invoice.Id, 0); + var balanceAmount = totalAmount - paidAmount; + + // Skip if filtering for pending invoices and balance is zero + if (isPending && (balanceAmount <= 0 || invoice.MarkAsCompleted)) + continue; + + var result = _mapper.Map(invoice); + result.BalanceAmount = balanceAmount; + results.Add(result); + } + + var totalPages = (int)Math.Ceiling((double)totalEntites / pageSize); + var response = new + { + CurrentPage = pageNumber, + TotalPages = totalPages, + TotalEntites = totalEntites, + Data = results + }; + + _logger.LogInfo("Successfully returned {ResultCount} invoices out of {TotalCount} total", results.Count, totalEntites); + return Ok(ApiResponse.SuccessResponse(response, $"{results.Count} invoices fetched successfully")); + } + + /// + /// Retrieves complete details of a specific invoice including associated comments, attachments, and payments. + /// + /// The unique identifier of the invoice. + /// Returns invoice details with associated data or a NotFound/BadRequest response. + + [HttpGet("invoice/details/{id}")] + public async Task GetInvoiceDetailsAsync(Guid id) + { + _logger.LogInfo("Fetching details for InvoiceId: {InvoiceId}, TenantId: {TenantId}", id, tenantId); + + // Get the currently logged-in employee + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // Log starting permission checks + _logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + + // Initiate permission check tasks asynchronously + var adminPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); + }); + + var viewPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.ViewCollection, loggedInEmployee.Id); + }); + + var createPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.CreateCollection, loggedInEmployee.Id); + }); + + var editPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.EditCollection, loggedInEmployee.Id); + }); + + var addPaymentPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.AddPayment, loggedInEmployee.Id); + }); + + // Await all permission checks to complete concurrently + await Task.WhenAll(adminPermissionTask, viewPermissionTask, createPermissionTask, editPermissionTask, addPaymentPermissionTask); + + // Capture permission results + var hasAdminPermission = adminPermissionTask.Result; + var hasViewPermission = viewPermissionTask.Result; + var hasCreatePermission = createPermissionTask.Result; + var hasEditPermission = editPermissionTask.Result; + var hasAddPaymentPermission = addPaymentPermissionTask.Result; + + // Log permission results for audit + _logger.LogInfo("Permission results for EmployeeId {EmployeeId}: Admin={Admin}, View={View}, Create={Create}, Edit={Edit}, Add Payment={AddPayment}", + loggedInEmployee.Id, hasAdminPermission, hasViewPermission, hasCreatePermission, hasEditPermission, hasAddPaymentPermission); + + // Check if user has any relevant permission; if none, deny access + if (!hasAdminPermission && !hasViewPermission && !hasCreatePermission && !hasEditPermission && !hasAddPaymentPermission) + { + _logger.LogWarning("Permission denied for EmployeeId {EmployeeId} - No collection-related permissions found.", loggedInEmployee.Id); + return StatusCode(403, ApiResponse.ErrorResponse( + "Access Denied", + "User does not have permission to access collection data.", + 403)); + } + + // Optionally log success or continue with further processing here + _logger.LogInfo("Permission granted for EmployeeId {EmployeeId} - Proceeding with collection access.", loggedInEmployee.Id); + + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + // Retrieve primary invoice details with related entities (project, created/updated by + roles) + var invoice = await context.Invoices + .Include(i => i.Project) + .Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole) + .Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole) + .AsNoTracking() + .FirstOrDefaultAsync(i => i.Id == id && i.TenantId == tenantId); + + if (invoice == null) + { + _logger.LogWarning("Invoice not found. InvoiceId: {InvoiceId}, TenantId: {TenantId}", id, tenantId); + return NotFound(ApiResponse.ErrorResponse("Invoice not found", "The specified invoice does not exist.", 404)); + } + + _logger.LogInfo("Invoice {InvoiceId} found. Fetching related data...", id); + + // Parallelize loading of child collections using independent DbContext instances — efficient and thread-safe + var commentsTask = LoadInvoiceCommentsAsync(id, tenantId); + var attachmentsTask = LoadInvoiceAttachmentsAsync(id, tenantId); + var paymentsTask = LoadReceivedInvoicePaymentsAsync(id, tenantId); + + await Task.WhenAll(commentsTask, attachmentsTask, paymentsTask); + + var comments = commentsTask.Result; + var attachments = attachmentsTask.Result; + var receivedInvoicePayments = paymentsTask.Result; + + // Map invoice to response view model + var response = _mapper.Map(invoice); + + // Populate related data + if (comments.Any()) + response.Comments = _mapper.Map>(comments); + + if (attachments.Any()) + { + response.Attachments = attachments.Select(a => + { + var result = _mapper.Map(a); + result.PreSignedUrl = _s3Service.GeneratePreSignedUrl(a.Document!.S3Key); + result.UploadedBy = _mapper.Map(a.Document.UploadedBy); + result.FileName = a.Document.FileName; + result.ContentType = a.Document.ContentType; + return result; + }).ToList(); + } + + if (receivedInvoicePayments.Any()) + response.ReceivedInvoicePayments = _mapper.Map>(receivedInvoicePayments); + + // Compute total paid and balance amounts + double totalPaidAmount = receivedInvoicePayments.Sum(rip => rip.Amount); + double totalAmount = invoice.BasicAmount + invoice.TaxAmount; + response.BalanceAmount = totalAmount - totalPaidAmount; + + _logger.LogInfo("Invoice {InvoiceId} details fetched successfully: Total = {TotalAmount}, Paid = {PaidAmount}, Balance = {BalanceAmount}", + id, totalAmount, totalPaidAmount, response.BalanceAmount); + + return Ok(ApiResponse.SuccessResponse(response, "Invoice details fetched successfully", 200)); + } + + #endregion + + #region =================================================================== Post Functions =================================================================== + + [HttpPost("invoice/create")] + public async Task CreateInvoiceAsync([FromBody] InvoiceDto model) + { + // Get the currently logged-in employee + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // Log starting permission checks + _logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + + // Initiate permission check tasks asynchronously + var adminPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); + }); + + var createPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.CreateCollection, loggedInEmployee.Id); + }); + + // Await all permission checks to complete concurrently + await Task.WhenAll(adminPermissionTask, createPermissionTask); + + // Capture permission results + var hasAdminPermission = adminPermissionTask.Result; + var hasCreatePermission = createPermissionTask.Result; + + // Log permission results for audit + _logger.LogInfo("Permission results for EmployeeId {EmployeeId}: Admin={Admin}, Create={Create}", + loggedInEmployee.Id, hasAdminPermission, hasCreatePermission); + + // Check if user has any relevant permission; if none, deny access + if (!hasAdminPermission && !hasCreatePermission) + { + _logger.LogWarning("Permission denied for EmployeeId {EmployeeId} - No collection-related permissions found.", loggedInEmployee.Id); + return StatusCode(403, ApiResponse.ErrorResponse( + "Access Denied", + "User does not have permission to access collection data.", + 403)); + } + + // Optionally log success or continue with further processing here + _logger.LogInfo("Permission granted for EmployeeId {EmployeeId} - Proceeding with collection access.", loggedInEmployee.Id); + + await using var _context = await _dbContextFactory.CreateDbContextAsync(); + + _logger.LogInfo("Starting invoice creation for ProjectId: {ProjectId} by EmployeeId: {EmployeeId}", + model.ProjectId, loggedInEmployee.Id); + + if (model.InvoiceNumber.Length > 17) + { + _logger.LogWarning("Invoice Number {InvoiceNumber} is greater than 17 charater", + model.InvoiceNumber); + return BadRequest(ApiResponse.ErrorResponse( + "Invoice Number cannot be greater than 17 charater", + "Invoice Number is greater than 17 charater", 400)); + } + + // Validate date sequence + if (model.InvoiceDate.Date > DateTime.UtcNow.Date) + { + _logger.LogWarning("Invoice date {InvoiceDate} cannot be in the future.", + model.InvoiceDate); + return BadRequest(ApiResponse.ErrorResponse( + "Invoice date cannot be in the future", + "Invoice date cannot be in the future", 400)); + } + if (model.InvoiceDate.Date > model.ClientSubmitedDate.Date) + { + _logger.LogWarning("Invoice date {InvoiceDate} is later than client submitted date {ClientSubmitedDate}", + model.InvoiceDate, model.ClientSubmitedDate); + return BadRequest(ApiResponse.ErrorResponse( + "Invoice date cannot be later than the client submitted date", + "Invoice date is later than client submitted date", 400)); + } + if (model.ClientSubmitedDate.Date > DateTime.UtcNow.Date) + { + _logger.LogWarning("Client submited date {ClientSubmitedDate} cannot be in the future.", + model.InvoiceDate); + return BadRequest(ApiResponse.ErrorResponse( + "Client submited date cannot be in the future", + "Client submited date cannot be in the future", 400)); + } + if (model.ClientSubmitedDate.Date > model.ExceptedPaymentDate.Date) + { + _logger.LogWarning("Client submitted date {ClientSubmitedDate} is later than expected payment date {ExpectedPaymentDate}", + model.ClientSubmitedDate, model.ExceptedPaymentDate); + return BadRequest(ApiResponse.ErrorResponse( + "Client submission date cannot be later than the expected payment date", + "Client submitted date is later than expected payment date", 400)); + } + + // Check for existing InvoiceNumber for this tenant before creating/updating to maintain uniqueness. + var invoiceNumberExists = await _context.Invoices + .AnyAsync(i => i.InvoiceNumber == model.InvoiceNumber && i.TenantId == tenantId); + + if (invoiceNumberExists) + { + // Log the conflict event with full context for audit/review. + _logger.LogWarning( + "Invoice number conflict detected for InvoiceNumber: {InvoiceNumber} and TenantId: {TenantId}", + model.InvoiceNumber, tenantId); + + // Return HTTP 409 (Conflict) with a descriptive, actionable message. + return StatusCode(409, ApiResponse.ErrorResponse( + "Invoice number already exists", + $"The invoice number '{model.InvoiceNumber}' is already in use for this tenant. Please choose a unique invoice number.", + 409)); + } + + // If E-InvoiceNumber is provided (optional), validate its uniqueness for this tenant. + if (!string.IsNullOrWhiteSpace(model.EInvoiceNumber)) + { + var eInvoiceNumberExists = await _context.Invoices + .AnyAsync(i => i.EInvoiceNumber == model.EInvoiceNumber && i.TenantId == tenantId); + + if (eInvoiceNumberExists) + { + _logger.LogWarning( + "E-Invoice number conflict detected for EInvoiceNumber: {EInvoiceNumber} and TenantId: {TenantId}", + model.EInvoiceNumber, tenantId); + + // Return HTTP 409 (Conflict) with a tailored message for E-Invoice. + return StatusCode(409, ApiResponse.ErrorResponse( + "E-Invoice number already exists", + $"The E-Invoice number '{model.EInvoiceNumber}' is already assigned to another invoice for this tenant. Please provide a unique E-Invoice number.", + 409)); + } + } + + + // Fetch project + var project = await _context.Projects + .FirstOrDefaultAsync(p => p.Id == model.ProjectId && p.TenantId == tenantId); + if (project == null) + { + _logger.LogWarning("Project not found: ProjectId {ProjectId}, TenantId {TenantId}", + model.ProjectId, tenantId); + return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); + } + + // Begin transaction scope with async flow support + await using var transaction = await _context.Database.BeginTransactionAsync(); + var invoice = new Invoice(); + try + { + // Map and create invoice + invoice = _mapper.Map(model); + invoice.IsActive = true; + invoice.MarkAsCompleted = false; + invoice.CreatedAt = DateTime.UtcNow; + invoice.CreatedById = loggedInEmployee.Id; + invoice.TenantId = tenantId; + + _context.Invoices.Add(invoice); + await _context.SaveChangesAsync(); // Save to generate invoice.Id + + // Handle attachments + var documents = new List(); + var invoiceAttachments = new List(); + if (model.Attachments?.Any() == true) + { + var batchId = Guid.NewGuid(); + + foreach (var attachment in model.Attachments) + { + string base64 = attachment.Base64Data?.Split(',').LastOrDefault() ?? ""; + if (string.IsNullOrWhiteSpace(base64)) + { + _logger.LogWarning("Base64 data is missing for attachment {FileName}", attachment.FileName ?? ""); + return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Image data missing", 400)); + } + + var fileType = _s3Service.GetContentTypeFromBase64(base64); + var fileName = _s3Service.GenerateFileName(fileType, tenantId, "invoice"); + var objectKey = $"tenant-{tenantId}/Project/{model.ProjectId}/Invoice/{fileName}"; + + await _s3Service.UploadFileAsync(base64, fileType, objectKey); + + var document = new Document + { + Id = Guid.NewGuid(), + BatchId = batchId, + UploadedById = loggedInEmployee.Id, + FileName = attachment.FileName ?? fileName, + ContentType = attachment.ContentType, + S3Key = objectKey, + FileSize = attachment.FileSize, + UploadedAt = DateTime.UtcNow, + TenantId = tenantId + }; + documents.Add(document); + + var invoiceAttachment = new InvoiceAttachment + { + InvoiceId = invoice.Id, + DocumentId = document.Id, + TenantId = tenantId + }; + invoiceAttachments.Add(invoiceAttachment); + } + + _context.Documents.AddRange(documents); + _context.InvoiceAttachments.AddRange(invoiceAttachments); + await _context.SaveChangesAsync(); // Save attachments and mappings + } + + // Commit transaction + await transaction.CommitAsync(); + _logger.LogInfo("Invoice {InvoiceId} created successfully with {AttachmentCount} attachments.", + invoice.Id, documents.Count); + } + catch (Exception ex) + { + await transaction.RollbackAsync(); + _logger.LogError(ex, "Transaction rolled back during invoice creation for ProjectId {ProjectId}", model.ProjectId); + return StatusCode(500, ApiResponse.ErrorResponse( + "Transaction failed: " + ex.Message, + "An error occurred while creating the invoice", 500)); + } + + // Build response + var response = _mapper.Map(invoice); + response.Project = _mapper.Map(project); + response.CreatedBy = _mapper.Map(loggedInEmployee); + response.BalanceAmount = response.BasicAmount + response.TaxAmount; + + return StatusCode(201, ApiResponse.SuccessResponse(response, "Invoice Created Successfully", 201)); + } + + /// + /// Creates a new received invoice payment record after validating business rules. + /// + /// The received invoice payment data transfer object containing payment details. + /// An action result containing the created payment view model or error response. + + [HttpPost("invoice/payment/received")] + public async Task CreateReceivedInvoicePaymentAsync([FromBody] ReceivedInvoicePaymentDto model) + { + // Get the currently logged-in employee + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // Log starting permission checks + _logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + + // Initiate permission check tasks asynchronously + var adminPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); + }); + + var addPaymentPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.AddPayment, loggedInEmployee.Id); + }); + + // Await all permission checks to complete concurrently + await Task.WhenAll(adminPermissionTask, addPaymentPermissionTask); + + // Capture permission results + var hasAdminPermission = adminPermissionTask.Result; + var hasAddPaymentPermission = addPaymentPermissionTask.Result; + + // Log permission results for audit + _logger.LogInfo("Permission results for EmployeeId {EmployeeId}: Admin={Admin}, Add Payment={AddPayment}", + loggedInEmployee.Id, hasAdminPermission, hasAddPaymentPermission); + + // Check if user has any relevant permission; if none, deny access + if (!hasAdminPermission && !hasAddPaymentPermission) + { + _logger.LogWarning("Permission denied for EmployeeId {EmployeeId} - No collection-related permissions found.", loggedInEmployee.Id); + return StatusCode(403, ApiResponse.ErrorResponse( + "Access Denied", + "User does not have permission to access collection data.", + 403)); + } + + // Optionally log success or continue with further processing here + _logger.LogInfo("Permission granted for EmployeeId {EmployeeId} - Proceeding with collection access.", loggedInEmployee.Id); + + // Validate input model + if (model == null) + { + _logger.LogWarning("Received invoice payment creation request with null model"); + return BadRequest(ApiResponse.ErrorResponse("Invalid model", "Request payload cannot be null", 400)); + } + + await using var _context = await _dbContextFactory.CreateDbContextAsync(); + + // Retrieve invoice with tenant isolation and no tracking for read-only access + var invoice = await _context.Invoices + .AsNoTracking() + .FirstOrDefaultAsync(i => i.Id == model.InvoiceId && i.TenantId == tenantId); + + if (invoice == null) + { + _logger.LogWarning("Invoice not found for ID {InvoiceId} and TenantId {TenantId}", model.InvoiceId, tenantId); + return NotFound(ApiResponse.ErrorResponse("Invoice not found", "The specified invoice does not exist", 404)); + } + + // Check if invoice is already marked as completed + if (invoice.MarkAsCompleted) + { + _logger.LogWarning("Attempt to add payment to completed invoice {InvoiceId}", model.InvoiceId); + return BadRequest(ApiResponse.ErrorResponse( + "Cannot add received payment to completed invoice", + "Payments cannot be added to invoices that are already marked as completed", 400)); + } + + // Validate payment received date is not in the future + if (model.PaymentReceivedDate.Date > DateTime.UtcNow.Date) + { + _logger.LogWarning("Future payment date {PaymentReceivedDate} provided for invoice {InvoiceId}", + model.PaymentReceivedDate, model.InvoiceId); + return BadRequest(ApiResponse.ErrorResponse( + "Payment received date cannot be in the future", + "The payment received date must not be later than the current date", 400)); + } + + // Validate client submitted date is not later than payment received date + if (invoice.ClientSubmitedDate.Date > model.PaymentReceivedDate.Date) + { + _logger.LogWarning("Client submitted date {ClientSubmitedDate} is later than payment received date {PaymentReceivedDate} for invoice {InvoiceId}", + invoice.ClientSubmitedDate, model.PaymentReceivedDate, model.InvoiceId); + return BadRequest(ApiResponse.ErrorResponse( + "Client submission date cannot be later than the payment received date", + "The client submission date cannot be later than the payment received date", 400)); + } + + // Retrieve all previous payments for the given invoice and tenant in a single, efficient query. + var receivedInvoicePayments = await _context.ReceivedInvoicePayments + .Where(rip => rip.InvoiceId == invoice.Id && rip.TenantId == tenantId) + .Select(rip => rip.Amount) // Only select required field for better performance. + .ToListAsync(); + + // Calculate the sum of all previous payments to determine the total paid so far. + var previousPaidAmount = receivedInvoicePayments.Sum(); + var totalPaidAmount = previousPaidAmount + model.Amount; + + // Compute the invoice's total amount payable including taxes. + var totalAmount = invoice.BasicAmount + invoice.TaxAmount; + + // Business rule validation: Prevent the overpayment scenario. + if (totalPaidAmount > totalAmount) + { + // Log the details for easier debugging and audit trails. + _logger.LogWarning( + "Overpayment attempt detected. InvoiceId: {InvoiceId}, TenantId: {TenantId}, TotalInvoiceAmount: {TotalInvoiceAmount}, PreviousPaidAmount: {PreviousPaidAmount}, AttemptedPayment: {AttemptedPayment}, CalculatedTotalPaid: {TotalPaidAmount}.", + invoice.Id, tenantId, totalAmount, previousPaidAmount, model.Amount, totalPaidAmount); + + // Return a bad request response with a clear, actionable error message. + return BadRequest(ApiResponse.ErrorResponse( + "Invalid payment: total paid amount exceeds invoice total.", + $"The total of existing payments ({previousPaidAmount}) plus the new payment ({model.Amount}) would exceed the invoice total ({totalAmount}). Please verify payment details.", + 400)); + } + + + try + { + // Map DTO to entity and set creation metadata + var receivedInvoicePayment = _mapper.Map(model); + receivedInvoicePayment.CreatedAt = DateTime.UtcNow; + receivedInvoicePayment.CreatedById = loggedInEmployee.Id; + receivedInvoicePayment.TenantId = tenantId; + + // Add new payment record and save changes + _context.ReceivedInvoicePayments.Add(receivedInvoicePayment); + await _context.SaveChangesAsync(); + + // Map entity to view model for response + var response = _mapper.Map(receivedInvoicePayment); + + _logger.LogInfo("Successfully created received payment {PaymentId} for invoice {InvoiceId}", + receivedInvoicePayment.Id, model.InvoiceId); + + return StatusCode(201, ApiResponse.SuccessResponse(response, "Payment invoice received successfully", 201)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while creating received payment for invoice {InvoiceId}", model.InvoiceId); + return StatusCode(500, ApiResponse.ErrorResponse( + "Internal server error", + "An unexpected error occurred while processing the request", 500)); + } + } + + /// + /// Adds a comment to the specified invoice, validating model and invoice existence. + /// + /// DTO containing InvoiceId and Comment text. + /// 201 Created with comment details, or error codes for validation/invoice not found. + + [HttpPost("invoice/add/comment")] + public async Task AddCommentToInvoiceAsync([FromBody] InvoiceCommentDto model) + { + // Get the currently logged-in employee + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // Log starting permission checks + _logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + + // Initiate permission check tasks asynchronously + var adminPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); + }); + + var viewPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.ViewCollection, loggedInEmployee.Id); + }); + + var createPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.CreateCollection, loggedInEmployee.Id); + }); + + var editPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.EditCollection, loggedInEmployee.Id); + }); + + var addPaymentPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.AddPayment, loggedInEmployee.Id); + }); + + // Await all permission checks to complete concurrently + await Task.WhenAll(adminPermissionTask, viewPermissionTask, createPermissionTask, editPermissionTask, addPaymentPermissionTask); + + // Capture permission results + var hasAdminPermission = adminPermissionTask.Result; + var hasViewPermission = viewPermissionTask.Result; + var hasCreatePermission = createPermissionTask.Result; + var hasEditPermission = editPermissionTask.Result; + var hasAddPaymentPermission = addPaymentPermissionTask.Result; + + // Log permission results for audit + _logger.LogInfo("Permission results for EmployeeId {EmployeeId}: Admin={Admin}, View={View}, Create={Create}, Edit={Edit}, Add Payment={AddPayment}", + loggedInEmployee.Id, hasAdminPermission, hasViewPermission, hasCreatePermission, hasEditPermission, hasAddPaymentPermission); + + // Check if user has any relevant permission; if none, deny access + if (!hasAdminPermission && !hasViewPermission && !hasCreatePermission && !hasEditPermission && !hasAddPaymentPermission) + { + _logger.LogWarning("Permission denied for EmployeeId {EmployeeId} - No collection-related permissions found.", loggedInEmployee.Id); + return StatusCode(403, ApiResponse.ErrorResponse( + "Access Denied", + "User does not have permission to access collection data.", + 403)); + } + + // Optionally log success or continue with further processing here + _logger.LogInfo("Permission granted for EmployeeId {EmployeeId} - Proceeding with collection access.", loggedInEmployee.Id); + + // Validate incoming data early to avoid unnecessary database calls. + if (string.IsNullOrWhiteSpace(model.Comment)) + { + _logger.LogWarning("Invalid or missing comment data for InvoiceId {InvoiceId}, TenantId {TenantId}", model.InvoiceId, tenantId); + return BadRequest(ApiResponse.ErrorResponse( + "Invalid comment data", + "The comment text and model must not be null or empty.", + 400)); + } + + await using var _context = await _dbContextFactory.CreateDbContextAsync(); + + // Find the target invoice for the specified tenant. + var invoice = await _context.Invoices + .FirstOrDefaultAsync(i => i.Id == model.InvoiceId && i.TenantId == tenantId); + + if (invoice == null) + { + _logger.LogWarning("Cannot add comment—invoice not found. InvoiceId {InvoiceId}, TenantId {TenantId}", model.InvoiceId, tenantId); + return NotFound(ApiResponse.ErrorResponse( + "Invoice not found", + $"Invoice with ID '{model.InvoiceId}' does not exist for the specified tenant.", + 404)); + } + + // Construct the new comment entity with required audit metadata. + var comment = new InvoiceComment + { + Id = Guid.NewGuid(), + Comment = model.Comment.Trim(), + InvoiceId = model.InvoiceId, + CreatedAt = DateTime.UtcNow, + CreatedById = loggedInEmployee.Id, + TenantId = tenantId + }; + + _context.InvoiceComments.Add(comment); + await _context.SaveChangesAsync(); + + _logger.LogInfo("Added new comment to invoice {InvoiceId} by employee {EmployeeId}, TenantId {TenantId}", + comment.InvoiceId, loggedInEmployee.Id, tenantId); + + var response = _mapper.Map(comment); + + // Return successful creation with comment details. + return StatusCode(201, ApiResponse.SuccessResponse( + response, + "Comment added to invoice successfully.", + 201)); + } + + #endregion + + #region =================================================================== Put Functions =================================================================== + + /// + /// Updates an existing invoice if it exists, has no payments, and the model is valid. + /// + /// The unique identifier of the invoice to update. + /// The updated invoice data transfer object. + /// Success response on update, or appropriate error if validation fails. + + [HttpPut("invoice/edit/{id}")] + public async Task UpdateInvoiceAsync(Guid id, [FromBody] InvoiceDto model) + { + // Get the currently logged-in employee + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // Log starting permission checks + _logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + + // Initiate permission check tasks asynchronously + var adminPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); + }); + + var editPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + return await _permission.HasPermission(PermissionsMaster.EditCollection, loggedInEmployee.Id); + }); + + // Await all permission checks to complete concurrently + await Task.WhenAll(adminPermissionTask, editPermissionTask); + + // Capture permission results + var hasAdminPermission = adminPermissionTask.Result; + var hasEditPermission = editPermissionTask.Result; + + // Log permission results for audit + _logger.LogInfo("Permission results for EmployeeId {EmployeeId}: Admin={Admin}, Process={Process}", + loggedInEmployee.Id, hasAdminPermission, hasEditPermission); + + // Check if user has any relevant permission; if none, deny access + if (!hasAdminPermission && !hasEditPermission) + { + _logger.LogWarning("Permission denied for EmployeeId {EmployeeId} - No collection-related permissions found.", loggedInEmployee.Id); + return StatusCode(403, ApiResponse.ErrorResponse( + "Access Denied", + "User does not have permission to access collection data.", + 403)); + } + + // Optionally log success or continue with further processing here + _logger.LogInfo("Permission granted for EmployeeId {EmployeeId} - Proceeding with collection access.", loggedInEmployee.Id); + + // Validate route and model ID consistency + if (!model.Id.HasValue || id != model.Id) + { + _logger.LogWarning("Invoice ID mismatch: route ID {RouteId} does not match model ID {ModelId}", id, model.Id ?? Guid.Empty); + return BadRequest(ApiResponse.ErrorResponse( + "Invalid invoice ID", + "The invoice ID in the URL does not match the ID in the request body.", + 400)); + } + + await using var _context = await _dbContextFactory.CreateDbContextAsync(); + using var scope = _serviceScopeFactory.CreateScope(); + var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); + + // Retrieve the invoice with tenant isolation + var invoice = await _context.Invoices + .FirstOrDefaultAsync(i => i.Id == id && i.TenantId == tenantId); + + if (invoice == null) + { + _logger.LogWarning("Invoice not found for ID {InvoiceId} and TenantId {TenantId}", id, tenantId); + return NotFound(ApiResponse.ErrorResponse( + "Invoice not found", + "The specified invoice does not exist for this tenant.", + 404)); + } + + // Fetch project + var project = await _context.Projects + .FirstOrDefaultAsync(p => p.Id == model.ProjectId && p.TenantId == tenantId); + if (project == null) + { + _logger.LogWarning("Project not found: ProjectId {ProjectId}, TenantId {TenantId}", + model.ProjectId, tenantId); + return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); + } + + // Prevent modification if any payment has already been received + var receivedPaymentExists = await _context.ReceivedInvoicePayments + .AnyAsync(rip => rip.InvoiceId == id && rip.TenantId == tenantId); + + if (receivedPaymentExists) + { + _logger.LogWarning("Update blocked: Payment already received for InvoiceId {InvoiceId}, TenantId {TenantId}", id, tenantId); + return BadRequest(ApiResponse.ErrorResponse( + "Invoice cannot be updated", + "This invoice has received payments and cannot be modified.", + 400)); + } + + try + { + var invoiceStateBeforeChange = _updateLogHelper.EntityToBsonDocument(invoice); + + // Map updated data to existing invoice entity + _mapper.Map(model, invoice); + invoice.UpdatedAt = DateTime.UtcNow; + invoice.UpdatedById = loggedInEmployee.Id; + + // Handle attachment updates if provided + if (model.Attachments?.Any() ?? false) + { + var inactiveDocumentIds = model.Attachments + .Where(a => !a.IsActive && a.DocumentId.HasValue) + .Select(a => a.DocumentId!.Value) + .ToList(); + + var newAttachments = model.Attachments + .Where(a => a.IsActive && !string.IsNullOrWhiteSpace(a.Base64Data)) + .ToList(); + + // Remove inactive attachments + if (inactiveDocumentIds.Any()) + { + var existingInvoiceAttachments = await _context.InvoiceAttachments + .AsNoTracking() + .Where(ia => inactiveDocumentIds.Contains(ia.DocumentId) && ia.TenantId == tenantId) + .ToListAsync(); + + _context.InvoiceAttachments.RemoveRange(existingInvoiceAttachments); + _logger.LogInfo("Removed {Count} inactive attachments for InvoiceId {InvoiceId}", existingInvoiceAttachments.Count, id); + } + + // Process and upload new attachments + if (newAttachments.Any()) + { + var batchId = Guid.NewGuid(); + var documents = new List(); + var invoiceAttachments = new List(); + + foreach (var attachment in newAttachments) + { + string base64Data = attachment.Base64Data?.Split(',').LastOrDefault() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(base64Data)) + { + _logger.LogWarning("Base64 data missing for attachment: {FileName}", attachment.FileName ?? "Unknown"); + return BadRequest(ApiResponse.ErrorResponse( + "Invalid attachment data", + "Base64 data is missing or malformed for one or more attachments.", + 400)); + } + + var contentType = _s3Service.GetContentTypeFromBase64(base64Data); + var fileName = _s3Service.GenerateFileName(contentType, tenantId, "invoice"); + var objectKey = $"tenant-{tenantId}/Project/{model.ProjectId}/Invoice/{fileName}"; + + // Upload file to S3 + await _s3Service.UploadFileAsync(base64Data, contentType, objectKey); + + // Create document record + var document = new Document + { + Id = Guid.NewGuid(), + BatchId = batchId, + UploadedById = loggedInEmployee.Id, + FileName = attachment.FileName ?? fileName, + ContentType = contentType, + S3Key = objectKey, + FileSize = attachment.FileSize, + UploadedAt = DateTime.UtcNow, + TenantId = tenantId + }; + documents.Add(document); + + // Link document to invoice + var invoiceAttachment = new InvoiceAttachment + { + InvoiceId = invoice.Id, + DocumentId = document.Id, + TenantId = tenantId + }; + invoiceAttachments.Add(invoiceAttachment); + } + + _context.Documents.AddRange(documents); + _context.InvoiceAttachments.AddRange(invoiceAttachments); + _logger.LogInfo("Added {Count} new attachments to InvoiceId {InvoiceId}", invoiceAttachments.Count, id); + } + } + + // Save all changes in a single transaction + await _context.SaveChangesAsync(); + + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = invoice.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = invoiceStateBeforeChange, + UpdatedAt = DateTime.UtcNow + }, "InvoiceModificationLog"); + + _logger.LogInfo("Invoice {InvoiceId} updated successfully by EmployeeId {EmployeeId}, TenantId {TenantId}", + invoice.Id, loggedInEmployee.Id, tenantId); + + // Build response + var response = _mapper.Map(invoice); + response.Project = _mapper.Map(project); + response.UpdatedBy = _mapper.Map(loggedInEmployee); + response.BalanceAmount = response.BasicAmount + response.TaxAmount; + + return Ok(ApiResponse.SuccessResponse(response, "Invoice updated successfully", 200)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while updating InvoiceId {InvoiceId}, TenantId {TenantId}", id, tenantId); + return StatusCode(500, ApiResponse.ErrorResponse( + "Internal server error", + "An unexpected error occurred while updating the invoice.", + 500)); + } + } + + /// + /// Marks the specified invoice as completed if it exists and is not already completed. + /// + /// The unique identifier of the invoice to mark as completed. + /// An action result indicating success or the nature of the error. + + [HttpPut("invoice/marked/completed/{invoiceId}")] + public async Task MarkAsCompletedAsync(Guid invoiceId) + { + // Create a scope for permission service resolution + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); + + // Get the currently logged-in employee + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // Log starting permission checks + _logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + + // Capture permission results + var hasAdminPermission = await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); + + // Log permission results for audit + _logger.LogInfo("Permission results for EmployeeId {EmployeeId}: Admin={Admin}", + loggedInEmployee.Id, hasAdminPermission); + + // Check if user has any relevant permission; if none, deny access + if (!hasAdminPermission) + { + _logger.LogWarning("Permission denied for EmployeeId {EmployeeId} - No collection-related permissions found.", loggedInEmployee.Id); + return StatusCode(403, ApiResponse.ErrorResponse( + "Access Denied", + "User does not have permission to access collection data.", + 403)); + } + + // Optionally log success or continue with further processing here + _logger.LogInfo("Permission granted for EmployeeId {EmployeeId} - Proceeding with collection access.", loggedInEmployee.Id); + + // Create a new async database context for the current request's scope. + await using var _context = await _dbContextFactory.CreateDbContextAsync(); + + // Attempt to find the invoice with tenant isolation; use AsNoTracking if no updates needed (but here we update so tracking is okay). + var invoice = await _context.Invoices + .FirstOrDefaultAsync(i => i.Id == invoiceId && i.TenantId == tenantId); + + // Log and return 404 if the invoice does not exist. + if (invoice == null) + { + _logger.LogWarning("Invoice not found for ID {InvoiceId} and TenantId {TenantId}", invoiceId, tenantId); + return NotFound(ApiResponse.ErrorResponse("Invoice not found", "The specified invoice does not exist", 404)); + } + + // If the invoice is already marked as completed, log and return meaningful error. + if (invoice.MarkAsCompleted) + { + _logger.LogWarning("Attempt to mark already completed invoice {InvoiceId} as completed by user {UserId}", invoiceId, loggedInEmployee.Id); + return BadRequest(ApiResponse.ErrorResponse( + "Invoice already completed", + "Invoice is already marked as completed", 400)); + } + + try + { + var invoiceStateBeforeChange = _updateLogHelper.EntityToBsonDocument(invoice); + + // Mark invoice as completed. + invoice.MarkAsCompleted = true; + invoice.UpdatedAt = DateTime.UtcNow; + invoice.UpdatedById = loggedInEmployee.Id; + + // Persist the change to the database. + await _context.SaveChangesAsync(); + + _logger.LogInfo("Invoice {InvoiceId} marked as completed by user {UserId}", invoiceId, loggedInEmployee.Id); + + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = invoice.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = invoiceStateBeforeChange, + UpdatedAt = DateTime.UtcNow + }, "InvoiceModificationLog"); + + var response = _mapper.Map(invoice); + response.UpdatedBy = _mapper.Map(loggedInEmployee); + response.BalanceAmount = response.BasicAmount + response.TaxAmount; + + return Ok(ApiResponse.SuccessResponse(response, "Invoice is marked as completed successfully", 200)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while marking invoice {InvoiceId} as completed by user {UserId}", invoiceId, loggedInEmployee.Id); + return StatusCode(500, ApiResponse.ErrorResponse( + "Internal server error", + "An unexpected error occurred while processing the request", 500)); + } + } + + #endregion + + #region =================================================================== Helper Functions =================================================================== + + /// + /// Loads invoice comments asynchronously with related metadata. + /// + private async Task> LoadInvoiceCommentsAsync(Guid invoiceId, Guid tenantId) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.InvoiceComments + .Include(ic => ic.CreatedBy).ThenInclude(e => e!.JobRole) + .AsNoTracking() + .Where(ic => ic.InvoiceId == invoiceId && ic.TenantId == tenantId) + .ToListAsync(); + } + + /// + /// Loads invoice attachments and their upload metadata asynchronously. + /// + private async Task> LoadInvoiceAttachmentsAsync(Guid invoiceId, Guid tenantId) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.InvoiceAttachments + .Include(ia => ia.Document) + .ThenInclude(d => d!.UploadedBy) + .ThenInclude(e => e!.JobRole) + .AsNoTracking() + .Where(ia => ia.InvoiceId == invoiceId && ia.TenantId == tenantId && ia.Document != null && ia.Document.UploadedBy != null) + .ToListAsync(); + } + + /// + /// Loads received invoice payment records asynchronously with creator metadata. + /// + private async Task> LoadReceivedInvoicePaymentsAsync(Guid invoiceId, Guid tenantId) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.ReceivedInvoicePayments + .Include(rip => rip.PaymentAdjustmentHead) + .Include(rip => rip.CreatedBy).ThenInclude(e => e!.JobRole) + .AsNoTracking() + .Where(rip => rip.InvoiceId == invoiceId && rip.TenantId == tenantId) + .ToListAsync(); + } + + #endregion + + } +} diff --git a/Marco.Pms.Services/Controllers/MasterController.cs b/Marco.Pms.Services/Controllers/MasterController.cs index 393d423..e0b969d 100644 --- a/Marco.Pms.Services/Controllers/MasterController.cs +++ b/Marco.Pms.Services/Controllers/MasterController.cs @@ -1,5 +1,6 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Activities; +using Marco.Pms.Model.Dtos.Collection; using Marco.Pms.Model.Dtos.DocumentManager; using Marco.Pms.Model.Dtos.Master; using Marco.Pms.Model.Forum; @@ -975,5 +976,38 @@ namespace Marco.Pms.Services.Controllers } #endregion + + #region =================================================================== Payment Adjustment Head APIs =================================================================== + [HttpGet("payment-adjustment-head/list")] + public async Task GetpaymentAdjustmentHeadsList([FromQuery] bool isActive = true) + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _masterService.GetPaymentAdjustmentHeadListAsync(isActive, loggedInEmployee, tenantId); + return StatusCode(response.StatusCode, response); + } + [HttpPost("payment-adjustment-head")] + public async Task CreatePaymentAdjustmentHead([FromBody] PaymentAdjustmentHeadDto dto) + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _masterService.CreatePaymentAdjustmentHeadAsync(dto, loggedInEmployee, tenantId); + return StatusCode(response.StatusCode, response); + } + + [HttpPut("payment-adjustment-head/edit/{id}")] + public async Task UpdatePaymentAdjustmentHead(Guid id, [FromBody] PaymentAdjustmentHeadDto dto) + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _masterService.UpdatePaymentAdjustmentHeadAsync(id, dto, loggedInEmployee, tenantId); + return StatusCode(response.StatusCode, response); + } + + [HttpDelete("payment-adjustment-head/delete/{id}")] + public async Task DeletePaymentAdjustmentHead(Guid id, [FromQuery] bool isActive = false) + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _masterService.DeletePaymentAdjustmentHeadAsync(id, isActive, loggedInEmployee, tenantId); + return StatusCode(response.StatusCode, response); + } + #endregion } } diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index ef0873a..2518013 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -1,9 +1,11 @@ using AutoMapper; using Marco.Pms.Model.AppMenu; +using Marco.Pms.Model.Collection; using Marco.Pms.Model.Directory; using Marco.Pms.Model.DocumentManager; using Marco.Pms.Model.Dtos.Activities; using Marco.Pms.Model.Dtos.AppMenu; +using Marco.Pms.Model.Dtos.Collection; using Marco.Pms.Model.Dtos.Directory; using Marco.Pms.Model.Dtos.DocumentManager; using Marco.Pms.Model.Dtos.Employees; @@ -26,6 +28,7 @@ using Marco.Pms.Model.Projects; using Marco.Pms.Model.TenantModels; using Marco.Pms.Model.TenantModels.MongoDBModel; using Marco.Pms.Model.ViewModels.Activities; +using Marco.Pms.Model.ViewModels.Collection; using Marco.Pms.Model.ViewModels.Directory; using Marco.Pms.Model.ViewModels.DocumentManager; using Marco.Pms.Model.ViewModels.Employee; @@ -256,6 +259,19 @@ namespace Marco.Pms.Services.MappingProfiles #endregion + #region ======================================================= Collection ======================================================= + CreateMap(); + CreateMap(); + CreateMap(); + + CreateMap(); + CreateMap(); + + CreateMap(); + + CreateMap(); + #endregion + #region ======================================================= Master ======================================================= CreateMap(); @@ -380,6 +396,35 @@ namespace Marco.Pms.Services.MappingProfiles #endregion + #region ======================================================= Contact Category Master ======================================================= + CreateMap(); + CreateMap(); + CreateMap(); + #endregion + + #region ======================================================= Contact Tag Master ======================================================= + CreateMap(); + CreateMap(); + CreateMap(); + #endregion + + #region ======================================================= Payment Adjustment Head Master ======================================================= + CreateMap(); + CreateMap(); + #endregion + + #region ======================================================= Expenses Status Master ======================================================= + #endregion + + #region ======================================================= Expenses Status Master ======================================================= + #endregion + + #region ======================================================= Expenses Status Master ======================================================= + #endregion + + #region ======================================================= Expenses Status Master ======================================================= + #endregion + #endregion #region ======================================================= Document ======================================================= diff --git a/Marco.Pms.Services/Service/MasterService.cs b/Marco.Pms.Services/Service/MasterService.cs index c693e05..d99c28f 100644 --- a/Marco.Pms.Services/Service/MasterService.cs +++ b/Marco.Pms.Services/Service/MasterService.cs @@ -1,21 +1,22 @@ using AutoMapper; using Marco.Pms.DataAccess.Data; using Marco.Pms.Helpers.Utility; +using Marco.Pms.Model.Collection; using Marco.Pms.Model.Directory; using Marco.Pms.Model.DocumentManager; using Marco.Pms.Model.Dtos.Activities; +using Marco.Pms.Model.Dtos.Collection; using Marco.Pms.Model.Dtos.DocumentManager; using Marco.Pms.Model.Dtos.Master; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; -using Marco.Pms.Model.Mapper; using Marco.Pms.Model.Master; using Marco.Pms.Model.MongoDBModels.Utility; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; +using Marco.Pms.Model.ViewModels.Collection; using Marco.Pms.Model.ViewModels.DocumentManager; using Marco.Pms.Model.ViewModels.Master; -using Marco.Pms.Services.Helpers; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Service; using Microsoft.CodeAnalysis; @@ -26,29 +27,29 @@ namespace Marco.Pms.Services.Service public class MasterService : IMasterService { private readonly IDbContextFactory _dbContextFactory; + private readonly IServiceScopeFactory _serviceScopeFactory; private readonly ApplicationDbContext _context; private readonly ILoggingService _logger; private readonly PermissionServices _permission; private readonly IMapper _mapper; private readonly UtilityMongoDBHelper _updateLogHelper; - private readonly CacheUpdateHelper _cache; public MasterService( IDbContextFactory dbContextFactory, + IServiceScopeFactory serviceScopeFactory, ApplicationDbContext context, ILoggingService logger, PermissionServices permission, IMapper mapper, - UtilityMongoDBHelper updateLogHelper, - CacheUpdateHelper cache) + UtilityMongoDBHelper updateLogHelper) { - _dbContextFactory = dbContextFactory; - _context = context; - _logger = logger; - _permission = permission; - _mapper = mapper; - _updateLogHelper = updateLogHelper; - _cache = cache; + _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); + _serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _permission = permission ?? throw new ArgumentNullException(nameof(permission)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + _updateLogHelper = updateLogHelper ?? throw new ArgumentNullException(nameof(updateLogHelper)); } #region =================================================================== Organization Type APIs =================================================================== @@ -451,6 +452,8 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("Service not found", "The requested service does not exist", 404); } + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(service); + // Step 4: Update and save service.Name = serviceMasterDto.Name.Trim(); service.Description = serviceMasterDto.Description.Trim(); @@ -459,6 +462,14 @@ namespace Marco.Pms.Services.Service var response = _mapper.Map(service); + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = service.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, "ServiceMasterModificationLog"); + _logger.LogInfo("Service updated successfully. Id: {Id}, TenantId: {TenantId}", service.Id, tenantId); return ApiResponse.SuccessResponse(response, "Service updated successfully", 200); } @@ -500,10 +511,27 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("Cannot delete system-defined service", "This service is system-defined and cannot be deleted", 400); } + var activityGroupExists = await _context.ActivityGroupMasters.AnyAsync(ag => ag.ServiceId == service.Id && ag.TenantId == tenantId); + if (activityGroupExists) + { + _logger.LogWarning("Activity group exists for this cannot be deleted ServiceId: {ServiceId}", id); + return ApiResponse.ErrorResponse("Activity group existed for this service cannot delete", "Activity group existed for this service cannot delete", 400); + } + + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(service); + // Step 3: Soft delete or restore service.IsActive = active; await _context.SaveChangesAsync(); + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = service.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, "ServiceMasterModificationLog"); + var status = active ? "restored" : "deactivated"; _logger.LogInfo("Service {ServiceId} has been {Status} successfully by EmployeeId: {EmployeeId}", id, status, loggedInEmployee.Id); @@ -637,14 +665,25 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("Activity group not found", "No such activity group exists", 404); } + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(activityGroup); + // Step 4: Update and save activityGroup.Name = activityGroupDto.Name.Trim(); activityGroup.Description = activityGroupDto.Description.Trim(); + activityGroup.ServiceId = activityGroupDto.ServiceId; await _context.SaveChangesAsync(); var response = _mapper.Map(activityGroup); + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = activityGroup.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, "ActivityGroupMasterModificationLog"); + _logger.LogInfo("Activity group updated successfully. Id: {Id}, TenantId: {TenantId}", activityGroup.Id, tenantId); return ApiResponse.SuccessResponse(response, "Activity group updated successfully", 200); } @@ -686,10 +725,27 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("Cannot delete system-defined activity group", "This activity group is system-defined and cannot be deleted", 400); } + var activityExists = await _context.ActivityMasters.AnyAsync(ag => ag.ActivityGroupId == activityGroup.Id && ag.TenantId == tenantId); + if (activityExists) + { + _logger.LogWarning("Activity exists for this cannot be deleted ActivityGroupId: {ActivityGroupId}", id); + return ApiResponse.ErrorResponse("Activity existed for this service cannot delete", "Activity existed for this service cannot delete", 400); + } + + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(activityGroup); + // Step 3: Perform soft delete or restore activityGroup.IsActive = isActive; await _context.SaveChangesAsync(); + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = activityGroup.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, "ActivityGroupMasterModificationLog"); + var status = isActive ? "restored" : "deactivated"; _logger.LogInfo("ActivityGroup {ActivityGroupId} has been {Status} by EmployeeId: {EmployeeId}", id, status, loggedInEmployee.Id); @@ -885,6 +941,8 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("Activity not found", "Activity not found", 404); } + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(activity); + // Step 4: Update activity core data activity.ActivityName = createActivity.ActivityName.Trim(); activity.UnitOfMeasurement = createActivity.UnitOfMeasurement.Trim(); @@ -892,6 +950,14 @@ namespace Marco.Pms.Services.Service await _context.SaveChangesAsync(); + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = activity.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, "ActivityMasterModificationLog"); + // Step 5: Handle checklist updates var existingChecklists = await _context.ActivityCheckLists .AsNoTracking() @@ -986,10 +1052,20 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("Activity not found", "Activity not found or already deleted", 404); } + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(activity); + // Step 3: Perform soft delete/restore activity.IsActive = isActive; await _context.SaveChangesAsync(); + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = activity.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, "ActivityMasterModificationLog"); + string status = isActive ? "restored" : "deactivated"; _logger.LogInfo("Activity {ActivityId} {Status} successfully by EmployeeId: {EmployeeId}", id, status, loggedInEmployee.Id); @@ -1006,205 +1082,743 @@ namespace Marco.Pms.Services.Service #region =================================================================== Contact Category APIs =================================================================== - public async Task> CreateContactCategory(CreateContactCategoryDto contactCategoryDto, Employee loggedInEmployee, Guid tenantId) - { - if (contactCategoryDto != null) - { - ContactCategoryMaster? existingContactCategory = await _context.ContactCategoryMasters.FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Name.ToLower() == (contactCategoryDto.Name != null ? contactCategoryDto.Name.ToLower() : "")); - if (existingContactCategory == null) - { - ContactCategoryMaster contactCategory = contactCategoryDto.ToContactCategoryMasterFromCreateContactCategoryDto(tenantId); - _context.ContactCategoryMasters.Add(contactCategory); - await _context.SaveChangesAsync(); - ContactCategoryVM categoryVM = contactCategory.ToContactCategoryVMFromContactCategoryMaster(); - - _logger.LogInfo("Employee ID {LoggedInEmployeeId} created a contact category {ContactCategoryId}.", loggedInEmployee.Id, contactCategory.Id); - return ApiResponse.SuccessResponse(categoryVM, "Category Created Successfully", 200); - } - _logger.LogWarning("Employee ID {LoggedInEmployeeId} attempted to create an existing contact category.", loggedInEmployee.Id); - return ApiResponse.ErrorResponse("Category already existed", "Category already existed", 409); - } - _logger.LogWarning("Employee with ID {LoggedInEmployeeId} sended empty payload", loggedInEmployee.Id); - return ApiResponse.ErrorResponse("User Send empty Payload", "User Send empty Payload", 400); - } - public async Task> UpdateContactCategory(Guid id, UpdateContactCategoryDto contactCategoryDto, Employee loggedInEmployee, Guid tenantId) - { - if (contactCategoryDto != null && id == contactCategoryDto.Id) - { - ContactCategoryMaster? contactCategory = await _context.ContactCategoryMasters.FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Id == id); - if (contactCategory != null) - { - contactCategory.Name = contactCategoryDto.Name ?? ""; - contactCategory.Description = contactCategoryDto.Description ?? ""; - - _context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog - { - RefereanceId = contactCategory.Id, - UpdatedById = loggedInEmployee.Id, - UpdateAt = DateTime.UtcNow - }); - - await _context.SaveChangesAsync(); - ContactCategoryVM categoryVM = contactCategory.ToContactCategoryVMFromContactCategoryMaster(); - - _logger.LogInfo("Employee ID {LoggedInEmployeeId} created a contact category {ContactCategoryId}.", loggedInEmployee.Id, contactCategory.Id); - return ApiResponse.SuccessResponse(categoryVM, "Category Created Successfully", 200); - } - _logger.LogWarning("Employee ID {LoggedInEmployeeId} attempted to update a contact category but not found in database.", loggedInEmployee.Id); - return ApiResponse.ErrorResponse("Category not found", "Category not found", 404); - } - _logger.LogWarning("Employee with ID {LoggedInEmployeeId} sended empty payload", loggedInEmployee.Id); - return ApiResponse.ErrorResponse("User Send empty Payload", "User Send empty Payload", 400); - } + /// + /// Retrieves the list of contact categories for the specified tenant. + /// Ensures the tenantId is valid, logs relevant information and handles errors gracefully. + /// + /// The employee making the request. + /// The unique identifier for the tenant. + /// ApiResponse containing a list of contact categories. public async Task> GetContactCategoriesList(Employee loggedInEmployee, Guid tenantId) { - var categoryList = await _context.ContactCategoryMasters.Where(c => c.TenantId == tenantId).ToListAsync(); - List contactCategories = new List(); - foreach (var category in categoryList) + // Validate parameters + if (loggedInEmployee == null) { - ContactCategoryVM categoryVM = category.ToContactCategoryVMFromContactCategoryMaster(); - contactCategories.Add(categoryVM); + _logger.LogWarning("Attempt to fetch contact categories with null employee object"); + return ApiResponse.ErrorResponse("Invalid employee context", "Invalid employee context", 400); + } + + if (tenantId == Guid.Empty) + { + _logger.LogWarning("Attempt to fetch contact categories with empty tenantId by Employee ID {LoggedInEmployeeId}", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Invalid tenant identifier", "Invalid tenant identifier", 400); + } + + try + { + // Fetch categories filtered by tenantId, ensuring no unnecessary tracking + var categoryList = await _context.ContactCategoryMasters + .AsNoTracking() + .Where(c => c.TenantId == tenantId) + .ToListAsync(); + + // Map database entities to view models + var contactCategories = _mapper.Map>(categoryList); + int fetchedCount = contactCategories.Count; + + _logger.LogInfo("{Count} contact categories fetched for TenantId {TenantId} by Employee ID {EmployeeId}", fetchedCount, tenantId, loggedInEmployee.Id); + return ApiResponse.SuccessResponse(contactCategories, $"{fetchedCount} contact categories fetched successfully", 200); + } + catch (Exception ex) + { + // Log exception details with context + _logger.LogError(ex, "Error fetching contact categories for TenantId {TenantId}, Employee ID {EmployeeId}", tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("An unexpected error occurred while fetching categories", "An unexpected error occurred while fetching categories", 500); } - _logger.LogInfo("{count} contact categoires are fetched by Employee with ID {LoggedInEmployeeId}", contactCategories.Count, loggedInEmployee.Id); - return ApiResponse.SuccessResponse(contactCategories, System.String.Format("{0} contact categories fetched successfully", contactCategories.Count), 200); } + + /// + /// Retrieves a single contact category by its unique ID and associated tenant. + /// Validates parameters, logs operations, and handles exceptions gracefully. + /// + /// Unique identifier for the contact category. + /// Employee requesting the data. + /// Unique identifier for the tenant. + /// ApiResponse with the contact category data, or error details. public async Task> GetContactCategoryById(Guid id, Employee loggedInEmployee, Guid tenantId) { - var category = await _context.ContactCategoryMasters.FirstOrDefaultAsync(c => c.Id == id && c.TenantId == tenantId); - if (category != null) + // Validate required parameters + if (loggedInEmployee == null) { - ContactCategoryVM categoryVM = category.ToContactCategoryVMFromContactCategoryMaster(); - _logger.LogInfo("Employee {EmployeeId} fetched contact category {ContactCategoryID}", loggedInEmployee.Id, category.Id); - return ApiResponse.SuccessResponse(categoryVM, "Category fetched successfully", 200); + _logger.LogWarning("Null employee object provided when fetching contact category {ContactCategoryID}", id); + return ApiResponse.ErrorResponse("Invalid employee context", "Invalid employee context", 400); } - _logger.LogWarning("Employee {EmployeeId} attempted to fetch contact category {ContactCategoryID} but not found in database", loggedInEmployee.Id, id); - return ApiResponse.ErrorResponse("Category not found", "Category not found", 404); + if (tenantId == Guid.Empty) + { + _logger.LogWarning("Empty tenantId provided by Employee {EmployeeId} when fetching contact category {ContactCategoryID}", loggedInEmployee.Id, id); + return ApiResponse.ErrorResponse("Invalid tenant identifier", "Invalid tenant identifier", 400); + } + + if (id == Guid.Empty) + { + _logger.LogWarning("Empty contact category ID specified by Employee {EmployeeId}", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Invalid contact category ID", "Invalid contact category ID", 400); + } + + try + { + // Efficient search for category, read-only query + var category = await _context.ContactCategoryMasters + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == id && c.TenantId == tenantId); + + if (category == null) + { + _logger.LogWarning("Employee {EmployeeId} attempted to fetch contact category {ContactCategoryID} (TenantId {TenantId}), but it was not found", loggedInEmployee.Id, id, tenantId); + return ApiResponse.ErrorResponse("Category not found", "Category not found", 404); + } + + // Map database entity to ViewModel + var categoryVM = _mapper.Map(category); + + _logger.LogInfo("Employee {EmployeeId} fetched contact category {ContactCategoryID} (TenantId {TenantId}) successfully", loggedInEmployee.Id, category.Id, tenantId); + return ApiResponse.SuccessResponse(categoryVM, "Category fetched successfully", 200); + } + catch (Exception ex) + { + // Exception logging with relevant context + _logger.LogError(ex, "Error fetching contact category {ContactCategoryID} for TenantId {TenantId} by Employee {EmployeeId}", id, tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("An unexpected error occurred while fetching category", "An unexpected error occurred while fetching category", 500); + } } + + /// + /// Creates a new contact category for the specified tenant. + /// Ensures the category name is unique within the tenant and logs all relevant actions. + /// + /// The DTO containing category creation data. + /// The employee initiating the request. + /// The tenant identifier to scope the operation. + /// ApiResponse containing the created category or error details. + public async Task> CreateContactCategory(CreateContactCategoryDto model, Employee loggedInEmployee, Guid tenantId) + { + // Validate input parameters + if (loggedInEmployee == null) + { + _logger.LogWarning("CreateContactCategory: Request with null employee context"); + return ApiResponse.ErrorResponse("Invalid employee context", "Invalid employee context", 400); + } + + if (tenantId == Guid.Empty) + { + _logger.LogWarning("CreateContactCategory: Invalid tenant ID {TenantId} provided by Employee {EmployeeId}", tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Invalid tenant identifier", "Invalid tenant identifier", 400); + } + + if (model == null) + { + _logger.LogWarning("Employee {EmployeeId} sent empty payload for contact category creation", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Request payload cannot be null", "Request payload cannot be null", 400); + } + + // Trim and validate name to prevent duplicates due to whitespace + // Trim and validate name + string trimmedName = model.Name.Trim(); + if (string.IsNullOrWhiteSpace(trimmedName)) + { + _logger.LogWarning("Employee {EmployeeId} attempted to create contact category with empty or whitespace-only name", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Category name is required", "Category name is required", 400); + } + + try + { + // Check for existing category with same name in the tenant + bool categoryExists = await _context.ContactCategoryMasters + .AnyAsync(c => c.TenantId == tenantId && c.Name == trimmedName); + + if (categoryExists) + { + _logger.LogWarning("Employee {EmployeeId} attempted to create duplicate contact category with name '{CategoryName}' for Tenant {TenantId}", + loggedInEmployee.Id, trimmedName, tenantId); + return ApiResponse.ErrorResponse("A category with this name already exists", "A category with this name already exists", 409); + } + + // Map DTO to entity + var contactCategory = new ContactCategoryMaster + { + Id = Guid.NewGuid(), // Ensure new ID is generated + Name = trimmedName, + Description = model.Description.Trim(), // Normalize description + TenantId = tenantId + }; + + + // Add and save to database + _context.ContactCategoryMasters.Add(contactCategory); + await _context.SaveChangesAsync(); + + // Map to response model + var categoryVM = _mapper.Map(contactCategory); + + _logger.LogInfo("Contact category created successfully: ID {ContactCategoryId}, Name '{CategoryName}', Tenant {TenantId}, by Employee {EmployeeId}", + contactCategory.Id, contactCategory.Name, tenantId, loggedInEmployee.Id); + + return ApiResponse.SuccessResponse(categoryVM, "Category created successfully", 201); // 201 Created + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating contact category for Tenant {TenantId} by Employee {EmployeeId}. Payload: {Payload}", + tenantId, loggedInEmployee.Id, model.Name); + return ApiResponse.ErrorResponse("An error occurred while creating the category", "An error occurred while creating the category", 500); + } + } + + /// + /// Updates an existing contact category within the specified tenant. + /// Validates ownership, ensures data integrity, logs changes, and supports audit tracking. + /// + /// The unique identifier of the contact category to update. + /// The DTO containing updated category data. + /// The employee initiating the update. + /// The tenant identifier to scope the operation. + /// ApiResponse containing the updated category or error details. + public async Task> UpdateContactCategory(Guid id, UpdateContactCategoryDto model, Employee loggedInEmployee, Guid tenantId) + { + // Validate input parameters + if (loggedInEmployee == null) + { + _logger.LogWarning("UpdateContactCategory: Request with null employee context"); + return ApiResponse.ErrorResponse("Invalid employee context", "Invalid employee context", 400); + } + + if (tenantId == Guid.Empty) + { + _logger.LogWarning("UpdateContactCategory: Invalid tenant ID {TenantId} provided by Employee {EmployeeId}", tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Invalid tenant identifier", "Invalid tenant identifier", 400); + } + + if (id == Guid.Empty) + { + _logger.LogWarning("UpdateContactCategory: Invalid category ID {CategoryId} provided by Employee {EmployeeId}", id, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Invalid category identifier", "Invalid category identifier", 400); + } + + if (model == null) + { + _logger.LogWarning("Employee {EmployeeId} sent null DTO for updating contact category {CategoryId}", loggedInEmployee.Id, id); + return ApiResponse.ErrorResponse("Request payload cannot be null", "Request payload cannot be null", 400); + } + + if (id != model.Id) + { + _logger.LogWarning("Employee {EmployeeId} attempted to update category {CategoryId} with mismatched DTO ID {DtoId}", + loggedInEmployee.Id, id, model.Id); + return ApiResponse.ErrorResponse("Category ID mismatch between route and payload", "Category ID mismatch between route and payload", 400); + } + + try + { + // Fetch the existing category with tenant scoping + var contactCategory = await _context.ContactCategoryMasters + .FirstOrDefaultAsync(c => c.Id == id && c.TenantId == tenantId); + + if (contactCategory == null) + { + _logger.LogWarning("Employee {EmployeeId} attempted to update non-existent contact category {CategoryId} for Tenant {TenantId}", + loggedInEmployee.Id, id, tenantId); + return ApiResponse.ErrorResponse("Category not found", "Category not found", 404); + } + + // Trim and validate name + string trimmedName = model.Name.Trim(); + if (string.IsNullOrWhiteSpace(trimmedName)) + { + _logger.LogWarning("Employee {EmployeeId} attempted to update category {CategoryId} with empty or whitespace-only name", + loggedInEmployee.Id, id); + return ApiResponse.ErrorResponse("Category name is required", "Category name is required", 400); + } + + // Check for duplicate name within tenant (excluding current category) + bool nameExists = await _context.ContactCategoryMasters + .AnyAsync(c => c.TenantId == tenantId && c.Name == trimmedName && c.Id != id); + + if (nameExists) + { + _logger.LogWarning("Employee {EmployeeId} attempted to rename category {CategoryId} to '{NewName}', which already exists in Tenant {TenantId}", + loggedInEmployee.Id, id, trimmedName, tenantId); + return ApiResponse.ErrorResponse("A category with this name already exists", "A category with this name already exists", 409); + } + + // Capture original state for audit log + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(contactCategory); + + // Update entity properties + contactCategory.Name = trimmedName; + contactCategory.Description = model.Description.Trim(); // Normalize description + + // Log update in directory and audit trail + _context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog + { + RefereanceId = contactCategory.Id, + UpdatedById = loggedInEmployee.Id, + UpdateAt = DateTime.UtcNow + }); + + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = contactCategory.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, "ContactCategoryMasterModificationLog"); + + // Save changes to database + await _context.SaveChangesAsync(); + + // Map to response model + var categoryVM = _mapper.Map(contactCategory); + + _logger.LogInfo("Contact category updated successfully: ID {ContactCategoryId}, Name '{CategoryName}', Tenant {TenantId}, by Employee {EmployeeId}", + contactCategory.Id, contactCategory.Name, tenantId, loggedInEmployee.Id); + + return ApiResponse.SuccessResponse(categoryVM, "Category updated successfully", 200); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating contact category {CategoryId} for Tenant {TenantId} by Employee {EmployeeId}", + id, tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("An error occurred while updating the category", "An error occurred while updating the category", 500); + } + } + + /// + /// Deletes a contact category by ID after ensuring it's not in use. + /// Orphaned contacts have their category reference cleared. Full audit trail is maintained. + /// + /// The unique identifier of the contact category to delete. + /// The employee initiating the deletion. + /// The tenant identifier to scope the operation. + /// ApiResponse indicating success or failure. public async Task> DeleteContactCategory(Guid id, Employee loggedInEmployee, Guid tenantId) { - ContactCategoryMaster? contactCategory = await _context.ContactCategoryMasters.FirstOrDefaultAsync(c => c.Id == id && c.TenantId == tenantId); - if (contactCategory != null) + // Validate input parameters + if (loggedInEmployee == null) { - List? existingContacts = await _context.Contacts.AsNoTracking().Where(c => c.ContactCategoryId == contactCategory.Id).ToListAsync(); - if (existingContacts.Count > 0) + _logger.LogWarning("DeleteContactCategory: Request with null employee context"); + return ApiResponse.ErrorResponse("Invalid employee context", "Invalid employee context", 400); + } + + if (tenantId == Guid.Empty) + { + _logger.LogWarning("DeleteContactCategory: Invalid tenant ID {TenantId} provided by Employee {EmployeeId}", tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Invalid tenant identifier", "Invalid tenant identifier", 400); + } + + if (id == Guid.Empty) + { + _logger.LogWarning("DeleteContactCategory: Invalid category ID {CategoryId} provided by Employee {EmployeeId}", id, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Invalid category identifier", "Invalid category identifier", 400); + } + + try + { + // Retrieve the category to delete with tenant scoping + var contactCategory = await _context.ContactCategoryMasters + .FirstOrDefaultAsync(c => c.Id == id && c.TenantId == tenantId); + + if (contactCategory == null) { - List? contacts = new List(); - foreach (var contact in existingContacts) - { - contact.ContactCategoryId = null; - contacts.Add(contact); - } - _context.Contacts.UpdateRange(contacts); + _logger.LogWarning("Employee {EmployeeId} attempted to delete non-existent contact category {CategoryId} for Tenant {TenantId}", + loggedInEmployee.Id, id, tenantId); + return ApiResponse.ErrorResponse("Category not found", "Category not found", 404); } + + // Check for associated contacts and update them in bulk + var hasAssociatedContacts = await _context.Contacts + .AnyAsync(c => c.ContactCategoryId == id && c.TenantId == tenantId); + + if (hasAssociatedContacts) + { + // Bulk update: Set ContactCategoryId to null for all related contacts + var rowsAffected = await _context.Contacts + .Where(c => c.ContactCategoryId == id && c.TenantId == tenantId) + .ExecuteUpdateAsync(setters => setters.SetProperty(c => c.ContactCategoryId, (Guid?)null)); + + _logger.LogInfo("Cleared ContactCategoryId for {RowCount} contacts previously linked to category {CategoryId}", + rowsAffected, id); + } + + // Capture original state for audit log before deletion + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(contactCategory); + + // Remove the category _context.ContactCategoryMasters.Remove(contactCategory); + // Log deletion in directory update log _context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog { RefereanceId = id, UpdatedById = loggedInEmployee.Id, UpdateAt = DateTime.UtcNow }); - await _context.SaveChangesAsync(); - _logger.LogInfo("Employee {EmployeeId} deleted contact category {ContactCategoryId}", loggedInEmployee.Id, id); - } - _logger.LogWarning("Employee {EmployeeId} tries to delete Category {CategoryId} but not found in database", loggedInEmployee.Id, id); - return ApiResponse.SuccessResponse(new { }, "Category deleted successfully", 200); + // Save all changes to database + await _context.SaveChangesAsync(); + + // Push audit log to external store (e.g., MongoDB) + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, "ContactCategoryMasterModificationLog"); + + _logger.LogInfo("Contact category deleted successfully: ID {ContactCategoryId}, Tenant {TenantId}, by Employee {EmployeeId}", + id, tenantId, loggedInEmployee.Id); + + return ApiResponse.SuccessResponse(new { }, "Category deleted successfully", 200); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting contact category {CategoryId} for Tenant {TenantId} by Employee {EmployeeId}", + id, tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("An error occurred while deleting the category", "An error occurred while deleting the category", 500); + } } + #endregion #region =================================================================== Contact Tag APIs =================================================================== - public async Task> GetContactTags(Employee loggedInEmployee, Guid tenantId) + /// + /// Retrieves all contact tags for the specified tenant. + /// Returns a list of active tags mapped to view models with full audit logging. + /// + /// The employee making the request. + /// The unique identifier for the tenant. + /// ApiResponse containing the list of contact tags or error details. + public async Task> GetContactTags(Employee loggedInEmployee, Guid tenantId) { - var taglist = await _context.ContactTagMasters.Where(t => t.TenantId == tenantId).ToListAsync(); - List contactTags = new List(); - foreach (var tag in taglist) + // Validate input parameters + if (loggedInEmployee == null) { - ContactTagVM tagVm = tag.ToContactTagVMFromContactTagMaster(); - contactTags.Add(tagVm); + _logger.LogWarning("GetContactTags: Request with null employee context"); + return ApiResponse.ErrorResponse("Invalid employee context", "Invalid employee context", 400); } - _logger.LogInfo("{count} contact Tags are fetched by Employee with ID {LoggedInEmployeeId}", contactTags.Count, loggedInEmployee.Id); - return ApiResponse.SuccessResponse(contactTags, System.String.Format("{0} contact tags fetched successfully", contactTags.Count), 200); - } - public async Task> CreateContactTag(CreateContactTagDto contactTagDto, Employee loggedInEmployee, Guid tenantId) - { - if (contactTagDto != null) + + if (tenantId == Guid.Empty) { - ContactTagMaster? existingContactTag = await _context.ContactTagMasters.FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Name.ToLower() == (contactTagDto.Name != null ? contactTagDto.Name.ToLower() : "")); - if (existingContactTag == null) + _logger.LogWarning("GetContactTags: Invalid tenant ID {TenantId} provided by Employee {EmployeeId}", tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Invalid tenant identifier", "Invalid tenant identifier", 400); + } + + try + { + // Fetch tags with tenant filtering and no tracking (read-only operation) + var tagList = await _context.ContactTagMasters + .AsNoTracking() + .Where(t => t.TenantId == tenantId) + .ToListAsync(); + + // Map to view models + var contactTags = _mapper.Map>(tagList); + int tagCount = contactTags.Count; + + // Log successful retrieval with context + _logger.LogInfo("{TagCount} contact tags fetched for Tenant {TenantId} by Employee {EmployeeId}", tagCount, tenantId, loggedInEmployee.Id); + return ApiResponse.SuccessResponse(contactTags, $"{tagCount} contact tags fetched successfully", 200); + } + catch (Exception ex) + { + // Log any unexpected errors with full context + _logger.LogError(ex, "Error fetching contact tags for Tenant {TenantId} by Employee {EmployeeId}", tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("An error occurred while retrieving contact tags", "An error occurred while retrieving contact tags", 500); + } + } + + /// + /// Creates a new contact tag for the specified tenant. + /// Ensures name uniqueness within the tenant, logs all actions, and supports auditability. + /// + /// The DTO containing tag creation data. + /// The employee initiating the request. + /// The tenant identifier to scope the operation. + /// ApiResponse containing the created tag or error details. + public async Task> CreateContactTag(CreateContactTagDto model, Employee loggedInEmployee, Guid tenantId) + { + // Validate input parameters + if (loggedInEmployee == null) + { + _logger.LogWarning("CreateContactTag: Request with null employee context"); + return ApiResponse.ErrorResponse("Invalid employee context", "Invalid employee context", 400); + } + + if (tenantId == Guid.Empty) + { + _logger.LogWarning("CreateContactTag: Invalid tenant ID {TenantId} provided by Employee {EmployeeId}", tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Invalid tenant identifier", "Invalid tenant identifier", 400); + } + + if (model == null) + { + _logger.LogWarning("Employee {EmployeeId} sent empty payload for contact tag creation", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Request payload cannot be null", "Request payload cannot be null", 400); + } + + // Trim and validate name + string trimmedName = model.Name.Trim(); + if (string.IsNullOrWhiteSpace(trimmedName)) + { + _logger.LogWarning("Employee {EmployeeId} attempted to create contact tag with empty or whitespace-only name", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Tag name is required", "Tag name is required", 400); + } + + try + { + // Check for existing tag with same name in the tenant + bool tagExists = await _context.ContactTagMasters + .AnyAsync(t => t.TenantId == tenantId && t.Name == trimmedName); + + if (tagExists) { - ContactTagMaster contactTag = contactTagDto.ToContactTagMasterFromCreateContactTagDto(tenantId); - _context.ContactTagMasters.Add(contactTag); - await _context.SaveChangesAsync(); - ContactTagVM tagVM = contactTag.ToContactTagVMFromContactTagMaster(); - - _logger.LogInfo("Employee ID {LoggedInEmployeeId} created a contact tag {ContactTagId}.", loggedInEmployee.Id, contactTag.Id); - return ApiResponse.SuccessResponse(tagVM, "Tag Created Successfully", 200); + _logger.LogWarning("Employee {EmployeeId} attempted to create duplicate contact tag with name '{TagName}' for Tenant {TenantId}", + loggedInEmployee.Id, trimmedName, tenantId); + return ApiResponse.ErrorResponse("A tag with this name already exists", "A tag with this name already exists", 409); } - _logger.LogWarning("Employee ID {LoggedInEmployeeId} attempted to create an existing contact tag.", loggedInEmployee.Id); - return ApiResponse.ErrorResponse("Tag already existed", "Tag already existed", 409); - } - _logger.LogWarning("Employee with ID {LoggedInEmployeeId} sended empty payload", loggedInEmployee.Id); - return ApiResponse.ErrorResponse("User Send empty Payload", "User Send empty Payload", 400); - } - public async Task> UpdateContactTag(Guid id, UpdateContactTagDto contactTagDto, Employee loggedInEmployee, Guid tenantId) - { - if (contactTagDto != null && contactTagDto.Id == id) - { - ContactTagMaster? contactTag = await _context.ContactTagMasters.AsNoTracking().FirstOrDefaultAsync(t => t.TenantId == tenantId && t.Id == contactTagDto.Id); - if (contactTag != null) + + // Create new tag entity + var contactTag = new ContactTagMaster { - contactTag = contactTagDto.ToContactTagMasterFromUpdateContactTagDto(tenantId); - _context.ContactTagMasters.Update(contactTag); + Id = Guid.NewGuid(), + Name = trimmedName, + Description = model.Description?.Trim() ?? "", // Normalize description + TenantId = tenantId, + }; - _context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog - { - RefereanceId = contactTag.Id, - UpdatedById = loggedInEmployee.Id, - UpdateAt = DateTime.UtcNow - }); - await _context.SaveChangesAsync(); - await _context.SaveChangesAsync(); + // Add and save to database + _context.ContactTagMasters.Add(contactTag); + await _context.SaveChangesAsync(); - ContactTagVM contactTagVm = contactTag.ToContactTagVMFromContactTagMaster(); + // Map to response model + var tagVM = _mapper.Map(contactTag); + // Log successful creation with full context + _logger.LogInfo("Contact tag created successfully: ID {ContactTagId}, Name '{TagName}', Tenant {TenantId}, by Employee {EmployeeId}", + contactTag.Id, contactTag.Name, tenantId, loggedInEmployee.Id); - - _logger.LogInfo("Contact tag master {ConatctTagId} updated successfully by employee {EmployeeId}", contactTagVm.Id, loggedInEmployee.Id); - return ApiResponse.SuccessResponse(contactTagVm, "Contact Tag master updated successfully", 200); - } - _logger.LogWarning("Contact Tag master {ContactTagId} not found in database", id); - return ApiResponse.ErrorResponse("Contact Tag master not found", "Contact tag master not found", 404); + return ApiResponse.SuccessResponse(tagVM, "Tag created successfully", 201); // 201 Created + } + catch (Exception ex) + { + // Log any unexpected errors with full context + _logger.LogError(ex, "Error creating contact tag for Tenant {TenantId} by Employee {EmployeeId}. Payload: {TagName}", + tenantId, loggedInEmployee.Id, model.Name); + return ApiResponse.ErrorResponse("An error occurred while creating the tag", "An error occurred while creating the tag", 500); } - _logger.LogWarning("Employee with ID {LoggedInEmployeeId} sended empty payload", loggedInEmployee.Id); - return ApiResponse.ErrorResponse("User Send empty Payload", "User Send empty Payload", 400); } + + /// + /// Updates an existing contact tag within the specified tenant. + /// Ensures data integrity, prevents name conflicts, and maintains a full audit trail. + /// + /// The unique identifier of the contact tag to update. + /// The DTO containing updated tag data. + /// The employee initiating the update. + /// The tenant identifier to scope the operation. + /// ApiResponse containing the updated tag or error details. + public async Task> UpdateContactTag(Guid id, UpdateContactTagDto model, Employee loggedInEmployee, Guid tenantId) + { + // Validate input parameters + if (loggedInEmployee == null) + { + _logger.LogWarning("UpdateContactTag: Request with null employee context"); + return ApiResponse.ErrorResponse("Invalid employee context", "Invalid employee context", 400); + } + + if (tenantId == Guid.Empty) + { + _logger.LogWarning("UpdateContactTag: Invalid tenant ID {TenantId} provided by Employee {EmployeeId}", tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Invalid tenant identifier", "Invalid tenant identifier", 400); + } + + if (id == Guid.Empty) + { + _logger.LogWarning("UpdateContactTag: Invalid tag ID {TagId} provided by Employee {EmployeeId}", id, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Invalid tag identifier", "Invalid tag identifier", 400); + } + + if (model == null) + { + _logger.LogWarning("Employee {EmployeeId} sent null DTO for updating contact tag {TagId}", loggedInEmployee.Id, id); + return ApiResponse.ErrorResponse("Request payload cannot be null", "Request payload cannot be null", 400); + } + + if (model.Id != id) + { + _logger.LogWarning("Employee {EmployeeId} attempted to update tag {TagId} with mismatched DTO ID {DtoId}", + loggedInEmployee.Id, id, model.Id); + return ApiResponse.ErrorResponse("Tag ID mismatch between route and payload", "Tag ID mismatch between route and payload", 400); + } + + try + { + // Fetch the existing tag with tenant scoping + var contactTag = await _context.ContactTagMasters + .FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId); + + if (contactTag == null) + { + _logger.LogWarning("Employee {EmployeeId} attempted to update non-existent contact tag {TagId} for Tenant {TenantId}", + loggedInEmployee.Id, id, tenantId); + return ApiResponse.ErrorResponse("Contact tag not found", "Contact tag not found", 404); + } + + // Trim and validate name + string trimmedName = (model.Name ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(trimmedName)) + { + _logger.LogWarning("Employee {EmployeeId} attempted to update tag {TagId} with empty or whitespace-only name", + loggedInEmployee.Id, id); + return ApiResponse.ErrorResponse("Tag name is required", "Tag name is required", 400); + } + + // Check for duplicate name within tenant (excluding current tag) + bool nameExists = await _context.ContactTagMasters + .AnyAsync(t => t.TenantId == tenantId && t.Name == trimmedName && t.Id != id); + + if (nameExists) + { + _logger.LogWarning("Employee {EmployeeId} attempted to rename tag {TagId} to '{NewName}', which already exists in Tenant {TenantId}", + loggedInEmployee.Id, id, trimmedName, tenantId); + return ApiResponse.ErrorResponse("A tag with this name already exists", "A tag with this name already exists", 409); + } + + // Capture original state for audit log + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(contactTag); + + // Update entity properties + contactTag.Name = trimmedName; + contactTag.Description = model.Description?.Trim() ?? ""; // Normalize description + + // Log update in directory and audit trail + _context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog + { + RefereanceId = contactTag.Id, + UpdatedById = loggedInEmployee.Id, + UpdateAt = DateTime.UtcNow + }); + + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = contactTag.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, "ContactTagMasterModificationLog"); + + // Save changes to database + await _context.SaveChangesAsync(); + + // Map to response model + var contactTagVm = _mapper.Map(contactTag); + + _logger.LogInfo("Contact tag updated successfully: ID {ContactTagId}, Name '{TagName}', Tenant {TenantId}, by Employee {EmployeeId}", + contactTag.Id, contactTag.Name, tenantId, loggedInEmployee.Id); + + return ApiResponse.SuccessResponse(contactTagVm, "Contact tag updated successfully", 200); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating contact tag {TagId} for Tenant {TenantId} by Employee {EmployeeId}", + id, tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("An error occurred while updating the tag", "An error occurred while updating the tag", 500); + } + } + + /// + /// Deletes a contact tag by ID after removing all associated tag mappings. + /// Maintains referential integrity and full audit trail for compliance and traceability. + /// + /// The unique identifier of the contact tag to delete. + /// The employee initiating the deletion. + /// The tenant identifier to scope the operation. + /// ApiResponse indicating success or failure. public async Task> DeleteContactTag(Guid id, Employee loggedInEmployee, Guid tenantId) { - ContactTagMaster? contactTag = await _context.ContactTagMasters.FirstOrDefaultAsync(c => c.Id == id && c.TenantId == tenantId); - if (contactTag != null) + // Validate input parameters + if (loggedInEmployee == null) { - List? tagMappings = await _context.ContactTagMappings.Where(t => t.ContactTagId == contactTag.Id).ToListAsync(); + _logger.LogWarning("DeleteContactTag: Request with null employee context"); + return ApiResponse.ErrorResponse("Invalid employee context", "Invalid employee context", 400); + } - _context.ContactTagMasters.Remove(contactTag); - if (tagMappings.Any()) + if (tenantId == Guid.Empty) + { + _logger.LogWarning("DeleteContactTag: Invalid tenant ID {TenantId} provided by Employee {EmployeeId}", tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Invalid tenant identifier", "Invalid tenant identifier", 400); + } + + if (id == Guid.Empty) + { + _logger.LogWarning("DeleteContactTag: Invalid tag ID {TagId} provided by Employee {EmployeeId}", id, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Invalid tag identifier", "Invalid tag identifier", 400); + } + + try + { + // Retrieve the tag to delete with tenant scoping + var contactTag = await _context.ContactTagMasters + .FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId); + + if (contactTag == null) { - _context.ContactTagMappings.RemoveRange(tagMappings); + _logger.LogWarning("Employee {EmployeeId} attempted to delete non-existent contact tag {TagId} for Tenant {TenantId}", + loggedInEmployee.Id, id, tenantId); + return ApiResponse.ErrorResponse("Contact tag not found", "Contact tag not found", 404); } + + // Capture original state for audit log before deletion + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(contactTag); + + // Remove all associated tag mappings in bulk + var mappingsExist = await _context.ContactTagMappings + .AnyAsync(m => m.ContactTagId == id); + + if (mappingsExist) + { + var rowsAffected = await _context.ContactTagMappings + .Where(m => m.ContactTagId == id) + .ExecuteDeleteAsync(); + + _logger.LogInfo("Deleted {RowCount} contact tag mappings associated with tag {TagId}", rowsAffected, id); + } + + // Log deletion in directory update log _context.DirectoryUpdateLogs.Add(new DirectoryUpdateLog { RefereanceId = id, UpdatedById = loggedInEmployee.Id, UpdateAt = DateTime.UtcNow }); - await _context.SaveChangesAsync(); - _logger.LogInfo("Employee {EmployeeId} deleted contact tag {ContactTagId}", loggedInEmployee.Id, id); - } - _logger.LogWarning("Employee {EmployeeId} tries to delete Tag {ContactTagId} but not found in database", loggedInEmployee.Id, id); - return ApiResponse.SuccessResponse(new { }, "Tag deleted successfully", 200); + // Remove the tag + _context.ContactTagMasters.Remove(contactTag); + + // Save all changes to database + await _context.SaveChangesAsync(); + + // Push audit log to external store (e.g., MongoDB) + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, "ContactTagMasterModificationLog"); + + _logger.LogInfo("Contact tag deleted successfully: ID {ContactTagId}, Tenant {TenantId}, by Employee {EmployeeId}", + id, tenantId, loggedInEmployee.Id); + + return ApiResponse.SuccessResponse(new { }, "Tag deleted successfully", 200); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting contact tag {TagId} for Tenant {TenantId} by Employee {EmployeeId}", + id, tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("An error occurred while deleting the tag", "An error occurred while deleting the tag", 500); + } } #endregion @@ -1252,9 +1866,9 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("An error occurred", "Unable to fetch work status list", 500); } } - public async Task> CreateWorkStatus(CreateWorkStatusMasterDto createWorkStatusDto, Employee loggedInEmployee, Guid tenantId) + public async Task> CreateWorkStatus(CreateWorkStatusMasterDto model, Employee loggedInEmployee, Guid tenantId) { - _logger.LogInfo("CreateWorkStatus called with Name: {Name}", createWorkStatusDto.Name ?? ""); + _logger.LogInfo("CreateWorkStatus called with Name: {Name}", model.Name); try { @@ -1269,19 +1883,19 @@ namespace Marco.Pms.Services.Service // Step 2: Check if work status with the same name already exists var existingWorkStatus = await _context.WorkStatusMasters - .FirstOrDefaultAsync(ws => ws.Name == createWorkStatusDto.Name && ws.TenantId == tenantId); + .FirstOrDefaultAsync(ws => ws.Name == model.Name && ws.TenantId == tenantId); if (existingWorkStatus != null) { - _logger.LogWarning("Work status already exists: {Name}", createWorkStatusDto.Name ?? ""); + _logger.LogWarning("Work status already exists: {Name}", model.Name); return ApiResponse.ErrorResponse("Work status already exists", "Work status already exists", 400); } // Step 3: Create new WorkStatusMaster entry var workStatus = new WorkStatusMaster { - Name = createWorkStatusDto.Name?.Trim() ?? "", - Description = createWorkStatusDto.Description?.Trim() ?? "", + Name = model.Name.Trim(), + Description = model.Description.Trim(), IsSystem = false, TenantId = tenantId }; @@ -1298,16 +1912,16 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("An error occurred", "Unable to create work status", 500); } } - public async Task> UpdateWorkStatus(Guid id, UpdateWorkStatusMasterDto updateWorkStatusDto, Employee loggedInEmployee, Guid tenantId) + public async Task> UpdateWorkStatus(Guid id, UpdateWorkStatusMasterDto model, Employee loggedInEmployee, Guid tenantId) { - _logger.LogInfo("UpdateWorkStatus called for WorkStatus ID: {Id}, New Name: {Name}", id, updateWorkStatusDto.Name ?? ""); + _logger.LogInfo("UpdateWorkStatus called for WorkStatus ID: {Id}, New Name: {Name}", id, model.Name); try { // Step 1: Validate input - if (id == Guid.Empty || id != updateWorkStatusDto.Id) + if (id == Guid.Empty || id != model.Id) { - _logger.LogWarning("Invalid ID provided for update. Route ID: {RouteId}, DTO ID: {DtoId}", id, updateWorkStatusDto.Id); + _logger.LogWarning("Invalid ID provided for update. Route ID: {RouteId}, DTO ID: {DtoId}", id, model.Id); return ApiResponse.ErrorResponse("Invalid data provided", "The provided work status ID is invalid", 400); } @@ -1331,20 +1945,31 @@ namespace Marco.Pms.Services.Service // Step 4: Check for duplicate name (optional) var isDuplicate = await _context.WorkStatusMasters - .AnyAsync(ws => ws.Name == updateWorkStatusDto.Name && ws.Id != id && ws.TenantId == tenantId); + .AnyAsync(ws => ws.Name == model.Name.Trim() && ws.Id != id && ws.TenantId == tenantId); if (isDuplicate) { - _logger.LogWarning("Duplicate work status name '{Name}' detected during update. ID: {Id}", updateWorkStatusDto.Name ?? "", id); + _logger.LogWarning("Duplicate work status name '{Name}' detected during update. ID: {Id}", model.Name, id); return ApiResponse.ErrorResponse("Work status with the same name already exists", "Duplicate name", 400); } + // Capture original state for audit log + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(workStatus); + // Step 5: Update fields - workStatus.Name = updateWorkStatusDto.Name?.Trim() ?? ""; - workStatus.Description = updateWorkStatusDto.Description?.Trim() ?? ""; + workStatus.Name = model.Name.Trim(); + workStatus.Description = model.Description.Trim(); await _context.SaveChangesAsync(); + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = workStatus.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, "WorkStatusMasterModificationLog"); + _logger.LogInfo("Work status updated successfully. ID: {Id}", id); return ApiResponse.SuccessResponse(workStatus, "Work status updated successfully", 200); } @@ -1392,10 +2017,21 @@ namespace Marco.Pms.Services.Service ); } + // Capture original state for audit log + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(workStatus); + // Step 5: Delete and persist _context.WorkStatusMasters.Remove(workStatus); await _context.SaveChangesAsync(); + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = workStatus.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, "WorkStatusMasterModificationLog"); + _logger.LogInfo("Work status deleted successfully. Id: {Id}", id); return ApiResponse.SuccessResponse(new { }, "Work status deleted successfully", 200); } @@ -1501,7 +2137,7 @@ namespace Marco.Pms.Services.Service // Saving the old entity in mongoDB - var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = expensesType.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), @@ -1556,7 +2192,7 @@ namespace Marco.Pms.Services.Service // Saving the old entity in mongoDB - var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = expensesType.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), @@ -1711,7 +2347,7 @@ namespace Marco.Pms.Services.Service // Saving the old entity in mongoDB - var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = paymentMode.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), @@ -1766,7 +2402,7 @@ namespace Marco.Pms.Services.Service // Saving the old entity in mongoDB - var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = paymentMode.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), @@ -1937,7 +2573,7 @@ namespace Marco.Pms.Services.Service // Saving the old entity in mongoDB - var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = documentCategory.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), @@ -1991,7 +2627,7 @@ namespace Marco.Pms.Services.Service // Saving the old entity in mongoDB - var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = documentCategory.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), @@ -2019,8 +2655,6 @@ namespace Marco.Pms.Services.Service #endregion #region =================================================================== Document Type APIs =================================================================== - - public async Task> GetDocumentTypeMasterListAsync(Guid? documentCategoryId, Employee loggedInEmployee, Guid tenantId) { try @@ -2163,7 +2797,7 @@ namespace Marco.Pms.Services.Service // Saving the old entity in mongoDB - var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = documentType.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), @@ -2222,7 +2856,7 @@ namespace Marco.Pms.Services.Service // Saving the old entity in mongoDB - var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = documentType.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), @@ -2248,6 +2882,248 @@ namespace Marco.Pms.Services.Service #endregion + #region =================================================================== Payment Adjustment Head APIs =================================================================== + + /// + /// Retrieves a list of payment adjustment heads for a specific tenant with optional active status filtering. + /// + /// Filter for active/inactive payment adjustment heads + /// The employee making the request (for auditing/authorization) + /// The tenant identifier to scope the data + /// An API response containing the list of payment adjustment head view models + /// + /// This method performs database-level filtering and uses projection to minimize data transfer. + /// Consider implementing pagination for tenants with large numbers of payment adjustment heads. + /// + public async Task> GetPaymentAdjustmentHeadListAsync(bool isActive, Employee loggedInEmployee, Guid tenantId) + { + try + { + // Log the request details for auditing and troubleshooting + _logger.LogInfo("Fetching payment adjustment heads for tenant {TenantId} with IsActive={IsActive}", tenantId, isActive); + + var paymentAdjustmentHeads = await _context.PaymentAdjustmentHeads + .AsNoTracking() // Improve performance by disabling change tracking for read-only operations + .Where(pah => pah.TenantId == tenantId && pah.IsActive == isActive) + .Select(pah => _mapper.Map(pah)) + .ToListAsync(); + + _logger.LogInfo("Successfully retrieved {Count} payment adjustment heads for tenant {TenantId}", paymentAdjustmentHeads.Count, tenantId); + + return ApiResponse.SuccessResponse( + paymentAdjustmentHeads.OrderBy(pah => pah.Name).ToList(), + $"Payment Adjustment Heads fetched successfully. Count: {paymentAdjustmentHeads.Count}", + 200); + } + catch (Exception ex) + { + // Log the full exception with context for better troubleshooting + _logger.LogError(ex, "Error occurred while fetching payment adjustment heads for tenant {TenantId}. IsActive: {IsActive}", tenantId, isActive); + return ApiResponse.ErrorResponse("An error occurred", "Unable to fetch payment adjustment heads", 500); + } + } + + /// + /// Creates a new payment adjustment head for the specified tenant after permission and uniqueness checks. + /// + /// DTO containing payment adjustment head data + /// The employee performing the action + /// The tenant identifier + /// API response with status and context + public async Task> CreatePaymentAdjustmentHeadAsync(PaymentAdjustmentHeadDto model, Employee loggedInEmployee, Guid tenantId) + { + try + { + // Permission validation + var hasManagePermission = await _permission.HasPermission(PermissionsMaster.ManageMasters, loggedInEmployee.Id); + if (!hasManagePermission) + { + _logger.LogWarning("Access denied for employee {EmployeeId} attempting to create payment adjustment head for tenant {TenantId}.", loggedInEmployee.Id, tenantId); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to manage masters.", 403); + } + + // Uniqueness check for payment adjustment head name + var nameExists = await _context.PaymentAdjustmentHeads + .AnyAsync(pah => pah.Name == model.Name && pah.TenantId == tenantId); + if (nameExists) + { + _logger.LogInfo("Duplicate payment adjustment head name '{Name}' detected for tenant {TenantId}.", model.Name, tenantId); + return ApiResponse.ErrorResponse("A payment adjustment head with this name already exists.", "Name of payment adjustment head already exists.", 409); + } + + // Create and persist new entity + var paymentAdjustmentHead = _mapper.Map(model); + paymentAdjustmentHead.IsActive = true; + paymentAdjustmentHead.TenantId = tenantId; + + _context.PaymentAdjustmentHeads.Add(paymentAdjustmentHead); + await _context.SaveChangesAsync(); + + var response = _mapper.Map(paymentAdjustmentHead); + + _logger.LogInfo("Payment adjustment head '{Name}' created successfully by employee {EmployeeId} for tenant {TenantId}.", paymentAdjustmentHead.Name, loggedInEmployee.Id, tenantId); + + return ApiResponse.SuccessResponse(response, "Payment adjustment head created successfully.", 201); + } + catch (Exception ex) + { + // Log full context with exception for easier debugging + _logger.LogError(ex, "Exception while creating payment adjustment head. Employee: {EmployeeId}, Tenant: {TenantId}, Data: {@Model}", loggedInEmployee.Id, tenantId, model); + return ApiResponse.ErrorResponse("An error occurred.", "Unable to create payment adjustment head at this moment.", 500); + } + } + + /// + /// Updates an existing payment adjustment head for a specified tenant after performing + /// necessary validation, permission, and uniqueness checks. + /// + /// Unique identifier of the payment adjustment head to update + /// DTO containing updated payment adjustment head data + /// The employee performing the action + /// The tenant identifier + /// API response object with update result and status message + public async Task> UpdatePaymentAdjustmentHeadAsync(Guid id, PaymentAdjustmentHeadDto model, Employee loggedInEmployee, Guid tenantId) + { + try + { + // --- Step 1: Validate request payload correctness --- + if (!model.Id.HasValue || model.Id != id) + { + _logger.LogWarning("Invalid ID provided in request. Model ID: {ModelId}, Route ID: {RouteId}, TenantId: {TenantId}", model.Id ?? Guid.Empty, id, tenantId); + return ApiResponse.ErrorResponse("Invalid request.", "Provided invalid ID.", 400); + } + + // --- Step 2: Validate permissions --- + var hasManagePermission = await _permission.HasPermission(PermissionsMaster.ManageMasters, loggedInEmployee.Id); + if (!hasManagePermission) + { + _logger.LogWarning("Access denied for employee {EmployeeId} attempting to update payment adjustment head for tenant {TenantId}.", loggedInEmployee.Id, tenantId); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to manage masters.", 403); + } + + // --- Step 3: Validate uniqueness constraint for name --- + var nameExists = await _context.PaymentAdjustmentHeads + .AnyAsync(pah => pah.Name == model.Name && pah.Id != id && pah.TenantId == tenantId); + + if (nameExists) + { + _logger.LogInfo("Duplicate payment adjustment head name '{Name}' detected during update for tenant {TenantId}.", model.Name, tenantId); + return ApiResponse.ErrorResponse("Conflict detected.", "A payment adjustment head with this name already exists.", 409); + } + + // --- Step 4: Retrieve and validate existing entity --- + var paymentAdjustmentHead = await _context.PaymentAdjustmentHeads + .FirstOrDefaultAsync(pah => pah.Id == id && pah.TenantId == tenantId); + + if (paymentAdjustmentHead == null) + { + _logger.LogWarning("Payment adjustment head with ID {Id} not found for tenant {TenantId}.", id, tenantId); + return ApiResponse.ErrorResponse("Not Found.", "Payment adjustment head not found.", 404); + } + + // Mapping PaymentAdjustmentHead to BsonDocument + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(paymentAdjustmentHead); + + // --- Step 5: Map changes and update entity --- + _mapper.Map(model, paymentAdjustmentHead); + + await _context.SaveChangesAsync(); + + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = paymentAdjustmentHead.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, "PaymentAdjustmentHeadModificationLog"); + + _logger.LogInfo("Payment adjustment head '{Name}' updated successfully by employee {EmployeeId} for tenant {TenantId}.", paymentAdjustmentHead.Name, loggedInEmployee.Id, tenantId); + + var response = _mapper.Map(paymentAdjustmentHead); + + // --- Step 6: Return structured success response --- + return ApiResponse.SuccessResponse(response, "Payment adjustment head updated successfully.", 200); + } + catch (Exception ex) + { + // --- Step 7: Handle and log exceptions with full context --- + _logger.LogError(ex, "Exception while updating payment adjustment head. Employee: {EmployeeId}, Tenant: {TenantId}, Model: {@Model}", loggedInEmployee.Id, tenantId, model); + + return ApiResponse.ErrorResponse("An unexpected error occurred.", "Unable to update payment adjustment head at this moment.", 500); + } + } + + /// + /// Activates or deactivates a payment adjustment head (soft delete/restore) for a tenant, + /// including audit logging and permission validation. + /// + /// Unique identifier of the payment adjustment head + /// Flag indicating activation (restore) or deactivation (delete) + /// Employee requesting the operation + /// The tenant identifier + /// API response object with operation status + public async Task> DeletePaymentAdjustmentHeadAsync(Guid id, bool isActive, Employee loggedInEmployee, Guid tenantId) + { + // Dynamically select operation word for logs and messages + var operation = isActive ? "restore" : "delete"; + + try + { + // Step 1: Permission check + var hasManagePermission = await _permission.HasPermission(PermissionsMaster.ManageMasters, loggedInEmployee.Id); + if (!hasManagePermission) + { + _logger.LogWarning("Access denied: Employee {EmployeeId} attempted to {Operation} payment adjustment head for tenant {TenantId}.", + loggedInEmployee.Id, operation, tenantId); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to manage masters.", 403); + } + + // Step 2: Entity existence check + var paymentAdjustmentHead = await _context.PaymentAdjustmentHeads + .FirstOrDefaultAsync(pah => pah.Id == id && pah.TenantId == tenantId); + + if (paymentAdjustmentHead == null) + { + _logger.LogWarning("Payment adjustment head with ID {Id} not found for tenant {TenantId} (attempted {Operation}).", + id, tenantId, operation); + return ApiResponse.ErrorResponse("Not Found.", "Payment adjustment head not found.", 404); + } + + // Step 3: Save pre-update state for audit log + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(paymentAdjustmentHead); + + // Step 4: Update IsActive status + paymentAdjustmentHead.IsActive = isActive; + + await _context.SaveChangesAsync(); + + // Step 5: Push update action to audit log + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = paymentAdjustmentHead.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, "PaymentAdjustmentHeadModificationLog"); + + _logger.LogInfo( + "Payment adjustment head (ID: {Id}, Name: {Name}) successfully {Operation}d by employee {EmployeeId} for tenant {TenantId}.", + paymentAdjustmentHead.Id, paymentAdjustmentHead.Name, operation, loggedInEmployee.Id, tenantId); + + return ApiResponse.SuccessResponse(new { }, $"Payment adjustment head {operation}d successfully.", 200); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Exception occurred while performing {Operation} on payment adjustment head (ID: {Id}) for tenant {TenantId}.", + operation, id, tenantId); + + return ApiResponse.ErrorResponse("An error occurred.", $"Exception occurred while trying to {operation} payment adjustment head.", 500); + } + } + + #endregion + #region =================================================================== Helper Function =================================================================== private static object ExceptionMapper(Exception ex) { @@ -2268,4 +3144,4 @@ namespace Marco.Pms.Services.Service #endregion } -} +} \ No newline at end of file diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IMasterService.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IMasterService.cs index a53edb0..4515b99 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IMasterService.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IMasterService.cs @@ -1,4 +1,5 @@ using Marco.Pms.Model.Dtos.Activities; +using Marco.Pms.Model.Dtos.Collection; using Marco.Pms.Model.Dtos.DocumentManager; using Marco.Pms.Model.Dtos.Master; using Marco.Pms.Model.Employees; @@ -46,6 +47,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> DeleteActivityGroupAsync(Guid id, bool isActive, Employee loggedInEmployee, Guid tenantId); #endregion + #region =================================================================== Contact Category APIs =================================================================== Task> CreateContactCategory(CreateContactCategoryDto contactCategoryDto, Employee loggedInEmployee, Guid tenantId); Task> UpdateContactCategory(Guid id, UpdateContactCategoryDto contactCategoryDto, Employee loggedInEmployee, Guid tenantId); @@ -104,5 +106,12 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> UpdateDocumentTypeMasterAsync(Guid id, CreateDocumentTypeDto model, Employee loggedInEmployee, Guid tenantId); Task> DeleteDocumentTypeMasterAsync(Guid id, bool isActive, Employee loggedInEmployee, Guid tenantId); #endregion + + #region =================================================================== Payment Adjustment Head APIs =================================================================== + Task> GetPaymentAdjustmentHeadListAsync(bool isActive, Employee loggedInEmployee, Guid tenantId); + Task> CreatePaymentAdjustmentHeadAsync(PaymentAdjustmentHeadDto model, Employee loggedInEmployee, Guid tenantId); + Task> UpdatePaymentAdjustmentHeadAsync(Guid id, PaymentAdjustmentHeadDto model, Employee loggedInEmployee, Guid tenantId); + Task> DeletePaymentAdjustmentHeadAsync(Guid id, bool isActive, Employee loggedInEmployee, Guid tenantId); + #endregion } }