diff --git a/FoodsharingSiegen.Contracts/Entity/Prospect.cs b/FoodsharingSiegen.Contracts/Entity/Prospect.cs index bbb4894..d5c7381 100644 --- a/FoodsharingSiegen.Contracts/Entity/Prospect.cs +++ b/FoodsharingSiegen.Contracts/Entity/Prospect.cs @@ -63,6 +63,16 @@ namespace FoodsharingSiegen.Contracts.Entity /// public bool Warning { get; set; } + /// + /// Gets or sets a token string used to securely authorize upload logic. + /// + public Guid? VerificationToken { get; set; } + + /// + /// Gets or sets uploaded identity verification images. + /// + public ICollection? Images { get; set; } + #endregion } } \ No newline at end of file diff --git a/FoodsharingSiegen.Contracts/Entity/ProspectImage.cs b/FoodsharingSiegen.Contracts/Entity/ProspectImage.cs new file mode 100644 index 0000000..9ea1441 --- /dev/null +++ b/FoodsharingSiegen.Contracts/Entity/ProspectImage.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace FoodsharingSiegen.Contracts.Entity +{ + /// + /// Represents an uploaded image for a prospect's verification. + /// + public class ProspectImage + { + [Key] + public Guid Id { get; set; } + + public Guid ProspectId { get; set; } + + [ForeignKey("ProspectId")] + public Prospect? Prospect { get; set; } + + [Required] + public byte[] ImageData { get; set; } = []; + + [Required] + [MaxLength(100)] + public string ContentType { get; set; } = string.Empty; + + public DateTime Created { get; set; } + } +} diff --git a/FoodsharingSiegen.Server/Controls/ProspectContainer.razor b/FoodsharingSiegen.Server/Controls/ProspectContainer.razor index 7bf67b5..10ffe57 100644 --- a/FoodsharingSiegen.Server/Controls/ProspectContainer.razor +++ b/FoodsharingSiegen.Server/Controls/ProspectContainer.razor @@ -31,11 +31,11 @@ @if (Prospect?.FsId != null && Prospect.FsId != 0) { - - - @Prospect?.FsId - - + + + @Prospect?.FsId + + } @@ -210,6 +210,14 @@ } + diff --git a/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.cs b/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.cs index 6005124..f951770 100644 --- a/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.cs +++ b/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.cs @@ -87,6 +87,16 @@ namespace FoodsharingSiegen.Server.Controls #endregion + #region Private Method OpenVerificationDialogAsync + + private async Task OpenVerificationDialogAsync() + { + if (Prospect == null) return; + await VerificationSettingsDialog.ShowAsync(ModalService, Prospect, OnDataChanged); + } + + #endregion + #region Private Method RemoveInteraction /// diff --git a/FoodsharingSiegen.Server/Data/FsContext.cs b/FoodsharingSiegen.Server/Data/FsContext.cs index bc1e123..b38424e 100644 --- a/FoodsharingSiegen.Server/Data/FsContext.cs +++ b/FoodsharingSiegen.Server/Data/FsContext.cs @@ -27,6 +27,11 @@ namespace FoodsharingSiegen.Server.Data /// public DbSet? Prospects { get; set; } + /// + /// Gets or sets the uploaded verification images mapping. + /// + public DbSet? ProspectImages { get; set; } + /// /// Gets or sets the value of the users (ab) /// diff --git a/FoodsharingSiegen.Server/Data/Service/ProspectService.cs b/FoodsharingSiegen.Server/Data/Service/ProspectService.cs index 881c4ad..596e1e9 100644 --- a/FoodsharingSiegen.Server/Data/Service/ProspectService.cs +++ b/FoodsharingSiegen.Server/Data/Service/ProspectService.cs @@ -111,7 +111,10 @@ namespace FoodsharingSiegen.Server.Data.Service { try { - var prospectsQuery = Context.Prospects!.AsNoTracking().Include(x => x.Interactions.OrderBy(i => i.Date)).ThenInclude(x => x.User).OrderBy(x => x.Name).AsQueryable(); + var prospectsQuery = Context.Prospects!.AsNoTracking() + .Include(x => x.Images) + .Include(x => x.Interactions.OrderBy(i => i.Date)).ThenInclude(x => x.User) + .OrderBy(x => x.Name).AsQueryable(); if(parameter.MustHaveInteractions != null && parameter.MustHaveInteractions.Any()) prospectsQuery = prospectsQuery.Where(x => x.Interactions.Any(i => parameter.MustHaveInteractions.Contains(i.Type))); @@ -206,5 +209,138 @@ namespace FoodsharingSiegen.Server.Data.Service } #endregion + + #region Image Upload Features + + public async Task> GenerateVerificationTokenAsync(Guid prospectId) + { + try + { + var prospect = await Context.Prospects! + .Include(x => x.Interactions) + .FirstOrDefaultAsync(x => x.Id == prospectId); + + if (prospect == null) return new(new Exception("Prospect not found")); + + if (prospect.Interactions.Any(x => x.Type == InteractionType.Verify)) + return new(new Exception("Die Identitätsprüfung wurde bereits abgeschlossen. Es kann kein neuer Token generiert werden.")); + + prospect.VerificationToken = Guid.NewGuid(); + prospect.Modified = DateTime.UtcNow; + + await Context.SaveChangesAsync(); + return new(prospect.VerificationToken.Value); + } + catch (Exception e) + { + return new(e); + } + } + + public async Task> GetProspectByVerificationTokenAsync(Guid token) + { + try + { + var prospect = await Context.Prospects! + .Include(x => x.Interactions) + .AsNoTracking() + .FirstOrDefaultAsync(x => x.VerificationToken == token); + + if (prospect == null) return new(new Exception("Ungültiger oder abgelaufener Token.")); + + if (prospect.Interactions.Any(x => x.Type == InteractionType.Verify)) + return new(new Exception("Die Identitätsprüfung wurde bereits abgeschlossen. Ein Hochladen weiterer Bilder ist nicht mehr möglich.")); + + return new(prospect); + } + catch (Exception e) + { + return new(e); + } + } + + public async Task AddVerificationImageAsync(Guid prospectId, byte[] imageData, string contentType) + { + try + { + var prospect = await Context.Prospects! + .Include(x => x.Interactions) + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == prospectId); + + if (prospect == null) return new(new Exception("Foodsaver nicht gefunden.")); + + if (prospect.Interactions.Any(x => x.Type == InteractionType.Verify)) + return new(new Exception("Die Identitätsprüfung wurde bereits abgeschlossen. Ein Hochladen weiterer Bilder ist nicht mehr möglich.")); + + // Verify max 5 images + var imgCount = await Context.ProspectImages!.CountAsync(x => x.ProspectId == prospectId); + if (imgCount >= 5) return new(new Exception("Maximum 5 images allowed")); + + var image = new ProspectImage + { + Id = Guid.NewGuid(), + ProspectId = prospectId, + ImageData = imageData, + ContentType = contentType, + Created = DateTime.UtcNow + }; + + await Context.ProspectImages!.AddAsync(image); + await Context.SaveChangesAsync(); + + return new(); + } + catch (Exception e) + { + return new(e); + } + } + + public async Task>> GetVerificationImagesAsync(Guid prospectId) + { + try + { + var images = await Context.ProspectImages! + .AsNoTracking() + .Where(x => x.ProspectId == prospectId) + .OrderBy(x => x.Created) + .ToListAsync(); + + return new(images); + } + catch (Exception e) + { + return new(e); + } + } + + public async Task DeleteVerificationImagesAsync(Guid prospectId) + { + try + { + var images = await Context.ProspectImages!.Where(x => x.ProspectId == prospectId).ToListAsync(); + if (images.Any()) + { + Context.ProspectImages!.RemoveRange(images); + + var prospect = await Context.Prospects!.FirstOrDefaultAsync(x => x.Id == prospectId); + if (prospect != null) + { + prospect.VerificationToken = null; // Clear token when images are deleted + } + + await Context.SaveChangesAsync(); + } + + return new(); + } + catch (Exception e) + { + return new(e); + } + } + + #endregion } } \ No newline at end of file diff --git a/FoodsharingSiegen.Server/Dialogs/VerificationSettingsDialog.razor b/FoodsharingSiegen.Server/Dialogs/VerificationSettingsDialog.razor new file mode 100644 index 0000000..cab17db --- /dev/null +++ b/FoodsharingSiegen.Server/Dialogs/VerificationSettingsDialog.razor @@ -0,0 +1,32 @@ +@using Blazorise +@inherits FsBase + +
+
+ @if (ShowLinkPanel) + { +
+

Kopiere diesen Link und teile ihn mit @Prospect?.Name:

+
+ + +
+
+ + } + else + { + + + + + + } +
+
diff --git a/FoodsharingSiegen.Server/Dialogs/VerificationSettingsDialog.razor.cs b/FoodsharingSiegen.Server/Dialogs/VerificationSettingsDialog.razor.cs new file mode 100644 index 0000000..224e740 --- /dev/null +++ b/FoodsharingSiegen.Server/Dialogs/VerificationSettingsDialog.razor.cs @@ -0,0 +1,109 @@ +using Blazorise; +using FoodsharingSiegen.Contracts.Entity; +using FoodsharingSiegen.Server.BaseClasses; +using FoodsharingSiegen.Server.Data.Service; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace FoodsharingSiegen.Server.Dialogs +{ + public partial class VerificationSettingsDialog : FsBase + { + [Inject] + public ProspectService ProspectService { get; set; } = null!; + + [Inject] + public NavigationManager NavigationManager { get; set; } = null!; + + [Inject] + public IJSRuntime JS { get; set; } = null!; + + [Parameter] + public Prospect? Prospect { get; set; } + + [Parameter] + public Func? OnDataChanged { get; set; } + + private int ImageCount { get; set; } = 0; + private bool ShowLinkPanel { get; set; } = false; + private string LinkUrl { get; set; } = string.Empty; + + protected override async Task OnInitializedAsync() + { + if (Prospect != null) + { + var result = await ProspectService.GetVerificationImagesAsync(Prospect.Id); + if (result.Success && result.Data != null) + { + ImageCount = result.Data.Count; + } + } + } + + public static async Task ShowAsync(IModalService modalService, Prospect? prospect, Func? onDataChanged) + { + var title = "Identitätsprüfung Einstellungen"; + var action = new Action>(b => + { + b.Add(nameof(Prospect), prospect); + b.Add(nameof(OnDataChanged), onDataChanged); + }); + + await modalService.Show(title, action, new ModalInstanceOptions { Size = ModalSize.Large }); + } + + private async Task GenerateLinkAsync() + { + if (Prospect == null) return; + + Guid token = Prospect.VerificationToken ?? Guid.Empty; + + if (token == Guid.Empty) + { + var result = await ProspectService.GenerateVerificationTokenAsync(Prospect.Id); + if (result.Success) + { + token = result.Data; + if (OnDataChanged != null) await OnDataChanged(); + } + else + { + await Notification.Error(result.Exception?.Message ?? "Ein Fehler ist aufgetreten."); + return; + } + } + + LinkUrl = NavigationManager.BaseUri + "verify/" + token.ToString(); + ShowLinkPanel = true; + } + + private async Task CopyLink() + { + await JS.InvokeVoidAsync("navigator.clipboard.writeText", LinkUrl); + } + + private async Task ViewImagesAsync() + { + await ModalService.Hide(); + if (Prospect != null) + { + await ViewImagesDialog.ShowAsync(ModalService, Prospect); + } + } + + private async Task DeleteImagesAsync() + { + if (Prospect == null) return; + + await ConfirmDialog.ShowAsync(ModalService, "Bilder Löschen", "Sollen alle Identitätsprüfungsbilder dieses Users unwiderruflich gelöscht werden?", async () => + { + var result = await ProspectService.DeleteVerificationImagesAsync(Prospect.Id); + if (result.Success) + { + ImageCount = 0; + StateHasChanged(); + } + }); + } + } +} diff --git a/FoodsharingSiegen.Server/Dialogs/ViewImagesDialog.razor b/FoodsharingSiegen.Server/Dialogs/ViewImagesDialog.razor new file mode 100644 index 0000000..72cc6f9 --- /dev/null +++ b/FoodsharingSiegen.Server/Dialogs/ViewImagesDialog.razor @@ -0,0 +1,44 @@ +@using Blazorise +@inherits FsBase + +@if (SelectedImageIndex.HasValue) +{ +
+ + +
+ + +
+
+} +else +{ + @if (_isLoading) + { +
+ } + else if (_images.Any()) + { +
+ @for (int i = 0; i < _images.Count; i++) + { + var index = i; +
+
+ +
+
+ } +
+ } + else + { +
Keine Bilder vorhanden.
+ } +
+ +
+} diff --git a/FoodsharingSiegen.Server/Dialogs/ViewImagesDialog.razor.cs b/FoodsharingSiegen.Server/Dialogs/ViewImagesDialog.razor.cs new file mode 100644 index 0000000..a1f2c1b --- /dev/null +++ b/FoodsharingSiegen.Server/Dialogs/ViewImagesDialog.razor.cs @@ -0,0 +1,50 @@ +using Blazorise; +using FoodsharingSiegen.Contracts.Entity; +using FoodsharingSiegen.Server.BaseClasses; +using FoodsharingSiegen.Server.Data.Service; +using Microsoft.AspNetCore.Components; + +namespace FoodsharingSiegen.Server.Dialogs +{ + public partial class ViewImagesDialog : FsBase + { + [Inject] + public ProspectService ProspectService { get; set; } = null!; + + [Parameter] + public Prospect? Prospect { get; set; } + + private bool _isLoading = true; + private List _images = new(); + private int? SelectedImageIndex { get; set; } + + protected override async Task OnInitializedAsync() + { + if (Prospect != null) + { + var result = await ProspectService.GetVerificationImagesAsync(Prospect.Id); + if (result.Success && result.Data != null) + { + foreach (var image in result.Data) + { + var base64 = Convert.ToBase64String(image.ImageData); + var imgSrc = $"data:{image.ContentType};base64,{base64}"; + _images.Add(imgSrc); + } + } + } + _isLoading = false; + } + + public static async Task ShowAsync(IModalService modalService, Prospect prospect) + { + var title = $"Bilder für {prospect.Name}"; + var action = new Action>(b => + { + b.Add(nameof(Prospect), prospect); + }); + + await modalService.Show(title, action, new ModalInstanceOptions { Size = ModalSize.ExtraLarge }); + } + } +} diff --git a/FoodsharingSiegen.Server/Migrations/20260420122950_AddImageVerification.Designer.cs b/FoodsharingSiegen.Server/Migrations/20260420122950_AddImageVerification.Designer.cs new file mode 100644 index 0000000..dd93987 --- /dev/null +++ b/FoodsharingSiegen.Server/Migrations/20260420122950_AddImageVerification.Designer.cs @@ -0,0 +1,267 @@ +// +using System; +using FoodsharingSiegen.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FoodsharingSiegen.Server.Migrations +{ + [DbContext(typeof(FsContext))] + [Migration("20260420122950_AddImageVerification")] + partial class AddImageVerification + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Audit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Data1") + .HasColumnType("TEXT"); + + b.Property("Data2") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserID") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserID"); + + b.ToTable("Audits"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Interaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alert") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Feedback") + .HasColumnType("INTEGER"); + + b.Property("FeedbackInfo") + .HasColumnType("TEXT"); + + b.Property("Info1") + .HasColumnType("TEXT"); + + b.Property("Info2") + .HasColumnType("TEXT"); + + b.Property("NotNeeded") + .HasColumnType("INTEGER"); + + b.Property("ProspectID") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserID") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProspectID"); + + b.HasIndex("UserID"); + + b.ToTable("Interactions"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Prospect", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FsId") + .HasColumnType("INTEGER"); + + b.Property("Memo") + .HasColumnType("TEXT"); + + b.Property("Modified") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RecordState") + .HasColumnType("INTEGER"); + + b.Property("VerificationToken") + .HasColumnType("TEXT"); + + b.Property("Warning") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Prospects"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.ProspectImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("ImageData") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ProspectId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProspectId"); + + b.ToTable("ProspectImages"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ForceLogout") + .HasColumnType("INTEGER"); + + b.Property("Groups") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Mail") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Memo") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Network") + .HasColumnType("INTEGER"); + + b.Property("ResetToken") + .HasColumnType("TEXT"); + + b.Property("ResetTokenExpiry") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Verified") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Audit", b => + { + b.HasOne("FoodsharingSiegen.Contracts.Entity.User", "User") + .WithMany() + .HasForeignKey("UserID"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Interaction", b => + { + b.HasOne("FoodsharingSiegen.Contracts.Entity.Prospect", "Prospect") + .WithMany("Interactions") + .HasForeignKey("ProspectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FoodsharingSiegen.Contracts.Entity.User", "User") + .WithMany("Interactions") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Prospect"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.ProspectImage", b => + { + b.HasOne("FoodsharingSiegen.Contracts.Entity.Prospect", "Prospect") + .WithMany("Images") + .HasForeignKey("ProspectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Prospect"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Prospect", b => + { + b.Navigation("Images"); + + b.Navigation("Interactions"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.User", b => + { + b.Navigation("Interactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FoodsharingSiegen.Server/Migrations/20260420122950_AddImageVerification.cs b/FoodsharingSiegen.Server/Migrations/20260420122950_AddImageVerification.cs new file mode 100644 index 0000000..1110971 --- /dev/null +++ b/FoodsharingSiegen.Server/Migrations/20260420122950_AddImageVerification.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FoodsharingSiegen.Server.Migrations +{ + /// + public partial class AddImageVerification : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "VerificationToken", + table: "Prospects", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "ProspectImages", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ProspectId = table.Column(type: "TEXT", nullable: false), + ImageData = table.Column(type: "BLOB", nullable: false), + ContentType = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Created = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProspectImages", x => x.Id); + table.ForeignKey( + name: "FK_ProspectImages_Prospects_ProspectId", + column: x => x.ProspectId, + principalTable: "Prospects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ProspectImages_ProspectId", + table: "ProspectImages", + column: "ProspectId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProspectImages"); + + migrationBuilder.DropColumn( + name: "VerificationToken", + table: "Prospects"); + } + } +} diff --git a/FoodsharingSiegen.Server/Migrations/FsContextModelSnapshot.cs b/FoodsharingSiegen.Server/Migrations/FsContextModelSnapshot.cs index 05af1a6..2006c0c 100644 --- a/FoodsharingSiegen.Server/Migrations/FsContextModelSnapshot.cs +++ b/FoodsharingSiegen.Server/Migrations/FsContextModelSnapshot.cs @@ -118,6 +118,9 @@ namespace FoodsharingSiegen.Server.Migrations b.Property("RecordState") .HasColumnType("INTEGER"); + b.Property("VerificationToken") + .HasColumnType("TEXT"); + b.Property("Warning") .HasColumnType("INTEGER"); @@ -126,6 +129,34 @@ namespace FoodsharingSiegen.Server.Migrations b.ToTable("Prospects"); }); + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.ProspectImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("ImageData") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ProspectId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProspectId"); + + b.ToTable("ProspectImages"); + }); + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.User", b => { b.Property("Id") @@ -205,8 +236,21 @@ namespace FoodsharingSiegen.Server.Migrations b.Navigation("User"); }); + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.ProspectImage", b => + { + b.HasOne("FoodsharingSiegen.Contracts.Entity.Prospect", "Prospect") + .WithMany("Images") + .HasForeignKey("ProspectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Prospect"); + }); + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Prospect", b => { + b.Navigation("Images"); + b.Navigation("Interactions"); }); diff --git a/FoodsharingSiegen.Server/Pages/UploadVerification.razor b/FoodsharingSiegen.Server/Pages/UploadVerification.razor new file mode 100644 index 0000000..3dcaddc --- /dev/null +++ b/FoodsharingSiegen.Server/Pages/UploadVerification.razor @@ -0,0 +1,95 @@ +@page "/verify/{Token:guid}" +@using FoodsharingSiegen.Contracts.Entity +@using System.IO +@layout LoginLayout + +
+ +
diff --git a/FoodsharingSiegen.Server/Pages/UploadVerification.razor.cs b/FoodsharingSiegen.Server/Pages/UploadVerification.razor.cs new file mode 100644 index 0000000..5ab4044 --- /dev/null +++ b/FoodsharingSiegen.Server/Pages/UploadVerification.razor.cs @@ -0,0 +1,141 @@ +using FoodsharingSiegen.Contracts.Entity; +using FoodsharingSiegen.Contracts.Model; +using FoodsharingSiegen.Server.Data.Service; +using FoodsharingSiegen.Shared.Helper; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.Extensions.Options; + +namespace FoodsharingSiegen.Server.Pages +{ + public partial class UploadVerification : ComponentBase + { + [Inject] + public ProspectService ProspectService { get; set; } = null!; + + [Inject] + public IOptions AppSettings { get; set; } = null!; + + [Parameter] + public Guid Token { get; set; } + + private bool _isLoading = true; + private Prospect? _prospect; + + private int _uploadedCount = 0; + private bool _isUploading = false; + + private string? _message; + private bool _isSuccess; + + private const int MaxAllowedFiles = 5; + private const long MaxFileSize = 10 * 1024 * 1024; // 10 MB + + protected override async Task OnInitializedAsync() + { + await LoadData(); + } + + private async Task LoadData() + { + _isLoading = true; + + try + { + var result = await ProspectService.GetProspectByVerificationTokenAsync(Token); + + if (result.Success && result.Data != null) + { + _prospect = result.Data; + } + else + { + _prospect = null; + _message = result.Exception?.Message ?? "Ein Fehler ist aufgetreten."; + _isSuccess = false; + } + } + catch (Exception ex) + { + _message = $"Fehler: {ex.Message}"; + _isSuccess = false; + } + finally + { + _isLoading = false; + } + } + + private async Task OnInputFileChange(InputFileChangeEventArgs e) + { + if (_prospect == null) return; + + _message = null; + + var files = e.GetMultipleFiles(MaxAllowedFiles); + + if (_uploadedCount + files.Count > MaxAllowedFiles) + { + _message = $"Es sind maximal {MaxAllowedFiles} Bilder erlaubt."; + _isSuccess = false; + return; + } + + _isUploading = true; + + int successCount = 0; + + foreach (var file in files) + { + try + { + if (file.Size > MaxFileSize) + { + _message = $"Bild '{file.Name}' überschreitet die erlaubte Größe von 10 MB."; + _isSuccess = false; + continue; + } + + // Resize the image to a max dimension of 1000 pixels (longest edge) to save DB space + var resizedImageFile = await file.RequestImageFileAsync(file.ContentType, 1000, 1000); + + using var stream = resizedImageFile.OpenReadStream(MaxFileSize); + using var memoryStream = new MemoryStream(); + + await stream.CopyToAsync(memoryStream); + + byte[] imageData = memoryStream.ToArray(); + + var saveResult = await ProspectService.AddVerificationImageAsync(_prospect.Id, imageData, resizedImageFile.ContentType); + + if (saveResult.Success) + { + successCount++; + } + else + { + _message = $"Fehler beim Speichern von {file.Name}: {saveResult.Exception?.Message}"; + _isSuccess = false; + break; + } + } + catch (Exception ex) + { + _message = $"Fehler bei {file.Name}: {ex.Message}"; + _isSuccess = false; + } + } + + _isUploading = false; + + if (successCount > 0) + { + _message = $"{successCount} Bild(er) erfolgreich hinzugefügt."; + _isSuccess = true; + _uploadedCount += successCount; + } + + StateHasChanged(); + } + } +}