using System.Threading.RateLimiting; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Repositories; using PowderCoating.Infrastructure.Services; using PowderCoating.Application.Interfaces; using PowderCoating.Application.Services; using PowderCoating.Application.Configuration; using PowderCoating.Shared.Constants; using PowderCoating.Web.BackgroundServices; using Serilog; using Serilog.Sinks.MSSqlServer; using Serilog.Sinks.ApplicationInsights.TelemetryConverters; using AutoMapper; using PowderCoating.Application.Mappings; var builder = WebApplication.CreateBuilder(args); // Bootstrap logger — console only, used during startup before connection string is available. // Replaced below with the full logger (files + DB sink) once the connection string is known. // On Azure App Service the content root is read-only; use the writable HOME/LogFiles path instead. var isAzure = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME")); var logsPath = isAzure ? Path.Combine(Environment.GetEnvironmentVariable("HOME")!, "LogFiles", "Application") : Path.Combine(builder.Environment.ContentRootPath, "logs"); Directory.CreateDirectory(logsPath); // Route Serilog internal errors (e.g. SQL sink failures) to stderr — never crash the app. Serilog.Debugging.SelfLog.Enable(Console.Error); Log.Logger = new LoggerConfiguration() .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") .CreateLogger(); builder.Host.UseSerilog(); // Log startup information Log.Information("Starting PowderCoating.Web application"); Log.Information("Environment: {Environment}", builder.Environment.EnvironmentName); Log.Information("Logs directory: {LogsPath}", logsPath); // Add services to the container var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); // Re-configure Serilog now that the connection string is available — add DB sink for Warning+ Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(builder.Configuration) .Enrich.FromLogContext() .Enrich.WithProperty("Environment", builder.Environment.EnvironmentName) .Enrich.WithProperty("Application", "PowderCoating.Web") .WriteTo.Console( outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}") .WriteTo.File( path: Path.Combine(logsPath, "powdercoating-.txt"), rollingInterval: RollingInterval.Day, retainedFileCountLimit: 30, outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{SourceContext}] {Message:lj} {Properties:j}{NewLine}{Exception}", shared: true, flushToDiskInterval: TimeSpan.FromSeconds(1)) .WriteTo.File( path: Path.Combine(logsPath, "errors-.txt"), rollingInterval: RollingInterval.Day, retainedFileCountLimit: 90, restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Error, outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{SourceContext}] {Message:lj} {Properties:j}{NewLine}{Exception}", shared: true, flushToDiskInterval: TimeSpan.FromSeconds(1)) // SQL sink disabled — Azure SQL connection resets on Linux cause sink instability. //.WriteTo.MSSqlServer(...) .CreateLogger(); // Application Insights sink — production only, requires ApplicationInsights:ConnectionString in config. // Writes Warning+ structured logs to Azure Monitor. The in-app SystemLogsController queries the same // workspace via LogsQueryClient so SuperAdmins can view logs without opening the Azure portal. var aiConnectionString = builder.Configuration["ApplicationInsights:ConnectionString"]; if (builder.Environment.IsProduction() && !string.IsNullOrWhiteSpace(aiConnectionString)) { var telemetryConfig = new Microsoft.ApplicationInsights.Extensibility.TelemetryConfiguration { ConnectionString = aiConnectionString }; Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(builder.Configuration) .Enrich.FromLogContext() .Enrich.WithProperty("Environment", builder.Environment.EnvironmentName) .Enrich.WithProperty("Application", "PowderCoating.Web") .WriteTo.Console( outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}") .WriteTo.File( path: Path.Combine(logsPath, "powdercoating-.txt"), rollingInterval: RollingInterval.Day, retainedFileCountLimit: 30, outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{SourceContext}] {Message:lj} {Properties:j}{NewLine}{Exception}", shared: true, flushToDiskInterval: TimeSpan.FromSeconds(1)) .WriteTo.File( path: Path.Combine(logsPath, "errors-.txt"), rollingInterval: RollingInterval.Day, retainedFileCountLimit: 90, restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Error, outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{SourceContext}] {Message:lj} {Properties:j}{NewLine}{Exception}", shared: true, flushToDiskInterval: TimeSpan.FromSeconds(1)) .WriteTo.ApplicationInsights( telemetryConfig, TelemetryConverter.Traces, restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Warning) .CreateLogger(); Log.Information("Application Insights sink enabled."); } builder.Services.AddSingleton(); builder.Services.AddDbContext((sp, options) => { options.UseSqlServer(connectionString, sqlOptions => { sqlOptions.EnableRetryOnFailure( maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(10), errorNumbersToAdd: null); }); options.AddInterceptors(sp.GetRequiredService()); }); builder.Services.AddDatabaseDeveloperPageExceptionFilter(); // Configure Data Protection // Production: keys stored in Azure Blob Storage (survives restarts and scales across instances) // Development: keys stored on local filesystem if (builder.Environment.IsProduction()) { var storageConnectionString = builder.Configuration["Storage:ConnectionString"] ?? throw new InvalidOperationException("Storage:ConnectionString is required in production."); builder.Services.AddDataProtection() .PersistKeysToAzureBlobStorage(storageConnectionString, "dataprotection", "keys.xml") .SetApplicationName("PowderCoatingApp"); } else { var keysPath = Path.Combine(builder.Environment.ContentRootPath, "DataProtection-Keys"); builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(keysPath)) .SetApplicationName("PowderCoatingApp"); } // Configure Identity builder.Services.AddIdentity(options => { options.SignIn.RequireConfirmedAccount = false; // Strong password policy options.Password.RequireDigit = true; options.Password.RequireLowercase = true; options.Password.RequireUppercase = true; options.Password.RequireNonAlphanumeric = true; // SECURITY: Require special characters options.Password.RequiredLength = 12; // SECURITY: Increased from 8 to 12 options.Password.RequiredUniqueChars = 4; // SECURITY: Require variety options.User.RequireUniqueEmail = true; // Account lockout for brute force protection options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.AllowedForNewUsers = true; }) .AddEntityFrameworkStores() .AddDefaultTokenProviders() .AddDefaultUI() .AddClaimsPrincipalFactory(); // Register HttpContextAccessor for multi-tenancy builder.Services.AddHttpContextAccessor(); // Register multi-tenancy services builder.Services.AddScoped(); // Register repositories and services builder.Services.AddScoped(); builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); builder.Services.AddScoped(); // Azure Blob Storage configuration builder.Services.Configure(builder.Configuration.GetSection("Storage")); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Register caching service (singleton for better performance) builder.Services.AddSingleton(); // Online user presence tracker (singleton in-memory store) builder.Services.AddSingleton(); builder.Services.AddHealthChecks(); // Register lookup cache service (scoped because it depends on IUnitOfWork) builder.Services.AddScoped(); // Configure AutoMapper builder.Services.AddSingleton(sp => { var loggerFactory = sp.GetRequiredService(); var config = new MapperConfiguration(cfg => { cfg.AddProfile(new CompanyProfile()); cfg.AddProfile(new CustomerProfile()); cfg.AddProfile(new JobProfile()); cfg.AddProfile(new QuoteProfile()); cfg.AddProfile(new InventoryProfile()); cfg.AddProfile(new EquipmentProfile()); cfg.AddProfile(new MaintenanceProfile()); cfg.AddProfile(new ShopWorkerProfile()); cfg.AddProfile(new CatalogProfile()); cfg.AddProfile(new VendorProfile()); cfg.AddProfile(new LookupProfile()); cfg.AddProfile(new AppointmentProfile()); cfg.AddProfile(new BugReportProfile()); cfg.AddProfile(new InvoiceProfile()); cfg.AddProfile(new AccountingProfile()); cfg.AddProfile(new PurchaseOrderProfile()); cfg.AddProfile(new PricingTierProfile()); }, loggerFactory); return config.CreateMapper(); }); // Add SignalR for real-time updates builder.Services.AddSignalR(); // Add session support builder.Services.AddSession(options => { options.IdleTimeout = TimeSpan.FromMinutes(30); options.Cookie.HttpOnly = true; // Prevent JavaScript access options.Cookie.IsEssential = true; options.Cookie.SecurePolicy = builder.Environment.IsProduction() ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest; //options.Cookie.SecurePolicy = CookieSecurePolicy.Always; // SECURITY: Require HTTPS options.Cookie.SameSite = SameSiteMode.Lax; // Lax required so the session cookie survives cross-origin redirects (e.g. Stripe → PaymentSuccess) options.Cookie.Name = ".PowderCoating.Session"; // Custom name (less predictable) }); // Add memory cache builder.Services.AddMemoryCache(); // Configure authorization policies for multi-tenancy builder.Services.AddAuthorization(options => { // System-level policies options.AddPolicy("SuperAdminOnly", policy => policy.RequireRole(AppConstants.Roles.SuperAdmin)); // Company-level policies options.AddPolicy("CompanyAdminOnly", policy => policy.RequireAssertion(context => { var user = context.User; var companyRole = user.FindFirst("CompanyRole")?.Value; return companyRole == AppConstants.CompanyRoles.CompanyAdmin || user.IsInRole(AppConstants.Roles.SuperAdmin); })); options.AddPolicy("CanManageJobs", policy => policy.RequireAssertion(context => { var user = context.User; // SuperAdmins always have access if (user.IsInRole(AppConstants.Roles.SuperAdmin)) return true; // Check for specific permission claim return user.HasClaim("Permission", "ManageJobs"); })); options.AddPolicy("CanManageInventory", policy => policy.RequireAssertion(context => { var user = context.User; if (user.IsInRole(AppConstants.Roles.SuperAdmin)) return true; return user.HasClaim("Permission", "ManageInventory"); })); options.AddPolicy("CanManageCustomers", policy => policy.RequireAssertion(context => { var user = context.User; if (user.IsInRole(AppConstants.Roles.SuperAdmin)) return true; return user.HasClaim("Permission", "ManageCustomers"); })); options.AddPolicy("CanCreateQuotes", policy => policy.RequireAssertion(context => { var user = context.User; if (user.IsInRole(AppConstants.Roles.SuperAdmin)) return true; return user.HasClaim("Permission", "CreateQuotes"); })); options.AddPolicy("CanManageCalendar", policy => policy.RequireAssertion(context => { var user = context.User; if (user.IsInRole(AppConstants.Roles.SuperAdmin)) return true; return user.HasClaim("Permission", "ManageCalendar"); })); options.AddPolicy("CanViewCalendar", policy => policy.RequireAssertion(context => { var user = context.User; if (user.IsInRole(AppConstants.Roles.SuperAdmin)) return true; return user.HasClaim("Permission", "ViewCalendar"); })); options.AddPolicy("CanManageProducts", policy => policy.RequireAssertion(context => { var user = context.User; if (user.IsInRole(AppConstants.Roles.SuperAdmin)) return true; return user.HasClaim("Permission", "ManageProducts"); })); options.AddPolicy("CanViewProducts", policy => policy.RequireAssertion(context => { var user = context.User; if (user.IsInRole(AppConstants.Roles.SuperAdmin)) return true; return user.HasClaim("Permission", "ViewProducts"); })); options.AddPolicy("CanManageEquipment", policy => policy.RequireAssertion(context => { var user = context.User; if (user.IsInRole(AppConstants.Roles.SuperAdmin)) return true; return user.HasClaim("Permission", "ManageEquipment"); })); options.AddPolicy("CanManageVendors", policy => policy.RequireAssertion(context => { var user = context.User; if (user.IsInRole(AppConstants.Roles.SuperAdmin)) return true; return user.HasClaim("Permission", "ManageVendors"); })); options.AddPolicy("CanManagePurchaseOrders", policy => policy.RequireAssertion(context => { var user = context.User; if (user.IsInRole(AppConstants.Roles.SuperAdmin)) return true; var companyRole = user.FindFirst("CompanyRole")?.Value; if (companyRole == AppConstants.CompanyRoles.CompanyAdmin || companyRole == AppConstants.CompanyRoles.Manager) return true; return user.HasClaim("Permission", "ManageInventory") || user.HasClaim("Permission", "ManageVendors"); })); options.AddPolicy("CanManageMaintenance", policy => policy.RequireAssertion(context => { var user = context.User; if (user.IsInRole(AppConstants.Roles.SuperAdmin)) return true; return user.HasClaim("Permission", "ManageMaintenance"); })); options.AddPolicy("CanManageInvoices", policy => policy.RequireAssertion(context => { var user = context.User; if (user.IsInRole(AppConstants.Roles.SuperAdmin)) return true; var companyRole = user.FindFirst("CompanyRole")?.Value; if (companyRole == AppConstants.CompanyRoles.CompanyAdmin || companyRole == AppConstants.CompanyRoles.Manager) return true; return user.HasClaim("Permission", "ManageInvoices") || user.HasClaim("Permission", "ManageJobs"); })); options.AddPolicy("CanViewReports", policy => policy.RequireAssertion(context => { var user = context.User; if (user.IsInRole(AppConstants.Roles.SuperAdmin)) return true; var companyRole = user.FindFirst("CompanyRole")?.Value; if (companyRole == AppConstants.CompanyRoles.CompanyAdmin || companyRole == AppConstants.CompanyRoles.Manager) return true; return user.HasClaim("Permission", "ViewReports"); })); options.AddPolicy("CanManageUsers", policy => policy.RequireAssertion(context => { var user = context.User; var companyRole = user.FindFirst("CompanyRole")?.Value; return companyRole == AppConstants.CompanyRoles.CompanyAdmin || user.IsInRole(AppConstants.Roles.SuperAdmin); })); options.AddPolicy("CanViewData", policy => policy.RequireAssertion(context => { // All authenticated company users can view return context.User.Identity?.IsAuthenticated == true; })); }); // ── Rate Limiting ───────────────────────────────────────────────────────────── // Per-IP partitioned policies using RateLimitPartition (ASP.NET Core 8 built-in). // auth — login / password-reset: 10 req/min per IP // registration — new account sign-up: 5 req/5 min per IP // public — payment portal, quote approval: 30 req/min per IP // webhook — Stripe/Twilio server-to-server: 200 req/min per IP // ai — AI endpoints: 15 req/min per user (cost protection) // authenticated— all logged-in users: 300 req/min per user builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; // login / password-reset — 10 per minute per IP options.AddPolicy(AppConstants.RateLimitPolicies.Auth, ctx => RateLimitPartition.GetSlidingWindowLimiter( ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new SlidingWindowRateLimiterOptions { Window = TimeSpan.FromMinutes(1), SegmentsPerWindow = 4, PermitLimit = 10, QueueLimit = 0, AutoReplenishment = true })); // new account registration — 5 per 5 minutes per IP options.AddPolicy(AppConstants.RateLimitPolicies.Registration, ctx => RateLimitPartition.GetFixedWindowLimiter( ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { Window = TimeSpan.FromMinutes(5), PermitLimit = 5, QueueLimit = 0, AutoReplenishment = true })); // payment portal + quote approval (anonymous) — 30 per minute per IP options.AddPolicy(AppConstants.RateLimitPolicies.Public, ctx => RateLimitPartition.GetSlidingWindowLimiter( ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new SlidingWindowRateLimiterOptions { Window = TimeSpan.FromMinutes(1), SegmentsPerWindow = 4, PermitLimit = 30, QueueLimit = 0, AutoReplenishment = true })); // Stripe/Twilio webhooks — 200 per minute per IP options.AddPolicy(AppConstants.RateLimitPolicies.Webhook, ctx => RateLimitPartition.GetFixedWindowLimiter( ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { Window = TimeSpan.FromMinutes(1), PermitLimit = 200, QueueLimit = 0, AutoReplenishment = true })); // AI endpoints — 15 per minute per user/IP options.AddPolicy(AppConstants.RateLimitPolicies.Ai, ctx => { var key = ctx.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; return RateLimitPartition.GetSlidingWindowLimiter(key, _ => new SlidingWindowRateLimiterOptions { Window = TimeSpan.FromMinutes(1), SegmentsPerWindow = 4, PermitLimit = 15, QueueLimit = 0, AutoReplenishment = true }); }); // Authenticated users — lenient ceiling options.AddPolicy(AppConstants.RateLimitPolicies.Authenticated, ctx => { var key = ctx.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; return RateLimitPartition.GetSlidingWindowLimiter(key, _ => new SlidingWindowRateLimiterOptions { Window = TimeSpan.FromMinutes(1), SegmentsPerWindow = 4, PermitLimit = 300, QueueLimit = 0, AutoReplenishment = true }); }); }); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddControllersWithViews(options => { options.Filters.AddService(); options.Filters.AddService(); }); builder.Services.AddRazorPages(); var app = builder.Build(); // Force en-US culture so currency/number formatting is consistent on Linux hosting var cultureInfo = new System.Globalization.CultureInfo("en-US"); System.Globalization.CultureInfo.DefaultThreadCurrentCulture = cultureInfo; System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = cultureInfo; // SECURITY: Add security headers middleware app.Use(async (context, next) => { // Prevent clickjacking context.Response.Headers.Append("X-Frame-Options", "DENY"); // Prevent MIME type sniffing context.Response.Headers.Append("X-Content-Type-Options", "nosniff"); // Enable XSS protection (for older browsers) context.Response.Headers.Append("X-XSS-Protection", "1; mode=block"); // Strict Transport Security (HSTS) — always emit; browsers ignore it over plain HTTP anyway context.Response.Headers.Append("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); // Content Security Policy — development allows unsafe-eval for hot-reload; production does not var cspScriptSrc = app.Environment.IsDevelopment() ? "'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://code.jquery.com https://js.stripe.com" : "'self' 'unsafe-inline' https://cdn.jsdelivr.net https://code.jquery.com https://js.stripe.com"; var cspConnectSrc = app.Environment.IsDevelopment() ? "'self' wss://localhost:* https://cdn.jsdelivr.net https://api.stripe.com" // Allow hot reload WebSocket in dev : "'self' https://cdn.jsdelivr.net https://api.stripe.com"; context.Response.Headers.Append("Content-Security-Policy", "default-src 'self'; " + $"script-src {cspScriptSrc}; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " + "img-src 'self' data: https:; " + $"connect-src {cspConnectSrc}; " + "frame-src https://js.stripe.com https://hooks.stripe.com"); // Referrer Policy - control referrer information context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin"); // Permissions Policy - disable unnecessary browser features context.Response.Headers.Append("Permissions-Policy", "geolocation=(), microphone=(), camera=()"); await next(); }); // Add Serilog request logging // 4xx and 5xx responses are elevated to Warning/Error so they clear the Warning+ AI sink filter. // Successful requests (< 400) remain at Information and are not sent to Application Insights, // keeping ingestion volume (and cost) low. app.UseSerilogRequestLogging(options => { options.GetLevel = (httpContext, elapsed, ex) => { if (ex != null || httpContext.Response.StatusCode >= 500) return Serilog.Events.LogEventLevel.Error; if (httpContext.Response.StatusCode >= 400) return Serilog.Events.LogEventLevel.Warning; return Serilog.Events.LogEventLevel.Information; }; options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => { diagnosticContext.Set("UserName", httpContext.User.Identity?.Name ?? "Anonymous"); diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].ToString()); diagnosticContext.Set("RemoteIP", httpContext.Connection.RemoteIpAddress?.ToString()); var companyId = httpContext.User.FindFirst("CompanyId")?.Value; if (!string.IsNullOrEmpty(companyId)) diagnosticContext.Set("CompanyId", companyId); }; }); // Configure the HTTP request pipeline // Forwarded headers must be first — Azure App Service (and any reverse proxy) sends // X-Forwarded-Proto so Request.IsHttps and scheme detection work correctly. app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, KnownNetworks = { }, KnownProxies = { } }); if (app.Environment.IsDevelopment()) { app.UseMigrationsEndPoint(); } else { app.UseExceptionHandler("/Home/Error"); app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); // Stamp LastLoginDate for any sign-in path not covered by explicit stamps (e.g. LoginWith2fa). app.UseMiddleware(); // Subscription enforcement middleware (after auth, before session) app.UseMiddleware(); app.UseSession(); // Force first-login password change before any other page app.UseMiddleware(); // Track authenticated user presence (throttled, in-memory) app.UseMiddleware(); app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); app.MapRazorPages(); // Map SignalR hubs app.MapHub("/hubs/notifications"); app.MapHub("/hubs/shop"); app.MapHealthChecks("/health"); // NOTE: Automatic seeding has been disabled. // SuperAdmins can manually seed data via Platform Management > Seed Data // To re-enable automatic seeding, uncomment the code below: /* // Seed database with initial data using (var scope = app.Services.CreateScope()) { var services = scope.ServiceProvider; try { var context = services.GetRequiredService(); var userManager = services.GetRequiredService>(); var roleManager = services.GetRequiredService>(); // Seed roles and admin user await SeedData.InitializeAsync(services, userManager, roleManager); } catch (Exception ex) { var logger = services.GetRequiredService>(); logger.LogError(ex, "An error occurred while seeding the database."); } } */ // Auto-apply pending EF migrations disabled — migrations are applied manually via SQL script before deployment. // if (!app.Environment.IsDevelopment()) // { // using var scope = app.Services.CreateScope(); // try // { // var db = scope.ServiceProvider.GetRequiredService(); // var pending = db.Database.GetPendingMigrations().ToList(); // if (pending.Count > 0) // { // Log.Information("Applying {Count} pending migration(s): {Migrations}", pending.Count, string.Join(", ", pending)); // db.Database.Migrate(); // Log.Information("Database migrations applied successfully"); // } // } // catch (Exception ex) // { // Log.Fatal(ex, "Failed to apply database migrations — application cannot start"); // throw; // } // } // Auto-seed system data (roles + default company + SuperAdmin users) on first run. // Runs whenever the AspNetUsers table is empty, regardless of environment. // All seed methods are idempotent so this is safe to leave enabled permanently. using (var scope = app.Services.CreateScope()) { try { var userManager = scope.ServiceProvider.GetRequiredService>(); var userCount = await userManager.Users.CountAsync(); if (userCount == 0) { Log.Information("No users found — running first-run system data seed"); var seedService = scope.ServiceProvider.GetRequiredService(); var seedResult = await seedService.SeedSystemDataAsync(); if (seedResult.Success) Log.Information("First-run seed complete: {Message}", seedResult.Message); else Log.Warning("First-run seed reported issues: {Message}", seedResult.Message); } } catch (Exception ex) { Log.Error(ex, "Error during first-run system data seed"); } } // Auto-seed subscription plan configs (global config, always ensure present) using (var scope = app.Services.CreateScope()) { try { var context = scope.ServiceProvider.GetRequiredService(); if (!context.Database.GetPendingMigrations().Any()) { // Default configs for all plans (used on fresh installs or to add missing plans) var defaults = new[] { new PowderCoating.Core.Entities.SubscriptionPlanConfig { Plan = 3, // Starter DisplayName = "Starter", Description = "Try it out — ideal for solo operators or very small shops", MaxUsers = 1, MaxActiveJobs = 1, MaxCustomers = 25, MaxQuotes = 5, MaxCatalogItems = 10, MaxJobPhotos = 0, MonthlyPrice = 19.99m, AnnualPrice = 199m, IsActive = true, SortOrder = 1, CompanyId = 0, CreatedAt = DateTime.UtcNow }, new PowderCoating.Core.Entities.SubscriptionPlanConfig { Plan = 0, // Basic DisplayName = "Basic", Description = "Perfect for small shops", MaxUsers = 3, MaxActiveJobs = 50, MaxCustomers = 100, MaxQuotes = 50, MaxCatalogItems = 100, MaxJobPhotos = 1, MonthlyPrice = 29m, AnnualPrice = 290m, IsActive = true, SortOrder = 2, CompanyId = 0, CreatedAt = DateTime.UtcNow }, new PowderCoating.Core.Entities.SubscriptionPlanConfig { Plan = 1, // Pro DisplayName = "Pro", Description = "For growing businesses", MaxUsers = 10, MaxActiveJobs = 250, MaxCustomers = 1000, MaxQuotes = 500, MaxCatalogItems = 500, MaxJobPhotos = 3, MonthlyPrice = 79m, AnnualPrice = 790m, IsActive = true, SortOrder = 3, CompanyId = 0, CreatedAt = DateTime.UtcNow }, new PowderCoating.Core.Entities.SubscriptionPlanConfig { Plan = 2, // Enterprise DisplayName = "Enterprise", Description = "Unlimited capacity", MaxUsers = -1, MaxActiveJobs = -1, MaxCustomers = -1, MaxQuotes = -1, MaxCatalogItems = -1, MaxJobPhotos = -1, MonthlyPrice = 199m, AnnualPrice = 1990m, IsActive = true, SortOrder = 4, CompanyId = 0, CreatedAt = DateTime.UtcNow } }; var existingPlans = context.SubscriptionPlanConfigs.IgnoreQueryFilters() .Select(c => c.Plan).ToHashSet(); var toAdd = defaults.Where(d => !existingPlans.Contains(d.Plan)).ToList(); if (toAdd.Any()) { context.SubscriptionPlanConfigs.AddRange(toAdd); context.SaveChanges(); Log.Information("Seeded {Count} subscription plan configuration(s)", toAdd.Count); } } } catch (Exception ex) { Log.Warning(ex, "Could not auto-seed subscription plan configs (may not be initialized yet)"); } } // ── Startup configuration validation ───────────────────────────────────────── // Fail fast with a clear message rather than a cryptic runtime error later. ValidateRequiredConfiguration(app.Configuration, app.Environment); try { Log.Information("Starting web application"); app.Run(); } catch (Exception ex) { Log.Fatal(ex, "Application terminated unexpectedly"); throw; } finally { Log.Information("Application shutting down"); Log.CloseAndFlush(); } // ── Startup configuration validation ───────────────────────────────────────────── static void ValidateRequiredConfiguration(IConfiguration config, IWebHostEnvironment env) { var errors = new List(); // Connection string — required in all environments if (string.IsNullOrWhiteSpace(config.GetConnectionString("DefaultConnection"))) errors.Add("ConnectionStrings:DefaultConnection is missing."); // In production, secrets must come from Key Vault / environment — not placeholder values if (env.IsProduction()) { var anthropicKey = config["AI:Anthropic:ApiKey"]; if (string.IsNullOrWhiteSpace(anthropicKey) || anthropicKey.StartsWith("your-")) Log.Warning("STARTUP: AI:Anthropic:ApiKey is not configured — AI features will be disabled."); var sendGridKey = config["SendGrid:ApiKey"]; if (string.IsNullOrWhiteSpace(sendGridKey) || sendGridKey.StartsWith("SG.your-")) Log.Warning("STARTUP: SendGrid:ApiKey is not configured — email notifications will be disabled."); var stripeSecret = config["Stripe:SecretKey"]; if (string.IsNullOrWhiteSpace(stripeSecret) || stripeSecret.StartsWith("sk_test_your")) Log.Warning("STARTUP: Stripe:SecretKey is not configured — payment processing will be disabled."); var stripeWebhookSecret = config["Stripe:WebhookSecret"]; if (string.IsNullOrWhiteSpace(stripeWebhookSecret)) errors.Add("Stripe:WebhookSecret is required in production — webhook signature verification will fail."); var storageConn = config["Storage:ConnectionString"]; if (string.IsNullOrWhiteSpace(storageConn)) Log.Warning("STARTUP: Storage:ConnectionString is not configured — file uploads to Azure Blob will be disabled."); } if (errors.Count > 0) { foreach (var error in errors) Log.Fatal("STARTUP CONFIGURATION ERROR: {Error}", error); throw new InvalidOperationException( $"Application cannot start due to {errors.Count} configuration error(s):\n" + string.Join("\n", errors)); } } // ── Serilog DB sink column configuration ───────────────────────────────────────── static ColumnOptions BuildLogColumnOptions() { var cols = new ColumnOptions(); // Keep only the standard columns we care about cols.Store.Remove(StandardColumn.Properties); // raw XML — we use AdditionalColumns instead cols.Store.Remove(StandardColumn.MessageTemplate); cols.Store.Add(StandardColumn.LogEvent); // full JSON blob for advanced querying // Promote enriched properties to their own searchable columns cols.AdditionalColumns = new List { new SqlColumn { ColumnName = "SourceContext", DataType = System.Data.SqlDbType.NVarChar, DataLength = 512, AllowNull = true }, new SqlColumn { ColumnName = "UserName", DataType = System.Data.SqlDbType.NVarChar, DataLength = 256, AllowNull = true }, new SqlColumn { ColumnName = "CompanyId", DataType = System.Data.SqlDbType.Int, AllowNull = true }, new SqlColumn { ColumnName = "RemoteIP", DataType = System.Data.SqlDbType.NVarChar, DataLength = 64, AllowNull = true }, }; cols.TimeStamp.ColumnName = "Timestamp"; cols.TimeStamp.ConvertToUtc = true; return cols; }