From c2de397a0fa55dc254decc7dba313efac250f505 Mon Sep 17 00:00:00 2001 From: Andre Beging Date: Sat, 29 Mar 2025 13:49:47 +0100 Subject: [PATCH] Add RecordState handling for prospects and support soft deletion Introduced the RecordState property to manage the state of prospects, enabling soft deletion and restoration. Updated related database migrations, UI interactions, and filtering logic to accommodate this addition. Also included automatic database migration at runtime to ensure schema compatibility. --- FoodsharingSiegen.Contracts/Entity/Enums.cs | 22 +- .../Entity/Prospect.cs | 5 + .../Model/Parameters.cs | 2 +- .../Controls/ProspectContainer.razor | 35 ++- .../Controls/ProspectContainer.razor.cs | 40 ++++ .../Controls/ProspectContainer.razor.css | 14 ++ FoodsharingSiegen.Server/Data/FsContext.cs | 42 ++-- .../Data/Service/ProspectService.cs | 4 + .../Dialogs/ConfirmDialog.razor.cs | 2 +- FoodsharingSiegen.Server/Extensions.cs | 25 +++ ...329121239_Proposal-RecordState.Designer.cs | 205 ++++++++++++++++++ .../20250329121239_Proposal-RecordState.cs | 29 +++ .../Migrations/FsContextModelSnapshot.cs | 5 +- .../Pages/ProspectsAll.razor.cs | 6 +- FoodsharingSiegen.Server/Program.cs | 1 + 15 files changed, 404 insertions(+), 33 deletions(-) create mode 100644 FoodsharingSiegen.Server/Migrations/20250329121239_Proposal-RecordState.Designer.cs create mode 100644 FoodsharingSiegen.Server/Migrations/20250329121239_Proposal-RecordState.cs diff --git a/FoodsharingSiegen.Contracts/Entity/Enums.cs b/FoodsharingSiegen.Contracts/Entity/Enums.cs index 9fac34e..057d277 100644 --- a/FoodsharingSiegen.Contracts/Entity/Enums.cs +++ b/FoodsharingSiegen.Contracts/Entity/Enums.cs @@ -17,7 +17,7 @@ namespace FoodsharingSiegen.Contracts.Entity /// The save profile audit type /// SaveProfile = 10, - + #region Usermanagement @@ -93,11 +93,11 @@ namespace FoodsharingSiegen.Contracts.Entity public enum ProspectStateFilter { All = 0, - + OnBoarding = 10, - + Verification = 20, - + Completed = 30 } @@ -127,6 +127,16 @@ namespace FoodsharingSiegen.Contracts.Entity Ambassador = 400 } + /// + /// Represents the state of a record within the system. + /// + public enum RecordState + { + Default = 10, + + Deleted = 20 + } + /// /// The fs network type enum /// @@ -202,12 +212,12 @@ namespace FoodsharingSiegen.Contracts.Entity /// The complete interaction type /// Complete = 70, - + /// /// The StepInBriefing interaction type /// StepInBriefing = 80, - + /// /// The StepInBriefing interaction type /// diff --git a/FoodsharingSiegen.Contracts/Entity/Prospect.cs b/FoodsharingSiegen.Contracts/Entity/Prospect.cs index cf5632c..c51eb9f 100644 --- a/FoodsharingSiegen.Contracts/Entity/Prospect.cs +++ b/FoodsharingSiegen.Contracts/Entity/Prospect.cs @@ -47,6 +47,11 @@ namespace FoodsharingSiegen.Contracts.Entity /// public string Name { get; set; } + /// + /// Gets or sets the state of the record within the system. + /// + public RecordState RecordState { get; set; } + /// /// Gets or sets the value of the warning (ab) /// diff --git a/FoodsharingSiegen.Contracts/Model/Parameters.cs b/FoodsharingSiegen.Contracts/Model/Parameters.cs index 5e1181f..e6b0921 100644 --- a/FoodsharingSiegen.Contracts/Model/Parameters.cs +++ b/FoodsharingSiegen.Contracts/Model/Parameters.cs @@ -5,5 +5,5 @@ namespace FoodsharingSiegen.Contracts.Model /// /// The get prospects parameter /// - public record GetProspectsParameter(List? MustHaveInteractions = null, List? CannotHaveInteractions = null); + public record GetProspectsParameter(List? MustHaveInteractions = null, List? CannotHaveInteractions = null, bool IncludeDeleted = false); } \ No newline at end of file diff --git a/FoodsharingSiegen.Server/Controls/ProspectContainer.razor b/FoodsharingSiegen.Server/Controls/ProspectContainer.razor index b166a90..3d1e9de 100644 --- a/FoodsharingSiegen.Server/Controls/ProspectContainer.razor +++ b/FoodsharingSiegen.Server/Controls/ProspectContainer.razor @@ -5,19 +5,34 @@ var divClass = $"{CssClass} pc-main"; if (Prospect is { Complete: true }) divClass += " complete"; if (Prospect is { Warning: true }) divClass += " warning"; + if (Prospect is { RecordState: RecordState.Deleted }) divClass += " deleted"; }
-
- @if (CurrentUser.IsInGroup(UserGroup.WelcomeTeam, UserGroup.Ambassador)) - { - - } - - @Prospect?.Name - - Profil öffnen - +
+
+ @Prospect?.Name + @Prospect?.FsId +
+
+ @if (CurrentUser.IsInGroup(UserGroup.WelcomeTeam, UserGroup.Ambassador)) + { + + } + + @if (CurrentUser.IsInGroup(UserGroup.WelcomeTeam, UserGroup.Ambassador)) + { + if (Prospect?.RecordState != RecordState.Deleted) + { + + } + else if(CurrentUser.IsAdmin()) + { + + } + + } +
@if (!string.IsNullOrWhiteSpace(Prospect?.Memo)) diff --git a/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.cs b/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.cs index 5dceb2c..b591559 100644 --- a/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.cs +++ b/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.cs @@ -48,6 +48,26 @@ namespace FoodsharingSiegen.Server.Controls #endregion + #region Private Method DeleteProspectAsync + + /// + /// Deletes the currently selected prospect after user confirmation. This action is irreversible and will update the record state to "Deleted". + /// + /// A task that represents the asynchronous delete operation. + private async Task DeleteProspectAsync() + { + if (Prospect == null) return; + + await ConfirmDialog.ShowAsync(ModalService, $"⚠️ {Prospect.Name} löschen", $"Soll {Prospect.Name} mit der FS-ID {Prospect.FsId} wirklich gelöscht werden? Das kann nicht rückgängig gemacht werden!!", async () => + { + Prospect.RecordState = RecordState.Deleted; + var updateR = await ProspectService.UpdateAsync(Prospect); + if (updateR.Success && OnDataChanged != null) await OnDataChanged(); + }); + } + + #endregion + #region Private Method EditProspectAsync private async Task EditProspectAsync() @@ -86,5 +106,25 @@ namespace FoodsharingSiegen.Server.Controls } #endregion + + #region Private Method RestoreProspectAsync + + /// + /// Restores the currently selected prospect after user confirmation. This action will update the record state to "Default". + /// + /// A task that represents the asynchronous restore operation. + private async Task RestoreProspectAsync() + { + if (Prospect == null) return; + + await ConfirmDialog.ShowAsync(ModalService, $"{Prospect.Name} wiederherstellen", $"Soll {Prospect.Name} mit der FS-ID {Prospect.FsId} wiederhergestellt werden?", async () => + { + Prospect.RecordState = RecordState.Default; + var updateR = await ProspectService.UpdateAsync(Prospect); + if (updateR.Success && OnDataChanged != null) await OnDataChanged(); + }); + } + + #endregion } } \ No newline at end of file diff --git a/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.css b/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.css index c7ce67c..0e089da 100644 --- a/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.css +++ b/FoodsharingSiegen.Server/Controls/ProspectContainer.razor.css @@ -29,6 +29,20 @@ box-shadow: 0 0 9px 4px rgba(214,100,23,0.87); } +.pc-main.deleted { + -webkit-box-shadow: 0 0 9px 4px rgb(214 23 23 / 87%); + -moz-box-shadow: 0 0 9px 4px rgb(214 23 23 / 87%); + box-shadow: 0 0 9px 4px rgb(214 23 23 / 87%); +} + .complete { background: #76ff003b; +} + +i.link { + cursor: pointer; color: #64ae24; +} + +i.link:hover { + color: #000; } \ No newline at end of file diff --git a/FoodsharingSiegen.Server/Data/FsContext.cs b/FoodsharingSiegen.Server/Data/FsContext.cs index bb02793..bc1e123 100644 --- a/FoodsharingSiegen.Server/Data/FsContext.cs +++ b/FoodsharingSiegen.Server/Data/FsContext.cs @@ -1,42 +1,43 @@ using FoodsharingSiegen.Contracts.Entity; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; namespace FoodsharingSiegen.Server.Data { /// - /// The fs context class (a. beging, 21.05.2022) + /// The fs context class (a. beging, 21.05.2022) /// - /// + /// public sealed class FsContext : DbContext { #region Public Properties /// - /// Gets or sets the value of the interactions (ab) + /// Gets or sets the value of the audits (ab) + /// + public DbSet? Audits { get; set; } + + /// + /// Gets or sets the value of the interactions (ab) /// public DbSet? Interactions { get; set; } /// - /// Gets or sets the value of the prospects (ab) + /// Gets or sets the value of the prospects (ab) /// public DbSet? Prospects { get; set; } /// - /// Gets or sets the value of the users (ab) + /// Gets or sets the value of the users (ab) /// public DbSet? Users { get; set; } - - /// - /// Gets or sets the value of the audits (ab) - /// - public DbSet? Audits { get; set; } #endregion #region Setup/Teardown /// - /// Initializes a new instance of the class + /// Initializes a new instance of the class /// /// The options (ab) public FsContext(DbContextOptions options) : base(options) @@ -45,11 +46,26 @@ namespace FoodsharingSiegen.Server.Data } #endregion - + + #region Override OnConfiguring + + /// + /// Configures the database context options. + /// + /// A builder used to create or modify options for the context. + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.ConfigureWarnings(builder => { builder.Ignore(RelationalEventId.PendingModelChangesWarning); }); + + base.OnConfiguring(optionsBuilder); + } + + #endregion + #region Public Method HasChanges /// - /// Describes whether this instance has changes + /// Describes whether this instance has changes /// /// The bool public bool HasChanges() diff --git a/FoodsharingSiegen.Server/Data/Service/ProspectService.cs b/FoodsharingSiegen.Server/Data/Service/ProspectService.cs index c96d28a..79c2932 100644 --- a/FoodsharingSiegen.Server/Data/Service/ProspectService.cs +++ b/FoodsharingSiegen.Server/Data/Service/ProspectService.cs @@ -115,6 +115,9 @@ namespace FoodsharingSiegen.Server.Data.Service if(parameter.CannotHaveInteractions != null && parameter.CannotHaveInteractions.Any()) prospectsQuery = prospectsQuery.Where(x => x.Interactions.All(i => !parameter.CannotHaveInteractions.Contains(i.Type))); + + if (!parameter.IncludeDeleted) + prospectsQuery = prospectsQuery.Where(x => x.RecordState != RecordState.Deleted); var prospects = await prospectsQuery.ToListAsync(); @@ -172,6 +175,7 @@ namespace FoodsharingSiegen.Server.Data.Service entityProspect.Name = prospect.Name; entityProspect.FsId = prospect.FsId; entityProspect.Warning = prospect.Warning; + entityProspect.RecordState = prospect.RecordState; var saveR = await Context.SaveChangesAsync(); diff --git a/FoodsharingSiegen.Server/Dialogs/ConfirmDialog.razor.cs b/FoodsharingSiegen.Server/Dialogs/ConfirmDialog.razor.cs index 3d66a42..059657b 100644 --- a/FoodsharingSiegen.Server/Dialogs/ConfirmDialog.razor.cs +++ b/FoodsharingSiegen.Server/Dialogs/ConfirmDialog.razor.cs @@ -38,7 +38,7 @@ namespace FoodsharingSiegen.Server.Dialogs var options = new ModalInstanceOptions { - Size = ModalSize.Small + // Size = ModalSize.Small }; await modalService.Show(title, x, options); diff --git a/FoodsharingSiegen.Server/Extensions.cs b/FoodsharingSiegen.Server/Extensions.cs index cfa8d9a..f7e1916 100644 --- a/FoodsharingSiegen.Server/Extensions.cs +++ b/FoodsharingSiegen.Server/Extensions.cs @@ -28,6 +28,31 @@ namespace FoodsharingSiegen.Server #endregion + /// + /// Ensures that all pending database migrations are applied at runtime. + /// This method checks for pending migrations in the database context and applies them, + /// allowing the application to remain compatible with the latest schema changes. + /// + /// An instance of used to access application services and lifecycle methods. + public static void ApplyMigrations(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // Check and apply pending migrations + var pendingMigrations = dbContext.Database.GetPendingMigrations(); + if (pendingMigrations.Any()) + { + Console.WriteLine("Applying pending migrations..."); + dbContext.Database.Migrate(); + Console.WriteLine("Migrations applied successfully."); + } + else + { + Console.WriteLine("No pending migrations found."); + } + } + #region Public Method LoadAppSettings /// diff --git a/FoodsharingSiegen.Server/Migrations/20250329121239_Proposal-RecordState.Designer.cs b/FoodsharingSiegen.Server/Migrations/20250329121239_Proposal-RecordState.Designer.cs new file mode 100644 index 0000000..b500295 --- /dev/null +++ b/FoodsharingSiegen.Server/Migrations/20250329121239_Proposal-RecordState.Designer.cs @@ -0,0 +1,205 @@ +// +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("20250329121239_Proposal-RecordState")] + partial class ProposalRecordState + { + /// + 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("Info") + .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("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RecordState") + .HasColumnType("INTEGER"); + + b.Property("Warning") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Prospects"); + }); + + 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("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.Prospect", b => + { + b.Navigation("Interactions"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.User", b => + { + b.Navigation("Interactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FoodsharingSiegen.Server/Migrations/20250329121239_Proposal-RecordState.cs b/FoodsharingSiegen.Server/Migrations/20250329121239_Proposal-RecordState.cs new file mode 100644 index 0000000..ffdf1d4 --- /dev/null +++ b/FoodsharingSiegen.Server/Migrations/20250329121239_Proposal-RecordState.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FoodsharingSiegen.Server.Migrations +{ + /// + public partial class ProposalRecordState : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RecordState", + table: "Prospects", + type: "INTEGER", + nullable: false, + defaultValue: 10); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RecordState", + table: "Prospects"); + } + } +} diff --git a/FoodsharingSiegen.Server/Migrations/FsContextModelSnapshot.cs b/FoodsharingSiegen.Server/Migrations/FsContextModelSnapshot.cs index 6d661cc..9cc9d2e 100644 --- a/FoodsharingSiegen.Server/Migrations/FsContextModelSnapshot.cs +++ b/FoodsharingSiegen.Server/Migrations/FsContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace FoodsharingSiegen.Server.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "6.0.1"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Audit", b => { @@ -103,6 +103,9 @@ namespace FoodsharingSiegen.Server.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("RecordState") + .HasColumnType("INTEGER"); + b.Property("Warning") .HasColumnType("INTEGER"); diff --git a/FoodsharingSiegen.Server/Pages/ProspectsAll.razor.cs b/FoodsharingSiegen.Server/Pages/ProspectsAll.razor.cs index 35af4bd..1aec9f1 100644 --- a/FoodsharingSiegen.Server/Pages/ProspectsAll.razor.cs +++ b/FoodsharingSiegen.Server/Pages/ProspectsAll.razor.cs @@ -59,7 +59,11 @@ namespace FoodsharingSiegen.Server.Pages /// private async Task LoadProspects() { - var parameter = new GetProspectsParameter(); + var parameter = new GetProspectsParameter + { + IncludeDeleted = true + }; + var prospectsR = await ProspectService.GetProspectsAsync(parameter); if (prospectsR.Success) ProspectList = prospectsR.Data; diff --git a/FoodsharingSiegen.Server/Program.cs b/FoodsharingSiegen.Server/Program.cs index 374ff4b..59fc6ee 100644 --- a/FoodsharingSiegen.Server/Program.cs +++ b/FoodsharingSiegen.Server/Program.cs @@ -35,6 +35,7 @@ builder.Services .AddMaterialIcons(); var app = builder.Build(); +app.ApplyMigrations(); // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment())