Files
meezi/src/Meezi.Infrastructure/Data/DemoMenuSeeder.cs
T
soroush.asadi 665e3ca279
CI/CD / CI · API (dotnet build + test) (push) Successful in 56s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 52s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m3s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 43s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Successful in 1m31s
fix(demo): scope category/item IDs per café to prevent PK collisions
DemoMenuSeeder used hardcoded IDs like cat_demo_coffee for every café.
If the dev seeder (runs when ASPNETCORE_ENVIRONMENT=Development) already
inserted those IDs for cafe_demo_001, a production café clicking
"Add demo data" hit a primary-key constraint violation.

Fix: EnsureMenuAsync now accepts useScopedIds=true which prefixes every
category and item ID with cafeId (e.g. cafe_abc_cat_demo_coffee).
CategoryId FKs on items are remapped through the same function.

DemoSeedService (the API endpoint handler) always passes useScopedIds=true.
DevelopmentDataSeeder keeps useScopedIds=false (default) so the existing
cafe_demo_001 rows in dev databases are not touched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:59:48 +03:30

220 lines
8.3 KiB
C#

using Meezi.Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Meezi.Infrastructure.Data;
public static class DemoMenuSeeder
{
/// <param name="useScopedIds">
/// When true, category and item IDs are prefixed with <paramref name="cafeId"/> so
/// multiple cafés can each have their own copy of the demo menu without a primary-key
/// collision. Pass false only for the legacy demo café (cafe_demo_001) whose IDs are
/// already in the database without a café prefix.
/// </param>
public static async Task EnsureMenuAsync(
AppDbContext db, string cafeId, string taxId, ILogger logger,
bool useScopedIds = false)
{
// When useScopedIds=true every row gets a deterministic ID that is unique per café:
// category → "{cafeId}_{catalogId}"
// item → "{cafeId}_{catalogId}"
// The catalog item's CategoryId is remapped through the same function.
string Scoped(string catalogId) =>
useScopedIds ? $"{cafeId}_{catalogId}" : catalogId;
if (!await db.Taxes.AnyAsync(t => t.Id == taxId && t.CafeId == cafeId))
{
db.Taxes.Add(new Tax
{
Id = taxId,
CafeId = cafeId,
Name = "مالیات",
Rate = 9,
IsDefault = true,
IsRequired = true,
IsCompound = false
});
}
var existingCategoryIds = await db.MenuCategories
.Where(c => c.CafeId == cafeId)
.ToDictionaryAsync(c => c.Id, StringComparer.Ordinal);
var categoriesAdded = 0;
foreach (var cat in DemoMenuCatalog.Categories)
{
var catId = Scoped(cat.Id);
if (existingCategoryIds.TryGetValue(catId, out var row))
{
if (string.IsNullOrWhiteSpace(row.Icon) && !string.IsNullOrWhiteSpace(cat.Icon))
row.Icon = cat.Icon;
if (string.IsNullOrWhiteSpace(row.IconPresetId) && !string.IsNullOrWhiteSpace(cat.IconPresetId))
row.IconPresetId = cat.IconPresetId;
if (string.IsNullOrWhiteSpace(row.IconStyle) && !string.IsNullOrWhiteSpace(cat.IconStyle))
row.IconStyle = cat.IconStyle;
if (string.IsNullOrWhiteSpace(row.NameEn) && !string.IsNullOrWhiteSpace(cat.NameEn))
row.NameEn = cat.NameEn;
if (string.IsNullOrWhiteSpace(row.NameAr) && cat.NameAr is not null)
row.NameAr = cat.NameAr;
continue;
}
db.MenuCategories.Add(new MenuCategory
{
Id = catId,
CafeId = cafeId,
Name = cat.Name,
NameEn = cat.NameEn,
NameAr = cat.NameAr,
Icon = cat.Icon,
IconPresetId = cat.IconPresetId,
IconStyle = cat.IconStyle,
SortOrder = cat.SortOrder,
TaxId = taxId,
IsActive = true
});
categoriesAdded++;
}
var existingItemIds = await db.MenuItems
.Where(i => i.CafeId == cafeId)
.Select(i => i.Id)
.ToListAsync();
var itemsAdded = 0;
foreach (var item in DemoMenuCatalog.Items)
{
var itemId = Scoped(item.Id);
if (existingItemIds.Contains(itemId))
continue;
db.MenuItems.Add(new MenuItem
{
Id = itemId,
CafeId = cafeId,
CategoryId = Scoped(item.CategoryId), // FK must point at scoped category ID
Name = item.Name,
NameEn = item.NameEn,
NameAr = item.NameAr,
Description = item.Description,
Price = item.PriceToman,
DiscountPercent = item.DiscountPercent,
ImageUrl = DemoMenuCatalog.ResolveItemImageUrl(item),
IsAvailable = true
});
itemsAdded++;
}
if (categoriesAdded > 0 || itemsAdded > 0)
await db.SaveChangesAsync();
if (categoriesAdded > 0 || itemsAdded > 0)
{
logger.LogInformation(
"Demo menu seed: +{Categories} categories, +{Items} items (catalog total {Total}) for cafe {CafeId}",
categoriesAdded,
itemsAdded,
DemoMenuCatalog.Items.Count,
cafeId);
}
await EnsureMenuImagesAsync(db, cafeId, logger);
await EnsureMenuTranslationsAsync(db, cafeId, logger);
}
/// <summary>Upserts ImageUrl from catalog/manifest/Food-101 fallbacks.</summary>
public static async Task EnsureMenuImagesAsync(AppDbContext db, string cafeId, ILogger logger)
{
var catalogById = DemoMenuCatalog.Items.ToDictionary(i => i.Id, StringComparer.Ordinal);
var items = await db.MenuItems
.Include(i => i.Category)
.Where(i => i.CafeId == cafeId)
.ToListAsync();
var updated = 0;
foreach (var row in items)
{
var resolved = catalogById.TryGetValue(row.Id, out var seed)
? DemoMenuCatalog.ResolveItemImageUrl(seed)
: MenuItemImageDefaults.ResolveImageUrl(row.Id, row.CategoryId, row.Category?.Name);
if (string.IsNullOrWhiteSpace(resolved)) continue;
var inCatalog = catalogById.ContainsKey(row.Id);
var shouldUpdate = MenuItemImageDefaults.NeedsImageRepair(row.ImageUrl) || inCatalog;
if (!shouldUpdate || string.Equals(row.ImageUrl, resolved, StringComparison.Ordinal))
continue;
row.ImageUrl = resolved;
updated++;
}
if (updated > 0)
{
await db.SaveChangesAsync();
logger.LogInformation("Menu image upsert: {Count} items updated for cafe {CafeId}", updated, cafeId);
}
}
/// <summary>Upserts NameEn/NameAr from catalog for demo menu rows.</summary>
public static async Task EnsureMenuTranslationsAsync(AppDbContext db, string cafeId, ILogger logger)
{
var catalogItems = DemoMenuCatalog.Items.ToDictionary(i => i.Id, StringComparer.Ordinal);
var catalogCats = DemoMenuCatalog.Categories.ToDictionary(c => c.Id, StringComparer.Ordinal);
var items = await db.MenuItems.Where(i => i.CafeId == cafeId && catalogItems.Keys.Contains(i.Id)).ToListAsync();
var itemUpdated = 0;
foreach (var row in items)
{
if (!catalogItems.TryGetValue(row.Id, out var seed)) continue;
var changed = false;
if (string.IsNullOrWhiteSpace(row.NameEn) && !string.IsNullOrWhiteSpace(seed.NameEn))
{
row.NameEn = seed.NameEn;
changed = true;
}
if (string.IsNullOrWhiteSpace(row.NameAr) && seed.NameAr is not null)
{
row.NameAr = seed.NameAr;
changed = true;
}
if (changed) itemUpdated++;
}
var categories = await db.MenuCategories.Where(c => c.CafeId == cafeId && catalogCats.Keys.Contains(c.Id)).ToListAsync();
var catUpdated = 0;
foreach (var row in categories)
{
if (!catalogCats.TryGetValue(row.Id, out var seed)) continue;
var changed = false;
if (string.IsNullOrWhiteSpace(row.NameEn) && !string.IsNullOrWhiteSpace(seed.NameEn))
{
row.NameEn = seed.NameEn;
changed = true;
}
if (string.IsNullOrWhiteSpace(row.NameAr) && seed.NameAr is not null)
{
row.NameAr = seed.NameAr;
changed = true;
}
if (string.IsNullOrWhiteSpace(row.Icon) && !string.IsNullOrWhiteSpace(seed.Icon))
{
row.Icon = seed.Icon;
changed = true;
}
if (changed) catUpdated++;
}
if (itemUpdated > 0 || catUpdated > 0)
{
await db.SaveChangesAsync();
logger.LogInformation(
"Menu translation upsert: {Items} items, {Cats} categories for cafe {CafeId}",
itemUpdated,
catUpdated,
cafeId);
}
}
}