feat(api): .NET 10 multi-tenant REST API

Full backend implementation:
- Multi-tenant cafe/restaurant management (menus, orders, tables, staff)
- POS order flow with ZarinPal and Snappfood payment integration
- OTP authentication via Kavenegar SMS
- QR digital menu with public discover/finder endpoints
- Customer loyalty, coupons, CRM
- PostgreSQL via EF Core, Redis for caching/sessions
- Background jobs, webhook handlers
- Full migration history

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-27 21:33:48 +03:30
parent 03376b3ea1
commit ef15fd6247
472 changed files with 120358 additions and 0 deletions
@@ -0,0 +1,320 @@
using System.Text.Json;
using Meezi.Core.Constants;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Platform;
using Microsoft.EntityFrameworkCore;
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>();
if (!env.IsDevelopment())
return;
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder");
await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await EnsureCatalogUpgradesAsync(db, logger);
if (!env.IsDevelopment())
return;
await SeedSystemAdminAsync(db, logger);
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
await SeedSettingsAsync(db, logger);
await EnsureIntegrationSettingsAsync(db, logger);
}
/// <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);
}
private static async Task EnsureCatalogUpgradesAsync(AppDbContext db, ILogger 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", "verify", "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)
{
if (await db.PlatformPlanDefinitions.AnyAsync())
return;
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)
}
};
db.PlatformPlanDefinitions.AddRange(plans);
await db.SaveChangesAsync();
logger.LogInformation("Platform seed: {Count} subscription plans", plans.Length);
}
private static async Task SeedFeaturesAsync(AppDbContext db, ILogger logger)
{
if (await db.PlatformFeatures.AnyAsync())
return;
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")
};
db.PlatformFeatures.AddRange(features);
await db.SaveChangesAsync();
logger.LogInformation("Platform seed: {Count} feature flags", features.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", "verify", "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
};
}