Approval Workflow v2 Phase 4 — UX:
Backend:
- DraftController.myDrafts: GET /api/v1/drafts/me?page&size — все статусы
(PENDING/APPROVED/REJECTED/WITHDRAWN) текущего authenticated user, sorted
submitted_at DESC. Resolve maker_id из JWT subject (fallback "anonymous"
если auth off).
- Reuses existing DraftService.listByMaker (Phase 1 method, был не exposed).
Frontend:
- routes/my-drafts.tsx: новый маршрут с табличным view.
Колонки: businessKey / operation / status / submittedAt / reviewedAt /
reviewerId / comment / actions. Status badge tinted (warning/success/
error/neutral). PENDING строки имеют Withdraw кнопку (calls existing
useWithdrawDraft с confirm).
- api/queries.ts: useMyDrafts (refetch 30s — maker увидит когда reviewer
decide).
- api/mutations.ts: useWithdrawDraft теперь invalidate'ит ['drafts'] —
/me + by-dict обе видят изменение.
- routes/__root.tsx: nav link "Мои черновики" / "My drafts" (после Reviews).
- i18n RU/EN: 18 keys (nav.myDrafts, myDrafts.* col/action/status).
Pluralization для total через i18next plurals.
Why это нужно:
- До этого maker submit'нул draft и пропадал — единственный feedback был
badge "На review" в records list (только для текущего dict). Здесь — все
его submissions с history + withdraw для PENDING.
- Reviewer pool (D3=A) маленький, /reviews queue только для них. Maker
без role'а не видит queue, но должен tracking своих submissions.
Tests: ordinis-rest-api unit 119/119, ordinis-app e2e 28/28, vitest 89/89.
Backend POST /api/v1/dictionaries/{name}/records/export-selected.csv:
принимает businessKeys[] (1..500), возвращает CSV с колонками
business_key, valid_from, valid_to, scope, data (JSONB inline).
Не-найденные ключи отдаются строкой с пустыми ячейками — admin видит
что ключ обработан, но данных нет. RFC 4180 экранирование.
requireReadAccess: для экспорта достаточно прав чтения (не write).
POST а не GET потому что >100 ключей не помещается в URL params
(Server: Request URI Too Long).
Frontend useBulkExportRecords: axios POST с responseType: blob →
temporary <a download> link → клик → revokeObjectURL через rAF.
Filename берётся из Content-Disposition header.
Toolbar: новая кнопка "Экспорт CSV" (variant=secondary) рядом с
"Закрыть выбранные" (variant=danger). Loading state через isPending.
Pagination для records list (PAGE_SIZE=50, client-side):
- visibleRecords = filteredRecords.slice(...)
- Footer "Записи 1–50 из 100, Стр. 1 из 2" с Prev/Next кнопками
- Auto-reset на page=0 при изменении filter/scope/AOI
- selectAll/visibleKeys теперь = только current page (WYSIWYG)
- При >50 записей видно навигацию, при <=50 footer скрыт
Прогон: 20/20 e2e + 60/60 rest-api + 89/89 admin-ui.
Backend POST /api/v1/dictionaries/{name}/records/bulk-close принимает
businessKeys[] (1..500) + reason. BulkRecordService крутит цикл через
inject'ed DictionaryRecordService — каждый close в своей транзакции
(SERIALIZABLE), partial success возможен. Result: closed[] / skipped[]
(already_closed, not_found) / errors[].
Frontend: checkbox column + sticky toolbar когда selection.size > 0.
Header checkbox с indeterminate state для partial select. selectAll
включает все ВИДИМЫЕ ключи (не игнорирует фильтры). При изменении
фильтра — useEffect выкидывает невидимые ключи из selection (WYSIWYG).
Bulk close dialog: count + reason input → confirm → результат панель
"Закрыто N, пропущено M, ошибок K" с детальным списком skipped/errors.
После успеха selection остаётся для skipped/errors — юзер видит что
ещё не сделано.
Лимит 500: защита от случайного огромного селекта или бага UI.
Дедупликация внутри batch'а silent skip — не error.
После создания subscription / rotate secret admin не имел способа
проверить что receiver жив и валидирует HMAC без ожидания реального
business event. Теперь:
- POST /api/v1/admin/webhooks/subscriptions/{id}/test
- Synchronous: receiver получает synthetic event с теми же headers
что у production delivery (X-Ordinis-Signature, X-Ordinis-Event-Type,
X-Ordinis-Scope) + дополнительный X-Ordinis-Test=true чтобы
receiver мог логировать/филтровать как тест.
- HMAC sign'ится через тот же HmacSigner — admin раскопировал secret
правильно если receiver валидирует. Иначе receiver вернёт 4xx
и UI покажет ошибку.
- SSRF guard validate URL host (private CIDR + allowed-hosts override)
— same policy как у dispatcher.
Bypass'ит outbox/dispatcher — нет webhook_deliveries row, нет attempt
count, нет следов в аудите доставок (это test, не business event).
UI:
- Кнопка 'Тест ping' с PaperPlaneTilt иконкой рядом с 'Сменить secret'
на webhook detail page.
- Inline Alert после ответа: success/failure/rejected с HTTP status,
latency, body preview / error message. Dismiss '✕'.
RBAC: RESTRICTED — endpoint отправляет HTTP request на user-controlled
URL (potential abuse vector если бы был INTERNAL).
Tests: rest-api compiles, 76 admin-ui passed.
Раньше для replay'а failed/DLQ delivery нужно было либо ждать
backoff schedule (до 24h), либо лезть в БД и руками сбрасывать
attempt_count + dlq_at. Теперь admin замечает failed delivery в
UI, фиксит receiver (cert renewal, URL update), кликает 'повторить'
— dispatcher подхватит на следующем sendTick.
Backend:
- POST /api/v1/admin/webhooks/deliveries/{id}/retry
- RBAC: RESTRICTED (можно spam'ить receiver = destructive)
- Использует existing WebhookDelivery.resetDlq() — атомарно
status=retrying, attemptCount=0, dlqAt=null, nextAttemptAt=now
UI:
- Колонка 'Действия' в delivery history таблице (на webhook detail page)
- IconButton 'Повторить' показывается только для status=dlq|retrying
(delivered/pending не имеет смысла retry'ить)
- Confirm dialog объясняет что receiver получит payload снова
- onSuccess invalidate ['webhooks','deliveries'] + ['webhooks','dlq']
Tests: 75 admin-ui passed, rest-api compiles clean. Логика
resetDlq() уже покрыта unit tests в WebhookDeliveryTest.