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,89 @@
using System.Threading.RateLimiting;
using Meezi.API.Security;
using Microsoft.AspNetCore.RateLimiting;
namespace Meezi.API.Extensions;
public static class SecurityExtensions
{
public static IServiceCollection AddMeeziSecurity(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<AbuseProtectionOptions>(configuration.GetSection(AbuseProtectionOptions.SectionName));
services.AddHttpClient("turnstile");
services.AddSingleton<IAbuseProtectionService, AbuseProtectionService>();
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.ContentType = "application/json";
await context.HttpContext.Response.WriteAsJsonAsync(
new
{
success = false,
error = new
{
code = "RATE_LIMITED",
message = "Too many requests. Please wait and try again."
}
},
token);
};
options.AddPolicy("public-read", httpContext =>
{
var ip = ClientIpResolver.GetClientIp(httpContext);
var limit = configuration.GetValue("Security:RateLimits:PublicReadsPerIpPerMinute", 120);
return RateLimitPartition.GetSlidingWindowLimiter(
$"read:{ip}",
_ => new SlidingWindowRateLimiterOptions
{
PermitLimit = limit,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 4,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
});
options.AddPolicy("public-write", httpContext =>
{
var ip = ClientIpResolver.GetClientIp(httpContext);
var limit = configuration.GetValue("Security:RateLimits:PublicWritesPerIpPerMinute", 20);
return RateLimitPartition.GetSlidingWindowLimiter(
$"write:{ip}",
_ => new SlidingWindowRateLimiterOptions
{
PermitLimit = limit,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 4,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
});
options.AddPolicy("auth-otp", httpContext =>
{
var ip = ClientIpResolver.GetClientIp(httpContext);
var limit = configuration.GetValue("Security:RateLimits:AuthOtpPerIpPerHour", 15);
return RateLimitPartition.GetFixedWindowLimiter(
$"otp:{ip}",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = limit,
Window = TimeSpan.FromHours(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
});
});
return services;
}
public static void UseMeeziSecurity(this WebApplication app)
{
app.UseRateLimiter();
}
}
@@ -0,0 +1,246 @@
using System.Text;
using System.Text.Json.Serialization;
using FluentValidation;
using Hangfire;
using Hangfire.MemoryStorage;
using Hangfire.PostgreSql;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Meezi.API.Hubs;
using Meezi.API.Configuration;
using Meezi.API.Jobs;
using Meezi.API.Services;
using Meezi.API.Services.Delivery;
using Meezi.Infrastructure.Services.Platform;
using Meezi.API.Services.Printing;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure;
using Serilog;
using StackExchange.Redis;
namespace Meezi.API.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMeeziServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddHttpContextAccessor();
services.AddMeeziSecurity(configuration);
services.AddInfrastructure(configuration);
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IConsumerAuthService, ConsumerAuthService>();
services.AddScoped<IConsumerOrdersService, ConsumerOrdersService>();
services.AddScoped<IKitchenStationService, KitchenStationService>();
services.AddScoped<INotificationInboxService, NotificationInboxService>();
services.AddScoped<IOrderNotificationService, OrderNotificationService>();
services.AddScoped<IWebsiteService, Meezi.Infrastructure.Services.WebsiteService>();
services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddSingleton<IRefreshTokenStore, RedisRefreshTokenStore>();
services.AddScoped<IPlanLimitChecker, PlanLimitChecker>();
services.AddScoped<IMenuService, MenuService>();
services.AddScoped<IBranchMenuService, BranchMenuService>();
services.AddScoped<IBranchIdentityService, BranchIdentityService>();
services.AddScoped<ITableService, TableService>();
services.AddScoped<IOrderService, OrderService>();
services.AddScoped<IKdsNotifier, KdsNotifier>();
services.AddScoped<ICustomerService, CustomerService>();
services.AddScoped<ICouponService, CouponService>();
services.AddScoped<ITaxService, TaxService>();
services.AddSingleton<IMediaStorageService, MediaStorageService>();
services.Configure<MenuAi3dOptions>(configuration.GetSection(MenuAi3dOptions.SectionName));
services.AddHttpClient("MenuAi3d");
services.AddHttpClient("OpenAi");
services.AddScoped<IOpenAiChatService, OpenAiChatService>();
services.AddScoped<ICoffeeAdvisorService, CoffeeAdvisorService>();
services.AddScoped<IMenuAi3dGenerationService, MenuAi3dGenerationService>();
services.AddScoped<IBranchLifecycleService, BranchLifecycleService>();
services.AddSingleton<ITerminalRegistryService, TerminalRegistryService>();
services.AddScoped<IInventoryService, InventoryService>();
services.AddScoped<ILoyaltyService, LoyaltyService>();
services.AddScoped<ISmsMarketingService, SmsMarketingService>();
services.AddScoped<IHrService, HrService>();
services.AddScoped<IReportService, ReportService>();
services.AddScoped<IDailyReportService, DailyReportService>();
services.AddScoped<IPublicService, PublicService>();
services.AddScoped<IReviewService, ReviewService>();
services.AddScoped<IBillingService, BillingService>();
services.AddScoped<IBillingPaymentOrchestrator, BillingPaymentOrchestrator>();
services.Configure<DeliveryPlatformsOptions>(configuration.GetSection(DeliveryPlatformsOptions.SectionName));
services.PostConfigure<DeliveryPlatformsOptions>(opts =>
{
if (string.IsNullOrEmpty(opts.Snappfood.WebhookSecret))
opts.Snappfood.WebhookSecret = configuration["Snappfood:WebhookSecret"] ?? "";
if (string.IsNullOrEmpty(opts.Snappfood.ApiKey))
opts.Snappfood.ApiKey = configuration["Snappfood:ApiKey"] ?? "";
if (string.IsNullOrEmpty(opts.Snappfood.ApiBaseUrl))
opts.Snappfood.ApiBaseUrl = configuration["Snappfood:ApiBaseUrl"] ?? "";
});
services.AddScoped<IWebhookSignatureService, WebhookSignatureService>();
services.AddScoped<IOrderNormalizer, OrderNormalizer>();
services.AddScoped<ICommissionCalculator, CommissionCalculator>();
services.AddScoped<IDeliveryOrderProcessor, DeliveryOrderProcessor>();
services.AddScoped<IDeliveryWebhookIngressService, DeliveryWebhookIngressService>();
services.AddScoped<IDeliveryStatusSyncService, DeliveryStatusSyncService>();
services.AddScoped<IDeliveryFinanceReportService, DeliveryFinanceReportService>();
services.AddScoped<ProcessDeliveryOrderJob>();
services.AddScoped<ISnappfoodWebhookService, SnappfoodWebhookService>();
services.AddScoped<IReservationService, ReservationService>();
services.AddScoped<IQueueService, QueueService>();
services.AddScoped<IShiftService, ShiftService>();
services.AddScoped<IExpenseService, ExpenseService>();
services.AddScoped<ReceiptBuilder>();
services.AddScoped<IPrinterService, NetworkPrinterService>();
services.AddHttpClient(nameof(PosDeviceService));
services.AddScoped<IPosDeviceService, PosDeviceService>();
services.AddScoped<SubscriptionRenewalReminderJob>();
services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
services.AddEndpointsApiExplorer();
services.AddSwaggerGen();
services.AddSignalR();
services.AddValidatorsFromAssemblyContaining<Program>();
var jwtKey = configuration["Jwt:Key"] ?? "meezi-dev-secret-key-min-32-chars!!";
var jwtIssuer = configuration["Jwt:Issuer"] ?? "meezi";
var jwtAudience = configuration["Jwt:Audience"] ?? "meezi";
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
context.Token = accessToken;
return Task.CompletedTask;
}
};
});
services.AddAuthorization();
var isTesting = configuration.GetValue<bool>("Testing:Enabled");
var redisConnection = configuration.GetConnectionString("Redis") ?? "localhost:6379";
if (isTesting)
{
services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect($"{redisConnection},abortConnect=false,connectTimeout=500"));
}
else
{
services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(redisConnection));
}
var connectionString = configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
services.AddHangfire(config =>
{
config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings();
if (isTesting)
config.UseMemoryStorage();
else
config.UsePostgreSqlStorage(options => options.UseNpgsqlConnection(connectionString));
});
if (!isTesting)
services.AddHangfireServer();
services.AddCors(options =>
{
options.AddPolicy("MeeziCors", policy =>
{
var origins = configuration.GetSection("Cors:Origins").Get<string[]>()
?? ["http://localhost:3000"];
policy.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
return services;
}
public static WebApplication ConfigureMeeziPipeline(this WebApplication app)
{
app.UseSerilogRequestLogging();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
var uploadsPath = Path.Combine(app.Environment.ContentRootPath, "uploads");
Directory.CreateDirectory(uploadsPath);
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsPath),
RequestPath = "/uploads"
});
app.UseCors("MeeziCors");
app.UseRouting();
app.UseMeeziSecurity();
app.UseAuthentication();
app.UseMiddleware<Middleware.TenantMiddleware>();
app.UseMiddleware<Middleware.PlanLimitMiddleware>();
app.UseAuthorization();
app.MapControllers();
app.MapHub<KdsHub>("/hubs/kds");
app.MapHub<GuestOrderHub>("/hubs/guest-order");
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
if (!app.Configuration.GetValue<bool>("Testing:Enabled"))
{
app.UseHangfireDashboard("/hangfire");
RecurringJob.AddOrUpdate<SubscriptionRenewalReminderJob>(
"subscription-renewal-reminder",
job => job.ExecuteAsync(),
Cron.Daily(6));
RecurringJob.AddOrUpdate<GenerateYesterdayReportsJob>(
"daily-reports-yesterday",
job => job.ExecuteAsync(),
"5 0 * * *",
new RecurringJobOptions { TimeZone = IranCalendar.TimeZone });
RecurringJob.AddOrUpdate<BranchPermanentDeleteJob>(
"branch-permanent-delete",
job => job.ExecuteAsync(),
Cron.Hourly);
}
return app;
}
}