Files
meezi/src/Meezi.Infrastructure/Data/PlatformDataSeeder.cs
T
soroush.asadi 09bba5f8cd
CI/CD / CI · API (dotnet build + test) (push) Successful in 45s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 34s
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 1m20s
fix(seed): count soft-deleted rows + make platform seeding non-fatal
Root cause of the crash-loop: a soft-deleted Free plan still occupies its Tier in the unique index, but the existing-row check queried THROUGH the soft-delete global filter and missed it, so the seeder re-inserted Free and violated IX_PlatformPlanDefinitions_Tier on boot. Fixes: (1) IgnoreQueryFilters() on the plan/feature existing-checks so soft-deleted tiers/keys are counted; (2) wrap plan/feature/location seeding in try/catch so any seeding failure logs and startup continues — non-essential seeding must never crash-loop the API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 02:26:11 +03:30

546 lines
24 KiB
C#

using System.Text.Json;
using Meezi.Core.Constants;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Platform;
using Meezi.Core.Utilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Meezi.Infrastructure.Data;
public static class PlatformDataSeeder
{
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public static async Task SeedAsync(IServiceProvider services)
{
var env = services.GetRequiredService<IHostEnvironment>();
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder");
await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
// Production-safe: ensure the platform owner's system-admin account exists
// on every boot (ALL environments) so the admin panel is reachable on a
// fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone".
await EnsureOwnerAdminAsync(db, config, logger);
// Best-effort, NON-FATAL seeding. These steps populate convenience data
// (map pins, plan/feature catalog) and must never crash-loop the API on
// boot — a failure is logged and startup continues so the service serves.
try
{
// Give cafés without a map pin an approximate location from their
// city so the public map lights up. Idempotent (fills nulls).
await BackfillCafeLocationsAsync(db, logger);
// Subscription plans + feature flags the admin panel reads in every
// environment. Idempotent: adds any tiers/keys that are missing.
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
}
catch (Exception ex)
{
logger.LogError(ex, "Non-fatal platform seeding step failed; continuing startup");
}
if (!env.IsDevelopment())
{
// Production: also ensure integration settings (Kavenegar enabled/template,
// etc.) exist so the admin Integrations page is populated. Idempotent.
await EnsureIntegrationSettingsAsync(db, logger);
return;
}
await EnsureCatalogUpgradesAsync(db, logger);
await SeedSystemAdminAsync(db, logger);
await SeedSettingsAsync(db, logger);
await EnsureIntegrationSettingsAsync(db, logger);
}
// Approximate centres for the major Iranian cities cafés sign up from.
private static readonly Dictionary<string, (double Lat, double Lng)> CityCentres = new(StringComparer.Ordinal)
{
["تهران"] = (35.70, 51.39),
["کرج"] = (35.84, 50.99),
["مشهد"] = (36.30, 59.61),
["اصفهان"] = (32.66, 51.67),
["شیراز"] = (29.59, 52.53),
["تبریز"] = (38.08, 46.29),
["قم"] = (34.64, 50.88),
["اهواز"] = (31.32, 48.67),
["کرمانشاه"] = (34.31, 47.07),
["رشت"] = (37.28, 49.58),
["ارومیه"] = (37.55, 45.07),
["همدان"] = (34.80, 48.52),
["یزد"] = (31.90, 54.37),
["اراک"] = (34.09, 49.69),
["کرمان"] = (30.28, 57.08),
["بندرعباس"] = (27.18, 56.27),
["قزوین"] = (36.28, 50.00),
["ساری"] = (36.57, 53.06),
["گرگان"] = (36.84, 54.44),
["زنجان"] = (36.68, 48.49),
["کیش"] = (26.56, 53.98),
};
/// <summary>
/// Gives cafés that have no map pin an approximate location at their city
/// centre (plus a small deterministic per-café offset so multiple cafés in
/// one city don't stack on a single point). Only fills rows where Latitude or
/// Longitude is null and the city is recognised; owners can drop an exact pin
/// later from Settings. Idempotent — never overwrites an existing pin.
/// </summary>
private static async Task BackfillCafeLocationsAsync(AppDbContext db, ILogger logger)
{
var cafes = await db.Cafes
.Where(c => c.DeletedAt == null
&& (c.Latitude == null || c.Longitude == null)
&& c.City != null)
.ToListAsync();
if (cafes.Count == 0) return;
var updated = 0;
foreach (var cafe in cafes)
{
var city = cafe.City!.Trim();
if (!CityCentres.TryGetValue(city, out var centre)) continue;
var (lat, lng) = ScatterAround(cafe.Id, centre.Lat, centre.Lng, 0.05);
cafe.Latitude = lat;
cafe.Longitude = lng;
updated++;
}
if (updated > 0)
{
await db.SaveChangesAsync();
logger.LogInformation(
"Cafe location backfill: set approximate coordinates for {Count} café(s) from city centre", updated);
}
}
private static (double Lat, double Lng) ScatterAround(string id, double lat, double lng, double spread)
{
unchecked
{
var h = 17;
foreach (var ch in id) h = (h * 31) + ch;
var ox = (((h & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
var oy = ((((h >> 16) & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
return (Math.Round(lat + oy, 5), Math.Round(lng + ox, 5));
}
}
/// <summary>
/// Ensures the platform owner's system-admin account exists in EVERY environment
/// (including production), so the admin panel is reachable on a fresh deploy.
/// The phone is configurable via "Seed:SystemAdminPhone" (env Seed__SystemAdminPhone)
/// and defaults to the platform owner's number. Idempotent — never duplicates.
/// </summary>
private static async Task EnsureOwnerAdminAsync(AppDbContext db, IConfiguration config, ILogger logger)
{
const string DefaultOwnerPhone = "09190345606";
const string DefaultAdminUsername = "admin";
var configuredPhone = config["Seed:SystemAdminPhone"];
var phone = PhoneNormalizer.Normalize(
string.IsNullOrWhiteSpace(configuredPhone) ? DefaultOwnerPhone : configuredPhone);
if (!PhoneNormalizer.IsValidIranMobile(phone))
{
logger.LogWarning("Owner system-admin seed skipped — invalid phone '{Phone}'", phone);
return;
}
var configuredUsername = config["Seed:SystemAdminUsername"];
var username = string.IsNullOrWhiteSpace(configuredUsername) ? DefaultAdminUsername : configuredUsername.Trim().ToLowerInvariant();
var defaultPassword = config["Seed:SystemAdminPassword"]; // optional — only set if provided
var existing = await db.SystemAdmins.FirstOrDefaultAsync(a => a.Phone == phone);
if (existing is null)
{
var admin = new SystemAdmin
{
Id = "sysadmin_owner",
Name = "مدیر سامانه",
Phone = phone,
IsActive = true,
Username = username,
PasswordHash = string.IsNullOrWhiteSpace(defaultPassword) ? null : PasswordHasher.Hash(defaultPassword)
};
db.SystemAdmins.Add(admin);
try
{
await db.SaveChangesAsync();
logger.LogInformation("Seeded owner system admin with phone {Phone}, username '{Username}'", phone, username);
}
catch (DbUpdateException)
{
logger.LogInformation("Owner system admin already seeded by another instance");
}
return;
}
// Patch existing admin: fill in missing username / password without overwriting set values
var patched = false;
if (string.IsNullOrWhiteSpace(existing.Username))
{
existing.Username = username;
patched = true;
}
if (string.IsNullOrWhiteSpace(existing.PasswordHash) && !string.IsNullOrWhiteSpace(defaultPassword))
{
existing.PasswordHash = PasswordHasher.Hash(defaultPassword);
patched = true;
}
if (patched)
{
await db.SaveChangesAsync();
logger.LogInformation("Patched owner system admin credentials (username/password)");
}
}
/// <summary>Idempotent plan/feature upgrades for all environments (including production).</summary>
public static async Task EnsureCatalogUpgradesAsync(IServiceProvider services)
{
await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder");
await EnsureCatalogUpgradesAsync(db, logger);
}
/// <summary>
/// Ensures every café has at least one active branch. Idempotent.
/// Creates a default branch named after the café for any café that has none.
/// </summary>
private static async Task EnsureDefaultBranchesAsync(AppDbContext db, ILogger logger)
{
// Load café IDs that have zero branches in one query
var cafeIdsWithBranches = await db.Branches
.Where(b => b.DeletedAt == null)
.Select(b => b.CafeId)
.Distinct()
.ToListAsync();
var cafesWithoutBranch = await db.Cafes
.Where(c => c.DeletedAt == null && !cafeIdsWithBranches.Contains(c.Id))
.Select(c => new { c.Id, c.Name })
.ToListAsync();
if (cafesWithoutBranch.Count == 0) return;
foreach (var cafe in cafesWithoutBranch)
{
db.Branches.Add(new Branch
{
CafeId = cafe.Id,
Name = cafe.Name,
IsActive = true,
});
}
await db.SaveChangesAsync();
logger.LogInformation("Created default branch for {Count} café(s) that had none", cafesWithoutBranch.Count);
}
private static async Task EnsureCatalogUpgradesAsync(AppDbContext db, ILogger logger)
{
// Ensure every café has at least one branch. Cafés registered before the
// auto-branch feature was added are patched on the first boot after upgrade.
await EnsureDefaultBranchesAsync(db, logger);
var featureAdds = new[]
{
("menu_3d", "منوی سه‌بعدی", "3D menu", "growth"),
("menu_3d_ai", "تولید ۳D با هوش مصنوعی", "AI 3D menu", "growth"),
("discover_profile", "پروفایل کشف", "Discover profile", "growth")
};
var existingKeys = await db.PlatformFeatures.Select(f => f.Key).ToListAsync();
var newFeatures = featureAdds
.Where(f => !existingKeys.Contains(f.Item1))
.Select(f => F(f.Item1, f.Item2, f.Item3, f.Item4))
.ToList();
if (newFeatures.Count > 0)
{
db.PlatformFeatures.AddRange(newFeatures);
await db.SaveChangesAsync();
logger.LogInformation("Platform upgrade: added {Count} features", newFeatures.Count);
}
var plans = await db.PlatformPlanDefinitions.ToListAsync();
var changed = 0;
foreach (var plan in plans)
{
if (plan.Tier is PlanTier.Free or PlanTier.Enterprise)
continue;
var keys = plan.Tier == PlanTier.Business || plan.Tier == PlanTier.Enterprise
? new[] { "menu_3d", "menu_3d_ai", "discover_profile" }
: new[] { "menu_3d", "discover_profile" };
var merged = MergeFeaturesJson(plan.FeaturesJson ?? "[]", keys);
if (merged == plan.FeaturesJson) continue;
plan.FeaturesJson = merged;
changed++;
}
if (changed > 0)
{
await db.SaveChangesAsync();
logger.LogInformation("Platform upgrade: updated features on {Count} plans", changed);
}
await EnsureIntegrationSettingsAsync(db, logger);
}
private static string MergeFeaturesJson(string json, params string[] keys)
{
var list = JsonSerializer.Deserialize<List<string>>(json, JsonOpts) ?? [];
if (list.Contains("*"))
return json;
var updated = false;
foreach (var key in keys)
{
if (!list.Contains(key))
{
list.Add(key);
updated = true;
}
}
return updated ? JsonSerializer.Serialize(list, JsonOpts) : json;
}
private static async Task EnsureIntegrationSettingsAsync(AppDbContext db, ILogger logger)
{
var defaults = new[]
{
S("payment.activeGateway", "zarinpal", "payment", "درگاه پیش‌فرض اشتراک"),
S("payment.zarinpal.enabled", "true", "payment", "فعال زرین‌پال"),
S("payment.zarinpal.sandbox", "true", "payment", "حالت تست زرین‌پال"),
S("payment.tara.enabled", "false", "payment", "فعال تارا"),
S("payment.tara.sandbox", "true", "payment", "حالت تست تارا"),
S("payment.snapppay.enabled", "false", "payment", "فعال اسنپ‌پی"),
S("payment.snapppay.sandbox", "true", "payment", "حالت تست اسنپ‌پی"),
S("payment.nextpay.enabled", "false", "payment", "فعال نکست‌پی"),
S("payment.nextpay.sandbox", "true", "payment", "حالت تست نکست‌پی"),
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوه‌نگار"),
S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"),
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
S("integrations.meshy.enabled", "false", "integrations", "فعال Meshy"),
S("integrations.meshy.menu3d.enabled", "true", "integrations", "ساخت ۳D منو")
};
var existing = await db.PlatformSettings.Select(s => s.Key).ToListAsync();
var missing = defaults.Where(d => !existing.Contains(d.Key)).ToList();
if (missing.Count == 0) return;
db.PlatformSettings.AddRange(missing);
await db.SaveChangesAsync();
logger.LogInformation("Platform seed: added {Count} integration settings", missing.Count);
}
private static async Task SeedSystemAdminAsync(AppDbContext db, ILogger logger)
{
const string phone = "09120000001";
if (await db.SystemAdmins.AnyAsync(a => a.Phone == phone))
return;
db.SystemAdmins.Add(new SystemAdmin
{
Id = "sysadmin_demo",
Name = "مدیر سامانه",
Phone = phone,
IsActive = true
});
await db.SaveChangesAsync();
logger.LogInformation("Platform seed: system admin phone {Phone}", phone);
}
private static async Task SeedPlansAsync(AppDbContext db, ILogger logger)
{
var plans = new[]
{
new PlatformPlanDefinition
{
Id = "plan_free",
Tier = PlanTier.Free,
DisplayNameFa = "رایگان",
DisplayNameEn = "Free",
MonthlyPriceToman = 0,
IsBillableOnline = false,
SortOrder = 0,
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Free), JsonOpts),
FeaturesJson = JsonSerializer.Serialize(new[] { "pos", "menu", "tables", "qr_menu" }, JsonOpts)
},
new PlatformPlanDefinition
{
Id = "plan_pro",
Tier = PlanTier.Pro,
DisplayNameFa = "حرفه‌ای",
DisplayNameEn = "Pro",
MonthlyPriceToman = PlanPricing.MonthlyToman(PlanTier.Pro),
IsBillableOnline = true,
SortOrder = 1,
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Pro), JsonOpts),
FeaturesJson = JsonSerializer.Serialize(new[]
{
"pos", "menu", "tables", "qr_menu", "crm", "coupons", "reports", "kds", "inventory",
"menu_3d", "discover_profile"
}, JsonOpts)
},
new PlatformPlanDefinition
{
Id = "plan_business",
Tier = PlanTier.Business,
DisplayNameFa = "کسب‌وکار",
DisplayNameEn = "Business",
MonthlyPriceToman = PlanPricing.MonthlyToman(PlanTier.Business),
IsBillableOnline = true,
SortOrder = 2,
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Business), JsonOpts),
FeaturesJson = JsonSerializer.Serialize(new[]
{
"pos", "menu", "tables", "qr_menu", "crm", "coupons", "reports", "kds", "inventory",
"hr", "sms", "reservations", "delivery", "expenses", "branches",
"menu_3d", "menu_3d_ai", "discover_profile"
}, JsonOpts)
},
new PlatformPlanDefinition
{
Id = "plan_enterprise",
Tier = PlanTier.Enterprise,
DisplayNameFa = "سازمانی",
DisplayNameEn = "Enterprise",
MonthlyPriceToman = 0,
IsBillableOnline = false,
SortOrder = 3,
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Enterprise), JsonOpts),
FeaturesJson = JsonSerializer.Serialize(new[] { "*" }, JsonOpts)
}
};
// Tier (not Id) carries the unique constraint, so dedupe on Tier — an
// existing Free plan may have a different Id, and inserting another
// Free-tier row would violate IX_PlatformPlanDefinitions_Tier.
// IgnoreQueryFilters: a SOFT-DELETED plan still occupies its Tier in the
// unique index, so it must be counted or the insert collides on boot.
var existingTiers = (await db.PlatformPlanDefinitions
.IgnoreQueryFilters()
.Select(p => p.Tier)
.ToListAsync())
.ToHashSet();
var missing = plans.Where(p => !existingTiers.Contains(p.Tier)).ToArray();
if (missing.Length == 0) return;
db.PlatformPlanDefinitions.AddRange(missing);
await db.SaveChangesAsync();
logger.LogInformation("Platform seed: +{Count} subscription plans", missing.Length);
}
private static async Task SeedFeaturesAsync(AppDbContext db, ILogger logger)
{
var features = new[]
{
F("pos", "صندوق", "POS", "core"),
F("menu", "منو", "Menu", "core"),
F("tables", "میزها", "Tables", "core"),
F("qr_menu", "منوی QR", "QR menu", "core"),
F("kds", "آشپزخانه", "KDS", "operations"),
F("crm", "مشتریان", "CRM", "growth"),
F("coupons", "کوپن", "Coupons", "growth"),
F("reports", "گزارش‌ها", "Reports", "analytics"),
F("inventory", "انبار", "Inventory", "operations"),
F("hr", "منابع انسانی", "HR", "operations"),
F("sms", "پیامک", "SMS", "growth"),
F("reservations", "رزرو", "Reservations", "growth"),
F("delivery", "پذیرش آنلاین", "Delivery", "integrations"),
F("expenses", "هزینه‌ها", "Expenses", "analytics"),
F("branches", "چند شعبه", "Branches", "core"),
F("taxes", "مالیات", "Taxes", "compliance"),
F("reviews", "نظرات", "Reviews", "growth"),
F("queue", "صف", "Queue", "operations"),
F("menu_3d", "منوی سه‌بعدی", "3D menu", "growth"),
F("menu_3d_ai", "تولید ۳D با هوش مصنوعی", "AI 3D menu", "growth"),
F("discover_profile", "پروفایل کشف", "Discover profile", "growth")
};
// Key carries the unique constraint, so dedupe on Key (not Id).
// IgnoreQueryFilters so a soft-deleted feature's Key is still counted.
var existingKeys = (await db.PlatformFeatures
.IgnoreQueryFilters()
.Select(f => f.Key)
.ToListAsync())
.ToHashSet(StringComparer.Ordinal);
var missing = features.Where(f => !existingKeys.Contains(f.Key)).ToArray();
if (missing.Length == 0) return;
db.PlatformFeatures.AddRange(missing);
await db.SaveChangesAsync();
logger.LogInformation("Platform seed: +{Count} feature flags", missing.Length);
}
private static PlatformFeature F(string key, string fa, string en, string group) => new()
{
Id = $"feat_{key}",
Key = key,
DisplayNameFa = fa,
DisplayNameEn = en,
ModuleGroup = group,
IsEnabledGlobally = true
};
private static async Task SeedSettingsAsync(AppDbContext db, ILogger logger)
{
if (await db.PlatformSettings.AnyAsync())
return;
var settings = new[]
{
S("app.name", "میزی", "branding", "نام اپلیکیشن"),
S("app.tagline", "میزت منتظرته", "branding", "شعار"),
S("auth.maxOtpPerHour", "5", "auth", "حداکثر OTP در ساعت"),
S("billing.zarinpalSandbox", "true", "billing", "درگاه تست زرین‌پال"),
S("support.autoCloseDays", "14", "support", "بستن خودکار تیکت پس از روز"),
S("payment.activeGateway", "zarinpal", "payment", "درگاه فعال اشتراک"),
S("payment.zarinpal.enabled", "true", "payment", "فعال زرین‌پال"),
S("payment.zarinpal.sandbox", "true", "payment", "حالت تست زرین‌پال"),
S("payment.nextpay.enabled", "false", "payment", "فعال نکست‌پی"),
S("payment.nextpay.sandbox", "true", "payment", "حالت تست نکست‌پی"),
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوه‌نگار"),
S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"),
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
S("integrations.meshy.enabled", "false", "integrations", "فعال Meshy"),
S("integrations.meshy.menu3d.enabled", "true", "integrations", "ساخت ۳D منو")
};
db.PlatformSettings.AddRange(settings);
await db.SaveChangesAsync();
logger.LogInformation("Platform seed: {Count} platform settings", settings.Length);
}
private static PlatformSetting S(string key, string value, string category, string desc) => new()
{
Id = $"cfg_{key.Replace('.', '_')}",
Key = key,
Value = value,
Category = category,
DescriptionFa = desc
};
}