1a44133a63
Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs, mappings, controllers, views, and import/export paths. Worker identity is now handled entirely through ApplicationUser with per-user LaborCostPerHour. ShopWorkerRoleCosts table remains in production pending manual data migration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1032 lines
46 KiB
C#
1032 lines
46 KiB
C#
using System.Threading.RateLimiting;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
|
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.Core.Interfaces.Services;
|
|
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<PowderCoating.Infrastructure.Data.AuditInterceptor>();
|
|
builder.Services.AddDbContext<ApplicationDbContext>((sp, options) =>
|
|
{
|
|
options.UseSqlServer(connectionString, sqlOptions =>
|
|
{
|
|
sqlOptions.EnableRetryOnFailure(
|
|
maxRetryCount: 5,
|
|
maxRetryDelay: TimeSpan.FromSeconds(10),
|
|
errorNumbersToAdd: null);
|
|
});
|
|
options.AddInterceptors(sp.GetRequiredService<PowderCoating.Infrastructure.Data.AuditInterceptor>());
|
|
});
|
|
|
|
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
|
|
{
|
|
// Non-production: store keys in SQL Server so they survive deploys and IIS app pool recycles
|
|
// without needing filesystem write permissions on the web root.
|
|
builder.Services.AddDataProtection()
|
|
.PersistKeysToDbContext<ApplicationDbContext>()
|
|
.SetApplicationName("PowderCoatingApp");
|
|
}
|
|
|
|
// Configure Identity
|
|
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(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<ApplicationDbContext>()
|
|
.AddDefaultTokenProviders()
|
|
.AddDefaultUI()
|
|
.AddClaimsPrincipalFactory<ApplicationUserClaimsPrincipalFactory>();
|
|
|
|
// Register HttpContextAccessor for multi-tenancy
|
|
builder.Services.AddHttpContextAccessor();
|
|
|
|
// Register multi-tenancy services
|
|
builder.Services.AddScoped<ITenantContext, TenantContext>();
|
|
|
|
// Register repositories and services
|
|
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
|
|
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
|
builder.Services.AddScoped<IFileService, FileService>();
|
|
// Azure Blob Storage configuration
|
|
builder.Services.Configure<StorageSettings>(builder.Configuration.GetSection("Storage"));
|
|
builder.Services.AddSingleton<IAzureBlobStorageService, AzureBlobStorageService>();
|
|
builder.Services.AddScoped<IProfilePhotoService, ProfilePhotoService>();
|
|
builder.Services.AddScoped<IJobPhotoService, JobPhotoService>();
|
|
builder.Services.AddScoped<IQuotePhotoService, QuotePhotoService>();
|
|
builder.Services.AddScoped<ICatalogImageService, CatalogImageService>();
|
|
builder.Services.AddScoped<IAiQuoteService, AiQuoteService>();
|
|
builder.Services.AddScoped<IAiQuickQuoteService, AiQuickQuoteService>();
|
|
builder.Services.AddSingleton<IAiUsageLogger, AiUsageLogger>();
|
|
builder.Services.AddScoped<IAiSchedulingService, AiSchedulingService>();
|
|
builder.Services.AddScoped<IAccountingAiService, AccountingAiService>();
|
|
builder.Services.AddScoped<IFinancialReportService, FinancialReportService>();
|
|
builder.Services.AddScoped<IOperationalReportService, OperationalReportService>();
|
|
builder.Services.AddScoped<IAiHelpService, AiHelpService>();
|
|
builder.Services.AddScoped<IAiCatalogPriceCheckService, AiCatalogPriceCheckService>();
|
|
builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>();
|
|
builder.Services.AddHttpClient();
|
|
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
|
|
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
|
|
builder.Services.AddScoped<IPricingCalculationService, PricingCalculationService>();
|
|
builder.Services.AddScoped<IJobItemAssemblyService, JobItemAssemblyService>();
|
|
builder.Services.AddScoped<IQuotePricingAssemblyService, QuotePricingAssemblyService>();
|
|
builder.Services.AddScoped<IPowderInsightsService, PowderInsightsService>();
|
|
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
|
|
builder.Services.AddScoped<IAiUsageReportService, AiUsageReportService>();
|
|
builder.Services.AddScoped<IDashboardReadService, DashboardReadService>();
|
|
builder.Services.AddScoped<ICompanyListService, CompanyListService>();
|
|
builder.Services.AddScoped<ICompanyDataPurgeService, CompanyDataPurgeService>();
|
|
builder.Services.AddScoped<IPdfService, PdfService>();
|
|
builder.Services.AddScoped<ISeedDataService, SeedDataService>();
|
|
builder.Services.AddScoped<ICompanyConfigHealthService, CompanyConfigHealthService>();
|
|
builder.Services.AddScoped<ILedgerService, LedgerService>();
|
|
builder.Services.AddScoped<IAccountBalanceService, AccountBalanceService>();
|
|
builder.Services.AddScoped<IQuickBooksIifService, QuickBooksIifService>();
|
|
builder.Services.AddScoped<PowderCoating.Web.Services.QuickBooksOnlineService>();
|
|
builder.Services.AddScoped<IInAppNotificationService, PowderCoating.Web.Services.InAppNotificationService>();
|
|
builder.Services.AddScoped<ICsvImportService, CsvImportService>();
|
|
builder.Services.AddScoped<IMeasurementConversionService, MeasurementConversionService>();
|
|
builder.Services.AddScoped<IPlatformSettingsService, PlatformSettingsService>();
|
|
builder.Services.AddScoped<IEmailService, EmailService>();
|
|
builder.Services.AddScoped<IAdminNotificationService, AdminNotificationService>();
|
|
builder.Services.AddScoped<ISmsService, SmsService>();
|
|
builder.Services.AddScoped<INotificationService, NotificationService>();
|
|
builder.Services.AddHostedService<PaymentReminderBackgroundService>();
|
|
builder.Services.AddHostedService<SubscriptionExpiryBackgroundService>();
|
|
builder.Services.AddHostedService<AuditLogRetentionBackgroundService>();
|
|
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
|
|
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
|
|
builder.Services.AddHostedService<RecurringTransactionService>();
|
|
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
|
builder.Services.AddScoped<IStripeService, StripeService>();
|
|
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
|
|
builder.Services.AddScoped<IStorageMigrationService, StorageMigrationService>();
|
|
builder.Services.AddScoped<PowderCoating.Application.Interfaces.IAuditService, PowderCoating.Infrastructure.Data.AuditService>();
|
|
|
|
// Register caching service (singleton for better performance)
|
|
builder.Services.AddSingleton<PowderCoating.Application.Services.ICachingService, PowderCoating.Application.Services.CachingService>();
|
|
|
|
// Online user presence tracker (singleton in-memory store)
|
|
builder.Services.AddSingleton<PowderCoating.Web.Services.IOnlineUserTracker, PowderCoating.Web.Services.OnlineUserTracker>();
|
|
|
|
builder.Services.AddHealthChecks();
|
|
|
|
// Register lookup cache service (scoped because it depends on IUnitOfWork)
|
|
builder.Services.AddScoped<PowderCoating.Application.Services.ILookupCacheService, PowderCoating.Application.Services.LookupCacheService>();
|
|
|
|
// Configure AutoMapper
|
|
builder.Services.AddSingleton<IMapper>(sp =>
|
|
{
|
|
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
|
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 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();
|
|
|
|
// Fido2/WebAuthn: no DI registration needed — PasskeyController builds a
|
|
// per-request Fido2 instance from the incoming Host header so the RPID matches
|
|
// automatically on every environment without config changes.
|
|
|
|
// 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;
|
|
var companyRole = user.FindFirst("CompanyRole")?.Value;
|
|
if (companyRole == AppConstants.CompanyRoles.Accountant)
|
|
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 ||
|
|
companyRole == AppConstants.CompanyRoles.Accountant)
|
|
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 ||
|
|
companyRole == AppConstants.CompanyRoles.Accountant)
|
|
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 ||
|
|
companyRole == AppConstants.CompanyRoles.Accountant)
|
|
return true;
|
|
return user.HasClaim("Permission", "ViewReports");
|
|
}));
|
|
|
|
options.AddPolicy("CanManageBills", 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 ||
|
|
companyRole == AppConstants.CompanyRoles.Accountant)
|
|
return true;
|
|
return user.HasClaim("Permission", "ManageBills");
|
|
}));
|
|
|
|
options.AddPolicy("CanManageAccounting", 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 ||
|
|
companyRole == AppConstants.CompanyRoles.Accountant)
|
|
return true;
|
|
return user.HasClaim("Permission", "ManageAccounting");
|
|
}));
|
|
|
|
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<PowderCoating.Web.Filters.EnforceSuperAdmin2FAFilter>();
|
|
builder.Services.AddScoped<PowderCoating.Web.Filters.PlatformFeaturesFilter>();
|
|
builder.Services.AddControllersWithViews(options =>
|
|
{
|
|
options.Filters.AddService<PowderCoating.Web.Filters.EnforceSuperAdmin2FAFilter>();
|
|
options.Filters.AddService<PowderCoating.Web.Filters.PlatformFeaturesFilter>();
|
|
});
|
|
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<PowderCoating.Web.Middleware.LoginTrackingMiddleware>();
|
|
|
|
// Subscription enforcement middleware (after auth, before session)
|
|
app.UseMiddleware<PowderCoating.Web.Middleware.SubscriptionMiddleware>();
|
|
|
|
app.UseSession();
|
|
|
|
// Force first-login password change before any other page
|
|
app.UseMiddleware<PowderCoating.Web.Middleware.MustChangePasswordMiddleware>();
|
|
|
|
// Track authenticated user presence (throttled, in-memory)
|
|
app.UseMiddleware<PowderCoating.Web.Middleware.OnlineUserMiddleware>();
|
|
|
|
// Kiosk intake steps use /Kiosk/Intake/{token}/{action} so the token is a path segment
|
|
app.MapControllerRoute(
|
|
name: "kiosk_intake",
|
|
pattern: "Kiosk/Intake/{token}/{action}",
|
|
defaults: new { controller = "Kiosk" });
|
|
|
|
app.MapControllerRoute(
|
|
name: "default",
|
|
pattern: "{controller=Home}/{action=Index}/{id?}");
|
|
|
|
app.MapRazorPages();
|
|
|
|
// Map SignalR hubs
|
|
app.MapHub<PowderCoating.Web.Hubs.NotificationHub>("/hubs/notifications");
|
|
app.MapHub<PowderCoating.Web.Hubs.ShopHub>("/hubs/shop");
|
|
app.MapHub<PowderCoating.Web.Hubs.KioskHub>("/hubs/kiosk");
|
|
|
|
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<ApplicationDbContext>();
|
|
var userManager = services.GetRequiredService<UserManager<ApplicationUser>>();
|
|
var roleManager = services.GetRequiredService<RoleManager<IdentityRole>>();
|
|
|
|
// Seed roles and admin user
|
|
await SeedData.InitializeAsync(services, userManager, roleManager);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
var logger = services.GetRequiredService<ILogger<Program>>();
|
|
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<ApplicationDbContext>();
|
|
// 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<UserManager<ApplicationUser>>();
|
|
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<ISeedDataService>();
|
|
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<PowderCoating.Infrastructure.Data.ApplicationDbContext>();
|
|
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);
|
|
|
|
// ── Data access architecture enforcement ─────────────────────────────────────
|
|
// Throws at startup if any non-exempt controller injects ApplicationDbContext directly.
|
|
// This is the Phase 4 gate: the app cannot start with a violation.
|
|
EnforceDataAccessArchitecture();
|
|
|
|
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<string>();
|
|
|
|
// 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));
|
|
}
|
|
}
|
|
|
|
// ── Data access architecture enforcement ─────────────────────────────────────────
|
|
/// <summary>
|
|
/// Scans every Controller subclass in the Web assembly at startup and throws if any
|
|
/// non-exempt controller declares ApplicationDbContext as a constructor parameter.
|
|
/// This enforces the rule defined in docs/DATA_ACCESS_ARCHITECTURE.md — if a developer
|
|
/// adds a new controller that injects ApplicationDbContext directly, the app will refuse
|
|
/// to start with a clear message naming the violator.
|
|
/// </summary>
|
|
static void EnforceDataAccessArchitecture()
|
|
{
|
|
// Controllers in this set are documented permanent exceptions — see DATA_ACCESS_ARCHITECTURE.md.
|
|
var permanentExceptions = new HashSet<string>
|
|
{
|
|
"StripeWebhookController",
|
|
"WebhooksController",
|
|
"PaymentController",
|
|
"RegistrationController",
|
|
"DataExportController",
|
|
"AccountDataExportController",
|
|
"DataPurgeController",
|
|
"SystemInfoController",
|
|
"SystemLogsController",
|
|
"CompanyHealthController",
|
|
"PasskeyController",
|
|
"AuditLogController",
|
|
"UserActivityController",
|
|
"EmailBroadcastController",
|
|
"RevenueController",
|
|
"StripeEventsController",
|
|
"SubscriptionManagementController",
|
|
"UsageQuotaController",
|
|
};
|
|
|
|
var violators = typeof(Program).Assembly.GetTypes()
|
|
.Where(t => t.IsClass && !t.IsAbstract
|
|
&& typeof(Microsoft.AspNetCore.Mvc.Controller).IsAssignableFrom(t)
|
|
&& !permanentExceptions.Contains(t.Name))
|
|
.Where(t => t.GetConstructors()
|
|
.Any(ctor => ctor.GetParameters()
|
|
.Any(p => p.ParameterType == typeof(ApplicationDbContext))))
|
|
.Select(t => t.Name)
|
|
.OrderBy(n => n)
|
|
.ToList();
|
|
|
|
if (violators.Count == 0) return;
|
|
|
|
var names = string.Join(", ", violators);
|
|
throw new InvalidOperationException(
|
|
$"DATA ACCESS VIOLATION — {violators.Count} controller(s) inject ApplicationDbContext directly " +
|
|
$"and are not in the permanent exceptions list.\n" +
|
|
$"Violators: {names}\n" +
|
|
$"Fix: route data access through IUnitOfWork. " +
|
|
$"To add a permanent exception, update both the controller comment and docs/DATA_ACCESS_ARCHITECTURE.md.");
|
|
}
|
|
|
|
// ── 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<SqlColumn>
|
|
{
|
|
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;
|
|
}
|