Add structured quantities + units to recipe ingredients
Phase 2 of structured quantities + UoM. Replaces the free-form Quantity string on RecipeIngredient with a structured (Quantity decimal, UnitOfMeasureId or FamilyUnitOfMeasureId) pair, plus an IsApproximate + QuantityNote escape hatch for "to taste" style entries. The unit FK pair mirrors the existing Product / FamilyProduct pattern, with the same at-most-one and tenant-scoping validation. Existing string Quantity values are dropped per the agreed wipe-to-null migration plan. Frontend ships a QuantityInput component (numeric field + unit dropdown fed by a runes-cached effective catalog from /api/units) and a shared formatter for read-only display. Recipe -> shopping list copy folds the structured quantity into the item Name for now; Phase 3 will move the fields onto ShoppingListItem directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,9 +24,28 @@ public sealed class RecipeBuilder
|
|||||||
public RecipeBuilder ForFamily(int familyId) { _familyId = familyId; return this; }
|
public RecipeBuilder ForFamily(int familyId) { _familyId = familyId; return this; }
|
||||||
public RecipeBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
|
public RecipeBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
|
||||||
|
|
||||||
public RecipeBuilder WithIngredient(string name, string? quantity = null, int sortOrder = 0)
|
public RecipeBuilder WithIngredient(string name, int sortOrder = 0, decimal? quantity = null, int? unitOfMeasureId = null, int? familyUnitOfMeasureId = null)
|
||||||
{
|
{
|
||||||
_ingredients.Add(new RecipeIngredient { Name = name, Quantity = quantity, SortOrder = sortOrder });
|
_ingredients.Add(new RecipeIngredient
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
SortOrder = sortOrder,
|
||||||
|
Quantity = quantity,
|
||||||
|
UnitOfMeasureId = unitOfMeasureId,
|
||||||
|
FamilyUnitOfMeasureId = familyUnitOfMeasureId,
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RecipeBuilder WithApproximateIngredient(string name, string quantityNote, int sortOrder = 0)
|
||||||
|
{
|
||||||
|
_ingredients.Add(new RecipeIngredient
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
SortOrder = sortOrder,
|
||||||
|
IsApproximate = true,
|
||||||
|
QuantityNote = quantityNote,
|
||||||
|
});
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
SourceUrl: "https://example.com/pasta",
|
SourceUrl: "https://example.com/pasta",
|
||||||
Ingredients:
|
Ingredients:
|
||||||
[
|
[
|
||||||
new RecipeEndpoints.IngredientRequest("pasta", "1 lb", 1),
|
new RecipeEndpoints.IngredientRequest("pasta", 1, Quantity: 1m),
|
||||||
new RecipeEndpoints.IngredientRequest("salt", null, 2),
|
new RecipeEndpoints.IngredientRequest("salt", 2),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||||
@@ -54,7 +54,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
Instructions: null,
|
Instructions: null,
|
||||||
Servings: null,
|
Servings: null,
|
||||||
SourceUrl: null,
|
SourceUrl: null,
|
||||||
Ingredients: [new RecipeEndpoints.IngredientRequest("pasta", "1 lb", 1, ProductId: product.Id)]);
|
Ingredients: [new RecipeEndpoints.IngredientRequest("pasta", 1, Quantity: 1m, ProductId: product.Id)]);
|
||||||
|
|
||||||
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
{
|
{
|
||||||
var request = new RecipeEndpoints.CreateRecipeRequest(
|
var request = new RecipeEndpoints.CreateRecipeRequest(
|
||||||
Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null,
|
Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null,
|
||||||
Ingredients: [new RecipeEndpoints.IngredientRequest("x", null, 1, ProductId: 1, FamilyProductId: 1)]);
|
Ingredients: [new RecipeEndpoints.IngredientRequest("x", 1, ProductId: 1, FamilyProductId: 1)]);
|
||||||
|
|
||||||
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
|
|
||||||
var request = new RecipeEndpoints.CreateRecipeRequest(
|
var request = new RecipeEndpoints.CreateRecipeRequest(
|
||||||
Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null,
|
Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null,
|
||||||
Ingredients: [new RecipeEndpoints.IngredientRequest("x", null, 1, FamilyProductId: foreignProductId)]);
|
Ingredients: [new RecipeEndpoints.IngredientRequest("x", 1, FamilyProductId: foreignProductId)]);
|
||||||
|
|
||||||
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||||
|
|
||||||
@@ -103,8 +103,8 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
{
|
{
|
||||||
var recipe = await CreateRecipeAsync(b => b
|
var recipe = await CreateRecipeAsync(b => b
|
||||||
.Titled("Soup").Describes("warm").Serves(2)
|
.Titled("Soup").Describes("warm").Serves(2)
|
||||||
.WithIngredient("water", "4 cups", 2)
|
.WithIngredient("water", sortOrder: 2, quantity: 4m)
|
||||||
.WithIngredient("broth cube", "1", 1));
|
.WithIngredient("broth cube", sortOrder: 1, quantity: 1m));
|
||||||
|
|
||||||
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
|
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
|
||||||
|
|
||||||
@@ -140,8 +140,8 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
{
|
{
|
||||||
var recipe = await CreateRecipeAsync(b => b
|
var recipe = await CreateRecipeAsync(b => b
|
||||||
.Titled("Stew")
|
.Titled("Stew")
|
||||||
.WithIngredient("beef", "1 lb", 1)
|
.WithIngredient("beef", sortOrder: 1, quantity: 1m)
|
||||||
.WithIngredient("carrot", "2", 2));
|
.WithIngredient("carrot", sortOrder: 2, quantity: 2m));
|
||||||
|
|
||||||
var update = new RecipeEndpoints.UpdateRecipeRequest(
|
var update = new RecipeEndpoints.UpdateRecipeRequest(
|
||||||
Title: "Veggie Stew",
|
Title: "Veggie Stew",
|
||||||
@@ -151,9 +151,9 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
SourceUrl: null,
|
SourceUrl: null,
|
||||||
Ingredients:
|
Ingredients:
|
||||||
[
|
[
|
||||||
new RecipeEndpoints.IngredientRequest("potato", "3", 1),
|
new RecipeEndpoints.IngredientRequest("potato", 1, Quantity: 3m),
|
||||||
new RecipeEndpoints.IngredientRequest("onion", "1", 2),
|
new RecipeEndpoints.IngredientRequest("onion", 2, Quantity: 1m),
|
||||||
new RecipeEndpoints.IngredientRequest("carrot", "4", 3),
|
new RecipeEndpoints.IngredientRequest("carrot", 3, Quantity: 4m),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
var response = await Client.PutAsJsonAsync($"/api/recipes/{recipe.Id}", update);
|
var response = await Client.PutAsJsonAsync($"/api/recipes/{recipe.Id}", update);
|
||||||
@@ -173,7 +173,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
{
|
{
|
||||||
var recipe = await CreateRecipeAsync(b => b
|
var recipe = await CreateRecipeAsync(b => b
|
||||||
.Titled("Toast")
|
.Titled("Toast")
|
||||||
.WithIngredient("bread", "2 slices", 1));
|
.WithIngredient("bread", sortOrder: 1, quantity: 2m));
|
||||||
|
|
||||||
var response = await Client.DeleteAsync($"/api/recipes/{recipe.Id}");
|
var response = await Client.DeleteAsync($"/api/recipes/{recipe.Id}");
|
||||||
|
|
||||||
@@ -181,4 +181,121 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
await Assert.That(await UseDbAsync(db => db.Recipes.CountAsync())).IsEqualTo(0);
|
await Assert.That(await UseDbAsync(db => db.Recipes.CountAsync())).IsEqualTo(0);
|
||||||
await Assert.That(await UseDbAsync(db => db.RecipeIngredients.CountAsync())).IsEqualTo(0);
|
await Assert.That(await UseDbAsync(db => db.RecipeIngredients.CountAsync())).IsEqualTo(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Create_persists_structured_quantity_and_global_unit()
|
||||||
|
{
|
||||||
|
var unitId = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var u = new UnitOfMeasure
|
||||||
|
{
|
||||||
|
Code = "lb", SingularName = "pound", PluralName = "pounds",
|
||||||
|
Abbreviation = "lb", Category = UnitCategory.Weight,
|
||||||
|
};
|
||||||
|
db.UnitsOfMeasure.Add(u);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return u.Id;
|
||||||
|
});
|
||||||
|
|
||||||
|
var request = new RecipeEndpoints.CreateRecipeRequest(
|
||||||
|
Title: "Burger", Description: null, Instructions: null, Servings: null, SourceUrl: null,
|
||||||
|
Ingredients: [new RecipeEndpoints.IngredientRequest("beef", 1, Quantity: 1.5m, UnitOfMeasureId: unitId)]);
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||||
|
var ing = await UseDbAsync(db => db.RecipeIngredients.SingleAsync());
|
||||||
|
await Assert.That(ing.Quantity).IsEqualTo(1.5m);
|
||||||
|
await Assert.That(ing.UnitOfMeasureId).IsEqualTo(unitId);
|
||||||
|
await Assert.That(ing.FamilyUnitOfMeasureId).IsNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Create_persists_approximate_ingredient_with_note()
|
||||||
|
{
|
||||||
|
var request = new RecipeEndpoints.CreateRecipeRequest(
|
||||||
|
Title: "Stew", Description: null, Instructions: null, Servings: null, SourceUrl: null,
|
||||||
|
Ingredients: [new RecipeEndpoints.IngredientRequest("salt", 1, IsApproximate: true, QuantityNote: "to taste")]);
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||||
|
var ing = await UseDbAsync(db => db.RecipeIngredients.SingleAsync());
|
||||||
|
await Assert.That(ing.IsApproximate).IsTrue();
|
||||||
|
await Assert.That(ing.QuantityNote).IsEqualTo("to taste");
|
||||||
|
await Assert.That(ing.Quantity).IsNull();
|
||||||
|
await Assert.That(ing.UnitOfMeasureId).IsNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Create_rejects_ingredient_with_both_unit_ids()
|
||||||
|
{
|
||||||
|
var request = new RecipeEndpoints.CreateRecipeRequest(
|
||||||
|
Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null,
|
||||||
|
Ingredients: [new RecipeEndpoints.IngredientRequest("x", 1, Quantity: 1m, UnitOfMeasureId: 1, FamilyUnitOfMeasureId: 1)]);
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Create_rejects_ingredient_referencing_other_familys_unit()
|
||||||
|
{
|
||||||
|
var foreignUnitId = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var other = new Family { Name = "Other", InviteCode = "other-code" };
|
||||||
|
db.Families.Add(other);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var u = new FamilyUnitOfMeasure
|
||||||
|
{
|
||||||
|
FamilyId = other.Id,
|
||||||
|
SingularName = "pinch", PluralName = "pinches",
|
||||||
|
Abbreviation = "pn", Category = UnitCategory.Volume,
|
||||||
|
};
|
||||||
|
db.FamilyUnitsOfMeasure.Add(u);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return u.Id;
|
||||||
|
});
|
||||||
|
|
||||||
|
var request = new RecipeEndpoints.CreateRecipeRequest(
|
||||||
|
Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null,
|
||||||
|
Ingredients: [new RecipeEndpoints.IngredientRequest("salt", 1, Quantity: 1m, FamilyUnitOfMeasureId: foreignUnitId)]);
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Get_projects_structured_quantity_fields()
|
||||||
|
{
|
||||||
|
var unitId = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var u = new UnitOfMeasure
|
||||||
|
{
|
||||||
|
Code = "cup", SingularName = "cup", PluralName = "cups",
|
||||||
|
Abbreviation = "cup", Category = UnitCategory.Volume,
|
||||||
|
};
|
||||||
|
db.UnitsOfMeasure.Add(u);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return u.Id;
|
||||||
|
});
|
||||||
|
var recipe = await CreateRecipeAsync(b => b
|
||||||
|
.Titled("Pancakes")
|
||||||
|
.WithIngredient("flour", sortOrder: 1, quantity: 2m, unitOfMeasureId: unitId)
|
||||||
|
.WithApproximateIngredient("salt", quantityNote: "to taste", sortOrder: 2));
|
||||||
|
|
||||||
|
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
|
||||||
|
|
||||||
|
var ingredients = body.GetProperty("ingredients").EnumerateArray().ToArray();
|
||||||
|
var flour = ingredients.Single(i => i.GetProperty("name").GetString() == "flour");
|
||||||
|
await Assert.That(flour.GetProperty("quantity").GetDecimal()).IsEqualTo(2m);
|
||||||
|
await Assert.That(flour.GetProperty("unitOfMeasureId").GetInt32()).IsEqualTo(unitId);
|
||||||
|
await Assert.That(flour.GetProperty("isApproximate").GetBoolean()).IsFalse();
|
||||||
|
|
||||||
|
var salt = ingredients.Single(i => i.GetProperty("name").GetString() == "salt");
|
||||||
|
await Assert.That(salt.GetProperty("isApproximate").GetBoolean()).IsTrue();
|
||||||
|
await Assert.That(salt.GetProperty("quantityNote").GetString()).IsEqualTo("to taste");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -443,12 +443,23 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
[Test]
|
[Test]
|
||||||
public async Task Add_recipe_appends_all_ingredients_with_quantity_prefix()
|
public async Task Add_recipe_appends_all_ingredients_with_quantity_prefix()
|
||||||
{
|
{
|
||||||
|
var cupId = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var u = new UnitOfMeasure
|
||||||
|
{
|
||||||
|
Code = "cup", SingularName = "cup", PluralName = "cups",
|
||||||
|
Abbreviation = "cup", Category = UnitCategory.Volume,
|
||||||
|
};
|
||||||
|
db.UnitsOfMeasure.Add(u);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return u.Id;
|
||||||
|
});
|
||||||
var list = await CreateListAsync(b => b.WithItem("existing", sortOrder: 5));
|
var list = await CreateListAsync(b => b.WithItem("existing", sortOrder: 5));
|
||||||
var recipe = await Data.CreateRecipeAsync(b => b
|
var recipe = await Data.CreateRecipeAsync(b => b
|
||||||
.Titled("Pancakes").CreatedBy(User)
|
.Titled("Pancakes").CreatedBy(User)
|
||||||
.WithIngredient("flour", "2 cups", 1)
|
.WithIngredient("flour", sortOrder: 1, quantity: 2m, unitOfMeasureId: cupId)
|
||||||
.WithIngredient("eggs", "2", 2)
|
.WithIngredient("eggs", sortOrder: 2, quantity: 2m)
|
||||||
.WithIngredient("salt", null, 3));
|
.WithApproximateIngredient("salt", quantityNote: "to taste", sortOrder: 3));
|
||||||
|
|
||||||
var response = await Client.PostAsync($"/api/lists/{list.Id}/add-recipe/{recipe.Id}", null);
|
var response = await Client.PostAsync($"/api/lists/{list.Id}/add-recipe/{recipe.Id}", null);
|
||||||
|
|
||||||
@@ -460,7 +471,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
await Assert.That(items.Count).IsEqualTo(4);
|
await Assert.That(items.Count).IsEqualTo(4);
|
||||||
await Assert.That(items.Select(i => i.Name)).IsEquivalentTo(new[]
|
await Assert.That(items.Select(i => i.Name)).IsEquivalentTo(new[]
|
||||||
{
|
{
|
||||||
"existing", "2 cups flour", "2 eggs", "salt"
|
"existing", "2 cup flour", "2 eggs", "salt (to taste)"
|
||||||
});
|
});
|
||||||
await Assert.That(items.Where(i => i.RecipeId == recipe.Id).Count()).IsEqualTo(3);
|
await Assert.That(items.Where(i => i.RecipeId == recipe.Id).Count()).IsEqualTo(3);
|
||||||
await Assert.That(items[3].SortOrder).IsGreaterThan(5);
|
await Assert.That(items[3].SortOrder).IsGreaterThan(5);
|
||||||
|
|||||||
@@ -197,6 +197,43 @@ public class UnitEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
await Assert.That(remaining).IsEqualTo(0);
|
await Assert.That(remaining).IsEqualTo(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Delete_family_unit_returns_409_when_referenced_by_recipe_ingredient()
|
||||||
|
{
|
||||||
|
var familyId = await GetFamilyIdAsync();
|
||||||
|
var unit = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var u = new FamilyUnitOfMeasure
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
SingularName = "pinch", PluralName = "pinches",
|
||||||
|
Abbreviation = "pn", Category = UnitCategory.Volume,
|
||||||
|
};
|
||||||
|
db.FamilyUnitsOfMeasure.Add(u);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var recipe = new Recipe
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
Title = "Soup",
|
||||||
|
CreatedByUserId = User.Id,
|
||||||
|
Ingredients = new List<RecipeIngredient>
|
||||||
|
{
|
||||||
|
new() { FamilyId = familyId, Name = "salt", SortOrder = 1, Quantity = 1m, FamilyUnitOfMeasureId = u.Id },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
db.Recipes.Add(recipe);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await Client.DeleteAsync($"/api/units/family/{unit.Id}");
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
|
||||||
|
var remaining = await UseDbAsync(db => db.FamilyUnitsOfMeasure.CountAsync());
|
||||||
|
await Assert.That(remaining).IsEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Endpoints_require_authentication()
|
public async Task Endpoints_require_authentication()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -117,10 +117,16 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
|||||||
modelBuilder.Entity<RecipeIngredient>(e =>
|
modelBuilder.Entity<RecipeIngredient>(e =>
|
||||||
{
|
{
|
||||||
e.Property(i => i.Name).HasMaxLength(200);
|
e.Property(i => i.Name).HasMaxLength(200);
|
||||||
e.Property(i => i.Quantity).HasMaxLength(50);
|
e.Property(i => i.Quantity).HasPrecision(12, 4);
|
||||||
|
e.Property(i => i.QuantityNote).HasMaxLength(200);
|
||||||
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.Product).WithMany().HasForeignKey(i => i.ProductId).OnDelete(DeleteBehavior.SetNull);
|
e.HasOne(i => i.Product).WithMany().HasForeignKey(i => i.ProductId).OnDelete(DeleteBehavior.SetNull);
|
||||||
e.HasOne(i => i.FamilyProduct).WithMany().HasForeignKey(i => i.FamilyProductId).OnDelete(DeleteBehavior.SetNull);
|
e.HasOne(i => i.FamilyProduct).WithMany().HasForeignKey(i => i.FamilyProductId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
// Restrict on delete: the unit-delete endpoint already blocks
|
||||||
|
// deletion when an ingredient references the unit, so this is a
|
||||||
|
// defense-in-depth safeguard rather than expected behavior.
|
||||||
|
e.HasOne(i => i.UnitOfMeasure).WithMany().HasForeignKey(i => i.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
e.HasOne(i => i.FamilyUnitOfMeasure).WithMany().HasForeignKey(i => i.FamilyUnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Product>(e =>
|
modelBuilder.Entity<Product>(e =>
|
||||||
|
|||||||
@@ -8,8 +8,19 @@ public class RecipeIngredient
|
|||||||
public int RecipeId { get; set; }
|
public int RecipeId { get; set; }
|
||||||
public Recipe Recipe { get; set; } = null!;
|
public Recipe Recipe { get; set; } = null!;
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public string? Quantity { get; set; }
|
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
// Structured quantity. Null = unspecified ("salt", with no amount).
|
||||||
|
public decimal? Quantity { get; set; }
|
||||||
|
// At most one of UnitOfMeasureId / FamilyUnitOfMeasureId is set on a row.
|
||||||
|
// Both null is permitted (matches Quantity = null).
|
||||||
|
public int? UnitOfMeasureId { get; set; }
|
||||||
|
public UnitOfMeasure? UnitOfMeasure { get; set; }
|
||||||
|
public int? FamilyUnitOfMeasureId { get; set; }
|
||||||
|
public FamilyUnitOfMeasure? FamilyUnitOfMeasure { get; set; }
|
||||||
|
// Approximations like "to taste" / "a pinch" — render QuantityNote instead
|
||||||
|
// of the structured Quantity/Unit pair when this flag is set.
|
||||||
|
public bool IsApproximate { get; set; }
|
||||||
|
public string? QuantityNote { get; set; }
|
||||||
// At most one of ProductId / FamilyProductId is set; both null = free-form.
|
// At most one of ProductId / FamilyProductId is set; both null = free-form.
|
||||||
public int? ProductId { get; set; }
|
public int? ProductId { get; set; }
|
||||||
public Product? Product { get; set; }
|
public Product? Product { get; set; }
|
||||||
|
|||||||
@@ -8,7 +8,17 @@ namespace YesChef.Api.Features.Recipes;
|
|||||||
|
|
||||||
public static class RecipeEndpoints
|
public static class RecipeEndpoints
|
||||||
{
|
{
|
||||||
public record IngredientRequest(string Name, string? Quantity, int SortOrder, int? ProductId = null, int? FamilyProductId = null);
|
public record IngredientRequest(
|
||||||
|
string Name,
|
||||||
|
int SortOrder,
|
||||||
|
decimal? Quantity = null,
|
||||||
|
int? UnitOfMeasureId = null,
|
||||||
|
int? FamilyUnitOfMeasureId = null,
|
||||||
|
bool IsApproximate = false,
|
||||||
|
string? QuantityNote = null,
|
||||||
|
int? ProductId = null,
|
||||||
|
int? FamilyProductId = null);
|
||||||
|
|
||||||
public record CreateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List<IngredientRequest> Ingredients);
|
public record CreateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List<IngredientRequest> Ingredients);
|
||||||
public record UpdateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List<IngredientRequest> Ingredients);
|
public record UpdateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List<IngredientRequest> Ingredients);
|
||||||
|
|
||||||
@@ -38,11 +48,8 @@ public static class RecipeEndpoints
|
|||||||
{
|
{
|
||||||
var familyId = http.User.GetFamilyId();
|
var familyId = http.User.GetFamilyId();
|
||||||
|
|
||||||
foreach (var ing in request.Ingredients)
|
if (await ValidateIngredients(db, familyId, request.Ingredients) is { } error)
|
||||||
{
|
return error;
|
||||||
if (await ShoppingListEndpoints.ValidateProductLink(db, familyId, ing.ProductId, ing.FamilyProductId) is { } error)
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
var recipe = new Recipe
|
var recipe = new Recipe
|
||||||
{
|
{
|
||||||
@@ -53,15 +60,7 @@ public static class RecipeEndpoints
|
|||||||
Servings = request.Servings,
|
Servings = request.Servings,
|
||||||
SourceUrl = request.SourceUrl,
|
SourceUrl = request.SourceUrl,
|
||||||
CreatedByUserId = http.User.GetUserId(),
|
CreatedByUserId = http.User.GetUserId(),
|
||||||
Ingredients = request.Ingredients.Select(i => new RecipeIngredient
|
Ingredients = request.Ingredients.Select(i => ToEntity(i, familyId)).ToList()
|
||||||
{
|
|
||||||
FamilyId = familyId,
|
|
||||||
Name = i.Name,
|
|
||||||
Quantity = i.Quantity,
|
|
||||||
SortOrder = i.SortOrder,
|
|
||||||
ProductId = i.ProductId,
|
|
||||||
FamilyProductId = i.FamilyProductId
|
|
||||||
}).ToList()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Recipes.Add(recipe);
|
db.Recipes.Add(recipe);
|
||||||
@@ -90,7 +89,19 @@ public static class RecipeEndpoints
|
|||||||
recipe.SourceUrl,
|
recipe.SourceUrl,
|
||||||
CreatedBy = recipe.CreatedByUser.Name,
|
CreatedBy = recipe.CreatedByUser.Name,
|
||||||
recipe.UpdatedAt,
|
recipe.UpdatedAt,
|
||||||
Ingredients = recipe.Ingredients.Select(i => new { i.Id, i.Name, i.Quantity, i.SortOrder, i.ProductId, i.FamilyProductId })
|
Ingredients = recipe.Ingredients.Select(i => new
|
||||||
|
{
|
||||||
|
i.Id,
|
||||||
|
i.Name,
|
||||||
|
i.SortOrder,
|
||||||
|
i.Quantity,
|
||||||
|
i.UnitOfMeasureId,
|
||||||
|
i.FamilyUnitOfMeasureId,
|
||||||
|
i.IsApproximate,
|
||||||
|
i.QuantityNote,
|
||||||
|
i.ProductId,
|
||||||
|
i.FamilyProductId
|
||||||
|
})
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,11 +114,8 @@ public static class RecipeEndpoints
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (recipe is null) return Results.NotFound();
|
if (recipe is null) return Results.NotFound();
|
||||||
|
|
||||||
foreach (var ing in request.Ingredients)
|
if (await ValidateIngredients(db, familyId, request.Ingredients) is { } error)
|
||||||
{
|
return error;
|
||||||
if (await ShoppingListEndpoints.ValidateProductLink(db, familyId, ing.ProductId, ing.FamilyProductId) is { } error)
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
recipe.Title = request.Title;
|
recipe.Title = request.Title;
|
||||||
recipe.Description = request.Description;
|
recipe.Description = request.Description;
|
||||||
@@ -117,15 +125,7 @@ public static class RecipeEndpoints
|
|||||||
recipe.UpdatedAt = DateTime.UtcNow;
|
recipe.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
db.RecipeIngredients.RemoveRange(recipe.Ingredients);
|
db.RecipeIngredients.RemoveRange(recipe.Ingredients);
|
||||||
recipe.Ingredients = request.Ingredients.Select(i => new RecipeIngredient
|
recipe.Ingredients = request.Ingredients.Select(i => ToEntity(i, familyId)).ToList();
|
||||||
{
|
|
||||||
FamilyId = familyId,
|
|
||||||
Name = i.Name,
|
|
||||||
Quantity = i.Quantity,
|
|
||||||
SortOrder = i.SortOrder,
|
|
||||||
ProductId = i.ProductId,
|
|
||||||
FamilyProductId = i.FamilyProductId
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.Ok(new { recipe.Id, recipe.Title });
|
return Results.Ok(new { recipe.Id, recipe.Title });
|
||||||
@@ -144,4 +144,30 @@ public static class RecipeEndpoints
|
|||||||
|
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult?> ValidateIngredients(YesChefDb db, int familyId, List<IngredientRequest> ingredients)
|
||||||
|
{
|
||||||
|
foreach (var ing in ingredients)
|
||||||
|
{
|
||||||
|
if (await ShoppingListEndpoints.ValidateProductLink(db, familyId, ing.ProductId, ing.FamilyProductId) is { } pe)
|
||||||
|
return pe;
|
||||||
|
if (await ShoppingListEndpoints.ValidateUnitLink(db, familyId, ing.UnitOfMeasureId, ing.FamilyUnitOfMeasureId) is { } ue)
|
||||||
|
return ue;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RecipeIngredient ToEntity(IngredientRequest i, int familyId) => new()
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
Name = i.Name,
|
||||||
|
SortOrder = i.SortOrder,
|
||||||
|
Quantity = i.Quantity,
|
||||||
|
UnitOfMeasureId = i.UnitOfMeasureId,
|
||||||
|
FamilyUnitOfMeasureId = i.FamilyUnitOfMeasureId,
|
||||||
|
IsApproximate = i.IsApproximate,
|
||||||
|
QuantityNote = i.QuantityNote,
|
||||||
|
ProductId = i.ProductId,
|
||||||
|
FamilyProductId = i.FamilyProductId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,7 +311,8 @@ public static class ShoppingListEndpoints
|
|||||||
|
|
||||||
var recipe = await db.Recipes
|
var recipe = await db.Recipes
|
||||||
.Where(r => r.Id == recipeId && r.FamilyId == familyId)
|
.Where(r => r.Id == recipeId && r.FamilyId == familyId)
|
||||||
.Include(r => r.Ingredients)
|
.Include(r => r.Ingredients).ThenInclude(i => i.UnitOfMeasure)
|
||||||
|
.Include(r => r.Ingredients).ThenInclude(i => i.FamilyUnitOfMeasure)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (recipe is null) return Results.NotFound();
|
if (recipe is null) return Results.NotFound();
|
||||||
|
|
||||||
@@ -329,7 +330,11 @@ public static class ShoppingListEndpoints
|
|||||||
{
|
{
|
||||||
FamilyId = familyId,
|
FamilyId = familyId,
|
||||||
ShoppingListId = listId,
|
ShoppingListId = listId,
|
||||||
Name = string.IsNullOrEmpty(ing.Quantity) ? ing.Name : $"{ing.Quantity} {ing.Name}",
|
// Phase 3 will give ShoppingListItem its own structured
|
||||||
|
// quantity/unit fields. Until then, fold the recipe's
|
||||||
|
// structured quantity into the free-form Name so the
|
||||||
|
// list still reads "2 cup flour" / "salt to taste".
|
||||||
|
Name = FormatIngredientForList(ing),
|
||||||
SortOrder = maxSort + idx + 1,
|
SortOrder = maxSort + idx + 1,
|
||||||
RecipeId = recipeId,
|
RecipeId = recipeId,
|
||||||
ProductId = ing.ProductId,
|
ProductId = ing.ProductId,
|
||||||
@@ -374,6 +379,46 @@ public static class ShoppingListEndpoints
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Render an ingredient's structured quantity into a free-form display
|
||||||
|
/// string for use as a shopping list item Name in Phase 2. Phase 3 will
|
||||||
|
/// remove this once ShoppingListItem grows its own structured fields.
|
||||||
|
/// </summary>
|
||||||
|
private static string FormatIngredientForList(RecipeIngredient ing)
|
||||||
|
{
|
||||||
|
if (ing.IsApproximate && !string.IsNullOrWhiteSpace(ing.QuantityNote))
|
||||||
|
return $"{ing.Name} ({ing.QuantityNote})";
|
||||||
|
|
||||||
|
var abbrev = ing.UnitOfMeasure?.Abbreviation ?? ing.FamilyUnitOfMeasure?.Abbreviation;
|
||||||
|
if (ing.Quantity is { } q)
|
||||||
|
{
|
||||||
|
// Trim trailing zeros so "2.0000" renders as "2".
|
||||||
|
var qty = q.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
return string.IsNullOrEmpty(abbrev) ? $"{qty} {ing.Name}" : $"{qty} {abbrev} {ing.Name}";
|
||||||
|
}
|
||||||
|
return ing.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the unit-of-measure FK pair on an ingredient/item payload.
|
||||||
|
/// Same shape as <see cref="ValidateProductLink"/>: at most one of the
|
||||||
|
/// two FKs may be set, the global must exist, the family unit must
|
||||||
|
/// belong to the caller's family. Returns null on success.
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task<IResult?> ValidateUnitLink(YesChefDb db, int familyId, int? unitOfMeasureId, int? familyUnitOfMeasureId)
|
||||||
|
{
|
||||||
|
if (unitOfMeasureId.HasValue && familyUnitOfMeasureId.HasValue)
|
||||||
|
return Results.BadRequest(new { error = "A quantity can reference a global unit or a family unit, not both." });
|
||||||
|
|
||||||
|
if (unitOfMeasureId.HasValue && !await db.UnitsOfMeasure.AnyAsync(u => u.Id == unitOfMeasureId.Value))
|
||||||
|
return Results.BadRequest(new { error = "Unknown unit of measure." });
|
||||||
|
|
||||||
|
if (familyUnitOfMeasureId.HasValue && !await db.FamilyUnitsOfMeasure.AnyAsync(u => u.Id == familyUnitOfMeasureId.Value && u.FamilyId == familyId))
|
||||||
|
return Results.BadRequest(new { error = "Unknown unit of measure." });
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Look up the section a product was last placed in at this store, for
|
/// Look up the section a product was last placed in at this store, for
|
||||||
/// this family. Returns null if no memory exists or no product link was
|
/// this family. Returns null if no memory exists or no product link was
|
||||||
|
|||||||
@@ -119,9 +119,11 @@ public static class UnitEndpoints
|
|||||||
var unit = await db.FamilyUnitsOfMeasure.FirstOrDefaultAsync(u => u.Id == id && u.FamilyId == familyId);
|
var unit = await db.FamilyUnitsOfMeasure.FirstOrDefaultAsync(u => u.Id == id && u.FamilyId == familyId);
|
||||||
if (unit is null) return Results.NotFound();
|
if (unit is null) return Results.NotFound();
|
||||||
|
|
||||||
// No usage check yet — RecipeIngredient/ShoppingListItem don't
|
// Block deletion when any recipe ingredient still references the
|
||||||
// reference units in Phase 1. Phase 2/3 will need to block delete
|
// unit. (ShoppingListItem will gain the same check in Phase 3.)
|
||||||
// when rows reference this unit.
|
var inUse = await db.RecipeIngredients.AnyAsync(i => i.FamilyUnitOfMeasureId == id);
|
||||||
|
if (inUse) return Results.Conflict(new { error = "Unit is in use by one or more recipes." });
|
||||||
|
|
||||||
db.FamilyUnitsOfMeasure.Remove(unit);
|
db.FamilyUnitsOfMeasure.Remove(unit);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
|
|||||||
+1056
File diff suppressed because it is too large
Load Diff
+129
@@ -0,0 +1,129 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace YesChef.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddStructuredRecipeIngredientQuantities : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Quantity is changing from free-form string to structured decimal.
|
||||||
|
// Per the wipe-to-null migration plan, drop the column outright
|
||||||
|
// rather than attempting a text→numeric cast that Postgres rejects
|
||||||
|
// for any non-numeric value.
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Quantity",
|
||||||
|
table: "RecipeIngredients");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "Quantity",
|
||||||
|
table: "RecipeIngredients",
|
||||||
|
type: "numeric(12,4)",
|
||||||
|
precision: 12,
|
||||||
|
scale: 4,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "FamilyUnitOfMeasureId",
|
||||||
|
table: "RecipeIngredients",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsApproximate",
|
||||||
|
table: "RecipeIngredients",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "QuantityNote",
|
||||||
|
table: "RecipeIngredients",
|
||||||
|
type: "character varying(200)",
|
||||||
|
maxLength: 200,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "UnitOfMeasureId",
|
||||||
|
table: "RecipeIngredients",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RecipeIngredients_FamilyUnitOfMeasureId",
|
||||||
|
table: "RecipeIngredients",
|
||||||
|
column: "FamilyUnitOfMeasureId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RecipeIngredients_UnitOfMeasureId",
|
||||||
|
table: "RecipeIngredients",
|
||||||
|
column: "UnitOfMeasureId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_RecipeIngredients_FamilyUnitsOfMeasure_FamilyUnitOfMeasureId",
|
||||||
|
table: "RecipeIngredients",
|
||||||
|
column: "FamilyUnitOfMeasureId",
|
||||||
|
principalTable: "FamilyUnitsOfMeasure",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_RecipeIngredients_UnitsOfMeasure_UnitOfMeasureId",
|
||||||
|
table: "RecipeIngredients",
|
||||||
|
column: "UnitOfMeasureId",
|
||||||
|
principalTable: "UnitsOfMeasure",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_RecipeIngredients_FamilyUnitsOfMeasure_FamilyUnitOfMeasureId",
|
||||||
|
table: "RecipeIngredients");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_RecipeIngredients_UnitsOfMeasure_UnitOfMeasureId",
|
||||||
|
table: "RecipeIngredients");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_RecipeIngredients_FamilyUnitOfMeasureId",
|
||||||
|
table: "RecipeIngredients");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_RecipeIngredients_UnitOfMeasureId",
|
||||||
|
table: "RecipeIngredients");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FamilyUnitOfMeasureId",
|
||||||
|
table: "RecipeIngredients");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsApproximate",
|
||||||
|
table: "RecipeIngredients");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "QuantityNote",
|
||||||
|
table: "RecipeIngredients");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UnitOfMeasureId",
|
||||||
|
table: "RecipeIngredients");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Quantity",
|
||||||
|
table: "RecipeIngredients");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Quantity",
|
||||||
|
table: "RecipeIngredients",
|
||||||
|
type: "character varying(50)",
|
||||||
|
maxLength: 50,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -404,6 +404,12 @@ namespace YesChef.Api.Migrations
|
|||||||
b.Property<int?>("FamilyProductId")
|
b.Property<int?>("FamilyProductId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("FamilyUnitOfMeasureId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IsApproximate")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@@ -412,9 +418,13 @@ namespace YesChef.Api.Migrations
|
|||||||
b.Property<int?>("ProductId")
|
b.Property<int?>("ProductId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<string>("Quantity")
|
b.Property<decimal?>("Quantity")
|
||||||
.HasMaxLength(50)
|
.HasPrecision(12, 4)
|
||||||
.HasColumnType("character varying(50)");
|
.HasColumnType("numeric(12,4)");
|
||||||
|
|
||||||
|
b.Property<string>("QuantityNote")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
b.Property<int>("RecipeId")
|
b.Property<int>("RecipeId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
@@ -422,16 +432,23 @@ namespace YesChef.Api.Migrations
|
|||||||
b.Property<int>("SortOrder")
|
b.Property<int>("SortOrder")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("UnitOfMeasureId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("FamilyId");
|
b.HasIndex("FamilyId");
|
||||||
|
|
||||||
b.HasIndex("FamilyProductId");
|
b.HasIndex("FamilyProductId");
|
||||||
|
|
||||||
|
b.HasIndex("FamilyUnitOfMeasureId");
|
||||||
|
|
||||||
b.HasIndex("ProductId");
|
b.HasIndex("ProductId");
|
||||||
|
|
||||||
b.HasIndex("RecipeId");
|
b.HasIndex("RecipeId");
|
||||||
|
|
||||||
|
b.HasIndex("UnitOfMeasureId");
|
||||||
|
|
||||||
b.ToTable("RecipeIngredients");
|
b.ToTable("RecipeIngredients");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -869,6 +886,11 @@ namespace YesChef.Api.Migrations
|
|||||||
.HasForeignKey("FamilyProductId")
|
.HasForeignKey("FamilyProductId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("YesChef.Api.Entities.FamilyUnitOfMeasure", "FamilyUnitOfMeasure")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FamilyUnitOfMeasureId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.HasOne("YesChef.Api.Entities.Product", "Product")
|
b.HasOne("YesChef.Api.Entities.Product", "Product")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("ProductId")
|
.HasForeignKey("ProductId")
|
||||||
@@ -880,13 +902,22 @@ namespace YesChef.Api.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("YesChef.Api.Entities.UnitOfMeasure", "UnitOfMeasure")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UnitOfMeasureId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.Navigation("Family");
|
b.Navigation("Family");
|
||||||
|
|
||||||
b.Navigation("FamilyProduct");
|
b.Navigation("FamilyProduct");
|
||||||
|
|
||||||
|
b.Navigation("FamilyUnitOfMeasure");
|
||||||
|
|
||||||
b.Navigation("Product");
|
b.Navigation("Product");
|
||||||
|
|
||||||
b.Navigation("Recipe");
|
b.Navigation("Recipe");
|
||||||
|
|
||||||
|
b.Navigation("UnitOfMeasure");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>
|
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export interface QuantityValue {
|
||||||
|
quantity: number | null;
|
||||||
|
unitOfMeasureId: number | null;
|
||||||
|
familyUnitOfMeasureId: number | null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { units } from '$lib/units.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: QuantityValue;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { value = $bindable(), ariaLabel = 'Quantity' }: Props = $props();
|
||||||
|
|
||||||
|
// The unit dropdown carries a composite id "kind:id" so we can route to
|
||||||
|
// the right FK column on the server. "" = no unit selected.
|
||||||
|
const composite = $derived.by(() => {
|
||||||
|
if (value.unitOfMeasureId !== null) return `Global:${value.unitOfMeasureId}`;
|
||||||
|
if (value.familyUnitOfMeasureId !== null) return `Family:${value.familyUnitOfMeasureId}`;
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function onUnitChange(e: Event) {
|
||||||
|
const raw = (e.currentTarget as HTMLSelectElement).value;
|
||||||
|
if (raw === '') {
|
||||||
|
value.unitOfMeasureId = null;
|
||||||
|
value.familyUnitOfMeasureId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [kind, idStr] = raw.split(':');
|
||||||
|
const id = Number(idStr);
|
||||||
|
if (kind === 'Global') {
|
||||||
|
value.unitOfMeasureId = id;
|
||||||
|
value.familyUnitOfMeasureId = null;
|
||||||
|
} else {
|
||||||
|
value.unitOfMeasureId = null;
|
||||||
|
value.familyUnitOfMeasureId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQuantityInput(e: Event) {
|
||||||
|
const raw = (e.currentTarget as HTMLInputElement).value;
|
||||||
|
value.quantity = raw === '' ? null : Number(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = $derived(
|
||||||
|
[...units.all].sort((a, b) => a.sortOrder - b.sortOrder || a.singularName.localeCompare(b.singularName))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
inputmode="decimal"
|
||||||
|
value={value.quantity ?? ''}
|
||||||
|
oninput={onQuantityInput}
|
||||||
|
placeholder="Qty"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={composite}
|
||||||
|
onchange={onUnitChange}
|
||||||
|
aria-label="Unit"
|
||||||
|
class="w-24 rounded-lg border border-gray-300 px-1 py-2 text-sm focus:border-primary focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">unit</option>
|
||||||
|
{#each sorted as unit}
|
||||||
|
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Unit } from '$lib/units.svelte';
|
||||||
|
|
||||||
|
export interface QuantityLike {
|
||||||
|
quantity: number | null;
|
||||||
|
unitOfMeasureId: number | null;
|
||||||
|
familyUnitOfMeasureId: number | null;
|
||||||
|
isApproximate: boolean;
|
||||||
|
quantityNote: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a structured quantity for display alongside an ingredient name.
|
||||||
|
* Returns null when there's nothing to render (no quantity and not approximate).
|
||||||
|
*
|
||||||
|
* Examples (when paired with a unit lookup):
|
||||||
|
* { quantity: 2, unit cup } -> "2 cup"
|
||||||
|
* { quantity: 1.5, unit lb } -> "1.5 lb"
|
||||||
|
* { quantity: 3, unit null } -> "3"
|
||||||
|
* { isApproximate, note "..." } -> "to taste"
|
||||||
|
*/
|
||||||
|
export function formatQuantity(q: QuantityLike, units: Unit[]): string | null {
|
||||||
|
if (q.isApproximate) {
|
||||||
|
return q.quantityNote?.trim() || null;
|
||||||
|
}
|
||||||
|
if (q.quantity === null) return null;
|
||||||
|
|
||||||
|
const unit = lookupUnit(q, units);
|
||||||
|
const qty = trimTrailingZeros(q.quantity);
|
||||||
|
return unit ? `${qty} ${unit.abbreviation}` : qty;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupUnit(q: QuantityLike, units: Unit[]): Unit | null {
|
||||||
|
if (q.unitOfMeasureId !== null) {
|
||||||
|
return units.find((u) => u.id === q.unitOfMeasureId && u.kind === 'Global') ?? null;
|
||||||
|
}
|
||||||
|
if (q.familyUnitOfMeasureId !== null) {
|
||||||
|
return units.find((u) => u.id === q.familyUnitOfMeasureId && u.kind === 'Family') ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTrailingZeros(n: number): string {
|
||||||
|
// "2.0000" → "2", "1.5000" → "1.5". Keeps fractional precision when present.
|
||||||
|
return n
|
||||||
|
.toFixed(4)
|
||||||
|
.replace(/\.?0+$/, '');
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
export type UnitKind = 'Global' | 'Family';
|
||||||
|
export type UnitCategory = 'Count' | 'Weight' | 'Volume' | 'Packaging';
|
||||||
|
|
||||||
|
export interface Unit {
|
||||||
|
id: number;
|
||||||
|
kind: UnitKind;
|
||||||
|
code: string | null;
|
||||||
|
singularName: string;
|
||||||
|
pluralName: string;
|
||||||
|
abbreviation: string;
|
||||||
|
category: UnitCategory;
|
||||||
|
isBase: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory cache of the effective unit catalog for the current session.
|
||||||
|
// Single fetch per page-load; refresh on demand via reload().
|
||||||
|
let cache = $state<Unit[] | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
export const units = {
|
||||||
|
get all() {
|
||||||
|
if (cache === null && !loading) {
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
return cache ?? [];
|
||||||
|
},
|
||||||
|
get loading() {
|
||||||
|
return loading;
|
||||||
|
},
|
||||||
|
get error() {
|
||||||
|
return error;
|
||||||
|
},
|
||||||
|
reload: load,
|
||||||
|
byId(id: number, kind: UnitKind): Unit | null {
|
||||||
|
return cache?.find((u) => u.id === id && u.kind === kind) ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
cache = await api<Unit[]>('/api/units');
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load units';
|
||||||
|
cache = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,18 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import { units } from '$lib/units.svelte';
|
||||||
|
import { formatQuantity } from '$lib/formatQuantity';
|
||||||
|
|
||||||
interface Ingredient {
|
interface Ingredient {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
quantity: string | null;
|
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
quantity: number | null;
|
||||||
|
unitOfMeasureId: number | null;
|
||||||
|
familyUnitOfMeasureId: number | null;
|
||||||
|
isApproximate: boolean;
|
||||||
|
quantityNote: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Recipe {
|
interface Recipe {
|
||||||
@@ -41,6 +47,8 @@
|
|||||||
api<Recipe>(`/api/recipes/${recipeId}`),
|
api<Recipe>(`/api/recipes/${recipeId}`),
|
||||||
api<ListSummary[]>('/api/lists')
|
api<ListSummary[]>('/api/lists')
|
||||||
]);
|
]);
|
||||||
|
// Touch the units store to kick off the catalog fetch.
|
||||||
|
void units.all;
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,9 +133,10 @@
|
|||||||
<h3 class="mb-2 text-lg font-semibold">Ingredients</h3>
|
<h3 class="mb-2 text-lg font-semibold">Ingredients</h3>
|
||||||
<ul class="space-y-1.5">
|
<ul class="space-y-1.5">
|
||||||
{#each recipe.ingredients as ingredient}
|
{#each recipe.ingredients as ingredient}
|
||||||
|
{@const display = formatQuantity(ingredient, units.all)}
|
||||||
<li class="flex gap-2 rounded-lg bg-white px-3 py-2 shadow-sm">
|
<li class="flex gap-2 rounded-lg bg-white px-3 py-2 shadow-sm">
|
||||||
{#if ingredient.quantity}
|
{#if display}
|
||||||
<span class="font-medium text-primary">{ingredient.quantity}</span>
|
<span class="font-medium text-primary">{display}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span>{ingredient.name}</span>
|
<span>{ingredient.name}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
|
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
|
||||||
|
import QuantityInput, { type QuantityValue } from '$lib/QuantityInput.svelte';
|
||||||
|
|
||||||
interface IngredientForm {
|
interface IngredientForm {
|
||||||
name: string;
|
name: string;
|
||||||
quantity: string;
|
quantity: QuantityValue;
|
||||||
|
isApproximate: boolean;
|
||||||
|
quantityNote: string;
|
||||||
productId: number | null;
|
productId: number | null;
|
||||||
familyProductId: number | null;
|
familyProductId: number | null;
|
||||||
}
|
}
|
||||||
@@ -18,7 +21,14 @@
|
|||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
function emptyIngredient(): IngredientForm {
|
function emptyIngredient(): IngredientForm {
|
||||||
return { name: '', quantity: '', productId: null, familyProductId: null };
|
return {
|
||||||
|
name: '',
|
||||||
|
quantity: { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null },
|
||||||
|
isApproximate: false,
|
||||||
|
quantityNote: '',
|
||||||
|
productId: null,
|
||||||
|
familyProductId: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function addIngredient() {
|
function addIngredient() {
|
||||||
@@ -29,6 +39,16 @@
|
|||||||
ingredients = ingredients.filter((_, i) => i !== idx);
|
ingredients = ingredients.filter((_, i) => i !== idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleApproximate(idx: number) {
|
||||||
|
const ing = ingredients[idx];
|
||||||
|
ing.isApproximate = !ing.isApproximate;
|
||||||
|
if (ing.isApproximate) {
|
||||||
|
ing.quantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null };
|
||||||
|
} else {
|
||||||
|
ing.quantityNote = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onIngredientProductChange(idx: number, product: ProductSuggestion | null) {
|
function onIngredientProductChange(idx: number, product: ProductSuggestion | null) {
|
||||||
const next = ingredients[idx];
|
const next = ingredients[idx];
|
||||||
if (product === null) {
|
if (product === null) {
|
||||||
@@ -59,8 +79,12 @@
|
|||||||
.filter((i) => i.name.trim())
|
.filter((i) => i.name.trim())
|
||||||
.map((i, idx) => ({
|
.map((i, idx) => ({
|
||||||
name: i.name,
|
name: i.name,
|
||||||
quantity: i.quantity || null,
|
|
||||||
sortOrder: idx,
|
sortOrder: idx,
|
||||||
|
quantity: i.isApproximate ? null : i.quantity.quantity,
|
||||||
|
unitOfMeasureId: i.isApproximate ? null : i.quantity.unitOfMeasureId,
|
||||||
|
familyUnitOfMeasureId: i.isApproximate ? null : i.quantity.familyUnitOfMeasureId,
|
||||||
|
isApproximate: i.isApproximate,
|
||||||
|
quantityNote: i.isApproximate ? (i.quantityNote || null) : null,
|
||||||
productId: i.productId,
|
productId: i.productId,
|
||||||
familyProductId: i.familyProductId
|
familyProductId: i.familyProductId
|
||||||
}))
|
}))
|
||||||
@@ -109,31 +133,45 @@
|
|||||||
<div>
|
<div>
|
||||||
<span class="mb-2 block text-sm font-medium text-gray-600">Ingredients</span>
|
<span class="mb-2 block text-sm font-medium text-gray-600">Ingredients</span>
|
||||||
{#each ingredients as ingredient, idx}
|
{#each ingredients as ingredient, idx}
|
||||||
<div class="mb-2 flex gap-2">
|
<div class="mb-3 space-y-1 rounded-lg border border-gray-100 p-2">
|
||||||
<input
|
<div class="flex gap-2">
|
||||||
type="text"
|
{#if ingredient.isApproximate}
|
||||||
bind:value={ingredient.quantity}
|
<input
|
||||||
placeholder="Qty"
|
type="text"
|
||||||
class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
|
bind:value={ingredient.quantityNote}
|
||||||
/>
|
placeholder="e.g. to taste"
|
||||||
<div class="flex-1">
|
class="w-44 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
|
||||||
<ProductTypeahead
|
/>
|
||||||
bind:value={ingredient.name}
|
{:else}
|
||||||
placeholder="Ingredient name"
|
<QuantityInput bind:value={ingredient.quantity} />
|
||||||
ariaLabel="Ingredient name"
|
{/if}
|
||||||
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
<div class="flex-1">
|
||||||
onProductChange={(p) => onIngredientProductChange(idx, p)}
|
<ProductTypeahead
|
||||||
/>
|
bind:value={ingredient.name}
|
||||||
|
placeholder="Ingredient name"
|
||||||
|
ariaLabel="Ingredient name"
|
||||||
|
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||||
|
onProductChange={(p) => onIngredientProductChange(idx, p)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if ingredients.length > 1}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => removeIngredient(idx)}
|
||||||
|
class="px-2 text-gray-300 active:text-danger"
|
||||||
|
aria-label="Remove ingredient"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if ingredients.length > 1}
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onclick={() => toggleApproximate(idx)}
|
||||||
onclick={() => removeIngredient(idx)}
|
class="text-xs text-gray-500 hover:text-primary"
|
||||||
class="px-2 text-gray-300 active:text-danger"
|
>
|
||||||
>
|
{ingredient.isApproximate ? '↩ use measured amount' : '~ approximate (e.g. to taste)'}
|
||||||
✕
|
</button>
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user