Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
- POST /api/admin/seed-demo?bulk=N — массовое наполнение каталога (до 60k товаров) батчами для нагрузочных замеров (идемпотентно по маркеру BULK-). - POS --bench <url> <user> <pass>: мерит первичную загрузку (сеть/запись отдельно), тёплый старт (открытие базы + поиск), дельта-синк. С gzip-декомпрессией. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
830 lines
50 KiB
C#
830 lines
50 KiB
C#
using System.Security.Claims;
|
||
using foodmarket.Api.Storage;
|
||
using Hangfire;
|
||
using Hangfire.PostgreSql;
|
||
using Prometheus;
|
||
using FluentValidation;
|
||
using foodmarket.Api.Infrastructure.RateLimiting;
|
||
using foodmarket.Api.Infrastructure.Tenancy;
|
||
using foodmarket.Api.Seed;
|
||
using foodmarket.Application.Common.Tenancy;
|
||
using foodmarket.Infrastructure.Identity;
|
||
using foodmarket.Infrastructure.Persistence;
|
||
using Microsoft.AspNetCore.Identity;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using OpenIddict.Validation.AspNetCore;
|
||
using Serilog;
|
||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||
|
||
Log.Logger = new LoggerConfiguration()
|
||
.WriteTo.Console()
|
||
.CreateBootstrapLogger();
|
||
|
||
try
|
||
{
|
||
var builder = WebApplication.CreateBuilder(args);
|
||
|
||
builder.Host.UseSerilog((ctx, services, cfg) =>
|
||
cfg.ReadFrom.Configuration(ctx.Configuration).ReadFrom.Services(services));
|
||
|
||
const string CorsPolicy = "food-market-cors";
|
||
builder.Services.AddCors(opts => opts.AddPolicy(CorsPolicy, p =>
|
||
{
|
||
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
|
||
?? ["http://localhost:5173"];
|
||
p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
|
||
}));
|
||
|
||
builder.Services.AddHttpContextAccessor();
|
||
builder.Services.AddScoped<ITenantContext, HttpContextTenantContext>();
|
||
|
||
// Sprint 30: «быстрый вход» на тестовом стенде. Dev-only флаг, выключен
|
||
// по умолчанию, с safety-override по Issuer (см. QuickLoginOptions).
|
||
builder.Services.Configure<foodmarket.Api.Infrastructure.QuickLoginOptions>(
|
||
builder.Configuration.GetSection("QuickLogin"));
|
||
|
||
// Sprint 20 / TD-3: Mapster config — singleton TypeAdapterConfig.
|
||
// Используется в контроллерах через ProjectToType<TDto>(MapsterConfig.Config)
|
||
// для SQL-friendly проекций. IMapper в DI не регистрируем, потому что
|
||
// ProjectToType<T>() работает напрямую с config'ом — это эквивалент
|
||
// ручного `Select(...)` без runtime-reflection.
|
||
builder.Services.AddSingleton(foodmarket.Application.Mapping.MapsterConfig.Config);
|
||
// ClaimsTransformation для SuperAdmin override: добавляет роли Admin/
|
||
// Storekeeper/Cashier при наличии X-Org-Override, чтобы [Authorize(Roles=...)]
|
||
// не отшивал edit-mode мутации SuperAdmin'а. См. SuperAdminOverrideClaimsTransformer.
|
||
builder.Services.AddScoped<Microsoft.AspNetCore.Authentication.IClaimsTransformation,
|
||
foodmarket.Api.Infrastructure.Tenancy.SuperAdminOverrideClaimsTransformer>();
|
||
|
||
// Prometheus metrics: singleton-интерсептор EF засекает длительность каждого
|
||
// SQL-запроса (см. food_market_db_query_duration_seconds). HTTP-метрики
|
||
// включаются ниже через UseHttpMetrics(). /metrics endpoint доступен всем
|
||
// (стандартная практика для Prometheus scrape) — на prod закрываем nginx'ом.
|
||
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>();
|
||
// OrgAuditInterceptor — scoped (зависит от ITenantContext). EF тащит его
|
||
// через AddInterceptors на каждое создание DbContext (DbContext тоже scoped).
|
||
builder.Services.AddScoped<foodmarket.Infrastructure.Persistence.OrgAuditInterceptor>();
|
||
// Sprint 14: явная конфигурация Npgsql connection pool.
|
||
// Дефолты Npgsql: Max=100, Min=0, IdleLifetime=300 — формально нормально,
|
||
// но Min=0 в моменты низкого трафика убивает все коннекшены, и первый
|
||
// запрос после простоя платит handshake + auth (50-100ms). Подняли
|
||
// Min до 10 — пул всегда греется. Max=100 оставили (PG default
|
||
// max_connections=100, превышать без тюна сервера нельзя).
|
||
// Переопределяется через ConnectionStrings:Default (если в строке
|
||
// уже есть Maximum/Minimum Pool Size — наш код не перетирает).
|
||
static string ApplyDefaultPoolConfig(string? raw)
|
||
{
|
||
if (string.IsNullOrEmpty(raw)) return raw ?? "";
|
||
var lower = raw.ToLowerInvariant();
|
||
var b = new System.Text.StringBuilder(raw);
|
||
if (!raw.EndsWith(";")) b.Append(';');
|
||
if (!lower.Contains("maximum pool size")) b.Append("Maximum Pool Size=100;");
|
||
if (!lower.Contains("minimum pool size")) b.Append("Minimum Pool Size=10;");
|
||
if (!lower.Contains("connection idle lifetime")) b.Append("Connection Idle Lifetime=300;");
|
||
// Auto-prepare часто используемых запросов — заметно ускоряет EF Core
|
||
// на стабильном rotational query mix'е. Threshold=5 = после 5 calls
|
||
// одного query шаблона PG получает PREPARE, дальнейшие round-trip'ы
|
||
// идут как EXECUTE prepared (без re-parse/re-plan).
|
||
if (!lower.Contains("max auto prepare")) b.Append("Max Auto Prepare=20;");
|
||
if (!lower.Contains("auto prepare min usages")) b.Append("Auto Prepare Min Usages=5;");
|
||
return b.ToString();
|
||
}
|
||
var poolTunedConnString = ApplyDefaultPoolConfig(builder.Configuration.GetConnectionString("Default"));
|
||
|
||
builder.Services.AddDbContext<AppDbContext>((sp, opts) =>
|
||
{
|
||
opts.UseNpgsql(poolTunedConnString,
|
||
npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name));
|
||
opts.UseOpenIddict();
|
||
opts.AddInterceptors(sp.GetRequiredService<
|
||
foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>());
|
||
opts.AddInterceptors(sp.GetRequiredService<
|
||
foodmarket.Infrastructure.Persistence.OrgAuditInterceptor>());
|
||
});
|
||
|
||
builder.Services.AddIdentity<User, Role>(opts =>
|
||
{
|
||
opts.Password.RequireDigit = true;
|
||
opts.Password.RequiredLength = 8;
|
||
opts.Password.RequireNonAlphanumeric = false;
|
||
opts.Password.RequireUppercase = false;
|
||
opts.User.RequireUniqueEmail = true;
|
||
})
|
||
.AddEntityFrameworkStores<AppDbContext>()
|
||
.AddDefaultTokenProviders();
|
||
|
||
builder.Services.Configure<IdentityOptions>(opts =>
|
||
{
|
||
opts.ClaimsIdentity.UserNameClaimType = Claims.Name;
|
||
opts.ClaimsIdentity.UserIdClaimType = Claims.Subject;
|
||
opts.ClaimsIdentity.RoleClaimType = Claims.Role;
|
||
});
|
||
|
||
builder.Services.AddOpenIddict()
|
||
.AddCore(opts => opts.UseEntityFrameworkCore().UseDbContext<AppDbContext>())
|
||
.AddServer(opts =>
|
||
{
|
||
opts.SetTokenEndpointUris("/connect/token");
|
||
opts.AllowPasswordFlow().AllowRefreshTokenFlow();
|
||
// Быстрый вход кассира по PIN на зарегистрированном устройстве
|
||
// (grant_type=pin, параметры retail_point_id + pin). См. AuthorizationController.
|
||
opts.AllowCustomFlow("pin");
|
||
opts.AcceptAnonymousClients();
|
||
opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api");
|
||
|
||
// Ключи подписи/шифрования: dev — persistent RSA-ключ в App_Data;
|
||
// prod/stage — X509-сертификаты из конфига (OpenIddict:SigningCertPath /
|
||
// EncryptionCertPath), persistent self-signed если файла нет.
|
||
// Подробности и dev-инвариант — в OpenIddictKeyConfigurator.
|
||
foodmarket.Api.Infrastructure.Security.OpenIddictKeyConfigurator
|
||
.ConfigureSigningAndEncryption(opts, builder.Configuration, builder.Environment);
|
||
|
||
opts.UseAspNetCore()
|
||
.EnableTokenEndpointPassthrough()
|
||
.DisableTransportSecurityRequirement();
|
||
|
||
opts.SetAccessTokenLifetime(TimeSpan.Parse(
|
||
builder.Configuration["OpenIddict:AccessTokenLifetime"] ?? "01:00:00"));
|
||
opts.SetRefreshTokenLifetime(TimeSpan.Parse(
|
||
builder.Configuration["OpenIddict:RefreshTokenLifetime"] ?? "30.00:00:00"));
|
||
// Rolling refresh включён по умолчанию: каждый refresh гасит старый
|
||
// токен (Redeemed) и выдаёт новый. Но по умолчанию OpenIddict даёт
|
||
// 30-секундный reuse-leeway — окно, в котором погашенный refresh ещё
|
||
// принимается (для гонок/ретраев). Для розничной админки это дыра:
|
||
// утёкший refresh остаётся рабочим 30с после ротации. Обнуляем окно —
|
||
// ротация инвалидирует старый refresh немедленно.
|
||
opts.SetRefreshTokenReuseLeeway(TimeSpan.Zero);
|
||
// Явный Issuer = публичный URL админки. Без него OpenIddict
|
||
// вычисляет issuer из текущего HTTP-запроса, что ломается за
|
||
// nginx-прокси если Host/X-Forwarded-Proto не доходят. Берём
|
||
// из конфигурации (OpenIddict:Issuer / docker compose env).
|
||
if (builder.Configuration["OpenIddict:Issuer"] is { Length: > 0 } issuer)
|
||
{
|
||
opts.SetIssuer(new Uri(issuer));
|
||
}
|
||
})
|
||
.AddValidation(opts =>
|
||
{
|
||
opts.UseLocalServer();
|
||
opts.UseAspNetCore();
|
||
});
|
||
|
||
// Force OpenIddict validation to handle ALL [Authorize] challenges — otherwise AddIdentity's
|
||
// cookie scheme takes over and 401 becomes a 302 redirect to /Account/Login for API calls.
|
||
var authBuilder = builder.Services.AddAuthentication(options =>
|
||
{
|
||
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||
});
|
||
|
||
// Sprint 20: SSO scaffolding. Внешние providers подключаются только когда
|
||
// в конфиге задан ClientId — иначе их не регистрируем (контроллер вернёт
|
||
// 503 на /api/auth/external/{provider}). Cookie-схема нужна для временного
|
||
// хранения OAuth-стейта между challenge и callback'ом.
|
||
var googleId = builder.Configuration["Authentication:Google:ClientId"];
|
||
var googleSecret = builder.Configuration["Authentication:Google:ClientSecret"];
|
||
var msId = builder.Configuration["Authentication:Microsoft:ClientId"];
|
||
var msSecret = builder.Configuration["Authentication:Microsoft:ClientSecret"];
|
||
var anySso = !string.IsNullOrEmpty(googleId) || !string.IsNullOrEmpty(msId);
|
||
if (anySso)
|
||
{
|
||
authBuilder.AddCookie("ExternalSignIn", o =>
|
||
{
|
||
o.Cookie.Name = "fm.external";
|
||
o.Cookie.HttpOnly = true;
|
||
o.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax;
|
||
o.ExpireTimeSpan = TimeSpan.FromMinutes(10);
|
||
});
|
||
if (!string.IsNullOrEmpty(googleId) && !string.IsNullOrEmpty(googleSecret))
|
||
{
|
||
authBuilder.AddGoogle("Google", o =>
|
||
{
|
||
o.ClientId = googleId;
|
||
o.ClientSecret = googleSecret;
|
||
o.SignInScheme = "ExternalSignIn";
|
||
o.CallbackPath = "/signin-google";
|
||
o.SaveTokens = false;
|
||
});
|
||
}
|
||
if (!string.IsNullOrEmpty(msId) && !string.IsNullOrEmpty(msSecret))
|
||
{
|
||
authBuilder.AddMicrosoftAccount("Microsoft", o =>
|
||
{
|
||
o.ClientId = msId;
|
||
o.ClientSecret = msSecret;
|
||
o.SignInScheme = "ExternalSignIn";
|
||
o.CallbackPath = "/signin-microsoft";
|
||
o.SaveTokens = false;
|
||
});
|
||
}
|
||
}
|
||
builder.Services.AddAuthorization(opts =>
|
||
{
|
||
// Check the "role" claim explicitly — robust against role-claim-type mismatches between
|
||
// OpenIddict validation identity and the default ClaimTypes.Role uri.
|
||
opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx =>
|
||
ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin"))));
|
||
});
|
||
// Permission-based авторизация: [RequiresPermission("...")] → policy perm:* →
|
||
// PermissionRequirement → проверка флага RolePermissions роли сотрудника.
|
||
builder.Services.AddSingleton<Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider,
|
||
foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationPolicyProvider>();
|
||
builder.Services.AddScoped<Microsoft.AspNetCore.Authorization.IAuthorizationHandler,
|
||
foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationHandler>();
|
||
|
||
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
||
// Sprint 13: централизованный логгер sensitive-операций (смена пароля,
|
||
// 2FA, выдача роли, смена владельца, revoke-all sessions). Пишет в
|
||
// org_audit_log + Serilog. См. SensitiveOpsAudit.
|
||
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit>();
|
||
// Sprint 14: генерация thumb/medium WebP-вариантов при загрузке картинки товара.
|
||
builder.Services.AddScoped<foodmarket.Api.Storage.ImageVariantService>();
|
||
|
||
// Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP).
|
||
// Лимиты конфигурируемы через RateLimiting:* (PerMinute/PerHour/Enabled).
|
||
builder.Services.AddAuthRateLimiting(builder.Configuration);
|
||
|
||
// Health-пробы: liveness (процесс жив) и readiness (БД + миграции применены).
|
||
// Readiness-чек помечен тегом "ready", чтобы /health/live его не запускал.
|
||
builder.Services.AddHealthChecks()
|
||
.AddCheck<foodmarket.Api.Infrastructure.Health.DatabaseReadyHealthCheck>(
|
||
"database", tags: ["ready"]);
|
||
|
||
// Email-отправка через MailKit. Singleton — внутри открывает scope для
|
||
// свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается
|
||
// на каждой отправке без рестарта приложения.
|
||
builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
|
||
foodmarket.Infrastructure.Email.MailKitEmailSender>();
|
||
|
||
// ─ Sprint 11: ОФД (фискализация чеков в РК) ────────────────────────
|
||
// Все провайдеры регистрируются одновременно — фабрика выбирает по
|
||
// FiscalProvider в настройках организации. Default — None: чеки
|
||
// проводятся без фискализации, поведение «как до Sprint 11».
|
||
//
|
||
// Mock = Transient (без HTTP); остальные через AddHttpClient — это
|
||
// даёт автоматический pooled HttpMessageHandler + интеграцию с
|
||
// IHttpClientFactory (вынос в Polly / переопределение handler'а
|
||
// в тестах через .AddHttpMessageHandler).
|
||
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider,
|
||
foodmarket.Infrastructure.Fiscal.MockFiscalProvider>();
|
||
builder.Services.AddHttpClient<foodmarket.Infrastructure.Fiscal.WebkassaProvider>();
|
||
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider>(sp =>
|
||
sp.GetRequiredService<foodmarket.Infrastructure.Fiscal.WebkassaProvider>());
|
||
builder.Services.AddHttpClient<foodmarket.Infrastructure.Fiscal.Kassa24Provider>();
|
||
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider>(sp =>
|
||
sp.GetRequiredService<foodmarket.Infrastructure.Fiscal.Kassa24Provider>());
|
||
builder.Services.AddHttpClient<foodmarket.Infrastructure.Fiscal.OfdSoloProvider>();
|
||
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider>(sp =>
|
||
sp.GetRequiredService<foodmarket.Infrastructure.Fiscal.OfdSoloProvider>());
|
||
builder.Services.AddScoped<foodmarket.Application.Common.Fiscal.IFiscalProviderFactory,
|
||
foodmarket.Infrastructure.Fiscal.FiscalProviderFactory>();
|
||
// EmailTemplates загружает embedded HTML и подставляет {{key}} —
|
||
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
|
||
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
|
||
builder.Services.AddDataProtection();
|
||
|
||
// Sprint 2026-10-05 (handoff §9 порт из sklad): для фото-приёмок клиент
|
||
// шлёт пачку снимков (до 60 МБ) — ответ API маленький; но мобильный
|
||
// «mobile-catalog» на 31 000 позиций — это ~2 МБ JSON, его стоит сжимать.
|
||
// Включаем gzip для application/json; «br» умеет не каждый прокси, а
|
||
// большой выгоды над gzip на коротких текстах не даёт.
|
||
builder.Services.AddResponseCompression(opts =>
|
||
{
|
||
opts.EnableForHttps = true;
|
||
opts.Providers.Add<Microsoft.AspNetCore.ResponseCompression.GzipCompressionProvider>();
|
||
opts.MimeTypes = Microsoft.AspNetCore.ResponseCompression.ResponseCompressionDefaults.MimeTypes
|
||
.Concat(new[] { "application/json; charset=utf-8", "text/csv" });
|
||
});
|
||
// MediatR (TD-1 partial CQRS): сканирует food-market.application для
|
||
// IRequest/IRequestHandler. Образцы: CreateSupplyCommand,
|
||
// PostRetailSaleCommand, GetSalesReportQuery. Контроллеры пока не
|
||
// переписаны под mediator — это паттерн-показ, не полный рефакторинг.
|
||
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(
|
||
typeof(foodmarket.Application.Inventory.IStockService).Assembly));
|
||
// Заглушки для абстракций CQRS-handler'ов: handler'ы пока не подключены
|
||
// к контроллерам (поэтапная миграция). Реальная имплементация появится
|
||
// когда контроллер начнёт отправлять команду через IMediator. Сейчас
|
||
// нужны только чтобы DI-validation на старте не падала.
|
||
builder.Services.AddTransient<foodmarket.Application.Purchases.Commands.ISupplyWriter,
|
||
foodmarket.Api.Infrastructure.Cqrs.UnusedSupplyWriter>();
|
||
builder.Services.AddTransient<foodmarket.Application.Sales.Commands.IRetailSalePoster,
|
||
foodmarket.Api.Infrastructure.Cqrs.UnusedRetailSalePoster>();
|
||
// FluentValidation: автоматическая регистрация валидаторов из сборки api.
|
||
// ValidationFilter гоняет валидаторы на каждом контроллер-action перед
|
||
// вызовом — fail возвращает 400 ValidationProblemDetails (RFC 7807).
|
||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
|
||
builder.Services.AddControllers(o =>
|
||
{
|
||
// Глобальный action filter — пишет audit-log при успешных мутациях
|
||
// в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
|
||
o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
||
o.Filters.AddService<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
|
||
// Port sklad 06.10 (handoff §1 / §10). Nullable=enable + ASP.NET MVC
|
||
// по умолчанию добавляет неявный [Required] на каждое non-nullable
|
||
// string в input-модели. Любое новое необязательное текстовое поле
|
||
// (InvoiceLine.Note, BarcodeSource и т.п.) — и клиент получает 400
|
||
// «The X field is required» на пустой строке. Отключаем: FluentValidation
|
||
// поверх (ValidationFilter + Validators.cs) явно гоняет NotEmpty там,
|
||
// где поле реально обязательно; остальные — nullable или имеют default.
|
||
o.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
|
||
})
|
||
.ConfigureApiBehaviorOptions(o =>
|
||
{
|
||
// ValidationProblemDetails совместим с humanizeError на фронте
|
||
// (data.errors[*][0] → data.title → data.detail). Параллельно пишем
|
||
// Warning с путём и собранными ошибками — так бэкенд всегда видит
|
||
// «за какое поле отвалилось» без детализации в теле ответа.
|
||
o.InvalidModelStateResponseFactory = ctx =>
|
||
{
|
||
var problems = ctx.ModelState
|
||
.Where(kv => kv.Value?.Errors.Count > 0)
|
||
.Select(kv => $"{kv.Key}: {string.Join(", ", kv.Value!.Errors.Select(e => e.ErrorMessage))}")
|
||
.ToList();
|
||
ctx.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
|
||
.CreateLogger("ModelValidation")
|
||
.LogWarning("[Проверка] {Path}: {Problems}",
|
||
ctx.HttpContext.Request.Path, string.Join("; ", problems));
|
||
var pd = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(ctx.ModelState)
|
||
{
|
||
Status = StatusCodes.Status400BadRequest,
|
||
Title = "Сервер не принял данные",
|
||
Detail = string.Join("; ", problems.Take(3))
|
||
+ (problems.Count > 3 ? $" и ещё {problems.Count - 3}" : ""),
|
||
};
|
||
return new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(pd)
|
||
{
|
||
ContentTypes = { "application/problem+json" },
|
||
};
|
||
};
|
||
});
|
||
builder.Services.AddEndpointsApiExplorer();
|
||
builder.Services.AddSwaggerGen(opts =>
|
||
{
|
||
opts.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
|
||
{
|
||
Title = "food-market API",
|
||
Version = "v1",
|
||
Description = "Multi-tenant POS/inventory backend. Все запросы " +
|
||
"ограничены организацией текущего JWT (claim `org_id`).",
|
||
});
|
||
// Bearer JWT через OpenIddict. Swagger UI «Authorize» подставит в Authorization.
|
||
var bearer = new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||
{
|
||
Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http,
|
||
Scheme = "bearer",
|
||
BearerFormat = "JWT",
|
||
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
|
||
Description = "Access token, полученный через POST /connect/token.",
|
||
};
|
||
opts.AddSecurityDefinition("Bearer", bearer);
|
||
opts.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement
|
||
{
|
||
[new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||
{
|
||
Reference = new Microsoft.OpenApi.Models.OpenApiReference
|
||
{
|
||
Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
|
||
Id = "Bearer",
|
||
},
|
||
}] = Array.Empty<string>(),
|
||
});
|
||
// Стабильные operationId для генерации TS-клиентов:
|
||
// <Controller>_<Verb><Action>. Verb включён чтобы избежать коллизии
|
||
// когда ASP.NET стрипает Async-суффикс и два метода (WipeAll, WipeAllAsync)
|
||
// получают одинаковое имя action → одинаковый operationId → duplicate.
|
||
// Минимальные API (/health, /metrics, /connect/*) не имеют ключей
|
||
// controller/action в RouteValues — для них фоллбэк на RelativePath.
|
||
opts.CustomOperationIds(api =>
|
||
{
|
||
var rv = api.ActionDescriptor.RouteValues;
|
||
rv.TryGetValue("controller", out var ctrl);
|
||
rv.TryGetValue("action", out var action);
|
||
var verb = api.HttpMethod is { Length: > 0 } m ? char.ToUpper(m[0]) + m[1..].ToLowerInvariant() : "";
|
||
if (string.IsNullOrEmpty(ctrl) || string.IsNullOrEmpty(action))
|
||
return $"{verb}_{api.RelativePath?.Replace('/', '_').Replace('{', '_').Replace('}', '_')}";
|
||
return $"{ctrl}_{verb}{action}";
|
||
});
|
||
// У нас есть одноимённые nested record'ы в разных контроллерах
|
||
// (например, StockRow в StockController и StockReportController, или
|
||
// EmployeeInput в Organizations.EmployeesController и SuperAdmin).
|
||
// Включаем имя контроллера в schemaId через FullName-suffix чтобы не
|
||
// словить duplicate schemaId — Swashbuckle падает на этом по умолчанию.
|
||
// FullName может быть null для generic-параметров — fallback на Name.
|
||
opts.CustomSchemaIds(t => (t.FullName ?? t.Name)
|
||
.Replace("foodmarket.Api.Controllers.", "")
|
||
.Replace("foodmarket.Application.", "")
|
||
.Replace("foodmarket.Domain.", "")
|
||
.Replace("+", "_")
|
||
.Replace(".", "_"));
|
||
});
|
||
|
||
// MoySklad import integration. Auto-decompress gzip responses from MoySklad's edge.
|
||
// BaseAddress берётся из конфигурации (MoySklad:BaseUrl) с дефолтом на боевой
|
||
// api.moysklad.ru — так интеграцию можно навести на mock-сервер в e2e/интеграционных
|
||
// тестах, не трогая прод. Трейлинг-слэш обязателен (RFC 3986 §5.3, см. MoySkladClient).
|
||
builder.Services.AddHttpClient<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>(http =>
|
||
{
|
||
var baseUrl = builder.Configuration["MoySklad:BaseUrl"];
|
||
if (!string.IsNullOrWhiteSpace(baseUrl))
|
||
http.BaseAddress = new Uri(baseUrl);
|
||
})
|
||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||
{
|
||
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
|
||
});
|
||
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
|
||
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
|
||
|
||
// Inventory
|
||
builder.Services.AddScoped<foodmarket.Application.Inventory.INegativeStockAlertService, foodmarket.Infrastructure.Inventory.NegativeStockAlertService>();
|
||
builder.Services.AddScoped<foodmarket.Application.Notifications.IPushSender, foodmarket.Infrastructure.Notifications.StubPushSender>();
|
||
builder.Services.AddScoped<foodmarket.Application.Notifications.IWebPushSender, foodmarket.Infrastructure.Notifications.WebPushSender>();
|
||
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
|
||
|
||
// Sprint 33: модуль «Деньги»
|
||
builder.Services.AddScoped<foodmarket.Application.Money.IMoneyService,
|
||
foodmarket.Infrastructure.Money.MoneyService>();
|
||
|
||
// Hangfire — фоновые джобы и UI-дашборд. Хранилище — наш Postgres
|
||
// (тот же ConnectionString:Default). Запускаем фактический сервер только
|
||
// когда приложение действительно работает (не в тестах): чтобы тестовые
|
||
// прогоны не плодили tables/jobs в одноразовом контейнере. Регистрация
|
||
// recurring jobs — после Build() в IHostedService HangfireJobsConfigurator.
|
||
var enableHangfireServer = builder.Configuration.GetValue("Hangfire:Enabled", true);
|
||
builder.Services.AddHangfire(cfg => cfg
|
||
.SetDataCompatibilityLevel(Hangfire.CompatibilityLevel.Version_180)
|
||
.UseSimpleAssemblyNameTypeSerializer()
|
||
.UseRecommendedSerializerSettings()
|
||
.UsePostgreSqlStorage(opts => opts.UseNpgsqlConnection(
|
||
builder.Configuration.GetConnectionString("Default"))));
|
||
if (enableHangfireServer)
|
||
{
|
||
builder.Services.AddHangfireServer(opts =>
|
||
{
|
||
opts.WorkerCount = 2;
|
||
opts.Queues = new[] { "default" };
|
||
});
|
||
builder.Services.AddHostedService<foodmarket.Api.Background.HangfireJobsConfigurator>();
|
||
// Sprint 14: timing-фильтр для всех job'ов — пишет длительность каждого
|
||
// выполнения в Serilog. Долгие (>30с) логируются как Warning.
|
||
builder.Services.AddSingleton<foodmarket.Api.Background.JobTimingFilter>();
|
||
builder.Services.AddHostedService<foodmarket.Api.Background.HangfireGlobalFilterRegistrar>();
|
||
}
|
||
builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
|
||
// Sprint 20: DB VACUUM ANALYZE + disk monitoring.
|
||
builder.Services.AddScoped<foodmarket.Api.Background.DatabaseMaintenanceJobs>();
|
||
builder.Services.AddScoped<foodmarket.Api.Background.DiskMonitoringJob>();
|
||
// Sprint 22: GDPR org export + DB schema docs.
|
||
builder.Services.AddScoped<foodmarket.Api.Background.OrgExportJob>();
|
||
builder.Services.AddScoped<foodmarket.Api.Background.DbSchemaDocsJob>();
|
||
// Sprint 24: API endpoint reference docs (weekly).
|
||
builder.Services.AddScoped<foodmarket.Api.Background.ApiReferenceDocsJob>();
|
||
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
|
||
|
||
// Telegram-бот владельца. Token + username берём из конфига; если token
|
||
// пустой — клиент тихо отключён, RunAsync джоба возвращается сразу. UI
|
||
// переходит в режим «бот не настроен» — кнопка «Привязать Telegram»
|
||
// показывает подсказку об env-переменной.
|
||
builder.Services.Configure<foodmarket.Api.Integrations.Telegram.TelegramOptions>(
|
||
builder.Configuration.GetSection("Telegram"));
|
||
builder.Services.AddHttpClient<foodmarket.Api.Integrations.Telegram.ITelegramBotClient,
|
||
foodmarket.Api.Integrations.Telegram.TelegramBotClient>(c =>
|
||
{
|
||
c.Timeout = TimeSpan.FromSeconds(15);
|
||
});
|
||
builder.Services.AddScoped<foodmarket.Api.Background.OwnerDailySummaryJob>();
|
||
builder.Services.AddScoped<foodmarket.Api.Background.NegativeStockNotificationJob>();
|
||
|
||
// Object storage (картинки товаров, CSV-импорт). Type=Local|Minio,
|
||
// fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs.
|
||
builder.Services.AddObjectStorage(builder.Configuration);
|
||
|
||
// SignalR + per-org notification publisher. Hub смонтирован ниже на
|
||
// /hubs/notifications. JsonPascalCase оставляем — фронт получает PascalCase
|
||
// имена полей в DTO (см. NotificationsPublisher payload-records).
|
||
builder.Services.AddSignalR();
|
||
builder.Services.AddSingleton<foodmarket.Api.Realtime.INotificationsPublisher,
|
||
foodmarket.Api.Realtime.NotificationsPublisher>();
|
||
|
||
// Sprint 31: приёмка по фото накладной. Extractor — Claude CLI headless
|
||
// (без API-ключей, нагрузка списывается с подписки). InvoiceWorker в фоне
|
||
// обрабатывает очередь pending → needs_review.
|
||
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Invoices.IInvoiceExtractor,
|
||
foodmarket.Api.Infrastructure.Invoices.ClaudeCliInvoiceExtractor>();
|
||
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Invoices.InvoiceProcessor>();
|
||
builder.Services.AddHostedService<foodmarket.Api.Infrastructure.Invoices.InvoiceWorker>();
|
||
|
||
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
||
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
||
builder.Services.AddHostedService<DevDataSeeder>();
|
||
// Демо-сидер для stage'a (триггерится через POST /api/admin/seed-demo).
|
||
builder.Services.AddScoped<foodmarket.Api.Seed.DemoTenantSeeder>();
|
||
// Sprint 30: демо-trio «Демо-магазина» для quick-login — только когда флаг
|
||
// эффективно включён. Внутри сам чекает EffectivelyEnabled().
|
||
builder.Services.AddHostedService<foodmarket.Api.Seed.QuickLoginDemoSeeder>();
|
||
// Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1).
|
||
builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>();
|
||
// Витринный сидер для скриншотов сайта (POST /api/admin/seed-demo?showcase=1).
|
||
builder.Services.AddScoped<foodmarket.Api.Seed.ShowcaseSeeder>();
|
||
builder.Services.AddScoped<foodmarket.Api.Seed.BulkCatalogSeeder>();
|
||
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
|
||
// DemoCatalogSeeder disabled: real catalog is imported from MoySklad.
|
||
// Keep the file as reference for anyone starting without MoySklad access —
|
||
// just re-register here to turn demo data back on.
|
||
// builder.Services.AddHostedService<DemoCatalogSeeder>();
|
||
|
||
// Sprint 13: security-заголовки (CSP, X-Frame-Options и т.д.). Опции
|
||
// переопределяются секцией Security в конфиге; по дефолту — самодостаточный
|
||
// SPA + API на одном origin, см. SecurityHeadersOptions.DefaultCsp.
|
||
builder.Services.AddSingleton(sp =>
|
||
{
|
||
var opts = new foodmarket.Api.Infrastructure.Security.SecurityHeadersOptions();
|
||
var section = builder.Configuration.GetSection("Security");
|
||
var csp = section["ContentSecurityPolicy"];
|
||
if (!string.IsNullOrWhiteSpace(csp)) opts.ContentSecurityPolicy = csp;
|
||
return opts;
|
||
});
|
||
// HSTS-параметры для продакшна. 365 дней + includeSubDomains — стандарт.
|
||
// preload включаем: домен admin.food-market.kz можно подать в
|
||
// hstspreload.org когда созреем, без preload директивы — нельзя.
|
||
builder.Services.AddHsts(opts =>
|
||
{
|
||
opts.MaxAge = TimeSpan.FromDays(365);
|
||
opts.IncludeSubDomains = true;
|
||
opts.Preload = true;
|
||
});
|
||
|
||
var app = builder.Build();
|
||
|
||
// Sprint 30: один раз на старте проверяем решение по quick-login
|
||
// (Enabled+Issuer). Пишет Warning и в прод-env, и в стенде, чтобы в логах
|
||
// всегда было видно, в каком режиме стартанули.
|
||
{
|
||
var ql = app.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<
|
||
foodmarket.Api.Infrastructure.QuickLoginOptions>>().Value;
|
||
var issuer = builder.Configuration["OpenIddict:Issuer"];
|
||
ql.LogSafetyDecision(app.Services.GetRequiredService<ILoggerFactory>()
|
||
.CreateLogger("QuickLogin"), issuer);
|
||
}
|
||
|
||
// HSTS — только когда мы за HTTPS (stage/prod через nginx). В Development
|
||
// обычно работаем по http://localhost:5081, и Strict-Transport-Security
|
||
// прибил бы локальный браузер на год. По дефолту 365 дней + preload.
|
||
if (!app.Environment.IsDevelopment())
|
||
{
|
||
app.UseHsts();
|
||
}
|
||
|
||
// Security-заголовки на КАЖДЫЙ ответ — раньше всех остальных middleware'ов,
|
||
// чтобы они применились даже на 429/403 от rate-limiter'а.
|
||
app.UseMiddleware<foodmarket.Api.Infrastructure.Security.SecurityHeadersMiddleware>();
|
||
|
||
// Sprint 23 / bug-003: глобальный перехватчик PostgreSQL serialization
|
||
// failure'ов (SqlState=40001) — мапит на 409 Conflict + retryable:true.
|
||
// Без него параллельные posting'и под Serializable отвечают 500.
|
||
app.UseMiddleware<foodmarket.Api.Infrastructure.SerializationConflictMiddleware>();
|
||
|
||
app.UseSerilogRequestLogging();
|
||
app.UseCors(CorsPolicy);
|
||
// Prometheus HTTP-метрики (http_requests_received_total, http_request_duration_seconds).
|
||
// Включаем сразу после CORS и до аутентификации, чтобы видеть и 401/403
|
||
// (это сигнал об атаке/неверной конфигурации). prometheus-net уже зашивает
|
||
// reserved-лейблы code/method/controller/action из route template'а —
|
||
// дополнительной кастомизации не нужно, и она запрещена (см. ValidateMappings).
|
||
app.UseHttpMetrics();
|
||
// До аутентификации: лимитируем перебор пароля ещё на входе, не доводя
|
||
// до проверки credential'ов в БД. Перед лимитером пик form-body /connect/token,
|
||
// чтобы per-username бакет имел ключ — без этого мы можем считать только по IP,
|
||
// и любая NAT/CI-машина роняется первой.
|
||
app.UseResponseCompression();
|
||
app.UseAuthFormUsernameKey();
|
||
app.UseRateLimiter();
|
||
// SignalR-clients не могут слать кастомные хедеры через WebSocket, поэтому
|
||
// токен приходит как `?access_token=...`. До UseAuthentication перекладываем
|
||
// его в Authorization-хедер, чтобы OpenIddict валидатор отработал штатно.
|
||
app.Use(async (ctx, next) =>
|
||
{
|
||
var path = ctx.Request.Path;
|
||
if (path.StartsWithSegments("/hubs")
|
||
&& !ctx.Request.Headers.ContainsKey("Authorization")
|
||
&& ctx.Request.Query.TryGetValue("access_token", out var tok)
|
||
&& !string.IsNullOrEmpty(tok))
|
||
{
|
||
ctx.Request.Headers["Authorization"] = $"Bearer {tok}";
|
||
}
|
||
await next();
|
||
});
|
||
app.UseAuthentication();
|
||
app.UseAuthorization();
|
||
// После аутентификации, до контроллеров: вытягиваем OrgId/UserId из ClaimsPrincipal
|
||
// и кладём в Serilog LogContext вместе с CorrelationId — каждая ILogger.Log
|
||
// в пайплайне автоматически получит эти лейблы.
|
||
app.UseMiddleware<foodmarket.Api.Infrastructure.Observability.LogEnrichmentMiddleware>();
|
||
// SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но
|
||
// только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*.
|
||
app.UseMiddleware<foodmarket.Api.Infrastructure.Tenancy.ReadonlyOverrideMiddleware>();
|
||
|
||
// Статика товарных изображений: физически /app/uploads (volume в compose),
|
||
// публичный URL /uploads/... — раздаются public, без auth.
|
||
var uploadsDir = System.IO.Path.Combine(app.Environment.ContentRootPath, "uploads");
|
||
System.IO.Directory.CreateDirectory(uploadsDir);
|
||
app.UseStaticFiles(new Microsoft.AspNetCore.Builder.StaticFileOptions
|
||
{
|
||
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsDir),
|
||
RequestPath = "/uploads",
|
||
});
|
||
|
||
// Swagger/OpenAPI: всегда в Development, в Production — только при IncludeSwagger=true
|
||
// (используется на stage для дебага без отдельного билда). На prod admin.food-market.kz
|
||
// флаг не выставляем — sensitive endpoint enumeration.
|
||
var includeSwagger = app.Environment.IsDevelopment()
|
||
|| builder.Configuration.GetValue<bool>("IncludeSwagger");
|
||
if (includeSwagger)
|
||
{
|
||
app.UseSwagger();
|
||
app.UseSwaggerUI(opts =>
|
||
{
|
||
opts.DocumentTitle = "food-market API";
|
||
opts.RoutePrefix = "swagger";
|
||
});
|
||
}
|
||
|
||
app.MapControllers();
|
||
// SignalR hub для live-уведомлений per-org. Path: /hubs/notifications.
|
||
// JWT доставляется либо через Authorization-заголовок (наш AuthClient
|
||
// делает это автоматически), либо через query `?access_token=...`
|
||
// (фронт react useNotificationsHub использует именно его, потому что
|
||
// браузерные WebSocket не поддерживают custom headers).
|
||
app.MapHub<foodmarket.Api.Realtime.NotificationsHub>("/hubs/notifications");
|
||
|
||
// /metrics — текстовый Prometheus exposition format. Скрейпится prometheus-сервером
|
||
// (rate=15s типично). Доступ без авторизации — стандартная практика;
|
||
// на prod ограничиваем nginx-уровнем (allow 10.0.0.0/8, deny all) или basic-auth.
|
||
app.MapMetrics("/metrics");
|
||
|
||
// Hangfire Dashboard на /hangfire — только SuperAdmin. Auth-фильтр
|
||
// проверяет System.Security.Claims.ClaimsPrincipal (стандартный
|
||
// OpenIddict-токен в Authorization-заголовке). Не вешаем UseHangfireServer —
|
||
// он уже стартует через AddHangfireServer выше.
|
||
if (enableHangfireServer)
|
||
{
|
||
app.UseHangfireDashboard("/hangfire", new Hangfire.DashboardOptions
|
||
{
|
||
Authorization = new[] { new foodmarket.Api.Background.SuperAdminHangfireFilter() },
|
||
// Не показываем команды Delete/Requeue по умолчанию из UI чтобы случайные клики
|
||
// не разрушили scheduled — все джобы декларативные, перерегистрация делает их
|
||
// идемпотентной.
|
||
IgnoreAntiforgeryToken = false,
|
||
});
|
||
}
|
||
|
||
// Liveness: процесс отвечает — без обращения к зависимостям (Predicate=false
|
||
// => ни один чек не запускается). Используется для рестарта зависшего контейнера.
|
||
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||
{
|
||
Predicate = _ => false,
|
||
ResponseWriter = WriteHealthResponse,
|
||
});
|
||
|
||
// Readiness: запускает чеки с тегом "ready" (БД + миграции). 503 если не готово.
|
||
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||
{
|
||
Predicate = check => check.Tags.Contains("ready"),
|
||
ResponseWriter = WriteHealthResponse,
|
||
});
|
||
|
||
// Backward-compat: /health = liveness (Dockerfile/nginx исторически бьют сюда).
|
||
app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow }));
|
||
|
||
app.MapGet("/api/_debug/whoami", (HttpContext ctx) =>
|
||
{
|
||
var identity = ctx.User.Identity as System.Security.Claims.ClaimsIdentity;
|
||
return Results.Ok(new
|
||
{
|
||
isAuthenticated = ctx.User.Identity?.IsAuthenticated,
|
||
authType = ctx.User.Identity?.AuthenticationType,
|
||
nameClaimType = identity?.NameClaimType,
|
||
roleClaimType = identity?.RoleClaimType,
|
||
isInRoleAdmin = ctx.User.IsInRole("Admin"),
|
||
hasAdminRoleClaim = ctx.User.HasClaim(c => c.Type == Claims.Role && c.Value == "Admin"),
|
||
claims = ctx.User.Claims.Select(c => new { c.Type, c.Value }),
|
||
});
|
||
}).RequireAuthorization();
|
||
|
||
app.MapGet("/api/me", async (HttpContext ctx, AppDbContext db) =>
|
||
{
|
||
var user = ctx.User;
|
||
var roles = user.FindAll(Claims.Role).Select(c => c.Value).ToList();
|
||
var orgIdRaw = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value;
|
||
var subRaw = user.FindFirst(Claims.Subject)?.Value;
|
||
|
||
// Дополнительные сигналы, чтобы фронт точно знал в каком состоянии юзер
|
||
// находится: orphan AppUser без живой org или без активного Employee
|
||
// получает /no-organization fallback вместо белого экрана на /dashboard.
|
||
bool hasLiveOrg = false;
|
||
bool hasActiveEmployee = false;
|
||
// Sprint 29 (simple-mode): флаг расширенного режима организации.
|
||
// false по умолчанию → UI показывает только «простое» меню.
|
||
// Включает только SuperAdmin через /api/super-admin/organizations/{id}/advanced-mode.
|
||
bool advancedMode = false;
|
||
// Sprint (mobile-api): эффективные права текущего пользователя —
|
||
// единый источник для мобильного и веба (скрывают одно и то же).
|
||
foodmarket.Domain.Organizations.RolePermissions? rolePerms = null;
|
||
List<Guid> retailPointIds = new();
|
||
var isSuperAdmin = roles.Contains(HttpContextTenantContext.SuperAdminRole);
|
||
var isAdmin = roles.Contains("Admin");
|
||
if (Guid.TryParse(orgIdRaw, out var orgId))
|
||
{
|
||
var org = await db.Organizations.IgnoreQueryFilters()
|
||
.Where(o => o.Id == orgId)
|
||
.Select(o => new { o.IsArchived, o.IsAdvancedMode })
|
||
.FirstOrDefaultAsync();
|
||
if (org is not null)
|
||
{
|
||
hasLiveOrg = !org.IsArchived;
|
||
advancedMode = org.IsAdvancedMode;
|
||
}
|
||
if (Guid.TryParse(subRaw, out var sub))
|
||
{
|
||
var emp = await db.Employees.IgnoreQueryFilters()
|
||
.Where(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive && !e.IsDeleted)
|
||
.Select(e => new { e.Id, e.Role })
|
||
.FirstOrDefaultAsync();
|
||
hasActiveEmployee = emp is not null;
|
||
rolePerms = emp?.Role?.Permissions;
|
||
// Точки продаж, к которым привязан сотрудник (для роли Кассир —
|
||
// касса пускает только на «свою» точку). Админ не привязан —
|
||
// пустой список + право RetailPointsManage → доступ ко всем.
|
||
if (emp is not null)
|
||
retailPointIds = await db.Set<foodmarket.Domain.Organizations.EmployeeRetailPointAssignment>()
|
||
.IgnoreQueryFilters()
|
||
.Where(a => a.EmployeeId == emp.Id && a.OrganizationId == orgId)
|
||
.Select(a => a.RetailPointId)
|
||
.ToListAsync();
|
||
}
|
||
}
|
||
|
||
var permissions = foodmarket.Domain.Organizations.RolePermissions.Effective(
|
||
rolePerms, isSuperAdmin, isAdmin, advancedMode);
|
||
|
||
return Results.Ok(new
|
||
{
|
||
sub = subRaw,
|
||
name = user.Identity?.Name,
|
||
email = user.FindFirst(Claims.Email)?.Value,
|
||
roles,
|
||
orgId = orgIdRaw,
|
||
hasLiveOrg,
|
||
hasActiveEmployee,
|
||
advancedMode,
|
||
// Эффективные права (имя→bool) с учётом роли и простого/расширенного
|
||
// режима. Клиенты (моб./веб) гейтят UI по этим флагам.
|
||
permissions,
|
||
// Точки продаж сотрудника (касса пускает кассира только на свою).
|
||
retailPointIds,
|
||
});
|
||
}).RequireAuthorization();
|
||
|
||
// Apply migrations on every startup (idempotent). Without this, fresh
|
||
// stage/prod deploys land on an empty DB and OpenIddict seeders fail.
|
||
using (var scope = app.Services.CreateScope())
|
||
{
|
||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||
db.Database.Migrate();
|
||
}
|
||
|
||
app.Run();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log.Fatal(ex, "Application terminated unexpectedly");
|
||
}
|
||
finally
|
||
{
|
||
Log.CloseAndFlush();
|
||
}
|
||
|
||
// JSON-ответ health-проб: общий статус + по каждому чеку. text/plain по умолчанию
|
||
// неудобен для мониторинга; отдаём структурировано.
|
||
static Task WriteHealthResponse(HttpContext context,
|
||
Microsoft.Extensions.Diagnostics.HealthChecks.HealthReport report)
|
||
{
|
||
context.Response.ContentType = "application/json";
|
||
return context.Response.WriteAsJsonAsync(new
|
||
{
|
||
status = report.Status.ToString(),
|
||
totalDurationMs = report.TotalDuration.TotalMilliseconds,
|
||
checks = report.Entries.Select(e => new
|
||
{
|
||
name = e.Key,
|
||
status = e.Value.Status.ToString(),
|
||
description = e.Value.Description,
|
||
}),
|
||
});
|
||
}
|
||
|
||
// Делает сгенерированный из top-level statements класс Program видимым для
|
||
// интеграционных тестов (WebApplicationFactory<Program>).
|
||
public partial class Program;
|