diff --git a/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/ICityRepository.cs b/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/ICityRepository.cs index 56aa981..a2a2370 100644 --- a/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/ICityRepository.cs +++ b/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/ICityRepository.cs @@ -14,4 +14,8 @@ Task> GetFilteredAsync( Guid id, PollStatus? status, CancellationToken cancellationToken = default); + + Task GetByIdWithImagesAsync( + Guid id, + CancellationToken cancellationToken = default); } diff --git a/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IDeletedImageRepository.cs b/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IDeletedImageRepository.cs new file mode 100644 index 0000000..d4d14da --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IDeletedImageRepository.cs @@ -0,0 +1,13 @@ +using Polls.Domain.Images; + +namespace Polls.Application.Common.Interfaces; + +public interface IDeletedImageRepository +{ + Task> GetBatchAsync( + int batchSize, + CancellationToken cancellationToken = default); + + void AddRange(IEnumerable images); + void RemoveRange(IEnumerable images); +} diff --git a/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IIdeaRepository.cs b/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IIdeaRepository.cs index 95597ef..9376039 100644 --- a/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IIdeaRepository.cs +++ b/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IIdeaRepository.cs @@ -13,6 +13,10 @@ Task> GetFilteredAsync( Task GetWithPollAsync( Guid id, CancellationToken cancellationToken = default); + + Task GetByIdWithImagesAsync( + Guid id, + CancellationToken cancellationToken = default); Task UpdateStatusByCityAsync( Guid cityId, diff --git a/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IImageRepository.cs b/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IImageRepository.cs new file mode 100644 index 0000000..5fbe711 --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IImageRepository.cs @@ -0,0 +1,10 @@ +using Polls.Domain.Images; + +namespace Polls.Application.Common.Interfaces; + +public interface IImageRepository where TImage : Image +{ + Task> GetByIdsAsync(IEnumerable ids, CancellationToken ct = default); + void AddRange(IEnumerable images); + void RemoveRange(IEnumerable images); +} diff --git a/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IImageStorageService.cs b/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IImageStorageService.cs new file mode 100644 index 0000000..e106684 --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IImageStorageService.cs @@ -0,0 +1,16 @@ +using Polls.Application.Common.Models; + +namespace Polls.Application.Common.Interfaces; + +public interface IImageStorageService +{ + Task> UploadRangeAsync( + IReadOnlyList files, + CancellationToken cancellationToken = default); + + Task DeleteByNamesAsync( + IEnumerable objectNames, + CancellationToken cancellationToken = default); + + string GetUrl(string fileName); +} diff --git a/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IPollRepository.cs b/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IPollRepository.cs index c8aeaee..218bc72 100644 --- a/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IPollRepository.cs +++ b/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IPollRepository.cs @@ -15,6 +15,10 @@ Task> GetFilteredAsync( Guid id, IdeaStatus? ideaStatus, CancellationToken cancellationToken = default); + + Task GetByIdWithImagesAsync( + Guid id, + CancellationToken cancellationToken = default); Task UpdateStatusByCityAsync( Guid cityId, diff --git a/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IUnitOfWork.cs b/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IUnitOfWork.cs index dfc30f0..46fe7cb 100644 --- a/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IUnitOfWork.cs +++ b/src/Backend/Services/Polls/Polls.Application/Common/Interfaces/IUnitOfWork.cs @@ -1,3 +1,5 @@ +using Polls.Domain.Images; + namespace Polls.Application.Common.Interfaces; public interface IUnitOfWork @@ -6,6 +8,10 @@ public interface IUnitOfWork IPollRepository Polls { get; } IIdeaRepository Ideas { get; } IPollScheduleJobRepository PollScheduleJobs { get; } + IImageRepository CityImages { get; } + IImageRepository PollImages { get; } + IImageRepository IdeaImages { get; } + IDeletedImageRepository DeletedImages { get; } Task SaveChangesAsync(CancellationToken cancellationToken = default); Task BeginTransactionAsync(CancellationToken cancellationToken = default); } diff --git a/src/Backend/Services/Polls/Polls.Application/Common/Models/BaseFilter.cs b/src/Backend/Services/Polls/Polls.Application/Common/Models/BaseFilter.cs index 5a02760..1526639 100644 --- a/src/Backend/Services/Polls/Polls.Application/Common/Models/BaseFilter.cs +++ b/src/Backend/Services/Polls/Polls.Application/Common/Models/BaseFilter.cs @@ -8,4 +8,5 @@ public abstract class BaseFilter public int Page { get; set; } = DefaultPage; public int PageSize { get; set; } = DefaultPageSize; public string? SearchTerm { get; set; } + public bool IncludeImages { get; set; } = true; } diff --git a/src/Backend/Services/Polls/Polls.Application/Common/Models/CityFilter.cs b/src/Backend/Services/Polls/Polls.Application/Common/Models/CityFilter.cs index 46d3da4..64bde86 100644 --- a/src/Backend/Services/Polls/Polls.Application/Common/Models/CityFilter.cs +++ b/src/Backend/Services/Polls/Polls.Application/Common/Models/CityFilter.cs @@ -5,5 +5,4 @@ namespace Polls.Application.Common.Models; public class CityFilter : BaseFilter { public CityStatus? Status { get; set; } - } diff --git a/src/Backend/Services/Polls/Polls.Application/Common/Models/ImageFile.cs b/src/Backend/Services/Polls/Polls.Application/Common/Models/ImageFile.cs new file mode 100644 index 0000000..2c64c05 --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Application/Common/Models/ImageFile.cs @@ -0,0 +1,8 @@ +namespace Polls.Application.Common.Models; + +public class ImageFile +{ + public required Stream Content { get; set; } + public required string FileName { get; set; } + public required string ContentType { get; set; } +} diff --git a/src/Backend/Services/Polls/Polls.Domain/Cities/City.cs b/src/Backend/Services/Polls/Polls.Domain/Cities/City.cs index 0771c7e..94c89a8 100644 --- a/src/Backend/Services/Polls/Polls.Domain/Cities/City.cs +++ b/src/Backend/Services/Polls/Polls.Domain/Cities/City.cs @@ -1,5 +1,6 @@ using Polls.Domain.Cities.Enums; using Polls.Domain.Common; +using Polls.Domain.Images; using Polls.Domain.Polls; namespace Polls.Domain.Cities; @@ -9,4 +10,5 @@ public class City : EntityBase public required Coordinates Coordinates { get; set; } public CityStatus Status { get; set; } = CityStatus.Undefined; public ICollection Polls { get; set; } = []; + public ICollection Images { get; set; } = []; } diff --git a/src/Backend/Services/Polls/Polls.Domain/Ideas/Idea.cs b/src/Backend/Services/Polls/Polls.Domain/Ideas/Idea.cs index 8dbb954..549c13a 100644 --- a/src/Backend/Services/Polls/Polls.Domain/Ideas/Idea.cs +++ b/src/Backend/Services/Polls/Polls.Domain/Ideas/Idea.cs @@ -1,5 +1,6 @@ using Polls.Domain.Common; using Polls.Domain.Ideas.Enums; +using Polls.Domain.Images; using Polls.Domain.Polls; namespace Polls.Domain.Ideas; @@ -10,4 +11,5 @@ public class Idea : EntityBase public Guid PollId { get; set; } public Poll Poll { get; set; } = null!; public IdeaStatus Status { get; set; } = IdeaStatus.Undefined; + public ICollection Images { get; set; } = []; } diff --git a/src/Backend/Services/Polls/Polls.Domain/Images/CityImage.cs b/src/Backend/Services/Polls/Polls.Domain/Images/CityImage.cs new file mode 100644 index 0000000..df7f57c --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Domain/Images/CityImage.cs @@ -0,0 +1,6 @@ +namespace Polls.Domain.Images; + +public class CityImage : Image +{ + public Guid CityId { get; set; } +} diff --git a/src/Backend/Services/Polls/Polls.Domain/Images/DeletedImage.cs b/src/Backend/Services/Polls/Polls.Domain/Images/DeletedImage.cs new file mode 100644 index 0000000..37c88bf --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Domain/Images/DeletedImage.cs @@ -0,0 +1,8 @@ +namespace Polls.Domain.Images; + +public class DeletedImage +{ + public Guid Id { get; set; } + public required string FileName { get; set; } + public DateTimeOffset QueuedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/Backend/Services/Polls/Polls.Domain/Images/IdeaImage.cs b/src/Backend/Services/Polls/Polls.Domain/Images/IdeaImage.cs new file mode 100644 index 0000000..b735d97 --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Domain/Images/IdeaImage.cs @@ -0,0 +1,6 @@ +namespace Polls.Domain.Images; + +public class IdeaImage : Image +{ + public Guid IdeaId { get; set; } +} diff --git a/src/Backend/Services/Polls/Polls.Domain/Images/Image.cs b/src/Backend/Services/Polls/Polls.Domain/Images/Image.cs new file mode 100644 index 0000000..a4b8130 --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Domain/Images/Image.cs @@ -0,0 +1,8 @@ +namespace Polls.Domain.Images; + +public class Image +{ + public Guid Id { get; set; } + public required string FileName { get; set; } + public int Order { get; set; } +} diff --git a/src/Backend/Services/Polls/Polls.Domain/Images/ImageErrors.cs b/src/Backend/Services/Polls/Polls.Domain/Images/ImageErrors.cs new file mode 100644 index 0000000..8578f1e --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Domain/Images/ImageErrors.cs @@ -0,0 +1,21 @@ +using Polls.Domain.Common; + +namespace Polls.Domain.Images; + +public static class ImageErrors +{ + public static Error NotFound(Guid id) => + Error.NotFound($"Image with id '{id}' was not found"); + + public static Error NotBelongsToEntity(Guid id) => + Error.Conflict($"Image with id '{id}' does not belong to this entity"); + + public static Error MaxImagesExceeded(int maxImagesCount) => + Error.Conflict($"Maximum number of images ({maxImagesCount}) exceeded"); + + public static Error InvalidFormat(string[] allowedFormats) => + Error.Conflict($"Invalid image format. Allowed: {string.Join(", ", allowedFormats)}"); + + public static Error FileTooLarge(int maxImageSize) => + Error.Conflict($"Image size exceeds {maxImageSize}MB limit"); +} diff --git a/src/Backend/Services/Polls/Polls.Domain/Images/PollImage.cs b/src/Backend/Services/Polls/Polls.Domain/Images/PollImage.cs new file mode 100644 index 0000000..57eea11 --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Domain/Images/PollImage.cs @@ -0,0 +1,6 @@ +namespace Polls.Domain.Images; + +public class PollImage : Image +{ + public Guid PollId { get; set; } +} diff --git a/src/Backend/Services/Polls/Polls.Domain/Polls/Poll.cs b/src/Backend/Services/Polls/Polls.Domain/Polls/Poll.cs index 725552a..3e627ee 100644 --- a/src/Backend/Services/Polls/Polls.Domain/Polls/Poll.cs +++ b/src/Backend/Services/Polls/Polls.Domain/Polls/Poll.cs @@ -1,5 +1,6 @@ using Polls.Domain.Common; using Polls.Domain.Ideas; +using Polls.Domain.Images; using Polls.Domain.Polls.Enums; namespace Polls.Domain.Polls; @@ -12,4 +13,5 @@ public class Poll : EntityBase public decimal BudgetAmount { get; set; } public PollStatus Status { get; set; } = PollStatus.Undefined; public ICollection Ideas { get; set; } = []; + public ICollection Images { get; set; } = []; } diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/DependencyInjection.cs b/src/Backend/Services/Polls/Polls.Infrastructure/DependencyInjection.cs index 9b1ba2c..d1bcf01 100644 --- a/src/Backend/Services/Polls/Polls.Infrastructure/DependencyInjection.cs +++ b/src/Backend/Services/Polls/Polls.Infrastructure/DependencyInjection.cs @@ -6,13 +6,16 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using Minio; using Polls.Application.Common.Interfaces; using Polls.Application.Jobs; +using Polls.Domain.Images; using Polls.Infrastructure.Jobs; using Polls.Infrastructure.Persistence; using Polls.Infrastructure.Persistence.Interceptors; using Polls.Infrastructure.Persistence.Options; using Polls.Infrastructure.Persistence.Repositories; +using Polls.Infrastructure.Storage; namespace Polls.Infrastructure; @@ -27,6 +30,11 @@ public static IServiceCollection AddInfrastructure( .ValidateDataAnnotations() .ValidateOnStart(); + services.AddOptions() + .Bind(configuration.GetSection(ImageStorageOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + services.AddTransient(typeof(Lazy<>), typeof(LazyResolver<>)); services @@ -57,8 +65,29 @@ public static IServiceCollection AddInfrastructure( .AddScoped() .AddScoped() .AddScoped() + .AddScoped, ImageRepository>() + .AddScoped, ImageRepository>() + .AddScoped, ImageRepository>() + .AddScoped() + .AddScoped() .AddScoped(); + var storageOptions = configuration + .GetSection(ImageStorageOptions.SectionName) + .Get() + ?? throw new InvalidOperationException( + $"Configuration section '{ImageStorageOptions.SectionName}' is missing or invalid."); + + services.AddMinio(client => + { + client + .WithEndpoint(storageOptions.Endpoint) + .WithCredentials(storageOptions.AccessKey, storageOptions.SecretKey) + .WithSSL(storageOptions.UseSsl); + }); + + services.AddScoped(); + services.AddHangfire((serviceProvider, config) => { var dbOptions = serviceProvider.GetRequiredService>().Value; @@ -75,23 +104,31 @@ public static IServiceCollection AddInfrastructure( services .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); return services; } - + public static void UseInfrastructure(this WebApplication app) { const string hangfireDashboardPath = "/hangfire"; const string pollCleanupJobId = "poll-cleanup"; - + const string imageCleanupJobId = "image-cleanup"; + if (app.Environment.IsDevelopment()) app.UseHangfireDashboard(hangfireDashboardPath); - + var recurringJobManager = app.Services.GetRequiredService(); + recurringJobManager.AddOrUpdate( pollCleanupJobId, job => job.ExecuteAsync(CancellationToken.None), Cron.Hourly); + + recurringJobManager.AddOrUpdate( + imageCleanupJobId, + job => job.ExecuteAsync(CancellationToken.None), + Cron.Hourly); } } diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Jobs/ImageCleanupJob.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Jobs/ImageCleanupJob.cs new file mode 100644 index 0000000..d5e76de --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Jobs/ImageCleanupJob.cs @@ -0,0 +1,29 @@ +using Polls.Application.Common.Interfaces; + +namespace Polls.Infrastructure.Jobs; + +public class ImageCleanupJob( + IUnitOfWork unitOfWork, + IImageStorageService storageService) +{ + public async Task ExecuteAsync(CancellationToken cancellationToken = default) + { + const int batchSize = 100; + var hasMore = true; + + while (hasMore) + { + var batch = await unitOfWork.DeletedImages.GetBatchAsync(batchSize, cancellationToken); + hasMore = batch.Count == batchSize; + + if (batch.Count == 0) + break; + + var fileNames = batch.Select(x => x.FileName).ToList(); + + await storageService.DeleteByNamesAsync(fileNames, cancellationToken); + unitOfWork.DeletedImages.RemoveRange(batch); + await unitOfWork.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/ApplicationDbContext.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/ApplicationDbContext.cs index bdbc91f..4bbf525 100644 --- a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/ApplicationDbContext.cs +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/ApplicationDbContext.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Polls.Domain.Cities; using Polls.Domain.Ideas; +using Polls.Domain.Images; using Polls.Domain.Polls; using Polls.Domain.PollScheduleJob; @@ -11,6 +12,10 @@ public class ApplicationDbContext(DbContextOptions options public DbSet Cities => Set(); public DbSet Polls => Set(); public DbSet Ideas => Set(); + public DbSet CityImages => Set(); + public DbSet PollImages => Set(); + public DbSet IdeaImages => Set(); + public DbSet DeletedImages => Set(); public DbSet PollScheduleJobs => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Configurations/CityImageConfiguration.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Configurations/CityImageConfiguration.cs new file mode 100644 index 0000000..d488bce --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Configurations/CityImageConfiguration.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Polls.Domain.Cities; +using Polls.Domain.Images; + +namespace Polls.Infrastructure.Persistence.Configurations; + +public class CityImageConfiguration : IEntityTypeConfiguration +{ + private const string TableName = "city_images"; + private const string IdColumnName = "id"; + private const string FileNameColumnName = "file_name"; + private const string OrderColumnName = "order"; + private const string CityIdColumnName = "city_id"; + private const string CityIdIndexName = "ix_city_images_city_id"; + private const int FileNameMaxLength = 500; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(TableName); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .HasColumnName(IdColumnName); + + builder.Property(x => x.FileName) + .HasColumnName(FileNameColumnName) + .IsRequired() + .HasMaxLength(FileNameMaxLength); + + builder.Property(x => x.Order) + .HasColumnName(OrderColumnName) + .IsRequired(); + + builder.Property(x => x.CityId) + .HasColumnName(CityIdColumnName) + .IsRequired(); + + builder.HasOne() + .WithMany(c => c.Images) + .HasForeignKey(x => x.CityId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(x => x.CityId) + .HasDatabaseName(CityIdIndexName); + } +} diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Configurations/DeletedImageConfiguration.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Configurations/DeletedImageConfiguration.cs new file mode 100644 index 0000000..ba97ca4 --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Configurations/DeletedImageConfiguration.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Polls.Domain.Images; + +namespace Polls.Infrastructure.Persistence.Configurations; + +public class DeletedImageConfiguration : IEntityTypeConfiguration +{ + private const string TableName = "deleted_images"; + private const string IdColumnName = "id"; + private const string FileNameColumnName = "file_name"; + private const string QueuedAtColumnName = "queued_at"; + private const int FileNameMaxLength = 500; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(TableName); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .HasColumnName(IdColumnName); + + builder.Property(x => x.FileName) + .HasColumnName(FileNameColumnName) + .IsRequired() + .HasMaxLength(FileNameMaxLength); + + builder.Property(x => x.QueuedAt) + .HasColumnName(QueuedAtColumnName) + .IsRequired(); + } +} diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Configurations/IdeaImageConfiguration.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Configurations/IdeaImageConfiguration.cs new file mode 100644 index 0000000..efbc049 --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Configurations/IdeaImageConfiguration.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Polls.Domain.Ideas; +using Polls.Domain.Images; + +namespace Polls.Infrastructure.Persistence.Configurations; + +public class IdeaImageConfiguration : IEntityTypeConfiguration +{ + private const string TableName = "idea_images"; + private const string IdColumnName = "id"; + private const string FileNameColumnName = "file_name"; + private const string OrderColumnName = "order"; + private const string IdeaIdColumnName = "idea_id"; + private const string IdeaIdIndexName = "ix_idea_images_idea_id"; + private const int FileNameMaxLength = 500; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(TableName); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .HasColumnName(IdColumnName); + + builder.Property(x => x.FileName) + .HasColumnName(FileNameColumnName) + .IsRequired() + .HasMaxLength(FileNameMaxLength); + + builder.Property(x => x.Order) + .HasColumnName(OrderColumnName) + .IsRequired(); + + builder.Property(x => x.IdeaId) + .HasColumnName(IdeaIdColumnName) + .IsRequired(); + + builder.HasOne() + .WithMany(i => i.Images) + .HasForeignKey(x => x.IdeaId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(x => x.IdeaId) + .HasDatabaseName(IdeaIdIndexName); + } +} diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Configurations/PollImageConfiguration.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Configurations/PollImageConfiguration.cs new file mode 100644 index 0000000..0253059 --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Configurations/PollImageConfiguration.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Polls.Domain.Images; +using Polls.Domain.Polls; + +namespace Polls.Infrastructure.Persistence.Configurations; + +public class PollImageConfiguration : IEntityTypeConfiguration +{ + private const string TableName = "poll_images"; + private const string IdColumnName = "id"; + private const string FileNameColumnName = "file_name"; + private const string OrderColumnName = "order"; + private const string PollIdColumnName = "poll_id"; + private const string PollIdIndexName = "ix_poll_images_poll_id"; + private const int FileNameMaxLength = 500; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(TableName); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .HasColumnName(IdColumnName); + + builder.Property(x => x.FileName) + .HasColumnName(FileNameColumnName) + .IsRequired() + .HasMaxLength(FileNameMaxLength); + + builder.Property(x => x.Order) + .HasColumnName(OrderColumnName) + .IsRequired(); + + builder.Property(x => x.PollId) + .HasColumnName(PollIdColumnName) + .IsRequired(); + + builder.HasOne() + .WithMany(p => p.Images) + .HasForeignKey(x => x.PollId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(x => x.PollId) + .HasDatabaseName(PollIdIndexName); + } +} diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Extensions/CityQueryBuilder.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Extensions/CityQueryBuilder.cs index 2eb5456..eb9b5ed 100644 --- a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Extensions/CityQueryBuilder.cs +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Extensions/CityQueryBuilder.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using Polls.Domain.Cities; using Polls.Domain.Cities.Enums; @@ -26,6 +27,15 @@ public CityQueryBuilder WithSearchTerm(string? searchTerm) && c.Description.ToLower().Contains(lower))); return this; } + + public CityQueryBuilder IncludeImages(bool include = true) + { + if (include) + { + _query = _query.Include(c => c.Images); + } + return this; + } public IQueryable Build() { diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Extensions/IdeaQueryBuilder.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Extensions/IdeaQueryBuilder.cs index 28b8919..c710c8a 100644 --- a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Extensions/IdeaQueryBuilder.cs +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Extensions/IdeaQueryBuilder.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using Polls.Domain.Ideas; using Polls.Domain.Ideas.Enums; @@ -33,6 +34,15 @@ public IdeaQueryBuilder WithSearchTerm(string? searchTerm) && i.Description.ToLower().Contains(lower))); return this; } + + public IdeaQueryBuilder IncludeImages(bool include) + { + if (include) + { + _query = _query.Include(i => i.Images); + } + return this; + } public IQueryable Build() { diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Extensions/PollQueryBuilder.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Extensions/PollQueryBuilder.cs index 10f0a5a..f7ae943 100644 --- a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Extensions/PollQueryBuilder.cs +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Extensions/PollQueryBuilder.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using Polls.Domain.Polls; using Polls.Domain.Polls.Enums; @@ -47,6 +48,15 @@ public PollQueryBuilder WithEndsAtBefore(DateTimeOffset? date) _query = _query.Where(p => p.EndsAt <= date); return this; } + + public PollQueryBuilder IncludeImages(bool include) + { + if (include) + { + _query = _query.Include(p => p.Images); + } + return this; + } public IQueryable Build() { diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Options/ImageStorageOptions.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Options/ImageStorageOptions.cs new file mode 100644 index 0000000..352306d --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Options/ImageStorageOptions.cs @@ -0,0 +1,12 @@ +namespace Polls.Infrastructure.Persistence.Options; + +public class ImageStorageOptions +{ + public const string SectionName = "ImageStorage"; + + public required string Endpoint { get; set; } + public required string AccessKey { get; set; } + public required string SecretKey { get; set; } + public required string BucketName { get; set; } + public bool UseSsl { get; set; } = false; +} diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/CityRepository.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/CityRepository.cs index e240460..cca46f6 100644 --- a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/CityRepository.cs +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/CityRepository.cs @@ -16,6 +16,7 @@ public async Task> GetFilteredAsync( return await new CityQueryBuilder(_dbSet.AsNoTracking()) .WithStatus(filter.Status) .WithSearchTerm(filter.SearchTerm) + .IncludeImages(filter.IncludeImages) .Build() .ToPagedListAsync(filter.Page, filter.PageSize, cancellationToken); } @@ -26,7 +27,20 @@ public async Task> GetFilteredAsync( CancellationToken cancellationToken = default) { return await _dbSet + .AsNoTracking() + .AsSplitQuery() + .Include(c => c.Images) .Include(c => c.Polls.Where(p => status == null || p.Status == status)) + .ThenInclude(p => p.Images) + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + } + + public async Task GetByIdWithImagesAsync( + Guid id, + CancellationToken cancellationToken = default) + { + return await _dbSet + .Include(c => c.Images) .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); } } diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/DeletedImageRepository.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/DeletedImageRepository.cs new file mode 100644 index 0000000..a8111c6 --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/DeletedImageRepository.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Polls.Application.Common.Interfaces; +using Polls.Domain.Images; + +namespace Polls.Infrastructure.Persistence.Repositories; + +public class DeletedImageRepository(ApplicationDbContext db) : IDeletedImageRepository +{ + public async Task> GetBatchAsync( + int batchSize, + CancellationToken cancellationToken = default) + { + return await db.DeletedImages + .OrderBy(x => x.QueuedAt) + .Take(batchSize) + .ToListAsync(cancellationToken); + } + + public void AddRange(IEnumerable images) + { + db.DeletedImages.AddRange(images); + } + + public void RemoveRange(IEnumerable images) + { + db.DeletedImages.RemoveRange(images); + } +} diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/IdeaRepository.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/IdeaRepository.cs index 494d87e..6935623 100644 --- a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/IdeaRepository.cs +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/IdeaRepository.cs @@ -18,6 +18,7 @@ public async Task> GetFilteredAsync( .WithPollId(filter.PollId) .WithStatus(filter.Status) .WithSearchTerm(filter.SearchTerm) + .IncludeImages(filter.IncludeImages) .Build() .ToPagedListAsync(filter.Page, filter.PageSize, cancellationToken); } @@ -27,7 +28,20 @@ public async Task> GetFilteredAsync( CancellationToken cancellationToken = default) { return await _dbSet + .AsNoTracking() + .AsSplitQuery() + .Include(i => i.Images) .Include(i => i.Poll) + .ThenInclude(p => p.Images) + .FirstOrDefaultAsync(i => i.Id == id, cancellationToken); + } + + public async Task GetByIdWithImagesAsync( + Guid id, + CancellationToken cancellationToken = default) + { + return await _dbSet + .Include(i => i.Images) .FirstOrDefaultAsync(i => i.Id == id, cancellationToken); } diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/ImageRepository.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/ImageRepository.cs new file mode 100644 index 0000000..09da024 --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/ImageRepository.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Polls.Application.Common.Interfaces; +using Polls.Domain.Images; + +namespace Polls.Infrastructure.Persistence.Repositories; + +public class ImageRepository(ApplicationDbContext db) : IImageRepository + where TImage : Image +{ + private readonly DbSet _dbSet = db.Set(); + + public async Task> GetByIdsAsync( + IEnumerable ids, + CancellationToken ct = default) + { + return await _dbSet + .Where(x => ids.Contains(x.Id)) + .ToListAsync(ct); + } + + public void AddRange(IEnumerable images) + { + _dbSet.AddRange(images); + } + + public void RemoveRange(IEnumerable images) + { + _dbSet.RemoveRange(images); + } +} diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/PollRepository.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/PollRepository.cs index ff1f015..913f7df 100644 --- a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/PollRepository.cs +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/Repositories/PollRepository.cs @@ -20,6 +20,7 @@ public async Task> GetFilteredAsync( .WithStatus(filter.Status) .WithSearchTerm(filter.SearchTerm) .WithEndsAtBefore(filter.EndsBefore) + .IncludeImages(filter.IncludeImages) .Build() .ToPagedListAsync(filter.Page, filter.PageSize, cancellationToken); } @@ -30,7 +31,20 @@ public async Task> GetFilteredAsync( CancellationToken cancellationToken = default) { return await _dbSet + .AsNoTracking() + .AsSplitQuery() + .Include(p => p.Images) .Include(p => p.Ideas.Where(i => ideaStatus == null || i.Status == ideaStatus)) + .ThenInclude(i => i.Images) + .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); + } + + public async Task GetByIdWithImagesAsync( + Guid id, + CancellationToken cancellationToken = default) + { + return await _dbSet + .Include(p => p.Images) .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); } diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/RepositoryCollection.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/RepositoryCollection.cs new file mode 100644 index 0000000..1597643 --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/RepositoryCollection.cs @@ -0,0 +1,14 @@ +using Polls.Application.Common.Interfaces; +using Polls.Domain.Images; + +namespace Polls.Infrastructure.Persistence; + +public sealed record RepositoryCollection( + Lazy Cities, + Lazy Polls, + Lazy PollScheduleJobs, + Lazy Ideas, + Lazy> CityImages, + Lazy> PollImages, + Lazy> IdeaImages, + Lazy DeletedImages); diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/UnitOfWork.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/UnitOfWork.cs index 921a16b..fb1a25b 100644 --- a/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/UnitOfWork.cs +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Persistence/UnitOfWork.cs @@ -1,18 +1,20 @@ using Polls.Application.Common.Interfaces; +using Polls.Domain.Images; namespace Polls.Infrastructure.Persistence; public sealed class UnitOfWork( ApplicationDbContext context, - Lazy cities, - Lazy polls, - Lazy pollScheduleJobs, - Lazy ideas) : IUnitOfWork + RepositoryCollection repositories) : IUnitOfWork { - public ICityRepository Cities => cities.Value; - public IPollRepository Polls => polls.Value; - public IIdeaRepository Ideas => ideas.Value; - public IPollScheduleJobRepository PollScheduleJobs => pollScheduleJobs.Value; + public ICityRepository Cities => repositories.Cities.Value; + public IPollRepository Polls => repositories.Polls.Value; + public IIdeaRepository Ideas => repositories.Ideas.Value; + public IPollScheduleJobRepository PollScheduleJobs => repositories.PollScheduleJobs.Value; + public IImageRepository CityImages => repositories.CityImages.Value; + public IImageRepository PollImages => repositories.PollImages.Value; + public IImageRepository IdeaImages => repositories.IdeaImages.Value; + public IDeletedImageRepository DeletedImages => repositories.DeletedImages.Value; public Task SaveChangesAsync(CancellationToken cancellationToken = default) { diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Polls.Infrastructure.csproj b/src/Backend/Services/Polls/Polls.Infrastructure/Polls.Infrastructure.csproj index 751a372..7258dda 100644 --- a/src/Backend/Services/Polls/Polls.Infrastructure/Polls.Infrastructure.csproj +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Polls.Infrastructure.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Backend/Services/Polls/Polls.Infrastructure/Storage/MinioStorageService.cs b/src/Backend/Services/Polls/Polls.Infrastructure/Storage/MinioStorageService.cs new file mode 100644 index 0000000..1da3cd8 --- /dev/null +++ b/src/Backend/Services/Polls/Polls.Infrastructure/Storage/MinioStorageService.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Options; +using Minio; +using Minio.DataModel.Args; +using Polls.Application.Common.Interfaces; +using Polls.Application.Common.Models; +using Polls.Infrastructure.Persistence.Options; + +namespace Polls.Infrastructure.Storage; + +public class MinioStorageService( + IMinioClient minioClient, + IOptions options) : IImageStorageService +{ + private readonly ImageStorageOptions _options = options.Value; + + public async Task> UploadRangeAsync( + IReadOnlyList files, + CancellationToken cancellationToken = default) + { + var uploadTasks = files.Select(file => UploadAsync(file, cancellationToken)); + var fileNames = await Task.WhenAll(uploadTasks); + return fileNames; + } + + public async Task DeleteByNamesAsync( + IEnumerable objectNames, + CancellationToken cancellationToken = default) + { + var objects = objectNames + .Select(name => new KeyValuePair(_options.BucketName, name)) + .ToList(); + + var args = new RemoveObjectsArgs() + .WithBucket(_options.BucketName) + .WithObjects(objects.Select(o => o.Value).ToList()); + + await minioClient.RemoveObjectsAsync(args, cancellationToken); + } + + public string GetUrl(string fileName) + => $"{_options.Endpoint.TrimEnd('/')}/{_options.BucketName}/{fileName}"; + + private async Task UploadAsync(ImageFile file, CancellationToken cancellationToken) + { + var extension = Path.GetExtension(file.FileName); + var objectName = $"{Guid.NewGuid()}{extension}"; + + var args = new PutObjectArgs() + .WithBucket(_options.BucketName) + .WithObject(objectName) + .WithStreamData(file.Content) + .WithObjectSize(file.Content.Length) + .WithContentType(file.ContentType); + + await minioClient.PutObjectAsync(args, cancellationToken); + + return objectName; + } +}