Add unit-of-measure catalog foundation
Phase 1 of structured quantities + UoM. Introduces a global UnitOfMeasure catalog (Code-keyed for stable backend lookup of canonical units like "each") and FamilyUnitOfMeasure for family-scoped customs, mirroring the product-catalog pattern. Endpoints expose the merged effective catalog plus CRUD for family customs. Abbreviation uniqueness is enforced per table at the DB layer and across tables at the API layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using YesChef.Api.Entities;
|
||||||
|
using YesChef.Api.Features.Units;
|
||||||
|
using YesChef.Api.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace YesChef.Api.IntegrationTests.Features;
|
||||||
|
|
||||||
|
public class UnitEndpointsTests : AuthenticatedIntegrationTest
|
||||||
|
{
|
||||||
|
private Task<int> GetFamilyIdAsync() => UseDbAsync(db =>
|
||||||
|
db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync());
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task List_returns_global_units_and_family_customs_merged()
|
||||||
|
{
|
||||||
|
var familyId = await GetFamilyIdAsync();
|
||||||
|
await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
db.UnitsOfMeasure.Add(new UnitOfMeasure
|
||||||
|
{
|
||||||
|
Code = "each", SingularName = "each", PluralName = "each",
|
||||||
|
Abbreviation = "ea", Category = UnitCategory.Count, IsBase = true, SortOrder = 0,
|
||||||
|
});
|
||||||
|
db.FamilyUnitsOfMeasure.Add(new FamilyUnitOfMeasure
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
SingularName = "scoop", PluralName = "scoops",
|
||||||
|
Abbreviation = "scp", Category = UnitCategory.Volume, SortOrder = 50,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
var results = await Client.GetFromJsonAsync<List<UnitEndpoints.UnitDto>>("/api/units");
|
||||||
|
|
||||||
|
await Assert.That(results!.Select(r => r.Abbreviation)).IsEquivalentTo(new[] { "ea", "scp" });
|
||||||
|
var each = results!.Single(r => r.Abbreviation == "ea");
|
||||||
|
await Assert.That(each.Kind).IsEqualTo(UnitEndpoints.UnitKind.Global);
|
||||||
|
await Assert.That(each.Code).IsEqualTo("each");
|
||||||
|
var scoop = results!.Single(r => r.Abbreviation == "scp");
|
||||||
|
await Assert.That(scoop.Kind).IsEqualTo(UnitEndpoints.UnitKind.Family);
|
||||||
|
await Assert.That(scoop.Code).IsNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task List_does_not_leak_other_family_units()
|
||||||
|
{
|
||||||
|
await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var other = new Family { Name = "Other", InviteCode = "other-code" };
|
||||||
|
db.Families.Add(other);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
db.FamilyUnitsOfMeasure.Add(new FamilyUnitOfMeasure
|
||||||
|
{
|
||||||
|
FamilyId = other.Id,
|
||||||
|
SingularName = "pinch", PluralName = "pinches",
|
||||||
|
Abbreviation = "pn", Category = UnitCategory.Volume,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
var results = await Client.GetFromJsonAsync<List<UnitEndpoints.UnitDto>>("/api/units");
|
||||||
|
|
||||||
|
await Assert.That(results!.Any(r => r.Abbreviation == "pn")).IsFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Create_persists_family_unit_and_returns_201()
|
||||||
|
{
|
||||||
|
var response = await Client.PostAsJsonAsync("/api/units",
|
||||||
|
new UnitEndpoints.CreateUnitRequest("sleeve", "sleeves", "slv", UnitCategory.Packaging));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||||
|
var dto = await response.Content.ReadFromJsonAsync<UnitEndpoints.UnitDto>();
|
||||||
|
await Assert.That(dto!.Kind).IsEqualTo(UnitEndpoints.UnitKind.Family);
|
||||||
|
await Assert.That(dto.Abbreviation).IsEqualTo("slv");
|
||||||
|
|
||||||
|
var persisted = await UseDbAsync(db => db.FamilyUnitsOfMeasure.SingleAsync());
|
||||||
|
await Assert.That(persisted.SingularName).IsEqualTo("sleeve");
|
||||||
|
await Assert.That(persisted.Category).IsEqualTo(UnitCategory.Packaging);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Create_returns_409_when_abbreviation_collides_with_global()
|
||||||
|
{
|
||||||
|
await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
db.UnitsOfMeasure.Add(new UnitOfMeasure
|
||||||
|
{
|
||||||
|
Code = "lb", SingularName = "pound", PluralName = "pounds",
|
||||||
|
Abbreviation = "lb", Category = UnitCategory.Weight,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync("/api/units",
|
||||||
|
new UnitEndpoints.CreateUnitRequest("librapound", "librapounds", "lb", UnitCategory.Weight));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Create_returns_409_when_abbreviation_collides_within_family()
|
||||||
|
{
|
||||||
|
await Client.PostAsJsonAsync("/api/units",
|
||||||
|
new UnitEndpoints.CreateUnitRequest("sleeve", "sleeves", "slv", UnitCategory.Packaging));
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync("/api/units",
|
||||||
|
new UnitEndpoints.CreateUnitRequest("slab", "slabs", "slv", UnitCategory.Packaging));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Create_returns_400_when_required_field_missing()
|
||||||
|
{
|
||||||
|
var response = await Client.PostAsJsonAsync("/api/units",
|
||||||
|
new UnitEndpoints.CreateUnitRequest(" ", "sleeves", "slv", UnitCategory.Packaging));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Update_family_unit_changes_fields()
|
||||||
|
{
|
||||||
|
var familyId = await GetFamilyIdAsync();
|
||||||
|
var unit = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var u = new FamilyUnitOfMeasure
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
SingularName = "old", PluralName = "olds",
|
||||||
|
Abbreviation = "od", Category = UnitCategory.Count, SortOrder = 1,
|
||||||
|
};
|
||||||
|
db.FamilyUnitsOfMeasure.Add(u);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await Client.PutAsJsonAsync($"/api/units/family/{unit.Id}",
|
||||||
|
new UnitEndpoints.UpdateUnitRequest("new", "news", "nw", UnitCategory.Weight, 2));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
||||||
|
var refreshed = await UseDbAsync(db => db.FamilyUnitsOfMeasure.SingleAsync(u => u.Id == unit.Id));
|
||||||
|
await Assert.That(refreshed.SingularName).IsEqualTo("new");
|
||||||
|
await Assert.That(refreshed.Abbreviation).IsEqualTo("nw");
|
||||||
|
await Assert.That(refreshed.Category).IsEqualTo(UnitCategory.Weight);
|
||||||
|
await Assert.That(refreshed.SortOrder).IsEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Update_family_unit_404_for_other_family_id()
|
||||||
|
{
|
||||||
|
var foreignId = 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 = "foreign", PluralName = "foreigns",
|
||||||
|
Abbreviation = "fg", Category = UnitCategory.Count,
|
||||||
|
};
|
||||||
|
db.FamilyUnitsOfMeasure.Add(u);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return u.Id;
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await Client.PutAsJsonAsync($"/api/units/family/{foreignId}",
|
||||||
|
new UnitEndpoints.UpdateUnitRequest("x", "xs", "xx", UnitCategory.Count, 0));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Delete_family_unit_removes_row()
|
||||||
|
{
|
||||||
|
var familyId = await GetFamilyIdAsync();
|
||||||
|
var unit = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var u = new FamilyUnitOfMeasure
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
SingularName = "doomed", PluralName = "doomeds",
|
||||||
|
Abbreviation = "dm", Category = UnitCategory.Count,
|
||||||
|
};
|
||||||
|
db.FamilyUnitsOfMeasure.Add(u);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await Client.DeleteAsync($"/api/units/family/{unit.Id}");
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
|
||||||
|
var remaining = await UseDbAsync(db => db.FamilyUnitsOfMeasure.CountAsync());
|
||||||
|
await Assert.That(remaining).IsEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Endpoints_require_authentication()
|
||||||
|
{
|
||||||
|
var response = await AnonymousClient.GetAsync("/api/units");
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using YesChef.Api.Entities;
|
||||||
|
|
||||||
|
namespace YesChef.Api.Data.Seed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the unit-of-measure catalog from the embedded JSON resource and
|
||||||
|
/// inserts any entries that aren't already present (matched by Code).
|
||||||
|
/// Idempotent — safe to call on every startup.
|
||||||
|
/// </summary>
|
||||||
|
public static class UnitSeeder
|
||||||
|
{
|
||||||
|
private const string ResourceName = "YesChef.Api.Data.Seed.units.json";
|
||||||
|
|
||||||
|
private record SeedEntry(
|
||||||
|
string Code,
|
||||||
|
string SingularName,
|
||||||
|
string PluralName,
|
||||||
|
string Abbreviation,
|
||||||
|
UnitCategory Category,
|
||||||
|
bool IsBase,
|
||||||
|
int SortOrder);
|
||||||
|
|
||||||
|
private record SeedFile(List<SeedEntry> Units);
|
||||||
|
|
||||||
|
public static async Task SeedAsync(YesChefDb db, ILogger logger, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entries = LoadEntries();
|
||||||
|
if (entries.Count == 0) return;
|
||||||
|
|
||||||
|
var existingCodes = await db.UnitsOfMeasure
|
||||||
|
.Select(u => u.Code)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var missing = entries.Where(e => !existingCodes.Contains(e.Code)).ToList();
|
||||||
|
if (missing.Count == 0) return;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
db.UnitsOfMeasure.AddRange(missing.Select(e => new UnitOfMeasure
|
||||||
|
{
|
||||||
|
Code = e.Code,
|
||||||
|
SingularName = e.SingularName,
|
||||||
|
PluralName = e.PluralName,
|
||||||
|
Abbreviation = e.Abbreviation,
|
||||||
|
Category = e.Category,
|
||||||
|
IsBase = e.IsBase,
|
||||||
|
SortOrder = e.SortOrder,
|
||||||
|
CreatedAt = now,
|
||||||
|
}));
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
logger.LogInformation("Unit seed inserted {Count} new units of measure.", missing.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<SeedEntry> LoadEntries()
|
||||||
|
{
|
||||||
|
using var stream = typeof(UnitSeeder).Assembly.GetManifestResourceStream(ResourceName)
|
||||||
|
?? throw new InvalidOperationException($"Embedded seed resource '{ResourceName}' not found.");
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
var seed = JsonSerializer.Deserialize<SeedFile>(stream, options);
|
||||||
|
return seed?.Units ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"units": [
|
||||||
|
{ "code": "each", "singularName": "each", "pluralName": "each", "abbreviation": "ea", "category": "Count", "isBase": true, "sortOrder": 0 },
|
||||||
|
{ "code": "dozen", "singularName": "dozen", "pluralName": "dozen", "abbreviation": "doz", "category": "Count", "isBase": false, "sortOrder": 1 },
|
||||||
|
|
||||||
|
{ "code": "oz", "singularName": "ounce", "pluralName": "ounces", "abbreviation": "oz", "category": "Weight", "isBase": false, "sortOrder": 10 },
|
||||||
|
{ "code": "lb", "singularName": "pound", "pluralName": "pounds", "abbreviation": "lb", "category": "Weight", "isBase": false, "sortOrder": 11 },
|
||||||
|
{ "code": "g", "singularName": "gram", "pluralName": "grams", "abbreviation": "g", "category": "Weight", "isBase": true, "sortOrder": 12 },
|
||||||
|
{ "code": "kg", "singularName": "kilogram", "pluralName": "kilograms", "abbreviation": "kg", "category": "Weight", "isBase": false, "sortOrder": 13 },
|
||||||
|
|
||||||
|
{ "code": "tsp", "singularName": "teaspoon", "pluralName": "teaspoons", "abbreviation": "tsp", "category": "Volume", "isBase": false, "sortOrder": 20 },
|
||||||
|
{ "code": "tbsp", "singularName": "tablespoon", "pluralName": "tablespoons", "abbreviation": "tbsp", "category": "Volume", "isBase": false, "sortOrder": 21 },
|
||||||
|
{ "code": "fl-oz", "singularName": "fluid ounce","pluralName": "fluid ounces","abbreviation": "fl oz", "category": "Volume", "isBase": false, "sortOrder": 22 },
|
||||||
|
{ "code": "cup", "singularName": "cup", "pluralName": "cups", "abbreviation": "cup", "category": "Volume", "isBase": false, "sortOrder": 23 },
|
||||||
|
{ "code": "pt", "singularName": "pint", "pluralName": "pints", "abbreviation": "pt", "category": "Volume", "isBase": false, "sortOrder": 24 },
|
||||||
|
{ "code": "qt", "singularName": "quart", "pluralName": "quarts", "abbreviation": "qt", "category": "Volume", "isBase": false, "sortOrder": 25 },
|
||||||
|
{ "code": "gal", "singularName": "gallon", "pluralName": "gallons", "abbreviation": "gal", "category": "Volume", "isBase": false, "sortOrder": 26 },
|
||||||
|
{ "code": "ml", "singularName": "milliliter", "pluralName": "milliliters", "abbreviation": "ml", "category": "Volume", "isBase": true, "sortOrder": 27 },
|
||||||
|
{ "code": "L", "singularName": "liter", "pluralName": "liters", "abbreviation": "L", "category": "Volume", "isBase": false, "sortOrder": 28 },
|
||||||
|
|
||||||
|
{ "code": "box", "singularName": "box", "pluralName": "boxes", "abbreviation": "bx", "category": "Packaging", "isBase": false, "sortOrder": 30 },
|
||||||
|
{ "code": "bag", "singularName": "bag", "pluralName": "bags", "abbreviation": "bag", "category": "Packaging", "isBase": false, "sortOrder": 31 },
|
||||||
|
{ "code": "case", "singularName": "case", "pluralName": "cases", "abbreviation": "cs", "category": "Packaging", "isBase": false, "sortOrder": 32 },
|
||||||
|
{ "code": "bottle", "singularName": "bottle", "pluralName": "bottles", "abbreviation": "btl", "category": "Packaging", "isBase": false, "sortOrder": 33 },
|
||||||
|
{ "code": "can", "singularName": "can", "pluralName": "cans", "abbreviation": "can", "category": "Packaging", "isBase": false, "sortOrder": 34 },
|
||||||
|
{ "code": "jar", "singularName": "jar", "pluralName": "jars", "abbreviation": "jar", "category": "Packaging", "isBase": false, "sortOrder": 35 },
|
||||||
|
{ "code": "pack", "singularName": "pack", "pluralName": "packs", "abbreviation": "pk", "category": "Packaging", "isBase": false, "sortOrder": 36 },
|
||||||
|
{ "code": "bunch", "singularName": "bunch", "pluralName": "bunches", "abbreviation": "bn", "category": "Packaging", "isBase": false, "sortOrder": 37 },
|
||||||
|
{ "code": "head", "singularName": "head", "pluralName": "heads", "abbreviation": "hd", "category": "Packaging", "isBase": false, "sortOrder": 38 },
|
||||||
|
{ "code": "loaf", "singularName": "loaf", "pluralName": "loaves", "abbreviation": "lf", "category": "Packaging", "isBase": false, "sortOrder": 39 },
|
||||||
|
{ "code": "carton", "singularName": "carton", "pluralName": "cartons", "abbreviation": "ctn", "category": "Packaging", "isBase": false, "sortOrder": 40 },
|
||||||
|
{ "code": "roll", "singularName": "roll", "pluralName": "rolls", "abbreviation": "rl", "category": "Packaging", "isBase": false, "sortOrder": 41 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
|||||||
public DbSet<FamilyProduct> FamilyProducts => Set<FamilyProduct>();
|
public DbSet<FamilyProduct> FamilyProducts => Set<FamilyProduct>();
|
||||||
public DbSet<FamilyProductOverride> FamilyProductOverrides => Set<FamilyProductOverride>();
|
public DbSet<FamilyProductOverride> FamilyProductOverrides => Set<FamilyProductOverride>();
|
||||||
public DbSet<ProductStoreSection> ProductStoreSections => Set<ProductStoreSection>();
|
public DbSet<ProductStoreSection> ProductStoreSections => Set<ProductStoreSection>();
|
||||||
|
public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>();
|
||||||
|
public DbSet<FamilyUnitOfMeasure> FamilyUnitsOfMeasure => Set<FamilyUnitOfMeasure>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -148,6 +150,29 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
|||||||
e.HasOne(o => o.Product).WithMany().HasForeignKey(o => o.ProductId).OnDelete(DeleteBehavior.Cascade);
|
e.HasOne(o => o.Product).WithMany().HasForeignKey(o => o.ProductId).OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<UnitOfMeasure>(e =>
|
||||||
|
{
|
||||||
|
e.Property(u => u.Code).HasMaxLength(50);
|
||||||
|
e.Property(u => u.SingularName).HasMaxLength(50);
|
||||||
|
e.Property(u => u.PluralName).HasMaxLength(50);
|
||||||
|
e.Property(u => u.Abbreviation).HasMaxLength(20);
|
||||||
|
e.Property(u => u.Category).HasConversion<int>();
|
||||||
|
e.HasIndex(u => u.Code).IsUnique();
|
||||||
|
e.HasIndex(u => u.Abbreviation).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<FamilyUnitOfMeasure>(e =>
|
||||||
|
{
|
||||||
|
e.Property(u => u.SingularName).HasMaxLength(50);
|
||||||
|
e.Property(u => u.PluralName).HasMaxLength(50);
|
||||||
|
e.Property(u => u.Abbreviation).HasMaxLength(20);
|
||||||
|
e.Property(u => u.Category).HasConversion<int>();
|
||||||
|
e.HasOne(u => u.Family).WithMany().HasForeignKey(u => u.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
// Per-family abbreviation uniqueness. Cross-table collision with the
|
||||||
|
// global catalog is enforced in the API layer on create/update.
|
||||||
|
e.HasIndex(u => new { u.FamilyId, u.Abbreviation }).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<ProductStoreSection>(e =>
|
modelBuilder.Entity<ProductStoreSection>(e =>
|
||||||
{
|
{
|
||||||
e.HasOne(p => p.Family).WithMany().HasForeignKey(p => p.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
e.HasOne(p => p.Family).WithMany().HasForeignKey(p => p.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace YesChef.Api.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Family-scoped unit of measure. Visible only to the owning family and
|
||||||
|
/// merged with the global <see cref="UnitOfMeasure"/> catalog on read.
|
||||||
|
/// </summary>
|
||||||
|
public class FamilyUnitOfMeasure
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int FamilyId { get; set; }
|
||||||
|
public Family Family { get; set; } = null!;
|
||||||
|
public required string SingularName { get; set; }
|
||||||
|
public required string PluralName { get; set; }
|
||||||
|
public required string Abbreviation { get; set; }
|
||||||
|
public UnitCategory Category { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace YesChef.Api.Entities;
|
||||||
|
|
||||||
|
public enum UnitCategory
|
||||||
|
{
|
||||||
|
Count = 0,
|
||||||
|
Weight = 1,
|
||||||
|
Volume = 2,
|
||||||
|
Packaging = 3,
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace YesChef.Api.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Global, read-only unit-of-measure catalog entry. Visible to every family.
|
||||||
|
/// Family-only units live in <see cref="FamilyUnitOfMeasure"/>. The effective
|
||||||
|
/// catalog for a family is global ∪ that family's customs.
|
||||||
|
/// </summary>
|
||||||
|
public class UnitOfMeasure
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
// Stable lookup key (e.g. "each", "lb"). Lets the backend reference
|
||||||
|
// canonical units without depending on auto-generated ids.
|
||||||
|
public required string Code { get; set; }
|
||||||
|
public required string SingularName { get; set; }
|
||||||
|
public required string PluralName { get; set; }
|
||||||
|
public required string Abbreviation { get; set; }
|
||||||
|
public UnitCategory Category { get; set; }
|
||||||
|
// Canonical-in-category marker, reserved for future conversion math.
|
||||||
|
public bool IsBase { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using YesChef.Api.Auth;
|
||||||
|
using YesChef.Api.Data;
|
||||||
|
using YesChef.Api.Entities;
|
||||||
|
|
||||||
|
namespace YesChef.Api.Features.Units;
|
||||||
|
|
||||||
|
public static class UnitEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Discriminator so the client can route subsequent PUT/DELETE
|
||||||
|
/// calls to the right code path. Global units cannot be edited.</summary>
|
||||||
|
public enum UnitKind { Global, Family }
|
||||||
|
|
||||||
|
public record UnitDto(
|
||||||
|
int Id,
|
||||||
|
UnitKind Kind,
|
||||||
|
string? Code,
|
||||||
|
string SingularName,
|
||||||
|
string PluralName,
|
||||||
|
string Abbreviation,
|
||||||
|
UnitCategory Category,
|
||||||
|
bool IsBase,
|
||||||
|
int SortOrder);
|
||||||
|
|
||||||
|
public record CreateUnitRequest(
|
||||||
|
string SingularName,
|
||||||
|
string PluralName,
|
||||||
|
string Abbreviation,
|
||||||
|
UnitCategory Category,
|
||||||
|
int SortOrder = 100);
|
||||||
|
|
||||||
|
public record UpdateUnitRequest(
|
||||||
|
string SingularName,
|
||||||
|
string PluralName,
|
||||||
|
string Abbreviation,
|
||||||
|
UnitCategory Category,
|
||||||
|
int SortOrder);
|
||||||
|
|
||||||
|
public static RouteGroupBuilder MapUnitEndpoints(this RouteGroupBuilder group)
|
||||||
|
{
|
||||||
|
// Effective catalog: global ∪ this family's customs. Client caches.
|
||||||
|
group.MapGet("/", async (YesChefDb db, HttpContext http) =>
|
||||||
|
{
|
||||||
|
var familyId = http.User.GetFamilyId();
|
||||||
|
|
||||||
|
var globalRows = await db.UnitsOfMeasure.AsNoTracking()
|
||||||
|
.OrderBy(u => u.SortOrder).ThenBy(u => u.SingularName)
|
||||||
|
.ToListAsync();
|
||||||
|
var familyRows = await db.FamilyUnitsOfMeasure.AsNoTracking()
|
||||||
|
.Where(u => u.FamilyId == familyId)
|
||||||
|
.OrderBy(u => u.SortOrder).ThenBy(u => u.SingularName)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var dtos = globalRows.Select(u => new UnitDto(
|
||||||
|
u.Id, UnitKind.Global, u.Code, u.SingularName, u.PluralName,
|
||||||
|
u.Abbreviation, u.Category, u.IsBase, u.SortOrder))
|
||||||
|
.Concat(familyRows.Select(u => new UnitDto(
|
||||||
|
u.Id, UnitKind.Family, null, u.SingularName, u.PluralName,
|
||||||
|
u.Abbreviation, u.Category, IsBase: false, u.SortOrder)));
|
||||||
|
|
||||||
|
return Results.Ok(dtos);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/", async (CreateUnitRequest request, YesChefDb db, HttpContext http) =>
|
||||||
|
{
|
||||||
|
var familyId = http.User.GetFamilyId();
|
||||||
|
if (ValidateNames(request.SingularName, request.PluralName, request.Abbreviation) is { } badRequest)
|
||||||
|
return badRequest;
|
||||||
|
|
||||||
|
var singular = request.SingularName.Trim();
|
||||||
|
var plural = request.PluralName.Trim();
|
||||||
|
var abbrev = request.Abbreviation.Trim();
|
||||||
|
|
||||||
|
if (await AbbreviationCollidesAsync(db, familyId, abbrev, excludeFamilyUnitId: null))
|
||||||
|
return Results.Conflict(new { error = $"A unit with abbreviation \"{abbrev}\" already exists." });
|
||||||
|
|
||||||
|
var unit = new FamilyUnitOfMeasure
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
SingularName = singular,
|
||||||
|
PluralName = plural,
|
||||||
|
Abbreviation = abbrev,
|
||||||
|
Category = request.Category,
|
||||||
|
SortOrder = request.SortOrder,
|
||||||
|
};
|
||||||
|
db.FamilyUnitsOfMeasure.Add(unit);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Results.Created($"/api/units/family/{unit.Id}", ToDto(unit));
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPut("/family/{id:int}", async (int id, UpdateUnitRequest request, YesChefDb db, HttpContext http) =>
|
||||||
|
{
|
||||||
|
var familyId = http.User.GetFamilyId();
|
||||||
|
var unit = await db.FamilyUnitsOfMeasure.FirstOrDefaultAsync(u => u.Id == id && u.FamilyId == familyId);
|
||||||
|
if (unit is null) return Results.NotFound();
|
||||||
|
|
||||||
|
if (ValidateNames(request.SingularName, request.PluralName, request.Abbreviation) is { } badRequest)
|
||||||
|
return badRequest;
|
||||||
|
|
||||||
|
var abbrev = request.Abbreviation.Trim();
|
||||||
|
if (!string.Equals(abbrev, unit.Abbreviation, StringComparison.Ordinal) &&
|
||||||
|
await AbbreviationCollidesAsync(db, familyId, abbrev, excludeFamilyUnitId: id))
|
||||||
|
return Results.Conflict(new { error = $"A unit with abbreviation \"{abbrev}\" already exists." });
|
||||||
|
|
||||||
|
unit.SingularName = request.SingularName.Trim();
|
||||||
|
unit.PluralName = request.PluralName.Trim();
|
||||||
|
unit.Abbreviation = abbrev;
|
||||||
|
unit.Category = request.Category;
|
||||||
|
unit.SortOrder = request.SortOrder;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Results.Ok(ToDto(unit));
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapDelete("/family/{id:int}", async (int id, YesChefDb db, HttpContext http) =>
|
||||||
|
{
|
||||||
|
var familyId = http.User.GetFamilyId();
|
||||||
|
var unit = await db.FamilyUnitsOfMeasure.FirstOrDefaultAsync(u => u.Id == id && u.FamilyId == familyId);
|
||||||
|
if (unit is null) return Results.NotFound();
|
||||||
|
|
||||||
|
// No usage check yet — RecipeIngredient/ShoppingListItem don't
|
||||||
|
// reference units in Phase 1. Phase 2/3 will need to block delete
|
||||||
|
// when rows reference this unit.
|
||||||
|
db.FamilyUnitsOfMeasure.Remove(unit);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult? ValidateNames(string singular, string plural, string abbreviation)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(singular)) return Results.BadRequest(new { error = "Singular name is required." });
|
||||||
|
if (string.IsNullOrWhiteSpace(plural)) return Results.BadRequest(new { error = "Plural name is required." });
|
||||||
|
if (string.IsNullOrWhiteSpace(abbreviation)) return Results.BadRequest(new { error = "Abbreviation is required." });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> AbbreviationCollidesAsync(
|
||||||
|
YesChefDb db, int familyId, string abbreviation, int? excludeFamilyUnitId)
|
||||||
|
{
|
||||||
|
// Effective uniqueness spans the global catalog AND this family's
|
||||||
|
// customs. DB indexes guard each table individually; this check
|
||||||
|
// catches cross-table collisions before they get to the user.
|
||||||
|
if (await db.UnitsOfMeasure.AnyAsync(u => u.Abbreviation == abbreviation))
|
||||||
|
return true;
|
||||||
|
return await db.FamilyUnitsOfMeasure.AnyAsync(u =>
|
||||||
|
u.FamilyId == familyId &&
|
||||||
|
u.Abbreviation == abbreviation &&
|
||||||
|
(excludeFamilyUnitId == null || u.Id != excludeFamilyUnitId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UnitDto ToDto(FamilyUnitOfMeasure u) => new(
|
||||||
|
u.Id, UnitKind.Family, Code: null, u.SingularName, u.PluralName,
|
||||||
|
u.Abbreviation, u.Category, IsBase: false, u.SortOrder);
|
||||||
|
}
|
||||||
+1025
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace YesChef.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUnitOfMeasureCatalog : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "FamilyUnitsOfMeasure",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
FamilyId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SingularName = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
PluralName = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Abbreviation = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Category = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_FamilyUnitsOfMeasure", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_FamilyUnitsOfMeasure_Families_FamilyId",
|
||||||
|
column: x => x.FamilyId,
|
||||||
|
principalTable: "Families",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "UnitsOfMeasure",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
SingularName = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
PluralName = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Abbreviation = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Category = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
IsBase = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_UnitsOfMeasure", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FamilyUnitsOfMeasure_FamilyId_Abbreviation",
|
||||||
|
table: "FamilyUnitsOfMeasure",
|
||||||
|
columns: new[] { "FamilyId", "Abbreviation" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UnitsOfMeasure_Abbreviation",
|
||||||
|
table: "UnitsOfMeasure",
|
||||||
|
column: "Abbreviation",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UnitsOfMeasure_Code",
|
||||||
|
table: "UnitsOfMeasure",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "FamilyUnitsOfMeasure");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "UnitsOfMeasure");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,6 +137,49 @@ namespace YesChef.Api.Migrations
|
|||||||
b.ToTable("FamilyProductOverrides");
|
b.ToTable("FamilyProductOverrides");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.FamilyUnitOfMeasure", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Abbreviation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("Category")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("FamilyId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("PluralName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("SingularName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FamilyId", "Abbreviation")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("FamilyUnitsOfMeasure");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("YesChef.Api.Entities.Invite", b =>
|
modelBuilder.Entity("YesChef.Api.Entities.Invite", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -566,6 +609,57 @@ namespace YesChef.Api.Migrations
|
|||||||
b.ToTable("StoreSections");
|
b.ToTable("StoreSections");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.UnitOfMeasure", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Abbreviation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("Category")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBase")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("PluralName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("SingularName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Abbreviation")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("UnitsOfMeasure");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("YesChef.Api.Entities.User", b =>
|
modelBuilder.Entity("YesChef.Api.Entities.User", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -654,6 +748,17 @@ namespace YesChef.Api.Migrations
|
|||||||
b.Navigation("Product");
|
b.Navigation("Product");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.FamilyUnitOfMeasure", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("YesChef.Api.Entities.Family", "Family")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FamilyId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Family");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("YesChef.Api.Entities.Invite", b =>
|
modelBuilder.Entity("YesChef.Api.Entities.Invite", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("YesChef.Api.Entities.User", "ConsumedByUser")
|
b.HasOne("YesChef.Api.Entities.User", "ConsumedByUser")
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ using YesChef.Api.Features.Products;
|
|||||||
using YesChef.Api.Features.Recipes;
|
using YesChef.Api.Features.Recipes;
|
||||||
using YesChef.Api.Features.ShoppingLists;
|
using YesChef.Api.Features.ShoppingLists;
|
||||||
using YesChef.Api.Features.Stores;
|
using YesChef.Api.Features.Stores;
|
||||||
|
using YesChef.Api.Features.Units;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -204,6 +205,7 @@ using (var scope = app.Services.CreateScope())
|
|||||||
{
|
{
|
||||||
var seedLogger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
|
var seedLogger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
|
||||||
await CatalogSeeder.SeedAsync(db, seedLogger);
|
await CatalogSeeder.SeedAsync(db, seedLogger);
|
||||||
|
await UnitSeeder.SeedAsync(db, seedLogger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +227,7 @@ storesGroup.MapGroup("/{storeId:int}/sections").MapStoreSectionEndpoints();
|
|||||||
app.MapGroup("/api/lists").MapShoppingListEndpoints().RequireAuthorization();
|
app.MapGroup("/api/lists").MapShoppingListEndpoints().RequireAuthorization();
|
||||||
app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization();
|
app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization();
|
||||||
app.MapGroup("/api/products").MapProductEndpoints().RequireAuthorization();
|
app.MapGroup("/api/products").MapProductEndpoints().RequireAuthorization();
|
||||||
|
app.MapGroup("/api/units").MapUnitEndpoints().RequireAuthorization();
|
||||||
app.MapHub<ShoppingListHub>("/hubs/shopping-list");
|
app.MapHub<ShoppingListHub>("/hubs/shopping-list");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="Data/Seed/products.json" />
|
<EmbeddedResource Include="Data/Seed/products.json" />
|
||||||
|
<EmbeddedResource Include="Data/Seed/units.json" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
Reference in New Issue
Block a user