using System.IO.Compression; using DMSDAL.CustomCode.ESSDocument; using DMSDAL.DTO.ESSDocument; using GB5Shared.DTO.Framework.Login; using GB5Shared.Storage; using iText.Kernel.Pdf; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; namespace DMSBLL.ESSDocument { public class ESSDocumentBLL : IESSDocumentBLL { // OBJECTTYPEID for Employee in TATTACHMENT private const int OBJECT_TYPE_EMPLOYEE = -2147482763; // ValidFrom sentinel: 1898 = document has no period private const int NO_PERIOD_YEAR = 1898; private static readonly HashSet AllowedMimeTypes = new(StringComparer.OrdinalIgnoreCase) { "application/pdf", "image/jpeg", "image/png", "image/gif", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }; private const long MaxUploadBytes = 10 * 1024 * 1024; // 10 MB private readonly IESSDocumentDAL _ESSDocumentDAL; private readonly IStorageProvider _StorageProvider; private readonly StorageConfiguration _StorageConfig; private readonly ILogger _Logger; public ESSDocumentBLL( IESSDocumentDAL essDocumentDAL, IStorageProvider storageProvider, IOptions storageConfig, ILogger logger) { _ESSDocumentDAL = essDocumentDAL; _StorageProvider = storageProvider; _StorageConfig = storageConfig.Value; _Logger = logger; } // ───────────────────────────────────────────────────────────────── // GET MY DOCUMENT LIST // ───────────────────────────────────────────────────────────────── public async Task> GetMyDocumentList( string? categoryCode, int? year, int? month, LoginDTO login, CancellationToken ct) { // MUSER.USERID = TATTACHMENT.OBJECTID when ISEMPLOYEE = 1 int employeeId = await _ESSDocumentDAL .GetEmployeeIdByUser(login, ct) .ConfigureAwait(false); var attachments = await _ESSDocumentDAL .ListMyDocuments( OBJECT_TYPE_EMPLOYEE, employeeId, categoryCode, year, month, login, ct) .ConfigureAwait(false); var list = attachments?.ToList() ?? new List(); if (!list.Any()) return new List(); return list .GroupBy(a => new { a.CategoryCode, a.CategoryName }) .OrderBy(g => g.Key.CategoryName) .Select(catGroup => new ESSDocumentGroupDTO { CategoryCode = catGroup.Key.CategoryCode, CategoryName = catGroup.Key.CategoryName, DocumentCount = catGroup.Count(), Periods = catGroup .GroupBy(a => new { Year = a.ValidFrom.Year == NO_PERIOD_YEAR ? (int?)null : a.ValidFrom.Year, Month = a.ValidFrom.Year == NO_PERIOD_YEAR ? (int?)null : (int?)a.ValidFrom.Month, PeriodLabel = a.PeriodLabel ?? string.Empty }) .OrderByDescending(pg => pg.Key.Year) .ThenByDescending(pg => pg.Key.Month) .Select(periodGroup => new ESSDocumentPeriodGroupDTO { PeriodLabel = string.IsNullOrWhiteSpace( periodGroup.Key.PeriodLabel) ? "General" : periodGroup.Key.PeriodLabel, Year = periodGroup.Key.Year, Month = periodGroup.Key.Month, Documents = periodGroup.Select(MapToItem).ToList() }) .ToList() }) .ToList(); } // ───────────────────────────────────────────────────────────────── // PREVIEW // ───────────────────────────────────────────────────────────────── public async Task<(Stream Stream, string MimeType, string FileName)> PreviewDocument(int attachmentId, LoginDTO login, CancellationToken ct) { var attachment = await GetAndAuthorize(attachmentId, login, ct) .ConfigureAwait(false); var stream = await _StorageProvider .GetStreamAsync(attachment.StoragePath!, ct) .ConfigureAwait(false); _Logger.LogInformation( "ESS preview: AttachmentId={AttachmentId} EmployeeId={EmployeeId}", attachmentId, attachment.EmployeeId); return (stream, attachment.MimeType ?? "application/octet-stream", attachment.DisplayFileName ?? "document"); } // ───────────────────────────────────────────────────────────────── // DOWNLOAD // ───────────────────────────────────────────────────────────────── public async Task<(Stream Stream, string MimeType, string FileName)> DownloadDocument(int attachmentId, LoginDTO login, CancellationToken ct) { var attachment = await GetAndAuthorize(attachmentId, login, ct) .ConfigureAwait(false); var stream = await _StorageProvider .GetStreamAsync(attachment.StoragePath!, ct) .ConfigureAwait(false); _Logger.LogInformation( "ESS download: AttachmentId={AttachmentId} EmployeeId={EmployeeId}", attachmentId, attachment.EmployeeId); return (stream, attachment.MimeType ?? "application/octet-stream", attachment.DisplayFileName ?? "document"); } // ───────────────────────────────────────────────────────────────── // SECURE DOWNLOAD // ───────────────────────────────────────────────────────────────── public async Task<(Stream Stream, string MimeType, string FileName)> DownloadDocumentSecure( int attachmentId, string secretKey, LoginDTO login, CancellationToken ct) { if (string.IsNullOrWhiteSpace(secretKey)) throw new ArgumentException( "Secret key is required for secure download."); var attachment = await GetAndAuthorize(attachmentId, login, ct) .ConfigureAwait(false); var rawStream = await _StorageProvider .GetStreamAsync(attachment.StoragePath!, ct) .ConfigureAwait(false); string baseName = Path.GetFileNameWithoutExtension( attachment.DisplayFileName ?? "document"); Stream resultStream; string resultMime; string resultFileName; if (string.Equals(attachment.MimeType, "application/pdf", StringComparison.OrdinalIgnoreCase)) { resultStream = EncryptPdf(rawStream, secretKey); resultMime = "application/pdf"; resultFileName = $"{baseName}_secured.pdf"; } else { resultStream = await CreateEncryptedZipAsync( rawStream, attachment.DisplayFileName ?? "document", secretKey); resultMime = "application/zip"; resultFileName = $"{baseName}_secured.zip"; } await rawStream.DisposeAsync().ConfigureAwait(false); _Logger.LogInformation( "ESS secure download: AttachmentId={AttachmentId} EmployeeId={EmployeeId}", attachmentId, attachment.EmployeeId); return (resultStream, resultMime, resultFileName); } // ───────────────────────────────────────────────────────────────── // UPLOAD // ───────────────────────────────────────────────────────────────── public async Task UploadDocument( Stream fileStream, string fileName, string mimeType, long fileSizeBytes, int documentTypeId, int? periodYear, int? periodMonth, string? displayName, LoginDTO login, CancellationToken ct) { if (!AllowedMimeTypes.Contains(mimeType)) throw new InvalidOperationException( $"File type not allowed: {mimeType}"); if (fileSizeBytes > MaxUploadBytes) throw new InvalidOperationException( $"File exceeds maximum size of " + $"{MaxUploadBytes / (1024 * 1024)} MB."); int employeeId = await _ESSDocumentDAL .GetEmployeeIdByUser(login, ct) .ConfigureAwait(false); string safeFileName = SanitizeFileName(fileName); string ext = Path.GetExtension(safeFileName).ToLowerInvariant(); string attachmentGuid = Guid.NewGuid().ToString("N"); DateTime now = DateTime.UtcNow; var pathCtx = new StoragePathContext { ClientId = login.ClientId.ToString(), ObjectTypeCode = "EMPLOYEE", ObjectId = employeeId, Year = periodYear ?? now.Year, Month = periodMonth ?? now.Month, AttachmentId = attachmentGuid, Ext = ext, DocumentTypeCode = documentTypeId.ToString() }; string storagePath = StoragePathResolver.Resolve( _StorageConfig.PathTemplate, pathCtx); DateTime validFrom = BuildValidFrom(periodYear, periodMonth); string periodLabel = BuildPeriodLabel(periodYear, periodMonth); await _StorageProvider .SaveAsync(fileStream, storagePath, mimeType, ct) .ConfigureAwait(false); try { var dto = new ESSDocumentInsertDTO { ObjectTypeId = OBJECT_TYPE_EMPLOYEE, EmployeeId = employeeId, DocumentTypeId = documentTypeId, ValidFrom = validFrom, ValidTo = new DateTime(9999, 12, 31), PeriodLabel = periodLabel, StoragePath = storagePath, DisplayFileName = displayName ?? safeFileName, AttachedFileName = safeFileName, MimeType = mimeType, FileSizeBytes = fileSizeBytes, AttachmentOption = 1, CreatedById = login.UserId, CreatedOn = now, ModifiedById = login.UserId, ModifiedOn = now }; int attachmentId = await _ESSDocumentDAL .InsertAttachment(dto, login, ct) .ConfigureAwait(false); _Logger.LogInformation( "ESS upload: EmployeeId={EmployeeId} AttachmentId={AttachmentId}", employeeId, attachmentId); return JsonConvert.SerializeObject(new { AttachmentId = attachmentId, FileName = dto.DisplayFileName, Message = "Document uploaded successfully." }); } catch { await _StorageProvider .DeleteAsync(storagePath, CancellationToken.None) .ConfigureAwait(false); throw; } } // ───────────────────────────────────────────────────────────────── // DELETE // ───────────────────────────────────────────────────────────────── public async Task DeleteDocument( int attachmentId, LoginDTO login, CancellationToken ct) { var attachment = await GetAndAuthorize(attachmentId, login, ct) .ConfigureAwait(false); if (attachment.AttachmentOption == 0) throw new InvalidOperationException( "System-generated documents cannot be deleted by employees."); if (!string.IsNullOrWhiteSpace(attachment.StoragePath)) await _StorageProvider .DeleteAsync(attachment.StoragePath, ct) .ConfigureAwait(false); await _ESSDocumentDAL .DeactivateAttachment(attachmentId, login, ct) .ConfigureAwait(false); _Logger.LogInformation( "ESS delete: AttachmentId={AttachmentId} EmployeeId={EmployeeId}", attachmentId, attachment.EmployeeId); return "Document deleted successfully."; } // ───────────────────────────────────────────────────────────────── // PRIVATE: GET AND AUTHORIZE // Single choke-point for all document access + ownership check // ───────────────────────────────────────────────────────────────── private async Task GetAndAuthorize( int attachmentId, LoginDTO login, CancellationToken ct) { var attachment = await _ESSDocumentDAL .GetById(attachmentId, login, ct) .ConfigureAwait(false); if (attachment is null || attachment.Status != 0) throw new FileNotFoundException( $"Document not found or inactive: {attachmentId}"); // DB lookup — WorkPartyId = -1 so never use it directly int employeeId = await _ESSDocumentDAL .GetEmployeeIdByUser(login, ct) .ConfigureAwait(false); if (attachment.EmployeeId != employeeId) { _Logger.LogWarning( "SECURITY: Unauthorized ESS access. " + "EmployeeId={EmployeeId} AttachmentId={AttachmentId} " + "Owner={Owner}", employeeId, attachmentId, attachment.EmployeeId); throw new UnauthorizedAccessException( "You do not have access to this document."); } return attachment; } // ───────────────────────────────────────────────────────────────── // PRIVATE: MAP TO ITEM (StoragePath excluded from response) // ───────────────────────────────────────────────────────────────── private static ESSDocumentItemDTO MapToItem(ESSDocumentDTO a) => new() { AttachmentId = a.AttachmentId, CategoryCode = a.CategoryCode, CategoryName = a.CategoryName, PeriodYear = a.ValidFrom.Year == NO_PERIOD_YEAR ? null : a.ValidFrom.Year, PeriodMonth = a.ValidFrom.Year == NO_PERIOD_YEAR ? null : (int?)a.ValidFrom.Month, PeriodLabel = a.PeriodLabel, FileName = a.DisplayFileName ?? string.Empty, MimeType = a.MimeType, FileSizeBytes = a.FileSizeBytes, IsSystemGenerated = a.AttachmentOption == 0, AllowPreview = string.Equals(a.MimeType, "application/pdf", StringComparison.OrdinalIgnoreCase), AllowDelete = a.AttachmentOption != 0, CreatedOn = a.CreatedOn }; private static DateTime BuildValidFrom(int? year, int? month) { if (year is null) return new DateTime(1898, 12, 31); return month is null ? new DateTime(year.Value, 1, 1) : new DateTime(year.Value, month.Value, 1); } private static string BuildPeriodLabel(int? year, int? month) { if (year is null) return string.Empty; if (month is null) return $"FY {year}-{(year + 1).ToString()!.Substring(2)}"; return new DateTime(year.Value, month.Value, 1).ToString("MMM yyyy"); } private static string SanitizeFileName(string fileName) { var invalid = Path.GetInvalidFileNameChars(); return string.Concat( fileName.Select(c => invalid.Contains(c) ? '_' : c)); } private static Stream EncryptPdf(Stream inputStream, string userPassword) { var outputStream = new MemoryStream(); var userPass = System.Text.Encoding.UTF8.GetBytes(userPassword); var ownerPass = System.Text.Encoding.UTF8.GetBytes( Guid.NewGuid().ToString()); using var reader = new PdfReader(inputStream); using var writer = new PdfWriter(outputStream, new WriterProperties().SetStandardEncryption( userPass, ownerPass, EncryptionConstants.ALLOW_PRINTING | EncryptionConstants.ALLOW_COPY, EncryptionConstants.ENCRYPTION_AES_256)); using var pdfDoc = new PdfDocument(reader, writer); outputStream.Position = 0; return outputStream; } private static async Task CreateEncryptedZipAsync( Stream fileStream, string fileName, string password) { var ms = new MemoryStream(); using (var archive = new ZipArchive( ms, ZipArchiveMode.Create, leaveOpen: true)) { var entry = archive.CreateEntry( fileName, CompressionLevel.Fastest); await using var entryStream = entry.Open(); await fileStream.CopyToAsync(entryStream).ConfigureAwait(false); } ms.Position = 0; return ms; } } }