# 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<Program>` параллельно нельзя —
  `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_<table>_<cols>` (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<LoyaltyProgramsController> _log;

    public LoyaltyProgramsController(
        AppDbContext db, ITenantContext tenant, ILogger<LoyaltyProgramsController> 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<ActionResult<Guid>> 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<ProgramInput>` в
  `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<PromoCode>(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<PromoCode> PromoCodes => Set<PromoCode>();
```

### 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<Guid>(type: "uuid", nullable: false),
                OrganizationId = t.Column<Guid>(type: "uuid", nullable: false),
                Code = t.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
                Discount = t.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
                ExpiresAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: true),
                IsActive = t.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
                CreatedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: false),
                UpdatedAt = t.Column<DateTime>(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<ProductInput>
{
    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<Program>()`. `ValidationFilter`
(глобальный action-filter в Program.cs) запускает их на каждом
action и возвращает 400 ProblemDetails (RFC 7807).

### Бизнес-валидация (требует БД)

Если правило требует справиться с БД (например, «склад существует и
не архивирован»), вынесите в первый шаг action-метода:

```csharp
[HttpPost]
public async Task<ActionResult> 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<IActionResult> 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 `<ProductImage src={url} size="thumb" />` — `<picture>` + srcset. |
| 14 | Hangfire-jobs: `JobTimingFilter` глобально логирует длительность; >30с — Warning. Для своих jobs ничего настраивать не надо. |
| 15 | `useFocusTrap` (`src/lib/useFocusTrap.ts`) — focus trap + return-focus для модалок. Уже подключён в `Modal` и `ConfirmDialog`. Для своего модала: `const ref = useFocusTrap<HTMLDivElement>(open)`. |
| 15 | a11y: каждая icon-only `<button>`/`<a>` нуждается в `aria-label="..."` и `aria-hidden="true"` на иконке внутри. Поля формы с ошибкой — `aria-invalid={true}` + `aria-describedby="err-id"` + `<span id="err-id" role="alert">...</span>`. Цвет текста для маленького font'а — `text-slate-500` минимум (4.61 contrast), не `text-slate-400` (2.63, fails WCAG AA). |
| 15 | Unit-coverage цель — 70% по строкам в Application+Domain. Добавляешь новую POCO → один touch-test в `DomainPocoSmokeTests`/`DomainFullPropertyTouchTests`. Property-based тест на бизнес-инвариант — `StockServicePropertyTests`-pattern (рандомные seed'ы, проверка инварианта). |

## Что НЕ делать

- НЕ инжектить `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).
- НЕ использовать `text-slate-400` для маленьких подписей на белом
  фоне — fails WCAG AA color contrast (Sprint 15). Минимум `text-slate-500`.
- НЕ делать icon-only `<button>`/`<a>` без `aria-label` — Screen readers
  пропустят его (Sprint 15 axe-core finding).

## Полезные ссылки

- [ARCHITECTURE.md](ARCHITECTURE.md) — слои и модули.
- [MULTI-TENANCY.md](MULTI-TENANCY.md) — query-filter, override-режим,
  расширенный чеклист «как добавить tenant-сущность».
- [RUNBOOK.md](RUNBOOK.md) — операционные процедуры, recovery drill
  (RTO ~25с подтверждённый).
- [openapi.md](openapi.md) — генерация TS-клиента из Swagger.
- [observability.md](observability.md) — Serilog + Prometheus + Grafana
  dashboard JSON (Sprint 13).
- [ofd-integration.md](ofd-integration.md) — ОФД-провайдеры (Sprint 11).
- [performance-baseline.md](performance-baseline.md) — k6-замеры (Sprint 12, 14).
- [secrets.md](secrets.md) — где живут секреты.
