using Distribution.Data; using Distribution.Infra.Authorization; using Distribution.Infra.UoW; using Microsoft.EntityFrameworkCore; namespace Distribution.Features.Expense; public class ExpenseAttachmentService( AppDBContext context, IConfiguration config, IUnitOfWork uow, ICurrentUserService currentUser) { private const long MaxBytes = 10 * 1024 * 1024; private static readonly string[] AllowedExtensions = [".pdf", ".jpg", ".jpeg", ".png", ".webp"]; private static readonly string[] AllowedContentTypes = ["application/pdf", "image/jpeg", "image/jpg", "image/png", "image/webp"]; private string AttachmentsRoot => config["Expense:AttachmentsRoot"] ?? throw new InvalidOperationException("Expense:AttachmentsRoot is not configured"); public async Task UploadAsync(Guid expenseId, IFormFile file, CancellationToken ct) { var expense = await context.Expenses.FirstOrDefaultAsync(e => e.Id == expenseId && e.IsActive, ct); if (expense is null) return null; if (file is null || file.Length == 0) throw new InvalidOperationException("File is empty"); if (file.Length > MaxBytes) throw new InvalidOperationException($"File exceeds the {MaxBytes / (1024 * 1024)}MB limit"); var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (!AllowedExtensions.Contains(ext)) throw new InvalidOperationException($"File type '{ext}' is not allowed"); if (!AllowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) throw new InvalidOperationException($"Content type '{file.ContentType}' is not allowed"); var userId = currentUser.UserId ?? throw new InvalidOperationException("Authenticated user is required"); var now = DateTime.Now; var relativeDir = Path.Combine(now.Year.ToString("D4"), now.Month.ToString("D2")); var absoluteDir = Path.Combine(AttachmentsRoot, relativeDir); Directory.CreateDirectory(absoluteDir); var storedName = $"{Guid.NewGuid()}{ext}"; var absolutePath = Path.Combine(absoluteDir, storedName); await using (var stream = File.Create(absolutePath)) { await file.CopyToAsync(stream, ct); } var attachment = new Models.ExpenseAttachment { FK_ExpenseId = expenseId, OriginalFileName = file.FileName, StoredPath = absolutePath, ContentType = file.ContentType, SizeBytes = file.Length, UploadedByUserId = userId }; await uow.BeginAsync(ct); try { await context.ExpenseAttachments.AddAsync(attachment, ct); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); try { File.Delete(absolutePath); } catch { } throw; } return new ExpenseAttachmentResponse(attachment.Id, attachment.OriginalFileName, attachment.ContentType, attachment.SizeBytes, attachment.CreatedAt); } public async Task<(Stream Stream, string ContentType, string FileName)?> DownloadAsync(Guid attachmentId, CancellationToken ct) { var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct); if (a is null) return null; if (!File.Exists(a.StoredPath)) return null; var stream = File.OpenRead(a.StoredPath); return (stream, a.ContentType, a.OriginalFileName); } public async Task DeleteAsync(Guid attachmentId, CancellationToken ct) { var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct); if (a is null) return false; a.IsActive = false; await uow.BeginAsync(ct); try { context.ExpenseAttachments.Update(a); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } // Best-effort file removal — keep DB soft-delete even if the file is gone. try { File.Delete(a.StoredPath); } catch { } return true; } }