# Developer guide — food-market Как поднять проект, что куда добавлять, какие паттерны соблюдать. Предполагается, что вы прочитали [ARCHITECTURE.md](ARCHITECTURE.md) и понимаете слои. ## Локальный setup ### Что нужно - **.NET 8 SDK 8.0.4xx** (см. `global.json`, `rollForward: latestFeature` — годится любой 8.0.4xx). - **Node 20+** и **pnpm 9+** (для web). - **PostgreSQL 14+** — на macOS обычно brew `postgresql@14`. БД: `food_market`, owner `nns`, пароль пустой. - **Docker** + **Docker Compose** — только для integration-тестов (Testcontainers) и stage-деплоя. ### Поднять с нуля ```bash git clone http://127.0.0.1:3000/nns/food-market.git cd food-market # 1) БД (если ещё нет) createdb -O nns food_market # пользователь nns должен существовать # 2) Backend ASPNETCORE_ENVIRONMENT=Development \ dotnet run --project src/food-market.api # первый запуск: применит миграции, посеит справочники, создаст # SuperAdmin admin@food-market.local / Admin12345!. # API на http://localhost:5081, Swagger на /swagger. # 3) Web (в другом терминале) cd src/food-market.web pnpm install pnpm dev # http://localhost:5173 # 4) Smoke curl http://localhost:5081/health # и зайти в браузере, залогиниться admin@food-market.local ``` ### Получить токен из CLI ```bash TOKEN=$(curl -sX POST http://localhost:5081/connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d 'grant_type=password&username=admin@food-market.local&password=Admin12345!&client_id=food-market-web&scope=openid profile email roles api offline_access' \ | jq -r .access_token) curl -sH "Authorization: Bearer $TOKEN" http://localhost:5081/api/me | jq ``` ## Запуск тестов ```bash # Unit-тесты (быстрые, ~7-10с) dotnet test tests/food-market.UnitTests/ # Integration (тянут Postgres-контейнер, ~30-60с на холодную) dotnet test tests/food-market.IntegrationTests/ # Фильтр по имени класса/метода dotnet test tests/... --filter "FullyQualifiedName~Fiscal" # Web — type-check + production build cd src/food-market.web && pnpm exec tsc --noEmit && pnpm build # E2E (Playwright против stage) cd tests/e2e && pnpm install pnpm playwright test stage-smoke.spec.ts ``` ### Гочи integration-тестов - **Testcontainers Ryuk** выключен (env `TESTCONTAINERS_RYUK_DISABLED=true`). Причина: контейнер reaper тянет образ с Docker Hub, на dev-vm сеть туда нестабильна. - **Rate-limiter** выключен через `RateLimiting__Enabled=false`. Он читает конфиг ЭАГЕРНО при регистрации сервисов — поэтому только через переменную окружения (см. memory `test_suites_setup`). - **Hangfire-сервер** выключен (`Hangfire__Enabled=false`) — иначе создаёт схему и держит коннект в одноразовом контейнере. - Один `ApiFactory` на всю xUnit-сессию через `[Collection(ApiCollection.Name)]`. Делать второй `WebApplicationFactory` параллельно нельзя — `HostFactoryResolver` сломается. ## Конвенции репо - C# 12, `Nullable` enabled, `ImplicitUsings` enabled. - Названия неймспейсов — `foodmarket.Domain`, `foodmarket.Application`, `foodmarket.Infrastructure`, `foodmarket.Api`. Папки в `src/` — `food-market.api`, `food-market.application`, … (с дефисом). Это расхождение исторически — менять не нужно. - Названия таблиц в БД — snake_case (явный `b.ToTable("retail_sales")`), столбцы — PascalCase из C# (EF default), индексы по `IX__` (EF default). - Комментарии в коде — рассказывающие *почему*, не *что*. Если из имени переменной/метода не понятно — переименуй; если из логики не понятно, *почему* — комментируй. - XML-doc на public API в Application/Infrastructure обязателен (даёт IntelliSense для другой стороны и появляется в Swagger). - Локализация UI — `src/lib/i18n.ts` (русский по умолчанию, есть заготовка под KZ — нужен переводчик). ## Паттерны: добавить controller с permission Пример: `POST /api/loyalty/programs` (создание программы лояльности), доступно только Admin'у орги или SuperAdmin'у в edit-mode. ```csharp // food-market.api/Controllers/Loyalty/LoyaltyProgramsController.cs [ApiController] [Authorize] [Route("api/loyalty/programs")] public class LoyaltyProgramsController : ControllerBase { private readonly AppDbContext _db; private readonly ITenantContext _tenant; private readonly ILogger _log; public LoyaltyProgramsController( AppDbContext db, ITenantContext tenant, ILogger log) { _db = db; _tenant = tenant; _log = log; } public record ProgramInput( [Required] string Name, [Range(1, 4)] int Type, [Range(0, 1000)] decimal Rate, bool IsActive); [HttpPost, RequiresPermission("LoyaltyEdit")] public async Task> Create( [FromBody] ProgramInput input, CancellationToken ct) { var p = new LoyaltyProgram { Name = input.Name.Trim(), Type = (LoyaltyProgramType)input.Type, Rate = input.Rate, IsActive = input.IsActive, // OrganizationId stamping применит в SaveChanges }; _db.LoyaltyPrograms.Add(p); await _db.SaveChangesAsync(ct); _log.LogInformation( "Loyalty program created: {ProgramId} {Name} org={OrgId}", p.Id, p.Name, _tenant.OrganizationId); return Ok(p.Id); } } ``` Что произошло: - `[Authorize]` — JWT-токен обязателен (валидируется OpenIddict). - `[Route("api/...")]` — обычный REST-маршрут. `api/` префикс обязателен для всех контроллеров (web-фронт ходит через `/api/*`, nginx это знает). - `[RequiresPermission("LoyaltyEdit")]` — резолвится в policy `perm:LoyaltyEdit`, handler проверяет `RolePermissions.LoyaltyEdit` булеву у роли текущего юзера. Добавь поле в `RolePermissions.cs` если ещё нет, миграция-`AddColumn` для `bool LoyaltyEdit NOT NULL DEFAULT false` + апдейт admin-роли в сидере. - `ProgramInput` — record с DataAnnotations. Для сложной валидации — отдельный FluentValidation `AbstractValidator` в `food-market.api/Infrastructure/Validation/Validators.cs` (см. паттерны там). - `_db.LoyaltyPrograms.Add(p)` без явного `OrganizationId` — `StampTenant` в `SaveChangesAsync` подставит. - Логирование структурированное: `{ProgramId}`, `{Name}`, `{OrgId}` — Serilog кладёт их как property'и (search'абельны в Loki/ES, не строкой). ### Если нужен Admin-only (грубее) ```csharp [HttpPut, Authorize(Roles = "Admin")] ``` это эквивалентно «или Identity-role Admin, или SuperAdmin в режиме override (через `SuperAdminOverrideClaimsTransformer`)». Подходит для редких операций; для регулярных используй `RequiresPermission`. ## Паттерны: добавить сущность с RowVersion и tenant Допустим, нужна новая сущность `PromoCode`. ### 1. Domain ```csharp // food-market.domain/Sales/PromoCode.cs public class PromoCode : TenantEntity, IVersionedEntity { public uint Xmin { get; set; } public string Code { get; set; } = ""; public decimal Discount { get; set; } public DateTime? ExpiresAt { get; set; } public bool IsActive { get; set; } = true; } ``` `TenantEntity` даёт `Id`, `CreatedAt`, `UpdatedAt`, `OrganizationId`. `IVersionedEntity` + `Xmin` — оптимистичная блокировка через PG xmin. ### 2. EF Configuration ```csharp // food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs b.Entity(e => { e.ToTable("promo_codes"); e.UseXminAsConcurrencyToken(); e.Ignore(x => x.Xmin); e.Property(x => x.Code).HasMaxLength(40).IsRequired(); e.Property(x => x.Discount).HasPrecision(18, 4); e.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique(); // ← OrganizationId первым! e.HasIndex(x => new { x.OrganizationId, x.IsActive }); }); ``` **Важно**: индексы — с `OrganizationId` первым полем. Все запросы пройдут через query filter и будут фильтроваться по этому полю; без правильного индекса PG будет full-scan тенант-таблицы. ### 3. DbSet ```csharp // food-market.infrastructure/Persistence/AppDbContext.cs public DbSet PromoCodes => Set(); ``` ### 4. Миграция руками ```csharp // food-market.infrastructure/Persistence/Migrations/20260608100000_PromoCodes.cs [DbContext(typeof(AppDbContext))] [Migration("20260608100000_PromoCodes")] public partial class PromoCodes : Migration { protected override void Up(MigrationBuilder b) { b.CreateTable( name: "promo_codes", schema: "public", columns: t => new { Id = t.Column(type: "uuid", nullable: false), OrganizationId = t.Column(type: "uuid", nullable: false), Code = t.Column(type: "character varying(40)", maxLength: 40, nullable: false), Discount = t.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), ExpiresAt = t.Column(type: "timestamp with time zone", nullable: true), IsActive = t.Column(type: "boolean", nullable: false, defaultValue: true), CreatedAt = t.Column(type: "timestamp with time zone", nullable: false), UpdatedAt = t.Column(type: "timestamp with time zone", nullable: true), }, constraints: t => t.PrimaryKey("PK_promo_codes", x => x.Id)); b.CreateIndex( name: "IX_promo_codes_OrganizationId_Code", schema: "public", table: "promo_codes", columns: new[] { "OrganizationId", "Code" }, unique: true); b.CreateIndex( name: "IX_promo_codes_OrganizationId_IsActive", schema: "public", table: "promo_codes", columns: new[] { "OrganizationId", "IsActive" }); } protected override void Down(MigrationBuilder b) => b.DropTable("promo_codes", "public"); } ``` **Обязательно**: `[DbContext]` + `[Migration("...")]` атрибуты — без них `db.Database.Migrate()` миграцию не подхватит (memory: `feedback_ef_migrations`). ### 5. Тест на изоляцию Добавить case в `TenantIsolationTests`: org A создаёт PromoCode, org B делает GET, видит пустой список. ## Валидация ### Простые правила — DataAnnotations ```csharp public record ProductInput( [Required, MaxLength(200)] string Name, [Range(0, 1e10)] decimal Price); ``` ### Сложные — FluentValidation В `food-market.api/Infrastructure/Validation/Validators.cs`: ```csharp public sealed class ProductInputValidator : AbstractValidator { public ProductInputValidator() { RuleFor(x => x.Name).NotEmpty().MaximumLength(200); RuleFor(x => x.Price).GreaterThanOrEqualTo(0); // Кросс-полевые правила, async, реализующие бизнес-инвариант RuleFor(x => x.Lines).NotEmpty().WithMessage("Хотя бы одна позиция."); RuleForEach(x => x.Lines).ChildRules(line => { line.RuleFor(l => l.Quantity).GreaterThan(0); line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0); }); } } ``` Валидаторы регистрируются автоматически через `AddValidatorsFromAssemblyContaining()`. `ValidationFilter` (глобальный action-filter в Program.cs) запускает их на каждом action и возвращает 400 ProblemDetails (RFC 7807). ### Бизнес-валидация (требует БД) Если правило требует справиться с БД (например, «склад существует и не архивирован»), вынесите в первый шаг action-метода: ```csharp [HttpPost] public async Task Create(ProductInput input, CancellationToken ct) { var groupOk = await _db.ProductGroups.AnyAsync(g => g.Id == input.ProductGroupId, ct); if (!groupOk) return BadRequest(new { error = "Группа не найдена.", field = "productGroupId" }); // ... } ``` Формат ошибки — `{ error: "...", field?: "..." }`. Фронт показывает `error` тостом, `field` подсвечивает в форме. ## Логирование Используем Serilog со структурированными полями. `LogEnrichmentMiddleware` уже добавляет `CorrelationId/OrgId/UserId` в каждую запись. ### Правила - **Не строкой**: `_log.LogInformation("Created product " + id)` — нет. `_log.LogInformation("Product created: {ProductId} name={Name}", id, name)` — да. - **Уровень**: - `Trace/Debug` — только для отладки конкретного бага. - `Information` — успешные mutate-операции, важные events (post/unpost документа, регистрация чека в ОФД). - `Warning` — что-то пошло не как ожидалось, но обработали (best-effort fail, retry-able ошибка). - `Error` — обработать не удалось, нужен внимательный человек. - `Critical` — приложение в плохом состоянии, может перестать работать. - **Не логировать** PII в открытом виде (пароли, токены, email — email можно, но не светить лишний раз). - **Exception как первый аргумент**: `_log.LogError(ex, "...", ...)`, не `_log.LogError("... " + ex.Message)` — теряется stack trace. ### Пример из RetailSalesController ```csharp _log.LogInformation( "RetailSale posted: {SaleNumber} store={StoreId} payment={Payment} lines={LinesCount} total={Total}", sale.Number, sale.StoreId, sale.Payment, sale.Lines.Count, sale.Total); // ... try { await _notify.PublishAsync(...); } catch (Exception ex) { // Notification — best-effort: не должна валить транзакцию (она уже закоммичена) _log.LogWarning(ex, "SignalR notify failed for sale {SaleId}", sale.Id); } ``` ## SignalR realtime Если нужно отправить уведомление на фронт (инвалидация query'я, показ тоста): ```csharp // в Program.cs INotificationsPublisher уже зарегистрирован public class MyController : ControllerBase { private readonly INotificationsPublisher _notify; [HttpPost("...")] public async Task Action(...) { // ... business logic ... await _notify.PublishAsync( organizationId, NotificationEvents.SalePosted, // строковая константа new SalePostedPayload(...)); // record DTO return NoContent(); } } ``` На фронте — `useNotifications()` хук подписан на хаб и инвалидирует relevant query'и. Новые event'ы добавлять в `NotificationEvents`, payload — в соседнем record'е. ## Что НЕ делать - НЕ инжектить `IServiceProvider` чтобы доставать сервисы лениво — объяви явные зависимости в конструкторе. Исключение: фабрики (Fiscal, Email) которые открывают scope для свежего DbContext'а. - НЕ писать raw SQL (`SqlQueryRaw`/`ExecuteSqlRaw`) без явного `WHERE OrganizationId = @org` — query-filter не применится. - НЕ менять снапшот в `Persistence/Migrations/AppDbContextModelSnapshot.cs` руками для добавления новых полей — он используется только инструментом `dotnet ef migrations add`, который мы не запускаем. Trying to add partial state ломает только инструмент, ничего не дав. Если хочется — обновляй целиком, синхронно с моделью; иначе оставь как есть. - НЕ добавлять платные компоненты: Kendo, DevExpress, Syncfusion commercial, Telerik (CLAUDE.md). - НЕ менять `global.json` без согласования (CLAUDE.md). - НЕ создавать миграции через `dotnet ef migrations add` — пиши руками (memory: `feedback_ef_migrations`). - НЕ делать `git push --force` на main (Forgejo — primary). ## Что добавилось после первого релиза этого guide'а | Sprint | Чем пользоваться | |---|---| | 13 | `SensitiveOpsAudit` (`food-market.api/Infrastructure/Audit/`) — централизованный логгер sensitive-операций. Вместо ручного `OrgAuditLogs.Add` — `_audit.LogAsync(action, entityType, entityId, payload)`. | | 13 | `[RequiresPermission("X")]` уже было; добавился `MeSessionsController.RevokeAll` — пример работы с `IOpenIddictAuthorizationManager`. | | 13 | Все ответы автоматически получают security-заголовки через `SecurityHeadersMiddleware`. Если новый endpoint требует ослабленную CSP (например, embeds другой домен) — добавь его path в `ShouldSkip` middleware'a. | | 14 | Композитный индекс `(OrganizationId, …)` на новых таблицах — must. Для отчётных запросов с фильтром по статусу — добавляй partial index `WHERE Status = X` с `INCLUDE` (covering). | | 14 | `ImageVariantService` — при upload картинок автоматически генерирует thumb/medium WebP. Через frontend `` — `` + srcset. | | 14 | Hangfire-jobs: `JobTimingFilter` глобально логирует длительность; >30с — Warning. Для своих jobs ничего настраивать не надо. | | 15 | `useFocusTrap` (`src/lib/useFocusTrap.ts`) — focus trap + return-focus для модалок. Уже подключён в `Modal` и `ConfirmDialog`. Для своего модала: `const ref = useFocusTrap(open)`. | | 15 | a11y: каждая icon-only `