c775146b64
Implementation A partner, not just software HexDive Technologies' local team handles setup, data migration, training and ongoing support — in Sinhala, Tamil and English. [confirm] 1 Discovery Map your network, products and processes. 2 Configuration Set up depots, pricing, schemes, routes and users. 3 Migration & training Move your data and train every team. 4 Go-live & support Dedicated account manager and local support desk.
109 lines
4.1 KiB
C#
109 lines
4.1 KiB
C#
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<ExpenseAttachmentResponse?> 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<bool> 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;
|
|
}
|
|
}
|