diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs index 26599d9..4faf258 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs @@ -162,7 +162,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest } [Test] - public async Task Delete_item_removes_it() + public async Task Delete_item_soft_removes_with_attribution() { var list = await CreateListAsync(b => b.WithItem("milk")); var itemId = list.Items[0].Id; @@ -170,7 +170,81 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest var response = await Client.DeleteAsync($"/api/lists/{list.Id}/items/{itemId}"); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); - await Assert.That(await UseDbAsync(db => db.ShoppingListItems.CountAsync())).IsEqualTo(0); + var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.Id == itemId)); + await Assert.That(item.RemovedAt).IsNotNull(); + await Assert.That(item.RemovedByUserId).IsEqualTo(User.Id); + } + + [Test] + public async Task Get_by_id_excludes_removed_items() + { + var list = await CreateListAsync(b => b.WithItem("kept").WithItem("gone")); + var goneId = list.Items.Single(i => i.Name == "gone").Id; + await Client.DeleteAsync($"/api/lists/{list.Id}/items/{goneId}"); + + var body = await Client.GetFromJsonAsync($"/api/lists/{list.Id}"); + var items = body.GetProperty("items").EnumerateArray() + .Select(i => i.GetProperty("name").GetString()).ToArray(); + await Assert.That(items).IsEquivalentTo(new[] { "kept" }); + } + + [Test] + public async Task Delete_item_returns_404_when_already_removed() + { + var list = await CreateListAsync(b => b.WithItem("milk")); + var itemId = list.Items[0].Id; + await Client.DeleteAsync($"/api/lists/{list.Id}/items/{itemId}"); + + var response = await Client.DeleteAsync($"/api/lists/{list.Id}/items/{itemId}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task Restore_item_clears_removed_fields() + { + var list = await CreateListAsync(b => b.WithItem("milk")); + var itemId = list.Items[0].Id; + await Client.DeleteAsync($"/api/lists/{list.Id}/items/{itemId}"); + + var response = await Client.PostAsync($"/api/lists/{list.Id}/items/{itemId}/restore", null); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.Id == itemId)); + await Assert.That(item.RemovedAt).IsNull(); + await Assert.That(item.RemovedByUserId).IsNull(); + } + + [Test] + public async Task Restore_returns_404_when_item_not_removed() + { + var list = await CreateListAsync(b => b.WithItem("milk")); + var itemId = list.Items[0].Id; + + var response = await Client.PostAsync($"/api/lists/{list.Id}/items/{itemId}/restore", null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task Check_returns_404_for_removed_item() + { + var list = await CreateListAsync(b => b.WithItem("milk")); + var itemId = list.Items[0].Id; + await Client.DeleteAsync($"/api/lists/{list.Id}/items/{itemId}"); + + var response = await Client.PatchAsync($"/api/lists/{list.Id}/items/{itemId}/check", null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task List_summary_excludes_removed_items_from_counts() + { + var list = await CreateListAsync(b => b.WithItem("kept").WithItem("gone")); + var goneId = list.Items.Single(i => i.Name == "gone").Id; + await Client.DeleteAsync($"/api/lists/{list.Id}/items/{goneId}"); + + var lists = await Client.GetFromJsonAsync>("/api/lists"); + var summary = lists!.Single(l => l.GetProperty("id").GetInt32() == list.Id); + await Assert.That(summary.GetProperty("itemCount").GetInt32()).IsEqualTo(1); } [Test] diff --git a/src/backend/YesChef.Api/Data/YesChefDb.cs b/src/backend/YesChef.Api/Data/YesChefDb.cs index 438a176..4ae792b 100644 --- a/src/backend/YesChef.Api/Data/YesChefDb.cs +++ b/src/backend/YesChef.Api/Data/YesChefDb.cs @@ -59,6 +59,7 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) e.Property(i => i.Name).HasMaxLength(300); e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade); e.HasOne(i => i.CheckedByUser).WithMany().HasForeignKey(i => i.CheckedByUserId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(i => i.RemovedByUser).WithMany().HasForeignKey(i => i.RemovedByUserId).OnDelete(DeleteBehavior.SetNull); e.HasOne(i => i.Recipe).WithMany().HasForeignKey(i => i.RecipeId).OnDelete(DeleteBehavior.SetNull); e.HasIndex(i => i.FamilyId); }); diff --git a/src/backend/YesChef.Api/Entities/ShoppingListItem.cs b/src/backend/YesChef.Api/Entities/ShoppingListItem.cs index a6f6e01..f53806d 100644 --- a/src/backend/YesChef.Api/Entities/ShoppingListItem.cs +++ b/src/backend/YesChef.Api/Entities/ShoppingListItem.cs @@ -15,4 +15,7 @@ public class ShoppingListItem public int? RecipeId { get; set; } public Recipe? Recipe { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? RemovedAt { get; set; } + public int? RemovedByUserId { get; set; } + public User? RemovedByUser { get; set; } } diff --git a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs index ea8e209..8007af5 100644 --- a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs +++ b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs @@ -23,8 +23,8 @@ public static class ShoppingListEndpoints l.Id, l.Name, Store = new { l.Store.Id, l.Store.Name }, - ItemCount = l.Items.Count, - CheckedCount = l.Items.Count(i => i.IsChecked), + ItemCount = l.Items.Count(i => i.RemovedAt == null), + CheckedCount = l.Items.Count(i => i.RemovedAt == null && i.IsChecked), l.UpdatedAt }) .FirstAsync(); @@ -52,8 +52,8 @@ public static class ShoppingListEndpoints l.Id, l.Name, Store = new { l.Store.Id, l.Store.Name }, - ItemCount = l.Items.Count, - CheckedCount = l.Items.Count(i => i.IsChecked), + ItemCount = l.Items.Count(i => i.RemovedAt == null), + CheckedCount = l.Items.Count(i => i.RemovedAt == null && i.IsChecked), l.UpdatedAt }) .ToListAsync(); @@ -96,9 +96,9 @@ public static class ShoppingListEndpoints var list = await db.ShoppingLists .Where(l => l.Id == id && l.FamilyId == familyId) .Include(l => l.Store) - .Include(l => l.Items.OrderBy(i => i.SortOrder)) + .Include(l => l.Items.Where(i => i.RemovedAt == null).OrderBy(i => i.SortOrder)) .ThenInclude(i => i.CheckedByUser) - .Include(l => l.Items) + .Include(l => l.Items.Where(i => i.RemovedAt == null).OrderBy(i => i.SortOrder)) .ThenInclude(i => i.Recipe) .FirstOrDefaultAsync(); @@ -184,7 +184,7 @@ public static class ShoppingListEndpoints { var familyId = http.User.GetFamilyId(); var item = await db.ShoppingListItems - .FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId && i.FamilyId == familyId); + .FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId && i.FamilyId == familyId && i.RemovedAt == null); if (item is null) return Results.NotFound(); var userId = http.User.GetUserId(); @@ -205,10 +205,11 @@ public static class ShoppingListEndpoints { var familyId = http.User.GetFamilyId(); var item = await db.ShoppingListItems - .FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId && i.FamilyId == familyId); + .FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId && i.FamilyId == familyId && i.RemovedAt == null); if (item is null) return Results.NotFound(); - db.ShoppingListItems.Remove(item); + item.RemovedAt = DateTime.UtcNow; + item.RemovedByUserId = http.User.GetUserId(); await db.SaveChangesAsync(); await hub.Clients.Group($"list-{listId}").SendAsync("ItemRemoved", new { item.Id }); @@ -216,6 +217,32 @@ public static class ShoppingListEndpoints return Results.NoContent(); }); + group.MapPost("/{listId:int}/items/{itemId:int}/restore", async (int listId, int itemId, YesChefDb db, HttpContext http, IHubContext hub) => + { + var familyId = http.User.GetFamilyId(); + var item = await db.ShoppingListItems + .Include(i => i.CheckedByUser) + .Include(i => i.Recipe) + .FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId && i.FamilyId == familyId && i.RemovedAt != null); + if (item is null) return Results.NotFound(); + + item.RemovedAt = null; + item.RemovedByUserId = null; + await db.SaveChangesAsync(); + + await hub.Clients.Group($"list-{listId}").SendAsync("ItemRestored", new + { + item.Id, + item.Name, + item.IsChecked, + CheckedByUserName = item.CheckedByUser?.Name, + item.SortOrder, + RecipeTitle = item.Recipe?.Title + }); + await BroadcastListSummary(hub, db, listId, familyId); + return Results.NoContent(); + }); + group.MapPost("/{listId:int}/add-recipe/{recipeId:int}", async (int listId, int recipeId, YesChefDb db, HttpContext http, IHubContext hub) => { var familyId = http.User.GetFamilyId(); diff --git a/src/backend/YesChef.Api/Migrations/20260508221005_AddShoppingListItemSoftRemove.Designer.cs b/src/backend/YesChef.Api/Migrations/20260508221005_AddShoppingListItemSoftRemove.Designer.cs new file mode 100644 index 0000000..db36d7d --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260508221005_AddShoppingListItemSoftRemove.Designer.cs @@ -0,0 +1,461 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using YesChef.Api.Data; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + [DbContext(typeof(YesChefDb))] + [Migration("20260508221005_AddShoppingListItemSoftRemove")] + partial class AddShoppingListItemSoftRemove + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("YesChef.Api.Entities.Family", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InviteCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteCode") + .IsUnique(); + + b.ToTable("Families"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("UserId", "FamilyId"); + + b.HasIndex("FamilyId"); + + b.ToTable("FamilyMemberships"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Instructions") + .HasColumnType("text"); + + b.Property("Servings") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Quantity") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckedByUserId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemovedByUserId") + .HasColumnType("integer"); + + b.Property("ShoppingListId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("RemovedByUserId"); + + b.HasIndex("ShoppingListId"); + + b.ToTable("ShoppingListItems"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Name") + .IsUnique(); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany("Ingredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.HasOne("YesChef.Api.Entities.User", "CheckedByUser") + .WithMany() + .HasForeignKey("CheckedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany() + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.User", "RemovedByUser") + .WithMany() + .HasForeignKey("RemovedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList") + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CheckedByUser"); + + b.Navigation("Family"); + + b.Navigation("Recipe"); + + b.Navigation("RemovedByUser"); + + b.Navigation("ShoppingList"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Navigation("Ingredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260508221005_AddShoppingListItemSoftRemove.cs b/src/backend/YesChef.Api/Migrations/20260508221005_AddShoppingListItemSoftRemove.cs new file mode 100644 index 0000000..4a44748 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260508221005_AddShoppingListItemSoftRemove.cs @@ -0,0 +1,60 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + /// + public partial class AddShoppingListItemSoftRemove : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RemovedAt", + table: "ShoppingListItems", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "RemovedByUserId", + table: "ShoppingListItems", + type: "integer", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingListItems_RemovedByUserId", + table: "ShoppingListItems", + column: "RemovedByUserId"); + + migrationBuilder.AddForeignKey( + name: "FK_ShoppingListItems_Users_RemovedByUserId", + table: "ShoppingListItems", + column: "RemovedByUserId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ShoppingListItems_Users_RemovedByUserId", + table: "ShoppingListItems"); + + migrationBuilder.DropIndex( + name: "IX_ShoppingListItems_RemovedByUserId", + table: "ShoppingListItems"); + + migrationBuilder.DropColumn( + name: "RemovedAt", + table: "ShoppingListItems"); + + migrationBuilder.DropColumn( + name: "RemovedByUserId", + table: "ShoppingListItems"); + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs index 0119fd6..bbbef58 100644 --- a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs +++ b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -223,6 +223,12 @@ namespace YesChef.Api.Migrations b.Property("RecipeId") .HasColumnType("integer"); + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemovedByUserId") + .HasColumnType("integer"); + b.Property("ShoppingListId") .HasColumnType("integer"); @@ -237,6 +243,8 @@ namespace YesChef.Api.Migrations b.HasIndex("RecipeId"); + b.HasIndex("RemovedByUserId"); + b.HasIndex("ShoppingListId"); b.ToTable("ShoppingListItems"); @@ -402,6 +410,11 @@ namespace YesChef.Api.Migrations .HasForeignKey("RecipeId") .OnDelete(DeleteBehavior.SetNull); + b.HasOne("YesChef.Api.Entities.User", "RemovedByUser") + .WithMany() + .HasForeignKey("RemovedByUserId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList") .WithMany("Items") .HasForeignKey("ShoppingListId") @@ -414,6 +427,8 @@ namespace YesChef.Api.Migrations b.Navigation("Recipe"); + b.Navigation("RemovedByUser"); + b.Navigation("ShoppingList"); }); diff --git a/src/frontend/src/routes/lists/[id]/+page.svelte b/src/frontend/src/routes/lists/[id]/+page.svelte index 2afc52f..7f2cd51 100644 --- a/src/frontend/src/routes/lists/[id]/+page.svelte +++ b/src/frontend/src/routes/lists/[id]/+page.svelte @@ -4,6 +4,7 @@ import { goto } from '$app/navigation'; import { api } from '$lib/api'; import { startConnection, stopConnection } from '$lib/signalr'; + import { toast } from '$lib/toast.svelte'; import type { HubConnection } from '@microsoft/signalr'; interface ListItem { @@ -70,6 +71,22 @@ items = items.filter((i) => i.id !== data.id); }); + connection.on( + 'ItemRestored', + (data: { + id: number; + name: string; + isChecked: boolean; + checkedByUserName: string | null; + sortOrder: number; + recipeTitle: string | null; + }) => { + if (!items.find((i) => i.id === data.id)) { + items = [...items, data]; + } + } + ); + connection.on('ListUpdated', (data: { id: number; name: string; storeId: number }) => { if (list) list = { ...list, name: data.name }; }); @@ -98,8 +115,17 @@ await api(`/api/lists/${listId}/items/${itemId}/check`, { method: 'PATCH' }); } - async function removeItem(itemId: number) { + async function removeItem(itemId: number, itemName: string) { await api(`/api/lists/${listId}/items/${itemId}`, { method: 'DELETE' }); + toast.info(`Removed "${itemName}"`, { + duration: 5000, + action: { + label: 'Undo', + onClick: async () => { + await api(`/api/lists/${listId}/items/${itemId}/restore`, { method: 'POST' }); + } + } + }); } async function archiveList() { @@ -157,7 +183,7 @@ {/if}