Initial commit
This commit is contained in:
@@ -0,0 +1,911 @@
|
||||
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<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
|
||||
{
|
||||
var keysPath = Path.Combine(builder.Environment.ContentRootPath, "DataProtection-Keys");
|
||||
builder.Services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(keysPath))
|
||||
.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<IAiQuoteService, AiQuoteService>();
|
||||
builder.Services.AddSingleton<IAiUsageLogger, AiUsageLogger>();
|
||||
builder.Services.AddScoped<IAiSchedulingService, AiSchedulingService>();
|
||||
builder.Services.AddScoped<IAccountingAiService, AccountingAiService>();
|
||||
builder.Services.AddScoped<IAiHelpService, AiHelpService>();
|
||||
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<IPowderInsightsService, PowderInsightsService>();
|
||||
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.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 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<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>();
|
||||
|
||||
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.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);
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
Reference in New Issue
Block a user