Distinguish picked-up from removed shopping list items

Soft-remove items via RemovedAt/RemovedByUserId instead of hard
deleting so the row survives for undo and future history reporting.
DELETE now sets the removal fields; a new POST .../restore clears
them. Active list reads (summary, detail, check toggle) filter to
RemovedAt IS NULL. Frontend surfaces an Undo toast on remove and
handles a new ItemRestored SignalR event.
This commit is contained in:
Josh Rogers
2026-05-08 20:07:41 -05:00
parent 9b2db931ee
commit 7fcae09afb
8 changed files with 682 additions and 15 deletions
@@ -162,7 +162,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
} }
[Test] [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 list = await CreateListAsync(b => b.WithItem("milk"));
var itemId = list.Items[0].Id; 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}"); var response = await Client.DeleteAsync($"/api/lists/{list.Id}/items/{itemId}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); 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<JsonElement>($"/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<List<JsonElement>>("/api/lists");
var summary = lists!.Single(l => l.GetProperty("id").GetInt32() == list.Id);
await Assert.That(summary.GetProperty("itemCount").GetInt32()).IsEqualTo(1);
} }
[Test] [Test]
@@ -59,6 +59,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.Property(i => i.Name).HasMaxLength(300); e.Property(i => i.Name).HasMaxLength(300);
e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade); 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.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.HasOne(i => i.Recipe).WithMany().HasForeignKey(i => i.RecipeId).OnDelete(DeleteBehavior.SetNull);
e.HasIndex(i => i.FamilyId); e.HasIndex(i => i.FamilyId);
}); });
@@ -15,4 +15,7 @@ public class ShoppingListItem
public int? RecipeId { get; set; } public int? RecipeId { get; set; }
public Recipe? Recipe { get; set; } public Recipe? Recipe { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? RemovedAt { get; set; }
public int? RemovedByUserId { get; set; }
public User? RemovedByUser { get; set; }
} }
@@ -23,8 +23,8 @@ public static class ShoppingListEndpoints
l.Id, l.Id,
l.Name, l.Name,
Store = new { l.Store.Id, l.Store.Name }, Store = new { l.Store.Id, l.Store.Name },
ItemCount = l.Items.Count, ItemCount = l.Items.Count(i => i.RemovedAt == null),
CheckedCount = l.Items.Count(i => i.IsChecked), CheckedCount = l.Items.Count(i => i.RemovedAt == null && i.IsChecked),
l.UpdatedAt l.UpdatedAt
}) })
.FirstAsync(); .FirstAsync();
@@ -52,8 +52,8 @@ public static class ShoppingListEndpoints
l.Id, l.Id,
l.Name, l.Name,
Store = new { l.Store.Id, l.Store.Name }, Store = new { l.Store.Id, l.Store.Name },
ItemCount = l.Items.Count, ItemCount = l.Items.Count(i => i.RemovedAt == null),
CheckedCount = l.Items.Count(i => i.IsChecked), CheckedCount = l.Items.Count(i => i.RemovedAt == null && i.IsChecked),
l.UpdatedAt l.UpdatedAt
}) })
.ToListAsync(); .ToListAsync();
@@ -96,9 +96,9 @@ public static class ShoppingListEndpoints
var list = await db.ShoppingLists var list = await db.ShoppingLists
.Where(l => l.Id == id && l.FamilyId == familyId) .Where(l => l.Id == id && l.FamilyId == familyId)
.Include(l => l.Store) .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) .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) .ThenInclude(i => i.Recipe)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
@@ -184,7 +184,7 @@ public static class ShoppingListEndpoints
{ {
var familyId = http.User.GetFamilyId(); var familyId = http.User.GetFamilyId();
var item = await db.ShoppingListItems 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(); if (item is null) return Results.NotFound();
var userId = http.User.GetUserId(); var userId = http.User.GetUserId();
@@ -205,10 +205,11 @@ public static class ShoppingListEndpoints
{ {
var familyId = http.User.GetFamilyId(); var familyId = http.User.GetFamilyId();
var item = await db.ShoppingListItems 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(); if (item is null) return Results.NotFound();
db.ShoppingListItems.Remove(item); item.RemovedAt = DateTime.UtcNow;
item.RemovedByUserId = http.User.GetUserId();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await hub.Clients.Group($"list-{listId}").SendAsync("ItemRemoved", new { item.Id }); await hub.Clients.Group($"list-{listId}").SendAsync("ItemRemoved", new { item.Id });
@@ -216,6 +217,32 @@ public static class ShoppingListEndpoints
return Results.NoContent(); return Results.NoContent();
}); });
group.MapPost("/{listId:int}/items/{itemId:int}/restore", async (int listId, int itemId, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> 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<ShoppingListHub> hub) => group.MapPost("/{listId:int}/add-recipe/{recipeId:int}", async (int listId, int recipeId, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
{ {
var familyId = http.User.GetFamilyId(); var familyId = http.User.GetFamilyId();
@@ -0,0 +1,461 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("InviteCode")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("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<int>("UserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Role")
.HasColumnType("integer");
b.HasKey("UserId", "FamilyId");
b.HasIndex("FamilyId");
b.ToTable("FamilyMemberships");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Instructions")
.HasColumnType("text");
b.Property<int?>("Servings")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Quantity")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("RecipeId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId");
b.HasIndex("RecipeId");
b.ToTable("RecipeIngredients");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<bool>("IsArchived")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("StoreId")
.HasColumnType("integer");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CheckedByUserId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<bool>("IsChecked")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<int?>("RecipeId")
.HasColumnType("integer");
b.Property<DateTime?>("RemovedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("RemovedByUserId")
.HasColumnType("integer");
b.Property<int>("ShoppingListId")
.HasColumnType("integer");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId", "Name")
.IsUnique();
b.ToTable("Stores");
});
modelBuilder.Entity("YesChef.Api.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("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
}
}
}
@@ -0,0 +1,60 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace YesChef.Api.Migrations
{
/// <inheritdoc />
public partial class AddShoppingListItemSoftRemove : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "RemovedAt",
table: "ShoppingListItems",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<int>(
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);
}
/// <inheritdoc />
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");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -223,6 +223,12 @@ namespace YesChef.Api.Migrations
b.Property<int?>("RecipeId") b.Property<int?>("RecipeId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<DateTime?>("RemovedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("RemovedByUserId")
.HasColumnType("integer");
b.Property<int>("ShoppingListId") b.Property<int>("ShoppingListId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -237,6 +243,8 @@ namespace YesChef.Api.Migrations
b.HasIndex("RecipeId"); b.HasIndex("RecipeId");
b.HasIndex("RemovedByUserId");
b.HasIndex("ShoppingListId"); b.HasIndex("ShoppingListId");
b.ToTable("ShoppingListItems"); b.ToTable("ShoppingListItems");
@@ -402,6 +410,11 @@ namespace YesChef.Api.Migrations
.HasForeignKey("RecipeId") .HasForeignKey("RecipeId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.User", "RemovedByUser")
.WithMany()
.HasForeignKey("RemovedByUserId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList") b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList")
.WithMany("Items") .WithMany("Items")
.HasForeignKey("ShoppingListId") .HasForeignKey("ShoppingListId")
@@ -414,6 +427,8 @@ namespace YesChef.Api.Migrations
b.Navigation("Recipe"); b.Navigation("Recipe");
b.Navigation("RemovedByUser");
b.Navigation("ShoppingList"); b.Navigation("ShoppingList");
}); });
@@ -4,6 +4,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { startConnection, stopConnection } from '$lib/signalr'; import { startConnection, stopConnection } from '$lib/signalr';
import { toast } from '$lib/toast.svelte';
import type { HubConnection } from '@microsoft/signalr'; import type { HubConnection } from '@microsoft/signalr';
interface ListItem { interface ListItem {
@@ -70,6 +71,22 @@
items = items.filter((i) => i.id !== data.id); 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 }) => { connection.on('ListUpdated', (data: { id: number; name: string; storeId: number }) => {
if (list) list = { ...list, name: data.name }; if (list) list = { ...list, name: data.name };
}); });
@@ -98,8 +115,17 @@
await api(`/api/lists/${listId}/items/${itemId}/check`, { method: 'PATCH' }); 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' }); 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() { async function archiveList() {
@@ -157,7 +183,7 @@
{/if} {/if}
</div> </div>
<button <button
onclick={() => removeItem(item.id)} onclick={() => removeItem(item.id, item.name)}
class="shrink-0 p-1 text-gray-300 active:text-danger" class="shrink-0 p-1 text-gray-300 active:text-danger"
aria-label="Remove {item.name}" aria-label="Remove {item.name}"
> >
@@ -192,7 +218,7 @@
{/if} {/if}
</div> </div>
<button <button
onclick={() => removeItem(item.id)} onclick={() => removeItem(item.id, item.name)}
class="shrink-0 p-1 text-gray-300 active:text-danger" class="shrink-0 p-1 text-gray-300 active:text-danger"
aria-label="Remove {item.name}" aria-label="Remove {item.name}"
> >