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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user