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]
|
[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();
|
||||||
|
|||||||
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 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}"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user