Files
meezi/src/Meezi.Infrastructure/Data/PlatformDataSeeder.cs
T
soroush.asadi c5d5a4006a feat(plans): Stage 2 — seed 5-tier matrix + feature catalog (editable defaults)
- 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.
2026-06-03 00:53:02 +03:30

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
};
}