Два бага в refresh-flow, из-за которых утёкший refresh-token давал доступ
после ротации (auth-edge step03):
1. AuthorizationController прокидывал в новый principal только
AuthorizationId, но не TokenId. Handler RedeemTokenEntry читает TokenId
из подписываемого principal, чтобы пометить погашаемый refresh как
Redeemed — без него старый токен оставался valid.
2. Даже после починки редемпшна OpenIddict по умолчанию даёт 30-секундный
reuse-leeway: погашенный refresh ещё принимается в этом окне. Обнуляем
окно (SetRefreshTokenReuseLeeway(TimeSpan.Zero)) — ротация инвалидирует
старый refresh сразу.
auth-edge: 10/10 (было 9/10).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Два P0-бага выявлены сценарием documents-edge:
BUG #11 (high): RetailSale.Post не проверял PaidCash+PaidCard ≥ Total.
Касса могла «провести» чек с фактической оплатой 0 — товар уходит
со склада, деньги не получены. Добавлена валидация: paid (округлённое
до 2 знаков) ≥ total, иначе 400 «Сумма оплаты меньше итога».
Сдача (PaidCash > Total) остаётся легальной.
BUG #12 (critical): Supply.Unpost не проверял, не уйдёт ли Stock в
минус после реверса. Сценарий: приёмка 10шт → продажа 5шт → unpost
приёмки ⇒ stock = -5. Это нарушение инварианта учёта. Добавлен guard:
агрегируем reverse-quantity по продукту, сравниваем с текущим
Stock.Quantity, при недостаче возвращаем 409 со списком конфликтных
строк.
Покрыто E2E documents-edge step05 (PaidCash<Total → 4xx) и step08
(unpost после sale → 409): обе проверки теперь зелёные.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Итоги сессии системного тестирования:
- full-cycle: 12/12 ✓
- multi-tenant-isolation: 12/12 ✓ (новый сценарий)
Найдено и исправлено 10 P0-багов: 7 в миграциях (расхождения схемы
с domain, отсутствующие [Migration] атрибуты, rudiment колонки Kind),
1 в безопасности (edit-mode override блокировался Authorize-ролями).
См. tests/e2e/reports/systemic-2026-05-23.md для полного описания
каждого бага, gap'ов и команд воспроизведения.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Новый E2E-сценарий покрывает критичную для multi-tenant SaaS поверхность:
1. Создание двух независимых организаций (Alpha и Beta) через SuperAdmin.
2. Логин под admin'ами Alpha и Beta, проверка разных org_id в JWT.
3. Alpha seed'ит counterparty + product.
4. Beta GET по прямым ID Alpha → 404 (не 200, не 403, не 500).
5. Beta GET листинги — Alpha-записей нет.
6. Beta PUT/DELETE по ID Alpha с валидным телом → 404.
7. Beta POST product со ссылкой на supplier Alpha → 4xx.
8. Beta-admin подделывает X-Org-Override:{alphaId} → запрос
игнорирует заголовок (только SuperAdmin может override).
9. SuperAdmin без override видит обе организации.
10. SuperAdmin + X-Org-Override без reason → read-only (PUT 403).
11. SuperAdmin + X-Org-Override + Reason ≥10 → PUT 200, audit_log растёт.
12. Stock + StockMovements Alpha не видны Beta.
Применение: `bash tests/e2e/run.sh multi-tenant-isolation --api-only`.
Использует ту же runner-инфраструктуру что и full-cycle.yml.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Проблема: в режиме «открыть как…» (SuperAdmin + X-Org-Override) с reason ≥10
символов ReadonlyOverrideMiddleware пропускает PUT/POST/DELETE, но затем
контроллер падает 403 на атрибуте [Authorize(Roles="Admin,Storekeeper")] —
у SuperAdmin'а нет роли Admin тенанта. Результат: edit-mode фактически
не работает ни на одном tenant-эндпоинте.
Симптом, обнаруженный E2E:
step11_superadmin_edit_override_with_reason: PUT → 403 «Forbidden»,
super_admin_audit_log не растёт.
Фикс: новый SuperAdminOverrideClaimsTransformer (IClaimsTransformation).
При каждом запросе с заголовком X-Org-Override и ролью SuperAdmin
временно добавляет роли Admin/Storekeeper/Cashier в principal — только
для этого запроса. Изоляция и аудит остаются:
- query filter всё равно скоупится через X-Org-Override (см.
HttpContextTenantContext.TryGetHttpOverrideOrg).
- SuperAdminEditAuditFilter пишет SuperAdminAuditLog с reason
при успешном 2xx ответе.
Проверено E2E multi-tenant-isolation: 12/12 шагов проходят.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Проблема: на свежей PostgreSQL `dotnet ef database update` падает на пяти
миграциях подряд + рантайм-несовместимость схемы с domain Product/Store/
Counterparty. Невозможно поднять стек ни на dev, ни на новом стейдже.
Найдено и починено:
1. Phase2c4_ReconcileStage пыталась AddColumn IsMarked, который Phase1Catalog
(после рефакторинга) уже добавляет. Завернули в IF NOT EXISTS.
2. Phase5d_ProductVatDecimal ALTER COLUMN products.Vat падал — Vat теперь
заменён на FK VatRateId, колонки нет. Завернули в IF EXISTS.
3. Phase5c_UnitsOfMeasureGlobal INSERT канонических ОКЕИ пропускал NOT NULL
колонку Symbol (а также DecimalPlaces, IsBase, CreatedAt). Дополнили
полным набором: шт/кг/л/м/уп.
4. Phase5d_DropUnitOfMeasureDescription дропала несуществующую колонку
(Description в новой схеме отсутствует). Завернули в IF EXISTS.
5. Phase5a_EmployeeSoftDelete и Phase5b_PlatformSettings были написаны
вручную без атрибутов [Migration] + [DbContext] — EF их игнорировал
и пропускал применение (см. memory/feedback_ef_migrations.md).
Добавили атрибуты + сделали идемпотентными.
6. Новая Phase5f_DropStoreKindRudiment: rudimentные колонки stores.Kind и
counterparties.Kind (NOT NULL без default'а) роняли любой INSERT —
ни одной организации/контрагента создать нельзя. Дропаем.
7. Новая Phase5g_ProductVatRealign: приводим products в соответствие с
domain — дропаем FK→vat_rates + колонку VatRateId + IsAlcohol + пустую
таблицу vat_rates; добавляем products.Vat numeric(5,2) DEFAULT 12 и
VatEnabled bool DEFAULT true. Без этого ProductsController падает 42703
при создании любого товара.
Все миграции идемпотентны (DO $$ ... IF EXISTS/NOT EXISTS ...) — повторное
применение на старой стейдж-БД безопасно.
Проверено: E2E full-cycle на свежей dev-БД проходит 12/12 шагов.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- onBlur читает e.target.value напрямую из DOM (нет stale closure)
- onChange не очищает ошибку, а ре-валидирует (только если ошибка уже показана)
- Устраняет баг на мобильном: blur иногда стреляет раньше последнего onChange
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Ошибки полей теперь показываются сразу после потери фокуса — без
нажатия «Сохранить». При начале ввода ошибка убирается (onChange).
Submit-валидация остаётся без изменений.
Охват: SignupForm (public), LoginPage, ForgotPasswordPage,
ResetPasswordPage, EmployeesPage, CounterpartiesPage, StoresPage,
OrganizationSettingsPage, SuperAdminOrgCreatePage, PriceTypesPage,
EmployeeRolesPage, RetailPointsPage.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Серверная защита от пустого RetailSale пришла вместе с FK-валидацией
(commit 5716829: «Чек должен содержать хотя бы одну позицию» → 400).
Этот коммит — UI-сторона.
В RetailSaleEditPage кнопка «Сохранить» теперь disabled когда
form.lines.length === 0, с tooltip «Добавьте хотя бы одну позицию».
Раньше пользователь мог нажать Сохранить, получить 400 «empty lines»,
не понять что делать.
Гэп из e2e-отчёта: новая орга стартует с пустым каталогом групп, и
ProductsController.Create падает с 400 «ProductGroupId required» пока
юзер вручную не заведёт группу. Это плохой UX — особенно для quick-
create товара из чека или приёмки.
Что сделано:
- ProductGroup получил поле IsSystem (default false) + миграция Phase5e.
- DevDataSeeder.SeedTenantReferencesAsync теперь создаёт идемпотентно
системную группу «Все товары» (IsSystem=true) при bootstrap'е новой
org. Та же логика срабатывает в SuperAdminOrganizationsController.Create
и AuthSignupController, потому что оба зовут SeedTenantReferencesAsync.
- ProductGroupsController.Delete: системная группа защищена от удаления
(400 «Системную группу удалить нельзя.»). Иначе продукты могли бы
осиротеть после ON DELETE RESTRICT.
- ProductEditPage / ProductQuickCreateModal: при создании нового товара
автоматически выбирают «Все товары» (или единственную группу), чтобы
пользователь мог сохранить продукт без лишнего клика.
Было: SupplyInput.SupplierId/StoreId/CurrencyId — non-nullable Guid. Если
JSON приходит без поля или с null, оно десериализуется в Guid.Empty, и
ошибка проявляется только на SaveChanges как PostgresException 23503
(FK violation) с HTTP 500. UI получает generic 500 и не понимает какое
поле виновато.
Что изменено:
- Добавлен helper RequiredGuid.FirstMissing(...) — возвращает имя первого
Guid.Empty поля или null.
- SuppliesController.Create/Update, RetailSalesController.Create/Update,
ProductsController.Create/Update — теперь начинают с проверки FK-Guid'ов
и возвращают 400 {error, field} если какое-то пусто.
- В тех же контроллерах SaveChanges обёрнут в SaveOrFkErrorAsync, который
ловит PostgresException SqlState=23503 (foreign_key_violation), парсит
ConstraintName и возвращает 400 вместо 500. Защита для случая когда
Guid не пуст, но указывает на удалённую/чужую запись.
TaskUpdate: closes step08-bug «Supply без supplierId → 500».
Сценарий из e2e: остаток 10, продаём 99999 → /post возвращал 204
[Posted] и стоки уходили в минус. Это критично — кассир мог провести
чек на товар, которого нет, и в БД появлялся отрицательный остаток.
Что изменено:
- В Post перед SaveChanges собираем сумму запрошенного qty по каждому
productId (учитываем дубль одного товара в нескольких строках чека).
- Читаем stocks.Quantity для всех затронутых productId на конкретном
StoreId одним запросом.
- Если хоть по одной строке available < requested — возвращаем 409
с body {error, lines:[{productId, productName, qty, available}]},
не делаем SaveChanges.
- Всё под BeginTransactionAsync(Serializable): защищает от race condition
между двумя одновременными post'ами на один товар (без блокировки оба
бы прочли «5», списали по 3, получили бы −1).
Description у пяти канонических ОКЕИ-единиц никогда не заполнялось ни UI,
ни импортом, ни сидером — выкидываем поле полностью (Domain → EF-config
→ DTO → Input → frontend types → Super-Admin форма). Migration
Phase5d_DropUnitOfMeasureDescription дропает колонку.
Code оставляем в БД (нужен для интеграций МойСклад/1С), но скрываем от
org Admin'а:
- /catalog/units-of-measure — только колонки Name + кнопка toggle, без
Code и Description; поиск/сортировка только по Name.
- /super-admin/units-of-measure — Code продолжает показываться в таблице
и форме редактирования.
Дропдаун единиц в ProductEditPage / ProductQuickCreateModal уже отдаёт
только {u.name} в options, проверено. На SupplyEditPage/RetailSaleEditPage
в строках документа отображается unitName, Code не показывался — без
изменений.
Stage прогон против commit ee127b2:
- 9 ✓ / 0 ✗ / 0 ⚠ / 3 ◯ (baseline: 8/1/0/3)
- step05 Cashier полностью зелёный: Identity-role «Cashier» маппится,
/api/organization/employees → 403
- step01 новая проверка серверной phone-ФЛК → 400 на невалидном
- step08 «Нет ни одной единицы измерения» исчез — новая орга получает
5 active globals через junction сразу при создании
- HIGH bug сменился: теперь блокер «product требует штрихкод» (отдельный
вопрос — либо баг ProductsController, либо e2e-сценарий должен
передавать barcode)
stage api зашёл в crash-loop после деплоя phase5c: DevDataSeeder упал
с «column IsActive does not exist», потому что миграция Phase5c не
была подхвачена db.Database.Migrate(). EF Core ищет миграции по
[MigrationAttribute] на классе (или Designer-файле, который этот
атрибут содержит). Без него миграция в сборке есть, но не известна
runtime-механизму.
Также чиню e2e: URL единиц был /api/catalog/units (404), правильный —
/api/catalog/units-of-measure.
Было: каждая орга держала свои 5 копий («штука», «кг», ...). 95 строк
в БД на 19 орг — duplication, любой Admin мог их редактировать.
Стало: 5 globals (OrganizationId=NULL), CRUD только у SuperAdmin. Орга
включает нужные единицы у себя через junction org_units_of_measure.
Backend:
- UnitOfMeasure: добавлен IsActive (для soft-delete с filtered unique index)
- Новый OrgUnitOfMeasure (junction PK Organization+Unit, FK Restrict)
- Migration Phase5c_UnitsOfMeasureGlobal: безопасная для prod —
поднимает по одной строке на (Code, Name) до global, remap'ит
products.UnitOfMeasureId, наполняет junction по факту существующих
привязок, удаляет дубликаты.
- /api/catalog/units-of-measure для org Admin: read-only список
enabled-globals + POST/DELETE /enable для toggle
- /api/super-admin/units-of-measure: full CRUD; DELETE soft (IsActive=false)
с 409 если есть products или active org-junction (со списком орг)
- DevDataSeeder.SeedTenantReferencesAsync вместо создания per-tenant
юнитов — auto-enable всех active globals через junction
Frontend:
- /catalog/units — checkbox-список (включить/выключить); CTA на платформу
для SuperAdmin
- /super-admin/units — full CRUD над глобалами, 409 со списком
организаций при попытке деактивировать используемую единицу
Logic gap из e2e-отчёта: SuperAdmin /organizations принимал любой текст
в Phone — серверной валидации ФЛК не было (только в /api/auth/signup).
Это позволяло сохранить «abc» в Organization.Phone и невалидные номера
для контрагентов и сотрудников.
— Application/Common/PhoneNormalization.cs (новый): TryNormalizeKz +
IsValidOrEmpty. Принимает любое форматирование, ведущая «8» → «7»;
валидно: 11 цифр, начинается с «77» (мобильный код KZ).
— SuperAdminOrganizationsController.Create/Update: 400 если phone не
парсится; в БД пишется нормализованная форма «+77001234567».
— CounterpartiesController.Create/Update: то же. Apply() нормализует.
— EmployeesController.Create/Update: то же.
— SuperAdminEmployeesController.Create/Update: то же.
— AuthSignupController: убран локальный NormalizeKzPhone, используется
shared. Сообщение об ошибке унифицировано.
Defense-in-depth к фронтовой валидации (PhoneInput / validatePhone).
Незаполненный phone остаётся валидным для опциональных полей —
контроллер сам решает требовать или нет.
Найдено в e2e-прогоне (отчёт reports/full-cycle-2026-05-07-baseline.md):
- GET /api/organization/employees вернул 200 для Cashier (ожидалось 403).
- Cashier у созданного через POST /employees вообще не получает
Identity-роли — серверная авторизация не работает.
Корни:
1. EmployeesController имел class-level [Authorize] без roles,
List/Get не имели per-method [Authorize(Roles=...)] — поэтому любой
аутентифицированный юзер мог читать список сотрудников.
2. EmployeesController.Create при createAccount=true вызывал
_userMgr.CreateAsync, но НЕ вызывал AddToRoleAsync — у созданного
юзера не было ни одной Identity-роли.
Фиксы:
- Class-level `[Authorize(Roles = "SuperAdmin,Admin")]` на
EmployeesController. Теперь List/Get/Create/Update/Delete все
требуют Admin (или SuperAdmin override). Per-method дубль убран.
- Новый helper `Api/Infrastructure/IdentityRoleMapper.cs`:
Администратор → Admin, Кладовщик → Storekeeper, Кассир → Cashier.
Кастомные orgRole не получают Identity-роли (это by-design — они
дают UI-permissions внутри org, без доступа к role-locked endpoint'ам).
- EmployeesController.Create вызывает AddToRoleAsync с замапленной
Identity-ролью если такая есть.
- SuperAdminEmployeesController.Create аналогично — вместо хардкод
"Admin" использует mapper с fallback на "Admin" (по запросу юзера
при создании учётки SuperAdmin'ом).
После фикса в e2e:
- Cashier → GET /api/organization/employees → 403 (было 200).
- /connect/token → /api/me содержит roles=["Cashier"].
- Cashier → /api/sales/retail-sales → 200 (рабочая авторизация).
Пункты 5 + 6 пакета SMTP-настроек.
API (AuthForgotPasswordController, anonymous):
- POST /api/auth/forgot-password { email }
· IP rate-limit: 3 попытки в час (in-memory ConcurrentDictionary
с per-IP списком timestamps; для одного API-инстанса хватает,
при scale-out — Redis).
· ВСЕГДА возвращает 200 (анти-юзер-энумерация). Реально шлёт письмо
только если юзер найден И активен И имеет email; иначе тихо
логирует и отдаёт 200.
· Использует UserManager.GeneratePasswordResetTokenAsync (Identity
AddDefaultTokenProviders уже подключён в Program.cs).
· Письмо: ссылка вида https://admin.food-market.kz/reset-password
?email=...&token=... (1 час валидна).
· Если SMTP не настроен — ловит EmailNotConfiguredException,
логирует и всё равно отдаёт 200 (UX-friendly).
- POST /api/auth/reset-password { email, token, newPassword }
· UserManager.ResetPasswordAsync. На InvalidToken — понятный
«Ссылка недействительна или истекла».
· После успеха revoke всех valid-OpenIddict tokens юзера
(UPDATE OpenIddictTokens SET Status='revoked' WHERE Subject=...).
UI:
- /forgot-password — anonymous, форма с email; submit → 200 «проверьте
почту» (одинаковый текст независимо от существования email).
- /reset-password — anonymous, читает email/token из query-string;
поля «новый пароль» + «повторите»; после успеха — auto-redirect
через 2.5 секунды на /login.
- LoginPage: добавлена ссылка «Забыли пароль?» под кнопкой «Войти».
Smoke-флоу:
1. SuperAdmin → /super-admin/platform-settings → SMTP creds + test-send.
2. Юзер → /login → «Забыли пароль?» → /forgot-password → email.
3. Письмо с ссылкой → /reset-password?email&token → новый пароль.
4. Login со старым паролем — отказ (revoked refresh + новый pwd).
5. Login с новым паролем → норма.
Страница SuperAdminPlatformSettingsPage в SuperAdmin консоли:
- Форма SMTP: Host, Port, выбор шифрования (STARTTLS / Implicit TLS / без),
Username, Password (placeholder показывает «(без изменений)» если уже
сохранён), FromEmail (обязательно), FromName.
- Поле «Причина изменения» (≥10 символов) — обязательно для PUT, пишется
в SuperAdminAuditLog.
- Блок «Тестовая отправка»: To/Subject/Body, кнопка реально шлёт через
текущие сохранённые настройки, ответ сервера показывается зелёной/
красной плашкой с message (для SuperAdmin диагностический текст MailKit
виден целиком).
Добавлен пункт меню в SuperAdminLayout «SMTP / Email» в группе «Тех.
обслуживание». Роут /super-admin/platform-settings зарегистрирован в App.tsx.
Пункт 2 + 3 пакета SMTP-настроек.
Backend:
- IEmailSender (Application/Common/Email) — общий интерфейс отправки
одного письма; EmailNotConfiguredException — для контроллеров чтобы
ловить и отдавать понятный 400 вместо 500.
- MailKitEmailSender (Infrastructure/Email) — реализация:
· регистрируется Singleton, на каждой отправке открывает scope для
свежего AppDbContext (конфиг перечитывается без рестарта);
· читает PlatformSettings из БД, расшифровывает пароль через
IDataProtector("foodmarket.smtp");
· поддержка SmtpUseSsl (implicit TLS / 465) и SmtpStartTls (587);
оба false → открытое соединение (для dev/MailHog);
· бросает EmailNotConfiguredException если host или from-email пусты,
или если расшифровка пароля падает (ключ DataProtection ротировался).
API:
- PlatformSettingsController:
GET /api/super-admin/platform-settings — все поля КРОМЕ пароля
(только has-password флаг + updatedAt).
PUT — принимает Reason (≥10) + все поля + опциональный
NewSmtpPassword. Спец-значение "__clear__" снимает пароль.
Пароль шифруется через DataProtection при записи. Audit-log.
POST /test-send — реальная отправка через текущие настройки;
ловит EmailNotConfiguredException → 400, остальные → 500
с message (SuperAdmin-only, diagnostic-info нужна).
DI:
- AddSingleton<IEmailSender, MailKitEmailSender>;
- AddDataProtection (default file-system key store ASP.NET Core).
Пакеты:
- MailKit 4.10.0 (4.8 имел moderate-severity advisory).
- Microsoft.AspNetCore.DataProtection 8.0.11 (transitive в API уже
был через OpenIddict, но Infrastructure нужен явный reference).
Платформенные настройки: один row, не tenant-scoped, видны и меняются
только Супер-администратором. Хранят SMTP-креды для исходящей почты
(forgot-password, нотификации). IMAP к этому отношения не имеет — IMAP
для чтения входящей, нам нужен SMTP.
Поля:
- SmtpHost, SmtpPort
- SmtpUseSsl (implicit TLS / 465) и SmtpStartTls (587, по дефолту true)
- SmtpUsername, SmtpPasswordEncrypted (хранится зашифрованным через
DataProtection API; в API-ответах не выходит, только has-password флаг)
- FromEmail, FromName
Миграция Phase5b_PlatformSettings создаёт таблицу public.platform_settings.
Конфиг EF в AppDbContext: HasMaxLength для строк.
Завершающий пункт пакета фиксов по ролям/валидации/удалению. Обход:
1. /connect/token — IsActive + BelongsToLiveOrg + SuperAdmin bypass.
2. JWT cookie vs Bearer — все три AuthN-схемы переопределены в
OpenIddictValidationAspNetCoreDefaults; cookie не активна для API.
3. X-Org-Override — фильтрует по IsInRole(SuperAdmin), подделать нельзя.
4. Tenant query filters — ITenantEntity и IOptionalTenantEntity
подключаются через reflection, фильтр консистентен с tenant.context.
5. Smoke per-role — sidebar+RoleGuard за один проход покрывает все
tenant-роуты; tenant-admin на /super-admin URL → описан risk + future fix.
6. Reset password / deactivate account — токены revoke в БД одним SQL.
7. Catch-22 для SuperAdmin платформы — он не Employee и не имеет
OrganizationId, через текущие endpoint-ы deactivate невозможен.
Findings разбиты на critical (закрыто этим пакетом), high/medium (не
закрыто — будущая серия) и low (косметика).
Раньше Sidebar строился только по флагу isSuperAdmin, и Кассир/
Кладовщик видели весь меню (включая «Сотрудники», «Контрагенты»,
«Настройки»), хотя серверные [Authorize(Roles = "Admin")] возвращали
бы 403 на каждом запросе.
Теперь:
- AppLayout.buildNav берёт roles[] из /api/me и собирает меню per-role:
· Каталог: Кассиру/Кладовщику только Товары; Admin — всё.
· Контрагенты: только Admin.
· Остатки: видят все три tenant-роли. Движения — Admin/Storekeeper.
· Закупки: Admin/Storekeeper.
· Продажи: Admin/Cashier.
· Импорт МойСклад, Настройки организации, Сотрудники, Роли,
Склады, Кассы — только Admin.
· Системная консоль — только SuperAdmin.
- Новый компонент RoleGuard (web/components/RoleGuard.tsx). Показывает
«Нет доступа» вместо страницы если у юзера нет нужной роли. Применён
в App.tsx для всех admin-only роутов: /settings/*, /catalog/{stores,
retail-points,counterparties}, /admin/import/moysklad. Защищает на
случай прямого ввода URL — sidebar их уже не показывает, но без
guard юзер увидел бы крутящееся колесо и 403.
Серверная авторизация ([Authorize(Roles=...)]) — основной слой защиты;
sidebar+RoleGuard — UX-слой.
Полное физическое удаление сотрудника невозможно — у него FK из
retail_sales и supplies. Поэтому теперь два шага:
IsActive=true → активный
IsActive=false + FiredAt → уволен (кнопка «Уволить»)
IsActive=false + IsDeleted=true + DeletedAt → удалён (кнопка «Удалить»)
— Domain: Employee получил поля IsDeleted/DeletedAt + миграция
Phase5a_EmployeeSoftDelete (drop column возможен через Down).
- API EmployeesController.Delete:
· если активен — переводит в Fired;
· если уже уволен — ставит IsDeleted=true + DeletedAt;
· если уже удалён — 409 Conflict;
· гарды Owner и self применяются на ОБОИХ шагах.
- API EmployeesController.List: новый query-param ?status=
active|fired|deleted|all (default: всё кроме deleted).
- DTO дополнен полями isDeleted, deletedAt, status (active/fired/deleted) —
фронтэнд использует для бейджа и логики кнопок.
- UI EmployeesPage:
· фильтр статуса в actions: «Активные и уволенные» (default),
«Только активные», «Только уволенные», «Только удалённые»,
«Все, включая удалённых».
· колонка «Статус» теперь с цветным бейджем (emerald/amber/rose).
· ФИО уволенного помечается «(уволен)», удалённого — line-through
+ «(удалён)».
· кнопка-действие в модалке: «Уволить» если active, «Удалить» если
fired, скрыта если уже deleted (заменена на pojaснение).
· confirm-текст обоих шагов разный — юзер понимает что произойдёт.
Существующие связанные документы (продажи, поставки) ссылаются на
employees по FK; имена для UI берутся из employee.LastName/FirstName +
status — отображение «Иванов И.И. (удалён)» работает автоматически.
Раньше явный validateEmail вызывался только в LoginPage и SignupForm.
Остальные формы (Counterparties, Employees, SuperAdminOrgCreate,
SuperAdminOrgEmployees, SuperAdminSetup) использовали голый
<TextInput type="email">, который пропускает «name@domain» без TLD.
Сделал общий TextInput строже: если type=email и pattern не задан явно,
автоматически проставляется pattern=[^@\s]+@[^@\s]+\.[A-Za-z]{2,}
(требует домен верхнего уровня минимум 2 буквы) + title с понятным
текстом подсказки. localizeNativeValidation (уже подключён к TextInput
через useEffect) переведёт patternMismatch в русское сообщение из
title-атрибута.
Это покрывает все формы с email-полем единообразно — отдельно править
каждую страницу не пришлось. Серверный AuthorizationController + signup
тоже проверяют email через свой validateEmail (food-market.web/lib/
validation.ts) — клиент и сервер консистентны.
Раньше Salary был <input type=number> с примитивной валидацией.
Заменено на MoneyInput (как в ценах товаров и др. денежных полях),
который:
- читает org-setting allowFractionalPrices (копейки или нет);
- показывает символ валюты (₸/$/€) справа;
- хранит draft-string для бесшовного ввода 100.50 без потери точки;
- onBlur нормализует значение.
Тип формы изменён: `salary: string` → `salary: number | null`,
сохранение payload берёт значение напрямую без Number() парсинга.
Других денежных полей в формах сотрудников/контрагентов (зарплаты,
авансы, штрафы, доплаты) сейчас НЕТ — есть только Salary в Employee
и MoneyInput уже используется в ProductEditPage (цены) и
SupplyEditPage (себестоимость и сумма по позициям). Поэтому пункт
закрыт одной правкой EmployeesPage.
ИНН — российское поле. В Казахстане физлица используют ИИН (12 цифр),
юрлица — БИН (12 цифр). Колонок «inn» в БД у нас нет (есть Bin для
юрлица + TaxNumber для ИИН физлица), миграция drop column не нужна —
только лейблы и комментарии.
Поправлено:
- EmployeesPage: «ИИН/ИНН» → «ИИН» (12 цифр, inputMode=numeric).
- CounterpartiesPage: убрано поле «ИНН / другой» — оставлены БИН и ИИН
с правильными ограничениями (12 цифр, numeric).
- SuperAdminOrgCreatePage / SuperAdminSetupPage: «БИН/ИНН» → «БИН».
- MoySkladImportPage: «(ИНН ...)» в ответе test → «(идентификатор ...)».
- Domain comments в Employee.cs / Counterparty.cs обновлены.
Внутренний MoySklad-DTO `Inn` оставлен — это входящий JSON из российского
API МойСклад, поле там действительно так называется. Маппится в Bin
при импорте контрагента (как и было).
Раньше клик по системной роли в списке выкидывал alert «Системная
роль, изменения недоступны». Теперь открывается обычная модалка с
правами, но: имя/описание disabled, все чекбоксы disabled, кнопка
«Сохранить» скрыта (вместо неё «Закрыть»). Юзер видит ровно какие
галки стоят у Администратора/Кладовщика/Кассира — это нужно как
шаблон при создании кастомной роли.
Также description страницы и заголовок модалки обновлены под новый
смысл: системные = только просмотр; кастомные = полный CRUD.
Менеджер/Закупщик/Бухгалтер сидились как кастомные шаблоны вместе с
организацией, но при этом числились системными в DevDataSeeder
(IsSystem=false), что путало UI (где-то нельзя было менять, где-то
можно было). Юзер хочет: при создании новой орги только три
системные роли (Admin, Storekeeper, Cashier), все остальные роли
администратор создаёт сам.
— SystemRoles.Manager убран. Identity-роли сидируются: SuperAdmin,
Admin, Cashier, Storekeeper.
— EmployeeRoles tenant-сидер создаёт только три записи (все IsSystem=true,
все три не редактируются и не удаляются обычным юзером — это правило
уже работало для Админа/Кассира, теперь покрывает Кладовщика).
— Authorize(Roles = ".. Manager ..") убрано из всех контроллеров (13 файлов):
Sales/RetailSales, Catalog/{Products,ProductImages,ProductGroups,
Counterparties,UnitsOfMeasure,RetailPoints,PriceTypes,Stores},
Purchases/Supplies, Organizations/{Employees,EmployeeRoles,
OrganizationSettings}.
Существующие организации с уже созданными «Менеджер/Закупщик/
Бухгалтер» записями НЕ затрагиваются — сидер пропускает org если в ней
уже есть роли (anyRole short-circuit). При желании админ может удалить
эти кастомные роли через UI.
Курсор не должен прыгать в конец после редактирования — он должен оставаться там, где юзер только что внёс изменение.
Корень проблемы: после onChange я вызываю onChange с каноничным «+77…», parent перерендеривает с этим значением, React переписывает input.value на новый display — а нативное поведение браузера в этом случае: при programmatic value-set курсор сбрасывается в конец.
Фикс: сохраняем «сколько цифр стояло до курсора» в момент редактирования, после ре-рендера в useLayoutEffect ставим курсор сразу после той же по счёту цифры в новом display. Учёт по цифрам (а не по абсолютной позиции) корректен даже когда format добавляет/убирает пробелы (например при переходе между «+7 700 1» и «+7 700 12»).
useLayoutEffect (а не useEffect) — чтобы курсор восстанавливался синхронно до paint, без визуального флика.
Прошлая попытка вручную обрабатывать каждую клавишу через onKeyDown ломала привычное поведение: selection + Backspace удалял только 1 цифру, нельзя было редактировать в середине, и т.д.
Новая модель совсем простая:
- Префикс «+7 7» (4 символа) залочен — onSelect клампит курсор/selection на digit-зону.
- Не-цифры блокируются через onBeforeInput (e.preventDefault если data содержит \D). Это покрывает и печать, и paste, и IME, и drag-drop.
- Всё остальное — нативное поведение браузера: selection + delete, click anywhere, arrow keys, Cmd+A, Cmd+V, drag-drop. После любого изменения onChange нормализует значение (rawToUserDigits извлекает 9 пользовательских цифр, отрезая залоченные «+7 7»).
- Display всегда «+7 7» + format(userDigits) — если юзер случайно стер часть префикса, она восстанавливается на ре-рендере.
Канон наружу: «+77XXXXXXXXX» (12 chars при полном номере), пустая строка если ничего не введено. Совместимо с существующим validatePhone (^77\d{9}$).
Курсор больше не «прыгает в конец» — его можно ставить куда угодно в digit-зоне (после префикса «+7 »), а Backspace/Delete/ввод цифры работают относительно позиции курсора, как в любом нормальном текстовом поле.
Реализация:
- cursorToDigitIdx — мапит позицию курсора в display к индексу в массиве цифр (пробелы форматирования пропускаются).
- digitIdxToCursor — обратное: после операции ставит курсор сразу после последней изменённой цифры (через пробел если он там есть, чтобы не было визуального скачка).
- Backspace на позиции idx удаляет цифру (idx-1); Delete удаляет цифру idx; ввод цифры вставляет в позицию idx.
- Префикс «+7 » защищён: onSelect ловит попытку курсора влезть в позиции 0..2 и мягко переставляет на 3.
Предыдущие итерации пытались парсить промежуточный raw input от браузера и постоянно ловили баги: курсор не там → парсер путал префикс с введёнными цифрами; Backspace на странной позиции → блокировался не там где надо.
Новая модель: фронт полностью контролирует input, нативный ввод запрещён. Все клавиши обрабатываются вручную в onKeyDown:
- Цифра → если набрано <10, добавить в конец.
- Backspace/Delete → если есть цифры, удалить последнюю.
- Любой другой символ → игнор (preventDefault).
- Шорткаты (Cmd/Ctrl+что-то), стрелки, Tab, Enter, Esc → пропускаем.
Курсор автоматически держим в конце display'а (после фокуса/клика/изменения). Paste обрабатывается отдельно через onPaste с нормализацией. onChange = no-op (React-warning заглушка).
Бенефиты: Backspace теперь всегда удаляет цифру, без оглядки на позицию. Не-цифры физически невозможно ввести. Префикс «+7 » никогда не повреждается.
Предыдущий fix не покрывал кейс когда курсор оказывался не сразу после «+7 » (в начале строки или в середине). parseRawInput не находил префикс на нужной позиции и «7» снова попадала в подсчёт.
Корректное решение: блокировать любой не-цифровой символ через preventDefault в onKeyDown — тогда он вообще не доходит до input, парсер видит только валидные цифры. Сохранены: Backspace/Delete (с защитой префикса), стрелки и навигация, Cmd/Ctrl+A/C/V/X для копи-пасты, Enter для submit. Paste произвольной строки по-прежнему работает через onChange.
Баг: при нажатии любой не-цифры (буквы, пробела) поле подставляло «7». Корень: extractDigits парсил полное e.target.value включая префикс «+7», и его «7» попадала в d, добавляясь как введённый юзером символ. Backspace на пустом поле не работал по той же причине.
Фикс: новая функция parseRawInput отделяет префикс «+7 » ДО извлечения цифр. Применена в handleChange обоих PhoneInput (web + public). Кейс paste без префикса (8XXXXXXXXXX / 7XXXXXXXXXX из 11 цифр) сохранён.
Поле телефона во всех формах (web + public) теперь использует общий компонент:
- Префикс «+7 » всегда виден и не удаляется (Backspace/Delete на позиции ≤3 блокируется).
- Принимаются только цифры (буквы и спецсимволы автоматически отфильтровываются).
- Авто-форматирование при вводе: «+7 7XX XXX XX XX».
- Paste произвольного формата нормализуется (поддерживается ведущая «8», «+7…», скобки, дефисы).
- Наружу через onChange отдаётся каноничное «+7XXXXXXXXXX».
Подключено в:
- food-market.public/SignupForm
- food-market.web/CounterpartiesPage (контрагенты)
- food-market.web/EmployeesPage (сотрудники)
- food-market.web/SuperAdminOrgEmployeesPage (управление сотрудниками SuperAdmin)
- food-market.web/SuperAdminOrgCreatePage (создание организации SuperAdmin)
Фронт:
- SignupForm: убрал «(необязательно)» из лейбла, добавил required + autoComplete=tel.
- validation.ts: validatePhone теперь возвращает ошибку при пустом значении и валидирует строго KZ-мобильный (^77\d{9}$); ведущая «8» нормализуется в «7». «79…» (РФ) отвергается.
Бэк:
- AuthSignupController: SignupInput.Phone теперь string (не nullable). Добавлен NormalizeKzPhone — единая нормализация на сервере, защита от обхода фронтового валидатора. На запись в Organization.Phone уходит каноничная форма «+7XXXXXXXXXX».
В списке организаций SuperAdmin одиночный клик навигировал на /super-admin/organizations/{id} и страница успевала отрисоваться → визуальное моргание. Изменил поведение:
- DataTable теперь поддерживает onRowDoubleClick параллельно onRowClick.
- SuperAdminOrganizationsPage: убран single-click navigate; double-click → setOrgOverride + переход на /dashboard этой орги (вход в её личный кабинет).
- Кнопки в колонке действий не задеты — у них уже stopPropagation.
Корень бага «кнопка Войти ведёт на zat.kz/410-Gone»:
.forgejo/workflows/docker-public.yml хардкодил PUBLIC_SITE_URL и
PUBLIC_APP_URL на zat.kz. На каждый git push CI собирал docker-image
с zat.kz и пушил под :latest, перетирая мои локальные пересборки.
Контейнер вечно крутил stale-бандл с href Войти=zat.kz/login.
Чиню env workflow:
- PUBLIC_SITE_URL → https://test.food-market.kz
- PUBLIC_APP_URL → https://admin.food-market.kz
- TG-нотификация о деплое — ссылка на test.food-market.kz.
Локально форсировал свежий image (--no-cache), push под :latest,
compose pull --force-recreate. Smoke на проде:
- href Войти → https://admin.food-market.kz/login
- Никаких zat.kz в /usr/share/nginx/html (grep пуст).
Bug: на test.food-market.kz href кнопки «Войти» показывал
https://app.food-market.zat.kz/login → 410 Gone (старый stage-домен
давно убран). Корни:
— Header.astro имел дефолт PUBLIC_APP_URL = https://test.food-market.kz
(после revert-коммита остался только public-домен, но Войти ведёт
на админку — должно быть admin.food-market.kz). Поправил, теперь
совпадает с SignupForm.tsx.
— Dockerfile ARG PUBLIC_APP_URL тоже был test.food-market.kz —
заменил на admin.food-market.kz.
— Добавил .dockerignore (node_modules / dist / .astro / .env / .git)
чтобы старый локальный dist/ не попадал в build-контекст и не
застревал в layer cache. Раньше это давало stale бандл с
app.food-market.zat.kz даже после изменений в src/.
Build/deploy: docker build --no-cache + compose pull --force-recreate.
Smoke на проде:
- href Войти → https://admin.food-market.kz/login
- Никаких .zat.kz упоминаний в /usr/share/nginx/html (grep пуст).
- og:image → https://test.food-market.kz/og/home.png
Источник — assets/brand/food-market-source.svg (1080x1080, FOOD #0DB70D
с яблоком вместо O, MARKET #4C4F54). Из source извлечены векторные
пути (Adobe-preview PNG-image теги вырезаны), собраны три варианта,
оптимизированы svgo.
Варианты в public/ обоих пакетов (food-market.web, food-market.public):
- logo.svg (2.1 KB) — full wordmark для светлого фона
- logo-light.svg (2.1 KB) — full wordmark белым (для тёмного фона)
- favicon.svg (1.0 KB) — mark-only: яблоко в зелёном круге,
читается с 16px (Astro favicon, web favicon)
Дополнительно (PWA maskable, food-market.web):
- logo-bg.svg (140 B) — solid #0DB70D 456x456 фон
- logo-fg.svg (1.0 KB) — белое яблоко на прозрачном, в safe-zone
Logo-компоненты переписаны:
- Logo.tsx (web) — было div+span с FOOD/MARKET буквами; теперь
<img src=/logo.svg|/logo-light.svg /> с одним пропом variant.
Сохранён API: variant=light|dark, className.
- Logo.astro (public) — то же; добавил пропс class для override-а.
Header.astro public-пакета использует <Logo />, не содержит
hardcoded текста — менять не нужно.
Все файлы под 2.2 KB после svgo.