Commit Graph

18 Commits

Author SHA1 Message Date
Zimin A.N. 74704dbf5b feat(notifications): Phase C — Don't Disturb (quiet hours window)
User задаёт quiet window (e.g. 22:00–08:00 Europe/Moscow) — external
channels (email + express) DROP'аются dispatcher'ом. In-app bell badge
остаётся (юзер видит при следующем визите UI). Cross-midnight aware.

## Backend

- Migration 0026: user_notification_settings table (user_id PK + quiet_hours_start/end LocalTime + tz)
- Entity UserNotificationSettings + Repository (stock CRUD)
- UserPreferencesGate extended:
  - Constructor takes settingsRepo
  - allows() checks isQuiet(userId) первым for external channels (EMAIL/EXPRESS/TELEGRAM)
  - isQuiet: load settings → if hasEffectiveQuietHours() → check current LocalTime в user's TZ
  - inWindow() helper: cross-midnight semantics (22:00–08:00 wraps midnight)
  - Invalid TZ → fallback к DEFAULT_TZ (Europe/Moscow) с WARN log
  - Test seam: setClockForTesting(Clock) для deterministic tests
- MeNotificationsSettingsController: GET/PUT /api/v1/me/notifications/settings
  - HH:mm input parsing, IANA TZ validation, enabled=false clears window
  - Auth required (JWT sub claim)

## Frontend

- NotificationSettings type в client.ts
- useMyNotificationSettings query (60s stale)
- useUpdateNotificationSettings mutation
- QuietHoursSection в /me/notifications/preferences route:
  - Switch toggle + 2 time inputs + TZ select (12 RU/UTC zones)
  - Optimistic local state synced from query
  - Save button с pending/success/error states
- i18n RU + EN

## Tests

- 9 new tests UserPreferencesGateQuietHoursTest:
  - Same-day window match (inclusive start, exclusive end)
  - Cross-midnight window match (22:00–08:00)
  - No settings row → не блокирует
  - Quiet window active → email + express dropped
  - Outside window → email allowed
  - start == end → disabled (zero-duration treated as off)
  - Reviewer pool bypasses (always ON)
  - Invalid TZ → fallback default
  - Specific pref ON + quiet ON → quiet wins (drop)
- NotificationsDispatcherTest constructor updated к new signature
- Total notifications tests: 53 → 62, все green

## Design rationale (drop vs defer)

Drop chosen за defer (queue + re-fire scheduler) потому что:
- Defer needs persistent queue + scheduled retry — high complexity
- Most notifications time-relevant (draft N hours ago less actionable)
- Bell badge показывает full feed — ничего не «теряется»
- Phase D summary digest можно build later если demand surfaces
2026-05-17 11:11:21 +03:00
Zimin A.N. e4cd7b9709 feat(ai): Phase 1 step 3-6 — LLM adapter + per-field AI suggest endpoint + UI
Approach C upgrade на templates foundation (!233). Admin может попросить AI
предложить новое поле — system prompt force'ит structural JSON Schema
fragment, admin review через accept/edit/reject. Bitemporal draft workflow
unchanged downstream.

## Backend
- `LlmAdapter` — OpenAI-compatible /v1/chat/completions client (Ollama,
  vLLM, OpenAI). Bean conditional на ordinis.ai.enabled=true.
- `AiSchemaService` — system prompt + 3 few-shot examples, JSON output
  parsing (с markdown fence stripping), validation (fieldName snake_case).
  In-memory circuit breaker: 10 fails в 60s → open 5 min.
- `AiSchemaController`:
  - GET /api/v1/ai/info → 200 (enabled) / 404 (disabled). Frontend probe.
  - POST /api/v1/ai/suggest-field → suggestion. Per-IP rate limit 30/min.
- All bean-conditional: ordinis.ai.enabled=false → controllers нет →
  endpoints 404 → frontend hides AI buttons automatically.

## Frontend
- `useAiFeatureAvailable` query — probes /ai/info, cache 5 min
- `useAiSuggestField` mutation — 30s timeout (LLM может быть slow)
- `AiFieldSuggestPanel` component — inline expandable: prompt → preview →
  accept/retry/reject. Error handling per HTTP status (503/429/422/502/404).
- DictionaryEditorDialog: AI toggle visible на schema tab когда
  aiAvailable=true. Accept wraps suggestion в mini-schema → parseSchemaJson
  (reuse existing kind-inference) → push в properties (overwrite если
  duplicate fieldName).
- i18n RU + EN

## Config
- application.yml: ordinis.ai.{enabled,endpoint,model,bearer-token,
  timeout-seconds}
- Helm writer.yaml: ORDINIS_AI_* env vars, optional secretRef для bearer
- values.yaml: ai.{enabled,endpoint,model,timeoutSeconds,bearerTokenSecretRef}
  defaults disabled

Pair с infra MR — values-staging enables AI с vortex.nstart.cloud Ollama
serving qwen2.5:7b.
2026-05-16 13:55:16 +03:00
Александр Зимин e2fc3bcbe0 feat(admin-ui): Phase B frontend — notifications preferences route + UI 2026-05-15 14:56:48 +00:00
Александр Зимин 9050e2427e feat(notifications): TODO 7 — draft decision toast + email + bell badge 2026-05-14 14:40:35 +00:00
Александр Зимин b3a9104d54 feat(schema-workflow): PATCH /drafts/{id} — edit без recreate (A) 2026-05-12 11:33:01 +00:00
Александр Зимин dd3a2d97e5 feat(schema-workflow): Phase 1b — SchemaDraftDrawer with status-aware actions 2026-05-12 11:08:31 +00:00
Александр Зимин 48dd8a487a fix(admin-ui): backlog sweep — 7 review items 2026-05-10 19:21:33 +00:00
Александр Зимин fc2fb35717 fix(admin-ui): top-5 critical review bugs (auth + AJV + TZ + idempotency + storage) 2026-05-10 16:22:35 +00:00
Zimin A.N. aedeafc84e feat(approval): /my-drafts maker self-tracking view + /api/v1/drafts/me
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.
2026-05-10 12:07:35 +03:00
Zimin A.N. 84a4de4ceb feat(approval): Approval Workflow v2 Phase 2 — admin UI reviewer queue + toggle
Phase 2 admin UI на скелете Phase 0/1 (e438c4c, 2020af9). Делает workflow
end-to-end usable: reviewer'ы могут approve/reject через UI; admins
включают approval_required per-dict через checkbox.

API surface (admin-ui):
- types: DraftStatus, DraftOperation, DraftResponse, SubmitDraftRequest,
  ReviewQueuePage. + DictionaryDefinition: approvalRequired, approvalMinRole.
- queries (react-query):
  * useReviewQueue (page, size) — global queue, refetch 30s.
  * useDraft(id) — single draft for diff drawer.
  * useLiveRecord(dict, bk) — current live record for side-by-side comparison.
- mutations:
  * useSubmitDraft(dict) — POST /dictionaries/{n}/records/drafts
  * useApproveDraft() — POST /drafts/{id}/approve
  * useRejectDraft() — POST /drafts/{id}/reject (reason required)
  * useWithdrawDraft() — DELETE /drafts/{id}

New /reviews route:
- Table queue: dict, business_key, operation badge, maker, submitted_at.
- Click "Просмотр" → side-by-side drawer:
  * left: current live (если есть; для CREATE — placeholder).
  * right: proposed (или CLOSE notice).
  * maker comment chip если есть.
  * Reviewer comment input + Approve / Reject buttons.
  * Reject disabled пока comment пустой (mirror's backend 400).
- Auto-invalidates на success — queue refresh, list updates.
- Nav link "Согласования" в header.

DictionaryEditorDialog Metadata tab:
- New checkbox "Требовать approval (maker-checker)" — orbit/8 styled
  (отделимо от Redis projection card visually).
- При checked → shown approval_min_role TextInput (e.g. "ordinis:internal").
- payload sends approvalRequired + approvalMinRole.

i18n RU/EN:
- nav.reviews, reviews.{title,description,empty,queueTotal_*,col.*,
  action.*, drawer.*}
- schema.approvalRequired.{label,hint}, schema.approvalMinRole.{label,hint}
- Plurals для RU queueTotal (one/few/many/other).

Verify:
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
- routeTree.gen.ts auto-regenerated с /reviews.

Phase 3 (next session):
- e2e tests: full flow maker submit → checker approve → record live + outbox event.
- e2e: reject keeps draft, doesn't publish.
- e2e: self-approve blocked (no_self_approve constraint).
- e2e: two pending drafts на one business_key blocked (one_pending_per_key).
- Per-dict opt-in soak: 1-2 dicts (например ground_station) с
  approval_required=true в staging.
2026-05-08 13:28:56 +03:00
Zimin A.N. 3eeaba058f feat(cascade): Phase 3 — CascadeCloseService + 409 routing + UI confirm dialog
dict-relationships-v2 epic, Phase 3. Cascade engine + UX guards.

Backend:
- New CascadeCloseService:
  * evaluatePlan(target, key, at) — read-only, splits dependents по
    onClose mode (BLOCK / WARN / CASCADE).
  * executeCascade(target, key, when, reason, confirmed) —
    @Transactional(timeout=30, isolation=SERIALIZABLE) atomic close
    target + all CASCADE deps в one hop (design F.OUT). 503
    x_references_cascade_timeout если tx exceeds 30s, 400
    x_references_cascade_too_large если N > 500.
  * RecordCloseSink interface — test seam над repo+audit+outbox
    mechanics (concrete classes на JDK 25 + Mockito incompat).
- Wired DictionaryRecordService.close():
  * Optional<CascadeCloseService> inject через ctor (back-compat ctor
    для тестов с null deps).
  * Pre-close evaluatePlan: blockers → 409 x_references_blocked_by_dependents,
    cascade non-empty → 409 x_references_cascade_required (просит cascade-close
    endpoint). WARN-only — close проходит.
- Endpoints в DictionaryRecordController:
  * GET /dictionaries/{dict}/records/{key}/cascade-preview — read-only план.
  * POST /dictionaries/{dict}/records/{key}/cascade-close?confirmed=true —
    atomic execute. Existing DELETE остался (теперь учитывает план).

Tests (8 unit tests):
- evaluatePlan empty / split-by-mode (BLOCK/WARN/CASCADE)
- 409 blockers exist (not closed)
- 409 cascade-required без confirmed
- WARN-only — closes target only, warnings returned
- CASCADE — atomic close target + 2 cascaded (verifies order + reasons)
- BLOCK overrides CASCADE — 409 даже при confirmed=true
- Empty plan — closes only target

Admin UI:
- New CascadeConfirmDialog component:
  * Trigger: 409 на existing close → opens dialog с GET cascade-preview.
  * BLOCKERS section — Alert error, list первых 5, "resolve first" hint.
  * CASCADE section — per-source grouping, sample 3 + overflow,
    badges per (count, onClose mode).
  * WARN section — yellow Alert "будут orphan".
  * Reason input + "Type CONFIRM" gate если N > 50 (UX guard).
  * Cancel — close без changes.
- Wired в dictionaries.$name.tsx: existing close-confirm flow на 409
  cascade error pivots в new dialog (same UX path, transparent для user).
- Types в client.ts: CascadeEntry, CascadePlan, CascadeCloseResult.
- Mutation useCascadeCloseRecord, query useCascadePreview.
- i18n RU/EN: cascade.* (35 keys, plurals для счётчиков).

Verify:
- mvn -pl ordinis-rest-api -am test: 106/106 PASS (8 new cascade tests).
- mvn verify -pl '!ordinis-app': все модули SUCCESS (5.1s).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.

Backward-compat: bundle v1.2.1 (без x-references-on-close) → onClose=BLOCK
default → existing dependents теперь блокируют close (правильное поведение
для FK semantics). Если был race condition close-with-deps в v1, он
exposed теперь как 409. CRITICAL: REGRESSION test покрывает.
2026-05-08 11:23:46 +03:00
Zimin A.N. 4252ad93a8 feat(records): bulk export CSV выбранных + pagination
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.
2026-05-07 12:12:31 +03:00
Zimin A.N. 5941207955 feat(records): bulk close — multi-select toolbar + per-record транзакция
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.
2026-05-07 11:19:57 +03:00
Zimin A.N. 66e89ce707 feat(webhook): test ping — admin верифицирует receiver одной кнопкой
После создания 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.
2026-05-06 20:59:47 +03:00
Zimin A.N. 2e3f2ddd16 feat(webhook): retry delivery — POST /deliveries/{id}/retry + UI кнопка
Раньше для 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.
2026-05-06 20:36:17 +03:00
Zimin A.N. 96bfac174d feat(webhook): Phase 4 — admin-ui /webhooks page
Routes:
- /webhooks (layout) → /webhooks/index (list) + /webhooks/$id (detail)
- nav link добавлен в __root.tsx между Outbox и Language switch

List page (webhooks.index.tsx):
- Table: name | URL | scope filter (colored dots) | dictionaries | status
- ScopeChips reuse SCOPE_DOT из lib/scope-style.ts (consistency с
  dictionaries scope coloring)
- Create dialog: name, URL, scope filter (MultiSelect), dictionary &
  event type (CSV inputs), description. Client-side validation
  (URL scheme, name required) дублирует server-side @Pattern.
- Secret reveal modal после create: warning + plaintext + copy button.
  Single-time view — после закрытия secret не получить, только rotate.

Detail page (webhooks.$id.tsx):
- Subscription metadata grid (URL, status, filters, masked secret,
  createdBy + timestamp)
- Action buttons: rotate-secret + delete (с confirmation)
- Delivery history table: outboxEventId | status badge (delivered/
  retrying/pending/dlq) | attempts | last attempt | HTTP code | error
- Pagination 50/page
- Status badges: delivered=success, retrying=warning, pending=neutral,
  dlq=error

API client (queries + mutations):
- 4 queries: subscriptions list, subscription by id, deliveries page,
  DLQ page (last unused в UI пока, но готов для admin DLQ tab)
- 4 mutations: create, update, delete, rotateSecret. Все invalidate
  правильные query keys.

i18n: 50 ключей × 2 локали (ru-RU + en-US) под webhooks.*
2026-05-06 15:22:28 +03:00
Zimin A.N. 987559a6ab feat(admin-ui): visual schema builder для создания/редактирования справочников
Новый функционал — заводить свои справочники через UI без code deploy:

API:
- CreateDictionaryRequest type в client.ts
- useCreateDictionary / useUpdateDictionary mutations с invalidate
  ['dictionaries'] и ['dictionary', name]

Компоненты (src/components/schema/):
- types.ts: PropertyDef + buildSchemaJson() (Draft-07) + parseSchemaJson()
  для двусторонней конвертации между UI state и schema_json
- PropertyEditor: name + kind select + required + description + type-specific
  опции (minLength/maxLength/pattern для string; minimum/maximum для number;
  enum values; uniqueItems для array). Up/Down/Trash icon buttons
- SchemaBuilder: список property cards + кнопка «добавить» + EmptyState
- DictionaryEditorDialog: модалка с 3 табами:
  * Метаданные (name/displayName/description/scope/bundle/version/locales)
  * Поля (SchemaBuilder)
  * JSON (readonly preview сгенерированной schema_json)

Поддерживаемые типы полей: string, integer, number, boolean, enum,
localized (i18n object с patternProperties), date (format=date),
datetime (format=date-time), array_string (с опц. enum + uniqueItems).

Wiring:
- /dictionaries: PageHeader actions = «+ Создать справочник» → Dialog в режиме create
  → onSuccess navigate на /dictionaries/{name}
- /dictionaries/$name: PageHeader actions расширен «Схема» (secondary) +
  «Создать запись» (primary). Dialog в edit-режиме prefilled из
  detailQuery.data + parseSchemaJson()

i18n: 30+ новых ключей в schema.* / scope.* (RU/EN)
2026-05-04 03:12:56 +03:00
Zimin A.N. b1b1f05e3c feat(admin-ui): record CRUD with json-schema-driven form
POST/PUT/DELETE формы для записей справочников. Schema-driven рендер полей
из definition.schemaJson — i18n-aware (x-localized → input на каждый supportedLocale),
enum → select, type → text/number/checkbox/array. Валидация через ajv (Draft-07,
match backend networknt validator).

- new: api/mutations.ts с Idempotency-Key (crypto.randomUUID) и query invalidation
- new: SchemaDrivenForm — react-hook-form + ajv compile per-schema, errors через
  setError по ajv instancePath
- new: Modal (Esc + backdrop click) и confirm dialog для close-операции
- nginx: regex location ^/api/v1/dictionaries/[^/]+$ → writer (admin schema fetch)
- queries: dictionaryDetailQuery возвращает full def с schemaJson
- i18n: RU/EN ключи для CRUD (create/edit/close, validFrom/validTo, confirm)
2026-05-04 01:49:08 +03:00