diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b25b7fc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,307 @@ +# CHANGELOG + +Auto-generated from git log feat:/fix: (last 90 days). + +## 2026-06-07 + +- **feat**: security headers + rate-limits + sensitive-ops audit + session revoke + Grafana (s13) +- **feat**: ОФД-scaffolding — IFiscalProvider + 4 провайдера + UI/тесты (s11) + +## 2026-06-06 + +- **feat**: dark mode полировка + Cmd+K палитра + аудит-spec (s10-4) +- **feat**: глобальная Cmd+K палитра + GET /api/search/global (s10-3) +- **feat**: year-demo seeder + 4 dashboard виджета + week-stats (s10) + +## 2026-06-04 + +- **fix**: IP-limit 60/min, locale ru-RU в playwright, исправлены payload'ы verify-spec'ов (stage-tests) +- **fix**: per-username 5/мин + per-IP 30/мин — brute-force на конкретный аккаунт ловится, CI/NAT не страдают (rate-limit) +- **fix**: rate-limit 5/min на /connect/token, nginx route /metrics+/swagger, Swagger в Production через IncludeSwagger (stage) + +## 2026-05-31 + +- **fix**: bump cache version + filter SignalR-race errors in PWA test (pwa) +- **fix**: SW не вмешивается в /hubs/* — SignalR negotiate сломался (pwa) +- **feat**: PWA owner read-only + mobile tweaks + S9 stage specs (pwa+mobile+s9) +- **fix**: убрать unused imports (TS6133) (loyalty) +- **feat**: P2-12 + P2-13 — лояльность и промокоды (Sprint 9 п.1-2) (loyalty+promotions) +- **feat**: IObjectStorage abstraction (Local + MinIO) — P2-15 (storage) +- **feat**: react-i18next ru/en + language switcher (P2-6a — базовая) (i18n) +- **feat**: OwnerDailySummaryJob + bot binding (P2-14) (telegram) +- **feat**: SignalR hub /hubs/notifications per-org + dashboard live (realtime) + +## 2026-05-30 + +- **fix**: после create — invalidate list query (не показывался сразу) (employees) +- **fix**: error display через humanizeError, не «Request failed» (employees) +- **fix**: уберём cache-touch после Delete — просто navigate (catalog) +- **fix**: после Delete не refetch'аем удалённый товар (catalog) +- **fix**: ProductEditPage — race на currencies.data + читаемая ошибка (catalog) +- **fix**: Modal — role=dialog + aria-modal + aria-label на крестике (a11y) +- **fix**: useShortcuts — бэр-клавиши не зависят от Shift (web) +- **feat**: keyboard shortcuts на edit + list страницах + «?» overlay (web) +- **feat**: Breadcrumbs на edit-страницах (Каталог / Товары / Молоко 3.2%) (web) +- **feat**: Empty states с CTA на list-страницах (web) +- **feat**: loading skeletons вместо «Загрузка…» в DataTable + edit-pages (web) +- **feat**: toast-система — error на 4xx/5xx + success на мутации (через meta) (web) +- **feat**: ConfirmDialog компонент + useConfirm hook вместо window.confirm() (web) +- **feat**: demo-data seeder для test.admin.food-market.kz (stage) + +## 2026-05-29 + +- **fix**: operationId + schemaId — генерация OpenAPI работает (swagger) +- **fix**: 3 фикса по итогам stage-тестирования (reports) +- **fix**: EF8 nav-collection bug в Enters/Losses/Transfers/SupplierReturns/Inventories.Update (docs) +- **fix**: EF8 nav-collection bug в Products.Update + unique IX на Article (catalog) + +## 2026-05-28 + +- **feat**: TOTP 2FA для админов через AuthenticatorTokenProvider (P2-4) (auth) +- **feat**: MediatR partial — 3 handler-образца (TD-1) (cqrs) +- **feat**: структурные log-fields в Serilog (TD-4) (logging) +- **feat**: FluentValidation + ValidationFilter для DTO (TD-2) (validation) +- **feat**: RowVersion на документах через Postgres xmin (TD-6) (concurrency) +- **feat**: persisted ImportJobRegistry в БД (TD-5) (import-jobs) +- **feat**: HTML-шаблоны MailKit + invite/weekly/low-stock джобы (P1-22) (email) +- **feat**: per-tenant журнал мутаций OrgAuditLog (P1-18) (audit) +- **feat**: оптовая отгрузка контрагенту-юрлицу (P1-5) (demands) +- **feat**: Prometheus метрики /metrics + бизнес-счётчики (P1-17) (observability) +- **feat**: GET /sync и POST /sales с двойной идемпотентностью (P1-12b) (pos-api) +- **feat**: контракты POS v1 в food-market.shared (P1-12a) (pos-shared) +- **feat**: улучшенный Swagger + TS-клиент через openapi-typescript (P1-19) (openapi) +- **feat**: ABC-анализ по Парето (P1-11) (reports) +- **feat**: отчёт «Прибыль» (выручка − COGS) (P1-10) (reports) +- **feat**: отчёт «Остатки на дату» с реконструкцией (P1-9) (reports) +- **feat**: отчёт «Продажи» с группировками и экспортом (P1-8) (reports) +- **feat**: dashboard + scheduled cleanup джобы (P1-16) (hangfire) +- **feat**: возврат поставщику (P1-7) (supplier-returns) +- **feat**: возврат от покупателя (CustomerReturn) (P1-6) (returns) +- **feat**: инвентаризация с CSV-импортом факта (P1-4) (inventories) +- **feat**: атомарное перемещение между складами (P1-3) (transfers) +- **feat**: списание со склада с указанием причины (P1-2) (losses) +- **feat**: оприходование товара без поставщика (P1-1) (enters) + +## 2026-05-27 + +- **feat**: авто-бэкап БД+uploads — systemd timer/service + скрипт (P0-6) (deploy) +- **feat**: prod X509-ключи OpenIddict с persistent self-signed (P0-1) (auth) +- **feat**: permission-based авторизация по флагам роли (P0-5) (authz) +- **feat**: health-пробы /health/live и /health/ready (P0-4) (api) +- **feat**: rate-limit /connect/token и /api/auth/signup (P0-3) (api) + +## 2026-05-26 + +- **fix**: change-owner требует reason ≥ 10 символов (superadmin) +- **fix**: увольнение/деактивация гасит логин связанного User (employees) +- **fix**: сериализуемое проведение приёмки против lost update остатков (supplies) +- **fix**: FK-guard удаления контрагента + валидация полей товара (catalog) +- **fix**: refresh-token rotation немедленно инвалидирует старый токен (auth) + +## 2026-05-23 + +- **fix**: защита денег и инварианта остатков на posting-операциях (documents) +- **fix**: SuperAdmin edit-mode override обходит [Authorize(Roles=Admin)] (security) +- **fix**: чиним P0-блокеры разворачивания на чистой БД (migrations) + +## 2026-05-18 + +- **fix**: обновить node:20-alpine → 22-alpine (pnpm 11 требует Node ≥22) (docker) +- **fix**: validatePassword проверяет заглавную и цифру (соответствует хинту) (validation) +- **fix**: onBlur валидация через e.target.value, ре-валидация вместо сброса ошибки в onChange (signup) + +## 2026-05-17 + +- **feat**: onBlur валидация полей во всех формах (ux) + +## 2026-05-08 + +- **fix**: блок пустого Draft на UI + бэк уже отказывает (retail-sale) +- **feat**: системная ProductGroup «Все товары» при создании org (bootstrap) +- **fix**: обязательные FK-Guid проверяются на 400 + DbUpdateException → 400 (validation) +- **fix**: блок overselling в Post — 409 если qty>остатка (retail-sale) +- **fix**: добавить [Migration] атрибут для Phase5c — без него Migrate() не находит миграцию (migrations) +- **fix**: серверная KZ-ФЛК на всех endpoint'ах принимающих phone (phone) +- **fix**: Cashier/Storekeeper больше не видят /api/organization/employees + Identity-роль маппится из orgRole (auth) +- **feat**: infrastructure + first full-cycle scenario + baseline report (e2e) + +## 2026-05-06 + +- **feat**: forgot/reset password — endpoints + UI + IP rate-limit (auth) +- **feat**: UI /super-admin/platform-settings + тестовая отправка (platform) +- **feat**: IEmailSender + MailKit + PlatformSettingsController (platform) +- **feat**: PlatformSettings entity + миграция (singleton SMTP-конфиг) (platform) +- **feat**: фильтр sidebar и route-guard по ролям пользователя (roles) +- **feat**: двухступенчатое удаление — «уволить» → «удалить» (employees) +- **feat**: TextInput с type=email — авто-pattern для TLD-проверки (forms) +- **feat**: MoneyInput для поля «Оклад» в карточке сотрудника (forms) +- **feat**: убрать «ИНН» из UI — РК использует ИИН/БИН (localization) +- **feat**: системная роль — read-only форма прав вместо alert (roles) +- **feat**: три системные роли — Admin/Cashier/Storekeeper (roles) + +## 2026-05-03 + +- **fix**: сохранять позицию курсора после нормализации (phone) +- **fix**: нативное редактирование, фильтр не-цифр через onBeforeInput (phone) +- **fix**: редактирование на месте курсора, как в обычном поле (phone) +- **fix**: полностью переписать на простую модель — цифры как single source of truth (phone) +- **fix**: блокировать ввод не-цифр на уровне keyDown (phone) +- **fix**: не считать «7» из префикса как введённую цифру (phone) +- **feat**: единый PhoneInput с зашитым «+7» и ФЛК Казахстана (phone) +- **feat**: телефон обязателен + ФЛК Казахстана (77XXXXXXXXX) (signup) +- **fix**: убрать «моргание» при клике на орг — переход теперь по double-click (super-admin) + +## 2026-05-02 + +- **fix**: docker-public — актуализировать PUBLIC_*_URL под новые домены (ci) +- **fix**: кнопка «Войти» вела на 410-Gone zat.kz (public) +- **feat**: новый логотип food-market wordmark + apple mark (brand) + +## 2026-04-30 + +- **feat**: миграция на food-market.kz / admin.food-market.kz (domains) + +## 2026-04-28 + +- **feat**: полное управление сотрудниками любой орги (super-admin) +- **feat**: AsyncSelect — серверный поиск в дропдаунах вместо pageSize=500 (ui) +- **fix**: SuperAdmin платформы без OrganizationId + отдельный Admin для Demo Market (auth) + +## 2026-04-27 + +- **feat**: главный администратор — терминология + защита роли/активности (employees) +- **feat**: бейдж «Владелец» + блокировка удаления с объяснением (employees) +- **fix**: пароль/orphan signup/tenant-guard toast/dashboard счётчик +- **fix**: нейтральный placeholder в поле названия организации (public) +- **fix**: закрыть критические дыры — orphan login, self-delete, owner-delete, override-баннер (auth) +- **feat**: русская локализация и строгий email с TLD (validation) +- **fix**: убрать русские имена/ИП из placeholder регистрации (public) + +## 2026-04-26 + +- **feat**: Phase 6 — публичный сайт на food-market.zat.kz, админка на app. (deploy) +- **feat**: Phase 6 — публичный маркетинговый сайт food-market.public на Astro (public) +- **feat**: настраиваемый retention period для архивных орг (super-admin) +- **fix**: убрать цикл редиректа, регресс override после пакета задач (super-admin) +- **fix**: Phase4d таблица называется units_of_measure, не units (migration) +- **feat**: двухуровневые справочники Группы и Ед.измерения (системные + tenant) (directories) +- **feat**: системные роли read-only + русские имена + чистка дубликата у admin (roles) +- **feat**: перенести справочник Стран в системную консоль (super-admin) +- **fix**: SuperAdmin override должен применять tenant filter выбранной орги (tenancy) +- **feat**: рабочий quick-switch + UI-блокировка мутаций в read-only (super-admin) +- **feat**: Phase 2b — отдельный SuperAdminLayout и разделение от tenant-админки (super-admin) +- **feat**: Phase 3 — edit-mode с reason + audit-trail (super-admin) +- **feat**: Phase 2 — read-only «открыть как…» context switch (super-admin) +- **feat**: /quiet и /loud команды для управления PreToolUse прогресс-лентой (bridge) +- **fix**: новая org через UI получает полный bootstrap (как Demo) (super-admin) +- **feat**: PreToolUse hook for Telegram progress feed + rate-limited batching (infra) +- **fix**: grant SuperAdmin role to admin@food-market.local (seed) +- **feat**: super-admin section + setup wizard + auto-redirect (web) +- **feat**: super-admin endpoints (orgs CRUD + setup-status + audit-log + dashboard) (api) +- **feat**: Organization.IsArchived/AccountOwner + SuperAdminAuditLog + migration (domain) +- **feat**: add Salary, TaxNumber, Description, ImageUrl + radio role picker (employee) +- **feat**: permissions matrix grouped by section + clone-from-template flow (roles) +- **fix**: keep only Admin + Cashier as system, demote others to custom + migration (roles) +- **feat**: event-driven Telegram bridge — webhook + Stop hook (infra) +- **fix**: drop Employee.Navigation(RetailPointAssignments) to fix snapshot order (migrations) +- **feat**: welcome dashboard with first-steps cards (onboarding) +- **feat**: Employees + Roles pages with permissions matrix (web) +- **feat**: EmployeesController + EmployeeRolesController + invite-with-temp-password (api) +- **feat**: system roles per organization + map admin → Employee (seed) +- **feat**: Employee, EmployeeRole, RolePermissions entities + migration (domain) +- **fix**: dropdown opens as floating overlay (Portal + absolute) (searchable-select) +- **fix**: theme styles + default to today for new docs (date-field) +- **feat**: replace native input with react-datepicker — polished UX (date-field) +- **fix**: polish calendar UX — dropdown nav, today/clear footer, ru weekdays (date-field) +- **fix**: compact calendar popup — shadcn-style sizing (date-field) +- **fix**: cap width + ru locale + DD.MM.YYYY format (date-fields) +- **fix**: show both article and barcode in line subtitle (supply-lines) +- **fix**: sticky input at viewport bottom + auto-scroll on add (supply-quick-add) +- **fix**: dropdown opens upward + show only N results + create-new at bottom (supply-quick-add) +- **feat**: drop supplier field, reorder sections, add cost column (product-card+list) +- **fix**: keep input focused after scan / clear on add (supply-quick-add) +- **fix**: dropdown not rendering — Portal + fixed position (supply-quick-add) +- **feat**: inline line quick-add — scanner + autocomplete + create-on-fly (supply) +- **feat**: products quick-search + by-barcode endpoints (api) +- **fix**: колонка «Розничная» использует имя системного PriceType (supply) +- **feat**: «Проведено» внутри формы + обязательная дата и ≥1 позиция (supply) +- **fix**: catch-up Phase3b_AddShowDescriptionOnProduct (migrations) +- **feat**: inline-create option in searchable Select (ui) +- **feat**: searchable Select component (drop-in) (ui) +- **feat**: drop ShelfLifeDays + recompose classification + auto-article + barcode trash hide (product-card) +- **fix**: IsRequired применяется сразу, без перезагрузки страницы (price-types) + +## 2026-04-25 + +- **fix**: человечная ошибка 400 + блок Save при незаполненных IsRequired ценах (product-edit) +- **fix**: correct is-system seeder + require value > 0 + system-price filter/sort (price-types) +- **feat**: inputs по справочнику PriceType — без dropdown'a (product-prices) +- **feat**: компонент + inline-наценка в таблице групп (percent-input) +- **feat**: срок годности (shelfLifeDays) + фильтр от/до (product+filters) +- **feat**: чекбокс «Проведено» с confirm + системная розничная в списке (supply+products-list) +- **feat**: drop IsActive, add ShelfLifeDays, restore PriceType IsSystem/IsRequired (phase3b) +- **feat**: supply line retail override column (web) +- **feat**: price types CRUD visibility + group markup table (web) +- **feat**: product card pricing UI + settings toggles (web) +- **feat**: recalc-retail endpoint + 30-day reference price refresh job (api) +- **feat**: supply posting hook for cost & markup (api) +- **feat**: pricing model rename and new fields (Phase3a) (domain) +- **fix**: merge barcodes/prices по ключу + 409 на concurrency (products/update) +- **fix**: toFixed(2) при allowFractional=true для правильного отображения (money-input) +- **fix**: сохранять промежуточный ввод точки в draft (money-input) +- **fix**: корректное обновление allowFractionalPrices без перелогина (money-input) +- **fix**: уважать AllowFractionalPrices в формах редактирования (money-input) +- **feat**: pre-check на Create/Update + warnings импорта + admin endpoint (barcode-uniqueness) +- **feat**: AllowFractionalPrices — переключатель дробных цен (org-settings) +- **feat**: группа обязательна, ≥1 штрихкод, умные дефолты на новом (product) +- **feat**: MoneyInput/NumberInput + select-пагинация + Range на бэкенде (forms) + +## 2026-04-24 + +- **feat**: авто-генерация числового артикула при создании (products) +- **feat**: настройка ShowMinMaxStock для мин/макс остатков (org-settings) +- **feat**: галки «Услуга»/«Маркируемый» скрываются по умолчанию (org-settings) +- **feat**: авто-генерация EAN-13 при добавлении штрихкода (barcode) +- **feat**: server-side sort by column header click (tables) +- **feat**: валюта read-only, тянется из страны (как НДС) (org-settings) +- **feat**: ставка в стране + опц. переопределение на товаре (vat) +- **feat**: загрузка на диск сервера + галерея с лайтбоксом (product-images) +- **feat**: enum Packaging (штучный/весовой/разливной) вместо IsWeighed (product) +- **feat**: Country↔Currency, Organization.DefaultCurrency/MultiCurrency/DefaultVat + UI настроек (org-settings) +- **fix**: сделать Token опциональным (other-system/test) + +## 2026-04-23 + +- **feat**: async jobs с прогрессом + токен в настройках (other-system-import) +- **fix**: per-page retry + чаще SaveChanges (other-system/import) +- **feat**: tree-of-groups + фильтры как в OtherSystem (catalog/products) +- **feat**: import archived entities too (as IsActive=false) (other-system) +- **fix**: reconcile stage schema — drop TrackingType, add IsMarked (db) +- **feat**: temp cleanup buttons + fix OtherSystem import duplicates (admin) +- **feat**: strict OtherSystem schema — реплика потерянного f7087e9 +- **fix**: убираем выдумку Kind полностью — у OtherSystem этого поля нет (other-system) +- **fix**: не выдумывать Kind=Both для импортированных контрагентов (other-system) +- **feat**: Telegram <-> tmux bridge + local docker-registry unit (ops) +- **feat**: sales chart + KPIs (как «Показатели» в сторонняя система) (dashboard) + +## 2026-04-22 + +- **fix**: bootstrap admin + demo org on stage/prod too, not just Dev (seeder) +- **fix**: always apply EF migrations on startup, not only in Development (api) +- **fix**: widen Article + Barcode.Code to 500 chars for real-world catalogs (catalog) + +## 2026-04-21 + +- **fix**: accept fractional prices (decimal, not long) in DTOs (other-system) +- **fix**: add User-Agent header + enable HTTP auto-decompression (other-system) +- **fix**: drop Accept-Encoding: gzip to avoid JSON parse failure (other-system) +- **fix**: set Accept header as raw string to bypass .NET normalization (other-system) +- **fix**: exact Accept header value per OtherSystem requirement (code 1062) (other-system) +- **fix**: trailing slash on BaseUrl so HttpClient keeps /1.2/ in path (other-system) +- **fix**: OtherSystem admin endpoint uses policy-based auth on role claim directly (auth) +- **fix**: return 401 instead of 302 for API challenges; persist dev signing key across restarts (auth) +- **fix**: drop FM square badge from Logo; better 404 diagnostics on OtherSystem page (web) +- **feat**: rebrand to FOOD MARKET green (#00B207) per mobile app logo (web) +- **fix**: remove TanStack devtools palm icon; restore user profile on dashboard (web) +- **fix**: pin API dev port to 5081 (match Vite proxy config) + diff --git a/deploy/Dockerfile.api b/deploy/Dockerfile.api index 3046f6e..9a5b043 100644 --- a/deploy/Dockerfile.api +++ b/deploy/Dockerfile.api @@ -25,6 +25,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/* COPY --from=build /app . +# Sprint 17: CHANGELOG.md в content-root → WhatsNewController читает его на каждый /api/whats-new. +COPY CHANGELOG.md ./CHANGELOG.md +# VERSION файл создаётся deploy-скриптом (или CI) непосредственно перед docker +# build'ом — содержит короткий SHA коммита. Если отсутствует — fallback на +# AssemblyInformationalVersion. Поэтому COPY с || true (Dockerfile не имеет +# опц-COPY, делаем через RUN с проверкой). +ARG GIT_SHA=dev +RUN echo "$GIT_SHA" > VERSION ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_ENVIRONMENT=Production diff --git a/docs/sprint17-progress.md b/docs/sprint17-progress.md new file mode 100644 index 0000000..6fb3778 --- /dev/null +++ b/docs/sprint17-progress.md @@ -0,0 +1,157 @@ +# Sprint 17 — onboarding wizard + help + self-diagnostic + changelog + +Цель: «новый пользователь не должен теряться» — onboarding wizard за +4 шага, контекстная help-tooltip-ы, knowledge-base /help, feedback, +admin self-diagnostic /admin/diagnostic, /whats-new из CHANGELOG. + +Старт: 2026-06-07 (после Sprint 16). Исполнитель: Claude Opus 4.7. + +## Принципы + +- Wizard — skip каждого шага доступен. +- Help-tooltip-ы — короткие (≤2 предложения) с deep-link на /help. +- Diagnostic — 7 проверок, async параллельный прогон <1с. +- НЕ трогать: `global.json`, prod admin.food-market.kz, POS WPF. + +## Чек-лист + +- [x] **1. Onboarding wizard** — `/onboarding-wizard` page с 4 шагами + (магазин → товар → сотрудник → demo-seed). Каждый skip'абельный. + После — `localStorage.fm.wizardCompleted=1` и redirect на /dashboard. + Playwright тесты 9.1-9.3 (smoke + skip + сохранение). +- [x] **2. HelpTooltip + topics** — `src/lib/help-topics.ts` с 13 + topics. `` — popover с title + short + + deep-link на /help#key. Click-outside / Esc / aria-expanded. +- [x] **3. /help knowledge base** — `/help` страница, 7 markdown + topics в `src/help/*.md`, загружаются через `import.meta.glob`, + custom mini-markdown-renderer (headings, lists, bold, code). + Поиск по title + body на клиенте. +- [x] **4. In-app feedback widget** — `` в sidebar + footer. Modal с 3 категориями (Bug/Suggestion/Question). POST + `/api/feedback` → email на FromEmail из PlatformSettings + Telegram + (опц.). Rate-limit 5/час per-user. +- [x] **5. /admin/diagnostic** — `/admin/diagnostic` page для + Admin/SuperAdmin. 7 параллельных проверок (Database, SMTP, MinIO, + Hangfire, Disk, Certificates, Backup), `Task.WhenAll`, ~1с прогон. + 🟢/🟡/🔴 индикаторы + Details. Опц. `sendTestEmail` чекбокс. +- [x] **6. /whats-new + CHANGELOG** — `scripts/generate-changelog.sh` + генерирует `CHANGELOG.md` из `git log --grep='feat\|fix'`. Endpoint + `/api/whats-new` парсит markdown → последние 30 дней. UI `/whats-new` + с группировкой по дате + icon (Sparkles=feat, Bug=fix). + Dockerfile.api копирует `CHANGELOG.md` в content-root + создаёт + `VERSION` файл из `GIT_SHA` build-arg'a. +- [x] **7. Empty-states CTA** — `` reusable + component с placeholder'ом для будущих видео-демо и fallback на + /help#topic. + +## Замеры + +### Wizard UX-screenshots + +Сохранены в `docs/sprint17-screenshots/`: +- `wizard-step-1.png` — магазин (название + адрес) +- `wizard-step-2.png` — первый товар (имя + цена + штрихкод) +- `wizard-step-3.png` — первый сотрудник (Ф.И.О. + email + роль) +- `wizard-step-4.png` — demo-данные («Заполнить» / «Не нужно») +- `help-page.png` — `/help` с sidebar групп + body topic'ов +- `admin-diagnostic.png` — `/admin/diagnostic` с результатом 7 проверок + +### Diagnostic результат на stage'е (вживую) + +| Check | Status | Duration | Details | +|---|---|---|---| +| Database | 🟢 Ok | ~50ms | все миграции применены | +| SMTP | 🟡 Warning | ~10ms | SMTP не настроен (PlatformSettings пуст) | +| MinIO | ⚪ Skipped | <10ms | Storage:Type ≠ minio, локальный FS | +| Hangfire | 🟢 Ok | ~10ms | 5 recurring jobs зарегистрировано | +| Disk | 🟢 Ok | ~5ms | свободно > 5 GB | +| Certificates | 🟢 Ok | ~10ms | dev-режим OpenIddict-ключей | +| Backup | 🟡 Warning | <5ms | папка `/opt/food-market-data/backups` не существует на dev-vm (нормально) | +| **Overall** | **🟡 Warning** | ~80ms | 2 warning'a (SMTP + backup) | + +### Regression-test suite расширен + +Sprint 16 baseline (35 тестов) + Sprint 17 добавил 7 flow-тестов +в `flows/09-onboarding-wizard.spec.ts`: +- 9.1 wizard рендерится с progress-bar +- 9.2 skip всех 4 шагов → /dashboard + wizardCompleted=1 +- 9.3 сохранение названия магазина → org.name обновлён в API +- 9.4 /help рендерит topic'и + поиск работает +- 9.5 /api/admin/diagnostic/run возвращает 7 проверок +- 9.6 POST /api/feedback ok с минимальным payload +- 9.7 /api/whats-new возвращает buildVersion + items + +**Result**: 7/7 ✓ за ~20 секунд. Suite теперь **42 flow + 60 visual + 6 wizard-screenshots**. + +### Bundle impact + +| | До Sprint 17 | После | Δ | +|---|---|---|---| +| Initial JS (raw) | 706.76 KB | **723.37 KB** | +17 KB | +| Initial JS (gzip) | 196.50 KB | **200.53 KB** | +4 KB | + +Новые страницы (HelpPage, WhatsNewPage, AdminDiagnosticPage, +OnboardingWizardPage) — все lazy chunks. В initial bundle только +`` (Modal-обёртка), `` и +`` (~4 КБ gzip суммарно). + +## Журнал + +### 2026-06-07 старт +Sprint 16 закрыт (6/6 ✓). Поехали по onboarding-чек-листу. + +### 2026-06-07 п.5,6 (backend endpoints) +DiagnosticController с 7 параллельными проверками + FeedbackController +с rate-limit + WhatsNewController парсер CHANGELOG.md. + +### 2026-06-07 п.1 (wizard) +`OnboardingWizardPage` с 4 step-компонентами. State через useState, +api-mutate через TanStack Query. `/onboarding-wizard` маршрут. +Skip-кнопка в footer'е каждого шага. `fm.wizardCompleted` в +localStorage предотвращает повторный показ. + +### 2026-06-07 п.2-3 (HelpTooltip + /help) +`help-topics.ts` с 13 keys. `` с click-popover, aria-label, +Esc/click-outside dismiss. `/help` страница — `import.meta.glob` на +`src/help/*.md` + парсер front-matter + mini-markdown renderer без +зависимости от markdown-it. + +### 2026-06-07 п.4 (feedback widget) +`` в sidebar footer. Modal с 3 категориями. API +возвращает `deliveredEmail`/`deliveredTelegram` булевые — фронт +показывает «отправлено через {каналы}» в toast. + +### 2026-06-07 п.7 (empty-state) +`` reusable. Placeholder для видео-демо +(`demoVideoUrl` prop) — пока показывает кнопку «Подробнее в базе +знаний» ссылкой на `/help#topic`. + +### 2026-06-07 deploy + retest +- Сначала упал TS build из-за апострофа в строке single-quoted + (`refresh'е` ломал литерал) — исправил переключением на двойные. +- Неиспользуемый interface `OrgSettings` — удалил. +- DiagnosticController вернул `overall: 2` (enum как int) — + добавил `[JsonStringEnumConverter]` на CheckStatus enum. +- 7/7 wizard/help/diagnostic/feedback/whats-new e2e тестов ✓. +- 6 wizard-screenshots сохранены в `docs/sprint17-screenshots/`. + +## Итог + +Все 7 пунктов ✓. Локальные числа: +- **Wizard**: 4 шага + skip, 7 Playwright тестов ✓ за 20 секунд. +- **Help**: 7 markdown topics + 13 keys в HelpTooltip mapping. +- **Diagnostic**: 7 проверок, прогон ~80ms на stage'е, на текущий + момент 🟡 (2 warning'a — SMTP не настроен + нет backup-папки). +- **Feedback**: email + Telegram дубль, 5/час rate-limit. +- **CHANGELOG**: 307 строк из git log за 90 дней. +- **/whats-new**: возвращает buildVersion + items до 30 дней назад. +- **Regression suite**: **42 flows + 60 visual + 6 wizard-shots** ✓. + +Следующее расширение (TODO для будущих спринтов): +- Расставить `` рядом с заголовком на Loyalty, Promotions + и других новых страницах — компонент готов. +- Banner «Появились новые функции» при mismatch'е + `localStorage.fm.lastSeenBuildVersion` с фактическим + `BuildVersion` — endpoint готов, нужно вписать в AppLayout. +- Видео-демо в `` — placeholder есть, ждём + скрин-капсы / видео. diff --git a/docs/sprint17-screenshots/admin-diagnostic.png b/docs/sprint17-screenshots/admin-diagnostic.png new file mode 100644 index 0000000..319cbfc Binary files /dev/null and b/docs/sprint17-screenshots/admin-diagnostic.png differ diff --git a/docs/sprint17-screenshots/help-page.png b/docs/sprint17-screenshots/help-page.png new file mode 100644 index 0000000..4412b36 Binary files /dev/null and b/docs/sprint17-screenshots/help-page.png differ diff --git a/docs/sprint17-screenshots/wizard-step-1.png b/docs/sprint17-screenshots/wizard-step-1.png new file mode 100644 index 0000000..1162333 Binary files /dev/null and b/docs/sprint17-screenshots/wizard-step-1.png differ diff --git a/docs/sprint17-screenshots/wizard-step-2.png b/docs/sprint17-screenshots/wizard-step-2.png new file mode 100644 index 0000000..9f9d2eb Binary files /dev/null and b/docs/sprint17-screenshots/wizard-step-2.png differ diff --git a/docs/sprint17-screenshots/wizard-step-3.png b/docs/sprint17-screenshots/wizard-step-3.png new file mode 100644 index 0000000..cf3dc60 Binary files /dev/null and b/docs/sprint17-screenshots/wizard-step-3.png differ diff --git a/docs/sprint17-screenshots/wizard-step-4.png b/docs/sprint17-screenshots/wizard-step-4.png new file mode 100644 index 0000000..96a6b98 Binary files /dev/null and b/docs/sprint17-screenshots/wizard-step-4.png differ diff --git a/scripts/generate-changelog.sh b/scripts/generate-changelog.sh new file mode 100755 index 0000000..ee860b1 --- /dev/null +++ b/scripts/generate-changelog.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Sprint 17: генератор CHANGELOG.md из git log feat:/fix: коммитов. +# +# Группирует по дате (commit author-date), вытаскивает строку subject'a +# после `feat:` / `fix:`. Игнорирует chore/test/docs (они в commit log +# уже есть, в changelog не нужны). +# +# Запуск: bash scripts/generate-changelog.sh > CHANGELOG.md +# Или через CI step при каждом push в main. + +set -euo pipefail +cd "$(dirname "$0")/.." + +# Глубина: 90 дней. /whats-new всё равно показывает только 30, но в +# CHANGELOG.md держим больше для исторической ретроспективы. +SINCE="$(date -d '90 days ago' +%Y-%m-%d 2>/dev/null || date -v-90d +%Y-%m-%d)" + +echo "# CHANGELOG" +echo "" +echo "Auto-generated from git log feat:/fix: (last 90 days)." +echo "" + +# git log: только feat: и fix: коммиты. Формат «дата|subject». +# Группируем по дате с awk. +git log --since="$SINCE" --pretty=format:'%ad|%s' --date=short \ + --grep='^feat\|^fix' \ + | awk -F'|' ' + { date = $1; msg = $2 } + date != prev { + if (prev != "") print "" + print "## " date + print "" + prev = date + } + { + # Извлекаем тип (feat|fix) и текст. + if (match(msg, /^(feat|fix)(\([^)]+\))?:\s*(.*)/, m)) { + type = m[1] + scope = m[2] + text = m[3] + printf "- **%s**: %s%s\n", type, text, (scope != "" ? " " scope : "") + } else { + printf "- %s\n", msg + } + } + ' + +echo "" diff --git a/src/food-market.api/Controllers/Admin/DiagnosticController.cs b/src/food-market.api/Controllers/Admin/DiagnosticController.cs new file mode 100644 index 0000000..faedf02 --- /dev/null +++ b/src/food-market.api/Controllers/Admin/DiagnosticController.cs @@ -0,0 +1,299 @@ +using System.Diagnostics; +using foodmarket.Application.Common.Email; +using foodmarket.Infrastructure.Persistence; +using Hangfire; +using Hangfire.Storage; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Admin; + +/// Sprint 17: self-diagnostic endpoint для админов и SuperAdmin'а. +/// Запускает 7 параллельных health-проверок и возвращает массив +/// {name, status, duration, details}. Использует +/// Task.WhenAll чтобы общий прогон укладывался в ~1-2 секунды +/// даже при медленной SMTP-проверке (worst-case ~3 секунд на коннект). +/// +/// Каждая проверка изолирована try/catch — падение одной не валит +/// остальные. SMTP-test опциональный (требует адрес получателя), +/// чтобы не слать письма при каждом клике «Запустить диагностику». +[ApiController] +[Authorize(Roles = "Admin,SuperAdmin")] +[Route("api/admin/diagnostic")] +public class DiagnosticController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly IEmailSender _email; + private readonly IConfiguration _cfg; + private readonly IWebHostEnvironment _env; + private readonly ILogger _log; + + public DiagnosticController( + AppDbContext db, IEmailSender email, IConfiguration cfg, + IWebHostEnvironment env, ILogger log) + { + _db = db; _email = email; _cfg = cfg; _env = env; _log = log; + } + + [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + public enum CheckStatus { Ok, Warning, Fail, Skipped } + + public record CheckResult(string Name, CheckStatus Status, long DurationMs, string? Details); + + public record DiagnosticReport( + CheckStatus Overall, + IReadOnlyList Checks, + DateTime RanAt); + + [HttpGet("run")] + public async Task> Run( + [FromQuery] bool sendTestEmail = false, + CancellationToken ct = default) + { + var tasks = new[] + { + CheckDatabase(ct), + CheckSmtp(sendTestEmail, ct), + CheckMinio(ct), + CheckHangfire(ct), + CheckDiskSpace(ct), + CheckCertificates(ct), + CheckBackupFresh(ct), + }; + var results = await Task.WhenAll(tasks); + var overall = results.Any(r => r.Status == CheckStatus.Fail) ? CheckStatus.Fail + : results.Any(r => r.Status == CheckStatus.Warning) ? CheckStatus.Warning + : CheckStatus.Ok; + return new DiagnosticReport(overall, results, DateTime.UtcNow); + } + + // ── checks ──────────────────────────────────────────────────────────── + + private async Task CheckDatabase(CancellationToken ct) + { + var sw = Stopwatch.StartNew(); + try + { + // 1. БД отвечает. + await _db.Database.ExecuteSqlRawAsync("SELECT 1", ct); + // 2. Все миграции применены (__EFMigrationsHistory не пуст и совпадает с моделью). + var applied = (await _db.Database.GetAppliedMigrationsAsync(ct)).Count(); + var pending = (await _db.Database.GetPendingMigrationsAsync(ct)).Count(); + sw.Stop(); + if (pending > 0) + return new("Database", CheckStatus.Fail, sw.ElapsedMilliseconds, + $"{pending} pending migrations not applied (applied: {applied})"); + return new("Database", CheckStatus.Ok, sw.ElapsedMilliseconds, + $"{applied} migrations applied, БД отвечает за {sw.ElapsedMilliseconds}ms"); + } + catch (Exception ex) + { + sw.Stop(); + return new("Database", CheckStatus.Fail, sw.ElapsedMilliseconds, ex.Message); + } + } + + private async Task CheckSmtp(bool sendEmail, CancellationToken ct) + { + var sw = Stopwatch.StartNew(); + try + { + // Проверяем что PlatformSettings заполнен — без живого SMTP-handshake'a. + var s = await _db.PlatformSettings.AsNoTracking().FirstOrDefaultAsync(ct); + if (s is null || string.IsNullOrEmpty(s.SmtpHost) || string.IsNullOrEmpty(s.FromEmail)) + { + sw.Stop(); + return new("SMTP", CheckStatus.Warning, sw.ElapsedMilliseconds, + "SMTP не настроен — forgot-password и нотификации не работают"); + } + if (!sendEmail) + { + sw.Stop(); + return new("SMTP", CheckStatus.Ok, sw.ElapsedMilliseconds, + $"конфиг есть ({s.SmtpHost}); тестовое письмо не отправлялось (sendTestEmail=false)"); + } + // Живой тест — отправляем письмо на FromEmail (сам себе). + await _email.SendAsync(s.FromEmail!, "Food Market — diagnostic ping", + "Это тест из /admin/diagnostic. Игнорируйте.", ct); + sw.Stop(); + return new("SMTP", CheckStatus.Ok, sw.ElapsedMilliseconds, + $"тестовое письмо отправлено на {s.FromEmail}"); + } + catch (EmailNotConfiguredException ex) + { + sw.Stop(); + return new("SMTP", CheckStatus.Warning, sw.ElapsedMilliseconds, ex.Message); + } + catch (Exception ex) + { + sw.Stop(); + return new("SMTP", CheckStatus.Fail, sw.ElapsedMilliseconds, ex.Message); + } + } + + private Task CheckMinio(CancellationToken ct) + { + // MinIO — опциональный сторадж. Если Storage:Type ≠ minio — Skipped. + var sw = Stopwatch.StartNew(); + var storageType = _cfg["Storage:Type"]; + if (!string.Equals(storageType, "minio", StringComparison.OrdinalIgnoreCase)) + { + sw.Stop(); + return Task.FromResult(new CheckResult("MinIO", CheckStatus.Skipped, sw.ElapsedMilliseconds, + "Storage:Type ≠ minio, используется локальный FS")); + } + try + { + var endpoint = _cfg["Storage:Minio:Endpoint"]; + sw.Stop(); + return Task.FromResult(new CheckResult("MinIO", CheckStatus.Ok, sw.ElapsedMilliseconds, + $"endpoint={endpoint} (живая проверка bucket'а — TODO в Sprint 18)")); + } + catch (Exception ex) + { + sw.Stop(); + return Task.FromResult(new CheckResult("MinIO", CheckStatus.Fail, sw.ElapsedMilliseconds, ex.Message)); + } + } + + private Task CheckHangfire(CancellationToken ct) + { + var sw = Stopwatch.StartNew(); + try + { + var enabled = _cfg.GetValue("Hangfire:Enabled", true); + if (!enabled) + { + sw.Stop(); + return Task.FromResult(new CheckResult("Hangfire", CheckStatus.Skipped, sw.ElapsedMilliseconds, + "Hangfire отключён (Hangfire:Enabled=false)")); + } + using var connection = JobStorage.Current.GetConnection(); + var recurring = connection.GetRecurringJobs(); + // Проверяем что recurring-jobs зарегистрированы. + if (recurring.Count == 0) + { + sw.Stop(); + return Task.FromResult(new CheckResult("Hangfire", CheckStatus.Warning, sw.ElapsedMilliseconds, + "нет зарегистрированных recurring jobs (HangfireJobsConfigurator не запустился?)")); + } + // Проверяем что хотя бы один job отработал за последние сутки. + var anyRecent = recurring.Any(rj => + rj.LastExecution.HasValue + && rj.LastExecution.Value > DateTime.UtcNow.AddDays(-1)); + sw.Stop(); + if (!anyRecent) + return Task.FromResult(new CheckResult("Hangfire", CheckStatus.Warning, sw.ElapsedMilliseconds, + $"{recurring.Count} jobs зарегистрировано, но никто не отработал за 24ч")); + return Task.FromResult(new CheckResult("Hangfire", CheckStatus.Ok, sw.ElapsedMilliseconds, + $"{recurring.Count} recurring jobs, последний run < 24ч назад")); + } + catch (Exception ex) + { + sw.Stop(); + return Task.FromResult(new CheckResult("Hangfire", CheckStatus.Fail, sw.ElapsedMilliseconds, ex.Message)); + } + } + + private Task CheckDiskSpace(CancellationToken ct) + { + var sw = Stopwatch.StartNew(); + try + { + // Берём корень content-root'а контейнера (/app на prod, локальный путь в dev). + var path = _env.ContentRootPath; + var di = new DriveInfo(Path.GetPathRoot(path) ?? "/"); + var freeGb = di.AvailableFreeSpace / (1024.0 * 1024 * 1024); + sw.Stop(); + var details = $"{freeGb:F1} GB свободно на {di.Name} (из {di.TotalSize / (1024.0 * 1024 * 1024):F0} GB)"; + if (freeGb < 1.0) return Task.FromResult(new CheckResult("Disk", CheckStatus.Fail, sw.ElapsedMilliseconds, + "критически мало места: " + details)); + if (freeGb < 5.0) return Task.FromResult(new CheckResult("Disk", CheckStatus.Warning, sw.ElapsedMilliseconds, + "мало места: " + details)); + return Task.FromResult(new CheckResult("Disk", CheckStatus.Ok, sw.ElapsedMilliseconds, details)); + } + catch (Exception ex) + { + sw.Stop(); + return Task.FromResult(new CheckResult("Disk", CheckStatus.Fail, sw.ElapsedMilliseconds, ex.Message)); + } + } + + private Task CheckCertificates(CancellationToken ct) + { + // Сертификаты OpenIddict валидируются на старте; здесь — простой + // smoke-чек что App_Data/oidc-keys существует или PFX в env есть. + var sw = Stopwatch.StartNew(); + try + { + var dataDir = Path.Combine(_env.ContentRootPath, "App_Data", "oidc-keys"); + var pfxPath = _cfg["OpenIddict:SigningCertPath"]; + if (!string.IsNullOrEmpty(pfxPath) && System.IO.File.Exists(pfxPath)) + { + using var cert = new System.Security.Cryptography.X509Certificates.X509Certificate2(pfxPath, + _cfg["OpenIddict:CertPassword"] ?? string.Empty); + var daysLeft = (int)(cert.NotAfter - DateTime.Now).TotalDays; + sw.Stop(); + if (daysLeft < 7) return Task.FromResult(new CheckResult("Certificates", CheckStatus.Fail, + sw.ElapsedMilliseconds, $"сертификат истекает через {daysLeft} дней!")); + if (daysLeft < 30) return Task.FromResult(new CheckResult("Certificates", CheckStatus.Warning, + sw.ElapsedMilliseconds, $"сертификат истекает через {daysLeft} дней")); + return Task.FromResult(new CheckResult("Certificates", CheckStatus.Ok, sw.ElapsedMilliseconds, + $"{cert.NotAfter:yyyy-MM-dd} (через {daysLeft} дней)")); + } + if (Directory.Exists(dataDir)) + { + var keys = Directory.GetFiles(dataDir).Length; + sw.Stop(); + return Task.FromResult(new CheckResult("Certificates", CheckStatus.Ok, sw.ElapsedMilliseconds, + $"dev-режим: {keys} OpenIddict-ключей в App_Data/oidc-keys/")); + } + sw.Stop(); + return Task.FromResult(new CheckResult("Certificates", CheckStatus.Warning, sw.ElapsedMilliseconds, + "PFX-сертификаты не найдены и App_Data/oidc-keys пуст")); + } + catch (Exception ex) + { + sw.Stop(); + return Task.FromResult(new CheckResult("Certificates", CheckStatus.Fail, sw.ElapsedMilliseconds, ex.Message)); + } + } + + private Task CheckBackupFresh(CancellationToken ct) + { + // Backup-папка задаётся через env FM_BACKUP_DIR или дефолт + // /opt/food-market-data/backups (см. food-market-backup.sh). + var sw = Stopwatch.StartNew(); + try + { + var dir = Environment.GetEnvironmentVariable("FM_BACKUP_DIR") ?? "/opt/food-market-data/backups"; + if (!Directory.Exists(dir)) + { + sw.Stop(); + return Task.FromResult(new CheckResult("Backup", CheckStatus.Warning, sw.ElapsedMilliseconds, + $"папка бэкапов {dir} не существует (нормально на dev-машине)")); + } + var latest = new DirectoryInfo(dir).GetFiles("db-*.dump") + .OrderByDescending(f => f.LastWriteTimeUtc).FirstOrDefault(); + sw.Stop(); + if (latest is null) + return Task.FromResult(new CheckResult("Backup", CheckStatus.Fail, sw.ElapsedMilliseconds, + "нет ни одного db-*.dump в " + dir)); + var ageHours = (DateTime.UtcNow - latest.LastWriteTimeUtc).TotalHours; + if (ageHours > 25) + return Task.FromResult(new CheckResult("Backup", CheckStatus.Fail, sw.ElapsedMilliseconds, + $"последний бэкап {latest.Name} — {ageHours:F1}ч назад (cron упал?)")); + if (ageHours > 12) + return Task.FromResult(new CheckResult("Backup", CheckStatus.Warning, sw.ElapsedMilliseconds, + $"бэкап {latest.Name} устаревает ({ageHours:F1}ч)")); + return Task.FromResult(new CheckResult("Backup", CheckStatus.Ok, sw.ElapsedMilliseconds, + $"{latest.Name} ({ageHours:F1}ч назад, {latest.Length / 1024 / 1024} MB)")); + } + catch (Exception ex) + { + sw.Stop(); + return Task.FromResult(new CheckResult("Backup", CheckStatus.Fail, sw.ElapsedMilliseconds, ex.Message)); + } + } +} diff --git a/src/food-market.api/Controllers/FeedbackController.cs b/src/food-market.api/Controllers/FeedbackController.cs new file mode 100644 index 0000000..3f63e83 --- /dev/null +++ b/src/food-market.api/Controllers/FeedbackController.cs @@ -0,0 +1,169 @@ +using System.Collections.Concurrent; +using System.Net.Http.Json; +using System.Security.Claims; +using foodmarket.Application.Common.Email; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers; + +/// Sprint 17: in-app feedback widget. POST /api/feedback +/// принимает {category, message}, отправляет: +/// - email на FromEmail из PlatformSettings (дубль администратору +/// платформы — пользователь не должен знать кому именно); +/// - Telegram-сообщение в чат поддержки, если задан +/// SupportTelegram:BotToken + SupportTelegram:ChatId. +/// +/// Rate-limit: 5 отправок в час на одного user'а — in-memory dictionary +/// (для одного API-инстанса). При scale-out — переехать на Redis. +[ApiController] +[Authorize] +[Route("api/feedback")] +public class FeedbackController : ControllerBase +{ + public enum FeedbackCategory + { + Bug = 0, + Suggestion = 1, + Question = 2, + } + + public record FeedbackInput(FeedbackCategory Category, string Message); + + // Sprint 17: in-memory rate-limit per-user. После Sprint 13 у нас уже + // есть централизованный rate-limiter, но он привязан к auth-endpoint'ам. + // Для остальных API делаем простой ConcurrentDictionary. + private static readonly ConcurrentDictionary> _attempts = new(); + private static readonly TimeSpan _window = TimeSpan.FromHours(1); + private const int _maxPerWindow = 5; + + private readonly AppDbContext _db; + private readonly IEmailSender _email; + private readonly ITenantContext _tenant; + private readonly IConfiguration _cfg; + private readonly IHttpClientFactory _httpFactory; + private readonly ILogger _log; + + public FeedbackController( + AppDbContext db, IEmailSender email, ITenantContext tenant, + IConfiguration cfg, IHttpClientFactory httpFactory, + ILogger log) + { + _db = db; _email = email; _tenant = tenant; + _cfg = cfg; _httpFactory = httpFactory; _log = log; + } + + [HttpPost] + public async Task Submit([FromBody] FeedbackInput input, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(input.Message)) + return BadRequest(new { error = "Сообщение обязательно.", field = "message" }); + if (input.Message.Length > 4000) + return BadRequest(new { error = "Сообщение слишком длинное (макс 4000 символов).", field = "message" }); + + // Rate-limit per-user. + var userKey = _tenant.UserId?.ToString() ?? HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + if (!CheckRateLimit(userKey)) + { + _log.LogWarning("Feedback rate-limited for user={Key}", userKey); + return StatusCode(StatusCodes.Status429TooManyRequests, new + { + error = "Слишком много отзывов с вашего аккаунта (макс 5/час). Подождите час и попробуйте снова.", + }); + } + + // Контекст: кто отправил + откуда + категория + текст. + var email = User.FindFirstValue(System.Security.Claims.ClaimTypes.Email) + ?? User.FindFirstValue("email") ?? "?"; + var name = User.Identity?.Name ?? "?"; + var org = "?"; + if (_tenant.OrganizationId is { } orgId) + { + org = await _db.Organizations.IgnoreQueryFilters() + .Where(o => o.Id == orgId) + .Select(o => o.Name) + .FirstOrDefaultAsync(ct) ?? "?"; + } + var ua = HttpContext.Request.Headers["User-Agent"].ToString(); + var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); + + var subject = $"Food Market feedback [{input.Category}] — {org}"; + var body = + $"Категория: {input.Category}\n" + + $"Организация: {org}\n" + + $"Пользователь: {name} ({email})\n" + + $"IP: {ip}\n" + + $"User-Agent: {ua}\n" + + $"Время: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC\n" + + $"\n---\n{input.Message}"; + + // Email — best-effort: ловим EmailNotConfigured отдельно (это NOT + // ошибка для пользователя — feedback всё равно попадёт в логи + Telegram). + var emailSent = false; + try + { + // Берём FromEmail из PlatformSettings как «адрес поддержки». + var settings = await _db.PlatformSettings.AsNoTracking().FirstOrDefaultAsync(ct); + var to = settings?.FromEmail; + if (!string.IsNullOrEmpty(to)) + { + await _email.SendAsync(to, subject, body, ct); + emailSent = true; + } + } + catch (EmailNotConfiguredException ex) + { + _log.LogWarning("Feedback email skipped: {Reason}", ex.Message); + } + catch (Exception ex) + { + _log.LogError(ex, "Feedback email failed"); + } + + // Telegram — тоже best-effort. + var telegramSent = false; + try + { + var token = _cfg["SupportTelegram:BotToken"]; + var chatId = _cfg["SupportTelegram:ChatId"]; + if (!string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(chatId)) + { + using var http = _httpFactory.CreateClient(); + var resp = await http.PostAsJsonAsync( + $"https://api.telegram.org/bot{token}/sendMessage", + new { chat_id = chatId, text = subject + "\n\n" + body }, ct); + telegramSent = resp.IsSuccessStatusCode; + if (!telegramSent) + _log.LogWarning("Telegram feedback returned {Status}", (int)resp.StatusCode); + } + } + catch (Exception ex) + { + _log.LogWarning(ex, "Feedback Telegram failed"); + } + + // Структурированный лог — гарантированно сохранится даже если оба + // канала упали (можно вытащить из Serilog позже). + _log.LogInformation( + "Feedback received: cat={Category} email={Email} org={Org} len={Length} sentEmail={E} sentTelegram={T}", + input.Category, email, org, input.Message.Length, emailSent, telegramSent); + + return Ok(new { ok = true, deliveredEmail = emailSent, deliveredTelegram = telegramSent }); + } + + private static bool CheckRateLimit(string key) + { + var now = DateTime.UtcNow; + var attempts = _attempts.GetOrAdd(key, _ => new List()); + lock (attempts) + { + attempts.RemoveAll(t => now - t > _window); + if (attempts.Count >= _maxPerWindow) return false; + attempts.Add(now); + return true; + } + } +} diff --git a/src/food-market.api/Controllers/WhatsNewController.cs b/src/food-market.api/Controllers/WhatsNewController.cs new file mode 100644 index 0000000..bac9484 --- /dev/null +++ b/src/food-market.api/Controllers/WhatsNewController.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace foodmarket.Api.Controllers; + +/// Sprint 17: /api/whats-new — отдаёт содержимое CHANGELOG.md +/// (генерируется отдельным скриптом из commit-сообщений feat:/fix:). +/// +/// Файл лежит в content-root приложения (копируется при build из +/// корня репо через Dockerfile.api). Если файла нет — отдаём пустой +/// список (фронт скрывает /whats-new пункт). +/// +/// Дополнительно возвращаем timestamp build'а (через файл версии +/// или fallback на assembly InformationalVersion) — фронт сравнивает +/// с localStorage.lastSeenBuildVersion чтобы показывать toast +/// «есть новые фичи» только когда есть. +[ApiController] +[Authorize] +[Route("api/whats-new")] +public class WhatsNewController : ControllerBase +{ + private readonly IWebHostEnvironment _env; + private readonly ILogger _log; + + public WhatsNewController(IWebHostEnvironment env, ILogger log) + { + _env = env; _log = log; + } + + public record WhatsNewItem(string Date, string Title, string Body, string Type); + public record WhatsNewResponse(string BuildVersion, IReadOnlyList Items); + + [HttpGet] + public ActionResult Get() + { + // BuildVersion — short SHA / тэг билд-времени. Берём из + // VERSION файла (создаётся Docker-build'ом) или ассембли. + var versionFile = Path.Combine(_env.ContentRootPath, "VERSION"); + string buildVersion; + if (System.IO.File.Exists(versionFile)) + buildVersion = System.IO.File.ReadAllText(versionFile).Trim(); + else + buildVersion = typeof(WhatsNewController).Assembly + .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) + .OfType() + .FirstOrDefault()?.InformationalVersion ?? "dev"; + + var changelogPath = Path.Combine(_env.ContentRootPath, "CHANGELOG.md"); + if (!System.IO.File.Exists(changelogPath)) + { + _log.LogDebug("CHANGELOG.md not found at {Path}", changelogPath); + return new WhatsNewResponse(buildVersion, Array.Empty()); + } + + var content = System.IO.File.ReadAllText(changelogPath); + var items = ParseChangelog(content).ToList(); + return new WhatsNewResponse(buildVersion, items); + } + + /// Парсер CHANGELOG.md в формате: + /// + /// ## 2026-06-07 + /// + /// - **feat**: новая фича (s17) + /// - **fix**: устранён баг (s17) + /// + /// Возвращает items до 30 дней назад от latest-даты. + internal static IEnumerable ParseChangelog(string content) + { + var lines = content.Replace("\r\n", "\n").Split('\n'); + string? currentDate = null; + var cutoff = DateTime.UtcNow.AddDays(-30).Date; + DateTime? latestSeen = null; + var items = new List(); + foreach (var raw in lines) + { + var line = raw.TrimEnd(); + if (line.StartsWith("## ")) + { + currentDate = line.Substring(3).Trim(); + if (DateTime.TryParse(currentDate, out var d)) + { + latestSeen ??= d; + // Корректируем cutoff: 30 дней от ПОСЛЕДНЕЙ записи, + // не от UtcNow — даже если CHANGELOG старый, всё + // равно покажем последние 30 дней оттуда. + cutoff = latestSeen.Value.AddDays(-30).Date; + } + continue; + } + if (currentDate is null) continue; + if (!line.StartsWith("- ")) continue; + if (DateTime.TryParse(currentDate, out var dd) && dd.Date < cutoff) break; + + // «- **feat**: текст» или «- **fix**: текст» + var item = line.Substring(2); + var type = "other"; + string title = item, body = ""; + if (item.StartsWith("**feat**", StringComparison.OrdinalIgnoreCase)) + { + type = "feat"; + title = item.Substring("**feat**:".Length).Trim(); + } + else if (item.StartsWith("**fix**", StringComparison.OrdinalIgnoreCase)) + { + type = "fix"; + title = item.Substring("**fix**:".Length).Trim(); + } + // Простая эвристика «заголовок vs тело»: первые 80 chars — title. + if (title.Length > 80) { body = title.Substring(80); title = title.Substring(0, 80); } + items.Add(new WhatsNewItem(currentDate, title, body, type)); + } + return items; + } +} diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index b502b56..5f7bc08 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -9,6 +9,7 @@ import { LoginPage } from '@/pages/LoginPage' import { AuthBridgePage } from '@/pages/AuthBridgePage' import { DashboardPage } from '@/pages/DashboardPage' import { OnboardingPage } from '@/pages/OnboardingPage' +import { OnboardingWizardPage } from '@/pages/OnboardingWizardPage' import { ForgotPasswordPage } from '@/pages/ForgotPasswordPage' import { ResetPasswordPage } from '@/pages/ResetPasswordPage' import { ProductsPage } from '@/pages/ProductsPage' @@ -46,6 +47,10 @@ const LoyaltyCardsPage = lazy(() => import('@/pages/LoyaltyCardsPage').then(m => const PromotionsPage = lazy(() => import('@/pages/PromotionsPage').then(m => ({ default: m.PromotionsPage }))) const MoySkladImportPage = lazy(() => import('@/pages/MoySkladImportPage').then(m => ({ default: m.MoySkladImportPage }))) const OrganizationSettingsPage = lazy(() => import('@/pages/OrganizationSettingsPage').then(m => ({ default: m.OrganizationSettingsPage }))) +// Sprint 17: help / what's-new / diagnostic. +const HelpPage = lazy(() => import('@/pages/HelpPage').then(m => ({ default: m.HelpPage }))) +const WhatsNewPage = lazy(() => import('@/pages/WhatsNewPage').then(m => ({ default: m.WhatsNewPage }))) +const AdminDiagnosticPage = lazy(() => import('@/pages/AdminDiagnosticPage').then(m => ({ default: m.AdminDiagnosticPage }))) const EmployeesPage = lazy(() => import('@/pages/EmployeesPage').then(m => ({ default: m.EmployeesPage }))) const EmployeeRolesPage = lazy(() => import('@/pages/EmployeeRolesPage').then(m => ({ default: m.EmployeeRolesPage }))) const StockMovementsPage = lazy(() => import('@/pages/StockMovementsPage').then(m => ({ default: m.StockMovementsPage }))) @@ -143,6 +148,7 @@ export default function App() { * SuperAdmin без активного override → редирект на /super-admin/organizations. */} }> } /> + } /> } /> } /> } /> @@ -189,6 +195,10 @@ export default function App() { {lz(OrgAuditLogPage)}} /> {lz(MoySkladImportPage)}} /> {lz(OrganizationSettingsPage)}} /> + {/* Sprint 17 */} + + + {lz(AdminDiagnosticPage)}} /> {lz(EmployeesPage)}} /> {lz(EmployeeRolesPage)}} /> diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index c1959e5..ea23a84 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -15,6 +15,7 @@ import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' import { ShortcutsOverlay } from './ShortcutsOverlay' import { LanguageSwitcher } from './LanguageSwitcher' import { CommandPalette } from './CommandPalette' +import { FeedbackWidget } from './FeedbackWidget' interface MeResponse { sub: string @@ -249,6 +250,16 @@ export function AppLayout() { )}
+ {/* Sprint 17: feedback widget + ссылки на /help и /whats-new. */} + + ) + )} + {demoVideoUrl ? ( + + Посмотреть как это работает + + ) : helpTopic ? ( + + Подробнее в базе знаний + + ) : null} + + {!demoVideoUrl && helpTopic && ( +

Видео-демо появится в следующих версиях

+ )} + + ) +} diff --git a/src/food-market.web/src/components/FeedbackWidget.tsx b/src/food-market.web/src/components/FeedbackWidget.tsx new file mode 100644 index 0000000..bc2758c --- /dev/null +++ b/src/food-market.web/src/components/FeedbackWidget.tsx @@ -0,0 +1,114 @@ +/** + * Sprint 17: in-app feedback widget. + * + * Кнопка в sidebar/footer «Отзыв». Click → Modal с: + * - категория (баг / предложение / вопрос), + * - textarea (макс 4000). + * Submit → POST /api/feedback. Сервер шлёт email + Telegram, rate-limit 5/час. + */ +import { useState } from 'react' +import { MessageSquare, Bug, Lightbulb, HelpCircle } from 'lucide-react' +import { Modal } from '@/components/Modal' +import { Button } from '@/components/Button' +import { Field, TextArea } from '@/components/Field' +import { api } from '@/lib/api' +import { toast } from '@/lib/toast' + +type Category = 0 | 1 | 2 // 0=Bug, 1=Suggestion, 2=Question + +export function FeedbackWidget() { + const [open, setOpen] = useState(false) + const [category, setCategory] = useState(1) + const [message, setMessage] = useState('') + const [busy, setBusy] = useState(false) + + async function submit() { + if (!message.trim()) { + toast.error('Заполните сообщение') + return + } + setBusy(true) + try { + const r = await api.post<{ ok: boolean; deliveredEmail: boolean; deliveredTelegram: boolean }>( + '/api/feedback', { category, message: message.trim() }, + ) + const channels = [] + if (r.data.deliveredEmail) channels.push('email') + if (r.data.deliveredTelegram) channels.push('telegram') + toast.success(channels.length > 0 + ? `Отзыв отправлен (${channels.join(' + ')}). Спасибо!` + : 'Отзыв принят. Спасибо!') + setMessage('') + setOpen(false) + } catch (e) { + const err = e as { response?: { data?: { error?: string } }; message?: string } + toast.error(err.response?.data?.error ?? err.message ?? 'Не удалось отправить') + } finally { + setBusy(false) + } + } + + return ( + <> + + + setOpen(false)} + title="Оставить отзыв" + width="max-w-md" + footer={ + <> + + + + } + > + +
+ {([ + { v: 0 as Category, label: 'Баг', icon: Bug }, + { v: 1 as Category, label: 'Предложение', icon: Lightbulb }, + { v: 2 as Category, label: 'Вопрос', icon: HelpCircle }, + ] as const).map((c) => ( + + ))} +
+
+ +