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:
@@ -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<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]
|
||||
|
||||
@@ -59,6 +59,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> 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);
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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<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) =>
|
||||
{
|
||||
var familyId = http.User.GetFamilyId();
|
||||
|
||||
Generated
+461
@@ -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 Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
@@ -223,6 +223,12 @@ namespace YesChef.Api.Migrations
|
||||
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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
onclick={() => removeItem(item.id, item.name)}
|
||||
class="shrink-0 p-1 text-gray-300 active:text-danger"
|
||||
aria-label="Remove {item.name}"
|
||||
>
|
||||
@@ -192,7 +218,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
onclick={() => removeItem(item.id, item.name)}
|
||||
class="shrink-0 p-1 text-gray-300 active:text-danger"
|
||||
aria-label="Remove {item.name}"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user