c5d5a4006a
- CanonicalPlans(): single source for Free·Starter·Pro·Business·Enterprise with the locked feature sets (Free is broad: KDS/queue/Koja/offline/reviews/reservations/ coupons/employees; Starter +watermark-removal/custom-styling/review-reply; Pro +CRM/ reports/taxes/HR/delivery/expenses/branches; Business +3D/AI-3D; Enterprise *). - Feature catalog: + offline, employees, watermark_removed, custom_menu_styling, review_reply, api, white_label. - New Starter plan (690k Toman default, billable, sort 1). - One-time, version-guarded matrix upgrade (catalog.planMatrixVersion=2): brings the existing (never-yet-admin-edited) prod plans to the canonical limits/features/order/ price and inserts Starter. Runs once; won't clobber later admin edits. - Replaced the additive feature-merge (which would wrongly re-add menu_3d to Pro). Defaults only — admins will be able to change everything in S4. 86 tests pass.
529 lines
24 KiB
C#
529 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);
|
|
}
|
|
|
|
// One-time: bring plan definitions to the current matrix (Free·Starter·Pro·
|
|
// Business·Enterprise). Existing plans were never admin-editable before this, so
|
|
// updating their limits/features/order/price to the canonical defaults is safe.
|
|
// Version-guarded so it runs once and never clobbers later admin edits.
|
|
const string matrixVersionKey = "catalog.planMatrixVersion";
|
|
const string matrixVersion = "2";
|
|
var verSetting = await db.PlatformSettings.FirstOrDefaultAsync(s => s.Key == matrixVersionKey);
|
|
if (verSetting?.Value != matrixVersion)
|
|
{
|
|
// Tier is unique across all rows (incl. soft-deleted), so at most one row per tier.
|
|
var byTier = (await db.PlatformPlanDefinitions.IgnoreQueryFilters().ToListAsync())
|
|
.ToDictionary(p => p.Tier);
|
|
foreach (var def in CanonicalPlans())
|
|
{
|
|
if (byTier.TryGetValue(def.Tier, out var ex))
|
|
{
|
|
ex.DisplayNameFa = def.DisplayNameFa;
|
|
ex.DisplayNameEn = def.DisplayNameEn;
|
|
ex.MonthlyPriceToman = def.MonthlyPriceToman;
|
|
ex.IsBillableOnline = def.IsBillableOnline;
|
|
ex.SortOrder = def.SortOrder;
|
|
ex.LimitsJson = def.LimitsJson;
|
|
ex.FeaturesJson = def.FeaturesJson;
|
|
ex.DeletedAt = null; // ensure all five plans are active
|
|
}
|
|
else
|
|
{
|
|
db.PlatformPlanDefinitions.Add(def);
|
|
}
|
|
}
|
|
if (verSetting is null)
|
|
db.PlatformSettings.Add(S(matrixVersionKey, matrixVersion, "catalog", "نسخه ماتریس پلنها"));
|
|
else
|
|
verSetting.Value = matrixVersion;
|
|
await db.SaveChangesAsync();
|
|
logger.LogInformation("Platform upgrade: applied plan matrix v{Version}", matrixVersion);
|
|
}
|
|
|
|
await EnsureIntegrationSettingsAsync(db, logger);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// ── Canonical plan matrix (Free·Starter·Pro·Business·Enterprise) ─────────────
|
|
// Single source of plan DEFAULTS. Free features are broad (KDS, queue, Koja,
|
|
// offline, reviews, reservations, coupons, employees); paid tiers add the rest.
|
|
private static readonly string[] FreeFeatures =
|
|
{
|
|
"pos", "menu", "tables", "qr_menu", "kds", "queue", "inventory",
|
|
"reservations", "reviews", "coupons", "discover_profile", "offline", "employees"
|
|
};
|
|
private static readonly string[] StarterFeatures =
|
|
FreeFeatures.Concat(new[] { "watermark_removed", "custom_menu_styling", "review_reply" }).ToArray();
|
|
private static readonly string[] ProFeatures =
|
|
StarterFeatures.Concat(new[] { "crm", "reports", "taxes", "hr", "delivery", "expenses", "branches" }).ToArray();
|
|
private static readonly string[] BusinessFeatures =
|
|
ProFeatures.Concat(new[] { "menu_3d", "menu_3d_ai" }).ToArray();
|
|
|
|
private static PlatformPlanDefinition Plan(
|
|
string id, PlanTier tier, string fa, string en, decimal price, bool billable, int sort, string[] features) => new()
|
|
{
|
|
Id = id,
|
|
Tier = tier,
|
|
DisplayNameFa = fa,
|
|
DisplayNameEn = en,
|
|
MonthlyPriceToman = price,
|
|
IsBillableOnline = billable,
|
|
SortOrder = sort,
|
|
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(tier), JsonOpts),
|
|
FeaturesJson = JsonSerializer.Serialize(features, JsonOpts)
|
|
};
|
|
|
|
private static PlatformPlanDefinition[] CanonicalPlans() =>
|
|
[
|
|
Plan("plan_free", PlanTier.Free, "رایگان", "Free", 0, false, 0, FreeFeatures),
|
|
Plan("plan_starter", PlanTier.Starter, "پایه", "Starter", 690_000, true, 1, StarterFeatures),
|
|
Plan("plan_pro", PlanTier.Pro, "حرفهای", "Pro", 1_490_000, true, 2, ProFeatures),
|
|
Plan("plan_business", PlanTier.Business, "کسبوکار", "Business", 3_490_000, true, 3, BusinessFeatures),
|
|
Plan("plan_enterprise", PlanTier.Enterprise, "سازمانی", "Enterprise", 0, false, 4, new[] { "*" }),
|
|
];
|
|
|
|
private static async Task SeedPlansAsync(AppDbContext db, ILogger logger)
|
|
{
|
|
var plans = CanonicalPlans();
|
|
|
|
// 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"),
|
|
F("offline", "حالت آفلاین", "Offline mode", "core"),
|
|
F("employees", "کارکنان", "Employees", "operations"),
|
|
F("watermark_removed", "حذف واترمارک منو", "Remove menu watermark", "growth"),
|
|
F("custom_menu_styling", "طراحی اختصاصی منو", "Custom menu styling", "growth"),
|
|
F("review_reply", "پاسخ به نظرات", "Reply to reviews", "growth"),
|
|
F("api", "API عمومی", "Public API", "integrations"),
|
|
F("white_label", "وایتلیبل", "White-label", "integrations")
|
|
};
|
|
|
|
// 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
|
|
};
|
|
}
|