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.
This commit is contained in:
Andre Beging
2025-03-29 13:49:47 +01:00
parent 16023a89e9
commit c2de397a0f
15 changed files with 404 additions and 33 deletions

View File

@@ -127,6 +127,16 @@ namespace FoodsharingSiegen.Contracts.Entity
Ambassador = 400
}
/// <summary>
/// Represents the state of a record within the system.
/// </summary>
public enum RecordState
{
Default = 10,
Deleted = 20
}
/// <summary>
/// The fs network type enum
/// </summary>

View File

@@ -47,6 +47,11 @@ namespace FoodsharingSiegen.Contracts.Entity
/// </summary>
public string Name { get; set; }
/// <summary>
/// Gets or sets the state of the record within the system.
/// </summary>
public RecordState RecordState { get; set; }
/// <summary>
/// Gets or sets the value of the warning (ab)
/// </summary>

View File

@@ -5,5 +5,5 @@ namespace FoodsharingSiegen.Contracts.Model
/// <summary>
/// The get prospects parameter
/// </summary>
public record GetProspectsParameter(List<InteractionType>? MustHaveInteractions = null, List<InteractionType>? CannotHaveInteractions = null);
public record GetProspectsParameter(List<InteractionType>? MustHaveInteractions = null, List<InteractionType>? CannotHaveInteractions = null, bool IncludeDeleted = false);
}

View File

@@ -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";
}
<div class="@divClass">
<h5 class="mb-0">
@if (CurrentUser.IsInGroup(UserGroup.WelcomeTeam, UserGroup.Ambassador))
{
<i class="fa-solid fa-pen-to-square mr-2" style="cursor: pointer; color: #64ae24;" @onclick="EditProspectAsync" @onclick:preventDefault></i>
}
<h5 class="mb-2 d-flex">
<div class="flex-grow-1">
@Prospect?.Name
<small style="font-size: .9rem;">@Prospect?.FsId</small>
</div>
<div>
@if (CurrentUser.IsInGroup(UserGroup.WelcomeTeam, UserGroup.Ambassador))
{
<i class="fa-solid fa-pen-to-square link mr-2" @onclick="EditProspectAsync" @onclick:preventDefault></i>
}
<a href="@(CurrentUser.NetworkLink)/profile/@Prospect?.FsId" target="_blank"><i class="fa-solid fa-eye"></i></a>
@if (CurrentUser.IsInGroup(UserGroup.WelcomeTeam, UserGroup.Ambassador))
{
if (Prospect?.RecordState != RecordState.Deleted)
{
<i class="fa-solid fa-trash link ml-2" @onclick="DeleteProspectAsync" @onclick:preventDefault></i>
}
else if(CurrentUser.IsAdmin())
{
<i class="fa-solid fa-recycle link ml-2" @onclick="RestoreProspectAsync" @onclick:preventDefault></i>
}
@Prospect?.Name
<small style="font-size: .9rem; opacity: .7;">
<a class="invert" href="@(CurrentUser.NetworkLink)/profile/@Prospect?.FsId" target="_blank">Profil öffnen</a>
</small>
}
</div>
</h5>
@if (!string.IsNullOrWhiteSpace(Prospect?.Memo))

View File

@@ -48,6 +48,26 @@ namespace FoodsharingSiegen.Server.Controls
#endregion
#region Private Method DeleteProspectAsync
/// <summary>
/// Deletes the currently selected prospect after user confirmation. This action is irreversible and will update the record state to "Deleted".
/// </summary>
/// <returns>A task that represents the asynchronous delete operation.</returns>
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
/// <summary>
/// Restores the currently selected prospect after user confirmation. This action will update the record state to "Default".
/// </summary>
/// <returns>A task that represents the asynchronous restore operation.</returns>
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
}
}

View File

@@ -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;
}

View File

@@ -1,42 +1,43 @@
using FoodsharingSiegen.Contracts.Entity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace FoodsharingSiegen.Server.Data
{
/// <summary>
/// The fs context class (a. beging, 21.05.2022)
/// The fs context class (a. beging, 21.05.2022)
/// </summary>
/// <seealso cref="DbContext"/>
/// <seealso cref="DbContext" />
public sealed class FsContext : DbContext
{
#region Public Properties
/// <summary>
/// Gets or sets the value of the interactions (ab)
/// Gets or sets the value of the audits (ab)
/// </summary>
public DbSet<Audit>? Audits { get; set; }
/// <summary>
/// Gets or sets the value of the interactions (ab)
/// </summary>
public DbSet<Interaction>? Interactions { get; set; }
/// <summary>
/// Gets or sets the value of the prospects (ab)
/// Gets or sets the value of the prospects (ab)
/// </summary>
public DbSet<Prospect>? Prospects { get; set; }
/// <summary>
/// Gets or sets the value of the users (ab)
/// Gets or sets the value of the users (ab)
/// </summary>
public DbSet<User>? Users { get; set; }
/// <summary>
/// Gets or sets the value of the audits (ab)
/// </summary>
public DbSet<Audit>? Audits { get; set; }
#endregion
#region Setup/Teardown
/// <summary>
/// Initializes a new instance of the <see cref="FsContext"/> class
/// Initializes a new instance of the <see cref="FsContext" /> class
/// </summary>
/// <param name="options">The options (ab)</param>
public FsContext(DbContextOptions<FsContext> options) : base(options)
@@ -46,10 +47,25 @@ namespace FoodsharingSiegen.Server.Data
#endregion
#region Override OnConfiguring
/// <summary>
/// Configures the database context options.
/// </summary>
/// <param name="optionsBuilder">A builder used to create or modify options for the context.</param>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.ConfigureWarnings(builder => { builder.Ignore(RelationalEventId.PendingModelChangesWarning); });
base.OnConfiguring(optionsBuilder);
}
#endregion
#region Public Method HasChanges
/// <summary>
/// Describes whether this instance has changes
/// Describes whether this instance has changes
/// </summary>
/// <returns>The bool</returns>
public bool HasChanges()

View File

@@ -116,6 +116,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();
return new(prospects);
@@ -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();

View File

@@ -38,7 +38,7 @@ namespace FoodsharingSiegen.Server.Dialogs
var options = new ModalInstanceOptions
{
Size = ModalSize.Small
// Size = ModalSize.Small
};
await modalService.Show(title, x, options);

View File

@@ -28,6 +28,31 @@ namespace FoodsharingSiegen.Server
#endregion
/// <summary>
/// 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.
/// </summary>
/// <param name="app">An instance of <see cref="WebApplication" /> used to access application services and lifecycle methods.</param>
public static void ApplyMigrations(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<FsContext>();
// 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
/// <summary>

View File

@@ -0,0 +1,205 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Data1")
.HasColumnType("TEXT");
b.Property<string>("Data2")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<Guid?>("UserID")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserID");
b.ToTable("Audits");
});
modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Interaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Alert")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<string>("Info")
.HasColumnType("TEXT");
b.Property<bool>("NotNeeded")
.HasColumnType("INTEGER");
b.Property<Guid>("ProspectID")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<Guid>("UserID")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProspectID");
b.HasIndex("UserID");
b.ToTable("Interactions");
});
modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Prospect", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<int>("FsId")
.HasColumnType("INTEGER");
b.Property<string>("Memo")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RecordState")
.HasColumnType("INTEGER");
b.Property<bool>("Warning")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Prospects");
});
modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("EncryptedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("ForceLogout")
.HasColumnType("INTEGER");
b.Property<string>("Groups")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Mail")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Memo")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Network")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<bool>("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
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FoodsharingSiegen.Server.Migrations
{
/// <inheritdoc />
public partial class ProposalRecordState : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "RecordState",
table: "Prospects",
type: "INTEGER",
nullable: false,
defaultValue: 10);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RecordState",
table: "Prospects");
}
}
}

View File

@@ -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<int>("RecordState")
.HasColumnType("INTEGER");
b.Property<bool>("Warning")
.HasColumnType("INTEGER");

View File

@@ -59,7 +59,11 @@ namespace FoodsharingSiegen.Server.Pages
/// </summary>
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;

View File

@@ -35,6 +35,7 @@ builder.Services
.AddMaterialIcons();
var app = builder.Build();
app.ApplyMigrations();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())