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
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>
546 lines
24 KiB
C#
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
|
|
};
|
|
}
|